Skip to content

[iOS 26/27] VoiceOver keeps the selected trait on every visited tab when tabs have icons #590

Description

@jmortizsilva

Bug summary

On iOS 27, VoiceOver keeps the selected accessibility trait on every tab that has been visited, instead of moving it to the active one. After visiting all three tabs of my app, swiping across the tab bar announces all three as "selected". The visual state is always correct — only the accessibility trait is wrong, so this is invisible unless you actually listen to it.

The trigger is tabBarIcon. With icons removed (text-only tabs) the trait behaves correctly; putting the icons back brings the bug straight back. I verified this on a real device with three over-the-air updates over the same binary, changing one thing at a time:

# Change Result
1 no tabBarStyle and no tabBarIcon correct
2 icons back, still no tabBarStyle bug returns
3 same app on a different tab library, icons present correct

Step 2 is what isolates it: with the background already gone, the icons were the only variable left.

What first pointed me at the icons was comparing two of my own apps: both use createNativeBottomTabNavigator from @bottom-tabs/react-navigation 1.4.0, unpatched, on the same iPhone running iOS 27. One misbehaves, the other does not — and the one that does not declares no tabBarIcon on any tab.

Where I think it comes from

ios/TabViewImpl.swift reconfigures the bar from a SwiftUI introspection that runs on every layout pass, and it writes to the UITabBarItems twice, once synchronously and once deferred:

private func updateTabBarItemImages(props: TabViewProps, tabBar: UITabBar?) {
  guard let tabBar, let items = tabBar.items else { return }

  configureTabBarItemImages(items: items, props: props)

  DispatchQueue.main.async { [weak tabBar] in
    guard let tabBar, let items = tabBar.items else { return }
    configureTabBarItemImages(items: items, props: props)
  }
}

Inside configureTabBarItemImages, the part that only runs when an icon exists:

if let icon {
  item.image = renderTabBarIcon(icon, …)
  item.selectedImage = renderTabBarIcon(selectedIcon, …)
}

Reassigning image / selectedImage makes UIKit rebuild that tab's button. That was harmless before, but on iOS 26/27 the bar is driven by UITab objects and the new shouldSelectTab(_:) delegate — the one this library already had to special-case in ios/TabItemEventModifier.swift:

if #available(iOS 27.0, *) {
  // iOS 27 routes SwiftUI TabView selection through shouldSelectTab.
  return true
}

Rebuilding the buttons behind that mechanism, twice per pass, looks like where the .selected trait is left on the old button rather than cleared. It also matches when it shows up: the trait gets stuck the first time each tab is opened, which is when the lazy scene mounts and triggers another layout pass.

I could not find any way to avoid it from JavaScript: that block runs whenever an icon is present, SF Symbol or image.

Library version

react-native-bottom-tabs 1.4.0 / @bottom-tabs/react-navigation 1.4.0

Environment info

  • iOS 27 (real device, not simulator — VoiceOver traits cannot be checked in the simulator)
  • react-native 0.86.2, New Architecture
  • Expo SDK 57 (~57.0.15), react 19.2.3
  • react-native-screens ~4.26.0

Steps to reproduce

  1. Build an app with createNativeBottomTabNavigator and three tabs, each with a tabBarIcon.
  2. Run it on a device with iOS 27 and turn VoiceOver on.
  3. Swipe through the tab bar: only the first tab is announced as selected. Correct so far.
  4. Double-tap the second tab, then swipe back across the bar.
  5. Expected: only tab 2 is announced as selected.
    Actual: tabs 1 and 2 are both announced as selected.
  6. Repeat with the third tab: all three are announced as selected.
  7. Remove every tabBarIcon and repeat — the trait now moves correctly.

Reproducible sample code

const Tab = createNativeBottomTabNavigator();

// Bug present. Remove the three `tabBarIcon` lines and it goes away.
<Tab.Navigator>
  <Tab.Screen
    name="Home"
    component={HomeScreen}
    options={{ tabBarLabel: 'Today', tabBarIcon: () => ({ sfSymbol: 'sun.max.fill' }) }}
  />
  <Tab.Screen
    name="Places"
    component={PlacesScreen}
    options={{ tabBarLabel: 'Places', tabBarIcon: () => ({ sfSymbol: 'list.bullet' }) }}
  />
  <Tab.Screen
    name="Alerts"
    component={AlertsScreen}
    options={{ tabBarLabel: 'Alerts', tabBarIcon: () => ({ sfSymbol: 'bell.fill' }) }}
  />
</Tab.Navigator>

No custom tint colors or tabBarStyle are needed to reproduce it; I had both at first and removing them changed nothing on its own.

Notes

  • Not verified beyond this shape: more than five tabs (system "More" tab), iPad sidebar, Android.
  • Happy to test a patch on a real iOS 27 device if that helps.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions