Original text: iOS keyboard problem with visible viewport (VisualViewport) API | AlloyTeam author: TAT. Rikumi

The four-year battle between Web developers and iOS has finally come to an end with the release of iOS 13.

IOS 8.2 and its keyboard woes

In March 2015, iOS released version 8.2. This may have seemed like a minor update to the modern operating system at the time, but in the eyes of Web developers, some subtle issues arose. It’s a hassle you can’t imagine in the Android world.

Until now, Web developers have been well aware that the innerWidth/innerHeight on the Window global object represents the size of the area in the browser window where the page is visible, while the outerWidth/outerHeight represents the overall size of the browser window. The area where you can see a page is also called a Viewport. In the CSS world, any position: Fixed elements, such as headers, sidebars, “back to top” buttons, and so on that are common in desktop Web design, are positioned out of the document flow and benchmarked against viewports to keep them fixed relative to the window as the page scrolls.

Starting with iOS 8.2, however, these concepts don’t work as well.

Problem 1: unreliable fixed

After iOS 8.2, perhaps in order to satisfy the design of the matte translucent keyboard to have something behind it, so as to achieve the effect of being invisible, or because of the interactive experience, do not want to have to re-render the keyboard animation multiple times during the pushing process, Safari, the only browser kernel assigned to iOS and the ancestor of Webkit, has changed the fixed element layout base area from the visible area above the keyboard to the entire window behind the keyboard.

The figure above is a representation of the general case. When you’re visiting a page on a traditional device (like the one at left), scroll to a certain location (the top of the purple border line), use your two fingers to zoom into a small area (the “viewable area” + “opaque keyboard” area), and then tap an input box to start writing text. At this point, the window (window object) will generate a resize event, and due to keyboard compression, the base area of the fixed element will become the area marked by the purple border line.

In iOS 8.2+ devices (as shown in the right picture), after scrolling to a certain position, use two fingers to zoom into a small area (” visible area “+” translucent keyboard “area in the picture), and then click an input box to start writing text. At this time, the Window object will no longer generate resize event. CSS and JS have no way of knowing whether the soft keyboard is turned on, let alone how much area the keyboard occupies. Therefore, the base area of the fixed element remains in the purple area of the right image, which will not change.

Since the image above is a general case, magnification is taken into account and the layout of the visible area appears to be unaffected by the naked eye. But in modern mobile Web design, where we often use Viewport Meta Tags and block multi-touch and double-click gestures to disable page enlargement, the problem becomes apparent:

In the era of the mobile Internet, the pages we browse on our phones become more and more pages designed for mobile devices. They are long, thin and easy to read without enlarging. On other traditional devices, when the keyboard is up, the window object resize, and all fixed layout elements are automatically pushed into the area above the keyboard. On iOS 8.2 devices, the window object no longer resize after the keyboard pops up, and the fixed element remains in its original position without any notice of the keyboard.

This is not a big deal for normal Web applications, but it can be a huge hit for applications that need to pursue special interactions. The biggest problem is that there is no longer anything securely attached to the keyboard, not a line of prompts, not a toolbar, not an autocomplete list.

Problem two: smart guy on the page push

As you can see on the right side of the image above, when the keyboard pops up, the page doesn’t know it’s there. So wouldn’t the experience be terrible if the target to be entered (i.e., an “input box” such as input, Textarea, or a common contenteditable element) was covered by a bouncing keyboard?

The designers of iOS figured this out, and they solved it in a clever way: scrolling.

As shown above, when you click on the input box to start typing, the keyboard animation pops up and the page scrolls along with it (and zooming if certain conditions are met, which is ignored here), but the scrolling results are somewhat unexpected: The input box itself understandably scrolls to the middle of the actual visual area, but the fixed element does not recalculate, but remains in its original relative position and is pushed up along with the input box; During scrolling, the bottom of the screen is also allowed to go beyond the bottom of the page (” scroll over “) to make the input field as visible as possible. When you put the keyboard away, the “over-scrolling” part will bounce back and the fixed element will be recalculated, but the page will not return to the same position as before you opened the keyboard.

This doesn’t seem like much of a problem, but here’s the problem: If we had a single-screen Web application with the HTML element set to Overflow: Hidden, the problem would look like this:

Before opening the keyboard, the page was unscrollable, which was exactly what we expected; But when you open the keyboard, whether it covers the input box or not, the page becomes scrollable. In other words, the Viewport concept “hangs” in such cases, detached from the actual display area on the screen and scrolling up and down. This scrolling can be masked by blocking the default behavior of the TouchMove event, but the keyboard will still scroll up that long distance automatically when it pops up.

More logically but cannot accept the problem is that if there is not just the page carefully the content of the vertical overflow, when the keyboard up, has entered a state of “strange” : not scroll area of the HTML, but shows the content of scroll down after a distance appears at the bottom of the (for example, a large number of white space), and because of the overflow: Hidden and can’t scroll back.

In a lot of cases where we don’t use 100%, we use the concept of 100vh in CSS to represent viewport height, which in Safari seems to indicate the maximum viewport height when the toolbar collapses automatically, so elements that are 100vh high are likely to overflow the HTML area. This is the main reason why vertical overflow can occur in pages of single-screen Web applications mentioned here.

It is worth mentioning that if we are in this “strange state” and still think of the page as a single screen without scrolling, and continue to engage in some complicated logical calculations using the distance between touch events and the top of the screen/viewport (screenY or clientY), There is a discrepancy between the location of the touch and the location on the page where the response is needed.

Past solutions

Before iOS 13 appeared, fixed unreliable problem could not be solved, unless we made some judgments on WKWebView scrollView in Native side. And exposed to the Web through the JS API – but it’s not very elegant to limit the power of a Web application to a specific client.

There are three possible solutions to the problem (problem 2) that forced scrolling occurs when the keyboard is opened and cannot be manually rolled back:

1. Focus after actively avoiding the keyboard

This is a more general and easy way to do it: When the touchend occurs on the input target (input, etc.), prevent the default behavior, rearrange the input box in advance, move the input box to a position that is unlikely to be blocked by the keyboard (of course, the specific height is not blocked, it can only be guessed at that time), and then immediately call the focus() method to actively focus the input box.

But once the keyboard is turned on, you still need to use anti-scrolling measures (which prevent the default behavior of TouchMove across the page) to prevent users from manually pushing up the page.

2. Reverse scrolling

At the moment the keyboard is up (the next macro task cycle of the focus event), we know from window.scrolly the target position of the page scroll. It’s easy to imagine that window.scrollto (0, 0) would revert to the original position at this point, but in practice, this would cause the entire page to teleport down and then gradually move back to the screen.

Why is that? We can explain it with the picture above. As we saw in the previous figure, iOS floats the viewport when the keyboard pops up, so it’s safe to assume that the viewport actually teleports when the keyboard pops up. At the same time that the page window.scrolly becomes the target value, the viewport is teleported to the same distance below the page, making it appear to the naked eye that the page is still in the same position. The viewport then begins to move up with the page until it again overlapped with the screen, creating the effect of forcing the page to scroll, while window.scrolly does not change gradually, but only at the beginning. Therefore, if we execute window.scrollto (0, 0) directly with the keyboard open, the page will follow the viewport to a lower position and then follow the viewport back to the screen.

In other words, the forced scrolling when the keyboard opens is not window.scrollto’s smooth mode, but driven by iOS Native’s scroll container. As long as the keyboard may cover the input field at the moment of focus, there is nothing we can do to prevent forced scrolling from happening and proceeding.

Since we can’t stop it, we can counteract it with a reverse scrolling animation. With window.scrollY after focus as the starting point and window.scrollY before focus (usually 0) as the ending point, a slow curve opposite to iOS Spring Animation is constructed, and the downward scrolling Animation is used to offset the upward scrolling Animation. You can allow the input field to be obscured when the keyboard pops up, and the page to vibrate only slightly.

The goal, of course, is not to obscure the input field with the keyboard, but to protect the page from forced scrolling in the first place. Therefore, after performing reverse scrolling, you can also move the position of the input box into visual range, away from the keyboard.

With this scheme, the same measures to prevent manual rolling mentioned above are needed.

3. Restore the keyboard to its original position

The above two scenarios are for situations where you don’t want forced scrolling. If you can allow forced scrolling when the keyboard is up, but want to return to the original position when the keyboard is down, simply use window.scrollTo in the blur event when the keyboard is down to return the page to the original position.

IOS 13 VisualViewport API and new ideas

Yesterday, I searched for Safari Keyboards on iOS on Google, And for The fifth time in desperation, I found Safari 13, Mobile Keyboards, And The VisualViewport API. The article notes that Safari 13 (iOS 13) already supports the VisualViewport API, an experimental standard that reflects the actual viewable area. According to the MDN page, only Internet Explorer and Legacy Edge currently do not support this API.

In testing, iOS 13 supports this API so well that it fully reflects the location of the visible area of the page without the keyboard. But why is there a cross-platform API to compensate when only iOS 8.2 doesn’t report keyboard pop-ups? Isn’t window.innerWidth, window.innerHeight, and resize events good enough in other browsers?

This needs to be explained by going back to the first picture in this article:

Yes, the problem is page scaling. As you can see, when the page is zoomed in, fixed elements do not move together to the actual viewable area. On Android, window.innerWidth and window.innerHeight do not change with page enlargement. On iOS, window.innerWidth and window.innerHeight decrease proportionally as the page expands. This does not remove the keyboard height, but it does reflect the size of the page area displayed on the screen.

The VisualViewport API on both Android and iOS completely reflects the position and size of the actual visual area in the page under a series of effects such as zooming and keyboard pop-up.

Therefore, the VisualViewport API for platforms other than iOS, the greatest significance is to reflect the page zoom area; For the iOS Safari browser, the biggest significance is that it can reflect the keyboard pop-up. Based on this, we can implement a fixed container that is truly fixed relative to the visible area.

Implement a VisualViewport component

How to implement a fixed container? Some Web developers may not be aware of this. In the Web developer’s intuition, a fixed element is always positioned relative to the viewport, and no single element can change the way it is positioned; But in fact, the problem is a little different.

If you’ve ever used a good scroll container such as iScroll, BetterScroll, AlloyTouch, etc., you may have encountered a problem: Fixed “isn’t working”, they may no longer be positioned relative to the viewport, but instead are confined to the scroll container.

This is because, in a performance bottleneck that scrolling containers often encounter, component developers often opt for CSS 3D Transform to force hardware acceleration and make scrolling smoother. In the 3D Transform enabled container, due to rendering restrictions, fixed elements are no longer positioned relative to the viewport, but are “circled” inside the 3D Transform container. We just need to do the opposite and turn on the 3D Transform for a container to place the fixed elements inside relative to the container.

Use React as an example to implement a VisualViewport component that is compatible with Android/iOS 13+ and always attached to the viewport area.

Define the VisualViewport type

Since I’m currently using TypeScript 3.7.5 without defining the VisualViewport API, first we need to manually erase the type.

interface VisualViewport extends EventTarget {
    width: number;
    height: number;
    scale: number;
    offsetTop: number;
    offsetLeft: number;
    pageTop: number;
    pageLeft: number;
}

// eslint-disable-next-line
declare global {
    interface Window {
        visualViewport?: VisualViewport;
    }
}
Copy the code

Define the components

In component, we support for VisualViewport VisualViewport apis used by the API platform, for does not support platform can use window. The innerWidth/window. The innerHeight are compatible.

import * as React from 'react'; interface VisualViewportComponentProps { className? : string; style? : React.CSSProperties; } interface VisualViewportComponentState { visualViewport: VisualViewport | null; windowInnerWidth: number; windowInnerHeight: number; } export default class VisualViewportComponent extends React.Component<{}, VisualViewportComponentState> { state: VisualViewportComponentState = { visualViewport: null, windowInnerWidth: window.innerWidth, windowInnerHeight: ComponentWillUnmount () {// TODO: componentWillUnmount() {// TODO: componentWillUnmount() {// TODO: componentWillUnmount() {// TODO: componentWillUnmount()} getStyles(): React.CSSProperties {// TODO: according to the state calculation style return {}; } render() { return <div className={'visual-viewport ' + (this.props.className || '')} style={this.getStyles()}> {this.props.children} </div>; }}Copy the code

Define event listeners

By listening for the Window. visualViewport resize and Scroll events, as well as the Window resize events, we convert the size changes of the visible and actual viewports into state changes within the component to trigger rerendering.

componentDidMount() { if (typeof window.visualViewport ! == 'undefined') { window.visualViewport.addEventListener('resize', this.onVisualViewportChange); window.visualViewport.addEventListener('scroll', this.onVisualViewportChange); } window.addEventListener('resize', this.onResize); } componentWillUnmount() { if (typeof window.visualViewport ! == 'undefined') { window.visualViewport.removeEventListener('resize', this.onVisualViewportChange); window.visualViewport.removeEventListener('scroll', this.onVisualViewportChange); } window.removeEventListener('resize', this.onResize); } onVisualViewportChange = (e: Event) => { this.setState({ visualViewport: e.target as VisualViewport || window.visualViewport }); } onResize = () => { this.setState({ windowInnerWidth: window.innerWidth, windowInnerHeight: window.innerHeight }); }Copy the code

Calculate the style

Next, we calculate the relative position of the visible viewport in the actual viewport based on the viewport provided in State and the actual viewport size and apply it to the component container style.

getStyles() { const { visualViewport, windowInnerWidth, windowInnerHeight, } = this.state; // Open the 3D Transform to position the fixed child relative to the container // Also set itself to fixed so that the position does not need to be moved frequently without zooming React.CSSProperties = { position: 'fixed', transform: 'translateZ(0)', ... this.props.style || {} }; // If (VisualViewport! Styles. left = math.max (0, 0, 0) {// Need boundary checking for iOS elastic scroll out of bounds Math.min( document.documentElement.scrollWidth - visualViewport.width, visualViewport.offsetLeft )) + 'px'; Styles. Top = math.max (0, Math.min( document.documentElement.scrollHeight - visualViewport.height, visualViewport.offsetTop )) + 'px'; styles.width = visualViewport.width + 'px'; styles.height = visualViewport.height + 'px'; } else {// Styles. top = '0' in case VisualViewport API is not supported (iOS 8~12); styles.left = '0'; styles.width = windowInnerWidth + 'px'; styles.height = windowInnerHeight + 'px'; } return styles; }Copy the code

Effect and Summary

With this implementation, our component can correctly locate the current visible viewport (the indigo blue area in the figure above) in the supported browsers and position the internal elements against the visible viewport. For mobile Web applications, such components have many uses, such as toolbars or auto-complete lists that attach to the keyboard, dialogs that need to avoid the keyboard center, and so on. It’s worth noting that the API also works on PC browsers (responding to page enlargements).

On iOS, this implementation has some obtuse and minor bugs (for example, when the keyboard is forced to scroll up after being expanded, it reveals a white substrate area that is inaccessible to either Viewport or VisualViewport).

But at least four years after the release of iOS 8.2, iOS 13’s support for VisualViewport has finally made it a relatively elegant way to get keyboard height, avoid it, and absorb it.


AlloyTeam welcomes excellent friends to join. Resume submission: [email protected] For details, please click Tencent AlloyTeam to recruit Web front-end engineers.