Skip to content

Commit 2661c28

Browse files
authored
feat(iOS, Tabs): add bottomAccessory support (#3288)
## Description Adds support for `botttomAccessory` in Bottom Tabs starting from iOS 26. ## Synchronization with ShadowTree When bottom accessory transitions between `regular` and `inline` environments (when tab bar is minimized), we need to update the position and size of the bottom accessory. Our approach is different for RN < 0.82 and RN >= 0.82. ### Possible approaches We considered following approaches: 1. asynchronous state updates & events - this would result in frame changing right after animation start to the final size - using `double-rendering` approach (explained further in PR description) wouldn't really improve the situation 2. (a)synchronous state updates & asynchronous events + DisplayLink - this allows us to track the native animation of the bottom accessory and match frame size - unfortunately, as DisplayLink allows us to read presentation layer frame, we will always be 1 frame delayed - with asynchronous updates, this delay increases to multiple frames; with synchronous updates this would be exactly 1 frame (but this doesn't mean it looks better!) 3. synchronous state updates & asynchronous events - synchronous state updates allow us to rely on `CoreAnimations` framework that animates views natively (details explained further in PR description) - unfortunately, this mechanism requires all changes to be performed synchronously - state update (size change) is handled synchronously thanks to `immediate` mode for state update introduced in RN 0.82 but "synchronous" event dispatch in RN 0.82 isn't exactly `synchronous` (update is performed on next loop beat, this is not enough for CA) - due to this limitation, any changes in reaction to environment change are buggy (see `Mounting/unmounting views during transition` section) 4. synchronous state updates & `double-rendering` - to mitigate problems described above, we render the component twice - first for `regular` environment, second for `inline` environment - we change which component is visible on environment change -> we can do this fully synchronously in native code - CA animates this change with cross-fade animation - this results seems to be a good compromise but requires React state synchronization between components, e.g. via context For now, we decided to use: - Paper: approach 2 (asynchronous) - Fabric RN < 0.82: approach 2 (asynchronous) - Fabric RN >= 0.82: approach 4 Those solutions are described in more detail below: ### Legacy architecture & New architecture prior to `[email protected]` In versions prior to RN 0.82, we need use `DisplayLink` and presentation layer frames to get intermediate frames during the transition. This approach however has a major drawback - we are always at least one frame behind the current state as we're observing what is currently presented. When the difference in size/origin between frames is significant, you can see the content "jumping". In the case of bottom accessory, this is especially visible when using non-translucent background and transitioning from `inline` to `regular` environment (pay attention to the right edge of the accessory). https://github.com/user-attachments/assets/3931c318-2bb7-4bc2-847f-95168578d7d2 Introduction of synchronous state updates in RN 0.82 (more details below) does not improve the situation when using this approach as we are still going to be at least one frame behind the animation. ### `[email protected]` and higher Thanks to introduction of [synchronous state updates in RN 0.82](facebook/react-native#52604), we can rely fully on native mechanisms for handling the transition. Bottom accessory only receives the final frame of the transition and thanks to synchronous state updates, we can immediately recalculate the layout in the Shadow Tree and update the Host Tree. This allows Core Animation framework to make the transition smooth. Details of how we think this works are available [here](#3285 (comment)). Unfortunately, when using `react-native`, the situation is a little bit more complicated. #### Text Text component behaves differently to the native platform. During the transition, it immediately adapts to the final frame size and then it is stretched. In bare UIKit app, the text adapt to new frame size at the end of the transition. | `react-native` | `UIKit` | | --- | --- | | <video src="https://github.com/user-attachments/assets/ea509fee-53bb-4300-a28d-3404525d47dd" /> | <video src="https://github.com/user-attachments/assets/17b2d3f8-5f04-406a-828c-504d040ad013" /> | This requires more investigation and potentially changes in `react-native`. #### Borders `CoreAnimation` does not support non-uniform borders so `react-native` handles them in a custom way that does not seem to be compatible with the transition mechanism. | non-uniform borders | uniform borders + **CA enabled** | | --- | --- | | <video src="https://github.com/user-attachments/assets/e9ce53a9-bb4b-4465-a986-1a1f08791454" /> | <video src="https://github.com/user-attachments/assets/bcf029a4-764a-446c-82b6-91bfd42494c6" /> | This requires more investigation and potentially changes in `react-native`. #### Images Similar problem (in a way it looks, not the exact mechanism of the bug) happens when using images e.g. with `width: 100%`. https://github.com/user-attachments/assets/1aa0b77a-7800-471f-bd38-ac3eed506b87 This requires more investigation and potentially changes in `react-native`. #### Mounting/unmounting views during transition While state updates are performed synchronously, any changes to React Element Tree in reaction to environment change are handled asynchronously. We think that this is why the transition handled by `CoreAnimation` breaks when trying to mount/unmount components on environment change. https://github.com/user-attachments/assets/ef0730db-386a-46be-8fcf-e0def3ca4097 Here, we try to remove the note icon. Unfortunately, the rest of the layout does not adapt. You can also observe the text stretching as mentioned 2 sections above. In order to mitigate this issue, we use `double-rendering` approach, which has been described in `Possible approaches` section. https://github.com/user-attachments/assets/7d105e62-1f69-4886-845a-464addfa608b ## Changes - add `BottomTabsAccessory` JS component and use it in `BottomTabs`, - add `BottomTabsAccessoryComponentView`, `BottomTabsAccessoryEventEmitter`, `BottomTabsAccessoryComponentViewManager`, - add `BottomAccessoryHelper` to handle size and environment changes, - add `BottomTabsAccessoryShadowStateProxy` to synchronize state between Host and ShadowTree, - adapt `BottomTabsHost` to accept 2 types of children (`Screen` and `Accessory`), - add test screen. ## Test code and steps to reproduce Run `Test3288`. ## Checklist - [x] Included code example that can be used to test this change - [x] Updated TS types - [ ] Updated documentation: <!-- For adding new props to native-stack --> - [ ] https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md - [ ] https://github.com/software-mansion/react-native-screens/blob/main/native-stack/README.md - [ ] https://github.com/software-mansion/react-native-screens/blob/main/src/types.tsx - [ ] https://github.com/software-mansion/react-native-screens/blob/main/src/native-stack/types.tsx - [x] Ensured that CI passes
1 parent 10af839 commit 2661c28

File tree

50 files changed

+1713
-57
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+1713
-57
lines changed

apps/src/tests/Test3288.tsx

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import React, {
2+
createContext,
3+
Dispatch,
4+
SetStateAction,
5+
useContext,
6+
useState,
7+
} from 'react';
8+
9+
import ConfigWrapperContext, {
10+
type Configuration,
11+
DEFAULT_GLOBAL_CONFIGURATION,
12+
} from '../shared/gamma/containers/bottom-tabs/ConfigWrapperContext';
13+
import {
14+
BottomTabsContainer,
15+
type TabConfiguration,
16+
} from '../shared/gamma/containers/bottom-tabs/BottomTabsContainer';
17+
import {
18+
ColorValue,
19+
Pressable,
20+
PressableProps,
21+
ScrollView,
22+
StyleSheet,
23+
Text,
24+
View,
25+
} from 'react-native';
26+
import { SettingsPicker, SettingsSwitch } from '../shared';
27+
import Colors from '../shared/styling/Colors';
28+
import PressableWithFeedback from '../shared/PressableWithFeedback';
29+
import { TabBarMinimizeBehavior } from 'react-native-screens';
30+
import { BottomTabsAccessoryEnvironment } from 'react-native-screens/components/bottom-tabs/BottomTabsAccessory.types';
31+
import { NavigationContainer } from '@react-navigation/native';
32+
33+
type BottomAccessoryConfig = {
34+
shown: boolean;
35+
backgroundColor: ColorValue;
36+
shouldAdaptToEnvironment: boolean;
37+
tabBarMinimizeBehavior: TabBarMinimizeBehavior;
38+
};
39+
40+
type BottomAccessoryContextInterface = {
41+
config: BottomAccessoryConfig;
42+
setConfig: Dispatch<SetStateAction<BottomAccessoryConfig>>;
43+
};
44+
45+
const DEFAULT_BOTTOM_ACCESSORY_CONFIG: BottomAccessoryConfig = {
46+
shown: true,
47+
backgroundColor: 'transparent',
48+
shouldAdaptToEnvironment: true,
49+
tabBarMinimizeBehavior: 'onScrollDown',
50+
};
51+
52+
const BottomAccessoryContext =
53+
createContext<BottomAccessoryContextInterface | null>(null);
54+
55+
const useBottomAccessoryContext = () => {
56+
const bottomAccessoryContext = useContext(BottomAccessoryContext);
57+
58+
if (!bottomAccessoryContext) {
59+
throw new Error(
60+
'useBottomAccessoryContext has to be used within <BottomAccessoryContext.Provider>',
61+
);
62+
}
63+
64+
return bottomAccessoryContext;
65+
};
66+
67+
function Config() {
68+
const { config, setConfig } = useBottomAccessoryContext();
69+
70+
return (
71+
<ScrollView contentContainerStyle={{ padding: 16, gap: 5 }}>
72+
<SettingsSwitch
73+
label="shown"
74+
value={config.shown}
75+
onValueChange={value => setConfig({ ...config, shown: value })}
76+
/>
77+
<SettingsPicker<string>
78+
label="backgroundColor"
79+
value={String(config.backgroundColor)}
80+
onValueChange={value =>
81+
setConfig({
82+
...config,
83+
backgroundColor: value,
84+
})
85+
}
86+
items={['transparent', Colors.NavyLightTransparent, Colors.BlueLight80]}
87+
/>
88+
<SettingsSwitch
89+
label="shouldAdaptToEnvironment"
90+
value={config.shouldAdaptToEnvironment}
91+
onValueChange={value =>
92+
setConfig({ ...config, shouldAdaptToEnvironment: value })
93+
}
94+
/>
95+
<SettingsPicker<TabBarMinimizeBehavior>
96+
label="tabBarMinimizeBehavior"
97+
value={config.tabBarMinimizeBehavior}
98+
onValueChange={value =>
99+
setConfig({
100+
...config,
101+
tabBarMinimizeBehavior: value,
102+
})
103+
}
104+
items={['automatic', 'onScrollDown', 'onScrollUp', 'never']}
105+
/>
106+
</ScrollView>
107+
);
108+
}
109+
110+
function TestScreen() {
111+
return (
112+
<ScrollView
113+
contentContainerStyle={{
114+
width: '100%',
115+
height: 'auto',
116+
gap: 15,
117+
paddingHorizontal: 30,
118+
}}>
119+
{[...Array(50).keys()].map(index => (
120+
<PressableWithFeedback
121+
key={index + 1}
122+
onPress={() => console.log(`Pressed #${index + 1}`)}
123+
style={{
124+
paddingVertical: 10,
125+
paddingHorizontal: 20,
126+
}}>
127+
<Text>Pressable #{index + 1}</Text>
128+
</PressableWithFeedback>
129+
))}
130+
</ScrollView>
131+
);
132+
}
133+
134+
const TAB_CONFIGS: TabConfiguration[] = [
135+
{
136+
tabScreenProps: {
137+
tabKey: 'Tab1',
138+
title: 'Config',
139+
icon: {
140+
ios: {
141+
type: 'sfSymbol',
142+
name: 'gear',
143+
},
144+
},
145+
},
146+
component: Config,
147+
},
148+
{
149+
tabScreenProps: {
150+
tabKey: 'Tab2',
151+
title: 'Test',
152+
icon: {
153+
ios: {
154+
type: 'sfSymbol',
155+
name: 'rectangle.stack',
156+
},
157+
},
158+
},
159+
component: TestScreen,
160+
},
161+
];
162+
163+
function getBottomAccessory(
164+
environment: BottomTabsAccessoryEnvironment,
165+
config: BottomAccessoryConfig,
166+
) {
167+
const pressableStyle: PressableProps['style'] = ({ pressed }) => ({
168+
shadowColor: '#000',
169+
shadowOffset: {
170+
width: 0.5,
171+
height: 0.5,
172+
},
173+
shadowOpacity: pressed ? 0.9 : 0.0,
174+
shadowRadius: 2,
175+
transform: pressed ? [{ scale: 1.1 }] : [],
176+
});
177+
return (
178+
<View
179+
style={[
180+
styles.container,
181+
{
182+
backgroundColor: config.backgroundColor,
183+
},
184+
]}>
185+
<View style={styles.left}>
186+
<View style={styles.cover} />
187+
<View style={styles.data}>
188+
<Text style={styles.title} numberOfLines={1}>
189+
Never Gonna Give You Up
190+
</Text>
191+
<Text style={styles.author}>Rick Astley</Text>
192+
</View>
193+
</View>
194+
195+
<View style={styles.right}>
196+
{(environment === 'regular' || !config.shouldAdaptToEnvironment) && (
197+
<Pressable
198+
onPress={() => console.log('You know the rules and so do I')}
199+
style={pressableStyle}>
200+
<Text style={{ fontSize: 28 }}></Text>
201+
</Pressable>
202+
)}
203+
204+
<Pressable
205+
onPress={() => console.log("We're no strangers to love")}
206+
style={pressableStyle}>
207+
<Text style={{ fontSize: 30 }}></Text>
208+
</Pressable>
209+
</View>
210+
</View>
211+
);
212+
}
213+
214+
function Tabs() {
215+
const [config, setConfig] = React.useState<Configuration>(
216+
DEFAULT_GLOBAL_CONFIGURATION,
217+
);
218+
219+
const { config: bottomAccessoryConfig } = useBottomAccessoryContext();
220+
221+
return (
222+
<ConfigWrapperContext.Provider
223+
value={{
224+
config,
225+
setConfig,
226+
}}>
227+
<BottomTabsContainer
228+
tabConfigs={TAB_CONFIGS}
229+
tabBarMinimizeBehavior={bottomAccessoryConfig.tabBarMinimizeBehavior}
230+
bottomAccessory={
231+
bottomAccessoryConfig.shown
232+
? environment =>
233+
getBottomAccessory(environment, bottomAccessoryConfig)
234+
: undefined
235+
}
236+
/>
237+
</ConfigWrapperContext.Provider>
238+
);
239+
}
240+
241+
function App() {
242+
const [bottomAccessoryConfig, setBottomAccessoryConfig] =
243+
useState<BottomAccessoryConfig>(DEFAULT_BOTTOM_ACCESSORY_CONFIG);
244+
245+
return (
246+
<NavigationContainer>
247+
<BottomAccessoryContext.Provider
248+
value={{
249+
config: bottomAccessoryConfig,
250+
setConfig: setBottomAccessoryConfig,
251+
}}>
252+
<Tabs />
253+
</BottomAccessoryContext.Provider>
254+
</NavigationContainer>
255+
);
256+
}
257+
258+
export default App;
259+
260+
const styles = StyleSheet.create({
261+
container: {
262+
flex: 1,
263+
flexDirection: 'row',
264+
justifyContent: 'space-between',
265+
alignItems: 'center',
266+
padding: 5,
267+
},
268+
left: {
269+
flex: 1,
270+
flexDirection: 'row',
271+
alignItems: 'center',
272+
gap: 10,
273+
marginLeft: 10,
274+
},
275+
cover: { backgroundColor: 'pink', width: 30, height: 30 },
276+
data: { flex: 1, paddingRight: 5 },
277+
title: { fontWeight: 'bold' },
278+
author: { fontSize: 10, color: 'gray' },
279+
right: {
280+
flex: 0,
281+
flexDirection: 'row-reverse',
282+
alignItems: 'center',
283+
gap: 12,
284+
paddingRight: 10,
285+
},
286+
});

apps/src/tests/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export { default as Test3248 } from './Test3248';
156156
export { default as Test3265 } from './Test3265';
157157
export { default as Test3271 } from './Test3271';
158158
export { default as Test3282 } from './Test3282';
159+
export { default as Test3288 } from './Test3288';
159160
export { default as Test3342 } from './Test3342';
160161
export { default as Test3346 } from './Test3346';
161162
export { default as Test3369 } from './Test3369';
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#pragma once
2+
3+
#include <react/debug/react_native_assert.h>
4+
#include <react/renderer/components/rnscreens/Props.h>
5+
#include <react/renderer/core/ConcreteComponentDescriptor.h>
6+
#include "RNSBottomTabsAccessoryShadowNode.h"
7+
8+
namespace facebook::react {
9+
10+
class RNSBottomTabsAccessoryComponentDescriptor final
11+
: public ConcreteComponentDescriptor<RNSBottomTabsAccessoryShadowNode> {
12+
public:
13+
using ConcreteComponentDescriptor::ConcreteComponentDescriptor;
14+
15+
void adopt(ShadowNode &shadowNode) const override {
16+
react_native_assert(
17+
dynamic_cast<RNSBottomTabsAccessoryShadowNode *>(&shadowNode));
18+
auto &bottomTabsAccessoryShadowNode =
19+
static_cast<RNSBottomTabsAccessoryShadowNode &>(shadowNode);
20+
21+
auto state = std::static_pointer_cast<
22+
const RNSBottomTabsAccessoryShadowNode::ConcreteState>(
23+
shadowNode.getState());
24+
auto stateData = state->getData();
25+
26+
if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) {
27+
bottomTabsAccessoryShadowNode.setSize(stateData.frameSize);
28+
}
29+
30+
ConcreteComponentDescriptor::adopt(shadowNode);
31+
}
32+
};
33+
34+
} // namespace facebook::react
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#include "RNSBottomTabsAccessoryShadowNode.h"
2+
3+
namespace facebook::react {
4+
5+
extern const char RNSBottomTabsAccessoryComponentName[] =
6+
"RNSBottomTabsAccessory";
7+
8+
Point RNSBottomTabsAccessoryShadowNode::getContentOriginOffset(
9+
bool /*includeTransform*/) const {
10+
auto stateData = getStateData();
11+
return stateData.contentOffset;
12+
}
13+
14+
} // namespace facebook::react
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#pragma once
2+
3+
#include <jsi/jsi.h>
4+
#include <react/renderer/components/rnscreens/EventEmitters.h>
5+
#include <react/renderer/components/rnscreens/Props.h>
6+
#include <react/renderer/components/view/ConcreteViewShadowNode.h>
7+
#include "RNSBottomTabsAccessoryState.h"
8+
9+
namespace facebook::react {
10+
11+
JSI_EXPORT extern const char RNSBottomTabsAccessoryComponentName[];
12+
13+
class JSI_EXPORT RNSBottomTabsAccessoryShadowNode final
14+
: public ConcreteViewShadowNode<
15+
RNSBottomTabsAccessoryComponentName,
16+
RNSBottomTabsAccessoryProps,
17+
RNSBottomTabsAccessoryEventEmitter,
18+
RNSBottomTabsAccessoryState> {
19+
public:
20+
using ConcreteViewShadowNode::ConcreteViewShadowNode;
21+
using StateData = ConcreteViewShadowNode::ConcreteStateData;
22+
23+
Point getContentOriginOffset(bool includeTransform) const override;
24+
};
25+
26+
} // namespace facebook::react
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#pragma once
2+
3+
#include <react/renderer/graphics/Point.h>
4+
#include <react/renderer/graphics/Size.h>
5+
6+
namespace facebook::react {
7+
8+
class JSI_EXPORT RNSBottomTabsAccessoryState final {
9+
public:
10+
using Shared = std::shared_ptr<const RNSBottomTabsAccessoryState>;
11+
12+
RNSBottomTabsAccessoryState() {};
13+
RNSBottomTabsAccessoryState(Size frameSize_, Point contentOffset_)
14+
: frameSize(frameSize_), contentOffset(contentOffset_) {};
15+
16+
const Size frameSize{};
17+
const Point contentOffset{};
18+
};
19+
20+
} // namespace facebook::react

0 commit comments

Comments
 (0)