diff --git a/native-views/react-native-bottom-tabs/docs/SPEC.md b/native-views/react-native-bottom-tabs/docs/SPEC.md index 2e8546741..988a6a6f7 100644 --- a/native-views/react-native-bottom-tabs/docs/SPEC.md +++ b/native-views/react-native-bottom-tabs/docs/SPEC.md @@ -104,6 +104,11 @@ height: a rendered custom bar's own measured height takes precedence; otherwise `tabBarHidden` forces zero. Native hide measurements MUST NOT overwrite a custom bar's height, including across native/custom presentation switches. +The iOS drawer MUST NOT receive touches inside the native tab bar at any +placement, because a vertical bar reports no bottom height. That covers +everything under the tab controller's view outside tab content, including the +floating item platter and side accessory. + With opening swipes enabled, a one-finger rightward gesture may start anywhere in the foreground content. At the first confirmed direction, the actual native touch target and its ancestor chain determine ownership. A horizontal scroll @@ -326,6 +331,12 @@ placement and can draw one round accessory button above the vertical bar. tab bar controller. It fires once after the bar is created and again on every change (rotation, folding, window resizing). Builds without the iOS 27.1 SDK and earlier systems always report `bottom`. Android never fires it. +- **Height on placement change:** every placement event is followed by + `onTabBarMeasured` with the bar's current height, because the height reported + for the previous placement no longer applies. A vertical bar reports its full + column height, so `BottomTabs` MUST expose 0 through + `BottomTabBarHeightContext` and the drawer's `bottomBarHeight` for `leading` + and `trailing` placement, and the measured height only for `bottom`. - **Side accessory:** `sideAccessoryIcon` (an image source, loaded with the React image loader and drawn as a template tinted with `inactiveTintColor`), `sideAccessoryLabel` (accessibility label), and `onSideAccessoryPress`. The diff --git a/native-views/react-native-bottom-tabs/ios/RCTBottomTabsContainerView.swift b/native-views/react-native-bottom-tabs/ios/RCTBottomTabsContainerView.swift index f9ab0b85f..713390f5a 100644 --- a/native-views/react-native-bottom-tabs/ios/RCTBottomTabsContainerView.swift +++ b/native-views/react-native-bottom-tabs/ios/RCTBottomTabsContainerView.swift @@ -344,6 +344,9 @@ class RCTBottomTabsContainerView: UIView { if placement != reportedTabBarPlacement { reportedTabBarPlacement = placement onTabBarPlacementChange?(["placement": placement]) + // The bar's frame follows its placement, so the height reported for the + // previous placement is stale. + onTabBarMeasured?(["height": tabBarHidden ? 0 : Double(tbc.tabBar.frame.size.height)]) } guard placement != "bottom", diff --git a/native-views/react-native-bottom-tabs/ios/RCTBottomTabsDrawerContainerView.swift b/native-views/react-native-bottom-tabs/ios/RCTBottomTabsDrawerContainerView.swift index 7ed8c94cf..57584d8ae 100644 --- a/native-views/react-native-bottom-tabs/ios/RCTBottomTabsDrawerContainerView.swift +++ b/native-views/react-native-bottom-tabs/ios/RCTBottomTabsDrawerContainerView.swift @@ -135,6 +135,7 @@ final class RCTBottomTabsDrawerContainerView: UIView, UIGestureRecognizerDelegat guard enabled else { return false } let start = touch.location(in: self) guard start.y < bounds.height - CGFloat(max(0, bottomBarHeight)) else { return false } + guard !isInsideNativeTabBar(touch.view) else { return false } touchTarget = touch.view if progress > 0 { return start.x >= bounds.width * 0.78 * progress } return swipeEnabled && !touchPathContainsExclusiveControl() @@ -171,6 +172,24 @@ final class RCTBottomTabsDrawerContainerView: UIView, UIGestureRecognizerDelegat return other is UIScreenEdgePanGestureRecognizer } + // A vertical bar reports no bottom height, and UIKit draws its items in a + // floating platter outside UITabBar, so match anything under the tab + // controller's view that is not tab content. + private func isInsideNativeTabBar(_ target: UIView?) -> Bool { + var path: [UIView] = [] + var view = target + while let current = view, current !== self { + if current is UITabBar { return true } + if let tabs = current.next as? UITabBarController { + let content = (tabs.viewControllers ?? []).compactMap { $0.viewIfLoaded } + return !path.contains { step in content.contains { $0 === step } } + } + path.append(current) + view = current.superview + } + return false + } + private func touchPathContainsExclusiveControl() -> Bool { var view = touchTarget while let current = view, current !== self { diff --git a/native-views/react-native-bottom-tabs/src/BottomTabs.tsx b/native-views/react-native-bottom-tabs/src/BottomTabs.tsx index 90c4f99ac..24f931349 100644 --- a/native-views/react-native-bottom-tabs/src/BottomTabs.tsx +++ b/native-views/react-native-bottom-tabs/src/BottomTabs.tsx @@ -399,9 +399,12 @@ const BottomTabs = ({ useRef>(null); const [nativeTabBarHeight, setNativeTabBarHeight] = React.useState(0); const [customTabBarHeight, setCustomTabBarHeight] = React.useState(0); + const [tabBarPlacement, setTabBarPlacement] = + React.useState("bottom"); + // A vertical bar measures its full column height but takes no bottom space. const tabBarHeight = renderCustomTabBar ? customTabBarHeight - : props.tabBarHidden + : props.tabBarHidden || tabBarPlacement !== "bottom" ? 0 : nativeTabBarHeight; const [measuredDimensions, setMeasuredDimensions] = React.useState< @@ -566,7 +569,9 @@ const BottomTabs = ({ const handleTabBarPlacementChange = React.useCallback( (event: NativeSyntheticEvent<{ placement: string }>) => { - onTabBarPlacementChange?.(event.nativeEvent.placement as TabBarPlacement); + const placement = event.nativeEvent.placement as TabBarPlacement; + setTabBarPlacement(placement); + onTabBarPlacementChange?.(placement); }, [onTabBarPlacementChange] );