Skip to content

Instantly share code, notes, and snippets.

@MartijnHols
Last active April 6, 2024 16:51
Show Gist options
  • Star 12 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save MartijnHols/e9f4f787efa9190885a708468f63c5bb to your computer and use it in GitHub Desktop.
Save MartijnHols/e9f4f787efa9190885a708468f63c5bb to your computer and use it in GitHub Desktop.
React hooks for getting the document height that updates when the On Screen Keyboard/Virtual Keyboard toggles
The latest version is available at https://martijnhols.nl/gists/how-to-get-document-height-ios-safari-osk
import { useEffect } from 'react'
const useOnScreenKeyboardScrollFix = () => {
useEffect(() => {
const handleScroll = () => {
window.scrollTo(0, 0)
}
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('scroll', handleScroll)
}
}, [])
}
export default useOnScreenKeyboardScrollFix
@MartijnHols
Copy link
Author

MartijnHols commented May 31, 2021

This can be useful for fixed layouts like in PWAs that try to appear like regular apps. For example in a chat interface:

2021-05-31 11 02 18

Both hooks are needed to achieve this effect.

@zizzfizzix
Copy link

zizzfizzix commented Jul 20, 2022

Your gist was really useful, thanks! I had to combine useDocumentHeight.ts with this approach from SO to use in Next.js with SSR.

@shaimalul-zencity
Copy link

@MartijnHols can you give us a complete code example? currently is not working for me and I don't know why :(

@MartijnHols
Copy link
Author

MartijnHols commented Mar 5, 2023

Not at this time, but I can give you some pointers that may or may not be related. I had a few iterations on this solution, here's the latest version I use:

Global styling:

        html,
        body {
          // Necessary for iOS when installed on homescreen, otherwise
          // useDocumentHeight does not include the notch height.
          // This must be vh not % due to a bug in iOS 15.1 where the address
          // bar sometimes minimizes after the OSK closes. When this happens,
          // only 100vh seems to have the correct value.
          height: 100vh;
        }
        #root {
          position: absolute;
          top: 0;
          left: 0;
          width: 100%;
          // This must be vh not % due to a bug in iOS 15.1 where the address
          // bar sometimes minimizes after the OSK closes. When this happens,
          // only 100vh seems to have the correct value.
          height: 100vh;
          overflow: hidden;
        }

And then this is the primary sizing component (essentially the child of #root):

FullViewportContainer.tsx:

import { ElementType, HTMLAttributes } from 'react'

import useIsOnScreenKeyboardOpen from './useIsOnScreenKeyboardOpen'
import useOnScreenKeyboardScrollFix from './useOnScreenKeyboardScrollFix'
import usePreventOverScrolling from './usePreventOverscrolling'
import useViewportSize from './utils/useViewportSize'

interface Props extends HTMLAttributes<HTMLDivElement> {
  element?: ElementType
}

const FullViewportContainer = ({
  element: Element = 'div',
  ...others
}: Props) => {
  const [, viewportHeight] = useViewportSize()
  useOnScreenKeyboardScrollFix()

  const isOnScreenKeyboardOpen = useIsOnScreenKeyboardOpen()

  const ref = usePreventOverScrolling() // gist readers note: this solves a different problem and can be omitted

  return (
    <Element
      {...others}
      ref={ref}
      style={{
        height: viewportHeight,
        padding: isOnScreenKeyboardOpen
          ? 'env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left)'
          : 'env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)',
        transition: 'padding 100ms, height 100ms',
      }}
    />
  )
}

export default FullViewportContainer

useViewportSize.ts (IIRC in a later project I ran into issues with the useViewportSizeEffect and reworked it, but I don't exactly recall where and what that was):
https://martijnhols.nl/gists/how-to-get-document-height-ios-safari-osk

useIsOnScreenKeyboardOpen.ts (this is pretty ugly but it works for me 🤷 ):
https://martijnhols.nl/gists/how-to-detect-the-on-screen-keyboard-in-ios-safari

@dylan0805
Copy link

Can I get usePreventOverScrolling hook , please ?

@MartijnHols
Copy link
Author

MartijnHols commented Nov 9, 2023

@dylan0805 This is pretty hacky; at the limits of what is reasonable to do in a browser. I'd only use if you really need to.

import { useEffect, useRef } from 'react'

import findNearestScrollContainer from './utils/findNearestScrollContainer'

// On iOS in the browser you can pull on elements down / make the page bounce
// similar to pull to refresh. This causes weird behavior when trying to
// simulate a full screen app.
// This is a partial fix for edge cases where this is still possible (e.g. on
// iOS 15 when hiding the button bar via the Aa button on the address bar). Most
// regular cases are fixed properly by ensuring the page content height isn't
// larger than the viewport.
const usePreventOverScrolling = () => {
  const container = useRef<HTMLDivElement>(null)
  useEffect(() => {
    const elem = container.current
    if (!elem) {
      return
    }

    let startTouch: Touch | undefined = undefined
    const handleTouchStart = (e: TouchEvent) => {
      if (e.touches.length !== 1) {
        return
      }
      startTouch = e.touches[0]
    }
    const handleTouchMove = (e: TouchEvent) => {
      if (e.touches.length !== 1 || !startTouch) {
        return
      }

      const deltaY = startTouch.pageY - e.targetTouches[0].pageY
      const deltaX = startTouch.pageX - e.targetTouches[0].pageX
      if (Math.abs(deltaX) > Math.abs(deltaY)) {
        // Horizontal scroll probably
        return
      }

      const target = e.target as HTMLElement
      const nearestScrollContainer = findNearestScrollContainer(target)
      if (!nearestScrollContainer) {
        console.log('Preventing scroll: no nearest scroll container')
        e.preventDefault()
        return
      }

      const isScrollingUp = deltaY < 0
      const isAtTop = nearestScrollContainer.scrollTop === 0
      if (isScrollingUp && isAtTop) {
        console.log(
          'Preventing scroll: already at top of nearest scroll container',
        )
        e.preventDefault()
        return
      }
      const isAtBottom =
        nearestScrollContainer.scrollTop ===
        nearestScrollContainer.scrollHeight -
          nearestScrollContainer.clientHeight
      if (!isScrollingUp && isAtBottom) {
        console.log(
          'Preventing scroll: already at bottom of nearest scroll container',
        )
        e.preventDefault()
        return
      }
    }

    elem.addEventListener('touchstart', handleTouchStart)
    elem.addEventListener('touchmove', handleTouchMove)
    return () => {
      elem.removeEventListener('touchstart', handleTouchStart)
      elem.removeEventListener('touchmove', handleTouchMove)
    }
  }, [container])

  return container
}

export default usePreventOverScrolling
const findNearestScrollContainer = (
  elem: HTMLElement,
): HTMLElement | undefined => {
  if (elem.scrollHeight > elem.offsetHeight) {
    return elem
  }

  const parent = elem.parentElement
  if (!parent) {
    return undefined
  }

  return findNearestScrollContainer(parent)
}

export default findNearestScrollContainer

@dylan0805
Copy link

dylan0805 commented Nov 9, 2023 via email

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