@momo-kits/native-kits 0.164.1-beta.15-debug → 0.164.1-beta.16-debug
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/compose/build.gradle.kts +1 -1
- package/compose/compose.podspec +1 -1
- package/compose/src/androidMain/kotlin/vn/momo/kits/platform/BackGesture.android.kt +12 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/NavigationContainer.kt +1 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/StackScreen.kt +6 -1
- package/compose/src/commonMain/kotlin/vn/momo/kits/navigation/SwipeBackHaptic.kt +39 -0
- package/compose/src/commonMain/kotlin/vn/momo/kits/platform/BackGesture.kt +15 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/navigation/HostBackGesture.ios.kt +125 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/BackGesture.ios.kt +35 -0
- package/compose/src/iosMain/kotlin/vn/momo/kits/platform/Platform.ios.kt +16 -3
- package/gradle.properties +1 -1
- package/ios/native-kits.podspec +1 -1
- package/ios-demo/MoMoUIKitsDemo.podspec +1 -1
- package/package.json +1 -1
package/compose/build.gradle.kts
CHANGED
package/compose/compose.podspec
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
package vn.momo.kits.platform
|
|
2
|
+
|
|
3
|
+
import androidx.compose.runtime.Composable
|
|
4
|
+
import androidx.compose.runtime.State
|
|
5
|
+
import androidx.compose.runtime.mutableStateOf
|
|
6
|
+
import androidx.compose.runtime.remember
|
|
7
|
+
|
|
8
|
+
// No passive observer exists at androidx.activity 1.10.x — a second enabled callback would steal
|
|
9
|
+
// the gesture from NavHost. Android's system back gesture emits its own platform haptic on commit.
|
|
10
|
+
// Revisit if androidx.activity >= 1.12 is adopted; this then becomes a copy of the iOS actual.
|
|
11
|
+
@Composable
|
|
12
|
+
actual fun rememberBackGestureProgress(): State<Float?> = remember { mutableStateOf(null) }
|
|
@@ -94,6 +94,7 @@ fun NavigationContainer(
|
|
|
94
94
|
setNavigator?.invoke(navigator)
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
SwipeBackHaptic(maxApi)
|
|
97
98
|
|
|
98
99
|
// Gesture pops never reach Navigator.dismissScreen(), so prune the registry from
|
|
99
100
|
// the back stack itself. collectLatest lets a rapid re-push cancel a pending prune.
|
|
@@ -179,8 +179,13 @@ internal fun StackScreen(
|
|
|
179
179
|
// onBackCompleted, so back still landed but nothing seeked. The root case it was guarding is
|
|
180
180
|
// already covered by `!canPop` below, and letting a tab screen through means the nested tab
|
|
181
181
|
// NavHost seeks its own transition back to the previous tab.
|
|
182
|
+
//
|
|
183
|
+
// `options.onBackHandler` was dropped for the same reason: it is set widely (tracking, custom
|
|
184
|
+
// close flows) and every one of those screens lost the seek. The trade is that NavHost pops
|
|
185
|
+
// them straight through `composeNavigator.popBackStack()`, so `onBackSafe` — and with it the
|
|
186
|
+
// handler — does not run on a gesture or hardware back. Screens that must intercept back
|
|
187
|
+
// should use `preventBack`, which is still a term below.
|
|
182
188
|
val hasCustomBackHandling = !SwipeBackConfig.enabled ||
|
|
183
|
-
options.onBackHandler != null ||
|
|
184
189
|
options.headerBackProps.preventBack != null ||
|
|
185
190
|
OverplayComponentRegistry.isOverplayShowing() ||
|
|
186
191
|
!canPop
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package vn.momo.kits.navigation
|
|
2
|
+
|
|
3
|
+
import androidx.compose.runtime.Composable
|
|
4
|
+
import androidx.compose.runtime.LaunchedEffect
|
|
5
|
+
import androidx.compose.runtime.snapshotFlow
|
|
6
|
+
import vn.momo.kits.platform.getScreenDimensions
|
|
7
|
+
import vn.momo.kits.platform.rememberBackGestureProgress
|
|
8
|
+
import vn.momo.maxapi.IMaxApi
|
|
9
|
+
|
|
10
|
+
// RN fires the tick at a flat 100 px of thumb travel; keep it unscaled so the physical distance is
|
|
11
|
+
// the same on every device.
|
|
12
|
+
private const val SWIPE_BACK_HAPTIC_THRESHOLD_DP = 50f
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fires one light tick once the back gesture has travelled ~100dp, re-arming when the finger drops
|
|
16
|
+
* back below the threshold. iOS only — [rememberBackGestureProgress] is always `null` on Android.
|
|
17
|
+
*/
|
|
18
|
+
@Composable
|
|
19
|
+
internal fun SwipeBackHaptic(maxApi: IMaxApi?) {
|
|
20
|
+
if (maxApi == null) return
|
|
21
|
+
|
|
22
|
+
val progress = rememberBackGestureProgress()
|
|
23
|
+
val screenWidth = getScreenDimensions().width.toFloat()
|
|
24
|
+
|
|
25
|
+
LaunchedEffect(maxApi, screenWidth) {
|
|
26
|
+
if (screenWidth <= 0f) return@LaunchedEffect
|
|
27
|
+
val threshold = SWIPE_BACK_HAPTIC_THRESHOLD_DP / screenWidth
|
|
28
|
+
var isSendHaptic = false
|
|
29
|
+
snapshotFlow { progress.value }.collect { fraction ->
|
|
30
|
+
val travelled = fraction ?: 0f
|
|
31
|
+
if (travelled >= threshold && !isSendHaptic) {
|
|
32
|
+
runCatching { maxApi.triggerEventVibration("light", callback = null) }
|
|
33
|
+
isSendHaptic = true
|
|
34
|
+
} else if (travelled < threshold && isSendHaptic) {
|
|
35
|
+
isSendHaptic = false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
package vn.momo.kits.platform
|
|
2
|
+
|
|
3
|
+
import androidx.compose.runtime.Composable
|
|
4
|
+
import androidx.compose.runtime.State
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Progress of an in-flight back gesture as a fraction of the container width, or `null` while no
|
|
8
|
+
* gesture is running. Observing only — it must never consume the gesture, otherwise it would steal
|
|
9
|
+
* it from NavHost.
|
|
10
|
+
*
|
|
11
|
+
* Android always returns `null`: its NavHost runs on `androidx.activity`'s OnBackPressedDispatcher,
|
|
12
|
+
* which exposes progress only to the top-most enabled callback, and that has to stay NavHost's.
|
|
13
|
+
*/
|
|
14
|
+
@Composable
|
|
15
|
+
expect fun rememberBackGestureProgress(): State<Float?>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
package vn.momo.kits.navigation
|
|
2
|
+
|
|
3
|
+
import androidx.compose.runtime.Composable
|
|
4
|
+
import androidx.compose.runtime.DisposableEffect
|
|
5
|
+
import androidx.compose.runtime.remember
|
|
6
|
+
import androidx.navigationevent.NavigationEvent
|
|
7
|
+
import androidx.navigationevent.NavigationEventDispatcher
|
|
8
|
+
import androidx.navigationevent.NavigationEventInput
|
|
9
|
+
import kotlinx.cinterop.ExperimentalForeignApi
|
|
10
|
+
import platform.Foundation.NSThread
|
|
11
|
+
import platform.darwin.dispatch_async
|
|
12
|
+
import platform.darwin.dispatch_get_main_queue
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Kit end of the host's edge-pan recogniser.
|
|
16
|
+
*
|
|
17
|
+
* iOS keeps exactly one back-gesture recogniser and it belongs to the host app — the kit never
|
|
18
|
+
* installs its own, so the `.failed -> completed` conflict between two competing recognisers is
|
|
19
|
+
* unreachable by construction. The host replays its drag here; the events are fed into the
|
|
20
|
+
* `NavigationEventDispatcher` that [NavigationContainer] provides, which drives NavHost's
|
|
21
|
+
* `PredictiveBackHandler` and therefore `SeekableTransitionState.seekTo`. Parallax, scrim and edge
|
|
22
|
+
* shadow are children of that same transition, so they follow the finger for free.
|
|
23
|
+
*
|
|
24
|
+
* Only primitives cross the Swift boundary: `androidx.navigationevent` is a private transitive
|
|
25
|
+
* dependency and is absent from the generated framework header.
|
|
26
|
+
*
|
|
27
|
+
* Call every method from the main thread; anything else is re-posted to the main queue.
|
|
28
|
+
*/
|
|
29
|
+
object HostBackGestureBridge {
|
|
30
|
+
private val attachedInputs = mutableListOf<HostNavigationEventInput>()
|
|
31
|
+
private var gestureInput: HostNavigationEventInput? = null
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether the top-most mounted container has an enabled back handler, i.e. the authoritative
|
|
35
|
+
* answer to "will Compose consume this back?". `false` means no container is mounted, or the
|
|
36
|
+
* mounted one deliberately opts out (kill switch, overlay showing, custom back handler), and
|
|
37
|
+
* the host should fall back to its own navigation.
|
|
38
|
+
*/
|
|
39
|
+
val hasEnabledHandlers: Boolean
|
|
40
|
+
get() = attachedInputs.lastOrNull()?.hasEnabledHandlers == true
|
|
41
|
+
|
|
42
|
+
/** Begins a drag. Pairs with exactly one [completed] or [cancelled]. */
|
|
43
|
+
fun started() = onMain {
|
|
44
|
+
val input = attachedInputs.lastOrNull() ?: return@onMain
|
|
45
|
+
gestureInput = input
|
|
46
|
+
input.backStarted()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Drag progress in `0f..1f` — the fraction of the screen width travelled. */
|
|
50
|
+
fun progressed(progress: Float) = onMain {
|
|
51
|
+
gestureInput?.backProgressed(progress.coerceIn(0f, 1f))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Released past the commit threshold: the screen pops.
|
|
56
|
+
*
|
|
57
|
+
* Falls back to the attached input when no drag was started, so a host that commits without
|
|
58
|
+
* ever seeking still gets a back. Dispatching a bare completion is the documented
|
|
59
|
+
* non-predictive path — the same shape as a back button press — and dropping it here is what
|
|
60
|
+
* silently stranded the mini-app root, where the host never seeks.
|
|
61
|
+
*/
|
|
62
|
+
fun completed() = onMain {
|
|
63
|
+
(gestureInput ?: attachedInputs.lastOrNull())?.backCompleted()
|
|
64
|
+
gestureInput = null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Released below the commit threshold: the screen springs back. */
|
|
68
|
+
fun cancelled() = onMain {
|
|
69
|
+
gestureInput?.backCancelled()
|
|
70
|
+
gestureInput = null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
internal fun attach(input: HostNavigationEventInput) {
|
|
74
|
+
attachedInputs += input
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
internal fun detach(input: HostNavigationEventInput) {
|
|
78
|
+
attachedInputs -= input
|
|
79
|
+
// A container unmounted mid-drag must not hand its pending terminal event to whichever
|
|
80
|
+
// container is on top now: NavigationEventInput fabricates a non-predictive start for a
|
|
81
|
+
// completed/cancelled it never saw begin, which would pop a screen out of nowhere.
|
|
82
|
+
if (gestureInput === input) gestureInput = null
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Wires one [NavigationContainer]'s dispatcher to [HostBackGestureBridge] while it is composed. */
|
|
87
|
+
@Composable
|
|
88
|
+
internal fun AttachHostBackGesture(dispatcher: NavigationEventDispatcher) {
|
|
89
|
+
val input = remember(dispatcher) { HostNavigationEventInput() }
|
|
90
|
+
|
|
91
|
+
DisposableEffect(dispatcher, input) {
|
|
92
|
+
dispatcher.addInput(input)
|
|
93
|
+
HostBackGestureBridge.attach(input)
|
|
94
|
+
onDispose {
|
|
95
|
+
HostBackGestureBridge.detach(input)
|
|
96
|
+
dispatcher.removeInput(input)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
internal class HostNavigationEventInput : NavigationEventInput() {
|
|
102
|
+
var hasEnabledHandlers: Boolean = false
|
|
103
|
+
private set
|
|
104
|
+
|
|
105
|
+
override fun onHasEnabledHandlersChanged(hasEnabledHandlers: Boolean) {
|
|
106
|
+
this.hasEnabledHandlers = hasEnabledHandlers
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Touch coordinates are not forwarded — the stack transition is seeked by progress alone, and
|
|
110
|
+
// leaving them out keeps the bridge's Swift-facing signatures primitive.
|
|
111
|
+
fun backStarted() = dispatchOnBackStarted(NavigationEvent(swipeEdge = NavigationEvent.EDGE_LEFT))
|
|
112
|
+
|
|
113
|
+
fun backProgressed(progress: Float) = dispatchOnBackProgressed(
|
|
114
|
+
NavigationEvent(swipeEdge = NavigationEvent.EDGE_LEFT, progress = progress)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
fun backCancelled() = dispatchOnBackCancelled()
|
|
118
|
+
|
|
119
|
+
fun backCompleted() = dispatchOnBackCompleted()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@OptIn(ExperimentalForeignApi::class)
|
|
123
|
+
private fun onMain(block: () -> Unit) {
|
|
124
|
+
if (NSThread.isMainThread()) block() else dispatch_async(dispatch_get_main_queue()) { block() }
|
|
125
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
package vn.momo.kits.platform
|
|
2
|
+
|
|
3
|
+
import androidx.compose.runtime.Composable
|
|
4
|
+
import androidx.compose.runtime.LaunchedEffect
|
|
5
|
+
import androidx.compose.runtime.State
|
|
6
|
+
import androidx.compose.runtime.mutableStateOf
|
|
7
|
+
import androidx.compose.runtime.remember
|
|
8
|
+
import androidx.compose.ui.InternalComposeUiApi
|
|
9
|
+
import androidx.compose.ui.backhandler.LocalCompatNavigationEventDispatcherOwner
|
|
10
|
+
import androidx.navigationevent.NavigationEventTransitionState
|
|
11
|
+
|
|
12
|
+
// NavigationEventDispatcher.transitionState is a passive observer: UIKitNavigationEventInput
|
|
13
|
+
// publishes every edge-pan event to it without any handler consuming the gesture. Its `progress`
|
|
14
|
+
// is touch.x / view width, i.e. a plain fraction of the screen width.
|
|
15
|
+
@OptIn(InternalComposeUiApi::class)
|
|
16
|
+
@Composable
|
|
17
|
+
actual fun rememberBackGestureProgress(): State<Float?> {
|
|
18
|
+
val dispatcher = LocalCompatNavigationEventDispatcherOwner.current?.navigationEventDispatcher
|
|
19
|
+
val progress = remember { mutableStateOf<Float?>(null) }
|
|
20
|
+
|
|
21
|
+
LaunchedEffect(dispatcher) {
|
|
22
|
+
if (dispatcher == null) {
|
|
23
|
+
progress.value = null
|
|
24
|
+
return@LaunchedEffect
|
|
25
|
+
}
|
|
26
|
+
dispatcher.transitionState.collect { state ->
|
|
27
|
+
progress.value = (state as? NavigationEventTransitionState.InProgress)
|
|
28
|
+
?.takeIf { it.direction == NavigationEventTransitionState.TRANSITIONING_BACK }
|
|
29
|
+
?.latestEvent
|
|
30
|
+
?.progress
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return progress
|
|
35
|
+
}
|
|
@@ -17,6 +17,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi
|
|
|
17
17
|
import androidx.compose.ui.InternalComposeUiApi
|
|
18
18
|
import androidx.compose.ui.Modifier
|
|
19
19
|
import androidx.compose.ui.backhandler.LocalCompatNavigationEventDispatcherOwner
|
|
20
|
+
import androidx.compose.ui.backhandler.BackHandler as ComposeBackHandler
|
|
20
21
|
import androidx.compose.ui.graphics.Color
|
|
21
22
|
import androidx.compose.ui.graphics.NativePaint
|
|
22
23
|
import androidx.compose.ui.graphics.toArgb
|
|
@@ -38,6 +39,7 @@ import platform.Foundation.NSBundle
|
|
|
38
39
|
import platform.UIKit.UIColor
|
|
39
40
|
import platform.UIKit.UIDevice
|
|
40
41
|
import platform.UIKit.UIScreen
|
|
42
|
+
import vn.momo.kits.navigation.AttachHostBackGesture
|
|
41
43
|
|
|
42
44
|
actual fun getPlatformName(): String = "iOS"
|
|
43
45
|
|
|
@@ -60,21 +62,32 @@ actual fun getStatusBarHeight(): Dp {
|
|
|
60
62
|
return WindowInsets.systemBars.asPaddingValues().calculateTopPadding()
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
// An enabled kit BackHandler is registered inside the NavHost destination, so it out-ranks
|
|
66
|
+
// NavHost's own predictive-back handler and swallows the gesture — that is how a screen opts out
|
|
67
|
+
// of swipe-back (the port of RN's `hasCustomBackHandling`).
|
|
68
|
+
@Suppress("DEPRECATION")
|
|
69
|
+
@OptIn(ExperimentalComposeUiApi::class)
|
|
63
70
|
@Composable
|
|
64
71
|
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
|
|
72
|
+
ComposeBackHandler(enabled = enabled, onBack = onBack)
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
@OptIn(InternalComposeUiApi::class)
|
|
68
76
|
@Composable
|
|
69
77
|
actual fun ProvideNavigationEventDispatcherOwner(content: @Composable () -> Unit) {
|
|
78
|
+
// Detached on purpose: passing the scene's owner through would arm Compose's own
|
|
79
|
+
// UIKitBackGestureRecognizer alongside the host's, and UIKit settles that conflict by driving
|
|
80
|
+
// one to .failed, which UIKitNavigationEventInput turns into a fabricated completed back.
|
|
81
|
+
// Enabled on purpose: every NavigationEventDispatcher.dispatchOn* early-returns while
|
|
82
|
+
// isEnabled is false, which would mute the host-driven seek as well.
|
|
70
83
|
val navigationEventDispatcherOwner = remember {
|
|
71
84
|
object : NavigationEventDispatcherOwner {
|
|
72
|
-
override val navigationEventDispatcher = NavigationEventDispatcher()
|
|
73
|
-
isEnabled = false
|
|
74
|
-
}
|
|
85
|
+
override val navigationEventDispatcher = NavigationEventDispatcher()
|
|
75
86
|
}
|
|
76
87
|
}
|
|
77
88
|
|
|
89
|
+
AttachHostBackGesture(navigationEventDispatcherOwner.navigationEventDispatcher)
|
|
90
|
+
|
|
78
91
|
CompositionLocalProvider(
|
|
79
92
|
LocalCompatNavigationEventDispatcherOwner provides navigationEventDispatcherOwner,
|
|
80
93
|
content = content
|
package/gradle.properties
CHANGED
package/ios/native-kits.podspec
CHANGED