Flutter 實現類似美團外賣店鋪頁面滑動效果

CyJay發表於2020-02-09

首先,我們看看目標和實現效果

美團外賣店鋪.gif
實現效果.gif

我這邊是把放活動的地方放在了TabBar上方。至於為什麼,哈哈,我怕麻煩,因為美團外賣的放活動的元件和下方商品的元件一併點菜評價商家頁面的切換而消失,但是這玩意兒又隨商品頁面的上滑而消失,算上主滑動元件,我們得做讓從商品列表元件上的滑動穿透兩級,實在是麻煩。所以我便把活動的元件放在了TabBar上方。

然後我們來分析一下頁面結構

美團外賣店鋪.jpg
美團外賣店鋪結構.png
看了前面的動態圖片,我們知道,TabBar下方的內容(即結構圖中的Body部分)隨頁面上滑而延伸,內部也包括了滑動元件。看到這種結構,我們自然很容易想到NestedScrollView這個元件。但是直接使用NestedScrollView有一些問題。舉個例子,先看例子程式碼:

  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      body: NestedScrollView(
        headerSliverBuilder: (BuildContext context, bool boxIsScrolled) {
          return <Widget>[
            SliverAppBar(
              pinned: true,
              title: Text("首頁",style: TextStyle(color: Colors.black)),
              backgroundColor: Colors.transparent,
              bottom: TabBar(
                controller: _tabController,
                labelColor: Colors.black,
                tabs: <Widget>[
                  Tab(text: "商品"),
                  Tab(text: "評價"),
                  Tab(text: "商家"),
                ],
              ),
            )
          ];
        },
        body: Container(
          color: Colors.blue,
          child: Center(
            child: Text("Body部分"),
          ),
        ),
      ),
    );
  }
複製程式碼

NestedScrollView問題.gif
看程式碼,我將SliverAppBar的背景設定為透明。當頁面上滑的時候,問題出現了,Body部分穿過了SliverAppBar狀態列下方,到達了螢幕頂部。這樣的話,做出來的效果肯定不是我們想要的。另外,由於NestedScrollView內部裡面只有一個ScrollController(下方程式碼中的innerController),Body裡面的所有列表的ScrollPosition都將會attach到這個ScrollController上,那麼就又有問題了,我們的商品頁面裡面有兩個列表,如果共用一個控制器,那麼ScrollPosition也使用的同一個,這可不行啊,畢竟列表都不一樣,所以因為NestedScrollView內部裡面只有一個ScrollController這一點,就決定了我們不能憑藉NestedScrollView來實現這個效果。但是,NestedScrollView對我們也不是沒有用,它可是為我們提供了關鍵思路。 為什麼說NestedScrollView依然對我們有用呢?因為它的特性呀,Body部分會隨頁面上滑而延伸,Body部分的底部始終在螢幕的底部。那麼這個Body部分的高度是怎麼來的?我們去看看NestedScrollView的程式碼:

  List<Widget> _buildSlivers(BuildContext context,
      ScrollController innerController, bool bodyIsScrolled) {
    return <Widget>[
      ...headerSliverBuilder(context, bodyIsScrolled),
      SliverFillRemaining(
        child: PrimaryScrollController(
          controller: innerController,
          child: body,
        ),
      ),
    ];
  }
複製程式碼

NestedScrollViewbody放到了SliverFillRemaining中,而這SliverFillRemaining的的確確是NestedScrollViewbody能夠填滿在前方元件於NestedScrollView底部之間的關鍵。好的,知道了這傢伙的存在,我們可以試試自己來做一個跟NestedScrollView有些類似的效果了。我選擇了最外層滑動元件CustomScrollView,嘿嘿,NestedScrollView也是繼承至CustomScrollView來實現的。

實現一個 NestedScrollView 類似的效果

首先我們寫一個跟NestedScrollView結構類似的介面ShopPage出來,關鍵程式碼如下:

class _ShopPageState extends State<ShopPage>{
    
    @override
    Widget build(BuildContext context) {
        return Scaffold(
          body: CustomScrollView(
            controller: _pageScrollController,
            physics: ClampingScrollPhysics(),
            slivers: <Widget>[
              SliverAppBar(
                  pinned: true,
                  title: Text("店鋪首頁", style: TextStyle(color: Colors.white)),
                  backgroundColor: Colors.blue,
                  expandedHeight: 300),
              SliverFillRemaining(
                  child: ListView.builder(
                      controller: _childScrollController,
                      padding: EdgeInsets.all(0),
                      physics: ClampingScrollPhysics(),
                      shrinkWrap: true,
                      itemExtent: 100.0,
                      itemCount: 30,
                      itemBuilder: (context, index) => Container(
                          padding: EdgeInsets.symmetric(horizontal: 1),
                          child: Material(
                            elevation: 4.0,
                            borderRadius: BorderRadius.circular(5.0),
                            color:
                            index % 2 == 0 ? Colors.cyan : Colors.deepOrange,
                            child: Center(child: Text(index.toString())),
                          ))))
            ],
          ),
        );
    }
}


               頁面結構                           滑動效果
複製程式碼

類NestedScrollView實現1.jpg
類NestedScrollView實現2.gif
由動圖可以看到,滑動下面的ListView不能帶動CustomScrollView中的SliverAppBar伸縮。我們應該怎麼實現呢?首先想想我們要的效果:

  • 向上滑動ListView時,如果SliverAppBar是展開狀態,應該先讓SliverAppBar收縮,當SliverAppBar不能收縮時,ListView才會滾動。
  • 向下滑動ListView時,當ListView已經滑動到第一個不能再滑動時,SliverAppBar應該展開,直到SliverAppBar完全展開。

SliverAppBar應不應該響應,響應的話是展開還是收縮。我們肯定需要根據滑動方向CustomScrollView與ListView已滑動距離來判斷。所以我們需要一個工具來根據滑動事件是誰發起的、CustomScrollView與ListView的狀態、滑動的方向、滑動的距離、滑動的速度等進行協調它們怎麼響應。

至於這個協調器怎麼寫,我們先不著急。我們應該搞清楚 滑動元件原理,推薦文章:

從零開始實現一個巢狀滑動的PageView(一)

從零開始實現一個巢狀滑動的PageView(二)

Flutter的滾動以及sliver約束

看了這幾個文章,結合我們的使用場景,我們需要明白:

  • 當手指在螢幕上滑動時,ScrollerPosition中的applyUserOffset方法會得到滑動向量;
  • 當手指離開螢幕時, ScrollerPosition中的goBallistic方法會得到手指離開螢幕前滑動速度;
  • 至始自終,主滑動元件上發起的滑動事件,對子滑動部件無干擾,那麼我們在協調時,只需要把子部件的事件傳給協調器分析、協調。

簡單來說,我們需要修改 ScrollerPosition, ScrollerController。修改ScrollerPosition是為了把手指滑動距離手指離開螢幕前滑動速度傳遞給協調器協調處理。修改ScrollerController是為了保證滑動控制器在建立ScrollerPosition建立的是我們修改過後的ScrollerPosition。那麼,開始吧!

實現子部件上下滑動關聯主部件

首先,假設我們的協調器類名為ShopScrollCoordinator

滑動控制器 ShopScrollerController

我們去複製ScrollerController的原始碼,然後為了方便區分,我們把類名改為ShopScrollController。 控制器需要修改的部分如下:

class ShopScrollController extends ScrollController {
  final ShopScrollCoordinator coordinator;

  ShopScrollController(
    this.coordinator, {
    double initialScrollOffset = 0.0,
    this.keepScrollOffset = true,
    this.debugLabel,
  })  : assert(initialScrollOffset != null),
        assert(keepScrollOffset != null),
        _initialScrollOffset = initialScrollOffset;

  ScrollPosition createScrollPosition(ScrollPhysics physics,
      ScrollContext context, ScrollPosition oldPosition) {
    return ShopScrollPosition(
      coordinator: coordinator,
      physics: physics,
      context: context,
      initialPixels: initialScrollOffset,
      keepScrollOffset: keepScrollOffset,
      oldPosition: oldPosition,
      debugLabel: debugLabel,
    );
  }
  ///其他的程式碼不要動
}
複製程式碼

滑動滾動位置 ShopScrollPosition

原版的ScrollerController建立的ScrollPositionScrollPositionWithSingleContext。 我們去複製ScrollPositionWithSingleContext的原始碼,然後為了方便區分,我們把類名改為ShopScrollPosition。前面說了,我們主要是需要修改applyUserOffset,goBallistic兩個方法。

class ShopScrollPosition extends ScrollPosition 
    implements ScrollActivityDelegate {
  final ShopScrollCoordinator coordinator; // 協調器

  ShopScrollPosition(
      {@required this.coordinator,
      @required ScrollPhysics physics,
      @required ScrollContext context,
      double initialPixels = 0.0,
      bool keepScrollOffset = true,
      ScrollPosition oldPosition,
      String debugLabel})
      : super(
          physics: physics,
          context: context,
          keepScrollOffset: keepScrollOffset,
          oldPosition: oldPosition,
          debugLabel: debugLabel,
       ) {
    if (pixels == null && initialPixels != null) correctPixels(initialPixels);
    if (activity == null) goIdle();
    assert(activity != null);
  }

  /// 當手指滑動時,該方法會獲取到滑動距離
  /// [delta]滑動距離,正增量表示下滑,負增量向上滑
  /// 我們需要把子部件的 滑動資料 交給協調器處理,主部件無干擾
  @override
  void applyUserOffset(double delta) {
    ScrollDirection userScrollDirection =
        delta > 0.0 ? ScrollDirection.forward : ScrollDirection.reverse;
    if (debugLabel != coordinator.pageLabel)
      return coordinator.applyUserOffset(delta, userScrollDirection, this);
    updateUserScrollDirection(userScrollDirection);
    setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta));
  }

  /// 以特定的速度開始一個物理驅動的模擬,該模擬確定[pixels]位置。
  /// 此方法遵從[ScrollPhysics.createBallisticSimulation],該方法通常在當前位置超出
  /// 範圍時提供滑動模擬,而在當前位置超出範圍但具有非零速度時提供摩擦模擬。
  /// 速度應以每秒邏輯畫素為單位。 
  /// [velocity]手指離開螢幕前滑動速度,正表示下滑,負向上滑
  @override
  void goBallistic(double velocity, [bool fromCoordinator = false]) {
    if (debugLabel != coordinator.pageLabel) {
      // 子部件滑動向上模擬滾動時才會關聯主部件
      if (velocity > 0.0) coordinator.goBallistic(velocity);
    } else {
      if (fromCoordinator && velocity <= 0.0) return;
    }
    assert(pixels != null);
    final Simulation simulation =
        physics.createBallisticSimulation(this, velocity);
    if (simulation != null) {
      beginActivity(BallisticScrollActivity(this, simulation, context.vsync));
    } else {
      goIdle();
    }
  }

  /// 返回未使用的增量。 
  /// 從[NestedScrollView]的自定義[ScrollPosition][_NestedScrollPosition]拷貝
  double applyClampedDragUpdate(double delta) {
    assert(delta != 0.0);
    final double min =
        delta < 0.0 ? -double.infinity : math.min(minScrollExtent, pixels);
    final double max =
        delta > 0.0 ? double.infinity : math.max(maxScrollExtent, pixels);
    final double oldPixels = pixels;
    final double newPixels = (pixels - delta).clamp(min, max) as double;
    final double clampedDelta = newPixels - pixels;
    if (clampedDelta == 0.0) return delta;
    final double overScroll = physics.applyBoundaryConditions(this, newPixels);
    final double actualNewPixels = newPixels - overScroll;
    final double offset = actualNewPixels - oldPixels;
    if (offset != 0.0) {
      forcePixels(actualNewPixels);
      didUpdateScrollPositionBy(offset);
    }
    return delta + offset;
 }

 /// 返回過度滾動。
 /// 從[NestedScrollView]的自定義[ScrollPosition][_NestedScrollPosition]拷貝
 double applyFullDragUpdate(double delta) {
   assert(delta != 0.0);
   final double oldPixels = pixels;
   // Apply friction: 施加摩擦:
   final double newPixels =
       pixels - physics.applyPhysicsToUserOffset(this, delta);
   if (oldPixels == newPixels) return 0.0;
   // Check for overScroll: 檢查過度滾動:
   final double overScroll = physics.applyBoundaryConditions(this, newPixels);
   final double actualNewPixels = newPixels - overScroll;
   if (actualNewPixels != oldPixels) {
     forcePixels(actualNewPixels);
     didUpdateScrollPositionBy(actualNewPixels - oldPixels);
   }
   return overScroll;
 }
}
複製程式碼

滑動協調器 ShopScrollCoordinator

class ShopScrollCoordinator {
  /// 頁面主滑動元件標識
  final String pageLabel = "page";
  
  /// 獲取主頁面滑動控制器
  ShopScrollController pageScrollController([double initialOffset = 0.0]) {
    assert(initialOffset != null, initialOffset >= 0.0);
    _pageInitialOffset = initialOffset;
    _pageScrollController = ShopScrollController(this,
        debugLabel: pageLabel, initialScrollOffset: initialOffset);
  return _pageScrollController;
  }
  /// 建立並獲取一個子滑動控制器
  ShopScrollController newChildScrollController([String debugLabel]) =>
      ShopScrollController(this, debugLabel: debugLabel);

  /// 子部件滑動資料協調
  /// [delta]滑動距離
  /// [userScrollDirection]使用者滑動方向
  /// [position]被滑動的子部件的位置資訊
  void applyUserOffset(double delta,
      [ScrollDirection userScrollDirection, ShopScrollPosition position]) {
    if (userScrollDirection == ScrollDirection.reverse) {
      /// 當使用者滑動方向是向上滑動
      updateUserScrollDirection(_pageScrollPosition, userScrollDirection);
      final innerDelta = _pageScrollPosition.applyClampedDragUpdate(delta);
      if (innerDelta != 0.0) {
        updateUserScrollDirection(position, userScrollDirection);
        position.applyFullDragUpdate(innerDelta);
      }
    } else {
      /// 當使用者滑動方向是向下滑動
      updateUserScrollDirection(position, userScrollDirection);
      final outerDelta = position.applyClampedDragUpdate(delta);
      if (outerDelta != 0.0) {
        updateUserScrollDirection(_pageScrollPosition, userScrollDirection);
        _pageScrollPosition.applyFullDragUpdate(outerDelta);
      }
    }
  }
}
複製程式碼

現在,我們在_ShopPageState裡新增程式碼:

class _ShopPageState extends State<ShopPage>{
  // 頁面滑動協調器
  ShopScrollCoordinator _shopCoordinator;
  // 頁面主滑動部件控制器
  ShopScrollController _pageScrollController;
  // 頁面子滑動部件控制器
  ShopScrollController _childScrollController;
    
  /// build 方法中的CustomScrollView和ListView 記得加上控制器!!!!

  @override
  void initState() {
    super.initState();
    _shopCoordinator = ShopScrollCoordinator();
    _pageScrollController = _shopCoordinator.pageScrollController();
    _childScrollController = _shopCoordinator.newChildScrollController();
  }

  @override
  void dispose() {
    _pageScrollController?.dispose();
    _childScrollController?.dispose();
    super.dispose();
  }
}
複製程式碼

這個時候,基本實現了實現子部件上下滑動關聯主部件。效果如圖:

實現子部件上下滑動關聯主部件.gif

實現美團外賣 點菜 頁面的Body結構

修改_ShopPageStateSliverFillRemaining中內容:

/// 注意新增一個新的控制器!!

SliverFillRemaining(
              child: Row(
            children: <Widget>[
              Expanded(
                  child: ListView.builder(
                      controller: _childScrollController,
                      padding: EdgeInsets.all(0),
                      physics: ClampingScrollPhysics(),
                      shrinkWrap: true,
                      itemExtent: 50,
                      itemCount: 30,
                      itemBuilder: (context, index) => Container(
                          padding: EdgeInsets.symmetric(horizontal: 1),
                          child: Material(
                            elevation: 4.0,
                            borderRadius: BorderRadius.circular(5.0),
                            color: index % 2 == 0
                                ? Colors.cyan
                                : Colors.deepOrange,
                            child: Center(child: Text(index.toString())),
                          )))),
              Expanded(
                  flex: 4,
                  child: ListView.builder(
                      controller: _childScrollController1,
                      padding: EdgeInsets.all(0),
                      physics: ClampingScrollPhysics(),
                      shrinkWrap: true,
                      itemExtent: 150,
                      itemCount: 30,
                      itemBuilder: (context, index) => Container(
                          padding: EdgeInsets.symmetric(horizontal: 1),
                          child: Material(
                            elevation: 4.0,
                            borderRadius: BorderRadius.circular(5.0),
                            color: index % 2 == 0
                                ? Colors.cyan
                                : Colors.deepOrange,
                            child: Center(child: Text(index.toString())),
                          ))))
            ],
          ))
複製程式碼

看效果

美團外賣點菜頁面實現1.gif
看來還有些問題,什麼問題呢?當我只上滑右邊的子部件,當SliverAppBar的最小化時,我們可以看到左邊的子部件的第一個居然不是0。如圖:
SliverFillRemaining穿過吸頂元件問題.jpg
跟前面的NestedScrollView中的問題一樣。那我們怎麼解決呢?改唄!靈感來自於,Flutter Candies 一桶天下 協調器新增方法:

/// 獲取body前吸頂元件高度
double Function() pinnedHeaderSliverHeightBuilder;

bool applyContentDimensions(double minScrollExtent, double maxScrollExtent,
    ShopScrollPosition position) {
  if (pinnedHeaderSliverHeightBuilder != null) {
    maxScrollExtent = maxScrollExtent - pinnedHeaderSliverHeightBuilder();
    maxScrollExtent = math.max(0.0, maxScrollExtent);
  }
  return position.applyContentDimensions(
      minScrollExtent, maxScrollExtent, true);
}
複製程式碼

修改ShopScrollPositionapplyContentDimensions方法:

@override
bool applyContentDimensions(double minScrollExtent, double maxScrollExtent,
    [bool fromCoordinator = false]) {
  if (debugLabel == coordinator.pageLabel && !fromCoordinator)
    return coordinator.applyContentDimensions(
        minScrollExtent, maxScrollExtent, this);
  return super.applyContentDimensions(minScrollExtent, maxScrollExtent);
}
複製程式碼

這個時候,我們只需要在頁面的初始化協調器後,給協調器賦值一個返回body之前的所有鎖頂元件摺疊後的高度之和的函式就可以了。

實現美團外賣 店鋪頁面 頭部全屏化展開顯示店鋪資訊效果

目標如圖:

美團外賣頭部全屏化展開顯示店鋪資訊效果.gif
為什麼說是全屏化,這個相信不需要我多講,展開的卡片周圍的灰色就是個padding而已。 用過SliverAppBar的人基本上都能想到,將它的expandedHeight設定成螢幕高度就可以實現頭部在展開的時候填充滿整個螢幕。但是,頁面中SliverAppBar預設並不是完全展開狀態,當然也不是完全收縮狀態,完全收縮狀態的話,這玩意兒就只剩個AppBar在頂部了。那麼我們應該怎麼讓它預設顯示成類似美團那樣的呢? 還記得我們的ScrollController的建構函式有個名稱為initialScrollOffset可傳引數吧,嘿嘿,只要我們把頁面主滑動部件的控制器設定了initialScrollOffset,頁面豈不是就會預設定在initialScrollOffset對應的位置。 好的,預設位置可以了。可是,從動圖可以看到,當我們下拉部件,使預設位置 < 主部件已下滑距離 < 最大展開高度並鬆開手指時,SliverAppBar會繼續展開至最大展開高度。那麼我們肯定要捕捉手指離開螢幕事件。這個時候呢,我們可以使用Listener元件包裹CustomScrollView,然後在ListeneronPointerUp中獲取手指離開螢幕事件。好的,思路有了。我們來看看怎麼實現吧:

協調器外部新增列舉:

enum PageExpandState { NotExpand, Expanding, Expanded }
複製程式碼

協調器新增程式碼:

/// 主頁面滑動部件預設位置
double _pageInitialOffset;

/// 獲取主頁面滑動控制器
ShopScrollController pageScrollController([double initialOffset = 0.0]) {
    assert(initialOffset != null, initialOffset >= 0.0);
    _pageInitialOffset = initialOffset;
    _pageScrollController = ShopScrollController(this,
        debugLabel: pageLabel, initialScrollOffset: initialOffset);
  return _pageScrollController;
}

/// 當預設位置不為0時,主部件已下拉距離超過預設位置,但超過的距離不大於該值時,
/// 若手指離開螢幕,主部件頭部會回彈至預設位置
double _scrollRedundancy = 80;

/// 當前頁面Header最大程度展開狀態
PageExpandState pageExpand = PageExpandState.NotExpand;    

/// 當手指離開螢幕
void onPointerUp(PointerUpEvent event) {
  final double _pagePixels = _pageScrollPosition.pixels;
  if (0.0 < _pagePixels && _pagePixels < _pageInitialOffset) {
    if (pageExpand == PageExpand.NotExpand &&
        _pageInitialOffset - _pagePixels > _scrollRedundancy) {
      _pageScrollPosition
          .animateTo(0.0,
              duration: const Duration(milliseconds: 400), curve: Curves.ease)
          .then((value) => pageExpand = PageExpand.Expanded);
    } else {
      pageExpand = PageExpand.Expanding;
      _pageScrollPosition
          .animateTo(_pageInitialOffset,
              duration: const Duration(milliseconds: 400), curve: Curves.ease)
          .then((value) => pageExpand = PageExpand.NotExpand);
    }
  }
}
複製程式碼

這個時候,我們把協調器的onPointerUp方法傳給ListeneronPointerUp,我們基本實現了想要的效果。 But,經過測試,其實它還有個小問題,有時候手指鬆開它並不會按照我們想象的那樣自動展開或者回到預設位置。問題是什麼呢?我們知道,手指滑動列表然後離開螢幕時,ScrollPositiongoBallistic方法會被呼叫,所以onPointerUp剛被呼叫立馬goBallistic也被呼叫,當goBallistic傳入的速度絕對值很小的時候,那麼列表的模擬滑動距離就很小很小,甚至為0.0。那麼結果是怎麼樣的,自然而然出現在腦袋中了吧。

我們還需要繼續修改一下ShopScrollPositiongoBallistic方法:

@override
void goBallistic(double velocity, [bool fromCoordinator = false]) {
  if (debugLabel != coordinator.pageLabel) {
    if (velocity > 0.0) coordinator.goBallistic(velocity);
  } else {
    if (fromCoordinator && velocity <= 0.0) return;
    if (coordinator.pageExpand == PageExpandState.Expanding) return;
  }
  assert(pixels != null);
  final Simulation simulation =
      physics.createBallisticSimulation(this, velocity);
  if (simulation != null) {
    beginActivity(BallisticScrollActivity(this, simulation, context.vsync));
  } else {
    goIdle();
  }
}
複製程式碼

記得頁面initState中,初始化_pageScrollController的時候,記得傳入預設位置的值。 此時需要注意一下,預設位置的值並不是頁面在預設狀態下SliverAppBar底部在距螢幕頂部的距離,而是螢幕高度減去其底部距螢幕頂部的距離,即initialOffset = screenHeight - x,而這個x我們根據設計或者自己的感覺來設定便是。這裡我取200。 來來來,我們看看效果怎麼樣!!

頭部全屏化展開顯示店鋪資訊實現.gif

文章專案案例 github連結 flutter_meituan_shop

相關文章