@libs-ui/components-scroll-measure-items-direction-vertical
v0.2.357-2
Published
> Directive Angular thực hiện virtual scrolling theo chiều dọc với kích thước item động (dynamic height).
Downloads
2,388
Readme
@libs-ui/components-scroll-measure-items-direction-vertical
Directive Angular thực hiện virtual scrolling theo chiều dọc với kích thước item động (dynamic height).
Giới thiệu
LibsUiScrollMeasureItemDirectionVerticalDirective là một Angular directive cho phép ảo hóa danh sách lớn theo chiều dọc (vertical virtual scrolling). Directive đo chiều cao từng item bất đồng bộ thông qua hàm callback do consumer cung cấp, từ đó chỉ render các item thực sự nằm trong viewport, giúp hiệu năng không bị ảnh hưởng dù list có hàng nghìn phần tử. Khác với các thư viện cố định item height, directive này hỗ trợ các item có chiều cao khác nhau (heterogeneous heights).
Tính năng
- ✅ Virtual scrolling chiều dọc — chỉ render item trong viewport
- ✅ Hỗ trợ item có chiều cao động (dynamic height) qua hàm đo bất đồng bộ
- ✅ Tìm kiếm binary search O(log N) cho mọi sự kiện scroll
- ✅ Buffer item (3 items) phía dưới viewport để tránh trắng màn khi cuộn nhanh
- ✅ API điều khiển:
scrollInto,scrollToPosition,scrollToIndex,reCalculatorViewPort - ✅ ResizeObserver — tự tính lại viewport khi container thay đổi kích thước
- ✅ Cleanup tự động qua
DestroyRefkhi directive bị destroy - ✅ Standalone directive, không cần NgModule
Khi nào sử dụng
- Danh sách có số lượng item lớn (> 200 phần tử) cần scroll theo chiều dọc
- Các item trong list có chiều cao khác nhau (chat messages, feed, timeline)
- Cần điều khiển scroll bằng code: scroll đến item cụ thể, scroll đến vị trí, scroll theo index
- Bảng dữ liệu, danh sách sản phẩm, log viewer, chat history
Cài đặt
npm install @libs-ui/components-scroll-measure-items-direction-verticalImport
import { LibsUiScrollMeasureItemDirectionVerticalDirective } from '@libs-ui/components-scroll-measure-items-direction-vertical';
import { IScrollMeasureItemDirectionVerticalFunctionsControl } from '@libs-ui/components-scroll-measure-items-direction-vertical';
import { IScrollMeasureStoreItemVerticalConvert } from '@libs-ui/components-scroll-measure-items-direction-vertical';Ví dụ sử dụng
Ví dụ 1 — Danh sách cơ bản với chiều cao cố định
// component.ts
import { Component, signal } from '@angular/core';
import {
LibsUiScrollMeasureItemDirectionVerticalDirective,
IScrollMeasureItemDirectionVerticalFunctionsControl,
} from '@libs-ui/components-scroll-measure-items-direction-vertical';
interface T_product {
id: number;
name: string;
price: number;
}
@Component({
selector: 'app-product-list',
standalone: true,
imports: [LibsUiScrollMeasureItemDirectionVerticalDirective],
templateUrl: './product-list.component.html',
})
export class ProductListComponent {
protected items = signal<T_product[]>(
Array.from({ length: 500 }, (_, i) => ({
id: i + 1,
name: `Sản phẩm ${i + 1}`,
price: (i + 1) * 10000,
}))
);
protected viewPortItems = signal<T_product[]>([]);
private scrollControl: IScrollMeasureItemDirectionVerticalFunctionsControl | null = null;
// Hàm đo chiều cao — phải cộng cả margin/padding nếu có
protected measureHeight = async (item: T_product): Promise<number> => {
return 64; // item cao cố định 64px
};
protected handlerViewPortItems(items: T_product[]): void {
this.viewPortItems.set(items);
}
protected handlerFunctionControl(control: IScrollMeasureItemDirectionVerticalFunctionsControl): void {
this.scrollControl = control;
}
protected handlerScrollToTop(): void {
this.scrollControl?.scrollToIndex(0);
}
}<!-- product-list.component.html -->
<div
#scrollContainer
class="w-full h-[500px] overflow-y-auto border border-gray-200 rounded relative"
>
<div
class="block w-full"
LibsUiScrollMeasureItemDirectionVerticalDirective
[elementScroll]="scrollContainer"
[items]="items()"
[functionGetHeightItem]="measureHeight"
(outViewPortItem)="handlerViewPortItems($event)"
(outFunctionControl)="handlerFunctionControl($event)"
>
@for (item of viewPortItems(); track item.id) {
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 h-[64px]">
<span class="font-medium">{{ item.name }}</span>
<span class="text-blue-600">{{ item.price | number }} đ</span>
</div>
}
</div>
</div>
<button
(click)="handlerScrollToTop()"
class="mt-2 px-4 py-2 bg-blue-500 text-white rounded"
>
Lên đầu
</button>Ví dụ 2 — Danh sách với chiều cao động (dynamic height)
// chat-list.component.ts
import { Component, signal } from '@angular/core';
import {
LibsUiScrollMeasureItemDirectionVerticalDirective,
IScrollMeasureItemDirectionVerticalFunctionsControl,
} from '@libs-ui/components-scroll-measure-items-direction-vertical';
interface T_chatMessage {
id: number;
text: string;
sender: string;
}
@Component({
selector: 'app-chat-list',
standalone: true,
imports: [LibsUiScrollMeasureItemDirectionVerticalDirective],
templateUrl: './chat-list.component.html',
})
export class ChatListComponent {
protected messages = signal<T_chatMessage[]>([
{ id: 1, sender: 'Alice', text: 'Xin chào!' },
{ id: 2, sender: 'Bob', text: 'Chào bạn! Hôm nay bạn có khỏe không? Mình vừa xem xong bộ phim rất hay, bạn có muốn nghe kể không?' },
{ id: 3, sender: 'Alice', text: 'Mình khỏe, cảm ơn bạn!' },
// ... nhiều messages hơn
]);
protected viewPortMessages = signal<T_chatMessage[]>([]);
private scrollControl: IScrollMeasureItemDirectionVerticalFunctionsControl | null = null;
// Chiều cao động: tính theo độ dài text (48px base + 20px/100 ký tự)
// BẮT BUỘC cộng margin: my-[4px] = 8px (4px top + 4px bottom)
protected measureMessageHeight = async (msg: T_chatMessage): Promise<number> => {
const lineCount = Math.ceil(msg.text.length / 40);
return 48 + lineCount * 20 + 8;
};
protected handlerViewPortMessages(items: T_chatMessage[]): void {
this.viewPortMessages.set(items);
}
protected handlerFunctionControl(control: IScrollMeasureItemDirectionVerticalFunctionsControl): void {
this.scrollControl = control;
}
// Scroll đến tin nhắn mới nhất
protected handlerScrollToLatest(): void {
const lastIndex = this.messages().length - 1;
this.scrollControl?.scrollToIndex(lastIndex);
}
// Scroll đến tin nhắn cụ thể theo object reference
protected handlerScrollToMessage(msg: T_chatMessage): void {
this.scrollControl?.scrollInto(msg);
}
}<!-- chat-list.component.html -->
<div class="flex flex-col h-[600px]">
<div
#chatContainer
class="flex-1 overflow-y-auto relative"
>
<div
class="block w-full px-4"
LibsUiScrollMeasureItemDirectionVerticalDirective
[elementScroll]="chatContainer"
[items]="messages()"
[functionGetHeightItem]="measureMessageHeight"
(outViewPortItem)="handlerViewPortMessages($event)"
(outFunctionControl)="handlerFunctionControl($event)"
>
@for (msg of viewPortMessages(); track msg.id) {
<div class="my-[4px] p-3 rounded-lg bg-gray-50 border border-gray-100">
<span class="text-xs font-semibold text-blue-600">{{ msg.sender }}</span>
<p class="text-sm text-gray-800 mt-1">{{ msg.text }}</p>
</div>
}
</div>
</div>
<button
(click)="handlerScrollToLatest()"
class="mx-4 mb-4 py-2 bg-blue-500 text-white rounded text-sm"
>
Tin nhắn mới nhất
</button>
</div>Ví dụ 3 — Sử dụng reCalculatorViewPort khi item thay đổi chiều cao
// expandable-list.component.ts
import { Component, signal } from '@angular/core';
import {
LibsUiScrollMeasureItemDirectionVerticalDirective,
IScrollMeasureItemDirectionVerticalFunctionsControl,
} from '@libs-ui/components-scroll-measure-items-direction-vertical';
interface T_expandableItem {
id: number;
title: string;
detail: string;
expanded: boolean;
}
@Component({
selector: 'app-expandable-list',
standalone: true,
imports: [LibsUiScrollMeasureItemDirectionVerticalDirective],
templateUrl: './expandable-list.component.html',
})
export class ExpandableListComponent {
protected items = signal<T_expandableItem[]>(
Array.from({ length: 200 }, (_, i) => ({
id: i + 1,
title: `Mục ${i + 1}`,
detail: `Chi tiết cho mục ${i + 1}. Thông tin này xuất hiện khi expand.`,
expanded: false,
}))
);
protected viewPortItems = signal<T_expandableItem[]>([]);
private scrollControl: IScrollMeasureItemDirectionVerticalFunctionsControl | null = null;
protected measureHeight = async (item: T_expandableItem): Promise<number> => {
// 48px thu gọn, 96px khi mở rộng, cộng 4px margin
return item.expanded ? 96 + 4 : 48 + 4;
};
protected handlerViewPortItems(items: T_expandableItem[]): void {
this.viewPortItems.set(items);
}
protected handlerFunctionControl(control: IScrollMeasureItemDirectionVerticalFunctionsControl): void {
this.scrollControl = control;
}
// Sau khi toggle, gọi reCalculatorViewPort để tính lại heights
protected handlerToggleItem(item: T_expandableItem): void {
item.expanded = !item.expanded;
this.items.set([...this.items()]);
// Tính lại viewport sau khi chiều cao thay đổi
this.scrollControl?.reCalculatorViewPort();
}
}<!-- expandable-list.component.html -->
<div
#listContainer
class="w-full h-[400px] overflow-y-auto border border-gray-200 rounded relative"
>
<div
class="block w-full"
LibsUiScrollMeasureItemDirectionVerticalDirective
[elementScroll]="listContainer"
[items]="items()"
[functionGetHeightItem]="measureHeight"
(outViewPortItem)="handlerViewPortItems($event)"
(outFunctionControl)="handlerFunctionControl($event)"
>
@for (item of viewPortItems(); track item.id) {
<div
class="my-[2px] px-4 border border-gray-100 rounded cursor-pointer"
[class.h-[48px]]="!item.expanded"
[class.h-[96px]]="item.expanded"
(click)="handlerToggleItem(item)"
>
<div class="flex items-center h-[48px]">
<span class="font-medium">{{ item.title }}</span>
<span class="ml-auto text-gray-400 text-xs">{{ item.expanded ? '▲' : '▼' }}</span>
</div>
@if (item.expanded) {
<p class="text-sm text-gray-600 pb-2">{{ item.detail }}</p>
}
</div>
}
</div>
</div>@Input()
| Input | Type | Default | Mô tả | Ví dụ |
|---|---|---|---|---|
| elementScroll | HTMLElement | required | Element chứa thanh cuộn dọc (overflow-y: auto/scroll). Thường là outer container được gắn template reference #scrollContainer. | [elementScroll]="scrollContainer" |
| items | Array<any> | required | Toàn bộ danh sách dữ liệu gốc. Directive đọc signal này để tính toán viewport và tái tính lại khi array thay đổi. | [items]="items()" |
| functionGetHeightItem | (item: any) => Promise<number> | required | Hàm bất đồng bộ nhận vào một item và trả về chiều cao (px) của item đó, bao gồm cả margin trên/dưới. Được gọi song song cho toàn bộ mảng để tối ưu tốc độ. | [functionGetHeightItem]="measureHeight" |
@Output()
| Output | Type | Mô tả | Handler TS | Binding HTML |
|---|---|---|---|---|
| (outViewPortItem) | Array<any> | Emit danh sách items cần render trong viewport hiện tại (bao gồm buffer 3 items phía dưới). Component con phải lưu vào signal riêng và dùng trong @for. | handlerViewPortItems(items: any[]): void { this.viewPortItems.set(items); } | (outViewPortItem)="handlerViewPortItems($event)" |
| (outFunctionControl) | IScrollMeasureItemDirectionVerticalFunctionsControl | Emit object chứa đầy đủ API điều khiển scroll. Emit một lần sau ngAfterViewInit. | handlerFunctionControl(ctrl: IScrollMeasureItemDirectionVerticalFunctionsControl): void { this.scrollControl = ctrl; } | (outFunctionControl)="handlerFunctionControl($event)" |
| (outDivVirtual) | HTMLDivElement | Emit element ảo (absolute, visibility hidden) được inject vào scroll container để giữ tổng chiều cao scroll đúng. Ít khi cần dùng trực tiếp. | handlerDivVirtual(el: HTMLDivElement): void { this.virtualEl = el; } | (outDivVirtual)="handlerDivVirtual($event)" |
| (outPaddingTop) | number | Emit giá trị padding-top (px) đang được áp dụng lên inner container để bù khoảng trống các item bị ẩn phía trên viewport. | handlerPaddingTop(value: number): void { this.currentPaddingTop = value; } | (outPaddingTop)="handlerPaddingTop($event)" |
FunctionsControl API
Nhận đối tượng IScrollMeasureItemDirectionVerticalFunctionsControl qua output (outFunctionControl) để điều khiển scroll bằng code:
import { IScrollMeasureItemDirectionVerticalFunctionsControl } from '@libs-ui/components-scroll-measure-items-direction-vertical';
private scrollControl: IScrollMeasureItemDirectionVerticalFunctionsControl | null = null;| Method | Signature | Mô tả |
|---|---|---|
| scrollInto | (item: any) => Promise<void> | Scroll container đến vị trí của item theo object reference. Item phải tồn tại trong items(). |
| scrollToPosition | (position: number) => Promise<void> | Scroll container đến vị trí scrollTop tuyệt đối (px) chỉ định. |
| scrollToIndex | (index: number) => Promise<void> | Scroll container đến item tại index chỉ định trong mảng items(). |
| reCalculatorViewPort | () => Promise<void> | Tính lại toàn bộ chiều cao và viewport. Gọi sau khi chiều cao item thay đổi (expand/collapse, nội dung thay đổi). |
| getViewPortItems | () => Array<any> | Trả về mảng items hiện đang trong viewport (đồng bộ). |
// Cách sử dụng FunctionsControl
protected handlerScrollToTop(): void {
this.scrollControl?.scrollToIndex(0);
}
protected handlerScrollToItem(item: T_product): void {
this.scrollControl?.scrollInto(item);
}
protected handlerScrollToMiddle(): void {
const middleIndex = Math.floor(this.items().length / 2);
this.scrollControl?.scrollToIndex(middleIndex);
}
protected handlerRefreshHeights(): void {
this.scrollControl?.reCalculatorViewPort();
}Types & Interfaces
import {
IScrollMeasureItemDirectionVerticalFunctionsControl,
IScrollMeasureStoreItemVerticalConvert,
} from '@libs-ui/components-scroll-measure-items-direction-vertical';
// API điều khiển scroll — nhận qua (outFunctionControl)
interface IScrollMeasureItemDirectionVerticalFunctionsControl {
scrollInto: (item: any) => Promise<void>;
scrollToPosition: (position: number) => Promise<void>;
scrollToIndex: (index: number) => Promise<void>;
reCalculatorViewPort: () => Promise<void>;
getViewPortItems: () => Array<any>;
}
// Cấu trúc nội bộ của item đã được đo — dùng khi cần truy cập tọa độ
interface IScrollMeasureStoreItemVerticalConvert {
ref: any; // Tham chiếu item gốc từ mảng items
itemHeight: number; // Chiều cao đã đo (px)
start: number; // Tọa độ Y bắt đầu của item (px từ đỉnh container)
end: number; // Tọa độ Y kết thúc của item (px từ đỉnh container)
}Lưu ý quan trọng
⚠️ elementScroll là outer container: Truyền [elementScroll] là element có overflow-y: auto/scroll, không phải element được gắn directive. Directive gắn vào inner container (div wrapper bên trong), còn elementScroll là outer div chứa thanh cuộn.
⚠️ Inner container phải là block: Element được gắn directive (inner container) PHẢI có class block hoặc display: block. Directive tự thêm position: relative và điều chỉnh padding-top lên element này, nên layout flex hay grid trực tiếp trên element đó sẽ gây sai vị trí.
⚠️ Chiều cao trong functionGetHeightItem phải bao gồm margin: Nếu item có my-[4px] (4px top + 4px bottom = 8px), cộng 8px vào giá trị trả về. Ví dụ: item cao 60px + margin 8px → trả về 68.
⚠️ Gọi reCalculatorViewPort sau khi item thay đổi chiều cao: Khi expand/collapse item hoặc nội dung thay đổi chiều cao, phải gọi scrollControl.reCalculatorViewPort() để directive tính lại. Chỉ thay đổi items() signal không đủ vì directive chỉ đo lại heights khi array reference thay đổi.
⚠️ Không dùng track $index trong @for: Theo coding convention dự án, bắt buộc dùng track item.id hoặc track item (track object reference). Dùng track $index gây bug re-render lộn xộn với virtual scroll.
⚠️ viewPortItems signal phải được khai báo riêng: Directive không quản lý state của consumer. Consumer phải tự khai báo protected viewPortItems = signal<T[]>([]) và cập nhật trong handler (outViewPortItem).
Demo
npx nx serve core-uiTruy cập: http://localhost:4500/scroll-measure-items/direction-vertical
