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

芙蓉秋江發表於2019-03-15

非常感謝 Didier Boelens 同意我將它的一些文章翻譯為中文發表,這是其中一篇。

本文通過一個例項詳細講解了 Flutter 中動畫的原理。

原文的程式碼塊有行號。在這裡不支援對程式碼新增行號,閱讀可能有些不方便,請諒解。

原文 連結

 

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

難度:中等

介紹

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

我要解釋的解決的方案(我稱之為“響應式按鈕”)使用以下概念:

可以在 GitHub 上找到本文的原始碼。 它也可以作為 Flutter 包使用:flutter_reactive_button

這是一個顯示了本文結果的動畫。

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

需求

在進入實施細節之前,讓我們首先考慮 Facebook 響應式按鈕的工作原理:

  • 當使用者按下按鈕,等待一段時間(稱為長按)時,螢幕上會顯示一個在所有內容之上的皮膚,等待使用者選擇該皮膚中包含的其中一個圖示;

  • 如果使用者將他/她的手指移動到圖示上,圖示的尺寸會增大;

  • 如果使用者將他/她的手指移開該圖示,圖示恢復其原始大小;

  • 如果使用者在仍然在圖示上方時釋放他/她的手指,則該圖示被選中;

  • 如果使用者在沒有圖示懸停的情況下釋放他/她的手指,則沒有選中任何一個圖示;

  • 如果使用者只是點選按鈕,意味著它不被視為長按,則該動作被視為正常的點選;

  • 我們可以在螢幕上有多個響應式按鈕例項;

  • 圖示應顯示在視口中。

解決方案的描述

各種不同的可視部分

下圖顯示瞭解決方案中涉及的不同部分:

  • ReactiveButton

    ReactiveButton可以放置在螢幕上的任何位置。 當使用者對其執行長按時,它會觸發 ReactiveIconContainer 的顯示。

  • ReactiveIconContainer

    一個簡單的容器,用於儲存不同的 ReactiveIcons

  • ReactiveIcon

    一個圖示,如果使用者將其懸停,可能會變大。

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

Overlay

應用程式啟動後,Flutter 會自動建立並渲染 Overlay Widget。 這個 Overlay Widget 只是一個棧 (Stack),它允許可視元件 “浮動” 在其他元件之上。 大多數情況下,這個 Overlay 主要用於導航器顯示路由(=頁面或螢幕),對話方塊,下拉選項 ...... 下圖說明了 Overlay 的概念。各元件彼此疊加。

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

您插入 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 將向任何感興趣的人廣播,無論是處於懸停狀態的還是不懸停的

下圖說明了這個想法。

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

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

希望你喜歡這篇文章。

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

相關文章