[译] 用 Flutter 实现 Facebook 的响应式按钮

701 阅读15分钟

非常感谢 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,这不是非常有效的方式

基于这些原因,我决定使用 OverlayOverlayEntry 的概念来显示 ReactiveIconContainer

手势检测

为了知道我们要做什么(显示图标,增大/缩小图标,选择......),我们需要使用一些手势检测。 换句话说,处理与用户手指相关的事件。 在 Flutter 中,有不同的方式来处理与用户手指的交互。

请注意,用户的 手指 在 Flutter 中称为 Pointer。 在这个解决方案中,我选择了 GestureDetector,它提供了我们需要的所有便捷工具,其中包括:

  • onHorizontalDragDown & onVerticalDragDown

    当指针触摸屏幕时调用的回调

  • onHorizontalDragStart & onVerticalDragStart

    当指针开始在屏幕上移动时调用的回调

  • onHorizontalDragEnd & onVerticalDragEnd

    当先前与屏幕接触的 Pointer 不再触摸屏幕时调用的回调

  • onHorizontalDragUpdate & onVerticalDragUpdate

    当指针在屏幕上移动时调用的回调

  • onTap

    当用户点击屏幕时调用的回调

  • onHorizontalDragCancel & onVerticalDragCancel

    当刚刚使用 Pointer 完成的操作(之前触摸过屏幕)时,调用的回调将不会导致任何点击事件

当用户在 ReactiveButton 上触摸屏幕时,一切都将开始,将 ReactiveButtonGestureDetector 包装似乎很自然,如下所示:

@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 回调,因为我们也要考虑拖动动作。 因此,我们必须自己实现。

这将实现如下: 当用户触摸屏幕时(通过 onHorizontalDragDownonVerticalDragDown),我们启动一个 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 包,更具体地说是 PublishSubjectObservable (而不是 StreamControllerStream ),因为这些类提供了我们稍后将使用的增强的功能。

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 )组合在一起,并提供一个实际的使用示例。

希望你喜欢这篇文章。

请继续关注下一篇文章,会很快发布的。 祝编码愉快。