非常感谢 Didier Boelens 同意我将它的一些文章翻译为中文发表,这是其中一篇。
本文通过一个实例详细讲解了 Flutter 中动画的原理。
原文的代码块有行号。在这里不支持对代码添加行号,阅读可能有些不方便,请谅解。
原文 链接
本文模仿 Facebook 的响应式按钮,使用 响应式编程、Overlay、Animation、Streams、BLoC 设计模式 和 GestureDetector 实现。
难度:中等
介绍
最近,有人问我如何在 Flutter 中模仿 Facebook 的响应式按钮。 经过一番思考之后,我意识到这是一个实践最近我在前几篇文章中讨论的主题的机会。
我要解释的解决的方案(我称之为“响应式按钮”)使用以下概念:
可以在 GitHub 上找到本文的源代码。 它也可以作为 Flutter 包使用:flutter_reactive_button。
这是一个显示了本文结果的动画。
需求
在进入实施细节之前,让我们首先考虑 Facebook 响应式按钮的工作原理:
-
当用户按下按钮,等待一段时间(称为长按)时,屏幕上会显示一个在所有内容之上的面板,等待用户选择该面板中包含的其中一个图标;
-
如果用户将他/她的手指移动到图标上,图标的尺寸会增大;
-
如果用户将他/她的手指移开该图标,图标恢复其原始大小;
-
如果用户在仍然在图标上方时释放他/她的手指,则该图标被选中;
-
如果用户在没有图标悬停的情况下释放他/她的手指,则没有选中任何一个图标;
-
如果用户只是点击按钮,意味着它不被视为长按,则该动作被视为正常的点击;
-
我们可以在屏幕上有多个响应式按钮实例;
-
图标应显示在视口中。
解决方案的描述
各种不同的可视部分
下图显示了解决方案中涉及的不同部分:
-
ReactiveButton
ReactiveButton可以放置在屏幕上的任何位置。 当用户对其执行长按时,它会触发 ReactiveIconContainer 的显示。
-
ReactiveIconContainer
一个简单的容器,用于保存不同的 ReactiveIcons
-
ReactiveIcon
一个图标,如果用户将其悬停,可能会变大。
Overlay
应用程序启动后,Flutter 会自动创建并渲染 Overlay Widget。 这个 Overlay Widget 只是一个栈 (Stack),它允许可视组件 “浮动” 在其他组件之上。 大多数情况下,这个 Overlay 主要用于导航器显示路由(=页面或屏幕),对话框,下拉选项 ...... 下图说明了 Overlay 的概念。各组件彼此叠加。
您插入 Overlay 的每一个 Widget 都必须通过 OverlayEntry 来插入。
利用这一概念,我们可以通过 OverlayEntry 轻松地在所有内容之上显示 ReactiveIconContainer。
为什么用 OverlayEntry 而不用通常的 Stack?
其中一个要求是我们需要在所有内容之上显示图标列表。
@override
Widget build(BuildContext context){
return Stack(
children: <Widget>[
_buildButton(),
_buildIconsContainer(),
...
],
);
}
Widget _buildIconsContainer(){
return !_isContainerVisible ? Container() : ...;
}
复制代码
如果我们使用 Stack,如上所示,这将导致一些问题:
-
我们永远不会确定 ReactiveIconContainer 会系统地处于一切之上,因为 ReactiveButton 本身可能是另一个 Stack 的一部分(也许在另一个 Widget 下);
-
我们需要实现一些逻辑来渲染或不渲染 ReactiveIconContainer,因此必须重新构建 Stack,这不是非常有效的方式
基于这些原因,我决定使用 Overlay 和 OverlayEntry 的概念来显示 ReactiveIconContainer。
手势检测
为了知道我们要做什么(显示图标,增大/缩小图标,选择......),我们需要使用一些手势检测。 换句话说,处理与用户手指相关的事件。 在 Flutter 中,有不同的方式来处理与用户手指的交互。
请注意,用户的 手指 在 Flutter 中称为 Pointer。 在这个解决方案中,我选择了 GestureDetector,它提供了我们需要的所有便捷工具,其中包括:
-
onHorizontalDragDown & onVerticalDragDown
当指针触摸屏幕时调用的回调
-
onHorizontalDragStart & onVerticalDragStart
当指针开始在屏幕上移动时调用的回调
-
onHorizontalDragEnd & onVerticalDragEnd
当先前与屏幕接触的 Pointer 不再触摸屏幕时调用的回调
-
onHorizontalDragUpdate & onVerticalDragUpdate
当指针在屏幕上移动时调用的回调
-
onTap
当用户点击屏幕时调用的回调
-
onHorizontalDragCancel & onVerticalDragCancel
当刚刚使用 Pointer 完成的操作(之前触摸过屏幕)时,调用的回调将不会导致任何点击事件
当用户在 ReactiveButton 上触摸屏幕时,一切都将开始,将 ReactiveButton 用 GestureDetector 包装似乎很自然,如下所示:
@override
Widget build(BuildContext context){
return GestureDetector(
onHorizontalDragStart: _onDragStart,
onVerticalDragStart: _onDragStart,
onHorizontalDragCancel: _onDragCancel,
onVerticalDragCancel: _onDragCancel,
onHorizontalDragEnd: _onDragEnd,
onVerticalDragEnd: _onDragEnd,
onHorizontalDragDown: _onDragReady,
onVerticalDragDown: _onDragReady,
onHorizontalDragUpdate: _onDragMove,
onVerticalDragUpdate: _onDragMove,
onTap: _onTap,
child: _buildButton(),
);
}
复制代码
与 onPan ...回调有关的特别说明
GestureDetector 还提供了名为 onPanStart,onPanCancel …… 的回调,也可以使用,并且在没有滚动区域时它可以正常工作。 因为在这个例子中,我们还需要考虑 ReactiveButton 可能位于 滚动区域中的情况,这不会像用户在屏幕上滑动他/她的手指那样工作,这也会导致滚动区域滚动。
与 onLongPress 回调有关的特别说明
正如您所看到的,我没有使用 onLongPress 回调,而需求表示当用户长按按钮时我们需要显示 ReactiveIconContainer。 为什么?
原因有两个:
-
捕获手势事件以确定悬停/选择哪个图标,使用 onLongPress 事件,不允许这样(拖动动作将被忽略)
-
也许我们需要定制“长按”持续时间
各部分的职责
现在让我们弄清楚各个部分的责任……
ReactiveButton
ReactiveButton 将负责:
-
捕获手势事件
-
检测到长按时显示 ReactiveIconContainer
-
当用户从屏幕上释放他/她的手指时隐藏 ReactiveIconContainer
-
为其调用者提供用户动作的结果(onTap,onSelected)
-
在屏幕上正确显示 ReactiveIconContainer
ReactiveIconContainer
ReactiveIconContainer 仅负责:
-
构造容器
-
实例化图标
ReactiveIcon
ReactiveIcon 将负责:
-
根据是否悬停显示不同大小的图标
-
告诉 ReactiveButton 它是否在悬停
各组件之间的通信
我们刚刚看到我们需要在组件之间发起一些通信,以便:
-
ReactiveButton 可以为 ReactiveIcon 提供屏幕上 Pointer 的位置(用于确定图标是否是悬停的)
-
ReactiveIcon 可以告诉 ReactiveButton 它是否悬停
为了不产生像意大利面条一样的代码,我将使用 Stream 的概念。
这样做,
-
ReactiveButton 会将 Pointer 的位置广播给有兴趣知道它的组件
-
ReactiveIcon 将向任何感兴趣的人广播,无论是处于悬停状态的还是不悬停的
下图说明了这个想法。
ReactiveButton 的确切位置
由于 ReactiveButton 可能位于页面中的任何位置,因此我们需要获取其位置才能显示 ReactiveIconContainer。
由于页面可能比视口大,而 ReactiveButton 可能位于页面的任何位置,我们需要获取其物理坐标。
以下帮助类为我们提供了该位置,以及与视口,窗口,可滚动...相关的其他信息。
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
///
/// Helper class to determine the position of a widget (via its BuildContext) in the Viewport,
/// considering the case where the screen might be larger than the viewport.
/// In this case, we need to consider the scrolling offset(s).
///
class WidgetPosition {
double xPositionInViewport;
double yPositionInViewport;
double viewportWidth;
double viewportHeight;
bool isInScrollable = false;
Axis scrollableAxis;
double scrollAreaMax;
double positionInScrollArea;
Rect rect;
WidgetPosition({
this.xPositionInViewport,
this.yPositionInViewport,
this.viewportWidth,
this.viewportHeight,
this.isInScrollable : false,
this.scrollableAxis,
this.scrollAreaMax,
this.positionInScrollArea,
this.rect,
});
WidgetPosition.fromContext(BuildContext context){
// Obtain the button RenderObject
final RenderObject object = context.findRenderObject();
// Get the physical dimensions and position of the button in the Viewport
final translation = object?.getTransformTo(null)?.getTranslation();
// Get the potential Viewport (case of scroll area)
final RenderAbstractViewport viewport = RenderAbstractViewport.of(object);
// Get the device Window dimensions and properties
final MediaQueryData mediaQueryData = MediaQuery.of(context);
// Get the Scroll area state (if any)
final ScrollableState scrollableState = Scrollable.of(context);
// Get the physical dimensions and dimensions on the Screen
final Size size = object?.semanticBounds?.size;
xPositionInViewport = translation.x;
yPositionInViewport = translation.y;
viewportWidth = mediaQueryData.size.width;
viewportHeight = mediaQueryData.size.height;
rect = Rect.fromLTWH(translation.x, translation.y, size.width, size.height);
// If viewport exists, this means that we are inside a Scrolling area
// Take this opportunity to get the characteristics of that Scrolling area
if (viewport != null){
final ScrollPosition position = scrollableState.position;
final RevealedOffset vpOffset = viewport.getOffsetToReveal(object, 0.0);
isInScrollable = true;
scrollAreaMax = position.maxScrollExtent;
positionInScrollArea = vpOffset.offset;
scrollableAxis = scrollableState.widget.axis;
}
}
@override
String toString(){
return 'X,Y in VP: $xPositionInViewport,$yPositionInViewport VP dimensions: $viewportWidth,$viewportHeight ScrollArea max: $scrollAreaMax X/Y in scroll: $positionInScrollArea ScrollAxis: $scrollableAxis';
}
}
复制代码
方案细节
好的,现在我们已经有了解决方案的大块组件规划,让我们构建所有这些……
确定用户的意图
这个小部件中最棘手的部分是了解用户想要做什么,换句话说,理解手势。
1. 长按 VS 点击
如前所述,我们不能使用 onLongPress 回调,因为我们也要考虑拖动动作。 因此,我们必须自己实现。
这将实现如下: 当用户触摸屏幕时(通过 onHorizontalDragDown 或 onVerticalDragDown),我们启动一个 Timer
如果用户在 Timer 延迟之前从屏幕上松开手指,则表示 长按 未完成
如果用户在 Timer 延迟之前没有释放他/她的手指,这意味着我们需要考虑是 长按 而不再是 点击。 然后我们显示 ReactiveIconContainer。
如果调用 onTap 回调,我们需要取消定时器。
以下代码提取说明了上面解释的实现。
import 'dart:async';
import 'package:flutter/material.dart';
class ReactiveButton extends StatefulWidget {
ReactiveButton({
Key key,
@required this.onTap,
@required this.child,
}): super(key: key);
/// Callback to be used when the user proceeds with a simple tap
final VoidCallback onTap;
/// Child
final Widget child;
@override
_ReactiveButtonState createState() => _ReactiveButtonState();
}
class _ReactiveButtonState extends State<ReactiveButton> {
// Timer to be used to determine whether a longPress completes
Timer timer;
// Flag to know whether we dispatch the onTap
bool isTap = true;
// Flag to know whether the drag has started
bool dragStarted = false;
@override
void dispose(){
_cancelTimer();
_hideIcons();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onHorizontalDragStart: _onDragStart,
onVerticalDragStart: _onDragStart,
onHorizontalDragCancel: _onDragCancel,
onVerticalDragCancel: _onDragCancel,
onHorizontalDragEnd: _onDragEnd,
onVerticalDragEnd: _onDragEnd,
onHorizontalDragDown: _onDragReady,
onVerticalDragDown: _onDragReady,
onHorizontalDragUpdate: _onDragMove,
onVerticalDragUpdate: _onDragMove,
onTap: _onTap,
child: widget.child,
);
}
//
// The user did a simple tap.
// We need to tell the parent
//
void _onTap(){
_cancelTimer();
if (isTap && widget.onTap != null){
widget.onTap();
}
}
// The user released his/her finger
// We need to hide the icons
void _onDragEnd(DragEndDetails details){
_cancelTimer();
_hideIcons();
}
void _onDragReady(DragDownDetails details){
// Let's wait some time to make the distinction
// between a Tap and a LongTap
isTap = true;
dragStarted = false;
_startTimer();
}
// Little trick to make sure we are hiding
// the Icons container if a 'dragCancel' is
// triggered while no move has been detected
void _onDragStart(DragStartDetails details){
dragStarted = true;
}
void _onDragCancel() async {
await Future.delayed(const Duration(milliseconds: 200));
if (!dragStarted){
_hideIcons();
}
}
//
// The user is moving the pointer around the screen
// We need to pass this information to whomever
// might be interested (icons)
//
void _onDragMove(DragUpdateDetails details){
...
}
// ###### LongPress related ##########
void _startTimer(){
_cancelTimer();
timer = Timer(Duration(milliseconds: 500), _showIcons);
}
void _cancelTimer(){
if (timer != null){
timer.cancel();
timer = null;
}
}
// ###### Icons related ##########
// We have waited enough to consider that this is
// a long Press. Therefore, let's display
// the icons
void _showIcons(){
// It is no longer a Tap
isTap = false;
...
}
void _hideIcons(){
...
}
}
复制代码
2. 显示/隐藏图标
当我们确定是时候显示图标时,如前所述,我们将使用 OverlayEntry 将它们显示在 所有内容之上。
以下代码说明了如何实例化 ReactiveIconContainer 并将其添加到 Overlay(以及如何从 Overlay 中删除它)。
OverlayState _overlayState;
OverlayEntry _overlayEntry;
void _showIcons(){
// It is no longer a Tap
isTap = false;
// Retrieve the Overlay
_overlayState = Overlay.of(context);
// Generate the ReactionIconContainer that will be displayed onto the Overlay
_overlayEntry = OverlayEntry(
builder: (BuildContext context){
return ReactiveIconContainer(
...
);
},
);
// Add it to the Overlay
_overlayState.insert(_overlayEntry);
}
void _hideIcons(){
_overlayEntry?.remove();
_overlayEntry = null;
}
复制代码
-
Line #9
我们从 BuildContext 中检索Overlay的实例
-
Lines #12-18
我们创建了一个 OverlayEntry 的新实例,它包含了 ReactiveIconContainer 的新实例
-
Line 21
我们将 OverlayEntry 添加到 Overlay 中
-
Line 25
当我们需要从屏幕上删除 ReactiveIconContainer 时,我们只需删除相应的 OverlayEntry
3. 手势的广播
之前我们说,通过使用 Streams, ReactiveButton 将用于把 Pointer 的移动广播到 ReactiveIcon。
为了实现这一目标,我们需要创建用于传递此信息的 Stream。
3.1. 简单的 StreamController vs BLoC
在 ReactiveButton 级别,一个简单的实现可能如下:
StreamController<Offset> _gestureStream = StreamController<Offset>.broadcast();
// then when we instantiate the OverlayEntry
...
_overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return ReactiveIconContainer(
stream: _gestureStream.stream,
);
}
);
// then when we need to broadcast the gestures
void _onDragMove(DragUpdateDetails details){
_gestureStream.sink.add(details.globalPosition);
}
复制代码
这是可以正常工作的,但是 从文章前面我们还提到,第二个流将用于将信息从 ReactiveIcons 传递到 ReactiveButton。 因此,我决定用 BLoC 设计模式。
下面是精简后的 BLoC,它仅用于通过使用 Stream 来传达手势。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart';
class ReactiveButtonBloc {
//
// Stream that allows broadcasting the pointer movements
//
PublishSubject<Offset> _pointerPositionController = PublishSubject<Offset>();
Sink<Offset> get inPointerPosition => _pointerPositionController.sink;
Observable<Offset> get outPointerPosition => _pointerPositionController.stream;
//
// Dispose the resources
//
void dispose() {
_pointerPositionController.close();
}
}
复制代码
正如您将看到的,我使用了 RxDart 包,更具体地说是 PublishSubject 和 Observable (而不是 StreamController 和 Stream ),因为这些类提供了我们稍后将使用的增强的功能。
3.2. 实例化 BLoC 并提供给 ReactiveIcon
由于 ReactiveButton 负责广播手势,因此它还负责实例化 BLoC,将其提供给 ReactiveIcons 并负责销毁其资源。
我们将通过以下方式实现:
class _ReactiveButtonState extends State<ReactiveButton> {
ReactiveButtonBloc bloc;
...
@override
void initState() {
super.initState();
// Initialization of the ReactiveButtonBloc
bloc = ReactiveButtonBloc();
...
}
@override
void dispose() {
_cancelTimer();
_hideIcons();
bloc?.dispose();
super.dispose();
}
...
//
// The user is moving the pointer around the screen
// We need to pass this information to whomever
// might be interested (icons)
//
void _onDragMove(DragUpdateDetails details) {
bloc.inPointerPosition.add(details.globalPosition);
}
...
// We have waited enough to consider that this is
// a long Press. Therefore, let's display
// the icons
void _showIcons() {
// It is no longer a Tap
isTap = false;
// Retrieve the Overlay
_overlayState = Overlay.of(context);
// Generate the ReactionIconContainer that will be displayed onto the Overlay
_overlayEntry = OverlayEntry(
builder: (BuildContext context) {
return ReactiveIconContainer(
bloc: bloc,
);
},
);
// Add it to the Overlay
_overlayState.insert(_overlayEntry);
}
...
}
复制代码
-
第10行:我们实例化了 bloc
-
第19行:我们释放了它的资源
-
第29行:捕获拖动手势时,我们通过 Stream 将其传递给图标
-
第47行:我们将 bloc 传递给 ReactiveIconContainer
确定悬停 / 选择哪个 ReactiveIcon
另一个有趣的部分是知道哪些 ReactiveIcon 被悬停以突出显示它。
1. 每个图标都将使用 Stream 来获取 Pointer 位置
为了获得 Pointer 的位置,每个 ReactiveIcon 将按如下方式订阅 Streams:
class _ReactiveIconState extends State<ReactiveIcon> with SingleTickerProviderStateMixin {
StreamSubscription _streamSubscription;
@override
void initState(){
super.initState();
// Start listening to pointer position changes
_streamSubscription = widget.bloc
.outPointerPosition
// take some time before jumping into the request (there might be several ones in a row)
.bufferTime(Duration(milliseconds: 100))
// and, do not update where this is no need
.where((batch) => batch.isNotEmpty)
.listen(_onPointerPositionChanged);
}
@override
void dispose(){
_animationController?.dispose();
_streamSubscription?.cancel();
super.dispose();
}
}
复制代码
我们使用 StreamSubscription 来监听由 ReactiveButton 通过 BLoC 广播的手势位置。
由于 Pointer 可能经常移动,因此每次手势位置发生变化时检测是否悬停图标都不是非常有效率。 为了减少这种检测次数,我们利用 Observable 来缓冲 ReactiveButton 发出的事件,并且每100毫秒只考虑一次变化。
2.确定指针是否悬停在图标上
为了确定 Pointer 是否悬停在一个图标上,我们:
-
通过 WidgetPosition 帮助类获得它的位置
-
通过 widgetPosition.rect.contains (位置)检测指针位置是否悬停在图标上
//
// The pointer position has changed
// We need to check whether it hovers this icon
// If yes, we need to highlight this icon (if not yet done)
// If not, we need to remove any highlight
//
bool _isHovered = false;
void _onPointerPositionChanged(List<Offset> position){
WidgetPosition widgetPosition = WidgetPosition.fromContext(context);
bool isHit = widgetPosition.rect.contains(position.last);
if (isHit){
if (!_isHovered){
_isHovered = true;
...
}
} else {
if (_isHovered){
_isHovered = false;
...
}
}
}
复制代码
由于缓冲 Stream 发出的事件,生成一系列位置,我们只考虑最后一个。 这解释了为什么在此程序中使用 position.last。
3. 突出显示正在悬停的 ReactiveIcon
为了突出显示正在悬停的 ReactiveIcon,我们将使用动画来增加其尺寸,如下所示:
class _ReactiveIconState extends State<ReactiveIcon> with SingleTickerProviderStateMixin {
StreamSubscription _streamSubscription;
AnimationController _animationController;
// Flag to know whether this icon is currently hovered
bool _isHovered = false;
@override
void initState(){
super.initState();
// Reset
_isHovered = false;
// Initialize the animation to highlight the hovered icon
_animationController = AnimationController(
value: 0.0,
duration: const Duration(milliseconds: 200),
vsync: this,
)..addListener((){
setState((){});
}
);
// Start listening to pointer position changes
_streamSubscription = widget.bloc
.outPointerPosition
// take some time before jumping into the request (there might be several ones in a row)
.bufferTime(Duration(milliseconds: 100))
// and, do not update where this is no need
.where((batch) => batch.isNotEmpty)
.listen(_onPointerPositionChanged);
}
@override
void dispose(){
_animationController?.dispose();
_streamSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Transform.scale(
scale: 1.0 + _animationController.value * 1.2,
alignment: Alignment.center,
child: _buildIcon(),
);
}
...
//
// The pointer position has changed
// We need to check whether it hovers this icon
// If yes, we need to highlight this icon (if not yet done)
// If not, we need to remove any highlight
// Also, we need to notify whomever interested in knowning
// which icon is highlighted or lost its highlight
//
void _onPointerPositionChanged(List<Offset> position){
WidgetPosition widgetPosition = WidgetPosition.fromContext(context);
bool isHit = widgetPosition.rect.contains(position.last);
if (isHit){
if (!_isHovered){
_isHovered = true;
_animationController.forward();
}
} else {
if (_isHovered){
_isHovered = false;
_animationController.reverse();
}
}
}
}
复制代码
说明:
-
第1行:我们使用 SingleTickerProviderStateMixin 作为动画的 Ticker
-
第16-20行:我们初始化一个 AnimationController
-
第20-23行:动画运行时,我们将重新构建 ReactiveIcon
-
第37行:我们需要在删除 ReactiveIcon 时释放 AnimationController 引用的的资源
-
第44-48行:我们将根据 AnimationController.value (范围[0..1])按任意比例缩放 ReactiveIcon
-
第67行:当 ReactiveIcon 悬停时,启动动画(从 0 - > 1)
-
第72行:当 ReactiveIcon 不再悬停时,启动动画(从 1 - > 0)
4. 使 ReactiveButton 知道 ReactiveIcon 是否被悬停
这个解释的最后一部分涉及到向 ReactiveButton 传达 ReactiveIcon 当前悬停的状态,这样,如果用户从屏幕上松开他/她手指的那一刻,我们需要知道哪个 ReactiveIcon 被认为是选择。
如前所述,我们将使用第二个 Stream 来传达此信息。
4.1. 要传递的信息
为了告诉 ReactiveButton 现在哪个 ReactiveIcon 正处于悬停的状态以及哪些 ReactiveIcon 不再悬停,我们将使用专门的消息:ReactiveIconSelectionMessage。 此消息将告知“可能选择了哪个图标”和“不再选择哪个图标”。
4.2. 应用于 BLoC 的修改
BLoC 现在需要包含新的 Stream 来传达此消息。
这是新的 BLoC:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart';
import 'package:reactive_button/reactive_icon_selection_message.dart';
class ReactiveButtonBloc {
//
// Stream that allows broadcasting the pointer movements
//
PublishSubject<Offset> _pointerPositionController = PublishSubject<Offset>();
Sink<Offset> get inPointerPosition => _pointerPositionController.sink;
Observable<Offset> get outPointerPosition => _pointerPositionController.stream;
//
// Stream that allows broadcasting the icons selection
//
PublishSubject<ReactiveIconSelectionMessage> _iconSelectionController = PublishSubject<ReactiveIconSelectionMessage>();
Sink<ReactiveIconSelectionMessage> get inIconSelection => _iconSelectionController.sink;
Stream<ReactiveIconSelectionMessage> get outIconSelection => _iconSelectionController.stream;
//
// Dispose the resources
//
void dispose() {
_iconSelectionController.close();
_pointerPositionController.close();
}
}
复制代码
4.3. 允许 ReactiveButton 接收消息
为了让 ReactiveButton 接收 ReactiveIcons 发出的此类通知,我们需要订阅此消息事件,如下所示:
class _ReactiveButtonState extends State<ReactiveButton> {
...
StreamSubscription streamSubscription;
ReactiveIconDefinition _selectedButton;
@override
void initState() {
super.initState();
// Initialization of the ReactiveButtonBloc
bloc = ReactiveButtonBloc();
// Start listening to messages from icons
streamSubscription = bloc.outIconSelection.listen(_onIconSelectionChange);
}
@override
void dispose() {
_cancelTimer();
_hideIcons();
streamSubscription?.cancel();
bloc?.dispose();
super.dispose();
}
...
//
// A message has been sent by an icon to indicate whether
// it is highlighted or not
//
void _onIconSelectionChange(ReactiveIconSelectionMessage message) {
if (identical(_selectedButton, message.icon)) {
if (!message.isSelected) {
_selectedButton = null;
}
} else {
if (message.isSelected) {
_selectedButton = message.icon;
}
}
}
}
复制代码
-
第14行:我们订阅了 Stream 发出的所有消息
-
第21行:当 ReactiveButton 被删除时, 取消订阅
-
第32-42行:处理 ReactiveIcons 发出的消息
4.4. ReactiveIcon 提交消息
当 ReactiveIcon 需要向 ReactiveButton 发送消息时,它只是按如下方式使用 Stream :
void _onPointerPositionChanged(List<Offset> position){
WidgetPosition widgetPosition = WidgetPosition.fromContext(context);
bool isHit = widgetPosition.rect.contains(position.last);
if (isHit){
if (!_isHovered){
_isHovered = true;
_animationController.forward();
_sendNotification();
}
} else {
if (_isHovered){
_isHovered = false;
_animationController.reverse();
_sendNotification();
}
}
}
//
// Send a notification to whomever is interesting
// in knowning the current status of this icon
//
void _sendNotification(){
widget.bloc
.inIconSelection
.add(ReactiveIconSelectionMessage(
icon: widget.icon,
isSelected: _isHovered,
));
}
复制代码
-
第8行和第14行:当 _isHovered 变量发生改变时,调用 _sendNotification 方法
-
第23-29行:向 Stream 发出 ReactiveIconSelectionMessage
小结
个人认为,源代码的其余部分不需要任何进一步的文档说明,因为它只涉及 ReactiveButton Widget 的参数和外观。
本文的目的是展示如何将多个主题( BLoC,Reactive Programming,Animation,Overlay )组合在一起,并提供一个实际的使用示例。
希望你喜欢这篇文章。
请继续关注下一篇文章,会很快发布的。 祝编码愉快。