我們在APP中經常可以看到各種抽屜,比如:某音的評論以及經典的豆瓣評論。這種抽屜效果,都是十分好看經典的設計。
但是在flutter中,只有側邊抽屜,沒看到有上拉的抽屜。項目中UI需要下面的效果:
本文更多是傳遞flutter學習與開發自定義Widget的一個思想。能夠更好的理解Flutter的GestureRecognizer、Transform、AnimationController等等
分析
遇到一個問題或者需求,我更建議大家把需求細化,細分。然后逐個分析,個個擊破。
- 抽屜里存放列表數據。上拉小于一定值 ,自動回彈到底部
- 當抽屜未到達頂部時,上拉列表,抽屜上移。
- 當抽屜到到達頂部時,上拉列表,抽屜不動,列表數據移動。
- 抽屜的列表數據,下拉時,出現最后一條數據時,整個抽屜隨之下拉
- 抽屜上拉時,有一個向上的加速度時,手指離開屏幕,抽屜會自動滾到頂部
解決方案
GestureRecognizer
母庸質疑,這里涉及到更多的是監聽手勢。監聽手指按下、移動、抬起以及加速度移動等。這些,通過flutter強大的GestureRecognizer就可以搞定。
Flutter Gestures 中簡單來說就是可以監聽用戶的以下手勢:
-
Tap
- onTabDown 按下
- onTapUp 抬起
- onTap 點擊
- onTapCancel
Double tap 雙擊
-
Vertical drag 垂直拖動屏幕
- onVerticalDragStart
- onVerticalDragUpdate
- onVerticalDragEnd
-
Horizontal drag 水平拖動屏幕
- onHorizontalDragStart
- onHorizontalDragUpdate
- onHorizontalDragEnd
-
Pan
- onPanStart 可能開始水平或垂直移動。如果設置了onHorizontalDragStart或onVerticalDragStart回調,則會導致崩潰 。
- onPanUpdate 觸摸到屏幕并在垂直或水平方移動。如果設置了onHorizontalDragUpdate或onVerticalDragUpdate回調,則會導致崩潰 。
- onPanEnd 在停止接觸屏幕時以特定速度移動。如果設置了onHorizontalDragEnd或onVerticalDragEnd回調,則會導致崩潰 。
每個行為,均有著對應的
Recognizer
去處理。
分別對應著下面:
在這里我們用到的就是VerticalDragGestureRecognizer
,用來監聽控件垂直方向接收的行為。
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
class BottomDragWidget extends StatefulWidget {
@override
_BottomDragWidgetState createState() => _BottomDragWidgetState();
}
class _BottomDragWidgetState extends State<BottomDragWidget> {
@override
Widget build(BuildContext context) {
return Stack(children: <Widget>[
Align(
alignment: Alignment.bottomCenter,
child: DragContainer(),
)
],);
}
}
class DragContainer extends StatefulWidget {
@override
_DragContainerState createState() => _DragContainerState();
}
class _DragContainerState extends State<DragContainer> {
double offsetDistance = 0.0;
@override
Widget build(BuildContext context) {
///使用Transform.translate 移動drag的位置
return Transform.translate(
offset: Offset(0.0, offsetDistance),
child: RawGestureDetector(
gestures: {MyVerticalDragGestureRecognizer: getRecognizer()},
child: Container(
width: 100.0,
height: 100.0,
color: Colors.brown,
),
),
);
}
GestureRecognizerFactoryWithHandlers<MyVerticalDragGestureRecognizer>
getRecognizer() {
return GestureRecognizerFactoryWithHandlers(
() => MyVerticalDragGestureRecognizer(), this._initializer);
}
void _initializer(MyVerticalDragGestureRecognizer instance) {
instance
..onStart = _onStart
..onUpdate = _onUpdate
..onEnd = _onEnd;
}
///接受觸摸事件
void _onStart(DragStartDetails details) {
print('觸摸屏幕${details.globalPosition}');
}
///垂直移動
void _onUpdate(DragUpdateDetails details) {
print('垂直移動${details.delta}');
offsetDistance = offsetDistance + details.delta.dy;
setState(() {});
}
///手指離開屏幕
void _onEnd(DragEndDetails details) {
print('離開屏幕');
}
}
class MyVerticalDragGestureRecognizer extends VerticalDragGestureRecognizer {
MyVerticalDragGestureRecognizer({Object debugOwner})
: super(debugOwner: debugOwner);
}
很簡單的,我們就完成了widget跟隨手指上下移動。
使用動畫
之前我們有說道,當我們松開手時,控件會自動跑到最下面,或者跑到最頂端。這里呢,我們就需要使用到AnimationController
了
animalController = AnimationController(
vsync: this, duration: const Duration(milliseconds: 250));
///easeOut 先快后慢
final CurvedAnimation curve =
new CurvedAnimation(parent: animalController, curve: Curves.easeOut);
animation = Tween(begin: start, end: end).animate(curve)
..addListener(() {
offsetDistance = animation.value;
setState(() {});
});
///自己滾動
animalController.forward();
在手指離開屏幕的回調方法中,在
void _onEnd(DragEndDetails details)
使用animalController
,也就是當手指離開屏幕,將上層的DragContainer
歸到原位。
到這里,已經解決了。滾動,自動歸位。下一步,就是解決比較困難的情況。
解決嵌套列表數據
在抽屜中,我們經常存放的是列表數據。所以,會有下面的情況:
也就是說,在下拉列表時,只有第一條顯示后,整個DragContainer
才會隨之下移。但是在Flutter中,并沒有可以判斷顯示第一條數據的回調監聽。但是官方,有NotificationListener
,用來進行滑動監聽的。
-
ScrollStartNotification
部件開始滑動 -
ScrollUpdateNotification
部件位置發生改變 -
OverscrollNotification
表示窗口小部件未更改它的滾動位置,因為更改會導致滾動位置超出其滾動范圍 -
ScrollEndNotification
部件停止滾動
可以有童鞋有疑問,為什么使用監聽垂直方向的手勢去移動位置,而不用
ScrollUpdateNotification
去更新DragContainer
的位置。這是因為:ScrollNotification
這個東西是一個滑動通知,他的通知是有延遲!
的。官方有說:Any attempt to adjust the build or layout based on a scroll notification would result in a layout that lagged one frame behind, which is a poor user experience.
也就是說,我們可以將DragContainer
放在NotificationListener
中,當觸發了ScrollEndNotification
的時候,也就是說整個列表數據需要向下移動了。
///在ios中,默認返回BouncingScrollPhysics,對于[BouncingScrollPhysics]而言,
///由于 double applyBoundaryConditions(ScrollMetrics position, double value) => 0.0;
///會導致:當listview的第一條目顯示時,繼續下拉時,不會調用上面提到的Overscroll監聽。
///故這里,設定為[ClampingScrollPhysics]
class OverscrollNotificationWidget extends StatefulWidget {
const OverscrollNotificationWidget({
Key key,
@required this.child,
// this.scrollListener,
}) : assert(child != null),
super(key: key);
final Widget child;
// final ScrollListener scrollListener;
@override
OverscrollNotificationWidgetState createState() =>
OverscrollNotificationWidgetState();
}
/// Contains the state for a [OverscrollNotificationWidget]. This class can be used to
/// programmatically show the refresh indicator, see the [show] method.
class OverscrollNotificationWidgetState
extends State<OverscrollNotificationWidget>
with TickerProviderStateMixin<OverscrollNotificationWidget> {
final GlobalKey _key = GlobalKey();
///[ScrollStartNotification] 部件開始滑動
///[ScrollUpdateNotification] 部件位置發生改變
///[OverscrollNotification] 表示窗口小部件未更改它的滾動位置,因為更改會導致滾動位置超出其滾動范圍
///[ScrollEndNotification] 部件停止滾動
///之所以不能使用這個來build或者layout,是因為這個通知的回調是會有延遲的。
///Any attempt to adjust the build or layout based on a scroll notification would
///result in a layout that lagged one frame behind, which is a poor user experience.
@override
Widget build(BuildContext context) {
print('NotificationListener build');
final Widget child = NotificationListener<ScrollStartNotification>(
key: _key,
child: NotificationListener<ScrollUpdateNotification>(
child: NotificationListener<OverscrollNotification>(
child: NotificationListener<ScrollEndNotification>(
child: widget.child,
onNotification: (ScrollEndNotification notification) {
_controller.updateDragDistance(
0.0, ScrollNotificationListener.end);
return false;
},
),
onNotification: (OverscrollNotification notification) {
if (notification.dragDetails != null &&
notification.dragDetails.delta != null) {
_controller.updateDragDistance(notification.dragDetails.delta.dy,
ScrollNotificationListener.edge);
}
return false;
},
),
onNotification: (ScrollUpdateNotification notification) {
return false;
},
),
onNotification: (ScrollStartNotification scrollUpdateNotification) {
_controller.updateDragDistance(0.0, ScrollNotificationListener.start);
return false;
},
);
return child;
}
}
enum ScrollNotificationListener {
///滑動開始
start,
///滑動結束
end,
///滑動時,控件在邊緣(最上面顯示或者最下面顯示)位置
edge
}
通過這個方案,我們就解決了列表數據的問題。最后一個問題,當手指快速向上滑動的時候然后松開手的時候,讓列表數據自動滾動頂端。這個快速上滑,如何解決。
坑
當dragContainer
中使用的是ScrollView,一定要將physics
的值設定為ClampingScrollPhysics
,否則不能監聽到ScrollEndNotification
。這是平臺不一致性導致的。在scroll_configuration.dart
中,有這么一段:
判斷Fling
對于這個,是我在由項目需求,魔改源碼的時候,無意中看到的。所以需要翻源碼了。在DragGestureRecognizer
中,官方有一個也是判斷Filing的地方,
不過這個方法是私有的,我們無法調用。(雖然dart可以反射,但是不建議。),我們就按照官方的思路一樣的寫就好了。
///MyVerticalDragGestureRecognizer 負責任務
///1.監聽child的位置更新
///2.判斷child在手松的那一刻是否是出于fling狀態
class MyVerticalDragGestureRecognizer extends VerticalDragGestureRecognizer {
final FlingListener flingListener;
/// Create a gesture recognizer for interactions in the vertical axis.
MyVerticalDragGestureRecognizer({Object debugOwner, this.flingListener})
: super(debugOwner: debugOwner);
final Map<int, VelocityTracker> _velocityTrackers = <int, VelocityTracker>{};
@override
void handleEvent(PointerEvent event) {
super.handleEvent(event);
if (!event.synthesized &&
(event is PointerDownEvent || event is PointerMoveEvent)) {
final VelocityTracker tracker = _velocityTrackers[event.pointer];
assert(tracker != null);
tracker.addPosition(event.timeStamp, event.position);
}
}
@override
void addPointer(PointerEvent event) {
super.addPointer(event);
_velocityTrackers[event.pointer] = VelocityTracker();
}
///來檢測是否是fling
@override
void didStopTrackingLastPointer(int pointer) {
final double minVelocity = minFlingVelocity ?? kMinFlingVelocity;
final double minDistance = minFlingDistance ?? kTouchSlop;
final VelocityTracker tracker = _velocityTrackers[pointer];
///VelocityEstimate 計算二維速度的
final VelocityEstimate estimate = tracker.getVelocityEstimate();
bool isFling = false;
if (estimate != null && estimate.pixelsPerSecond != null) {
isFling = estimate.pixelsPerSecond.dy.abs() > minVelocity &&
estimate.offset.dy.abs() > minDistance;
}
_velocityTrackers.clear();
if (flingListener != null) {
flingListener(isFling);
}
///super.didStopTrackingLastPointer(pointer) 會調用[_handleDragEnd]
///所以將[lingListener(isFling);]放在前一步調用
super.didStopTrackingLastPointer(pointer);
}
@override
void dispose() {
_velocityTrackers.clear();
super.dispose();
}
}
好的,這就解決了Filing的判斷。
最后效果
模擬器有點卡~
源碼地址
Flutter 豆瓣客戶端,誠心開源
Flutter 豆瓣客戶端,誠心開源
Flutter Container
Flutter SafeArea
Flutter Row Column MainAxisAlignment Expanded
Flutter Image全解析
Flutter 常用按鈕總結
Flutter ListView豆瓣電影排行榜
Flutter Card
Flutter Navigator&Router(導航與路由)
OverscrollNotification不起效果引起的Flutter感悟分享
Flutter 上拉抽屜實現