Last active
September 17, 2025 17:51
-
-
Save ferretwithaberet/5df16e9e99e98a165496a34c247c243a to your computer and use it in GitHub Desktop.
react-native-ui-lib FAB and SpeedDial
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { RenderFunctionOrNode, renderFunctionOrNode } from "@/utils/render"; | |
| import React from "react"; | |
| import { View } from "react-native"; | |
| import { Button, ButtonProps } from "react-native-ui-lib"; | |
| import tw, { Style } from "twrnc"; | |
| export type FABButtonProps = Omit<ButtonProps, "label" | "round"> & { | |
| ref?: React.Ref<View>; | |
| }; | |
| export const FABButton = (props: FABButtonProps) => { | |
| const { style, ...restProps } = props; | |
| return <Button {...restProps} style={[tw`w-12 h-12`, style]} round />; | |
| }; | |
| type Position = ["top" | "bottom", "left" | "right"]; | |
| type PositionKey = `${Position[0]}-${Position[1]}`; | |
| export const getPositionFromKey = (position: PositionKey) => | |
| position.split("-") as Position; | |
| const POSITION_STYLING: Record<PositionKey, Style> = { | |
| "top-left": tw`top-3 left-3`, | |
| "top-right": tw`top-3 right-3`, | |
| "bottom-left": tw`bottom-3 left-3 `, | |
| "bottom-right": tw`bottom-3 right-3`, | |
| }; | |
| export type FABProps = FABButtonProps & { | |
| position?: PositionKey; | |
| button?: RenderFunctionOrNode<[dom: React.ReactNode]>; | |
| }; | |
| export const FAB = (props: FABProps) => { | |
| const { position = "bottom-right", button, ...restProps } = props; | |
| const _button = <FABButton {...restProps} />; | |
| return ( | |
| <View style={[tw`absolute`, POSITION_STYLING[position]]}> | |
| {renderFunctionOrNode(button, _button) ?? _button} | |
| </View> | |
| ); | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { FAB, FABButton } from "./FAB"; | |
| import SpeedDial from "./SpeedDial"; | |
| export { FABButtonProps, FABProps } from "./FAB"; | |
| export { SpeedDialProps } from "./SpeedDial"; | |
| export default Object.assign(FAB, { | |
| Button: FABButton, | |
| SpeedDial, | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export type RenderFunction<TRenderArgs extends unknown[]> = ( | |
| ...args: TRenderArgs | |
| ) => React.ReactNode; | |
| export type RenderFunctionOrNode<TRenderArgs extends unknown[] = []> = | |
| | React.ReactNode | |
| | RenderFunction<TRenderArgs>; | |
| export const renderFunctionOrNode = <TRenderArgs extends unknown[] = []>( | |
| renderFunctionOrNode: RenderFunctionOrNode<TRenderArgs>, | |
| ...args: TRenderArgs | |
| ) => { | |
| if (typeof renderFunctionOrNode === "function") | |
| return renderFunctionOrNode(...args); | |
| return renderFunctionOrNode; | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // TODO: Fix iOS exiting animation not working at all | |
| import { getTextStyle } from "@/utils/text"; | |
| import React, { Children, useLayoutEffect, useRef, useState } from "react"; | |
| import { Platform, View as RNView, useWindowDimensions } from "react-native"; | |
| import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; | |
| import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| import { Colors, Modal, Text, View } from "react-native-ui-lib"; | |
| import tw from "twrnc"; | |
| import { | |
| FAB, | |
| FABButton, | |
| FABButtonProps, | |
| FABProps, | |
| getPositionFromKey, | |
| } from "./FAB"; | |
| type Layout = { | |
| x: number; | |
| y: number; | |
| width: number; | |
| height: number; | |
| }; | |
| export type SpeedDialItemProps = FABButtonProps & { | |
| label?: string; | |
| }; | |
| const SpeedDialItem = (props: SpeedDialItemProps) => { | |
| const { label, labelProps, ...restProps } = props; | |
| const { white = true, style, ...restLabelProps } = labelProps ?? {}; | |
| return ( | |
| <> | |
| <FABButton {...restProps} /> | |
| {label ? ( | |
| <Text | |
| {...restLabelProps} | |
| style={[getTextStyle("text70R"), style]} | |
| white={white} | |
| > | |
| {label} | |
| </Text> | |
| ) : null} | |
| </> | |
| ); | |
| }; | |
| export type SpeedDialProps = FABProps & { | |
| open?: boolean; | |
| initialOpen?: boolean; | |
| onOpenChange?: (open: boolean) => void; | |
| }; | |
| const SpeedDial = (props: SpeedDialProps) => { | |
| const { position = "bottom-right" } = props; | |
| const { | |
| open, | |
| initialOpen = false, | |
| onOpenChange, | |
| children, | |
| ...restProps | |
| } = props; | |
| const [innerOpen, setInnerOpen] = useState(initialOpen); | |
| const actualOpen = open !== undefined ? open : innerOpen; | |
| const dimensions = useWindowDimensions(); | |
| const safeAreaInsets = useSafeAreaInsets(); | |
| const [layout, setLayout] = useState<Layout | null>(null); | |
| const buttonRef = useRef<RNView>(null); | |
| useLayoutEffect(() => { | |
| measureLayout(); | |
| }, [position]); | |
| const measureLayout = () => { | |
| buttonRef.current?.measureInWindow((x, y, width, height) => | |
| setLayout({ x, y, width, height }) | |
| ); | |
| }; | |
| const setOpen = (open: boolean) => { | |
| setInnerOpen(open); | |
| onOpenChange?.(open); | |
| }; | |
| const [vertical, horizontal] = getPositionFromKey(position); | |
| const isTop = vertical === "top"; | |
| const isLeft = horizontal === "left"; | |
| const safeAreaVertical = Platform.select({ | |
| default: 0, | |
| android: safeAreaInsets.top + safeAreaInsets.bottom, | |
| }); | |
| const safeAreaHorizontal = Platform.select({ | |
| default: 0, | |
| android: safeAreaInsets.left + safeAreaInsets.right, | |
| }); | |
| return ( | |
| <> | |
| <FAB {...restProps} ref={buttonRef} onPress={() => setOpen(true)} /> | |
| <Modal | |
| transparent | |
| visible={actualOpen} | |
| onBackgroundPress={() => setOpen(false)} | |
| > | |
| <Animated.View | |
| style={[ | |
| tw`flex-1 pointer-events-box-none`, | |
| { backgroundColor: Colors.rgba(Colors.black, 0.8) }, | |
| ]} | |
| entering={FadeIn.duration(400)} | |
| exiting={FadeOut.duration(400)} | |
| > | |
| {layout ? ( | |
| <View | |
| style={[ | |
| tw.style("absolute gap-2 my-2", { "flex-col-reverse": !isTop }), | |
| isTop | |
| ? { | |
| top: layout.y + layout.height, | |
| } | |
| : { | |
| bottom: dimensions.height - layout.y - safeAreaVertical, | |
| }, | |
| isLeft | |
| ? { left: layout.x } | |
| : { | |
| right: | |
| dimensions.width - | |
| layout.x - | |
| layout.width - | |
| safeAreaHorizontal, | |
| }, | |
| ]} | |
| > | |
| {Children.toArray(children).map((child, index, arr) => { | |
| const element = child as React.ReactElement<SpeedDialItemProps>; | |
| const { onPress } = element.props; | |
| const fadeDurationMs = 200; | |
| const staggerMs = 50; | |
| return ( | |
| <Animated.View | |
| key={index} | |
| style={tw.style("flex-row items-center gap-2", { | |
| "flex-row-reverse": !isLeft, | |
| })} | |
| entering={FadeIn.duration(fadeDurationMs).delay( | |
| index * staggerMs | |
| )} | |
| exiting={FadeOut.duration(fadeDurationMs).delay( | |
| (arr.length - index - 1) * staggerMs | |
| )} | |
| > | |
| {React.cloneElement(element, { | |
| onPress: (e) => { | |
| setOpen(false); | |
| onPress?.(e); | |
| }, | |
| })} | |
| </Animated.View> | |
| ); | |
| })} | |
| </View> | |
| ) : null} | |
| </Animated.View> | |
| </Modal> | |
| </> | |
| ); | |
| }; | |
| export default Object.assign(SpeedDial, { Item: SpeedDialItem }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment