From e143bcd030863d4c221d50b30aae74c20f2c9beb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 19:18:44 +0000 Subject: [PATCH] Add iOS 26 fluid zoom transition for Menu sheets On iOS 26+, Menu bottom sheets now morph from their trigger button using UIViewController.preferredTransition = .zoom. This creates a fluid visual connection between the trigger and the presented sheet, complementing the Liquid Glass design language. The sourceViewTag prop is also available on the generic Dialog/BottomSheet layer so any dialog can opt into the zoom transition. https://claude.ai/code/session_011cNDhEb2cDgg5QbVuzEyH1 --- modules/bottom-sheet/ios/BottomSheetModule.swift | 4 ++++ modules/bottom-sheet/ios/SheetView.swift | 10 ++++++++++ modules/bottom-sheet/src/BottomSheet.types.ts | 5 +++-- src/components/Menu/index.tsx | 10 +++++++--- src/components/Menu/index.web.tsx | 4 +++- src/components/Menu/types.ts | 4 +++- 6 files changed, 30 insertions(+), 7 deletions(-) diff --git a/modules/bottom-sheet/ios/BottomSheetModule.swift b/modules/bottom-sheet/ios/BottomSheetModule.swift index 579608e75b..2269fbd910 100644 --- a/modules/bottom-sheet/ios/BottomSheetModule.swift +++ b/modules/bottom-sheet/ios/BottomSheetModule.swift @@ -42,6 +42,10 @@ public class BottomSheetModule: Module { Prop("preventExpansion") { (view: SheetView, prop: Bool) in view.preventExpansion = prop } + + Prop("sourceViewTag") { (view: SheetView, prop: Int?) in + view.sourceViewTag = prop + } } } } diff --git a/modules/bottom-sheet/ios/SheetView.swift b/modules/bottom-sheet/ios/SheetView.swift index 68cf38f631..360c7a9101 100644 --- a/modules/bottom-sheet/ios/SheetView.swift +++ b/modules/bottom-sheet/ios/SheetView.swift @@ -26,6 +26,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate { var preventDismiss = false var preventExpansion = false var cornerRadius: CGFloat? + var sourceViewTag: Int? var minHeight = 0.0 var maxHeight: CGFloat! { didSet { @@ -135,6 +136,15 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate { } sheetVc.view.addSubview(innerView) + if #available(iOS 26.0, *), + let tag = self.sourceViewTag, + let bridge = self.appContext?.reactBridge, + let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: tag)) { + sheetVc.preferredTransition = .zoom { _ in + return sourceView + } + } + self.sheetVc = sheetVc self.isOpening = true diff --git a/modules/bottom-sheet/src/BottomSheet.types.ts b/modules/bottom-sheet/src/BottomSheet.types.ts index 150932d423..1e26a2af90 100644 --- a/modules/bottom-sheet/src/BottomSheet.types.ts +++ b/modules/bottom-sheet/src/BottomSheet.types.ts @@ -1,5 +1,5 @@ -import React from 'react' -import {ColorValue, NativeSyntheticEvent} from 'react-native' +import {type ColorValue, type NativeSyntheticEvent} from 'react-native' +import type React from 'react' export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening' @@ -25,6 +25,7 @@ export interface BottomSheetViewProps { backgroundColor?: ColorValue containerBackgroundColor?: ColorValue disableDrag?: boolean + sourceViewTag?: number minHeight?: number maxHeight?: number diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index 65d279e196..5c39fa24e4 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -1,5 +1,6 @@ -import {cloneElement, Fragment, isValidElement, useMemo} from 'react' +import {cloneElement, Fragment, isValidElement, useMemo, useRef} from 'react' import { + findNodeHandle, Pressable, type StyleProp, type TextStyle, @@ -46,9 +47,11 @@ export function Root({ control?: Dialog.DialogControlProps }>) { const defaultControl = Dialog.useDialogControl() + const triggerRef = useRef(null) const context = useMemo( () => ({ control: control || defaultControl, + triggerRef, }), [control, defaultControl], ) @@ -79,7 +82,7 @@ export function Trigger({ pressed, }, props: { - ref: null, + ref: context.triggerRef, onPress: context.control.open, onFocus, onBlur, @@ -101,11 +104,12 @@ export function Outer({ }>) { const context = useMenuContext() const {_} = useLingui() + const sourceViewTag = findNodeHandle(context.triggerRef.current) ?? undefined return ( + nativeOptions={{preventExpansion: true, sourceViewTag}}> {/* Re-wrap with context since Dialogs are portal-ed to root */} diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index ecb74a19e2..70fdcb5687 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -1,4 +1,4 @@ -import {forwardRef, useCallback, useId, useMemo, useState} from 'react' +import {forwardRef, useCallback, useId, useMemo, useRef, useState} from 'react' import { Pressable, type StyleProp, @@ -62,9 +62,11 @@ export function Root({ }>) { const {_} = useLingui() const defaultControl = useMenuControl() + const triggerRef = useRef(null) const context = useMemo( () => ({ control: control || defaultControl, + triggerRef, }), [control, defaultControl], ) diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts index a0f0a1ed60..ab436c4c58 100644 --- a/src/components/Menu/types.ts +++ b/src/components/Menu/types.ts @@ -3,6 +3,7 @@ import { type AccessibilityRole, type GestureResponderEvent, type PressableProps, + type View, } from 'react-native' import type React from 'react' @@ -12,6 +13,7 @@ import {type Props as SVGIconProps} from '#/components/icons/common' export type ContextType = { control: Dialog.DialogOuterProps['control'] + triggerRef: React.RefObject } export type ItemContextType = { @@ -61,7 +63,7 @@ export type TriggerChildProps = * object is empty. */ props: { - ref: null + ref: React.RefObject onPress: () => void onFocus: () => void onBlur: () => void