Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-scene-insets.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions docs/docs/docs/guides/standalone-usage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ScrollView
style={{ flex: 1, backgroundColor: 'white' }}
contentInsetAdjustmentBehavior={insets ? 'never' : 'automatic'}
contentContainerStyle={
insets && {
paddingTop: insets.top,
paddingEnd: I18nManager.isRTL ? insets.left : insets.right,
paddingBottom: insets.bottom,
paddingStart: I18nManager.isRTL ? insets.right : insets.left,
}
}
>
<Text>Content stays clear of the native bars.</Text>
</ScrollView>
);
}
```

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.
25 changes: 23 additions & 2 deletions packages/example-shared/src/Examples/FourTabs.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,8 +18,28 @@ interface Props {
activeIndicatorColor?: TabViewProps['activeIndicatorColor'];
}

function InsetArticle(props: ComponentProps<typeof Article>) {
const insets = useTabSceneInsets();
return (
<Article
{...props}
contentInsetAdjustmentBehavior={insets ? 'never' : 'automatic'}
contentContainerStyle={
insets
? {
paddingTop: 16 + insets.top,
paddingEnd: I18nManager.isRTL ? insets.left : insets.right,
paddingBottom: 16 + insets.bottom,
paddingStart: I18nManager.isRTL ? insets.right : insets.left,
}
: { paddingVertical: 16 }
}
/>
);
}

const renderScene = SceneMap({
article: Article,
article: InsetArticle,
albums: Albums,
contacts: Contacts,
chat: Chat,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions packages/react-native-bottom-tabs/ios/RCTTabViewComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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<const RNCTabViewEventEmitter>(_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<const RNCTabViewEventEmitter>(_eventEmitter);
if (eventEmitter) {
Expand Down
35 changes: 33 additions & 2 deletions packages/react-native-bottom-tabs/ios/RepresentableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
struct RepresentableView: PlatformViewRepresentable {
var view: PlatformView
var onInsetsChange: ((CGFloat, CGFloat, CGFloat, CGFloat) -> Void)? = nil

Check warning on line 10 in packages/react-native-bottom-tabs/ios/RepresentableView.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Implicit Optional Initialization Violation: Optional should be implicitly initialized without nil (implicit_optional_initialization)

#if os(macOS)

Expand All @@ -21,12 +22,42 @@
#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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions packages/react-native-bottom-tabs/ios/TabViewImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
if #available(iOS 18, macOS 15, visionOS 2, tvOS 18, *) {
NewTabView(
props: props,
onSceneInsetsChange: onSceneInsetsChange,
onLayout: onLayout,
onSelect: onSelect
) {
Expand All @@ -27,6 +28,7 @@
} else {
LegacyTabView(
props: props,
onSceneInsetsChange: onSceneInsetsChange,
onLayout: onLayout,
onSelect: onSelect
) {
Expand All @@ -39,6 +41,7 @@

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

Expand Down Expand Up @@ -219,7 +222,7 @@
}
}

private func configureTabBarItemImages(items: [UITabBarItem], props: TabViewProps) {

Check warning on line 225 in packages/react-native-bottom-tabs/ios/TabViewImpl.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Function Body Length Violation: Function body should span 50 lines or less excluding comments and whitespace: currently spans 61 lines (function_body_length)
for (tabBarIndex, item) in items.enumerated() {
guard let tabData = props.filteredItems[safe: tabBarIndex],
let itemIndex = props.items.firstIndex(where: { $0.key == tabData.key })
Expand Down Expand Up @@ -359,9 +362,9 @@
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color,
.paragraphStyle: paragraphStyle,

Check warning on line 365 in packages/react-native-bottom-tabs/ios/TabViewImpl.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Trailing Comma Violation: Collection literals should not have trailing commas (trailing_comma)
]
let titleSize = (title as NSString).size(withAttributes: attributes)

Check warning on line 367 in packages/react-native-bottom-tabs/ios/TabViewImpl.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Legacy Objective-C Reference Type Violation: Prefer Swift value types to bridged Objective-C reference types (legacy_objc_type)
let imageSize = CGSize(
width: max(iconSize.width, ceil(titleSize.width)) + 8,
height: iconSize.height + 3 + ceil(titleSize.height)
Expand All @@ -388,7 +391,7 @@

tintedIcon.draw(in: iconFrame)

(title as NSString).draw(

Check warning on line 394 in packages/react-native-bottom-tabs/ios/TabViewImpl.swift

View workflow job for this annotation

GitHub Actions / swift-lint

Legacy Objective-C Reference Type Violation: Prefer Swift value types to bridged Objective-C reference types (legacy_objc_type)
in: CGRect(
x: 0,
y: iconSize.height + 3,
Expand Down Expand Up @@ -563,4 +566,4 @@
self
}
}
}

Check warning on line 569 in packages/react-native-bottom-tabs/ios/TabViewImpl.swift

View workflow job for this annotation

GitHub Actions / swift-lint

File Length Violation: File should contain 400 lines or less: currently contains 569 (file_length)
3 changes: 3 additions & 0 deletions packages/react-native-bottom-tabs/ios/TabViewProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
}

Expand Down Expand Up @@ -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
Expand Down
49 changes: 43 additions & 6 deletions packages/react-native-bottom-tabs/src/TabView.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import {
TabSceneInsetsContext,
type TabSceneInsets,
} from './utils/TabSceneInsetsContext';
import React, { useLayoutEffect, useRef } from 'react';
import type {
OnSceneInsetsChange,
OnNativeLayout,
OnPageSelectedEventData,
OnTabBarMeasured,
Expand Down Expand Up @@ -282,6 +287,9 @@
// @ts-ignore
const focusedKey = navigationState.routes[navigationState.index].key;
const customTabBarWrapperRef = useRef<View>(null);
const [sceneInsets, setSceneInsets] = React.useState<
Record<string, TabSceneInsets>
>({});
const [tabBarHeight, setTabBarHeight] = React.useState<number | undefined>(0);
const [measuredDimensions, setMeasuredDimensions] = React.useState<
{ width: DimensionValue; height: DimensionValue } | undefined
Expand All @@ -307,7 +315,7 @@

if (!loaded.includes(focusedKey)) {
// Set the current tab to be loaded if it was not loaded before
setLoaded((loaded) => [...loaded, focusedKey]);

Check warning on line 318 in packages/react-native-bottom-tabs/src/TabView.tsx

View workflow job for this annotation

GitHub Actions / lint

'loaded' is already declared in the upper scope on line 314 column 10
}

const icons = React.useMemo(
Expand Down Expand Up @@ -435,6 +443,30 @@
[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 });
Expand Down Expand Up @@ -469,6 +501,7 @@
onPageSelected={handlePageSelected}
onTabBarMeasured={handleTabBarMeasured}
onNativeLayout={handleNativeLayout}
onSceneInsetsChange={handleSceneInsetsChange}
hapticFeedbackEnabled={hapticFeedbackEnabled}
layoutDirection={layoutDirection}
activeTintColor={activeTintColor}
Expand Down Expand Up @@ -510,12 +543,16 @@
focused ? 'auto' : 'no-hide-descendants'
}
>
<DelayedFreeze freeze={!!freeze}>
{renderScene({
route,
jumpTo,
})}
</DelayedFreeze>
<TabSceneInsetsContext.Provider
value={renderCustomTabBar ? undefined : sceneInsets[route.key]}
>
<DelayedFreeze freeze={!!freeze}>
{renderScene({
route,
jumpTo,
})}
</DelayedFreeze>
</TabSceneInsetsContext.Provider>
</View>
);
})}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';

Check warning on line 1 in packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts

View workflow job for this annotation

GitHub Actions / lint

'react-native/Libraries/Utilities/codegenNativeComponent' React Native deep imports are deprecated. Please use the top level import instead
import type { ColorValue, ProcessedColorValue, ViewProps } from 'react-native';
import type {
DirectEventHandler,
Expand All @@ -7,7 +7,7 @@
WithDefault,
} from 'react-native/Libraries/Types/CodegenTypes';
//@ts-ignore
import type { ImageSource } from 'react-native/Libraries/Image/ImageSource';

Check warning on line 10 in packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts

View workflow job for this annotation

GitHub Actions / lint

'react-native/Libraries/Image/ImageSource' React Native deep imports are deprecated. Please use the top level import instead

export type OnPageSelectedEventData = Readonly<{
key: string;
Expand All @@ -17,6 +17,14 @@
height: Int32;
}>;

export type OnSceneInsetsChange = Readonly<{
key: string;
top: Double;
right: Double;
bottom: Double;
left: Double;
}>;

export type OnNativeLayout = Readonly<{
width: Double;
height: Double;
Expand Down Expand Up @@ -44,6 +52,7 @@
onPageSelected?: DirectEventHandler<OnPageSelectedEventData>;
onTabLongPress?: DirectEventHandler<OnPageSelectedEventData>;
onTabBarMeasured?: DirectEventHandler<OnTabBarMeasured>;
onSceneInsetsChange?: DirectEventHandler<OnSceneInsetsChange>;
onNativeLayout?: DirectEventHandler<OnNativeLayout>;
icons?: ReadonlyArray<ImageSource>;
focusedIcons?: ReadonlyArray<ImageSource>;
Expand Down
5 changes: 5 additions & 0 deletions packages/react-native-bottom-tabs/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Loading