简体   繁体   中英

Scroll to bottom of an overflowing div in React

I have a ul with a max-height and overflow-y: auto .

When the user enters enough li elements, the ul starts to scroll, but I want the last li with the form in it to always be present and viewable to the user.

I've tried implementing a scrollToBottom function that looks like this:

scrollToBottom() {
    this.formLi.scrollIntoView({ behavior: 'smooth' });
}

But that just makes the ul jump to the top of the screen and show the li with the form in it as the only visible thing.

Is there a better way to accomplish this? Answers I find tend to be a bit older and use ReactDOM. Thanks!

CSS:

.prompt-box .options-holder {
    list-style: none;
    padding: 0;
    width: 95%;
    margin: 12px auto;
    max-height: 600px;
    overflow-y: auto;
}

HTML:

<ul className='options-holder'>
    {
        this.state.items.map((item, index) => (
            <li key={item.id} className={`option ${index === 0 ? 'first' : ''}`}>
                <div className='circle' onClick={this.removeItem} />

                <p className='item-text'>{ item.text }</p>
            </li>
        ))
    }

    <li key={0} className={`option form ${this.state.items.length === 0 ? 'only' : ''}`} ref={el => (this.formLi = el)}>
        <div className='circle form' />

        <form className='new-item-form' onSubmit={this.handleSubmit}>
            <input 
                autoFocus 
                className='new-item-input' 
                placeholder='Type something and press return...' 
                onChange={this.handleChange} 
                value={this.state.text}
                ref={(input) => (this.formInput = input)} />
        </form>
    </li> 
</ul>

I have a container that fills the height of the page. This container has display flex and flex direction column. Then I have a Messages component which is a ul with one li per message plus a special AlwaysScrollToBottom component as the last child that, with the help of useEffect , scrolls to the bottom of the list.

const AlwaysScrollToBottom = () => {
  const elementRef = useRef();
  useEffect(() => elementRef.current.scrollIntoView());
  return <div ref={elementRef} />;
};

const Messages = ({ items }) => (
  <ul>
    {items.map(({id, text}) => <li key={id}>{text}</li>)}
    <AlwaysScrollToBottom />
  </ul>
)

const App = () => (
  <div className="container">
    <Messages items={[...]}>
    <MessageComposer />
  </div>
)

The CSS is

.container {
  display: flex;
  height: 100vh;
  flex-direction: column;
}

ul {
  flex-grow: 1;
  overflow-y: auto;
}

MessageComposer has been omitted for brevity, it contains the form, the input field, etc. to send messages.

Use react-scroll library. However you will have to explicitly set ID of your ul .

import { animateScroll } from "react-scroll";

scrollToBottom() {
    animateScroll.scrollToBottom({
      containerId: "options-holder"
    });
}

You can call scrollToBottom on setState callback.

For example

this.setState({ items: newItems}, this.scrollToBottom);

Or by using setTimeout.

Or you can even set Element from react-scroll and scroll to a certain li .

Seems like you're building a chat-like interface. You can try wrapping the <ul> and the div.circle-form in a separate div like below:

 ul{ border:1px solid red; height:13em; overflow-y:auto; position:relative; } ul li{ border-bottom:1px solid #ddd; list-style:none; margin-left:0; padding:6px; } .wrapper{ background:#eee; height:15em; position:relative; } .circle-form{ background:#ddd; height:2em; padding:3px; position:absolute; bottom:0; left:0; right:0; z-index:2; } .circle-form input[type=text]{ padding:8px; width:50%; }
 <div class="wrapper"> <ul id="list"> <li>item</li> <li>item</li> <li>item</li> <li>item</li> <li>item</li> <li>item</li> <li>item</li> <li>item</li> </ul> <div class="circle-form"> <input type="text" name="type_here" placeholder="Enter something..." autofocus/> </div> </div>

EDIT

And to scroll to the bottom of the list with javascript

var list = document.getElementById("list");
list.scrollTop = list.offsetHeight;

I had a similar issue when it came to rendering an array of components that came from a user import. I ended up using the componentDidUpdate() function to get mine to work.

componentDidUpdate() {
        // I was not using an li but may work to keep your div scrolled to the bottom as li's are getting pushed to the div
        const objDiv = document.getElementById('div');
        objDiv.scrollTop = objDiv.scrollHeight;
      }

I have a script that I've used in one of my projects to scroll top Smoothly, I made a little refactor to scroll the height of your div (scroll bottom) I hope it helps.

scroll.js

function currentYPosition() {
  if (self.pageYOffset) return self.pageYOffset;
  if (document.documentElement && document.documentElement.scrollHeight) return document.documentElement.scrollHeight;
  if (document.body.scrollHeight) return document.body.scrollHeight;

  return 0;
}

function elmYPosition(eID) {
  let elm = document.getElementById(eID);
  let y = elm.offsetHeight;
  let node = elm;
  while (node.offsetParent && node.offsetParent != document.body) {
    node = node.offsetParent;
    y += node.offsetHeight;
  }
  return y;
}

export default function smoothScroll(eID, string) {
  let startY = currentYPosition();
  let stopY = elmYPosition(eID);
  let distance = stopY > startY ? stopY - startY : startY - stopY;
  let speed = Math.round(distance / 10);
  let speedTimeout = 250;
  if (speed >= 100) speed = 100;
  if (string) speed = 1;
  let step = Math.round(distance / 25);
  let leapY = stopY > startY ? startY + step : startY - step;
  let timer = 0;
  if (stopY > startY) {
    for (let i = startY; i < stopY; i += step) {
      setTimeout('window.scrollTo(0, ' + leapY + ')', timer * speed);
      leapY += step;
      if (leapY > stopY) leapY = stopY;
      timer++;
    }
    return;
  }
  for (let i = startY; i > stopY; i -= step) {
    setTimeout('window.scrollTo(0, ' + (leapY) + ')', timer * speed);
    leapY -= step;
    if (leapY < stopY){
      leapY = stopY;
    } 
    timer++;
  }
}

You should import this inside your component, there are 2 parameters(the ID of your element, in this case, you can use ref. The second one is a string that I've used to treat the speed of the scrolling.

import scroll from './your-path/scroll.js';
.
.
.
<ul className='options-holder'>
{
    this.state.items.map((item, index) => (
        <li key={item.id} className={`option ${index === 0 ? 'first' : ''}`} ref={el => (this.formLi = el)}>
            <div className='circle' onClick={this.removeItem} />

            <p className='item-text'>{ item.text }</p>
        </li>
    ))
}

<li key={0} className={`option form ${this.state.items.length === 0 ? 'only' : ''}`} ref={el => (this.formLi = el)}>
    <div className='circle form' />

    <form className='new-item-form' onSubmit={this.handleSubmit}>
        <input 
            autoFocus 
            className='new-item-input' 
            placeholder='Type something and press return...' 
            onChange={this.handleChange} 
            value={this.state.text}
            ref={(input) => (this.formInput = input)} />
    </form>
</li> 

Idk how you are mapping this LI inside your render but you should make a verification and if there's the property Overflow you should run the scroll.

There's a reasonable answer for your component is jumping to the first element, you're hitting the ref for the FIRST element, not the last.

Possible workaround:

scroll(this.state.items[this.state.items.length - 1]);

Update 1: Gist of the original scroll.js, scrolling to the top

Simply use position:sticky st the last list item is always shown.

li:last-child {
  position:sticky;
  bottom:0;
}

Demo

Caniuse

i solved it with a general utility function that works outside of any specific component. you need to define a ref on the parent (list box, multiline edit, whatever) and the last child element (or wherever you wish to scroll to).

export const scrollToRef = (parentRef, childRef) => {
    trace("scrollToRef:ref.current.offsetTop", childRef.current.offsetTop);
    parentRef.current.scrollTo(0, childRef.current.offsetTop)
}

Im using it a functional component and calling it as an effect whenever my parent container gets updated.

You can use "react-scroll-to-bottom" module to do it.

Auto scroll to bottom when new child added.

import ScrollToBottom from 'react-scroll-to-bottom';

<ScrollToBottom className={ROOT_CSS}>
   <p>
       Nostrud nisi duis veniam ex esse laboris consectetur officia et. Velit 
       cillum est veniam culpa magna sit
  </p>
</ScrollToBottom>

Link: https://www.npmjs.com/package/react-scroll-to-bottom

The answer here didn't quite work for me because I am using CSS Grid instead of Flexbox so the added div created another grid row.

To work around this I elaborated to create a Hook which takes a ref and gets the last child to scrollIntoView() .

import { useEffect } from 'react'

export const useScrollToBottom = ref => {
  useEffect(() => {
    ref?.current?.querySelector(':scope > :last-child')?.scrollIntoView()
  }, [ref])
}
const Messages = ({ items }) => {
  const scrollContainer = useRef()
  useScrollToBottom(scrollContainer)

  return (
    <ul ref={scrollContainer}>
      {items.map(({id, text}) => <li key={id}>{text}</li>)}
    </ul>
  )

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM