Split View
A resizable split layout with two stacked sections
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated react-native-gesture-handler react-native-safe-area-contextCopy and paste the following code into your project.
component/molecules/split-view
import React, { memo } from "react";import { FlatList, StyleSheet, Text, View, Dimensions } from "react-native";import { Gesture, GestureDetector, GestureHandlerRootView,} from "react-native-gesture-handler";import Animated, { Extrapolation, interpolate, useAnimatedStyle, useSharedValue, withSpring,} from "react-native-reanimated";import { SafeAreaView, useSafeAreaInsets,} from "react-native-safe-area-context";import { DRAG_HANDLE_HEIGHT } from "./conf";import type { SplitViewProps, SpringConfig } from "./types";const { height: SCREEN_HEIGHT } = Dimensions.get("window");const SplitViewInner = <TTop, TBottom>({ topSectionItems, bottomSectionItems, bottomSectionTitle, initialTopSectionHeight, minSectionHeight, maxTopSectionHeight, maxBottomSectionHeight, velocityThreshold, springConfig, containerBackgroundColor, sectionBackgroundColor, dividerBackgroundColor, dragHandleColor, renderTopItem, renderBottomItem, renderHeader, topKeyExtractor, bottomKeyExtractor, showHeader, topListContentContainerStyle, bottomListContentContainerStyle, topListStyle, bottomListStyle, sectionTitleStyle, sectionTitleTextColor,}: SplitViewProps<TTop, TBottom>): | (React.ReactNode & JSX.Element & React.ReactElement) | null => { const topSectionHeight = useSharedValue<number>(initialTopSectionHeight); const startY = useSharedValue<number>(0); const isDragging = useSharedValue<boolean>(false); const insets = useSafeAreaInsets(); const minTopHeight = maxBottomSectionHeight ? SCREEN_HEIGHT - maxBottomSectionHeight - 60 : minSectionHeight; const middleHeight = (minTopHeight + maxTopSectionHeight) / 2; const panGesture = Gesture.Pan() .onStart(() => { startY.value = topSectionHeight.value; isDragging.value = true; }) .onUpdate((event) => { const nextHeight = startY.value + event.translationY; topSectionHeight.value = Math.max( minTopHeight, Math.min(nextHeight, maxTopSectionHeight), ); }) .onEnd((event) => { "worklet"; isDragging.value = false; let targetHeight: number; const clampedVelocity = Math.max(-4000, Math.min(4000, event.velocityY)); const snapPoints = [minTopHeight, middleHeight, maxTopSectionHeight]; if (Math.abs(clampedVelocity) > velocityThreshold) { const currentHeight = topSectionHeight.value; if (clampedVelocity > 0) { const nextSnapIndex = snapPoints.findIndex( (point) => point > currentHeight + 20, ); targetHeight = nextSnapIndex !== -1 ? snapPoints[nextSnapIndex] : maxTopSectionHeight; } else { const reversedPoints = [...snapPoints].reverse(); const prevSnapIndex = reversedPoints.findIndex( (point) => point < currentHeight - 20, ); targetHeight = prevSnapIndex !== -1 ? reversedPoints[prevSnapIndex] : minTopHeight; } } else { let closestPoint = snapPoints[0]; let minDistance = Math.abs(snapPoints[0] - topSectionHeight.value); for (let i = 1; i < snapPoints.length; i++) { const distance = Math.abs(snapPoints[i] - topSectionHeight.value); if (distance < minDistance) { minDistance = distance; closestPoint = snapPoints[i]; } } targetHeight = closestPoint; } topSectionHeight.value = withSpring(targetHeight, { ...springConfig, overshootClamping: true, }); }); const topSectionAnimatedStyle = useAnimatedStyle(() => ({ height: topSectionHeight.value, opacity: interpolate( topSectionHeight.value, [minTopHeight, minTopHeight + 50], [0.3, 1], Extrapolation.CLAMP, ), })); const bottomSectionAnimatedStyle = useAnimatedStyle(() => { const calculatedHeight = SCREEN_HEIGHT - topSectionHeight.value - 60 - insets.bottom - insets.top; const finalHeight = maxBottomSectionHeight ? Math.min(calculatedHeight, maxBottomSectionHeight) : calculatedHeight; return { height: finalHeight, opacity: interpolate( topSectionHeight.value, [maxTopSectionHeight - 50, maxTopSectionHeight], [1, 0.3], Extrapolation.CLAMP, ), }; }); const dragHandleAnimatedStyle = useAnimatedStyle(() => ({ transform: [ { scale: withSpring(isDragging.value ? 1.2 : 1, springConfig) }, ], })); const dragHandleContainerAnimatedStyle = useAnimatedStyle(() => ({ top: topSectionHeight.value - DRAG_HANDLE_HEIGHT / 2, })); return ( <GestureHandlerRootView style={styles.flex}> <SafeAreaView style={[ styles.container, { backgroundColor: containerBackgroundColor }, ]} > {showHeader && renderHeader?.()} <View style={[ styles.mainContainer, { backgroundColor: dividerBackgroundColor }, ]} > <Animated.View style={[ styles.topSection, { backgroundColor: sectionBackgroundColor }, topSectionAnimatedStyle, ]} > <FlatList data={topSectionItems} renderItem={renderTopItem} keyExtractor={topKeyExtractor} style={[styles.list, topListStyle]} contentContainerStyle={[ styles.listContent, topListContentContainerStyle, ]} showsVerticalScrollIndicator={false} /> </Animated.View> <Animated.View style={[ styles.bottomSection, { backgroundColor: sectionBackgroundColor }, bottomSectionAnimatedStyle, ]} > <View style={[styles.sectionHeader, sectionTitleStyle]}> <Text style={[styles.sectionTitle, { color: sectionTitleTextColor }]} > {bottomSectionTitle} </Text> </View> <View style={styles.bottomListContainer}> <FlatList data={bottomSectionItems} renderItem={renderBottomItem} keyExtractor={bottomKeyExtractor} style={[styles.list, bottomListStyle]} contentContainerStyle={[ styles.listContent, bottomListContentContainerStyle, ]} showsVerticalScrollIndicator={false} nestedScrollEnabled={true} /> </View> </Animated.View> <GestureDetector gesture={panGesture}> <Animated.View style={[ styles.dragHandleContainer, { backgroundColor: dividerBackgroundColor }, dragHandleContainerAnimatedStyle, ]} > <Animated.View style={[ styles.dragHandle, { backgroundColor: dragHandleColor }, dragHandleAnimatedStyle, ]} /> </Animated.View> </GestureDetector> </View> </SafeAreaView> </GestureHandlerRootView> );};export const SplitView = memo(SplitViewInner) as <TTop, TBottom>( props: SplitViewProps<TTop, TBottom>,) => React.ReactNode & JSX.Element;const styles = StyleSheet.create({ flex: { flex: 1 }, container: { flex: 1 }, mainContainer: { flex: 1 }, topSection: { overflow: "hidden", borderBottomLeftRadius: 20, borderBottomRightRadius: 20, marginBottom: 30, }, bottomSection: { overflow: "hidden", borderTopLeftRadius: 20, borderTopRightRadius: 20, }, bottomListContainer: { flex: 1, }, list: { flex: 1 }, listContent: { padding: 16, paddingBottom: 16, }, sectionHeader: { paddingHorizontal: 16, paddingTop: 10, alignSelf: "center", }, sectionTitle: { fontSize: 18, fontWeight: "600", }, dragHandleContainer: { position: "absolute", left: 0, right: 0, alignItems: "center", justifyContent: "center", zIndex: 1000, marginTop: 15, paddingVertical: 0, }, dragHandle: { width: 40, height: 4, borderRadius: 2, alignSelf: "center", marginVertical: 8, },});export type { SplitViewProps, SpringConfig };Usage
import { View, Text, StyleSheet, Dimensions } from "react-native";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";import { SplitView } from "@/components/molecules/split-view";const { height: SCREEN_HEIGHT } = Dimensions.get("window");interface Note { id: string; content: string;}interface Task { id: string; label: string; time: string;}export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), Coolvetica: require("@/assets/fonts/Coolvetica-Rg.otf"), }); const notes: Note[] = [ { id: "1", content: "Design system updates" }, { id: "2", content: "Review pull requests" }, { id: "3", content: "Team sync meeting" }, { id: "4", content: "Update documentation" }, { id: "5", content: "Fix navigation bug" }, ]; const tasks: Task[] = [ { id: "1", label: "Morning standup", time: "09:00" }, { id: "2", label: "Code review", time: "10:30" }, { id: "3", label: "Client call", time: "14:00" }, { id: "4", label: "Sprint planning", time: "16:00" }, { id: "5", label: "Team retrospective", time: "17:30" }, ]; return ( <> <StatusBar style="light" /> <SplitView<Note, Task> topSectionItems={notes} bottomSectionItems={tasks} bottomSectionTitle="Tasks" initialTopSectionHeight={SCREEN_HEIGHT * 0.5} minSectionHeight={10} maxTopSectionHeight={SCREEN_HEIGHT * 0.7} velocityThreshold={800} springConfig={{ damping: 20, stiffness: 150, mass: 0.5, }} containerBackgroundColor="#0a0a0a" sectionBackgroundColor="#141414" dividerBackgroundColor="#0a0a0a" dragHandleColor="#333" sectionTitleTextColor="#fff" showHeader={true} renderHeader={() => ( <View style={styles.header}> <Text style={[ styles.title, { fontFamily: fontLoaded ? "Coolvetica" : undefined }, ]} > Notes </Text> <View style={styles.iconButton}> <SymbolView name="plus" size={20} tintColor="#fff" /> </View> </View> )} renderTopItem={({ item }) => ( <View style={styles.noteCard}> <View style={styles.noteDot} /> <Text style={[ styles.noteText, { fontFamily: fontLoaded ? "SfProRounded" : undefined }, ]} > {item.content} </Text> </View> )} renderBottomItem={({ item }) => ( <View style={styles.taskRow}> <View style={styles.checkbox} /> <View style={styles.taskInfo}> <Text style={[ styles.taskLabel, { fontFamily: fontLoaded ? "SfProRounded" : undefined }, ]} > {item.label} </Text> </View> <Text style={[ styles.taskTime, { fontFamily: fontLoaded ? "SfProRounded" : undefined }, ]} > {item.time} </Text> </View> )} topKeyExtractor={(item) => item.id} bottomKeyExtractor={(item) => item.id} /> </> );}const styles = StyleSheet.create({ header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 24, paddingTop: 60, paddingBottom: 20, backgroundColor: "#0a0a0a", }, title: { fontSize: 32, fontWeight: "700", color: "#fff", }, iconButton: { width: 40, height: 40, borderRadius: 20, backgroundColor: "rgba(255,255,255,0.08)", justifyContent: "center", alignItems: "center", }, noteCard: { flexDirection: "row", alignItems: "center", backgroundColor: "#1a1a1a", borderRadius: 12, padding: 16, marginBottom: 8, gap: 12, }, noteDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: "#333", }, noteText: { flex: 1, fontSize: 15, color: "#e0e0e0", }, taskRow: { flexDirection: "row", alignItems: "center", paddingVertical: 16, paddingHorizontal: 4, borderBottomWidth: 1, borderBottomColor: "#1a1a1a", gap: 12, }, checkbox: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: "#333", }, taskInfo: { flex: 1, }, taskLabel: { fontSize: 15, color: "#e0e0e0", }, taskTime: { fontSize: 13, color: "#666", },});Props
SpringConfig
React Native Reanimated
React Native Gesture Handler
React Native Safe Area Context
