0

On mobile, it's a common UI pattern to have a scrollable element inside a draggable element. When you reach the end of the scrollable element, you start dragging the outer element. E.g. in this GIF (https://media.giphy.com/media/9MJgBkoZfqA7jRdQop/giphy.gif), after scrolling to the top, if you continuing scrolling, it'll drag the subreddits menu.

I want to implement a similar pattern using JS/CSS. To do this, I need to detect if users continue scrolling after reaching the end. Is this possible? If so, is it possible to determine how much they scroll after reaching the end?

Leo Jiang
  • 24,497
  • 49
  • 154
  • 284
  • Separate discussion: it should be possible to achieve this UI pattern using nested scrollable elements, but it'll likely break easily. Has this been done before? – Leo Jiang Oct 05 '20 at 22:12
  • Is this what you are looking for? [https://stackoverflow.com/questions/876115/how-can-i-determine-if-a-div-is-scrolled-to-the-bottom](https://stackoverflow.com/questions/876115/how-can-i-determine-if-a-div-is-scrolled-to-the-bottom) – Combobulated Dec 08 '20 at 21:50
  • No, once you're at the end, I want to know if users continue scrolling – Leo Jiang Dec 08 '20 at 21:50
  • I think that this is what you are looking for: [https://stackoverflow.com/questions/876115/how-can-i-determine-if-a-div-is-scrolled-to-the-bottom](https://stackoverflow.com/questions/876115/how-can-i-determine-if-a-div-is-scrolled-to-the-bottom) – Combobulated Dec 08 '20 at 21:52
  • I don't see how the outer element is draggable. It's just *scrolling* to the top, like the contents below it. It might not have a scrollbar, but still. When it reaches the top, it becomes fixed. – Bergi Dec 08 '20 at 22:05
  • If I get this right, judging by the gif, you don't really need to know how much they scroll after reaching the end. Once they reached the end of scrollable element and keep scrolling - you need to pass the event to parent and invoke drag until user releases the element. And, further more, I wouldn't even bother with the drag (for several reasons) and did animated expand/collapse instead, IMHO – Pavel Dec 08 '20 at 22:48

7 Answers7

2
 window.onscroll = function(element) {
    if ((window.innerHeight + window.pageYOffset) >= document.body.offsetHeight) {
       alert("you're at the bottom of the page");
    }
 };

Using element parameter to know the current exact x y where mouse is now at to calculate more and some how much was scrolled

Javascript: How to detect if browser window is scrolled to bottom?

lissettdm
  • 12,267
  • 1
  • 18
  • 39
Ziv Adler
  • 169
  • 2
  • 11
  • As far as I can tell, the scroll event stops triggering once they reach the end. I want to know whether they continue scrolling after they reach the end – Leo Jiang Dec 15 '20 at 17:56
1

JavaScript:

// get the button
var theBtn = document.getElementById('theBtn');
// get the box
var theBox = document.getElementById('theBox');

// add event to the button on click show/hide(toggle) the box
theBtn.addEventListener('click', () => {
  theBox.classList.toggle('active');
});

// when scrolling on the box
theBox.onscroll = function(){
  // get the top of the div
  var theBoxTop = theBox.scrollTop;
  if(theBoxTop <= 0){
    // when it reaches 0 or less, hide the box. It'll toggle the class, since it's "show" will "hide"
    theBox.classList.toggle('active');
  }
};
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
  font-size: 10px;
  font-family: 'Arial', sans-serif;
  height: 1500px;
}

html {
  scroll-behavior: smooth;
}

ul {
  list-style-type: none;
}

#theBox ul li {
  border: 1px solid;
  height: 100px;
}

#navbar-bottom {
  height: 100px;
  width: 100%;
  background: rgb(90, 111, 143);
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  box-shadow: 0 0 2px 2px rgba(90, 111, 143, 0.562);
  display: flex;
  justify-content: space-around;
  align-items: center;
}

#theBox {
  background-color: red;
  height: 350px;
  width: 100%;
  position: fixed;
  bottom: 0;
  transform: translateY(100%);
  transition: all 0.3s;
  overflow-y: scroll;
}

#theBox.active{
  transform: translateY(0);
}

.myBtns {
  width: 50px;
  height: 50px;
  border-radius: 50%;
  border: none;
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  cursor: pointer;
}

.myBtns span {
  height: 3px;
  width: 30px;
  background-color: black;
  margin: 3px 0;
}
<main role="main">

    <div id="theBox">
      <ul>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
      </ul>
    </div>

    <div id="navbar-bottom">
      <button class="myBtns"></button>
      <button class="myBtns" id="theBtn">
        <span></span>
        <span></span>
        <span></span>
      </button>
      <button class="myBtns"></button>
    </div>
  </main>

jQuery:

// add event to the button on click show/hide(toggle) the box
$('#theBtn').click(function(){
  $('#theBox').toggleClass('active');
});

// when scrolling on the box
$('#theBox').scroll(function () {
  // get the top of the div
  var theBoxTop = $('#theBox').scrollTop();
  // when it reaches 0 or less, hide the box. It'll toggle the class, since it's "show" will "hide"
  if(theBoxTop <= 0){
    $('#theBox').toggleClass('active');
  }
});
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
  font-size: 10px;
  font-family: 'Arial', sans-serif;
  height: 1500px;
}

html {
  scroll-behavior: smooth;
}

ul {
  list-style-type: none;
}

#theBox ul li {
  border: 1px solid;
  height: 100px;
}

#navbar-bottom {
  height: 100px;
  width: 100%;
  background: rgb(90, 111, 143);
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  box-shadow: 0 0 2px 2px rgba(90, 111, 143, 0.562);
  display: flex;
  justify-content: space-around;
  align-items: center;
}

#theBox {
  background-color: red;
  height: 350px;
  width: 100%;
  position: fixed;
  bottom: 0;
  transform: translateY(100%);
  transition: all 0.3s;
  overflow-y: scroll;
}

#theBox.active{
  transform: translateY(0);
}

.myBtns {
  width: 50px;
  height: 50px;
  border-radius: 50%;
  border: none;
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  cursor: pointer;
}

.myBtns span {
  height: 3px;
  width: 30px;
  background-color: black;
  margin: 3px 0;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<main role="main">

    <div id="theBox">
      <ul>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
        <li><p>Text</p></li>
      </ul>
    </div>

    <div id="navbar-bottom">
      <button class="myBtns"></button>
      <button class="myBtns" id="theBtn">
        <span></span>
        <span></span>
        <span></span>
      </button>
      <button class="myBtns"></button>
    </div>
  </main>
001
  • 2,019
  • 4
  • 22
1
window.onscroll = function(ev) {
    if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) {
       alert("you are at the bottom of the page");
    }
};

Link to demo: http://jsfiddle.net/5xpoe4yg/

Mike Szyndel
  • 10,461
  • 10
  • 47
  • 63
sunil c.j
  • 34
  • 3
  • As far as I can tell, the scroll event stops triggering once they reach the end. I want to know whether they continue scrolling after they reach the end – Leo Jiang Dec 15 '20 at 17:56
1

If You need to keep track of the user activity after the bottom (or the top) of the page has been reached, beside the scroll event, You need to track the the wheel event. Moreover, on mobile, You need to track also touchstart and touchmove events.

Not all these events are normalized across browsers, so I did my own normalization function, which is more or less something like this: var compulsivity = Math.log2(Math.max(scrollAmount, 0.01) * wheelAmount);

Below is a complete playground. You can test it in Chrome using the Mobile View of the Developer Tools, or in other browsers using the TouchEmulator.

function Tracker(page) {
  this.page = page;
  this.moveUp = 0;
  this.moveDown = 0;
  this.startTouches = {};
  this.moveTouches = {};
  this.lastScrollY = 0;
  this.monitor = {};
  this.startThreshold = 160;
  this.moveThreshold = 10;
  this.iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
  this.pullToRefresh = window.chrome || navigator.userAgent.match('CriOS');
  this.amplitude = 16 / Math.log(2);
  this.page.ownerDocument.addEventListener( 'onwheel' in document ? 'wheel' : 'onmousewheel' in document ? 'mousewheel' : 'DOMMouseScroll', this, { passive: true } );
  /* The basic scroll event cannot be canceled, so it does not need to be set passive.*/
  this.page.ownerDocument.addEventListener('scroll', this);
  this.page.addEventListener('touchstart', this, { passive: true });
  /* Maybe we need to cancel pullToRefresh */
  this.page.addEventListener('touchmove', this, { passive: false });
  return this;
}

Tracker.prototype.handleEvent = function (e) { /* handleEvent is built-in */
  var winHeight = (this.iOS ? document.documentElement.clientHeight : window.innerHeight) | 0,
    currScrollY = window.pageYOffset | 0,
    amountScrollY = (this.lastScrollY - currScrollY) | 0,
    elHeight = this.page.offsetHeight | 0,
    elTop = -currScrollY, elBottom = winHeight - elHeight + currScrollY,
    isTop = elTop >= 0, isBottom = elBottom >= 0;

  switch (e.type) {
    case 'wheel':
    case 'onmousewheel':
    case 'mousewheel':
    case 'DOMMouseScroll':
      var wheelDelta = e.wheelDelta ? e.wheelDelta : e.deltaY ? -e.deltaY : -e.detail,
        wheelDir = (wheelDelta > 0) - (wheelDelta < 0),
        wheelUp = wheelDir < 0, wheelDown = wheelDir > 0,
        wheelAmount = 100 * wheelDir;

      if (isTop && wheelDown) {
        this.moveUp++;
        this.moveDown = 0;
      } else if (isBottom && wheelUp) {
        this.moveUp = 0;
        this.moveDown++;
      } else {
        this.moveUp = 0;
        this.moveDown = 0;
      }

      var compulsivity = this.amplitude * Math.log(Math.max(this.moveUp, this.moveDown, 0.01) * wheelAmount* wheelDir);
      this.monitor[e.type].track(wheelAmount, compulsivity);
      break;
    case 'scroll':
      /* end of scroll event for iOS,  start/end of scroll event for other browsers */
      this.lastScrollY = currScrollY;
      this.monitor[e.type].track(amountScrollY, 0);
      break;
    case 'touchstart':
      var touches = [].slice.call(e.touches), i = touches.length;
      while (i--) {
        var touch = touches[i], id = touch.identifier;
        this.startTouches[id] = touch;
        this.moveTouches[id] = touch;
      }
      break;
    case 'touchmove':
      var touches = [].slice.call(e.touches), i = touches.length, 
        currTouches = {},
        swipeUp = false, swipeDown = false,
        currMoveY = 0, totalMoveY = 0;
      while (i--) {
        var touch = touches[i], id = touch.identifier;
        currTouches[id] = touch;
        if (id in this.moveTouches) {
          currMoveY = this.moveTouches[id].screenY - touch.screenY;
        }
        if (id in this.startTouches) {
          totalMoveY = this.startTouches[id].screenY - touch.screenY;
        }
        swipeUp = currMoveY > 0 || totalMoveY > 0;
        swipeDown = currMoveY < 0 || totalMoveY < 0;
        if (this.pullToRefresh && isTop && swipeDown && e.cancelable) {
          e.preventDefault();
          console.log('Reload prevented');
        }
      }
      this.moveTouches = currTouches;
      var moveDir = (totalMoveY > 0) - (totalMoveY < 0),
        longSwipe = moveDir * totalMoveY > this.startThreshold,
        shortSwipe = moveDir * totalMoveY > this.moveThreshold,
        realSwipe = longSwipe || shortSwipe;

      if (isTop && swipeDown) {
        if (realSwipe) this.moveUp++;
        this.moveDown = 0;
      } else if (isBottom && swipeUp) {
        this.moveUp = 0;
        if (realSwipe) this.moveDown++;
      } else {
        this.moveUp = 0;
        this.moveDown = 0;
      }

      var compulsivity =  this.amplitude * Math.log(Math.max(this.moveUp, this.moveDown, 0.01) * moveDir * totalMoveY);
      this.monitor[e.type].track(currMoveY, compulsivity);
      break;
  }
};

function Monitor(events) {
  this.ctx = null;
  this.cont = null;
  this.events = events;
  this.values = [];
  this.average = 0;
  this.lastDrawTime = 0;
  this.inertiaDuration = 200;
  return this;
}

Monitor.prototype.showOn = function (container) {
  var cv = document.createElement('canvas');
  this.ctx = cv.getContext('2d');
  this.cont = document.getElementById(container);
  cv.width = this.cont.offsetWidth;
  cv.height = this.cont.offsetHeight;
  cv.style.top = 0;
  cv.style.left = 0;
  cv.style.zIndex = -1;
  cv.style.position = 'absolute';
  cv.style.backgroundColor = '#000';
  this.cont.appendChild(cv);
  var self = this;
  window.addEventListener('resize', function () {
    var cv = self.ctx.canvas, cont = self.cont;
    cv.width = cont.offsetWidth;
    cv.height = cont.offsetHeight;
  });
  return this;
};

Monitor.prototype.track = function (value, average) {
  this.average = average;
  if (this.values.push(value) > this.ctx.canvas.width) this.values.shift();
  if (value) this.lastDrawTime = new Date().getTime();
};

Monitor.prototype.draw = function () {
  if (this.ctx) {
    var cv = this.ctx.canvas, w = cv.width, h = cv.height;
    var i = this.values.length, x = w | 0, y = (0.5 * h) | 0;
    cv.style.backgroundColor = 'rgb(' + this.average + ', 0, 0)';
    this.ctx.clearRect(0, 0, w, h);
    this.ctx.strokeStyle = '#00ffff';
    this.ctx.lineWidth = 1;
    this.ctx.beginPath();
    while (i--) {
      x -= 4;
      if (x < 0) break;
      this.ctx.moveTo(x, y);
      this.ctx.lineTo(x + 1, y);
      this.ctx.lineTo(x + 1, y - this.values[i]);
    }
    this.ctx.stroke();
    var elapsed = new Date().getTime() - this.lastDrawTime;
    /* cool down */
    this.average = this.average > 0 ? (this.average * 0.9) | 0 : 0;
    if (elapsed > this.inertiaDuration) {
      this.track(0, this.average);
    }
  }
  var self = this;
  setTimeout(function () {
    self.draw();
  }, 100);
};

Monitor.prototype.connectTo = function (tracker) {
  var events = this.events.split(' '), i = events.length;
  while (i--) {
    tracker.monitor[events[i]] = this;
  }
  this.draw();
  return this;
};

function loadSomeData(target) {
  $.ajax({
    url: 'https://jsonplaceholder.typicode.com/users',
    method: 'GET',
    crossDomain: true,
    dataType: 'json',
    success: function (users) {
      var html = '', $ul = $(target).find('ul');
      $.each(users, function (i, user) {
        var item = '<li><a class="ui-alt-icon ui-nodisc-icon">';
        item += '<h2>' + user.name + '</h2>';
        item += '<p><strong>' + user.company.name + '</strong></p>';
        item += '<p>' + user.address.zipcode + ', ' + user.address.city + '</p>';
        item += '<p>' + user.phone + '</p>';
        item += '<p>' + user.email + '</p>';
        item += '<p class="ui-body-inherit ui-li-aside ui-li-count"><strong>' + user.id + '</strong></p>';
        item += '</a></li>';
        html += item;
      });
      $ul.append(html).listview('refresh');
    },
  });
}

$(document)
  .on('pagecreate', '#page-list', function (e) {
    $("[data-role='header'], [data-role='footer']").toolbar({ theme: 'a', position: 'fixed', tapToggle: false });
    loadSomeData(e.target);
  })
  .on('pageshow', '#page-list', function (e, ui) {
    var tracker = $.data(this, 'mobile-page', new Tracker(this));
    new Monitor('touchstart touchmove').connectTo(tracker).showOn('header');
    new Monitor('scroll wheel mousewheel DOMMouseScroll').connectTo(tracker).showOn('footer');
  });
.ui-page {
  touch-action: none;
}
h1, h2, h3, h4, h5, h6, p {
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}
/* JQM no frills */
.ui-btn,
.ui-title,
.ui-btn:hover,
.ui-btn:focus,
.ui-btn:active,
.ui-btn:visited {
  text-shadow: none !important;
}
* {
  -webkit-box-shadow: none !important;
  -moz-box-shadow: none !important;
  box-shadow: none !important;
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Compulsivity</title>
    <meta name="description" content="Compulsivity" />
    <meta name="HandheldFriendly" content="True" />
    <meta name="MobileOptimized" content="320" />
    <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, width=device-width, minimal-ui shrink-to-fit=no" />
    <meta http-equiv="cleartype" content="on" />
    <!-- Add to homescreen for Chrome on Android -->
    <meta name="mobile-web-app-capable" content="yes" />
    <!-- For iOS web apps. Delete if not needed. -->
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-status-bar-style" content="black" />
    <meta name="apple-mobile-web-app-title" content="Compulsivity" />
    <link rel="stylesheet" href="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.css" />
    <!--
    <script type="application/javascript" src="lib/touch-emulator.js"></script>
    <script> TouchEmulator(); </script>
    -->
    <script type="application/javascript" src="https://cdn.jsdelivr.net/npm/jquery@2.2.4/dist/jquery.min.js"></script>
    <script type="application/javascript" src="https://code.jquery.com/mobile/1.4.5/jquery.mobile-1.4.5.min.js"></script>
  </head>
  <body>
    <div id="header" data-role="header"><h4 style="color: #fff">Compulsivity</h4></div>
    <div id="page-list" data-role="page">
      <div data-role="content" role="main">
        <ul data-role="listview" data-filter="true" data-inset="true"></ul>
      </div>
    </div>
    <div id="footer" data-role="footer"><h4 style="color: #fff">Scroll</h4></div>
  </body>
</html>

Among others, You need to be aware also of the pull-to-refresh and inertia (or momentum) of the smooth scroll behaviors.

Please, try to scroll or to swipe and look how the events are tracked: either the top bar or bottom bar will change color to display the user activity after reaching the bottom or the top respectively of the page.

deblocker
  • 7,629
  • 2
  • 24
  • 59
1

There are two solutions for this. One is for touch devices and second for devices using mouse.

Using Wheel event

If target is a mouse device, then we will use following method:

document.onwheel = event => ScrollAction(event);

For more info on wheel event, please visit this link.

Touch Devices

If target is a touch device then following method will be useful:

document.ontouchcancel = event => TouchInterrupt(event);
document.ontouchend = event => FingerRemoved(event);
document.ontouchmove = event => FingerDragged(event);
document.ontouchstart = event => FingerPlaced(event);

For more info on touch events, please visit this link.

I think your problem fully is solved by this solution.

Farman Ali
  • 97
  • 10
0

Your specific question is solveable by listening to the wheel event, although the result is not terribly precise. The wheel event often fires before the scroll event so this example will sometimes log negative scroll value on the first scroll up from the bottom of the page:

const content = document.querySelector('.content');

for (let i = 0; i < 50; i++) {
  const p = document.createElement('p');
  p.textContent = 'Content';
  content.append(p);
};

content.addEventListener('wheel', e => {
  const atBottom = content.scrollHeight - content.scrollTop === content.clientHeight;
  if (atBottom) console.log(e.deltaY);
});
* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

body {
  height: 100vh;
  width: 100%;
}

.content {
  overflow-y: scroll;
  height: 100%;
}
<div class="content"></div>

As others have suggested, a better approach for your use case might instead be to have an overlay which you can trigger on click/touch and then scroll into view. One issue you might run into is that deeply nested scroll on web browsers can get real ugly real fast, without resorting to pure JS solutions which also have their own performance issues.

lawrence-witt
  • 8,094
  • 3
  • 13
  • 32
0

This is a popup that, when clicked on, opens and enables you to scroll. When it gets to the top of the page, it's header sticks.

var navbar = document.querySelector('.navbar'),
    navheader = document.querySelector('.navheader');

// Toggle navbar
navheader.addEventListener('click', e => {
  navbar.classList.toggle('open');
  if (!navbar.classList.contains('open')) {
    navbar.style.overflow = 'hidden';
    document.body.style.overflow = '';
    navbar.scrollTop = 0;
    stickTop = false;
    navbar.classList.remove('sticky');
    navbar.style.top = '';
    navbar.style.transition = '.2s';
    setTimeout(() => {
      navbar.style.transition = '';
    }, 200);
  }
  else {
    navbar.style.overflow = 'overlay';
    navbar.style.transition = '.2s';
    setTimeout(() => {
      navbar.style.transition = '';
    }, 200);
  }
})

var prevtop = 0;
var stickTop = false;

// Add scroll listener
navbar.addEventListener('scroll', e => {
  // If navbar is open
  if (navbar.classList.contains('open')) {
    if (!stickTop) {
      navbar.style.top = navbar.getBoundingClientRect().top - navbar.scrollTop + 'px';
    }
    
    if ((window.innerHeight - navbar.getBoundingClientRect().bottom) >= 0) {
      document.body.style.overflow = 'hidden';
      navbar.style.overflow = 'auto';
      navbar.style.top = 0;
      navbar.classList.add('sticky');
      stickTop = true;
    }
    
    if (navbar.scrollTop == 0) {
      navbar.classList.remove('open');
      navbar.style.overflow = 'hidden';
      document.body.style.overflow = '';
      stickTop = false;
      navbar.classList.remove('sticky');
      navbar.style.top = '';
      navbar.style.transition = '.2s';
      setTimeout(() => {
        navbar.style.transition = '';
      }, 200);
    }
  }
})
body {
  font-family: sans-serif;
}

.navbar {
  position: fixed;
  top: calc(100vh - 50px);
  height: 100vh;
  left: 0;
  width: 100%;
  overflow: hidden;
}

.navbar.open {
  top: 50vh;
}

.navcontent {
  background: black;
  width: 100%;
  color: white;
}
.navcontent p {
  margin: 0;
}

.navheader {
  height: 50px;
  width: 100%;
  background: lightblue;
  cursor: pointer;
  top: 0;
  position: sticky;
  display: flex;
  justify-content: center;
  z-index: 1;
}

.navheader::before {
  width: 50px;
  height: 3px;
  margin-top: 10px;
  background: white;
  border-radius: 3px;
  content: '';
}
<div class="navbar">
  <div class="navheader"></div>
  <div class="navcontent"><p>S</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>E</p></div>
</div>
<div class="content">
<p>S</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>A</p><p>E</p>
</div>
benhatsor
  • 1,863
  • 6
  • 20