@libs-ui/components-scroll-measure-items-direction-horizontal
v0.2.357-2
Published
> Directive ảo hóa danh sách theo chiều ngang (horizontal virtual scrolling) với kích thước item động (dynamic width).
Readme
@libs-ui/components-scroll-measure-items-direction-horizontal
Directive ảo hóa danh sách theo chiều ngang (horizontal virtual scrolling) với kích thước item động (dynamic width).
Giới thiệu
LibsUiScrollMeasureItemDirectionHorizontalDirective là directive Angular giúp tối ưu hiệu năng khi render danh sách lớn theo chiều ngang. Thay vì render toàn bộ DOM, directive chỉ giữ trong DOM các item đang nằm trong viewport (cộng thêm một số buffer), đồng thời dùng một element ảo để duy trì kích thước scroll chính xác. Directive hỗ trợ item có chiều rộng không đồng đều thông qua hàm đo width bất đồng bộ do consumer cung cấp.
Tính năng
- ✅ Virtual Scrolling chiều ngang: Chỉ render item trong vùng nhìn thấy, giảm số lượng DOM node đáng kể.
- ✅ Dynamic Width: Hỗ trợ mỗi item có chiều rộng khác nhau thông qua hàm
functionGetWidthItembất đồng bộ. - ✅ Buffer ahead: Tự động render thêm 3 item ngoài viewport để tránh khoảng trắng khi scroll nhanh.
- ✅ Scroll Control API: Cung cấp
scrollInto,scrollToIndex,scrollToPosition,reCalculatorViewPortqua outputoutFunctionControl. - ✅ ResizeObserver tích hợp: Tự động tính lại viewport khi kích thước container thay đổi.
- ✅ Đo width song song: Dùng
Promise.allđể đo width toàn bộ item cùng lúc — tối ưu cho list hàng nghìn cột. - ✅ Tự cleanup: Hủy observer, xóa element ảo và timer khi directive bị destroy.
Khi nào sử dụng
- Hiển thị danh sách ảnh, thẻ, tab, cột bảng theo chiều ngang với số lượng lớn (hàng trăm đến hàng nghìn item).
- Kích thước các item không đều nhau (ví dụ: tên cột dài ngắn khác nhau trong bảng dữ liệu lớn).
- Cần cuộn đến một item hoặc index cụ thể theo chương trình (
scrollToIndex,scrollInto). - Cần tính lại viewport khi dữ liệu hoặc kích thước container thay đổi động (
reCalculatorViewPort).
Cài đặt
npm install @libs-ui/components-scroll-measure-items-direction-horizontalImport
import {
LibsUiScrollMeasureItemDirectionHorizontalDirective,
IScrollMeasureItemDirectionHorizontalFunctionsControl,
IScrollMeasureStoreItemConvert,
} from '@libs-ui/components-scroll-measure-items-direction-horizontal';
@Component({
standalone: true,
imports: [LibsUiScrollMeasureItemDirectionHorizontalDirective],
// ...
})
export class MyComponent {}Ví dụ sử dụng
Ví dụ 1 — Virtual scroll cơ bản (1000 item, chiều rộng đồng đều)
// my-list.component.ts
import { Component, signal } from '@angular/core';
import {
LibsUiScrollMeasureItemDirectionHorizontalDirective,
IScrollMeasureItemDirectionHorizontalFunctionsControl,
} from '@libs-ui/components-scroll-measure-items-direction-horizontal';
interface T_Column {
id: number;
label: string;
}
@Component({
selector: 'app-my-list',
standalone: true,
imports: [LibsUiScrollMeasureItemDirectionHorizontalDirective],
templateUrl: './my-list.component.html',
})
export class MyListComponent {
protected columns = signal<T_Column[]>(
Array.from({ length: 1000 }, (_, i) => ({ id: i + 1, label: `Cột ${i + 1}` }))
);
protected viewPortColumns = signal<T_Column[]>([]);
protected scrollControl: IScrollMeasureItemDirectionHorizontalFunctionsControl | null = null;
protected measureColumnWidth = async (_item: T_Column): Promise<number> => {
// Chiều rộng cố định 120px mỗi cột
return 120;
};
protected handlerViewPortItem(items: T_Column[]): void {
this.viewPortColumns.set(items);
}
protected handlerFunctionControl(control: IScrollMeasureItemDirectionHorizontalFunctionsControl): void {
this.scrollControl = control;
}
protected handlerScrollToFirst(): void {
this.scrollControl?.scrollToIndex(0);
}
protected handlerScrollToLast(): void {
this.scrollControl?.scrollToIndex(this.columns().length - 1);
}
}<!-- my-list.component.html -->
<div class="flex gap-2 mb-4">
<button (click)="handlerScrollToFirst()">Đến đầu</button>
<button (click)="handlerScrollToLast()">Đến cuối</button>
</div>
<!-- Outer container: overflow-x-auto, có thanh cuộn ngang -->
<div
#scrollContainer
class="w-full h-[80px] overflow-x-auto border border-gray-300 rounded relative">
<!-- Inner container: gắn directive, nằm bên trong outer -->
<div
class="flex items-center h-full"
LibsUiScrollMeasureItemDirectionHorizontalDirective
[elementScroll]="scrollContainer"
[items]="columns()"
[functionGetWidthItem]="measureColumnWidth"
(outViewPortItem)="handlerViewPortItem($event)"
(outFunctionControl)="handlerFunctionControl($event)">
@for (col of viewPortColumns(); track col.id) {
<div class="shrink-0 w-[120px] h-[60px] flex items-center justify-center border-r border-gray-200 text-sm">
{{ col.label }}
</div>
}
</div>
</div>Ví dụ 2 — Item có chiều rộng động (tính từ nội dung)
// dynamic-width-list.component.ts
import { Component, signal } from '@angular/core';
import {
LibsUiScrollMeasureItemDirectionHorizontalDirective,
IScrollMeasureItemDirectionHorizontalFunctionsControl,
} from '@libs-ui/components-scroll-measure-items-direction-horizontal';
interface T_Tag {
id: number;
name: string;
}
@Component({
selector: 'app-dynamic-width-list',
standalone: true,
imports: [LibsUiScrollMeasureItemDirectionHorizontalDirective],
templateUrl: './dynamic-width-list.component.html',
})
export class DynamicWidthListComponent {
protected tags = signal<T_Tag[]>([
{ id: 1, name: 'Angular' },
{ id: 2, name: 'Virtual Scroll' },
{ id: 3, name: 'Performance Optimization' },
{ id: 4, name: 'UI' },
{ id: 5, name: 'Horizontal Scrolling with Dynamic Items' },
{ id: 6, name: 'TypeScript' },
{ id: 7, name: 'RxJS' },
{ id: 8, name: 'Signal' },
]);
protected viewPortTags = signal<T_Tag[]>([]);
protected scrollControl: IScrollMeasureItemDirectionHorizontalFunctionsControl | null = null;
/**
* Ước tính chiều rộng dựa trên số ký tự.
* Cộng thêm 32px padding trái/phải (16px mỗi bên) và 8px gap.
*/
protected measureTagWidth = async (item: T_Tag): Promise<number> => {
const charWidth = 8;
return item.name.length * charWidth + 32 + 8;
};
protected handlerViewPortItem(items: T_Tag[]): void {
this.viewPortTags.set(items);
}
protected handlerFunctionControl(control: IScrollMeasureItemDirectionHorizontalFunctionsControl): void {
this.scrollControl = control;
}
protected handlerScrollIntoTag(tag: T_Tag): void {
this.scrollControl?.scrollInto(tag);
}
}<!-- dynamic-width-list.component.html -->
<div
#tagScrollContainer
class="w-full h-[60px] overflow-x-auto relative">
<div
class="flex items-center gap-2 h-full"
LibsUiScrollMeasureItemDirectionHorizontalDirective
[elementScroll]="tagScrollContainer"
[items]="tags()"
[functionGetWidthItem]="measureTagWidth"
(outViewPortItem)="handlerViewPortItem($event)"
(outFunctionControl)="handlerFunctionControl($event)">
@for (tag of viewPortTags(); track tag.id) {
<span
class="shrink-0 px-4 py-1 bg-blue-100 text-blue-800 rounded-full text-sm whitespace-nowrap cursor-pointer"
(click)="handlerScrollIntoTag(tag)">
{{ tag.name }}
</span>
}
</div>
</div>Ví dụ 3 — Cuộn theo chương trình và tính lại viewport
// programmatic-scroll.component.ts
import { Component, signal } from '@angular/core';
import {
LibsUiScrollMeasureItemDirectionHorizontalDirective,
IScrollMeasureItemDirectionHorizontalFunctionsControl,
} from '@libs-ui/components-scroll-measure-items-direction-horizontal';
interface T_Photo {
id: number;
width: number;
src: string;
}
@Component({
selector: 'app-programmatic-scroll',
standalone: true,
imports: [LibsUiScrollMeasureItemDirectionHorizontalDirective],
templateUrl: './programmatic-scroll.component.html',
})
export class ProgrammaticScrollComponent {
protected photos = signal<T_Photo[]>(
Array.from({ length: 500 }, (_, i) => ({
id: i + 1,
width: 100 + (i % 4) * 50, // 100, 150, 200, 250 lặp lại
src: `https://picsum.photos/id/${i + 1}/${100 + (i % 4) * 50}/80`,
}))
);
protected viewPortPhotos = signal<T_Photo[]>([]);
protected scrollControl: IScrollMeasureItemDirectionHorizontalFunctionsControl | null = null;
protected measurePhotoWidth = async (item: T_Photo): Promise<number> => {
// Cộng thêm 8px gap giữa các ảnh
return item.width + 8;
};
protected handlerViewPortItem(items: T_Photo[]): void {
this.viewPortPhotos.set(items);
}
protected handlerFunctionControl(control: IScrollMeasureItemDirectionHorizontalFunctionsControl): void {
this.scrollControl = control;
}
protected handlerScrollToIndex(index: number): void {
this.scrollControl?.scrollToIndex(index);
}
protected handlerScrollToPosition(px: number): void {
this.scrollControl?.scrollToPosition(px);
}
protected handlerRecalculate(): void {
this.scrollControl?.reCalculatorViewPort();
}
}<!-- programmatic-scroll.component.html -->
<div class="flex gap-2 mb-3">
<button (click)="handlerScrollToIndex(0)">Đầu (0)</button>
<button (click)="handlerScrollToIndex(249)">Giữa (249)</button>
<button (click)="handlerScrollToIndex(499)">Cuối (499)</button>
<button (click)="handlerScrollToPosition(1000)">Vị trí 1000px</button>
<button (click)="handlerRecalculate()">Tính lại viewport</button>
</div>
<div
#photoScroll
class="w-full h-[100px] overflow-x-auto relative border border-gray-200 rounded">
<div
class="flex items-center gap-2 h-full"
LibsUiScrollMeasureItemDirectionHorizontalDirective
[elementScroll]="photoScroll"
[items]="photos()"
[functionGetWidthItem]="measurePhotoWidth"
(outViewPortItem)="handlerViewPortItem($event)"
(outFunctionControl)="handlerFunctionControl($event)">
@for (photo of viewPortPhotos(); track photo.id) {
<img
class="shrink-0 h-[80px] rounded object-cover"
[style.width.px]="photo.width"
[src]="photo.src"
[alt]="'Photo ' + photo.id" />
}
</div>
</div>@Input()
| Input | Type | Default | Mô tả | Ví dụ |
|---|---|---|---|---|
| [elementScroll] | HTMLElement | required | Element cha có overflow-x: auto chứa thanh cuộn ngang. Thường dùng template variable #scrollContainer. | [elementScroll]="scrollContainer" |
| [items] | Array<any> | required | Toàn bộ mảng dữ liệu nguồn. Directive tự tính toán item nào cần render dựa trên viewport. | [items]="columns()" |
| [functionGetWidthItem] | (item: any) => Promise<number> | required | Hàm bất đồng bộ trả về chiều rộng (px) của một item, bao gồm cả padding và margin/gap. Được gọi song song cho toàn bộ mảng khi items thay đổi. | [functionGetWidthItem]="measureWidth" |
@Output()
| Output | Type | Mô tả | Handler TS | Binding HTML |
|---|---|---|---|---|
| (outViewPortItem) | Array<any> | Emit danh sách item cần render trong viewport (kèm buffer 3 item). Consumer dùng mảng này cho @for. | handlerViewPortItem(items: T_Item[]): void { items.stopPropagation?.(); this.viewPortItems.set(items); } | (outViewPortItem)="handlerViewPortItem($event)" |
| (outFunctionControl) | IScrollMeasureItemDirectionHorizontalFunctionsControl | Emit object chứa các hàm điều khiển scroll. Lưu lại để gọi scrollToIndex, scrollInto,... theo chương trình. | handlerFunctionControl(ctrl: IScrollMeasureItemDirectionHorizontalFunctionsControl): void { this.scrollControl = ctrl; } | (outFunctionControl)="handlerFunctionControl($event)" |
| (outDivVirtual) | HTMLDivElement | Emit element ảo (invisible) được chèn vào container để duy trì tổng chiều rộng scroll. Ít khi cần dùng trực tiếp. | handlerDivVirtual(el: HTMLDivElement): void { this.virtualEl = el; } | (outDivVirtual)="handlerDivVirtual($event)" |
| (outPaddingLeft) | number | Emit giá trị paddingLeft (px) hiện tại của inner container — bù cho các item đã scroll qua khỏi viewport bên trái. | handlerPaddingLeft(value: number): void { this.currentPaddingLeft = value; } | (outPaddingLeft)="handlerPaddingLeft($event)" |
Types & Interfaces
import {
IScrollMeasureItemDirectionHorizontalFunctionsControl,
IScrollMeasureStoreItemConvert,
} from '@libs-ui/components-scroll-measure-items-direction-horizontal';
/**
* Object điều khiển scroll được emit qua (outFunctionControl).
* Lưu lại reference sau khi nhận để gọi các phương thức theo chương trình.
*/
interface IScrollMeasureItemDirectionHorizontalFunctionsControl {
/** Scroll container đến vị trí bắt đầu của item được truyền vào. */
scrollInto: (item: any) => Promise<void>;
/** Scroll container đến vị trí pixel chỉ định (scrollLeft). */
scrollToPosition: (position: number) => Promise<void>;
/** Scroll container đến vị trí bắt đầu của item tại index chỉ định. */
scrollToIndex: (index: number) => Promise<void>;
/**
* Gọi lại hàm đo width toàn bộ item và tính lại vùng hiển thị.
* Dùng khi kích thước item thay đổi sau khi render (ví dụ: font load xong, dữ liệu cập nhật).
*/
reCalculatorViewPort: () => Promise<void>;
/** Trả về mảng item đang hiển thị trong viewport tại thời điểm gọi. */
getViewPortItems: () => Array<any>;
}
/**
* Cấu trúc nội bộ lưu trữ thông tin vị trí từng item.
* Được emit qua (outDivVirtual) nếu consumer cần debug layout.
*/
interface IScrollMeasureStoreItemConvert {
/** Tham chiếu đến item gốc trong mảng [items]. */
ref: any;
/** Chiều rộng tính toán (px) của item này. */
itemWidth: number;
/** Vị trí bắt đầu (scrollLeft) của item, tính từ đầu container. */
start: number;
/** Vị trí kết thúc (scrollLeft + itemWidth) của item. */
end: number;
}Lưu ý quan trọng
⚠️ Kiến trúc 2 lớp bắt buộc: Directive phải được gắn vào inner container (thẻ flex bên trong), KHÔNG phải outer container có overflow-x: auto. Input [elementScroll] phải trỏ đến outer container — đây là element có thanh cuộn thực sự.
⚠️ Hàm functionGetWidthItem phải tính cả margin/gap: Chiều rộng trả về phải bao gồm toàn bộ không gian mà item chiếm (bao gồm margin, gap). Nếu thiếu, tổng chiều rộng ảo sẽ sai và scroll sẽ bị lệch vị trí.
⚠️ Directive thêm position: relative và điều chỉnh paddingLeft vào inner container: Không đặt các style này thủ công trên element gắn directive vì sẽ bị override. Thay vào đó, dùng output (outPaddingLeft) nếu cần biết giá trị hiện tại.
⚠️ Element ảo divVirtual được chèn vào outer container: Directive tự tạo và quản lý một <div> ẩn bên trong elementScroll để giữ đúng tổng chiều rộng scroll. Không cần tạo thủ công. Element này tự xóa khi directive destroy.
⚠️ items thay đổi reference mới kích hoạt đo lại: Mỗi khi items signal thay đổi, toàn bộ width được đo lại song song qua Promise.all. Với list rất lớn (> 5000 item) và hàm đo nặng, cân nhắc debounce ở phía consumer trước khi cập nhật items.
Demo
npx nx serve core-uiTruy cập: http://localhost:4500/components/scroll-measure-items/direction-horizontal
