Let's Use a Hiding Header Hook

nrgapple

Adam

Posted on June 11, 2020

Let's Use a Hiding Header Hook

Recently our team at the PWA Store decided to upgrade our header to a hiding header. A hiding header allows for more screen space on mobile and over all makes our react app feel more responsive. This is a huge bonus if your app has a lot of data to show, but minimal space to show it.

Third Party Solutions

The PWA Store was created with React Ionic. Ionic does a lot of the heavy lifting for the developer. And wouldn't you know it, their own documentation already has a hiding header on it. So adding that functionality should be ez pz, right? Wrong.

After diving deep into the header component documentation it was clear that hiding the header was not possible through Ionic. There is a function called collapse, but this only works on iOS. Also, it just hides to reveal another smaller header.

Our second thought was to search npm for something that was already implemented. We ran across React Headroom and it seemed to be everything we were looking for and just an npm install away.

After installing and adding it to our app, Headroom was broken. It didn't work nicely with our app. Actually it didn't work at all. Bummer.

Build a HidingHeader Component

At this point we realized it was time to create it on our own. Since the header would be on many of the app listing pages it would need to be reusable. The first idea was to make a HidingHeader component. The logic for checking the scroll distance of the content would reside inside the component making adding the header to a page a simple import.

<HidingHeader scrollY={scrollY} />
Enter fullscreen mode Exit fullscreen mode

But this created too many unnecessary rerenders to the DOM as every change in the scroll y position of the content was causing the HidingHeader component to rerender. The only time that the HidingHeader needs to update is when its position should change. So how do we hold that state and only update the header when it is actually needed?

Introducing the useHidingHeader Hook 👏👏👏

const [hideDecimal, setScrollY] = useHidingHeader(threshold: number)
Enter fullscreen mode Exit fullscreen mode

useHidingHeader hook updates a decimal value called hideDecimal between 0-1 to let the HidingHeader component know how much of the header should be hidden. 0 means not hidden at all and 1 fully hidden. Our page's content component sends a callback when scrolling in the y direction updates. This value is then set in the setScrollY(position: number) state. Finally we pass a threshold value into the hook to tell it how much of a change in scroll it takes to completely hide the header. Handling the state of the Header this way ensures that the HidingHeader component will not update for state change unless there is an actual change in how it is displayed.

HidingHeader.tsx

import { IonHeader, IonToolbar } from "@ionic/react"
import React, { memo, useMemo, useRef } from "react"

interface ContainerProps {
  children: any
  // number between 0 and 1
  hideDecimal: number
}

const HidingHeader: React.FC<ContainerProps> = ({ hideDecimal, children }) => {
  const header = useRef<any>(null)
  const styles = useMemo(
    () => ({
      marginTop: `${-hideDecimal * 100}px`,
      marginBottom: `${hideDecimal * 100}px`,
    }),
    [hideDecimal]
  )

  return useMemo(
    () => (
      <IonHeader
        ref={header}
        style={styles}
        className="ion-no-border bottom-line-border"
      >
        <IonToolbar>{children}</IonToolbar>
      </IonHeader>
    ),
    [children, styles]
  )
}

export default memo(HidingHeader)
Enter fullscreen mode Exit fullscreen mode

We update the margins of our Header component when the hideDecimal changes. This moves the Header up and away from view in the window.

useHidingHeader.ts

import { useState, useEffect } from "react"

type NumberDispatchType = (
  threshold: number
) => [number, React.Dispatch<React.SetStateAction<number>>]

export const useHidingHeader: NumberDispatchType = (threshold: number) => {
  const [initialChange, setInitialChange] = useState<number>(0)
  const [scrollYCurrent, setScrollYCurrent] = useState<number>(0)
  // number between 0 and 1
  const [hideDecimal, setHideDecimal] = useState<number>(0)
  const [scrollYPrevious, setScrollYPrevious] = useState<number>(0)

  useEffect(() => {
    // at the top or scrolled backwards => reset
    if (scrollYCurrent <= 0 || scrollYPrevious > scrollYCurrent) {
      setHideDecimal(0)
      setInitialChange(scrollYCurrent)
    } else {
      if (scrollYCurrent > initialChange) {
        // start hiding
        if (scrollYCurrent < initialChange + threshold)
          setHideDecimal((scrollYCurrent - initialChange) / threshold)
        // fulling hidden
        else if (hideDecimal !== 1) setHideDecimal(1)
      }
    }
    setScrollYPrevious(scrollYCurrent)
  }, [scrollYCurrent])

  return [hideDecimal, setScrollYCurrent]
}
Enter fullscreen mode Exit fullscreen mode

Typing the Hook

type NumberDispatchType = (
  threshold: number
) => [number, React.Dispatch<React.SetStateAction<number>>]
Enter fullscreen mode Exit fullscreen mode

One of the most annoying, but rewarding parts of using Typescript is typing your objects. So in this instance, how do you type a hook? First we must understand what our hook really is.

useHidingHeader takes in a number and returns an array. The array's order is important, so we must take that into consideration when typing. Inside our array we have a number and the setter. The setter is a dispatch function defined inside the body of our hook. This setter is actually a React Dispatch that dispatches an action for setting a useState's value.

The Logic

// at the top or scrolled backwards => reset
if (scrollYCurrent <= 0 || scrollYPrevious > scrollYCurrent) {
  setHideDecimal(0)
  setInitialChange(scrollYCurrent)
} else {
  if (scrollYCurrent > initialChange) {
    // start hiding
    if (scrollYCurrent < initialChange + threshold)
      setHideDecimal((scrollYCurrent - initialChange) / threshold)
    // fulling hidden
    else if (hideDecimal !== 1) setHideDecimal(1)
  }
}
setScrollYPrevious(scrollYCurrent)
Enter fullscreen mode Exit fullscreen mode

The actual logic behind the hook can be found within the useEffect. We must store the initialChange value of the scroll. This is the value that the scroll y is compared to. Next, we need to store the scrollYPrevious value of the scroll. This is the value that the scroll bar was at the previous time the scroll was updated.

Every time scrollYCurrent is set we execute the function in the useEffect.

If the scroll bar is at the top or its value is less than the previous value we reset the header's position by updating hideDecimal to 0.

When scrolling down two things can happen: we are in between the initialChange value and the threshold or we have passed that state and are continuing to scroll down.

Usage

const Home: React.FC = () => {
  const [hideDecimal, setScrollYCurrent] = useHidingHeader(50)
  return (
    <IonPage>
      <HidingHeader hideDecimal={hideDecimal}>
        <div className="HomeHeader">
          <div>
            <h1>PWA Store</h1>
            <IonNote>Progressive Web App Discovery</IonNote>
          </div>
        </div>
      </HidingHeader>
      <IonContent
        fullscreen={true}
        scrollEvents={true}
        onIonScroll={(e) => setScrollYCurrent(e.detail.scrollTop)}
      >
        <div>
          Things and stuff.
        </div>
      </IonContent>
    </IonPage>
  )
}
Enter fullscreen mode Exit fullscreen mode

Wrapping Up

When some state changes every frame, it can be very beneficial to update the side effects to that change only when necessary. This limits the amount of rerenders to the DOM and our application's overall performance. By using a hook to control the state of our header's margins, we are able to update our header only when it really matters.

Here we see the DOM update only happening when the header is changing its size.

Alt Text

Thanks for reading and please let me know if you can come up with an even better way to do this!

✌️

💖 💪 🙅 🚩
nrgapple
Adam

Posted on June 11, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related