27

This:

$('body').on('touchmove', function(e) { e.preventDefault(); });

Works, but will disable scrolling throughout the whole page, which is far from ideal.

This:

$('*').on('touchstart', function(e){
    var element = $(this).get(0);

    if ( element.scrollTop <= 0 )                                           element.scrollTop = 1;
    if ( element.scrollTop + element.offsetHeight >= element.scrollHeight ) element.scrollTop = element.scrollHeight - element.offsetHeight - 1;
});

Works on pages that have a scrolling area. However when there is nothing to scroll it will again show the rubber-band.

So my question:

How can you disable the rubber band effect and still keep the -webkit-overflow-scrolling areas scrollable?

[Update]

Best Solution

Disable scrolling on all non-scrollable elements such as a tab bar or a navigation bar.

anElement.addEventListener('touchmove', function( event ){ event.preventDefault() };

Attach a scroll handler to the scrollable elements such as the main content.

anElement.addEventListener('touchstart', function( event ){
        if( this.scrollTop === 0 ) {
            this.scrollTop += 1;
        } else if( this.scrollTop + this.offsetHeight >= this.scrollHeight ) {
            this.scrollTop -= 1;
        }
}
Mark
  • 16,906
  • 20
  • 84
  • 117

9 Answers9

31

Ran into the same issue recently with a SPA where the <body> rubber-banding was detracting from the experience, but I needed scrolling in sub-areas. Many thanks to dSquared's suggestions, as Method 1 worked best for me. Here is my small expansion of his suggestion that I implemented in a project for work that looks all the way up the tree to find any elements (not just divs) that have a .scroll class on it:

// Prevent rubber-banding of the body, but allow for scrolling elements
$('body').on('touchmove', function (e) {
    var searchTerms = '.scroll, .scroll-y, .scroll-x',
        $target = $(e.target),
        parents = $target.parents(searchTerms);

    if (parents.length || $target.hasClass(searchTerms)) {
        // ignore as we want the scroll to happen
        // (This is where we may need to check if at limit)
    } else {
        e.preventDefault();
    }
});

And here is what the CSS looks like:

body {
    height: 100%;
    overflow: hidden;
}
.scroll, .scroll-y, .scroll-x {
    -webkit-overflow-scrolling: touch;
}
.scroll > *, .scroll-y > *, .scroll-x > * {
    -webkit-transform : translateZ(0);
}
.scroll { overflow: auto; }
.scroll-y { overflow-y: auto; }
.scroll-x { overflow-x: auto; }

You only need one library (jQuery or Zepto) and you get native scrolling with momentum and no rubber-banding on the body. Also, I've added the translateZ to fix some issues I've had with elements disappearing during scrolling and it can be used to GPU accelerate your elements.

BUT (and this is a big but), as dSquared points out, the whole page rubber-bands when the scroll element is at its limit and attempted to scroll further. Personally, I consider this a failure so I'm continuing to work on it, just wanted to pitch in on trying to figure this out. Adding a check along the lines of the OP's code might be the answer, but I haven't tried it.

UPDATE (10/7/12):

After lots of work, I've gotten the following code working perfectly in iOS6 (haven't tested in anything else). No rubber-banding on the body, no more issues when at the limit of the scroll area, and it has native scrolling performance throughout. It's obviously a lot more code that originally, but I think this will give the behavior closest to the OP's goals.

(function registerScrolling($) {
    var prevTouchPosition = {},
        scrollYClass = 'scroll-y',
        scrollXClass = 'scroll-x',
        searchTerms = '.' + scrollYClass + ', .' + scrollXClass;

    $('body').on('touchstart', function (e) {
        var $scroll = $(e.target).closest(searchTerms),
            targetTouch = e.originalEvent.targetTouches[0];

        // Store previous touch position if within a scroll element
        prevTouchPosition = $scroll.length ? { x: targetTouch.pageX, y: targetTouch.pageY } : {};
    });

$('body').on('touchmove', function (e) {
    var $scroll = $(e.target).closest(searchTerms),
        targetTouch = e.originalEvent.targetTouches[0];

    if (prevTouchPosition && $scroll.length) {
        // Set move helper and update previous touch position
        var move = {
            x: targetTouch.pageX - prevTouchPosition.x,
            y: targetTouch.pageY - prevTouchPosition.y
        };
        prevTouchPosition = { x: targetTouch.pageX, y: targetTouch.pageY };

        // Check for scroll-y or scroll-x classes
        if ($scroll.hasClass(scrollYClass)) {
            var scrollHeight = $scroll[0].scrollHeight,
                outerHeight = $scroll.outerHeight(),

                atUpperLimit = ($scroll.scrollTop() === 0),
                atLowerLimit = (scrollHeight - $scroll.scrollTop() === outerHeight);

            if (scrollHeight > outerHeight) {
                // If at either limit move 1px away to allow normal scroll behavior on future moves,
                // but stop propagation on this move to remove limit behavior bubbling up to body
                if (move.y > 0 && atUpperLimit) {
                    $scroll.scrollTop(1);
                    e.stopPropagation();
                } else if (move.y < 0 && atLowerLimit) {
                    $scroll.scrollTop($scroll.scrollTop() - 1);
                    e.stopPropagation();
                }

                // If only moving right or left, prevent bad scroll.
                if(Math.abs(move.x) > 0 && Math.abs(move.y) < 3){
                  e.preventDefault()
                }

                // Normal scrolling behavior passes through
            } else {
                // No scrolling / adjustment when there is nothing to scroll
                e.preventDefault();
            }
        } else if ($scroll.hasClass(scrollXClass)) {
            var scrollWidth = $scroll[0].scrollWidth,
                outerWidth = $scroll.outerWidth(),

                atLeftLimit = $scroll.scrollLeft() === 0,
                atRightLimit = scrollWidth - $scroll.scrollLeft() === outerWidth;

            if (scrollWidth > outerWidth) {
                if (move.x > 0 && atLeftLimit) {
                    $scroll.scrollLeft(1);
                    e.stopPropagation();
                } else if (move.x < 0 && atRightLimit) {
                    $scroll.scrollLeft($scroll.scrollLeft() - 1);
                    e.stopPropagation();
                }
                // If only moving up or down, prevent bad scroll.
                if(Math.abs(move.y) > 0 && Math.abs(move.x) < 3){
                  e.preventDefault();
                }

                // Normal scrolling behavior passes through
            } else {
                // No scrolling / adjustment when there is nothing to scroll
                e.preventDefault();
            }
        }
    } else {
        // Prevent scrolling on non-scrolling elements
        e.preventDefault();
    }
});
})(jQuery);
Anthony Graglia
  • 5,355
  • 5
  • 46
  • 75
Tim Hall
  • 1,475
  • 14
  • 16
  • 1
    I probably spent about two months on this problem but went a totally bad route. YTMND. – RandallB Dec 12 '12 at 17:47
  • Wait! I spoke too soon. If you try to drag your finger upward on a scroll-x element, it'll still rubberband sadly. :( – RandallB Dec 12 '12 at 18:23
  • I'm using an ipad (not version 6) and I put the scroll-y class on a dialog. It prevents it from rubber banding up, but not from rubber banding down. Know hwo to fix this? – Daniel Kaplan Sep 27 '13 at 04:42
  • While this seems to improve the issue, I can still force the body to rubber band without too much effort by pulling on sub-areas. – hisnameisjimmy Oct 02 '13 at 18:48
  • This is absolutely superb. I was using https://github.com/pinadesign/overscroll, but one problem with that is you cannot start a touch scroll from a textarea, so a form with large textareas it becomes unusable. Thank you all. – Adam Marshall Jan 07 '14 at 12:01
  • Hi, I'm struggling to get this working, I'm missing something but not sure what? I've copied the css part in, added the the class name scroll-y to the div i want to scroll and added the script to the bottom of my footer, it stops the rubber band effect but does not allow me to scroll? any ideas what i'm doing wrong? – Dan Jan 23 '14 at 09:25
  • I did this in IOS7, it's all working except that I'm experiencing some jittering at the limits, but only on the first time. Eg. 1. Scroll down - goes past limits 2. Scroll down - stays within limits 3. Scroll up - goes past limits 4. Scroll down - goes past limits 5. Scroll down - goes past limits 6. Scroll down - stays withing limits 7. Scroll down - stays within limits. Any suggestions? – Phedg1 Jun 18 '14 at 01:20
  • This seems to kill select2 overflowing select boxes if the page itself does not overflow... probably easily amended though! – Adam Marshall Apr 13 '15 at 14:01
  • unfortunately, this does not disable rubber banding while also allowing the body to be scrollable. If that solution is important to you, check the library free solution I posted below. – smallscript Oct 18 '22 at 21:43
8

Unfortunately there isn't a 'magic bullet' fix for this as the rubber-band scrolling on Mobile Safari is an inbuilt 'feature' of the browser itself. By using any default scrolling mechanism provided by the browser, you will end up with the rubber-band scrolling to some degree.

There are two ways I would suggest to tackle this:

Method 1

Bind to the touchmove event on the </body> element and check the target of the touchmove event to see if you want it to fire or not like so:

HTML

<div class="scroll">
    <p>...</p>
    <p>...</p>
</div>

JS

$('body').on('touchmove', function(e) {
    // this is the node the touchmove event fired on
    // in this example it would be the </p> element
    target = e.target;

    // we need to find the parent container
    // we get it like so; assumes div as parent
    parent = $(e.target).closest('div');

    // check if the parent is a scroll window by class //
    if ($(parent).hasClass('scroll')){
        // ignore as we want the scroll to happen
    } else {
        e.preventDefault();
    }
});

JSFiddle Example Here

This method uses the default scrolling of the browser, however it has the drawback that you will still have the rubber-band scrolling when at the top or bottom of the scroll </div>.

Method 2

Bind to the touchmove event of the </body> element as before, however in this case we prevent All touchmove events and rely on the excellent iScroll 4 plugin to handle the scrolling, like so:

HTML

<div id="wrapper">
    <div id="scroller">
        <p>...</p>
        <p>...</p>
    </div>
</div>

JS

$(document).ready(function(){
    // prevent all scroll //
    $('body').on('touchmove', function(e) {
        e.preventDefault();
    });

    // apply iscroll to scrolling element
    // requires use of id
    var newscroll = new iScroll('wrapper');
});​​

JSFiddle Example Here

This is my preferred method as it blocks all rubber-band scrolling and provides a nice scrolling area, however it relies on the use of a plugin.

I hope this helps

dSquared
  • 9,725
  • 5
  • 38
  • 54
3

Did anyone ever consider just using position fixed on the body? That is a nice, simple and native solution. No Javascript needed.

body{
    position: fixed;
}
MaZoli
  • 1,388
  • 1
  • 11
  • 17
  • I think this wouldn't work in all situations, but since I'm using React and I can't access the DOM as easily, this was exactly what I needed. – yaakov Sep 23 '20 at 15:21
  • It works in most Situations. There is a short input lag if you drag "through" the position fixed element. But you should be fine. – MaZoli Sep 24 '20 at 08:26
2

Here's a solution that uses jQuery and Hammer.js (jquery-implementation). That's two libraries, but if you're working on mobile, chances are you'll want to include Hammer anyway.

For every drag-event that bubbles to the top (so non-scrolling drag-interactions can use stopPropagation) the handler checks if it bubbled through any elements with class=scrolling, if so, whether the user is scrolling within the allowed boundaries of that scrollContainer and only then does it permit native scrolling.

$("body").hammer().on('drag swipe', function(e){

    var scrollTarget = $(e.gesture.target).closest(".scrollable");
    if(scrollTarget.length)
    {
        var scrollTopMax = scrollTarget[0].scrollHeight - scrollTarget.outerHeight();
        if(scrollTopMax > 0){
            var scrollTop = scrollTarget.scrollTop();
            if(scrollTop > 0 && scrollTop < scrollTopMax){
                //console.log("scrolling in the middle");
            }
            else if(scrollTop <= 0 && e.gesture.deltaY < 0){
                //console.log("scrolling from top");
            }
            else if(scrollTop >= scrollTopMax && e.gesture.deltaY > 0){
                //console.log("scrolling from bottom");
            }
            else{
                //console.log("trying to scroll out of boundaries");
                e.gesture.preventDefault();
            }
        }
        else{
            //console.log("content to short to scroll");
            e.gesture.preventDefault();
        }
    }
    else{
        //console.log("no containing element with class=scrollable");
        e.gesture.preventDefault();
    }
});

To kill drags via pinch etc.; escape as necessary to allow zooming if your view is user-scalable

$("body").hammer().on('doubletap rotate pinch', function(e){
    e.gesture.preventDefault();
});

Tested on ios7/safari, android4.3/webview and android4.3/firefoxMobile25 and the only solution that didn't break.

Tobl
  • 671
  • 5
  • 17
  • What settings on CSS `touch-action` are needed to make this work? – Aides Jun 01 '16 at 12:37
  • The answer predates touch-action by quite a bit, so auto; but hopefully by now there are better solutions available. – Tobl Jun 09 '16 at 20:25
  • Actually the problem still exists and the solutions are still problematic. Check out [my SO post](http://stackoverflow.com/questions/37586286/prevent-overflow-rubberband-scrolling-on-ios) on some in-depth analysis of the problem and some summarized solution approaches. What finally did the trick in my case was the script [iNoBounce](https://github.com/lazd/iNoBounce). – Aides Jun 10 '16 at 07:28
  • Tbh, this kind of script is what I meant with 'better solutions'. The abstract sounds like it works similar to my solution, but having it as a script gives us easy implementation and centralized bug tracking. The only issue I see is the focus on iOS, is it worthless on Android? – Tobl Jun 10 '16 at 13:16
1

I wrote, in my opinion, the best solution for this problem. It will disable scrolling in general unless the element has y scrolling.

/********************************************************************************
 * Disable rubber band (c)2013 - Mark van Wijnen | www.CrystalMinds.nl
 ********************************************************************************/
$(function(){
    var scrollY = 0;

    $(document).on('touchstart', function( e ){
        scrollY = e.originalEvent.touches.item(0).clientY;
    });

    $(document).on('touchmove', function( e ){
        var scrollPos       = e.target.scrollTop;
        var scrollDelta     = scrollY - e.originalEvent.touches.item(0).clientY;
        var scrollBottom    = scrollPos + $(e.target).height();
        scrollY             = e.originalEvent.touches.item(0).clientY;

        if ( $(e.target).css( 'overflow-y' ) != 'scroll' || ( scrollDelta < 0 && scrollPos == 0 ) || ( scrollDelta > 0 && scrollBottom == e.target.scrollHeight ) ) 
            e.preventDefault();
    });
});
Mark
  • 16,906
  • 20
  • 84
  • 117
  • hi @mark, if you're interested in HTML5 consulting for a iphone web app, would you mind emailing info at panabee dot com? thanks! one suggestion: some may specify "auto" as the value instead of "scroll." – Crashalot Jul 20 '13 at 23:50
  • also is it necessary to update scrollY in touchmove? just comparing against the original tap should indicate direction, right? – Crashalot Jul 20 '13 at 23:51
  • It's been a while since I wrote the code but I am guessing I am updating the scrollY for the scrollDelta property. When you scroll up in a scrollable area while you are already at the top the bouncing will occur as well, and this prevents that. – Mark Jul 23 '13 at 12:53
  • What do you mean by HTML5 consulting? – Mark Jul 23 '13 at 12:54
  • 7
    @Mark Since you so carefully included a copyright notice, can you clarify the license you're releasing this snippet under? – pward123 Dec 21 '13 at 21:56
1

Based on @Mark's answer, we came up with this alternative, which seems to work. Replace .page_list with the class names of scrollable items.

var INITIAL_Y = 0; // Tracks initial Y position, needed to kill Safari bounce effect

function kill_safari_bounce() {
    $( document ).on( 'touchstart', function( e ){
        INITIAL_Y = e.originalEvent.touches[0].clientY;
    });

    $( document ).on( 'touchmove', function( e ) {
        // Get scrollable ancestor if one exists
        var scrollable_ancestor = $( e.target ).closest( '.page_list' )[0];

        // Nothing scrollable? Block move.
        if ( !scrollable_ancestor ) {
            e.preventDefault();
            return;
        }

        // If here, prevent move if at scrollable boundaries.
        var scroll_delta = INITIAL_Y - e.originalEvent.touches[0].clientY;
        var scroll_pos = scrollable_ancestor.scrollTop;         
        var at_bottom = (scroll_pos + $(scrollable_ancestor).height()) == scrollable_ancestor.scrollHeight;

        if ( (scroll_delta < 0 && scroll_pos == 0) ||
             (scroll_delta > 0 && at_bottom) ){
            e.preventDefault();
        }    
    });
}
Crashalot
  • 33,605
  • 61
  • 269
  • 439
  • @Crashalot Can you make a working fiddle, I cannot get this to work, i must be missing something. Thanks –  Apr 29 '15 at 15:48
1

Finally I mixed some methods and these codes are the working version. But you must include the hammer.js

CSS

.scrollable{
    overflow:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch;
    *{-webkit-transform:translate3d(0,0,0);}
}

JAVASCRIPT

$(document).on("touchmove",function(e){
    e.preventDefault();
});
$("body").on("touchstart",".scrollable",function(e){
    if(e.currentTarget.scrollTop===0){
        e.currentTarget.scrollTop = 1;
    }else if(e.currentTarget.scrollHeight === e.currentTarget.scrollTop + e.currentTarget.offsetHeight){
        e.currentTarget.scrollTop-=1;
    }
});
$("body").on("touchmove",".scrollable",function(e){
    e.stopPropagation();
});

$("body").hammer().on("pinch",function(e){
    e.gesture.preventDefault();
});
ozgrozer
  • 1,824
  • 1
  • 23
  • 35
0

Having tried the above techniques and other variants discussed on the web, I opted for the implementation I wrote and tested below.

I hope that the code is of use to the community at large. The iOS rubber-band scroll-behavior is, still as of 2022, a serious PITA for building PWAs and other types of web-content on iOS devices.

aloha,
dsim
p.s., In my testing I wrote the root as a <pg-shell id=root> custom-element to encapsulate the functionality to further simplify using it.


The above techniques minimize rubber-banding. However, when you account for scroll-direction you enable scrolling within sub-elements or scrolling up within root. The use of scroll event prevents a momentum scroll-up from causing rubber-band bouncing.

To achieve this you need to use a root-scroll-interception element within body like

<body><main id=root>..</main></body>

Then the following will prevent rubber banding while allowing scrolling in other directions including <main> itself when desired.

The code below has no dependencies on any libraries


function disableIosRubberBand() {
  let start, once = false; /* debounce unnecessary preventDefault intercepts */
  // Stop momentum underscroll (requires scroll-root other than body, like main)
  let root = document.getElementById('root') || document.body;
  root.addEventListener('scroll', e => {
      if(e.currentTarget?.scrollTop < 0)
        event.currentTarget.scrollTop = 0;
    },
    {passive: false, useCapture: true}
  );
  // Detect touch start to determine scroll-direction later in `move` and to do
  // nothing if we actually have Y down-scrollable target (scrollTop > 0).
  root.addEventListener('touchstart',
    e => {
      once = false;
      for(let t = event.target;;t = t.parentElement) {
        if(t.scrollTop > 0)
          return;
        else if(t == root)
          break;
      }
      once = true; start = event.touches[0];
    },
    {passive: false, useCapture: false}
  );
  // Determine scroll-gesture angle; verify not Y-down-scrollable; debounce `once`.
  root.addEventListener('touchmove',
    e => {
      if(!once) return;
      const move = event.touches[0];
      let deltaY = start.clientY - move.clientY;
      let deltaX = start.clientX - move.clientX;
      let angle = (Math.atan2(deltaY,deltaX)
        * 180 / Math.PI); if(angle < 0) angle += 360;
      if(angle > (270-45) && angle < (270+45)) {
        if(e?.currentTarget?.scrollTop <= 0) {
          e.currentTarget.scrollTop = 0;
          e.preventDefault();
        }
      }
      once = false;
    },
    {passive: false, useCapture: false}
  );
};disableIosRubberBand();
smallscript
  • 643
  • 7
  • 13
-1

Just add a -webkit-overflow-scrolling: auto; to the div you want to prevent from bouncing