AMP, iOS, Scrolling and Position Fixed

New Solution

AMP has switched to a new approach to implement scrollable iframes. It’s described in detail in AMP, iOS, Scrolling and Position Fixed Redo — the wrapper approach.

Intro

Our goal for AMP is to ensure that documents are embeddable in variety of the environments. Whether the document is viewed standalone or in a WebView or in a iframe — it should be fully functional and the behavior should be, as much as possible, the same. We started with a simple use case where an AMP document is embedded into a Web App via an iframe. It doesn’t sound too “strange”. But in all fairness the iframes haven’t been used for this purpose a lot recently.

So, the structure looks like this:

<html>
<head>
<title>I’m a Web App and I show AMP documents</title>
<style>
iframe {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
</style>
</head>
<body>
<iframe … width="100%" height="100%"
scrolling="yes"
src="https://cdn.ampproject.org/c/pub1.com/doc1"></iframe>
</body>
</html>

This generally works well on mobile devices. An alternative approach we tried was to resize the iframe to the full document height and use static positioning and thus delegate the scrolling to the top window. However, we discarded this approach for a couple of reasons:

  1. To preserve `position:fixed` within the embedded AMP documents, which doesn’t make sense when viewport height equals to document height.
  2. Calculating document height is error-prone, flaky and has latency.

And, of course, we got few tradeoffs as the result. Mainly the fact that with `scrolling=yes` iframe we are losing some mobile features, such as hiding of address bar on scroll. However, we decided this was a good tradeoff. Additionally, some browsers are currently working to extend these features to non-body-scrollable use cases as well.

And here we ran into iOS.

Problem 1: iOS does not support `scrollable=yes` for iframes

Bug: https://bugs.webkit.org/show_bug.cgi?id=149264

Simply put: you can’t have scrollable iframes on iOS. We, however, found a way to work around this. See ViewportBindingNaturalIosEmbed_. In short, we scroll the actual `BODY` element of the document. Thus, even though the iframe itself does not scroll, the content of the iframe does.

The resulting AMP document looks like this:

<html AMP
style="overflow-y: auto; -webkit-overflow-scrolling: touch;">
<head></head>
<body
style="
overflow-y: auto;
-webkit-overflow-scrolling: touch;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
">
</body>
</html>

And this works very well, but…

Problem 2: Now scrollTop, scrollLeft, scrollHeight, scrollWidth don’t work

Bug: https://bugs.webkit.org/show_bug.cgi?id=106133

This is a long-standing WebKit issue. scrollTop and others were assigned to `document.body` but actually delegated to `document.documentElement`. Eventually this will be resolved when `scrollingElement` will be implemented everywhere. And hopefully it won’t conflict with the solution described for Problem 1. However, in the meantime, `scrollTop` is always zero for our setup and so are all related values such as `window.pageYOffset`.

The solution is to add a scrolling-position element that’s positioned at the top of the document. Its `getBoundingClientRect().top` can be used to recalculate the scroll position of the document.

The resulting markup looks like this:

<html AMP
style="overflow-y: auto; -webkit-overflow-scrolling: touch;">
<head></head>
<body
style="
overflow-y: auto;
-webkit-overflow-scrolling: touch;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
">
<div id="scroll-pos"
style="
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
visibility: hidden;
"></div>
</body>
</html>

And JavaScript code looks like this:

function getScrollTop() {
// Take a minus of the scrollPos.top value because the scroll
// position is measured when scrollPos element is scrolled up,
// outside of viewport, into negative `top` space.
return -scrollPos.getBoundingClientRect().top;
}

So, this is ugly, but it works. A similar approach can be used for `scrollLeft`, `scrollHeight` and all the rest.

And then, this…

Problem 3: `Position:fixed` is very buggy in the `overflow:auto` container

Bug: https://bugs.webkit.org/show_bug.cgi?id=154399

If markup contains an element with `position:fixed` inside the the container with `overflow:auto`, it’s behavior is very broken: the `position:fixed` element jumps and flickers during scrolling. It looks like the `position:fixed` is slightly scrolled and then quickly jumps back into its correct place. The effect is very bad. See this video for demo.

Oops. All these hacks and workarounds, and now this. How do we fix this? Here’s a crazy idea that seems to work. We add a fictitious element to the `document.documentElement`, NOT `body`, so it’s a sibling of the `body`. We call it the “fixed layer”. It’s positioned to take the whole viewport. We will use CSS definitions to find all elements that might be `fixed` (hopefully there won’t be too many of these) and if at some point they are indeed `fixed`, we will move them to “fixed layer” with the right `z-index`.

The markup looks like this:

<html AMP
style="overflow-y: auto; -webkit-overflow-scrolling: touch;">
<head>
<style>
#fixed-element {
position: fixed;
right: 20px;
top: 20px;
}
</style>
</head>
<body
style="
overflow-y: auto;
-webkit-overflow-scrolling: touch;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
">
<div id="fixed-element">
</div>
</body>
<div id="fixed-layer"
style="
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
pointer-events: none;
">
</div>
</html>

After we determine that `fixed-element` is indeed “fixed”, we move it to `fixed-layer` and it looks like this:

<div id="fixed-layer"
style="
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
">
<div id="fixed-element"
style="pointer-events: initial; z-index: 11;">
</div>
</div>

Thus we can move `fixed-element` between its original position inside `body` and `fixed-element` depending on whether it’s fixed or not.

Any drawbacks? Definitely:

  1. It’s crazy and scary!
  2. Calculating `z-index` could be rather painful.
  3. This way we lose some of the ancestor CSS selectors.

But it works. See implementation in PR #2128. Any other ideas?