This article has been published exclusively by guolin_blog, an official wechat account

When the number of lines exceeds the specified number, ellipsis and full text are displayed.

Implementation approach
  • When the content exceeds the specified number of lines, calculates the position of the first (start) and last (end) characters in the string
  • Measure what to concatenate. Full text)
  • Calculate the number of characters that match the width of the concatenation content (num)
  • Truncate the entire string from 0 to (end-num)
  • Concatenate the content to display and set the click event
  • Set the text

There’s a little bit of a problem here, because the content is immediately following the original text, not at the boundary of the TextView. Also, ClickableSpan sets the click event. If the TextView sets the click event, it will fire at the same time as the TextView’s own click event. If you click on another text without setting it, the click event is not passed to the parent View, but is consumed by the TextView. The diagram below:

Here’s another idea
  • The first steps are the same as above, are to intercept the content, set text
  • After the text is drawn, manually draw the content of the prompt
  • Add click events

This method ensures that the prompt is on the boundary of the TextView, but the click event needs to be overwritten by onTouchEvent() itself, which is a bit more cumbersome.

Content to intercept
 SpannableStringBuilder span = new SpannableStringBuilder();
            int start = layout.getLineStart(mShowMaxLine - 1);
            int end = layout.getLineEnd(mShowMaxLine - 1);
            if (mTipGravity == END) {
                TextPaint paint = getPaint();
                StringBuilder builder = new StringBuilder(ELLIPSIZE_END).append("").append(mFoldText);
                end -= paint.breakText(mOriginalText, start, end, false, paint.measureText(builder.toString()), null);
            } else {
                end--;
            }
Copy the code

Intercepts text when the number of lines of content exceeds the maximum. Layout is the layout of a TextView, where getLineStart() and getLineEnd() get the position of the first and last character of the line, respectively (the position is counted from the first character). The breakText() method calculates the number of characters to intercept. Here’s a quick look at the breakText() method:

Parameters:

  • Measured string
  • Measure the starting position
  • The position where the measurement ends
  • Measure direction,true from front to back, false from back to front
  • The maximum width of the truncated string
  • Intercepts the actual width of the string

Finally returns the number of characters to intercept. After the content is intercepted, the prompt text is processed. The following two methods are briefly explained respectively

Method 1: Directly join the content, set the click event
  CharSequence ellipsize = mOriginalText.subSequence(0, end);
  span.append(ellipsize);
  span.append(ELLIPSIZE_END);
 if (mTipGravity == END) {
                span.append("");
            } else {
                span.append("\n");
            }
            int length;
            if (isExpand) {
                span.append(mExpandText);
                length = mExpandText.length();
            } else {
                span.append(mFoldText);
                length = mFoldText.length();
            }
            if (mTipClickable) {
                span.setSpan(mSpan, span.length() - length, span.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
                setMovementMethod(LinkMovementMethod.getInstance());
            }
            span.setSpan(new ForegroundColorSpan(mTipColor), span.length() - length, span.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
        }
        super.setText(span, type);
Copy the code

Concatenate text on the truncated content with SpannableString and set the corresponding click event.

Method 2: Override onDraw() to draw prompt text and override onTouchEvent() to set the click event

Folded state:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if(isOverMaxLine && ! IsExpand) {// foldif (mTipGravity == END) {
                minX = getWidth() - getPaddingLeft() - getPaddingRight() - getTextWidth("  全文");
                maxX = getWidth() - getPaddingLeft() - getPaddingRight();
                minY = getHeight() - (getPaint().getFontMetrics().descent - getPaint().getFontMetrics().ascent) - getPaddingBottom();
                maxY = getHeight() - getPaddingBottom();
                canvas.drawText("  全文", minX,
                        getHeight() - getPaint().getFontMetrics().descent - getPaddingBottom(), mPaint);
            } else {
                minX = getPaddingLeft();
                maxX = minX + getTextWidth("Full");
                minY = getHeight() - (getPaint().getFontMetrics().descent - getPaint().getFontMetrics().ascent) - getPaddingBottom();
                maxY = getHeight() - getPaddingBottom();
                canvas.drawText("Full", minX, getHeight() - getPaint().getFontMetrics().descent - getPaddingBottom(), mPaint); }}}Copy the code

After the text is captured, rewrite the onDraw() method to calculate the coordinates and draw the text.

PS:minX,maxX,minY,maxY are used for subsequent click events and can be ignored if not required. These four values correspond to the coordinates of the upper left and lower right corner of the prompt respectively.

Expanded state:

SpannableStringBuilder spannable = new SpannableStringBuilder(mOriginalText);if (isShowTipAfterExpand) {
                spannable.append("Close full text");
                spannable.setSpan(new ForegroundColorSpan(mTipColor), spannable.length() - 5, spannable.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
            }
            super.setText(spannable, type);
Copy the code

The coordinate calculation here is a little bit more complicated than the above, and the prompt may break the line.

int mLineCount = getLineCount();
            Layout layout = getLayout();
            minX = getPaddingLeft() + layout.getPrimaryHorizontal(spannable.toString().lastIndexOf("Closed") - 1);
            maxX = getPaddingLeft() + layout.getSecondaryHorizontal(spannable.toString().lastIndexOf("Wen") + 1);
            Rect bound = new Rect();
            if(mLineCount > originalLineCount) {// Not on the same line layout.getLineBounds(OriginallInecount-1, bound); minY = getPaddingTop() + bound.top; middleY = minY + getPaint().getFontMetrics().descent - getPaint().getFontMetrics().ascent; maxY = middleY + getPaint().getFontMetrics().descent - getPaint().getFontMetrics().ascent; }else{// Same line layout.getLineBounds(OriginallInecount-1, bound); minY = getPaddingTop() + bound.top; maxY = minY + getPaint().getFontMetrics().descent - getPaint().getFontMetrics().ascent; }Copy the code

PS: HERE I choose direct stitching. If the expanded state also needs to be attached to the boundary, please refer to the folded state for self-realization.

Finally, override onTouchEvent() to set the click event

  @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mTipClickable) {
            switch (event.getActionMasked()) {
                case MotionEvent.ACTION_DOWN:
                    clickTime = System.currentTimeMillis();
                    if(! isClickable()) {if (isInRange(event.getX(), event.getY())) {
                            return true; }}break;

                case MotionEvent.ACTION_CANCEL:
                case MotionEvent.ACTION_UP:
                    long delTime = System.currentTimeMillis() - clickTime;
                    clickTime = 0L;
                    if(delTime < ViewConfiguration.getTapTimeout() && isInRange(event.getX(), event.getY())) { isExpand = ! isExpand;setText(mOriginalText);
                        return true;
                    }
                    break;
                default:
                    break; }}return super.onTouchEvent(event);
    }
Copy the code

PS: ACTION_DOWN we need to check whether the TextView itself has a click event. If not, we need to manually retrun true, otherwise the click event cannot be delivered. If the click range is too small, we can adjust the isInRange method by ourselves.

Here are two reasons for the problem with method one:
  • Why does a ClickableSpan click event fire at the same time as a TextView click event

Let’s take a look at TextView’s onTouchEvent() method

  @Override
    public boolean onTouchEvent(MotionEvent event) {
        final int action = event.getActionMasked();
        if(mEditor ! = null) { mEditor.onTouchEvent(event);if(mEditor.mSelectionModifierCursorController ! = null && mEditor.mSelectionModifierCursorController.isDragAcceleratorActive()) {return true; } } final boolean superResult = super.onTouchEvent(event); / / to omit...if((mMovement ! = null || onCheckIsTextEditor()) && isEnabled() && mText instanceof Spannable && mLayout ! = null) { boolean handled =false;
            if(mMovement ! = null) { handled |= mMovement.onTouchEvent(this, (Spannable) mText, event); } // omit...if (handled) {
                return true; }}return superResult;
    }
Copy the code

You can see that the onTouchEvent() method of the parent class is executed first, followed by the onTouchEvent() method of the MovementMethod. So what we’ve set up here is LinkMovementMethod, so let’s track that down.

@Override
    public boolean onTouchEvent(TextView widget, Spannable buffer,
                                MotionEvent event) {
        int action = event.getAction();

        if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
            int x = (int) event.getX();
            int y = (int) event.getY();

            x -= widget.getTotalPaddingLeft();
            y -= widget.getTotalPaddingTop();

            x += widget.getScrollX();
            y += widget.getScrollY();

            Layout layout = widget.getLayout();
            int line = layout.getLineForVertical(y);
            int off = layout.getOffsetForHorizontal(line, x);

            ClickableSpan[] links = buffer.getSpans(off, off, ClickableSpan.class);

            if(links.length ! = 0) {if (action == MotionEvent.ACTION_UP) {
                    links[0].onClick(widget);
                } else if (action == MotionEvent.ACTION_DOWN) {
                    Selection.setSelection(buffer,
                        buffer.getSpanStart(links[0]),
                        buffer.getSpanEnd(links[0]));
                }
                return true;
            } else{ Selection.removeSelection(buffer); }}return super.onTouchEvent(widget, buffer, event);
    }
Copy the code

The previous section calculates whether the ClickableSpan is present at the click location. If so, ACTION_UP and ACTION_DOWN return true and the onClick() method of ClickableSpan is called on the ACTION_UP event. If not, return super.onTouchEvent(widget, buffer, event). Breakpoint finds that the value is always true(used later).

That is, TxetView’s onTouchEvent() should return true regardless of whether the ClickSpan is handled, meaning that the TextView will consume the ClickSpan.

If there is a ClickableSpan event, execute it, return true, and tell TextView that I consumed the event. If not, the onTouchEvent() method of the parent class is executed by TextView.

If the ClickableSpan is not ClickableSpan, the ClickableSpan is not ClickableSpan. Also is not. By printing the log, TextView executes the ClickableSpan click method first and then the View click method, for reasons I don’t really know. So, you can override TextView’s setOnClickListener() and onClick() methods to add a variable.

    @Override
    public void setOnClickListener(@Nullable OnClickListener l) {
        listener = l;
        super.setOnClickListener(this);
    }
    @Override
    public void onClick(View v) {
        if (isExpandSpanClick) {
            isExpandSpanClick = false;
        } else{ listener.onClick(v); }}Copy the code
  • With ClickableSpan set, why can’t TextView click events be passed to the parent view

It says that in TextView’s onTouchEvent() method, It calls super.onTouchEvent(event), then mmovement.onTouchEvent (this, (Spannable) mText, event), and returns. Because the handled value has always been true, the event has been consumed by the TextView, causing it to be unhandled. So, we need to modify the LInkMovementMethod onTouchEvent() method to return false if there is no ClickableSpan in the clickrange. Click event still cannot be delivered after modification. Going back to TextView’s onTouchEvent() method, it now returns superResult, which is super.ontouchenent (). Take a look at the View’s onTouchEvent() method

 public boolean onTouchEvent(MotionEvent event) {
        final float x = event.getX();
        final floaty = event.getY(); final int viewFlags = mViewFlags; final int action = event.getAction(); final boolean clickable = ((viewFlags & CLICKABLE) == CLICKABLE || (viewFlags & LONG_CLICKABLE) == LONG_CLICKABLE) || (viewFlags & CONTEXT_CLICKABLE) == CONTEXT_CLICKABLE; / /... omitif (clickable || (viewFlags & TOOLTIP) == TOOLTIP) {
            switch (action) {
                caseACTION_UP: // omit...break;

                caseACTION_DOWN: // omit...break;

                caseACTION_CANCEL: // omit...break;

                caseACTION_MOVE: // omit...break;
            }

            return true;
        }

        return false;
    }
Copy the code

First, the clickable variable is true if the view can be clicked, long pressed, context-clicked. All the way to if, the clickable is true, so it returns true, and that value will be returned in the onTouchEvent() of the TextView, telling the parent that the event was consumed. But textView can’t be clicked, long pressed by default. That is, setting up ClickableSpan changes these Settings.

    public final void setMovementMethod(MovementMethod movement) {
        if(mMovement ! = movement) { mMovement = movement;if(movement ! = null && ! (mText instanceof Spannable)) {setText(mText);
            }

            fixFocusableAndClickableSettings();

            // SelectionModifierCursorController depends on textCanBeSelected, which depends on
            // mMovement
            if (mEditor != null) mEditor.prepareCursorControllers();
        }
    }
Copy the code

SetMovementMethod () method inside a fixFocusableAndClickableSettings () method, tracking

    private void fixFocusableAndClickableSettings() {
        if(mMovement ! = null || (mEditor ! = null && mEditor.mKeyListener ! = null)) {setFocusable(FOCUSABLE);
            setClickable(true);
            setLongClickable(true);
        } else {
            setFocusable(FOCUSABLE_AUTO);
            setClickable(false);
            setLongClickable(false); }}Copy the code

It turns out that the Settings have been changed, causing the problems above. So, we also need to call ClickableSpan after setting it up

setFocusable(false);
setClickable(false);
setLongClickable(false);
Copy the code

This way, the event can be passed to the parent View


Fixed recyclerView reuse problem, specific use of reference demo_Java version

Reference: blog.csdn.net/zhuhai__yiz…

The enclosed demo:

Java

Kotlin