-
Notifications
You must be signed in to change notification settings - Fork 93
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 11 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" /> | ||
|
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. 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. |
||
|
|
||
| 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.
|
||
| ::: | ||
|
|
||
| ### 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" /> | ||
|
Collaborator
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. Same here let's add here
Contributor
Author
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. |
||
|
|
||
| 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.
|
||
| ::: | ||
|
okwasniewski marked this conversation as resolved.
Outdated
|
||
|
|
||
| ### 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
Collaborator
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,
Contributor
Author
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]; | ||
| } | ||
|
Collaborator
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. This is not necessary.
Contributor
Author
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)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,4 +1,5 @@ | ||||||
| import SwiftUI | ||||||
| import React | ||||||
|
okwasniewski marked this conversation as resolved.
|
||||||
|
|
||||||
| @available(iOS 18, macOS 15, visionOS 2, tvOS 18, *) | ||||||
| struct NewTabView: AnyTabView { | ||||||
|
|
@@ -51,5 +52,75 @@ | |||||
| .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 accessoryView = bottomAccessoryView { | ||||||
|
okwasniewski marked this conversation as resolved.
Outdated
|
||||||
| if #available(iOS 26.0, *) { | ||||||
| BottomAccessoryRepresentableView(view: accessoryView) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| @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
|
||||||
| } | ||||||
|
|
||||||
| func updateUIView(_ uiView: PlatformView, context: Context) { | ||||||
| emitPlacementChanged(for: view) | ||||||
|
okwasniewski marked this conversation as resolved.
|
||||||
| } | ||||||
|
|
||||||
| private func emitPlacementChanged(for uiView: PlatformView) { | ||||||
| let selectorString = "emitOnPlacementChanged:" | ||||||
| let selector = NSSelectorFromString(selectorString) | ||||||
|
okwasniewski marked this conversation as resolved.
Outdated
Collaborator
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. I think this could be solved by a shared protocol between
Contributor
Author
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. Sure! I will do that asap
Contributor
Author
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.
Contributor
Author
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. The commit above introduced a bug making it impossible to press pressable items in the accessory view. So I need to look into that. Do you know what could have caused this?
Contributor
Author
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. It is adding the BottomAccessoryProvider as
Contributor
Author
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. Solved it in 8dfd717 |
||||||
| if uiView.responds(to: selector) { | ||||||
| var placementValue = "none" | ||||||
| if (tabViewBottomAccessoryPlacement == .inline) { | ||||||
|
Check warning on line 118 in packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
||||||
|
okwasniewski marked this conversation as resolved.
Outdated
|
||||||
| placementValue = "inline" | ||||||
| } else if (tabViewBottomAccessoryPlacement == .expanded) { | ||||||
|
Check warning on line 120 in packages/react-native-bottom-tabs/ios/TabView/NewTabView.swift
|
||||||
|
Collaborator
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.
Suggested change
Contributor
Author
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. |
||||||
| 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