From a52b01b8afa5fc27207f65906cc5781f9c2d11d3 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Tue, 26 May 2026 08:58:49 +0100 Subject: [PATCH] fix(iOS): fix disablePageAnimations on iOS 26 --- .../ios/TabItemEventModifier.swift | 39 ++++++++++++++++++- .../ios/TabViewImpl.swift | 23 ++++++----- .../ios/TabViewProvider.swift | 13 ++++++- .../react-native-bottom-tabs/src/TabView.tsx | 28 +++++++++++-- 4 files changed, 87 insertions(+), 16 deletions(-) diff --git a/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift b/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift index 42eb1e6..ea336a0 100644 --- a/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift +++ b/packages/react-native-bottom-tabs/ios/TabItemEventModifier.swift @@ -8,6 +8,15 @@ import UIKit private final class TabBarDelegate: NSObject, UITabBarControllerDelegate { var onClick: ((_ index: Int) -> Bool)? + var disablePageAnimations = false + + func tabBarController( + _ tabBarController: UITabBarController, + animationControllerForTransitionFrom fromVC: UIViewController, + to toVC: UIViewController + ) -> UIViewControllerAnimatedTransitioning? { + disablePageAnimations ? DisabledTabTransitionAnimator() : nil + } func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { #if os(iOS) @@ -40,7 +49,32 @@ private final class TabBarDelegate: NSObject, UITabBarControllerDelegate { } } +private final class DisabledTabTransitionAnimator: NSObject, UIViewControllerAnimatedTransitioning { + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + 0 + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + guard let toViewController = transitionContext.viewController(forKey: .to), + let toView = transitionContext.view(forKey: .to) + else { + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + return + } + + toView.frame = transitionContext.finalFrame(for: toViewController) + + UIView.performWithoutAnimation { + transitionContext.containerView.addSubview(toView) + transitionContext.containerView.layoutIfNeeded() + } + + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } +} + struct TabItemEventModifier: ViewModifier { + let disablePageAnimations: Bool let onTabEvent: (_ key: Int, _ isLongPress: Bool) -> Bool private let delegate = TabBarDelegate() @@ -52,6 +86,7 @@ struct TabItemEventModifier: ViewModifier { } func handle(tabController: UITabBarController) { + delegate.disablePageAnimations = disablePageAnimations delegate.onClick = { index in onTabEvent(index, false) } @@ -122,8 +157,8 @@ extension View { /** Event for tab items. Returns true if should prevent default (switching tabs). */ - func onTabItemEvent(_ handler: @escaping (Int, Bool) -> Bool) -> some View { - modifier(TabItemEventModifier(onTabEvent: handler)) + func onTabItemEvent(disablePageAnimations: Bool, _ handler: @escaping (Int, Bool) -> Bool) -> some View { + modifier(TabItemEventModifier(disablePageAnimations: disablePageAnimations, onTabEvent: handler)) } } diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift index 29eb3bb..d91e30a 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift @@ -44,9 +44,10 @@ struct TabViewImpl: View { var body: some View { tabContent + .disableAnimations(props.disablePageAnimations) .tabBarMinimizeBehavior(props.minimizeBehavior) #if !os(tvOS) && !os(macOS) && !os(visionOS) - .onTabItemEvent { index, isLongPress in + .onTabItemEvent(disablePageAnimations: props.disablePageAnimations) { index, isLongPress in let item = props.filteredItems[safe: index] guard let key = item?.key else { return false } @@ -82,14 +83,6 @@ struct TabViewImpl: View { .tintColor(props.selectedActiveTintColor) .getSidebarAdaptable(enabled: props.sidebarAdaptable ?? false) .onChange(of: props.selectedPage ?? "") { newValue in - #if !os(macOS) - if props.disablePageAnimations { - UIView.setAnimationsEnabled(false) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - UIView.setAnimationsEnabled(true) - } - } - #endif #if os(tvOS) || os(macOS) || os(visionOS) onSelect(newValue) #endif @@ -197,6 +190,18 @@ struct TabViewImpl: View { #endif extension View { + @ViewBuilder + func disableAnimations(_ disabled: Bool) -> some View { + if disabled { + self.transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + } else { + self + } + } + @ViewBuilder func getSidebarAdaptable(enabled: Bool) -> some View { if #available(iOS 18.0, macOS 15.0, tvOS 18.0, visionOS 2.0, *) { diff --git a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift index deac524..9599032 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift @@ -85,7 +85,18 @@ public final class TabInfo: NSObject { @objc public var selectedPage: NSString? { didSet { - props.selectedPage = selectedPage as? String + let nextSelectedPage = selectedPage as? String + + if disablePageAnimations { + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + + withTransaction(transaction) { + props.selectedPage = nextSelectedPage + } + } else { + props.selectedPage = nextSelectedPage + } } } diff --git a/packages/react-native-bottom-tabs/src/TabView.tsx b/packages/react-native-bottom-tabs/src/TabView.tsx index 4e93f09..4c2737d 100644 --- a/packages/react-native-bottom-tabs/src/TabView.tsx +++ b/packages/react-native-bottom-tabs/src/TabView.tsx @@ -251,6 +251,7 @@ const TabView = ({ tabLabelStyle, renderBottomAccessoryView, layoutDirection = 'locale', + disablePageAnimations = false, ...props }: Props) => { // @ts-ignore @@ -274,14 +275,29 @@ const TabView = ({ return navigationState.routes; }, [navigationState.routes]); + const routeKeys = React.useMemo( + () => trimmedRoutes.map((route) => route.key), + [trimmedRoutes] + ); + /** * List of loaded tabs, tabs will be loaded when navigated to. */ const [loaded, setLoaded] = React.useState([focusedKey]); - - if (!loaded.includes(focusedKey)) { + const loadedRoutes = disablePageAnimations + ? routeKeys + : loaded.includes(focusedKey) + ? loaded + : [...loaded, focusedKey]; + + if ( + disablePageAnimations && + routeKeys.some((routeKey) => !loaded.includes(routeKey)) + ) { + setLoaded(routeKeys); + } else if (!loaded.includes(focusedKey)) { // Set the current tab to be loaded if it was not loaded before - setLoaded((loaded) => [...loaded, focusedKey]); + setLoaded(loadedRoutes); } const icons = React.useMemo( @@ -409,6 +425,7 @@ const TabView = ({ onPageSelected={handlePageSelected} onTabBarMeasured={handleTabBarMeasured} onNativeLayout={handleNativeLayout} + disablePageAnimations={disablePageAnimations} hapticFeedbackEnabled={hapticFeedbackEnabled} layoutDirection={layoutDirection} activeTintColor={activeTintColor} @@ -418,7 +435,10 @@ const TabView = ({ labeled={labeled} > {trimmedRoutes.map((route) => { - if (getLazy({ route }) !== false && !loaded.includes(route.key)) { + if ( + getLazy({ route }) !== false && + !loadedRoutes.includes(route.key) + ) { // Don't render a screen if we've never navigated to it return (