Implement ContentAvoidingLayout for timeline items (#2113)
* Implement `ContentAvoidingLayout` for timeline items * Truncate long mention pills --------- Co-authored-by: Benoit Marty <benoit@matrix.org> Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
0381027dae
commit
4f6c7421bd
110 changed files with 573 additions and 299 deletions
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components
|
|||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -69,7 +70,8 @@ fun TimelineEventTimestampView(
|
|||
Row(
|
||||
modifier = Modifier
|
||||
.then(clickModifier)
|
||||
.padding(start = 16.dp) // Add extra padding for touch target size
|
||||
// Add extra padding for touch target size
|
||||
.padding(PaddingValues(start = TimelineEventTimestampViewDefaults.spacing))
|
||||
.then(modifier),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
|
|
@ -107,3 +109,7 @@ internal fun TimelineEventTimestampViewPreview(@PreviewParameter(TimelineItemEve
|
|||
onLongClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
object TimelineEventTimestampViewDefaults {
|
||||
val spacing = 16.dp
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
|
@ -67,7 +68,8 @@ import io.element.android.features.messages.impl.timeline.TimelineEvents
|
|||
import io.element.android.features.messages.impl.timeline.TimelineRoomInfo
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.event.toExtraPadding
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
||||
import io.element.android.features.messages.impl.timeline.model.InReplyToDetails
|
||||
|
|
@ -80,6 +82,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
|||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
|
||||
|
|
@ -448,12 +451,13 @@ private fun MessageEventBubbleContent(
|
|||
fun WithTimestampLayout(
|
||||
timestampPosition: TimestampPosition,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
canShrinkContent: Boolean = false,
|
||||
content: @Composable (onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit) -> Unit,
|
||||
) {
|
||||
when (timestampPosition) {
|
||||
TimestampPosition.Overlay ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) {
|
||||
content()
|
||||
content {}
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onClick = onTimestampClicked,
|
||||
|
|
@ -466,20 +470,26 @@ private fun MessageEventBubbleContent(
|
|||
)
|
||||
}
|
||||
TimestampPosition.Aligned ->
|
||||
Box(modifier) {
|
||||
content()
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onClick = onTimestampClicked,
|
||||
onLongClick = ::onTimestampLongClick,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
ContentAvoidingLayout(
|
||||
modifier = modifier,
|
||||
// The spacing is negative to make the content overlap the empty space at the start of the timestamp
|
||||
spacing = (-4).dp,
|
||||
overlayOffset = DpOffset(0.dp, -1.dp),
|
||||
shrinkContent = canShrinkContent,
|
||||
content = { content(this::onContentLayoutChanged) },
|
||||
overlay = {
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onClick = onTimestampClicked,
|
||||
onLongClick = ::onTimestampLongClick,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
)
|
||||
TimestampPosition.Below ->
|
||||
Column(modifier) {
|
||||
content()
|
||||
content {}
|
||||
TimelineEventTimestampView(
|
||||
event = event,
|
||||
onClick = onTimestampClicked,
|
||||
|
|
@ -498,7 +508,8 @@ private fun MessageEventBubbleContent(
|
|||
timestampPosition: TimestampPosition,
|
||||
showThreadDecoration: Boolean,
|
||||
inReplyToDetails: InReplyToDetails?,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
canShrinkContent: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val timestampLayoutModifier: Modifier
|
||||
|
|
@ -515,7 +526,8 @@ private fun MessageEventBubbleContent(
|
|||
}
|
||||
timestampPosition != TimestampPosition.Overlay -> {
|
||||
timestampLayoutModifier = Modifier
|
||||
contentModifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
|
||||
contentModifier = Modifier
|
||||
.padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)
|
||||
}
|
||||
else -> {
|
||||
timestampLayoutModifier = Modifier
|
||||
|
|
@ -530,8 +542,9 @@ private fun MessageEventBubbleContent(
|
|||
val contentWithTimestamp = @Composable {
|
||||
WithTimestampLayout(
|
||||
timestampPosition = timestampPosition,
|
||||
canShrinkContent = canShrinkContent,
|
||||
modifier = timestampLayoutModifier,
|
||||
) {
|
||||
) { onContentLayoutChanged ->
|
||||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
onLinkClicked = { url ->
|
||||
|
|
@ -548,9 +561,9 @@ private fun MessageEventBubbleContent(
|
|||
}
|
||||
}
|
||||
},
|
||||
extraPadding = event.toExtraPadding(),
|
||||
eventSink = eventSink,
|
||||
modifier = contentModifier,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = contentModifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -594,6 +607,7 @@ private fun MessageEventBubbleContent(
|
|||
showThreadDecoration = event.isThreaded,
|
||||
timestampPosition = timestampPosition,
|
||||
inReplyToDetails = event.inReplyTo,
|
||||
canShrinkContent = event.content is TimelineItemVoiceContent,
|
||||
modifier = bubbleModifier
|
||||
)
|
||||
}
|
||||
|
|
@ -655,7 +669,7 @@ internal fun TimelineItemEventRowPreview() = ElementPreview {
|
|||
isMine = it,
|
||||
content = aTimelineItemTextContent().copy(
|
||||
body = "A long text which will be displayed on several lines and" +
|
||||
" hopefully can be manually adjusted to test different behaviors."
|
||||
" hopefully can be manually adjusted to test different behaviors."
|
||||
),
|
||||
groupPosition = TimelineItemGroupPosition.First,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import androidx.compose.ui.zIndex
|
|||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
|
||||
import io.element.android.features.messages.impl.timeline.components.event.TimelineItemEventContentView
|
||||
import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.ReadReceiptViewState
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.TimelineItemReadReceiptView
|
||||
import io.element.android.features.messages.impl.timeline.components.receipt.aReadReceiptData
|
||||
|
|
@ -81,7 +80,6 @@ fun TimelineItemStateEventRow(
|
|||
TimelineItemEventContentView(
|
||||
content = event.content,
|
||||
onLinkClicked = {},
|
||||
extraPadding = noExtraPadding,
|
||||
eventSink = eventSink,
|
||||
modifier = Modifier.defaultTimelineContentPadding()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContentProvider
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
|
@ -44,15 +46,17 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
|||
@Composable
|
||||
fun TimelineItemAudioView(
|
||||
content: TimelineItemAudioContent,
|
||||
extraPadding: ExtraPadding,
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val iconSize = 32.dp
|
||||
val spacing = 8.dp
|
||||
Row(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.size(iconSize)
|
||||
.clip(CircleShape)
|
||||
.background(ElementTheme.materialColors.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
|
|
@ -65,7 +69,7 @@ fun TimelineItemAudioView(
|
|||
.size(16.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Spacer(Modifier.width(spacing))
|
||||
Column {
|
||||
Text(
|
||||
text = content.body,
|
||||
|
|
@ -75,11 +79,15 @@ fun TimelineItemAudioView(
|
|||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = content.fileExtensionAndSize + extraPadding.getStr(ElementTheme.typography.fontBodySmRegular),
|
||||
text = content.fileExtensionAndSize,
|
||||
color = ElementTheme.materialColors.secondary,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
extraWidth = iconSize + spacing
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +99,6 @@ internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioCon
|
|||
ElementPreview {
|
||||
TimelineItemAudioView(
|
||||
content,
|
||||
extraPadding = noExtraPadding,
|
||||
onContentLayoutChanged = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components.event
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
|
@ -29,14 +30,14 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
@Composable
|
||||
fun TimelineItemEncryptedView(
|
||||
@Suppress("UNUSED_PARAMETER") content: TimelineItemEncryptedContent,
|
||||
extraPadding: ExtraPadding,
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TimelineItemInformativeView(
|
||||
text = stringResource(id = CommonStrings.common_waiting_for_decryption_key),
|
||||
iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
|
||||
iconResourceId = CommonDrawables.ic_waiting_to_decrypt,
|
||||
extraPadding = extraPadding,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
|
@ -48,6 +49,6 @@ internal fun TimelineItemEncryptedViewPreview() = ElementPreview {
|
|||
content = TimelineItemEncryptedContent(
|
||||
data = UnableToDecryptContent.Data.Unknown
|
||||
),
|
||||
extraPadding = noExtraPadding
|
||||
onContentLayoutChanged = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components.event
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.features.messages.impl.timeline.TimelineEvents
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
|
||||
import io.element.android.features.messages.impl.timeline.di.rememberPresenter
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
|
||||
|
|
@ -41,32 +42,32 @@ import io.element.android.libraries.architecture.Presenter
|
|||
@Composable
|
||||
fun TimelineItemEventContentView(
|
||||
content: TimelineItemEventContent,
|
||||
extraPadding: ExtraPadding,
|
||||
onLinkClicked: (url: String) -> Unit,
|
||||
eventSink: (TimelineEvents) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit = {},
|
||||
) {
|
||||
val presenterFactories = LocalTimelineItemPresenterFactories.current
|
||||
when (content) {
|
||||
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
|
||||
content = content,
|
||||
extraPadding = extraPadding,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemRedactedContent -> TimelineItemRedactedView(
|
||||
content = content,
|
||||
extraPadding = extraPadding,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemTextBasedContent -> TimelineItemTextView(
|
||||
content = content,
|
||||
extraPadding = extraPadding,
|
||||
modifier = modifier,
|
||||
onLinkClicked = onLinkClicked,
|
||||
onContentLayoutChanged = onContentLayoutChanged
|
||||
)
|
||||
is TimelineItemUnknownContent -> TimelineItemUnknownView(
|
||||
content = content,
|
||||
extraPadding = extraPadding,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemLocationContent -> TimelineItemLocationView(
|
||||
|
|
@ -87,12 +88,12 @@ fun TimelineItemEventContentView(
|
|||
)
|
||||
is TimelineItemFileContent -> TimelineItemFileView(
|
||||
content = content,
|
||||
extraPadding = extraPadding,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemAudioContent -> TimelineItemAudioView(
|
||||
content = content,
|
||||
extraPadding = extraPadding,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = modifier
|
||||
)
|
||||
is TimelineItemStateContent -> TimelineItemStateView(
|
||||
|
|
@ -109,7 +110,7 @@ fun TimelineItemEventContentView(
|
|||
TimelineItemVoiceView(
|
||||
state = presenter.present(),
|
||||
content = content,
|
||||
extraPadding = extraPadding,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemFileContentProvider
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
|
@ -44,15 +46,17 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables
|
|||
@Composable
|
||||
fun TimelineItemFileView(
|
||||
content: TimelineItemFileContent,
|
||||
extraPadding: ExtraPadding,
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val iconSize = 32.dp
|
||||
val spacing = 8.dp
|
||||
Row(
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.size(iconSize)
|
||||
.clip(CircleShape)
|
||||
.background(ElementTheme.materialColors.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
|
|
@ -66,7 +70,7 @@ fun TimelineItemFileView(
|
|||
.rotate(-45f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Spacer(Modifier.width(spacing))
|
||||
Column {
|
||||
Text(
|
||||
text = content.body,
|
||||
|
|
@ -76,11 +80,15 @@ fun TimelineItemFileView(
|
|||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = content.fileExtensionAndSize + extraPadding.getStr(textStyle = ElementTheme.typography.fontBodySmRegular),
|
||||
text = content.fileExtensionAndSize,
|
||||
color = ElementTheme.materialColors.secondary,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
onTextLayout = ContentAvoidingLayout.measureLastTextLine(
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
extraWidth = iconSize + spacing
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -91,6 +99,6 @@ fun TimelineItemFileView(
|
|||
internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileContentProvider::class) content: TimelineItemFileContent) = ElementPreview {
|
||||
TimelineItemFileView(
|
||||
content,
|
||||
extraPadding = noExtraPadding,
|
||||
onContentLayoutChanged = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
|
|
@ -39,12 +41,19 @@ fun TimelineItemInformativeView(
|
|||
text: String,
|
||||
iconDescription: String,
|
||||
@DrawableRes iconResourceId: Int,
|
||||
extraPadding: ExtraPadding,
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier = modifier.onSizeChanged { size ->
|
||||
onContentLayoutChanged(
|
||||
ContentAvoidingLayoutData(
|
||||
contentWidth = size.width,
|
||||
contentHeight = size.height,
|
||||
)
|
||||
)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
resourceId = iconResourceId,
|
||||
|
|
@ -57,7 +66,7 @@ fun TimelineItemInformativeView(
|
|||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
text = text + extraPadding.getStr(textStyle = ElementTheme.typography.fontBodyMdRegular)
|
||||
text = text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +78,6 @@ internal fun TimelineItemInformativeViewPreview() = ElementPreview {
|
|||
text = "Info",
|
||||
iconDescription = "",
|
||||
iconResourceId = CompoundDrawables.ic_delete,
|
||||
extraPadding = noExtraPadding,
|
||||
onContentLayoutChanged = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components.event
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
|
@ -28,14 +29,14 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
@Composable
|
||||
fun TimelineItemRedactedView(
|
||||
@Suppress("UNUSED_PARAMETER") content: TimelineItemRedactedContent,
|
||||
extraPadding: ExtraPadding,
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TimelineItemInformativeView(
|
||||
text = stringResource(id = CommonStrings.common_message_removed),
|
||||
iconDescription = stringResource(id = CommonStrings.common_message_removed),
|
||||
iconResourceId = CompoundDrawables.ic_delete,
|
||||
extraPadding = extraPadding,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
|
@ -45,6 +46,6 @@ fun TimelineItemRedactedView(
|
|||
internal fun TimelineItemRedactedViewPreview() = ElementPreview {
|
||||
TimelineItemRedactedView(
|
||||
TimelineItemRedactedContent,
|
||||
extraPadding = noExtraPadding
|
||||
onContentLayoutChanged = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,12 +22,11 @@ import androidx.compose.material3.LocalContentColor
|
|||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.text.buildSpannedString
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayout
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
|
@ -38,9 +37,9 @@ import io.element.android.wysiwyg.compose.EditorStyledText
|
|||
@Composable
|
||||
fun TimelineItemTextView(
|
||||
content: TimelineItemTextBasedContent,
|
||||
extraPadding: ExtraPadding,
|
||||
onLinkClicked: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit = {},
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalContentColor provides ElementTheme.colors.textPrimary,
|
||||
|
|
@ -49,19 +48,14 @@ fun TimelineItemTextView(
|
|||
|
||||
val formattedBody = content.formattedBody
|
||||
val body = SpannableString(formattedBody ?: content.body)
|
||||
val extraPaddingText = extraPadding.getStr()
|
||||
|
||||
Box(modifier) {
|
||||
val textWithPadding = remember(body) {
|
||||
buildSpannedString {
|
||||
append(body)
|
||||
append(extraPaddingText)
|
||||
}
|
||||
}
|
||||
EditorStyledText(
|
||||
text = textWithPadding,
|
||||
text = body,
|
||||
onLinkClickedListener = onLinkClicked,
|
||||
style = ElementRichTextEditorStyle.textStyle(),
|
||||
onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged),
|
||||
releaseOnDetach = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -74,7 +68,6 @@ internal fun TimelineItemTextViewPreview(
|
|||
) = ElementPreview {
|
||||
TimelineItemTextView(
|
||||
content = content,
|
||||
extraPadding = ExtraPadding(extraWidth = 32.dp),
|
||||
onLinkClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.components.event
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
|
|
@ -28,14 +29,14 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
@Composable
|
||||
fun TimelineItemUnknownView(
|
||||
@Suppress("UNUSED_PARAMETER") content: TimelineItemUnknownContent,
|
||||
extraPadding: ExtraPadding,
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
TimelineItemInformativeView(
|
||||
text = stringResource(id = CommonStrings.common_unsupported_event),
|
||||
iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
|
||||
iconResourceId = CompoundDrawables.ic_info_solid,
|
||||
extraPadding = extraPadding,
|
||||
onContentLayoutChanged = onContentLayoutChanged,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
|
@ -45,6 +46,6 @@ fun TimelineItemUnknownView(
|
|||
internal fun TimelineItemUnknownViewPreview() = ElementPreview {
|
||||
TimelineItemUnknownView(
|
||||
content = TimelineItemUnknownContent,
|
||||
extraPadding = noExtraPadding
|
||||
onContentLayoutChanged = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
|
|
@ -43,6 +44,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
|
||||
|
|
@ -64,7 +66,7 @@ import kotlinx.coroutines.delay
|
|||
fun TimelineItemVoiceView(
|
||||
state: VoiceMessageState,
|
||||
content: TimelineItemVoiceContent,
|
||||
extraPadding: ExtraPadding,
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun playPause() {
|
||||
|
|
@ -73,9 +75,18 @@ fun TimelineItemVoiceView(
|
|||
|
||||
val a11y = stringResource(CommonStrings.common_voice_message)
|
||||
Row(
|
||||
modifier = modifier.semantics {
|
||||
contentDescription = a11y
|
||||
},
|
||||
modifier = modifier
|
||||
.semantics {
|
||||
contentDescription = a11y
|
||||
}
|
||||
.onSizeChanged {
|
||||
onContentLayoutChanged(
|
||||
ContentAvoidingLayoutData(
|
||||
contentWidth = it.width,
|
||||
contentHeight = it.height,
|
||||
)
|
||||
)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
when (state.button) {
|
||||
|
|
@ -105,7 +116,6 @@ fun TimelineItemVoiceView(
|
|||
seekEnabled = !context.isScreenReaderEnabled(),
|
||||
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
|
||||
)
|
||||
Spacer(Modifier.width(extraPadding.getDpSize()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -237,7 +247,7 @@ internal fun TimelineItemVoiceViewPreview(
|
|||
TimelineItemVoiceView(
|
||||
state = timelineItemVoiceViewParameters.state,
|
||||
content = timelineItemVoiceViewParameters.content,
|
||||
extraPadding = noExtraPadding,
|
||||
onContentLayoutChanged = {},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -250,7 +260,7 @@ internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview {
|
|||
TimelineItemVoiceView(
|
||||
state = it.state,
|
||||
content = it.content,
|
||||
extraPadding = noExtraPadding,
|
||||
onContentLayoutChanged = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,209 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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 io.element.android.features.messages.impl.timeline.components.layout
|
||||
|
||||
import android.text.Layout
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.text.roundToPx
|
||||
import io.element.android.wysiwyg.compose.EditorStyledText
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A layout with 2 children: the [content] and the [overlay].
|
||||
*
|
||||
* It will try to place the [overlay] on top of the [content] if possible, avoiding the area of it that is non-overlapping.
|
||||
* If the [overlay] can't be placed on top of the [content], it will be placed to the right of it, if it fits, otherwise, to its bottom in a new row.
|
||||
*
|
||||
* @param overlay The 'overlay' component of the layout, which will be positioned relative to the [content].
|
||||
* @param modifier The modifier for the layout.
|
||||
* @param spacing The spacing between the [content] and the [overlay]. Defaults to `0.dp`.
|
||||
* @param overlayOffset The offset of the [overlay] from the bottom right corner of the [content].
|
||||
* @param shrinkContent Whether the content should be shrunk to fit the available width or not. Defaults to `false`.
|
||||
* @param content The 'content' component of the layout.
|
||||
*/
|
||||
@Composable
|
||||
fun ContentAvoidingLayout(
|
||||
overlay: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
spacing: Dp = 0.dp,
|
||||
overlayOffset: DpOffset = DpOffset.Zero,
|
||||
shrinkContent: Boolean = false,
|
||||
content: @Composable ContentAvoidingLayoutScope.() -> Unit,
|
||||
) {
|
||||
val scope = remember { ContentAvoidingLayoutScopeInstance() }
|
||||
|
||||
Layout(
|
||||
modifier = modifier,
|
||||
content = {
|
||||
scope.content()
|
||||
overlay()
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
assert(measurables.size == 2) { "ContentAvoidingLayout must have exactly 2 children" }
|
||||
|
||||
// Measure the `overlay` view first, in case we need to shrink the `content`
|
||||
val overlayPlaceable = measurables.last().measure(Constraints(minWidth = 0, maxWidth = constraints.maxWidth))
|
||||
val contentConstraints = if (shrinkContent) {
|
||||
Constraints(minWidth = 0, maxWidth = constraints.maxWidth - overlayPlaceable.width)
|
||||
} else {
|
||||
Constraints(minWidth = 0, maxWidth = constraints.maxWidth)
|
||||
}
|
||||
val contentPlaceable = measurables.first().measure(contentConstraints)
|
||||
|
||||
var layoutWidth = contentPlaceable.width
|
||||
var layoutHeight = contentPlaceable.height
|
||||
|
||||
val data = scope.data
|
||||
|
||||
// Free space = width of the whole component - width of its non overlapping contents
|
||||
val freeSpace = max(contentPlaceable.width - data.nonOverlappingContentWidth, 0)
|
||||
|
||||
when {
|
||||
// When the content + the overlay don't fit in the available max width, we need to move the overlay to a new row
|
||||
!shrinkContent && data.nonOverlappingContentWidth + overlayPlaceable.width > constraints.maxWidth -> {
|
||||
layoutHeight += overlayPlaceable.height + overlayOffset.y.roundToPx()
|
||||
}
|
||||
// If the content is smaller than the available max width, we can move the overlay to the right of the content
|
||||
contentPlaceable.width < constraints.maxWidth -> {
|
||||
// If both the content and the overlay plus the padding can fit inside the current layoutWidth, there is no need to increase it
|
||||
if (freeSpace < overlayPlaceable.width + spacing.roundToPx()) {
|
||||
// Otherwise, we need to increase it by the width of the overlay + some padding adjustments
|
||||
val calculatedWidth = max(data.nonOverlappingContentWidth + overlayPlaceable.width + spacing.roundToPx(), contentPlaceable.width)
|
||||
layoutWidth = min(calculatedWidth, constraints.maxWidth)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
layoutWidth = max(layoutWidth, constraints.minWidth)
|
||||
layoutHeight = max(layoutHeight, constraints.minHeight)
|
||||
|
||||
layout(layoutWidth, layoutHeight) {
|
||||
contentPlaceable.placeRelative(0, 0)
|
||||
overlayPlaceable.placeRelative(layoutWidth - overlayPlaceable.width, layoutHeight - overlayPlaceable.height + overlayOffset.y.roundToPx())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class to hold the content layout data.
|
||||
* This is used to pass the data from the content to the [ContentAvoidingLayout].
|
||||
*
|
||||
* @param contentWidth The full width of the content in pixels.
|
||||
* @param contentHeight The full height of the content in pixels.
|
||||
* @param nonOverlappingContentWidth The width of the part of the content that can't overlap with the timestamp.
|
||||
* @param nonOverlappingContentHeight The height of the part of the content that can't overlap with the timestamp.
|
||||
*/
|
||||
@Suppress("DataClassShouldBeImmutable")
|
||||
data class ContentAvoidingLayoutData(
|
||||
var contentWidth: Int = 0,
|
||||
var contentHeight: Int = 0,
|
||||
var nonOverlappingContentWidth: Int = contentWidth,
|
||||
var nonOverlappingContentHeight: Int = contentHeight,
|
||||
)
|
||||
|
||||
/**
|
||||
* A scope for the [ContentAvoidingLayout].
|
||||
*/
|
||||
interface ContentAvoidingLayoutScope {
|
||||
|
||||
/**
|
||||
* It should be called when the content layout changes, so it can update the [ContentAvoidingLayoutData] and measure and layout the content properly.
|
||||
*/
|
||||
fun onContentLayoutChanged(data: ContentAvoidingLayoutData)
|
||||
}
|
||||
|
||||
private class ContentAvoidingLayoutScopeInstance(
|
||||
val data: ContentAvoidingLayoutData = ContentAvoidingLayoutData(),
|
||||
) : ContentAvoidingLayoutScope {
|
||||
override fun onContentLayoutChanged(data: ContentAvoidingLayoutData) {
|
||||
this.data.contentWidth = data.contentWidth
|
||||
this.data.contentHeight = data.contentHeight
|
||||
this.data.nonOverlappingContentWidth = data.nonOverlappingContentWidth
|
||||
this.data.nonOverlappingContentHeight = data.nonOverlappingContentHeight
|
||||
}
|
||||
}
|
||||
|
||||
object ContentAvoidingLayout {
|
||||
/**
|
||||
* Measures the last line of a [TextLayoutResult] and calls [onContentLayoutChanged] with the [ContentAvoidingLayoutData].
|
||||
*
|
||||
* This is supposed to be used in the `onTextLayout` parameter of a Text based component.
|
||||
*/
|
||||
@Composable
|
||||
internal fun measureLastTextLine(
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||
extraWidth: Dp = 0.dp,
|
||||
): ((TextLayoutResult) -> Unit) {
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val extraWidthPx = extraWidth.roundToPx()
|
||||
return { textLayout: TextLayoutResult ->
|
||||
// We need to add the external extra width so it's not taken into account as 'free space'
|
||||
val lastLineWidth = when (layoutDirection) {
|
||||
LayoutDirection.Ltr -> textLayout.getLineRight(textLayout.lineCount - 1).roundToInt()
|
||||
LayoutDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt()
|
||||
}
|
||||
val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1).roundToInt()
|
||||
onContentLayoutChanged(
|
||||
ContentAvoidingLayoutData(
|
||||
contentWidth = textLayout.size.width + extraWidthPx,
|
||||
contentHeight = textLayout.size.height,
|
||||
nonOverlappingContentWidth = lastLineWidth + extraWidthPx,
|
||||
nonOverlappingContentHeight = lastLineHeight,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the last line of a [Layout] and calls [onContentLayoutChanged] with the [ContentAvoidingLayoutData].
|
||||
*
|
||||
* This is supposed to be used in the `onTextLayout` parameter of an [EditorStyledText] component.
|
||||
*/
|
||||
@Composable
|
||||
internal fun measureLegacyLastTextLine(
|
||||
onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit,
|
||||
extraWidth: Dp = 0.dp,
|
||||
): ((Layout) -> Unit) {
|
||||
val extraWidthPx = extraWidth.roundToPx()
|
||||
return { textLayout: Layout ->
|
||||
// We need to add the external extra width so it's not taken into account as 'free space'
|
||||
val lastLineWidth = textLayout.getLineWidth(textLayout.lineCount - 1).roundToInt()
|
||||
val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1)
|
||||
onContentLayoutChanged(
|
||||
ContentAvoidingLayoutData(
|
||||
contentWidth = textLayout.width + extraWidthPx,
|
||||
contentHeight = textLayout.height,
|
||||
nonOverlappingContentWidth = lastLineWidth + extraWidthPx,
|
||||
nonOverlappingContentHeight = lastLineHeight,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
package io.element.android.features.messages.impl.timeline.model.event
|
||||
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class TimelineItemAudioContent(
|
||||
|
|
@ -29,7 +30,7 @@ data class TimelineItemAudioContent(
|
|||
) : TimelineItemEventContent {
|
||||
|
||||
val fileExtensionAndSize =
|
||||
io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize(
|
||||
formatFileExtensionAndSize(
|
||||
fileExtension,
|
||||
formattedFileSize
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue