1- @file:OptIn(ExperimentalMaterialApi ::class )
2-
31package com.getcode.ui.components
42
5- import androidx.compose.animation.AnimatedContent
63import androidx.compose.animation.animateColorAsState
74import androidx.compose.animation.core.Spring
85import androidx.compose.animation.core.animateDpAsState
96import androidx.compose.animation.core.animateFloatAsState
107import 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
149import androidx.compose.foundation.Image
1510import androidx.compose.foundation.background
16- import androidx.compose.foundation.clickable
11+ import androidx.compose.foundation.gestures.AnchoredDraggableState
12+ import androidx.compose.foundation.gestures.DraggableAnchors
1713import androidx.compose.foundation.gestures.Orientation
14+ import androidx.compose.foundation.gestures.anchoredDraggable
15+ import androidx.compose.foundation.gestures.animateTo
1816import androidx.compose.foundation.layout.Arrangement
1917import androidx.compose.foundation.layout.Box
2018import androidx.compose.foundation.layout.BoxScope
2119import androidx.compose.foundation.layout.Column
2220import androidx.compose.foundation.layout.PaddingValues
23- import androidx.compose.foundation.layout.Spacer
24- import androidx.compose.foundation.layout.fillMaxSize
2521import androidx.compose.foundation.layout.fillMaxWidth
2622import androidx.compose.foundation.layout.height
2723import androidx.compose.foundation.layout.offset
2824import androidx.compose.foundation.layout.padding
2925import 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
3826import 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
4329import androidx.compose.runtime.Composable
4430import androidx.compose.runtime.LaunchedEffect
4531import androidx.compose.runtime.Stable
4632import androidx.compose.runtime.derivedStateOf
4733import androidx.compose.runtime.getValue
4834import androidx.compose.runtime.mutableIntStateOf
4935import androidx.compose.runtime.mutableStateOf
50- import androidx.compose.runtime.rememberUpdatedState
5136import androidx.compose.runtime.remember
5237import androidx.compose.runtime.rememberCoroutineScope
38+ import androidx.compose.runtime.rememberUpdatedState
5339import androidx.compose.runtime.setValue
5440import androidx.compose.ui.Alignment
5541import androidx.compose.ui.Modifier
5642import androidx.compose.ui.draw.alpha
43+ import androidx.compose.ui.draw.clip
5744import androidx.compose.ui.graphics.Color
5845import androidx.compose.ui.graphics.ColorFilter
5946import androidx.compose.ui.graphics.Shape
@@ -74,7 +61,6 @@ import com.getcode.theme.DesignSystem
7461import com.getcode.theme.White20
7562import com.getcode.theme.White50
7663import com.getcode.ui.theme.CodeCircularProgressIndicator
77- import com.getcode.ui.core.addIf
7864import kotlinx.coroutines.CoroutineScope
7965import kotlinx.coroutines.delay
8066import kotlinx.coroutines.launch
@@ -116,7 +102,6 @@ private object Thumb {
116102}
117103
118104private 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-
296289enum class Anchor { Start , End }
297290
298291@Composable
299292private 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