software-mansion / software-mansion/react-native-screens

iOS 26 header button extends itself intermittently

Open
#3,834 9 comments 3 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

missing-repro platform:ios
Dominant language
TypeScript
Stars
3.7k
Forks
714
Avg merge
2d 23h
Merged PRs (30d)
71

Description

Description

Using Expo 54 and react-native-screens 4.16.0. On iOS 26, the custom header button on the right, extends itself intermittently. It's hard to reproduce consistently, but happens quite frequently.

Image

headerButton.tsx

import { tokens } from '@/design';
import { color } from '@/design/colors';
import React from 'react';
import { AccessibilityRole, Platform, StyleProp, View, ViewStyle } from 'react-native';
import { BorderlessButton, RectButton } from 'react-native-gesture-handler';
import { Pressable } from './Pressable';
import { isLiquidGlassAvailable } from 'expo-glass-effect';

interface HeaderButtonProps {
  onPress?: () => void;
  children: React.ReactNode;
  testID?: string;
  accessibilityRole?: AccessibilityRole;
  accessibilityLabel?: string;
  disabled?: boolean;
  rippleShape?: 'circle' | 'rect';
  style?: StyleProp<ViewStyle>;
}

const InnerContent = ({ children }: { children: React.ReactNode }) => (
  <View style={{ flexDirection: 'row', alignItems: 'center', gap: tokens.spacing.xs }}>
    {children}
  </View>
);

export function HeaderButton({
  onPress,
  children,
  testID,
  accessibilityRole = 'button',
  accessibilityLabel,
  disabled = false,
  rippleShape = 'circle',
  style,
}: HeaderButtonProps) {
  if (Platform.OS === 'android') {
    if (rippleShape === 'rect') {
      return (
        <RectButton
          onPress={onPress}
          testID={testID}
          accessibilityRole={accessibilityRole}
          accessibilityLabel={accessibilityLabel}
          enabled={!disabled}
          rippleColor={color.androidHighlight}
          style={[
            {
              paddingHorizontal: 6,
              opacity: disabled ? 0.3 : 1,
              minWidth: 48,
              minHeight: 48,
              borderRadius: tokens.radius.full,
              justifyContent: 'center',
              alignItems: 'center',
            },
            style,
          ]}
        >
          <InnerContent>{children}</InnerContent>
        </RectButton>
      );
    }
    return (
      <BorderlessButton
        onPress={onPress}
        testID={testID}
        accessibilityRole={accessibilityRole}
        accessibilityLabel={accessibilityLabel}
        enabled={!disabled}
        rippleColor={color.androidHighlight}
        hitSlop={16}
        rippleRadius={20}
        style={[
          {
            paddingHorizontal: 0,
            opacity: disabled ? 0.3 : 1,
            justifyContent: 'center',
            alignItems: 'center',
          },
          style,
        ]}
      >
        <InnerContent>{children}</InnerContent>
      </BorderlessButton>
    );
  }

  return (
    <Pressable
      onPress={onPress}
      testID={testID}
      accessibilityRole={accessibilityRole}
      accessibilityLabel={accessibilityLabel}
      disabled={disabled}
      haptic
      hitSlop={8}
      style={[
        {
          paddingHorizontal: isLiquidGlassAvailable() ? 6 : undefined,
          opacity: disabled ? 0.3 : 1,
          justifyContent: 'center',
          alignItems: 'center',
        },
        style,
      ]}
    >
      <InnerContent>{children}</InnerContent>
    </Pressable>
  );
}

HeaderMenuButton.tsx

import { MenuAction, MenuView, NativeActionEvent } from '@react-native-menu/menu';
import { isLiquidGlassAvailable } from 'expo-glass-effect';
import React from 'react';
import { type AccessibilityRole, Platform, StyleSheet, View } from 'react-native';
import { HeaderButton } from './HeaderButton';

// Liquid Glass headers render icon-only actions with a noticeably smaller circular visual than the
// pre-iOS-26 header buttons. Matching that 36pt visual keeps the menu trigger aligned with the
// existing checkmark/save buttons while still constraining the native MenuView anchor.
const LIQUID_GLASS_HEADER_MENU_BUTTON_SIZE = 36;

type AccessibleMenuViewProps = React.ComponentProps<typeof MenuView> & {
  accessibilityLabel?: string;
  accessibilityRole?: AccessibilityRole;
};

// TODO: Remove this once @react-native-menu/menu exposes standard accessibility props in its TS types.
const AccessibleMenuView = MenuView as React.ComponentType<AccessibleMenuViewProps>;

interface HeaderMenuButtonProps {
  actions: MenuAction[];
  onPressAction: ({ nativeEvent }: NativeActionEvent) => void;
  children: React.ReactNode;
  testID?: string;
  accessibilityLabel: string;
}

export function HeaderMenuButton({
  actions,
  onPressAction,
  children,
  testID,
  accessibilityLabel,
}: HeaderMenuButtonProps) {
  const shouldUseConstrainedAnchor = Platform.OS === 'ios' && isLiquidGlassAvailable();

  return (
    <AccessibleMenuView
      accessibilityLabel={accessibilityLabel}
      accessibilityRole="button"
      testID={testID}
      actions={actions}
      onPressAction={onPressAction}
      shouldOpenOnLongPress={false}
      style={shouldUseConstrainedAnchor ? styles.menuAnchor : undefined}
    >
      <View
        pointerEvents="none"
        accessible={false}
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        style={shouldUseConstrainedAnchor ? styles.content : undefined}
      >
        <HeaderButton
          accessibilityLabel={accessibilityLabel}
          style={shouldUseConstrainedAnchor ? styles.visualButton : undefined}
        >
          {children}
        </HeaderButton>
      </View>
    </AccessibleMenuView>
  );
}

const styles = StyleSheet.create({
  menuAnchor: {
    width: LIQUID_GLASS_HEADER_MENU_BUTTON_SIZE,
    height: LIQUID_GLASS_HEADER_MENU_BUTTON_SIZE,
    minWidth: LIQUID_GLASS_HEADER_MENU_BUTTON_SIZE,
    minHeight: LIQUID_GLASS_HEADER_MENU_BUTTON_SIZE,
    flexGrow: 0,
    flexShrink: 0,
    justifyContent: 'center',
    alignItems: 'center',
  },
  content: {
    width: '100%',
    height: '100%',
    justifyContent: 'center',
    alignItems: 'center',
  },
  visualButton: {
    width: '100%',
    height: '100%',
    paddingHorizontal: 0,
  },
});
Steps to reproduce

Keep viewing from one screen to another until I see the issue. It's really intermittent.

Snack or a link to a repository

N/A

Screens version

4.16.0

React Native version

0.81.5

Platforms

iOS

JavaScript runtime

None

Workflow

None

Architecture

None

Build type

Release mode

Device

Real device

Device model

No response

Acknowledgements

Yes

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the supplied HeaderButton.tsx and HeaderMenuButton.tsx examples, then reproduce repeated navigation on a real iOS 26 device using Expo 54, react-native-screens 4.16.0, and React Native 0.81.5 in release mode. Compare the header button layout across transitions and verify that the right-side custom button no longer expands intermittently.

Written by the indexing model from the issue text.

Assessment

Tech stack
react-native, typescript
Domain
mobile
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Needs clarification
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.