Back to Articles

Building an Interactive Resizable Split View in React Native

#reanimated#ui

In modern mobile UI, creating dynamic and responsive layouts is key to a great user experience. Static screens can feel rigid, but layouts that respond to user input make an app feel alive and intuitive. Today, we'll dive deep into building a complex, interactive UI: a resizable split-screen view in React Native.

This component features a draggable divider that allows users to resize two content panes. As the panes resize, the content within them intelligently animates to fit the available space. We'll also add some delightful micro-interactions. We'll be using the powerhouse combination of React Native Reanimated for smooth, performant animations and React Native Gesture Handler for fluid gesture control.

Twitter Video Download.png

Why Micro-animations Matter

Before we jump into the code, let's briefly touch on why we're putting so much effort into these animations. Micro-animations are small, functional animations that serve a purpose. They:

  • Provide Feedback: Instantly confirm a user's action, like tapping a button or completing a task.
  • Guide the User: Direct attention and create a clear sense of spatial awareness within the app.
  • Add Polish: Elevate the user experience from merely functional to truly delightful, making the app feel more polished and professional.

In our component, the spring-based snapping of the divider, the fading of the cards BUT, there is always a but, these micro animations are great when you have a polished UI. Users can not feel or taste the micro animations if you do not have polished, consistent UI. Micro animations are great when you combine it with polished UI.

Section 1: Setting Up the Layout and State

First, let's lay the foundation. Our screen is divided into a top section and a bottom section. The height of the top section will be dynamic to update content based on dragging, controlled by a shared value from Reanimated.

A shared value is a special type of variable that can be modified from the JavaScript thread and read on the UI thread without causing performance-killing re-renders. This is the secret sauce for smooth animations in Reanimated. We define some constants for our layout heights and animation physics.

Dos and Don'ts:

  • Do: Use constants for layout values (HEADER_HEIGHT, MIN_SECTION_HEIGHT, etc.). This makes your code cleaner, easier to read, and much simpler to update later.
  • Don't: Hardcode magic numbers directly in your styles.
tsx
import Animated, { useSharedValue, useAnimatedStyle, withSpring, } from 'react-native-reanimated'; import { Dimensions, StyleSheet, View } from 'react-native'; // Screen and layout constants const { height: SCREEN_HEIGHT } = Dimensions.get('window'); const HEADER_HEIGHT = 60; const MIN_SECTION_HEIGHT = 100; const MAX_TOP_SECTION_HEIGHT = SCREEN_HEIGHT * 0.7; const DEFAULT_TOP_SECTION_HEIGHT = SCREEN_HEIGHT * 0.45; // Main Component Setup export default function ResizableSplitView() { // This shared value will drive our animations const topSectionHeight = useSharedValue(DEFAULT_TOP_SECTION_HEIGHT); // Animated styles for the top and bottom sections const topSectionAnimatedStyle = useAnimatedStyle(() => ({ height: topSectionHeight.value, })); const bottomSectionAnimatedStyle = useAnimatedStyle(() => ({ // The bottom section's height is derived from the top's height: SCREEN_HEIGHT - HEADER_HEIGHT - topSectionHeight.value - 100, })); return ( <View style={styles.mainContainer}> {/* ... Header ... */} <Animated.View style={[styles.topSection, topSectionAnimatedStyle]}> {/* Top content*/} </Animated.View> {/* ... Drag Handle ... */} <Animated.View style={[styles.bottomSection, bottomSectionAnimatedStyle]}> {/* Bottom content*/} </Animated.View> </View> ); }

image.png

Section 2: Making it Draggable with Gesture Handler

Now for the core interaction: dragging the divider to update section heights. We use Gesture.Pan() from React Native Gesture Handler to capture the user's finger movement. The logic is broken into three parts:

  1. onStart: When the user first touches the handle, we record the starting height of the top pane.
  2. onUpdate: As the user drags, we calculate the new height by adding the translation distance to the starting height. We also "clamp" this value to ensure the user can't drag it beyond our defined MIN_SECTION_HEIGHT and MAX_TOP_SECTION_HEIGHT.
  3. onEnd: This is where the magic happens. When the user lifts their finger, we want the pane to snap to one of three predefined positions (minimized, default, or maximized).
    • If the user "flicked" the handle (high velocity), we snap to the next logical position in the direction of the flick.
    • If it was a slow drag, we find the closest snap point and animate to it.

We use **withSpring()** to drive the final animation, giving it a natural, physical feel.

tsx
// imports and constants import { Gesture, GestureDetector } from 'react-native-gesture-handler'; // Animation configuration const ANIMATION_CONFIG = { damping: 25, stiffness: 300, mass: 0.8, overshootClamping: false, restDisplacementThreshold: 0.01, restSpeedThreshold: 0.01, }; // Gesture constants const VELOCITY_THRESHOLD = 800; const HEIGHT_THRESHOLD = 50; // ResizableSplitView component const startY = useSharedValue(0); const panGesture = Gesture.Pan() .onStart(() => { startY.value = topSectionHeight.value; }) .onUpdate((event) => { let newHeight = startY.value + event.translationY; // Clamp the height between min and max values topSectionHeight.value = Math.max( MIN_SECTION_HEIGHT, Math.min(newHeight, MAX_TOP_SECTION_HEIGHT) ); }) .onEnd((event) => { // Snap-to-point logic based on velocity and position const velocity = event.velocityY; let targetHeight; if (Math.abs(velocity) > VELOCITY_THRESHOLD) { // High velocity flick targetHeight = velocity > 0 ? DEFAULT_TOP_SECTION_HEIGHT : MAX_TOP_SECTION_HEIGHT; } else { // Low velocity drag, snap to nearest const distanceToMin = Math.abs(topSectionHeight.value - MIN_SECTION_HEIGHT); const distanceToDefault = Math.abs(topSectionHeight.value - DEFAULT_TOP_SECTION_HEIGHT); const distanceToMax = Math.abs(topSectionHeight.value - MAX_TOP_SECTION_HEIGHT); if (distanceToMin < distanceToDefault && distanceToMin < distanceToMax) { targetHeight = MIN_SECTION_HEIGHT; } else if (distanceToDefault < distanceToMax) { targetHeight = DEFAULT_TOP_SECTION_HEIGHT; } else { targetHeight = MAX_TOP_SECTION_HEIGHT; } } isDragging.value = false; // Animate to the target height with a spring effect topSectionHeight.value = withSpring(targetHeight, ANIMATION_CONFIG); }); return ( <View style={styles.mainContainer}> <Animated.View style={[styles.topSection, topSectionAnimatedStyle]}> {/* Top content */} </Animated.View> {/* ... Drag Handle ... */} <GestureDetector gesture={panGesture}> <Animated.View style={styles.dragHandleContainer}> <Animated.View style={[ styles.dragHandle, dragIndicatorAnimatedStyle ]}/> </GestureDetector> <Animated.View style={[styles.bottomSection, bottomSectionAnimatedStyle]}> {/* Bottom content */} </Animated.View> </View> );

splits.png

Let's add one more delightful detail. When you start to drag the drag handle, it smoothly scales up a little. This small animation acts as a visual confirmation, giving the interaction a tangible, real-world feel. It's a subtle cue that the app is responding to your touch with haptic.

To achieve this, we'll rely on useSharedValue and useAnimatedStyle. We create a shared value, isDragging, which we set to true inside the gesture's onStart event and back to false on onEnd. This boolean then drives the scale animation, making the handle grow and shrink at just the right moments.

tsx
// We need to keep the state of the user action const isDragging = useSharedValue(false); const dragIndicatorAnimatedStyle = useAnimatedStyle(() => ({ transform: [ { // if it is dragging, then make it scalled with withSpring. scale: withSpring(!isDragging.value ? 1 : 1.1, ANIMATION_CONFIG), }, ], }));

drag-handler.png

Section 3: Animating Content with interpolate

A resizable view isn't very useful if the content inside it doesn't adapt. We'll create two states for our top pane: a compact summary view when it's minimized, and an expanded grid of notes when it's larger. We achieve this by using the interpolate function from Reanimated. interpolate maps a shared value from one range of numbers to another. Here, we map the topSectionHeight to style properties like opacity, scale, and translateY.

  • Compact View: As the height approaches MIN_SECTION_HEIGHT, we fade in the compact card and fade out the expanded grid.
  • Expanded View: As the height moves away from MIN_SECTION_HEIGHT, we do the opposite.

Dos and Don'ts:

  • Do: Use interpolate to create complex, synchronized animations based on a single driver value (like a gesture or scroll position). It's incredibly powerful and performant.
  • Do: Use the 'clamp' option with interpolate to prevent the output values from exceeding the specified range, which avoids unexpected visual glitches.
tsx
const compactCardsAnimatedStyle = useAnimatedStyle(() => { const opacity = interpolate( topSectionHeight.value, [MIN_SECTION_HEIGHT, MIN_SECTION_HEIGHT + 100], [1, 0], // Fade out as the pane gets bigger 'clamp' ); // ... other properties like scale and translateY return { opacity: withSpring(opacity), /* ... */ }; }); // Animation for the expanded grid (visible when pane is large) const expandedCardsAnimatedStyle = useAnimatedStyle(() => { const opacity = interpolate( topSectionHeight.value, [MIN_SECTION_HEIGHT + 50, MIN_SECTION_HEIGHT + 150], [0, 1], // Fade in as the pane gets bigger 'clamp' ); // ... other properties like scale and translateY return { opacity: withSpring(opacity), /* ... */ }; }); // ... in the top section ... <Animated.View style={[styles.topSection, topSectionAnimatedStyle]}> <Animated.View style={[compactCardsAnimatedStyle, styles.compactCardsContainer]}> <Text style={styles.compactCardsText}> Hello , you have 10 tasks to do today. </Text> </Animated.View> <Animated.View style={[expandedCardsAnimatedStyle, styles.expandedCardsContainer]}> {/* ScrollView with NoteCard grid */} </Animated.View> </Animated.View>

top-section-wrapper.png

Conclusion

We've successfully built a complex, interactive, and beautiful component from the ground up. Keep things consistent, clean and reusable. Always pay attention design before micro animations. If you do not have polished UI, micro animations does not cover it. Great micro animations come with polished UI. Don't forget the details: Small, polished micro-animations can elevate your app from good to great. Here is the full code,