Skip to content

Commit

Permalink
fix: improve swipe detection and animation
Browse files Browse the repository at this point in the history
  • Loading branch information
vitorhugods committed May 7, 2024
1 parent 0fff668 commit 472f387
Show file tree
Hide file tree
Showing 2 changed files with 86 additions and 50 deletions.
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

0 comments on commit 472f387

Please sign in to comment.