From 22d9b087a2702d0bf7e0e0b647eb5d740967ff26 Mon Sep 17 00:00:00 2001 From: Thiago Brezinski Date: Sun, 20 Sep 2026 15:26:33 +0100 Subject: [PATCH] feat(ios): expose native tab scene safe-area insets --- .changeset/tidy-scene-insets.md | 5 ++ docs/docs/docs/guides/standalone-usage.mdx | 48 ++++++++++++++++++ .../example-shared/src/Examples/FourTabs.tsx | 25 +++++++++- .../ios/Bridge/BottomTabsBridge.h | 1 + .../ios/RCTTabViewComponentView.mm | 13 +++++ .../ios/RepresentableView.swift | 35 ++++++++++++- .../ios/TabView/LegacyTabView.swift | 5 +- .../ios/TabView/NewTabView.swift | 5 +- .../ios/TabViewImpl.swift | 3 ++ .../ios/TabViewProvider.swift | 3 ++ .../react-native-bottom-tabs/src/TabView.tsx | 49 ++++++++++++++++--- .../src/TabViewNativeComponent.ts | 9 ++++ .../react-native-bottom-tabs/src/index.tsx | 5 ++ .../src/utils/TabSceneInsetsContext.ts | 17 +++++++ 14 files changed, 211 insertions(+), 12 deletions(-) create mode 100644 .changeset/tidy-scene-insets.md create mode 100644 packages/react-native-bottom-tabs/src/utils/TabSceneInsetsContext.ts diff --git a/.changeset/tidy-scene-insets.md b/.changeset/tidy-scene-insets.md new file mode 100644 index 00000000..d8f4c0ec --- /dev/null +++ b/.changeset/tidy-scene-insets.md @@ -0,0 +1,5 @@ +--- +'react-native-bottom-tabs': minor +--- + +Expose `useTabSceneInsets()` with per-scene, four-edge native safe-area insets on iOS. Apps can inset foreground content around vertical tab bars while preserving edge-to-edge backgrounds. diff --git a/docs/docs/docs/guides/standalone-usage.mdx b/docs/docs/docs/guides/standalone-usage.mdx index d9f02c43..ee8f9d71 100644 --- a/docs/docs/docs/guides/standalone-usage.mdx +++ b/docs/docs/docs/guides/standalone-usage.mdx @@ -319,3 +319,51 @@ Function to get the style for a tab scene. Function to determine if a tab should prevent default switching behavior when pressed. - Default: Uses `route.preventsDefault` + +### Scene safe-area insets (iOS) + +`useTabSceneInsets()` exposes the native scene's safe area as physical `top`, +`right`, `bottom`, and `left` distances in points. It updates when the scene's +safe area changes, including when the system moves the tab bar to a side. +Scenes retain their full bounds so backgrounds can extend behind system bars. +Apply the insets to foreground content when needed: + +```tsx +import { useTabSceneInsets } from 'react-native-bottom-tabs'; +import { I18nManager, ScrollView, Text } from 'react-native'; + +function Article() { + const insets = useTabSceneInsets(); + + return ( + + Content stays clear of the native bars. + + ); +} +``` + +The hook must be used in a rendered tab scene. It returns `undefined` before +native measurement, outside a scene, with a custom tab bar, and on unsupported +platforms. Insets belong to each scene separately, including lazily mounted +scenes. They describe the scene's total native safe area, not just the tab bar: +do not add them to an existing safe-area inset or automatic scroll adjustment. +The left and right values remain physical edges in RTL layouts. The example maps +them to logical start/end padding because React Native can swap left/right +styles in RTL. If the content overrides its layout direction, use that direction +for the mapping. + +This hook does not resize scenes, change `useBottomTabBarHeight()`, or provide +keyboard avoidance. Apps using React Navigation can import it from +`react-native-bottom-tabs` inside a native tab screen as well. diff --git a/packages/example-shared/src/Examples/FourTabs.tsx b/packages/example-shared/src/Examples/FourTabs.tsx index 774d6679..bedb2ca7 100644 --- a/packages/example-shared/src/Examples/FourTabs.tsx +++ b/packages/example-shared/src/Examples/FourTabs.tsx @@ -1,5 +1,6 @@ -import TabView, { SceneMap } from 'react-native-bottom-tabs'; +import TabView, { SceneMap, useTabSceneInsets } from 'react-native-bottom-tabs'; import { useState, type ComponentProps } from 'react'; +import { I18nManager } from 'react-native'; import { Article } from '../Screens/Article'; import { Albums } from '../Screens/Albums'; import { Contacts } from '../Screens/Contacts'; @@ -17,8 +18,28 @@ interface Props { activeIndicatorColor?: TabViewProps['activeIndicatorColor']; } +function InsetArticle(props: ComponentProps) { + const insets = useTabSceneInsets(); + return ( +
+ ); +} + const renderScene = SceneMap({ - article: Article, + article: InsetArticle, albums: Albums, contacts: Contacts, chat: Chat, diff --git a/packages/react-native-bottom-tabs/ios/Bridge/BottomTabsBridge.h b/packages/react-native-bottom-tabs/ios/Bridge/BottomTabsBridge.h index 798e2cf0..2a225712 100644 --- a/packages/react-native-bottom-tabs/ios/Bridge/BottomTabsBridge.h +++ b/packages/react-native-bottom-tabs/ios/Bridge/BottomTabsBridge.h @@ -10,6 +10,7 @@ - (void)onPageSelectedWithKey:(NSString *)key reactTag:(NSNumber *)reactTag; - (void)onLongPressWithKey:(NSString *)key reactTag:(NSNumber *)reactTag; - (void)onTabBarMeasuredWithHeight:(NSInteger)height reactTag:(NSNumber *)reactTag; +- (void)onSceneInsetsChangeWithKey:(NSString *)key top:(CGFloat)top right:(CGFloat)right bottom:(CGFloat)bottom left:(CGFloat)left; - (void)onLayoutWithSize:(CGSize)size reactTag:(NSNumber *)reactTag; @end diff --git a/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm b/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm index 8e9cae58..18575df4 100644 --- a/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm +++ b/packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm @@ -288,6 +288,19 @@ - (void)onTabBarMeasuredWithHeight:(NSInteger)height reactTag:(NSNumber *)reactT } } +- (void)onSceneInsetsChangeWithKey:(NSString *)key top:(CGFloat)top right:(CGFloat)right bottom:(CGFloat)bottom left:(CGFloat)left { + auto eventEmitter = std::static_pointer_cast(_eventEmitter); + if (eventEmitter) { + eventEmitter->onSceneInsetsChange(RNCTabViewEventEmitter::OnSceneInsetsChange { + .key = [key cStringUsingEncoding:kCFStringEncodingUTF8], + .top = top, + .right = right, + .bottom = bottom, + .left = left + }); + } +} + - (void)onLayoutWithSize:(CGSize)size reactTag:(NSNumber *)reactTag { auto eventEmitter = std::static_pointer_cast(_eventEmitter); if (eventEmitter) { diff --git a/packages/react-native-bottom-tabs/ios/RepresentableView.swift b/packages/react-native-bottom-tabs/ios/RepresentableView.swift index 09adaca4..6f204924 100644 --- a/packages/react-native-bottom-tabs/ios/RepresentableView.swift +++ b/packages/react-native-bottom-tabs/ios/RepresentableView.swift @@ -7,6 +7,7 @@ import SwiftUI */ struct RepresentableView: PlatformViewRepresentable { var view: PlatformView + var onInsetsChange: ((CGFloat, CGFloat, CGFloat, CGFloat) -> Void)? = nil #if os(macOS) @@ -21,12 +22,42 @@ struct RepresentableView: PlatformViewRepresentable { #else func makeUIView(context: Context) -> PlatformView { - let wrapper = UIView() + let wrapper = SceneInsetsView() + wrapper.onInsetsChange = onInsetsChange wrapper.addSubview(view) return wrapper } - func updateUIView(_ uiView: PlatformView, context: Context) {} + func updateUIView(_ uiView: PlatformView, context: Context) { + guard let wrapper = uiView as? SceneInsetsView else { return } + wrapper.onInsetsChange = onInsetsChange + wrapper.reportInsets() + } #endif } + +#if !os(macOS) +private class SceneInsetsView: UIView { + var onInsetsChange: ((CGFloat, CGFloat, CGFloat, CGFloat) -> Void)? + private var lastInsets: UIEdgeInsets? + + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + reportInsets() + } + + override func layoutSubviews() { + super.layoutSubviews() + reportInsets() + } + + func reportInsets() { + guard window != nil, !bounds.isEmpty, let onInsetsChange else { return } + let insets = safeAreaInsets + guard lastInsets != insets else { return } + lastInsets = insets + onInsetsChange(insets.top, insets.right, insets.bottom, insets.left) + } +} +#endif diff --git a/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift b/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift index 188ddfde..831fdb76 100644 --- a/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift +++ b/packages/react-native-bottom-tabs/ios/TabView/LegacyTabView.swift @@ -3,6 +3,7 @@ import SwiftUI struct LegacyTabView: AnyTabView { @ObservedObject var props: TabViewProps + var onSceneInsetsChange: (String, CGFloat, CGFloat, CGFloat, CGFloat) -> Void var onLayout: (CGSize) -> Void var onSelect: (String) -> Void var updateTabBarAppearance: () -> Void @@ -49,7 +50,9 @@ struct LegacyTabView: AnyTabView { onSelect: onSelect ) - RepresentableView(view: child) + RepresentableView(view: child, onInsetsChange: { top, right, bottom, left in + onSceneInsetsChange(tabData.key, top, right, bottom, left) + }) .ignoresSafeArea(.container, edges: .all) .tabItem { TabItem( diff --git a/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift b/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift index 2f18f210..72b8e33f 100644 --- a/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift +++ b/packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift @@ -5,6 +5,7 @@ import SwiftUI struct NewTabView: AnyTabView { @ObservedObject var props: TabViewProps + var onSceneInsetsChange: (String, CGFloat, CGFloat, CGFloat, CGFloat) -> Void var onLayout: (CGSize) -> Void var onSelect: (String) -> Void var updateTabBarAppearance: () -> Void @@ -39,7 +40,9 @@ struct NewTabView: AnyTabView { ) Tab(value: tabData.key, role: tabData.role?.convert()) { - RepresentableView(view: child.view) + RepresentableView(view: child.view, onInsetsChange: { top, right, bottom, left in + onSceneInsetsChange(tabData.key, top, right, bottom, left) + }) .ignoresSafeArea(.container, edges: .all) .tabAppear(using: context) .hideTabBar(props.tabBarHidden) diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift index 68053e1f..210db0b7 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift @@ -17,6 +17,7 @@ struct TabViewImpl: View { if #available(iOS 18, macOS 15, visionOS 2, tvOS 18, *) { NewTabView( props: props, + onSceneInsetsChange: onSceneInsetsChange, onLayout: onLayout, onSelect: onSelect ) { @@ -27,6 +28,7 @@ struct TabViewImpl: View { } else { LegacyTabView( props: props, + onSceneInsetsChange: onSceneInsetsChange, onLayout: onLayout, onSelect: onSelect ) { @@ -39,6 +41,7 @@ struct TabViewImpl: View { var onSelect: (_ key: String) -> Void var onLongPress: (_ key: String) -> Void + var onSceneInsetsChange: (String, CGFloat, CGFloat, CGFloat, CGFloat) -> Void var onLayout: (_ size: CGSize) -> Void var onTabBarMeasured: (_ height: Int) -> Void diff --git a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift index e5663453..b5b10afc 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift @@ -48,6 +48,7 @@ public final class TabInfo: NSObject { func onPageSelected(key: String, reactTag: NSNumber?) func onLongPress(key: String, reactTag: NSNumber?) func onTabBarMeasured(height: Int, reactTag: NSNumber?) + func onSceneInsetsChange(key: String, top: CGFloat, right: CGFloat, bottom: CGFloat, left: CGFloat) func onLayout(size: CGSize, reactTag: NSNumber?) } @@ -220,6 +221,8 @@ public final class TabInfo: NSObject { self.delegate?.onPageSelected(key: key, reactTag: self.reactTag) } onLongPress: { key in self.delegate?.onLongPress(key: key, reactTag: self.reactTag) + } onSceneInsetsChange: { key, top, right, bottom, left in + self.delegate?.onSceneInsetsChange(key: key, top: top, right: right, bottom: bottom, left: left) } onLayout: { size in self.delegate?.onLayout(size: size, reactTag: self.reactTag) } onTabBarMeasured: { height in diff --git a/packages/react-native-bottom-tabs/src/TabView.tsx b/packages/react-native-bottom-tabs/src/TabView.tsx index 0acfb842..7f9bfe83 100644 --- a/packages/react-native-bottom-tabs/src/TabView.tsx +++ b/packages/react-native-bottom-tabs/src/TabView.tsx @@ -1,5 +1,10 @@ +import { + TabSceneInsetsContext, + type TabSceneInsets, +} from './utils/TabSceneInsetsContext'; import React, { useLayoutEffect, useRef } from 'react'; import type { + OnSceneInsetsChange, OnNativeLayout, OnPageSelectedEventData, OnTabBarMeasured, @@ -282,6 +287,9 @@ const TabView = ({ // @ts-ignore const focusedKey = navigationState.routes[navigationState.index].key; const customTabBarWrapperRef = useRef(null); + const [sceneInsets, setSceneInsets] = React.useState< + Record + >({}); const [tabBarHeight, setTabBarHeight] = React.useState(0); const [measuredDimensions, setMeasuredDimensions] = React.useState< { width: DimensionValue; height: DimensionValue } | undefined @@ -435,6 +443,30 @@ const TabView = ({ [setTabBarHeight] ); + const handleSceneInsetsChange = React.useCallback( + ({ + nativeEvent: { key, top, right, bottom, left }, + }: { + nativeEvent: OnSceneInsetsChange; + }) => { + const insets = { top, right, bottom, left }; + setSceneInsets((previous) => { + const current = previous[key]; + if ( + current && + current.top === insets.top && + current.right === insets.right && + current.bottom === insets.bottom && + current.left === insets.left + ) { + return previous; + } + return { ...previous, [key]: insets }; + }); + }, + [] + ); + const handleNativeLayout = React.useCallback( ({ nativeEvent: { width, height } }: { nativeEvent: OnNativeLayout }) => { setMeasuredDimensions({ width, height }); @@ -469,6 +501,7 @@ const TabView = ({ onPageSelected={handlePageSelected} onTabBarMeasured={handleTabBarMeasured} onNativeLayout={handleNativeLayout} + onSceneInsetsChange={handleSceneInsetsChange} hapticFeedbackEnabled={hapticFeedbackEnabled} layoutDirection={layoutDirection} activeTintColor={activeTintColor} @@ -510,12 +543,16 @@ const TabView = ({ focused ? 'auto' : 'no-hide-descendants' } > - - {renderScene({ - route, - jumpTo, - })} - + + + {renderScene({ + route, + jumpTo, + })} + + ); })} diff --git a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts index 50ecf7c4..c679b36f 100644 --- a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts +++ b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts @@ -17,6 +17,14 @@ export type OnTabBarMeasured = Readonly<{ height: Int32; }>; +export type OnSceneInsetsChange = Readonly<{ + key: string; + top: Double; + right: Double; + bottom: Double; + left: Double; +}>; + export type OnNativeLayout = Readonly<{ width: Double; height: Double; @@ -44,6 +52,7 @@ export interface TabViewProps extends ViewProps { onPageSelected?: DirectEventHandler; onTabLongPress?: DirectEventHandler; onTabBarMeasured?: DirectEventHandler; + onSceneInsetsChange?: DirectEventHandler; onNativeLayout?: DirectEventHandler; icons?: ReadonlyArray; focusedIcons?: ReadonlyArray; diff --git a/packages/react-native-bottom-tabs/src/index.tsx b/packages/react-native-bottom-tabs/src/index.tsx index 19544834..324ccdab 100644 --- a/packages/react-native-bottom-tabs/src/index.tsx +++ b/packages/react-native-bottom-tabs/src/index.tsx @@ -8,6 +8,11 @@ export default TabView; /** * Utilities */ +export { + useTabSceneInsets, + TabSceneInsetsContext, +} from './utils/TabSceneInsetsContext'; +export type { TabSceneInsets } from './utils/TabSceneInsetsContext'; export { SceneMap } from './SceneMap'; export { useBottomTabBarHeight } from './utils/useBottomTabBarHeight'; export { BottomTabBarHeightContext } from './utils/BottomTabBarHeightContext'; diff --git a/packages/react-native-bottom-tabs/src/utils/TabSceneInsetsContext.ts b/packages/react-native-bottom-tabs/src/utils/TabSceneInsetsContext.ts new file mode 100644 index 00000000..688305e6 --- /dev/null +++ b/packages/react-native-bottom-tabs/src/utils/TabSceneInsetsContext.ts @@ -0,0 +1,17 @@ +import * as React from 'react'; + +export type TabSceneInsets = Readonly<{ + top: number; + right: number; + bottom: number; + left: number; +}>; + +export const TabSceneInsetsContext = React.createContext< + TabSceneInsets | undefined +>(undefined); + +/** Native scene safe-area insets, or undefined before measurement/on unsupported platforms. */ +export function useTabSceneInsets() { + return React.useContext(TabSceneInsetsContext); +}