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