diff --git a/.changeset/perfect-onions-reply.md b/.changeset/perfect-onions-reply.md new file mode 100644 index 00000000..63010e7f --- /dev/null +++ b/.changeset/perfect-onions-reply.md @@ -0,0 +1,5 @@ +--- +'react-native-bottom-tabs': patch +--- + +feat: add useBottomTabBarHeight() hook diff --git a/apps/example/ios/Podfile.lock b/apps/example/ios/Podfile.lock index 126cf27d..f33a663f 100644 --- a/apps/example/ios/Podfile.lock +++ b/apps/example/ios/Podfile.lock @@ -1209,7 +1209,7 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga - - react-native-bottom-tabs (0.5.2): + - react-native-bottom-tabs (0.6.0): - DoubleConversion - glog - RCT-Folly (= 2024.01.01.00) @@ -1222,7 +1222,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-bottom-tabs/common (= 0.5.2) + - react-native-bottom-tabs/common (= 0.6.0) - React-NativeModulesApple - React-RCTFabric - React-rendererdebug @@ -1234,7 +1234,7 @@ PODS: - SDWebImageSVGCoder (>= 1.7.0) - SwiftUIIntrospect (~> 1.0) - Yoga - - react-native-bottom-tabs/common (0.5.2): + - react-native-bottom-tabs/common (0.6.0): - DoubleConversion - glog - RCT-Folly (= 2024.01.01.00) @@ -1945,7 +1945,7 @@ SPEC CHECKSUMS: React-logger: d79b704bf215af194f5213a6b7deec50ba8e6a9b React-Mapbuffer: b982d5bba94a8bc073bda48f0d27c9b28417fae3 React-microtasksnativemodule: 8fa285fed833a04a754bf575f8ded65fc240b88d - react-native-bottom-tabs: 402d19b4a55e6e17c78736a9dd2592cd9864881e + react-native-bottom-tabs: c17ddaf86c160134349c6325e020c1f38ee5f743 react-native-safe-area-context: 73505107f7c673cd550a561aeb6271f152c483b6 React-nativeconfig: 8c83d992b9cc7d75b5abe262069eaeea4349f794 React-NativeModulesApple: b8465afc883f5bf3fe8bac3767e394d581a5f123 @@ -1982,8 +1982,8 @@ SPEC CHECKSUMS: SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d - Yoga: 055f92ad73f8c8600a93f0e25ac0b2344c3b07e6 + Yoga: aa3df615739504eebb91925fc9c58b4922ea9a08 PODFILE CHECKSUM: 1c1dbca3e400ef935aa9a150cb2dcb58fb8c4536 -COCOAPODS: 1.15.2 +COCOAPODS: 1.14.3 diff --git a/apps/example/src/Components/MusicControl.tsx b/apps/example/src/Components/MusicControl.tsx new file mode 100644 index 00000000..12a2794d --- /dev/null +++ b/apps/example/src/Components/MusicControl.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; + +type MusicControlProps = { + bottomOffset: number; +}; + +export const MusicControl: React.FC = ({ bottomOffset }) => { + return ( + + + + + Currently Playing Song + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + musicControlContainer: { + position: 'absolute', + left: 15, + right: 15, + borderRadius: 18, + height: 55, + backgroundColor: '#fff', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.25, + shadowRadius: 5, + elevation: 8, + }, + musicControlContent: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 8, + }, + songInfo: { + flex: 1, + marginRight: 16, + }, + songTitle: { + fontSize: 14, + fontWeight: '600', + color: '#000', + }, + controls: { + flexDirection: 'row', + alignItems: 'center', + }, + controlButton: { + fontSize: 24, + paddingHorizontal: 12, + color: '#000', + }, +}); diff --git a/apps/example/src/Screens/Contacts.tsx b/apps/example/src/Screens/Contacts.tsx index 1d40e535..4494bf2a 100644 --- a/apps/example/src/Screens/Contacts.tsx +++ b/apps/example/src/Screens/Contacts.tsx @@ -9,6 +9,8 @@ import { Text, View, } from 'react-native'; +import { useBottomTabBarHeight } from 'react-native-bottom-tabs'; +import { MusicControl } from '../Components/MusicControl'; type Item = { name: string; number: number }; @@ -97,6 +99,7 @@ export function Contacts({ query, ...rest }: Props) { console.log(Platform.OS, ' Rendering Contacts'); const renderItem = ({ item }: { item: Item }) => ; + const tabBarHeight = useBottomTabBarHeight(); const ref = React.useRef(null); useScrollToTop(ref); @@ -117,6 +120,7 @@ export function Contacts({ query, ...rest }: Props) { renderItem={renderItem} ItemSeparatorComponent={ItemSeparator} /> + ); } diff --git a/docs/docs/docs/guides/usage-with-react-navigation.mdx b/docs/docs/docs/guides/usage-with-react-navigation.mdx index b4b608f6..ac8d6ed5 100644 --- a/docs/docs/docs/guides/usage-with-react-navigation.mdx +++ b/docs/docs/docs/guides/usage-with-react-navigation.mdx @@ -265,3 +265,38 @@ React.useEffect(() => { return unsubscribe; }, [navigation]); ``` + +### Hooks + +`useBottomTabBarHeight` + +This hook returns the height of the bottom tab bar. This is useful when you want to place a component above the tab bar on iOS. It's not needed to offset the content of the screen as the navigator does it automatically. + +```tsx +import { useBottomTabBarHeight } from 'react-native-bottom-tabs'; + +function MyComponent() { + const tabBarHeight = useBottomTabBarHeight(); + + return ( + + {/* Content */} + + + ); +} +``` + +Alternatively, you can use the `BottomTabBarHeightContext` directly if you are using a class component or need it in a reusable component that can be used outside the bottom tab navigator: + +```tsx +import { BottomTabBarHeightContext } from 'react-native-bottom-tabs'; + +// ... + + + {tabBarHeight => ( + /* render something */ + )} + +``` diff --git a/packages/react-native-bottom-tabs/ios/PageSelectedEvent.swift b/packages/react-native-bottom-tabs/ios/Events/PageSelectedEvent.swift similarity index 98% rename from packages/react-native-bottom-tabs/ios/PageSelectedEvent.swift rename to packages/react-native-bottom-tabs/ios/Events/PageSelectedEvent.swift index fa690581..9799709f 100644 --- a/packages/react-native-bottom-tabs/ios/PageSelectedEvent.swift +++ b/packages/react-native-bottom-tabs/ios/Events/PageSelectedEvent.swift @@ -4,26 +4,26 @@ import React private var key: NSString @objc public var viewTag: NSNumber @objc public var coalescingKey: UInt16 - + @objc public var eventName: String { return "onPageSelected" } - + @objc public init(reactTag: NSNumber, key: NSString, coalescingKey: UInt16) { self.viewTag = reactTag self.key = key self.coalescingKey = coalescingKey super.init() } - + @objc public func canCoalesce() -> Bool { return false } - + @objc public class func moduleDotMethod() -> String { return "RCTEventEmitter.receiveEvent" } - + @objc public func arguments() -> [Any] { return [ viewTag, diff --git a/packages/react-native-bottom-tabs/ios/Events/TabBarMeasuredEvent.swift b/packages/react-native-bottom-tabs/ios/Events/TabBarMeasuredEvent.swift new file mode 100644 index 00000000..114e1f79 --- /dev/null +++ b/packages/react-native-bottom-tabs/ios/Events/TabBarMeasuredEvent.swift @@ -0,0 +1,36 @@ +import React + +@objc public class TabBarMeasuredEvent: NSObject, RCTEvent { + private var height: NSInteger + @objc public var viewTag: NSNumber + @objc public var coalescingKey: UInt16 + + @objc public var eventName: String { + return "onTabBarMeasured" + } + + @objc public init(reactTag: NSNumber, height: NSInteger, coalescingKey: UInt16) { + self.viewTag = reactTag + self.height = height + self.coalescingKey = coalescingKey + super.init() + } + + @objc public func canCoalesce() -> Bool { + return false + } + + @objc public class func moduleDotMethod() -> String { + return "RCTEventEmitter.receiveEvent" + } + + @objc public func arguments() -> [Any] { + return [ + viewTag, + RCTNormalizeInputEventName(eventName) ?? eventName, + [ + "height": height + ] + ] + } +} diff --git a/packages/react-native-bottom-tabs/ios/TabLongPressedEvent.swift b/packages/react-native-bottom-tabs/ios/Events/TabLongPressedEvent.swift similarity index 98% rename from packages/react-native-bottom-tabs/ios/TabLongPressedEvent.swift rename to packages/react-native-bottom-tabs/ios/Events/TabLongPressedEvent.swift index 493f4de9..48dabcae 100644 --- a/packages/react-native-bottom-tabs/ios/TabLongPressedEvent.swift +++ b/packages/react-native-bottom-tabs/ios/Events/TabLongPressedEvent.swift @@ -8,26 +8,26 @@ protocol RCTEvent {} private var key: NSString @objc public var viewTag: NSNumber @objc public var coalescingKey: UInt16 - + @objc public var eventName: String { return "onTabLongPress" } - + @objc public init(reactTag: NSNumber, key: NSString, coalescingKey: UInt16) { self.viewTag = reactTag self.key = key self.coalescingKey = coalescingKey super.init() } - + @objc public func canCoalesce() -> Bool { return false } - + @objc public class func moduleDotMethod() -> String { return "RCTEventEmitter.receiveEvent" } - + @objc public func arguments() -> [Any] { return [ viewTag, diff --git a/packages/react-native-bottom-tabs/ios/Fabric/RCTTabViewComponentView.mm b/packages/react-native-bottom-tabs/ios/Fabric/RCTTabViewComponentView.mm index 629ea080..47022b77 100644 --- a/packages/react-native-bottom-tabs/ios/Fabric/RCTTabViewComponentView.mm +++ b/packages/react-native-bottom-tabs/ios/Fabric/RCTTabViewComponentView.mm @@ -223,6 +223,15 @@ - (void)onLongPressWithKey:(NSString *)key reactTag:(NSNumber *)reactTag { } } +- (void)onTabBarMeasuredWithHeight:(NSInteger)height reactTag:(NSNumber *)reactTag { + auto eventEmitter = std::static_pointer_cast(_eventEmitter); + if (eventEmitter) { + eventEmitter->onTabBarMeasured(RNCTabViewEventEmitter::OnTabBarMeasured { + .height = (int)height + }); + } +} + @end Class RNCTabViewCls(void) diff --git a/packages/react-native-bottom-tabs/ios/RCTTabViewViewManager.mm b/packages/react-native-bottom-tabs/ios/RCTTabViewViewManager.mm index 4165e04d..f1fbb1cf 100644 --- a/packages/react-native-bottom-tabs/ios/RCTTabViewViewManager.mm +++ b/packages/react-native-bottom-tabs/ios/RCTTabViewViewManager.mm @@ -30,6 +30,7 @@ - (instancetype)init RCT_EXPORT_VIEW_PROPERTY(items, NSArray) RCT_EXPORT_VIEW_PROPERTY(onPageSelected, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onTabLongPress, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onTabBarMeasured, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(selectedPage, NSString) RCT_EXPORT_VIEW_PROPERTY(tabViewStyle, NSString) RCT_EXPORT_VIEW_PROPERTY(icons, NSArray); @@ -59,6 +60,11 @@ - (void)onPageSelectedWithKey:(NSString *)key reactTag:(NSNumber *)reactTag { [self.bridge.eventDispatcher sendEvent:event]; } +- (void)onTabBarMeasuredWithHeight:(NSInteger)height reactTag:(NSNumber *)reactTag { + auto event = [[TabBarMeasuredEvent alloc] initWithReactTag:reactTag height:height coalescingKey:_coalescingKey++]; + [self.bridge.eventDispatcher sendEvent:event]; +} + - (UIView *)view { return [[TabViewProvider alloc] initWithDelegate:self]; diff --git a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift index a4e33363..5d57e7ba 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewImpl.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewImpl.swift @@ -55,10 +55,11 @@ struct RepresentableView: UIViewRepresentable { */ struct TabViewImpl: View { @ObservedObject var props: TabViewProps - var onSelect: (_ key: String) -> Void - var onLongPress: (_ key: String) -> Void @Weak var tabBar: UITabBar? + var onSelect: (_ key: String) -> Void + var onLongPress: (_ key: String) -> Void + var onTabBarMeasured: (_ height: Int) -> Void var body: some View { TabView(selection: $props.selectedPage) { @@ -83,6 +84,9 @@ struct TabViewImpl: View { #endif .introspectTabView(closure: { tabController in tabBar = tabController.tabBar + onTabBarMeasured( + Int(tabController.tabBar.frame.size.height) + ) }) .configureAppearance(props: props, tabBar: tabBar) .tintColor(props.selectedActiveTintColor) diff --git a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift index 6591f135..5276647e 100644 --- a/packages/react-native-bottom-tabs/ios/TabViewProvider.swift +++ b/packages/react-native-bottom-tabs/ios/TabViewProvider.swift @@ -11,7 +11,7 @@ import SDWebImageSVGCoder @objc public let sfSymbol: String @objc public let activeTintColor: UIColor? @objc public let hidden: Bool - + @objc public init( key: String, @@ -34,6 +34,7 @@ import SDWebImageSVGCoder @objc public protocol TabViewProviderDelegate { func onPageSelected(key: String, reactTag: NSNumber?) func onLongPress(key: String, reactTag: NSNumber?) + func onTabBarMeasured(height: Int, reactTag: NSNumber?) } @objc public class TabViewProvider: UIView { @@ -46,6 +47,7 @@ import SDWebImageSVGCoder @objc var onPageSelected: RCTDirectEventBlock? @objc var onTabLongPress: RCTDirectEventBlock? + @objc var onTabBarMeasured: RCTDirectEventBlock? @objc public var icons: NSArray? { didSet { @@ -131,19 +133,19 @@ import SDWebImageSVGCoder props.inactiveTintColor = inactiveTintColor } } - + @objc public var fontFamily: NSString? { didSet { props.fontFamily = fontFamily as? String } } - + @objc public var fontWeigth: NSString? { didSet { props.fontWeight = fontWeigth as? String } } - + @objc public var fontSize: NSNumber? { didSet { props.fontSize = fontSize as? Int @@ -182,7 +184,10 @@ import SDWebImageSVGCoder self.delegate?.onPageSelected(key: key, reactTag: self.reactTag) } onLongPress: { key in self.delegate?.onLongPress(key: key, reactTag: self.reactTag) + } onTabBarMeasured: { height in + self.delegate?.onTabBarMeasured(height: height, reactTag: self.reactTag) }) + if let hostingController = self.hostingController, let parentViewController = reactViewController() { parentViewController.addChild(hostingController) addSubview(hostingController.view) @@ -196,27 +201,27 @@ import SDWebImageSVGCoder // TODO: Diff the arrays and update only changed items. // Now if the user passes `unfocusedIcon` we update every item. guard let imageSources = icons as? [RCTImageSource?] else { return } - + for (index, imageSource) in imageSources.enumerated() { guard let imageSource = imageSource, let url = imageSource.request.url else { continue } - + let isSVG = url.pathExtension.lowercased() == "svg" - + var options: SDWebImageOptions = [.continueInBackground, .scaleDownLargeImages, .avoidDecodeImage, .highPriority] - + if isSVG { options.insert(.decodeFirstFrameOnly) } - + let context: [SDWebImageContextOption: Any]? = isSVG ? [ .imageThumbnailPixelSize: iconSize, .imagePreserveAspectRatio: true ] : nil - + SDWebImageManager.shared.loadImage( with: url, options: options, diff --git a/packages/react-native-bottom-tabs/src/TabView.tsx b/packages/react-native-bottom-tabs/src/TabView.tsx index 9510ec38..a188c91b 100644 --- a/packages/react-native-bottom-tabs/src/TabView.tsx +++ b/packages/react-native-bottom-tabs/src/TabView.tsx @@ -8,6 +8,7 @@ import { View, processColor, } from 'react-native'; +import { BottomTabBarHeightContext } from './utils/BottomTabBarHeightContext'; //@ts-ignore import type { ImageSource } from 'react-native/Libraries/Image/ImageSource'; @@ -169,6 +170,7 @@ const TabView = ({ }: Props) => { // @ts-ignore const focusedKey = navigationState.routes[navigationState.index].key; + const [tabBarHeight, setTabBarHeight] = React.useState(0); const trimmedRoutes = React.useMemo(() => { if ( @@ -253,66 +255,73 @@ const TabView = ({ }); return ( - { - const index = trimmedRoutes.findIndex((route) => route.key === key); - onTabLongPress?.(index); - }} - onPageSelected={({ nativeEvent: { key } }) => { - jumpTo(key); - }} - hapticFeedbackEnabled={hapticFeedbackEnabled} - activeTintColor={activeTintColor} - inactiveTintColor={inactiveTintColor} - barTintColor={barTintColor} - rippleColor={rippleColor} - > - {trimmedRoutes.map((route) => { - if (getLazy({ route }) !== false && !loaded.includes(route.key)) { - // Don't render a screen if we've never navigated to it - if (Platform.OS === 'android') { - return null; + + { + const index = trimmedRoutes.findIndex((route) => route.key === key); + onTabLongPress?.(index); + }} + onPageSelected={({ nativeEvent: { key } }) => { + jumpTo(key); + }} + onTabBarMeasured={({ nativeEvent: { height } }) => { + setTabBarHeight(height); + }} + hapticFeedbackEnabled={hapticFeedbackEnabled} + activeTintColor={activeTintColor} + inactiveTintColor={inactiveTintColor} + barTintColor={barTintColor} + rippleColor={rippleColor} + > + {trimmedRoutes.map((route) => { + if (getLazy({ route }) !== false && !loaded.includes(route.key)) { + // Don't render a screen if we've never navigated to it + if (Platform.OS === 'android') { + return null; + } + return ( + + ); } + + const focused = route.key === focusedKey; + const opacity = focused ? 1 : 0; + const zIndex = focused ? 0 : -1; + return ( + pointerEvents={focused ? 'auto' : 'none'} + accessibilityElementsHidden={!focused} + importantForAccessibility={ + focused ? 'auto' : 'no-hide-descendants' + } + style={ + Platform.OS === 'android' + ? [StyleSheet.absoluteFill, { zIndex, opacity }] + : styles.fullWidth + } + > + {renderScene({ + route, + jumpTo, + })} + ); - } - - const focused = route.key === focusedKey; - const opacity = focused ? 1 : 0; - const zIndex = focused ? 0 : -1; - - return ( - - {renderScene({ - route, - jumpTo, - })} - - ); - })} - + })} + + ); }; diff --git a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts index 75020fc6..e79b1022 100644 --- a/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts +++ b/packages/react-native-bottom-tabs/src/TabViewNativeComponent.ts @@ -12,6 +12,10 @@ export type OnPageSelectedEventData = Readonly<{ key: string; }>; +export type OnTabBarMeasured = Readonly<{ + height: Int32; +}>; + export type TabViewItems = ReadonlyArray<{ key: string; title: string; @@ -26,6 +30,7 @@ export interface TabViewProps extends ViewProps { selectedPage: string; onPageSelected?: DirectEventHandler; onTabLongPress?: DirectEventHandler; + onTabBarMeasured?: DirectEventHandler; icons?: ReadonlyArray; labeled?: boolean; sidebarAdaptable?: boolean; diff --git a/packages/react-native-bottom-tabs/src/index.tsx b/packages/react-native-bottom-tabs/src/index.tsx index 356f5e7b..58090804 100644 --- a/packages/react-native-bottom-tabs/src/index.tsx +++ b/packages/react-native-bottom-tabs/src/index.tsx @@ -1,7 +1,18 @@ import TabView from './TabView'; +/** + * Views + */ +export default TabView; + +/** + * Utilities + */ export { SceneMap } from './SceneMap'; +export { useBottomTabBarHeight } from './utils/useBottomTabBarHeight'; +export { BottomTabBarHeightContext } from './utils/BottomTabBarHeightContext'; +/** + * Types + */ export type { AppleIcon } from './types'; - -export default TabView; diff --git a/packages/react-native-bottom-tabs/src/utils/BottomTabBarHeightContext.ts b/packages/react-native-bottom-tabs/src/utils/BottomTabBarHeightContext.ts new file mode 100644 index 00000000..15c1c7a8 --- /dev/null +++ b/packages/react-native-bottom-tabs/src/utils/BottomTabBarHeightContext.ts @@ -0,0 +1,5 @@ +import * as React from 'react'; + +export const BottomTabBarHeightContext = React.createContext< + number | undefined +>(undefined); diff --git a/packages/react-native-bottom-tabs/src/utils/useBottomTabBarHeight.ts b/packages/react-native-bottom-tabs/src/utils/useBottomTabBarHeight.ts new file mode 100644 index 00000000..90eb6b5c --- /dev/null +++ b/packages/react-native-bottom-tabs/src/utils/useBottomTabBarHeight.ts @@ -0,0 +1,15 @@ +import * as React from 'react'; + +import { BottomTabBarHeightContext } from './BottomTabBarHeightContext'; + +export function useBottomTabBarHeight() { + const height = React.useContext(BottomTabBarHeightContext); + + if (height === undefined) { + throw new Error( + "Couldn't find the bottom tab bar height. Are you inside a screen in Native Bottom Tab Navigator?" + ); + } + + return height; +}