Skip to content

Commit 6e825ea

Browse files
authored
feat(Android, Tabs): partially implement RFC-1028 for Tabs on Android (#3776)
## Description > [!note] > I'll wait with landing this PR until I get approvals on #3781. These two should be landed in tandem. Partially implements [RFC-1028](#3702) for the Tabs component on Android. This PR changes the communication model between the native and JS sides of the Tabs implementation, switching from *controlled mode* to *managed mode*, and introduces a richer event payload for tab change events. This is part of a broader refactor series for the Tabs component. State conflict resolution algorithm (as described in RFC-1028) is deferred to a follow-up PR. Related to #3702. Closes software-mansion/react-native-screens-labs#990 ## Changes ### Android (native) - **Switched from *controlled* to *managed* mode**: The container now first updates its model and then sends events to the Element realm, rather than waiting for JS to confirm state. - **Replaced `TabsHostNativeFocusChangeEvent` with `TabsHostTabChangeEvent`**: The new event carries an extended payload (`selectedScreenKey` + `provenance`) aligned with RFC-1028 model. - **Moved menu model construction out of appearance update**: Previously, `BottomNavigationMenuView` model was built inside `updateBottomNavigationViewAppearance`. It's now a separate step that happens before any container update is triggered, which is architecturally cleaner and required by the new update flow. - **Streamlined container update logic**: Native update now begins with `TabsContainer::onMenuItemSelected` (triggered by `OnMenuItemClicked` listener). JS-initiated update begins with `TabsHost` setting an operation on `TabsContainer` and flushing it, which in turn triggers `onMenuItemSelected` via `bottomNavigationView.menu.selectedItemId` assignment. - **Removed `ContainerUpdateCoordinator`**: Replaced with the stack model — operations are flushed on `UIManagerListener.didMountItems` callback, aligning with how Stack works on Android and iOS. - **`TabsScreen` no longer tracks its own selection state**: `isFocused` prop has been removed. Selection state is now passed to the native side via the new `navState` prop on `TabsHost`. - **Added `TabsContainerDelegate`** and **`MenuHelpers`** as supporting infrastructure. ### JS / TypeScript - **`onNativeFocusChange` replaced with `onTabChange`**: New event name follows RFC-1028. The payload is richer, providing more context. This is a **breaking change** — downstream code will need to adapt. - **`navState` prop added to `TabsHost`**: Carries `selectedScreenKey` and `provenance`, enabling the managed mode operation flow. - **`isFocused` removed from `TabsScreen`**: Screen no longer manages its own focus awareness. - **Updated JS `TabsContainer` example implementation**: - Distinguishes between `native-tab-change` and `js-tab-change` dispatch actions. - Introduces `confirmedState` (state confirmed as displayed by native side) and `suggestedState` (state sent to native but not yet confirmed). - Prevents user-defined `onTabChange` callback from overriding `TabsContainer`'s internal handler. ## Test plan - Tested manually using `apps/src/tests/single-feature-tests/tabs/test-tabs-simple-nav.tsx` on Android. - Verified basic tab navigation works in managed mode (native-initiated and JS-initiated tab changes). ## Checklist - [x] Included code example that can be used to test this change. - [ ] Updated / created local changelog entries in relevant test files. - [ ] For visual changes, included screenshots / GIFs / recordings documenting the change. - [x] For API changes, updated relevant public types. - [ ] Ensured that CI passes
1 parent 17a6682 commit 6e825ea

30 files changed

Lines changed: 669 additions & 420 deletions

android/src/main/java/com/swmansion/rnscreens/gamma/tabs/appearance/TabsAppearanceApplicator.kt

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,11 @@ import com.facebook.react.uimanager.PixelUtil
1515
import com.google.android.material.R
1616
import com.google.android.material.bottomnavigation.BottomNavigationView
1717
import com.google.android.material.navigation.NavigationBarView
18-
import com.swmansion.rnscreens.gamma.tabs.host.TabsHost
1918
import com.swmansion.rnscreens.gamma.tabs.screen.TabsScreen
2019
import com.swmansion.rnscreens.utils.resolveColorAttr
2120

2221
@SuppressLint("PrivateResource") // We want to use variables from material design for default values
23-
class TabsAppearanceApplicator(
22+
internal class TabsAppearanceApplicator(
2423
private val bottomNavigationView: BottomNavigationView,
2524
) {
2625
private val states =
@@ -33,11 +32,10 @@ class TabsAppearanceApplicator(
3332

3433
fun updateSharedAppearance(
3534
context: Context,
36-
tabsHost: TabsHost,
35+
tabBarAppearance: TabsAppearance?,
36+
isTabBarHidden: Boolean,
3737
) {
38-
val tabBarAppearance = tabsHost.currentFocusedTab.tabsScreen.appearance
39-
40-
bottomNavigationView.isVisible = !tabsHost.tabBarHidden
38+
bottomNavigationView.isVisible = !isTabBarHidden
4139
bottomNavigationView.setBackgroundColor(
4240
tabBarAppearance?.tabBarBackgroundColor
4341
?: resolveColorAttr(context, R.attr.colorSurfaceContainer),
@@ -115,9 +113,8 @@ class TabsAppearanceApplicator(
115113

116114
fun updateFontStyles(
117115
context: Context,
118-
tabsHost: TabsHost,
116+
tabBarAppearance: TabsAppearance?,
119117
) {
120-
val tabBarAppearance = tabsHost.currentFocusedTab.tabsScreen.appearance
121118
val bottomNavigationMenuView = bottomNavigationView.getChildAt(0) as ViewGroup
122119

123120
for (menuItem in bottomNavigationMenuView.children) {
Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,41 @@
11
package com.swmansion.rnscreens.gamma.tabs.appearance
22

33
import android.content.Context
4-
import android.view.Menu
54
import android.view.MenuItem
6-
import androidx.core.view.size
75
import com.google.android.material.bottomnavigation.BottomNavigationView
8-
import com.swmansion.rnscreens.gamma.tabs.host.TabsHost
6+
import com.swmansion.rnscreens.gamma.tabs.container.TabsContainer
7+
import com.swmansion.rnscreens.gamma.tabs.container.menuItemIdForFragmentAtIndex
98
import com.swmansion.rnscreens.gamma.tabs.screen.TabsScreen
109
import com.swmansion.rnscreens.gamma.tabs.screen.TabsScreenFragment
1110

12-
class TabsAppearanceCoordinator(
11+
internal class TabsAppearanceCoordinator(
1312
private val bottomNavigationView: BottomNavigationView,
1413
private val tabsScreenFragments: MutableList<TabsScreenFragment>,
1514
) {
1615
private val appearanceApplicator = TabsAppearanceApplicator(bottomNavigationView)
1716

1817
fun updateTabAppearance(
1918
context: Context,
20-
tabsHost: TabsHost,
19+
tabsContainer: TabsContainer,
2120
) {
22-
appearanceApplicator.updateSharedAppearance(context, tabsHost)
23-
updateMenuItems(context, tabsHost)
24-
appearanceApplicator.updateFontStyles(context, tabsHost) // It needs to be updated after updateMenuItems
21+
val selectedTabAppearance = tabsContainer.selectedTab.tabsScreen.appearance
22+
appearanceApplicator.updateSharedAppearance(context, selectedTabAppearance, tabsContainer.tabBarHidden)
23+
updateMenuItems(context, selectedTabAppearance)
24+
appearanceApplicator.updateFontStyles(context, selectedTabAppearance) // It needs to be updated after updateMenuItems
2525
}
2626

2727
private fun updateMenuItems(
2828
context: Context,
29-
tabsHost: TabsHost,
29+
tabsAppearance: TabsAppearance?,
3030
) {
31-
if (bottomNavigationView.menu.size != tabsScreenFragments.size) {
32-
// Most likely first render or some tab has been removed. Let's nuke the menu (easiest option).
33-
bottomNavigationView.menu.clear()
34-
}
35-
val appearance = tabsHost.currentFocusedTab.tabsScreen.appearance
3631
tabsScreenFragments.forEachIndexed { index, fragment ->
37-
val menuItem = bottomNavigationView.menu.getOrCreateMenuItem(index, fragment.tabsScreen)
38-
check(menuItem.itemId == index) { "[RNScreens] Illegal state: menu items are shuffled" }
39-
updateMenuItemAppearance(context, menuItem, fragment.tabsScreen, appearance)
32+
val menuItemId = menuItemIdForFragmentAtIndex(index)
33+
val menuItem =
34+
checkNotNull(bottomNavigationView.menu.findItem(menuItemId)) {
35+
"[RNScreens] Missing MenuItem for id: $menuItemId"
36+
}
37+
check(menuItem.itemId == menuItemIdForFragmentAtIndex(index)) { "[RNScreens] Illegal state: menu items are shuffled" }
38+
updateMenuItemAppearance(context, menuItem, fragment.tabsScreen, tabsAppearance)
4039
}
4140
}
4241

@@ -50,8 +49,3 @@ class TabsAppearanceCoordinator(
5049
appearanceApplicator.updateBadgeAppearance(context, menuItem, tabsScreen, appearance)
5150
}
5251
}
53-
54-
private fun Menu.getOrCreateMenuItem(
55-
index: Int,
56-
tabsScreen: TabsScreen,
57-
): MenuItem = this.findItem(index) ?: this.add(Menu.NONE, index, Menu.NONE, tabsScreen.tabTitle)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.swmansion.rnscreens.gamma.tabs.container
2+
3+
import android.view.Menu
4+
import android.view.MenuItem
5+
import com.swmansion.rnscreens.gamma.tabs.screen.TabsScreen
6+
7+
// MenuItem ids are offset by one, because 0 has special meaning in the BottomNavigationMenuView API.
8+
9+
/**
10+
* Compute MenuItem id for a fragment at given index in "tabsModel" of TabsContainer
11+
*/
12+
internal fun menuItemIdForFragmentAtIndex(fragmentIndex: Int): Int = fragmentIndex + 1
13+
14+
/**
15+
* Compute fragment index in "tabsModel" of TabsContainer for a MenuItem with given id.
16+
*/
17+
internal fun fragmentIndexForMenuItemId(menuItemId: Int): Int {
18+
check(menuItemId >= 1) { "[RNScreens] MenuItem id must not be less than 1" }
19+
return menuItemId - 1
20+
}
21+
22+
internal fun Menu.getOrCreateMenuItemForFragmentAt(
23+
index: Int,
24+
tabsScreen: TabsScreen,
25+
): MenuItem =
26+
this.findItem(menuItemIdForFragmentAtIndex(index)) ?: this.add(
27+
Menu.NONE,
28+
menuItemIdForFragmentAtIndex(index),
29+
Menu.NONE,
30+
tabsScreen.tabTitle,
31+
)

0 commit comments

Comments
 (0)