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

ScrollController: Kéo vô hạn, cuộn mượt và load thêm dữ liệu

Được viết bởi Admin|0 lượt xem
ScrollController: Kéo vô hạn, cuộn mượt và load thêm dữ liệu

Tóm tắt nhanh

Hướng dẫn sử dụng ScrollController trong Flutter để tạo infinite scroll, smooth scrolling và load more data khi cuộn.

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

ScrollController giúp bạn kiểm soát scroll behavior và implement infinite scroll. Bài viết này sẽ hướng dẫn bạn sử dụng ScrollController.

ScrollController cơ bản

1. Tạo ScrollController

[object Object]

2. Attach to ListView

[object Object]

Infinite Scroll

1. Detect scroll position

[object Object]

2. Complete Example

[object Object]

Smooth Scrolling

1. Scroll to position

[object Object]

2. Scroll to top

[object Object]

Best Practices

1. Dispose Controller

[object Object]

2. Check has listeners

[object Object]

Phân trang bằng con trỏ (cursor) thay vì offset

Cách làm phổ biến là gửi lên offset (hoặc page) để server trả về đúng đoạn dữ liệu. Cách này có một lỗi rất khó chịu trong thực tế: nếu danh sách phía server thay đổi trong lúc người dùng đang cuộn — có bản ghi mới được thêm, hoặc một bản ghi bị xoá — thì trang tiếp theo sẽ bỏ sót hoặc lặp lại dữ liệu. Với danh sách ít thay đổi thì hiếm khi thấy, với bảng tin thì thấy ngay.

Cách xử lý tốt hơn là dùng cursor: thay vì "cho tôi 20 bản ghi từ vị trí 40", bạn hỏi "cho tôi 20 bản ghi tiếp theo sau bản ghi này". Server trả về thêm một con trỏ cho lần gọi sau.

[object Object]

Quy tắc cần nhớ: lưu lại cursor đã dùng ở lần gọi gần nhất, và bỏ qua yêu cầu tải thêm nếu cursor đó vừa được gọi. Đây là cách chặn trùng dữ liệu hiệu quả hơn việc chỉ dựa vào cờ _isLoading.

7 lỗi thường gặp khi làm infinite scroll

Đây là các lỗi gặp nhiều nhất khi đọc code load-more của người mới, kèm cách sửa.

  1. Gọi tải thêm liên tục cho cùng một trang. Nguyên nhân là listener của ScrollController phát sự kiện ở mỗi khung hình cuộn, không phải mỗi lần chạm đáy. Cách sửa: đặt cờ đang tải và trả về ngay, đồng thời so sánh cursor đã xử lý.
  2. Quên gỡ listener và dispose controller. Controller bị giữ lại sẽ làm callback chạy khi màn hình đã bị xoá, và bạn gặp lỗi setState() called after dispose().
  3. Dùng widget danh sách sai. Column hoặc ListView(children: [...]) dựng toàn bộ phần tử con ngay từ đầu. Với danh sách dài, dùng ListView.builder — xem thêm cách dùng ListView, GridView, PageView.
  4. Không có trạng thái khi đã hết dữ liệu. Người dùng cuộn mãi mà không thấy gì và tưởng app treo. Cần một dòng "Bạn đã xem hết" ở cuối danh sách.
  5. Không có trạng thái lỗi và nút thử lại. Mạng yếu là chuyện bình thường, không phải chuyện hiếm. Nếu tài trang lỗi mà không hiện gì, người dùng không biết phải làm gì tiếp.
  6. Danh sách ngắn hơn màn hình. Nếu 20 bản ghi đầu chưa đủ dài để cuộn được, sự kiện cuộn không bao giờ phát, và bạn không bao giờ tải được trang hai. Cần gọi tải thêm ngay sau khi dựng xong danh sách đầu tiên nếu nội dung không đủ cao.
  7. Danh sách bị "nhảy" khi thêm dữ liệu mới. Nếu bạn chèn phần tử mới lên đầu danh sách, vị trí cuộn sẽ nhảy. Dùng Key ổn định cho từng phần tử (ValueKey(item.id)) để Flutter giữ đúng vị trí.

Ví dụ đầy đủ: kéo để làm mới, tải thêm, xử lý lỗi

Ví dụ dưới đây tách trạng thái màn hình thành các trường hợp rõ ràng, chặn gọi trùng bằng cursor, và có nút thử lại khi lỗi.

[object Object]

Dùng ở giao diện:

[object Object]

Điểm đáng chú ý: phần cuối danh sách không chỉ là vòng xoay đang tải, mà còn phản ánh ba trạng thái khác nhau — đang tải, lỗi (kèm nút thử lại), và hết dữ liệu. Đây là khác biệt lớn giữa bản demo và bản dùng được.

Cuộn tới đầu danh sách khi có dữ liệu mới

Sau khi tải lại, nếu người dùng đang ở giữa danh sách cũ, bạn cần đưa họ về đầu. Lưu ý hai điểm: kiểm tra hasClients trước khi gọi, và chờ khung hình tiếp theo trước khi cuộn.

[object Object]

Nếu gọi ngay sau setState, danh sách có thể chưa kịp cập nhật kích thước, và lệnh cuộn sẽ không có tác dụng hoặc nhảy sai vị trí.

Ẩn / hiện AppBar theo hướng cuộn mà không làm app giật

Cách gọn nhất khi dùng CustomScrollView là để SliverAppBar tự xử lý:

[object Object]

Nếu bạn phải tự xử lý theo offset, đừng dùng setState cho cả màn hình ở mỗi sự kiện cuộn — làm vậy app sẽ rebuild liên tục và rất dễ giật. Thay vào đó, lưu giá trị vào ValueNotifier<double> và chỉ widget nào cần đổi mới lắng nghe:

[object Object]

Cách này chỉ vẽ lại phần thanh trên cùng, không đụng tới danh sách bên dưới — quan trọng khi danh sách dài. Các kỹ thuật giảm rebuild khác được tổng hợp ở bài tối ưu hiệu năng Flutter.

Khi nào KHÔNG cần ScrollController

Không phải lúc nào cũng cần controller. Một nguyên tắc thực dụng:

  • Chỉ muốn biết người dùng đang cuộn (ví dụ ẩn bàn phím, ghi log): dùng NotificationListener<ScrollNotification> — không cần quản lý vòng đời, không cần dispose.
  • Muốn nhảy tới vị trí cụ thể hoặc cần offset để phân trang: dùng ScrollController.
  • Nhiều danh sách trên cùng màn hình: mỗi danh sách một controller. Một ScrollController không nên gắn vào hai widget cuộn cùng lúc.

Kiểm tra chuyện cuộn bằng test

Đây là phần gần như bị bỏ qua, dù bug load-more rất dễ tái diễn khi sửa code. Trong widget test, bạn có thể kéo danh sách và kiểm tra dữ liệu mới có xuất hiện hay không:

[object Object]

Và điều đáng kiểm tra hơn cả: số lần gọi API. Giả lập lớp service, đếm số lần gọi, và khẳng định rằng cuộn liên tục không làm tăng số lần gọi cho cùng một cursor. Cách viết test cho service đã được hướng dẫn ở bài viết unit test cho service gọi API.

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

Một ScrollController gắn được cho hai ListView không?

Không nên. Một controller chỉ nên điều khiển một widget cuộn tại một thời điểm. Nếu cần đồng bộ, hãy xử lý thủ công hoặc dùng chung một danh sách thay vì hai danh sách cuộn riêng.

Làm sao biết người dùng đã tới cuối danh sách?

So sánh position.pixels với position.maxScrollExtent. Nên trừ hao một khoảng (200–400 pixel) để tải trước khi người dùng chạm đáy, giúp trải nghiệm liền mạch hơn.

Tại sao sự kiện cuộn lại chạy nhiều lần như vậy?

Vì Flutter phát thông báo cuộn ở mỗi khung hình trong lúc cuộn. Đó là lý do bạn phải chặn bằng cờ trạng thái hoặc bằng cursor đã gọi, nếu không sẽ tạo ra hàng loạt request trùng.

Dùng ScrollController trong luồng CustomScrollView có khó không?

Không khó, chỉ cần nhớ controller do widget cha truyền xuống cho CustomScrollView, và kiểm tra hasClients trước khi gọi animateTo vì lúc mới dựng, danh sách chưa gắn vào controller.

Cuộn trong bố cục phức tạp: NestedScrollView và tab thu gọn

Màn hình hồ sơ người dùng là ví dụ điển hình: phía trên có ảnh bìa và thanh tab, phía dưới là nội dung cuộn theo từng tab. Nếu bạn đặt một danh sách có chiều cao cố định cho phần dưới, bạn sẽ mất trải nghiệm cuộn liền mạch. NestedScrollView sinh ra để giải quyết đúng bài toán này.

[object Object]

Điểm quan trọng nằm ở hai widget SliverOverlapAbsorber và SliverOverlapInjector: khi AppBar co lại, phần chồng lên nhau có thể làm nội dung bị đẩy sai vị trí (dòng đầu tiên bị che, hoặc xuất hiện khoảng trắng). Hai widget này trao đổi thông tin về phần chồng đó để bù trừ chính xác.

Trong mỗi tab, cách dựng danh sách cũng cần điều chỉnh:

[object Object]

Ba lỗi hay gặp ở màn hình dạng này:

  1. Thiếu SliverOverlapInjector trong từng tab → dòng đầu tiên bị thanh tab che mất một phần.
  2. Đặt widget cuộn thường (ListView) làm body của NestedScrollView — phần này cần là một vùng cuộn biết cách cộng tác với scroll lồng, nên dùng CustomScrollView như ví dụ.
  3. Mỗi tab không có PageStorageKey → khi chuyển tab rồi quay lại, vị trí cuộn bị mất.

Kéo để làm mới khi danh sách đang rỗng

Một chi tiết nhỏ nhưng ảnh hưởng trực tiếp tới cảm nhận chất lượng: khi danh sách rỗng hoặc đang lỗi, RefreshIndicator thường không hoạt động, vì nội dung không đủ cao để kéo. Người dùng bị kẹt, không có cách nào tải lại ngoài việc đóng app.

Cách xử lý: bọc trạng thái rỗng trong một vùng cuộn luôn cho phép kéo, và đặt chiều cao tối thiểu bằng chiều cao khung nhìn.

[object Object]

AlwaysScrollableScrollPhysics là chi tiết bắt buộc: mặc định, Flutter tắt cử chỉ cuộn khi nội dung không vượt quá khung nhìn, và khi đó kéo để làm mới sẽ không kích hoạt.

Giữ vị trí cuộn khi rời màn hình rồi quay lại

Người dùng cuộn xuống bài thứ 40, mở chi tiết, bấm Back và thấy danh sách nhảy về đầu — đây là lỗi trải nghiệm rất phổ biến và cũng rất dễ sửa. Ba cách, theo mức độ phù hợp:

  • Danh sách nằm trong tab hoặc trong PageView: thêm PageStorageKey cho widget cuộn. Flutter sẽ tự lưu và khôi phục vị trí.
  • Màn hình bị xoá khỏi cây khi điều hướng: giữ trạng thái của màn hình bằng AutomaticKeepAliveClientMixin (thường dùng cho từng tab), hoặc lưu offset vào provider/state bên ngoài rồi khôi phục trong initState.
  • Bạn muốn lưu cả vị trí qua nhiều lần mở app: lưu offset (hoặc id phần tử đang xem) xuống bộ nhớ cục bộ, rồi sau khi danh sách dựng xong thì cuộn tới. Cách này dùng nhiều ở ứng dụng đọc tin và ứng dụng học tập.
[object Object]

Lưu ý về thứ tự: phải khôi phục sau khung hình đầu tiên, vì lúc đó danh sách mới có kích thước thật để giới hạn giá trị offset.

Danh sách chat: cuộn xuống cuối và xử lý bàn phím

Danh sách tin nhắn có hai yêu cầu ngược với danh sách thường: nội dung mới nhất nằm ở dưới cùng, và khi bàn phím mở, phần đang xem không được nhảy.

Cách dựng phổ biến là đảo chiều danh sách:

[object Object]

Với reverse: true, index 0 là tin nhắn mới nhất, và việc thêm tin nhắn mới không làm nội dung nhảy. Đây là lý do hầu hết ứng dụng chat dùng cách này thay vì tự cuộn xuống cuối bằng animateTo.

Nếu bạn vẫn muốn cuộn có hiệu ứng khi có tin nhắn mới:

[object Object]

Chi tiết đáng chú ý: chỉ tự cuộn khi người dùng đang ở gần cuối. Nếu họ đang đọc tin nhắn cũ mà app tự kéo xuống, đó là lỗi trải nghiệm khó chịu hơn cả việc không tự cuộn.

Tải lại dữ liệu: giữ nội dung cũ thay vì xoá trắng màn hình

Khi người dùng kéo để làm mới ở một danh sách đã có dữ liệu, đừng đặt trạng thái về "đang tải" làm giao diện trắng rồi mới hiện lại. Thứ tự đúng là: gọi API → khi có kết quả mới thì thay dữ liệu và vẽ một lần. Nếu lỗi, giữ nguyên dữ liệu cũ và chỉ hiện thông báo.

[object Object]

Quy tắc đáng nhớ: dữ liệu cũ + thông báo lỗi luôn tốt hơn màn hình trắng + thông báo lỗi. Người dùng vẫn đọc được nội dung trong khi mạng chập chờn.

Đo hiệu năng cuộn thay vì phỏng đoán

Phần lớn vấn đề cuộn giật được xác định trong vài phút nếu bạn đo đúng cách, và gần như không thể đoán đúng bằng cảm giác. Quy trình ngắn:

  1. Chạy app ở chế độ profile (flutter run --profile) trên máy thật. Chế độ debug chậm hơn nhiều lần và số liệu không dùng được để kết luận.
  2. Mở DevTools, vào tab Performance, bật ghi rồi cuộn nhanh. Cột thời gian cho mỗi khung hình cần nằm dưới ngưỡng 16ms để giữ 60 khung hình mỗi giây.
  3. Nếu cột vượt ngưỡng nằm ở luồng UI, vấn đề thường là code Dart: hàm build nặng, tính toán trong build, hoặc quá nhiều widget bị dựng lại.
  4. Nếu cột vượt ngưỡng nằm ở luồng Raster, vấn đề thường là vẽ: ảnh quá lớn, bóng đổ phức tạp, hiệu ứng cắt bo góc mà không có RepaintBoundary.
  5. Kiểm tra riêng việc cuộn nhanh nhiều lần để phát hiện rò rỉ hoặc số lần gọi API tăng bất thường.

Một mẹo kiểm tra nhanh: dùng debugPrintRebuildDirtyWidgets trong môi trường phát triển để in ra widget nào đang dựng lại. Với danh sách, nếu thấy cả trang dựng lại khi chỉ một dòng đổi, bạn biết ngay phạm vi lắng nghe đang quá rộng.

Tự kiểm tra: 6 câu hỏi trước khi hoàn thành tính năng cuộn

Trước khi coi tính năng đã xong, hãy tự trả lời sáu câu này. Đây cũng là những câu xuất hiện khi phỏng vấn và khi review code:

  1. Controller đã được dispose và listener đã được gỡ chưa?
  2. Nếu người dùng cuộn liên tục trong 5 giây, có bao nhiêu request được gửi? Có bị trùng không?
  3. Danh sách có trạng thái rỗng, trạng thái lỗi và nút thử lại chưa?
  4. Khi mất mạng ở trang thứ ba, giao diện hiển thị gì và người dùng tiếp tục được không?
  5. Vị trí cuộn có được giữ khi mở chi tiết rồi quay lại không?
  6. Trên máy thật ở chế độ profile, cuộn nhanh có vượt ngưỡng khung hình không?

Nếu cả sáu câu đều có câu trả lời rõ ràng, tính năng của bạn đang ở mức tốt hơn phần lớn danh sách trong các dự án thực tế.

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

Có nên dùng animateTo hay jumpTo?

jumpTo đổi vị trí tức thì, phù hợp khi khôi phục vị trí đã lưu lúc mở màn hình. animateTo có hiệu ứng, phù hợp khi người dùng bấm nút "về đầu trang". Dùng ngược lại — hiệu ứng khi khôi phục vị trí cũ — sẽ làm người dùng thấy màn hình tự chạy.

reverse: true có làm đảo thứ tự dữ liệu không?

Không, chỉ đảo chiều của vùng cuộn. Dữ liệu vẫn theo thứ tự bạn truyền vào, còn index 0 sẽ xuất hiện ở phía cuối khung nhìn.

Khi nào cần AutomaticKeepAliveClientMixin?

Khi bạn muốn một phần tử trong danh sách giữ nguyên trạng thái (ô nhập liệu đang gõ, video đang phát) dù bị cuộn ra khỏi vùng nhìn thấy. Cần gọi super.build(context) trong build và trả về true ở wantKeepAlive, nếu không sẽ không có tác dụng.

Vì sao danh sách có shrinkWrap: true lại chậm?

Vì shrinkWrap buộc Flutter đo toàn bộ phần tử con để tính chiều cao, thay vì chỉ dựng phần đang nhìn thấy. Với danh sách dài, chi phí này tăng theo số lượng phần tử — đúng thứ mà ListView.builder được tạo ra để tránh.

Làm sao tải thêm khi người dùng cuộn bằng chuột trên web?

Cách làm giống hệt trên mobile, vì sự kiện cuộn phát ra như nhau. Khác biệt là tốc độ: chuột cuộn từng nấc nên người dùng có thể chạm cuối danh sách rất nhanh, và nếu ngưỡng kích hoạt quá nhỏ (ví dụ 50 pixel) bạn sẽ thấy nhiều yêu cầu nối tiếp nhau. Hãy để ngưỡng khoảng 300–500 pixel trên bản web, và luôn giữ cờ chặn gọi trùng. Ngoài ra, nên kiểm tra cả trường hợp người dùng kéo thanh cuộn (scrollbar) — hành vi này cũng phát thông báo cuộn nên logic không cần viết riêng.

Vì sao maxScrollExtent trả về 0 ở lần cuộn đầu tiên?

Vì lúc đó danh sách chưa được đo xong. Nếu bạn đọc maxScrollExtent trong initState hoặc ngay sau khi gán controller, giá trị có thể là 0 hoặc chưa hợp lệ. Cách xử lý: kiểm tra hasContentDimensions trước khi dùng, hoặc chờ khung hình đầu tiên bằng WidgetsBinding.instance.addPostFrameCallback. Đây cũng là lý do việc "tự cuộn xuống cuối danh sách chat" ngay trong initState thường không có tác dụng.

Nên tự viết infinite scroll hay dùng package có sẵn?

Dùng package khi bạn chỉ cần một danh sách đơn giản và muốn xong nhanh. Tự viết khi bạn cần kiểm soát logic tải (nhiều nguồn dữ liệu, quy tắc ưu tiên, cache theo từng phần). Với dự án cần đi phỏng vấn, tự viết là bài luyện tốt hơn, vì bạn phải giải quyết đủ bốn vấn đề: chặn gọi trùng, trạng thái lỗi, hết dữ liệu và giữ vị trí cuộn.

Checklist trước khi kết thúc tính năng cuộn

Dùng danh sách này khi review code của mình hoặc của đồng nghiệp. Mỗi dòng tương ứng với một lỗi thật đã gặp trong dự án:

  • Controller và listener được gỡ trong dispose.
  • Có cờ chặn gọi trùng, và có thêm điều kiện so sánh cursor đã yêu cầu.
  • Danh sách có đủ bốn trạng thái: đang tải, có dữ liệu, rỗng, lỗi kèm nút thử lại.
  • Trạng thái rỗng vẫn kéo để làm mới được (đã dùng AlwaysScrollableScrollPhysics).
  • Nếu danh sách ngắn hơn màn hình, có cơ chế tải thêm ngay sau khi dựng.
  • Phần tử có Key ổn định, không dùng chỉ số khi danh sách có thể đổi thứ tự.
  • Vị trí cuộn được giữ khi mở chi tiết rồi quay lại.
  • Không có shrinkWrap trên danh sách dài.
  • Đã kiểm tra ở chế độ profile trên máy thật, và số lần gọi API khi cuộn nhanh nằm trong khoảng mong đợi.
  • Không có context được dùng sau await mà thiếu kiểm tra mounted.

Kết luận

ScrollController giúp:

  • ✅ Infinite scroll
  • ✅ Smooth scrolling
  • ✅ Load more data
  • ✅ Control scroll behavior

Tóm tắt:

  1. Create Controller: Tạo ScrollController
  2. Attach: Attach to scrollable widget
  3. Listen: Listen to scroll events
  4. Load More: Load more khi gần cuối

Sử dụng ScrollController sẽ tạo trải nghiệm cuộn mượt 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.