Orientation change in IOS with on-screen keyboard showing.

Has anyone had any trouble keeping a full-screen layout properly positioned and scaled on IOS when showing an on-screen keyboard?

The past few days I've been struggling a bit and I thought I'd share some of my findings with the rest of you, see if I missed anything or if it helps someone else:

The original setup
My app is designed to scale such that it fits within window.innerWidth and window.innerHeight. This by itself is a bit tricky on IOS7 because the browser either places part of the html tag underneath the browser's tab bar, or cuts off a small peace of the bottom of the html tag at the bottom of the screen (the 20px bug some of you may be familiar with).

To stay within the window's inner bounds, I use the following styles:
html, body {
    position: relative;
    display: block;
    width: 100%;
    height: 100% !important; // For ipad on IOS7
    top: 0; // For ipad on IOS7
    left: 0;
    margin: 0;
    padding: 0;
}
body,
.enyo-body-fit {
    position: fixed; // For ipad on IOS7
}
I also did not need for the user to be able to zoom the webpage, so I started out with the following viewport metatag:
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
Next up, my main app has a layout, containing a fixed Header and Footer, and a content area fitted in between:
enyo.kind({
    kind: "MainLayout",
    layoutKind: "FittableRowsLayout",
    components: [{
        name: "header",
        kind: "Header"
    }, {
        name: "content-container",
        fit: true,
        components: [{
            kind: "MyContent"
        }]
    }, {
        name: "footer",
        kind: "Footer"
    }]
});

Comments

  • edited January 2015
    The problem case
    Imagine in the above layout, the "MyComponent" kind ends up being a page with a form on it. The user taps an input to focus it. The following happens:

    - The input receives focus
    - The on-screen keyboard opens
    - The browser scrolls the focused input into view

    Note how, despite the fact that the visible space on the screen has changed, no resize event is generated by the browser.

    Next up, rotate the ipad so it's orientation changes while the keyboard is still open. The following happens:

    - The browser's chrome rotates
    - A resize event is triggered during which the window, html tag, and body tag receive dimensions as if the iPad did not rotate at all (the old dimensions).
    - A second resize event is triggered during which the window, html tag, and body tag receive new dimensions.
    - The layout reflows.

    The problem is, the new dimensions are wrong, and the content of the browser window is offset to the right of the screen, to wards the bottom of the screen, too narrow, or offset both to the bottom and to the right. The exact flaws depend if you move from portrait to landscape, the other way round, and if you rotate between orientations more than once.

    After some debugging I found the following:
    - The body tag reports the correct pixel height.
    - The html tag reports the correct pixel width.
    - The other dimensions as well as window.innerWidth and window.innerHeight can be wrong.
    - You can find out the exact offset of the html and body tag by checking their bounds using Element.getBoundingClientRect.
  • Attempted fix with on-screen keyboard enabled. (findings, and failures)
    Using the above debugging info, I tried to fix things. With varying success:

    Extracting the correct width and height from the body and html tags and applying them to the layout's node will make the layout render at the correct width, but it will still be offset incorrectly on the screen.

    Using the JavaScript console, you can use left and top css styles to move the body or html tag into a correct position on the screen.

    However, if you try doing this inside a resize event handler, it does not work. Safari apparently notices you're shuffling things around and spontaneously does some re-shuffling of it's own. Despite many attempts, I have not found a way to trick Safari into displaying things properly.
  • edited January 2015
    Current solution: Kill the keyboard
    So, accepting the fact that I could not get things to behave while having a keyboard on the screen, next step was to get rid of the keyboard. What follows is a complicated mess of conditions required to get things to work:

    First off, IOS does not tell you if there is an on-screen keyboard and since it does not trigger any events in response to showing or hiding the keyboard, you cannot reliably predict if there will be an on-screen keyboard. So instead, I went for the next best thing; Detecting when an input element has received focus.
    This can lead to false-positives, but at least it does not lead to false negatives, which is most important in the current scenario.

    The easiest way to find if the user focused an input or textarea is to inspect document.activeElement. However, I found for some reason this approach fails in some edge-cases.

    The more reliable alternative is to abuse focusin and focusout events to keep our own record of focused input elements:
    // Make enyo listen to focusin and focusout events
    enyo.dispatcher.listen(window, "focusin");
    enyo.dispatcher.listen(window, "focusout");
    
    enyo.kind({
        kind: "MainLayout",
    
        ...
    
        handlers: {
            onfocusin: "onfocusinHandler",
            onfocusout: "onfocusoutHandler"
        },
        onfocusinHandler: function(inSender, inEvent) {
            var focusNode = inEvent.srcElement,
                focusTag,
                isInput;
    
            focusTag = focusNode && focusNode.tagName && focusNode.tagName.toLowerCase();
            isInput = focusNode &&
                !focusNode.readOnly &&
                (focusTag === "textarea" ||
                    (focusTag === "input" &&
                        /^(color|email|number|password|search|tel|text|url)$/.test(focusNode.type)
                    )
                );
            this.focusedInput = isInput ? focusNode : undefined;
        },
        onfocusoutHandler: function() {
            this.focusedInput = undefined;
        },
    
        ...
    
    });
  • edited January 2015
    Now that we know when an input-element is focused, we can force it to blur if we detect a resize event that changed the orientation of the device. The idea is to restore the browser to a keyboard-less state and avoid the on-screen keyboard induced rendering bugs all together:
    enyo.kind({
        kind: "MainLayout",
    
        ...
    
        _orientationCache: undefined,
        resizeHandler: enyo.inherit(function(sup) {
            return function() {
                var focusedInput = this.focusedInput,
                    orientation = window.orientation,
                    orientationCache = this._orientationCache;
    
                // IOS does not handle orientation changes while showing on screen
                // keyboard very well.
                if (focusedInput && orientation !== orientationCache) {
                    this._needsReflow = true;
                    focusedInput.blur();
                }
    
                sup.apply(this, arguments);
                this._orientationCache = orientation;
                return false;
            };
        }),
    
        ...
    
    });
  • edited January 2015
    This works, partially, but not entirely. There are still a few strange things going on:
    - The iPad can get confused about the height of the window (I have seen multiple wrong dimensions, some of which don't disappear unless you refresh the browser tab)
    - IOS is still positioning the content badly in some cases, despite the hidden keyboard.

    In order to keep these in check, I added a few extra tweaks. First up the viewport metatag. Adding a setting for the height solves the problem with incorrect window height:
    <meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
    Next up, scrolling the window to the top-left somehow helps IOS orientate itself properly. This solves some of the problems content being positioned with undesired offsets in a variety of scenarios, the current orientation change scenario being one of them.
    enyo.kind({
        kind: "MainLayout",
    
        ...
    
        resizeHandler: enyo.inherit(function(sup) {
            return function() {
                window.scrollTo(0,0);
    
                ...
    
            };
        }),
    
        ...
    
    });
  • edited January 2015
    Sadly, despite collapsing the keyboard (by blurring the focused input) IOS needs a moment to switch back to its normal rendering flow.

    To achieve this, we interrupt the resize event handling and initiate a new resize flow after the input element has blurred. We can use the focusout handler for this.

    Finally, when we do trigger a new resize flow, we need to trigger it within a 0 millisecond timeout, or IOS will still have some troubles getting things scaled and positioned correctly.

    All this leads us to the final setup:

    The css:
    html, body {
        position: relative;
        display: block;
        width: 100%;
        height: 100% !important; // For ipad on IOS7
        top: 0; // For ipad on IOS7
        left: 0;
        margin: 0;
        padding: 0;
    }
    body,
    .enyo-body-fit {
        position: fixed; // For ipad on IOS7
    }
    The viewport metatag:
    <meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
    The Layout:
    
    (function() {
    
        // Make enyo listen to focusin and focusout events
        enyo.dispatcher.listen(window, "focusin");
        enyo.dispatcher.listen(window, "focusout");
    
        enyo.kind({
            kind: "MainLayout",
            layoutKind: "FittableRowsLayout",
            components: [{
                name: "header",
                kind: "Header"
            }, {
                name: "content-container",
                fit: true,
                components: [{
                    kind: "MyContent"
                }]
            }, {
                name: "footer",
                kind: "Footer"
            }],
            handlers: {
                onfocusin: "onfocusinHandler",
                onfocusout: "onfocusoutHandler"
            },
    
            //* Keep track of focused input elements, we need to know about them to
            //* fix orientation change layout bugs on IOS when showing on-screen
            //* keyboard.
            focusedInput: undefined,
            onfocusinHandler: function(inSender, inEvent) {
                var focusNode = inEvent.srcElement,
                    focusTag,
                    isInput;
    
                focusTag = focusNode && focusNode.tagName && focusNode.tagName.toLowerCase();
                isInput = focusNode &&
                    !focusNode.readOnly &&
                    (focusTag === "textarea" ||
                        (focusTag === "input" &&
                            /^(color|email|number|password|search|tel|text|url)$/.test(focusNode.type)
                        )
                    );
                this.focusedInput = isInput ? focusNode : undefined;
            },
            onfocusoutHandler: function() {
                var that = this;
    
                this.focusedInput = undefined;
    
                // Reflow if needed
                if (this._needsReflow) {
                    // Delay needed for IOS
                    setTimeout(function() {
                        that.resized();
                    }, 0);
                }
            },
    
            //* Reflow management
            _needsReflow: false,
            _orientationCache: undefined,
            resizeHandler: enyo.inherit(function(sup) {
                return function() {
                    var focusedInput = this.focusedInput,
                        orientation = window.orientation,
                        orientationCache = this._orientationCache;
    
                    // Helps IOS find it's bearings.
                    window.scrollTo(0,0);
    
                    // IOS does not handle orientation changes while showing on screen
                    // keyboard well.
                    if (focusedInput && orientation !== orientationCache) {
                        this._needsReflow = true;
                        focusedInput.blur();
                        return true;
                    }
    
                    // Normal resize handling
                    sup.apply(this, arguments);
    
                    // Update orientationCache
                    this._orientationCache = orientation;
    
                    // Propagate
                    return false;
                };
            }),
        });
    })(enyo);
  • So far, I have tested this on an iPad in IOS7. I still have to do regression tests to see if nothing breaks on any other platforms and I still have to test if things work better or differntly on IOS8.
Sign In or Register to comment.