Hướng Nghiệp Dữ Liệu

Cách dùng ListView, GridView, PageView – 3 widget bắt buộc phải biết

Được viết bởi Admin|0 lượt xem
Cách dùng ListView, GridView, PageView – 3 widget bắt buộc phải biết

Tóm tắt nhanh

Hướng dẫn chi tiết cách sử dụng ListView, GridView và PageView trong Flutter - 3 widget quan trọng nhất cho hiển thị danh sách.

Mục lục bài viết (51)

ListView, GridView và PageView là 3 widget quan trọng nhất để hiển thị danh sách trong Flutter. Bài viết này sẽ hướng dẫn bạn sử dụng chúng.

ListView

1. ListView cơ bản

[object Object]
[object Object]

3. ListView.separated

[object Object]

4. ListView với scroll direction

[object Object]

GridView

1. GridView.count

[object Object]
[object Object]

3. GridView với aspect ratio

[object Object]

PageView

1. PageView cơ bản

[object Object]

2. PageView.builder

[object Object]

3. PageView với controller

[object Object]

Ví dụ thực tế

1. Product List

[object Object]
[object Object]

3. Onboarding Pages

[object Object]

Best Practices

1. Sử dụng builder

[object Object]

2. Lazy Loading

[object Object]

Chọn widget nào cho trường hợp nào

Ba widget này khác nhau ở cách cuộn, và đó là tiêu chí chọn:

  • Danh sách một chiều, số lượng không biết trước: ListView.
  • Lưới ảnh, thẻ sản phẩm, thư viện: GridView.
  • Từng trang toàn màn hình, cuộn ngang kiểu lật trang: PageView — banner quảng cáo, màn hình giới thiệu, trình xem ảnh.
  • Nhiều loại nội dung xếp dọc trong cùng một vùng cuộn (banner + danh sách + lưới): dùng CustomScrollView với các sliver, không lồng nhiều widget cuộn vào nhau.

Một điều cần nhớ: cả ba widget đều là "vùng cuộn", nên không lồng vùng cuộn vào vùng cuộn cùng chiều nếu không có lý do rõ ràng. Đây là nguồn gốc của phần lớn lỗi về cuộn trong Flutter.

ListView: những tham số ảnh hưởng trực tiếp tới hiệu năng

Sau khi đã dùng builder, đây là các tham số tiếp theo đáng quan tâm:

[object Object]

Giải thích ngắn gọn lợi ích:

  • itemExtent hoặc prototypeItem: khi biết trước chiều cao mỗi dòng, Flutter tính được vị trí cuộn mà không phải đo từng phần tử. Với danh sách vài nghìn dòng, khác biệt thấy rõ khi kéo thanh cuộn nhanh.
  • cacheExtent: tăng lên sẽ dựng sẵn nhiều phần tử ngoài màn hình, cuộn mượt hơn nhưng tốn bộ nhớ hơn. Không nên đặt quá lớn.
  • addAutomaticKeepAlives: giữ trạng thái các item đang hiển thị, hữu ích khi mỗi dòng có ô nhập liệu hoặc video đang phát.

Với danh sách lớn còn có ListView.custom cho phép tự quản lý phần tử con, nhưng chỉ nên dùng khi bạn thật sự cần kiểm soát ở mức đó.

GridView: chọn gridDelegate theo màn hình, không theo số cột cố định

SliverGridDelegateWithFixedCrossAxisCount với crossAxisCount: 2 là cách viết phổ biến nhưng có nhược điểm: trên tablet, hai cột sẽ thành hai cột rất to, và trên màn hình nhỏ thì hai cột lại quá chật.

Cách làm linh hoạt hơn là đặt chiều rộng tối đa cho mỗi ô và để Flutter tự tính số cột:

[object Object]

Hai điểm rất hay gây lỗi khi làm lưới:

  • childAspectRatio sai làm nội dung tràn ra ngoài ô và hiện cảnh báo tràn khung. Nếu nội dung có chiều cao thay đổi theo chữ, nên dùng mainAxisExtent (chiều cao cố định tính bằng pixel) để dễ kiểm soát hơn là đoán tỉ lệ.
  • Ảnh trong lưới cần fit: BoxFit.cover và kích thước ảnh gần với kích thước hiển thị. Dùng ảnh gốc vài MB cho ô 200px là nguyên nhân phổ biến của việc cuộn giật, xem thêm bài tối ưu hiệu năng Flutter.

PageView: điều khiển, chỉ báo trang và các lỗi thật

PageView dùng PageController để biết trang hiện tại và để nhảy tới trang mong muốn.

[object Object]

Bốn lỗi thường gặp với PageView:

  1. Quên dispose cho PageController. Mở/đóng trình xem ảnh nhiều lần sẽ rò rỉ bộ nhớ.
  2. Gọi nextPage khi controller chưa gắn vào widget. Cần kiểm tra _controller.hasClients trước khi điều khiển.
  3. Dùng viewportFraction = 1.0 cho băng chuyền. Muốn thấy một phần trang kế tiếp như carousel, đặt viewportFraction: 0.85 và cuộn về giữa bằng padEnds: false.
  4. Đặt ảnh chất lượng cao cho mọi trang. PageView chỉ dựng vài trang gần đó, nhưng ảnh lớn vẫn làm giật khi vuốt; nên tải ảnh theo kích thước hiển thị.

Cuộn lồng nhau: lỗi kinh điển và cách sửa đúng

Đây là lỗi gần như ai cũng gặp ít nhất một lần: đặt ListView bên trong Column và nhận lỗi Vertical viewport was given unbounded height.

Nguyên nhân: ListView muốn chiếm toàn bộ chiều cao còn trống, nhưng Column không giới hạn chiều cao cho con của nó. Ba cách xử lý, theo thứ tự nên cân nhắc:

  1. Bọc trong Expanded nếu danh sách nên chiếm hết phần còn lại của màn hình. Đây là cách đúng trong đa số trường hợp.
  2. shrinkWrap: true kèm physics: NeverScrollableScrollPhysics() khi danh sách ngắn và bạn muốn cả trang cuộn như một khối. Lưu ý shrinkWrap buộc Flutter đo toàn bộ phần tử, nên đừng dùng cho danh sách dài.
  3. Chuyển sang CustomScrollView với sliver khi trang thực sự có nhiều khối cuộn chung một trục:
[object Object]

Với CustomScrollView, tất cả khối nằm trong một vùng cuộn duy nhất nên không còn tình trạng hai vùng cuộn tranh nhau, và thanh cuộn phản ánh đúng toàn bộ chiều dài trang.

Những lỗi khác hay gặp

  1. Dùng chỉ số làm key khi danh sách có thay đổi thứ tự. Hãy dùng ValueKey(item.id) để trạng thái không "nhảy" sang phần tử khác.
  2. Gọi tải thêm bên trong itemBuilder. itemBuilder chạy mỗi khi phần tử được dựng, nên hàm tải sẽ bị gọi nhiều lần. Phải chặn bằng cờ trạng thái ở ngoài — xem bài về ScrollController và tải thêm dữ liệu.
  3. Danh sách nằm trong widget không có chiều cao xác định, ví dụ trong Row mà không bọc Expanded hay SizedBox.
  4. Trộn nhiều trục cuộn mà không có tín hiệu cho người dùng. Một danh sách dọc chứa lưới cuộn ngang bên trong vẫn ổn, nhưng nên có chỉ báo để người dùng biết còn nội dung ở hai bên.

Câu hỏi thường gặp

Khi nào dùng ListView thường thay vì ListView.builder?

Khi số phần tử ít và cố định, ví dụ menu cài đặt có 6 dòng. Với danh sách lấy từ API hoặc có thể dài, luôn dùng builder.

GridView có hỗ trợ tải thêm khi cuộn không?

Có, hoạt động giống ListView. Bạn dùng ScrollController hoặc chuyển sang CustomScrollView với SliverGrid và thêm một phần tử ở cuối làm nơi hiển thị trạng thái đang tải.

Vì sao danh sách bị vẽ lại nhiều lần khi cuộn?

Vì các phần tử ra khỏi vùng nhìn thấy có thể bị xoá và dựng lại. Cách giảm: đặt const cho phần tĩnh, tách widget nhỏ, dùng RepaintBoundary cho phần có hiệu ứng riêng.

Trên web, danh sách dài có vấn đề gì không?

Có một khác biệt: chuột cuộn từng nấc nên người dùng dễ cảm thấy giật nếu danh sách rất dài. Cân nhắc bật cuộn mượt cho vùng cuộn và tránh shrinkWrap trên danh sách lớn.

Sliver: khi một trang có nhiều khối cuộn khác loại

Ba widget trên đủ cho phần lớn màn hình. Nhưng khi trang có nhiều khối cùng cuộn theo một trục — banner, danh sách, lưới, phần giới thiệu — bạn cần đến sliver.

Các sliver dùng thường xuyên nhất:

  • SliverToBoxAdapter: bọc một widget bình thường vào thế giới sliver. Đây là cầu nối bạn dùng nhiều nhất.
  • SliverList: danh sách theo chiều dọc, có bản builder để tải lười.
  • SliverGrid: lưới, dùng cùng các gridDelegate như GridView.
  • SliverPadding: thêm khoảng đệm cho sliver con (không dùng Padding cho sliver — sẽ lỗi).
  • SliverFillRemaining: chiếm trọn phần còn lại, hữu ích cho trạng thái rỗng hoặc lỗi ở cuối trang.
  • SliverAppBar: thanh tiêu đề thu gọn theo nội dung.
[object Object]

Lợi ích lớn nhất không phải là code gọn hơn, mà là một thanh cuộn duy nhất cho cả trang. Người dùng thấy đúng độ dài trang, kéo một lần là đi hết nội dung, và không gặp tình trạng hai vùng cuộn tranh nhau xử lý cử chỉ.

Một lỗi rất hay gặp: đặt Padding hoặc Container trực tiếp trong slivers. slivers chỉ nhận sliver, nên bạn cần SliverToBoxAdapter hoặc SliverPadding. Nếu không, bạn sẽ gặp lỗi kiểu chứa widget không hợp lệ.

Danh sách có tiêu đề nhóm dính

Ứng dụng danh bạ, tin nhắn, hoặc danh sách bài theo chuyên mục thường cần tiêu đề nhóm dính ở trên khi nhóm đó đang được xem. Cách làm sạch nhất là dùng SliverPersistentHeader với pinned: true.

Ý tưởng: dữ liệu phẳng, nhưng chèn thêm một phần tử đánh dấu nhóm; khi cuộn, tiêu đề nhóm hiện tại dính lại trên cùng cho tới khi nhóm tiếp theo đẩy nó lên.

[object Object]

Cách này tránh phải tự tính vị trí dính bằng tay — việc rất dễ sai khi danh sách có phần tử cao thấp khác nhau. Lưu ý bộ đệm dính sẽ chiếm chỗ trên màn hình, nên hãy giữ chiều cao tiêu đề nhóm nhỏ (khoảng 32–48 điểm ảnh).

Thêm và xoá phần tử có hiệu ứng

ListView.builder cập nhật tức thì, không có hiệu ứng, nên người dùng khó nhận ra thao tác vừa thực hiện. Khi cần hiệu ứng xuất hiện và biến mất, có hai hướng:

  • Danh sách hiện có, chỉ cần hiệu ứng nhẹ cho phần tử: bao từng phần tử bằng AnimatedSize hoặc AnimatedOpacity, hoặc dùng gói chuyên dụng cho hiệu ứng danh sách.
  • Cần hiệu ứng đúng nghĩa cho thao tác thêm/xoá: dùng AnimatedList (hoặc SliverAnimatedList) — bạn phải chủ động gọi insertItem và removeItem thay vì chỉ đổi danh sách dữ liệu.
[object Object]

Điểm cần chú ý: AnimatedList giữ danh sách riêng của nó theo chỉ số, nên mọi thay đổi dữ liệu phải đi qua insertItem/removeItem. Nếu bạn vừa sửa mảng dữ liệu vừa gọi phương thức của AnimatedList sai thứ tự, danh sách sẽ hiển thị lệch so với dữ liệu — bug khá khó lần.

Với thao tác xoá, hiệu ứng không chỉ để đẹp: nó cho người dùng một khoảnh khắc để nhận ra việc vừa xảy ra và (nếu bạn kèm SnackBar hoàn tác) để sửa sai.

Hero transition giữa danh sách và màn hình chi tiết

Hiệu ứng ảnh phóng to từ danh sách lên màn hình chi tiết rất dễ làm và tạo cảm giác chuyên nghiệp. Nguyên tắc là dùng cùng một Hero tag ở cả hai màn hình.

[object Object]

Hai lỗi thường gặp: trùng tag trong cùng một màn hình (ví dụ dùng tag là product cho mọi phần tử) sẽ gây lỗi hoặc hiệu ứng nhảy sai; và ảnh khác kích thước quá lớn làm hiệu ứng trông méo — hãy dùng cùng tỉ lệ khung ở hai đầu hoặc bọc AspectRatio phù hợp.

Băng chuyền ảnh quảng cáo là ứng dụng phổ biến nhất của PageView. Để thấy một phần trang kế tiếp, đặt viewportFraction nhỏ hơn 1:

[object Object]

Nếu cần băng chuyền chạy vòng vô hạn, nguyên tắc chung là tạo danh sách dài tưởng tượng (ví dụ dùng chỉ số theo mô-đun của danh sách thật) và bắt đầu từ giữa. Cách này đơn giản và đủ tốt cho hầu hết trường hợp, nhưng cần chú ý nếu bạn có chỉ báo trang — hãy tính chỉ báo theo chỉ số mô-đun, không theo chỉ số thật.

Với giao diện Material 3, ngoài PageView bạn cũng có widget băng chuyền dựng sẵn giúp giảm code. Cân nhắc nó khi cần nhanh; còn khi cần kiểm soát chi tiết về hiệu ứng và khoảng cách, PageView vẫn là lựa chọn chủ động hơn.

Ảnh trong danh sách: nguồn gốc phổ biến nhất của việc giật

Phần lớn danh sách có cảm giác chậm không phải do widget mà do ảnh. Một vài nguyên tắc giúp cải thiện rõ rệt:

  • Yêu cầu ảnh đúng kích thước hiển thị. Nếu ô hiển thị 72 pixel mà bạn tải ảnh 2000 pixel, mỗi khung hình phải xử lý khối dữ liệu lớn hơn nhiều lần cần thiết.
  • Dùng cacheWidth/cacheHeight khi ảnh đã có nhưng kích thước chưa phù hợp. Cách này giảm đáng kể bộ nhớ dùng cho ảnh.
  • Luôn có loadingBuilder và errorBuilder. Danh sách có vài ảnh lỗi mà không xử lý sẽ hiện khoảng trắng hoặc biểu tượng vỡ, làm giao diện trông thiếu hoàn thiện.
  • Cố định tỉ lệ khung cho vùng ảnh (bằng AspectRatio hoặc kích thước cố định). Nếu không, khi ảnh tải xong, chiều cao thay đổi làm cả danh sách nhảy vị trí.
  • Bọc ảnh lớn có hiệu ứng riêng trong RepaintBoundary để tránh việc vẽ lại lan sang các phần tử lân cận.

Những con số nên nhớ khi đo hiệu năng

Khi kiểm tra trải nghiệm cuộn, ba con số dưới đây giúp bạn kết luận nhanh thay vì phỏng đoán:

  • 16ms cho mỗi khung hình là ngưỡng để giữ 60 khung hình/giây. Vượt ngưỡng này ở nhiều khung hình liên tiếp sẽ thấy giật.
  • Luồng UI và luồng Raster là hai nơi cần xem trong DevTools. Vấn đề ở luồng UI thường do code Dart (tính toán, rebuild quá nhiều), vấn đề ở luồng Raster thường do vẽ (ảnh lớn, hiệu ứng, cắt bo góc).
  • Số lần gọi API khi cuộn 10 giây là chỉ số kiểm tra logic phân trang. Nếu con số này lớn hơn số trang cần tải, bạn đang gọi trùng — xem lại bài ScrollController.

Luôn đo ở chế độ profile trên máy thật. Chế độ debug chậm hơn nhiều lần, và số đo trên trình giả lập không phản ánh máy người dùng.

Test danh sách: cuộn tới phần tử và kiểm tra dữ liệu tải thêm

Danh sách là vùng ít được test nhất, nhưng lại là nơi hay phát sinh lỗi khi refactor. Hai dạng test rẻ và có giá trị nhất:

[object Object]

scrollUntilVisible tự cuộn cho tới khi tìm thấy phần tử, nên không cần đoán khoảng cách kéo. Với danh sách có tải thêm, hãy kiểm tra thêm rằng số lần gọi API không tăng khi cuộn lên xuống trong cùng một vùng.

Câu hỏi thường gặp (tiếp)

Khi nào nên chuyển từ ListView sang CustomScrollView?

Khi trang có từ hai loại khối nội dung trở lên cần cuộn chung một trục, hoặc khi cần thanh tiêu đề thu gọn, tiêu đề nhóm dính. Nếu trang chỉ có một danh sách, ListView là đủ và ít code hơn.

Có cách nào để danh sách không mất vị trí cuộn khi quay lại màn hình không?

Có. Thêm PageStorageKey cho widget cuộn nếu nó nằm trong tab, hoặc giữ trạng thái màn hình bằng AutomaticKeepAliveClientMixin. Nếu cần nhớ qua nhiều lần mở app, hãy lưu offset và khôi phục sau khung hình đầu tiên.

GridView có hiệu ứng cuộn khác ListView không?

Không. Cả hai đều là vùng cuộn tải lười, cùng các quy tắc về cacheExtent, itemExtent (với lưới là mainAxisExtent), và cùng các vấn đề về ảnh.

Vì sao lưới ảnh của tôi hiện khoảng trắng giữa các ô?

Gần như luôn là do tỉ lệ khung không khớp: childAspectRatio khai báo khác với tỉ lệ thật của nội dung, hoặc ảnh không dùng BoxFit.cover. Hãy cố định tỉ lệ khung cho cả ô và ảnh, thay vì để nội dung tự quyết định.

Băng chuyền nên dùng PageView hay ListView cuộn ngang?

PageView khi mỗi mục chiếm gần trọn khung nhìn và bạn muốn hiệu ứng "lật trang" (banner, màn hình giới thiệu). ListView cuộn ngang khi các mục nhỏ hơn và người dùng có thể xem nhiều mục cùng lúc (danh sách thể loại, sản phẩm liên quan). Một khác biệt thực tế: PageView luôn dừng ở mốc trang nên cảm giác chính xác hơn; ListView ngang cuộn tự do, phù hợp khi người dùng chỉ muốn quét nhanh nhiều mục.

Làm sao tải thêm dữ liệu trong GridView?

Nguyên tắc giống ListView: dùng ScrollController (hoặc chuyển sang CustomScrollView với SliverGrid) và thêm một phần tử cuối để hiển thị trạng thái đang tải. Điểm cần chú ý riêng với lưới là số cột có thể thay đổi theo chiều rộng màn hình, nên đừng tính chỉ số phần tử dựa trên số cột. Hãy dùng một biến trạng thái riêng cho trạng thái tải và hết dữ liệu, đúng như cách làm ở bài hướng dẫn tải thêm dữ liệu.

Có nên dùng package quản lý danh sách thay vì tự viết?

Với nhu cầu thông thường, ba widget gốc là đủ và ít rủi ro hơn. Chỉ cân nhắc package khi bạn cần thứ khó làm bằng tay: kéo thả sắp xếp lại phần tử, danh sách phân nhóm có tiêu đề dính phức tạp, hoặc hiệu ứng chuyển động nâng cao. Mỗi package thêm vào là một phụ thuộc phải theo dõi khi nâng cấp Flutter, nên hãy chọn khi lợi ích rõ ràng.

Checklist danh sách trước khi release

Danh sách là nơi người dùng dành phần lớn thời gian, nên cũng là nơi lỗi bị nhận ra ngay. Rà nhanh các điểm sau:

  • Đã dùng builder cho danh sách có thể dài, không dùng children tĩnh.
  • Chiều cao mỗi dòng cố định thì đã đặt itemExtent hoặc mainAxisExtent.
  • Phần tử có Key ổn định theo id, không theo chỉ số.
  • Ảnh dùng kích thước gần với hiển thị, có loadingBuilder và errorBuilder.
  • Tỉ lệ khung của ô lưới và ảnh khớp nhau, không còn khoảng trắng bất thường.
  • Không có vùng cuộn lồng nhau cùng trục mà không có lý do.
  • Trạng thái rỗng, đang tải và lỗi đều có giao diện riêng.
  • Vị trí cuộn được giữ khi rời màn hình rồi quay lại.
  • Cỡ chữ lớn nhất trong cài đặt hệ thống không làm tràn khung.
  • Đã đo ở chế độ profile trên máy thật khi cuộn nhanh.

Kết luận

3 widget quan trọng:

  • ✅ ListView: Hiển thị danh sách dọc
  • ✅ GridView: Hiển thị grid layout
  • ✅ PageView: Hiển thị pages có thể swipe

Tóm tắt:

  1. ListView: Danh sách items
  2. GridView: Grid layout
  3. PageView: Swipeable pages
  4. Best Practices: Sử dụng builder, lazy loading

Nắm vững 3 widget này sẽ giúp bạn xây dựng UI mạnh mẽ! 🚀


Học Flutter bài bản theo lộ trình

Nếu bạn muốn học có người sửa bài thay vì tự ghép tài liệu, Flutter Hướng Nghiệp đào tạo theo 3 cấp:

Lớp online trực tiếp qua Zoom/Google Meet, có bản ghi lại — không bắt buộc đến cơ sở.
Xem lịch khai giảng hoặc so sánh 5 cách học lập trình mobile nếu còn phân vân.