12

Under Mobile Safari, is it possible to allow one absolutely positioned div to scroll without allowing the entire page to bob up and down when it the scroll reaches the edges (elastically scrolling)?

Here is a minimal working example of the issue I'm facing:

<!doctype html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        #a, #b {
            position: absolute;
            top: 0;
            left: 0;
            height: 100%;
            padding: 10px;
            overflow: auto;
        }
        #a {
            width: 80px;
            background: #f00;
        }
        #b {
            background: #00f;
            left: 80px;
            width: 100%;
        }
    </style>
    <script src="http://code.jquery.com/jquery-1.10.1.min.js"></script>
    <script>
        function pdcb(e) {
            e.preventDefault();
        }
        function npcb(e) {
            e.stopPropagation();
        }
        $(document).on('touchstart touchmove', pdcb).
                    on('touchstart touchmove', '.scrollable', npcb);
    </script>
</head>
<body>
    <div id="a" class="scrollable">
        This<br>
        should<br>
        be<br>
        scrollable<br>
        but<br>
        not<br>
        scroll<br>
        the<br>
        whole<br>
        page<br>
        This<br>
        should<br>
        be<br>
        scrollable<br>
        but<br>
        not<br>
        scroll<br>
        the<br>
        whole<br>
        page<br>
        This<br>
        should<br>
        be<br>
        scrollable<br>
        but<br>
        not<br>
        scroll<br>
        the<br>
        whole<br>
        page<br>
        This<br>
        should<br>
        be<br>
        scrollable<br>
        but<br>
        not<br>
        scroll<br>
        the<br>
        whole<br>
        page<br>
        This<br>
        should<br>
        be<br>
        scrollable<br>
        but<br>
        not<br>
        scroll<br>
        the<br>
        whole<br>
        page<br>
    </div>
    <div id="b">
        this should never scroll
    </div>
</body>
</html>

Solution:

$(document).on('touchmove', function(e) {
    e.preventDefault();
}).ready(function() {
    $(".scrollable").on('touchstart', function(e) {
        this.allowUp = (this.scrollTop > 0);
        this.allowDown = (this.scrollTop < this.scrollHeight - this.clientHeight);
        this.prevTop = null;
        this.prevBot = null;
        this.lastY = e.originalEvent.pageY;
    }).on('touchmove', function(e) {
        var event = e.originalEvent;
        var up = (event.pageY > this.lastY), down = !up;
        this.lastY = event.pageY;

        if ((up && this.allowUp) || (down && this.allowDown))
            event.stopPropagation();
        else
            event.preventDefault();
    });
});
Aaron Yodaiken
  • 19,163
  • 32
  • 103
  • 184
  • 1
    no, not a duplicate. having two `div`s changes things. – Aaron Yodaiken Jun 03 '13 at 02:56
  • 2
    People marking this as a duplicate - This is not a duplicate question - the question referenced only deals with preventing native momentum scrolling in iOS completely - this question/solution is in regards to enabling momentum scrolling on specific divs while blocking scrolling on the entire page. Useful for web apps with many fixed position elements. – 1nfiniti Jun 03 '13 at 13:34
  • If you want this to work with dynamically added elements (like in a single page JS app), replace `$(".scrollable").on('touchstart', ...)` for `$(document.body).on('touchstart', '.scrollable', ...)`. – micho Feb 26 '14 at 11:42
  • This code will block scrolling up when you are already at the top of the div. If you use this for an iOS app, it might feel broken. A workaround for this is scrolling down 1px the scrollable element, so scrolling up is possible with the bounce effect: `$('.scrollable')[0].scrollTop = 1`. You could also handle the end scrolling event and set scrollTop to 1 whenever it reaches 0, so it's never blocked from scrolling up. – micho Feb 26 '14 at 11:45
  • Make sure you add `-webkit-overflow-scrolling: touch` to the CSS declaration of your `.scrollable` class to get the nice native scroll. – micho Feb 26 '14 at 11:46

3 Answers3

18

The original answers are fantastic, but have a few flaws that I resolved:

  • If an element is at the top or the bottom, it won't scroll up and down respectively.
  • If an element is added dynamically, it won't have the scroll handlers.
  • There were unused variables (prevTop, prevBot)

My answer addresses those. (Notice that I use .scroll-y, instead of .scrollable)

First, add these CSS rules:

.scroll-y {
  overflow-y: auto;
  overflow-x: hidden;
  -webkit-overflow-scrolling: touch; /* nice webkit native scroll */
}

Add the .scroll-y class to any elements you want to make scroll.

Then, add this JS somewhere:

// Disable scroll for the document, we'll handle it ourselves
$(document).on('touchmove', function(e) {
  e.preventDefault();
});

// Check if we should allow scrolling up or down
$(document.body).on("touchstart", ".scroll-y", function (e) {
  // If the element is scrollable (content overflows), then...
  if (this.scrollHeight !== this.clientHeight) {
    // If we're at the top, scroll down one pixel to allow scrolling up
    if (this.scrollTop === 0) {
      this.scrollTop = 1;
    }
    // If we're at the bottom, scroll up one pixel to allow scrolling down
    if (this.scrollTop === this.scrollHeight - this.clientHeight) {
      this.scrollTop = this.scrollHeight - this.clientHeight - 1;
    }
  }
  // Check if we can scroll
  this.allowUp = this.scrollTop > 0;
  this.allowDown = this.scrollTop < (this.scrollHeight - this.clientHeight);
  this.lastY = e.originalEvent.pageY;
});

$(document.body).on('touchmove', ".scroll-y", function(e) {
  var event = e.originalEvent;
  var up = event.pageY > this.lastY;
  var down = !up;
  this.lastY = event.pageY;

  if ((up && this.allowUp) || (down && this.allowDown)) {
    event.stopPropagation();
  } else {
    event.preventDefault();
  }
});
micho
  • 2,196
  • 2
  • 21
  • 26
  • 1
    I wish I could upvote this a million times more, I've been searching for a working solution to this for days, so thanks! – beyond-code Aug 20 '14 at 10:47
12

While you're not hitting the edges of your div's content, you need to allow the native touchmove event to work on that element (so it can scroll), but you're going to want to stop the event from bubbling up the DOM so that it doesn't trigger scrolling on the page body.

When you hit the boundary of your element, you need to prevent the native momentum scrolling entirely.

The code I use for this is as follows (apologies to the original author, this is adapted from a tutorial on this topic I found somewhere on the internet in the past... Can't seem to find the URL now though):

where elem is your DOM node

elem.addEventListener('touchstart', function(event){
    this.allowUp = (this.scrollTop > 0);
    this.allowDown = (this.scrollTop < this.scrollHeight - this.clientHeight);
    this.prevTop = null; this.prevBot = null;
    this.lastY = event.pageY;
});

elem.addEventListener('touchmove', function(event){
    var up = (event.pageY > this.lastY), down = !up;
    this.lastY = event.pageY;

    if ((up && this.allowUp) || (down && this.allowDown)) event.stopPropagation();
    else event.preventDefault();
});

I usually define an array of elements and loop through them - applying this code to each one iteratively.

Best of luck, hope this helps.

1nfiniti
  • 2,032
  • 14
  • 19
  • I'm also assuming your .scrollable class uses `-webkit-overflow-scrolling: touch` ? If not, it should :) – 1nfiniti Jun 03 '13 at 03:38
  • 1
    For whoever is confused by the format: the original poster added a "Solution" part which takes this code and implements it. It works well for me. – micho Dec 30 '13 at 09:28
  • If you're using this for a dynamic app, remember to re-bind the event listeners to the new elements when they are replaced! I replaced `elem.addEventListener` for jQuery's `$(document.body).on("touchstart", ".scrollable", function () {...});` – micho Feb 09 '14 at 20:17
  • @micho thank you very much, your comment is really helpful! – godblessstrawberry Apr 18 '16 at 09:26
0

The note provided by Aaron Grey helped!

See link: http://blog.christoffer.me/six-things-i-learnt-about-ios-safaris-rubber-band-scrolling/

<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="minimum-scale=1.0, width=device-width, maximum-scale=1.0, user-scalable=no, initial-scale=1">
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <style>

        .page{
            font-size: 24px;
            overflow: scroll;
        }

        .menu{
            position: fixed;
            top: 0;
            bottom: 0;
            left: 0;
            width: 80%;
            background: gray;
            z-index: 1;
            font-size: 10px;
            overflow: scroll;
            /* uncomment to get smooth momentum scroll, but also a rubber band effect */
            /*-webkit-overflow-scrolling: touch;*/
        }

        .menu-item{
            padding: 10px;
            background: darkgray;
            font-size: 24px;
        }

    </style>
</head>

<body>

<div class="menu scrollable">
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
    <div class="menu-item">hello world</div>
</div>

<div class="page disable-scrolling">
    Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's
    standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make
    a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting,
    remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing
    Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions
    of Lorem Ipsum.
</div>

<script>


    document.ontouchmove = function ( event ) {

        var isTouchMoveAllowed = true, target = event.target;

        while ( target !== null ) {
            if ( target.classList && target.classList.contains( 'disable-scrolling' ) ) {
                isTouchMoveAllowed = false;
                break;
            }
            target = target.parentNode;
        }

        if ( !isTouchMoveAllowed ) {
            event.preventDefault();
        }

    };

    function removeIOSRubberEffect( element ) {

        element.addEventListener( "touchstart", function () {

            var top = element.scrollTop, totalScroll = element.scrollHeight, currentScroll = top + element.offsetHeight;

            if ( top === 0 ) {
                element.scrollTop = 1;
            } else if ( currentScroll === totalScroll ) {
                element.scrollTop = top - 1;
            }

        } );

    }

    removeIOSRubberEffect( document.querySelector( ".scrollable" ) );


</script>

</body>
</html>
Repressed Radar
  • 144
  • 1
  • 7