Merge pull request #826 from vector-im/feature/bma/swipeAction

Improve swipe to reply rendering
This commit is contained in:
Benoit Marty 2023-07-10 13:56:29 +02:00 committed by GitHub
commit 0fbf799d15
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 205 additions and 93 deletions

View file

@ -14,13 +14,13 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@ -35,26 +36,25 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DismissDirection
import androidx.compose.material3.DismissState
import androidx.compose.material3.DismissValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismiss
import androidx.compose.material3.rememberDismissState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
@ -66,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
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.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.TimelineItemGroupPosition
import io.element.android.features.messages.impl.timeline.model.bubble.BubbleState
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
@ -78,6 +79,9 @@ import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.swipe.SwipeableActionsState
import io.element.android.libraries.designsystem.swipe.rememberSwipeableActionsState
import io.element.android.libraries.designsystem.text.toPx
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
@ -93,7 +97,10 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import org.jsoup.Jsoup
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
fun TimelineItemEventRow(
@ -110,6 +117,7 @@ fun TimelineItemEventRow(
onSwipeToReply: () -> Unit,
modifier: Modifier = Modifier
) {
val coroutineScope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() }
fun onUserDataClicked() {
@ -121,56 +129,88 @@ fun TimelineItemEventRow(
inReplyToClick(inReplyToEventId)
}
if (canReply) {
val dismissState = rememberDismissState(
confirmValueChange = {
if (it == DismissValue.DismissedToEnd) {
onSwipeToReply()
Column(modifier = modifier.fillMaxWidth()) {
if (event.groupPosition.isNew()) {
Spacer(modifier = Modifier.height(16.dp))
} else {
Spacer(modifier = Modifier.height(2.dp))
}
if (canReply) {
val state: SwipeableActionsState = rememberSwipeableActionsState()
val offset = state.offset.value
val swipeThresholdPx = 40.dp.toPx()
val thresholdCrossed = abs(offset) > swipeThresholdPx
SwipeSensitivity(3f) {
Box(Modifier.fillMaxWidth()) {
Row(modifier = Modifier.matchParentSize()) {
ReplySwipeIndicator({ offset / 120 })
}
TimelineItemEventRowContent(
modifier = Modifier
.absoluteOffset { IntOffset(x = offset.roundToInt(), y = 0) }
.draggable(
orientation = Orientation.Horizontal,
enabled = !state.isResettingOnRelease,
onDragStopped = {
coroutineScope.launch {
if (thresholdCrossed) {
onSwipeToReply()
}
state.resetOffset()
}
},
state = state.draggableState,
),
event = event,
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
onTimestampClicked = onTimestampClicked,
inReplyToClicked = ::inReplyToClicked,
onUserDataClicked = ::onUserDataClicked,
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
)
}
// Do not dismiss the message, return false!
false
}
)
SwipeToDismiss(
state = dismissState,
background = {
ReplySwipeIndicator({ dismissState.toSwipeProgress() })
},
directions = setOf(DismissDirection.StartToEnd),
dismissContent = {
TimelineItemEventRowContent(
event = event,
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
onTimestampClicked = onTimestampClicked,
inReplyToClicked = ::inReplyToClicked,
onUserDataClicked = ::onUserDataClicked,
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
)
}
)
} else {
TimelineItemEventRowContent(
event = event,
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
onTimestampClicked = onTimestampClicked,
inReplyToClicked = ::inReplyToClicked,
onUserDataClicked = ::onUserDataClicked,
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
)
} else {
TimelineItemEventRowContent(
event = event,
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
onTimestampClicked = onTimestampClicked,
inReplyToClicked = ::inReplyToClicked,
onUserDataClicked = ::onUserDataClicked,
onReactionClicked = { emoji -> onReactionClick(emoji, event) },
onMoreReactionsClicked = { onMoreReactionsClick(event) },
)
}
}
// This is assuming that we are in a ColumnScope, but this is OK, for both Preview and real usage.
if (event.groupPosition.isNew()) {
Spacer(modifier = modifier.height(16.dp))
} else {
Spacer(modifier = modifier.height(2.dp))
}
/**
* Impact ViewConfiguration.touchSlop by [sensitivityFactor].
* Inspired from https://issuetracker.google.com/u/1/issues/269627294.
* @param sensitivityFactor the factor to multiply the touchSlop by. The highest value, the more the user will
* have to drag to start the drag.
* @param content the content to display.
*/
@Composable
fun SwipeSensitivity(
sensitivityFactor: Float,
content: @Composable () -> Unit,
) {
val current = LocalViewConfiguration.current
CompositionLocalProvider(
LocalViewConfiguration provides object : ViewConfiguration by current {
override val touchSlop: Float
get() = current.touchSlop * sensitivityFactor
}
) {
content()
}
}
@ -266,14 +306,6 @@ private fun TimelineItemEventRowContent(
}
}
private fun DismissState.toSwipeProgress(): Float {
return when (targetValue) {
DismissValue.Default -> 0f
DismissValue.DismissedToEnd -> progress * 3
DismissValue.DismissedToStart -> progress * 3
}
}
@Composable
private fun MessageSenderInformation(
sender: String,
@ -544,6 +576,7 @@ private fun ContentToPreview() {
body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors."
),
groupPosition = TimelineItemGroupPosition.First,
),
isHighlighted = false,
canReply = true,
@ -562,6 +595,7 @@ private fun ContentToPreview() {
content = aTimelineItemImageContent().copy(
aspectRatio = 5f
),
groupPosition = TimelineItemGroupPosition.Last,
),
isHighlighted = false,
canReply = true,
@ -606,7 +640,8 @@ private fun ContentToPreviewWithReply() {
body = "A long text which will be displayed on several lines and" +
" hopefully can be manually adjusted to test different behaviors."
),
inReplyTo = aInReplyToReady(replyContent)
inReplyTo = aInReplyToReady(replyContent),
groupPosition = TimelineItemGroupPosition.First,
),
isHighlighted = false,
canReply = true,
@ -625,7 +660,8 @@ private fun ContentToPreviewWithReply() {
content = aTimelineItemImageContent().copy(
aspectRatio = 5f
),
inReplyTo = aInReplyToReady(replyContent)
inReplyTo = aInReplyToReady(replyContent),
groupPosition = TimelineItemGroupPosition.Last,
),
isHighlighted = false,
canReply = true,
@ -699,7 +735,6 @@ private fun ContentTimestampToPreview(event: TimelineItem.Event) {
}
}
@Preview
@Composable
internal fun TimelineItemEventRowWithManyReactionsLightPreview() =