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:
Jorge Martin Espinosa 2024-01-03 12:32:02 +01:00 committed by GitHub
parent 0381027dae
commit 4f6c7421bd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
110 changed files with 573 additions and 299 deletions

View file

@ -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
}

View file

@ -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,
),

View file

@ -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()
)

View file

@ -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 = {},
)
}

View file

@ -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 = {},
)
}

View file

@ -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
)
}

View file

@ -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 = {},
)
}

View file

@ -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 = {},
)
}

View file

@ -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 = {},
)
}

View file

@ -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 = {},
)
}

View file

@ -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 = {},
)
}

View file

@ -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 = {},
)
}
}

View file

@ -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,
)
)
}
}
}

View file

@ -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
)