Detecting the opening or closing of a virtual keyboard on a touchscreen device
Asked Answered
T

5

9

I have an inelegant workaround for this issue, and am hoping that others may already have more robust solutions.


On a touchscreen, tapping on an editable text field will bring up an on-screen keyboard, and this will change the amount of screen space available. Left untreated, this may hide key elements, or push a footer out of place.

On a laptop or desktop computer, opening an editable text field creates no such layout changes.

In my current project, I want to ensure that certain key items are visible even when a virtual keyboard is open, so I need to detect when such a change occurs. I can then add a class to the body element, to change the layout to suit the presence of the keyboard.

When searching for existing solutions online, I discovered that:

  1. There is no perfect way of knowing that your code is running on a mobile device
  2. There are non-mobile devices that have touchscreens, and which may also have keyboards
  3. A focus element may not be editable
  4. contentEditable elements will open the on-screen keyboard
  5. The address bar may decide to reappear and take up essential screen space at the same time the virtual keyboard appears, squeezing the available space even more.

I have posted the solution that I have come up with below. It relies on detecting a change in height of the window within a second of the keyboard focus changing. I am hoping that you might have a better solution to propose that has been tested cross-platform, cross-browser and across devices.


I've created a repository on GitHub.
You can test my solution here.

In my tests, this may give a false positive if the user is using a computer with a touchscreen and a keyboard and mouse, and uses the mouse first to (de-)select an editable element and then immediately changes the window height. If you find other false positives or negatives, either on a computer or a mobile device, please let me know.


;(function (){

  class Keyboard {
    constructor () {
      this.screenWidth = screen.width        // detect orientation
      this.windowHeight = window.innerHeight // detect keyboard change
      this.listeners = {
        resize: []
      , keyboardchange: []
      , focuschange: []
      }

      this.isTouchScreen = 'ontouchstart' in document.documentElement

      this.focusElement = null
      this.changeFocusTime = new Date().getTime()
      this.focusDelay = 1000 // at least 600 ms is required

      let focuschange = this.focuschange.bind(this)
      document.addEventListener("focus", focuschange, true)
      document.addEventListener("blur", focuschange, true)

      window.onresize = this.resizeWindow.bind(this)
    }

    focuschange(event) {
      let target = event.target
      let elementType = null
      let checkType = false
      let checkEnabled = false
      let checkEditable = true

      if (event.type === "focus") {
        elementType = target.nodeName
        this.focusElement = target

        switch (elementType) {
          case "INPUT":
            checkType = true
          case "TEXTAREA":
            checkEditable = false
            checkEnabled = true
          break
        }

        if (checkType) {
          let type = target.type
          switch (type) {
            case "color":
            case "checkbox":
            case "radio":
            case "date":
            case "file":
            case "month":
            case "time":
              this.focusElement = null
              checkEnabled = false
            default:
              elementType += "[type=" + type +"]"
          }
        }

        if (checkEnabled) {
          if (target.disabled) {
            elementType += " (disabled)"
            this.focusElement = null
          }
        }

        if (checkEditable) {
          if (!target.contentEditable) {
            elementType = null
            this.focusElement = null
          }
        }
      } else {
        this.focusElement = null
      }

      this.changeFocusTime = new Date().getTime()

      this.listeners.focuschange.forEach(listener => {
        listener(this.focusElement, elementType)
      })
    }

    resizeWindow() {
      let screenWidth = screen.width;
      let windowHeight = window.innerHeight
      let dimensions = {
        width: innerWidth
      , height: windowHeight
      }
      let orientation = (screenWidth > screen.height)
                      ? "landscape"
                      : "portrait"

      let focusAge = new Date().getTime() - this.changeFocusTime
      let closed = !this.focusElement
                && (focusAge < this.focusDelay)            
                && (this.windowHeight < windowHeight)
      let opened = this.focusElement 
                && (focusAge < this.focusDelay)
                && (this.windowHeight > windowHeight)

      if ((this.screenWidth === screenWidth) && this.isTouchScreen) {
        // No change of orientation

        // opened or closed can only be true if height has changed.
        // 
        // Edge case
        // * Will give a false positive for keyboard change.
        // * The user has a tablet computer with both screen and
        //   keyboard, and has just clicked into or out of an
        //   editable area, and also changed the window height in
        //   the appropriate direction, all with the mouse.

        if (opened) {
          this.keyboardchange("shown", dimensions)
        } else if (closed) {
          this.keyboardchange("hidden", dimensions)
        } else {
          // Assume this is a desktop touchscreen computer with
          // resizable windows
          this.resize(dimensions, orientation)
        }

      } else {
        // Orientation has changed
        this.resize(dimensions, orientation)
      }

      this.windowHeight = windowHeight
      this.screenWidth = screenWidth
    }

    keyboardchange(change, dimensions) {
      this.listeners.keyboardchange.forEach(listener => {
        listener(change, dimensions)
      })
    }

    resize(dimensions, orientation) {
      this.listeners.resize.forEach(listener => {
        listener(dimensions, orientation)
      })
    }

    addEventListener(eventName, listener) {
      // log("*addEventListener " + eventName)

      let listeners = this.listeners[eventName] || []
      if (listeners.indexOf(listener) < 0) {
        listeners.push(listener)
      }
    }

    removeEventListener(eventName, listener) {
      let listeners = this.listeners[eventName] || []
      let index = listeners.indexOf(listener)

      if (index < 0) {
      } else {       
        listeners.slice(index, 1)
      }
    }
  }

  window.keyboard = new Keyboard()

})()
Telefilm answered 16/12, 2017 at 2:23 Comment(2)
you can use screen.availHeight and screen.availWidth to detect the screen size changes. I found this too – Chloras
Hint: the canonical way to add an answer to your own question is … to add an answer πŸ˜€ Helps to find answers where we typically look for them. When posting a question, there's even a checkbox "Answer your own question". – Airminded
H
6

There is a new experimental API that is meant exactly to track size changes due to the keyboard appearing and other mobile weirdness like that.

window.visualViewport

https://developer.mozilla.org/en-US/docs/Web/API/Visual_Viewport_API

By listening to resize events and comparing the height to the height to the so called "layout viewport". See that it changed by a significant amount, like maybe 30 pixels. You might deduce something like "the keyboard is showing".

if('visualViewport' in window) {
  window.visualViewport.addEventListener('resize', function(event) {
    if(event.target.height + 30 < document.scrollElement.clientHeight) {
        console.log("keyboard up?");
    } else {
        console.log("keyboard down?");
    }
  });
}

(code above is untested and I suspect zooming might trigger false positive, might have to check for scaling changes as well)

Herminiahermione answered 27/5, 2020 at 23:45 Comment(2)
document.scrollElement is undefined in mobile Safari, and it seems that the error is swallowed. At least in BrowserStack. – Reactive
Another problem is that this only works on Safari. When using window.innerHeight It doesn't work for Android where event.target.height is always the same as innerHeight. – Reactive
P
4

Using visualViewPort

This was inspired by on-screen-keyboard-detector. It works on Android and iOS.

if ('visualViewport' in window) {
  const VIEWPORT_VS_CLIENT_HEIGHT_RATIO = 0.75;
  window.visualViewport.addEventListener('resize', function (event) {
    if (
      (event.target.height * event.target.scale) / window.screen.height <
      VIEWPORT_VS_CLIENT_HEIGHT_RATIO
    )
      console.log('keyboard is shown');
    else console.log('keyboard is hidden');
  });
}

Another approach using virtualKeyboard

This worked, but isn't supported in iOS yet.

if ('virtualKeyboard' in navigator) {
  // Tell the browser you are taking care of virtual keyboard occlusions yourself.
  navigator.virtualKeyboard.overlaysContent = true;
  navigator.virtualKeyboard.addEventListener('geometrychange', (event) => {
    const { x, y, width, height } = event.target.boundingRect;
    if (height > 0) console.log('keyboard is shown');
    else console.log('keyboard is hidden');
});

Source: https://developer.chrome.com/docs/web-platform/virtual-keyboard/

Pons answered 29/11, 2022 at 18:37 Comment(0)
C
2

As no direct way to detect the keyboard opening, you can only detect by the height and width. See more

In javascript screen.availHeight and screen.availWidth maybe help.

Chloras answered 16/12, 2017 at 2:49 Comment(0)
C
1

I'm detecting the visibility of a virtual keyboard as follows:

window.addEventListener('resize', (event) => {
  // if current/available height ratio is small enough, virtual keyboard is probably visible
  const isKeyboardHidden = ((window.innerHeight / window.screen.availHeight) > 0.6);
});
Caliphate answered 4/7, 2022 at 7:34 Comment(0)
O
0

This is a difficult problem to get 'right'. You can try and hide the footer on input element focus, and show on blur, but that isn't always reliable on iOS. Every so often (one time in ten, say, on my iPhone 4S) the focus event seems to fail to fire (or maybe there is a race condition with JQuery Mobile), and the footer does not get hidden.

After much trial and error, I came up with this interesting solution:

<head>
    ...various JS and CSS imports...
    <script type="text/javascript">
        document.write( '<style>#footer{visibility:hidden}@media(min-height:' + ($( window ).height() - 10) + 'px){#footer{visibility:visible}}</style>' );
    </script>
</head>

Essentially: use JavaScript to determine the window height of the device, then dynamically create a CSS media query to hide the footer when the height of the window shrinks by 10 pixels. Because opening the keyboard resizes the browser display, this never fails on iOS. Because it's using the CSS engine rather than JavaScript, it's much faster and smoother too!

Note: I found using 'visibility:hidden' less glitchy than 'display:none' or 'position:static', but your mileage may vary.

Onanism answered 13/2, 2018 at 23:17 Comment(0)

© 2022 - 2024 β€” McMap. All rights reserved.