From 99b5e34e065d13f38fdf89b936d1b71759a33e22 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Fri, 3 Jul 2026 12:03:07 +0100 Subject: [PATCH 1/2] fix ios lazy tab first-load flash --- .changeset/lazy-placeholders-style.md | 6 +++ apps/example/src/Examples/LazyTabs.tsx | 16 +++++++ .../src/Examples/NativeBottomTabsLazy.tsx | 8 ++++ apps/example/src/Screens/SolidColor.tsx | 21 +++++++++ .../ios/TabItemEventModifier.swift | 39 +++++++++++++++- .../ios/TabViewImpl.swift | 4 +- .../react-native-bottom-tabs/src/TabView.tsx | 46 ++++++++++++++++--- 7 files changed, 131 insertions(+), 9 deletions(-) create mode 100644 .changeset/lazy-placeholders-style.md create mode 100644 apps/example/src/Screens/SolidColor.tsx diff --git a/.changeset/lazy-placeholders-style.md b/.changeset/lazy-placeholders-style.md new file mode 100644 index 00000000..231e732b --- /dev/null +++ b/.changeset/lazy-placeholders-style.md @@ -0,0 +1,6 @@ +--- +'react-native-bottom-tabs': patch +'@bottom-tabs/react-navigation': patch +--- + +Avoid iOS lazy tab flashes by styling lazy placeholders and disabling native page transitions while lazy tabs are still unloaded. diff --git a/apps/example/src/Examples/LazyTabs.tsx b/apps/example/src/Examples/LazyTabs.tsx index 00bfa722..4ab5e571 100644 --- a/apps/example/src/Examples/LazyTabs.tsx +++ b/apps/example/src/Examples/LazyTabs.tsx @@ -3,11 +3,15 @@ import { useState } from 'react'; import { Article } from '../Screens/Article'; import { Albums } from '../Screens/Albums'; import { Contacts } from '../Screens/Contacts'; +import { Chat } from '../Screens/Chat'; +import { SolidColor } from '../Screens/SolidColor'; const renderScene = SceneMap({ article: Article, albums: Albums, contacts: Contacts, + chat: Chat, + solid: SolidColor, }); export default function LazyTabs() { @@ -35,6 +39,18 @@ export default function LazyTabs() { title: 'Contacts', testID: 'contactsTestID', }, + { + key: 'chat', + focusedIcon: require('../../assets/icons/chat_dark.png'), + title: 'Chat', + testID: 'chatTestID', + }, + { + key: 'solid', + focusedIcon: require('../../assets/icons/person_dark.png'), + title: 'Solid', + testID: 'solidTestID', + }, ]); return ( diff --git a/apps/example/src/Examples/NativeBottomTabsLazy.tsx b/apps/example/src/Examples/NativeBottomTabsLazy.tsx index 3a1e3bce..034a054f 100644 --- a/apps/example/src/Examples/NativeBottomTabsLazy.tsx +++ b/apps/example/src/Examples/NativeBottomTabsLazy.tsx @@ -3,6 +3,7 @@ import { Albums } from '../Screens/Albums'; import { Contacts } from '../Screens/Contacts'; import { Chat } from '../Screens/Chat'; import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation'; +import { SolidColor } from '../Screens/SolidColor'; const Tab = createNativeBottomTabNavigator(); @@ -38,6 +39,13 @@ export default function NativeBottomTabsLazy() { tabBarIcon: () => require('../../assets/icons/chat_dark.png'), }} /> + require('../../assets/icons/newspaper.svg'), + }} + /> ); } diff --git a/apps/example/src/Screens/SolidColor.tsx b/apps/example/src/Screens/SolidColor.tsx new file mode 100644 index 00000000..e759515a --- /dev/null +++ b/apps/example/src/Screens/SolidColor.tsx @@ -0,0 +1,21 @@ +import { StyleSheet, Text, View } from 'react-native'; + +export function SolidColor() { + return ( + + Solid Color + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'black', + }, + text: { + color: 'red', + }, +}); diff --git a/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift b/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift index 674e602d..827463be 100644 --- a/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift +++ b/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift @@ -8,6 +8,7 @@ import UIKit private final class TabBarDelegate: NSObject, UITabBarControllerDelegate { var onClick: ((_ index: Int?, _ identifier: String?) -> Bool)? + weak var props: TabViewProps? func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { if #available(iOS 27.0, *) { @@ -71,9 +72,36 @@ private final class TabBarDelegate: NSObject, UITabBarControllerDelegate { $0 === tab || $0.identifier == tab.identifier } } + + func tabBarController( + _ tabBarController: UITabBarController, + animationControllerForTransitionFrom fromVC: UIViewController, + to toVC: UIViewController + ) -> UIViewControllerAnimatedTransitioning? { + props?.disablePageAnimations == true ? DisabledTabTransitionAnimator() : nil + } +} + +private final class DisabledTabTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + 0 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let toView = transitionContext.view(forKey: .to), + let toViewController = transitionContext.viewController(forKey: .to) else { + transitionContext.completeTransition(false) + return + } + + toView.frame = transitionContext.finalFrame(for: toViewController) + transitionContext.containerView.addSubview(toView) + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } } struct TabItemEventModifier: ViewModifier { + @ObservedObject var props: TabViewProps let onTabEvent: (_ index: Int?, _ identifier: String?, _ isLongPress: Bool) -> Bool private let delegate = TabBarDelegate() @@ -85,6 +113,7 @@ struct TabItemEventModifier: ViewModifier { } func handle(tabController: UITabBarController) { + delegate.props = props delegate.onClick = { index, identifier in onTabEvent(index, identifier, false) } @@ -155,8 +184,14 @@ extension View { /** Event for tab items. Returns true if should prevent default (switching tabs). */ - func onTabItemEvent(_ handler: @escaping (Int?, String?, Bool) -> Bool) -> some View { - modifier(TabItemEventModifier(onTabEvent: handler)) + func onTabItemEvent( + props: TabViewProps, + _ handler: @escaping (Int?, String?, Bool) -> Bool + ) -> some View { + modifier(TabItemEventModifier( + props: props, + onTabEvent: handler + )) } } diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift index dee9afa4..355cf5d0 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift @@ -46,7 +46,9 @@ struct TabViewImpl: View { tabContent .tabBarMinimizeBehavior(props.minimizeBehavior) #if !os(tvOS) && !os(macOS) && !os(visionOS) - .onTabItemEvent { index, identifier, isLongPress in + .onTabItemEvent( + props: props + ) { index, identifier, isLongPress in let item = identifier.flatMap { props.filteredItems.findByKey($0) } ?? index.flatMap { props.filteredItems[safe: $0] } guard let key = item?.key else { return false } diff --git a/packages/react-native-bottom-tabs/src/TabView.tsx b/packages/react-native-bottom-tabs/src/TabView.tsx index d9a1ccab..07511603 100644 --- a/packages/react-native-bottom-tabs/src/TabView.tsx +++ b/packages/react-native-bottom-tabs/src/TabView.tsx @@ -299,12 +299,13 @@ const TabView = ({ } return navigationState.routes; }, [navigationState.routes]); + const shouldDeferUnloadedLazySelection = + Platform.OS === 'ios' && parseFloat(String(Platform.Version)) >= 26; /** * List of loaded tabs, tabs will be loaded when navigated to. */ const [loaded, setLoaded] = React.useState([focusedKey]); - if (!loaded.includes(focusedKey)) { // Set the current tab to be loaded if it was not loaded before setLoaded((loaded) => [...loaded, focusedKey]); @@ -347,7 +348,11 @@ const TabView = ({ hidden: getHidden?.({ route }), testID: getTestID?.({ route }), role: getRole?.({ route }), - preventsDefault: getPreventsDefault?.({ route }), + preventsDefault: + getPreventsDefault?.({ route }) || + (shouldDeferUnloadedLazySelection && + getLazy({ route }) !== false && + !loaded.includes(route.key)), }; }), [ @@ -363,6 +368,9 @@ const TabView = ({ getTestID, getRole, getPreventsDefault, + getLazy, + loaded, + shouldDeferUnloadedLazySelection, ] ); @@ -384,6 +392,12 @@ const TabView = ({ onIndexChange(index); }); + const hasUnloadedLazyRoute = + shouldDeferUnloadedLazySelection && + trimmedRoutes.some( + (route) => getLazy({ route }) !== false && !loaded.includes(route.key) + ); + const handleTabLongPress = React.useCallback( ({ nativeEvent: { key } }: { nativeEvent: OnPageSelectedEventData }) => { const index = trimmedRoutes.findIndex((route) => route.key === key); @@ -394,9 +408,22 @@ const TabView = ({ const handlePageSelected = React.useCallback( ({ nativeEvent: { key } }: { nativeEvent: OnPageSelectedEventData }) => { + const route = trimmedRoutes.find((route) => route.key === key); + const preventsDefault = route ? getPreventsDefault({ route }) : false; + + if (route && !preventsDefault) { + setLoaded((loaded) => + loaded.includes(key) ? loaded : [...loaded, key] + ); + } + jumpTo(key); }, - [jumpTo] + [ + getPreventsDefault, + jumpTo, + trimmedRoutes, + ] ); const handleTabBarMeasured = React.useCallback( @@ -442,18 +469,27 @@ const TabView = ({ activeTintColor={activeTintColor} inactiveTintColor={inactiveTintColor} experimentalBakedTintColors={experimentalBakedTintColors} + disablePageAnimations={ + props.disablePageAnimations || hasUnloadedLazyRoute + } barTintColor={tabBarStyle?.backgroundColor} rippleColor={rippleColor} labeled={labeled} > {trimmedRoutes.map((route) => { + const customStyle = getSceneStyle({ route }); + if (getLazy({ route }) !== false && !loaded.includes(route.key)) { // Don't render a screen if we've never navigated to it return ( ); } @@ -461,8 +497,6 @@ const TabView = ({ const focused = route.key === focusedKey; const freeze = !focused ? getFreezeOnBlur({ route }) : false; - const customStyle = getSceneStyle({ route }); - return ( Date: Tue, 7 Jul 2026 09:10:23 +0100 Subject: [PATCH 2/2] Revert "fix ios lazy tab first-load flash" This reverts commit 99b5e34e065d13f38fdf89b936d1b71759a33e22. --- .changeset/lazy-placeholders-style.md | 6 --- apps/example/src/Examples/LazyTabs.tsx | 16 ------- .../src/Examples/NativeBottomTabsLazy.tsx | 8 ---- apps/example/src/Screens/SolidColor.tsx | 21 --------- .../ios/TabItemEventModifier.swift | 39 +--------------- .../ios/TabViewImpl.swift | 4 +- .../react-native-bottom-tabs/src/TabView.tsx | 46 +++---------------- 7 files changed, 9 insertions(+), 131 deletions(-) delete mode 100644 .changeset/lazy-placeholders-style.md delete mode 100644 apps/example/src/Screens/SolidColor.tsx diff --git a/.changeset/lazy-placeholders-style.md b/.changeset/lazy-placeholders-style.md deleted file mode 100644 index 231e732b..00000000 --- a/.changeset/lazy-placeholders-style.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'react-native-bottom-tabs': patch -'@bottom-tabs/react-navigation': patch ---- - -Avoid iOS lazy tab flashes by styling lazy placeholders and disabling native page transitions while lazy tabs are still unloaded. diff --git a/apps/example/src/Examples/LazyTabs.tsx b/apps/example/src/Examples/LazyTabs.tsx index 4ab5e571..00bfa722 100644 --- a/apps/example/src/Examples/LazyTabs.tsx +++ b/apps/example/src/Examples/LazyTabs.tsx @@ -3,15 +3,11 @@ import { useState } from 'react'; import { Article } from '../Screens/Article'; import { Albums } from '../Screens/Albums'; import { Contacts } from '../Screens/Contacts'; -import { Chat } from '../Screens/Chat'; -import { SolidColor } from '../Screens/SolidColor'; const renderScene = SceneMap({ article: Article, albums: Albums, contacts: Contacts, - chat: Chat, - solid: SolidColor, }); export default function LazyTabs() { @@ -39,18 +35,6 @@ export default function LazyTabs() { title: 'Contacts', testID: 'contactsTestID', }, - { - key: 'chat', - focusedIcon: require('../../assets/icons/chat_dark.png'), - title: 'Chat', - testID: 'chatTestID', - }, - { - key: 'solid', - focusedIcon: require('../../assets/icons/person_dark.png'), - title: 'Solid', - testID: 'solidTestID', - }, ]); return ( diff --git a/apps/example/src/Examples/NativeBottomTabsLazy.tsx b/apps/example/src/Examples/NativeBottomTabsLazy.tsx index 034a054f..3a1e3bce 100644 --- a/apps/example/src/Examples/NativeBottomTabsLazy.tsx +++ b/apps/example/src/Examples/NativeBottomTabsLazy.tsx @@ -3,7 +3,6 @@ import { Albums } from '../Screens/Albums'; import { Contacts } from '../Screens/Contacts'; import { Chat } from '../Screens/Chat'; import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation'; -import { SolidColor } from '../Screens/SolidColor'; const Tab = createNativeBottomTabNavigator(); @@ -39,13 +38,6 @@ export default function NativeBottomTabsLazy() { tabBarIcon: () => require('../../assets/icons/chat_dark.png'), }} /> - require('../../assets/icons/newspaper.svg'), - }} - /> ); } diff --git a/apps/example/src/Screens/SolidColor.tsx b/apps/example/src/Screens/SolidColor.tsx deleted file mode 100644 index e759515a..00000000 --- a/apps/example/src/Screens/SolidColor.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { StyleSheet, Text, View } from 'react-native'; - -export function SolidColor() { - return ( - - Solid Color - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'black', - }, - text: { - color: 'red', - }, -}); diff --git a/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift b/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift index 827463be..674e602d 100644 --- a/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift +++ b/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift @@ -8,7 +8,6 @@ import UIKit private final class TabBarDelegate: NSObject, UITabBarControllerDelegate { var onClick: ((_ index: Int?, _ identifier: String?) -> Bool)? - weak var props: TabViewProps? func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { if #available(iOS 27.0, *) { @@ -72,36 +71,9 @@ private final class TabBarDelegate: NSObject, UITabBarControllerDelegate { $0 === tab || $0.identifier == tab.identifier } } - - func tabBarController( - _ tabBarController: UITabBarController, - animationControllerForTransitionFrom fromVC: UIViewController, - to toVC: UIViewController - ) -> UIViewControllerAnimatedTransitioning? { - props?.disablePageAnimations == true ? DisabledTabTransitionAnimator() : nil - } -} - -private final class DisabledTabTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { - func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { - 0 - } - - func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { - guard let toView = transitionContext.view(forKey: .to), - let toViewController = transitionContext.viewController(forKey: .to) else { - transitionContext.completeTransition(false) - return - } - - toView.frame = transitionContext.finalFrame(for: toViewController) - transitionContext.containerView.addSubview(toView) - transitionContext.completeTransition(!transitionContext.transitionWasCancelled) - } } struct TabItemEventModifier: ViewModifier { - @ObservedObject var props: TabViewProps let onTabEvent: (_ index: Int?, _ identifier: String?, _ isLongPress: Bool) -> Bool private let delegate = TabBarDelegate() @@ -113,7 +85,6 @@ struct TabItemEventModifier: ViewModifier { } func handle(tabController: UITabBarController) { - delegate.props = props delegate.onClick = { index, identifier in onTabEvent(index, identifier, false) } @@ -184,14 +155,8 @@ extension View { /** Event for tab items. Returns true if should prevent default (switching tabs). */ - func onTabItemEvent( - props: TabViewProps, - _ handler: @escaping (Int?, String?, Bool) -> Bool - ) -> some View { - modifier(TabItemEventModifier( - props: props, - onTabEvent: handler - )) + func onTabItemEvent(_ handler: @escaping (Int?, String?, Bool) -> Bool) -> some View { + modifier(TabItemEventModifier(onTabEvent: handler)) } } diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift index 355cf5d0..dee9afa4 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift @@ -46,9 +46,7 @@ struct TabViewImpl: View { tabContent .tabBarMinimizeBehavior(props.minimizeBehavior) #if !os(tvOS) && !os(macOS) && !os(visionOS) - .onTabItemEvent( - props: props - ) { index, identifier, isLongPress in + .onTabItemEvent { index, identifier, isLongPress in let item = identifier.flatMap { props.filteredItems.findByKey($0) } ?? index.flatMap { props.filteredItems[safe: $0] } guard let key = item?.key else { return false } diff --git a/packages/react-native-bottom-tabs/src/TabView.tsx b/packages/react-native-bottom-tabs/src/TabView.tsx index 07511603..d9a1ccab 100644 --- a/packages/react-native-bottom-tabs/src/TabView.tsx +++ b/packages/react-native-bottom-tabs/src/TabView.tsx @@ -299,13 +299,12 @@ const TabView = ({ } return navigationState.routes; }, [navigationState.routes]); - const shouldDeferUnloadedLazySelection = - Platform.OS === 'ios' && parseFloat(String(Platform.Version)) >= 26; /** * List of loaded tabs, tabs will be loaded when navigated to. */ const [loaded, setLoaded] = React.useState([focusedKey]); + if (!loaded.includes(focusedKey)) { // Set the current tab to be loaded if it was not loaded before setLoaded((loaded) => [...loaded, focusedKey]); @@ -348,11 +347,7 @@ const TabView = ({ hidden: getHidden?.({ route }), testID: getTestID?.({ route }), role: getRole?.({ route }), - preventsDefault: - getPreventsDefault?.({ route }) || - (shouldDeferUnloadedLazySelection && - getLazy({ route }) !== false && - !loaded.includes(route.key)), + preventsDefault: getPreventsDefault?.({ route }), }; }), [ @@ -368,9 +363,6 @@ const TabView = ({ getTestID, getRole, getPreventsDefault, - getLazy, - loaded, - shouldDeferUnloadedLazySelection, ] ); @@ -392,12 +384,6 @@ const TabView = ({ onIndexChange(index); }); - const hasUnloadedLazyRoute = - shouldDeferUnloadedLazySelection && - trimmedRoutes.some( - (route) => getLazy({ route }) !== false && !loaded.includes(route.key) - ); - const handleTabLongPress = React.useCallback( ({ nativeEvent: { key } }: { nativeEvent: OnPageSelectedEventData }) => { const index = trimmedRoutes.findIndex((route) => route.key === key); @@ -408,22 +394,9 @@ const TabView = ({ const handlePageSelected = React.useCallback( ({ nativeEvent: { key } }: { nativeEvent: OnPageSelectedEventData }) => { - const route = trimmedRoutes.find((route) => route.key === key); - const preventsDefault = route ? getPreventsDefault({ route }) : false; - - if (route && !preventsDefault) { - setLoaded((loaded) => - loaded.includes(key) ? loaded : [...loaded, key] - ); - } - jumpTo(key); }, - [ - getPreventsDefault, - jumpTo, - trimmedRoutes, - ] + [jumpTo] ); const handleTabBarMeasured = React.useCallback( @@ -469,27 +442,18 @@ const TabView = ({ activeTintColor={activeTintColor} inactiveTintColor={inactiveTintColor} experimentalBakedTintColors={experimentalBakedTintColors} - disablePageAnimations={ - props.disablePageAnimations || hasUnloadedLazyRoute - } barTintColor={tabBarStyle?.backgroundColor} rippleColor={rippleColor} labeled={labeled} > {trimmedRoutes.map((route) => { - const customStyle = getSceneStyle({ route }); - if (getLazy({ route }) !== false && !loaded.includes(route.key)) { // Don't render a screen if we've never navigated to it return ( ); } @@ -497,6 +461,8 @@ const TabView = ({ const focused = route.key === focusedKey; const freeze = !focused ? getFreezeOnBlur({ route }) : false; + const customStyle = getSceneStyle({ route }); + return (