From 472f38705bb7f04c9f8618f615f2a2df0c5e7d05 Mon Sep 17 00:00:00 2001 From: Vitor Hugo Schwaab Date: Tue, 7 May 2024 18:08:13 +0200 Subject: [PATCH] fix: improve swipe detection and animation --- .../messages/item/MessageContainerItem.kt | 2 +- .../messages/item/RegularMessageItem.kt | 134 +++++++++++------- 2 files changed, 86 insertions(+), 50 deletions(-) diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt index 7283b881f01..e68407246c3 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/MessageContainerItem.kt @@ -77,7 +77,7 @@ fun MessageContainerItem( shouldDisplayFooter: Boolean = true, onReplyClickable: Clickable? = null, isSelectedMessage: Boolean = false, - isInteractionAvailable: Boolean = true + isInteractionAvailable: Boolean = true, ) { val selfDeletionTimerState = rememberSelfDeletionTimer(message.header.messageStatus.expirationStatus) if ( diff --git a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt index c587826c69d..88050562468 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/conversations/messages/item/RegularMessageItem.kt @@ -19,27 +19,29 @@ package com.wire.android.ui.home.conversations.messages.item import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SwipeToDismissBox -import androidx.compose.material3.SwipeToDismissBoxState -import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,13 +53,18 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.platform.ViewConfiguration import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp import com.wire.android.R import com.wire.android.media.audiomessage.AudioState import com.wire.android.model.Clickable @@ -274,92 +281,121 @@ sealed interface SwipableMessageConfiguration { class SwipableToReply(val onSwipedToReply: (uiMessage: UIMessage.Regular) -> Unit) : SwipableMessageConfiguration } -@OptIn(ExperimentalMaterial3Api::class) +enum class SwipeAnchor { + CENTERED, + START_TO_END +} + +@OptIn(ExperimentalFoundationApi::class) @Composable private fun SwipableToReplyBox( modifier: Modifier = Modifier, onSwipedToReply: () -> Unit = {}, - content: @Composable RowScope.() -> Unit + content: @Composable () -> Unit ) { val density = LocalDensity.current val haptic = LocalHapticFeedback.current + val configuration = LocalConfiguration.current + val screenWidth = with(density) { configuration.screenWidthDp.dp.toPx() } var didVibrateOnCurrentDrag by remember { mutableStateOf(false) } // Finish the animation in the first 25% of the drag val progressUntilAnimationCompletion = 0.33f - val dismissState = remember { - SwipeToDismissBoxState( - SwipeToDismissBoxValue.Settled, - density, - positionalThreshold = { distance: Float -> distance * progressUntilAnimationCompletion }, + val dragWidth = screenWidth * progressUntilAnimationCompletion + val dragState = remember { + AnchoredDraggableState( + initialValue = SwipeAnchor.CENTERED, + positionalThreshold = { dragWidth }, + velocityThreshold = { screenWidth }, + animationSpec = tween(), confirmValueChange = { changedValue -> - if (changedValue == SwipeToDismissBoxValue.StartToEnd) { + if (changedValue == SwipeAnchor.START_TO_END) { // Attempt to finish dismiss, notify reply intention onSwipedToReply() } - if (changedValue == SwipeToDismissBoxValue.Settled) { + if (changedValue == SwipeAnchor.CENTERED) { // Reset the haptic feedback when drag is stopped didVibrateOnCurrentDrag = false } // Reject state change, only allow returning back to rest position - changedValue == SwipeToDismissBoxValue.Settled + changedValue == SwipeAnchor.CENTERED + }, + anchors = DraggableAnchors { + SwipeAnchor.CENTERED at 0f + SwipeAnchor.START_TO_END at screenWidth } ) } val primaryColor = colorsScheme().primary - // TODO: RTL is currently broken https://issuetracker.google.com/issues/321600474 - // Maybe addressed in compose3 1.3.0 (currently in alpha) - SwipeToDismissBox( - state = dismissState, - modifier = modifier, - content = content, - enableDismissFromEndToStart = false, - backgroundContent = { + + val currentViewConfiguration = LocalViewConfiguration.current + val scopedViewConfiguration = object : ViewConfiguration by currentViewConfiguration { + // Make it easier to scroll by giving the user a bit more length to identify the gesture as vertical + override val touchSlop: Float + get() = currentViewConfiguration.touchSlop * 3f + } + CompositionLocalProvider(LocalViewConfiguration provides scopedViewConfiguration) { + Box( + modifier = modifier.fillMaxSize(), + ) { + // Drag indication Row( modifier = Modifier - .fillMaxSize() + .matchParentSize() .drawBehind { - // TODO(RTL): Might need adjusting once RTL is supported (also lacking in SwipeToDismissBox) + // TODO(RTL): Might need adjusting once RTL is supported drawRect( color = primaryColor, topLeft = Offset(0f, 0f), - size = Size(dismissState.requireOffset().absoluteValue, size.height), + size = Size(dragState.requireOffset().absoluteValue, size.height), ) }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start ) { - if (dismissState.dismissDirection == SwipeToDismissBoxValue.StartToEnd - // Sometimes this is called with progress 1f when the user stops the interaction, causing a blink. - // Ignore these cases as it doesn't make any difference - && dismissState.progress < 1f - ) { - val adjustedProgress = min(1f, (dismissState.progress / progressUntilAnimationCompletion)) - val iconSize = dimensions().fabIconSize - val spacing = dimensions().spacing16x + if (dragState.offset > 0f) { + val dragProgress = dragState.offset / dragWidth + val adjustedProgress = min(1f, dragProgress) val progress = FastOutLinearInEasing.transform(adjustedProgress) - val xOffset = with(density) { - val offsetBeforeScreenStart = iconSize.toPx() - val offsetAfterScreenStart = spacing.toPx() - val totalTravelDistance = offsetBeforeScreenStart + offsetAfterScreenStart - -offsetBeforeScreenStart + (totalTravelDistance * progress) - } - // Got to the end, user can release to + // Got to the end, user can release to perform action, so we vibrate to show it if (progress == 1f && !didVibrateOnCurrentDrag) { haptic.performHapticFeedback(HapticFeedbackType.LongPress) didVibrateOnCurrentDrag = true } - Icon( - painter = painterResource(id = R.drawable.ic_reply), - contentDescription = "", - modifier = Modifier - .size(iconSize) - .offset { IntOffset(xOffset.toInt(), 0) }, - tint = colorsScheme().onPrimary - ) + + ReplySwipeIcon(dragWidth, density, progress) } } + // Message content, which is draggable + Box( + modifier = Modifier + .fillMaxSize() + .anchoredDraggable(dragState, Orientation.Horizontal, startDragImmediately = false) + .offset { + val x = dragState.requireOffset().toInt() + IntOffset(x, 0) + }, + ) { content() } } + } +} + +@Composable +private fun ReplySwipeIcon(dragWidth: Float, density: Density, progress: Float) { + val midPointBetweenStartAndGestureEnd = dragWidth / 2 + val iconSize = dimensions().fabIconSize + val targetIconAnchorPosition = midPointBetweenStartAndGestureEnd - with(density) { iconSize.toPx() / 2 } + val xOffset = with(density) { + val totalTravelDistance = iconSize.toPx() + targetIconAnchorPosition + -iconSize.toPx() + (totalTravelDistance * progress) + } + Icon( + painter = painterResource(id = R.drawable.ic_reply), + contentDescription = "", + modifier = Modifier + .size(iconSize) + .offset { IntOffset(xOffset.toInt(), 0) }, + tint = colorsScheme().onPrimary ) }