Flutter上拉抽屉实现

我们在APP中经常可以看到各种抽屉,比如:某音的评论以及经典的豆瓣评论。这种抽屉效果,都是十分好看经典的设计。
但是在flutter中,只有侧边抽屉,没看到有上拉的抽屉。项目中UI需要下面的效果:

Flutter抽屉

本文更多是传递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去处理。

分别对应着下面:

GestureRecognizer

在这里我们用到的就是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);
}


3.gif

很简单的,我们就完成了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();

33.gif

在手指离开屏幕的回调方法中,在void _onEnd(DragEndDetails details)使用animalController,也就是当手指离开屏幕,将上层的DragContainer归到原位。

到这里,已经解决了。滚动,自动归位。下一步,就是解决比较困难的情况。

解决嵌套列表数据

在抽屉中,我们经常存放的是列表数据。所以,会有下面的情况:

列表数据

也就是说,在下拉列表时,只有第一条显示后,整个DragContainer才会随之下移。但是在Flutter中,并没有可以判断显示第一条数据的回调监听。但是官方,有NotificationListener,用来进行滑动监听的。

ScrollNotification

  • 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中,有这么一段:

scroll_configuration

判断Fling

对于这个,是我在由项目需求,魔改源码的时候,无意中看到的。所以需要翻源码了。在DragGestureRecognizer中,官方有一个也是判断Filing的地方,

_isFlingGesture

不过这个方法是私有的,我们无法调用。(虽然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的判断。

最后效果

part1.gif
part2.gif

模拟器有点卡~

源码地址

博客地址

Flutter 豆瓣客户端,诚心开源
Flutter 豆瓣客户端,诚心开源
Flutter Container
Flutter SafeArea
Flutter Row Column MainAxisAlignment Expanded
Flutter Image全解析
Flutter 常用按钮总结
Flutter ListView豆瓣电影排行榜
Flutter Card
Flutter Navigator&Router(导航与路由)
OverscrollNotification不起效果引起的Flutter感悟分享
Flutter 上拉抽屉实现

Flutter 更改状态栏颜色

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,271评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,725评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,252评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,634评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,549评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,985评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,471评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,128评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,257评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,233评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,235评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,940评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,528评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,623评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,858评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,245评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,790评论 2 339