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
This commit is contained in:
Claude
2026-02-28 19:18:44 +00:00
committed by Samuel Newman
parent c42502fdcb
commit e143bcd030
6 changed files with 30 additions and 7 deletions
@@ -42,6 +42,10 @@ public class BottomSheetModule: Module {
Prop("preventExpansion") { (view: SheetView, prop: Bool) in Prop("preventExpansion") { (view: SheetView, prop: Bool) in
view.preventExpansion = prop view.preventExpansion = prop
} }
Prop("sourceViewTag") { (view: SheetView, prop: Int?) in
view.sourceViewTag = prop
}
} }
} }
} }
+10
View File
@@ -26,6 +26,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
var preventDismiss = false var preventDismiss = false
var preventExpansion = false var preventExpansion = false
var cornerRadius: CGFloat? var cornerRadius: CGFloat?
var sourceViewTag: Int?
var minHeight = 0.0 var minHeight = 0.0
var maxHeight: CGFloat! { var maxHeight: CGFloat! {
didSet { didSet {
@@ -135,6 +136,15 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
} }
sheetVc.view.addSubview(innerView) 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.sheetVc = sheetVc
self.isOpening = true self.isOpening = true
@@ -1,5 +1,5 @@
import React from 'react' import {type ColorValue, type NativeSyntheticEvent} from 'react-native'
import {ColorValue, NativeSyntheticEvent} from 'react-native' import type React from 'react'
export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening' export type BottomSheetState = 'closed' | 'closing' | 'open' | 'opening'
@@ -25,6 +25,7 @@ export interface BottomSheetViewProps {
backgroundColor?: ColorValue backgroundColor?: ColorValue
containerBackgroundColor?: ColorValue containerBackgroundColor?: ColorValue
disableDrag?: boolean disableDrag?: boolean
sourceViewTag?: number
minHeight?: number minHeight?: number
maxHeight?: number maxHeight?: number
+7 -3
View File
@@ -1,5 +1,6 @@
import {cloneElement, Fragment, isValidElement, useMemo} from 'react' import {cloneElement, Fragment, isValidElement, useMemo, useRef} from 'react'
import { import {
findNodeHandle,
Pressable, Pressable,
type StyleProp, type StyleProp,
type TextStyle, type TextStyle,
@@ -46,9 +47,11 @@ export function Root({
control?: Dialog.DialogControlProps control?: Dialog.DialogControlProps
}>) { }>) {
const defaultControl = Dialog.useDialogControl() const defaultControl = Dialog.useDialogControl()
const triggerRef = useRef<View>(null)
const context = useMemo<ContextType>( const context = useMemo<ContextType>(
() => ({ () => ({
control: control || defaultControl, control: control || defaultControl,
triggerRef,
}), }),
[control, defaultControl], [control, defaultControl],
) )
@@ -79,7 +82,7 @@ export function Trigger({
pressed, pressed,
}, },
props: { props: {
ref: null, ref: context.triggerRef,
onPress: context.control.open, onPress: context.control.open,
onFocus, onFocus,
onBlur, onBlur,
@@ -101,11 +104,12 @@ export function Outer({
}>) { }>) {
const context = useMenuContext() const context = useMenuContext()
const {_} = useLingui() const {_} = useLingui()
const sourceViewTag = findNodeHandle(context.triggerRef.current) ?? undefined
return ( return (
<Dialog.Outer <Dialog.Outer
control={context.control} control={context.control}
nativeOptions={{preventExpansion: true}}> nativeOptions={{preventExpansion: true, sourceViewTag}}>
<Dialog.Handle /> <Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */} {/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}> <Context.Provider value={context}>
+3 -1
View File
@@ -1,4 +1,4 @@
import {forwardRef, useCallback, useId, useMemo, useState} from 'react' import {forwardRef, useCallback, useId, useMemo, useRef, useState} from 'react'
import { import {
Pressable, Pressable,
type StyleProp, type StyleProp,
@@ -62,9 +62,11 @@ export function Root({
}>) { }>) {
const {_} = useLingui() const {_} = useLingui()
const defaultControl = useMenuControl() const defaultControl = useMenuControl()
const triggerRef = useRef<View>(null)
const context = useMemo<ContextType>( const context = useMemo<ContextType>(
() => ({ () => ({
control: control || defaultControl, control: control || defaultControl,
triggerRef,
}), }),
[control, defaultControl], [control, defaultControl],
) )
+3 -1
View File
@@ -3,6 +3,7 @@ import {
type AccessibilityRole, type AccessibilityRole,
type GestureResponderEvent, type GestureResponderEvent,
type PressableProps, type PressableProps,
type View,
} from 'react-native' } from 'react-native'
import type React from 'react' import type React from 'react'
@@ -12,6 +13,7 @@ import {type Props as SVGIconProps} from '#/components/icons/common'
export type ContextType = { export type ContextType = {
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
triggerRef: React.RefObject<View | null>
} }
export type ItemContextType = { export type ItemContextType = {
@@ -61,7 +63,7 @@ export type TriggerChildProps =
* object is empty. * object is empty.
*/ */
props: { props: {
ref: null ref: React.RefObject<View | null>
onPress: () => void onPress: () => void
onFocus: () => void onFocus: () => void
onBlur: () => void onBlur: () => void