Привет друг! В этой статье попробую описать, один из вариантов автоматического скролла к последнему, приходящему сообщению в чате. В React приложении.
Минута саморекламы😇:
Хочешь порядка в коде, тебе сюда https://dzen.ru/a/Z1iL1J62BiwFEYgd.
Ну а теперь, поехали!🛼
1. Создаём болванку
Как болванку буду использовать собранный проект при помощи vite:
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App
2. Создаём компоненты
Создадим две компоненты Display.tsx и MessageForm.tsx и немного подготовим App.tsx, на MessageForm.tsx и App.tsx подробно останавливаться не буду, статья не об этом. Круто отмазался от того, что мне лень ?😇
App.tsx:
import {useState} from "react";
import {Display} from "./display/Display.tsx";
import {MessageForm} from "./messageForm/MessageForm.tsx";
import s from './App.module.css';
export type MessageType = {
id: string,
title: string
};
export const App = () => {
const [messages, setMessages] = useState<MessageType[]>([{id:'1', title: 'hello friend'}]);
const setMessageHandler = (newMessageTitle: string) => {
const newMessage = {
id: `${Date.now()}`,
title: newMessageTitle,
};
setMessages([...messages, newMessage]);
};
return (
<div className={s.appContainer}>
<Display messages={messages}/>
<MessageForm setMessageHandler={setMessageHandler}/>
</div>
)
};
.appContainer {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
min-height: 100vh;
min-width: 100vh;
gap: 5px;
}
- messages - состояние для хранения сообщений.
- setMessageHandler - функция для добавления сообщения.
MessageForm.tsx:
import {ChangeEvent, FC, useState} from "react";
import s from './MessageForm.module.css'
interface IMessageForm {
setMessageHandler: (newMessageTitle: string) => void;
}
export const MessageForm: FC<IMessageForm> = ({ setMessageHandler }) => {
const [inputValue, setInputValue] = useState<string>('');
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const onAddMessage = () => {
if (inputValue.trim()) {
setMessageHandler(inputValue);
setInputValue("");
}
};
return (
<div className={s.messageFormContainer}>
<input type="text" value={inputValue} onChange={onInputChange} />
<button onClick={onAddMessage}>add message</button>
</div>
);
};
Display.tsx
import {FC} from "react";
import {MessageType} from "../App.tsx";
import s from './Display.module.css';
interface IDisplay {
messages: MessageType[]
}
export const Display: FC<IDisplay> = ({messages}) => {
return (
<div className={s.displayContainer}>
{messages.map(message => (
<div key={message.id} className={s.message}>
{message.title}
</div>
))}
</div>
);
};
.displayContainer {
display: flex;
flex-direction: column;
min-width: 100vh;
border: 1px solid black;
border-radius: 25px;
padding: 10px;
background-color: #f9f9f9;
.message {
margin-bottom: 5px;
padding: 5px;
border-radius: 5px;
background-color: #f0f0f0;
}
}
3. Пишем логику для автоскролла
И так всё волшебство будет проходить в Display.tsx.
Для достижения сего действия нам нужен доступ к DOM элементу для взаимодействия с ним (включая специфические свойства и методы DOM-элемента), в нашем случаем родительская дивка компоненты DIsplay.tsx, а значит ref - в студию. Ну и парочку состояний, позицию вертикального скролла в момент прокрутки и флажок для определения того, скролим, мы в итоге или где?.
const containerRef = useRef<HTMLDivElement>(null);
const [lastScrollTop, setLastScrollTop] = useState<number>(0);
const [isAutoScroll, setIsAutoScroll] = useState<boolean>(true);
Создадим функцию scrollEventHandler которая будет:
- Вычислять текущую позицию прокрутки (scrollTop).
- Определять, идет ли прокрутка вниз и находится ли пользователь внизу контейнера.
- Включать или отключать авто-прокрутку (isAutoScroll).
- Обновлять предыдущее положение прокрутки (lastScrollTop) для следующего вызова.
const scrollEventHandler = (event: React.UIEvent<HTMLDivElement, UIEvent>) => {
const element = event.currentTarget;
const maxScrollPosition = element.scrollHeight - element.clientHeight;
if (element.scrollTop > lastScrollTop && Math.abs(maxScrollPosition - element.scrollTop) < 10) {
setIsAutoScroll(true);
} else {
setIsAutoScroll(false);
}
setLastScrollTop(element.scrollTop);
};
- const element = event.currentTarget;
event.currentTarget — это элемент, на котором произошло событие (в данном случае контейнер div с рефом containerRef).
- const maxScrollPosition = element.scrollHeight - element.clientHeight;
1. Вычисляем максимальную возможную позицию прокрутки.
2. element.scrollHeight: Полная высота контента внутри элемента (включая прокрученную и видимую части).
3. element.clientHeight: Высота видимой области элемента.
Разница между ними дает максимальную позицию, на которую можно прокрутить элемент вниз.
- if (element.scrollTop > lastScrollTop && Math.abs(maxScrollPosition - element.scrollTop) < 10) {
1. Проверяем два условия:
1.1 element.scrollTop > lastScrollTop: Прокрутка идет вниз (текущая позиция больше предыдущей).
1.2 scrollTop: Текущее расстояние от верхнего края контейнера до верхней прокручиваемой части.
2. Math.abs(maxScrollPosition - element.scrollTop) < 10:
Контейнер находится близко к нижней границе (разница менее 10px).
Итог: Если пользователь прокручивает вниз и находится почти внизу контейнера, активируется авто-прокрутка.
- setIsAutoScroll(true);
Устанавливаем состояние isAutoScroll в true, сигнализируя, что авто-прокрутка включена.
- } else { setIsAutoScroll(false); }
Если хотя бы одно из условий не выполняется (например, пользователь прокручивает вверх или находится не внизу контейнера):
Устанавливаем isAutoScroll в false, сигнализируя, что авто-прокрутка отключена.
- setLastScrollTop(element.scrollTop);
Обновляем значение lastScrollTop текущей позицией прокрутки (scrollTop).
Это необходимо для сравнения в следующем вызове функции, чтобы определить направление прокрутки (вверх или вниз).
Уже почти, ещё немного 🤩.
И так мы создали функцию для отслеживания и включения прокрутки, но чего то не хватает отслеживания в реальном времени прокручивать или нет состояние isAutoScroll, а так же добавления нового сообщения messages. useEffect скадыщь🪄! Иными словами, если isAutoScroll изменилось → useEffect выполняется:
useEffect(() => {
if (isAutoScroll && containerRef.current) {
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, [isAutoScroll, messages]);
Обновление isAutoScroll или lastScrollTop вызывает повторный рендер.
useEffect срабатывает при изменении зависимости isAutoScroll или messages:
- Если isAutoScroll стало true: Контейнер автоматически прокручивается вниз (до конца) через scrollTo.
- Если isAutoScroll стало false, useEffect не выполняет никаких действий, так как условие if (isAutoScroll && containerRef.current) не выполняется.
- useEffect срабатывает при изменении зависимости messages:
Если isAutoScroll === true, контейнер автоматически прокручивается вниз до последнего сообщения.
Если isAutoScroll === false, прокрутка не происходит, так как пользователь уже отключил авто-прокрутку, взаимодействуя с контейнером вручную.
- scrollEventHandler не срабатывает:
Это происходит, потому что добавление нового сообщения само по себе не вызывает события прокрутки. Событие прокрутки срабатывает только при взаимодействии пользователя.
Хорошо бы не забыть про стили:
Display.module.css:
.displayContainer {
display: flex;
flex-direction: column;
height: 300px;
min-width: 100vh;
/* Включен скролл */
overflow-y: scroll;
border: 1px solid black;
border-radius: 25px;
padding: 10px;
background-color: #f9f9f9;
/* Скрытие полосы прокрутки в Firefox */
scrollbar-width: none;
.message {
margin-bottom: 5px;
padding: 5px;
border-radius: 5px;
background-color: #f0f0f0;
}
}
/* Скрытие полосы прокрутки (Chrome, Safari, Edge) */
.displayContainer::-webkit-scrollbar {
display: none;
}
Итого, если двумя словами разумеется:
- Пользователь прокручивает контейнер → scrollEventHandler срабатывает: Определяет, находится ли пользователь внизу и прокручивает ли он вниз.
Обновляет состояние isAutoScroll. - Если isAutoScroll изменилось → useEffect выполняется: Прокручивает контейнер вниз, если включена авто-прокрутка.
- Если приходит новое сообщение → useEffect выполняется: Прокручивает контейнер вниз, только если isAutoScroll === true.
Таким образом, функция scrollEventHandler срабатывает только на событие прокрутки, а useEffect срабатывает на изменение зависимостей (isAutoScroll или messages).
4. Итоговый код
Итоговый Display.tsx:
import {FC, useEffect, useRef, useState} from "react";
import {MessageType} from "../App.tsx";
import s from './Display.module.css';
interface IDisplay {
messages: MessageType[]
}
export const Display: FC<IDisplay> = ({messages}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [lastScrollTop, setLastScrollTop] = useState<number>(0);
const [isAutoScroll, setIsAutoScroll] = useState<boolean>(true);
const scrollEventHandler = (event: React.UIEvent<HTMLDivElement, UIEvent>) => {
const element = event.currentTarget;
const maxScrollPosition = element.scrollHeight - element.clientHeight;
if (element.scrollTop > lastScrollTop && Math.abs(maxScrollPosition - element.scrollTop) < 10) {
setIsAutoScroll(true);
} else {
setIsAutoScroll(false);
}
setLastScrollTop(element.scrollTop);
};
useEffect(() => {
if (isAutoScroll && containerRef.current) {
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, [isAutoScroll, messages]);
return (
<div ref={containerRef} className={s.displayContainer} onScroll={scrollEventHandler}>
{messages.map(message => (
<div key={message.id} className={s.message}>
{message.title}
</div>
))}
</div>
);
};
.displayContainer {
display: flex;
flex-direction: column;
height: 300px;
min-width: 100vh;
overflow-y: scroll;
border: 1px solid black;
border-radius: 25px;
padding: 10px;
background-color: #f9f9f9;
scrollbar-width: none;
.message {
margin-bottom: 5px;
padding: 5px;
border-radius: 5px;
background-color: #f0f0f0;
}
}
.displayContainer::-webkit-scrollbar {
display: none;
}
Ну вот собственно и всё 🚀🚀🚀.
Спасибо за внимание! До Новых Встреч!🤗🤗🤗