@momo-kits/native-kits 0.164.1-beta.8-debug → 0.164.1-beta.9-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.
@@ -40,7 +40,7 @@ kotlin {
40
40
  }
41
41
 
42
42
  cocoapods {
43
- version = "0.164.1-beta.8-debug"
43
+ version = "0.164.1-beta.9-debug"
44
44
  summary = "IOS Shared module"
45
45
  homepage = "https://momo.vn"
46
46
  ios.deploymentTarget = "15.0"
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |spec|
2
2
  spec.name = 'compose'
3
- spec.version = '0.164.1-beta.8-debug'
3
+ spec.version = '0.164.1-beta.9-debug'
4
4
  spec.homepage = 'https://momo.vn'
5
5
  spec.source = { :http=> ''}
6
6
  spec.authors = ''
@@ -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,8 @@ fun NavigationContainer(
94
94
  setNavigator?.invoke(navigator)
95
95
  }
96
96
 
97
+ SwipeBackHaptic(maxApi)
98
+
97
99
  // Gesture pops never reach Navigator.dismissScreen(), so prune the registry from
98
100
  // the back stack itself. collectLatest lets a rapid re-push cancel a pending prune.
99
101
  LaunchedEffect(navController, registry) {
@@ -46,9 +46,9 @@ private val EDGE_SHADOW_WIDTH = 8.dp
46
46
 
47
47
  /**
48
48
  * Kill switch for the swipe-back gesture. Set to `false` to make kit back handling always win,
49
- * which disables the predictive-back drag on both platforms — on iOS that means the host-driven
50
- * seek stops following the finger, though the host's own gesture still fires a plain back. Screen
51
- * transitions stay on the parallax curve either way.
49
+ * which disables the predictive-back drag on both platforms — the gesture then pops without
50
+ * following the finger, and fires no haptic. Screen transitions stay on the parallax curve either
51
+ * way.
52
52
  */
53
53
  object SwipeBackConfig {
54
54
  // Snapshot-backed so a host toggle takes effect on the next frame instead of waiting for an
@@ -0,0 +1,41 @@
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 = 100f
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
+ // With the kill switch off the kit's own BackHandler swallows the gesture, so the card never
21
+ // moves — a tick there would be a phantom.
22
+ if (maxApi == null || !SwipeBackConfig.enabled) return
23
+
24
+ val progress = rememberBackGestureProgress()
25
+ val screenWidth = getScreenDimensions().width.toFloat()
26
+
27
+ LaunchedEffect(maxApi, screenWidth) {
28
+ if (screenWidth <= 0f) return@LaunchedEffect
29
+ val threshold = SWIPE_BACK_HAPTIC_THRESHOLD_DP / screenWidth
30
+ var isSendHaptic = false
31
+ snapshotFlow { progress.value }.collect { fraction ->
32
+ val travelled = fraction ?: 0f
33
+ if (travelled >= threshold && !isSendHaptic) {
34
+ runCatching { maxApi.triggerEventVibration("light", callback = null) }
35
+ isSendHaptic = true
36
+ } else if (travelled < threshold && isSendHaptic) {
37
+ isSendHaptic = false
38
+ }
39
+ }
40
+ }
41
+ }
@@ -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,65 @@
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.NavigationEventDispatcher
7
+ import androidx.navigationevent.NavigationEventInput
8
+
9
+ /**
10
+ * Read-only answer to "will Compose consume this back?", for the iOS host.
11
+ *
12
+ * Compose owns the edge-pan gesture through its own `UIKitBackGestureRecognizer`; the host reads
13
+ * this to decide whether to stand down or keep driving its own recogniser, instead of guessing.
14
+ * Nothing here dispatches — [ObserveBackGestureOwnership] only listens.
15
+ *
16
+ * Pair it with [Navigator.canPop]: the mini-app root reports an enabled handler too (it swallows
17
+ * back to reach `maxApi.dismiss`), yet there the host still owns the gesture.
18
+ */
19
+ object ComposeBackGesture {
20
+ private val observers = mutableListOf<BackHandlerObserver>()
21
+
22
+ /** State of the top-most mounted container; `false` when none is mounted. */
23
+ val hasEnabledHandlers: Boolean
24
+ get() = observers.lastOrNull()?.hasEnabledHandlers == true
25
+
26
+ internal fun attach(observer: BackHandlerObserver) {
27
+ observers += observer
28
+ }
29
+
30
+ internal fun detach(observer: BackHandlerObserver) {
31
+ observers -= observer
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Publishes one [NavigationContainer]'s back-handling state to [ComposeBackGesture] while composed.
37
+ *
38
+ * Only called for a container whose dispatcher is the scene's — a bare-`UIView` host gets the
39
+ * detached fallback dispatcher, which can never receive an edge pan, so reporting its handlers
40
+ * would tell the host to stand down in favour of a gesture that does not exist.
41
+ */
42
+ @Composable
43
+ internal fun ObserveBackGestureOwnership(dispatcher: NavigationEventDispatcher) {
44
+ val observer = remember(dispatcher) { BackHandlerObserver() }
45
+
46
+ DisposableEffect(dispatcher, observer) {
47
+ dispatcher.addInput(observer)
48
+ ComposeBackGesture.attach(observer)
49
+ onDispose {
50
+ ComposeBackGesture.detach(observer)
51
+ dispatcher.removeInput(observer)
52
+ }
53
+ }
54
+ }
55
+
56
+ // A NavigationEventInput that never dispatches: adding an input is the only way to observe the
57
+ // dispatcher's enabled-handler state without registering a handler, which would consume the gesture.
58
+ internal class BackHandlerObserver : NavigationEventInput() {
59
+ var hasEnabledHandlers: Boolean = false
60
+ private set
61
+
62
+ override fun onHasEnabledHandlersChanged(hasEnabledHandlers: Boolean) {
63
+ this.hasEnabledHandlers = hasEnabledHandlers
64
+ }
65
+ }
@@ -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
+ }
@@ -39,7 +39,7 @@ import platform.Foundation.NSBundle
39
39
  import platform.UIKit.UIColor
40
40
  import platform.UIKit.UIDevice
41
41
  import platform.UIKit.UIScreen
42
- import vn.momo.kits.navigation.AttachHostBackGesture
42
+ import vn.momo.kits.navigation.ObserveBackGestureOwnership
43
43
 
44
44
  actual fun getPlatformName(): String = "iOS"
45
45
 
@@ -75,21 +75,29 @@ actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
75
75
  @OptIn(InternalComposeUiApi::class)
76
76
  @Composable
77
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.
83
- val navigationEventDispatcherOwner = remember {
78
+ val platformOwner = LocalCompatNavigationEventDispatcherOwner.current
79
+ if (platformOwner != null) {
80
+ // UIKitNavigationEventInput is registered on the scene's dispatcher, and that dispatcher
81
+ // having enabled handlers is what arms Compose's UIKitBackGestureRecognizer. Providing a
82
+ // fresh NavigationEventDispatcher() here would shadow it with an input-less one that never
83
+ // sees a single edge-pan event — no recogniser, and no finger-following seek.
84
+ ObserveBackGestureOwnership(platformOwner.navigationEventDispatcher)
85
+ content()
86
+ return
87
+ }
88
+
89
+ // Bare UIView hosts have no scene dispatcher, and BackHandler/PredictiveBackHandler error() on
90
+ // a null owner. Disabled: nothing can feed it, so it exists only to keep those from throwing.
91
+ val fallbackOwner = remember {
84
92
  object : NavigationEventDispatcherOwner {
85
- override val navigationEventDispatcher = NavigationEventDispatcher()
93
+ override val navigationEventDispatcher = NavigationEventDispatcher().apply {
94
+ isEnabled = false
95
+ }
86
96
  }
87
97
  }
88
98
 
89
- AttachHostBackGesture(navigationEventDispatcherOwner.navigationEventDispatcher)
90
-
91
99
  CompositionLocalProvider(
92
- LocalCompatNavigationEventDispatcherOwner provides navigationEventDispatcherOwner,
100
+ LocalCompatNavigationEventDispatcherOwner provides fallbackOwner,
93
101
  content = content
94
102
  )
95
103
  }
package/gradle.properties CHANGED
@@ -18,7 +18,7 @@ kotlin.apple.xcodeCompatibility.nowarn=true
18
18
  name="ComposeKits"
19
19
  group=vn.momo.kits
20
20
  artifact.id=kits
21
- version=0.164.1-beta.8
21
+ version=0.164.1-beta.9
22
22
 
23
23
  repo=GitLab
24
24
  url=https://gitlab.mservice.com.vn/api/v4/projects/5400/packages/maven
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKits'
3
- s.version = '0.164.1-beta.8'
3
+ s.version = '0.164.1-beta.9'
4
4
  s.summary = 'MoMoUIKits for iOS'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
@@ -1,6 +1,6 @@
1
1
  Pod::Spec.new do |s|
2
2
  s.name = 'MoMoUIKitsDemo'
3
- s.version = '0.164.1-beta.8'
3
+ s.version = '0.164.1-beta.9'
4
4
  s.summary = 'Demo browser for MoMoUIKits SwiftUI components'
5
5
  s.homepage = 'https://momo.vn'
6
6
  s.license = { :type => 'MIT' }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@momo-kits/native-kits",
3
- "version": "0.164.1-beta.8-debug",
3
+ "version": "0.164.1-beta.9-debug",
4
4
  "private": false,
5
5
  "dependeNavigationncies": {},
6
6
  "devDependencies": {},
@@ -1,125 +0,0 @@
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
- }