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

五迷三道 提交于 2019-12-24 02:48:48

问题


I done the dotted underline textview using this Dotted underline in TextView using SpannableString in Android. But dotted underline textview not wrapping to the next line. I have attached screenshot for reference. Please advice your ideas. Thanks

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)
    }
}

*Set dotted line using SpannableStringbuilder *

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

Error:

Expected:


回答1:


The problem is that a ReplacementSpan cannot cross a line boundary. See Drawing a rounded corner background on text for more information on this issue.

You could use the solution from the blog post mentioned above, but we can simplify that solution based upon your requirements as follows:

Here is the general procedure:

  1. Place Annotation spans around the text in the TextView that we want to underline.
  2. Let the text be laid out and catch the TextView just before drawing using a predraw listener. At this point the text is laid out as it will be displayed on the screen.
  3. Replace each Annotation span with one or more DottedUnderlineSpans ensuring that each underline span does not cross a line boundary.
  4. Strip trailing white space from the ReplacementSpan since we don't want to underline trailing white space.
  5. Replace the text in the TextView.

A little complicated, but it will allow the use of the DottedUnderlineSpan class. This may not be a 100% solution since the width of the ReplacementSpan may vary from the width of the text under certain circumstances.

I do, however, recommend that you use a custom TextView with annotations to mark the placement of the underlines. This is probably going to be the easiest to do and to understand and is unlikely to have unforeseen side effects. The general procedure is to mark the text with annotation spans as above, but interpret these annotation spans in the draw() function of a custom text view to produce the underlines.

I have put together a small project to demonstrate these methods. The output looks like the following for a TextView with no underlined text, one with underlined text using the DottedUnderlineSpan and one with underlined text in a custom 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

I reworked this a little.

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>



回答2:


I made a simple example that I have posted on Github (https://github.com/jaindiv26/DottedTextSample). I've followed the Deva's approach and made some adjustment, it's working for multiple lines too. Check out this example.




回答3:


Dotted underline in TextView using SpannableString in Android.

1. Make DottedLineSpan Common 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. Use this code in your 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;
        }
    }


来源:https://stackoverflow.com/questions/58220223/dotted-underline-textview-not-wrapping-to-the-next-line-using-spannablestring-in

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!