Học từ Telegram #22: Ngày dính khi cuộn, khoá cuộn khi đổi tab
Ba mẹo cuộn của Telegram Web A: nhãn ngày chỉ hiện khi đang cuộn, đổi tab không xô layout, dừng quán tính iOS trước khi gán scrollTop. Kèm bản sửa.
Ba chi tiết cuộn trong Telegram Web A mà ít ai để ý, cho tới khi app của mình làm sai:
- Kéo khung chat, một nhãn "Hôm qua" dính ở đỉnh cho biết đang ở ngày nào. Dừng tay 1 giây, nhãn mờ đi, không che tin nhắn đầu tiên.
- Mở hồ sơ một người, cuộn xuống tới thanh tab "Ảnh / Link / File", đổi từ tab dài sang tab ngắn: thanh tab không tụt xuống, nội dung không xô ngang trong lúc trượt.
- Trên iPhone, đang vuốt mạnh danh sách mà app gán
scrollTop(ví dụ sau khi tải thêm tin), quán tính cuộn không ghi đè lại vị trí app vừa đặt.
Mỗi mẹo chỉ vài dòng, nằm trong useStickyDates.ts, useTransitionFixes.ts và resetScroll.ts. Bài này đọc từng file, sửa hai lỗi nhỏ, rồi viết lại.
Tip
Tóm tắt
- Nhãn ngày:
position: sticky+ một class "đang cuộn". Đứng yên 1 giây (debounce) thì tìm nhãn đang dính ở đỉnh, gắn.stuckđể CSS choopacity: 0. - Đổi tab: tắt cuộn trong lúc trượt và bù đúng bề rộng thanh cuộn bằng
margin-right; đặtmin-heightcho vùng nội dung để tab ngắn không làm khung co lại. - iOS: gán
overflow: hidden→scrollTop→ bỏoverflowđể dừng quán tính đang chạy. - Bản gốc trả
overflow-yvềscrollcố định (thanh cuộn luôn hiện) và tính bề rộng thanh cuộn gồm cả viền; bản viết lại khôi phục giá trị cũ và trừ viền.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
Thử trong demo: cuộn khung chat rồi dừng tay, nhãn đang dính mờ đi sau 1 giây. Phần 2: cuộn hồ sơ xuống tới khi thanh tab chạm đỉnh, đổi qua "Link (ngắn)" ở từng chế độ; log ghi lại nội dung xô ngang bao nhiêu px và thanh tab dịch bao nhiêu.
#Telegram làm thế nào?
#1. Nhãn ngày chỉ hiện khi đang cuộn
components/middle/hooks/useStickyDates.ts (59 dòng):
const DEBOUNCE = 1000;
const STICKY_TOP = 10;
const STICKY_TOP_WITH_TOOLS = 60;
const updateStickyDates = useCallback((container, hasTools) => {
markIsScrolled();
if (!document.body.classList.contains('is-scrolling-messages')) {
fastRaf(() => document.body.classList.add('is-scrolling-messages'));
}
runDebounced(() => {
fastRaf(() => {
const currentStuck = document.querySelector('.stuck');
if (currentStuck) currentStuck.classList.remove('stuck');
const stuckDateEl = findStuckDate(container, hasTools);
if (stuckDateEl) stuckDateEl.classList.add('stuck');
document.body.classList.remove('is-scrolling-messages');
});
});
}, [markIsScrolled, runDebounced]);
ts
findStuckDate tìm nhãn có -offsetHeight ≤ offsetTop - scrollTop ≤ 10 (60 khi có thanh công cụ ghim tin phía trên), tức là nhãn đang nằm sát đỉnh khung, vị trí mà position: sticky giữ nó lại.
CSS trong MessageList.scss làm phần còn lại:
.MessageList .sticky-date { pointer-events: none; opacity: 1; transition: opacity .3s ease; }
body:not(.is-scrolling-messages) .MessageList .sticky-date.stuck { opacity: 0; }
.MessageList.scrolled:not(.is-animating) .sticky-date { position: sticky; top: 0.625rem; }
.has-header-tools .MessageList .sticky-date { top: 3.75rem; }
css
Ba chi tiết hay:
- Chỉ nhãn đang dính bị ẩn. Các nhãn ngày nằm giữa danh sách (không dính) vẫn hiện như vách ngăn giữa các ngày.
position: stickychỉ bật sau lần cuộn đầu (class.scrolled). Comment trong hook giải thích: không ẩn đồng bộ được nhãn đang dính ngay lúc mở chat từuseLayoutEffect, nên tránh hẳn tình huống đó; đổi lại lần hiện đầu tiên không có animation.is-animatingtắt sticky trong lúc chuyển chat để nhãn không nhảy theo animation.
#2. Đổi tab mà không xô layout
components/right/hooks/useTransitionFixes.ts dùng trong Profile.tsx:
// Set `min-height` for shared media container to prevent jumping when switching tabs
function setMinHeight() {
const container = containerRef.current!;
const transitionEl = container.querySelector('.Transition.shared-media-transition');
const tabsEl = container.querySelector('.TabList');
if (transitionEl && tabsEl) {
transitionEl.style.minHeight = `${container.offsetHeight - tabsEl.offsetHeight}px`;
}
}
// Workaround for scrollable content flickering during animation.
const applyTransitionFix = useCallback(() => {
const container = containerRef.current!;
if (container.style.overflowY !== 'hidden') {
const scrollBarWidth = container.offsetWidth - container.clientWidth;
container.style.overflowY = 'hidden';
container.style.marginRight = `${scrollBarWidth}px`;
}
}, [containerRef]);
const releaseTransitionFix = useCallback(() => {
const container = containerRef.current!;
container.style.overflowY = 'scroll';
container.style.marginRight = '0';
}, [containerRef]);
ts
min-height: hồ sơ gồm phần thông tin, thanh tab, rồi nội dung tab, tất cả trong một khung cuộn. Đang cuộn xuống (thanh tab ở đỉnh), đổi sang tab chỉ có 2 link: tổng chiều cao co lại, trình duyệt kẹpscrollTop, thanh tab tụt xuống giữa màn. Đặtmin-height = khung - thanh tabthì nội dung luôn đủ cao để thanh tab ở lại đỉnh. Demo đo được thanh tab dịch 123px khi không cómin-height, 0px khi có.- Khoá cuộn trong lúc trượt: component
Transitiontrượt tab cũ ra, tab mới vào. Comment ghi "scrollable content flickering": có lẽ trong lúc hai tab cùng tồn tại, chiều cao nội dung đổi liên tục làm thanh cuộn hiện/ẩn và nội dung giật. Tắtoverflow-ythì hết giật, nhưng thanh cuộn biến mất làm vùng nội dung rộng thêm đúng bề rộng thanh cuộn, nội dung xô sang phải. Bù lại bằngmargin-rightđúng bằng chừng đó.
Hai lỗi nhỏ:
releaseTransitionFixgánoverflowY = 'scroll'cố định, không trả lại giá trị trước khi khoá. Nếu CSS ban đầu làauto, sau lần đổi tab đầu tiên thanh cuộn luôn hiện kể cả khi nội dung ngắn. (Có thể CSS của Telegram vốn làscrollnên không thấy; nhưng hàm như vậy không dùng lại được ở chỗ khác.)offsetWidth - clientWidthgồm cả viền trái phải. Khung cóborder: 1pxthì margin bù dư 2px. Khung của Telegram không có viền nên không lộ.
#3. Dừng quán tính iOS
export default (container: HTMLDivElement, scrollTop?: number) => {
if (IS_IOS) container.style.overflow = 'hidden';
if (scrollTop !== undefined) container.scrollTop = scrollTop;
if (IS_IOS) container.style.overflow = '';
};
ts
Được gọi ở InfiniteScroll.tsx (sau khi đổi vị trí cuộn để giữ chỗ đang đọc) và useScrollHooks.ts ngay trước khi tải thêm tin (resetScroll(containerRef.current!) không có scrollTop). Trường hợp sau chỉ để dừng quán tính: người dùng vuốt mạnh tới đỉnh, app chèn 50 tin cũ lên trên rồi đặt lại scrollTop; nếu quán tính còn chạy, Safari tiếp tục cuộn theo vận tốc cũ và đè lên vị trí vừa đặt.
#Bên trong trình duyệt
position: stickygiữ phần tử trong luồng bình thường cho tới khi nó chạm ngưỡngtopcủa khung cuộn gần nhất, sau đó dính lại, nhưng không ra khỏi khối cha. Mỗi nhóm ngày là một khối riêng (nhãn + tin của ngày đó), nên nhãn "Thứ Ba" dính tới khi hết tin thứ Ba thì bị nhãn "Thứ Tư" đẩy lên. Không cần JS để tính chuyện đó.- Thanh cuộn chiếm chỗ hay không tuỳ hệ điều hành: Windows và Linux mặc định thanh cuộn "cổ điển" chiếm khoảng 15–17px; macOS và mobile dùng thanh cuộn nổi rộng 0px. Mẹo bù
margin-rightchỉ có tác dụng ở loại thứ nhất. (Demo cố định thanh cuộn 12px bằng::-webkit-scrollbarđể thấy rõ trên mọi máy chạy Chromium; Playwright mặc định ẩn thanh cuộn, phải tắt cờ--hide-scrollbarsmới đo được.) scrollTopbị kẹp vào[0, scrollHeight - clientHeight]. Nội dung co lại thì trình duyệt tự giảmscrollTop; đó là lý do thanh tab tụt.- Quán tính trên iOS là animation do trình duyệt điều khiển, không có API dừng. Đổi
overflowsanghiddenlàm phần tử tạm hết là khung cuộn, Safari huỷ animation cuộn của nó.
stateDiagram-v2
direction LR
[*] --> DungYen
DungYen --> DangCuon: scroll, thêm class is-scrolling
DangCuon --> DangCuon: scroll, đặt lại hẹn 1s
DangCuon --> DungYen: hết 1s, tìm nhãn đang dính, gắn stuck, gỡ is-scrolling
note right of DungYen
nhãn .stuck có opacity 0
end note
#Viết lại
Nhãn ngày, chỉ tìm trong khung của mình:
src/lib/tgStickyDate.ts
/**
* Gọi ở mỗi sự kiện scroll của `container`. Khi cuộn: nhãn ngày hiện bình thường.
* Đứng yên `idleMs`: nhãn đang dính ở đỉnh được gắn `.stuck` (CSS cho mờ đi) để không che tin nhắn.
*/
export function createStickyDates(container: HTMLElement, opts: StickyDatesOptions = {}) {
const { selector = '.sticky-date', idleMs = 1000, stickyTop = 10, scrollingClass = 'is-scrolling' } = opts;
let timer: ReturnType<typeof setTimeout> | undefined;
const findStuck = () => {
const top = container.getBoundingClientRect().top;
return Array.from(container.querySelectorAll<HTMLElement>(selector)).find((el) => {
const r = el.getBoundingClientRect();
const offset = r.top - top;
return -r.height <= offset && offset <= stickyTop;
});
};
const onScroll = () => {
if (!container.classList.contains(scrollingClass)) {
requestAnimationFrame(() => container.classList.add(scrollingClass));
}
clearTimeout(timer);
timer = setTimeout(() => {
requestAnimationFrame(() => {
// chỉ tìm trong container này (bản gốc dùng document.querySelector('.stuck'), trúng cả list khác)
container.querySelector(`${selector}.stuck`)?.classList.remove('stuck');
findStuck()?.classList.add('stuck');
container.classList.remove(scrollingClass);
});
}, idleMs);
};
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
clearTimeout(timer);
container.removeEventListener('scroll', onScroll);
};
}
ts
Khoá cuộn khi chuyển tab:
src/lib/tgStickyDate.ts
export function lockScrollForTransition(container: HTMLElement) {
if (container.dataset.scrollLocked) return () => {};
const prevOverflow = container.style.overflowY;
const prevMargin = container.style.marginRight;
// offsetWidth - clientWidth gồm cả viền; trừ viền ra để còn đúng bề rộng thanh cuộn
const cs = getComputedStyle(container);
const scrollbarWidth =
container.offsetWidth - container.clientWidth - parseFloat(cs.borderLeftWidth) - parseFloat(cs.borderRightWidth);
container.dataset.scrollLocked = '1';
container.style.overflowY = 'hidden';
container.style.marginRight = `${scrollbarWidth}px`;
// Trả lại đúng giá trị cũ. Bản gốc ép `overflowY = 'scroll'`, nên sau lần đầu
// thanh cuộn luôn hiện kể cả khi nội dung ngắn.
return () => {
container.style.overflowY = prevOverflow;
container.style.marginRight = prevMargin;
delete container.dataset.scrollLocked;
};
}
ts
Xem toàn bộ src/lib/tgStickyDate.ts
src/lib/tgStickyDate.ts
/**
* Ba mẹo cuộn nhỏ từ Telegram Web A:
* 1. Nhãn ngày "dính" chỉ hiện khi đang cuộn (components/middle/hooks/useStickyDates.ts)
* 2. Khoá cuộn trong lúc chuyển tab mà layout không xô (components/right/hooks/useTransitionFixes.ts)
* 3. Dừng quán tính cuộn trên iOS trước khi gán scrollTop (util/resetScroll.ts)
*/
// ---------------------------------------------------------------- 1. nhãn ngày dính
export interface StickyDatesOptions {
/** phần tử nhãn ngày, CSS cho nó `position: sticky; top: …` */
selector?: string;
/** đứng yên bao lâu thì ẩn nhãn đang dính, Telegram dùng 1000ms */
idleMs?: number;
/** nhãn coi là "đang dính" nếu cách đỉnh khung ≤ giá trị này (10px, 60px khi có thanh công cụ) */
stickyTop?: number;
/** class gắn lên `container` khi đang cuộn (Telegram gắn `is-scrolling-messages` lên body) */
scrollingClass?: string;
}
/**
* Gọi ở mỗi sự kiện scroll của `container`. Khi cuộn: nhãn ngày hiện bình thường.
* Đứng yên `idleMs`: nhãn đang dính ở đỉnh được gắn `.stuck` (CSS cho mờ đi) để không che tin nhắn.
*/
export function createStickyDates(container: HTMLElement, opts: StickyDatesOptions = {}) {
const { selector = '.sticky-date', idleMs = 1000, stickyTop = 10, scrollingClass = 'is-scrolling' } = opts;
let timer: ReturnType<typeof setTimeout> | undefined;
const findStuck = () => {
const top = container.getBoundingClientRect().top;
return Array.from(container.querySelectorAll<HTMLElement>(selector)).find((el) => {
const r = el.getBoundingClientRect();
const offset = r.top - top;
return -r.height <= offset && offset <= stickyTop;
});
};
const onScroll = () => {
if (!container.classList.contains(scrollingClass)) {
requestAnimationFrame(() => container.classList.add(scrollingClass));
}
clearTimeout(timer);
timer = setTimeout(() => {
requestAnimationFrame(() => {
// chỉ tìm trong container này (bản gốc dùng document.querySelector('.stuck'), trúng cả list khác)
container.querySelector(`${selector}.stuck`)?.classList.remove('stuck');
findStuck()?.classList.add('stuck');
container.classList.remove(scrollingClass);
});
}, idleMs);
};
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
clearTimeout(timer);
container.removeEventListener('scroll', onScroll);
};
}
// ---------------------------------------------------------------- 2. khoá cuộn khi chuyển tab
/**
* Trong lúc animation chuyển tab: tắt cuộn để nội dung không giật, và bù đúng bề rộng
* thanh cuộn vừa mất bằng `margin-right` để layout không xô sang phải.
*/
export function lockScrollForTransition(container: HTMLElement) {
if (container.dataset.scrollLocked) return () => {};
const prevOverflow = container.style.overflowY;
const prevMargin = container.style.marginRight;
// offsetWidth - clientWidth gồm cả viền; trừ viền ra để còn đúng bề rộng thanh cuộn
const cs = getComputedStyle(container);
const scrollbarWidth =
container.offsetWidth - container.clientWidth - parseFloat(cs.borderLeftWidth) - parseFloat(cs.borderRightWidth);
container.dataset.scrollLocked = '1';
container.style.overflowY = 'hidden';
container.style.marginRight = `${scrollbarWidth}px`;
// Trả lại đúng giá trị cũ. Bản gốc ép `overflowY = 'scroll'`, nên sau lần đầu
// thanh cuộn luôn hiện kể cả khi nội dung ngắn.
return () => {
container.style.overflowY = prevOverflow;
container.style.marginRight = prevMargin;
delete container.dataset.scrollLocked;
};
}
/**
* Đặt `min-height` cho vùng nội dung tab = chiều cao khung - thanh tab.
* Đổi từ tab dài sang tab ngắn thì khung không bị co lại, scrollTop không tụt.
*/
export function keepTabMinHeight(container: HTMLElement, content: HTMLElement, tabs: HTMLElement) {
const update = () => {
content.style.minHeight = `${container.offsetHeight - tabs.offsetHeight}px`;
};
update();
const ro = new ResizeObserver(update);
ro.observe(container);
return () => ro.disconnect();
}
// ---------------------------------------------------------------- 3. dừng quán tính iOS
export const IS_IOS =
typeof navigator !== 'undefined' &&
(/iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1));
/**
* Trên iOS, gán scrollTop trong lúc còn quán tính thì cú vuốt dang dở ghi đè lại.
* Tắt overflow một nhịp làm Safari dừng quán tính, rồi bật lại.
*/
export function resetScroll(container: HTMLElement, scrollTop?: number, isIOS = IS_IOS) {
if (isIOS) container.style.overflow = 'hidden';
if (scrollTop !== undefined) container.scrollTop = scrollTop;
if (isIOS) container.style.overflow = '';
}
ts
#Đi qua code
createStickyDatesgắn listenerscroll(passive) ngay vào khung, thay vì để component gọiupdateStickyDatesở mỗi lần cuộn. Class "đang cuộn" gắn lên khung, không lênbody, nên hai danh sách trên cùng trang không ảnh hưởng nhau. Bản gốc gọidocument.querySelector('.stuck'): có lớp.stucknào khác trong trang (một component khác dùng cùng tên) là bị gỡ nhầm.findStuckdùnggetBoundingClientRectso với đỉnh khung thay vìoffsetTop - scrollTop.offsetToptính theooffsetParent, chỉ đúng khi khung cuộn chính làoffsetParentcủa nhãn; vị trí trên màn hình thì luôn đúng.- Việc thêm class đi qua
requestAnimationFrame, giốngfastRafcủa Telegram: nhiều sự kiện scroll trong một frame chỉ đổi DOM một lần. lockScrollForTransitiontrả về hàm mở khoá, đóng gói đúng giá trị cũ củaoverflowYvàmarginRight. Gọi khoá hai lần liên tiếp (hai lần đổi tab nhanh) thì lần sau không làm gì, để không "nhớ" nhầmhiddenlà giá trị gốc.keepTabMinHeightdùngResizeObservertrên khung thay vìwindow.resize: khung đổi cao vì lý do khác (bàn phím ảo, panel bên cạnh) cũng được cập nhật.
Khác bản gốc: class "đang cuộn" gắn lên khung; tìm nhãn bằng getBoundingClientRect; khôi phục overflow-y cũ; trừ viền khi tính bề rộng thanh cuộn; min-height theo ResizeObserver.
#Tại sao không làm cách khác?
- Ẩn nhãn bằng JS ở mỗi lần cuộn (tính vị trí, đặt
opacity): tốn hơn và dễ giật. Class + debounce chỉ đụng DOM hai lần cho cả một lượt cuộn. scrollbar-gutter: stablegiữ sẵn chỗ cho thanh cuộn kể cả khi không cuộn, nên tắtoverflowkhông làm nội dung xô. Đây là cách hiện đại nên dùng nếu chấp nhận một dải trống bên phải. Telegram Web A ra đời trước khi thuộc tính này phổ biến.- Đặt
heightcố định cho nội dung tab: tab dài sẽ phải cuộn riêng (cuộn lồng cuộn), khó dùng trên mobile.min-heightchỉ chặn chiều co, không chặn chiều giãn. overscroll-behaviorhayscroll-behaviorkhông dừng được quán tính đang chạy; chúng chỉ quyết định cuộn có lan ra ngoài và có mượt không.
#Edge case và cạm bẫy
- Nhãn đầu tiên lúc mở chat: nếu bật
stickyngay, nhãn ngày của tin cuối có thể đang dính ở đỉnh và hiện rõ khi chat vừa mở. Telegram chỉ bậtstickysau lần cuộn đầu. - Debounce 1 giây và cuộn bằng bàn phím: phím
End/PageDowncũng phátscroll, nên vẫn chạy đúng. Cuộn bằng code (scrollTo) cũng phátscroll, nên nhãn hiện lên trong 1 giây dù người dùng không cuộn; nếu không muốn thì tạm gỡ listener. - Thanh cuộn nổi (macOS, mobile):
offsetWidth - clientWidth = 0, mẹo bù không làm gì và cũng không cần. overflow: hiddenlàm mấtscrollTop? Không: phần tử vẫn giữscrollTopvà vẫn là khung cuộn của các phần tửstickybên trong, chỉ người dùng không cuộn được.resetScrolltrên trình duyệt không phải iOS: không làm gì thêm. Dò iOS bằng user agent dễ sai với iPad (Safari iPadOS báo là Mac); bản viết lại kiểm thêmmaxTouchPoints > 1.
#Dùng trong dự án
function MessageList({ groups }: { groups: DayGroup[] }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => createStickyDates(ref.current!, { stickyTop: 10 }), []);
return (
<div ref={ref} className='message-list'>
{groups.map((g) => (
<section key={g.day}>
<div className='sticky-date'><span>{g.label}</span></div>
{g.messages.map((m) => <Message key={m.id} {...m} />)}
</section>
))}
</div>
);
}
tsx
.message-list .sticky-date { position: sticky; top: 8px; transition: opacity .3s; pointer-events: none; }
.message-list:not(.is-scrolling) .sticky-date.stuck { opacity: 0; }
css
Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it, vi } from 'vitest';
import { createStickyDates, lockScrollForTransition, resetScroll } from './tgStickyDate';
describe('lockScrollForTransition', () => {
it('khoá rồi trả lại đúng style cũ (không ép overflow: scroll)', () => {
const el = document.createElement('div');
el.style.overflowY = 'auto';
const release = lockScrollForTransition(el);
expect(el.style.overflowY).toBe('hidden');
release();
expect(el.style.overflowY).toBe('auto');
expect(el.style.marginRight).toBe('');
});
});
describe('resetScroll', () => {
it('trên iOS tắt overflow trong lúc gán scrollTop', () => {
const el = document.createElement('div');
const seen: string[] = [];
Object.defineProperty(el, 'scrollTop', { set: () => seen.push(el.style.overflow), get: () => 0 });
resetScroll(el, 100, true);
expect(seen).toEqual(['hidden']);
expect(el.style.overflow).toBe('');
});
});
describe('createStickyDates', () => {
it('đang cuộn có class, đứng yên 1s thì gỡ class và đánh dấu nhãn đang dính', async () => {
vi.useFakeTimers();
const box = document.createElement('div');
box.innerHTML = '<div class="sticky-date"></div><div class="sticky-date"></div>';
document.body.appendChild(box);
const [a, b] = box.querySelectorAll<HTMLElement>('.sticky-date');
box.getBoundingClientRect = () => ({ top: 100 }) as DOMRect;
a.getBoundingClientRect = () => ({ top: 105, height: 20 }) as DOMRect; // đang dính ở đỉnh
b.getBoundingClientRect = () => ({ top: 300, height: 20 }) as DOMRect;
createStickyDates(box);
box.dispatchEvent(new Event('scroll'));
await vi.advanceTimersByTimeAsync(20);
expect(box.classList.contains('is-scrolling')).toBe(true);
await vi.advanceTimersByTimeAsync(1020);
expect(box.classList.contains('is-scrolling')).toBe(false);
expect(a.classList.contains('stuck')).toBe(true);
expect(b.classList.contains('stuck')).toBe(false);
vi.useRealTimers();
});
});
ts
#So với cách làm hiện đại
scrollbar-gutter: stable: giữ chỗ cho thanh cuộn, giải quyết tận gốc chuyện xô ngang khi tắt cuộn (caniuse). Safari hỗ trợ muộn hơn các trình duyệt khác; xem bảng trước khi dùng.- Scroll-driven animations (
animation-timeline: scroll()) cho phép làm hiệu ứng theo vị trí cuộn bằng CSS, nhưng chưa có cách thuần CSS để biết "đang cuộn hay đứng yên". Sự kiệnscrollend(xem caniuse để biết trình duyệt nào đã hỗ trợ) giúp bỏ debounce, nhưng nó bắn ngay khi cuộn dừng chứ không chờ 1 giây. - Bài Học từ Facebook #16 bàn về giữ vị trí khi chèn tin cũ, phần còn lại của câu chuyện cuộn trong khung chat.
Bài này thuộc series Học từ mã nguồn Telegram (25 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Mở đầu
Phần 1: dữ liệu và đồng bộ
- #1 Gửi tin lạc quan, khớp tin thật bằng randomId
- #2 Lịch sử tin nhắn chia hai đảo
- #3 Lưu global state lúc rảnh, chỉ giữ phần cần
- #4 Gom update realtime theo nhịp animation
- #5 Store tự viết và diff danh sách có key
Phần 2: animation và giao diện
- #6 Animation sắp xếp danh sách chat không đo DOM
- #7 Ảnh bay giữa hai cây DOM trong media viewer
- #8 Bố cục album chọn theo hàm chi phí
- #9 Giờ gửi nằm gọn trong bong bóng chat
- #10 Composer biến hình, Transition và gạch chân tab
Phần 3: media, mobile, thông báo
- #11 Thumbnail 100 byte và viền sticker nén
- #12 Player sticker: DPR, hạ fps, cache khung
- #13 Mobile: --vh, bàn phím ảo, nhấn giữ, số điện thoại
- #14 Web Push không trùng và đánh dấu đã đọc
Phần 4: soạn tin, media, chi tiết UI
- #15 Rich text ⇄ entities, thanh định dạng nổi, nháp theo chat
- #16 Ô nhập tự giãn, emoji phóng to, gom tin theo ngày/người/album
- #17 Bảng emoji/sticker chỉ render nhóm đang xem
- #18 mediaLoader gộp request, ảnh mờ sang nét, spinner không chớp
- #19 Trình phát audio toàn cục và playlist
- #20 UiLoader chạy đua 700ms, ba mức animation
- #21 PanZoom bằng matrix() trong media viewer
- #22 Ngày dính khi cuộn, khoá cuộn, dừng momentum iOS (bài này)
- #23 Vùng thả file, nén ảnh trước khi gửi, copy ảnh
- #24 Tiêu đề nhấp nháy và favicon đếm tin mới
Code + demo: examples/web-patterns · Mở trên StackBlitz

