Skip to content

Commit

Permalink
feat(iOS): Add support for UINavigationBackButtonDisplayMode (#2123)
Browse files Browse the repository at this point in the history
## Description

~This PR improves upon #2105. #2105 allowed to use iOS 14 default back
button behavior when label is not provided. This PR allows to modify the
behavior by allowing to provide UINavigationButtonBackButtonDisplayMode
and enables it for custom text (without style modifications). The main
problem is that we used to provide backButtonItem in most of the cases
which
[disables](https://developer.apple.com/documentation/uikit/uinavigationitem/3656350-backbuttondisplaymode)
backButtonDisplayMode.~

This PR adds possibility to customize default behavior of back button
using `backButtonDisplayMode`
([UINavigationBackButtonDisplayMode](https://developer.apple.com/documentation/uikit/uinavigationitem/backbuttondisplaymode))
for iOS.

:warning: **This modifies only default back button**, when any
customization is added (including headerBackTitle) in native part we
create custom `RNSUIBarButtonItem` and set it as `backButtonItem`, which
[disables](https://developer.apple.com/documentation/uikit/uinavigationitem/3656350-backbuttondisplaymode)
`backButtonDisplayMode` behavior.

I tried to make it work together with custom label (`headerBackTitle`)
using `prevItem.backButtonTitle`, but due to iOS limitations it is not
viable option. It influences also back button menu - changes the label
of previous screen - which is not the behavior we want.

To sum up, `backButtonDisplayMode` work when none of:
- `headerBackTitleStyle.fontFamily`
- `headerBackTitleStyle.fontSize`
- `headerBackTitle`
- `disableBackButtonMenu`

are set. 

## Screenshots / GIFs

|Paper|Fabric|
|-|-|
|<video
src="https://github.com/software-mansion/react-native-screens/assets/11800297/c6aa7697-4331-4cb4-a81d-7f77f128513d"
/>|<video
src="https://github.com/software-mansion/react-native-screens/assets/11800297/fa0edd92-1aa2-45e5-a466-516c0ec120d2"
/>|

<details>
<summary>Example component used in tests:</summary>

```jsx
import * as React from 'react';
import { Button, View, Text, StyleSheet } from 'react-native';
import { NavigationContainer, ParamListBase } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';

const Stack = createNativeStackNavigator();

type NavProp = {
  navigation: NativeStackNavigationProp<ParamListBase>;
};

export default function App() {
  return (
    <NavigationContainer>
      <Stack.Navigator>
        <Stack.Screen
          name="screenA"
          component={ScreenA}
          options={{ headerTitle: 'A: Home' }}
        />
        <Stack.Screen
          name="screenB"
          component={ScreenB}
          options={{
            headerTitle: 'B: default',
            backButtonDisplayMode: 'default',
          }}
        />
        <Stack.Screen
          name="screenC"
          component={ScreenC}
          options={{
            headerTitle: 'C: generic',
            backButtonDisplayMode: 'generic',
          }}
        />
        <Stack.Screen
          name="screenD"
          component={ScreenD}
          options={{
            headerTitle: 'D: minimal',
            backButtonDisplayMode: 'minimal',
          }}
        />
        <Stack.Screen
          name="screenE"
          component={ScreenE}
          options={{
            headerTitle: 'E: custom',
            headerBackTitle: 'Back Title',
            backButtonDisplayMode: 'minimal',
          }}
        />
      </Stack.Navigator>
    </NavigationContainer>
  );
}

const ScreenA = ({ navigation }: NavProp) => (
  <View style={styles.container}>
    <Text>Screen A</Text>
    <Button
      onPress={() => navigation.navigate('screenB')}
      title="Go to screen B"
    />
  </View>
);

const ScreenB = ({ navigation }: NavProp) => (
  <View style={styles.container}>
    <Text>Screen B</Text>
    <Text>backButtonDisplayMode: default</Text>
    <Button
      onPress={() => navigation.navigate('screenC')}
      title="Go to screen C"
    />
  </View>
);

const ScreenC = ({ navigation }: NavProp) => (
  <View style={{ flex: 1, paddingTop: 50 }}>
    <Text>Screen C</Text>
    <Text>backButtonDisplayMode: generic</Text>
    <Button
      onPress={() => navigation.navigate('screenD')}
      title="Go to screen D"
    />
  </View>
);

const ScreenD = ({ navigation }: NavProp) => (
  <View style={styles.container}>
    <Text>Screen D</Text>
    <Text>backButtonDisplayMode: minimal</Text>
    <Button
      onPress={() => navigation.navigate('screenE')}
      title="Go to screen E"
    />
  </View>
);

const ScreenE = (_props: NavProp) => (
  <View style={styles.container}>
    <Text>Screen E</Text>
    <Text>backButtonDisplayMode omitted because of the headerBackTitle</Text>
  </View>
);

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: 'center', justifyContent: 'space-around' },
});
```

</details>

## Checklist

- [x] Included code example that can be used to test this change
- [x] Updated TS types
- [x] Updated documentation: <!-- For adding new props to native-stack
-->
- [x]
https://github.com/software-mansion/react-native-screens/blob/main/guides/GUIDE_FOR_LIBRARY_AUTHORS.md
- [x]
https://github.com/software-mansion/react-native-screens/blob/main/native-stack/README.md
- [x]
https://github.com/software-mansion/react-native-screens/blob/main/src/types.tsx
- [x]
https://github.com/software-mansion/react-native-screens/blob/main/src/native-stack/types.tsx
- [x] Ensured that CI passes

Tested #1864: Paper ✅ Fabric ✅
Tested #1646: Paper ❌ Fabric ❌ - but it does not work on main too, could
now be achieved using `backButtonDisplayMode: ‘minimal’`

---------

Co-authored-by: Kacper Kafara <kacper.kafara@swmansion.com>
  • Loading branch information
maciekstosio and kkafar committed May 16, 2024
1 parent f3630d9 commit b47c4ac
Show file tree
Hide file tree
Showing 13 changed files with 103 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -201,4 +201,8 @@ class ScreenStackHeaderConfigViewManager : ViewGroupManager<ScreenStackHeaderCon
override fun setDisableBackButtonMenu(view: ScreenStackHeaderConfig?, value: Boolean) {
logNotAvailable("disableBackButtonMenu")
}

override fun setBackButtonDisplayMode(view: ScreenStackHeaderConfig?, value: String?) {
logNotAvailable("backButtonDisplayMode")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ public void setProperty(T view, String propName, @Nullable Object value) {
case "disableBackButtonMenu":
mViewManager.setDisableBackButtonMenu(view, value == null ? false : (boolean) value);
break;
case "backButtonDisplayMode":
mViewManager.setBackButtonDisplayMode(view, (String) value);
break;
case "hideBackButton":
mViewManager.setHideBackButton(view, value == null ? false : (boolean) value);
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public interface RNSScreenStackHeaderConfigManagerInterface<T extends View> {
void setTitleFontWeight(T view, @Nullable String value);
void setTitleColor(T view, @Nullable Integer value);
void setDisableBackButtonMenu(T view, boolean value);
void setBackButtonDisplayMode(T view, @Nullable String value);
void setHideBackButton(T view, boolean value);
void setBackButtonInCustomView(T view, boolean value);
void setTopInsetEnabled(T view, boolean value);
Expand Down
7 changes: 7 additions & 0 deletions guides/GUIDE_FOR_LIBRARY_AUTHORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,13 @@ Controls whether the stack should be in `rtl` or `ltr` form.

Boolean indicating whether to show the menu on longPress of iOS >= 14 back button.

### `backButtonDisplayMode` (iOS only)

Enum value indicating display mode of **default** back button. It works on iOS >= 14, and is used only when none of: `backTitleFontFamily`, `backTitleFontSize`, `disableBackButtonMenu` or `backTitle` is set. Otherwise, when the button is customized, under the hood we use iOS native `backButtonItem` which overrides `backButtonDisplayMode`. Read more [#2123](https://github.com/software-mansion/react-native-screens/pull/2123). Possible options:
- `default` – show given back button previous controller title, system generic or just icon based on available space
- `generic` – show given system generic or just icon based on available space
- `minimal` – show just an icon

### `hidden`

When set to `true` the header will be hidden while the parent `Screen` is on the top of the stack. The default value is `false`.
Expand Down
6 changes: 6 additions & 0 deletions ios/RNSConvert.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ namespace react = facebook::react;

@interface RNSConvert : NSObject

+ (UISemanticContentAttribute)UISemanticContentAttributeFromCppEquivalent:
(react::RNSScreenStackHeaderConfigDirection)direction;

+ (UINavigationItemBackButtonDisplayMode)UINavigationItemBackButtonDisplayModeFromCppEquivalent:
(react::RNSScreenStackHeaderConfigBackButtonDisplayMode)backButtonDisplayMode;

+ (RNSScreenStackPresentation)RNSScreenStackPresentationFromCppEquivalent:
(react::RNSScreenStackPresentation)stackPresentation;

Expand Down
24 changes: 24 additions & 0 deletions ios/RNSConvert.mm
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,30 @@
#ifdef RCT_NEW_ARCH_ENABLED
@implementation RNSConvert

+ (UISemanticContentAttribute)UISemanticContentAttributeFromCppEquivalent:
(react::RNSScreenStackHeaderConfigDirection)direction
{
switch (direction) {
case react::RNSScreenStackHeaderConfigDirection::Rtl:
return UISemanticContentAttributeForceRightToLeft;
case react::RNSScreenStackHeaderConfigDirection::Ltr:
return UISemanticContentAttributeForceLeftToRight;
}
}

+ (UINavigationItemBackButtonDisplayMode)UINavigationItemBackButtonDisplayModeFromCppEquivalent:
(react::RNSScreenStackHeaderConfigBackButtonDisplayMode)backButtonDisplayMode
{
switch (backButtonDisplayMode) {
case react::RNSScreenStackHeaderConfigBackButtonDisplayMode::Default:
return UINavigationItemBackButtonDisplayModeDefault;
case react::RNSScreenStackHeaderConfigBackButtonDisplayMode::Generic:
return UINavigationItemBackButtonDisplayModeGeneric;
case react::RNSScreenStackHeaderConfigBackButtonDisplayMode::Minimal:
return UINavigationItemBackButtonDisplayModeMinimal;
}
}

+ (RNSScreenStackPresentation)RNSScreenStackPresentationFromCppEquivalent:
(react::RNSScreenStackPresentation)stackPresentation
{
Expand Down
2 changes: 2 additions & 0 deletions ios/RNSScreenStackHeaderConfig.h
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
@property (nonatomic) BOOL translucent;
@property (nonatomic) BOOL backButtonInCustomView;
@property (nonatomic) UISemanticContentAttribute direction;
@property (nonatomic) UINavigationItemBackButtonDisplayMode backButtonDisplayMode;

+ (void)willShowViewController:(UIViewController *)vc
animated:(BOOL)animated
Expand All @@ -70,5 +71,6 @@

+ (UIBlurEffectStyle)UIBlurEffectStyle:(id)json;
+ (UISemanticContentAttribute)UISemanticContentAttribute:(id)json;
+ (UINavigationItemBackButtonDisplayMode)UINavigationItemBackButtonDisplayMode:(id)json;

@end
40 changes: 25 additions & 15 deletions ios/RNSScreenStackHeaderConfig.mm
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#import <React/RCTFont.h>
#import <React/RCTImageLoader.h>
#import <React/RCTImageSource.h>
#import "RNSConvert.h"
#import "RNSScreen.h"
#import "RNSScreenStackHeaderConfig.h"
#import "RNSSearchBar.h"
Expand Down Expand Up @@ -513,6 +514,13 @@ + (void)updateViewController:(UIViewController *)vc

auto isBackButtonCustomized = !isBackTitleBlank || config.disableBackButtonMenu;

#if defined(__IPHONE_OS_VERSION_MAX_ALLOWED) && defined(__IPHONE_14_0) && \
__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_14_0
if (@available(iOS 14.0, *)) {
prevItem.backButtonDisplayMode = config.backButtonDisplayMode;
}
#endif

if (config.isBackTitleVisible) {
if ((config.backTitleFontFamily &&
// While being used by react-navigation, the `backTitleFontFamily` will
Expand Down Expand Up @@ -786,26 +794,16 @@ - (void)prepareForRecycle
_initialPropsSet = NO;
}

+ (react::ComponentDescriptorProvider)componentDescriptorProvider
{
return react::concreteComponentDescriptorProvider<react::RNSScreenStackHeaderConfigComponentDescriptor>();
}

- (NSNumber *)getFontSizePropValue:(int)value
{
if (value > 0)
return [NSNumber numberWithInt:value];
return nil;
}

- (UISemanticContentAttribute)getDirectionPropValue:(react::RNSScreenStackHeaderConfigDirection)direction
+ (react::ComponentDescriptorProvider)componentDescriptorProvider
{
switch (direction) {
case react::RNSScreenStackHeaderConfigDirection::Rtl:
return UISemanticContentAttributeForceRightToLeft;
case react::RNSScreenStackHeaderConfigDirection::Ltr:
return UISemanticContentAttributeForceLeftToRight;
}
return react::concreteComponentDescriptorProvider<react::RNSScreenStackHeaderConfigComponentDescriptor>();
}

- (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::Shared const &)oldProps
Expand Down Expand Up @@ -852,9 +850,11 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props::
_backTitleFontSize = [self getFontSizePropValue:newScreenProps.backTitleFontSize];
_hideBackButton = newScreenProps.hideBackButton;
_disableBackButtonMenu = newScreenProps.disableBackButtonMenu;
_backButtonDisplayMode =
[RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode];

if (newScreenProps.direction != oldScreenProps.direction) {
_direction = [self getDirectionPropValue:newScreenProps.direction];
_direction = [RNSConvert UISemanticContentAttributeFromCppEquivalent:newScreenProps.direction];
}

_backTitleVisible = newScreenProps.backTitleVisible;
Expand Down Expand Up @@ -945,8 +945,8 @@ - (UIView *)view
RCT_EXPORT_VIEW_PROPERTY(hideShadow, BOOL)
RCT_EXPORT_VIEW_PROPERTY(backButtonInCustomView, BOOL)
RCT_EXPORT_VIEW_PROPERTY(disableBackButtonMenu, BOOL)
// `hidden` is an UIView property, we need to use different name internally
RCT_REMAP_VIEW_PROPERTY(hidden, hide, BOOL)
RCT_EXPORT_VIEW_PROPERTY(backButtonDisplayMode, UINavigationItemBackButtonDisplayMode)
RCT_REMAP_VIEW_PROPERTY(hidden, hide, BOOL) // `hidden` is an UIView property, we need to use different name internally
RCT_EXPORT_VIEW_PROPERTY(translucent, BOOL)

@end
Expand Down Expand Up @@ -1002,6 +1002,16 @@ + (NSMutableDictionary *)blurEffectsForIOSVersion
UISemanticContentAttributeUnspecified,
integerValue)

RCT_ENUM_CONVERTER(
UINavigationItemBackButtonDisplayMode,
(@{
@"default" : @(UINavigationItemBackButtonDisplayModeDefault),
@"generic" : @(UINavigationItemBackButtonDisplayModeGeneric),
@"minimal" : @(UINavigationItemBackButtonDisplayModeMinimal),
}),
UINavigationItemBackButtonDisplayModeDefault,
integerValue)

RCT_ENUM_CONVERTER(UIBlurEffectStyle, ([self blurEffectsForIOSVersion]), UIBlurEffectStyleExtraLight, integerValue)

@end
7 changes: 7 additions & 0 deletions native-stack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ String that applies `rtl` or `ltr` form to the stack. On Android, you have to ad

Boolean indicating whether to show the menu on longPress of iOS >= 14 back button.

#### `backButtonDisplayMode` (iOS only)

Enum value indicating display mode of **default** back button. It works on iOS >= 14, and is used only when none of: `backTitleFontFamily`, `backTitleFontSize`, `disableBackButtonMenu` or `backTitle` is set. Otherwise, when the button is customized, under the hood we use iOS native `backButtonItem` which overrides `backButtonDisplayMode`. Read more [#2123](https://github.com/software-mansion/react-native-screens/pull/2123). Possible options:
- `default` – show given back button previous controller title, system generic or just icon based on available space
- `generic` – show given system generic or just icon based on available space
- `minimal` – show just an icon

#### `fullScreenSwipeEnabled` (iOS only)

Boolean indicating whether the swipe gesture should work on whole screen. Swiping with this option results in the same transition animation as `simple_push` by default. It can be changed to other custom animations with `customAnimationOnSwipe` prop, but default iOS swipe animation is not achievable due to usage of custom recognizer. Defaults to `false`.
Expand Down
3 changes: 3 additions & 0 deletions src/fabric/ScreenStackHeaderConfigNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ type OnAttachedEvent = Readonly<{}>;
// eslint-disable-next-line @typescript-eslint/ban-types
type OnDetachedEvent = Readonly<{}>;

type BackButtonDisplayMode = 'minimal' | 'default' | 'generic';

export interface NativeProps extends ViewProps {
onAttached?: DirectEventHandler<OnAttachedEvent>;
onDetached?: DirectEventHandler<OnDetachedEvent>;
Expand All @@ -39,6 +41,7 @@ export interface NativeProps extends ViewProps {
titleFontWeight?: string;
titleColor?: ColorValue;
disableBackButtonMenu?: boolean;
backButtonDisplayMode?: WithDefault<BackButtonDisplayMode, 'default'>;
hideBackButton?: boolean;
backButtonInCustomView?: boolean;
// TODO: implement this props on iOS
Expand Down
9 changes: 9 additions & 0 deletions src/native-stack/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,15 @@ export type NativeStackNavigationOptions = {
* @platform ios
*/
disableBackButtonMenu?: boolean;
/**
* How the back button behaves by default (when not customized). Available on iOS>=14, and is used only when none of: `backTitleFontFamily`, `backTitleFontSize`, `disableBackButtonMenu` or `backTitle` is set.
* The following values are currently supported (they correspond to https://developer.apple.com/documentation/uikit/uinavigationitembackbuttondisplaymode?language=objc):
* - "default" – show given back button previous controller title, system generic or just icon based on available space
* - "generic" – show given system generic or just icon based on available space
* - "minimal" – show just an icon
* @platform ios
*/
backButtonDisplayMode?: ScreenStackHeaderConfigProps['backButtonDisplayMode'];
/**
* Whether inactive screens should be suspended from re-rendering. Defaults to `false`.
* Defaults to `true` when `enableFreeze()` is run at the top of the application.
Expand Down
2 changes: 2 additions & 0 deletions src/native-stack/views/HeaderConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default function HeaderConfig({
backButtonInCustomView,
direction,
disableBackButtonMenu,
backButtonDisplayMode = 'default',
headerBackTitle,
headerBackTitleStyle = {},
headerBackTitleVisible = true,
Expand Down Expand Up @@ -120,6 +121,7 @@ export default function HeaderConfig({
color={tintColor}
direction={direction}
disableBackButtonMenu={disableBackButtonMenu}
backButtonDisplayMode={backButtonDisplayMode}
hidden={headerShown === false}
hideBackButton={headerHideBackButton}
hideShadow={headerHideShadow}
Expand Down
10 changes: 10 additions & 0 deletions src/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type SearchBarCommands = {
cancelSearch: () => void;
};

export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal';
export type StackPresentationTypes =
| 'push'
| 'modal'
Expand Down Expand Up @@ -461,6 +462,15 @@ export interface ScreenStackHeaderConfigProps extends ViewProps {
* @platform ios
*/
disableBackButtonMenu?: boolean;
/**
* How the back button behaves by default (when not customized). Available on iOS>=14, and is used only when none of: `backTitleFontFamily`, `backTitleFontSize`, `disableBackButtonMenu` or `backTitle` is set.
* The following values are currently supported (they correspond to https://developer.apple.com/documentation/uikit/uinavigationitembackbuttondisplaymode?language=objc):
* - "default" – show given back button previous controller title, system generic or just icon based on available space
* - "generic" – show given system generic or just icon based on available space
* - "minimal" – show just an icon
* @platform ios
*/
backButtonDisplayMode?: BackButtonDisplayMode;
/**
* When set to true the header will be hidden while the parent Screen is on the top of the stack. The default value is false.
*/
Expand Down

0 comments on commit b47c4ac

Please sign in to comment.