Split View

A resizable split layout with two stacked sections

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated react-native-gesture-handler react-native-safe-area-context

Copy 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