Flutter 实战系列:个性化 ListView physics

3,202 阅读4分钟

背景

由于这篇总结是产品需求驱动的,先简要描述下 Sofanovel 项目的需求:仿照 inkitt 首页,实现个带有 hover 效果的横向列表,我们先直接来看下最后实现效果:

![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/11/12/16e5eef25580d503~tplv-t2oaga2asx-image.image)![](https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/gold-user-assets/2019/11/12/16e5eef25580d503~tplv-t2oaga2asx-image.image)

解决思路

这个需求在 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 永远不受控制,充满屏幕,如图:

另一种实现: 加上 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(),
  ),
)

实现效果是这个样子的:

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