Avatar

Ciao, I'm Julia.

How To Add A Sticky Footer To The React Native Modal

#react-native

4 min read

I recently had to migrate the modals in our app across to the React Native Modal component. We were previously using an older library that is not longer maintained, and thought it'd be safer to go with RN's Modal, as it's a core component and does most of what we needed our modals to do out of the box.

In order to keep styling and behaviour consistent across the app, we created a custom Modal to encapsulate the style and behaviour we wanted.

import { SafeLayout } from '@covid/components'
import * as React from 'react'
import { Modal as RNModal, SafeAreaView, ScrollView, StyleSheet } from 'react-native'

interface IProps {
  children: React.ReactNode;
  onRequestClose: () => void;
  visible: boolean;
}

export default function Modal(props: IProps) {
  return (
    <RNModal transparent animationType="fade" onRequestClose={props.onRequestClose} visible={props.visible}>
      <SafeAreaView style={styles.modal}>
        <ScrollView
          alwaysBounceVertical={false}
          contentContainerStyle={styles.contentContainer}
          showsVerticalScrollIndicator={false}
          style={styles.scrollView}
        >
          {props.children}
        </ScrollView>
      </SafeAreaView>
    </RNModal>
  )
}

As you can see, the props needed are really just the onRequestClose callback, and the boolean to make the modal visible. We set some defaults in the form of styles, disabling vertical bounce and not showing the vertical scroll indicator.

This was a simple implementation of a modal, and worked well for our needs...until last week when it didn't. Up until now, information we stuck in a modal tended to be short and snappy, with no need for any scrolling, since it would all fit within a single screen height. That changed last week when we had a lengthy bit of content that needed to be made available to the user, but wasn't really necessary for them to read, if they didn't want / need to.

The solution we thought would work best, was in effect a modal that would pop up, with a scrollable area where the lengthy content would be displayed, and a sticky button at the bottom which would close the modal onPress.

Our current Modal component didn't allow for a sticky button - as you can see, all content for the Modal would be passed in via props.children which makes no distinction for sticky footers. What we therefore needed to do was allow the passing of a footer component into our Modal, in addition to having a fade off effect between the current content, and the top of the sticky footer. We felt this was needed to indicate to the user that there's more content below to read, if needed. We decided to use the React Native Linear Gradient package for this.

One issue that cropped up was that alignments for the vertical scrollbar, the edge of our modal and the linear gradient didn't match up well. There was a bit of fiddling around with the styling that needed to be done, to ensure it all looked good on iOS. The code below is where we ended up with our Modal.

import * as React from 'react'
import { Modal as RNModal, SafeAreaView, ScrollView, StyleSheet, View } from 'react-native'
import LinearGradient from 'react-native-linear-gradient'

interface IProps {
  children: React.ReactNode;
  footerChildren?: React.ReactNode;
  onRequestClose: () => void;
  visible: boolean;
  showVerticalScrollIndicator?: boolean;
}

const BORDER_RADIUS = 16
const CONTENT_SPACING = 20
const SCROLL_INDICATOR_OFFSET = BORDER_RADIUS / 4 + 2

// To ensure the scroll indicator aligns to the edge on iOS.
const INSETS = {
  bottom: -3,
  left: 0,
  right: -3,
  top: -3,
}

const COLORS = ['#ffffff00', 'white']

export default function Modal(props: IProps) {
  return (
    <RNModal transparent animationType="fade" onRequestClose={props.onRequestClose} visible={props.visible}>
      <SafeAreaView style={styles.safeLayout}>
        <View style={styles.view}>
          <View style={styles.view2}>
            <ScrollView
              alwaysBounceVertical={false}
              contentContainerStyle={styles.contentContainer}
              scrollIndicatorInsets={INSETS}
              showsVerticalScrollIndicator={props.showVerticalScrollIndicator || false}
              style={styles.scrollView}
            >
              {props.children}
            </ScrollView>
            {props.footerChildren ? (
              <View style={styles.padding}>
                <LinearGradient colors={COLORS} style={styles.linearGradient} />
                {props.footerChildren}
              </View>
            ) : null}
          </View>
        </View>
      </SafeAreaView>
    </RNModal>
  )
}

const styles = StyleSheet.create({
  contentContainer: {
    padding: CONTENT_SPACING - SCROLL_INDICATOR_OFFSET,
  },
  linearGradient: {
    backgroundColor: '#ffffff00',
    height: CONTENT_SPACING,
    left: CONTENT_SPACING,
    position: 'absolute',
    right: CONTENT_SPACING,
    top: -CONTENT_SPACING,
    zIndex: 1,
  },
  padding: {
    paddingBottom: CONTENT_SPACING,
    paddingHorizontal: CONTENT_SPACING,
  },
  safeLayout: {
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
  },
  scrollView: {
    margin: SCROLL_INDICATOR_OFFSET,
  },
  view: {
    flex: 0,
    flexGrow: 0,
    flexShrink: 1,
    marginBottom: 'auto',
    marginTop: 'auto',
    padding: CONTENT_SPACING,
  },
  view2: {
    backgroundColor: 'white',
    borderRadius: BORDER_RADIUS,
    flex: 0,
    flexGrow: 0,
    flexShrink: 1,
  },
})

I thought I'd document this as it was quite fiddly to get right. If you've had to implement something similar, I'd love to hear how you did it!

Web Mentions

Tweet about this post and have it show up here!

© 2016-2021 Julia Tan · Powered by Next JS.