Build a Google Maps style panel overlay with scrollable inner content in React Native using rn-sliding-up-panel and react-native-maps

Based on StackOverflow questions like Is there a React Native utility for drag-to-expand panels? and Drag up a ScrollView then continue scroll in React Native, it seems like a common task for React Native developers is creating a draggable and expandable panel overlay for a map screen, like you see below.

There are a few features about this kind of panel that enhance the user experience but are tricky to implement:

  1. When the panel expands, the content in the panel acts like a typical Scrollview in React Native.
  2. When the panel is collapsed, the content in the panel is no longer scrollable
  3. Dragging one’s finger down the screen when the expanded panel is shown only collapses the panel when it seems appropraite, otherwise it scrolls down the content in the expanded panel.

With the help of an open-source package, namely rn-sliding-up-panel, you can implement this user interface quickly in React Native. Here’s how.

Sections

Setup

The panel itself only has one required dependency.

I’ll be using other packages to make things easier (such as expo and react-native-paper), but none of these are necessary.

I’ll initialize my project using expo-cli and adding typescript support. Feel free to use your favorite React Native command line init tool or template.

expo init -t expo-template-blank-typescript rn-google-maps-style-draggable-panel

Into this blank project, I will add a <Map> component and retrieve from it data for the currently selected marker on the map. The retrieved data for the marker will be used to fill in the <MapPanel> component that is both draggable and scrollable.

I will only go over how to implement the <MapPanel> component in this article. For right now, the <MapPanel> component will have a prop marker which contains all the data associated with the given marker on the map that the user has selected.

So to start off with, I’ll have the following:

App.tsx

import { StatusBar } from 'expo-status-bar';
import React, { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Map, { MarkerData } from './Map';
import MapPanel from './MapPanel';

export default function App() {
  const [selectedMarker, setSelectedMarker] = useState(null as MarkerData);

  function onMarkerPress(marker: MarkerData): void {
    setSelectedMarker(marker);
  }

  function onMapPress(): void {
    setSelectedMarker(null);
  }

  return (
    <View style={styles.container}>
      <StatusBar style="auto" />
      <Map onMarkerPress={onMarkerPress} onMapPress={onMapPress} />
      {selectedMarker !== null
        ?
          <MapPanel marker={selectedMarker} />
        :
          null
      }
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});

MapPanel.tsx

import React from 'react';
import { MarkerData } from './Map';

interface IMapPanelProps {
  marker: MarkerData
}

const MapPanel: React.FunctionComponent<IMapPanelProps> = ({ marker }) => {
  return null;
};

export default MapPanel;

Next I’ll add the draggable panel and show data in that panel for each marker on the map.

Installing and initializing rn-sliding-up-panel

Add the open-source package rn-sliding-up-panel to your project

yarn add rn-sliding-up-panel

Now I’ll include the <SlidingUpPanel> component into MapPanel.tsx and the new file will look like the following

MapPanel.tsx

import React, { useRef, useState } from 'react';
import { 
  Platform, StatusBar, Animated,
  ScrollView, StyleSheet, useWindowDimensions,
  View
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import SlidingUpPanel from 'rn-sliding-up-panel';
import { MarkerData } from './Map';
import MarkerDisplay from './MarkerDisplay';
import PanelHandle from './PanelHandle';

const ios = Platform.OS === 'ios';

interface IMapPanelProps {
  marker: MarkerData
}

const MapPanel: React.FunctionComponent<IMapPanelProps> = ({ marker }) => {
  const deviceHeight = useWindowDimensions().height;
  const insets = useSafeAreaInsets();
  const statusBarHeight: number = ios ? insets.bottom : StatusBar.currentHeight as number;
  const draggableRange = {
    top: deviceHeight - statusBarHeight,
    bottom: deviceHeight / 2.8
  };

  const snappingPoints = [
    draggableRange.top,
    draggableRange.bottom
  ];

  const panelRef = useRef<SlidingUpPanel | null>(null);
  const [panelPositionVal] = useState(new Animated.Value(draggableRange.bottom));

  return (
    <SlidingUpPanel
      ref={panelRef}
      animatedValue={panelPositionVal}
      draggableRange={draggableRange}
      snappingPoints={snappingPoints}
      backdropOpacity={0}
      showBackdrop={false}
      height={deviceHeight}
    >
      <View style={styles.panelContent}>
        <PanelHandle />
        <ScrollView scrollEnabled={false}>
          <MarkerDisplay marker={marker} />
        </ScrollView>
      </View>
    </SlidingUpPanel>
  );
};

const styles = StyleSheet.create({
  panelContent: {
    flex: 1,
    width: '100%',
    height: '100%',
    backgroundColor: 'white'
  }
});

export default MapPanel;
Don’t worry about <PanelHandle /> and <MarkerDisplay />. Those are just for show and don’t affect the panel functionality. If you’re really interested, all the code for this project is here on GitHub.

Now I have a draggable panel that works as follows (note that I cannot scroll the content inside the panel yet, I’ll get to that later).

A few things to highlight about the use of <SlidingUpPanel>:

  1. Because of TypeScript, our use of useRef to hold a reference to a React node looks like useRef<SlidingUpPanel | null>(null)
  2. The top of draggableRange needs to exclude the height of the status bar / notch for the layout to look correct.

At this point, the panel drags just like I want it to. Next, I’ll add scrollability to it.

Adding scrollability to a draggable panel

Adding a <ScrollView> as the child of <SlidingUpPanel> doesn’t just workβ„’ unfortunately. This is because the finger gesture to scroll the content inside the panel is also the same finger gesture to drag the panel up and down. In essence, I have to figure out whether a finger swipe down means the panel should slide down or whether it means the content inside the panel should scroll down.

A confusing number of events are triggered when a finger swipes down over the panel. And these events have confusingly similar-sounding names like onMomentumScrollBegin, onMomentumDragEnd, and onScrollBeginDrag. But the correct handlers (and state for those handlers) to implement are as follows:

const [scrollEnabled, setScrollEnabled] = useState(false);
const [allowDragging, setAllowDragging] = useState(true);
const [atTop, setAtTop] = useState(true);

// fired when panel is finished being dragged up or down
// if panel is dragged to 'top' position, then we switch to scrollmode
const onMomentumDragEnd = useCallback((value: number) => {
  if (value === draggableRange.top && !scrollEnabled) {
    setScrollEnabled(true);
    setAtTop(true);
  }
}, [draggableRange, scrollEnabled]);

// fired when scroll is finished inside the panel,
// if the content in the panel has scrolled to the very top,
// then we allow the panel to be dragged down
// (only if the next gesture is down, not up)
const onMomentumScrollEnd = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
  const { nativeEvent } = event;
  if (nativeEvent.contentOffset.y === 0) {
    setAtTop(true);
    if (ios) {
      setAllowDragging(true);
    }
  }
}, []);

const PANEL_VELOCITY = ios ? 1 : 2.3;
const hideFullScreenPanelOptions: SlidingUpPanelAnimationConfig = {
  velocity: PANEL_VELOCITY,
  toValue: draggableRange.bottom
};

// if panel is at the top and scrolling is allowed
// check the velocity of the drag,
// if the velocity is downward, then we animate the panel to its bottom state
// if the velocity is upward, we treat the drag like a scroll instead
const onDragStart = useCallback((_: number, gestureState: PanResponderGestureState) => {
  if (atTop && scrollEnabled) {
    if (gestureState.vy > 0) {
      setScrollEnabled(false);
      if (ios) {
        setAllowDragging(true);
      }
      if (panelRef && panelRef.current) {
        panelRef.current.show(hideFullScreenPanelOptions);
      }
    } else {
      setAtTop(false);
      if (ios) {
        setAllowDragging(false);
      }
    }
  }
}, [atTop, scrollEnabled, panelRef]);

And the JSX below these changes to

return (
  <SlidingUpPanel
    ref={panelRef}
    animatedValue={panelPositionVal}
    draggableRange={draggableRange}
    snappingPoints={snappingPoints}
    backdropOpacity={0}
    showBackdrop={false}
    height={deviceHeight}
    allowDragging={allowDragging}
    onMomentumDragEnd={onMomentumDragEnd}
    onDragStart={onDragStart}
  >
    <View style={styles.panelContent}>
      <PanelHandle />
      <ScrollView
        scrollEnabled={scrollEnabled}
        showsVerticalScrollIndicator={false}
        bounces={false}
        onMomentumScrollEnd={onMomentumScrollEnd}
      >
        <MarkerDisplay marker={marker} />
      </ScrollView>
    </View>
  </SlidingUpPanel>
);

The callbacks are essentially telling the panel the following:

  1. When the panel is at the top and the panel’s content isn’t overflowing over the top, check the direction of the next finger gesture. If the next finger gesture is downwards, drag the panel to the bottom, else scroll the panel content up.
  2. If the panel content is overflowing over the top, then scrolling should be enabled and dragging should be disabled.
  3. If the panel is not at the top, then dragging should be enabled and scrolling should be disabled.

There are some other interesting things here to note:

  1. I use panel.show(...) because it gives control over the velocity of the panel animation. If that’s not needed, panel.hide() with no arguments will achieve the same thing.
  2. iOS seems to let finger gestures be interpreted as drags before letting them be interpreted as scrolls, and Android seems to do the opposite, so I explicitly toggle dragging on and off for iOS only (I’m already toggling scrolling on and off for both platforms).

And now I have a map overlay panel that seamlessly transitions between dragging and scrolling. Check it out!

Conclusion

This implementation hopefully helps anyone who saw this type of component in apps like Google Maps, SpotAngels, etc. and wanted to recreate it. If you want to see the full source code, it’s all available here on GitHub. As always, comment below or contact me with any questions or additions.

Farhan Kathawala
Farhan Kathawala
Full Stack Web / React Native Developer

A happy full stack developer sharing tidbits of everything and anything web / mobile app dev.

Related