天天看點

Flutter 小技巧之 ListView 和 PageView 的各種花式嵌套

這次的 Flutter 小技巧是 ​

​ListView​

​​ 和 ​

​PageView​

​​ 的花式嵌套,不同 ​

​Scrollable​

​​ 的嵌套沖突問題相信大家不會陌生,今天就通過 ​

​ListView​

​​ 和 ​

​PageView​

​ 的三種嵌套模式帶大家收獲一些不一樣的小技巧。

正常嵌套

最常見的嵌套應該就是橫向 ​

​PageView​

​​ 加縱向 ​

​ListView​

​ 的組合,一般情況下這個組合不會有什麼問題,除非你硬是要斜着滑。

最近剛好遇到好幾個人同時在問:“斜滑 ​

​ListView​

​​ 容易切換到 ​

​PageView​

​​ 滑動” 的問題,如下 GIF 所示,當使用者在滑動 ​

​ListView​

​​ 時,滑動角度帶上傾斜之後,可能就會導緻滑動的是 ​

​PageView​

​​ 而不是 ​

​ListView​

​ 。

Flutter 小技巧之 ListView 和 PageView 的各種花式嵌套

雖然從我個人體驗上并不覺得這是個問題,但是如果産品硬是要你修改,難道要自己重寫 ​

​PageView​

​ 的手勢響應嗎?

我們簡單看一下,不管是 ​

​PageView​

​​ 還是 ​

​ListView​

​​ 它們的滑動效果都來自于 ​

​Scrollable​

​​ ,而 ​

​Scrollable​

​​ 内部針對不同方向的響應,是通過 ​

​RawGestureDetector​

​ 完成:

  • ​VerticalDragGestureRecognizer​

    ​ 處理垂直方向的手勢
  • ​HorizontalDragGestureRecognizer​

    ​ 處理水準方向的手勢

是以簡單看它們響應的判斷邏輯,可以看到一個很有趣的方法 ​

​computeHitSlop​

​ : 根據 pointer 的類型确定當然命中需要的最小像素,觸摸預設是 kTouchSlop (18.0)。

Flutter 小技巧之 ListView 和 PageView 的各種花式嵌套

看到這你有沒有靈光一閃:如果我們把 ​

​PageView​

​ 的 touchSlop 修改了,是不是就可以調整它響應的靈敏度? 恰好在 ​

​computeHitSlop​

​​ 方法裡,它可以通過 ​

​DeviceGestureSettings​

​​ 來配置,而 ​

​DeviceGestureSettings​

​​ 來自于 ​

​MediaQuery​

​ ,是以如下代碼所示:

body: MediaQuery(
  ///調高 touchSlop 到 50 ,這樣 pageview 滑動可能有點點影響,
  ///但是大機率處理了斜着滑動觸發的問題
  data: MediaQuery.of(context).copyWith(
      gestureSettings: DeviceGestureSettings(
    touchSlop: 50,
  )),
  child: PageView(
    scrollDirection: Axis.horizontal,
    pageSnapping: true,
    children: [
      HandlerListView(),
      HandlerListView(),
    ],
  ),
),      

小技巧一:通過嵌套一個 ​

​MediaQuery​

​ ,然後調整 ​

​gestureSettings​

​ 的 ​

​touchSlop​

​ 進而修改 ​

​PageView​

​ 的靈明度 ,另外不要忘記,還需要把 ​

​ListView​

​​ 的 ​

​touchSlop​

​​ 切換會預設 的 ​

​kTouchSlop​

​ :

class HandlerListView extends StatefulWidget{
  @override
  _MyListViewState createState() => _MyListViewState();
}
class _MyListViewState extends State<HandlerListView> {
  @override
  Widget build(BuildContext context) {
    return MediaQuery(
      ///這裡 touchSlop  需要調回預設
      data: MediaQuery.of(context).copyWith(
          gestureSettings: DeviceGestureSettings(
        touchSlop: kTouchSlop,
      )),
      child: ListView.separated(
        itemCount: 15,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('Item $index'),
          );
        },
        separatorBuilder: (context, index) {
          return const Divider(
            thickness: 3,
          );
        },
      ),
    );
  }
}      

最後我們看一下效果,如下 GIF 所示,現在就算你斜着滑動,也很觸發 ​

​PageView​

​​ 的水準滑動,隻有橫向移動時才會觸發 ​

​PageView​

​ 的手勢,當然, 如果要說這個粗暴的寫法有什麼問題的話,大概就是降低了 ​

​PageView​

​ 響應的靈敏度。

Flutter 小技巧之 ListView 和 PageView 的各種花式嵌套

同方向 PageView 嵌套 ListView

介紹完正常使用,接着來點不一樣的,在垂直切換的 ​

​PageView​

​ 裡嵌套垂直滾動的 ​

​ ListView​

​ , 你第一感覺是不是覺得不靠譜,為什麼會有這樣的場景?

對于産品來說,他們不會考慮你如何實作的問題,他們隻會拍着腦袋說淘寶可以,為什麼你不行,是以如果是你,你會怎麼做?

而關于這個需求,社群目前讨論的結果是:把 ​

​PageView​

​ 和 ​

​ListView​

​ 的滑動禁用,然後通過 ​

​RawGestureDetector​

​ 自己管理。

如果對實作邏輯分析沒興趣,可以直接看本小節末尾的 ​​源碼連結 ​​。

看到自己管理先不要慌,雖然要自己實作 ​

​PageView​

​​ 和 ​

​ListView​

​​ 的手勢分發,但是其實并不需要重寫 ​

​PageView​

​​ 和 ​

​ListView​

​​ ,我們可以複用它們的 ​

​Darg​

​ 響應邏輯,如下代碼所示:

  • 通過​

    ​NeverScrollableScrollPhysics​

    ​​ 禁止了​

    ​PageView​

    ​​ 和​

    ​ListView​

    ​ 的滾動效果
  • 通過頂部​

    ​RawGestureDetector ​

    ​​的​

    ​VerticalDragGestureRecognizer​

    ​ 自己管理手勢事件
  • 配置​

    ​PageController​

    ​​ 和​

    ​ScrollController​

    ​ 用于擷取狀态
body: RawGestureDetector(
  gestures: <Type, GestureRecognizerFactory>{
    VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
            VerticalDragGestureRecognizer>(
        () => VerticalDragGestureRecognizer(),
        (VerticalDragGestureRecognizer instance) {
      instance
        ..onStart = _handleDragStart
        ..onUpdate = _handleDragUpdate
        ..onEnd = _handleDragEnd
        ..onCancel = _handleDragCancel;
    })
  },
  behavior: HitTestBehavior.opaque,
  child: PageView(
    controller: _pageController,
    scrollDirection: Axis.vertical,
    ///屏蔽預設的滑動響應
    physics: const NeverScrollableScrollPhysics(),
    children: [
      ListView.builder(
        controller: _listScrollController,
        ///屏蔽預設的滑動響應
        physics: const NeverScrollableScrollPhysics(),
        itemBuilder: (context, index) {
          return ListTile(title: Text('List Item $index'));
        },
        itemCount: 30,
      ),
      Container(
        color: Colors.green,
        child: Center(
          child: Text(
            'Page View',
            style: TextStyle(fontSize: 50),
          ),
        ),
      )
    ],
  ),
),      

接着我們看 ​

​_handleDragStart​

​​ 實作,如下代碼所示,在産生手勢 ​

​details​

​ 時,我們主要判斷:

  • 通過​

    ​ScrollController​

    ​​ 判斷​

    ​ListView​

    ​ 是否可見
  • 判斷觸摸位置是否在​

    ​ListIView​

    ​ 範圍内
  • 根據狀态判斷通過哪個​

    ​Controller​

    ​​ 去生産​

    ​Drag​

    ​ 對象,用于響應後續的滑動事件
void _handleDragStart(DragStartDetails details) {
    ///先判斷 Listview 是否可見或者可以調用
    ///一般不可見時 hasClients false ,因為 PageView 也沒有 keepAlive
    if (_listScrollController?.hasClients == true &&
        _listScrollController?.position.context.storageContext != null) {
      ///擷取 ListView 的  renderBox
      final RenderBox? renderBox = _listScrollController
          ?.position.context.storageContext
          .findRenderObject() as RenderBox;

      ///判斷觸摸的位置是否在 ListView 内
      ///不在範圍内一般是因為 ListView 已經滑動上去了,坐标位置和觸摸位置不一緻
      if (renderBox?.paintBounds
              .shift(renderBox.localToGlobal(Offset.zero))
              .contains(details.globalPosition) ==
          true) {
        _activeScrollController = _listScrollController;
        _drag = _activeScrollController?.position.drag(details, _disposeDrag);
        return;
      }
    }

    ///這時候就可以認為是 PageView 需要滑動      

前面我們主要在觸摸開始時,判斷需要響應的對象時​

​ ListView​

​​ 還是 ​

​PageView​

​​ ,然後通過 ​

​_activeScrollController​

​​ 儲存當然響應對象,并且通過 Controller 生成用于響應手勢資訊的 ​

​Drag​

​ 對象。

簡單說:滑動事件發生時,預設會建立一個 ​

​Drag​

​​ 用于處理後續的滑動事件,​

​Drag​

​​ 會對原始事件進行加工之後再給到 ​

​ScrollPosition​

​ 去觸發後續滑動效果。

接着在 ​

​_handleDragUpdate​

​​ 方法裡,主要是判斷響應是不是需要切換到 ​

​PageView ​

​:

  • 如果不需要就繼續用前面得到的​

    ​_drag?.update(details)​

    ​​響應​

    ​ ListView​

    ​ 滾動
  • 如果需要就通過​

    ​_pageController​

    ​​ 切換新的​

    ​_drag​

    ​ 對象用于響應
void _handleDragUpdate(DragUpdateDetails details) {
  if (_activeScrollController == _listScrollController &&

      ///手指向上移動,也就是快要顯示出底部 PageView
      details.primaryDelta! < 0 &&

      ///到了底部,切換到 PageView
      _activeScrollController?.position.pixels ==
          _activeScrollController?.position.maxScrollExtent) {
    ///切換相應的控制器
    _activeScrollController = _pageController;
    _drag?.cancel();

    ///參考  Scrollable 裡
    ///因為是切換控制器,也就是要更新 Drag
    ///拖拽流程要切換到 PageView 裡,是以需要  DragStartDetails
    ///是以需要把 DragUpdateDetails 變成 DragStartDetails
    ///提取出 PageView 裡的 Drag 相應 details      
這裡有個小知識點:如上代碼所示,我們可以簡單通過 ​

​details.primaryDelta​

​ 判斷滑動方向和移動的是否是主軸

最後如下 GIF 所示,可以看到 ​

​PageView​

​​ 嵌套 ​

​ListView​

​ 同方向滑動可以正常運作了,但是目前還有個兩個小問題,從圖示可以看到:

  • 在切換之後 ​

    ​ ListView​

    ​ 的位置沒有儲存下來
  • 産品要求去除 ​

    ​ListView​

    ​ 的邊緣溢出效果
Flutter 小技巧之 ListView 和 PageView 的各種花式嵌套

是以我們需要對 ​

​ListView​

​ 做一個 KeepAlive ,然後用簡單的方法去除 Android 邊緣滑動的 Material 效果:

  • 通過​

    ​with AutomaticKeepAliveClientMixin​

    ​​ 讓​

    ​ListView​

    ​ 在切換之後也保持滑動位置
  • 通過​

    ​ScrollConfiguration.of(context).copyWith(overscroll: false)​

    ​ 快速去除 Scrollable 的邊緣 Material 效果
child: PageView(
  controller: _pageController,
  scrollDirection: Axis.vertical,
  ///去掉 Android 上預設的邊緣拖拽效果
  scrollBehavior:
      ScrollConfiguration.of(context).copyWith(overscroll: false),


///對 PageView 裡的 ListView 做 KeepAlive 記住位置
class KeepAliveListView extends StatefulWidget{
  final ScrollController? listScrollController;
  final int itemCount;

  KeepAliveListView({
    required this.listScrollController,
    required this.itemCount,
  });

  @override
  KeepAliveListViewState createState() => KeepAliveListViewState();
}

class KeepAliveListViewState extends State<KeepAliveListView>
    with AutomaticKeepAliveClientMixin{
  @override
  Widget build(BuildContext context) {
    super.build(context);
    return ListView.builder(
      controller: widget.listScrollController,

      ///屏蔽預設的滑動響應
      physics: const NeverScrollableScrollPhysics(),
      itemBuilder: (context, index) {
        return ListTile(title: Text('List Item $index'));
      },
      itemCount: widget.itemCount,
    );
  }

  @override
  bool get wantKeepAlive => true;
}      

是以這裡我們有解鎖了另外一個小技巧:通過 ​

​ScrollConfiguration.of(context).copyWith(overscroll: false)​

​ 快速去除 Android 滑動到邊緣的 Material 2效果,為什麼說 Material2, 因為 Material3 上變了,具體可見: ​​Flutter 3 下的 ThemeExtensions 和 Material3​​ 。

Flutter 小技巧之 ListView 和 PageView 的各種花式嵌套
本小節源碼可見: ​​github.com/CarGuo/gsy_…​​

同方向 ListView 嵌套 PageView

那還有沒有更非正常的?答案是肯定的,畢竟産品的小腦袋,怎麼會想不到在垂直滑動的 ​

​ListView​

​ 裡嵌套垂直切換的 ​

​ PageView​

​ 這種需求。

有了前面的思路,其實實作這個邏輯也是異曲同工:把 ​

​PageView​

​ 和 ​

​ListView​

​ 的滑動禁用,然後通過 ​

​RawGestureDetector​

​ 自己管理,不同的就是手勢方法分發的差異。

RawGestureDetector(
          gestures: <Type, GestureRecognizerFactory>{
            VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
                    VerticalDragGestureRecognizer>(
                () => VerticalDragGestureRecognizer(),
                (VerticalDragGestureRecognizer instance) {
              instance
                ..onStart = _handleDragStart
                ..onUpdate = _handleDragUpdate
                ..onEnd = _handleDragEnd
                ..onCancel = _handleDragCancel;
            })
          },
          behavior: HitTestBehavior.opaque,
          child: ListView.builder(
                ///屏蔽預設的滑動響應
                physics: NeverScrollableScrollPhysics(),
                controller: _listScrollController,
                itemCount: 5,
                itemBuilder: (context, index) {
                  if (index == 0) {
                    return Container(
                      height: 300,
                      child: KeepAlivePageView(
                        pageController: _pageController,
                        itemCount: itemCount,
                      ),
                    );
                  }
                  return Container(
                      height: 300,
                      color: Colors.greenAccent,
                      child: Center(
                        child: Text(
                          "Item $index",
                          style: TextStyle(fontSize: 40, color: Colors.blue),
                        ),
                      ));
                }),
        )      

同樣是在 ​

​_handleDragStart​

​ 方法裡,這裡首先需要判斷:

  • ​ListView​

    ​​ 如果已經滑動過,就不響應頂部​

    ​PageView​

    ​ 的事件
  • 如果此時​

    ​ListView​

    ​​ 處于頂部未滑動,判斷手勢位置是否在​

    ​PageView​

    ​​ 裡,如果是響應​

    ​PageView​

    ​ 的事件
void _handleDragStart(DragStartDetails details) {
    ///隻要不是頂部,就不響應 PageView 的滑動
    ///是以這個判斷隻支援垂直 PageView 在  ListView 的頂部
    if (_listScrollController.offset > 0) {
      _activeScrollController = _listScrollController;
      _drag = _listScrollController.position.drag(details, _disposeDrag);
      return;
    }

    ///此時處于  ListView 的頂部
    if (_pageController.hasClients) {
      ///擷取 PageView
      final RenderBox renderBox =
          _pageController.position.context.storageContext.findRenderObject()
              as RenderBox;

      ///判斷觸摸範圍是不是在 PageView
      final isDragPageView = renderBox.paintBounds
          .shift(renderBox.localToGlobal(Offset.zero))
          .contains(details.globalPosition);

      ///如果在 PageView 裡就切換到 PageView
      if (isDragPageView) {
        _activeScrollController = _pageController;
        _drag = _activeScrollController.position.drag(details, _disposeDrag);
        return;
      }
    }

    ///不在 PageView 裡就繼續響應 ListView      

接着在 ​

​_handleDragUpdate​

​​ 方法裡,判斷如果 ​

​PageView​

​​ 已經滑動到最後一頁,也将滑動事件切換到 ​

​ListView​

void _handleDragUpdate(DragUpdateDetails details) {
  var scrollDirection = _activeScrollController.position.userScrollDirection;

  ///判斷此時響應的如果還是 _pageController,是不是到了最後一頁
  if (_activeScrollController == _pageController &&
      scrollDirection == ScrollDirection.reverse &&

      ///是不是到最後一頁了,到最後一頁就切換回 pageController
      (_pageController.page != null &&
          _pageController.page! >= (itemCount - 1))) {
    ///切換回 ListView      

當然,同樣還有 KeepAlive 和去除清單 Material 邊緣效果,最後運作效果如下 GIF 所示。

Flutter 小技巧之 ListView 和 PageView 的各種花式嵌套
本小節源碼可見:​​github.com/CarGuo/gsy_…​​

最後再補充一個小技巧:如果你需要 Flutter 列印手勢競技的過程,可以配置 ​

​debugPrintGestureArenaDiagnostics = true;​

​來讓 Flutter 輸出手勢競技的處理過程。

import 'package:flutter/gestures.dart';
void main() {
  debugPrintGestureArenaDiagnostics = true;
  runApp(MyApp());
}      

最後

///listView 關聯 listView
class ListViewLinkListView extends StatefulWidget{
  @override
  _ListViewLinkListViewState createState() => _ListViewLinkListViewState();
}

class _ListViewLinkListViewState extends State<ListViewLinkListView> {
  ScrollController _primaryScrollController = ScrollController();
  ScrollController _subScrollController = ScrollController();

  Drag? _primaryDrag;
  Drag? _subDrag;

  @override
  void initState() {
    super.initState();
  }

  @override
  void dispose() {
    _primaryScrollController.dispose();
    _subScrollController.dispose();
    super.dispose();
  }

  void _handleDragStart(DragStartDetails details) {
    _primaryDrag =
        _primaryScrollController.position.drag(details, _disposePrimaryDrag);
    _subDrag = _subScrollController.position.drag(details, _disposeSubDrag);
  }

  void _handleDragUpdate(DragUpdateDetails details) {
    _primaryDrag?.update(details);

    ///除以10實作差量效果
    _subDrag?.update(DragUpdateDetails(
        sourceTimeStamp: details.sourceTimeStamp,
        delta: details.delta / 30,
        primaryDelta: (details.primaryDelta ?? 0) / 30,
        globalPosition: details.globalPosition,
        localPosition: details.localPosition));
  }

  void _handleDragEnd(DragEndDetails details) {
    _primaryDrag?.end(details);
    _subDrag?.end(details);
  }

  void _handleDragCancel() {
    _primaryDrag?.cancel();
    _subDrag?.cancel();
  }

  void _disposePrimaryDrag() {
    _primaryDrag = null;
  }

  void _disposeSubDrag() {
    _subDrag = null;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          title: Text("ListViewLinkListView"),
        ),
        body: RawGestureDetector(
          gestures: <Type, GestureRecognizerFactory>{
            VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<
                    VerticalDragGestureRecognizer>(
                () => VerticalDragGestureRecognizer(),
                (VerticalDragGestureRecognizer instance) {
              instance
                ..onStart = _handleDragStart
                ..onUpdate = _handleDragUpdate
                ..onEnd = _handleDragEnd
                ..onCancel = _handleDragCancel;
            })
          },
          behavior: HitTestBehavior.opaque,
          child: ScrollConfiguration(
            ///去掉 Android 上預設的邊緣拖拽效果
            behavior:
                ScrollConfiguration.of(context).copyWith(overscroll: false),
            child: Row(
              children: [
                new Expanded(
                    child: ListView.builder(

                        ///屏蔽預設的滑動響應
                        physics: NeverScrollableScrollPhysics(),
                        controller: _primaryScrollController,
                        itemCount: 55,
                        itemBuilder: (context, index) {
                          return Container(
                              height: 300,
                              color: Colors.greenAccent,
                              child: Center(
                                child: Text(
                                  "Item $index",
                                  style: TextStyle(
                                      fontSize: 40, color: Colors.blue),
                                ),
                              ));
                        })),
                new SizedBox(
                  width: 5,
                ),
                new Expanded(
                  child: ListView.builder(

                      ///屏蔽預設的滑動響應
                      physics: NeverScrollableScrollPhysics(),
                      controller: _subScrollController,
                      itemCount: 55,
                      itemBuilder: (context, index) {
                        return Container(
                          height: 300,
                          color: Colors.deepOrange,
                          child: Center(
                            child: Text(
                              "Item $index",
                              style:
                                  TextStyle(fontSize: 40, color: Colors.white),
                            ),
                          ),
                        );
                      }),
                ),
              ],
            ),
          ),
        ));
  }
}