A Bottom Sheet That Hosts React Native Views

React Native has matured considerably since its 2015 release, with libraries like Reanimated and Gesture Handler enabling native-quality animations and gestures from pure JavaScript. At Shopify, React Native powers many of our applications, and we actively contribute back to the ecosystem. One area where documentation gets thin is when you need to reuse an existing native component that must render React Native views inside it. This guide walks through building a Material Design-style bottom sheet on Android that accepts two React Native subviews: one for the main content and one for the sheet itself.

The implementation focuses on Android with Kotlin, assumes basic familiarity with both, and uses a ViewGroupManager to bridge the native container into React Native.

Project Setup and Dependencies

Start from a React Native project configured for Android with Kotlin and TypeScript. If needed, generate one with:

react-native init NativeComponents —template react-native-template-typescript

Add the following Gradle dependencies to your root android/build.gradle, substituting your Kotlin version where shown:

This pulls in all libraries required by the code in this guide. For production, pin explicit version numbers rather than using the + wildcard.

Exposing the Native View Manager

Create a new file, NativeComponentsReactPackage.kt, to hold an empty package that you will populate with view managers:

class NativeComponentsReactPackage : ReactPackage {
    override fun createNativeModules(reactContext: ReactApplicationContext) = listOf<NativeModule>()

    override fun createViewManagers(reactContext: ReactApplicationContext) =
        listOf<ViewManager<*, *>>(ReactNativeBottomSheet())
}

Then register that package in your Application class by adding it to the list of packages.

The Native View Manager

Create ReactNativeBottomSheet.kt, extending ViewGroupManager<ViewGroup>. The getName() method returns the string used to reference the component from JavaScript, and createViewInstance() handles instantiation and setup. For now, the manager will return a basic view:

override fun getName() = "BottomSheet"

override fun createViewInstance(reactContext: ThemedReactContext) =
    LayoutInflater.from(reactContext).inflate(R.layout.bottom_sheet, null) as CoordinatorLayout

A Layout That Supports Bottom Sheet Behavior

Rather than building the layout programmatically, inflate from XML. Add bottom_sheet.xml to android/app/src/main/res/layout/:

<?xml version="1.0" encoding="utf-8"?>
<CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <FrameLayout
        android:id="@+id/content"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <LinearLayout
        android:id="@+id/bottom_sheet"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        app:behavior_peekHeight="80dp"
        app:layout_behavior="@string/bottom_sheet_behavior" />
</CoordinatorLayout>

This defines two containers: a content area for the main screen, and a bottom sheet that can translate upward over it using CoordinatorLayout behaviors. With the layout in place, update createViewInstance() to inflate it and store references to the two child containers:

val view = LayoutInflater.from(reactContext).inflate(R.layout.bottom_sheet, null)
container = view.findViewById(R.id.container)
bottomSheet = view.findViewById(R.id.bottom_sheet)

If you’re not using Kotlin synthetic properties, substitute findViewById(R.id.container) explicitly.

Routing React Native Subviews

By default, ViewGroupManager.addView() adds children to the root CoordinatorLayout, which would place content and sheet contents as siblings, not inside the intended containers. Override the method to direct children correctly:

override fun addView(parent: ViewGroup, child: View, index: Int) {
    if (container.childCount == 0) {
        container.addView(child)
    } else {
        bottomSheet.addView(child)
    }
}

This routes the first child passed from React Native into the content area, and every subsequent child into the bottom sheet. The component is designed to accept exactly two children, so further child management will be added later. After these changes, react-native run-android should compile and install successfully.

Referencing the Component in TypeScript

Create BottomSheet.tsx to require the native module and export a standard React component accepting style and children:

import React from 'react';
import { requireNativeComponent, ViewStyle } from 'react-native';

interface Props {
    style?: ViewStyle;
    children: React.ReactNode;
}

const NativeBottomSheet = requireNativeComponent<Props>('BottomSheet');

export function BottomSheet(props: Props) {
    return <NativeBottomSheet {...props} />;
}

Update App.tsx to render two children: the main content first, then the content for the bottom sheet.

Programmatic State Control and Events

For this component to be useful, it should respond to gestures and allow programmatic expanding and collapsing. Both are handled natively through BottomSheetBehaviour. Update createViewInstance() to attach an instance of it, then expose a native method to set the sheet state:

@ReactMethod
fun setSheetState(view: ViewGroup, state: String) {
    val bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet)
    when (state) {
        "expanded" -> bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
        "collapsed" -> bottomSheetBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
    }
}

This adds a sheetState prop to the component, accepting either collapsed or expanded. Update BottomSheet.tsx to accept and forward it, along with an optional onStateChange callback:

type State = 'collapsed' | 'expanded';

interface Props {
    style?: ViewStyle;
    sheetState?: State;
    onStateChange?: (state: State) => void;
    children: React.ReactNode;
}

When the sheet changes state through gesture, the UI should reflect that in React. In native code, attach a callback to BottomSheetBehavior that emits an event named BottomSheetStateChange whenever collapsed/expanded changes:

shellCallback.setBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
    override fun onStateChanged(bottomSheet: View, newState: Int) {
        if (newState == BottomSheetBehavior.STATE_EXPANDED || newState == BottomSheetBehavior.STATE_COLLAPSED) {
            val event = Arguments.createMap()
            event.putString("state", if (newState == BottomSheetBehavior.STATE_EXPANDED) "expanded" else "collapsed")
            reactContext.getJSModule(RCTEventEmitter::class.java)
                .receiveEvent(view.id, "BottomSheetStateChange", event)
        }
    }

    override fun onSlide(bottomSheet: View, slideOffset: Float) {}
})

Back in BottomSheet.tsx, subscribe to that event with onReceiveCommandListener or onNativeEvent:

const NativeBottomSheet = requireNativeComponent<Props>('BottomSheet');

interface Props {
    style?: ViewStyle;
    sheetState?: 'collapsed' | 'expanded';
    children: React.ReactNode;
}

With both control directions set up, dragging the sheet will update the state-driven portion of the UI, and pressing a button (if visible while collapsed) can programmatically expand it.

Why Manage Native Subviews This Way

Cross-platform components that run entirely in React Native are always the goal at Shopify. When that’s not feasible, ViewGroupManager subclasses let you reuse native views without giving up React Native’s flexible layout model. The overhead is minimal: a small amount of Kotlin plus a matching TypeScript component, and existing native implementations stay usable as first-class React Native building blocks.

What's in the Repo

The complete code for this walkthrough lives in the react-native-bottom-sheet-example repository. You can reference it while working through the implementation or use it as a starting point for your own native container components.

For a production-ready Android bottom sheet implementation, the react-native wrapper for Android BottomSheetBehavior provides a full-featured alternative. The Material Design guideline for CoordinatorLayout and BottomSheetBehaviour also helps clarify the underlying mechanics — essentially, you are building a container that holds exactly two children.


This article originally appeared on the Shopify Engineering blog, written by Joe Redridge and published on Feb 25, 2020.