Проблема в том, что ReplacementSpan не может пересечь границу линии. См. Рисование фона с закругленными углами на тексте для получения дополнительной информации по этой проблеме.
Вы можете использовать решение из публикации в блоге, упомянутой выше, но мы можем упростить это решение на основе ваших требований, какследует:
Вот общая процедура:
- Поместить Аннотация охватывает текст в TextView , который мы хотим подчеркнуть.
- Позвольте тексту быть размеченным и поймать TextView непосредственно перед рисованием с использованием прослушивателя predraw. На этом этапе текст отображается так, как он будет отображаться на экране.
- Замените каждый Аннотация span одним или несколькими DottedUnderlineSpans , гарантируя, что каждый подчеркиваниене пересекайте границу линии.
- Удалите конечные пробелы из ReplacementSpan , поскольку мы не хотим подчеркивать конечные пробелы.
- Замените текст в TextView .
Немного сложно, но это позволит использовать класс DottedUnderlineSpan . Это не может быть 100% решением, поскольку ширина ReplacementSpan при определенных обстоятельствах может отличаться от ширины текста.
Однако я рекомендую использовать пользовательские TextView с аннотациями для отметки размещения подчеркивания. Это, вероятно, будет проще всего сделать и понять, и вряд ли будет иметь непредвиденные побочные эффекты. Общая процедура состоит в том, чтобы пометить текст с интервалами аннотаций, как описано выше, но интерпретировать эти интервалы аннотаций в функции draw()
пользовательского текстового представления для получения подчеркивания.
Я собрал небольшой проект для демонстрацииэти методы. Вывод выглядит следующим образом для TextView без подчеркнутого текста, с подчеркнутым текстом с использованием DottedUnderlineSpan и с подчеркнутым текстом в пользовательском TextView .
MainActivity.kt
class MainActivity : AppCompatActivity() {
private lateinit var textView0: TextView
private lateinit var textView1: TextView
private lateinit var textView2: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
textView0 = findViewById(R.id.textView0)
textView1 = findViewById(R.id.textView1)
textView2 = findViewById<UnderlineTextView>(R.id.textView2)
if (savedInstanceState != null) {
textView1.text = SpannableString(savedInstanceState.getCharSequence("textView1"))
removeUnderlineSpans(textView1)
textView2.text = SpannableString(savedInstanceState.getCharSequence("textView2"))
} else {
val stringToUnderline = resources.getString(R.string.string_to_underline)
val spannableString0 = SpannableString(stringToUnderline)
val spannableString1 = SpannableString(stringToUnderline)
val spannableString2 = SpannableString(stringToUnderline)
// Get a good selection of underlined text
val toUnderline = listOf(
"production or conversion cycle",
"materials",
"into",
"goods",
"production and conversion cycle, where raw materials are transformed",
"saleable finished goods."
)
toUnderline.forEach { str -> setAnnotation(spannableString0, str) }
textView0.text = spannableString0
toUnderline.forEach { str -> setAnnotation(spannableString1, str) }
textView1.setText(spannableString1, TextView.BufferType.SPANNABLE)
toUnderline.forEach { str -> setAnnotation(spannableString2, str) }
textView2.setText(spannableString2, TextView.BufferType.SPANNABLE)
}
// Let the layout proceed and catch processing before drawing occurs to add underlines.
textView1.viewTreeObserver.addOnPreDrawListener(
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
textView1.viewTreeObserver.removeOnPreDrawListener(this)
setUnderlinesForAnnotations(textView1)
return false
}
}
)
}
// The following is used of the manifest file specifies
// <activity android:configChanges="orientation">; otherwise, orientation processing
// occurs in onCreate()
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
removeUnderlineSpans(textView1)
textView1.viewTreeObserver.addOnPreDrawListener(
object : ViewTreeObserver.OnPreDrawListener {
override fun onPreDraw(): Boolean {
textView1.viewTreeObserver.removeOnPreDrawListener(this)
setUnderlinesForAnnotations(textView1)
return false
}
}
)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putCharSequence("textView1", textView1.text)
outState.putCharSequence("textView2", textView2.text)
}
private fun setAnnotation(spannableString: SpannableString, subStringToUnderline: String) {
val dottedAnnotation =
Annotation(ANNOTATION_FOR_UNDERLINE_KEY, ANNOTATION_FOR_UNDERLINE_IS_DOTTED)
val start = spannableString.indexOf(subStringToUnderline)
if (start >= 0) {
val end = start + subStringToUnderline.length
spannableString.setSpan(dottedAnnotation, start, end, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
}
}
private fun setUnderlinesForAnnotations(textView: TextView) {
val text = SpannableString(textView.text)
val spans =
text.getSpans(0, text.length, Annotation::class.java).filter { span ->
span.key == ANNOTATION_FOR_UNDERLINE_KEY
}
if (spans.isNotEmpty()) {
val layout = textView.layout
spans.forEach { span ->
setUnderlineForAnnotation(text, span, layout)
}
textView.setText(text, TextView.BufferType.SPANNABLE)
}
}
private fun setUnderlineForAnnotation(text: Spannable, span: Annotation, layout: Layout) {
// Offset of first character in span
val spanStart = text.getSpanStart(span)
// Offset of first character *past* the end of the span.
val spanEnd = text.getSpanEnd(span)
// text.removeSpan(span)
// The span starts on this line
val startLine = layout.getLineForOffset(spanStart)
// Offset of the line that holds the last character of the span. Since
// spanEnd is the offset of the first character past the end of the span, we need
// to subtract one in case the span ends at the end of a line.
val endLine = layout.getLineForOffset(spanEnd)
for (line in startLine..endLine) {
// Offset to first character of the line.
val lineStart = layout.getLineStart(line)
// Offset to the character just past the end of this line.
val lineEnd = layout.getLineEnd(line)
// segStart..segEnd covers the part of the span on this line.
val segStart = max(spanStart, lineStart)
var segEnd = min(spanEnd, lineEnd)
// Don't want to underline end-of-line white space.
while ((segEnd > segStart) and Character.isWhitespace(text[segEnd - 1])) {
segEnd--
}
if (segEnd > segStart) {
val dottedUnderlineSpan = DottedUnderlineSpan()
text.setSpan(
dottedUnderlineSpan, segStart, segEnd, Spanned.SPAN_INCLUSIVE_INCLUSIVE
)
}
}
}
private fun removeUnderlineSpans(textView: TextView) {
val text = SpannableString(textView.text)
val spans = text.getSpans(0, text.length, DottedUnderlineSpan::class.java)
spans.forEach { span ->
text.removeSpan(span)
}
textView.setText(text, TextView.BufferType.SPANNABLE)
}
companion object {
const val ANNOTATION_FOR_UNDERLINE_KEY = "underline"
const val ANNOTATION_FOR_UNDERLINE_IS_DOTTED = "dotted"
}
}
DottedUnderlineSpan
Я немного переработал это.
class DottedUnderlineSpan(
lineColor: Int = Color.RED,
dashPathEffect: DashPathEffect =
DashPathEffect(
floatArrayOf(DASHPATH_INTERVAL_ON, DASHPATH_INTERVAL_OFF), 0f
),
dashStrokeWidth: Float = DOTTEDSTROKEWIDTH
) : ReplacementSpan() {
private val mPaint = Paint()
private val mPath = Path()
init {
with(mPaint) {
color = lineColor
style = Paint.Style.STROKE
pathEffect = dashPathEffect
strokeWidth = dashStrokeWidth
}
}
override fun getSize(
paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?
): Int {
return paint.measureText(text, start, end).toInt()
}
override fun draw(
canvas: Canvas, text: CharSequence, start: Int,
end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint
) {
canvas.drawText(text, start, end, x, y.toFloat(), paint)
val spanLength = paint.measureText(text.subSequence(start, end).toString())
val offsetY =
paint.fontMetrics.bottom - paint.fontMetrics.descent + TEXT_TO_UNDERLINE_SEPARATION
mPath.reset()
mPath.moveTo(x, y + offsetY)
mPath.lineTo(x + spanLength, y + offsetY)
canvas.drawPath(mPath, mPaint)
}
companion object {
const val DOTTEDSTROKEWIDTH = 5f
const val DASHPATH_INTERVAL_ON = 4f
const val DASHPATH_INTERVAL_OFF = 4f
const val TEXT_TO_UNDERLINE_SEPARATION = 3
}
}
UnderlineTextView
class UnderlineTextView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
private val mPath = Path()
private val mPaint = Paint()
init {
with(mPaint) {
color = Color.RED
style = Paint.Style.STROKE
pathEffect =
DashPathEffect(
floatArrayOf(DASHPATH_INTERVAL_ON, DASHPATH_INTERVAL_OFF), 0f
)
strokeWidth = DOTTEDSTROKEWIDTH
}
}
override fun draw(canvas: Canvas) {
super.draw(canvas)
// Underline goes on top of the text.
if (text is Spanned && layout != null) {
canvas.withTranslation(totalPaddingStart.toFloat(), totalPaddingTop.toFloat()) {
drawUnderlines(canvas, text as Spanned)
}
}
}
private fun drawUnderlines(canvas: Canvas, allText: Spanned) {
val spans =
allText.getSpans(0, allText.length, Annotation::class.java).filter { span ->
span.key == ANNOTATION_FOR_UNDERLINE_KEY && span.value == ANNOTATION_FOR_UNDERLINE_IS_DOTTED
}
if (spans.isNotEmpty()) {
spans.forEach { span ->
drawUnderline(canvas, allText, span)
}
}
}
private fun drawUnderline(canvas: Canvas, allText: Spanned, span: Annotation) {
// Offset of first character in span
val spanStart = allText.getSpanStart(span)
// Offset of first character *past* the end of the span.
val spanEnd = allText.getSpanEnd(span)
// The span starts on this line
val startLine = layout.getLineForOffset(spanStart)
// Offset of the line that holds the last character of the span. Since
// spanEnd is the offset of the first character past the end of the span, we need
// to subtract one in case the span ends at the end of a line.
val endLine = layout.getLineForOffset(spanEnd - 1)
for (line in startLine..endLine) {
// Offset of first character of the line.
val lineStart = layout.getLineStart(line)
// The segment always start somewhere on the start line. For other lines, the segment
// starts at zero.
val segStart = if (line == startLine) {
max(spanStart, lineStart)
} else {
0
}
// Offset to the character just past the end of this line.
val lineEnd = layout.getLineEnd(line)
// segStart..segEnd covers the part of the span on this line.
val segEnd = min(spanEnd, lineEnd)
// Get x-axis coordinate for the underline to compute the span length. This is OK
// since the segment we are looking at is confined to a single line.
val startStringOnLine = layout.getPrimaryHorizontal(segStart)
val endStringOnLine =
if (segEnd == lineEnd) {
// If segment ends at the line's end, then get the rightmost position on
// the line not imcluding trailing white space which we don't want to underline.
layout.getLineRight(line)
} else {
// The segment's end is on this line, so get offset to end of the last character
// in the segment.
layout.getPrimaryHorizontal(segEnd)
}
val spanLength = endStringOnLine - startStringOnLine
// Get the y-coordinate for the underline.
val offsetY = layout.getLineBaseline(line) + TEXT_TO_UNDERLINE_SEPARATION
// Now draw the underline.
mPath.reset()
mPath.moveTo(startStringOnLine, offsetY)
mPath.lineTo(startStringOnLine + spanLength, offsetY)
canvas.drawPath(mPath, mPaint)
}
}
fun setUnderlineColor(underlineColor: Int) {
mPaint.color = underlineColor
}
companion object {
const val DOTTEDSTROKEWIDTH = 5f
const val DASHPATH_INTERVAL_ON = 4f
const val DASHPATH_INTERVAL_OFF = 4f
const val TEXT_TO_UNDERLINE_SEPARATION = 3f
const val ANNOTATION_FOR_UNDERLINE_KEY = "underline"
const val ANNOTATION_FOR_UNDERLINE_IS_DOTTED = "dotted"
}
}
activity_main.xml
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
tools:context=".MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:context=".MainActivity">
<TextView
android:id="@+id/Label0"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Plain Text"
app:layout_constraintBottom_toTopOf="@+id/textView0"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/textView0"
android:layout_width="188dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="#DDD6D6"
android:paddingBottom="2dp"
android:text="@string/string_to_underline"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toTopOf="@+id/label1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/Label0" />
<TextView
android:id="@+id/label1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="DottedUndelineSpan"
app:layout_constraintBottom_toTopOf="@+id/textView1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView0" />
<TextView
android:id="@+id/textView1"
android:layout_width="188dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="#DDD6D6"
android:paddingBottom="2dp"
android:text="@string/string_to_underline"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toTopOf="@+id/label2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/label1" />
<TextView
android:id="@+id/label2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="UnderlineTextView"
app:layout_constraintBottom_toTopOf="@+id/textView2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView1" />
<com.example.dottedunderlinespan.UnderlineTextView
android:id="@+id/textView2"
android:layout_width="188dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:background="#DDD6D6"
android:paddingBottom="2dp"
android:text="@string/string_to_underline"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/label2" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>