前言
作为一名前两年只专注于Android最近才开始接触Flutter(Dart)的跨平台小白来说,很多东西都是在解决业务需求过程中边做边学习的,甚至还会带着Android惯性思维去看待问题。在上手了一个简单UI需求之后就遇到了一个和“事件处理”有关的需求,通过这个需求算是对Flutter事件处理有了一个初步的认知,所以就有了这一篇学习笔记的诞生。
划重点:通篇是比较基础的知识,适合有原生开发经验的Flutter初学者阅读,已入门的大神们可以直接跳过啦┭┮
需求背景
现有一个动态详情页如图,可以在屏幕上通过手动上下滑或者点击最右侧上下两个按钮进行动态内容的切换。
很显然这是个 PageView
(对应Android中的ViewPager
),它有个属性physics
可以定义如何响应用户操作,比如滑动到边界如何展示,默认情况下 iOS 会有弹性伸缩效果,Android 上会有弧形微光效果。
现在为了增强滑动到边界的体验,当用户处于第一条并继续向上滑或者处于最后一条并继续向下滑,需要弹出toast提示用户已经到顶/底了。
触摸事件
刚接到需求第一反应是分两个场景实现:按钮切换、手动切换。可以知道决定是否要弹出提示以及提示内容的两大因素是:滑动趋势(向上or向下)和当前页面index(判断第一页or最后一页)。
当前页面index是已知的,按钮的位置就是趋势,这个很好做;而手动切换,我立刻想起了Android中的MotionEvent
,它包括了ACTION_DOWN
、ACTION_MOVE
、ACTION_UP
等触摸事件,所以只要在DOWN&UP事件中通过Y轴差值就能得到滑动趋势。
PointerEvent
Android中的MotionEvent
在Flutter对应的是PointerEvent
,相应触摸事件有PointerDownEvent
、 PointerMoveEvent
、 PointerUpEvent
这些子类。
Listener
Flutter中提供Listener
来监听以上PointerEvent
,知道这些信息后,很快就写了以下代码(主要是手动切换的逻辑,按钮切换很简单不贴了):
// 1、在PageView上新增一个Listener节点
Listener(
onPointerDown: (event) {
// 2、在手指按下时记录此时的位置和页面
_downY = event.position.dy;
_downPageIndex = _currentPageIndex;
},
onPointerUp: (event) {、
// 3、在手指抬起时计算Y轴差值
final delta = event.position.dy - _downY;
if (delta > 0 && _downPageIndex == 0) {
Toasts.show(Strings.already_reached_the_top);
}
if (delta < 0 && _downPageIndex == value.length - 1) {
Toasts.show(Strings.already_reached_the_bottom);
}
},
child: PageView.builder(
...
),
),
我以为这就结束了,结果自测的时候才注意到PageView
中item右侧有个评论区ListView
,上下滑动这个ListView
也会产生上述触摸事件,同样会通知到Listener
,导致错误地弹出toast。所以关键就是如何只获取到PageView
本身的事件,这时我又产生了新的疑问,Flutter又是怎么分发触摸事件的,会和Android是类似的吗,或许会有什么突破口?
事件分发机制
其实Flutter和Android事件分发机制的核心步骤一样,整体包括事件分发、拦截和响应(dispatch-intercept-onTouch)这三块。此外还有一个重要概念是HitTestResult
,它维护了一组可以分发事件的渲染对象,比如手指按下后触发了一个PointerDownEvent
事件,它会从顶层开始按照深度优先遍历整棵渲染树,如果事件的触发位置在该渲染对象范围内则会被放入HitTestResult
,由于是深度遍历方式,子节点肯定会比父节点优先分发事件。
由于直接使用的是UI库中PageView
,很显然已经处理过和ListView
的滑动冲突问题了,那么它是否有提供自己滑动信息的回调呢?通过查看API文档,发现确实可以监听PageView
的滚动距离(offset)和滚动页面(page),如下图表现的是从第4页通过按钮不断切换到第一页。
_pageController.addListener(() {
print("minmin pageView offset = ${_pageController.offset},page = ${_pageController.page}");
});
但一旦到达边界(比如已经到第一页继续向上)就不会继续监听了,看来还要想想其他办法。
手势和手势冲突
GestureDetector
在学习PointerEvent
时,我注意到Flutter也有手势的概念,描述一个或多个PointerEvent
组成的语义动作,如GestureDetector
就可以识别如双击、长按、拖动、缩放等手势,实际上它内部封装了 Listener
,不同的是它还有手势竞争这一概念,即一个手势只会被一个GestureDetector
消耗,一个GestureDetector
监听多个手势也会产生冲突。
为了理解这一点, 现在把PageView
上个节点换成GestureDetector
并监听水平和垂直拖动事件看会发生什么。
// 1、在PageView上新增一个GestureDetector节点
GestureDetector(
// 2、监听水平和垂直拖动事件
onVerticalDragStart: (details) => print('minmin onVerticalDragStart'),
onVerticalDragEnd: (details) => print('minmin onVerticalDragEnd'),
onHorizontalDragStart: (details) => print('minmin onHorizontalDragStart'),
onHorizontalDragEnd: (details) => print('minmin onHorizontalDragEnd'),
child: PageView.builder(
...
),
),
由于PageView
本身已经处理了垂直拖动的手势,此时GestureDetector
只能监听到水平拖动事件了。可以看到不管是Listener
还是GestureDetector
都不能只获取PageView
本身的事件,换个思路,那是否可以通过拦截ListView
上的事件曲线救国呢?
通知机制
实际上Flutter有这样一套通知机制,每个节点都可以向上发送通知,而且通知可以在任意节点中止,中止后通知不会再向上传递。
ScrollNotification
比如PageView
、ListView
都会发送ScrollNotification
滚动通知,包括不同类型的通知子类有ScrollStartNotification
、 ScrollEndNotification
、 OverscrollNotification
等,下面总结了PageView
在不同操作下的滚动通知情况。
// 无论手动切换还是按钮切换,都是从1个 ScrollStartNotification 开始到1个 ScrollEndNotification 结束
// 其中,可以通过中间是 ScrollUpdateNotification 和 OverscrollNotification 区分是否滑动到边界
// 另外,如果滚动到边界后手动操作,中间会有n个 OverscrollNotification
// 即 PageView 在不同操作下 ScrollNotification 的回调情况:
// 按钮切换(非边界):1次 ScrollStartNotification -> n次 ScrollUpdateNotification -> 1次 ScrollEndNotification
// 手动切换(非边界):1次 ScrollStartNotification -> n次 ScrollUpdateNotification -> 1次 ScrollEndNotification
// 按钮切换(边界):1次 ScrollStartNotification -> 1次 OverscrollNotification-> 1次 ScrollEndNotification
// 手动切换(边界):1次 ScrollStartNotification -> n次 OverscrollNotification -> 1次
NotificationListener
NotificationListener
用来监听子节点的通知,通过设置onNotification
通知处理回调的返回值来控制是否继续向上发送通知,返回 true表示父节点将不再收到通知。知道这些信息后就有了以下代码:
// 1、在PageView上新增一个NotificationListener节点,只接收ScrollNotification
NotificationListener<ScrollNotification>(
onNotification: (notification) {
ScrollEndNotification
// 2、根据PageView的滚动表现,在 ScrollEndNotification 中判断是否要弹出toast
switch (notification.runtimeType) {
case ScrollUpdateNotification:
_isOverscroll = false;
break;
case ScrollEndNotification:
if (_isOverscroll) {
if (_currentPageIndex == 0) {
Toasts.show(Strings.already_reached_the_top);
}
if (_currentPageIndex == value.length - 1) {
Toasts.show(Strings.already_reached_the_bottom);
}
}
break;
case OverscrollNotification:
_isOverscroll = true;
break;
default:
break;
}
return false;
},
child: PageView.builder(
...
// 3、设置为ClampingScrollPhysics保证一定调用到OverscrollNotification
physics: ClampingScrollPhysics(),
itemBuilder: (BuildContext context, int index) {
...
return NotificationListener(
onNotification: (_) {
// 4、在item外新增NotificationListener节点,并中止滚动通知
return true;
},
child: ...;
},
),
),
如此完成了需求,也不需要单独处理按钮切换了。
总结
以上就是我通过一个小需求对Flutter事件处理相关知识的学习记录,其实都是很常用的API,一开始没有直接想到解决办法还是自己不熟悉Flutter。因水平有限,如有错漏还望指出~