From f3db43f3179a84982ade645237a34fe9e2a6f346 Mon Sep 17 00:00:00 2001 From: Ganard Date: Mon, 27 Jan 2020 12:41:43 +0100 Subject: [PATCH] Ellipsize: introduce EllipsizingTextView --- .../core/platform/EllipsizingTextView.java | 445 ++++++++++++++++++ ...onstraint_set_composer_layout_expanded.xml | 2 +- .../layout/fragment_create_direct_room.xml | 2 +- ...ent_create_direct_room_directory_users.xml | 2 +- .../main/res/layout/fragment_create_room.xml | 2 +- .../main/res/layout/fragment_home_detail.xml | 2 +- .../res/layout/fragment_matrix_profile.xml | 2 +- .../main/res/layout/fragment_room_detail.xml | 4 +- .../res/layout/fragment_room_member_list.xml | 2 +- .../fragment_room_preview_no_preview.xml | 2 +- .../res/layout/item_autocomplete_emoji.xml | 2 +- .../res/layout/item_bottom_sheet_action.xml | 2 +- .../item_bottom_sheet_message_preview.xml | 4 +- .../layout/item_bottom_sheet_room_preview.xml | 2 +- .../layout/item_create_direct_room_user.xml | 4 +- vector/src/main/res/layout/item_device.xml | 2 +- .../src/main/res/layout/item_form_switch.xml | 2 +- vector/src/main/res/layout/item_group.xml | 2 +- .../main/res/layout/item_profile_action.xml | 4 +- .../res/layout/item_profile_matrix_item.xml | 4 +- .../src/main/res/layout/item_public_room.xml | 2 +- vector/src/main/res/layout/item_room.xml | 6 +- .../main/res/layout/item_room_category.xml | 2 +- .../main/res/layout/item_room_directory.xml | 4 +- .../main/res/layout/item_room_invitation.xml | 4 +- .../res/layout/item_timeline_event_base.xml | 2 +- vector/src/main/res/layout/item_user.xml | 4 +- .../main/res/layout/merge_composer_layout.xml | 2 +- 28 files changed, 482 insertions(+), 37 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/core/platform/EllipsizingTextView.java diff --git a/vector/src/main/java/im/vector/riotx/core/platform/EllipsizingTextView.java b/vector/src/main/java/im/vector/riotx/core/platform/EllipsizingTextView.java new file mode 100644 index 0000000000..bc6332cd58 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/platform/EllipsizingTextView.java @@ -0,0 +1,445 @@ +/* + * Copyright 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package im.vector.riotx.core.platform; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.text.Layout; +import android.text.Layout.Alignment; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.StaticLayout; +import android.text.TextUtils; +import android.text.TextUtils.TruncateAt; +import android.text.style.ForegroundColorSpan; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatTextView; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * A {@link android.widget.TextView} that ellipsizes more intelligently. + * This class supports ellipsizing multiline text through setting {@code android:ellipsize} + * and {@code android:maxLines}. + *

+ * Note: {@link android.text.TextUtils.TruncateAt#MARQUEE} ellipsizing type is not supported. + * This as to be used to get rid of the StaticLayout issue with maxLines and ellipsize causing some performance issues. + */ +public class EllipsizingTextView extends AppCompatTextView { + public static final int ELLIPSIZE_ALPHA = 0x88; + private SpannableString ELLIPSIS = new SpannableString("\u2026"); + + private static final Pattern DEFAULT_END_PUNCTUATION + = Pattern.compile("[\\.!?,;:\u2026]*$", Pattern.DOTALL); + private final List mEllipsizeListeners = new ArrayList<>(); + private EllipsizeStrategy mEllipsizeStrategy; + private boolean isEllipsized; + private boolean isStale; + private boolean programmaticChange; + private CharSequence mFullText; + private int mMaxLines; + private float mLineSpacingMult = 1.0f; + private float mLineAddVertPad = 0.0f; + + /** + * The end punctuation which will be removed when appending {@link #ELLIPSIS}. + */ + private Pattern mEndPunctPattern; + + public EllipsizingTextView(Context context) { + this(context, null); + } + + + public EllipsizingTextView(Context context, AttributeSet attrs) { + this(context, attrs, android.R.attr.textViewStyle); + } + + + public EllipsizingTextView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + TypedArray a = context.obtainStyledAttributes(attrs, + new int[]{android.R.attr.maxLines, android.R.attr.ellipsize}, defStyle, 0); + setMaxLines(a.getInt(0, Integer.MAX_VALUE)); + a.recycle(); + setEndPunctuationPattern(DEFAULT_END_PUNCTUATION); + final int currentTextColor = getCurrentTextColor(); + final int ellipsizeColor = Color.argb(ELLIPSIZE_ALPHA, Color.red(currentTextColor), Color.green(currentTextColor), Color.blue(currentTextColor)); + ELLIPSIS.setSpan(new ForegroundColorSpan(ellipsizeColor), 0, ELLIPSIS.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + } + + public void setEndPunctuationPattern(Pattern pattern) { + mEndPunctPattern = pattern; + } + + @SuppressWarnings("unused") + public void addEllipsizeListener(@NonNull EllipsizeListener listener) { + mEllipsizeListeners.add(listener); + } + + @SuppressWarnings("unused") + public void removeEllipsizeListener(@NonNull EllipsizeListener listener) { + mEllipsizeListeners.remove(listener); + } + + @SuppressWarnings("unused") + public boolean isEllipsized() { + return isEllipsized; + } + + /** + * @return The maximum number of lines displayed in this {@link android.widget.TextView}. + */ + public int getMaxLines() { + return mMaxLines; + } + + @Override + public void setMaxLines(int maxLines) { + super.setMaxLines(maxLines); + mMaxLines = maxLines; + isStale = true; + } + + /** + * Determines if the last fully visible line is being ellipsized. + * + * @return {@code true} if the last fully visible line is being ellipsized; + * otherwise, returns {@code false}. + */ + public boolean ellipsizingLastFullyVisibleLine() { + return mMaxLines == Integer.MAX_VALUE; + } + + @Override + public void setLineSpacing(float add, float mult) { + mLineAddVertPad = add; + mLineSpacingMult = mult; + super.setLineSpacing(add, mult); + } + + @Override + public void setText(CharSequence text, BufferType type) { + if (!programmaticChange) { + mFullText = text instanceof Spanned ? (Spanned) text : text; + isStale = true; + } + super.setText(text, type); + } + + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + if (ellipsizingLastFullyVisibleLine()) { + isStale = true; + } + } + + @Override + public void setPadding(int left, int top, int right, int bottom) { + super.setPadding(left, top, right, bottom); + if (ellipsizingLastFullyVisibleLine()) { + isStale = true; + } + } + + @Override + protected void onDraw(@NonNull Canvas canvas) { + if (isStale) { + resetText(); + } + super.onDraw(canvas); + } + + /** + * Sets the ellipsized text if appropriate. + */ + private void resetText() { + int maxLines = getMaxLines(); + CharSequence workingText = mFullText; + boolean ellipsized = false; + + if (maxLines != -1) { + if (mEllipsizeStrategy == null) setEllipsize(null); + workingText = mEllipsizeStrategy.processText(mFullText); + ellipsized = !mEllipsizeStrategy.isInLayout(mFullText); + } + + if (!workingText.equals(getText())) { + programmaticChange = true; + try { + setText(workingText); + } finally { + programmaticChange = false; + } + } + + isStale = false; + if (ellipsized != isEllipsized) { + isEllipsized = ellipsized; + for (EllipsizeListener listener : mEllipsizeListeners) { + listener.ellipsizeStateChanged(ellipsized); + } + } + } + + /** + * Causes words in the text that are longer than the view is wide to be ellipsized + * instead of broken in the middle. Use {@code null} to turn off ellipsizing. + *

+ * Note: Method does nothing for {@link android.text.TextUtils.TruncateAt#MARQUEE} + * ellipsizing type. + * + * @param where part of text to ellipsize + */ + @Override + public void setEllipsize(TruncateAt where) { + if (where == null) { + mEllipsizeStrategy = new EllipsizeNoneStrategy(); + return; + } + + switch (where) { + case END: + mEllipsizeStrategy = new EllipsizeEndStrategy(); + break; + case START: + mEllipsizeStrategy = new EllipsizeStartStrategy(); + break; + case MIDDLE: + mEllipsizeStrategy = new EllipsizeMiddleStrategy(); + break; + case MARQUEE: + default: + mEllipsizeStrategy = new EllipsizeNoneStrategy(); + break; + } + } + + /** + * A listener that notifies when the ellipsize state has changed. + */ + public interface EllipsizeListener { + void ellipsizeStateChanged(boolean ellipsized); + } + + /** + * A base class for an ellipsize strategy. + */ + private abstract class EllipsizeStrategy { + /** + * Returns ellipsized text if the text does not fit inside of the layout; + * otherwise, returns the full text. + * + * @param text text to process + * @return Ellipsized text if the text does not fit inside of the layout; + * otherwise, returns the full text. + */ + public CharSequence processText(CharSequence text) { + return !isInLayout(text) ? createEllipsizedText(text) : text; + } + + /** + * Determines if the text fits inside of the layout. + * + * @param text text to fit + * @return {@code true} if the text fits inside of the layout; + * otherwise, returns {@code false}. + */ + public boolean isInLayout(CharSequence text) { + Layout layout = createWorkingLayout(text); + return layout.getLineCount() <= getLinesCount(); + } + + /** + * Creates a working layout with the given text. + * + * @param workingText text to create layout with + * @return {@link android.text.Layout} with the given text. + */ + protected Layout createWorkingLayout(CharSequence workingText) { + return new StaticLayout(workingText, getPaint(), + getWidth() - getCompoundPaddingLeft() - getCompoundPaddingRight(), + Alignment.ALIGN_NORMAL, mLineSpacingMult, + mLineAddVertPad, false /* includepad */); + } + + /** + * Get how many lines of text we are allowed to display. + */ + protected int getLinesCount() { + if (ellipsizingLastFullyVisibleLine()) { + int fullyVisibleLinesCount = getFullyVisibleLinesCount(); + return fullyVisibleLinesCount == -1 ? 1 : fullyVisibleLinesCount; + } else { + return mMaxLines; + } + } + + /** + * Get how many lines of text we can display so their full height is visible. + */ + protected int getFullyVisibleLinesCount() { + Layout layout = createWorkingLayout(""); + int height = getHeight() - getCompoundPaddingTop() - getCompoundPaddingBottom(); + int lineHeight = layout.getLineBottom(0); + return height / lineHeight; + } + + /** + * Creates ellipsized text from the given text. + * + * @param fullText text to ellipsize + * @return Ellipsized text + */ + protected abstract CharSequence createEllipsizedText(CharSequence fullText); + } + + /** + * An {@link EllipsizingTextView.EllipsizeStrategy} that + * does not ellipsize text. + */ + private class EllipsizeNoneStrategy extends EllipsizeStrategy { + @Override + protected CharSequence createEllipsizedText(CharSequence fullText) { + return fullText; + } + } + + /** + * An {@link EllipsizingTextView.EllipsizeStrategy} that + * ellipsizes text at the end. + */ + private class EllipsizeEndStrategy extends EllipsizeStrategy { + @Override + protected CharSequence createEllipsizedText(CharSequence fullText) { + Layout layout = createWorkingLayout(fullText); + int cutOffIndex = layout.getLineEnd(mMaxLines - 1); + int textLength = fullText.length(); + int cutOffLength = textLength - cutOffIndex; + if (cutOffLength < ELLIPSIS.length()) cutOffLength = ELLIPSIS.length(); + CharSequence workingText = TextUtils.substring(fullText, 0, textLength - cutOffLength).trim(); + + while (!isInLayout(TextUtils.concat(stripEndPunctuation(workingText), ELLIPSIS))) { + int lastSpace = TextUtils.lastIndexOf(workingText, ' '); + if (lastSpace == -1) { + break; + } + workingText = TextUtils.substring(workingText, 0, lastSpace).trim(); + } + + workingText = TextUtils.concat(stripEndPunctuation(workingText), ELLIPSIS); + SpannableStringBuilder dest = new SpannableStringBuilder(workingText); + + if (fullText instanceof Spanned) { + TextUtils.copySpansFrom((Spanned) fullText, 0, workingText.length(), null, dest, 0); + } + return dest; + } + + /** + * Strips the end punctuation from a given text according to {@link #mEndPunctPattern}. + * + * @param workingText text to strip end punctuation from + * @return Text without end punctuation. + */ + public String stripEndPunctuation(CharSequence workingText) { + return mEndPunctPattern.matcher(workingText).replaceFirst(""); + } + } + + /** + * An {@link EllipsizingTextView.EllipsizeStrategy} that + * ellipsizes text at the start. + */ + private class EllipsizeStartStrategy extends EllipsizeStrategy { + @Override + protected CharSequence createEllipsizedText(CharSequence fullText) { + Layout layout = createWorkingLayout(fullText); + int cutOffIndex = layout.getLineEnd(mMaxLines - 1); + int textLength = fullText.length(); + int cutOffLength = textLength - cutOffIndex; + if (cutOffLength < ELLIPSIS.length()) cutOffLength = ELLIPSIS.length(); + CharSequence workingText = TextUtils.substring(fullText, cutOffLength, textLength).trim(); + + while (!isInLayout(TextUtils.concat(ELLIPSIS, workingText))) { + int firstSpace = TextUtils.indexOf(workingText, ' '); + if (firstSpace == -1) { + break; + } + workingText = TextUtils.substring(workingText, firstSpace, workingText.length()).trim(); + } + + workingText = TextUtils.concat(ELLIPSIS, workingText); + SpannableStringBuilder dest = new SpannableStringBuilder(workingText); + + if (fullText instanceof Spanned) { + TextUtils.copySpansFrom((Spanned) fullText, textLength - workingText.length(), + textLength, null, dest, 0); + } + return dest; + } + } + + /** + * An {@link EllipsizingTextView.EllipsizeStrategy} that + * ellipsizes text in the middle. + */ + private class EllipsizeMiddleStrategy extends EllipsizeStrategy { + @Override + protected CharSequence createEllipsizedText(CharSequence fullText) { + Layout layout = createWorkingLayout(fullText); + int cutOffIndex = layout.getLineEnd(mMaxLines - 1); + int textLength = fullText.length(); + int cutOffLength = textLength - cutOffIndex; + if (cutOffLength < ELLIPSIS.length()) cutOffLength = ELLIPSIS.length(); + cutOffLength += cutOffIndex % 2; // Make it even. + String firstPart = TextUtils.substring( + fullText, 0, textLength / 2 - cutOffLength / 2).trim(); + String secondPart = TextUtils.substring( + fullText, textLength / 2 + cutOffLength / 2, textLength).trim(); + + while (!isInLayout(TextUtils.concat(firstPart, ELLIPSIS, secondPart))) { + int lastSpaceFirstPart = firstPart.lastIndexOf(' '); + int firstSpaceSecondPart = secondPart.indexOf(' '); + if (lastSpaceFirstPart == -1 || firstSpaceSecondPart == -1) break; + firstPart = firstPart.substring(0, lastSpaceFirstPart).trim(); + secondPart = secondPart.substring(firstSpaceSecondPart, secondPart.length()).trim(); + } + + SpannableStringBuilder firstDest = new SpannableStringBuilder(firstPart); + SpannableStringBuilder secondDest = new SpannableStringBuilder(secondPart); + + if (fullText instanceof Spanned) { + TextUtils.copySpansFrom((Spanned) fullText, 0, firstPart.length(), + null, firstDest, 0); + TextUtils.copySpansFrom((Spanned) fullText, textLength - secondPart.length(), + textLength, null, secondDest, 0); + } + return TextUtils.concat(firstDest, ELLIPSIS, secondDest); + } + } +} \ No newline at end of file diff --git a/vector/src/main/res/layout/constraint_set_composer_layout_expanded.xml b/vector/src/main/res/layout/constraint_set_composer_layout_expanded.xml index 4c9225dba7..d246c988e6 100644 --- a/vector/src/main/res/layout/constraint_set_composer_layout_expanded.xml +++ b/vector/src/main/res/layout/constraint_set_composer_layout_expanded.xml @@ -60,7 +60,7 @@ app:layout_constraintTop_toTopOf="parent" tools:text="@tools:sample/first_names" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -