Skip to content

iOS 26 header button extends itself intermittently #3834

@vicovictor

Description

@vicovictor

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    missing-reproThis issue need minimum repro scenarioplatform:iosIssue related to iOS part of the library

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions