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:
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
Reference in New Issue
Block a user