iOS mobile scroll in Web + React

Esaú Suárez Ramos
Turo Engineering
Published in
6 min readDec 5, 2018
Floating panel without background scroll

Every developer who’s ever had to deal with scrollable floating elements in iOS will agree with the following fact: there is a direct relationship between iOS scrolling and headaches.

As the title states, this isn’t limited to Safari mobile: the scope of this article covers all iOS mobile devices and their browsers.

There are some very descriptive posts outside there already (see resources at the end of the article). Their main goal is to offer a way to overcome iOS behavior when handling nested scrolling.

Unfortunately, to date, there is no perfect way around it. The goal of this post is to show the evolution of the solution we ended up building at Turo.

State of the art

  • Level 1: prevent background scroll in almost every browser
  • Level 2: enable iOS momentum scroll in nested containers
  • Level 3: prevent background scroll in iOS mobile

Our solution

  • Level 4: enable momentum scroll without scrolling the background
  • Level 5: centralize scroll management: IosMobileScrollContext

The Limitation

  • Level 6: iOS Safari restricted browser area

Getting to business

Level 1: prevent background scroll in almost every browser

The two most popular ways to achieve this are clean and straightforward CSS approaches.

#1

.is-scrollLocked {   overflow: hidden;}

#2

.is-scrollLocked {   position: fixed;   top: 0;   left: 0;   right: 0;   bottom: 0;}

Then, we need to add this class to the <body> element, using means such as vanilla JS: document.body.classList.add('is-scrollLocked').

Level 2: enable iOS momentum scroll in nested containers

This is actually a WebKit feature that can also be achieved using CSS only (Don’t get overly excited, I promise there’s going to be some JS further down the post).

.is-momentumScrollable {   -webkit-overflow-scrolling: touch;}

This class should now be used in scrollable containers where we’d like to enable momentum scroll (including the document body itself).

document.body.classList.add('is-momentumScrollable')

Level 3: prevent background scroll in iOS mobile

iOS mobile is going to be our special headache when handling scrollable containers. The CSS solution stated in “Level 1” isn’t going to be enough here.

This time, we need to rely on Javascript. Specifically, we need to prevent the touchmove event because otherwise it will bubble up and end up causing a scroll event even if overflow is hidden.

The snippet to handle this scenario is not too complex, we need to add an event listener to window just considering a specific quirk: we have to specify a third parameter to mark this event listener as notpassive . You can read up on why it’s required in this MDN link (or you could also try it and then see the console warning).

const preventDefault = e => e.preventDefault();// When rendering our container
window.addEventListener('touchmove', preventDefault, {
passive: false
});
// Remember to clean up when removing it
window.removeEventListener('touchmove', preventDefault);

Level 4: enable momentum scroll without scrolling the background

I’ll start off by defining what I call the bounce effect: if you’ve ever tried scrolling past the end of a document in iOS touch screens, you may have noticed the full page gets dragged away from the bottom of the screen and then it bounces back to the edge.

The bounce effect happens not only at body level, but also at nested scrollable elements level. There is one side effect to this animation: a bounce triggers a scroll event at the document level, no matter where it was originated, and it can’t be prevented.

The consequence of this behavior is what we call an “undesired” scroll. Think of a scrollable modal on top of a list; scrolling inside the modal ends up scrolling the background list, too.

What’s the solution to this? Should we disable momentum scroll? We definitely want to keep smooth scrolling, but we don’t want users to end up in an unexpected state after closing the modal.

Our solution: prevent the bounce altogether!

How can this be done? Piece of cake: prevent the scroll from reaching the first and the last pixel of the scroll height. This needs to happen before we give touchmove events a change to bubble up, so we need to listen to touchstart. Here’s a snippet showing how to achieve it:

function scrollToPreventBounce(htmlElement) {
const {scrollTop, offsetHeight, scrollHeight} = htmlElement;

// If at top, bump down 1px
if (scrollTop <= 0) {
htmlElement.scrollTo(0, 1);
return;
}

// If at bottom, bump up 1px
if (scrollTop + offsetHeight >= scrollHeight) {
htmlElement.scrollTo(0, scrollHeight - offsetHeight - 1);
}
}
// When rendering the element
function afterRender() {
htmlElement.addEventListener('touchstart', scrollToPreventBounce);
}
// Remember to clean-up when removing it
function beforeRemove() {
htmlElement.removeEventListener('touchstart', scrollToPreventBounce);
}

Level 5: complex layout scroll management: SafariScrollProvider

What happens when you stumble upon a complex layout where some of its pieces should be static, some of them should be scrollable and, worst of all, you need to prevent unwanted background scroll?

At Turo, we use React, so the following step-up is going to rely on the React Context API.

By embracing React context, we can basically create a single component to manage when should we allow scrolling and when should we prevent it.

The idea is to register all scrollable and non-scrollable elements in a single place and bind a single event listener to prevent touchmove (be efficient whenever you can).

Ingredients:

  • IosMobileScrollContext with its <IosMobileScrollContexProvider> and <IosMobileScrollContextConsumer>
// Inside IosMobileScrollProvider// We need to register divs when they are rendered
state = {
nonScrollableDivs: [],
scrollableDivs: [],
};
// We bind the event listener using consolidated-events
componentDidMount() {
this.removeWindowTouchMoveListener = addEventListener(
window,
'touchmove',
this.preventUnwantedScroll,
{
passive: false,
}
);
}
// Make sure you clean up :)
componentWillUnmount() {
this.removeWindowTouchMoveListener();
}
// We check whether we should prevent scroll or allow the div to scroll
preventUnwantedScroll = e => {
const {target} = e;
const isTargetScrollable = this.state.scrollableDivs.some(scrollableDiv =>
scrollableDiv.contains(target)
);
const isTargetScrollBlocked = this.state.nonScrollableDivs.some(nonScrollableDiv =>
nonScrollableDiv.contains(target)
);
if (isTargetScrollBlocked && !isTargetScrollable) {
e.preventDefault();
e.stopPropagation();
return false;
}

return true;
};
  • <NestedDiv>. This component registers a non-scrollable div.
export class NestedDiv extends Component {

elementRef = React.createRef();
// We register this div as non scrollable
componentDidMount() {
this.unregisterNonScrollableDiv = this.props.iosMobileScroll.registerNonScrollableDiv(
this.elementRef.current
);
}
// Clean up
componentWillUnmount() {
this.unregisterNonScrollableDiv();
}

render() {
const {children, iosMobileScroll, ...passThroughProps} = this.props;
return (
<div ref={this.elementRef} {...passThroughProps}>
{children}
</div>
);
}
}

export default withIosMobileScrollContextValues(NestedDiv);
  • <NestedScrollableDiv>. This component registers a scrollable div and prevents the bounce by implementing the “Level 3” handler.
export class NestedScrollableDiv extends Component {

elementRef = React.createRef();
// We register this div as scrollable
componentDidMount() {
this.unregisterScrollableDiv = this.props.iosMobileScroll.registerScrollableDiv(
this.elementRef.current
);
}
// We make sure to clean up
componentWillUnmount() {
this.unregisterScrollableDiv();
}

// We use our handler to prevent the bounce
handleTouchStart = () => {
const el = this.elementRef.current;
if (!el) {
return;
}

scrollToPreventBounce(el);
};

render() {
const {children, ...passThroughProps} = this.props;
return (
<div
ref={this.elementRef}
{...passThroughProps}
onTouchStart={this.handleTouchStart}
{children}
</div>
);
}
}

export default withIosMobileScrollContextValues(NestedScrollableDiv);

The snippets above should give you a broad idea of how to achieve the desired behavior. The provider and scrollable components could check for iOS mobile before binding the events and we should also integrate the generic CSS solutions for the rest of the browsers.

Level 6: iOS Safari restricted browser area

Here comes a fact about Safari mobile: there’s a restricted area.

Restricted area (red).

Interesting discovery, isn’t it? Well, it turns it comes with the following features:

  • It’s a 100% width * 44px height block.
  • It’s anchored at the bottom of the browser.
  • It’s not always visible.
  • It listens to touch events. Tapping in it shows Safari’s bottom navigation bar.
  • Scrolling up on a document shows it.
  • Scrolling down more than a certain amount of pixels hides it.
  • touchmove events on it trigger a document scroll.
  • touchmove events in this area are not controlled by the DOM, but by Safari itself.

Essentially, this means the background will still be scrollable when scrolling too close to the bottom edge of the browser. At the moment, the only workaround for this consists in:

  1. Store the current scroll position when opening a new scrollable element in the foreground.
  2. Whenever a document scroll event happens, compare the background scroll position with the previously stored value.
  3. If the position changed, restore the previous position.

Key takeaway

The most valuable lesson I’ve learned following this process is: it’s important to take into account iOS mobile into your designs (hello again, Internet Explorer), but beware: sometimes you’re going to need to decide and set some limits to your scope. You now have the tools to either compromise or take head-on what you need to implement for your cross-browser scroll experience.

Resources

--

--

Esaú Suárez Ramos
Turo Engineering

Following the JS way since 2014. Frontend Engineer @ Turo