[譯] 用 Flutter 實現 Facebook 的響應式按鈕

很是感謝 Didier Boelens 贊成我將它的一些文章翻譯爲中文發表,這是其中一篇。html

本文經過一個實例詳細講解了 Flutter 中動畫的原理。react

原文的代碼塊有行號。在這裏不支持對代碼添加行號,閱讀可能有些不方便,請諒解。git

原文 連接github

 

本文模仿 Facebook 的響應式按鈕,使用 響應式編程、Overlay、Animation、Streams、BLoC 設計模式 和 GestureDetector 實現。編程

難度:中等設計模式

介紹

最近,有人問我如何在 Flutter 中模仿 Facebook 的響應式按鈕。 通過一番思考以後,我意識到這是一個實踐最近我在前幾篇文章中討論的主題的機會。bash

我要解釋的解決的方案(我稱之爲「響應式按鈕」)使用如下概念:markdown

能夠在 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 )組合在一塊兒,並提供一個實際的使用示例。

但願你喜歡這篇文章。

請繼續關注下一篇文章,會很快發佈的。 祝編碼愉快。

相關文章
相關標籤/搜索