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.
This commit is contained in:
David Langley
2023-07-18 21:18:27 +01:00
parent 467ce70ba8
commit a1c482d673
3 changed files with 274 additions and 112 deletions

View File

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

View File

@@ -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<Placeable>): List<List<Placeable>> {
val rows = mutableListOf<List<Placeable>>()
var currentRow = mutableListOf<Placeable>()
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<List<Placeable>>, expandButton: Placeable, addMoreButton: Placeable): List<List<Placeable>> {
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<Placeable>>): List<List<Placeable>> {
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<List<Placeable>>): 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 = {}
)
}
}
)
}

View File

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