react-native-screens 4.25.0-beta.1 → 4.25.0-beta.3

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.
Files changed (96) hide show
  1. package/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayout.kt +6 -14
  2. package/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderAppBarLayoutBehavior.kt +29 -0
  3. package/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/StackHeaderCoordinator.kt +56 -0
  4. package/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfig.kt +11 -0
  5. package/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigProviding.kt +5 -0
  6. package/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/config/StackHeaderConfigViewManager.kt +35 -0
  7. package/android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/subview/StackHeaderSubview.kt +3 -7
  8. package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsActionOrigin.kt +26 -0
  9. package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainer.kt +227 -151
  10. package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsNavigationState.kt +60 -0
  11. package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/{TabsContainerDelegate.kt → TabsNavigationStateObserver.kt} +19 -14
  12. package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsNavigationStateObserverRegistry.kt +88 -0
  13. package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHost.kt +40 -24
  14. package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostEventEmitter.kt +11 -9
  15. package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/TabsHostViewManager.kt +19 -7
  16. package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectedEvent.kt +4 -3
  17. package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectionPreventedEvent.kt +3 -3
  18. package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/host/event/TabsHostTabSelectionRejectedEvent.kt +12 -11
  19. package/ios/conversion/RNSConversions-Tabs.mm +19 -0
  20. package/ios/conversion/RNSConversions.h +3 -0
  21. package/ios/tabs/bottom-accessory/RNSTabsBottomAccessoryHelper.mm +34 -5
  22. package/ios/tabs/host/RNSTabBarController.h +152 -99
  23. package/ios/tabs/host/RNSTabBarController.mm +137 -113
  24. package/ios/tabs/host/RNSTabsHostComponentView.h +7 -8
  25. package/ios/tabs/host/RNSTabsHostComponentView.mm +37 -33
  26. package/ios/tabs/host/RNSTabsHostEventEmitter.h +4 -4
  27. package/ios/tabs/host/RNSTabsHostEventEmitter.mm +5 -3
  28. package/ios/tabs/host/RNSTabsNavigationState.h +142 -27
  29. package/ios/tabs/host/RNSTabsNavigationState.mm +35 -2
  30. package/ios/tabs/host/RNSTabsNavigationStateObserverRegistry.h +62 -0
  31. package/ios/tabs/host/RNSTabsNavigationStateObserverRegistry.mm +104 -0
  32. package/lib/commonjs/components/gamma/stack/header/StackHeaderConfig.android.js +46 -1
  33. package/lib/commonjs/components/gamma/stack/header/StackHeaderConfig.android.js.map +1 -1
  34. package/lib/commonjs/components/safe-area/SafeAreaView.web.js +2 -3
  35. package/lib/commonjs/components/safe-area/SafeAreaView.web.js.map +1 -1
  36. package/lib/commonjs/components/tabs/host/TabsHost.android.js +2 -2
  37. package/lib/commonjs/components/tabs/host/TabsHost.android.js.map +1 -1
  38. package/lib/commonjs/components/tabs/host/TabsHost.ios.js +2 -2
  39. package/lib/commonjs/components/tabs/host/TabsHost.ios.js.map +1 -1
  40. package/lib/commonjs/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.js.map +1 -1
  41. package/lib/commonjs/flags.js +1 -0
  42. package/lib/commonjs/flags.js.map +1 -1
  43. package/lib/module/components/gamma/stack/header/StackHeaderConfig.android.js +46 -1
  44. package/lib/module/components/gamma/stack/header/StackHeaderConfig.android.js.map +1 -1
  45. package/lib/module/components/safe-area/SafeAreaView.web.js +1 -1
  46. package/lib/module/components/safe-area/SafeAreaView.web.js.map +1 -1
  47. package/lib/module/components/tabs/host/TabsHost.android.js +2 -2
  48. package/lib/module/components/tabs/host/TabsHost.android.js.map +1 -1
  49. package/lib/module/components/tabs/host/TabsHost.ios.js +2 -2
  50. package/lib/module/components/tabs/host/TabsHost.ios.js.map +1 -1
  51. package/lib/module/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.js.map +1 -1
  52. package/lib/module/flags.js +1 -0
  53. package/lib/module/flags.js.map +1 -1
  54. package/lib/typescript/components/gamma/split/SplitHost.types.d.ts +1 -1
  55. package/lib/typescript/components/gamma/split/SplitHost.types.d.ts.map +1 -1
  56. package/lib/typescript/components/gamma/stack/header/StackHeaderConfig.android.d.ts.map +1 -1
  57. package/lib/typescript/components/gamma/stack/header/StackHeaderConfig.android.types.d.ts +183 -8
  58. package/lib/typescript/components/gamma/stack/header/StackHeaderConfig.android.types.d.ts.map +1 -1
  59. package/lib/typescript/components/gamma/stack/header/StackHeaderConfig.types.d.ts +37 -0
  60. package/lib/typescript/components/gamma/stack/header/StackHeaderConfig.types.d.ts.map +1 -1
  61. package/lib/typescript/components/gamma/stack/header/android/StackHeaderSubview.android.types.d.ts +1 -1
  62. package/lib/typescript/components/gamma/stack/header/android/StackHeaderSubview.android.types.d.ts.map +1 -1
  63. package/lib/typescript/components/gamma/stack/host/StackHost.types.d.ts +1 -1
  64. package/lib/typescript/components/gamma/stack/host/StackHost.types.d.ts.map +1 -1
  65. package/lib/typescript/components/safe-area/SafeAreaView.web.d.ts +1 -1
  66. package/lib/typescript/components/safe-area/SafeAreaView.web.d.ts.map +1 -1
  67. package/lib/typescript/components/tabs/host/TabsHost.types.d.ts +29 -19
  68. package/lib/typescript/components/tabs/host/TabsHost.types.d.ts.map +1 -1
  69. package/lib/typescript/components/tabs/index.d.ts +1 -1
  70. package/lib/typescript/components/tabs/index.d.ts.map +1 -1
  71. package/lib/typescript/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.d.ts +5 -0
  72. package/lib/typescript/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.d.ts.map +1 -1
  73. package/lib/typescript/fabric/tabs/TabsHostAndroidNativeComponent.d.ts +5 -5
  74. package/lib/typescript/fabric/tabs/TabsHostAndroidNativeComponent.d.ts.map +1 -1
  75. package/lib/typescript/fabric/tabs/TabsHostIOSNativeComponent.d.ts +5 -5
  76. package/lib/typescript/fabric/tabs/TabsHostIOSNativeComponent.d.ts.map +1 -1
  77. package/lib/typescript/flags.d.ts +1 -0
  78. package/lib/typescript/flags.d.ts.map +1 -1
  79. package/package.json +1 -1
  80. package/src/components/gamma/split/SplitHost.types.ts +1 -1
  81. package/src/components/gamma/stack/header/StackHeaderConfig.android.tsx +72 -2
  82. package/src/components/gamma/stack/header/StackHeaderConfig.android.types.ts +183 -8
  83. package/src/components/gamma/stack/header/StackHeaderConfig.types.ts +37 -0
  84. package/src/components/gamma/stack/header/android/StackHeaderSubview.android.types.ts +1 -1
  85. package/src/components/gamma/stack/host/StackHost.types.ts +1 -1
  86. package/src/components/safe-area/SafeAreaView.web.tsx +1 -1
  87. package/src/components/tabs/host/TabsHost.android.tsx +2 -2
  88. package/src/components/tabs/host/TabsHost.ios.tsx +2 -2
  89. package/src/components/tabs/host/TabsHost.types.ts +29 -19
  90. package/src/components/tabs/index.ts +1 -1
  91. package/src/fabric/gamma/stack/StackHeaderConfigAndroidNativeComponent.ts +6 -0
  92. package/src/fabric/tabs/TabsHostAndroidNativeComponent.ts +5 -5
  93. package/src/fabric/tabs/TabsHostIOSNativeComponent.ts +5 -5
  94. package/src/flags.ts +1 -0
  95. package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsContainerOps.kt +0 -7
  96. package/android/src/main/java/com/swmansion/rnscreens/gamma/tabs/container/TabsNavState.kt +0 -43
@@ -36,10 +36,17 @@ import com.swmansion.rnscreens.safearea.SafeAreaView
36
36
  import com.swmansion.rnscreens.utils.RNSLog
37
37
  import kotlin.properties.Delegates
38
38
 
39
+ /**
40
+ * View that hosts the bottom navigation bar and the currently selected tab's content.
41
+ *
42
+ * Public API surface (the only contract third-party native consumers should rely on) is
43
+ * grouped under the `Public API` region below. Members in `Host-internal API` and other
44
+ * regions are implementation detail — the host (`TabsHost`) is the only intended caller
45
+ * and these can change without notice.
46
+ */
39
47
  @SuppressLint("ViewConstructor") // Created only by us. Should never be restored.
40
- internal class TabsContainer(
48
+ class TabsContainer internal constructor(
41
49
  private val context: Context,
42
- private val delegate: TabsContainerDelegate,
43
50
  ) : FrameLayout(context),
44
51
  ColorSchemeProviding,
45
52
  TabsScreenDelegate,
@@ -70,23 +77,25 @@ internal class TabsContainer(
70
77
  }
71
78
  }
72
79
 
73
- private var navState: TabsNavState = TabsNavState.EMPTY
74
- private var lastUINavState: TabsNavState = TabsNavState.EMPTY
80
+ private var navState: TabsNavigationState = TabsNavigationState.EMPTY
81
+ private var lastUINavState: TabsNavigationState = TabsNavigationState.EMPTY
75
82
  private val tabsModel: MutableList<TabsScreenFragment> = arrayListOf()
76
- internal var rejectOpsWithStaleNavState: Boolean = false
83
+
84
+ internal var rejectStaleNavigationStateUpdates: Boolean = false
77
85
 
78
86
  internal val selectedTab: TabsScreenFragment
79
87
  get() =
80
- checkNotNull(getFragmentForScreenKey(navState.selectedKey)) { "[RNScreens] No selected tab present" }
88
+ checkNotNull(getFragmentForScreenKey(navState.selectedScreenKey)) { "[RNScreens] No selected tab present" }
81
89
 
82
90
  internal val invalidationFlags = TabsContainerInvalidationFlags()
83
91
 
84
- private var pendingOperation: TabsContainerOp? = null
85
- internal val hasPendingOperation
86
- get() = pendingOperation != null
92
+ private var pendingStateUpdateRequest: TabsNavigationStateUpdateRequest? = null
93
+
94
+ private fun requirePendingStateUpdateRequest(): TabsNavigationStateUpdateRequest =
95
+ checkNotNull(pendingStateUpdateRequest) { "[RNScreens] Attempt to require nullish pendingStateUpdateRequest" }
87
96
 
88
97
  /**
89
- * Denotes whether container is currently performing update triggered by the `pendingOperation`.
98
+ * Denotes whether container is currently performing update triggered by the `pendingStateUpdateRequest`.
90
99
  */
91
100
  private var isInExternalOperationContext: Boolean = false
92
101
 
@@ -113,6 +122,8 @@ internal class TabsContainer(
113
122
  private val specialEffectsHandler = SpecialEffectsHandler()
114
123
  private val colorSchemeCoordinator = ColorSchemeCoordinator()
115
124
 
125
+ private val observerRegistry = TabsNavigationStateObserverRegistry()
126
+
116
127
  internal var colorScheme: ColorScheme by colorSchemeCoordinator::colorScheme
117
128
  internal var tabBarRespectsIMEInsets: Boolean = false
118
129
 
@@ -138,9 +149,8 @@ internal class TabsContainer(
138
149
  updateInterfaceInsets()
139
150
  invalidationFlags.isNavigationMenuAppearanceInvalidated = true
140
151
  post {
141
- performContainerUpdateIfNeeded()
152
+ flushPendingUpdates()
142
153
  }
143
- // updateNavigationMenuIfNeeded(oldValue, newValue)
144
154
  }
145
155
  }
146
156
 
@@ -152,12 +162,110 @@ internal class TabsContainer(
152
162
  invalidationFlags.invalidateAll()
153
163
  }
154
164
 
165
+ // region Public API (third-party stable)
166
+
167
+ /**
168
+ * Current navigation state of the container.
169
+ * Returns [TabsNavigationState.EMPTY] before the first tab is selected.
170
+ */
171
+ val navigationState: TabsNavigationState
172
+ get() = navState
173
+
174
+ /**
175
+ * Queue a navigation state update. Apply via [flushPendingUpdates] or rely on the
176
+ * host's next render cycle to apply automatically.
177
+ */
178
+ fun submitSelectionOfTabsScreenWithKey(screenKey: String) {
179
+ setPendingNavigationStateUpdate(
180
+ TabsNavigationStateUpdateRequest(
181
+ screenKey,
182
+ navigationState.provenance,
183
+ TabsActionOrigin.PROGRAMMATIC_NATIVE,
184
+ ),
185
+ )
186
+ }
187
+
188
+ /**
189
+ * Apply any pending invalidations and state updates in a single coordinated pass.
190
+ * No-op when nothing is dirty or the view is detached.
191
+ */
192
+ fun flushPendingUpdates() {
193
+ if (invalidationFlags.any() && isAttachedToWindow) {
194
+ performContainerUpdate()
195
+ }
196
+ }
197
+
198
+ fun addNavigationStateObserver(observer: TabsNavigationStateObserver): Boolean = observerRegistry.add(observer)
199
+
200
+ fun removeNavigationStateObserver(observer: TabsNavigationStateObserver): Boolean = observerRegistry.remove(observer)
201
+
202
+ // endregion
203
+
204
+ // region Host-internal API
205
+
206
+ /**
207
+ * Queue a navigation state update. Apply via [flushPendingUpdates] or rely on the
208
+ * host's next render cycle to apply automatically.
209
+ */
210
+ internal fun setPendingNavigationStateUpdate(request: TabsNavigationStateUpdateRequest?) {
211
+ pendingStateUpdateRequest = request
212
+ invalidationFlags.isSelectedTabInvalidated = request != null
213
+ }
214
+
215
+ internal fun addTabsScreenAt(
216
+ index: Int,
217
+ tabsScreen: TabsScreen,
218
+ ) {
219
+ tabsModel.add(index, TabsScreenFragment(tabsScreen))
220
+ invalidationFlags.invalidateAll()
221
+ }
222
+
223
+ internal fun removeTabsScreenAt(index: Int): TabsScreen? =
224
+ tabsModel.removeAt(index).tabsScreen.also {
225
+ invalidationFlags.invalidateAll()
226
+ }
227
+
228
+ internal fun removeTabsScreen(tabsScreen: TabsScreen): Boolean =
229
+ tabsModel.removeIf { it.tabsScreen === tabsScreen }.also { isRemoved ->
230
+ if (isRemoved) invalidationFlags.invalidateAll()
231
+ }
232
+
233
+ internal fun removeAllTabsScreens() {
234
+ tabsModel.clear()
235
+ invalidationFlags.invalidateAll()
236
+ }
237
+
238
+ internal fun setupFragmentManager() {
239
+ fragmentManager =
240
+ checkNotNull(FragmentManagerHelper.findFragmentManagerForView(this)) {
241
+ "[RNScreens] Nullish fragment manager - can't run container operations"
242
+ }
243
+ }
244
+
245
+ internal fun teardownFragmentManager() {
246
+ fragmentManager = null
247
+ }
248
+
249
+ /**
250
+ * Idempotent teardown. Releases observer references and clears any pending operation.
251
+ * Called by the host on view lifecycle end. Note: named `tearDown` (not `invalidate`) to avoid
252
+ * shadowing [android.view.View.invalidate].
253
+ */
254
+ internal fun tearDown() {
255
+ observerRegistry.clear()
256
+ setPendingNavigationStateUpdate(null)
257
+ }
258
+
259
+ // endregion
260
+
261
+ // region View lifecycle / insets / appearance
262
+
155
263
  override fun onAttachedToWindow() {
156
264
  RNSLog.d(TAG, "TabsContainer [$id] attached to window")
157
265
 
158
266
  super.onAttachedToWindow()
159
267
  setupFragmentManager()
160
- performContainerUpdateIfNeeded()
268
+ flushPendingUpdates()
161
269
 
162
270
  colorSchemeCoordinator.setup(this) { uiNightMode ->
163
271
  applyDayNightUiMode(uiNightMode)
@@ -196,40 +304,94 @@ internal class TabsContainer(
196
304
  return insets
197
305
  }
198
306
 
199
- internal fun setContainerOperation(op: TabsContainerOp) {
200
- pendingOperation = op
201
- invalidationFlags.isSelectedTabInvalidated = true
307
+ override fun onLayoutChange(
308
+ view: View?,
309
+ left: Int,
310
+ top: Int,
311
+ right: Int,
312
+ bottom: Int,
313
+ oldLeft: Int,
314
+ oldTop: Int,
315
+ oldRight: Int,
316
+ oldBottom: Int,
317
+ ) {
318
+ require(view is BottomNavigationView) {
319
+ "[RNScreens] TabsContainer's onLayoutChange expects BottomNavigationView, received $view instead"
320
+ }
321
+
322
+ val oldHeight = oldBottom - oldTop
323
+ val newHeight = bottom - top
324
+
325
+ if (newHeight != oldHeight) {
326
+ updateInterfaceInsets(newHeight)
327
+ }
202
328
  }
203
329
 
204
- internal fun addTabsScreenAt(
205
- index: Int,
206
- tabsScreen: TabsScreen,
207
- ) {
208
- tabsModel.add(index, TabsScreenFragment(tabsScreen))
209
- invalidationFlags.invalidateAll()
330
+ override fun setOnInterfaceInsetsChangeListener(listener: SafeAreaView) {
331
+ if (interfaceInsetsChangeListener == null) {
332
+ bottomNavigationView.addOnLayoutChangeListener(this)
333
+ }
334
+ interfaceInsetsChangeListener = listener
210
335
  }
211
336
 
212
- internal fun removeTabsScreenAt(index: Int): TabsScreen? =
213
- tabsModel.removeAt(index).tabsScreen.also {
214
- invalidationFlags.invalidateAll()
337
+ override fun removeOnInterfaceInsetsChangeListener(listener: SafeAreaView) {
338
+ if (interfaceInsetsChangeListener == listener) {
339
+ interfaceInsetsChangeListener = null
340
+ bottomNavigationView.removeOnLayoutChangeListener(this)
215
341
  }
342
+ }
216
343
 
217
- internal fun removeTabsScreen(tabsScreen: TabsScreen): Boolean =
218
- tabsModel.removeIf { it.tabsScreen === tabsScreen }.also { isRemoved ->
219
- if (isRemoved) invalidationFlags.invalidateAll()
344
+ override fun getInterfaceInsets(): EdgeInsets = EdgeInsets(0.0f, 0.0f, 0.0f, bottomNavigationView.height.toFloat())
345
+
346
+ override fun getResolvedUiNightMode() = colorSchemeCoordinator.getResolvedUiNightMode()
347
+
348
+ override fun addColorSchemeListener(listener: ColorSchemeListener) = colorSchemeCoordinator.addColorSchemeListener(listener)
349
+
350
+ override fun removeColorSchemeListener(listener: ColorSchemeListener) = colorSchemeCoordinator.removeColorSchemeListener(listener)
351
+
352
+ // endregion
353
+
354
+ // region TabsScreenDelegate impl
355
+
356
+ override fun onAppearanceChanged(tabsScreen: TabsScreen) {
357
+ if (selectedTab.tabsScreen === tabsScreen) {
358
+ invalidationFlags.isNavigationMenuAppearanceInvalidated = true
359
+ post {
360
+ this.flushPendingUpdates()
361
+ }
220
362
  }
363
+ }
221
364
 
222
- internal fun removeAllTabsScreens() {
223
- tabsModel.clear()
224
- invalidationFlags.invalidateAll()
365
+ override fun onMenuItemAttributesChange(tabsScreen: TabsScreen) {
366
+ getMenuItemForTabsScreen(tabsScreen)?.let { menuItem ->
367
+ val appearance = selectedTab.tabsScreen.appearance
368
+ appearanceCoordinator.updateMenuItemAppearance(
369
+ themedContext,
370
+ menuItem,
371
+ tabsScreen,
372
+ appearance,
373
+ )
374
+ a11yCoordinator.setA11yPropertiesToTabItem(menuItem, tabsScreen)
375
+ }
225
376
  }
226
377
 
227
- internal fun performContainerUpdateIfNeeded() {
228
- if (invalidationFlags.any() && isAttachedToWindow) {
229
- performContainerUpdate()
378
+ override fun getFragmentForTabsScreen(tabsScreen: TabsScreen): TabsScreenFragment? =
379
+ tabsModel.find {
380
+ it.tabsScreen ===
381
+ tabsScreen
230
382
  }
383
+
384
+ override fun onFragmentConfigurationChange(
385
+ tabsScreen: TabsScreen,
386
+ config: Configuration,
387
+ ) {
388
+ this.onConfigurationChanged(config)
231
389
  }
232
390
 
391
+ // endregion
392
+
393
+ // region Private helpers
394
+
233
395
  private fun performContainerUpdate() {
234
396
  if (invalidationFlags.isNavigationMenuStructureInvalidated) {
235
397
  invalidationFlags.isNavigationMenuStructureInvalidated = false
@@ -249,27 +411,25 @@ internal class TabsContainer(
249
411
  }
250
412
 
251
413
  private fun performOperation() {
252
- if (pendingOperation == null) {
414
+ if (pendingStateUpdateRequest == null) {
253
415
  RNSLog.w(TAG, "TabsContainer::performOperation called w/o pending operation; skipping update")
254
416
  return
255
417
  }
256
418
 
257
- check(hasPendingOperation) { "[RNScreens] Attempt to update container with empty state and no pending update" }
258
- check(pendingOperation is TabSelectOp)
259
- val tabSelectOp = pendingOperation as TabSelectOp
419
+ val stateUpdateRequest = requirePendingStateUpdateRequest()
260
420
 
261
421
  val nextSelectedMenuItemId =
262
- checkNotNull(getMenuItemIdForFragment(requireFragmentForScreenKey(tabSelectOp.navState.selectedKey))) {
263
- "[RNScreens] Failed to find Menu Item for screenKey: ${tabSelectOp.navState.selectedKey}"
422
+ checkNotNull(getMenuItemIdForFragment(requireFragmentForScreenKey(stateUpdateRequest.selectedScreenKey))) {
423
+ "[RNScreens] Failed to find Menu Item for screenKey: ${stateUpdateRequest.selectedScreenKey}"
264
424
  }
265
425
 
266
- if (rejectOpsWithStaleNavState && isNavStateStale(tabSelectOp.navState)) {
267
- delegate.onNavStateUpdateRejected(
426
+ if (rejectStaleNavigationStateUpdates && isNavigationStateStale(stateUpdateRequest)) {
427
+ observerRegistry.emitOnNavigationStateUpdateRejected(
268
428
  navState,
269
- tabSelectOp.navState,
270
- TabsNavStateUpdateRejectionReason.STALE,
429
+ stateUpdateRequest,
430
+ TabsNavigationStateRejectionReason.STALE,
271
431
  )
272
- pendingOperation = null
432
+ pendingStateUpdateRequest = null
273
433
  return
274
434
  }
275
435
 
@@ -279,14 +439,14 @@ internal class TabsContainer(
279
439
  bottomNavigationView.selectedItemId = nextSelectedMenuItemId
280
440
  isInExternalOperationContext = false
281
441
  } else {
282
- delegate.onNavStateUpdateRejected(
442
+ observerRegistry.emitOnNavigationStateUpdateRejected(
283
443
  navState,
284
- tabSelectOp.navState,
285
- TabsNavStateUpdateRejectionReason.REPEATED,
444
+ stateUpdateRequest,
445
+ TabsNavigationStateRejectionReason.REPEATED,
286
446
  )
287
447
  }
288
448
 
289
- pendingOperation = null
449
+ pendingStateUpdateRequest = null
290
450
  }
291
451
 
292
452
  private fun updateNavigationMenuStructure() {
@@ -316,8 +476,8 @@ internal class TabsContainer(
316
476
 
317
477
  private fun updateSelectedFragment(nextSelectedFragment: TabsScreenFragment): Boolean {
318
478
  if (navState.isEmpty()) {
319
- check(isInExternalOperationContext && hasPendingOperation)
320
- navState = TabsNavState(nextSelectedFragment.requireScreenKey, 0)
479
+ check(isInExternalOperationContext && pendingStateUpdateRequest != null)
480
+ navState = TabsNavigationState(nextSelectedFragment.requireScreenKey, 0)
321
481
  requireFragmentManager
322
482
  .createTransactionWithReordering()
323
483
  .add(contentView.id, nextSelectedFragment)
@@ -328,7 +488,7 @@ internal class TabsContainer(
328
488
  val currentSelectedFragment = selectedTab
329
489
 
330
490
  if (nextSelectedFragment === currentSelectedFragment) {
331
- progressNavigationState(navState.selectedKey)
491
+ progressNavigationState(navState.selectedScreenKey)
332
492
  return true
333
493
  }
334
494
 
@@ -343,8 +503,8 @@ internal class TabsContainer(
343
503
  return true
344
504
  }
345
505
 
346
- private fun progressNavigationState(selectedKey: String) {
347
- navState = TabsNavState(selectedKey, navState.provenance + 1)
506
+ private fun progressNavigationState(selectedScreenKey: String) {
507
+ navState = TabsNavigationState(selectedScreenKey, navState.provenance + 1)
348
508
  if (!isInExternalOperationContext) {
349
509
  lastUINavState = navState
350
510
  }
@@ -363,7 +523,7 @@ internal class TabsContainer(
363
523
 
364
524
  // If this is user action we test whether it should be prevented before we progress the state.
365
525
  if (!isRepeated && !isInExternalOperationContext && nextSelectedFragment.isPreventNativeSelectionEnabled) {
366
- delegate.onNavStateUpdatePrevented(navState, nextSelectedFragment.requireScreenKey)
526
+ observerRegistry.emitOnNavigationStateUpdatePrevented(navState, nextSelectedFragment.requireScreenKey)
367
527
  return false
368
528
  }
369
529
 
@@ -373,11 +533,16 @@ internal class TabsContainer(
373
533
  if (isRepeated) specialEffectsHandler.handleRepeatedTabSelection() else false
374
534
 
375
535
  if (stateChanged) {
376
- delegate.onNavStateUpdate(
536
+ observerRegistry.emitOnNavigationStateUpdate(
377
537
  navState,
378
538
  isRepeated = isRepeated,
379
539
  hasTriggeredSpecialEffect = hasTriggeredSpecialEffect,
380
- isNativeAction = !isInExternalOperationContext,
540
+ actionOrigin =
541
+ if (isInExternalOperationContext) {
542
+ requirePendingStateUpdateRequest().actionOrigin
543
+ } else {
544
+ TabsActionOrigin.USER
545
+ },
381
546
  )
382
547
  }
383
548
 
@@ -413,7 +578,7 @@ internal class TabsContainer(
413
578
 
414
579
  private fun getSelectedTabsScreenFragmentId(): Int? =
415
580
  tabsModel
416
- .indexOfFirst { it.requireScreenKey == navState.selectedKey }
581
+ .indexOfFirst { it.requireScreenKey == navState.selectedScreenKey }
417
582
  .takeIf { it != -1 }
418
583
 
419
584
  private fun getMenuItemForTabsScreen(tabsScreen: TabsScreen): MenuItem? =
@@ -424,40 +589,6 @@ internal class TabsContainer(
424
589
  bottomNavigationView.menu.findItem(menuItemIdForFragmentAtIndex(index))
425
590
  }
426
591
 
427
- override fun getResolvedUiNightMode() = colorSchemeCoordinator.getResolvedUiNightMode()
428
-
429
- override fun addColorSchemeListener(listener: ColorSchemeListener) = colorSchemeCoordinator.addColorSchemeListener(listener)
430
-
431
- override fun removeColorSchemeListener(listener: ColorSchemeListener) = colorSchemeCoordinator.removeColorSchemeListener(listener)
432
-
433
- override fun onAppearanceChanged(tabsScreen: TabsScreen) {
434
- if (selectedTab.tabsScreen === tabsScreen) {
435
- invalidationFlags.isNavigationMenuAppearanceInvalidated = true
436
- post {
437
- this.performContainerUpdateIfNeeded()
438
- }
439
- }
440
- }
441
-
442
- override fun onMenuItemAttributesChange(tabsScreen: TabsScreen) {
443
- getMenuItemForTabsScreen(tabsScreen)?.let { menuItem ->
444
- val appearance = selectedTab.tabsScreen.appearance
445
- appearanceCoordinator.updateMenuItemAppearance(
446
- themedContext,
447
- menuItem,
448
- tabsScreen,
449
- appearance,
450
- )
451
- a11yCoordinator.setA11yPropertiesToTabItem(menuItem, tabsScreen)
452
- }
453
- }
454
-
455
- override fun getFragmentForTabsScreen(tabsScreen: TabsScreen): TabsScreenFragment? =
456
- tabsModel.find {
457
- it.tabsScreen ===
458
- tabsScreen
459
- }
460
-
461
592
  private fun getFragmentForScreenKey(screenKey: String): TabsScreenFragment? = tabsModel.find { it.requireScreenKey == screenKey }
462
593
 
463
594
  private fun requireFragmentForScreenKey(screenKey: String): TabsScreenFragment =
@@ -465,50 +596,6 @@ internal class TabsContainer(
465
596
  "[RNScreens] Requested fragment for key: $screenKey does not exist"
466
597
  }
467
598
 
468
- override fun onFragmentConfigurationChange(
469
- tabsScreen: TabsScreen,
470
- config: Configuration,
471
- ) {
472
- this.onConfigurationChanged(config)
473
- }
474
-
475
- override fun onLayoutChange(
476
- view: View?,
477
- left: Int,
478
- top: Int,
479
- right: Int,
480
- bottom: Int,
481
- oldLeft: Int,
482
- oldTop: Int,
483
- oldRight: Int,
484
- oldBottom: Int,
485
- ) {
486
- require(view is BottomNavigationView) {
487
- "[RNScreens] TabsContainer's onLayoutChange expects BottomNavigationView, received $view instead"
488
- }
489
-
490
- val oldHeight = oldBottom - oldTop
491
- val newHeight = bottom - top
492
-
493
- if (newHeight != oldHeight) {
494
- updateInterfaceInsets(newHeight)
495
- }
496
- }
497
-
498
- override fun setOnInterfaceInsetsChangeListener(listener: SafeAreaView) {
499
- if (interfaceInsetsChangeListener == null) {
500
- bottomNavigationView.addOnLayoutChangeListener(this)
501
- }
502
- interfaceInsetsChangeListener = listener
503
- }
504
-
505
- override fun removeOnInterfaceInsetsChangeListener(listener: SafeAreaView) {
506
- if (interfaceInsetsChangeListener == listener) {
507
- interfaceInsetsChangeListener = null
508
- bottomNavigationView.removeOnLayoutChangeListener(this)
509
- }
510
- }
511
-
512
599
  private fun updateInterfaceInsets(newHeight: Int? = null) {
513
600
  val height = if (tabBarHidden) 0 else (newHeight ?: bottomNavigationView.height)
514
601
 
@@ -517,8 +604,6 @@ internal class TabsContainer(
517
604
  }
518
605
  }
519
606
 
520
- override fun getInterfaceInsets(): EdgeInsets = EdgeInsets(0.0f, 0.0f, 0.0f, bottomNavigationView.height.toFloat())
521
-
522
607
  private fun getInsetsForBottomNavigationView(insets: WindowInsets): WindowInsets? {
523
608
  if (tabBarRespectsIMEInsets) {
524
609
  return insets
@@ -533,22 +618,13 @@ internal class TabsContainer(
533
618
  .toWindowInsets()
534
619
  }
535
620
 
536
- internal fun setupFragmentManager() {
537
- fragmentManager =
538
- checkNotNull(FragmentManagerHelper.findFragmentManagerForView(this)) {
539
- "[RNScreens] Nullish fragment manager - can't run container operations"
540
- }
541
- }
542
-
543
- internal fun teardownFragmentManager() {
544
- fragmentManager = null
545
- }
546
-
547
- private fun isNavStateStale(state: TabsNavState): Boolean {
621
+ private fun isNavigationStateStale(request: TabsNavigationStateUpdateRequest): Boolean {
548
622
  if (navState.isEmpty() || lastUINavState.isEmpty()) return false
549
- return state.provenance < lastUINavState.provenance
623
+ return request.baseProvenance < lastUINavState.provenance
550
624
  }
551
625
 
626
+ // endregion
627
+
552
628
  companion object {
553
629
  const val TAG = "TabsContainer"
554
630
  }
@@ -0,0 +1,60 @@
1
+ package com.swmansion.rnscreens.gamma.tabs.container
2
+
3
+ import com.swmansion.rnscreens.gamma.tabs.container.TabsNavigationStateRejectionReason.REPEATED
4
+ import com.swmansion.rnscreens.gamma.tabs.container.TabsNavigationStateRejectionReason.STALE
5
+
6
+ /**
7
+ * Describes navigation state of a tabs container.
8
+ *
9
+ * @property selectedScreenKey Screen key of the currently selected tab.
10
+ * @property provenance Monotonically increasing number describing the history (generation) of the state.
11
+ * State with provenance `N + 1` is derived from state with provenance `N`.
12
+ * This allows detecting stale updates.
13
+ */
14
+ data class TabsNavigationState(
15
+ val selectedScreenKey: String,
16
+ val provenance: Int,
17
+ ) {
18
+ internal fun isEmpty(): Boolean = this === EMPTY
19
+
20
+ internal fun isNotEmpty(): Boolean = !this.isEmpty()
21
+
22
+ companion object {
23
+ val EMPTY = TabsNavigationState("", Int.MIN_VALUE)
24
+ }
25
+ }
26
+
27
+ /**
28
+ * A request to change navigation state.
29
+ *
30
+ * Carries the target [selectedScreenKey], the [baseProvenance] of the state the request was derived from,
31
+ * and the [actionOrigin] (actor) that initiated it. Mirrors the public `TabsHostNavStateRequest` TS type
32
+ * plus an [actionOrigin] carried internally.
33
+ *
34
+ * @property selectedScreenKey Screen key of the requested tab.
35
+ * @property baseProvenance Provenance of the state this request was derived from. Used for staleness detection.
36
+ * @property actionOrigin Origin (actor) that initiated this request.
37
+ */
38
+ data class TabsNavigationStateUpdateRequest(
39
+ val selectedScreenKey: String,
40
+ val baseProvenance: Int,
41
+ val actionOrigin: TabsActionOrigin,
42
+ )
43
+
44
+ /**
45
+ * Reason why a navigation state update was rejected by the container.
46
+ *
47
+ * - [STALE] — the update's provenance indicates that it is based on a stale state.
48
+ * - [REPEATED] — the requested tab is already selected.
49
+ */
50
+ enum class TabsNavigationStateRejectionReason {
51
+ STALE,
52
+ REPEATED,
53
+ ;
54
+
55
+ override fun toString(): String =
56
+ when (this) {
57
+ STALE -> "stale"
58
+ REPEATED -> "repeated"
59
+ }
60
+ }
@@ -1,36 +1,41 @@
1
1
  package com.swmansion.rnscreens.gamma.tabs.container
2
2
 
3
3
  /**
4
- * Callback interface for observing navigation state changes and rejections
5
- * in [TabsContainer]. Implemented by [com.swmansion.rnscreens.gamma.tabs.host.TabsHost] to relay events to JS.
4
+ * Observer of navigation state changes on a [TabsContainer].
5
+ *
6
+ * Multiple observers may register against a single container via
7
+ * [TabsContainer.addNavigationStateObserver] / [TabsContainer.removeNavigationStateObserver].
8
+ * The host (`TabsHost` on Android) registers itself as an observer to relay events to JS;
9
+ * downstream native libraries integrating directly against [TabsContainer] may register
10
+ * additional observers.
6
11
  */
7
- internal interface TabsContainerDelegate {
12
+ interface TabsNavigationStateObserver {
8
13
  /**
9
14
  * Called when the container accepts a navigation state change.
10
15
  *
11
16
  * @param navState The new navigation state after the change.
12
17
  * @param isRepeated Whether the same tab that was already selected has been selected again.
13
18
  * @param hasTriggeredSpecialEffect Whether a special effect (e.g. scroll-to-top) was triggered.
14
- * @param isNativeAction Whether the change was initiated by a native user action (tap).
19
+ * @param actionOrigin Origin (actor) that requested this transition.
15
20
  */
16
- fun onNavStateUpdate(
17
- navState: TabsNavState,
21
+ fun onNavigationStateUpdate(
22
+ navState: TabsNavigationState,
18
23
  isRepeated: Boolean,
19
24
  hasTriggeredSpecialEffect: Boolean,
20
- isNativeAction: Boolean,
25
+ actionOrigin: TabsActionOrigin,
21
26
  )
22
27
 
23
28
  /**
24
29
  * Called when the container rejects a navigation state update.
25
30
  *
26
31
  * @param currentNavState The currently active navigation state that was kept.
27
- * @param rejectedNavState The navigation state update that was rejected.
32
+ * @param rejectedRequest The navigation state update request that was rejected.
28
33
  * @param reason Why the update was rejected.
29
34
  */
30
- fun onNavStateUpdateRejected(
31
- currentNavState: TabsNavState,
32
- rejectedNavState: TabsNavState,
33
- reason: TabsNavStateUpdateRejectionReason,
35
+ fun onNavigationStateUpdateRejected(
36
+ currentNavState: TabsNavigationState,
37
+ rejectedRequest: TabsNavigationStateUpdateRequest,
38
+ reason: TabsNavigationStateRejectionReason,
34
39
  )
35
40
 
36
41
  /**
@@ -41,8 +46,8 @@ internal interface TabsContainerDelegate {
41
46
  * @param currentNavState The currently active navigation state that was kept.
42
47
  * @param preventedScreenKey The screen key of the tab whose selection was prevented.
43
48
  */
44
- fun onNavStateUpdatePrevented(
45
- currentNavState: TabsNavState,
49
+ fun onNavigationStateUpdatePrevented(
50
+ currentNavState: TabsNavigationState,
46
51
  preventedScreenKey: String,
47
52
  )
48
53
  }