Skip to content

Commit f60d428

Browse files
committed
library: Add predictive back gesture support
* Only for Android * SuperDialog / SuperBottomSheet
1 parent 334abf2 commit f60d428

File tree

11 files changed

+277
-13
lines changed

11 files changed

+277
-13
lines changed

example/src/commonMain/kotlin/component/TextComponent.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import androidx.compose.foundation.layout.padding
1717
import androidx.compose.foundation.layout.width
1818
import androidx.compose.runtime.Composable
1919
import androidx.compose.runtime.MutableState
20+
import androidx.compose.runtime.getValue
21+
import androidx.compose.runtime.mutableStateOf
2022
import androidx.compose.runtime.remember
23+
import androidx.compose.runtime.setValue
2124
import androidx.compose.ui.Alignment
2225
import androidx.compose.ui.Modifier
2326
import androidx.compose.ui.geometry.CornerRadius
@@ -32,8 +35,10 @@ import top.yukonga.miuix.kmp.basic.ButtonDefaults
3235
import top.yukonga.miuix.kmp.basic.Card
3336
import top.yukonga.miuix.kmp.basic.CardDefaults
3437
import top.yukonga.miuix.kmp.basic.Checkbox
38+
import top.yukonga.miuix.kmp.basic.ColorPalette
3539
import top.yukonga.miuix.kmp.basic.Icon
3640
import top.yukonga.miuix.kmp.basic.IconButton
41+
import top.yukonga.miuix.kmp.basic.Slider
3742
import top.yukonga.miuix.kmp.basic.SmallTitle
3843
import top.yukonga.miuix.kmp.basic.Switch
3944
import top.yukonga.miuix.kmp.basic.Text
@@ -499,6 +504,13 @@ fun BottomSheet(
499504
}
500505
}
501506
) {
507+
var progress by remember { mutableStateOf(0.5f) }
508+
Slider(
509+
progress = progress,
510+
onProgressChange = { newProgress -> progress = newProgress },
511+
decimalPlaces = 3,
512+
modifier = Modifier.padding(bottom = 12.dp)
513+
)
502514
Card(
503515
modifier = Modifier.padding(bottom = 12.dp),
504516
colors = CardDefaults.defaultColors(
@@ -519,6 +531,13 @@ fun BottomSheet(
519531
}
520532
)
521533
}
534+
val miuixColor = MiuixTheme.colorScheme.primary
535+
var selectedColor by remember { mutableStateOf(miuixColor) }
536+
ColorPalette(
537+
initialColor = selectedColor,
538+
onColorChanged = { selectedColor = it },
539+
showPreview = false
540+
)
522541
}
523542
}
524543

iosApp/iosApp/Info.plist

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
<key>CFBundleShortVersionString</key>
1818
<string>1.0.4</string>
1919
<key>CFBundleVersion</key>
20-
<string>561</string>
20+
<string>562</string>
2121
<key>LSRequiresIPhoneOS</key>
2222
<true/>
2323
<key>CADisableMinimumFrameDurationOnPhone</key>

miuix/src/androidMain/kotlin/top/yukonga/miuix/kmp/utils/Utils.android.kt

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ import android.annotation.SuppressLint
77
import android.content.Context
88
import android.os.Build
99
import android.view.RoundedCorner
10+
import androidx.activity.BackEventCompat as AndroidBackEventCompat
11+
import androidx.activity.OnBackPressedCallback
12+
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
1013
import androidx.compose.runtime.Composable
14+
import androidx.compose.runtime.DisposableEffect
1115
import androidx.compose.runtime.derivedStateOf
1216
import androidx.compose.runtime.getValue
1317
import androidx.compose.runtime.remember
18+
import androidx.compose.runtime.rememberUpdatedState
1419
import androidx.compose.ui.ExperimentalComposeUiApi
1520
import androidx.compose.ui.platform.LocalConfiguration
1621
import androidx.compose.ui.platform.LocalContext
@@ -84,4 +89,66 @@ actual fun BackHandler(
8489
onBack: () -> Unit
8590
) {
8691
androidx.compose.ui.backhandler.BackHandler(enabled = enabled, onBack = onBack)
92+
}
93+
94+
@Composable
95+
actual fun PredictiveBackHandler(
96+
enabled: Boolean,
97+
onBackStarted: ((BackEventCompat) -> Unit)?,
98+
onBackProgressed: ((BackEventCompat) -> Unit)?,
99+
onBackCancelled: (() -> Unit)?,
100+
onBack: () -> Unit
101+
) {
102+
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
103+
104+
val currentOnBackStarted by rememberUpdatedState(onBackStarted)
105+
val currentOnBackProgressed by rememberUpdatedState(onBackProgressed)
106+
val currentOnBackCancelled by rememberUpdatedState(onBackCancelled)
107+
val currentOnBack by rememberUpdatedState(onBack)
108+
val currentEnabled by rememberUpdatedState(enabled)
109+
110+
DisposableEffect(backDispatcher) {
111+
val callback = object : OnBackPressedCallback(currentEnabled) {
112+
override fun handleOnBackStarted(backEvent: AndroidBackEventCompat) {
113+
currentOnBackStarted?.invoke(
114+
BackEventCompat(
115+
progress = backEvent.progress,
116+
swipeEdge = backEvent.swipeEdge,
117+
touchX = backEvent.touchX,
118+
touchY = backEvent.touchY
119+
)
120+
)
121+
}
122+
123+
override fun handleOnBackProgressed(backEvent: AndroidBackEventCompat) {
124+
currentOnBackProgressed?.invoke(
125+
BackEventCompat(
126+
progress = backEvent.progress,
127+
swipeEdge = backEvent.swipeEdge,
128+
touchX = backEvent.touchX,
129+
touchY = backEvent.touchY
130+
)
131+
)
132+
}
133+
134+
override fun handleOnBackCancelled() {
135+
currentOnBackCancelled?.invoke()
136+
}
137+
138+
override fun handleOnBackPressed() {
139+
currentOnBack()
140+
}
141+
}
142+
143+
backDispatcher?.addCallback(callback)
144+
145+
onDispose {
146+
callback.remove()
147+
}
148+
}
149+
150+
// Update enabled state when it changes
151+
DisposableEffect(enabled) {
152+
onDispose { }
153+
}
87154
}

miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/extra/SuperBottomSheet.kt

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ import androidx.compose.ui.unit.dp
4545
import kotlinx.coroutines.launch
4646
import top.yukonga.miuix.kmp.basic.Text
4747
import top.yukonga.miuix.kmp.theme.MiuixTheme
48-
import top.yukonga.miuix.kmp.utils.BackHandler
4948
import top.yukonga.miuix.kmp.utils.G2RoundedCornerShape
5049
import top.yukonga.miuix.kmp.utils.MiuixPopupUtils.Companion.DialogLayout
50+
import top.yukonga.miuix.kmp.utils.PredictiveBackHandler
5151
import top.yukonga.miuix.kmp.utils.getWindowSize
5252

5353
/**
@@ -90,6 +90,9 @@ fun SuperBottomSheet(
9090

9191
val dimAlpha = remember { mutableFloatStateOf(1f) }
9292
val currentOnDismissRequest by rememberUpdatedState(onDismissRequest)
93+
val sheetHeightPx = remember { mutableIntStateOf(0) }
94+
val dragOffsetY = remember { Animatable(0f) }
95+
val coroutineScope = rememberCoroutineScope()
9396

9497
DialogLayout(
9598
visible = show,
@@ -109,14 +112,41 @@ fun SuperBottomSheet(
109112
defaultWindowInsetsPadding = defaultWindowInsetsPadding,
110113
dragHandleColor = dragHandleColor,
111114
dimAlpha = dimAlpha,
115+
sheetHeightPx = sheetHeightPx,
116+
dragOffsetY = dragOffsetY,
112117
onDismissRequest = currentOnDismissRequest,
113118
content = content
114119
)
115120
}
116121

117-
BackHandler(enabled = show.value) {
118-
currentOnDismissRequest?.invoke()
119-
}
122+
PredictiveBackHandler(
123+
enabled = show.value,
124+
onBackProgressed = { event ->
125+
coroutineScope.launch {
126+
// Calculate offset based on back progress
127+
val maxOffset = if (sheetHeightPx.value > 0) {
128+
sheetHeightPx.value.toFloat()
129+
} else {
130+
500f
131+
}
132+
val offset = event.progress * maxOffset
133+
dragOffsetY.snapTo(offset)
134+
135+
// Update dim alpha
136+
dimAlpha.value = 1f - event.progress
137+
}
138+
},
139+
onBackCancelled = {
140+
coroutineScope.launch {
141+
// Reset to original position
142+
dragOffsetY.animateTo(0f, animationSpec = tween(durationMillis = 150))
143+
dimAlpha.value = 1f
144+
}
145+
},
146+
onBack = {
147+
currentOnDismissRequest?.invoke()
148+
}
149+
)
120150
}
121151

122152
@Composable
@@ -132,6 +162,8 @@ private fun SuperBottomSheetContent(
132162
defaultWindowInsetsPadding: Boolean,
133163
dragHandleColor: Color,
134164
dimAlpha: MutableState<Float>,
165+
sheetHeightPx: MutableState<Int>,
166+
dragOffsetY: Animatable<Float, *>,
135167
onDismissRequest: (() -> Unit)?,
136168
content: @Composable () -> Unit
137169
) {
@@ -145,9 +177,6 @@ private fun SuperBottomSheetContent(
145177
WindowInsets.statusBars.getTop(density).toDp()
146178
}
147179

148-
val sheetHeightPx = remember { mutableIntStateOf(0) }
149-
val dragOffsetY = remember { Animatable(0f) }
150-
151180
val rootBoxModifier = remember(onDismissRequest) {
152181
Modifier
153182
.pointerInput(onDismissRequest) {

miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/extra/SuperDialog.kt

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,30 @@ import androidx.compose.runtime.Composable
1717
import androidx.compose.runtime.MutableState
1818
import androidx.compose.runtime.derivedStateOf
1919
import androidx.compose.runtime.getValue
20+
import androidx.compose.runtime.mutableFloatStateOf
21+
import androidx.compose.runtime.mutableIntStateOf
2022
import androidx.compose.runtime.remember
23+
import androidx.compose.runtime.rememberCoroutineScope
2124
import androidx.compose.runtime.rememberUpdatedState
25+
import androidx.compose.runtime.setValue
2226
import androidx.compose.ui.Alignment
2327
import androidx.compose.ui.Modifier
2428
import androidx.compose.ui.draw.clip
2529
import androidx.compose.ui.graphics.Color
30+
import androidx.compose.ui.graphics.graphicsLayer
2631
import androidx.compose.ui.input.pointer.pointerInput
32+
import androidx.compose.ui.layout.onGloballyPositioned
2733
import androidx.compose.ui.platform.LocalDensity
2834
import androidx.compose.ui.text.font.FontWeight
2935
import androidx.compose.ui.text.style.TextAlign
3036
import androidx.compose.ui.unit.DpSize
3137
import androidx.compose.ui.unit.dp
38+
import kotlinx.coroutines.launch
3239
import top.yukonga.miuix.kmp.basic.Text
3340
import top.yukonga.miuix.kmp.theme.MiuixTheme
34-
import top.yukonga.miuix.kmp.utils.BackHandler
3541
import top.yukonga.miuix.kmp.utils.G2RoundedCornerShape
3642
import top.yukonga.miuix.kmp.utils.MiuixPopupUtils.Companion.DialogLayout
43+
import top.yukonga.miuix.kmp.utils.PredictiveBackHandler
3744
import top.yukonga.miuix.kmp.utils.getRoundedCorner
3845
import top.yukonga.miuix.kmp.utils.getWindowSize
3946

@@ -71,9 +78,16 @@ fun SuperDialog(
7178
) {
7279
if (!show.value) return
7380

81+
val dimAlpha = remember { mutableFloatStateOf(1f) }
82+
val dialogHeightPx = remember { mutableIntStateOf(0) }
83+
var backProgress by remember { mutableFloatStateOf(0f) }
84+
val currentOnDismissRequest by rememberUpdatedState(onDismissRequest)
85+
val coroutineScope = rememberCoroutineScope()
86+
7487
DialogLayout(
7588
visible = show,
7689
enableWindowDim = enableWindowDim,
90+
dimAlpha = dimAlpha
7791
) {
7892
SuperDialogContent(
7993
modifier = modifier,
@@ -85,14 +99,31 @@ fun SuperDialog(
8599
outsideMargin = outsideMargin,
86100
insideMargin = insideMargin,
87101
defaultWindowInsetsPadding = defaultWindowInsetsPadding,
88-
onDismissRequest = onDismissRequest,
102+
backProgress = backProgress,
103+
dialogHeightPx = dialogHeightPx,
104+
onDismissRequest = currentOnDismissRequest,
89105
content = content
90106
)
91107
}
92108

93-
BackHandler(enabled = show.value) {
94-
onDismissRequest?.invoke()
95-
}
109+
PredictiveBackHandler(
110+
enabled = show.value,
111+
onBackProgressed = { event ->
112+
coroutineScope.launch {
113+
backProgress = event.progress
114+
dimAlpha.floatValue = 1f - event.progress
115+
}
116+
},
117+
onBackCancelled = {
118+
coroutineScope.launch {
119+
backProgress = 0f
120+
dimAlpha.floatValue = 1f
121+
}
122+
},
123+
onBack = {
124+
currentOnDismissRequest?.invoke()
125+
}
126+
)
96127
}
97128

98129
@Composable
@@ -106,6 +137,8 @@ private fun SuperDialogContent(
106137
outsideMargin: DpSize,
107138
insideMargin: DpSize,
108139
defaultWindowInsetsPadding: Boolean,
140+
backProgress: Float,
141+
dialogHeightPx: MutableState<Int>,
109142
onDismissRequest: (() -> Unit)?,
110143
content: @Composable () -> Unit
111144
) {
@@ -136,6 +169,8 @@ private fun SuperDialogContent(
136169
}
137170
}
138171

172+
val isLargeScreen = windowHeight >= 480.dp && windowWidth >= 840.dp
173+
139174
val rootBoxModifier = Modifier
140175
.then(
141176
if (defaultWindowInsetsPadding)
@@ -154,6 +189,26 @@ private fun SuperDialogContent(
154189

155190
val columnModifier = modifier
156191
.widthIn(max = 420.dp)
192+
.onGloballyPositioned { coordinates ->
193+
dialogHeightPx.value = coordinates.size.height
194+
}
195+
.then(
196+
// Apply predictive back animation
197+
if (isLargeScreen) {
198+
// Large screen: scale and fade out
199+
Modifier.graphicsLayer {
200+
val scale = 1f - (backProgress * 0.2f)
201+
scaleX = scale
202+
scaleY = scale
203+
alpha = 1f - (backProgress * 0.5f)
204+
}
205+
} else {
206+
// Small screen: slide down
207+
Modifier.graphicsLayer {
208+
translationY = backProgress * 800f
209+
}
210+
}
211+
)
157212
.pointerInput(Unit) {
158213
detectTapGestures { /* Consume click to prevent dismissal */ }
159214
}

miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/utils/Utils.kt

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,38 @@ expect fun getRoundedCorner(): Dp
4747
expect fun BackHandler(
4848
enabled: Boolean,
4949
onBack: () -> Unit
50+
)
51+
52+
/**
53+
* Data class representing the progress of a predictive back gesture.
54+
*
55+
* @param progress The progress of the back gesture, ranging from 0.0 (just started) to 1.0 (completed).
56+
* @param swipeEdge The edge from which the swipe originated.
57+
* @param touchX The horizontal position of the touch event.
58+
* @param touchY The vertical position of the touch event.
59+
*/
60+
data class BackEventCompat(
61+
val progress: Float,
62+
val swipeEdge: Int = 0,
63+
val touchX: Float = 0f,
64+
val touchY: Float = 0f
65+
)
66+
67+
/**
68+
* Handles the predictive back gesture with callbacks for different stages.
69+
* On non-Android platforms, this falls back to simple BackHandler behavior.
70+
*
71+
* @param enabled Whether the back handler is enabled.
72+
* @param onBackStarted Called when the back gesture starts. Receives initial BackEventCompat.
73+
* @param onBackProgressed Called when the back gesture progresses. Receives updated BackEventCompat.
74+
* @param onBackCancelled Called when the back gesture is cancelled (user didn't complete the swipe).
75+
* @param onBack Called when the back gesture is completed or when back button is pressed.
76+
*/
77+
@Composable
78+
expect fun PredictiveBackHandler(
79+
enabled: Boolean = true,
80+
onBackStarted: ((BackEventCompat) -> Unit)? = null,
81+
onBackProgressed: ((BackEventCompat) -> Unit)? = null,
82+
onBackCancelled: (() -> Unit)? = null,
83+
onBack: () -> Unit
5084
)

0 commit comments

Comments
 (0)