Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Crazy Fancy Android Text

Daniel Lew
February 02, 2019

Crazy Fancy Android Text

Talk given about styling text on Android @ DevFestMN, February 2019.

Video: https://youtu.be/4cweBUdOvww

Daniel Lew

February 02, 2019
Tweet

More Decks by Daniel Lew

Other Decks in Programming

Transcript

  1. Crazy Fancy Android Text
    DevFest MN (2019)

    @danlew42

    View Slide

  2. View Slide

  3. The Trouble with Trello (Markdown)
    • Basic nesting of elements would fail

    • Image loading was iffy at best

    • Endless URL parsing issues

    • Code blocks would fail to render due to cool wind breezes

    • HTML-escaped entities would scramble text

    • Russian text crashes the app

    View Slide

  4. Text -> Abstract Syntax Tree -> Fancy TextView

    View Slide

  5. Simple Styles

    View Slide

  6. Styling an Entire TextView

    View Slide

  7. Styling an Entire TextView
    android:text="Hello, Normal World!" />
    android:text="Hello, Bold World!"
    android:textStyle="bold" />
    android:text="Hello, Italic World!"
    android:textStyle="italic" />

    View Slide

  8. View Slide

  9. View Slide

  10. Spannable
    Span
    Span
    Span
    Span
    Span
    Span

    View Slide

  11. Definitions
    Span - markup objects that can style text
    Spannable - text with mutable spans
    Spanned - text with spans

    View Slide

  12. Constructing Spannables

    View Slide

  13. Easy Mode: HTML

    Whoa this is wild!
    \n\n
    How did you do that?
    \n\n
    I cheated Spans!

    ==

    View Slide

  14. Easy Mode: HTML

    Whoa this is wild!
    \n\n
    How did you do that?
    \n\n
    I cheated Spans!

    View Slide

  15. Easy Mode: HTML
    textView.text = HtmlCompat.fromHtml(
    "Whoa this is wild!" +
    "\n\n" +
    "How did you do that?" +
    "\n\n" +
    "I cheated Spans!”, 0
    )

    View Slide

  16. Easy Mode: HTML
    == textView.text = HtmlCompat.fromHtml(
    "Whoa this is wild!" +
    "\n\n" +
    "How did you do that?" +
    "\n\n" +
    "I cheated Spans!”, 0
    )

    View Slide

  17. Easy Mode: HTML
    !=

    View Slide

  18. Easy Mode: HTML
    !=
    Whoa this is wild!
    \n\n
    How did you do that?
    \n\n
    I cheated Spans!

    textView.text = HtmlCompat.fromHtml(
    "Whoa this is wild!" +
    "\n\n" +
    "How did you do that?" +
    "\n\n" +
    "I cheated Spans!”, 0
    )

    View Slide

  19. HTML Drawbacks
    • Limited tag support

    • Strings support different tags than Html.fromHtml()

    • Details: https://blog.danlew.net/2011/04/13/html_in_text_views/

    View Slide

  20. Hard Mode: Build Your Own
    • Code only

    • Verbose

    • Flexible & powerful

    View Slide

  21. Hard Mode: Build Your Own
    == val text = SpannableString("Whoa this is wild!\n\nHow did you do that?\n\nI cheated Spans!")
    text.setSpan(SuperscriptSpan(), 0, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StyleSpan(Typeface.ITALIC), 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(SubscriptSpan(), 10, 12, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StyleSpan(Typeface.BOLD), 13, 17, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(ForegroundColorSpan(Color.RED), 20, 40, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StrikethroughSpan(), 42, 51, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

    View Slide

  22. Hard Mode: Build Your Own
    val text = SpannableString("Whoa this is wild!\n\nHow did you do that?\n\nI cheated Spans!")
    text.setSpan(SuperscriptSpan(), 0, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StyleSpan(Typeface.ITALIC), 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(SubscriptSpan(), 10, 12, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StyleSpan(Typeface.BOLD), 13, 17, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(ForegroundColorSpan(Color.RED), 20, 40, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StrikethroughSpan(), 42, 51, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

    View Slide

  23. Hard Mode: Build Your Own
    val text = SpannableString("Whoa this is wild!\n\nHow did you do that?\n\nI cheated Spans!")
    text.setSpan(SuperscriptSpan(), 0, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StyleSpan(Typeface.ITALIC), 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(SubscriptSpan(), 10, 12, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StyleSpan(Typeface.BOLD), 13, 17, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(ForegroundColorSpan(Color.RED), 20, 40, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StrikethroughSpan(), 42, 51, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

    View Slide

  24. Hard Mode: Build Your Own
    Class Mutable Text Mutable Markup
    SpannedString No No
    SpannableString No Yes
    SpannableStringBuilder Yes Yes

    View Slide

  25. Hard Mode: Build Your Own
    val text = SpannableString("Whoa this is wild!\n\nHow did you do that?\n\nI cheated Spans!")
    text.setSpan(SuperscriptSpan(), 0, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StyleSpan(Typeface.ITALIC), 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(SubscriptSpan(), 10, 12, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StyleSpan(Typeface.BOLD), 13, 17, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(ForegroundColorSpan(Color.RED), 20, 40, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StrikethroughSpan(), 42, 51, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

    View Slide

  26. Hard Mode: Build Your Own
    val text = SpannableString("Whoa this is wild!\n\nHow did you do that?\n\nI cheated Spans!")
    text.setSpan(SuperscriptSpan(), 0, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StyleSpan(Typeface.ITALIC), 5, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(SubscriptSpan(), 10, 12, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StyleSpan(Typeface.BOLD), 13, 17, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(ForegroundColorSpan(Color.RED), 20, 40, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(StrikethroughSpan(), 42, 51, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

    View Slide

  27. Hard Mode: Build Your Own
    text.setSpan(
    SuperscriptSpan(),
    0,
    4,
    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
    )
    Span
    Start
    End
    WTF?

    View Slide

  28. Span Flags
    • Flags define behavior

    • Can control precise behavior…

    • …But just use 0 or SPAN_EXCLUSIVE_EXCLUSIVE

    View Slide

  29. Multiple Spans

    View Slide

  30. Built-In Spans
    N

    View Slide

  31. Span Types
    • Appearance-only spans

    • Metric-affecting spans

    • Paragraph-affecting spans

    View Slide

  32. Appearance-only Spans
    • StyleSpan

    • UnderlineSpan

    • ForegroundColorSpan

    • BackgroundColorSpan

    • URLSpan

    View Slide

  33. Metric-affecting Spans
    • SuperscriptSpan

    • RelativeSizeSpan

    • ImageSpan

    View Slide

  34. Paragraph-affecting Spans
    • LeadingMarginSpan

    • DrawableMarginSpan

    • BulletSpan

    • QuoteSpan

    View Slide

  35. Bad Spans
    • BulletSpan - Sucks until API 28!

    • QuoteSpan - Sucks until API 28!

    • URLSpan - Crashes when there’s no intent handler!

    View Slide

  36. Custom Spans

    View Slide

  37. Custom Span Process
    • Pick a Span to extend

    • Decipher how it works

    • Get frustrated by limited Span API

    View Slide

  38. Thematic Break Span

    View Slide

  39. Thematic Break Span
    ≈ ≈ ReplacementSpan

    View Slide

  40. Thematic Break Span
    ReplacementSpan

    View Slide

  41. Thematic Break Span
    ReplacementSpan

    View Slide

  42. Thematic Break Span
    ReplacementSpan

    View Slide

  43. Thematic Break Span
    ReplacementSpan

    View Slide

  44. Thematic Break Span
    class ThematicBreakSpan(val lineColor: Int, val lineHeight: Int, val verticalPadding: Int)
    : ReplacementSpan() {
    override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int,
    fm: Paint.FontMetricsInt?): Int {
    if (fm != null) {
    fm.ascent = -lineHeight - (2 * verticalPadding)
    fm.descent = 0
    fm.top = fm.ascent
    fm.bottom = 0
    }
    return 1
    }
    ...
    }

    View Slide

  45. Thematic Break Span
    class ThematicBreakSpan(val lineColor: Int, val lineHeight: Int, val verticalPadding: Int)
    : ReplacementSpan() {
    override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int,
    fm: Paint.FontMetricsInt?): Int {
    if (fm != null) {
    fm.ascent = -lineHeight - (2 * verticalPadding)
    fm.descent = 0
    fm.top = fm.ascent
    fm.bottom = 0
    }
    return 1
    }
    ...
    }

    View Slide

  46. Thematic Break Span
    class ThematicBreakSpan(val lineColor: Int, val lineHeight: Int, val verticalPadding: Int)
    : ReplacementSpan() {
    override fun draw(canvas: Canvas, text: CharSequence?,
    start: Int, end: Int,
    x: Float, top: Int, y: Int, bottom: Int,
    paint: Paint) {
    val color = paint.color
    val style = paint.style
    paint.color = lineColor
    paint.style = Paint.Style.FILL
    canvas.drawRect(etc etc etc)
    paint.color = color
    paint.style = style
    }
    }

    View Slide

  47. Thematic Break Span
    class ThematicBreakSpan(val lineColor: Int, val lineHeight: Int, val verticalPadding: Int)
    : ReplacementSpan() {
    override fun draw(canvas: Canvas, text: CharSequence?,
    start: Int, end: Int,
    x: Float, top: Int, y: Int, bottom: Int,
    paint: Paint) {
    val color = paint.color
    val style = paint.style
    paint.color = lineColor
    paint.style = Paint.Style.FILL
    canvas.drawRect(etc etc etc)
    paint.color = color
    paint.style = style
    }
    }

    View Slide

  48. Custom Span Limitations
    • Bound by API limitations

    • Cannot Parcelable custom spans

    • Cannot copy/paste your custom span

    • Choose wisely what you extend

    View Slide

  49. Example Custom Spans
    • BetterURLSpan : URLSpan

    • BulletSpanCompat : BulletSpan

    • QuoteSpanCompat : QuoteSpan

    • CodeBackgroundSpan : LineBackgroundSpan

    • ListItemSpan : BulletSpan

    • ThematicBreakSpan : ReplacementSpan

    View Slide

  50. Odds & Ends

    View Slide

  51. Reusing Spans?
    val boldSpan = StyleSpan(Typeface.BOLD)
    val text = SpannableString("Alice threw the ball to Bob")
    text.setSpan(boldSpan, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(boldSpan, 24, 27, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)

    View Slide

  52. Reusing Spans?
    val boldSpan = StyleSpan(Typeface.BOLD)
    val text = SpannableString("Alice threw the ball to Bob")
    text.setSpan(boldSpan, 0, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    text.setSpan(boldSpan, 24, 27, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
    ==

    View Slide

  53. Spacing Newlines
    Newlines Newlines w/ RelativeSizeSpan

    View Slide

  54. Construction Optimization
    Class Mutable Text Mutable Markup
    SpannableString No Yes
    SpannableStringBuilder Yes Yes

    View Slide

  55. Construction Optimization
    • SpannableStringBuilder was taking 98% of construction time

    • Faster method:

    val sb = StringBuilder()
    val spans = mutableListOf()
    data class SpanInfo(val what: Any, val start: Int, val end: Int, val flags: Int)

    View Slide

  56. Construction Optimization
    Construction Layout
    SpannableStringBuilder 51.3ms 9.4ms
    StringBuilder + SpannableString 1.5ms 16.4ms
    https://blog.danlew.net/2018/08/30/exploring-spannable-performance/

    View Slide

  57. Replacement Spans
    • Cannot attach to empty space

    • Options

    • Invisible character

    • Fallback text if replacement span fails

    • Space character

    View Slide

  58. Testing Spans
    • Comparing two Spannables is difficult because…

    • Span API is weird

    • Spans do not implement equals()

    • Not all Span attributes are public

    View Slide

  59. Testing Spans
    val spanObjects = spanned.getSpans(0, spanned.length, Any::class.java)
    spanObjects.forEach { span ->
    val start = expected.getSpanStart(span)
    val end = expected.getSpanEnd(span)
    val flags = expected.getSpanFlags(span)
    }

    View Slide

  60. Testing Spans
    val spanObjects = spanned.getSpans(0, spanned.length, Any::class.java)
    spanObjects.forEach { span ->
    val start = spanned.getSpanStart(span)
    val end = spanned.getSpanEnd(span)
    val flags = spanned.getSpanFlags(span)
    }

    View Slide

  61. Testing Spans
    fun assertSpanEquals(expected: RelativeSizeSpan, actual: RelativeSizeSpan) {
    assertEquals(expected.sizeChange, actual.sizeChange)
    }
    fun assertSpanEquals(expected: LeadingMarginSpan.Standard, actual:LeadingMarginSpan.Standard) {
    assertEquals(expected.getLeadingMargin(true), actual.getLeadingMargin(true))
    assertEquals(expected.getLeadingMargin(false), actual.getLeadingMargin(false))
    }

    View Slide

  62. Resources
    • Florina Muntenescu: https://medium.com/@florina.muntenescu

    • Optimizing spans

    • Styling internationalized text

    • …And more!

    • Google IO Talk: https://youtu.be/x-FcOX6ErdI

    View Slide

  63. Questions?
    @danlew42

    View Slide