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:

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.