diff --git a/app/src/main/java/org/futo/circles/view/read_more/ReadMoreTextView.kt b/app/src/main/java/org/futo/circles/view/read_more/ReadMoreTextView.kt new file mode 100644 index 0000000000000000000000000000000000000000..ae8447ed9108e7051b26b30e92f7c41e9d860322 --- /dev/null +++ b/app/src/main/java/org/futo/circles/view/read_more/ReadMoreTextView.kt @@ -0,0 +1,238 @@ +package org.futo.circles.view.read_more + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Rect +import android.graphics.Typeface +import android.text.Layout +import android.text.TextPaint +import android.text.TextUtils +import android.text.style.ClickableSpan +import android.text.style.TextAppearanceSpan +import android.util.AttributeSet +import android.view.View +import androidx.appcompat.widget.AppCompatTextView +import androidx.core.content.res.use +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import org.futo.circles.R +import kotlin.text.Typography.ellipsis +import kotlin.text.Typography.nbsp + +class ReadMoreTextView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = R.attr.readMoreTextViewStyle +) : AppCompatTextView(context, attrs, defStyleAttr) { + + private var readMoreMaxLines: Int = 2 + private var readMoreText: String? = null + private var readMoreTextColor: ColorStateList? = null + private var bufferType: BufferType? = null + private var expanded: Boolean = false + private var originalText: CharSequence? = null + private var collapseText: CharSequence? = null + + init { + context.obtainStyledAttributes( + attrs, R.styleable.ReadMoreTextView, defStyleAttr, 0 + ).use { ta -> + readMoreMaxLines = ta.getInt( + R.styleable.ReadMoreTextView_readMoreMaxLines, + readMoreMaxLines + ) + readMoreText = ta.getString(R.styleable.ReadMoreTextView_readMoreText) + ?.replace(' ', nbsp) + readMoreTextColor = ta.getColorStateList( + R.styleable.ReadMoreTextView_readMoreTextColor + ) ?: readMoreTextColor + } + + if (hasOnClickListeners()) throw IllegalStateException("Custom onClickListener not supported") + super.setOnClickListener { toggle() } + + if (originalText != null) invalidateText() + + } + + override fun setLines(lines: Int) { + throw IllegalStateException("Use the app:readMoreMaxLines") + } + + override fun setMaxLines(maxLines: Int) { + throw IllegalStateException("Use the app:readMoreMaxLines") + } + + override fun setEllipsize(where: TextUtils.TruncateAt?) { + throw IllegalStateException("Not supported") + } + + override fun setOnClickListener(l: OnClickListener?) { + throw IllegalStateException("Not supported") + } + + private fun toggle() { + setExpanded(!expanded) + } + + private fun setExpanded(expanded: Boolean) { + if (this.expanded != expanded) { + this.expanded = expanded + invalidateText() + } + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + if (w != oldw) { + originalText?.let { originalText -> + updateText(originalText, w) + } + } + } + + override fun setText(text: CharSequence?, type: BufferType?) { + this.originalText = text + this.bufferType = type + updateText(text ?: "", width) + } + + private fun updateText(text: CharSequence, width: Int) { + val maximumTextWidth = width - (paddingLeft + paddingRight) + val readMoreMaxLines = readMoreMaxLines + if (maximumTextWidth > 0 && readMoreMaxLines > 0) { + val layout = StaticLayoutCompat.Builder(text, paint, maximumTextWidth) + .setLineSpacing(lineSpacingExtra, lineSpacingMultiplier) + .setIncludePad(includeFontPadding) + .build() + if (layout.lineCount <= readMoreMaxLines) { + this.collapseText = text + } else { + this.collapseText = buildSpannedString { + val countUntilMaxLine = layout.getLineVisibleEnd(readMoreMaxLines - 1) + if (text.length <= countUntilMaxLine) { + append(text) + } else { + val overflowText = buildOverflowText() + val overflowTextWidth = StaticLayoutCompat.Builder( + overflowText, + paint, + maximumTextWidth + ) + .build() + .getLineWidth(0).toInt() + + val textAppearanceSpan = TextAppearanceSpan( + null, + Typeface.NORMAL, + textSize.toInt(), + readMoreTextColor, + null + ) + val clickSpan = object : ClickableSpan() { + override fun onClick(widget: View) { + toggle() + } + + override fun updateDrawState(ds: TextPaint) { + ds.isUnderlineText = false + } + + } + val spans = listOfNotNull( + textAppearanceSpan, + clickSpan + ) + val readMoreTextWithStyle = buildReadMoreText(spans = spans.toTypedArray()) + val readMorePaint = TextPaint().apply { + set(paint) + textAppearanceSpan.updateMeasureState(this) + } + val readMoreTextWidth = StaticLayoutCompat.Builder( + readMoreTextWithStyle, + readMorePaint, + maximumTextWidth + ) + .build() + .getLineWidth(0).toInt() + val readMoreWidth = overflowTextWidth + readMoreTextWidth + + val replaceCount = text + .substringOf(layout, line = readMoreMaxLines) + .calculateReplaceCountToBeSingleLineWith(maximumTextWidth - readMoreWidth) + append(text.subSequence(0, countUntilMaxLine - replaceCount)) + append(overflowText) + append(readMoreTextWithStyle) + } + } + } + } else { + this.collapseText = text + } + invalidateText() + } + + private fun buildOverflowText( + text: String? = readMoreText + ): String { + return buildString { + append(ellipsis) + if (text.isNullOrEmpty().not()) append(nbsp) + } + } + + private fun buildReadMoreText( + text: String? = readMoreText, + vararg spans: Any + ): CharSequence { + return buildSpannedString { + if (text.isNullOrEmpty().not()) { + inSpans(spans = spans) { + append(text) + } + } + } + } + + private fun CharSequence.substringOf(layout: Layout, line: Int): CharSequence { + val lastLineStartIndex = layout.getLineStart(line - 1) + val lastLineEndIndex = layout.getLineEnd(line - 1) + return subSequence(lastLineStartIndex, lastLineEndIndex) + } + + private fun CharSequence.calculateReplaceCountToBeSingleLineWith( + maximumTextWidth: Int + ): Int { + val currentTextBounds = Rect() + var replacedCount = -1 + do { + replacedCount++ + val replacedText = substring(0, this.length - replacedCount) + paint.getTextBounds(replacedText, 0, replacedText.length, currentTextBounds) + } while (replacedCount < this.length && currentTextBounds.width() >= maximumTextWidth) + + val lastVisibleChar: Char? = this.getOrNull(this.length - replacedCount - 1) + val firstOverflowChar: Char? = this.getOrNull(this.length - replacedCount) + if (lastVisibleChar?.isSurrogate() == true && firstOverflowChar?.isHighSurrogate() == false) { + val subText = substring(0, this.length - replacedCount) + if (subText.isNotEmpty()) { + return length - subText.indexOfLast { it.isHighSurrogate() } + } + } + return replacedCount + } + + private fun invalidateText() { + if (expanded) { + super.setText(originalText, bufferType) + super.setMaxLines(NO_LIMIT_LINES) + } else { + super.setText(collapseText, bufferType) + super.setMaxLines(readMoreMaxLines) + } + } + + private companion object { + private const val NO_LIMIT_LINES = Integer.MAX_VALUE + } +} \ No newline at end of file diff --git a/app/src/main/java/org/futo/circles/view/read_more/StaticLayoutCompat.kt b/app/src/main/java/org/futo/circles/view/read_more/StaticLayoutCompat.kt new file mode 100644 index 0000000000000000000000000000000000000000..4957b065af6426f2c42baa8a49fdde86d0c68b9c --- /dev/null +++ b/app/src/main/java/org/futo/circles/view/read_more/StaticLayoutCompat.kt @@ -0,0 +1,56 @@ +package org.futo.circles.view.read_more + +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import androidx.annotation.FloatRange + +internal class StaticLayoutCompat { + + class Builder( + private val text: CharSequence, + private val start: Int, + private val end: Int, + private val paint: TextPaint, + private val width: Int + ) { + constructor(text: CharSequence, paint: TextPaint, width: Int) : + this(text, 0, text.length, paint, width) + + private var alignment: Layout.Alignment = Layout.Alignment.ALIGN_NORMAL + private var spacingMult = 1f + private var spacingAdd = 0f + private var includePad = true + private var ellipsizedWidth = width + private var ellipsize: TextUtils.TruncateAt? = null + private var maxLines = Integer.MAX_VALUE + + + fun setLineSpacing( + spacingAdd: Float, + @FloatRange(from = 0.0) spacingMult: Float + ): Builder { + this.spacingAdd = spacingAdd + this.spacingMult = spacingMult + return this + } + + fun setIncludePad(includePad: Boolean): Builder { + this.includePad = includePad + return this + } + + fun build(): StaticLayout { + return StaticLayout.Builder + .obtain(text, start, end, paint, width) + .setAlignment(alignment) + .setLineSpacing(spacingAdd, spacingMult) + .setIncludePad(includePad) + .setEllipsize(ellipsize) + .setEllipsizedWidth(ellipsizedWidth) + .setMaxLines(maxLines) + .build() + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/view_text_post.xml b/app/src/main/res/layout/view_text_post.xml index 8a245793765501c1f10652079b5178ceaa27687e..fcf69dbe2521502bafab6f69ca2473a3dd391197 100644 --- a/app/src/main/res/layout/view_text_post.xml +++ b/app/src/main/res/layout/view_text_post.xml @@ -1,16 +1,20 @@ <?xml version="1.0" encoding="utf-8"?> <org.futo.circles.view.PostLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/lTextPost" android:layout_width="match_parent" android:layout_height="wrap_content"> - <TextView + <org.futo.circles.view.read_more.ReadMoreTextView android:id="@+id/tvContent" style="@style/postMessage" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/post_text_side_margin" android:autoLink="web" - android:textIsSelectable="true" /> + android:textIsSelectable="true" + app:readMoreMaxLines="7" + app:readMoreText="@string/show_more" + app:readMoreTextColor="@color/blue" /> </org.futo.circles.view.PostLayout> \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index cf6e865ece5e7da909547051f6c56dbec4bfdd01..288f89e98354e5835b5ad3174892537d4a717c20 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -16,4 +16,11 @@ <declare-styleable name="GroupPostHeaderView"> <attr name="optionsVisible" format="boolean" /> </declare-styleable> + + <declare-styleable name="ReadMoreTextView"> + <attr name="readMoreMaxLines" format="integer" /> + <attr name="readMoreText" format="string" /> + <attr name="readMoreTextColor" format="reference|color" /> + </declare-styleable> + <attr name="readMoreTextViewStyle" format="reference" /> </resources> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f426987107791c50305798d275e94842436079f5..4869804d213cd84d69af6322db53d1d5f1dd2d6f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -297,6 +297,7 @@ <string name="strong_password">Strong password</string> <string name="very_strong_password">Very strong password</string> <string name="back">Back</string> + <string name="show_more">Show more</string> <string-array name="report_categories"> <item>@string/crude_language</item>