-
Notifications
You must be signed in to change notification settings - Fork 70
feat(iOS): implement bottom accessory view #446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 13 commits
98bdead
4fecda5
22d4208
e56a051
70a51df
9478d1b
20ade50
cde474b
787aaeb
abb4666
7cbed96
9d44ef6
078939b
28af2bc
e6d79df
764153c
cb6e515
8dfd717
6107658
11aabc6
40b59bc
0e9d7a3
d02504c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import TabView, { SceneMap } from 'react-native-bottom-tabs'; | ||
| import { useState } from 'react'; | ||
| import { Article } from '../Screens/Article'; | ||
| import { Albums } from '../Screens/Albums'; | ||
| import { Contacts } from '../Screens/Contacts'; | ||
| import { Text, View, type TextStyle, type ViewStyle } from 'react-native'; | ||
|
|
||
| const bottomAccessoryViewStyle: ViewStyle = { | ||
| width: '100%', | ||
| height: '100%', | ||
| justifyContent: 'center', | ||
| alignItems: 'center', | ||
| }; | ||
|
|
||
| const textStyle: TextStyle = { textAlign: 'center' }; | ||
|
|
||
| const renderScene = SceneMap({ | ||
| article: Article, | ||
| albums: Albums, | ||
| contacts: Contacts, | ||
| }); | ||
|
|
||
| export default function BottomAccessoryView() { | ||
| const [index, setIndex] = useState(0); | ||
| const [routes] = useState([ | ||
| { | ||
| key: 'article', | ||
| title: 'Article', | ||
| focusedIcon: require('../../assets/icons/article_dark.png'), | ||
| badge: '!', | ||
| }, | ||
| { | ||
| key: 'albums', | ||
| title: 'Albums', | ||
| focusedIcon: require('../../assets/icons/grid_dark.png'), | ||
| badge: '5', | ||
| }, | ||
| { | ||
| key: 'contacts', | ||
| focusedIcon: require('../../assets/icons/person_dark.png'), | ||
| title: 'Contacts', | ||
| role: 'search', | ||
| }, | ||
| ]); | ||
|
|
||
| const [bottomAccessoryDimensions, setBottomAccessoryDimensions] = useState({ | ||
| width: 0, | ||
| height: 0, | ||
| }); | ||
|
|
||
| return ( | ||
| <TabView | ||
| sidebarAdaptable | ||
| minimizeBehavior="onScrollDown" | ||
| navigationState={{ index, routes }} | ||
| onIndexChange={setIndex} | ||
| renderScene={renderScene} | ||
| renderBottomAccessoryView={({ placement }) => ( | ||
| <View | ||
| style={bottomAccessoryViewStyle} | ||
| onLayout={(e) => setBottomAccessoryDimensions(e.nativeEvent.layout)} | ||
| > | ||
| <Text style={textStyle}> | ||
| Placement: {placement}. Dimensions:{' '} | ||
| {bottomAccessoryDimensions.width}x{bottomAccessoryDimensions.height} | ||
| </Text> | ||
| </View> | ||
| )} | ||
| /> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -200,6 +200,14 @@ Color of tab indicator. | |
|
|
||
| - Type: `ColorValue` | ||
|
|
||
| #### `renderBottomAccessoryView` <Badge text="iOS" type="info" /> | ||
|
||
|
|
||
| Function that returns a React element to render as [bottom accessory](https://developer.apple.com/documentation/uikit/uitabbarcontroller/bottomaccessory). | ||
|
|
||
| :::note | ||
| This feature requires iOS 26.0 or later and is only available on iOS. On older versions, this prop is ignored. | ||
okwasniewski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ::: | ||
|
|
||
| ### Route Configuration | ||
|
|
||
| Each route in the `routes` array can have the following properties: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -216,7 +216,13 @@ function MyTabs() { | |
| ); | ||
| } | ||
| ``` | ||
| #### `renderBottomAccessoryView` <Badge text="iOS" type="info" /> | ||
|
||
|
|
||
| Function that returns a React element to render as [bottom accessory](https://developer.apple.com/documentation/uikit/uitabbarcontroller/bottomaccessory). | ||
|
|
||
| :::note | ||
| This feature requires iOS 26.0 or later and is only available on iOS. On older versions, this prop is ignored. | ||
okwasniewski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ::: | ||
okwasniewski marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| ### Options | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| #ifdef RCT_NEW_ARCH_ENABLED | ||
| #import <React/RCTViewComponentView.h> | ||
| #if TARGET_OS_OSX | ||
| #import <AppKit/AppKit.h> | ||
| #else | ||
| #import <UIKit/UIKit.h> | ||
| #endif | ||
|
|
||
| NS_ASSUME_NONNULL_BEGIN | ||
|
|
||
| @interface RCTBottomAccessoryComponentView: RCTViewComponentView | ||
|
|
||
| - (void)emitOnPlacementChanged:(NSString *)placement; | ||
|
|
||
| @end | ||
|
|
||
| NS_ASSUME_NONNULL_END | ||
|
|
||
| #endif /* RCTBottomAccessoryComponentView_h */ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| #ifdef RCT_NEW_ARCH_ENABLED | ||
| #import "RCTBottomAccessoryComponentView.h" | ||
|
|
||
| #import <react/renderer/components/RNCTabView/ComponentDescriptors.h> | ||
| #import <react/renderer/components/RNCTabView/EventEmitters.h> | ||
| #import <react/renderer/components/RNCTabView/Props.h> | ||
| #import <react/renderer/components/RNCTabView/RCTComponentViewHelpers.h> | ||
|
|
||
| #import <React/RCTFabricComponentsPlugins.h> | ||
|
|
||
| #if __has_include("react_native_bottom_tabs/react_native_bottom_tabs-Swift.h") | ||
| #import "react_native_bottom_tabs/react_native_bottom_tabs-Swift.h" | ||
| #else | ||
| #import "react_native_bottom_tabs-Swift.h" | ||
| #endif | ||
|
|
||
| using namespace facebook::react; | ||
|
|
||
| @implementation RCTBottomAccessoryComponentView | ||
|
|
||
| + (ComponentDescriptorProvider)componentDescriptorProvider | ||
| { | ||
| return concreteComponentDescriptorProvider<BottomAccessoryViewComponentDescriptor>(); | ||
| } | ||
|
|
||
| - (instancetype)initWithFrame:(CGRect)frame | ||
| { | ||
| if (self = [super initWithFrame:frame]) { | ||
| static const auto defaultProps = std::make_shared<const BottomAccessoryViewProps>(); | ||
| } | ||
|
|
||
| return self; | ||
| } | ||
|
|
||
| - (void)setFrame:(CGRect)frame | ||
| { | ||
| [super setFrame:frame]; | ||
| auto eventEmitter = std::static_pointer_cast<const BottomAccessoryViewEventEmitter>(_eventEmitter); | ||
| if (eventEmitter) { | ||
| eventEmitter->onNativeLayout(BottomAccessoryViewEventEmitter::OnNativeLayout { | ||
| .height = frame.size.height, | ||
| .width = frame.size.width | ||
| }); | ||
|
Comment on lines
+49
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's add a TODO here, There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| } | ||
|
|
||
| - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps | ||
| { | ||
| [super updateProps:props oldProps:oldProps]; | ||
| } | ||
|
||
|
|
||
| - (void)emitOnPlacementChanged:(NSString *)placement { | ||
| auto eventEmitter = std::static_pointer_cast<const BottomAccessoryViewEventEmitter>(_eventEmitter); | ||
| if (eventEmitter) { | ||
| eventEmitter->onPlacementChanged(BottomAccessoryViewEventEmitter::OnPlacementChanged { | ||
| .placement = std::string([placement UTF8String]) | ||
| }); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Class<RCTComponentViewProtocol> BottomAccessoryViewCls(void) | ||
| { | ||
| return RCTBottomAccessoryComponentView.class; | ||
| } | ||
|
|
||
| @end | ||
|
|
||
| #endif | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import React | ||
okwasniewski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| import SwiftUI | ||
|
|
||
| @available(iOS 18, macOS 15, visionOS 2, tvOS 18, *) | ||
|
|
@@ -51,5 +52,75 @@ struct NewTabView: AnyTabView { | |
| .measureView { size in | ||
| onLayout(size) | ||
| } | ||
| .modifier(ConditionalBottomAccessoryModifier(props: props)) | ||
| } | ||
| } | ||
|
|
||
| struct ConditionalBottomAccessoryModifier: ViewModifier { | ||
| let props: TabViewProps | ||
|
|
||
| // Check if there's a bottom accessory component view | ||
| private var hasBottomAccessory: Bool { | ||
| props.children.contains { child in | ||
| let className = String(describing: type(of: child.view)) | ||
| return className == "RCTBottomAccessoryComponentView" | ||
| } | ||
| } | ||
|
|
||
| // Find the bottom accessory view | ||
| private var bottomAccessoryView: PlatformView? { | ||
| props.children.first { child in | ||
| let className = String(describing: type(of: child.view)) | ||
| return className == "RCTBottomAccessoryComponentView" | ||
| }?.view | ||
| } | ||
|
|
||
| func body(content: Content) -> some View { | ||
| if #available(iOS 26.0, macOS 26.0, tvOS 26.0, visionOS 3.0, *), hasBottomAccessory { | ||
| content | ||
| .tabViewBottomAccessory { | ||
| renderBottomAccessoryView() | ||
| } | ||
| } else { | ||
| content | ||
| } | ||
| } | ||
|
|
||
| @ViewBuilder | ||
| private func renderBottomAccessoryView() -> some View { | ||
| if let bottomAccessoryView { | ||
| if #available(iOS 26.0, *) { | ||
| BottomAccessoryRepresentableView(view: bottomAccessoryView) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 26.0, *) | ||
| struct BottomAccessoryRepresentableView: PlatformViewRepresentable { | ||
| @Environment(\.tabViewBottomAccessoryPlacement) var tabViewBottomAccessoryPlacement | ||
| var view: PlatformView | ||
|
|
||
| func makeUIView(context: Context) -> PlatformView { | ||
| emitPlacementChanged(for: view) | ||
| return view | ||
okwasniewski marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| func updateUIView(_ uiView: PlatformView, context: Context) { | ||
| emitPlacementChanged(for: view) | ||
okwasniewski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| private func emitPlacementChanged(for uiView: PlatformView) { | ||
| let selectorString = "emitOnPlacementChanged:" | ||
| let selector = NSSelectorFromString(selectorString) | ||
okwasniewski marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| if uiView.responds(to: selector) { | ||
| var placementValue = "none" | ||
| if tabViewBottomAccessoryPlacement == .inline { | ||
| placementValue = "inline" | ||
| } else if tabViewBottomAccessoryPlacement == .expanded { | ||
| placementValue = "expanded" | ||
| } | ||
| uiView.perform(selector, with: placementValue) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bug: KVC Access Fails for Private VariableThe |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import React from 'react'; | ||
| import type { DimensionValue, ViewStyle } from 'react-native'; | ||
| import BottomAccessoryViewNativeComponent, { | ||
| type OnNativeLayout, | ||
| type OnPlacementChanged, | ||
| } from './BottomAccessoryViewNativeComponent'; | ||
|
|
||
| const defaultStyle: ViewStyle = { | ||
| position: 'absolute', | ||
| top: 0, | ||
| left: 0, | ||
| }; | ||
|
|
||
| export interface BottomAccessoryViewProps { | ||
| renderBottomAccessoryView: (props: { | ||
| placement: 'inline' | 'expanded' | 'none'; | ||
| }) => React.ReactNode; | ||
| } | ||
|
|
||
| export const BottomAccessoryView = (props: BottomAccessoryViewProps) => { | ||
| const { renderBottomAccessoryView } = props; | ||
| const [bottomAccessoryDimensions, setBottomAccessoryDimensions] = | ||
| React.useState< | ||
| { width: DimensionValue; height: DimensionValue } | undefined | ||
| >({ width: '100%', height: '100%' }); | ||
| const [placement, setPlacement] = React.useState< | ||
| 'inline' | 'expanded' | 'none' | ||
| >('none'); | ||
|
|
||
| const handleNativeLayout = React.useCallback( | ||
| ({ nativeEvent: { width, height } }: { nativeEvent: OnNativeLayout }) => { | ||
| setBottomAccessoryDimensions({ width, height }); | ||
| }, | ||
| [setBottomAccessoryDimensions] | ||
| ); | ||
|
|
||
| const handlePlacementChanged = React.useCallback( | ||
| ({ nativeEvent }: { nativeEvent: OnPlacementChanged }) => { | ||
| if ( | ||
| nativeEvent.placement === 'inline' || | ||
| nativeEvent.placement === 'expanded' || | ||
| nativeEvent.placement === 'none' | ||
| ) { | ||
| setPlacement(nativeEvent.placement); | ||
| } | ||
| }, | ||
| [setPlacement] | ||
| ); | ||
|
|
||
| return ( | ||
| <BottomAccessoryViewNativeComponent | ||
| style={[defaultStyle, bottomAccessoryDimensions]} | ||
| onNativeLayout={handleNativeLayout} | ||
| onPlacementChanged={handlePlacementChanged} | ||
| > | ||
| {renderBottomAccessoryView({ placement })} | ||
| </BottomAccessoryViewNativeComponent> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's add
<Badge text="experimental" type="danger"/>hereThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
d02504c