Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ class ScreenStackHeaderSubviewManager :
value: Boolean,
) = Unit

override fun setPreventScrollToTopEnabled(
view: ScreenStackHeaderSubview?,
value: Boolean,
) = Unit

override fun updateState(
view: ScreenStackHeaderSubview,
props: ReactStylesDiffMap?,
Expand Down
62 changes: 62 additions & 0 deletions apps/src/tests/issue-tests/Test3731.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { Button, ScrollView, Text, View } from 'react-native';
import LongText from '../../shared/LongText';

type RouteParamList = {
Screen1: undefined;
};

const Stack = createNativeStackNavigator<RouteParamList>();

function Screen1() {
return (
<ScrollView>
<View
style={{
marginVertical: 20,
marginHorizontal: 200,
justifyContent: 'center',
}}>
<Text style={{ fontWeight: 'bold' }}>
Use iPadOS 26+. Scroll down and tap the subviews. The scroll view
SHOULD NOT scroll to top unless header is tapped outside of any header
subview.
</Text>
</View>
<LongText size="xl" />
<LongText size="xl" />
<LongText size="xl" />
</ScrollView>
);
}

const headerButton = (
<Button title="Click me" onPress={() => console.log('Button clicked!')} />
);
const headerButtonFn = () => headerButton;

export default function App() {
return (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen
name="Screen1"
component={Screen1}
options={{
headerLeft: headerButtonFn,
headerTitle: headerButtonFn,
unstable_headerRightItems: () => [
{
type: 'custom',
element: headerButton,
hidesSharedBackground: true,
},
],
}}
/>
</Stack.Navigator>
</NavigationContainer>
);
}
1 change: 1 addition & 0 deletions apps/src/tests/issue-tests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export { default as Test3576 } from './Test3576';
export { default as Test3596 } from './Test3596';
export { default as Test3611 } from './Test3611';
export { default as Test3617 } from './Test3617';
export { default as Test3731 } from './Test3731';
export { default as TestScreenAnimation } from './TestScreenAnimation';
// The following test was meant to demo the "go back" gesture using Reanimated
// but the associated PR in react-navigation is currently put on hold
Expand Down
1 change: 1 addition & 0 deletions ios/RNSScreenStackHeaderSubview.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ NS_ASSUME_NONNULL_BEGIN

@property (nonatomic) RNSScreenStackHeaderSubviewType type;
@property (nonatomic, readwrite) BOOL synchronousShadowStateUpdatesEnabled;
@property (nonatomic, readwrite) BOOL preventScrollToTopEnabled;

@property (nonatomic, weak) UIView *reactSuperview;

Expand Down
27 changes: 27 additions & 0 deletions ios/RNSScreenStackHeaderSubview.mm
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#import "RNSConvert.h"
#import "RNSDefines.h"
#import "RNSScreenStackHeaderConfig.h"
#import "RNSScrollToTopGuardGestureRecognizer.h"

#ifdef RCT_NEW_ARCH_ENABLED
#import <cxxreact/ReactNativeVersion.h>
Expand Down Expand Up @@ -145,12 +146,31 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
{
const auto &newHeaderSubviewProps = *std::static_pointer_cast<const react::RNSScreenStackHeaderSubviewProps>(props);

[self setPreventScrollToTopEnabled:newHeaderSubviewProps.preventScrollToTopEnabled];
[self setType:[RNSConvert RNSScreenStackHeaderSubviewTypeFromCppEquivalent:newHeaderSubviewProps.type]];

// Workaround for iPadOS 26+ header subviews. For left and right subviews, we apply this to wrapper view.
if (_preventScrollToTopEnabled &&
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if only applicable to 26+, could we exclude this code from lower versions? then applyToViewIfNecessary could be iPadOS26+ either and we fully hide this fix from other OS versions

(_type == RNSScreenStackHeaderSubviewTypeTitle || _type == RNSScreenStackHeaderSubviewTypeCenter) &&
![self hasScrollToTopGuardGestureRecognizer]) {
[RNSScrollToTopGuardGestureRecognizer applyToViewIfNecessary:self];
}

[self setHidesSharedBackground:newHeaderSubviewProps.hidesSharedBackground];
[self setSynchronousShadowStateUpdatesEnabled:newHeaderSubviewProps.synchronousShadowStateUpdatesEnabled];
[super updateProps:props oldProps:oldProps];
}

- (BOOL)hasScrollToTopGuardGestureRecognizer
{
for (UIGestureRecognizer *recognizer in self.gestureRecognizers) {
if ([recognizer isKindOfClass:[RNSScrollToTopGuardGestureRecognizer class]]) {
return YES;
}
}
return NO;
}

+ (react::ComponentDescriptorProvider)componentDescriptorProvider
{
return react::concreteComponentDescriptorProvider<react::RNSScreenStackHeaderSubviewComponentDescriptor>();
Expand Down Expand Up @@ -261,6 +281,13 @@ - (UIBarButtonItem *)getUIBarButtonItem
// of the center. To mitigate this, we add a wrapper view that will center
// RNSScreenStackHeaderSubview inside of itself.
UIView *wrapperView = [UIView new];

// Workaround for iPadOS 26+ header subviews. For center subview, we apply this directly to header subview
// in updateProps.
Comment thread
kligarski marked this conversation as resolved.
if (_preventScrollToTopEnabled) {
[RNSScrollToTopGuardGestureRecognizer applyToViewIfNecessary:wrapperView];
}

wrapperView.translatesAutoresizingMaskIntoConstraints = NO;

self.translatesAutoresizingMaskIntoConstraints = NO;
Expand Down
22 changes: 22 additions & 0 deletions ios/RNSScrollToTopGuardGestureRecognizer.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#pragma once

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

/**
* This gesture recognizer is necessary to handle header subviews with custom view
* on iPadOS 26+. Otherwise, clicking a custom view will result in scroll to top action even if
* there is a pressable inside the custom view.
*/
@interface RNSScrollToTopGuardGestureRecognizer : UITapGestureRecognizer <UIGestureRecognizerDelegate>
Comment thread
kligarski marked this conversation as resolved.

/**
* This method creates and adds the scroll to top guard gesture recognizer on iPadOS 26+.
* Otherwise, it is a no-op.
*/
+ (void)applyToViewIfNecessary:(UIView *)view;

@end

NS_ASSUME_NONNULL_END
51 changes: 51 additions & 0 deletions ios/RNSScrollToTopGuardGestureRecognizer.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#import "RNSScrollToTopGuardGestureRecognizer.h"
#import "RNSDefines.h"

@implementation RNSScrollToTopGuardGestureRecognizer

#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)

- (instancetype)init
{
if (self = [super initWithTarget:self action:nil]) {
self.cancelsTouchesInView = NO;
self.delegate = self;
}
return self;
}

#pragma mark - UIGestureRecognizerDelegate

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
return YES;
}

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
{
// We want to target tap gesture recognizers only (one responsible for scroll to top must be one).
if (![otherGestureRecognizer isKindOfClass:[UITapGestureRecognizer class]]) {
return NO;
}

// We don't know the exact recognizer responsible for scroll to top so we block all
// gesture recognizers above us.
return ![otherGestureRecognizer.view isDescendantOfView:gestureRecognizer.view];
}

#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)

+ (void)applyToViewIfNecessary:(nonnull UIView *)view
{
#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
if (@available(iOS 26.0, *)) {
if (UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad) {
[view addGestureRecognizer:[RNSScrollToTopGuardGestureRecognizer new]];
}
}
#endif // RNS_IPHONE_OS_VERSION_AVAILABLE(26_0)
}

@end
15 changes: 15 additions & 0 deletions src/components/ScreenStackHeaderConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ export const ScreenStackHeaderBackButtonImage = (
style={styles.headerSubview}
synchronousShadowStateUpdatesEnabled={
featureFlags.experiment.synchronousHeaderSubviewUpdatesEnabled
}
preventScrollToTopEnabled={
featureFlags.experiment.ipados26PreventScrollToTopForHeaderSubviews
}>
<Image resizeMode="center" fadeDuration={0} {...props} />
</ScreenStackHeaderSubview>
Expand All @@ -154,6 +157,9 @@ export const ScreenStackHeaderRightView = (
synchronousShadowStateUpdatesEnabled={
featureFlags.experiment.synchronousHeaderSubviewUpdatesEnabled
}
preventScrollToTopEnabled={
featureFlags.experiment.ipados26PreventScrollToTopForHeaderSubviews
}
style={[styles.headerSubview, style]}
/>
);
Expand All @@ -171,6 +177,9 @@ export const ScreenStackHeaderLeftView = (
synchronousShadowStateUpdatesEnabled={
featureFlags.experiment.synchronousHeaderSubviewUpdatesEnabled
}
preventScrollToTopEnabled={
featureFlags.experiment.ipados26PreventScrollToTopForHeaderSubviews
}
style={[styles.headerSubview, style]}
/>
);
Expand All @@ -188,6 +197,9 @@ export const ScreenStackHeaderCenterView = (
synchronousShadowStateUpdatesEnabled={
featureFlags.experiment.synchronousHeaderSubviewUpdatesEnabled
}
preventScrollToTopEnabled={
featureFlags.experiment.ipados26PreventScrollToTopForHeaderSubviews
}
style={[styles.headerSubviewCenter, style]}
/>
);
Expand All @@ -202,6 +214,9 @@ export const ScreenStackHeaderSearchBarView = (
synchronousShadowStateUpdatesEnabled={
featureFlags.experiment.synchronousHeaderSubviewUpdatesEnabled
}
preventScrollToTopEnabled={
featureFlags.experiment.ipados26PreventScrollToTopForHeaderSubviews
}
style={styles.headerSubview}
/>
);
Expand Down
1 change: 1 addition & 0 deletions src/fabric/ScreenStackHeaderSubviewNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface NativeProps extends ViewProps {
type?: CT.WithDefault<HeaderSubviewTypes, 'left'>;
hidesSharedBackground?: boolean;
synchronousShadowStateUpdatesEnabled?: CT.WithDefault<boolean, false>;
preventScrollToTopEnabled?: CT.WithDefault<boolean, true>;
}

export default codegenNativeComponent<NativeProps>(
Expand Down
20 changes: 20 additions & 0 deletions src/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const RNS_ANDROID_RESET_SCREEN_SHADOW_STATE_ON_ORIENTATION_CHANGE_DEFAULT =
true;
const RNS_IOS_PREVENT_REATTACHMENT_OF_DISMISSED_SCREENS = true;
const RNS_IOS_26_ALLOW_INTERACTIONS_DURING_TRANSITION = true;
const RNS_IPADOS_26_PREVENT_SCROLL_TO_TOP_FOR_HEADER_SUBVIEWS = true;
const RNS_DEBUG_LOGGING = false;

// TODO: Migrate freeze here
Expand Down Expand Up @@ -61,6 +62,8 @@ const _featureFlags = {
RNS_IOS_PREVENT_REATTACHMENT_OF_DISMISSED_SCREENS,
ios26AllowInteractionsDuringTransition:
RNS_IOS_26_ALLOW_INTERACTIONS_DURING_TRANSITION,
ipados26PreventScrollToTopForHeaderSubviews:
RNS_IPADOS_26_PREVENT_SCROLL_TO_TOP_FOR_HEADER_SUBVIEWS,
},
stable: {
debugLogging: RNS_DEBUG_LOGGING,
Expand Down Expand Up @@ -152,6 +155,12 @@ const rnsDebugLoggingAccessor = createStableFeatureFlagAccessor(
RNS_DEBUG_LOGGING,
);

const ipados26PreventScrollToTopForHeaderSubviewsAccessor =
createExperimentalFeatureFlagAccessor(
'ipados26PreventScrollToTopForHeaderSubviews',
RNS_IPADOS_26_PREVENT_SCROLL_TO_TOP_FOR_HEADER_SUBVIEWS,
);

/**
* Exposes configurable global behaviour of the library.
*
Expand Down Expand Up @@ -217,6 +226,17 @@ export const featureFlags = {
set ios26AllowInteractionsDuringTransition(value: boolean) {
ios26AllowInteractionsDuringTransitionAccessor.set(value);
},
/**
* On iPadOS 26+, prevents ScrollView from scrolling to top when header subview
* is pressed. On by default.
* PR: https://github.com/software-mansion/react-native-screens/pull/3731
*/
get ipados26PreventScrollToTopForHeaderSubviews() {
return ipados26PreventScrollToTopForHeaderSubviewsAccessor.get();
},
set ipados26PreventScrollToTopForHeaderSubviews(value: boolean) {
ipados26PreventScrollToTopForHeaderSubviewsAccessor.set(value);
},
},
/**
* Section for stable flags, which can be used to configure library behaviour.
Expand Down
Loading