Skip to content

fix(ios): deliver long presses on modern tab bars - #587

Open
thiagobrez wants to merge 1 commit into
mainfrom
codex/duo-tab-long-press
Open

thiagobrez wants to merge 1 commit into
mainfrom
codex/duo-tab-long-press

Conversation

@thiagobrez

@thiagobrez thiagobrez commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator

PR Description

Long presses on modern iOS tab bars select a tab but do not deliver the expected tabLongPress event. Their buttons live outside the legacy UITabBar hierarchy, and UIKit expands and duplicates them during its native hold animation.

Observe touches from the tab controller's view, recognize modern and legacy tab buttons, and capture the route when the touch begins. Resolve vertical and horizontal order (including RTL), ignore the system More destination, and allow UIKit's gestures to continue. Reuse the recognizer while refreshing its callback.

Fixes #573.

How to test?

Open Native Bottom Tabs from Chat and hold Article for one second. Its callback changes the label to New Article. Verify normal Chat selection still works and Contacts still prevents selection.

Verified with agent-device 0.20.10 on:

  • iPhone Duo / iOS 27.1: right vertical tabs and both landscape orientations, including left vertical tabs.
  • iPhone 17 Pro / iOS 26.5: horizontal LTR and RTL tabs. RTL was tested using a temporary example configuration, then restored.

Native Xcode build and lint pass. Workspace tests and typecheck pass on the common upstream base. UIKit tab-button class inspection remains an implementation dependency, as in the previous recognizer.

Screenshots

Before — hold selects Article without its callback After — callback changes the label to New Article
02-long-press-before-no-callback.mp4
02-long-press-after-callback.mp4

@thiagobrez

Copy link
Copy Markdown
Collaborator Author

I tested this with a search role tab. Long press fires tabLongPress on the wrong route for every tab after the search tab on iOS 26 and 27.

Setup: the repro from #596 (Home, Search with role: 'search', Favorites, Library) in a fresh React Native 0.88 app. Each screen has a tabLongPress listener that shows the route it was fired for. I applied this PR to main together with #602, so the tab labels are right, and long-pressed each tab with agent-device. Each cell shows the route that received tabLongPress:

Built with Simulator Favorites Library Search Home
Xcode 27.1 iOS 27.0 Search ❌ Favorites ❌ Library ❌ Home ✅
Xcode 26.6 iOS 27.0 Search ❌ Favorites ❌ Library ❌ Home ✅
Xcode 26.6 iOS 26.5 Search ❌ Favorites ❌ Library ❌ Home ✅
Xcode 27.1 iOS 18.6 Favorites ✅ Library ✅ Search ✅ Home ✅

Selecting the tab still works; only the event goes to the wrong route.

Cause: tabIndex(for:) returns the button's on-screen position. onTabItemEvent then uses it as an index into filteredItems, which is in declaration order. From iOS 26 the search tab is drawn after the other tabs: as a separate button with Xcode 26, or inline at the end with Xcode 27. So the two orders differ. On iOS 18 the search tab stays where it's declared, which is why 18.6 works.

For the fix: looking the position up in tabBar.items isn't enough, because that order depends on the SDK. It's declaration order with Xcode 26 and on-screen order with Xcode 27 (#596; #602 fixes the item styling). The pressed button needs to be matched to its tab some other way than by position, then to that tab's index in tabController.tabs. The More check (items[index]) relies on the same order, so with Xcode 26 builds it can check the wrong item. I found that by reading the code and haven't tested it.

Xcode 27.1, iOS 27.0, after long-pressing Favorites:

Long-pressing Favorites fires tabLongPress for Search

App.tsx
import * as React from 'react';
import { Text, View } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation';

const Tab = createNativeBottomTabNavigator();
const LastLongPress = React.createContext('none');

const screen = (label) => () => {
  const last = React.useContext(LastLongPress);
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text>{label}</Text>
      <Text>{`tabLongPress: ${last}`}</Text>
    </View>
  );
};

const Home = screen('Home screen');
const Search = screen('Search screen');
const Favorites = screen('Favorites screen');
const Library = screen('Library screen');

export default function App() {
  const [last, setLast] = React.useState('none');
  const listeners = (name) => ({ tabLongPress: () => setLast(name) });
  return (
    <LastLongPress.Provider value={last}>
      <NavigationContainer>
        <Tab.Navigator>
          <Tab.Screen
            name="Home"
            component={Home}
            listeners={listeners('Home')}
            options={{ tabBarIcon: () => ({ sfSymbol: 'house.fill' }) }}
          />
          <Tab.Screen
            name="Search"
            component={Search}
            listeners={listeners('Search')}
            options={{
              role: 'search',
              tabBarIcon: () => ({ sfSymbol: 'magnifyingglass' }),
            }}
          />
          <Tab.Screen
            name="Favorites"
            component={Favorites}
            listeners={listeners('Favorites')}
            options={{ tabBarIcon: () => ({ sfSymbol: 'heart.fill' }) }}
          />
          <Tab.Screen
            name="Library"
            component={Library}
            listeners={listeners('Library')}
            options={{ tabBarIcon: () => ({ sfSymbol: 'rectangle.stack.fill' }) }}
          />
        </Tab.Navigator>
      </NavigationContainer>
    </LastLongPress.Provider>
  );
}

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.

[iOS] Tabs don't fire tabLongPress event on iOS 26

1 participant