@@ -8,6 +8,7 @@ import androidx.compose.foundation.gestures.AnchoredDraggableState
88import androidx.compose.foundation.gestures.DraggableAnchors
99import androidx.compose.foundation.gestures.Orientation
1010import androidx.compose.foundation.gestures.anchoredDraggable
11+ import androidx.compose.foundation.gestures.animateTo
1112import androidx.compose.foundation.layout.Arrangement
1213import androidx.compose.foundation.layout.Box
1314import androidx.compose.foundation.layout.Row
@@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.size
1718import androidx.compose.material3.Icon
1819import androidx.compose.runtime.Composable
1920import androidx.compose.runtime.CompositionLocalProvider
21+ import androidx.compose.runtime.LaunchedEffect
2022import androidx.compose.runtime.Stable
2123import androidx.compose.runtime.getValue
2224import androidx.compose.runtime.mutableStateOf
@@ -40,26 +42,60 @@ import androidx.compose.ui.unit.dp
4042import com.wire.android.R
4143import com.wire.android.ui.common.colorsScheme
4244import com.wire.android.ui.common.dimensions
43- import com.wire.android.ui.home.conversations.model.UIMessage
4445import kotlin.math.absoluteValue
4546import kotlin.math.min
4647
4748@Stable
48- sealed interface SwipableMessageConfiguration {
49- data object NotSwipable : SwipableMessageConfiguration
50- class SwipableToReply (val onSwipedToReply : (uiMessage: UIMessage .Regular ) -> Unit ) : SwipableMessageConfiguration
49+ sealed interface SwipeableMessageConfiguration {
50+ data object NotSwipeable : SwipeableMessageConfiguration
51+ class Swipeable (
52+ val onSwipedRight : (() -> Unit )? = null ,
53+ val onSwipedLeft : (() -> Unit )? = null ,
54+ ) : SwipeableMessageConfiguration
5155}
5256
5357enum class SwipeAnchor {
5458 CENTERED ,
55- START_TO_END
59+ START_TO_END ,
60+ END_TO_START ,
5661}
5762
63+ data class SwipeAction (
64+ val icon : Int ,
65+ val action : () -> Unit ,
66+ )
67+
68+ @Composable
69+ internal fun SwipeableMessageBox (
70+ configuration : SwipeableMessageConfiguration .Swipeable ,
71+ modifier : Modifier = Modifier ,
72+ content : @Composable () -> Unit ,
73+ ) {
74+ SwipeableBox (
75+ modifier = modifier,
76+ onSwipeRight = configuration.onSwipedRight?.let {
77+ SwipeAction (
78+ icon = R .drawable.ic_reply,
79+ action = it,
80+ )
81+ },
82+ onSwipeLeft = configuration.onSwipedLeft?.let {
83+ SwipeAction (
84+ icon = R .drawable.ic_react,
85+ action = it,
86+ )
87+ },
88+ content = content,
89+ )
90+ }
91+
92+ @Suppress(" CyclomaticComplexMethod" )
5893@OptIn(ExperimentalFoundationApi ::class )
5994@Composable
60- internal fun SwipableToReplyBox (
95+ private fun SwipeableBox (
6196 modifier : Modifier = Modifier ,
62- onSwipedToReply : () -> Unit = {},
97+ onSwipeRight : SwipeAction ? = null,
98+ onSwipeLeft : SwipeAction ? = null,
6399 content : @Composable () -> Unit
64100) {
65101 val density = LocalDensity .current
@@ -86,55 +122,83 @@ internal fun SwipableToReplyBox(
86122 velocityThreshold = { screenWidth },
87123 snapAnimationSpec = tween(),
88124 decayAnimationSpec = splineBasedDecay(density),
89- confirmValueChange = { changedValue ->
90- if (changedValue == SwipeAnchor .START_TO_END ) {
91- // Attempt to finish dismiss, notify reply intention
92- onSwipedToReply()
93- }
94- if (changedValue == SwipeAnchor .CENTERED ) {
95- // Reset the haptic feedback when drag is stopped
96- didVibrateOnCurrentDrag = false
97- }
98- // Reject state change, only allow returning back to rest position
99- changedValue == SwipeAnchor .CENTERED
100- },
101125 anchors = DraggableAnchors {
126+
102127 SwipeAnchor .CENTERED at 0f
103- SwipeAnchor .START_TO_END at screenWidth
128+
129+ if (onSwipeRight != null ) {
130+ SwipeAnchor .START_TO_END at dragWidth
131+ }
132+
133+ if (onSwipeLeft != null ) {
134+ SwipeAnchor .END_TO_START at - dragWidth
135+ }
104136 }
105137 )
106138 }
139+
140+ LaunchedEffect (dragState.settledValue) {
141+ when (dragState.settledValue) {
142+ SwipeAnchor .START_TO_END -> {
143+ onSwipeRight?.action?.invoke()
144+ dragState.animateTo(SwipeAnchor .CENTERED )
145+ }
146+
147+ SwipeAnchor .END_TO_START -> {
148+ onSwipeLeft?.action?.invoke()
149+ dragState.animateTo(SwipeAnchor .CENTERED )
150+ }
151+
152+ SwipeAnchor .CENTERED -> {}
153+ }
154+ didVibrateOnCurrentDrag = false
155+ }
156+
107157 val primaryColor = colorsScheme().primary
108158
109159 Box (
110160 modifier = modifier.fillMaxSize(),
111161 ) {
162+
163+ val dragOffset = dragState.requireOffset()
164+
112165 // Drag indication
113166 Row (
114167 modifier = Modifier
115168 .matchParentSize()
116169 .drawBehind {
117- // TODO(RTL): Might need adjusting once RTL is supported
118170 drawRect(
119171 color = primaryColor,
120- topLeft = Offset (0f , 0f ),
121- size = Size (dragState.requireOffset().absoluteValue, size.height),
172+ topLeft = if (dragOffset >= 0f ) {
173+ Offset (0f , 0f )
174+ } else {
175+ Offset (size.width - dragOffset.absoluteValue, 0f )
176+ },
177+ size = Size (dragOffset.absoluteValue, size.height),
122178 )
123179 },
124180 verticalAlignment = Alignment .CenterVertically ,
125181 horizontalArrangement = Arrangement .Start
126182 ) {
183+
184+ val dragProgress = dragState.offset.absoluteValue / dragWidth
185+ val adjustedProgress = min(1f , dragProgress)
186+ val progress = FastOutLinearInEasing .transform(adjustedProgress)
187+
188+ // Got to the end, user can release to perform action, so we vibrate to show it
189+ if (progress == 1f && ! didVibrateOnCurrentDrag) {
190+ haptic.performHapticFeedback(HapticFeedbackType .LongPress )
191+ didVibrateOnCurrentDrag = true
192+ }
193+
127194 if (dragState.offset > 0f ) {
128- val dragProgress = dragState.offset / dragWidth
129- val adjustedProgress = min(1f , dragProgress)
130- val progress = FastOutLinearInEasing .transform(adjustedProgress)
131- // Got to the end, user can release to perform action, so we vibrate to show it
132- if (progress == 1f && ! didVibrateOnCurrentDrag) {
133- haptic.performHapticFeedback(HapticFeedbackType .LongPress )
134- didVibrateOnCurrentDrag = true
195+ onSwipeRight?.let { action ->
196+ SwipeActionIcon (action.icon, screenWidth, dragWidth, density, progress)
197+ }
198+ } else if (dragState.offset < 0f ) {
199+ onSwipeLeft?.let {
200+ SwipeActionIcon (it.icon, screenWidth, dragWidth, density, progress, false )
135201 }
136-
137- ReplySwipeIcon (dragWidth, density, progress)
138202 }
139203 }
140204 // Message content, which is draggable
@@ -154,20 +218,37 @@ internal fun SwipableToReplyBox(
154218}
155219
156220@Composable
157- private fun ReplySwipeIcon (dragWidth : Float , density : Density , progress : Float ) {
221+ private fun SwipeActionIcon (
222+ resourceId : Int ,
223+ screenWidth : Float ,
224+ dragWidth : Float ,
225+ density : Density ,
226+ progress : Float ,
227+ swipeRight : Boolean = true
228+ ) {
158229 val midPointBetweenStartAndGestureEnd = dragWidth / 2
159230 val iconSize = dimensions().fabIconSize
160231 val targetIconAnchorPosition = midPointBetweenStartAndGestureEnd - with (density) { iconSize.toPx() / 2 }
161232 val xOffset = with (density) {
162233 val totalTravelDistance = iconSize.toPx() + targetIconAnchorPosition
163- - iconSize.toPx() + (totalTravelDistance * progress)
234+ if (swipeRight) {
235+ (totalTravelDistance * progress) - iconSize.toPx()
236+ } else {
237+ (totalTravelDistance * progress) - iconSize.toPx() / 2
238+ }
164239 }
165240 Icon (
166- painter = painterResource(id = R .drawable.ic_reply ),
241+ painter = painterResource(id = resourceId ),
167242 contentDescription = " " ,
168243 modifier = Modifier
169244 .size(iconSize)
170- .offset { IntOffset (xOffset.toInt(), 0 ) },
245+ .offset {
246+ if (swipeRight) {
247+ IntOffset (xOffset.toInt(), 0 )
248+ } else {
249+ IntOffset (screenWidth.toInt() - xOffset.toInt(), 0 )
250+ }
251+ },
171252 tint = colorsScheme().onPrimary
172253 )
173254}
0 commit comments