ReplacementSpan - "chips" across lines

874 views Asked by At

I'm using a custom replacement span to draw a background around particular words (adds padding around the words, words are identified by surrounding symbols, much like an HTML tag), and to even change the text size of those words (although I'm not doing so in this case). This works just fine when there is a single line, everything shows as expected:

chip_that_works_right

This is done by adjusting the FontMetrics in the overridden getSize, adjusting top and bottom to add the padding for background, and adjusting the returned size (width) to add the same, like this:

override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
    if (textSize != null) {
        paint.textSize = textSize
    }

    if (fm != null) {
        val newMetrics = paint.fontMetricsInt
        fm.descent = newMetrics.descent
        fm.ascent = newMetrics.ascent
        fm.leading = newMetrics.leading
        fm.top = (newMetrics.top - strokeWidth - padding).roundToInt()
        fm.bottom = (newMetrics.bottom + strokeWidth + padding).roundToInt()
    }

    return (padding + strokeWidth + paint.measureText(
        text.subSequence(start + 1, end - 1).toString().uppercase()
    ) + padding + strokeWidth).roundToInt()
}

The onDraw handles drawing the rectangles and the text to the canvas.

The problem comes with multiline text. I know that the contents of a ReplacementSpan won't wrap/split, the entire ReplacementSpan will be wrapped, and that's expected and wanted in this case. The problem appears to be with positioning of text within the chips when we go to multiline. I get some odd values for top/bottom, that have different sizes than expected based on the font metrics from getSize(). On the first line, the text shows at the bottom of the chip, and the second line the text shows at the top of the chip:

broken multiline

This looks to me like the line height is not being adjusted to handle the additional padding when there are multiple lines. I've tried implementing LineHeightSpan in my ReplacementSpan, but that doesn't work, because it must be applied to the entire paragraph.

The closest I've come to getting it to work, is to apply a LineHeightSpan, using the explicit height:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    mySpan.setSpan(LineHeightSpan.Standard(71),0,narrativeString.length-1,Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}

This isn't a real solution though, because it doesn't adjust based on the height of the ReplacementSpan. And it appears to be just a little off (boxes appear to be a little bit taller than they should be, and text appears to be a little bit towards the bottom, instead of centered):

almost but not quite

Is there any way to get this to work properly, apart from creating a separate view for each word, and plugging them into something like a FlexboxLayout?


Update: I've attempted the method suggested by Zain, following the article and repo suggested by Zain, and it doesn't work either.

Firstly, the horizontal padding doesn't impact the width of the "word". If you put a chip on two adjacent words, the padding will overlap. Secondly, the vertical padding doesn't actually change the line height. Adding vertical padding will overlap the background onto other lines if the padding goes beyond the line height, or chips that are on adjacent line. Any padding that falls outside the text view (e.g., above first line, below last line), gets cut off.

1

There are 1 answers

2
Zain On

This looks to me like the line height is not being adjusted to handle the additional padding when there are multiple lines.

The getLineForOffset() can be used to detect the multi-lined text of a span:

val startLine = layout.getLineForOffset(getSpanStart(span))
val endLine = layout.getLineForOffset(getSpanEnd(span))

if (startLine == endLine) // single line span
else // multi-line span

And each case can be handled with a unique renderer before drawing to the canvas. This allows to handle the first line of text, middle lines, and last line of text with different drawables so that the spanned area seems to be coherent across the lines:

enter image description here

This is well-handled by this repo which also puts LTR/RTL text directions into consideration:

/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
*/
package com.android.example.text.styling.roundedbg

import android.graphics.Canvas
import android.graphics.drawable.Drawable
import android.text.Layout
import kotlin.math.max
import kotlin.math.min

/**
 * Base class for single and multi line rounded background renderers.
 *
 * @param horizontalPadding the padding to be applied to left & right of the background
 * @param verticalPadding the padding to be applied to top & bottom of the background
 */
internal abstract class TextRoundedBgRenderer(
        val horizontalPadding: Int,
        val verticalPadding: Int
) {
    /**
     * Draw the background that starts at the {@code startOffset} and ends at {@code endOffset}.
     *
     * @param canvas Canvas to draw onto
     * @param layout Layout that contains the text
     * @param startLine the start line for the background
     * @param endLine the end line for the background
     * @param startOffset the character offset that the background should start at
     * @param endOffset the character offset that the background should end at
     */
    abstract fun draw(
        canvas: Canvas,
        layout: Layout,
        startLine: Int,
        endLine: Int,
        startOffset: Int,
        endOffset: Int
    )

    /**
     * Get the top offset of the line and add padding into account so that there is a gap between
     * top of the background and top of the text.
     *
     * @param layout Layout object that contains the text
     * @param line line number
     */
    protected fun getLineTop(layout: Layout, line: Int): Int {
        return layout.getLineTopWithoutPadding(line) - verticalPadding
    }

    /**
     * Get the bottom offset of the line and add padding into account so that there is a gap between
     * bottom of the background and bottom of the text.
     *
     * @param layout Layout object that contains the text
     * @param line line number
     */
    protected fun getLineBottom(layout: Layout, line: Int): Int {
        return layout.getLineBottomWithoutPadding(line) + verticalPadding
    }
}

/**
 * Draws the background for text that starts and ends on the same line.
 *
 * @param horizontalPadding the padding to be applied to left & right of the background
 * @param verticalPadding the padding to be applied to top & bottom of the background
 * @param drawable the drawable used to draw the background
 */
internal class SingleLineRenderer(
    horizontalPadding: Int,
    verticalPadding: Int,
    val drawable: Drawable
) : TextRoundedBgRenderer(horizontalPadding, verticalPadding) {

    override fun draw(
        canvas: Canvas,
        layout: Layout,
        startLine: Int,
        endLine: Int,
        startOffset: Int,
        endOffset: Int
    ) {
        val lineTop = getLineTop(layout, startLine)
        val lineBottom = getLineBottom(layout, startLine)
        // get min of start/end for left, and max of start/end for right since we don't
        // the language direction
        val left = min(startOffset, endOffset)
        val right = max(startOffset, endOffset)
        drawable.setBounds(left, lineTop, right, lineBottom)
        drawable.draw(canvas)
    }
}

/**
 * Draws the background for text that starts and ends on different lines.
 *
 * @param horizontalPadding the padding to be applied to left & right of the background
 * @param verticalPadding the padding to be applied to top & bottom of the background
 * @param drawableLeft the drawable used to draw left edge of the background
 * @param drawableMid the drawable used to draw for whole line
 * @param drawableRight the drawable used to draw right edge of the background
 */
internal class MultiLineRenderer(
    horizontalPadding: Int,
    verticalPadding: Int,
    val drawableLeft: Drawable,
    val drawableMid: Drawable,
    val drawableRight: Drawable
) : TextRoundedBgRenderer(horizontalPadding, verticalPadding) {

    override fun draw(
        canvas: Canvas,
        layout: Layout,
        startLine: Int,
        endLine: Int,
        startOffset: Int,
        endOffset: Int
    ) {
        // draw the first line
        val paragDir = layout.getParagraphDirection(startLine)
        val lineEndOffset = if (paragDir == Layout.DIR_RIGHT_TO_LEFT) {
            layout.getLineLeft(startLine) - horizontalPadding
        } else {
            layout.getLineRight(startLine) + horizontalPadding
        }.toInt()

        var lineBottom = getLineBottom(layout, startLine)
        var lineTop = getLineTop(layout, startLine)
        drawStart(canvas, startOffset, lineTop, lineEndOffset, lineBottom)

        // for the lines in the middle draw the mid drawable
        for (line in startLine + 1 until endLine) {
            lineTop = getLineTop(layout, line)
            lineBottom = getLineBottom(layout, line)
            drawableMid.setBounds(
                (layout.getLineLeft(line).toInt() - horizontalPadding),
                lineTop,
                (layout.getLineRight(line).toInt() + horizontalPadding),
                lineBottom
            )
            drawableMid.draw(canvas)
        }

        val lineStartOffset = if (paragDir == Layout.DIR_RIGHT_TO_LEFT) {
            layout.getLineRight(startLine) + horizontalPadding
        } else {
            layout.getLineLeft(startLine) - horizontalPadding
        }.toInt()

        // draw the last line
        lineBottom = getLineBottom(layout, endLine)
        lineTop = getLineTop(layout, endLine)

        drawEnd(canvas, lineStartOffset, lineTop, endOffset, lineBottom)
    }

    /**
     * Draw the first line of a multiline annotation. Handles LTR/RTL.
     *
     * @param canvas Canvas to draw onto
     * @param start start coordinate for the background
     * @param top top coordinate for the background
     * @param end end coordinate for the background
     * @param bottom bottom coordinate for the background
     */
    private fun drawStart(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) {
        if (start > end) {
            drawableRight.setBounds(end, top, start, bottom)
            drawableRight.draw(canvas)
        } else {
            drawableLeft.setBounds(start, top, end, bottom)
            drawableLeft.draw(canvas)
        }
    }

    /**
     * Draw the last line of a multiline annotation. Handles LTR/RTL.
     *
     * @param canvas Canvas to draw onto
     * @param start start coordinate for the background
     * @param top top position for the background
     * @param end end coordinate for the background
     * @param bottom bottom coordinate for the background
     */
    private fun drawEnd(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) {
        if (start > end) {
            drawableLeft.setBounds(end, top, start, bottom)
            drawableLeft.draw(canvas)
        } else {
            drawableRight.setBounds(start, top, end, bottom)
            drawableRight.draw(canvas)
        }
    }
}

The repo applies this to a custom TextView:


/**
 * A TextView that can draw rounded background to the portions of the text. See
 * [TextRoundedBgHelper] for more information.
 *
 * See [TextRoundedBgAttributeReader] for supported attributes.
 */
class RoundedBgTextView : AppCompatTextView {

    private val textRoundedBgHelper: TextRoundedBgHelper

    @JvmOverloads
    constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = android.R.attr.textViewStyle
    ) : super(context, attrs, defStyleAttr) {
        val attributeReader = TextRoundedBgAttributeReader(context, attrs)
        textRoundedBgHelper = TextRoundedBgHelper(
            horizontalPadding = attributeReader.horizontalPadding,
            verticalPadding = attributeReader.verticalPadding,
            drawable = attributeReader.drawable,
            drawableLeft = attributeReader.drawableLeft,
            drawableMid = attributeReader.drawableMid,
            drawableRight = attributeReader.drawableRight
        )
    }

    override fun onDraw(canvas: Canvas) {
        // need to draw bg first so that text can be on top during super.onDraw()
        if (text is Spanned && layout != null) {
            canvas.withTranslation(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat()) {
                textRoundedBgHelper.draw(canvas, text as Spanned, layout)
            }
        }
        super.onDraw(canvas)
    }
}
/**
 * Helper class to draw multi-line rounded background to certain parts of a text. The start/end
 * positions of the backgrounds are annotated with [android.text.Annotation] class. Each annotation
 * should have the annotation key set to **rounded**.
 *
 * i.e.:
 * ```
 *    <!--without the quotes at the begining and end Android strips the whitespace and also starts
 *        the annotation at the wrong position-->
 *    <string name="ltr">"this is <annotation key="rounded">a regular</annotation> paragraph."</string>
 * ```
 *
 * **Note:** BiDi text is not supported.
 *
 * @param horizontalPadding the padding to be applied to left & right of the background
 * @param verticalPadding the padding to be applied to top & bottom of the background
 * @param drawable the drawable used to draw the background
 * @param drawableLeft the drawable used to draw left edge of the background
 * @param drawableMid the drawable used to draw for whole line
 * @param drawableRight the drawable used to draw right edge of the background
 */
class TextRoundedBgHelper(
    val horizontalPadding: Int,
    verticalPadding: Int,
    drawable: Drawable,
    drawableLeft: Drawable,
    drawableMid: Drawable,
    drawableRight: Drawable
) {

    private val singleLineRenderer: TextRoundedBgRenderer by lazy {
        SingleLineRenderer(
            horizontalPadding = horizontalPadding,
            verticalPadding = verticalPadding,
            drawable = drawable
        )
    }

    private val multiLineRenderer: TextRoundedBgRenderer by lazy {
        MultiLineRenderer(
            horizontalPadding = horizontalPadding,
            verticalPadding = verticalPadding,
            drawableLeft = drawableLeft,
            drawableMid = drawableMid,
            drawableRight = drawableRight
        )
    }

    /**
     * Call this function during onDraw of another widget such as TextView.
     *
     * @param canvas Canvas to draw onto
     * @param text
     * @param layout Layout that contains the text
     */
    fun draw(canvas: Canvas, text: Spanned, layout: Layout) {
        // ideally the calculations here should be cached since they are not cheap. However, proper
        // invalidation of the cache is required whenever anything related to text has changed.
        val spans = text.getSpans(0, text.length, Annotation::class.java)
        spans.forEach { span ->
            if (span.value.equals("rounded")) {
                val spanStart = text.getSpanStart(span)
                val spanEnd = text.getSpanEnd(span)
                val startLine = layout.getLineForOffset(spanStart)
                val endLine = layout.getLineForOffset(spanEnd)

                // start can be on the left or on the right depending on the language direction.
                val startOffset = (layout.getPrimaryHorizontal(spanStart)
                    + -1 * layout.getParagraphDirection(startLine) * horizontalPadding).toInt()
                // end can be on the left or on the right depending on the language direction.
                val endOffset = (layout.getPrimaryHorizontal(spanEnd)
                    + layout.getParagraphDirection(endLine) * horizontalPadding).toInt()

                val renderer = if (startLine == endLine) singleLineRenderer else multiLineRenderer
                renderer.draw(canvas, layout, startLine, endLine, startOffset, endOffset)
            }
        }
    }
}

And the desired span drawable can be attached in XML with additional attributes defined in TextRoundedBgAttributeReader.

Sample usage:

<com.android.example.text.styling.roundedbg.RoundedBgTextView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="@string/my_annotated_text"
    app:roundedTextDrawable="@drawable/rounded"
    app:roundedTextDrawableLeft="@drawable/rounded_left"
    app:roundedTextDrawableMid="@drawable/rounded_mid"
    app:roundedTextDrawableRight="@drawable/rounded_right" />

And you can either annotate spans either in strings.xml:

<string name="my_annotated_text">"this is <annotation key="rounded">a regular</annotation> paragraph."</string>

Or programmatically:

val span = SpannableString("this is my text value that needs to be spanned")
span.setSpan(Annotation("", "rounded"), 0, 10, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
span.setSpan(Annotation("", "rounded"), 15, 19, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)

This article explains that deeply; and the mentioned repo has samples of testing.