From a1c482d673b4576e2d2f1c4059d2e99b22c0719c Mon Sep 17 00:00:00 2001 From: David Langley Date: Tue, 18 Jul 2023 21:18:27 +0100 Subject: [PATCH] Add custom reaction layout - Add the custom reaction layout that only shows the expand UI after 2 lines. - It also enforces that the add more and expand buttons are always on the same line. - In LTR languages we want an incoming message's reactions to be LRT and outgoing to be RTL. - For RTL languages it should be the opposite. --- .../components/TimelineItemEventRow.kt | 2 +- .../components/TimelineItemReactionsLayout.kt | 207 ++++++++++++++++++ .../components/TimelineItemReactionsView.kt | 177 ++++++--------- 3 files changed, 274 insertions(+), 112 deletions(-) create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 8d7ff263e8..f783e19563 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -291,7 +291,7 @@ private fun TimelineItemEventRowContent( if (event.reactionsState.reactions.isNotEmpty()) { TimelineItemReactions( reactionsState = event.reactionsState, - mainAxisAlignment = if (event.isMine) FlowMainAxisAlignment.End else FlowMainAxisAlignment.Start, + isOutgoing = event.isMine, onReactionClicked = onReactionClicked, onMoreReactionsClicked = { onMoreReactionsClicked(event) }, modifier = Modifier diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt new file mode 100644 index 0000000000..8f3f5865ea --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsLayout.kt @@ -0,0 +1,207 @@ +/* + * 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 + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddReaction +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import io.element.android.features.messages.impl.R +import io.element.android.libraries.designsystem.preview.DayNightPreviews +import io.element.android.libraries.designsystem.preview.ElementPreview + +/** + * A flow layout for reactions that will show a collapse/expand button when the layout wraps over a defined number of rows. + * It displays an add more button when there are greater than 0 reactions and always displays the reaction and add more button + * on the same row (moving them both to a new row if necessary). + * @param itemSpacing The horizontal spacing between items + * @param rowSpacing The vertical spacing between rows + * @param expanded Whether the layout should display in expanded or collapsed state + * @param rowsBeforeCollapsible The number of rows before the collapse/expand button is shown + * @param expandButton The expand button + * @param addMoreButton The add more button + * @param reactions The reaction buttons + */ +@Composable +fun TimelineItemReactionsLayout( + modifier: Modifier = Modifier, + itemSpacing: Dp = 0.dp, + rowSpacing: Dp = 0.dp, + expanded: Boolean = false, + rowsBeforeCollapsible: Int? = 2, + expandButton: @Composable () -> Unit, + addMoreButton: @Composable () -> Unit, + reactions: @Composable () -> Unit +) { + SubcomposeLayout(modifier) { constraints -> + // Given the placeables and returns a structure representing + // how they should wrap on to multiple rows given the constraints max width. + fun calculateRows(measurables: List): List> { + val rows = mutableListOf>() + var currentRow = mutableListOf() + var rowX = 0 + + measurables.forEach { placeable -> + val horizontalSpacing = if (currentRow.isEmpty()) 0 else itemSpacing.toPx().toInt() + // If the current view does not fine on this row bump to the next + if (rowX + placeable.width > constraints.maxWidth) { + rows.add(currentRow) + currentRow = mutableListOf() + rowX = 0 + } + rowX += horizontalSpacing + placeable.width + currentRow.add(placeable) + } + // If there are items in the current row remember to append it to the returned value + if (currentRow.size > 0) { + rows.add(currentRow) + } + return rows + } + + // Used to render the collapsed state, this takes the rows inputted and adds the extra button to the last row, + // removing only as many trailing reactions as needed to make space for it. + fun replaceTrailingItemsWithButtons(rowsIn: List>, expandButton: Placeable, addMoreButton: Placeable): List> { + val rows = rowsIn.toMutableList() + val lastRow = rows[rows.size - 1] + val buttonsWidth = expandButton.width + itemSpacing.toPx().toInt() + addMoreButton.width + var rowX = 0 + lastRow.forEachIndexed { i, placeable -> + val horizontalSpacing = if (i == 0) 0 else itemSpacing.toPx().toInt() + rowX += placeable.width + horizontalSpacing + if (rowX > (constraints.maxWidth - (buttonsWidth + horizontalSpacing))) { + val lastRowWithButton = lastRow.take(i) + listOf(expandButton, addMoreButton) + rows[rows.size - 1] = lastRowWithButton + return rows + } + } + val lastRowWithButton = lastRow + listOf(expandButton, addMoreButton) + rows[rows.size - 1] = lastRowWithButton + return rows + } + + // To prevent the add more and expand buttons from wrapping on to separate lines. + // If there is one item on the last line, it moves the expand button down. + fun ensureCollapseAndAddMoreButtonsAreOnTheSameRow(rowsIn: List>): List> { + val lastRow = rowsIn.last().toMutableList() + if (lastRow.size != 1) { + return rowsIn + } + val rows = rowsIn.toMutableList() + val secondLastRow = rows[rows.size - 2].toMutableList() + val expandButtonPlaceable = secondLastRow.removeLast() + lastRow.add(0, expandButtonPlaceable) + rows[rows.size - 2] = secondLastRow + rows[rows.size - 1] = lastRow + return rows + } + + /// Given a list of rows place them in the layout. + fun layoutRows(rows: List>): MeasureResult { + var width = 0 + var height = 0 + val placeables = rows.mapIndexed { i, row -> + var rowX = 0 + var rowHeight = 0 + val verticalSpacing = if (i == 0) 0 else rowSpacing.toPx().toInt() + val rowWithPoints = row.mapIndexed { j, placeable -> + val horizontalSpacing = if (j == 0) 0 else itemSpacing.toPx().toInt() + val point = IntOffset(rowX + horizontalSpacing, height + verticalSpacing) + rowX += placeable.width + horizontalSpacing + rowHeight = maxOf(rowHeight, placeable.height) + Pair(placeable, point) + } + height += rowHeight + verticalSpacing + width = maxOf(width, rowX) + rowWithPoints + }.flatten() + + return layout(width = width, height = height) { + placeables.forEach { + val (placeable, origin) = it + placeable.placeRelative(origin.x, origin.y) + } + } + } + + val reactionsPlaceables = subcompose(0, reactions).map { it.measure(constraints) } + if (reactionsPlaceables.isEmpty()) { + return@SubcomposeLayout layoutRows(listOf()) + } + val addMorePlaceable = subcompose(1, addMoreButton).first().measure(constraints) + val expandPlaceable = subcompose(2, expandButton).first().measure(constraints) + + // Calculate the layout of the rows with the reactions button and add more button + val reactionsAndAddMore = calculateRows(reactionsPlaceables + listOf(addMorePlaceable)) + // If we have extended beyond the defined number of rows we are showing the expand/collapse ui + if (rowsBeforeCollapsible?.let { reactionsAndAddMore.size > it } == true) { + if (expanded) { + // Show all subviews with the add more button at the end + var reactionsAndButtons = calculateRows(reactionsPlaceables + listOf(expandPlaceable, addMorePlaceable)) + reactionsAndButtons = ensureCollapseAndAddMoreButtonsAreOnTheSameRow(reactionsAndButtons) + layoutRows(reactionsAndButtons) + } else { + // Truncate to `rowsBeforeCollapsible` number of rows and replace the reactions at the end of the last row with the buttons + val collapsedRows = reactionsAndAddMore.take(rowsBeforeCollapsible) + val collapsedRowsWithButtons = replaceTrailingItemsWithButtons(collapsedRows, expandPlaceable, addMorePlaceable) + layoutRows(collapsedRowsWithButtons) + } + } else { + // Otherwise we are just showing all items without the expand button + layoutRows(reactionsAndAddMore) + } + } +} + +@DayNightPreviews +@Composable +internal fun TimelineItemReactionsLayoutPreview() = ElementPreview { + TimelineItemReactionsLayout( + expanded = false, + expandButton = { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Text( + text = stringResource(id = R.string.screen_room_timeline_less_reactions) + ), + onClick = { }, + ) + }, + addMoreButton = { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction), + onClick = {} + ) + }, + reactions = { + io.element.android.features.messages.impl.timeline.aTimelineItemReactions(count = 18).reactions.forEach { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Reaction( + it + ), + onClick = {} + ) + } + } + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt index ca0a58da70..962d3a2b2b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt @@ -19,18 +19,16 @@ package io.element.android.features.messages.impl.timeline.components import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AddReaction import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import com.google.accompanist.flowlayout.FlowMainAxisAlignment -import com.google.accompanist.flowlayout.FlowRow import io.element.android.features.messages.impl.R import io.element.android.features.messages.impl.timeline.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.model.AggregatedReaction @@ -38,162 +36,119 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemReac import io.element.android.libraries.designsystem.preview.DayNightPreviews import io.element.android.libraries.designsystem.preview.ElementPreview import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toPersistentList - -/** - * The maximum number of items that can be displayed before some items will be hidden - * - * TODO The threshold should be based on the number of rows, rather than items. - * Once items would spill onto a third row, they should be hidden. - * Note this could be particularly worthwhile to handle reactions that are - * longer than a single character (as annotation keys are free text). - */ -private const val COLLAPSE_ITEMS_THRESHOLD = 8 @Composable fun TimelineItemReactions( reactionsState: TimelineItemReactions, - mainAxisAlignment: FlowMainAxisAlignment, + isOutgoing: Boolean, onReactionClicked: (emoji: String) -> Unit, onMoreReactionsClicked: () -> Unit, modifier: Modifier = Modifier, ) { var expanded: Boolean by rememberSaveable { mutableStateOf(false) } - val reactions by remember(reactionsState, expanded) { - derivedStateOf { - val numToDisplay = if (expanded) { - reactionsState.reactions.count() - } else { - COLLAPSE_ITEMS_THRESHOLD - } - reactionsState.reactions.take(numToDisplay).toPersistentList() - } + // In LTR languages we want an incoming message's reactions to be LRT and outgoing to be RTL. + // For RTL languages it should be the opposite. + val reactionsLayoutDirection = if (!isOutgoing) LocalLayoutDirection.current + else if (LocalLayoutDirection.current == LayoutDirection.Ltr) + LayoutDirection.Rtl + else + LayoutDirection.Ltr + + CompositionLocalProvider(LocalLayoutDirection provides reactionsLayoutDirection) { + TimelineItemReactionsView( + modifier = modifier, + reactions = reactionsState.reactions, + expanded = expanded, + onReactionClick = onReactionClicked, + onMoreReactionsClick = onMoreReactionsClicked, + onToggleExpandClick = { expanded = !expanded }, + ) } - - val expandableState by remember { - derivedStateOf { - if (expanded) { - ExpandableState.Expanded - } else { - val hiddenItems = reactionsState.reactions.count() - reactions.count() - if (hiddenItems > 0) { - ExpandableState.Collapsed(hidden = hiddenItems) - } else { - ExpandableState.None - } - } - } - } - - TimelineItemReactionsView( - modifier = modifier, - reactions = reactions, - expandableState = expandableState, - mainAxisAlignment = mainAxisAlignment, - onReactionClick = onReactionClicked, - onMoreReactionsClick = onMoreReactionsClicked, - onExpandClick = { expanded = true }, - onCollapseClick = { expanded = false } - ) -} - -private sealed class ExpandableState { - object None: ExpandableState() - data class Collapsed(val hidden: Int): ExpandableState() - object Expanded : ExpandableState() } @Composable private fun TimelineItemReactionsView( reactions: ImmutableList, - expandableState: ExpandableState, - mainAxisAlignment: FlowMainAxisAlignment, + expanded: Boolean, onReactionClick: (emoji: String) -> Unit, onMoreReactionsClick: () -> Unit, - onExpandClick: () -> Unit, - onCollapseClick: () -> Unit, + onToggleExpandClick: () -> Unit, modifier: Modifier = Modifier -) = - FlowRow( - modifier = modifier, - mainAxisSpacing = 4.dp, - crossAxisSpacing = 4.dp, - mainAxisAlignment = mainAxisAlignment, - ) { +) = TimelineItemReactionsLayout( + modifier = modifier, + itemSpacing = 4.dp, + rowSpacing = 4.dp, + expanded = expanded, + expandButton = { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Text( + text = stringResource(id = if (expanded) R.string.screen_room_reactions_show_less else R.string.screen_room_reactions_show_more) + ), + onClick = onToggleExpandClick, + ) + }, + addMoreButton = { + MessagesReactionButton( + content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction), + onClick = onMoreReactionsClick + ) + }, + reactions = { reactions.forEach { reaction -> MessagesReactionButton( content = MessagesReactionsButtonContent.Reaction(reaction = reaction), onClick = { onReactionClick(reaction.key) } ) } - when (expandableState) { - ExpandableState.Expanded -> - MessagesReactionButton( - content = MessagesReactionsButtonContent.Text( - text = stringResource(id = R.string.screen_room_timeline_less_reactions) - ), - onClick = onCollapseClick, - ) - is ExpandableState.Collapsed -> { - val hidden = expandableState.hidden - MessagesReactionButton( - content = MessagesReactionsButtonContent.Text( - text = pluralStringResource(id = R.plurals.screen_room_timeline_more_reactions, hidden, hidden) - ), - onClick = onExpandClick, - ) - } - ExpandableState.None -> { - // No expand or collapse action available - } - } - MessagesReactionButton( - content = MessagesReactionsButtonContent.Icon(Icons.Outlined.AddReaction), - onClick = onMoreReactionsClick - ) } +) @DayNightPreviews @Composable fun TimelineItemReactionsViewPreview() = ElementPreview { ContentToPreview( - reactions = aTimelineItemReactions(count = 1).reactions, - expandableState = ExpandableState.None, + reactions = aTimelineItemReactions(count = 1).reactions ) } @DayNightPreviews @Composable -fun TimelineItemReactionsViewCollapsedPreview() = ElementPreview { +fun TimelineItemReactionsViewFewPreview() = ElementPreview { ContentToPreview( - reactions = aTimelineItemReactions(count = 3).reactions, - expandableState = ExpandableState.Collapsed(hidden = 7), + reactions = aTimelineItemReactions(count = 3).reactions ) } @DayNightPreviews @Composable -fun TimelineItemReactionsViewExpandedPreview() = ElementPreview { +fun TimelineItemReactionsViewIncomingPreview() = ElementPreview { ContentToPreview( - reactions = aTimelineItemReactions(count = 10).reactions, - expandableState = ExpandableState.Expanded, + reactions = aTimelineItemReactions(count = 18).reactions + ) +} + +@DayNightPreviews +@Composable +fun TimelineItemReactionsViewOutgoingPreview() = ElementPreview { + ContentToPreview( + reactions = aTimelineItemReactions(count = 18).reactions, + isOutgoing = true ) } @Composable private fun ContentToPreview( reactions: ImmutableList, - expandableState: ExpandableState + isOutgoing: Boolean = false ) { - TimelineItemReactionsView( - reactions = reactions, - expandableState = expandableState, - mainAxisAlignment = FlowMainAxisAlignment.Center, - onReactionClick = {}, - onMoreReactionsClick = {}, - onExpandClick = {}, - onCollapseClick = {} + TimelineItemReactions( + reactionsState = TimelineItemReactions( + reactions + ), + isOutgoing = isOutgoing, + onReactionClicked = {}, + onMoreReactionsClicked = {}, ) }