[merge,WIP] interface'd FooteredTextView

Change-Id: I62f09fff7d094ebb3bf6690b17c951e4e48e80c7
This commit is contained in:
SpiritCroc 2023-02-08 12:44:59 +01:00
parent 51274af2fe
commit 8c2b9ec6f4
9 changed files with 202 additions and 145 deletions

View File

@ -0,0 +1,149 @@
package im.vector.app.core.ui.views
import android.graphics.Canvas
import android.graphics.Rect
import android.text.Layout
import android.text.Spannable
import android.text.Spanned
import android.view.View
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.text.getSpans
import androidx.core.text.toSpanned
import androidx.core.view.ViewCompat.LAYOUT_DIRECTION_RTL
import im.vector.app.R
import im.vector.app.features.html.HtmlCodeSpan
import io.noties.markwon.core.spans.EmphasisSpan
import kotlin.math.ceil
import kotlin.math.max
/**
* TextView that reserves space at the bottom for overlaying it with a footer, e.g. in a FrameLayout or RelativeLayout
*/
interface AbstractFooteredTextView {
fun getAppCompatTextView(): AppCompatTextView
fun setMeasuredDimensionExposed(measuredWidth: Int, measuredHeight: Int)
val footerState: FooterState
class FooterState {
var footerHeight: Int = 0
var footerWidth: Int = 0
//var widthLimit: Float = 0f
// Some Rect to use during draw, since we should not alloc it during draw
val testBounds = Rect()
// Workaround to RTL languages with non-RTL content messages aligning left instead of start
var requiredHorizontalCanvasMove = 0f
}
fun updateDimensionsWithFooter(widthMeasureSpec: Int, heightMeasureSpec: Int) = with(getAppCompatTextView()) {
// Default case
footerState.requiredHorizontalCanvasMove = 0f
// Get max available width
//val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = View.MeasureSpec.getSize(widthMeasureSpec)
//val widthLimit = if (widthMode == MeasureSpec.AT_MOST) { widthSize.toFloat() } else { Float.MAX_VALUE }
val widthLimit = widthSize.toFloat()
/*
// Sometimes, widthLimit is not the actual limit, so remember it... ?
if (this.widthLimit > widthLimit) {
widthLimit = this.widthLimit
} else {
this.widthLimit = widthLimit
}
*/
val lastLine = layout.lineCount - 1
// Let's check if the last line's text has the same RTL behaviour as the layout direction.
val viewIsRtl = layoutDirection == LAYOUT_DIRECTION_RTL
val looksLikeRtl = layout.getParagraphDirection(lastLine) == Layout.DIR_RIGHT_TO_LEFT
/*
val lastVisibleCharacter = layout.getLineVisibleEnd(lastLine) - 1
val looksLikeRtl = layout.isRtlCharAt(lastVisibleCharacter)
*/
// Get required width for all lines
var maxLineWidth = 0f
for (i in 0 until layout.lineCount) {
// For some reasons, the getLineWidth is not working too well with RTL lines when rendering replies.
// -> https://github.com/SchildiChat/SchildiChat-android/issues/74
// However, the bounds method is a little generous sometimes (reserving too much space),
// so we don't want to use it over getLineWidth() unless required.
maxLineWidth = if (layout.getParagraphDirection(i) == Layout.DIR_RIGHT_TO_LEFT) {
layout.getLineBounds(i, footerState.testBounds)
max((footerState.testBounds.right - footerState.testBounds.left).toFloat(), maxLineWidth)
} else {
max(layout.getLineWidth(i), maxLineWidth)
}
}
// Fix wrap_content in multi-line texts by using maxLineWidth instead of measuredWidth here
// (compare WrapWidthTextView.kt)
var newWidth = ceil(maxLineWidth).toInt()
var newHeight = measuredHeight
val widthLastLine = layout.getLineWidth(lastLine)
// Required width if putting footer in the same line as the last line
val widthWithHorizontalFooter = (
if (looksLikeRtl == viewIsRtl)
widthLastLine
else
(maxLineWidth + resources.getDimensionPixelSize(R.dimen.sc_footer_rtl_mismatch_extra_padding))
) + footerState.footerWidth
// If the last line is a multi-line code block, we have never space in the last line (as the black background always uses full width)
val forceNewlineFooter: Boolean
// For italic text, we need some extra space due to a wrap_content bug: https://stackoverflow.com/q/4353836
val addItalicPadding: Boolean
if (text is Spannable || text is Spanned) {
val span = text.toSpanned()
// If not found, -1+1 = 0
val lastLineStart = span.lastIndexOf("\n") + 1
val lastLineCodeSpans = span.getSpans<HtmlCodeSpan>(lastLineStart)
forceNewlineFooter = lastLineCodeSpans.any { it.isBlock }
addItalicPadding = span.getSpans<EmphasisSpan>().isNotEmpty()
} else {
forceNewlineFooter = false
addItalicPadding = false
}
// Is there space for a horizontal footer?
if (widthWithHorizontalFooter <= widthLimit && !forceNewlineFooter) {
// Reserve extra horizontal footer space if necessary
if (widthWithHorizontalFooter > newWidth) {
newWidth = ceil(widthWithHorizontalFooter).toInt()
if (viewIsRtl) {
footerState.requiredHorizontalCanvasMove = widthWithHorizontalFooter - measuredWidth
}
}
} else {
// Reserve vertical footer space
newHeight += footerState.footerHeight
// Ensure enough width for footer bellow
newWidth = max(newWidth, footerState.footerWidth +
resources.getDimensionPixelSize(R.dimen.sc_footer_padding_compensation) +
2 * resources.getDimensionPixelSize(R.dimen.sc_footer_overlay_padding))
}
if (addItalicPadding) {
newWidth += resources.getDimensionPixelSize(R.dimen.italic_text_view_extra_padding)
}
//setMeasuredDimension(newWidth, newHeight)
Pair(newWidth, newHeight)
}
fun updateFooterOnPreDraw(canvas: Canvas?) {
// Workaround to RTL languages with non-RTL content messages aligning left instead of start
if (footerState.requiredHorizontalCanvasMove > 0f) {
canvas?.translate(footerState.requiredHorizontalCanvasMove, 0f)
}
}
}

View File

@ -0,0 +1,32 @@
package im.vector.app.core.ui.views
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import io.element.android.wysiwyg.EditorStyledTextView
class FooteredEditorStyledTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
): EditorStyledTextView(context, attrs, defStyleAttr), AbstractFooteredTextView {
override val footerState: AbstractFooteredTextView.FooterState = AbstractFooteredTextView.FooterState()
override fun getAppCompatTextView(): AppCompatTextView = this
override fun setMeasuredDimensionExposed(measuredWidth: Int, measuredHeight: Int) = setMeasuredDimension(measuredWidth, measuredHeight)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// First, let super measure the content for our normal TextView use
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val updatedMeasures = updateDimensionsWithFooter(widthMeasureSpec, heightMeasureSpec)
setMeasuredDimension(updatedMeasures.first, updatedMeasures.second)
}
override fun onDraw(canvas: Canvas?) {
updateFooterOnPreDraw(canvas)
super.onDraw(canvas)
}
}

View File

@ -2,148 +2,29 @@ package im.vector.app.core.ui.views
import android.content.Context
import android.graphics.Canvas
import android.graphics.Rect
import android.text.Layout
import android.text.Spannable
import android.text.Spanned
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.text.getSpans
import androidx.core.text.toSpanned
import im.vector.app.R
import im.vector.app.features.html.HtmlCodeSpan
import io.noties.markwon.core.spans.EmphasisSpan
import kotlin.math.ceil
import kotlin.math.max
/**
* TextView that reserves space at the bottom for overlaying it with a footer, e.g. in a FrameLayout or RelativeLayout
*/
class FooteredTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
): AppCompatTextView(context, attrs, defStyleAttr) {
): AppCompatTextView(context, attrs, defStyleAttr), AbstractFooteredTextView {
var footerHeight: Int = 0
var footerWidth: Int = 0
//var widthLimit: Float = 0f
// Some Rect to use during draw, since we should not alloc it during draw
private val testBounds = Rect()
// Workaround to RTL languages with non-RTL content messages aligning left instead of start
private var requiredHorizontalCanvasMove = 0f
override val footerState: AbstractFooteredTextView.FooterState = AbstractFooteredTextView.FooterState()
override fun getAppCompatTextView(): AppCompatTextView = this
override fun setMeasuredDimensionExposed(measuredWidth: Int, measuredHeight: Int) = setMeasuredDimension(measuredWidth, measuredHeight)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// First, let super measure the content for our normal TextView use
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// Default case
requiredHorizontalCanvasMove = 0f
// Get max available width
//val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
//val widthLimit = if (widthMode == MeasureSpec.AT_MOST) { widthSize.toFloat() } else { Float.MAX_VALUE }
val widthLimit = widthSize.toFloat()
/*
// Sometimes, widthLimit is not the actual limit, so remember it... ?
if (this.widthLimit > widthLimit) {
widthLimit = this.widthLimit
} else {
this.widthLimit = widthLimit
}
*/
val lastLine = layout.lineCount - 1
// Let's check if the last line's text has the same RTL behaviour as the layout direction.
val viewIsRtl = layoutDirection == LAYOUT_DIRECTION_RTL
val looksLikeRtl = layout.getParagraphDirection(lastLine) == Layout.DIR_RIGHT_TO_LEFT
/*
val lastVisibleCharacter = layout.getLineVisibleEnd(lastLine) - 1
val looksLikeRtl = layout.isRtlCharAt(lastVisibleCharacter)
*/
// Get required width for all lines
var maxLineWidth = 0f
for (i in 0 until layout.lineCount) {
// For some reasons, the getLineWidth is not working too well with RTL lines when rendering replies.
// -> https://github.com/SchildiChat/SchildiChat-android/issues/74
// However, the bounds method is a little generous sometimes (reserving too much space),
// so we don't want to use it over getLineWidth() unless required.
maxLineWidth = if (layout.getParagraphDirection(i) == Layout.DIR_RIGHT_TO_LEFT) {
layout.getLineBounds(i, testBounds)
max((testBounds.right - testBounds.left).toFloat(), maxLineWidth)
} else {
max(layout.getLineWidth(i), maxLineWidth)
}
}
// Fix wrap_content in multi-line texts by using maxLineWidth instead of measuredWidth here
// (compare WrapWidthTextView.kt)
var newWidth = ceil(maxLineWidth).toInt()
var newHeight = measuredHeight
val widthLastLine = layout.getLineWidth(lastLine)
// Required width if putting footer in the same line as the last line
val widthWithHorizontalFooter = (
if (looksLikeRtl == viewIsRtl)
widthLastLine
else
(maxLineWidth + resources.getDimensionPixelSize(R.dimen.sc_footer_rtl_mismatch_extra_padding))
) + footerWidth
// If the last line is a multi-line code block, we have never space in the last line (as the black background always uses full width)
val forceNewlineFooter: Boolean
// For italic text, we need some extra space due to a wrap_content bug: https://stackoverflow.com/q/4353836
val addItalicPadding: Boolean
if (text is Spannable || text is Spanned) {
val span = text.toSpanned()
// If not found, -1+1 = 0
val lastLineStart = span.lastIndexOf("\n") + 1
val lastLineCodeSpans = span.getSpans<HtmlCodeSpan>(lastLineStart)
forceNewlineFooter = lastLineCodeSpans.any { it.isBlock }
addItalicPadding = span.getSpans<EmphasisSpan>().isNotEmpty()
} else {
forceNewlineFooter = false
addItalicPadding = false
}
// Is there space for a horizontal footer?
if (widthWithHorizontalFooter <= widthLimit && !forceNewlineFooter) {
// Reserve extra horizontal footer space if necessary
if (widthWithHorizontalFooter > newWidth) {
newWidth = ceil(widthWithHorizontalFooter).toInt()
if (viewIsRtl) {
requiredHorizontalCanvasMove = widthWithHorizontalFooter - measuredWidth
}
}
} else {
// Reserve vertical footer space
newHeight += footerHeight
// Ensure enough width for footer bellow
newWidth = max(newWidth, footerWidth +
resources.getDimensionPixelSize(R.dimen.sc_footer_padding_compensation) +
2 * resources.getDimensionPixelSize(R.dimen.sc_footer_overlay_padding))
}
if (addItalicPadding) {
newWidth += resources.getDimensionPixelSize(R.dimen.italic_text_view_extra_padding)
}
setMeasuredDimension(newWidth, newHeight)
val updatedMeasures = updateDimensionsWithFooter(widthMeasureSpec, heightMeasureSpec)
setMeasuredDimension(updatedMeasures.first, updatedMeasures.second)
}
override fun onDraw(canvas: Canvas?) {
// Workaround to RTL languages with non-RTL content messages aligning left instead of start
if (requiredHorizontalCanvasMove > 0f) {
canvas?.translate(requiredHorizontalCanvasMove, 0f)
}
updateFooterOnPreDraw(canvas)
super.onDraw(canvas)
}

View File

@ -31,7 +31,7 @@ import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.core.ui.views.FooteredTextView
import im.vector.app.core.ui.views.AbstractFooteredTextView
import im.vector.app.core.utils.TextUtils
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
@ -224,7 +224,7 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
val audioPlaybackTime by bind<TextView>(R.id.audioPlaybackTime)
val progressLayout by bind<ViewGroup>(R.id.messageFileUploadProgressLayout)
val fileSize by bind<TextView>(R.id.fileSize)
val captionView by bind<FooteredTextView>(R.id.messageCaptionView)
val captionView by bind<AbstractFooteredTextView>(R.id.messageCaptionView)
val audioPlaybackDuration by bind<TextView>(R.id.audioPlaybackDuration)
val audioSeekBar by bind<SeekBar>(R.id.audioSeekBar)
}

View File

@ -30,7 +30,7 @@ import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.core.ui.views.FooteredTextView
import im.vector.app.core.ui.views.AbstractFooteredTextView
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
@ -154,7 +154,7 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
val fileImageWrapper by bind<ViewGroup>(R.id.messageFileImageView)
val fileDownloadProgress by bind<ProgressBar>(R.id.messageFileProgressbar)
val filenameView by bind<TextView>(R.id.messageFilenameView)
val captionView by bind<FooteredTextView>(R.id.messageCaptionView)
val captionView by bind<AbstractFooteredTextView>(R.id.messageCaptionView)
}
companion object {

View File

@ -34,17 +34,15 @@ import im.vector.app.core.epoxy.onClick
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.core.files.LocalFilesHelper
import im.vector.app.core.glide.GlideApp
import im.vector.app.core.ui.views.FooteredTextView
import im.vector.app.core.ui.views.AbstractFooteredTextView
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.style.granularRoundedCorners
import im.vector.app.features.home.room.detail.timeline.view.ScMessageBubbleWrapView
import im.vector.app.features.media.ImageContentRenderer
import im.vector.app.features.themes.defaultScBubbleAppearance
import kotlin.math.round
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.util.MimeTypes
@EpoxyModelClass
abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Holder>() {
@ -255,7 +253,7 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
class Holder : AbsMessageItem.Holder(STUB_ID) {
val progressLayout by bind<ViewGroup>(R.id.messageMediaUploadProgressLayout)
val imageView by bind<ImageView>(R.id.messageThumbnailView)
val captionView by bind<FooteredTextView>(R.id.messageCaptionView)
val captionView by bind<AbstractFooteredTextView>(R.id.messageCaptionView)
val playContentView by bind<ImageView>(R.id.messageMediaPlayView)
val mediaContentView by bind<ViewGroup>(R.id.messageContentMedia)
}

View File

@ -17,23 +17,18 @@
package im.vector.app.features.home.room.detail.timeline.item
import android.text.Spanned
import android.text.method.MovementMethod
import android.view.ViewStub
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.text.PrecomputedTextCompat
import androidx.core.view.isVisible
import androidx.core.widget.TextViewCompat
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.epoxy.onLongClickIgnoringLinks
import im.vector.app.core.ui.views.FooteredTextView
import im.vector.app.core.ui.views.AbstractFooteredTextView
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.reply.InReplyToView
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.reply.PreviewReplyUiState
import im.vector.app.features.home.room.detail.timeline.reply.ReplyPreviewRetriever
import im.vector.app.features.home.room.detail.timeline.tools.findPillsAndProcess
import im.vector.app.features.home.room.detail.timeline.url.AbstractPreviewUrlView
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
@ -105,7 +100,7 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
holder.previewUrlView.delegate = previewUrlCallback
holder.previewUrlView.renderMessageLayout(attributes.informationData.messageLayout)
val messageView: FooteredTextView = holder.messageView(useRichTextEditorStyle) //if (useRichTextEditorStyle) holder.richMessageView else holder.plainMessageView
val messageView: AbstractFooteredTextView = holder.messageView(useRichTextEditorStyle) //if (useRichTextEditorStyle) holder.richMessageView else holder.plainMessageView
if (useBigFont) {
messageView.textSize = 44F
} else {
@ -155,10 +150,10 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
lateinit var previewUrlView: AbstractPreviewUrlView // set to either previewUrlViewElement or previewUrlViewSc by layout
private val richMessageStub by bind<ViewStub>(R.id.richMessageTextViewStub)
private val plainMessageStub by bind<ViewStub>(R.id.plainMessageTextViewStub)
val richMessageView: FooteredTextView by lazy {
val richMessageView: AbstractFooteredTextView by lazy {
richMessageStub.inflate().findViewById(R.id.messageTextView)
}
val plainMessageView: FooteredTextView by lazy {
val plainMessageView: AbstractFooteredTextView by lazy {
plainMessageStub.inflate().findViewById(R.id.messageTextView)
}
fun messageView(useRichTextEditorStyle: Boolean) = if (useRichTextEditorStyle) richMessageView else plainMessageView

View File

@ -6,7 +6,8 @@
but it also makes it align better for RTL texts in RTL locales - at least
until the user pill is touched...
-->
<im.vector.app.core.ui.views.FooteredTextView android:id="@+id/messageTextView"
<im.vector.app.core.ui.views.FooteredTextView
android:id="@+id/messageTextView"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
style="@style/Widget.Vector.TextView.Body"

View File

@ -6,7 +6,8 @@
but it also makes it align better for RTL texts in RTL locales - at least
until the user pill is touched...
-->
<io.element.android.wysiwyg.EditorStyledTextView xmlns:android="http://schemas.android.com/apk/res/android"
<io.element.android.wysiwyg.EditorStyledTextView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/messageTextView"
style="@style/Widget.Vector.TextView.Body"