Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: improve swipe detection and animation [WPB-9046] #2985

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
)
}

Expand Down
Loading