I'm Terrence

Flutter 实战系列:个性化 ListView physics

背景

由于这篇总结是产品需求驱动的,先简要描述下 Sofanovel 项目的需求:仿照 inkitt 首页,实现个带有 hover 效果的横向列表,我们先直接来看下最后实现效果:
![](http://emyms.bs2dl.yy.com/MmQzM2UyOTctYjZmMy00NTA5LWE2OTktMzViYmJjNzY1NzM0.gif)![](http://emyms.bs2dl.yy.com/MmQzM2UyOTctYjZmMy00NTA5LWE2OTktMzViYmJjNzY1NzM0.gif)

解决思路

这个需求在 iOS 原生的 UIKIt 下 很好解决的,UIScrollView 本来就有个 paging 的属性,来实现这个 “翻页” 效果。而 Flutter 也有个类似的控件 PageView, 我们先来看下 PageView 的实现:

PageView

普通的 PageView 实现是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
return Container(
height: 200,
width: 200,
child: PageView(
children: TestDatas.map((color) {
return Container(
width: 100,
height: 200,
color: color,
);
}).toList(),
),
)

效果是 width 永远不受控制,充满屏幕,如图:

另一种实现:
加上 PageController 的 viewportFraction 修饰:

1
2
3
4
5
6
7
8
9
10
11
12
13
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
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 ,这是每次滑动手势结束后会触发,最终都是为了调用下面这句,来产生滑动效果:

1
2
ScrollSpringSimulation(spring, position.pixels, target, velocity,
tolerance: tolerance);

target 这个参数是整个类的主角,其他辅助函数都是为了计算出这个值而已,target 是表示这次滑动的终点,也就是说,我们通过控制这个参数来控制这次触摸结束后,listview 停在哪里。

其次,构造方法里面里面的 parent 参数也是挺重要的,主要用来组合各种 physics 属性,这里留在后面再说。

选中动效

这一步无非就是用 scrollView 监听 scroll offset, 到了指定位置就 setState ,已触发选中效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
_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;
});
}
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
_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 的源码了,里面有这样一个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

/// 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 的时候。
所以,我们重载一下这个方法就可以了。

1
2
@override
bool shouldAcceptUserOffset(ScrollMetrics position) => true;

另外,也可以通过构造方法 parent 这个入参去组合多个的已有的 physics 来完成这种特性:

1
2
3
4
5
_physics = PagingScrollPhysics(
itemDimension: itemWidth,
leadingSpacing: _leadingPortion,
maxSize: itemWidth * (testData.length - 1) - _leadingPortion,
parent: BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()));