Android 中使用 SpannableString 的虚线下划线 TextView 未换行到下一行

Dotted underline TextView not wrapping to the next line using SpannableString in Android

我使用这个 完成了虚线下划线文本视图。但是虚线下划线 textview 没有换行到下一行 。我附上截图以供参考。请提出您的想法。谢谢

class DottedUnderlineSpan(mColor: Int, private val mSpan: String) : ReplacementSpan() {
    private val paint: Paint
    private var width: Int = 0
    private var spanLength: Float = 0f
    private val lengthIsCached = false
    internal var strokeWidth: Float = 0f
    internal var dashPathEffect: Float = 0f
    internal var offsetY: Float = 0f

    init {
        strokeWidth = 5f
        dashPathEffect = 4f
        offsetY = 14f
        paint = Paint()
        paint.color = mColor
        paint.style = Paint.Style.STROKE
        paint.pathEffect = DashPathEffect(floatArrayOf(dashPathEffect, dashPathEffect), 0f)
        paint.strokeWidth = strokeWidth
    }

    override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
        width = paint.measureText(text, start, end).toInt()
        return width
    }

    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)

        if (!lengthIsCached)
            spanLength = paint.measureText(mSpan)
        val path = Path()
        path.moveTo(x, y + offsetY)
        path.lineTo(x + spanLength, y + offsetY)
        canvas.drawPath(path, this.paint)
    }
}

*使用 SpannableStringbuilder 设置虚线 *

   DottedUnderlineSpan dottedUnderlineSpan = new DottedUnderlineSpan(underlineColor, dottedString);
                    strBuilder.setSpan(dottedUnderlineSpan, start, end, Spanned.SPAN_INCLUSIVE_INCLUSIVE);

错误:

预计:

问题是 ReplacementSpan 不能跨越线边界。有关此问题的更多信息,请参阅 Drawing a rounded corner background on text

您可以使用上述博客 post 中的解决方案,但我们可以根据您的要求简化该解决方案,如下所示:

这是一般程序:

  1. Annotation 放置在 TextView 中我们想要加下划线的文本周围。
  2. 放置文本并在使用预绘制侦听器绘制之前捕获 TextView。此时,文本已按照将在屏幕上显示的方式进行布局。
  3. 用一个或多个 DottedUnderlineSpans 替换每个 Annotation 范围,确保每个下划线范围不跨越线边界。
  4. ReplacementSpan 中去除尾随白色 space,因为我们不想在尾随白色 space.
  5. 下划线
  6. 替换 TextView 中的文本。

有点复杂,但它允许使用 DottedUnderlineSpan class。 这可能不是 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>

我在 Github (https://github.com/jaindiv26/DottedTextSample) 上发布了一个简单的示例。我已经按照 Deva 的方法进行了一些调整,它也适用于多行。看看这个例子。

在 Android.

中使用 SpannableString 在 TextView 中使用虚线下划线

1.使 DottedLineSpan 通用 Class.

class DottedLineSpan extends ReplacementSpan {
    private Paint p = new Paint();
    private int mWidth;
    private String mSpan;

    private float mSpanLength = 0F;
    private boolean mLengthIsCached = false;
    private Float mOffsetY = 0f;

    DottedLineSpan(int _color, String _spannedText, Context context){
        float mStrokeWidth = context.getResources().getDimension(R.dimen.stroke_width);
        float mDashPathEffect = context.getResources().getDimension(R.dimen.dash_path_effect);
        mOffsetY = context.getResources().getDimension(R.dimen.offset_y);

        p = new Paint();
        p.setColor(_color);
        p.setStyle(Paint.Style.STROKE);
        p.setPathEffect(new DashPathEffect(new float[]{mDashPathEffect, mDashPathEffect}, 0));
        p.setStrokeWidth(mStrokeWidth);
        mSpan = _spannedText;
        mSpanLength = _spannedText.length();
    }

    @Override
    public int getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm) {
        mWidth = (int) paint.measureText(text, start, end);
        return mWidth;
    }

    @Override
    public void draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint) {
        canvas.drawText(text, start, end, x, y, paint);
        if(!mLengthIsCached)
            mSpanLength = paint.measureText(mSpan);
        Path path = new Path();
        path.moveTo(x, y + mOffsetY);
        path.lineTo(x + mSpanLength, y + mOffsetY);
        canvas.drawPath(path, this.p);
    }
}

2。在您的 activity.

中使用此代码
public class MainActivity extends AppCompatActivity {

        private TextView textView;

        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);

            textView = findViewById(R.id.textView);

            String string = "Android is a mobile operating system based on a modified version of the Linux kernel and other open source software, designed primarily for touchscreen mobile devices such as smartphones and tablets. ";

            String textToUnderline = "modified version of the Linux kernel";

            SpannableString text = new SpannableString(string);

            int[] range = getStartingAndEndOfSentence(string, textToUnderline);

            DottedLineSpan dottedLineSpan = new DottedLineSpan(R.color.colorPrimary, textToUnderline, this);
            text.setSpan(dottedLineSpan, range[0], range[1], Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);

            textView.setText(text);
        }

        int[] getStartingAndEndOfSentence(String wholeString, String partOfAString) {

            int[] range = new int[2];

            String[] s1 = wholeString.split("\s+");
            String[] s2 = partOfAString.split("\s+");

            if (s2.length == 1) {
                String word = s2[0];
                range[0] = wholeString.indexOf(word);
                range[1] = range[0] + word.length();
            } else {
                int length = 0;
                for (int i = 0; i < s1.length; i++) {
                    length = length + s1[i].length() + 1;
                    if (s1[i].equals(s2[0])) {
                        if(s1[i+1].equals(s2[1])) {
                            range[0] = length - (s1[i].length() + 1);
                            range[1] = range[0] + partOfAString.length();
                            break;
                        }
                    }
                }
            }
            return range;
        }
    }