Skip to content

Commit 6f06437

Browse files
committed
feat(ui): further refine and dial in swipe gesture
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent d3c256a commit 6f06437

1 file changed

Lines changed: 85 additions & 39 deletions

File tree

  • ui/components/src/main/kotlin/com/getcode/ui/components/chat

ui/components/src/main/kotlin/com/getcode/ui/components/chat/MessageNode.kt

Lines changed: 85 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.getcode.ui.components.chat
22

33
import androidx.compose.animation.AnimatedVisibility
4+
import androidx.compose.animation.core.Spring
5+
import androidx.compose.animation.core.spring
46
import androidx.compose.animation.core.tween
57
import androidx.compose.animation.fadeIn
68
import androidx.compose.animation.fadeOut
@@ -14,6 +16,8 @@ import androidx.compose.foundation.gestures.AnchoredDraggableState
1416
import androidx.compose.foundation.gestures.DraggableAnchors
1517
import androidx.compose.foundation.gestures.Orientation
1618
import androidx.compose.foundation.gestures.anchoredDraggable
19+
import androidx.compose.foundation.gestures.detectDragGestures
20+
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
1721
import androidx.compose.foundation.gestures.snapTo
1822
import androidx.compose.foundation.layout.Arrangement
1923
import androidx.compose.foundation.layout.Box
@@ -30,6 +34,8 @@ import androidx.compose.foundation.layout.widthIn
3034
import androidx.compose.foundation.shape.CircleShape
3135
import androidx.compose.foundation.shape.CornerBasedShape
3236
import androidx.compose.foundation.shape.CornerSize
37+
import androidx.compose.material.ExperimentalMaterialApi
38+
import androidx.compose.material.FractionalThreshold
3339
import androidx.compose.material.Icon
3440
import androidx.compose.material.Text
3541
import androidx.compose.material.icons.Icons
@@ -40,27 +46,37 @@ import androidx.compose.runtime.LaunchedEffect
4046
import androidx.compose.runtime.getValue
4147
import androidx.compose.runtime.mutableStateOf
4248
import androidx.compose.runtime.remember
49+
import androidx.compose.runtime.rememberCoroutineScope
4350
import androidx.compose.runtime.setValue
51+
import androidx.compose.runtime.snapshotFlow
4452
import androidx.compose.ui.Alignment
4553
import androidx.compose.ui.Modifier
4654
import androidx.compose.ui.draw.clip
55+
import androidx.compose.ui.geometry.Offset
4756
import androidx.compose.ui.graphics.Color
4857
import androidx.compose.ui.graphics.ColorFilter
4958
import androidx.compose.ui.graphics.CompositingStrategy
5059
import androidx.compose.ui.graphics.Shape
5160
import androidx.compose.ui.graphics.graphicsLayer
61+
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
62+
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
63+
import androidx.compose.ui.input.nestedscroll.nestedScroll
64+
import androidx.compose.ui.input.pointer.pointerInput
5265
import androidx.compose.ui.platform.LocalDensity
5366
import androidx.compose.ui.res.painterResource
5467
import androidx.compose.ui.text.TextStyle
5568
import androidx.compose.ui.text.font.FontWeight
5669
import androidx.compose.ui.unit.IntOffset
5770
import androidx.compose.ui.unit.dp
71+
import androidx.compose.ui.unit.times
5872
import com.getcode.model.ID
5973
import com.getcode.model.chat.MessageContent
6074
import com.getcode.model.chat.MessageStatus
6175
import com.getcode.model.chat.Sender
6276
import com.getcode.theme.CodeTheme
77+
import com.getcode.ui.components.Anchor
6378
import com.getcode.ui.components.R
79+
import com.getcode.ui.components.SlideToConfirmDefaults
6480
import com.getcode.ui.components.chat.messagecontents.AnnouncementMessage
6581
import com.getcode.ui.components.chat.messagecontents.DeletedMessage
6682
import com.getcode.ui.components.chat.messagecontents.EncryptedContent
@@ -70,10 +86,13 @@ import com.getcode.ui.components.chat.messagecontents.MessageText
7086
import com.getcode.ui.components.chat.utils.ReplyMessageAnchor
7187
import com.getcode.ui.components.chat.utils.localizedText
7288
import com.getcode.ui.components.text.markup.Markup
89+
import com.getcode.ui.utils.toPx
7390
import com.getcode.util.vibration.LocalVibrator
7491
import kotlinx.coroutines.delay
92+
import kotlinx.coroutines.launch
7593
import kotlinx.datetime.Instant
7694
import kotlin.coroutines.cancellation.CancellationException
95+
import kotlin.math.max
7796
import kotlin.math.roundToInt
7897
import kotlin.reflect.KClass
7998

@@ -86,7 +105,10 @@ object MessageNodeDefaults {
86105
@Composable get() = DefaultShape.copy(topStart = CornerSize(3.dp))
87106

88107
private val NextSameShapeIncoming: CornerBasedShape
89-
@Composable get() = DefaultShape.copy(bottomStart = CornerSize(3.dp), topStart = CornerSize(3.dp))
108+
@Composable get() = DefaultShape.copy(
109+
bottomStart = CornerSize(3.dp),
110+
topStart = CornerSize(3.dp)
111+
)
90112

91113
private val MiddleSameShapeIncoming: CornerBasedShape
92114
@Composable get() = DefaultShape.copy(
@@ -98,7 +120,10 @@ object MessageNodeDefaults {
98120
@Composable get() = DefaultShape.copy(topEnd = CornerSize(3.dp))
99121

100122
private val NextSameShapeOutgoing: CornerBasedShape
101-
@Composable get() = DefaultShape.copy(bottomEnd = CornerSize(3.dp), topEnd = CornerSize(3.dp))
123+
@Composable get() = DefaultShape.copy(
124+
bottomEnd = CornerSize(3.dp),
125+
topEnd = CornerSize(3.dp)
126+
)
102127

103128
private val MiddleSameShapeOutgoing: CornerBasedShape
104129
@Composable get() = DefaultShape.copy(
@@ -189,7 +214,7 @@ private enum class MessageNodeDragAnchors {
189214
DEFAULT, REPLY
190215
}
191216

192-
@OptIn(ExperimentalFoundationApi::class)
217+
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class)
193218
@Composable
194219
fun MessageNode(
195220
contents: MessageContent,
@@ -219,9 +244,8 @@ fun MessageNode(
219244

220245
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
221246
val maxWidth = maxWidth
222-
val swipeThreshold = with(density) { maxWidth.toPx() } * 0.20f
223-
var hasTriggeredTick by remember { mutableStateOf(false) }
224-
var hasFiredReply by remember { mutableStateOf(false) }
247+
val swipeThreshold = with(density) { maxWidth.toPx() } * 0.40f
248+
var hasReachedThreshold by remember { mutableStateOf(false) }
225249

226250
val anchors = remember(maxWidth) {
227251
DraggableAnchors {
@@ -234,46 +258,46 @@ fun MessageNode(
234258
AnchoredDraggableState(
235259
initialValue = MessageNodeDragAnchors.DEFAULT,
236260
anchors = anchors,
237-
positionalThreshold = { it * 0.3f },
238-
velocityThreshold = { with(density) { 200.dp.toPx() } },
261+
positionalThreshold = { it * 0.9f },
262+
velocityThreshold = { Float.POSITIVE_INFINITY },
239263
confirmValueChange = { targetValue ->
240-
if (targetValue == MessageNodeDragAnchors.REPLY && !hasFiredReply) {
241-
hasFiredReply = true
242-
onReply()
264+
if (targetValue == MessageNodeDragAnchors.REPLY && !hasReachedThreshold) {
265+
hasReachedThreshold = true
266+
vibrator.tick()
243267
}
244-
true
268+
false
245269
},
246-
snapAnimationSpec = tween(durationMillis = 400),
270+
snapAnimationSpec = spring(
271+
dampingRatio = Spring.DampingRatioNoBouncy,
272+
stiffness = Spring.StiffnessMediumLow,
273+
),
247274
decayAnimationSpec = splineBasedDecay(density)
248275
)
249276
}
250277

251-
LaunchedEffect(replyDragState.currentValue) {
252-
if (replyDragState.currentValue == MessageNodeDragAnchors.REPLY) {
253-
// Reset drag state to allow future replies
254-
delay(200)
255-
try {
256-
replyDragState.snapTo(MessageNodeDragAnchors.DEFAULT)
257-
println("Animation completed")
258-
} catch (e: CancellationException) {
259-
println("Animation canceled: ${e.message}")
260-
}
261-
262-
hasFiredReply = false
263-
hasTriggeredTick = false
278+
LaunchedEffect(hasReachedThreshold, replyDragState.targetValue) {
279+
if (hasReachedThreshold && replyDragState.targetValue == MessageNodeDragAnchors.DEFAULT && replyDragState.isAnimationRunning) {
280+
onReply()
281+
hasReachedThreshold = false
264282
}
265283
}
266284

267-
LaunchedEffect(replyDragState.offset) {
268-
if (replyDragState.offset >= swipeThreshold && !hasTriggeredTick) {
269-
hasTriggeredTick = true
270-
vibrator.tick()
285+
val nestedScrollConnection = remember {
286+
object : NestedScrollConnection {
287+
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
288+
// Block vertical scrolling only when horizontal drag is active
289+
return if (replyDragState.offset != 0f) Offset.Zero else super.onPreScroll(
290+
available,
291+
source
292+
)
293+
}
271294
}
272295
}
273296

274297
Box(
275298
modifier = modifier
276299
.graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }
300+
.nestedScroll(nestedScrollConnection)
277301
.anchoredDraggable(
278302
state = replyDragState,
279303
enabled = enableReply,
@@ -284,7 +308,13 @@ fun MessageNode(
284308
Box(
285309
modifier = Modifier
286310
.padding(horizontal = CodeTheme.dimens.inset)
287-
.offset { IntOffset(x = replyDragState.offset.coerceAtMost(swipeThreshold).roundToInt(), y = 0) }
311+
.offset {
312+
IntOffset(
313+
x = replyDragState.offset.coerceAtMost(maxWidth.toPx() * 0.3f)
314+
.roundToInt(),
315+
y = 0
316+
)
317+
}
288318
) {
289319
val scope = rememberMessageNodeScope(
290320
contents = contents,
@@ -308,8 +338,12 @@ fun MessageNode(
308338

309339
when (contents) {
310340
is MessageContent.Exchange -> {
311-
val alignment = if (sender.isSelf) Alignment.CenterEnd else Alignment.CenterStart
312-
Box(modifier = modifier.fillMaxWidth(), contentAlignment = alignment) {
341+
val alignment =
342+
if (sender.isSelf) Alignment.CenterEnd else Alignment.CenterStart
343+
Box(
344+
modifier = modifier.fillMaxWidth(),
345+
contentAlignment = alignment
346+
) {
313347
MessagePayment(
314348
modifier = Modifier
315349
.sizeableWidth()
@@ -340,8 +374,12 @@ fun MessageNode(
340374
}
341375

342376
is MessageContent.SodiumBox -> {
343-
val alignment = if (sender.isSelf) Alignment.CenterEnd else Alignment.CenterStart
344-
Box(modifier = modifier.fillMaxWidth(), contentAlignment = alignment) {
377+
val alignment =
378+
if (sender.isSelf) Alignment.CenterEnd else Alignment.CenterStart
379+
Box(
380+
modifier = modifier.fillMaxWidth(),
381+
contentAlignment = alignment
382+
) {
345383
EncryptedContent(
346384
modifier = Modifier
347385
.sizeableWidth()
@@ -438,14 +476,22 @@ fun MessageNode(
438476
}
439477

440478
AnimatedVisibility(
441-
visible = replyDragState.offset > 0f,
479+
visible = replyDragState.offset > with(density) { 5.dp.toPx() },
442480
enter = fadeIn() + scaleIn(),
443481
exit = scaleOut() + fadeOut(),
444-
modifier = Modifier.align(Alignment.CenterStart)
445-
.offset { IntOffset(x = replyDragState.offset.coerceAtMost(swipeThreshold).roundToInt() - 20.dp.roundToPx(), y = 0) }
482+
modifier = Modifier
483+
.align(Alignment.CenterStart)
484+
.offset {
485+
IntOffset(
486+
x = replyDragState.offset.times(0.2f).coerceAtMost(20.dp.toPx())
487+
.roundToInt(), y = 0
488+
)
489+
}
446490
) {
447491
Icon(
448-
modifier = Modifier,
492+
modifier = Modifier.graphicsLayer {
493+
alpha = (replyDragState.offset.times(0.2f) / 20.dp.toPx()).coerceIn(0f, 1f)
494+
},
449495
imageVector = Icons.AutoMirrored.Default.Reply,
450496
contentDescription = "Swipe to Reply",
451497
)

0 commit comments

Comments
 (0)