/** * Fab — the floating action button: one screen, one thing it is mostly for. * * ```tsx * } accessibilityLabel="New note" onPress={compose} /> * ``` * * ## It floats, which is the whole problem with it * * A button pinned over the content is a button covering some of it, and the * bottom-right corner of a scrolling list is exactly where the last row goes. So * a Fab is right when a screen has *one* action worth that trade and wrong when * it has three — a corner with three buttons in it is a toolbar that has been * put in the wrong place. Where a screen has several, the honest shapes are a * `ButtonGroup` in a bar or, if one of them really does lead, this with a * `Fab.Group` behind it. * * It positions itself absolutely against its nearest positioned ancestor, which * on a screen means the screen. Give the content below it enough bottom padding * to scroll clear — this cannot know how tall your list is, and a Fab sitting on * the last row forever is the failure people actually hit. * * ## Extended, and when to bother * * ```tsx * }>Write * ``` * * A lone glyph is a guess unless the glyph is a plus. `extended` spells the * action out, costs the width, and is worth it for anything a plus would not * have said. * * ## The speed dial * * ```tsx * } accessibilityLabel="Add"> * } label="Photo" onPress={addPhoto} /> * } label="File" onPress={addFile} /> * * ``` * * The actions come out of the button one after another rather than together — * a stagger of a few frames each, which is enough to read as a list unfolding * instead of a menu appearing. Every action carries its label beside it, because * a column of unlabelled circles is a quiz. * * ## The menu * * ```tsx * } accessibilityLabel="Add"> * } label="Photo" onPress={addPhoto} /> * } label="File" onPress={addFile} /> * * ``` * * `layout="menu"` unfolds one panel of rows out of the button instead of a * column of buttons — the shape the platform's own menus take. Each row is a * label with its glyph, on the side the appearance puts it or `iconPlacement` * says, and the panel springs out of the corner the * trigger sits in, so it reads as the button opening rather than a sheet * arriving. Drawn in glass, the panel is one piece of the material; the rows * are content on it. * * ## Glass * * ```tsx * } accessibilityLabel="New note" /> * ``` * * `glass` draws the button in the material iOS 26 uses for its own floating * controls, in place of the variant's fill. A control floating over content is * exactly what that material is for: it refracts what scrolls under it rather * than covering it, and lifts its own edge, so the shadow goes too. * * Every variant takes the plain material, because a tint is a dimmer: measured * over content, a colour laid on the glass turns it back into a fill and a * monochrome one only greys it. The glyph reads in the ordinary foreground * colour, and in red on `destructive` — the colour that carries meaning goes * on the glyph, where it stays legible. Pressed, it answers the way the platform's * own glass controls do: the material swells and brightens under the finger, * in place of this component's press scale. The material exists on iOS 26 and * above with the optional `expo-glass-effect` installed. Everywhere else — older iOS, * Android, web, Reduce Transparency on — the flag is inert and the button keeps * its ordinary fill, so nothing has to be written twice. * * Opening also drops a scrim over the screen. Not for looks: an open dial is * modal — the next tap either picks something or closes it — and a scrim is what * says so, as well as what catches the tap that closes it. * * ## Where a group has to be written * * A `Fab.Group` draws its scrim and its buttons as two absolutely positioned * siblings in whatever it is written inside, so put it in the screen's root * container. That is where its `offset` is measured from, and it is what the * scrim covers. * * It stays in the screen's own view tree rather than being lifted out of it, * which is the part that matters after navigation: pushing a screen over this * one hides the dial with everything else on it. A group lifted to the top of * the app would still be drawn over the screen that replaced it. */ import { Children, cloneElement, createContext, forwardRef, isValidElement, useCallback, useContext, useMemo, useState, type ReactElement, type ReactNode, } from 'react'; import { Pressable, StyleSheet, View, type GestureResponderEvent, type ViewProps, } from 'react-native'; import Animated, { Easing, Extrapolation, interpolate, runOnJS, useAnimatedStyle, useDerivedValue, withSpring, type SharedValue, } from 'react-native-reanimated'; import { tv, type VariantProps } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { IconColorProvider } from '../../icons'; import { useBackHandler } from '../../hooks/use-back-handler'; import { NativeHost, getComposeMenu, getSwiftUIMenu, getSwiftUIModifiers, } from '../../native'; import { AnimatedPressable, type AnimatedPressableProps, } from '../../primitives/animated-pressable'; import { Glass, GlassContainer, useGlassMaterial } from '../../primitives/glass'; import { Scrim } from '../../primitives/scrim'; import { Text } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { selectionTick } from '../../utils/haptics'; /** Which corner the button sits in. */ export type FabPlacement = 'bottom-right' | 'bottom-center' | 'bottom-left'; /** How far from the edges a floating button sits, in points. */ const DEFAULT_OFFSET = 16; /** * The spring the dial opens on. Underdamped enough to overshoot a little and * settle, which is what makes buttons read as arriving rather than being * placed; the stagger between actions comes from where each one reads the * spring, not from a delay. */ const OPEN_SPRING = { damping: 15, stiffness: 170, mass: 0.9 } as const; /** How far behind the action above starts, as a share of the dial's opening. */ const STAGGER = 0.08; /** How far an action starts below its resting place, in points. */ const ACTION_TRAVEL = 12; /** * How a glass dial's action starts: nothing, sitting on the trigger, and * merging with it while the two are within this distance. Scale, not * opacity — the material survives a zero scale and not a zero opacity. Shorter than the * dial's gaps, so pieces at rest stay separate and only overlapping ones * flow together. */ const RISE_FROM_SCALE = 0; const DIAL_BLEND = 6; /** How far into an action's arrival its label grows out of it, and from how far aside. */ const LABEL_AFTER = 0.45; const LABEL_TRAVEL = 16; /** A quarter turn on the trigger while the dial is open — a plus becomes a cross. */ const OPEN_ROTATION = 45; /** How the menu is drawn: the platform's own shape, or rows with a glyph well. */ export type FabMenuAppearance = 'platform' | 'wells'; /** Which side of a menu row the glyph sits on. */ export type FabMenuIconPlacement = 'leading' | 'trailing'; /** * The metrics of each appearance, in points. Fixed rather than measured so * the panel's height — and with it the point it grows from — is known on the * first frame. */ interface MenuMetrics { width: number; radius: number; row: number; hairline: number; padding: number; icon: FabMenuIconPlacement; } const MENU_METRICS: Record = { /* As wide and as round as the platform's own menus, with the glyph after the label and a hairline between rows, the way they draw it. */ platform: { width: 250, radius: 26, row: 44, hairline: 1, padding: 6, icon: 'trailing' }, /* Tighter, with the glyph leading in a tinted well and each row its own pill — the shape of a menu an app designed rather than the system. */ wells: { width: 224, radius: 22, row: 48, hairline: 0, padding: 6, icon: 'leading' }, }; /** * How small the menu is once it has left the trigger, and how far into the * dial's spring it gets there. * * A closed panel has to be *nothing*, not something small. Resting at a * fraction of its size leaves a box above the trigger for as long as the * spring takes to satisfy its completion threshold — which is most of a * second, and is not a frame anyone would call an animation. So zero is where * it starts and ends, and the pop to `MENU_FROM_SCALE` happens over the first * sliver of the spring: the panel still reads as coming out of the button at a * size rather than growing from a point. */ const MENU_FROM_SCALE = 0.3; const MENU_POP_AT = 0.06; /** The gap between the panel and the trigger — the group's `gap-3`. */ const GROUP_GAP = 12; /** * Where the menu grows from: the centre of the trigger, in the panel's own * coordinates. * * Not the panel's corner. The panel sits a gap above the trigger, and a panel * scaling from its own corner grows out of thin air just above the button. * Scaling about the trigger's centre — below the panel, and in from its * edge by half a button — is what makes it come out of the button. */ function menuOrigin( placement: FabPlacement, metrics: MenuMetrics, width: number, count: number, size: FabSize ): [number, number, number] { const height = metrics.padding * 2 + metrics.row * count + metrics.hairline * Math.max(0, count - 1); const half = SIZE_PX[size] / 2; const y = height + GROUP_GAP + half; if (placement === 'bottom-left') return [half, y, 0]; if (placement === 'bottom-center') return [width / 2, y, 0]; return [width - half, y, 0]; } /** How the group lines its parts up under each placement. */ const GROUP_ALIGN: Record = { 'bottom-right': 'items-end', 'bottom-center': 'items-center', 'bottom-left': 'items-start', }; /** The diameter of each size, in points — the radius the material rounds itself to. */ const SIZE_PX = { sm: 44, md: 56, lg: 64 } as const; const fabVariants = tv({ slots: { root: 'items-center justify-center rounded-full bg-primary shadow-lg', content: 'flex-row items-center justify-center gap-2', label: 'font-medium text-primary-foreground', }, variants: { size: { sm: { root: 'h-11 min-w-11', label: 'text-sm' }, md: { root: 'h-14 min-w-14', label: 'text-base' }, lg: { root: 'h-16 min-w-16', label: 'text-lg' }, }, /* An icon-only button is a circle, so its width is its height and it takes no padding. Extended, it is a stadium and the padding is what makes it one. */ extended: { true: {}, false: {}, }, variant: { primary: { root: 'bg-primary', label: 'text-primary-foreground' }, secondary: { root: 'bg-secondary', label: 'text-secondary-foreground' }, surface: { root: 'border border-border bg-popover', label: 'text-foreground' }, destructive: { root: 'bg-destructive', label: 'text-destructive-solid-foreground', }, }, disabled: { true: { root: 'opacity-[0.64] shadow-none' }, }, /* The material replaces the fill, the border and the shadow: it draws its own edge, and a shadow under a translucent surface is a smudge behind it. Only set when the material is really being drawn — see `useGlassMaterial`. */ glass: { true: { root: 'border-0 bg-transparent shadow-none' }, }, }, compoundVariants: [ /* A faded material stops being one, so a disabled glass button dims what sits on the material rather than the material itself. */ { glass: true, disabled: true, class: { root: 'opacity-100', content: 'opacity-[0.64]' } }, { extended: true, size: 'sm', class: { root: 'px-4' } }, { extended: true, size: 'md', class: { root: 'px-5' } }, { extended: true, size: 'lg', class: { root: 'px-6' } }, { extended: false, size: 'sm', class: { root: 'w-11' } }, { extended: false, size: 'md', class: { root: 'w-14' } }, { extended: false, size: 'lg', class: { root: 'w-16' } }, ], defaultVariants: { size: 'md', variant: 'primary', extended: false, glass: false, }, }); type FabVariantProps = VariantProps; /** How big the button is. */ export type FabSize = NonNullable; /** What the button is drawn in. */ export type FabVariant = NonNullable; /** The theme token each variant's icon reads against. */ const CONTENT_COLOR_VAR: Record = { primary: '--color-primary-foreground', destructive: '--color-destructive-solid-foreground', secondary: '--color-secondary-foreground', surface: '--color-foreground', }; /** * The token each variant's glyph reads in over the glass. * * The material is never tinted: a tint is a dimmer, and at any strength it * turns the glass back into a fill or greys it. So the glass is neither light * nor dark and the glyph reads in the ordinary foreground — except on * `destructive`, where the colour that carries meaning goes on the glyph * instead, the way a menu's destructive row is red text on the same panel. */ const GLASS_CONTENT_COLOR_VAR: Record = { primary: '--color-foreground', destructive: '--color-destructive', secondary: '--color-foreground', surface: '--color-foreground', }; /** Where a floating button parks itself, given its offset. */ function anchor(placement: FabPlacement, offset: number) { const base = { position: 'absolute' as const, bottom: offset }; if (placement === 'bottom-left') return { ...base, left: offset }; if (placement === 'bottom-center') return { ...base, alignSelf: 'center' as const }; return { ...base, right: offset }; } /* -------------------------------------------------------------------------- * * Fab * -------------------------------------------------------------------------- */ export interface FabProps extends Omit, Omit { className?: string; /** The glyph. Sized by you — this is the one thing that should not guess. */ icon?: ReactNode; /** * The label, which turns the circle into a stadium. Needs `extended`; a * label with nowhere to go is a label that gets clipped by the circle. */ children?: ReactNode; /** Spell the action out beside the glyph. */ extended?: boolean; /** * Pin it over the content, in a corner. * * Left out, it is an ordinary button in the flow — which is what you want * inside a `Fab.Group`, or when the screen already has somewhere for it to * sit. */ placement?: FabPlacement; /** Distance from the edges when `placement` is set, in points. */ offset?: number; /** Placement-aware view style. Press-state styling belongs in `className`. */ style?: ViewProps['style']; disabled?: boolean; /** * A tick on press. Off by default — needs the optional `expo-haptics`, and * is silent without it. */ haptics?: boolean; /** * Draw it in Liquid Glass — the material iOS 26 uses for its own floating * controls — instead of the variant's fill. Every variant takes the plain * material, with its glyph in the foreground colour and in red on * `destructive`. Pressed, the material swells and brightens the way the * platform's own glass controls do. * * Needs iOS 26 and the optional `expo-glass-effect`. Below that, on Android, * on web, or with Reduce Transparency on, it does nothing and the button * keeps its ordinary fill. */ glass?: boolean; /** Required for an icon-only button. A lone glyph reads out as nothing. */ accessibilityLabel?: string; } const FabRoot = forwardRef( ( { className, icon, children, extended = false, placement, offset = DEFAULT_OFFSET, size, variant, disabled = false, haptics = false, glass = false, onPress, style, accessibilityState, ...props }, ref ) => { const isDisabled = Boolean(disabled); // Asked for *and* drawable. Where the material cannot be drawn the flag // changes nothing, so the fill, border and shadow all stay. const material = useGlassMaterial() && glass; const resolvedVariant = variant ?? 'primary'; const { root, content, label } = fabVariants({ size, variant, extended: extended && !!children, disabled: isDisabled, glass: material, }); const themed = useCSSVariable( (material ? GLASS_CONTENT_COLOR_VAR : CONTENT_COLOR_VAR)[resolvedVariant] ); const contentColor = typeof themed === 'string' ? themed : undefined; const handlePress = useCallback>( (event) => { if (haptics) selectionTick(); onPress?.(event); }, [haptics, onPress] ); const pressable = ( {icon} {extended && children ? ( {children} ) : null} ); /* * Drawn in glass, the material is the box and the pressable fills it. The * platform only tracks a touch that lands inside the glass view, and * tracking it is what makes the button swell and glow under the finger, * so the pressable has to be inside rather than under it. It rounds * itself to the button's own radius: the material clipped by a rounded * parent loses its lit edge. */ return ( {material ? ( {pressable} ) : ( pressable )} ); } ); FabRoot.displayName = 'Fab'; /* -------------------------------------------------------------------------- * * Group — the speed dial * -------------------------------------------------------------------------- */ /** Whatever was written as a dial child, and the one prop a slot reaches for. */ type PressableChild = ReactElement<{ onPress?: (event: GestureResponderEvent) => void }>; /** * What opens out of the trigger: a column of buttons, one panel of rows, or * the platform's own menu. */ export type FabGroupLayout = 'dial' | 'menu' | 'native'; /** * How big a hosted glyph is drawn in a platform menu row, in points. * * Stated rather than measured because it has to be: anything of ours inside * the native tree needs a definite size on both axes above it, and an icon * whose box the platform is left to work out is the crash this library has * already paid for twice. */ const NATIVE_ROW_ICON = 24; interface FabGroupContextValue { /** 0 closed, 1 open. Every action reads it and its own index off it. */ progress: SharedValue; count: number; size: FabSize; /** The trigger's material, which the actions unfold in too. */ glass: boolean; layout: FabGroupLayout; /** The menu's appearance and where its rows put the glyph. */ appearance: FabMenuAppearance; iconPlacement: FabMenuIconPlacement; rowClassName?: string; close: () => void; } const FabGroupContext = createContext(null); function useFabGroup(part: string): FabGroupContextValue { const ctx = useContext(FabGroupContext); if (!ctx) throw new Error(`${part} must be used inside .`); return ctx; } export interface FabGroupProps extends Omit { className?: string; /** The glyph on the trigger. */ icon?: ReactNode; /** The trigger's label, if it should be extended while closed. */ label?: string; /** `Fab.Action` children, in the order they should unfold. */ children?: ReactNode; /** Controlled open state. */ open?: boolean; onOpenChange?: (open: boolean) => void; /** Which corner of the *screen* the whole dial parks in. */ placement?: FabPlacement; /** Distance from the screen's edges, in points. Add your safe-area inset. */ offset?: number; /** * What opens out of the trigger. `dial`, the default, is a column of round * buttons with their labels beside them. `menu` is one panel of rows — a * label with its glyph, on the side the appearance puts it — that springs * out of the trigger's corner, the way the platform's own menus do. * `native` hands the menu to the platform: SwiftUI on iOS, Jetpack Compose * on Android. * * A native menu is drawn by the platform, so `className` and the theme * tokens do not reach it, and the rows take `label` and `systemImage` rather * than an `icon` element. `blur` still applies: the scrim behind the menu is * ours, so the page recedes the way it does behind the dial and the panel. * * On iOS the platform owns the menu's open state, so `open` and * `onOpenChange` do nothing there. Android's menu is controlled and honours * both. * * Where the platform menu cannot be drawn — on the web, or without * `@expo/ui` installed — this falls back to `menu`. */ layout?: FabGroupLayout; /** * How a menu is drawn. `platform`, the default, is the shape the platform's * own menus take: a hairline between rows and the glyph after the label. * `wells` is tighter, with the glyph leading in a tinted well and each row * its own pill — a menu the app designed rather than the system. * * Menu layout only. */ appearance?: FabMenuAppearance; /** Which side of a menu row the glyph sits on. Each appearance has its own default. */ iconPlacement?: FabMenuIconPlacement; /** The menu panel's width in points. Each appearance has its own default. */ menuWidth?: number; /** The menu panel's corner radius in points. Each appearance has its own default. */ menuRadius?: number; /** Extra classes for the menu panel. */ menuClassName?: string; /** Extra classes for every menu row. A row's own `className` comes after. */ rowClassName?: string; size?: FabSize; variant?: FabVariant; disabled?: boolean; haptics?: boolean; /** * Draw the trigger and its actions in Liquid Glass. The same flag as on * `Fab`, with the same floor: iOS 26 and `expo-glass-effect`, inert * elsewhere. */ glass?: boolean; /** Frost the screen behind the open dial instead of dimming it. */ blur?: boolean; /** Required — the trigger is a lone glyph until it is opened. */ accessibilityLabel?: string; /** * Turn the trigger's glyph a quarter circle while the dial is open. * * On by default, and it is doing real work when the glyph is a plus: the * same mark becomes a cross, which says "this closes now" without a second * icon that has to be swapped in. Turn it off for a glyph that means * something at one angle only. */ rotateOnOpen?: boolean; } /** * A trigger with actions behind it. * * The scrim and the dial are two absolutely positioned siblings written into * the group's own parent — the scrim first, so the dial is drawn over it. Both * are laid out against that parent, which is why a group belongs in the * screen's root container: it is what `offset` is measured from and what the * scrim covers. */ const FabGroup = forwardRef( ( { className, icon, label, children, open: openProp, onOpenChange, placement = 'bottom-right', offset = DEFAULT_OFFSET, layout = 'dial', appearance = 'platform', iconPlacement, menuWidth, menuRadius, menuClassName, rowClassName, size = 'md', variant, disabled = false, haptics = false, glass = false, blur = false, rotateOnOpen = true, accessibilityLabel, style, ...props }, ref ) => { const [internalOpen, setInternalOpen] = useState(false); const open = openProp ?? internalOpen; // Asked for *and* drawable, decided once here so the trigger, the actions // and their motion all agree; where the material cannot be drawn the flag // is inert everywhere rather than in the trigger alone. const material = useGlassMaterial() && glass; const setOpen = useCallback( (next: boolean) => { if (openProp === undefined) setInternalOpen(next); onOpenChange?.(next); }, [openProp, onOpenChange] ); const actions = Children.toArray(children).filter(isValidElement) as PressableChild[]; /* * The count, pulled out as a plain number before any worklet sees it. * * A worklet captures every variable it *references*, whole — so reading * `actions.length` inside one would drag the array of React elements onto * the UI thread with it, and an element holds a fibre, which cannot be * serialised. The error that produces names neither this file nor this * line, so: never reach through an object into a worklet. */ const actionCount = actions.length; /* * The platform's menu, when one was asked for and the toolkit is there. * * Resolved before anything below reads `layout`, because a native menu * that cannot be drawn is not an error — it falls back to the panel, and * every decision after this one has to be made against the layout that is * really being drawn rather than the one that was requested. */ const swiftMenu = layout === 'native' ? getSwiftUIMenu() : null; const composeMenu = layout === 'native' && !swiftMenu ? getComposeMenu() : null; const resolvedLayout: FabGroupLayout = layout === 'native' && !swiftMenu && !composeMenu ? 'menu' : layout; /* * One shared value for the whole dial rather than one per action. * * The stagger is a function of the action's index, applied on the UI * thread inside each action's own style — so opening is a single timing * driving every row, and there is no chain of JavaScript timeouts to get * out of step with itself when the dial is closed halfway through opening. */ /* * The actions stay mounted while the spring runs back down, so closing is * the opening in reverse rather than a cut. `closing` is set the moment * `open` drops and cleared by the spring when it comes to rest; the * mounted-only-while-open rule below still holds once it has. */ const [closing, setClosing] = useState(false); // Noted during render rather than in an effect: an effect lands a frame // after the render that dropped `open`, and that frame would have no // actions in it — a blink, and a remount of the material mid-flight. const [wasOpen, setWasOpen] = useState(open); if (wasOpen !== open) { setWasOpen(open); if (!open) setClosing(true); } const settle = useCallback(() => setClosing(false), []); const progress = useDerivedValue( () => withSpring(open ? 1 : 0, OPEN_SPRING, (finished) => { if (finished && !open) runOnJS(settle)(); }), [open, settle] ); const present = open || closing; const rotation = useAnimatedStyle(() => ({ transform: [ { rotate: `${interpolate(progress.value, [0, 1], [0, OPEN_ROTATION])}deg` }, ], })); const close = useCallback(() => setOpen(false), [setOpen]); const context = useMemo( () => ({ progress, count: actionCount, size, glass: material, layout: resolvedLayout, appearance, iconPlacement: iconPlacement ?? MENU_METRICS[appearance].icon, rowClassName, close, }), [progress, actionCount, size, material, resolvedLayout, appearance, iconPlacement, rowClassName, close] ); const toggle = useCallback(() => { if (haptics) selectionTick(); setOpen(!open); }, [haptics, open, setOpen]); // An open dial owns the back button: back should shut it, not leave it // standing over the screen underneath. useBackHandler(open, close); const themedDestructive = useCSSVariable('--color-destructive'); const Group = material && resolvedLayout === 'dial' ? GlassContainer : View; /* * The trigger as the platform hosts it. * * Sized here rather than left to the platform, on both axes where both are * known. An axis handed to `matchContents` is written back to for good, and * a hosted view with nothing definite above it is the crash this library * has already paid for twice — so a circle states its diameter, and only an * extended button's width, which is its label's, is the platform's to * measure. */ const triggerSize = SIZE_PX[size]; const triggerExtended = !!label; const triggerFrame = triggerExtended ? { height: triggerSize } : { width: triggerSize, height: triggerSize }; const nativeTrigger = ( {label} ); /* * The screen behind an open platform menu, frosted the way it is behind * the dial and the panel — the page is what recedes, and a native menu * over an untouched page reads as a control belonging to something else. * * It closes on a tap, which is the safety net rather than the mechanism. * The platform dismisses its own menu from its own window, so this never * sees that tap and never competes for it; what it catches is the next * one. Menu content is not promised an `onDisappear`, and a frosted page * with no way back would be worse than no scrim at all. */ const nativeScrim = open ? ( ) : null; /* * SwiftUI's menu. The trigger goes through the `label` slot, which takes a * view as readily as a string — so the button stays ours and only the menu * is the platform's. * * The open state is not passed and cannot be: SwiftUI owns it. `open` and * `onOpenChange` are inert on this path, which is stated on the prop. */ if (swiftMenu) { const { Host, RNHostView, Menu, Button } = swiftMenu; const swiftModifiers = getSwiftUIModifiers(); /* * What each row carries beyond its label. * * The first one also carries the menu's open state. SwiftUI's menu owns * that state and reports nothing about it, but it builds its content * only when it presents it — so the first row appearing is the menu * appearing, and that is what puts the screen behind it under the * scrim. */ const itemModifiers = (item: Partial, index: number) => { const list: unknown[] = []; if (item.disabled && swiftModifiers?.disabled) { list.push(swiftModifiers.disabled(true)); } if (index === 0 && swiftModifiers?.onAppear) { list.push(swiftModifiers.onAppear(() => setOpen(true))); } /* * Every row carries the teardown, not just the first. * * The one that reports the menu opening is enough to raise the scrim, * but taking it down cannot rest on a single view: whichever row * SwiftUI tears down first has to be able to say so, or a menu * dismissed from anywhere else leaves the page frosted behind it. */ if (swiftModifiers?.onDisappear) { list.push(swiftModifiers.onDisappear(() => setOpen(false))); } return list.length > 0 ? list : undefined; }; return ( <> {nativeScrim} {nativeTrigger}}> {actions.map((action, index) => { const item = action.props as Partial; return ( ); } /* * Compose's dropdown menu — the same instruction, a different control. * * This one takes the open state rather than owning it, so the group's own * state drives it and `open`/`onOpenChange` work here. Its rows take a text * slot rather than a label prop, and a glyph of ours goes in the leading * slot at a stated size. */ if (composeMenu) { const { Host, RNHostView, Text: ComposeText, DropdownMenu, DropdownMenuItem, } = composeMenu; const destructiveColor = typeof themedDestructive === 'string' ? themedDestructive : undefined; return ( <> {nativeScrim} {nativeTrigger} {actions.map((action, index) => { const item = action.props as Partial; return ( { close(); item.onPress?.(); }} > {item.icon ? ( {item.icon} ) : null} {item.label ?? ''} ); })} ); } /* * Two absolutely positioned siblings, scrim first, both in the group's own * parent — which is why a group belongs in the screen's root container. * * This used to go through a portal, and the portal was the bug. Portalled * content is mounted at the app root, above the router, and is removed only * when the component that declared it unmounts. A stack keeps the screen * you pushed from mounted, so a group declared on that screen carried on * drawing over every screen after it — open or closed, since the trigger * travelled through the portal too. * * Ordering is the reason the portal was reached for in the first place: a * scrim that covers the screen must not cover the dial it belongs to. As * siblings it comes out right for free — the scrim is written first and the * dial after it, and the later sibling draws on top. * * Nothing remounts when the dial opens. The trigger is in the same place in * the tree either way, so opening adds the scrim and the actions and leaves * the button alone. */ return ( <> {open ? ( // Catches the tap that closes the dial, and says it is modal. ) : null} {/* A glass dial's pieces merge while they overlap — the actions are one blob with the trigger until they rise clear of it. */} {/* Mounted only while open, and while closing: a column of actions kept alive behind the trigger would still be in the accessibility tree, and a screen reader would walk into four buttons nobody can see. */} {present && resolvedLayout === 'menu' ? ( {actions} ) : null} {present && resolvedLayout === 'dial' ? actions.map((action, index) => ( {action} )) : null} {icon} ) : ( icon ) } extended={!!label && !open} size={size} variant={variant} disabled={disabled} glass={glass} accessibilityLabel={accessibilityLabel} accessibilityState={{ disabled, expanded: open }} onPress={toggle} > {label} ); } ); FabGroup.displayName = 'Fab.Group'; /** * The menu panel: one surface, springing out of the trigger's corner. * * It scales up from that corner rather than fading in, and the two are not * interchangeable: the material cannot be faded — at zero it stops drawing * and does not come back — and a panel growing out of the button is what * says the button opened. The rows inside fade in on the dial's own stagger, * nearest the trigger first. */ function FabMenu({ progress, placement, size, glass, appearance, width: widthProp, radius: radiusProp, className, children, }: { /** The dial's own 0-to-1, so the panel and its unmount cannot disagree. */ progress: SharedValue; placement: FabPlacement; size: FabSize; glass: boolean; appearance: FabMenuAppearance; width?: number; radius?: number; className?: string; children: PressableChild[]; }) { const metrics = MENU_METRICS[appearance]; const width = widthProp ?? metrics.width; const radius = radiusProp ?? metrics.radius; /* * The panel rides the group's own spring rather than one of its own. * * It used to run a second, faster spring, and closing raced it against the * one that decides when the panel unmounts: the panel reached its collapsed * scale early and then sat there, a small box above the trigger, until the * slower spring came to rest and took it away. One value cannot race * itself, so the panel is gone exactly when the dial says it is. * * Scale and not opacity, for the same reason the dial's actions are: the * material survives a zero scale and does not come back from a zero * opacity. */ const style = useAnimatedStyle(() => ({ transform: [ { scale: interpolate( progress.value, [0, MENU_POP_AT, 1], [0, MENU_FROM_SCALE, 1], Extrapolation.CLAMP ), }, ], })); return ( {children.map((action, index) => ( 0 && index > 0}> {action} ))} ); } /** * How far along its own arrival an action is, from the dial's one progress. * * The stagger runs bottom-up: the action nearest the trigger arrives first, * which is the order a hand travelling away from the button meets them in. */ function slotProgress(progress: number, count: number, index: number): number { 'worklet'; // A short lag per action rather than a full turn each: the actions leave // the trigger nearly together and settle nearly together, so a later one // does not rise through a slot an earlier one is already sitting in. const from = (count - 1 - index) * STAGGER; const to = from + 1 - STAGGER * Math.max(0, count - 1); // The spring's overshoot past its target is passed on to every action, so // a button arrives with a little bounce rather than stopping dead. It is // added on top of the clamped window rather than read through it: a window // that ends before the dial's does would otherwise leave its action past // its slot for good. const within = interpolate(progress, [from, Math.min(1, to)], [0, 1], Extrapolation.CLAMP); return within + Math.max(0, progress - 1); } /** The same progress, decelerating into 1 and never past it — for size and opacity. */ function settled(t: number): number { 'worklet'; return Easing.out(Easing.cubic)(Math.min(1, Math.max(0, t))); } /** Which slot an action is in — what it needs to know to animate itself. */ const FabSlotContext = createContext(0); /** * One action's slot in the unfolding. * * The stagger runs bottom-up: the action nearest the trigger arrives first, * which is the order a hand travelling away from the button meets them in. */ function FabActionSlot({ index, separator = false, children, }: { index: number; /** A hairline above the slot — a menu row's, never a dial button's. */ separator?: boolean; children: PressableChild; }) { const { progress, count, glass, layout, close } = useFabGroup('Fab.Action'); // A menu's rows are content on one panel, not glass of their own, so they // may fade; a dial's actions are each their own material. const material = glass && layout === 'dial'; /* * Whatever is in the slot closes the dial when it is pressed. * * `Fab.Action` does this itself, but a plain `Fab` written as a child is a * perfectly reasonable thing to reach for and knows nothing about the dial it * is in. Left alone it runs its action — navigating, usually — with the dial * still open behind it. Wrapping the handler here covers both, and closing * twice is closing once. */ const { onPress } = children.props; const child = onPress ? cloneElement(children, { onPress: (event: GestureResponderEvent) => { close(); onPress(event); }, }) : children; /* * A glass action never fades. The material stops drawing under an ancestor * at zero opacity and does not come back when the opacity does, so a fade * from zero is a button that sometimes never appears — whichever ones got * their first frame at zero. A glass dial's action animates its own parts * instead, rising out of the trigger; see `Fab.Action`. The slot only * fades a plain action, where it reads better on a flat surface. */ const style = useAnimatedStyle(() => { if (material) return {}; const t = slotProgress(progress.value, count, index); return { opacity: settled(t), transform: [{ translateY: interpolate(t, [0, 1], [ACTION_TRAVEL, 0]) }], }; }); return ( {separator ? : null} {child} ); } /* -------------------------------------------------------------------------- * * Action * -------------------------------------------------------------------------- */ export interface FabActionProps extends Omit { className?: string; /** The glyph. */ icon?: ReactNode; /** * The glyph for a native menu row, as an SF Symbol name. * * `layout="native"` only, and iOS only — SwiftUI names its symbols rather * than taking a view for them, so `icon` cannot cross over. Ignored * everywhere else, so a group can carry both and be right on either path. */ systemImage?: string; /** What it does, beside the glyph. A column of unlabelled circles is a quiz. */ label?: string; onPress?: () => void; disabled?: boolean; /** Draws it in the destructive colour, for the one that removes something. */ destructive?: boolean; /** Extra classes for the label — the chip in a dial, the row's text in a menu. */ labelClassName?: string; } /** * One choice in an open dial: a small round button with its name beside it. * * The label is a chip of its own rather than text on the background, because * the background is whatever the screen behind the scrim happens to be and * plain text over it is legible on some screens and not others. * * Pressing one closes the dial before running the action. Leaving it open over * whatever the action just did is the wrong default: the dial is a menu, and a * menu that stays up after a choice reads as the choice not having registered. */ const FabAction = forwardRef( ( { className, icon, // Read off the element by a native group, never rendered here. systemImage: _systemImage, label, onPress, disabled = false, destructive = false, labelClassName, ...props }, ref ) => { const { progress, count, size, glass, layout, appearance, iconPlacement, rowClassName, close } = useFabGroup('Fab.Action'); const index = useContext(FabSlotContext); const [pressed, setPressed] = useState(false); /* * A glass dial's action rises out of the trigger. * * It starts small and sitting on the button, and springs up to its slot * on the dial's stagger; inside the group's glass container the two * materials are one blob until it pulls free. The distance is known from * the fixed sizes, so nothing is measured. The label follows once the * button is most of the way there, sliding in from the button's side — * it is glass too, so it moves rather than fades. */ const actionSize: FabSize = size === 'lg' ? 'md' : 'sm'; const rise = (count - 1 - index) * (SIZE_PX[actionSize] + GROUP_GAP) + GROUP_GAP + SIZE_PX[size] / 2 + SIZE_PX[actionSize] / 2; // Travel follows the spring, overshoot and all; size settles without it. const buttonStyle = useAnimatedStyle(() => { const t = slotProgress(progress.value, count, index); return { transform: [ { translateY: interpolate(t, [0, 1], [rise, 0]) }, { scale: interpolate(settled(t), [0, 1], [RISE_FROM_SCALE, 1]) }, ], }; }); const chipStyle = useAnimatedStyle(() => { const t = slotProgress(progress.value, count, index); // Rides up with its button, then grows out of it once the button is // most of the way to its slot. const late = settled(interpolate(t, [LABEL_AFTER, 1], [0, 1], 'clamp')); return { transform: [ { translateY: interpolate(t, [0, 1], [rise, 0]) }, { translateX: interpolate(late, [0, 1], [LABEL_TRAVEL, 0]) }, { scale: interpolate(late, [0, 1], [RISE_FROM_SCALE, 1]) }, ], }; }); const handlePress = useCallback(() => { close(); onPress?.(); }, [close, onPress]); const themedRow = useCSSVariable(destructive ? '--color-destructive' : '--color-foreground'); const rowColor = typeof themedRow === 'string' ? themedRow : undefined; /* * In a menu the action is a row on the panel. The press tints the row * rather than scaling it — a row that shrinks inside a panel that does * not looks detached from it — and the tint is React state rather than a * worklet because it is a colour toggle, not a curve. */ if (layout === 'menu') { const wells = appearance === 'wells'; const glyph = wells ? ( {icon} ) : ( icon ); return ( setPressed(true)} onPressOut={() => setPressed(false)} pressScale={1} className={cn( 'flex-row items-center gap-3', wells ? 'h-12 rounded-2xl px-2' : 'h-11 px-4', iconPlacement === 'trailing' ? 'justify-between' : null, pressed && 'bg-foreground/10', disabled && 'opacity-40', rowClassName, className )} {...props} > {iconPlacement === 'leading' ? glyph : null} {label} {iconPlacement === 'trailing' ? glyph : null} ); } return ( {label ? ( // The chip is in the same material as the button beside it, so a // glass dial is glass all the way across and not glass with paper // labels. Without the material it is the popover surface it was. glass ? ( {label} ) : ( {label} ) ) : null} ); } ); FabAction.displayName = 'Fab.Action'; export const Fab = Object.assign(FabRoot, { Group: FabGroup, Action: FabAction, });