美文网首页Flutter
Flutter仿Apple Music播放界面上滑抽屉

Flutter仿Apple Music播放界面上滑抽屉

作者: 烫烫琨烫烫烫烫琨烫烫 | 来源:发表于2019-07-14 16:42 被阅读0次

    Flutter仿Apple Music播放界面上滑抽屉

    • 先看Apple Music的效果,底部播放控制滑动打开或关闭播放界面,滑动跟随手指,伴随着图片的放大缩小和其他控件的显示隐藏。
    Jul-13-2019 15-41-20

    实现步骤

    • 第一步先实现上下滑动。

      首先界面分两部分,一部分是我们要实现可滑动的播放界面,一部分是主页面,两部分布局使用 Stack 嵌套,

      class MyApp extends StatelessWidget {
        @override
        Widget build(BuildContext context) {
          return MaterialApp(
            home: new Scaffold(
              body: new SafeArea(
                child: new Stack(
                  children: <Widget>[
                    new Container(
                      color: Colors.blue,
                      child: new Center(
                        child: new Text("这里是主页面"),
                      ),
                    ),
                    new BottomDrawer(),
                  ],
                ),
              ),
            ),
          );
        }
      }
      

      BottomDrawer 就是可滑动的抽屉,实现滑动事件需要 GestureDetector组件,它封装了常用的手势操作,具体如下:

      image-20190713162859421

    这我们使用到只有 onVerticalDra 垂直滑动的相关事件,其他不做过多解释。如何让组件跟随手指滑动呢。这里使用 Transform 类,它能实现平移、旋转、缩放等操作。

    class BottomDrawer extends StatefulWidget {
      @override
      State<StatefulWidget> createState() => _BottomDrawer();
    }
    
    class _BottomDrawer extends State<BottomDrawer> {
      ///底部预显示高度
      final double defaultDisplayOffset = 100.0;
    
      ///默认偏移 就是初始化的偏移位置
      double defaultOffset;
    
      ///当前滑动的位置
      double offsetDistance;
    
      ///屏幕高度
      double screenHeight;
    
      void _onDragUpdate(DragUpdateDetails details) {
        ///details.delta.dy 拿到此次滑动的偏移高度
        offsetDistance = offsetDistance + details.delta.dy;
        setState(() {});
      }
    
      @override
      void initState() {
        super.initState();
      }
    
      @override
      void didChangeDependencies() {
        super.didChangeDependencies();
        screenHeight = MediaQuery.of(context).size.height;
    
        ///默认偏移 等于屏幕高度减去 底部预显示的高度
        defaultOffset = screenHeight - defaultDisplayOffset;
        offsetDistance = defaultOffset;
      }
    
      @override
      Widget build(BuildContext context) {
        return new Transform.translate(
          offset: Offset(0.0, offsetDistance),
          child: new GestureDetector(
            onVerticalDragUpdate: _onDragUpdate,
            child: new Container(
              color: Colors.white,
            ),
          ),
        );
      }
    }
    
    

    监听滑动的偏移值,然后更新 Transform 的偏移值,跟手滑动就实现了,就这么简单。

    Jul-13-2019 16-45-18
    • 第二步实现弹性滑动,当上滑一定的值通过动画让它自动滑动到顶部,下滑反之。监听 onVerticalDragEnd 滑动结束,在这里判断滑动反向,然后判断滑动距离是有大于有效值,大于的话上滑直接滑动到顶部,下滑直接滑动到底部。小于有效值的话就判断滑动无效,从哪里来回哪里去。

      首先记住滑动开始的位置

        ///滑动开始
        void _onDragStart(DragStartDetails details) {
         dragStartOffset = offsetDistance;
        }
      

      然后在滑动结束的时候判断此次滑动是否有效

        ///是否是向上滑动  当前位置小于开始位置为向上
          final bool isUp = offsetDistance < dragStartOffset;
      
          double endOffset;
      
          ///滑动距离绝对值大于 有效范围 说明滑动有效 上滑到顶 下滑到底部
          ///否的话 无效 从哪里来回哪里去 底部上滑回底部 顶部下滑回顶部
          if ((offsetDistance - dragStartOffset).abs() > offsetRange) {
            endOffset = isUp ? 0 : defaultOffset;
          } else {
            endOffset = isUp ? defaultOffset : 0;
          }
          _animationDrag(endOffset);
      

      当判断滑动有效调用 _animationDrag 方法动画执行到顶部或底部

        ///动画控制器
        AnimationController animationController;
      
        ///动画
        Animation<double> animation;
      
        ///动画值是否重置
        bool onResetControllerValue = false;
      
        @override
        void initState() {
          super.initState();
      
          ///duration 动画执行时间
          ///vsync 防止UI不在焦点界面继续执行 消耗不必要的资源
          animationController = AnimationController(
              vsync: this, duration: const Duration(milliseconds: 250));
        }
      
        @override
        void dispose() {
          super.dispose();
          animationController.dispose();
        }
      
      void _animationDrag(double endOffset) {
          ///执行动画前需要将上次的动画的值清除
          ///但是对动画进行了监听 清除赋值为0的时候会刷新界面
          ///所以加个bool值判断
          onResetControllerValue = true;
          animationController.value = 0.0;
          onResetControllerValue = false;
      
          ///动画执行规律曲线
          final CurvedAnimation curve =
              new CurvedAnimation(parent: animationController, curve: Curves.easeIn);
      
          ///动画执行范围 开始为当前偏移量 结束为目标偏移量
          animation = Tween(begin: offsetDistance, end: endOffset).animate(curve)
            ..addListener(() {
              ///animation.value拿到之后赋值给offsetDistance然后刷新界面
              if (!onResetControllerValue) {
                offsetDistance = animation.value;
                setState(() {});
              }
            });
      
          ///启动动画
          animationController.forward();
        }
      
      

      这里的动画可以简单理解为在给定的执行规律、范围、时间下,有规律的修改 animation.value的值,而我们做的就是监听这个值的变化然后根据这个值去刷新界面。跟多动画相关这里不做深究。

      Jul-13-2019 17-39-40
    • 第三步就是图片的缩放,组件透明度的修改了,图片的缩放我们滑动的比例来计算图片的宽高和边界,透明渐变的话我这里使用了 Opacity 组件,在需要修改透明度的组件外面套一层 Opacity, 修改它的 opacity 值就能修改透明度了。

        ///当前滑动偏移的百分比
        final double offsetScale;
      
        ///封面最小宽度
        final double imageMinWidth = 52.0;
      
        ///封面图片最大边界
        final double imageMaxMargin = 48.0;
      
        ///封面图片最小边界
        final double imageMinMargin = 12.0;
        
        PlayMain({Key key, this.offsetScale}) : super(key: key);
        
          @override
        Widget build(BuildContext context) {
               ///封面最大宽度
          final imageMaxWidth =
              MediaQuery.of(context).size.width - imageMaxMargin * 2;
      
          ///底部小控制器透明度
          double smallOpacity = offsetScale;
      
          /// 主播放界面组件透明度
          double mainOpacity = 1.0 - offsetScale;
          mainOpacity = max(0, mainOpacity);
          final imageWidth =
              (imageMaxWidth - imageMinWidth) * mainOpacity + imageMinWidth;
      
          ///封面边界
          double imageMargin =
              (imageMaxMargin - imageMinMargin) * mainOpacity + imageMinMargin;
              
         return new GestureDetector(
            child: new Column(
              children: <Widget>[
                new Stack(
                  children: <Widget>[
                    ///底部小控制器
                    new Opacity(
                      opacity: smallOpacity,
                      child: new Container(
                        height: imageMinWidth + imageMinMargin * 2,
                        child: new Row(
                          crossAxisAlignment: CrossAxisAlignment.center,
                          children: <Widget>[
                            new Padding(
                                padding: EdgeInsets.only(
                                    left: (imageMinWidth + imageMinMargin * 2))),
                            new Expanded(child: new Text("阴天快乐")),
                            new IconButton(
                                icon: new Icon(Icons.stop),
                                onPressed: () {
                                  Fluttertoast.showToast(msg: "播放暂停按钮");
                                }),
                            new IconButton(
                                icon: new Icon(Icons.skip_next),
                                onPressed: () {
                                  Fluttertoast.showToast(msg: "下一曲");
                                }),
                          ],
                        ),
                      ),
                    ),
      
                    ///图片
                    new Container(
                      margin: EdgeInsets.only(left: imageMargin, top: imageMargin),
                      child: new Image(
                          width: imageWidth,
                          height: imageWidth,
                          fit: BoxFit.fitHeight,
                          image: new NetworkImage(
                              "https://p2.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg")),
                    ),
                  ],
                ),
      
                ///主播放界面控制器
                new Opacity(
                  opacity: mainOpacity,
                  child: new Container(
                    margin: const EdgeInsets.only(
                      left: 24,
                      right: 24,
                      top: 36,
                    ),
                    child: new Column(
                      mainAxisSize: MainAxisSize.max,
                      children: <Widget>[
                        new LinearProgressIndicator(
                          backgroundColor: Colors.pink[200],
                          value: 0.5,
                          valueColor: new AlwaysStoppedAnimation<Color>(Colors.pink),
                        ),
                        new Row(
                          children: <Widget>[
                            Text("2:19"),
                            Expanded(
                              child: new Container(),
                            ),
                            Text("5:00"),
                          ],
                        ),
                        new Text("阴天快乐"),
                        new Text("陈奕迅-rice & shine"),
                      ],
                    ),
                  ),
                )
              ],
            ),
          );
        }
      
      image

    现在基本就完成了仿Apple Music的底部上滑抽屉,根据滑动距离实现缩放和渐变的动画,代码还是比较简单。由于是demo,所有代码比较杂乱,还有一些计算的边界处理没有做。附上所有源码:

    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return new MaterialApp(
          home: new Scaffold(
            body: new SafeArea(
              child: new Stack(
                children: <Widget>[
                  new Container(
                    color: Colors.blue,
                    child: new Center(
                      child: new Text("这里是主页面"),
                    ),
                  ),
                  new BottomDrawer(),
                ],
              ),
            ),
          ),
        );
      }
    }
    
    class BottomDrawer extends StatefulWidget {
      @override
      State<StatefulWidget> createState() => _BottomDrawer();
    }
    
    class _BottomDrawer extends State<BottomDrawer> with TickerProviderStateMixin {
      ///底部预显示高度
      final double defaultDisplayOffset = 100.0;
    
      ///滑动有效范围
      final double offsetRange = 100;
    
      ///默认偏移 就是初始化的偏移位置
      double defaultOffset;
    
      ///当前滑动的位置
      double offsetDistance;
    
      ///滑动开始的位置
      double dragStartOffset;
    
      ///屏幕高度
      double screenHeight;
    
      ///动画控制器
      AnimationController animationController;
    
      ///动画
      Animation<double> animation;
    
      ///动画值是否重置
      bool onResetControllerValue = false;
    
      @override
      void initState() {
        super.initState();
    
        ///duration 动画执行时间
        ///vsync 防止UI不在焦点界面继续执行 消耗不必要的资源
        animationController = AnimationController(
            vsync: this, duration: const Duration(milliseconds: 250));
      }
    
      @override
      void dispose() {
        super.dispose();
        animationController.dispose();
      }
    
      ///滑动开始
      void _onDragStart(DragStartDetails details) {
        dragStartOffset = offsetDistance;
      }
    
      ///滑动过程中位置更新
      void _onDragUpdate(DragUpdateDetails details) {
        ///details.delta.dy 拿到此次滑动的偏移高度
        offsetDistance = offsetDistance + details.delta.dy;
        setState(() {});
      }
    
      void _onDragEnd(DragEndDetails details) {
        ///是否是向上滑动  当前位置小于开始位置为向上
        final bool isUp = offsetDistance < dragStartOffset;
    
        double endOffset;
    
        ///滑动距离绝对值大于 有效范围 说明滑动有效 上滑到顶 下滑到底部
        ///否的话 无效 从哪里来回哪里去 底部上滑回底部 顶部下滑回顶部
        if ((offsetDistance - dragStartOffset).abs() > offsetRange) {
          endOffset = isUp ? 0 : defaultOffset;
        } else {
          endOffset = isUp ? defaultOffset : 0;
        }
        _animationDrag(endOffset);
      }
    
      void _animationDrag(double endOffset) {
        ///执行动画前需要将上次的动画的值清除
        ///但是对动画进行了监听 清除赋值为0的时候会刷新界面
        ///所以加个bool值判断
        onResetControllerValue = true;
        animationController.value = 0.0;
        onResetControllerValue = false;
    
        ///动画执行曲线
        final CurvedAnimation curve =
            new CurvedAnimation(parent: animationController, curve: Curves.easeIn);
    
        ///动画执行范围 开始为当前偏移量 结束为目标偏移量
        animation = Tween(begin: offsetDistance, end: endOffset).animate(curve)
          ..addListener(() {
            ///animation.value拿到之后赋值给offsetDistance然后刷新界面
            if (!onResetControllerValue) {
              offsetDistance = animation.value;
              setState(() {});
            }
          });
    
        ///启动动画
        animationController.forward();
      }
    
      @override
      void didChangeDependencies() {
        super.didChangeDependencies();
        screenHeight = MediaQuery.of(context).size.height;
    
        ///默认偏移 等于屏幕高度减去 底部预显示的高度
        defaultOffset = screenHeight - defaultDisplayOffset;
        offsetDistance = defaultOffset;
      }
    
      @override
      Widget build(BuildContext context) {
        ///当前偏移比例
        final double scale = offsetDistance / defaultOffset;
        print("scale:$scale");
        return new Transform.translate(
          offset: Offset(0.0, offsetDistance),
          child: new GestureDetector(
            onVerticalDragUpdate: _onDragUpdate,
            onVerticalDragStart: _onDragStart,
            onVerticalDragEnd: _onDragEnd,
            child: new Container(
              color: Colors.white,
              child: PlayMain(offsetScale: scale),
            ),
          ),
        );
      }
    }
    
    class PlayMain extends StatelessWidget {
      ///当前滑动偏移的百分比
      final double offsetScale;
    
      ///封面最小宽度
      final double imageMinWidth = 52.0;
    
      ///封面图片最大边界
      final double imageMaxMargin = 48.0;
    
      ///封面图片最小边界
      final double imageMinMargin = 12.0;
    
      PlayMain({Key key, this.offsetScale}) : super(key: key);
    
      @override
      Widget build(BuildContext context) {
        ///封面最大宽度
        final imageMaxWidth =
            MediaQuery.of(context).size.width - imageMaxMargin * 2;
    
        ///底部小控制器透明度
        double smallOpacity = offsetScale;
    
        /// 主播放界面组件透明度
        double mainOpacity = 1.0 - offsetScale;
        mainOpacity = max(0, mainOpacity);
        final imageWidth =
            (imageMaxWidth - imageMinWidth) * mainOpacity + imageMinWidth;
    
        ///封面边界
        double imageMargin =
            (imageMaxMargin - imageMinMargin) * mainOpacity + imageMinMargin;
    
        return new GestureDetector(
          child: new Column(
            children: <Widget>[
              new Stack(
                children: <Widget>[
                  ///底部小控制器
                  new Opacity(
                    opacity: smallOpacity,
                    child: new Container(
                      height: imageMinWidth + imageMinMargin * 2,
                      child: new Row(
                        crossAxisAlignment: CrossAxisAlignment.center,
                        children: <Widget>[
                          new Padding(
                              padding: EdgeInsets.only(
                                  left: (imageMinWidth + imageMinMargin * 2))),
                          new Expanded(child: new Text("阴天快乐")),
                          new IconButton(
                              icon: new Icon(Icons.stop),
                              onPressed: () {
                                Fluttertoast.showToast(msg: "播放暂停按钮");
                              }),
                          new IconButton(
                              icon: new Icon(Icons.skip_next),
                              onPressed: () {
                                Fluttertoast.showToast(msg: "下一曲");
                              }),
                        ],
                      ),
                    ),
                  ),
    
                  ///图片
                  new Container(
                    margin: EdgeInsets.only(left: imageMargin, top: imageMargin),
                    child: new Image(
                        width: imageWidth,
                        height: imageWidth,
                        fit: BoxFit.fitHeight,
                        image: new NetworkImage(
                            "https://p2.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg")),
                  ),
                ],
              ),
    
              ///主播放界面控制器
              new Opacity(
                opacity: mainOpacity,
                child: new Container(
                  margin: const EdgeInsets.only(
                    left: 24,
                    right: 24,
                    top: 36,
                  ),
                  child: new Column(
                    mainAxisSize: MainAxisSize.max,
                    children: <Widget>[
                      new LinearProgressIndicator(
                        backgroundColor: Colors.pink[200],
                        value: 0.5,
                        valueColor: new AlwaysStoppedAnimation<Color>(Colors.pink),
                      ),
                      new Row(
                        children: <Widget>[
                          Text("2:19"),
                          Expanded(
                            child: new Container(),
                          ),
                          Text("5:00"),
                        ],
                      ),
                      new Text("阴天快乐"),
                      new Text("陈奕迅-rice & shine"),
                    ],
                  ),
                ),
              )
            ],
          ),
        );
      }
    }
    

    初学小白的个人学习笔记,欢迎大佬检查,如有不对请指出。

    相关文章

      网友评论

        本文标题:Flutter仿Apple Music播放界面上滑抽屉

        本文链接:https://www.haomeiwen.com/subject/riqikctx.html