Search code examples
javascriptcssreactjsmobile-safarihybrid-mobile-app

How to fix viewport in place when virtual keyboard opens in mobile Safari?


Goal

On mobile Safari, when the virtual keyboard is open, the screen should render like this image: enter image description here

where:

  • The navbar and the input are fixed in place
  • The list of text messages is scrollable

Problem

On mobile Safari, when the soft keyboard is open, dragging on the input or navbar can move the entire viewport up and down.

I have a screen capture video demonstrating the problem: https://youtu.be/GStBjRVpoGU

I've spent months on this problem, including trawling through many similar questions on Stack Overflow, but I've been unable to find a solution that works (or, at least, a solution that I've been able to make work).

Background

This app is a hybrid mobile app: built as a web app with React.js but wrapped in a React Native app using the WebView component. Nevertheless, the same problem exists even if the web app is opened in a normal mobile Safari window.

Mobile Safari has a related problem where the soft keyboard pushes the whole viewport up when it is opened so that the top half of the viewport is pushed up and off the screen. This excellent blog provides both a description of that problem and a solution for it. I implemented that solution. It stops the viewport from moving up when the soft keyboard opens, but the viewport can still slide around after the soft keyboard opens.

Another problem is that mobile Safari doesn't update window.innerHeight when the soft keyboard is opened/closed. To get around this, I used react-native-keyboard-spacer in the React Native app, a bit like this:

render() {
    return (
      <React.Fragment>
        <SafeAreaView >
          <WebView/>
        </SafeAreaView>
        <KeyboardSpacer />
      </React.Fragment>
    );
  }

This changes the height of the Webview whenever the soft keyboard opens/closes, and thus window.innerHeight etc also changes.

It's also known that position: fixed; on mobile Safari doesn't work so well when the soft keyboard is open, so I used position: absolute; instead, thanks to the suggestions at this other very useful blog.

I created a code sandbox to demonstrate the problem. Open it in mobile Safari to see the screen slide after the virtual keyboard is opened. You can also the actual code sandbox code here, which represents the closest I've come to solving this problem.

One thing to note about the code sandbox, though. There’s no WebView or KeyboardSpacer: it’s just a web page. As such, I’ve had to hardcode some heights in. But if you open it in mobile Safari you’ll see the viewport sliding all over the place once the soft keyboard is open.

Has anybody seen this particular problem before? How did you fix it? Many thanks in advance.


Solution

  • With the help of JMathew, I found a solution for this problem.

    See a working codesandbox example here.

    1. Put your navbar, message list, and input into container divs.
    2. Add refs to those container divs. For example:
      const messageListContainerRef = useRef<HTMLDivElement>(null);
      
      //...
    
      return (
        //...
        <div ref={messageListContainerRef}>
          <MessageList/>
        </div>
        //...
      )
    
    1. When the virtual keyboard opens (when the dummy input is focused), add an event listener for the touchmove event to the inputContainerRef and navbarContainerRef. Use that to preventDefault() on the touch move event, which will prevent the User from being able to slide the input and navbar up and down. For example:
    if (inputContainerRef.current) {
      inputContainerRef.current.addEventListener('touchmove', (e) => {
        e.preventDefault();
      });
    }
    
    1. You will still want to be able to scroll through the message list, so you can't just preventDefault() on it. Instead, use this solution (link now dead) to prevent the User from scrolling past the bottom (and top) of the page by setting the scrollTop value:
    if (messageListContainerRef.current) {
      messageListContainerRef.current.addEventListener('touchmove', (e: any) => {
        if (!e.currentTarget) {
          return;
        }
        if (e.currentTarget.scrollTop === 0) {
          e.currentTarget.scrollTop = 1;
        } else if (e.currentTarget.scrollHeight === e.currentTarget.scrollTop +
                                                    e.currentTarget.offsetHeight) {
          e.currentTarget.scrollTop -= 1;
        }
      });
    }
    

    Depending on your want/need, you could set those touchmove event listeners on component mount, rather than on input focus. You could even set them for the whole document.body, if that works for you.