Building a React Native Bottom Sheet with clean code in mind.

A practical example on the bottom sheet and separation of concerns.

Featured on Hashnode

Building a slideable sheet in React Native isn't that hard. This post is all about writing a minimalistic sheet in no time!

Before diving head first in your favorite editor, let's define the requirements first and see how we can separate this module in meaningful layers.

Functionality scope

  1. A sheet is a thing at the bottom of the screen that can be dragged up and down.

  2. It has several dock points. When dragging the sheet down it should stick to the next lower dock point. When dragging it up then it should stick to the next higher dock point.

  3. The sheet knows its current position

  4. The sheet provides the handle. A container that is used to drag the sheet up and down.

  5. The sheet position will be represented in Percent.

  6. The sheet shall have the methods #collapse and #draw .

    1. Draw shall open the first dock point.

    2. Collapse shall close the sheet (go to dock point 0 Percent)

  7. The sheet renders a backdrop, that will close the sheet upon click.

With this in mind we can divide the responsibilities into different layers. For one there is the domain layer:

The Domain Layer contains the calculations, the representation of dock points and the information that we are using Percent, to make all the information we are working with, explicit.

The UI Layer will have the actual sheet component that then renders the handle, the sheet and the backdrop. It will accept the inner sheet component as parameter, so that the sheet and its domain won't get polluted with irrelevant information.


Did you know?! I have published similiar code with tests on GitHub. Check it out here:

https://github.com/RoyalZSoftware/mobile-sheet


Building the domain layer

I am a huge fan of representing the words, that are being used when talking about this module with a coworker, as objects. We do not want a discrepancy between the discussions about it and the code base.

That's exactly we I will create a Percent type at first. I will also include the common percent values: 0 and 100.

class Percent {
  public static ZERO = new Percent(0);
  public static HUNDRED = new Percent(100);

  public mathematicalValue: number;
  constructor(public humanNumber: number) {
    this.mathematicalValue = humanNumber / 100;
  }
}

With this in place we can create our Sheet class.

class Sheet {
  protected static COLLAPSED = Percent.ZERO;

  private _position: Percent;

  constructor(public readonly dockPoints: Percent[]) { // dockpoints are provided from outside
    this._position = Sheet.COLLAPSED;
    if (dockPoints.length === 0)
        throw new Error("Need at least one dockpoint.");
  }

  draw() {
    this._position = this.dockPoints[0];
  }

  collapse() {
    this._position = Sheet.COLLAPSED;
  }

  changePosition(position: Percent, dockDirection: 'UP' | 'DOWN') {
    this._position = position;
  }

  dock(dockDirection: 'UP' | 'DOWN') {
    const dockPoint = direction === 'UP' ?
                      this._findNearestHigherDockPoint(position) :
                      this._findNearestLowerDockPoint(position);
    this._position = dockPoint;
  }
  get position() {
    return this._position;
  }

  private _findNearestHigherDockPoint(position: Percent) {
    let highest = position;
    for (const dockPoint of this.dockPoints) {
      if (dockPoint.mathematicalValue > highest.mathematicalValue) {
        highest = dockPoint;
      }
    }
    return highest;
  }
  private _findNearestLowerDockPoint(position: Percent) {
    let lowest = position;
    for (const dockPoint of this.dockPoints) {
      if (dockPoint.mathematicalValue < lowest.mathematicalValue)
        lowest = dockPoint;
    }
    return lowest;
  }
}

You can find tests for this in the GitHub repository. https://github.com/RoyalZSoftware/mobile-sheet/blob/main/packages/mobile-sheet/src/sheet.spec.ts

Building the React Native UI Component

const SheetContext = createContext<Sheet>({} as Sheet);

export function SheetProvider({
  children,
  dockPoints,
}: {
  children: any;
  dockPoints: Percent[];
}) {
    const sheet = useMemo(() => new Sheet(dockPoints), []);
  return (
    <SheetContext.Provider value={}>
      {children}
    </SheetContext.Provider>
  );
}

export function SheetComponent({children}: {children: any}) {
  const sheet = useContext(SheetContext);

  const Backdrop = () => (
        <View
          style={{
            position: "absolute",
            top: "0",
            left: "0",
            width: "100vw",
            height: "100vh",
          }}
        ></View>
  )

  const Handle = () => {
    let [startPageY, setStartPageY] = useState<number | undefined>();
    return (
    <View style={{backgroundColor: 'green'}}
    onTouchStart={(event) => {
        setStartPageY(event.nativeEvent.touches[0].pageY);
    }}
    onTouchMove={(event) => {
        sheet.changePosition(new Percent((event.nativeEvent.touches[0].pageY / window.innerHeight) * 100))
    }}
    onTouchEnd={(event) => {
        const endPageY = event.nativeEvent.touches[0].pageY;
        const dockDirection = startPageY > endPageY ? 'DOWN' : 'UP';
        sheet.dock(dockDirection);
    }}>
    DRAG ME
    </View>)
  }

  return (
    <View>
      {sheet.position.mathematicalValue > 0 ? <Backdrop/> : <></>}
      <View style={{translateY: 'calc(100% - ' + sheet.position.humanNumber + "%)"}}>
        <Handle/>
        {children}
      </View>
    </View>
  );
}

Conclusion

This is it. We built a simple and minimalistic bottom sheet for React Native. And we further built it in a way to:

  1. test it

  2. extend it so that vertical sheets are possible

  3. make the dockpoints configurable

  4. and have the sheet independent of the inner components.

I hope this post helped you to understand the separation of concerns principle and why it is awesome to have code written with it in mind.


A more sophisticated solution is https://github.com/gorhom/react-native-bottom-sheet.

It is widely used and has great support for most use cases. Check it out!


Did you enjoy reading this post? :) Subscribe to the newsletter to not miss further posts on topics like this.