flutter開發實戰-手勢Gesture與ListView滾動競技場的可滑動關閉組件
最近看到了一個插件,實現一個可滑動關閉組件。滑動關閉組件即手指向下滑動,組件隨手指移動,當移動一定位置時候,手指抬起后組件滑出屏幕。
一、GestureDetector嵌套Container非ListView
如果要可滑動關閉,則需要手勢GestureDetector,GestureDetector這里實現了onVerticalDragDown、onVerticalDragUpdate、onVerticalDragEnd,通過手勢,更新AnimatedContainer的高度。
@overrideWidget build(BuildContext context) {Size screenSize = MediaQuery.of(context).size;return Column(mainAxisSize: MainAxisSize.min,children: <Widget>[GestureDetector(onVerticalDragDown: _onVerticalDragDown,onVerticalDragUpdate: _onVerticalDragUpdate,onVerticalDragEnd: _onVerticalDragEnd,child: AnimatedContainer(curve: Curves.easeOut,duration: Duration(milliseconds: 250),onEnd: () {_onAniPositionedEnd(context);},height: yBottomOffset + widget.displayHeight,width: screenSize.width,clipBehavior: Clip.hardEdge,decoration: const BoxDecoration(color: Colors.transparent,),child: widget.child,),),],);}
我們通過onVerticalDragUpdate來更新AnimatedContainer的高度height,
void _onVerticalDragUpdate(DragUpdateDetails details) {print("_onVerticalDragUpdate");if (details.delta.dy <= 0) {// 向上isDragDirectionUp = true;} else {// 向下isDragDirectionUp = false;}yBottomOffset -= details.delta.dy;if (yBottomOffset > 0.0) {yBottomOffset = 0.0;}if (yBottomOffset < -widget.displayHeight) {yBottomOffset = -widget.displayHeight;}setState(() {});}
當拖動手勢結束之后,來檢測是否是隱藏狀態。
void _onVerticalDragEnd(DragEndDetails details) {print("_onVerticalDragEnd");if (yBottomOffset < -widget.displayHeight / 3) {// 隱藏移除yBottomOffset = -widget.displayHeight;isCompleteHide = true;} else {yBottomOffset = 0.0;isCompleteHide = false;}setState(() {});}
AnimatedContainer中有onEnd方法回調,當動畫結束之后,在此方法回調中來處理是否pop等操作
void _onAniPositionedEnd(BuildContext context) {print("_onAniPositionedEnd");if (isCompleteHide) {// 隱藏了,則移除Navigator.of(context).pop();}}
DragBottomSheet2完整代碼如下
import 'package:flutter/material.dart';class DragBottomSheet2 extends StatefulWidget {const DragBottomSheet2({super.key,required this.child,required this.displayHeight,});// childfinal Widget child;// 展示的child高度final double displayHeight;@overrideState<DragBottomSheet2> createState() => _DragBottomSheet2State();
}class _DragBottomSheet2State extends State<DragBottomSheet2> {bool? isDragDirectionUp;double yBottomOffset = 0.0;bool isCompleteHide = false;void _onVerticalDragDown(DragDownDetails details) {print("_onVerticalDragDown");}void _onVerticalDragUpdate(DragUpdateDetails details) {print("_onVerticalDragUpdate");if (details.delta.dy <= 0) {// 向上isDragDirectionUp = true;} else {// 向下isDragDirectionUp = false;}yBottomOffset -= details.delta.dy;if (yBottomOffset > 0.0) {yBottomOffset = 0.0;}if (yBottomOffset < -widget.displayHeight) {yBottomOffset = -widget.displayHeight;}setState(() {});}void _onVerticalDragEnd(DragEndDetails details) {print("_onVerticalDragEnd");if (yBottomOffset < -widget.displayHeight / 3) {// 隱藏移除yBottomOffset = -widget.displayHeight;isCompleteHide = true;} else {yBottomOffset = 0.0;isCompleteHide = false;}setState(() {});}void _onAniPositionedEnd(BuildContext context) {print("_onAniPositionedEnd");if (isCompleteHide) {// 隱藏了,則移除Navigator.of(context).pop();}}@overrideWidget build(BuildContext context) {Size screenSize = MediaQuery.of(context).size;return Column(mainAxisSize: MainAxisSize.min,children: <Widget>[GestureDetector(onVerticalDragDown: _onVerticalDragDown,onVerticalDragUpdate: _onVerticalDragUpdate,onVerticalDragEnd: _onVerticalDragEnd,child: AnimatedContainer(curve: Curves.easeOut,duration: Duration(milliseconds: 250),onEnd: () {_onAniPositionedEnd(context);},height: yBottomOffset + widget.displayHeight,width: screenSize.width,clipBehavior: Clip.hardEdge,decoration: const BoxDecoration(color: Colors.transparent,),child: widget.child,),),],);}
}
點擊按鈕彈出bottomSheet2代碼如下
void showBottomSheet2(BuildContext context) {Size size = MediaQuery.of(context).size;double displayHeight = size.height - 88;showModalBottomSheet(context: context,isScrollControlled: true,builder: (ctx) {return DragBottomSheet2(displayHeight: displayHeight,child: Container(width: size.width,height: displayHeight,color: Colors.orangeAccent,child: Text('內容',style: TextStyle(color: Colors.black,),),),);},);}
效果圖如下
二、GestureDetector嵌套ListView
GestureDetector嵌套ListView后,Flutter會根據競技場Arena機制,通過一定邏輯選擇一個組件勝出。
Flutter為了解決手勢沖突問題,Flutter給開發者提供了一套解決方案。在該方案中,Flutter引入了Arena(競技場)概念,然后把沖突的手勢加入到Arena中并競爭,誰勝利,誰就獲得手勢的后續處理權。
Arena競技場的原理請看https://juejin.cn/post/6874570159768633357
所以在GestureDetector嵌套ListView后,Flutter框架會將這些Gesture與ListView組件都加入競技場,然后通過一定的邏輯選擇一個組件勝出,通常同類組件嵌套時最內層的組件勝出,勝出的組件會處理接下來的move和up事件,其它組件則不會繼續處理這些事件了。所以在GestureDetector嵌套ListView的場景中,由于是ListView最終勝出,所以后續的事件都交由ListView處理,而GestureDetector收不到后續的事件,也就不會響應用戶的手勢了。因此,我們解決這個問題的第一步就是要讓GestureDetector在這種場景下也能收到后續的事件
參考請看https://zhuanlan.zhihu.com/p/680586251
我們需要根據GestureDetector真正處理用戶手勢事件的是內部的Recognizer,比如處理上下滑動的是VerticalDragGestureRecognizer而Recognizer在競技場失敗后也可以單方面宣布自己勝出這樣即使在競技場失敗了,GestureDetector也能收到后續的手勢事件
因此我們現定義一個單方面宣布勝出的Recognizer
class _MyVerticalDragGestureRecognizer extends VerticalDragGestureRecognizer {@overridevoid rejectGesture(int pointer) {// 單方面宣布自己勝出acceptGesture(pointer);}
}
我們需要將Recognizer加入到GestureDetector中,會用到RawGestureDetector
RawGestureDetector(gestures: {_MyVerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<_MyVerticalDragGestureRecognizer>(() => _MyVerticalDragGestureRecognizer(),(_MyVerticalDragGestureRecognizer recognizer) {recognizer..onStart = (DragStartDetails details) {}..onUpdate = (DragUpdateDetails details) {}..onEnd = (DragEndDetails details) {};}),},child: ...);
這時候當滾動ListView時候,也能收到手勢事件了。
監聽ListView的滾動,時候我們需要用到NotificationListener
NotificationListener( // 監聽內部ListView的滑動變化onNotification: (ScrollNotification notification) {if (notification is OverscrollNotification && notification.overscroll < 0) {// 用戶向下滑動,ListView已經滑動到頂部,處理GestureDetector的滑動事件} else if (notification is ScrollUpdateNotification) {// 用戶在ListView中執行滑動動作,關閉外部GestureDetector的滑動處理} else {}return false;},child: //ListView),
最后DragGestureBottomSheet完整代碼如下
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_app_demolab/drag_sheet_controller.dart';class DragGestureBottomSheet extends StatefulWidget {const DragGestureBottomSheet({super.key,required this.child,required this.displayHeight,this.duration = const Duration(milliseconds: 200),this.openDraggable = true,this.autoNavigatorPop = true,this.onShow,this.onHide,});// childfinal Widget child;// 展示的child高度final double displayHeight;// 拖動動畫時長durationfinal Duration duration;// 是否需要拖動final bool openDraggable;// 是否需要自動popfinal bool autoNavigatorPop;// This method will be executed when the solid bottom sheet is completely// opened.final void Function()? onShow;// This method will be executed when the solid bottom sheet is completely// closed.final void Function()? onHide;@overrideState<DragGestureBottomSheet> createState() => _DragGestureBottomSheetState();
}class _DragGestureBottomSheetState extends State<DragGestureBottomSheet> {bool? isDragDirectionUp;double yBottomOffset = 0.0;bool isDraggable = false;bool isCompleteHide = false;DragSheetController? dragSheetController;@overridevoid initState() {// TODO: implement initStatedragSheetController = DragSheetController();dragSheetController?.dispatch(widget.displayHeight);super.initState();}@overridevoid dispose() {// TODO: implement disposedragSheetController?.dispose();super.dispose();}void _onVerticalDragUpdate(data) {if (widget.openDraggable) {print("data.delta.dy:${data.delta.dy}");if (data.delta.dy <= 0) {// 向上isDragDirectionUp = true;} else {// 向下isDragDirectionUp = false;}yBottomOffset -= data.delta.dy;if (yBottomOffset > 0.0) {yBottomOffset = 0.0;}if (yBottomOffset < -widget.displayHeight) {yBottomOffset = -widget.displayHeight;}double height = widget.displayHeight + yBottomOffset;dragSheetController?.dispatch(height);}}void _onVerticalDragEnd(data) {if (widget.openDraggable) {// 根據判斷是否隱藏與顯示if (false == isDragDirectionUp) {if (yBottomOffset < -widget.displayHeight / 3) {// 隱藏移除yBottomOffset = -widget.displayHeight;isCompleteHide = true;} else {yBottomOffset = 0.0;isCompleteHide = false;}} else {yBottomOffset = 0.0;isCompleteHide = false;}double height = widget.displayHeight + yBottomOffset;dragSheetController?.dispatch(height);}}void _onAniPositionedEnd(BuildContext context) {// 動畫結束print("_onAniPositionedEnd");if (isCompleteHide) {// 隱藏,則調用hidenif (widget.onHide != null) {widget.onHide!.call();}} else {// 顯示,則調用showif (widget.onShow != null) {widget.onShow!.call();}}if (isCompleteHide && widget.autoNavigatorPop) {// 隱藏了,則移除Navigator.of(context).pop();}}@overrideWidget build(BuildContext context) {Size screenSize = MediaQuery.of(context).size;return Column(mainAxisSize: MainAxisSize.min,children: <Widget>[RawGestureDetector(gestures: {_MyVerticalDragGestureRecognizer:GestureRecognizerFactoryWithHandlers<_MyVerticalDragGestureRecognizer>(() => _MyVerticalDragGestureRecognizer(),(_MyVerticalDragGestureRecognizer recognizer) {recognizer..onStart = (DragStartDetails details) {}..onUpdate = (DragUpdateDetails details) {if (!isDraggable) {return;}_onVerticalDragUpdate(details);}..onEnd = (DragEndDetails details) {_onVerticalDragEnd(details);};}),},child: StreamBuilder(stream: dragSheetController?.streamData,initialData: widget.displayHeight,builder: (_, snapshot) {return AnimatedContainer(curve: Curves.easeOut,duration: widget.duration,onEnd: () {_onAniPositionedEnd(context);},height: snapshot.data,width: screenSize.width,clipBehavior: Clip.hardEdge,decoration: const BoxDecoration(color: Colors.transparent,),child: NotificationListener(// 監聽內部ListView的滑動變化onNotification: (ScrollNotification notification) {if (notification is OverscrollNotification &¬ification.overscroll < 0) {// 用戶向下滑動,ListView已經滑動到頂部,處理GestureDetector的滑動事件isDraggable = true;} else if (notification is ScrollUpdateNotification) {// 用戶在ListView中執行滑動動作,關閉外部GestureDetector的滑動處理isDraggable = false;} else {}return false;},child: widget.child,),);},),)],);}
}class _MyVerticalDragGestureRecognizer extends VerticalDragGestureRecognizer {@overridevoid rejectGesture(int pointer) {// 單方面宣布自己勝出acceptGesture(pointer);}
}
三、DragSheetController處理數據流
這里定義了DragSheetController來處理數據流,DragSheetController中包括streamController、subscription、streamSink、streamData
StreamBuilder是一個Widget,它依賴Stream來做異步數據獲取刷新widget。
Stream是一種用于異步處理數據流的機制,它允許我們從一端發射一個事件,從另外一端去監聽事件的變化.Stream類似于JavaScript中的Promise、Swift中的Future或Java中的RxJava,它們都是用來處理異步事件和數據的。Stream是一個抽象接口,我們可以通過StreamController接口可以方便使用Stream。
使用詳情請查看https://brucegwo.blog.csdn.net/article/details/136232000
最后DragSheetController代碼如下
import 'dart:async';/// 處理Stream、StreamController相關邏輯
class DragSheetController {StreamSubscription<double>? subscription;//創建StreamControllerStreamController<double>? streamController = StreamController<double>.broadcast();// 獲取StreamSink用于發射事件StreamSink<double>? get streamSink => streamController?.sink;// 獲取Stream用于監聽Stream<double>? get streamData => streamController?.stream;// Adds new values to streamsvoid dispatch(double value) {streamSink?.add(value);}// Closes streamsvoid dispose() {streamSink?.close();}
}
通過DragSheetController,當拖動時候高度發生變化時候會調用dispatch方法,dispatch來發射數據流,DragGestureBottomSheet中通過StreamBuilder來調整AnimatedContainer的高度。
最后調用使用DragGestureBottomSheet
我們使用showModalBottomSheet展示DragGestureBottomSheet時候
// 顯示底部彈窗void showCustomBottomSheet(BuildContext context) {Size size = MediaQuery.of(context).size;double displayHeight = size.height - 88;showModalBottomSheet(context: context,isScrollControlled: true,builder: (ctx) {return DragGestureBottomSheet(displayHeight: displayHeight,autoNavigatorPop: true,openDraggable: true,onHide: () {print("onHide");},onShow: () {print("onShow");},child: Container(width: size.width,height: displayHeight,color: Colors.white,child: ScrollConfiguration(behavior: NoIndicatorScrollBehavior(),child: ListView.builder(itemCount: 20,physics: ClampingScrollPhysics(),itemBuilder: (context, index) {return GestureDetector(child: Container(width: size.width,height: 100,decoration: BoxDecoration(color: Colors.transparent,border: Border.all(color: Colors.black12,width: 0.25,style: BorderStyle.solid,),),child: Column(mainAxisAlignment: MainAxisAlignment.center,children: [Text('index -- $index'),SizedBox(width: 50,child: ClipOval(child:Image.asset("assets/images/hero_test.png")),),],),),onTap: () {Navigator.of(context).push(CupertinoPageRoute(builder: (BuildContext context) {return HeroPage();}));},);},),),),);},);}
效果圖如下
https://brucegwo.blog.csdn.net/article/details/136241765
四、小結
flutter開發實戰-手勢Gesture與ListView滾動競技場的可滑動關閉組件
學習記錄,每天不停進步。