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

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: [
                  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 createState() => _BottomDrawer();
}

class _BottomDrawer extends State {
  ///底部预显示高度
  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 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: [
              new Stack(
                children: [
                  ///底部小控制器
                  new Opacity(
                    opacity: smallOpacity,
                    child: new Container(
                      height: imageMinWidth + imageMinMargin * 2,
                      child: new Row(
                        crossAxisAlignment: CrossAxisAlignment.center,
                        children: [
                          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: [
                      new LinearProgressIndicator(
                        backgroundColor: Colors.pink[200],
                        value: 0.5,
                        valueColor: new AlwaysStoppedAnimation(Colors.pink),
                      ),
                      new Row(
                        children: [
                          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: [
              new Container(
                color: Colors.blue,
                child: new Center(
                  child: new Text("这里是主页面"),
                ),
              ),
              new BottomDrawer(),
            ],
          ),
        ),
      ),
    );
  }
}

class BottomDrawer extends StatefulWidget {
  @override
  State createState() => _BottomDrawer();
}

class _BottomDrawer extends State with TickerProviderStateMixin {
  ///底部预显示高度
  final double defaultDisplayOffset = 100.0;

  ///滑动有效范围
  final double offsetRange = 100;

  ///默认偏移 就是初始化的偏移位置
  double defaultOffset;

  ///当前滑动的位置
  double offsetDistance;

  ///滑动开始的位置
  double dragStartOffset;

  ///屏幕高度
  double screenHeight;

  ///动画控制器
  AnimationController animationController;

  ///动画
  Animation 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: [
          new Stack(
            children: [
              ///底部小控制器
              new Opacity(
                opacity: smallOpacity,
                child: new Container(
                  height: imageMinWidth + imageMinMargin * 2,
                  child: new Row(
                    crossAxisAlignment: CrossAxisAlignment.center,
                    children: [
                      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: [
                  new LinearProgressIndicator(
                    backgroundColor: Colors.pink[200],
                    value: 0.5,
                    valueColor: new AlwaysStoppedAnimation(Colors.pink),
                  ),
                  new Row(
                    children: [
                      Text("2:19"),
                      Expanded(
                        child: new Container(),
                      ),
                      Text("5:00"),
                    ],
                  ),
                  new Text("阴天快乐"),
                  new Text("陈奕迅-rice & shine"),
                ],
              ),
            ),
          )
        ],
      ),
    );
  }
}

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

你可能感兴趣的:(Flutter仿Apple Music播放界面上滑抽屉)