Skip to content

Instantly share code, notes, and snippets.

@MartijnHols
Last active July 25, 2022 15:18
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save MartijnHols/709965559cbdb6b241c12e5866941e69 to your computer and use it in GitHub Desktop.
Save MartijnHols/709965559cbdb6b241c12e5866941e69 to your computer and use it in GitHub Desktop.
iOS edge drag navigation monitor

Using a transition to open screens helps users keep track of where they are and mimicks the native apps they're used to.

2021-11-11 09 58 48

But on iOS Safari this leads to an issue. In Safari users can drag the edges of the screen to navigate to the previous and next page. This also works on iOS in native apps to close "pushed" screens (screens that animate in from the left). Keeping this behavior in place is likely desirable, but this can conflict with custom screen transitions.

2021-11-11 10 06 22

If you pay close attention, you'll see the screen transitions from one side to the other twice. The first is Safari transitioning between the current and previous page neatly with the position of the finger of the user. The second is the transition from our app "catching up". We don't need both transitions, so one has to go. The first transition most neatly matches what the user is doing and what happens in native apps, so we need to disable our custom transition when that's triggered.

Fortunately we can detect the edge navigation by monitoring the touch events on the document. If they're close enough to the edge (in my experiments within 25 pixels seemed the perfect threshold), they're edge-drag-navigating. If we track when they're doing this in some sort of global state, we can use this information to temporarily disable the transition.

Below is an example implementation that uses Apollo Client's reactive vars for global state. The component can be changed to work with any global state library available, or even context. You can put the component anywhere, so long as it's active on the page.

import { makeVar } from '@apollo/client'
import { useEffect } from 'react'

export const isEdgeDragNavigationVar = makeVar(false)

// Selected by repeatedly triggering the gesture and finding a common threshold
const IOS_EDGE_DRAG_NAVIGATION_THRESHOLD = 25

/**
 * On iOS a user can navigate to the previous or next page in the modal stack
 * in both native apps and webapps with a swiping gesture from the edge of the
 * screen. This is the only way to detect that behavior. This can be used for
 * example to disable modal transitions to avoid showing the sliding transition
 * twice.
 */
export const EdgeDragNavigationMonitor = () => {
  useEffect(() => {
    let timer: ReturnType<typeof setTimeout>
    const handleTouchStart = (e: TouchEvent) => {
      if (
        e.touches[0].pageX > IOS_EDGE_DRAG_NAVIGATION_THRESHOLD &&
        e.touches[0].pageX <
          window.innerWidth - IOS_EDGE_DRAG_NAVIGATION_THRESHOLD
      ) {
        return
      }

      isEdgeDragNavigationVar(true)
      if (timer) {
        clearTimeout(timer)
      }
    }
    const handleTouchEnd = () => {
      timer = setTimeout(() => isEdgeDragNavigationVar(false), 200)
    }

    document.addEventListener('touchstart', handleTouchStart)
    document.addEventListener('touchend', handleTouchEnd)

    return () => {
      document.removeEventListener('touchstart', handleTouchStart)
      document.removeEventListener('touchend', handleTouchEnd)
      if (timer) {
        clearTimeout(timer)
      }
    }
  }, [])

  return null
}

export default EdgeDragNavigationMonitor

Now when the user starts edge-drag-navigating, the isEdgeDragNavigationVar's value will be set to true. This can be used in your screen transition to toggle it off like in the below example component.

import { useReactiveVar } from '@apollo/client'
import { ReactNode } from 'react'

import { isEdgeDragNavigationVar } from './EdgeDragNavigationMonitor'

export const TRANSITION_DURATION = 300

interface Props extends HTMLAttributes<HTMLDivElement> {
  isActive?: boolean
  children: ReactNode
}

const ScreenTransition = ({ children, isActive, style, ...others }: Props) => {
  const isEdgeDragNavigation = useReactiveVar(isEdgeDragNavigationVar)

  return (
    <div
      style={{
        transform: `translateX(${isActive ? 100 : 0}%)`,
        transition: `transform
          ${isEdgeDragNavigation ? 0 : TRANSITION_DURATION}ms
          ease-out`,
        ...style,
      }}
      {...others}
    >
      {children}
    </div>
  )
}

export default ScreenTransition

Now you have flawless edge navigation, just like native apps. (Except that you have forward navigation which doesn't exist in native apps. You could kill that if you want by manipulating the history.)

2021-11-11 10 23 46

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment