Skip to content

Commit 8f63797

Browse files
committed
refactor(design-system): enhance SwipeableRow default animations and expose swipe progress allowing dynamic background color and swipe progress handling
1 parent 8cac656 commit 8f63797

5 files changed

Lines changed: 348 additions & 112 deletions

File tree

app-ui-catalog/src/main/kotlin/net/thunderbird/ui/catalog/ui/page/molecule/items/SwipeableRowItems.kt

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import androidx.compose.runtime.remember
1919
import androidx.compose.runtime.rememberCoroutineScope
2020
import androidx.compose.runtime.setValue
2121
import androidx.compose.ui.Modifier
22+
import androidx.compose.ui.graphics.lerp
2223
import androidx.compose.ui.text.style.TextAlign
2324
import androidx.compose.ui.text.style.TextOverflow
2425
import app.k9mail.core.ui.compose.designsystem.atom.DividerHorizontal
@@ -188,7 +189,7 @@ private fun SwipeableRowDisabled(behaviour: SwipeBehaviour, onSwipeEnd: (SwipeDi
188189
directions = persistentSetOf(),
189190
behaviour = behaviour,
190191
onSwipeEnd = onSwipeEnd,
191-
) { error("Should not be visible") }
192+
) { _, _ -> error("Should not be visible") }
192193
}
193194
}
194195

@@ -204,20 +205,25 @@ private fun SwipeableRowCustomBackground(behaviour: SwipeBehaviour, onSwipeEnd:
204205
},
205206
directions = persistentSetOf(SwipeDirection.StartToEnd, SwipeDirection.EndToStart),
206207
onSwipeEnd = onSwipeEnd,
207-
) { direction ->
208+
) { swipeProgress, direction ->
209+
val backgroundColor = if (direction == SwipeDirection.StartToEnd) {
210+
MainTheme.colors.error
211+
} else {
212+
MainTheme.colors.success
213+
}
208214
Surface(
209-
color = if (direction == SwipeDirection.StartToEnd) {
210-
MainTheme.colors.error
211-
} else {
212-
MainTheme.colors.success
213-
},
215+
color = lerp(
216+
start = MainTheme.colors.surfaceContainer,
217+
stop = backgroundColor,
218+
fraction = swipeProgress,
219+
),
214220
contentColor = MainTheme.colors.onPrimary,
215221
) {
216222
TextBodyLarge(
217223
text = "Swiped from ${direction.name.lowercase()}.",
218224
modifier = Modifier
219225
.fillMaxWidth()
220-
.padding(MainTheme.spacings.quadruple),
226+
.swipeableCommonTextPadding(),
221227
textAlign = when (direction) {
222228
SwipeDirection.StartToEnd -> TextAlign.Start
223229
SwipeDirection.EndToStart -> TextAlign.End
@@ -236,19 +242,29 @@ private fun SwipeableRowItems(
236242
backgroundItemText: (SwipeDirection) -> String,
237243
modifier: Modifier = Modifier,
238244
directions: ImmutableSet<SwipeDirection> = persistentSetOf(),
239-
onSwipeEnd: (SwipeDirection) -> Unit,
240-
backgroundContent: @Composable RowScope.(SwipeDirection) -> Unit = { direction ->
245+
onSwipeEnd: (SwipeDirection) -> Unit = {},
246+
backgroundContent: @Composable RowScope.(Float, SwipeDirection) -> Unit = { swipeProgress, direction ->
241247
Surface(
242-
color = MainTheme.colors.primaryContainer,
248+
color = lerp(
249+
start = MainTheme.colors.surfaceContainer,
250+
stop = MainTheme.colors.primaryContainer,
251+
fraction = swipeProgress,
252+
),
243253
contentColor = MainTheme.colors.onPrimaryContainer,
244254
modifier = Modifier.fillMaxSize(),
245255
) {
246-
Row {
256+
Row(
257+
horizontalArrangement = when (direction) {
258+
SwipeDirection.StartToEnd -> Arrangement.Start
259+
SwipeDirection.EndToStart -> Arrangement.End
260+
SwipeDirection.Settled -> Arrangement.Center
261+
},
262+
) {
247263
TextBodyLarge(
248264
text = backgroundItemText(direction),
249265
modifier = Modifier
250266
.fillMaxWidth(fraction = if (behaviour is SwipeBehaviour.Reveal) behaviour.threshold else 1f)
251-
.padding(vertical = MainTheme.spacings.triple, horizontal = MainTheme.spacings.default),
267+
.swipeableCommonTextPadding(),
252268
textAlign = when (direction) {
253269
SwipeDirection.StartToEnd -> TextAlign.Start
254270
SwipeDirection.EndToStart -> TextAlign.End
@@ -266,7 +282,12 @@ private fun SwipeableRowItems(
266282
val swipeableRowState = rememberSwipeableRowState(
267283
startToEndBehaviour = startToEndBehaviour,
268284
endToStartBehaviour = endToStartBehaviour,
269-
accessibilityActions = buildAccessibilityActions(startToEndBehaviour, endToStartBehaviour),
285+
accessibilityActions = remember(startToEndBehaviour, endToStartBehaviour) {
286+
buildAccessibilityActions(
287+
startToEndBehaviour,
288+
endToStartBehaviour,
289+
)
290+
},
270291
)
271292
Column(
272293
modifier = modifier.padding(MainTheme.spacings.double),
@@ -275,7 +296,7 @@ private fun SwipeableRowItems(
275296
SwipeableRow(
276297
state = swipeableRowState,
277298
backgroundContent = {
278-
backgroundContent(swipeDirection ?: SwipeDirection.Settled)
299+
backgroundContent(swipeableRowState.swipeProgress, swipeDirection ?: SwipeDirection.Settled)
279300
},
280301
gesturesEnabled = directions.isNotEmpty(),
281302
modifier = Modifier.fillMaxWidth(),
@@ -289,19 +310,20 @@ private fun SwipeableRowItems(
289310
text = foregroundItemText,
290311
modifier = Modifier
291312
.fillMaxWidth()
292-
.padding(vertical = MainTheme.spacings.triple, horizontal = MainTheme.spacings.default),
313+
.swipeableCommonTextPadding(),
293314
)
294315
}
295316
}
296317

297318
TextBodyLarge(
298-
text = swipeDirection?.let { "Swiping to the ${it.name.lowercase()} direction." } ?: "Not swiping yet.",
319+
text = swipeDirection
320+
?.takeIf { it != SwipeDirection.Settled }
321+
?.let { "Swiping to the ${it.name.lowercase()} direction." }
322+
?: "Not swiping yet.",
299323
)
300-
301324
}
302325
}
303326

304-
@Composable
305327
private fun buildAccessibilityActions(
306328
startToEndBehaviour: SwipeBehaviour,
307329
endToStartBehaviour: SwipeBehaviour,
@@ -325,3 +347,7 @@ private val SwipeBehaviour.actionId: Int
325347
is SwipeBehaviour.Reveal if autoReset -> R.string.swipe_accessibility_reveal_and_reset_custom_action
326348
is SwipeBehaviour.Reveal -> R.string.swipe_accessibility_reveal_custom_action
327349
}
350+
351+
@Composable
352+
private fun Modifier.swipeableCommonTextPadding() = this then
353+
Modifier.padding(vertical = MainTheme.spacings.triple, horizontal = MainTheme.spacings.default)

core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/molecule/swipe/SwipeBehaviour.kt

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ package net.thunderbird.core.ui.compose.designsystem.molecule.swipe
33
import androidx.annotation.FloatRange
44
import androidx.compose.animation.ExitTransition
55
import androidx.compose.animation.core.AnimationSpec
6+
import androidx.compose.animation.core.FastOutSlowInEasing
67
import androidx.compose.animation.core.SpringSpec
8+
import androidx.compose.animation.core.tween
79
import androidx.compose.animation.fadeOut
10+
import androidx.compose.animation.shrinkVertically
811
import androidx.compose.runtime.Immutable
912
import kotlin.time.Duration
1013
import kotlin.time.Duration.Companion.milliseconds
1114
import net.thunderbird.core.ui.compose.designsystem.molecule.swipe.SwipeBehaviour.Companion.DEFAULT_AUTO_RESET_DELAY_MILLIS
12-
import net.thunderbird.core.ui.compose.designsystem.molecule.swipe.SwipeBehaviour.Companion.DEFAULT_THRESHOLD
1315

1416
/**
1517
* Defines the behaviour of a swipeable component when a swipe gesture is
@@ -42,14 +44,12 @@ sealed interface SwipeBehaviour {
4244
val threshold: Float
4345

4446
/**
45-
* The animation specification used to animate the swipe transition between states.
47+
* The animation specification used when the swipe gesture ends and the row settles to its
48+
* final position (revealed, dismissed, or back to resting).
4649
*
47-
* This defines how the row animates when transitioning during swipe gestures, including
48-
* the duration, easing curve, and other animation parameters. The animation is applied
49-
* to the horizontal offset of the swipeable content as it moves between its settled
50-
* position and the fully swiped state.
50+
* For example, a bouncy spring for reveal settle and a fast tween for dismissing.
5151
*/
52-
val animationSpec: AnimationSpec<Float>
52+
val settleAnimationSpec: AnimationSpec<Float>
5353

5454
/**
5555
* Determines whether haptic feedback should be triggered during swipe interactions.
@@ -64,9 +64,6 @@ sealed interface SwipeBehaviour {
6464
* Defines a reveal swipe behaviour that allows content to be shown or hidden
6565
* through swipe gestures.
6666
*
67-
* @property threshold The fraction of the swipeable area that must be traversed
68-
* before the row settles into the revealed state. Must be between 0.25 and 1.0.
69-
* Defaults to [DEFAULT_THRESHOLD]
7067
* @property autoReset Whether the row should automatically return to its initial
7168
* position after being revealed after a while. Defaults to `false`
7269
* @property autoResetDelayMillis The duration to wait before automatically resetting
@@ -76,7 +73,7 @@ sealed interface SwipeBehaviour {
7673
data class Reveal(
7774
@get:FloatRange(from = 0.25, to = 1.0)
7875
override val threshold: Float = DEFAULT_THRESHOLD,
79-
override val animationSpec: AnimationSpec<Float> = DefaultAnimation,
76+
override val settleAnimationSpec: AnimationSpec<Float> = DefaultSettleAnimation,
8077
override val enableHapticFeedback: Boolean = true,
8178
val autoReset: Boolean = false,
8279
val autoResetDelayMillis: Duration = DEFAULT_AUTO_RESET_DELAY_MILLIS.milliseconds,
@@ -90,16 +87,20 @@ sealed interface SwipeBehaviour {
9087
* or final actions where the row should be removed from view after completing the
9188
* swipe gesture.
9289
*
93-
* @property threshold The fraction of the swipeable area that must be traversed
94-
* before the row settles into the revealed state. Must be between 0.25 and 1.0.
95-
* Defaults to [DEFAULT_THRESHOLD]
90+
* @property dismissTransition The exit transition that will be applied when the
91+
* item is dismissed.
9692
*/
9793
data class Dismiss(
9894
@get:FloatRange(from = 0.25, to = 1.0)
9995
override val threshold: Float = DEFAULT_THRESHOLD,
100-
override val animationSpec: AnimationSpec<Float> = DefaultAnimation,
96+
override val settleAnimationSpec: AnimationSpec<Float> = DefaultDismissAnimation,
10197
override val enableHapticFeedback: Boolean = true,
102-
val dismissTransition: ExitTransition = fadeOut(),
98+
val dismissTransition: ExitTransition = fadeOut(tween(durationMillis = 150)) + shrinkVertically(
99+
tween(
100+
durationMillis = 200,
101+
delayMillis = 50,
102+
),
103+
),
103104
) : SwipeBehaviour
104105

105106
/**
@@ -110,14 +111,50 @@ sealed interface SwipeBehaviour {
110111
*/
111112
data object Disabled : SwipeBehaviour {
112113
override val threshold: Float = 1f
113-
override val animationSpec: AnimationSpec<Float> = DefaultAnimation
114+
override val settleAnimationSpec: AnimationSpec<Float> = DefaultSettleAnimation
114115
override val enableHapticFeedback: Boolean = false
115116
}
116117

117118
companion object {
118-
const val DEFAULT_THRESHOLD = 0.5f
119-
const val DEFAULT_AUTO_RESET_DELAY_MILLIS = 500L
119+
internal const val DEFAULT_THRESHOLD = 0.5f
120+
internal const val DEFAULT_AUTO_RESET_DELAY_MILLIS = 500L
120121

121-
val DefaultAnimation = SpringSpec(visibilityThreshold = 0.01f)
122+
/**
123+
* The default animation specification used for settling swipe gestures back to
124+
* their resting position.
125+
*
126+
* This animation uses a spring-based motion with moderate damping and high stiffness
127+
* to create a responsive, natural-feeling return animation.
128+
* The spring characteristics are tuned to provide a quick yet smooth transition that
129+
* feels snappy without being jarring.
130+
*
131+
* This default can be overridden by providing a custom animation specification to the
132+
* swipe behaviour.
133+
*
134+
* @see SwipeBehaviour.settleAnimationSpec
135+
*/
136+
val DefaultSettleAnimation = SpringSpec(
137+
dampingRatio = 0.75f,
138+
stiffness = 600f,
139+
visibilityThreshold = 0.01f,
140+
)
141+
142+
/**
143+
* The default animation specification used for dismissing the [SwipeableRow] when
144+
* the swipe distance exceeds the [threshold], removing it from screen.
145+
*
146+
* This animation uses a tween interpolation with a duration of 250 milliseconds
147+
* and applies [FastOutSlowInEasing] for smooth, natural motion. The animation is
148+
* applied when a swipe gesture is released.
149+
*
150+
* This default can be overridden by providing a custom animation specification to the
151+
* [dismiss swipe behaviour][Dismiss].
152+
*
153+
* @see SwipeBehaviour.Dismiss.dismissTransition
154+
*/
155+
val DefaultDismissAnimation: AnimationSpec<Float> = tween(
156+
durationMillis = 250,
157+
easing = FastOutSlowInEasing,
158+
)
122159
}
123160
}

core/ui/compose/designsystem/src/main/kotlin/net/thunderbird/core/ui/compose/designsystem/molecule/swipe/SwipeableRow.kt

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import androidx.compose.animation.EnterTransition
55
import androidx.compose.animation.togetherWith
66
import androidx.compose.foundation.gestures.Orientation
77
import androidx.compose.foundation.layout.Box
8+
import androidx.compose.foundation.layout.BoxScope
89
import androidx.compose.foundation.layout.Row
910
import androidx.compose.foundation.layout.RowScope
1011
import androidx.compose.foundation.layout.absoluteOffset
@@ -26,6 +27,7 @@ import kotlin.math.roundToInt
2627
import kotlinx.coroutines.flow.collectLatest
2728
import kotlinx.coroutines.flow.distinctUntilChanged
2829
import kotlinx.coroutines.flow.drop
30+
import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId
2931
import net.thunderbird.core.ui.compose.designsystem.molecule.swipe.SwipeDirection.EndToStart
3032
import net.thunderbird.core.ui.compose.designsystem.molecule.swipe.SwipeDirection.StartToEnd
3133
import net.thunderbird.core.ui.compose.designsystem.molecule.swipe.SwipeDirectionAccessibilityAction.EndToStartAccessibilityAction
@@ -66,40 +68,41 @@ fun SwipeableRow(
6668
) {
6769
val hapticFeedback = LocalHapticFeedback.current
6870

71+
LaunchedEffect(state, onSwipeChange) {
72+
snapshotFlow { state.swipeDirection }
73+
.drop(1) // Skip the initial `Settled` emission
74+
.distinctUntilChanged()
75+
.collectLatest { onSwipeChange(it) }
76+
}
77+
78+
val accessibilityCustomActions = rememberAccessibilityActions(
79+
state = state,
80+
gesturesEnabled = gesturesEnabled,
81+
onSwipeEnd = { direction ->
82+
state.accessibilityState.swipeToDirection(direction)
83+
performHapticFeedback(state, hapticFeedback)
84+
onSwipeEnd(direction)
85+
},
86+
)
87+
6988
AnimatedContent(
70-
targetState = state.swipeState,
71-
modifier = modifier,
72-
transitionSpec = { EnterTransition.None togetherWith state.dismissTransition },
89+
targetState = state.swipeState == SwipeState.Dismissed,
90+
transitionSpec = { EnterTransition.None togetherWith state.activeExitTransition },
7391
label = "SwipeableRowContentAnimation",
74-
) { swipeState ->
75-
if (swipeState != SwipeState.Dismissed) {
76-
LaunchedEffect(state, onSwipeChange) {
77-
snapshotFlow { state.swipeDirection }
78-
.drop(1) // Skip the initial `Settled` emission
79-
.distinctUntilChanged()
80-
.collectLatest { onSwipeChange(it) }
81-
}
82-
83-
val accessibilityCustomActions = rememberAccessibilityActions(
84-
state = state,
85-
gesturesEnabled = gesturesEnabled,
86-
onSwipeEnd = { direction ->
87-
state.accessibilityState.swipeToDirection(direction)
88-
performHapticFeedback(state, hapticFeedback)
89-
onSwipeEnd(direction)
90-
},
91-
)
92+
modifier = modifier.testTagAsResourceId(SwipeableRowDefaults.SWIPEABLE_ROW_ANIMATED_CONTENT_TEST_TAG),
93+
) { isDismissed ->
94+
if (!isDismissed) {
9295
Box(
9396
modifier = Modifier
97+
.testTagAsResourceId(SwipeableRowDefaults.SWIPEABLE_ROW_CORE_ELEMENT_TEST_TAG)
9498
.semantics(mergeDescendants = true) { customActions = accessibilityCustomActions }
9599
.onSizeChanged { state.onContainerSizeChanged(it) },
96100
propagateMinConstraints = true,
97101
) {
98-
if (gesturesEnabled && state.swipeDirection != SwipeDirection.Settled) {
99-
Row(content = backgroundContent, modifier = Modifier.matchParentSize())
100-
}
102+
SwipeableRowBackground(state, gesturesEnabled, backgroundContent)
101103
Row(
102104
modifier = Modifier
105+
.testTagAsResourceId(SwipeableRowDefaults.SWIPEABLE_ROW_DRAGGABLE_ELEMENT_TEST_TAG)
103106
.draggable(
104107
state = state.draggableState,
105108
orientation = Orientation.Horizontal,
@@ -120,6 +123,22 @@ fun SwipeableRow(
120123
}
121124
}
122125

126+
@Composable
127+
private fun BoxScope.SwipeableRowBackground(
128+
state: SwipeableRowState,
129+
gesturesEnabled: Boolean,
130+
backgroundContent: @Composable RowScope.() -> Unit,
131+
) {
132+
if (gesturesEnabled && state.swipeDirection != SwipeDirection.Settled) {
133+
Row(
134+
content = backgroundContent,
135+
modifier = Modifier
136+
.matchParentSize()
137+
.testTagAsResourceId(SwipeableRowDefaults.SWIPEABLE_ROW_BACKGROUND_CONTENT_TEST_TAG),
138+
)
139+
}
140+
}
141+
123142
private fun performHapticFeedback(
124143
state: SwipeableRowState,
125144
hapticFeedback: HapticFeedback,
@@ -172,3 +191,10 @@ private fun rememberAccessibilityActions(
172191
}
173192
}
174193
}
194+
195+
object SwipeableRowDefaults {
196+
const val SWIPEABLE_ROW_ANIMATED_CONTENT_TEST_TAG = "SwipeableRow_AnimatedContent"
197+
const val SWIPEABLE_ROW_CORE_ELEMENT_TEST_TAG = "SwipeableRow_core_element"
198+
const val SWIPEABLE_ROW_BACKGROUND_CONTENT_TEST_TAG = "SwipeableRow_background_content"
199+
const val SWIPEABLE_ROW_DRAGGABLE_ELEMENT_TEST_TAG = "SwipeableRow_draggable_element"
200+
}

0 commit comments

Comments
 (0)