Flutter 實戰系列:個性化 ListView physics

YYDev發表於2019-11-12

背景

由於這篇總結是產品需求驅動的,先簡要描述下 Sofanovel 專案的需求:仿照 inkitt 首頁,實現個帶有 hover 效果的橫向列表,我們先直接來看下最後實現效果:

![](https://user-gold-cdn.xitu.io/2019/11/12/16e5eef25580d503?w=240&h=360&f=gif&s=290603)![](https://user-gold-cdn.xitu.io/2019/11/12/16e5eef25580d503?w=240&h=360&f=gif&s=290603)

解決思路

這個需求在 iOS 原生的 UIKIt 下 很好解決的,UIScrollView 本來就有個 paging 的屬性,來實現這個 “翻頁” 效果。而 Flutter 也有個類似的控制元件 PageView, 我們先來看下 PageView 的實現:

PageView

普通的 PageView 實現是這樣的:

return Container(
  height: 200,
  width: 200,
  child: PageView(
    children: TestDatas.map((color) {
      return Container(
        width: 100,
        height: 200,
        color: color,
      );
    }).toList(),
  ),
)
複製程式碼

效果是 width 永遠不受控制,充滿螢幕,如圖:

Flutter 實戰系列:個性化 ListView physics

另一種實現: 加上 PageController 的 viewportFraction 修飾:

return Container(
  height: 200,
  child: PageView(
    controller: PageController(initialPage: 0, viewportFraction: 0.8),
    children: TestDatas.map((color) {
      return Container(
        width: 100,
        height: 200,
        color: color,
      );
    }).toList(),
  ),
)
複製程式碼

實現效果是這個樣子的:

Flutter 實戰系列:個性化 ListView physics

viewportFraction 這個引數只能粗略地表示 選中區域 佔螢幕的百分比,而這個區域永遠落在中央,不能簡單實現偏左或者偏右的自定義化,因此捨棄了 pageView 的實現。

ListView

賦予翻頁效果

從橫向佈局的 ListView 入手開搞,自定義一個帶有 pageView 特性的 physics

class PagingScrollPhysics extends ScrollPhysics {
  final double itemDimension; // ListView children item 固定寬度
  final double leadingSpacing; // 選中 item 離左邊緣留白
  final double maxSize; // 最大可滑動區域

  PagingScrollPhysics(
      {this.maxSize,
      this.leadingSpacing,
      this.itemDimension,
      ScrollPhysics parent})
      : super(parent: parent);

  @override
  PagingScrollPhysics applyTo(ScrollPhysics ancestor) {
    return PagingScrollPhysics(
        maxSize: maxSize,
        itemDimension: itemDimension,
        leadingSpacing: leadingSpacing,
        parent: buildParent(ancestor));
  }

  double _getPage(ScrollPosition position, double leading) {
    return (position.pixels + leading) / itemDimension;
  }

  double _getPixels(double page, double leading) {
    return (page * itemDimension) - leading;
  }

  double _getTargetPixels(
    ScrollPosition position,
    Tolerance tolerance,
    double velocity,
    double leading,
  ) {
    double page = _getPage(position, leading);

    if (position.pixels < 0) {
      return 0;
    }

    if (position.pixels >= maxSize) {
      return maxSize;
    }

    if (position.pixels > 0) {
      if (velocity < -tolerance.velocity) {
        page -= 0.5;
      } else if (velocity > tolerance.velocity) {
        page += 0.5;
      }
      return _getPixels(page.roundToDouble(), leading);
    }
  }

  @override
  Simulation createBallisticSimulation(
      ScrollMetrics position, double velocity) {
    // If we're out of range and not headed back in range, defer to the parent
    // ballistics, which should put us back in range at a page boundary.

    if ((velocity <= 0.0 && position.pixels <= position.minScrollExtent))
      return super.createBallisticSimulation(position, velocity);

    final Tolerance tolerance = this.tolerance;

    final double target =
        _getTargetPixels(position, tolerance, velocity, leadingSpacing);
    if (target != position.pixels)
      return ScrollSpringSimulation(spring, position.pixels, target, velocity,
          tolerance: tolerance);
    return null;
  }

  @override
  bool get allowImplicitScrolling => false;
}
複製程式碼

程式碼一大堆,我們聚焦入口 createBallisticSimulation ,這是每次滑動手勢結束後會觸發,最終都是為了呼叫下面這句,來產生滑動效果:

ScrollSpringSimulation(spring, position.pixels, target, velocity,
    tolerance: tolerance);
複製程式碼

target 這個引數是整個類的主角,其他輔助函式都是為了計算出這個值而已,target 是表示這次滑動的終點,也就是說,我們通過控制這個引數來控制這次觸控結束後,listview 停在哪裡。

其次,構造方法裡面裡面的 parent 引數也是挺重要的,主要用來組合各種 physics 屬性,這裡留在後面再說。

選中動效

這一步無非就是用 scrollView 監聽 scroll offset, 到了指定位置就 setState ,已觸發選中效果。

    _scrollCtl.addListener(() {
      double test =
          _bookWidth != null ? _scrollCtl.offset / (_bookWidth + margin) : 1;
      int next = test.round();
      if (next < 0) {
        next = 0;
      }
      if (next >= testData.length) {
        next = testData.length - 1;
      }
      if (_currentPage != next) {
        setState(() {
          _currentPage = next;
        });
      }
    });
複製程式碼
_buildBookItem(Map data, bool active, {num width}) {
  width = _bookWidth;
  // Animated Properties
  final double blur = active ? 5 : 0;
  final double offset = active ? 2 : 0;
  final double top = active ? 10 : 20;
  final double bottom = active ? 10 : 20;

  return GestureDetector(
    onTap: () {
      if (data['index'] == _currentPage) {
        _jump();
      } else {
        scrollToPage(data['index']);
      }
    },
    child: AnimatedContainer(
      width: width,
      height: 1.38 * width,
      child: Center(child: Text(data['index'].toString())),
      duration: Duration(milliseconds: 500),
      curve: Curves.easeOutQuint,
      margin: EdgeInsets.only(top: top, bottom: bottom, right: margin),
      decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(4),
          color: randomColor,
          boxShadow: [
            BoxShadow(
                color: Colors.black87,
                blurRadius: blur,
                offset: Offset(offset, offset))
          ]),
    ),
  );
}
複製程式碼

後話

在自測時發現過這樣一個問題:當 listView 裡面的 children 過少時, 整個 listView 壓根不能滑動, physics 裡面的 createBallisticSimulation 實現得再完美,也觸發不了其中的回撥的。為了避免這種情況,比較粗暴的方法是,在 children 加空白 Container,以充滿 listView 固有的寬度或者高度,來讓 listView 滿足可滑動的前提。

正規軍解法

為何 chidren 過少就滑動不了?這裡要看下 ScrollPhysics 的原始碼了,裡面有這樣一個方法:


  /// Whether the scrollable should let the user adjust the scroll offset, for
  /// example by dragging.
  ///
  /// By default, the user can manipulate the scroll offset if, and only if,
  /// there is actually content outside the viewport to reveal.
  ///
  /// The given `position` is only valid during this method call. Do not keep a
  /// reference to it to use later, as the values may update, may not update, or
  /// may update to reflect an entirely unrelated scrollable.
  bool shouldAcceptUserOffset(ScrollMetrics position) {
    if (parent == null)
      return position.pixels != 0.0 || position.minScrollExtent != position.maxScrollExtent;
    return parent.shouldAcceptUserOffset(position);
  }
複製程式碼

原始碼裡面註釋得很清楚了,唯有內容超出顯示範圍時,才可以觸發他的滾動,即 position.minScrollExtent != position.maxScrollExtent 的時候。 所以,我們過載一下這個方法就可以了。

@override
bool shouldAcceptUserOffset(ScrollMetrics position) => true;
複製程式碼

另外,也可以通過構造方法 parent 這個入參去組合多個的已有的 physics 來完成這種特性:

_physics = PagingScrollPhysics(
        itemDimension: itemWidth,
        leadingSpacing: _leadingPortion,
        maxSize: itemWidth * (testData.length - 1) - _leadingPortion,
        parent: BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()));
複製程式碼

Author:Terrence

相關文章