最近在做一个聊天的页面,参考微信的聊天页面,对ListView
有下面几个需求
- 使用列表Widget,例如:ListView, CustomScrollView等
- 支持
scrollToEnd
,当键盘,表情面板,工具面板弹出时,消息滑动到底部
- 支持获取位置用于跳转
getCurrentIndexInfo
,用于保持加载数据时候的位置不变
- 支持
jumpToIndex
和scrollToIndex
,避免手动计算位置
- 滑动位置要准确,没有误差
- 滑动到底部不会出现bounce
- 由于键盘上移的时候scrollToEnd
以ListView
为例,网上推荐的做法是
1 2 3 4 5 6 7 8
| void _scrollToEnd() { final offset = _scrollController.position.maxScrollExtent; _scrollController.animateTo(offset, duration: Duration(milliseconds: 250), curve: Curves.easeInOut ); }
|
但上面做法存在一个问题,就是误差,当内容是高度可变的时候就会有误差,如下面例子
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
| final _random = Random();
class _TestPageState extends State<TestPage> { List<double> _items = List.generate(180, (index) => _random.nextInt(100) + 100.0); ScrollController _scrollController = ScrollController(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("scrollToEnd"), actions: <Widget>[ IconButton( icon: Icon(Icons.add), onPressed: _scrollToEnd, ), ], ), body: ListView.builder( controller: _scrollController, itemBuilder: (c, i) { return Container( margin: EdgeInsets.all(8), height: _items[i], color: Colors.orange, child: Text("$i"), ); }, itemCount: _items.length), ); } }
|
存在问题
从截图中可以看到,maxScrollExtent
有时候偏小,有时候偏大,如果是偏大的情况,scrollView在scroll到屏幕外后再反弹(bounce的效果),这是由于ListView在渲染的时候,没出现在屏幕的Widget是不会被渲染的,这个时候还不能确定所有Widget的实际高度,ScrollView会根据当前渲染的Widget估算其他Widget的高度,所以带来误差,而如果widget是等高的,则不会有误差问题
如果能scrollToIndex
应该可以解决问题,直接滑动到某一项
自带的ScrollController不支持scrollToIndex,找到下面2个第三方库,支持scrollToIndex,两个库都可以精确滑动到对应的位置
scroll_to_index
: 通过分段滑动,边滑动边计算,在滑动的过程中可以得到widget的高度,达到scrollToIndex的目的
scrollable_positioned_list
- 对于滚动列表进行
extendCache
,缓存多两个屏幕的widget
- 为了计算位置,我们知道滚动到第0项,位置肯定是准的,也就是
offset=0
,scrollable_positioned_list用一个辅助的列表做滚动位置,让滚动的目标为0,这样就可以避免计算的误差
- 保持缓存区间所有Widget的位置信息,当目标位置在当前列表的缓存区间的时候,直接scrollToOffset,否则,使用辅助列表配合滚动,两个列表都只缓存开始和结束位置的widget,而不需要计算中间的widget,当列表增大时,不会带来太大的性能消耗
存在问题
scroll_to_index
: 由于是采用多次滚动的方式,对于数据量大的话滑动会持续时间比较长,而且看起来非常不顺滑,抖动厉害,性能消耗比较大
scrollable_positioned_list
: 当滑动到底部(最后一个项)的时候,他会把index项滑动到0的位置再回弹,会出现bounce
bounce问题
基于上面问题,考虑对scrollable_positioned_list
滑动之前添加溢出检查,避免溢出造成bounce,具体代码见这里
scrollable_positioned_list默认使用相对位置alignment,这里改为offset
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
| void _jumpTo({@required int index, double offset}) { cancelScrollCallback?.call();
final controller = _showFrontList ? frontScrollController : backScrollController; final lastTarget = _showFrontList ? frontTarget : backTarget; final direction = index > lastTarget ? 1 : -1; setState(() { if (lastTarget != index) { if (_showFrontList) { frontTarget = index; } else { backTarget = index; } } }); var jumpOffset = 0 + offset; if (direction == -1) { controller.jumpTo(jumpOffset); } else { controller.jumpTo(jumpOffset); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { var offset = min(jumpOffset, controller.position.maxScrollExtent); if (controller.offset != offset) { controller.jumpTo(offset); } }); } }
|
对于scrollToIndex
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
| ...
if (itemPosition != null) { final localScrollAmount = itemPosition.itemLeadingEdge * startingScrollController.position.viewportDimension;
var animateOffset = startingScrollController.offset + localScrollAmount - offset; animateOffset = min(animateOffset, startingScrollController.position.maxScrollExtent); await startingScrollController.animateTo(animateOffset, duration: duration, curve: curve); } else { ... startAnimationCallback = () { SchedulerBinding.instance.addPostFrameCallback((_) async { frontOpacity.parent = _opacityAnimation(startingListDisplay).animate( AnimationController(vsync: this, duration: duration)..forward()); startAnimationCallback = () {}; var endJump = -direction * (_screenScrollCount * startingScrollController.position.viewportDimension - offset); var startScroll = startingScrollController.offset + direction * scrollAmount;
endingScrollController.jumpTo(endJump); var endScroll = min( 0.0 + offset, endingScrollController.position.maxScrollExtent); endScroll = max(endScroll, endingScrollController.position.minScrollExtent); endCompleter.complete(endingScrollController.animateTo(endScroll, duration: duration, curve: curve));
startCompleter.complete(startingScrollController .animateTo(startScroll, duration: duration, curve: curve));
cancelScrollCallback = () => _cancelScroll(startingListDisplay); }); }; ... }
|
获取index位置
当列表滑动到顶部的时候,需要加载上一页的聊天数据,我们希望加载数据后刷新页面,用户所在的位置不变(不要跳动),可以保留当前位置,在刷新后更新位置
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
| List<dynamic> _getCurrentIndexInfo(bool wholeVisible) { final controller = _showFrontList ? frontScrollController : backScrollController; final notifier = _showFrontList ? frontItemPositionNotifier : backItemPositionNotifier; var visibleItems = notifier.itemPositions.value.where((i) { if (wholeVisible) { return i.itemLeadingEdge >= 0 && i.itemTrailingEdge <= 1; } else { return i.itemTrailingEdge > 0 && i.itemLeadingEdge < 1; } }); ItemPosition firstVisibleItem = visibleItems.fold(null, (v, i) { if (v == null) { return i; } else if (i.index < v.index) { return i; } else { return v; } }); var offset = controller.position.viewportDimension * firstVisibleItem.itemLeadingEdge; return [firstVisibleItem.index, offset]; }
|
键盘处理
键盘弹出的时候,我们希望ChatBar是动画上移的,并且listview需要scrollToEnd,Scaffold
有个属性resizeToAvoidBottomInset
用于控制键盘弹出时的内容区域,但是没有动画,直接变化看起来非常突兀,这里关掉了这个属性,我们自己来控制键盘弹出时的UI变化
1 2 3 4
| Scaffold( resizeToAvoidBottomInset: false, ... )
|
如果使用动画修改ListView的高度,则无法和scrollToEnd配合起来,应为scrollToEnd无法根据动画一致滚动,这样性能上会比较差,这里采用占位的方式,键盘弹出的时候,不修改ListView的高度,而是在ListView底部添加一个占位item
,修改这个占位item高度(不需要动画),然后scrollToEnd,这个滚动可以做到平滑,另外ChatBar键盘弹出时添加上动画即可
仿写微信项目在这里