Skip to content

Commit

Permalink
fix(iOS, Paper): fix header layout when updating non focued screen (#…
Browse files Browse the repository at this point in the history
…2552)

## Description

> [!note]
> This issue seems to concern only old architecture. See below for
description of Fabric situation 👇🏻

This PR aims to fix a bug described below 👇🏻 and at the same time
balancing on the thin edge of not introducing regressions regarding:
#2316, #2248, #2385.

### Bug description

See `Test2552`. 

The flow is as follows: 

1. we have tab navigator with nested stack navigators on each tab (A &
B),
2. In `useLayoutEffect` we schedule a timer which callback changing the
subview elements,
3. before the timer fires we change the tab from A to B,
4. wait few seconds fot timer to fire,
5. go back to A,
6. notice that the subviews are laid out incorrectly (video below 👇🏻)


https://github.com/user-attachments/assets/2bf621a7-efd4-44cf-95e1-45a46e425f07


Basically what happens is we're sending `layoutIfNeeded` to navigation
bar before subviews are mounted under navigation bar view hierarchy.
This causes "isLayoutDirty" flags to be cleaned up and subsequent
`layoutIfNeeded` messages have no effect.

## Changes

We now wait with triggering layout for the subview to be attached to
window.

> [!note]
> TODO: possibly we should call the layout from `didMoveToWindow` but I
haven't found the case it does not work without the call, so I'm not
adding it for now.

> [!note]
> Calling layout on whole navigation bar for every subview update seems
wrong, however the change is subview change can have effect on other
neighbouring views (e.g. long title which need to be truncated) & it
seems that we need to do this. Maybe we could get away will requesting
it only from UINavigationBarContentView skipping few levels, but this is
left for consideration in the future.

> [!important]
> I do not understand why we need to send `layoutIfNeeded` and
`setNeedsLayout` is not enough, but not sending the former results in
regressions in Test432. Leaving it for future considerations.

### Fabric 

The strategy with setting screen options inside timer nested in
useLayoutEffect seems to not work at all on new architecture. My
impression is that the timer gets cancelled every time the screen loses
focus (tab is changed). I do not know whether this is a bug on our side,
or maybe it should work this way or it is Fabric bug. This should be
debugged in future.


## Test code and steps to reproduce

Test2552 - Follows the steps described above ☝🏻 

Test432 - Follow the steps from issues described in mentioned issues
:point_up:

## Checklist

- [ ] Included code example that can be used to test this change
- [ ] 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
- [ ] Ensured that CI passes
  • Loading branch information
kkafar authored Dec 6, 2024
1 parent 1142399 commit 8c43de6
Show file tree
Hide file tree
Showing 4 changed files with 215 additions and 9 deletions.
182 changes: 182 additions & 0 deletions apps/src/tests/Test2552.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { View, Button, StyleSheet } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import {
NativeStackScreenProps,
createNativeStackNavigator,
} from '@react-navigation/native-stack';
import React, { useEffect, useLayoutEffect, useState } from 'react';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { Square } from '../shared';

type FirstStackParamList = {
Home: undefined;
Details: undefined;
};

type SecondStackParamList = {
Settings: undefined;
Info: undefined;
};

const HomeScreen = ({
navigation,
}: NativeStackScreenProps<FirstStackParamList>) => {

useLayoutEffect(() => {
// Set initial title
navigation.setOptions({
title: 'Initial Title',
});

// Set headerLeft and headerRight after 2 seconds
const timer1 = setTimeout(() => {
navigation.setOptions({
headerLeft: () => <Square size={16} color="goldenrod" />,
headerRight: () => <Square size={20} color="green" />,
});
}, 3000);

// Clean up timers
return () => {
clearTimeout(timer1);
};
}, [navigation]);
return (
<View style={styles.container}>
<Button
title={'Go to details'}
onPress={() => navigation.navigate('Details')}
/>
</View>
);
};

const DetailsScreen = ({
navigation,
}: NativeStackScreenProps<FirstStackParamList>) => {
const [x, setX] = useState(false);
useEffect(() => {
navigation.setOptions({
headerBackVisible: !x,
headerRight: () =>
x ? <Square size={20} color="green" /> : <Square size={10} />,
});
}, [navigation, x]);

return <Button title="Toggle subviews" onPress={() => setX(prev => !prev)} />;
};

const InfoScreen = ({
navigation,
}: NativeStackScreenProps<SecondStackParamList>) => {
const [hasLeftItem, setHasLeftItem] = useState(false);

const square1 = (props: { tintColor?: string }) => (
<View style={{ gap: 8, flexDirection: 'row' }}>
{hasLeftItem && <Square {...props} color="green" size={20} />}
<Square {...props} color="green" size={20} />
</View>
);

const square2 = (props: { tintColor?: string }) => (
<Square {...props} color="red" size={20} />
);

useLayoutEffect(() => {
navigation.setOptions({
headerRight: square1,
headerTitle: undefined,
headerLeft: hasLeftItem ? square2 : undefined,
});
}, [navigation, hasLeftItem]);

return (
<View style={styles.container}>
<Button
title="Toggle subviews"
onPress={() => setHasLeftItem(prev => !prev)}
/>
</View>
);
};

const SettingsScreen = ({
navigation,
}: NativeStackScreenProps<SecondStackParamList>) => {
return (
<View style={styles.container}>
<Button
title={'Go to Info'}
onPress={() => navigation.navigate('Info')}
/>
</View>
);
};

const FirstStack = createNativeStackNavigator<FirstStackParamList>();

const FirstStackNavigator = () => {
return (
<FirstStack.Navigator>
<FirstStack.Screen
name="Home"
component={HomeScreen}
/>
<FirstStack.Screen name="Details" component={DetailsScreen} />
</FirstStack.Navigator>
);
};

const SecondStack = createNativeStackNavigator<SecondStackParamList>();

const SecondStackNavigator = () => {
return (
<SecondStack.Navigator>
<SecondStack.Screen
name="Settings"
component={SettingsScreen}
options={{
headerLeft: () => <Square size={16} />,
}}
/>
<SecondStack.Screen name="Info" component={InfoScreen} />
</SecondStack.Navigator>
);
};

const Tabs = createBottomTabNavigator();



export function BottomTabNavigator() {
return (
<Tabs.Navigator screenOptions={{ headerShown: false }}>
<Tabs.Screen name="First" component={FirstStackNavigator} />
<Tabs.Screen name="Second" component={SecondStackNavigator} />
</Tabs.Navigator>
);
}

const RootStack = createNativeStackNavigator();

export default function App() {
return (
<NavigationContainer>
<RootStack.Navigator>
<RootStack.Screen
name="Root"
component={BottomTabNavigator}
options={{ headerShown: false }}
/>
</RootStack.Navigator>
</NavigationContainer>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
2 changes: 2 additions & 0 deletions apps/src/tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export { default as Test2320 } from './Test2320';
export { default as Test2332 } from './Test2332';
export { default as Test2379 } from './Test2379';
export { default as Test2395 } from './Test2395';
export { default as Test2552 } from './Test2552';
export { default as TestScreenAnimation } from './TestScreenAnimation';
export { default as TestScreenAnimationV5 } from './TestScreenAnimationV5';
export { default as TestHeader } from './TestHeader';
Expand All @@ -126,3 +127,4 @@ export { default as TestModalNavigation } from './TestModalNavigation';
export { default as TestMemoryLeak } from './TestMemoryLeak';
export { default as TestFormSheet } from './TestFormSheet';
export { default as TestAndroidTransitions } from './TestAndroidTransitions';

12 changes: 8 additions & 4 deletions ios/RNSScreenStackHeaderConfig.mm
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ + (UINavigationBarAppearance *)buildAppearance:(UIViewController *)vc
UIImage *shadowImage = appearance.shadowImage;
// transparent background color
[appearance configureWithTransparentBackground];

if (!config.hideShadow) {
appearance.shadowColor = shadowColor;
appearance.shadowImage = shadowImage;
Expand Down Expand Up @@ -688,11 +688,13 @@ + (void)updateViewController:(UIViewController *)vc
UINavigationBarAppearance *scrollEdgeAppearance =
[[UINavigationBarAppearance alloc] initWithBarAppearance:appearance];
if (config.largeTitleBackgroundColor != nil) {
// Add support for using a fully transparent bar when the backgroundColor is set to transparent.
// Add support for using a fully transparent bar when the backgroundColor is set to transparent.
if (CGColorGetAlpha(config.largeTitleBackgroundColor.CGColor) == 0.) {
// This will also remove the background blur effect in the large title which is otherwise inherited from the standard appearance.
// This will also remove the background blur effect in the large title which is otherwise inherited from the
// standard appearance.
[scrollEdgeAppearance configureWithTransparentBackground];
// This must be set to nil otherwise a default view will be added to the navigation bar background with an opaque background.
// This must be set to nil otherwise a default view will be added to the navigation bar background with an
// opaque background.
scrollEdgeAppearance.backgroundColor = nil;
} else {
scrollEdgeAppearance.backgroundColor = config.largeTitleBackgroundColor;
Expand Down Expand Up @@ -852,6 +854,8 @@ - (void)mountChildComponentView:(UIView<RCTComponentViewProtocol> *)childCompone

// [_reactSubviews insertObject:(RNSScreenStackHeaderSubview *)childComponentView atIndex:index];
[self insertReactSubview:(RNSScreenStackHeaderSubview *)childComponentView atIndex:index];

// TODO: This could be called only once per transaction.
[self updateViewControllerIfNeeded];
}

Expand Down
28 changes: 23 additions & 5 deletions ios/RNSScreenStackHeaderSubview.mm
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,27 @@ - (nullable RNSScreenStackHeaderConfig *)getHeaderConfig

// We're forcing the navigation controller's view to re-layout
// see: https://github.com/software-mansion/react-native-screens/pull/2385
- (void)layoutNavigationBarIfNeeded
- (void)layoutNavigationBar
{
// If we're not attached yet, we should not layout the navigation bar,
// because the layout flow won't reach us & we will clear "isLayoutDirty" flags
// on view above us, causing subsequent layout request to not reach us.
if (self.window == nil) {
return;
}

RNSScreenStackHeaderConfig *headerConfig = [self getHeaderConfig];
UINavigationController *navctr = headerConfig.screenView.reactViewController.navigationController;
[navctr.navigationBar layoutIfNeeded];

UIView *toLayoutView = navctr.navigationBar;

// TODO: It is possible, that this needs to be called only on old architecture.
// Make sure that Test432 keeps working.
[toLayoutView setNeedsLayout];

// TODO: Determine why this must be called & deferring layout to next "update cycle"
// is not sufficient. See Test2552 and Test432. (Talking Paper here).
[toLayoutView layoutIfNeeded];
}

#ifdef RCT_NEW_ARCH_ENABLED
Expand Down Expand Up @@ -101,7 +117,7 @@ - (void)updateLayoutMetrics:(const react::LayoutMetrics &)layoutMetrics
self);
} else {
self.bounds = CGRect{CGPointZero, frame.size};
[self layoutNavigationBarIfNeeded];
[self layoutNavigationBar];
}
}
RNS_IGNORE_SUPER_CALL_BEGIN
Expand All @@ -118,8 +134,10 @@ - (void)reactSetFrame:(CGRect)frame
{
// Block any attempt to set coordinates on RNSScreenStackHeaderSubview. This
// makes UINavigationBar the only one to control the position of header content.
[super reactSetFrame:CGRectMake(0, 0, frame.size.width, frame.size.height)];
[self layoutNavigationBarIfNeeded];
if (!CGSizeEqualToSize(frame.size, self.frame.size)) {
[super reactSetFrame:CGRectMake(0, 0, frame.size.width, frame.size.height)];
[self layoutNavigationBar];
}
}
#endif // RCT_NEW_ARCH_ENABLED

Expand Down

0 comments on commit 8c43de6

Please sign in to comment.