Disclosure Group
Expandable disclosure group
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated expo-blurCopy and paste the following code into your project.
component/molecules/disclosure-group
import { BlurView, type BlurViewProps } from "expo-blur";import React, { createContext, useContext, useState, useCallback, useMemo, memo,} from "react";import { Pressable, ScrollView, StyleSheet, Text, useWindowDimensions, View, type LayoutChangeEvent, type ViewStyle,} from "react-native";import Animated, { Easing, Extrapolation, interpolate, useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming,} from "react-native-reanimated";import type { DisclosureGroupComposition, IDisclosureGroupContext, IDisclosureGroupItem, IDisclosureGroupItems, IDisclosureGroupTrigger, IDisclosureGroups,} from "./types";const AnimatedBlurView = Animated.createAnimatedComponent<BlurViewProps>(BlurView);const createDisclosureGroupContext = () => createContext<IDisclosureGroupContext | undefined>(undefined);const useDisclosureGroup = ( context: React.Context<IDisclosureGroupContext | undefined>,): IDisclosureGroupContext => { const value = useContext(context); if (!value) { throw new Error( "DisclosureGroup components must be used within DisclosureGroup", ); } return value;};const DisclosureGroupInstanceContext = createContext< React.Context<IDisclosureGroupContext | undefined> | undefined>(undefined);const DisclosureGroupBase: React.FC<IDisclosureGroups> = ({ children,}: IDisclosureGroups): React.ReactNode & React.JSX.Element => { const [isOpen, setIsOpen] = useState<boolean>(false); const [contentHeight, setContentHeight] = useState<number>(0); const DisclosureContext = useMemo(() => createDisclosureGroupContext(), []); const toggleDisclosure = useCallback((): void => { setIsOpen((prev) => !prev); }, []); const closeDisclosure = useCallback((): void => { setIsOpen(false); }, []); const contextValue = useMemo<IDisclosureGroupContext>( () => ({ isOpen, toggleDisclosure, closeDisclosure, contentHeight, setContentHeight, contextId: Math.random().toString(36), }), [isOpen, toggleDisclosure, closeDisclosure, contentHeight], ); return ( <DisclosureGroupInstanceContext.Provider value={DisclosureContext}> <DisclosureContext.Provider value={contextValue}> <View style={styles.container}>{children}</View> </DisclosureContext.Provider> </DisclosureGroupInstanceContext.Provider> );};const DisclosureGroupTriggerBase: React.FC<IDisclosureGroupTrigger> = ({ children, style, contentContainerStyle, showChevron = true, chevronColor = "#ffffff",}): React.ReactNode & React.JSX.Element => { const DisclosureContext = useContext(DisclosureGroupInstanceContext); if (!DisclosureContext) { throw new Error( "DisclosureGroup.Trigger must be used within DisclosureGroup", ); } const { isOpen, toggleDisclosure } = useDisclosureGroup(DisclosureContext); const rotation = useSharedValue<number>(0); const { width } = useWindowDimensions(); React.useEffect(() => { rotation.value = withTiming(isOpen ? 180 : 0, { duration: 300, easing: Easing.bezier(0.4, 0, 0.2, 1), }); }, [isOpen]); const animatedChevronStyle = useAnimatedStyle<ViewStyle>(() => ({ transform: [{ rotate: `${rotation.value}deg` }], })); return ( <Pressable onPress={toggleDisclosure} style={[styles.trigger, style]}> <View style={[styles.triggerContent, contentContainerStyle]}> {children} {showChevron && ( <Animated.View style={[styles.chevronContainer, animatedChevronStyle]} > <Text style={[styles.chevron, { color: chevronColor }]}>^</Text> </Animated.View> )} </View> </Pressable> );};const DisclosureGroupItemsBase: React.FC<IDisclosureGroupItems> = ({ children, maxHeight = 400, scrollable = true, blurTint = "dark", useBlur = false, style,}): React.ReactNode & React.JSX.Element => { const DisclosureContext = useContext(DisclosureGroupInstanceContext); if (!DisclosureContext) { throw new Error( "DisclosureGroup.Items must be used within DisclosureGroup", ); } const { isOpen, contentHeight, setContentHeight } = useDisclosureGroup(DisclosureContext); const animationProgress = useSharedValue<number>(0); const opacity = useSharedValue<number>(0); const blurIntensity = useSharedValue<number>(0); const [measured, setMeasured] = useState<boolean>(false); React.useEffect(() => { if (isOpen && measured) { animationProgress.value = withTiming(1, { duration: 350, easing: Easing.bezier(0.4, 0, 0.2, 1), }); blurIntensity.value = withTiming(0); opacity.value = withTiming(1, { duration: 300 }); } else { blurIntensity.value = withTiming(20); animationProgress.value = withTiming(0); opacity.value = withTiming(0); } }, [isOpen, measured]); const animatedContainerStyle = useAnimatedStyle<ViewStyle>(() => { const targetHeight = Math.min(contentHeight || 0, maxHeight); return { height: interpolate( animationProgress.value, [0, 1], [0, targetHeight], Extrapolation.CLAMP, ), opacity: opacity.value, }; }); const animatedBlurViewProps = useAnimatedProps(() => ({ intensity: blurIntensity.value, })); const handleLayout = useCallback( <T extends LayoutChangeEvent>(event: T): void => { const { height } = event.nativeEvent.layout; if (height > 0 && !measured) { setContentHeight(height); setMeasured(true); } }, [measured], ); if (!measured) { return ( <View style={styles.measurementContainer} onLayout={handleLayout}> <View style={styles.itemsContent}>{children}</View> </View> ); } const content = scrollable ? ( <ScrollView style={{ maxHeight }} showsVerticalScrollIndicator={false} bounces > <View style={styles.itemsContent}>{children}</View> <AnimatedBlurView tint={blurTint} animatedProps={animatedBlurViewProps} style={StyleSheet.absoluteFillObject} pointerEvents="none" /> </ScrollView> ) : ( <View style={styles.itemsContent}>{children}</View> ); if (useBlur) { return ( <Animated.View style={[styles.itemsContainer, animatedContainerStyle, style]} > {children} <AnimatedBlurView tint={blurTint} animatedProps={animatedBlurViewProps} style={StyleSheet.absoluteFillObject} pointerEvents="none" /> </Animated.View> ); } return ( <Animated.View style={[styles.itemsContainer, animatedContainerStyle, style]} > {content} </Animated.View> );};const DisclosureGroupItemBase: React.FC<IDisclosureGroupItem> = ({ children, onPress, style, disabled = false,}): React.ReactNode & React.JSX.Element => { const DisclosureContext = useContext(DisclosureGroupInstanceContext); if (!DisclosureContext) { throw new Error("DisclosureGroup.Item must be used within DisclosureGroup"); } const scale = useSharedValue<number>(1); const animatedStyle = useAnimatedStyle<ViewStyle>(() => ({ transform: [{ scale: scale.value }], })); const handlePress = useCallback( <T extends (() => void) | undefined>(cb: T): void => { if (!disabled) cb?.(); }, [disabled], ); return ( <Pressable disabled={disabled} onPress={() => handlePress(onPress)} onPressIn={() => (scale.value = 0.97)} onPressOut={() => (scale.value = 1)} > <Animated.View style={[styles.item, style, animatedStyle]}> {children} </Animated.View> </Pressable> );};const DisclosureGroupTrigger = memo<IDisclosureGroupTrigger>( DisclosureGroupTriggerBase,);const DisclosureGroupItems = memo<IDisclosureGroupItems>( DisclosureGroupItemsBase,);const DisclosureGroupItem = memo<IDisclosureGroupTrigger>( DisclosureGroupItemBase,);export const DisclosureGroup: React.NamedExoticComponent<IDisclosureGroups> & DisclosureGroupComposition = Object.assign(memo(DisclosureGroupBase), { Trigger: DisclosureGroupTrigger, Items: DisclosureGroupItems, Item: DisclosureGroupItem,});const styles = StyleSheet.create({ container: { width: "100%" }, trigger: {}, triggerContent: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", padding: 16, }, chevronContainer: { marginLeft: 12 }, chevron: { fontSize: 16, fontWeight: "600" }, measurementContainer: { position: "absolute", opacity: 0, pointerEvents: "none", }, itemsContainer: {}, itemsContent: { paddingHorizontal: 8, paddingTop: 10, paddingBottom: 4, }, item: { padding: 12, borderRadius: 12, marginBottom: 4, },});Usage
import { View, Text, StyleSheet } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";import { DisclosureGroup } from "@/components/molecules/disclosure-group";export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); const OPTIONS = [ { label: "Edit", icon: "pencil" }, { label: "Duplicate", icon: "doc.on.doc" }, { label: "Share", icon: "square.and.arrow.up" }, { label: "Delete", icon: "trash", destructive: true }, ]; return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <View style={styles.content}> <Text style={[ styles.title, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > Settings </Text> <Text style={[ styles.subtitle, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > Manage your preferences </Text> <View style={styles.card}> <DisclosureGroup> <DisclosureGroup.Trigger showChevron={false} contentContainerStyle={styles.triggerContent} > <View style={styles.triggerLeft}> <SymbolView name="ellipsis.circle.fill" size={20} tintColor="#fff" /> <Text style={[ styles.triggerText, fontLoaded && { fontFamily: "SfProRounded" }, ]} > More Options </Text> </View> <SymbolView name="chevron.down" size={14} tintColor="#555" /> </DisclosureGroup.Trigger> <DisclosureGroup.Items maxHeight={300} scrollable={false}> {OPTIONS.map((option, index) => ( <DisclosureGroup.Item key={index} onPress={() => console.log(option.label)} style={styles.item} > <SymbolView name={option.icon as any} size={18} tintColor={option.destructive ? "#ff453a" : "#fff"} /> <Text style={[ styles.itemText, option.destructive && styles.destructiveText, fontLoaded && { fontFamily: "SfProRounded" }, ]} > {option.label} </Text> </DisclosureGroup.Item> ))} </DisclosureGroup.Items> </DisclosureGroup> </View> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#0a0a0a", }, content: { paddingHorizontal: 20, paddingTop: 100, gap: 0, }, title: { fontSize: 28, fontWeight: "700", color: "#fff", }, subtitle: { fontSize: 15, color: "#555", }, card: { backgroundColor: "#141414", borderRadius: 16, overflow: "hidden", marginTop: 20, }, triggerContent: { padding: 16, }, triggerLeft: { flexDirection: "row", alignItems: "center", gap: 12, }, triggerText: { fontSize: 16, fontWeight: "500", color: "#fff", }, item: { flexDirection: "row", alignItems: "center", gap: 12, padding: 14, backgroundColor: "#1a1a1a", borderRadius: 12, marginBottom: 6, }, itemText: { fontSize: 15, color: "#fff", }, destructiveText: { color: "#ff453a", },});Props
IDisclosureGroupItem
React Native Reanimated
Expo Blur
