非常感謝 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 )組合在一起,並提供一個實際的使用示例。
希望你喜歡這篇文章。
請繼續關注下一篇文章,會很快釋出的。 祝編碼愉快。