Skip to content

Commit ee8d06c

Browse files
authored
refactor(ui): migrate SlideToConfirm from Material swipeable to AnchoredDraggable (#826)
Replace deprecated Material ExperimentalMaterialApi swipeable with Foundation AnchoredDraggableState. Migrate Text to Material3, fix thumb clipping, add state-based and interactive previews. Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent ade143b commit ee8d06c

2 files changed

Lines changed: 128 additions & 117 deletions

File tree

‎apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -367,7 +367,7 @@ internal class SendFlowViewModel @Inject constructor(
367367
is Event.OnContactClicked -> { state -> state }
368368
is Event.SendInvite -> { state -> state }
369369
is Event.SendCashToContact -> { state -> state }
370-
is Event.NavigateToAmountEntry -> { state -> state }
370+
is Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) }
371371
is Event.ResolveCompleted -> { state -> state }
372372
is Event.ResolveFailed -> { state -> state }
373373
is Event.OnSendRequested -> { state -> state }
@@ -379,9 +379,7 @@ internal class SendFlowViewModel @Inject constructor(
379379
)
380380
)
381381
}
382-
is Event.SendComplete -> { state ->
383-
state.copy(sendProgress = LoadingSuccessState())
384-
}
382+
is Event.SendComplete -> { state -> state }
385383
Event.ContactNotResolved -> { state -> state }
386384
}
387385
}

‎ui/components/src/main/kotlin/com/getcode/ui/components/SlideToConfirm.kt‎

Lines changed: 126 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,46 @@
1-
@file:OptIn(ExperimentalMaterialApi::class)
2-
31
package com.getcode.ui.components
42

5-
import androidx.compose.animation.AnimatedContent
63
import androidx.compose.animation.animateColorAsState
74
import androidx.compose.animation.core.Spring
85
import androidx.compose.animation.core.animateDpAsState
96
import androidx.compose.animation.core.animateFloatAsState
107
import androidx.compose.animation.core.spring
11-
import androidx.compose.animation.fadeIn
12-
import androidx.compose.animation.fadeOut
13-
import androidx.compose.animation.togetherWith
8+
import androidx.compose.animation.splineBasedDecay
149
import androidx.compose.foundation.Image
1510
import androidx.compose.foundation.background
16-
import androidx.compose.foundation.clickable
11+
import androidx.compose.foundation.gestures.AnchoredDraggableState
12+
import androidx.compose.foundation.gestures.DraggableAnchors
1713
import androidx.compose.foundation.gestures.Orientation
14+
import androidx.compose.foundation.gestures.anchoredDraggable
15+
import androidx.compose.foundation.gestures.animateTo
1816
import androidx.compose.foundation.layout.Arrangement
1917
import androidx.compose.foundation.layout.Box
2018
import androidx.compose.foundation.layout.BoxScope
2119
import androidx.compose.foundation.layout.Column
2220
import androidx.compose.foundation.layout.PaddingValues
23-
import androidx.compose.foundation.layout.Spacer
24-
import androidx.compose.foundation.layout.fillMaxSize
2521
import androidx.compose.foundation.layout.fillMaxWidth
2622
import androidx.compose.foundation.layout.height
2723
import androidx.compose.foundation.layout.offset
2824
import androidx.compose.foundation.layout.padding
2925
import androidx.compose.foundation.layout.size
30-
import androidx.compose.foundation.shape.CircleShape
31-
import androidx.compose.material.ExperimentalMaterialApi
32-
import androidx.compose.material.FractionalThreshold
33-
import androidx.compose.material.SwipeProgress
34-
import androidx.compose.material.SwipeableDefaults
35-
import androidx.compose.material.SwipeableState
36-
import androidx.compose.material.Text
37-
import androidx.compose.material.TextButton
3826
import androidx.compose.material.icons.Icons
39-
import androidx.compose.material.icons.rounded.ArrowForward
40-
import androidx.compose.material.minimumInteractiveComponentSize
41-
import androidx.compose.material.rememberSwipeableState
42-
import androidx.compose.material.swipeable
27+
import androidx.compose.material.icons.automirrored.rounded.ArrowForward
28+
import androidx.compose.material3.Text
4329
import androidx.compose.runtime.Composable
4430
import androidx.compose.runtime.LaunchedEffect
4531
import androidx.compose.runtime.Stable
4632
import androidx.compose.runtime.derivedStateOf
4733
import androidx.compose.runtime.getValue
4834
import androidx.compose.runtime.mutableIntStateOf
4935
import androidx.compose.runtime.mutableStateOf
50-
import androidx.compose.runtime.rememberUpdatedState
5136
import androidx.compose.runtime.remember
5237
import androidx.compose.runtime.rememberCoroutineScope
38+
import androidx.compose.runtime.rememberUpdatedState
5339
import androidx.compose.runtime.setValue
5440
import androidx.compose.ui.Alignment
5541
import androidx.compose.ui.Modifier
5642
import androidx.compose.ui.draw.alpha
43+
import androidx.compose.ui.draw.clip
5744
import androidx.compose.ui.graphics.Color
5845
import androidx.compose.ui.graphics.ColorFilter
5946
import androidx.compose.ui.graphics.Shape
@@ -74,7 +61,6 @@ import com.getcode.theme.DesignSystem
7461
import com.getcode.theme.White20
7562
import com.getcode.theme.White50
7663
import com.getcode.ui.theme.CodeCircularProgressIndicator
77-
import com.getcode.ui.core.addIf
7864
import kotlinx.coroutines.CoroutineScope
7965
import kotlinx.coroutines.delay
8066
import kotlinx.coroutines.launch
@@ -116,7 +102,6 @@ private object Thumb {
116102
}
117103

118104
private object Track {
119-
val VelocityThreshold = SwipeableDefaults.VelocityThreshold * 10
120105
val Shape: Shape
121106
@Composable get() = CodeTheme.shapes.small
122107

@@ -176,15 +161,33 @@ fun SlideToConfirm(
176161
) {
177162
val currentIsLoading by rememberUpdatedState(isLoading)
178163
val hapticFeedback = LocalHapticFeedback.current
179-
val swipeState = rememberSwipeableState(
180-
initialValue = Anchor.Start,
181-
)
164+
val density = LocalDensity.current
165+
val thumbSize = Thumb.Size
166+
val horizontalPadding = CodeTheme.dimens.grid.x1
167+
val overhead = with(density) { (2 * horizontalPadding + thumbSize).toPx() }
168+
169+
val swipeState = remember {
170+
AnchoredDraggableState(
171+
initialValue = Anchor.Start,
172+
positionalThreshold = { totalDistance ->
173+
SlideToConfirmDefaults.SnapThreshold * (totalDistance - overhead).coerceAtLeast(0f)
174+
},
175+
velocityThreshold = { with(density) { 1250.dp.toPx() } },
176+
snapAnimationSpec = spring(),
177+
decayAnimationSpec = splineBasedDecay(density),
178+
)
179+
}
182180

183181
val composeScope = rememberCoroutineScope()
184182
val hintState = remember { SlideHintState(composeScope) }
185183

186184
val swipeFraction by remember {
187-
derivedStateOf { calculateSwipeFraction(swipeState.progress) }
185+
derivedStateOf {
186+
val offset = swipeState.offset
187+
val end = swipeState.anchors.positionOf(Anchor.End)
188+
if (offset.isNaN() || end.isNaN() || end <= 0f) 0f
189+
else (offset / end).coerceIn(0f, 1f)
190+
}
188191
}
189192

190193
LaunchedEffect(swipeFraction, enabled) {
@@ -228,7 +231,7 @@ fun SlideToConfirm(
228231
shape = trackShape,
229232
color = trackColor,
230233
) {
231-
if (!isSuccess) {
234+
if (!isSuccess && !isLoading) {
232235
hint(swipeFraction, PaddingValues(horizontal = Thumb.Size + CodeTheme.dimens.grid.x2), label)
233236
}
234237

@@ -274,53 +277,39 @@ fun SlideToConfirm(
274277
modifier = Modifier
275278
.alpha(thumbAlpha)
276279
.offset {
277-
IntOffset(
278-
x = swipeState.offset.value.roundToInt() + bumpFactor.roundToPx(),
279-
y = 0
280-
)
280+
val rawOffset = swipeState.offset
281+
val x = if (rawOffset.isNaN()) 0
282+
else rawOffset.roundToInt() + bumpFactor.roundToPx()
283+
IntOffset(x = x, y = 0)
281284
},
282285
)
283286
}
284287
}
285288

286-
private fun calculateSwipeFraction(progress: SwipeProgress<Anchor>): Float {
287-
val atAnchor = progress.from == progress.to
288-
val fromStart = progress.from == Anchor.Start
289-
return if (atAnchor) {
290-
if (fromStart) 0f else 1f
291-
} else {
292-
if (fromStart) progress.fraction else 1f - progress.fraction
293-
}
294-
}
295-
296289
enum class Anchor { Start, End }
297290

298291
@Composable
299292
private fun Track(
300-
swipeState: SwipeableState<Anchor>,
293+
swipeState: AnchoredDraggableState<Anchor>,
301294
enabled: Boolean,
302295
modifier: Modifier = Modifier,
303296
shape: Shape = Track.Shape,
304297
color: Color = Track.Color,
305298
content: @Composable (BoxScope.() -> Unit),
306299
) {
307-
val density = LocalDensity.current
308300
var fullWidth by remember { mutableIntStateOf(0) }
309301

310302
val horizontalPadding = CodeTheme.dimens.grid.x1
311-
312303
val thumbSize = Thumb.Size
313-
val startOfTrackPx = 0f
314-
val endOfTrackPx = remember(fullWidth) {
315-
with(density) { fullWidth - (2 * horizontalPadding + thumbSize).toPx() + thumbSize.value }
316-
}
317304

318-
val snapThreshold = SlideToConfirmDefaults.SnapThreshold
319-
val thresholds = { from: Anchor, _: Anchor ->
320-
if (from == Anchor.Start) {
321-
FractionalThreshold(snapThreshold)
322-
} else {
323-
FractionalThreshold(1f - snapThreshold)
305+
LaunchedEffect(fullWidth) {
306+
if (fullWidth > 0) {
307+
swipeState.updateAnchors(
308+
DraggableAnchors {
309+
Anchor.Start at 0f
310+
Anchor.End at fullWidth.toFloat()
311+
}
312+
)
324313
}
325314
}
326315

@@ -329,21 +318,16 @@ private fun Track(
329318
.onSizeChanged { fullWidth = it.width }
330319
.height(thumbSize + CodeTheme.dimens.grid.x1)
331320
.fillMaxWidth()
332-
.swipeable(
333-
enabled = enabled,
321+
.anchoredDraggable(
334322
state = swipeState,
323+
enabled = enabled,
335324
orientation = Orientation.Horizontal,
336-
anchors = mapOf(
337-
startOfTrackPx to Anchor.Start,
338-
endOfTrackPx to Anchor.End,
339-
),
340-
thresholds = thresholds,
341-
velocityThreshold = Track.VelocityThreshold,
342325
)
343326
.background(
344327
color = color,
345328
shape = shape,
346329
)
330+
.clip(shape)
347331
.padding(
348332
PaddingValues(
349333
horizontal = horizontalPadding,
@@ -369,7 +353,7 @@ private fun Thumb(
369353
contentAlignment = Alignment.Center
370354
) {
371355
Image(
372-
Icons.Rounded.ArrowForward,
356+
Icons.AutoMirrored.Rounded.ArrowForward,
373357
contentDescription = null,
374358
)
375359
}
@@ -384,63 +368,92 @@ private fun calculateHintTextColor(swipeFraction: Float): Color {
384368

385369
@Preview
386370
@Composable
387-
private fun Preview() {
371+
private fun InteractivePreview() {
388372
var isLoading by remember { mutableStateOf(false) }
389373
var isSuccess by remember { mutableStateOf(false) }
390374
DesignSystem {
391375
Column(
392-
verticalArrangement = Arrangement.Bottom,
393376
modifier = Modifier
394-
.background(Color.White)
395-
.fillMaxSize()
377+
.background(Color.Black)
378+
.padding(
379+
horizontal = CodeTheme.dimens.inset,
380+
vertical = CodeTheme.dimens.grid.x6
381+
),
382+
horizontalAlignment = Alignment.CenterHorizontally,
383+
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3)
396384
) {
397-
Column(
398-
modifier = Modifier
399-
.background(Color.Black)
400-
.padding(
401-
horizontal = CodeTheme.dimens.inset,
402-
vertical = CodeTheme.dimens.grid.x6
403-
),
404-
horizontalAlignment = Alignment.CenterHorizontally,
405-
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3)
406-
) {
407-
SlideToConfirm(
408-
modifier = Modifier.addIf(isSuccess) {
409-
Modifier.clickable {
410-
isLoading = false
411-
isSuccess = false
412-
}
413-
},
414-
isLoading = isLoading,
415-
isSuccess = isSuccess,
416-
onConfirm = { isLoading = true },
417-
)
418-
AnimatedContent(
419-
targetState = !isLoading,
420-
transitionSpec = { fadeIn() togetherWith fadeOut() }
421-
) { show ->
422-
if (show) {
423-
TextButton(
424-
shape = CircleShape,
425-
onClick = { isLoading = false }) {
426-
Text(
427-
text = "Cancel",
428-
style = CodeTheme.typography.caption,
429-
color = White50
430-
)
431-
}
432-
} else {
433-
Spacer(modifier = Modifier.minimumInteractiveComponentSize())
434-
}
385+
SlideToConfirm(
386+
isLoading = isLoading,
387+
isSuccess = isSuccess,
388+
onConfirm = { isLoading = true },
389+
)
390+
391+
LaunchedEffect(isLoading) {
392+
if (isLoading) {
393+
delay(1500)
394+
isSuccess = true
435395
}
396+
}
436397

437-
LaunchedEffect(isLoading) {
438-
if (isLoading) {
439-
delay(1500)
440-
isSuccess = true
441-
}
398+
LaunchedEffect(isSuccess) {
399+
if (isSuccess) {
400+
delay(1500)
401+
isLoading = false
402+
isSuccess = false
442403
}
443404
}
444405
}
445406
}
446-
}
407+
}
408+
409+
@Preview
410+
@Composable
411+
private fun StatesPreview() {
412+
DesignSystem {
413+
Column(
414+
modifier = Modifier
415+
.background(Color.Black)
416+
.padding(
417+
horizontal = CodeTheme.dimens.inset,
418+
vertical = CodeTheme.dimens.grid.x6
419+
),
420+
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3)
421+
) {
422+
Text(
423+
text = "Enabled",
424+
color = White50,
425+
style = CodeTheme.typography.caption,
426+
)
427+
SlideToConfirm(
428+
onConfirm = {},
429+
)
430+
Text(
431+
text = "Disabled",
432+
color = White50,
433+
style = CodeTheme.typography.caption,
434+
)
435+
SlideToConfirm(
436+
onConfirm = {},
437+
enabled = false,
438+
)
439+
Text(
440+
text = "Loading",
441+
color = White50,
442+
style = CodeTheme.typography.caption,
443+
)
444+
SlideToConfirm(
445+
onConfirm = {},
446+
isLoading = true,
447+
)
448+
Text(
449+
text = "Success",
450+
color = White50,
451+
style = CodeTheme.typography.caption,
452+
)
453+
SlideToConfirm(
454+
onConfirm = {},
455+
isSuccess = true,
456+
)
457+
}
458+
}
459+
}

0 commit comments

Comments
 (0)