Skip to content

refactor(Android, Tabs): bit-flag container invalidation flags, re-apply only invalidated tab bar parts - #5011

Draft
kkafar wants to merge 3 commits into
@kkafar/rfc-1823-android-tabs-batch-item-updatesfrom
claude/project-thread-4pvvbj
Draft

kkafar wants to merge 3 commits into
@kkafar/rfc-1823-android-tabs-batch-item-updatesfrom
claude/project-thread-4pvvbj

Conversation

@kkafar

@kkafar kkafar commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

Requested by Kacper · project thread

Description

RFC-1823 A6 follow-up. Two steps, one commit each, plus a fix that the device run surfaced.

  1. TabsContainerInvalidationFlags was a class of four mutable booleans. It is now a @JvmInline value class over bit flags, the same shape as StackHeaderInvalidationFlags and TabsItemInvalidationFlags. No behaviour change.
  2. Every bar-wide change (tab selection, tabBarHidden, an icon size, the selected tab's appearance, the colour scheme) ran the same whole-bar pass: icon box, visibility, shared appearance, badges and accessibility of every menu item, then fonts. The single appearance flag is now split into the parts that pass applied, and TabsContainer.updateTabBarIfNeeded applies each part only when its flag is set, like StackHeaderCoordinatorLayout.processUpdate.

Closes https://github.com/software-mansion/react-native-screens-labs/issues/1976.

Stacked on #4976 at 4ff4e0f (rebased onto main after #4975 was merged as 95bb9d2), including its ContainerUpdateScheduler commits. This PR changes what an update applies; #4976 decides when it runs.

Changes

Step 1 (3cd2f4d)

  • New container/TabsContainerInvalidationFlags.kt: NONE, SELECTED_TAB, NAVIGATION_MENU_STRUCTURE, NAVIGATION_MENU_APPEARANCE, MENU_ITEMS, ALL, plus or, and, without, containsAny and isEmpty. and and without are new compared with the other two classes: the container clears flags per update phase.
  • TabsContainer keeps the flags in a private var (it was internal val, with no users outside the class). invalidate(flags) records them and requests an update from the scheduler; recordInvalidation(flags) only records, for the constructor and for invalidations raised by an update that applies them in the same pass. takeInvalidationFlags(mask) clears what each phase applies.

Step 2 (aac9216)

Trigger Before After
Tab selected whole-bar pass only the parts of the new tab's appearance that differ from what the bar shows; nothing if the tabs look the same
Selected tab's appearance changed whole-bar pass only the differing parts (shared appearance, badge colours, title fonts)
tabBarItemIconSize / active indicator size of any tab whole-bar pass icon box, then that menu item's icon and indicator (plus every icon if the box changed)
tabBarHidden whole-bar pass visibility only
Colour scheme whole-bar pass shared appearance and badge colours (the parts with theme colour defaults)
Tab added / removed every flag, incl. a selection pass without a request menu structure and icon box; a real rebuild invalidates all menu items and the fonts of the new item views
  • New flags: TAB_BAR_VISIBILITY, ICON_BOX, SELECTED_APPEARANCE, SHARED_APPEARANCE, BADGE_APPEARANCE, TITLE_FONTS; groups THEME, BAR_WIDE, TAB_BAR.
  • SELECTED_APPEARANCE compares the selected tab's appearance with appliedAppearance (what the bar shows) through hasSameSharedAppearanceAs / hasSameBadgeAppearanceAs / hasSameTitleFontAs (TabsAppearanceModel.kt), which split TabsAppearance's fields by where they are applied. Icon size and indicator size stay per tab.
  • TabsAppearanceCoordinator.updateBarAppearance is split into updateIconBox, updateTabBarVisibility and updateSharedAppearance; the menu items read the applied icon box (iconBoxDp). TabsAppearanceApplicator.updateSharedAppearance no longer sets visibility (applyTabBarVisibility).
  • The menu items loop in updateTabBarIfNeeded looks items up with bottomNavigationView.requireMenuItem(fragment.menuItemId). getMenuItemForItem is removed: its only caller (updateMenuItem) is folded into the tab bar pass. The comments on onItemInvalidated and TabsMenuItemState.consumePendingFlags now name the tab bar update instead of the whole-bar one.
  • Accessibility is no longer re-applied to every menu item on each bar pass. Material 1.14 replaces item views only when the menu is rebuilt (NavigationBarMenuView.buildMenuView, reached only from our menu.clear() / menu.add()), and our rebuild resets the menu item states to fully dirty. The content description lives on the MenuItem, which initialize() re-reads.
  • Size and appearance changes raised while detached are recorded and flushed on attach, instead of being dropped and relying on the attach-time colour-scheme callback re-running the whole bar.
  • The tab bar pass waits, keeping its flags, until a tab is selected and the menu matches the tabs. (Before, a pass with no selection threw in selectedTab, and a colour-scheme pass between adding a tab and the menu rebuild threw on the missing MenuItem.)
  • Every invalidation from outside an update goes through invalidate(), so it requests an update; onItemInvalidated no longer requests one at its end.

Fix: active indicator bounds (fc108a1)

  • Material masks each item's ripple with the item's active indicator drawable (NavigationBarItemView.refreshItemBackground), so laying out the ripple sets that drawable's bounds to the icon container. The indicator view resets its background's bounds only when its own size changes.
  • With an icon box larger than a tab's indicator, the indicator was then redrawn as tall as the icon box (for example 56x52 instead of 56x36). The whole-bar pass hid this by re-creating the indicator drawables on every update (setItemActiveIndicatorColor). Step 2 stopped doing that, so the stale bounds showed on mount and after any relayout.
  • TabsBottomNavigationView now restores each indicator's bounds before every draw (a pre-draw listener while attached) and redraws the indicator view when they had drifted. The listener runs on every frame of the window: one findViewById and a bounds comparison per menu item.

Left out (suggestions)

  • Menu rebuilds. updateNavigationMenuStructure clears and re-adds every menu item, and Material rebuilds all item views on each add (n + 1 rebuilds), dropping every item's state. Diffing the menu and suspending presenter updates would need restricted MenuBuilder APIs; tab sets rarely change at runtime.
  • Colour scheme on attach. The attach flush runs before ColorSchemeCoordinator.setup, whose callback then re-applies the theme parts under the resolved theme: two passes for those parts. Setting up the colour scheme first would make it one, but changes when nested containers resolve their scheme. The callback also still applies synchronously inside a batch.
  • Finer shared appearance. SHARED_APPEARANCE re-applies colours, label mode, ripple and indicator together. Material already skips an unchanged label visibility mode, and the colour setters are cheap.
  • Shared flag shape. There are now three copies of the same value-class shape. A common base isn't possible without boxing (value classes can't extend classes, and an interface boxes), so it stays duplicated.
  • Ripple mask. The ripple and the indicator share one drawable, so its bounds fit only one of them. With the fix, a press ripple on a tab whose indicator is smaller than the icon box is indicator-sized but anchored at the top of the icon container (inferred, not observed: no example screen combines an explicit ripple colour with such a tab). Giving the ripple its own mask would fix that and remove the per-frame check, but means replacing Material's ripple every time refreshItemBackground rebuilds it.
  • Default ripple (pre-existing, not from this PR). With no tabBarItemRippleColor, updateSharedAppearance resolves R.attr.itemRippleColor as a theme attribute. It is a BottomNavigationView styleable attribute, so it falls back to transparent and the default tab bar has no visible touch ripple (logcat: "Failed to resolve color attribute. Falling back to 0."). Same on main; worth its own fix.

Test plan

  • ktlint 1.3.0 (the version spotless.gradle pins) on android/src/**/*.kt and node scripts/check-android-layers.js: pass (cloud, on fc108a1).
  • CI is not triggered for PRs stacked on a branch, so it is dispatched by hand on claude/project-thread-4pvvbj.
  • Device runs, emulator Pixel 3a API 34, release build of FabricExample:
    • On 8ba2f20 (step 2 before the rebases; it differs from step 2 now only by the base's menu item lookup, visibility and comment changes): Detox 69 passed, 1 IME-insets timing flake (passed 5/5 twice alone). The pill regression was found and bisected here: base and step 1 fine, step 2 not.
    • On 826ccbd: Detox (12 Android tabs files, plus bottomTabs and nativeBottomTabs) 70 passed, 0 failed, 28 skipped (iOS-only describes); logcat crash buffer empty.

On a device (826ccbd):

  • Manual test-tabs-appearance-defined-by-selected-tab: colours (sampled from the framebuffer), label mode, badge colours and fonts follow the selected tab on every switch, by tap and programmatically; 18 rapid mixed switches end in the right state.
  • Manual test-tabs-item-icon-tint-and-size: drawn pill equals the indicator view at about 50 points (on mount, every tab switch, runtime icon size 32 / 56 / default, indicator 96x64 / auto, JS picker open / close, background / foreground, light / dark). Sizes: Sized 71x52, Multicolor 70x38, Indicator 56x36, Image 64x32, Controls 64x32. With a 64dp icon box every tab keeps its own pill and the bar height grows and returns.
  • Manual test-tabs-general-appearance-android: tabBarHidden toggles; testIDs and content descriptions are stable across tab switches and all label modes. The explicit-colour ripple on the Indicator tab is a pill exactly over the indicator, selected or not.
  • Manual light / dark: system night mode × RN scheme × TabsHost scheme, 20 states; badge colours follow the theme unless set explicitly.
  • Badges: runtime badge toggle and per-tab badge colours. In-place appearance change of the selected tab (Test4258), including remount while detached: applies, reverts, no crash.

Checklist

  • Included code example that can be used to test this change.
  • For visual changes, included screenshots / GIFs / recordings documenting the change.
  • For API changes, updated relevant public types.
  • Ensured that CI passes

🤖 Generated with Claude Code

https://claude.ai/code/session_01P3v2mWCBgU7pKxJjUc3sMB

@kkafar kkafar self-assigned this Oct 21, 2026
@coderabbitai

coderabbitai Bot commented Oct 21, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kkafar
kkafar force-pushed the claude/project-thread-4pvvbj branch from 5a1d5e0 to 8ba2f20 Compare October 21, 2026 18:23
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from ac9dc4e to 8bce222 Compare October 21, 2026 18:28
@kkafar
kkafar force-pushed the claude/project-thread-4pvvbj branch from 8ba2f20 to 92b11d3 Compare October 21, 2026 18:32
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch 2 times, most recently from 0fa6b66 to 9d8597d Compare October 21, 2026 18:38
@kkafar
kkafar force-pushed the claude/project-thread-4pvvbj branch from 92b11d3 to 5e2f94f Compare October 21, 2026 18:39
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from 9d8597d to a5d4810 Compare October 21, 2026 18:46
@kkafar
kkafar force-pushed the claude/project-thread-4pvvbj branch from 5e2f94f to 181baba Compare October 21, 2026 18:48
@kkafar
kkafar force-pushed the @kkafar/rfc-1823-android-tabs-batch-item-updates branch from f4283d0 to 4ff4e0f Compare October 22, 2026 09:23
… invalidation flags

TabsContainerInvalidationFlags was a class of four mutable booleans. It is now a
value class over bit flags, like StackHeaderInvalidationFlags and
TabsItemInvalidationFlags, in its own file. TabsContainer keeps the pending flags
in a private var, raises them with invalidate(flags), which also requests an
update from the ContainerUpdateScheduler, or with recordInvalidation(flags) where
no request is made (the constructor and invalidations raised during an update),
and clears what each update phase applies with takeInvalidationFlags(mask).
No behaviour change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3v2mWCBgU7pKxJjUc3sMB
…ab bar

Any bar-wide change (tab selection, tabBarHidden, an icon size, the selected
tab's appearance, the colour scheme) ran the same whole-bar pass: icon box,
visibility, shared appearance, badges and accessibility of every menu item, and
fonts. The single NAVIGATION_MENU_APPEARANCE flag is split into the parts that
pass applied, and TabsContainer.updateTabBarIfNeeded applies each one only when
its flag is set, like StackHeaderCoordinatorLayout.processUpdate:

- TAB_BAR_VISIBILITY: tabBarHidden only toggles visibility.
- ICON_BOX: icon / active indicator size changes and tab set changes re-resolve
  the shared icon box; the changed menu item applies its own icon and indicator.
- SELECTED_APPEARANCE: a selection change or an appearance change of the
  selected tab compares the selected appearance with the one the bar shows and
  re-applies only the parts that differ: SHARED_APPEARANCE (bar colours, label
  visibility mode, ripple, active indicator), BADGE_APPEARANCE (badge colours of
  every item) and TITLE_FONTS. Switching between tabs that look the same applies
  nothing bar-wide.
- The colour scheme re-applies only THEME (shared appearance and badge colours),
  the parts with theme colour defaults.

Accessibility is no longer re-applied to every menu item on each bar pass:
Material replaces item views only when the menu is rebuilt, and a rebuild
resets the menu item states (fully dirty). A rebuild also invalidates the fonts
of the new item views. Adding or removing a tab no longer invalidates
everything, only the menu structure and the icon box.

Size and appearance changes raised while the container is detached are now
recorded and flushed on attach, instead of relying on the attach-time
colour scheme callback re-applying the whole bar. Nothing is applied before
the first tab is selected or while the menu is out of date; the flags stay
pending. Every container invalidation from outside an update now goes through
invalidate(), which requests the update.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3v2mWCBgU7pKxJjUc3sMB
…le resizes them

Material masks an item's ripple with the item's active indicator drawable,
so laying out the ripple sets that drawable's bounds to the icon container.
The active indicator view resets its background's bounds only when its own
size changes. With an icon box larger than a tab's active indicator, the
indicator was then redrawn as tall as the icon box.

The whole-bar pass hid this by re-creating the indicator drawables on every
selection and appearance update. Since the tab bar re-applies only the parts
that changed, the stale bounds showed on mount and after any relayout of the
bar. TabsBottomNavigationView now restores each indicator's bounds before
every draw and redraws the indicator when they had drifted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P3v2mWCBgU7pKxJjUc3sMB
@kkafar
kkafar force-pushed the claude/project-thread-4pvvbj branch from 826ccbd to fc108a1 Compare October 22, 2026 09:24

kkafar commented Oct 22, 2026

Copy link
Copy Markdown
Member Author

Android e2e on fc108a1 (run): not caused by this PR

Two attempts on the same commit failed in two different places:

  • Attempt 1: test-tabs-tab-bar-layout-direction, the Android RTL describe (5 tests). Its beforeAll failed in device.launchApp({ newInstance: true }): isReady was rejected with "The app has unexpectedly disconnected from Detox server" before any test body ran. The app was launching onto the home screen, so no Tabs host existed yet. The same error at launchApp failed unrelated suites on other branches yesterday: test-stack-toolbar-menu-commands-android (run) and Test640 (run). In attempt 2 this suite passed.
  • Attempt 2: Test658 failed 2 of 5 tests. The JS thread (mqt_v_js) stayed busy for two minutes while opening modals, and the closing test timed out the same way. Test658 is a v4 native stack with modals only, so none of the classes this PR changes are created. It passed in attempt 1 (51 s). Its 4 minutes of Detox timeouts then pushed the job past timeout-minutes: 60 during the last 7 test files, which passed (or were skipped as iOS-only) in attempt 1.

So every Android e2e file has passed on fc108a1 in one of the two attempts, and the e2e run on the base (4ff4e0f, run) is green. This diff only changes Kotlin classes under core/containers/tabs, which only a Tabs host creates.

No fix exists for either problem yet. For the timeout, I'm proposing this patch rather than adding it here, since it's CI config. Green runs already take 55 to 59 minutes (for example this run on main), so one slow file is enough to hit the cap:

--- a/.github/workflows/android-e2e-test-fabric.yml
+++ b/.github/workflows/android-e2e-test-fabric.yml
@@ jobs:
   test:
     runs-on: ubuntu-latest
-    timeout-minutes: 60
+    timeout-minutes: 75

The launch disconnect needs its own investigation. The uploaded artifact holds only screenshots, so the app's log isn't available from CI. I've used my one re-run, so I won't trigger another.


Generated by Claude Code

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants