Flutter 的分頁問題:如何避免跳過內容


摘要

在Flutter開發中,分頁功能常面臨內容跳過的問題,本篇文章將提供解決方案以提升用戶體驗及應用效能。 歸納要點:

  • 深入探討Flutter的`PageView`效能瓶頸,提供最佳化策略如使用`PageView.builder`和`CachedNetworkImage`以改善頁面載入速度。
  • 比較各種狀態管理方案(Provider, BLoC, Riverpod等),分析如何有效避免因狀態更新不當導致的跳頁問題,並附上具體程式碼範例。
  • 利用`NotificationListener`與`ScrollController`監控滾動事件,預防快速滾動引起的跳頁行為,提升使用者體驗。
透過多種技術與最佳實踐,我們可以有效避免在Flutter中出現的分頁跳過問題,讓開發者能更流暢地創建高效、穩定的應用。


分頁是大型資料集合視覺化中最重要的功能之一。例如,假設我們有一個包含一千件商品的清單,你不應該一次性載入所有專案。因此,我們需要使用分頁來部分載入它們。最初,我建立了一個產品資料類如下:

class Product {   const Product({     required this.id,     required this.name,     required this.description,     required this.price,   });    final int id;   final String name;   final String description;   final double price;    @override   String toString() {     return 'Product(id: $id, name: $name, price: $price)';   } }

為了模擬分頁過程,我編寫了一個簡單的 Singleton 類別:

import 'dart:math';  import 'package:pagination_test_app/product.dart';  class PaginatedProductGenerator {   // Private constructor   PaginatedProductGenerator._internal() : _random = Random();    // Singleton instance   static PaginatedProductGenerator? _instance;    static PaginatedProductGenerator get instance {     _instance ??= PaginatedProductGenerator._internal();     return _instance!;   }    final Random _random;    List getPage(int offset, int limit) {     final products = List.generate(limit, (index) {       final id = offset + index + 1;       final name = 'Product $id';       final description = 'Description for Product $id';       final price = (100 + _random.nextDouble() * 900)           .toStringAsFixed(2); // Prices between 100-1000       return Product(         id: id,         name: name,         description: description,         price: double.parse(price),       );     });      return products;   } }

如您所見,當我們呼叫 getNextPage 時,它會為我們提供新的 N(在這個例子中是限制數量)專案。現在假設我們有 10 頁,每頁都有 10 個產品(限制為 10)。因此,我們最多可以擁有 100 個專案(分頁結束)。在 Flutter 中的基本分頁設定如下:

class ProductsPage extends StatefulWidget {   const ProductsPage({super.key});    @override   State createState() => _ProductsPageState(); }  class _ProductsPageState extends State {   final List _products = [];   final ScrollController _controller = ScrollController();   bool _isLoading = false;   bool _hasMore = true;   int _offset = 0;   final _limit = 10;    @override   void initState() {     super.initState();      _loadMoreProducts(); // Initial load     _controller.addListener(_onScroll);   }    void _onScroll() {     if (_controller.offset >= _controller.position.maxScrollExtent &&         !_controller.position.outOfRange &&         !_isLoading &&         _hasMore) {       _loadMoreProducts();     }   }    Future _loadMoreProducts() async {     if (_isLoading || !_hasMore) return;      setState(() {       _isLoading = true;     });      // Simulate network delay     await Future.delayed(const Duration(seconds: 1));      final newProducts = PaginatedProductGenerator.instance.getPage(       _offset,       _limit,     );      setState(() {       _products.addAll(newProducts);       _isLoading = false;       _offset += _limit;        /// mock end point, it is coming from API in normal case       /// generally, we have a parameter in response of server like       /// nextPage, or hasNext, etc.       if (_products.length == 100) {         _hasMore = false;       }     });   }    @override   Widget build(BuildContext context) {     return Scaffold(       body: ListView.builder(         controller: _controller,         itemBuilder: (context, index) {           if (index < _products.length) {             final product = _products[index];              return Card(               child: Column(                 children: [                   Text(product.name),                   const SizedBox(height: 8),                   Text(product.description),                   const SizedBox(height: 8),                   Text(product.price.toString()),                 ],               ),             );           }            // Loading indicator at the bottom           return const Center(             child: Padding(               padding: EdgeInsets.all(16.0),               child: CircularProgressIndicator(),             ),           );         },         itemCount: _products.length + (_isLoading ? 1 : 0),       ),     );   }    @override   void dispose() {     _controller.dispose();     super.dispose();   } }

這裡有兩個案例。為了更好地理解,我將使用 Flutter Web 進行解釋。第一個案例是最佳情況。如果前十個專案佔據了頁面上的所有空間,那麼一切運作正常,分頁功能也會正常運作。


第二個案例是問題所在。如果 10 個專案無法覆蓋所有空間(這是不可預測的,因為裝置或解析度可能會比您的測試案例大),那麼分頁將無法運作,因為 ScrollView 無法判斷您是在滾動以載入專案還是其他原因。


你可能會認為,我們可以計算單個專案的高度,然後根據螢幕大小生成每頁所需的專案數量。這種解決方案可能難以實現。基於此原因,我建立了一個簡單的 PaginatedListView(對於網格或 slivers 也是一樣),其結構如下:

import 'package:flutter/material.dart'; import 'package:pagination_test_app/responsive_layout.dart';  class PaginatedListView extends StatefulWidget {   const PaginatedListView({     super.key,     this.itemCount = 0,     required this.itemBuilder,     this.isLoading = false,     this.hasMore = true,     this.loadMore,   });    final int itemCount;   final IndexedWidgetBuilder itemBuilder;   final bool isLoading;   final bool hasMore;   final VoidCallback? loadMore;    @override   State createState() => _PaginatedListViewState(); }  class _PaginatedListViewState extends State {   final _controller = ScrollController();    @override   void initState() {     super.initState();     _controller.addListener(_scrollListener);     WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitialFill());   }    @override   void didChangeDependencies() {     context.windowSize;     super.didChangeDependencies();     WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitialFill());   }    @override   void didUpdateWidget(covariant PaginatedListView oldWidget) {     super.didUpdateWidget(oldWidget);     WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitialFill());   }    bool get skipPagination => widget.isLoading || !widget.hasMore;    void _checkInitialFill() {     if (skipPagination) return;      print('extent: ${_controller.position.maxScrollExtent}');      if (_controller.position.maxScrollExtent == 0) {       widget.loadMore?.call();     }   }    void _scrollListener() {     if (skipPagination) return;      if (_controller.offset >= _controller.position.maxScrollExtent &&         !_controller.position.outOfRange) {       widget.loadMore?.call();     }   }    @override   Widget build(BuildContext context) {     return ListView.builder(       controller: _controller,       itemBuilder: (context, index) {         if (index < widget.itemCount) {           return widget.itemBuilder(context, index);         }          return const Center(           child: Padding(             padding: EdgeInsets.all(16.0),             child: CircularProgressIndicator(),           ),         );       },       itemCount: widget.itemCount + (widget.hasMore ? 1 : 0),     );   } }

Flutter ListView 效能最佳化:動態載入與視窗大小調整問題解決

解釋:skipPagination——這個函式限制了在正在進行的載入過程中再次載入專案,或是在所有專案已經載入完成的情況下。 checkInitialFill——當我們在 initState 中呼叫這個函式時,它會檢查列表是否填滿了所有可用空間。如果沒有可滾動內容,maxScrollExtent 將返回 0(即列表未填滿所有空間)。 _scrollListener——當使用者滾動列表時,這個函式將自動處理更多載入的過程。 didUpdateWidget——它會定期被呼叫,直到填滿所有可用空間,因為我們的狀態來自上層,每次分頁時,列表都會重新載入。目前,我們的 UI 成功處理了初始填充過程!當使用者調整瀏覽器大小而裝置解析度改變時,此程式碼示例卻無法正常運作。我們該如何解決呢?

我們編寫了一個簡單的繼承模型,以監聽大小和佈局變化。

import 'package:flutter/widgets.dart';  enum Layout { mobile, desktop, tablet }  enum LayoutAspect { layout, size }  class ResponsiveLayoutScopeWrapper extends StatelessWidget {   const ResponsiveLayoutScopeWrapper({     super.key,     required this.child,   });    final Widget child;    @override   Widget build(BuildContext context) {     final size = MediaQuery.sizeOf(context);     final width = size.width;      final Layout layout;      if (width > 1024) {       layout = Layout.desktop;     } else if (width >= 738) {       layout = Layout.tablet;     } else {       layout = Layout.mobile;     }      return ResponsiveLayoutScope(       layout: layout,       size: size,       child: child,     );   } }  class ResponsiveLayoutScope extends InheritedModel {   const ResponsiveLayoutScope({     super.key,     required super.child,     required this.layout,     required this.size,   });    final Layout layout;   final Size size;    static ResponsiveLayoutScope? of(BuildContext context, LayoutAspect aspect) {     return context.dependOnInheritedWidgetOfExactType(       aspect: aspect,     );   }    static Size sizeOf(BuildContext context) =>       of(context, LayoutAspect.size)!.size;    static Layout layoutOf(BuildContext context) =>       of(context, LayoutAspect.layout)!.layout;    @override   bool updateShouldNotify(ResponsiveLayoutScope oldWidget) {     return oldWidget.layout != layout || oldWidget.size != size;   }    @override   bool updateShouldNotifyDependent(     covariant InheritedModel oldWidget,     Set dependencies,   ) {     if (oldWidget is! ResponsiveLayoutScope) return false;      if (oldWidget.layout != layout &&         dependencies.contains(LayoutAspect.layout)) {       return true;     } else if (oldWidget.size != size &&         dependencies.contains(LayoutAspect.size)) {       return true;     }      return false;   } }  extension LayoutExt on BuildContext {   Layout get windowLayout => ResponsiveLayoutScope.layoutOf(this);    Size get windowSize => ResponsiveLayoutScope.sizeOf(this);    bool get isMobile => windowLayout == Layout.mobile;    bool get isDesktop => windowLayout == Layout.desktop;    bool get isTablet => windowLayout == Layout.tablet; }

您只能聆聽當前瀏覽器的大小或佈局!而且,我們應該為應用程式提供我們上述材料的供應商。

class MyApp extends StatelessWidget {   const MyApp({super.key});    @override   Widget build(BuildContext context) {     return ResponsiveLayoutScopeWrapper(       child: MaterialApp(         title: 'Flutter Demo',         theme: ThemeData(           colorScheme: ColorScheme.fromSeed(seedColor: Colors.white),           useMaterial3: true,         ),         home: const ProductsPage(),       ),     );   } }

最後一步將在分頁檢視中進行。因此:

  @override   void didChangeDependencies() {     context.windowSize;     super.didChangeDependencies();     WidgetsBinding.instance.addPostFrameCallback((_) => _checkInitialFill());   }

我們應該在這裡新增 `didChangeDependencies`。原因是當應用程式的大小發生變化時,它會觸發 `ListView` 自行調整大小。在這種情況下,如果有可用的空間來顯示列表專案,則會再次呼叫載入更多功能。現在,我們的程式碼運作正常!


它也適用於不同的解析度!


我們做到了!感謝您閱讀我的文章。如果您喜歡這篇文章,別忘了點贊喔!



MD

專家

相關討論

❖ 相關專欄