Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Maintaining scroll position only works when not near the bottom of messages div

I'm trying to mimic other mobile chatting apps where when you select the send-message textbox and it opens the virtual keyboard, the bottom-most message is still in view. There doesn't seem to be a way to do this with CSS amazingly, so JavaScript resize (only way to find out when the keyboard is opened and closed apparently) events and manual scrolling to the rescue.

Someone provided this solution and I found out this solution, which both seem to work.

Except in one case. For some reason, if you are within MOBILE_KEYBOARD_HEIGHT (250 pixels in my case) pixels of the bottom of the messages div, when you close the mobile keyboard, something strange happens. With the former solution, it scrolls to the bottom. And with the latter solution, it instead scrolls up MOBILE_KEYBOARD_HEIGHT pixels from the bottom.

If you are scrolled above this height, both solutions provided above work flawlessly. It's only when you are near the bottom that they have this minor issue.

I thought maybe it was just my program causing this with some weird stray code, but no, I even reproduced a fiddle and it has this exact issue. My apologies for making this so difficult to debug, but if you go to https://jsfiddle.net/t596hy8d/6/show (the show suffix provides a full-screen mode) on your phone, you should be able to see the same behavior.

That behavior being, if you scroll up enough, opening and closing the keyboard maintains the position. However, if you close the keyboard within MOBILE_KEYBOARD_HEIGHT pixels of the bottom, you'll find that it scrolls to the bottom instead.

What is causing this?

Code reproduction here:

window.onload = function(e){ 
  document.querySelector(".messages").scrollTop = 10000;
  
  bottomScroller(document.querySelector(".messages"));
}
  

function bottomScroller(scroller) {
  let scrollBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;

  scroller.addEventListener('scroll', () => { 
  scrollBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
  });   

  window.addEventListener('resize', () => { 
  scroller.scrollTop = scroller.scrollHeight - scrollBottom - scroller.clientHeight;

  scrollBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
  });
}
.container {
  width: 400px;
  height: 87vh;
  border: 1px solid #333;
  display: flex;
  flex-direction: column;
}

.messages {
  overflow-y: auto;
  height: 100%;
}

.send-message {
  width: 100%;
  display: flex;
  flex-direction: column;
}
<div class="container">
  <div class="messages">
  <div class="message">hello 1</div>
  <div class="message">hello 2</div>
  <div class="message">hello 3</div>
  <div class="message">hello 4</div>
  <div class="message">hello 5</div>
  <div class="message">hello 6 </div>
  <div class="message">hello 7</div>
  <div class="message">hello 8</div>
  <div class="message">hello 9</div>
  <div class="message">hello 10</div>
  <div class="message">hello 11</div>
  <div class="message">hello 12</div>
  <div class="message">hello 13</div>
  <div class="message">hello 14</div>
  <div class="message">hello 15</div>
  <div class="message">hello 16</div>
  <div class="message">hello 17</div>
  <div class="message">hello 18</div>
  <div class="message">hello 19</div>
  <div class="message">hello 20</div>
  <div class="message">hello 21</div>
  <div class="message">hello 22</div>
  <div class="message">hello 23</div>
  <div class="message">hello 24</div>
  <div class="message">hello 25</div>
  <div class="message">hello 26</div>
  <div class="message">hello 27</div>
  <div class="message">hello 28</div>
  <div class="message">hello 29</div>
  <div class="message">hello 30</div>
  <div class="message">hello 31</div>
  <div class="message">hello 32</div>
  <div class="message">hello 33</div>
  <div class="message">hello 34</div>
  <div class="message">hello 35</div>
  <div class="message">hello 36</div>
  <div class="message">hello 37</div>
  <div class="message">hello 38</div>
  <div class="message">hello 39</div>
  </div>
  <div class="send-message">
	<input />
  </div>
</div>
like image 305
Ryan Peschel Avatar asked Jan 11 '20 07:01

Ryan Peschel


People also ask

How do you make a Div fixed position while scrolling?

To make div fixed scrolling to that div with CSS, we can use the position: sticky CSS property. position: -webkit-sticky; position: sticky; top: 0; z-index: 1; to set position to sticky and z-index to 1 to make the element with the sticky position show on top after we scroll to it.

How do you check if div scroll at the bottom?

To detect when a user scrolls to bottom of div with React, we can check if the sum of the scrollTop and clientHeight properties of a scrollable element is equal to the scrollHeight property of the same element. We call the useRef hook to create a ref and we assign the returned ref to the inner div, which is scrollable.


3 Answers

I think what you want is overflow-anchor

Support is increasing, but not total, yet https://caniuse.com/#feat=css-overflow-anchor

From a CSS-Tricks article on it:

Scroll Anchoring prevents that "jumping" experience by locking the user's position on the page while changes are taking place in the DOM above the current location. This allows the user to stay anchored where they are on the page even as new elements are loaded to the DOM.

The overflow-anchor property allows us to opt-out of the Scroll Anchoring feature in the event that it is preferred to allow content to be re-flow as elements are loaded.

Here's a slightly modified version of one of their examples:

let scroller = document.querySelector('#scroller');
let anchor = document.querySelector('#anchor');

// https://ajaydsouza.com/42-phrases-a-lexophile-would-love/
let messages = [
  'I wondered why the baseball was getting bigger. Then it hit me.',
  'Police were called to a day care, where a three-year-old was resisting a rest.',
  'Did you hear about the guy whose whole left side was cut off? He’s all right now.',
  'The roundest knight at King Arthur’s round table was Sir Cumference.',
  'To write with a broken pencil is pointless.',
  'When fish are in schools they sometimes take debate.',
  'The short fortune teller who escaped from prison was a small medium at large.',
  'A thief who stole a calendar… got twelve months.',
  'A thief fell and broke his leg in wet cement. He became a hardened criminal.',
  'Thieves who steal corn from a garden could be charged with stalking.',
  'When the smog lifts in Los Angeles , U. C. L. A.',
  'The math professor went crazy with the blackboard. He did a number on it.',
  'The professor discovered that his theory of earthquakes was on shaky ground.',
  'The dead batteries were given out free of charge.',
  'If you take a laptop computer for a run you could jog your memory.',
  'A dentist and a manicurist fought tooth and nail.',
  'A bicycle can’t stand alone; it is two tired.',
  'A will is a dead giveaway.',
  'Time flies like an arrow; fruit flies like a banana.',
  'A backward poet writes inverse.',
  'In a democracy it’s your vote that counts; in feudalism, it’s your Count that votes.',
  'A chicken crossing the road: poultry in motion.',
  'If you don’t pay your exorcist you can get repossessed.',
  'With her marriage she got a new name and a dress.',
  'Show me a piano falling down a mine shaft and I’ll show you A-flat miner.',
  'When a clock is hungry it goes back four seconds.',
  'The guy who fell onto an upholstery machine was fully recovered.',
  'A grenade fell onto a kitchen floor in France and resulted in Linoleum Blownapart.',
  'You are stuck with your debt if you can’t budge it.',
  'Local Area Network in Australia : The LAN down under.',
  'He broke into song because he couldn’t find the key.',
  'A calendar’s days are numbered.',
];

function randomMessage() {
  return messages[(Math.random() * messages.length) | 0];
}

function appendChild() {
  let msg = document.createElement('div');
  msg.className = 'message';
  msg.innerText = randomMessage();
  scroller.insertBefore(msg, anchor);
}
setInterval(appendChild, 1000);
html {
  height: 100%;
  display: flex;
}

body {
  min-height: 100%;
  width: 100%;
  display: flex;
  flex-direction: column;
  padding: 0;
}

#scroller {
  flex: 2;
}

#scroller * {
  overflow-anchor: none;
}

.new-message {
  position: sticky;
  bottom: 0;
  background-color: blue;
  padding: .2rem;
}

#anchor {
  overflow-anchor: auto;
  height: 1px;
}

body {
  background-color: #7FDBFF;
}

.message {
  padding: 0.5em;
  border-radius: 1em;
  margin: 0.5em;
  background-color: white;
}
<div id="scroller">
  <div id="anchor"></div>
</div>

<div class="new-message">
  <input type="text" placeholder="New Message">
</div>

Open this on mobile: https://cdpn.io/chasebank/debug/PowxdOR

What that's doing is basically disabling any default anchoring of the new message elements, with #scroller * { overflow-anchor: none }

And instead anchoring an empty element #anchor { overflow-anchor: auto } that will always come after those new messages, since the new messages are being inserted before it.

There has to be a scroll to notice a change in anchoring, which I think is generally good UX. But either way, the current scroll position should be maintained when the keyboard opens.

like image 61
Chase Avatar answered Nov 15 '22 13:11

Chase


My solution is the same as your proposed solution with an addition of conditional check. Here's a description of my solution:

  • Record the last scroll position scrollTop and last clientHeight of .messages to oldScrollTop and oldHeight respectively
  • Update oldScrollTop and oldHeight every time a resize happens on window and update oldScrollTop every time a scroll happens on .messages
  • When window is shrunk (when the virtual keyboard shows), the height of .messages will automatically retract. The intended behaviour is to make the bottommost content of .messages still visible even when .messages' height retracts. This requires us to manually adjust the scroll position scrollTop of .messages.
  • When the virtual keyboard shows, update scrollTop of .messages to make sure that the bottommost part of .messages before its height retraction happens is still visible
  • When the virtual keyboard hides, update scrollTop of .messages to make sure that the bottommost part of .messages remains the bottommost part of .messages after height expansion (unless expansion cannot happen upwards; this happens when you're almost at the top of .messages)

What caused the problem?

My (initial possibly flawed) logical thinking is: resize happens, .messages' height changes, update on .messages scrollTop happens inside our resize event handler. However, upon .messages' height expansion, a scroll event curiously happens before a resize! And even more curious, the scroll event only happens when we hide the keyboard when we have scrolled above the maximum scrollTop value of when .messages is not retracted. In my case, this means that when I scroll below 270.334px (the maximum scrollTop before .messages is retracted) and hide the keyboard, that weird scroll before resize event happens and scrolls your .messages to exactly 270.334px. This obviously messes up our solution above.

Fortunately, we can work around this. My personal deduction of why this scroll before the resize event happens is because .messages cannot maintain its scrollTop position of above 270.334px when it expands in height (this is why I mentioned that my initial logical thinking is flawed; simply because there's no way for .messages to maintain its scrollTop position above its maximum value). Therefore, it immediately sets its scrollTop to the maximum value it can give (which is, unsurprisingly, 270.334px).

What can we do?

Because we only update oldHeight on resize, we can check if this forced scroll (or more correctly, resize) happens and if it does, don't update oldScrollTop (because we have already handled that in resize!) We simply need to compare oldHeight and the current height on scroll to see if this forced scrolling happens. This works because the condition of oldHeight not being equal to the current height on scroll will only be true when resize happens (which is coincidentally when that forced scrolling happens).

Here's the code (in JSFiddle) below:

window.onload = function(e) {
  let messages = document.querySelector('.messages')
  messages.scrollTop = messages.scrollHeight - messages.clientHeight
  bottomScroller(messages);
}


function bottomScroller(scroller) {
  let oldScrollTop = scroller.scrollTop
  let oldHeight = scroller.clientHeight

  scroller.addEventListener('scroll', e => {
    console.log(`Scroll detected:
      old scroll top = ${oldScrollTop},
      old height = ${oldHeight},
      new height = ${scroller.clientHeight},
      new scroll top = ${scroller.scrollTop}`)
    if (oldHeight === scroller.clientHeight)
      oldScrollTop = scroller.scrollTop
  });

  window.addEventListener('resize', e => {
    let newScrollTop = oldScrollTop + oldHeight - scroller.clientHeight

    console.log(`Resize detected:
      old scroll top = ${oldScrollTop},
      old height = ${oldHeight},
      new height = ${scroller.clientHeight},
      new scroll top = ${newScrollTop}`)
    scroller.scrollTop = newScrollTop
    oldScrollTop = newScrollTop
    oldHeight = scroller.clientHeight
  });
}
.container {
  width: 400px;
  height: 87vh;
  border: 1px solid #333;
  display: flex;
  flex-direction: column;
}

.messages {
  overflow-y: auto;
  height: 100%;
}

.send-message {
  width: 100%;
  display: flex;
  flex-direction: column;
}
<div class="container">
  <div class="messages">
    <div class="message">hello 1</div>
    <div class="message">hello 2</div>
    <div class="message">hello 3</div>
    <div class="message">hello 4</div>
    <div class="message">hello 5</div>
    <div class="message">hello 6 </div>
    <div class="message">hello 7</div>
    <div class="message">hello 8</div>
    <div class="message">hello 9</div>
    <div class="message">hello 10</div>
    <div class="message">hello 11</div>
    <div class="message">hello 12</div>
    <div class="message">hello 13</div>
    <div class="message">hello 14</div>
    <div class="message">hello 15</div>
    <div class="message">hello 16</div>
    <div class="message">hello 17</div>
    <div class="message">hello 18</div>
    <div class="message">hello 19</div>
    <div class="message">hello 20</div>
    <div class="message">hello 21</div>
    <div class="message">hello 22</div>
    <div class="message">hello 23</div>
    <div class="message">hello 24</div>
    <div class="message">hello 25</div>
    <div class="message">hello 26</div>
    <div class="message">hello 27</div>
    <div class="message">hello 28</div>
    <div class="message">hello 29</div>
    <div class="message">hello 30</div>
    <div class="message">hello 31</div>
    <div class="message">hello 32</div>
    <div class="message">hello 33</div>
    <div class="message">hello 34</div>
    <div class="message">hello 35</div>
    <div class="message">hello 36</div>
    <div class="message">hello 37</div>
    <div class="message">hello 38</div>
    <div class="message">hello 39</div>
  </div>
  <div class="send-message">
    <input />
  </div>
</div>

Tested on Firefox and Chrome for mobile and it works for both browsers.

like image 43
Richard Avatar answered Nov 15 '22 13:11

Richard


I have finally found a solution that actually works. Although it may not be ideal, it actually works in all cases. Here is the code:

bottomScroller(document.querySelector(".messages"));

bottomScroller = scroller => {
  let pxFromBottom = 0;

  let calcPxFromBottom = () => pxFromBottom = scroller.scrollHeight - (scroller.scrollTop + scroller.clientHeight);

  setInterval(calcPxFromBottom, 500);

  window.addEventListener('resize', () => { 
    scroller.scrollTop = scroller.scrollHeight - pxFromBottom - scroller.clientHeight;
  });
}

Some epiphanies I had along the way:

  1. When closing the virtual keyboard, a scroll event occurs instantly before the resize event. This seems to only happen when closing the keyboard, not opening it. This is the reason you cannot use the scroll event to set pxFromBottom, because if you are near the bottom it will set itself to 0 in the scroll event right before the resize event, messing up the calculation.

  2. Another reason why all the solutions had difficulty near the bottom of the messages div is a bit tricky to understand. For example, in my resize solution I just add or subtract 250 (mobile keyboard height) to scrollTop when opening or closing the virtual keyboard. This works perfectly except near the bottom. Why? Because let's say you are 50 pixels from the bottom and close the keyboard. It will subtract 250 from scrollTop (the keyboard height), but it should only subtract 50! So it will always reset to the wrong fixed position when closing the keyboard near the bottom.

  3. I also believe you cannot use onFocus and onBlur events for this solution, because those only occur when initially selecting the textbox to open the keyboard. You are perfectly able to open and close the mobile keyboard without activating these events, and as such, they are not able to used here.

I believe the above points are important to developing a solution, because they are non-obvious at first, but prevent a robust solution from developing.

I don't like this solution (interval is a bit inefficient and prone to race conditions), but I cannot find anything better that always works.

like image 45
Ryan Peschel Avatar answered Nov 15 '22 14:11

Ryan Peschel