美文网首页FlutterFlutter相关
Flutter 实现一个灵动的按钮

Flutter 实现一个灵动的按钮

作者: kengou | 来源:发表于2019-05-19 21:53 被阅读0次

    Flutter 实现一个灵动的按钮

    看文章的大致内容和实现效果,请看"本文概要"

    看具体代码,请看"具体实现"

    看实现过程思路和其中的问题等,请看"心路历程"

    本文概要

    最近在仿写一个项目,其中需要实现一个点击之后具有回弹动画的按钮。原生好像也没有这样效果的按钮,于是自己动手撸了一个。本文主要讲解了我实现这个按钮的具体过程。具体效果如下。

    具体效果.gif

    具体实现

    主要是通过自定义一个StatefulWidget,当用户点击时,通过AnimationController来播放动画,从而实现点击回弹动画。

    以下是具体代码。

    import 'package:flutter/material.dart';
    
    // 果冻按钮 点击回弹
    class JellyButton extends StatefulWidget {
    
      // 动画的时间
      final Duration duration;
      // 动画图标的大小
      final Size size;
      // 点击后的回调
      final VoidCallback onTap;
      // 未选中时的图片
      final String unCheckedImgAsset;
      // 选中时的图片
      final String checkedImgAsset;
      // 是否选中
      final bool checked;
      // 一定要添加这个背景颜色 不知道为什么 container不添加背景颜色 不能撑开
      final Color backgroundColor;
      final EdgeInsetsGeometry padding;
    
      const JellyButton({
        this.duration = const Duration(milliseconds: 500),
        this.size = const Size(40.0, 40.0),
        this.onTap,
        @required this.unCheckedImgAsset,
        @required this.checkedImgAsset,
        this.checked = false,
        this.backgroundColor = Colors.transparent,
        this.padding = const EdgeInsets.all(8.0)
      });
    
      @override
      _JellyButtonState createState() => _JellyButtonState();
    }
    
    class _JellyButtonState extends State<JellyButton> with TickerProviderStateMixin {
    
      // 动画控制器 点击触发播放动画
      AnimationController _controller;
      // 非线性动画 用来实现点击效果
      CurvedAnimation _animation;
    
      @override
      void initState() {
        super.initState();
        // 初始化 Controller
        _controller = AnimationController(vsync: this, duration: widget.duration);
        // 线性动画 可以让按钮从小到大变化
        Animation<double> linearAnimation = Tween(begin: 0.0, end: 1.0).animate(_controller);
        // 将线性动画转化成非线性动画 让按钮点击效果更加灵动
        _animation = CurvedAnimation(parent: linearAnimation, curve: Curves.elasticOut);
        // 一开始不播放动画 直接显示原始大小
        _controller.forward(from: 1.0);
      }
    
      @override
      void dispose() {
        // 记得要释放Controller的资源
        _controller?.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return GestureDetector(
          onTap: () {
            // 点击的同时 播放动画
            _playAnimation();
            if (widget.onTap != null) {
              widget.onTap();
            }
          },
          child: Container(
            // 添加这个约束 为了让按钮可以撑满屏幕 (主要是为了实现我仿写的项目的效果)
            constraints: BoxConstraints(minWidth: widget.size.width, minHeight: widget.size.height),
            color: widget.backgroundColor,
            padding: widget.padding,
            child: Center(
              child: AnimatedBuilder(
                  animation: _animation,
                  builder: (context, child) {
                    // size / 1.55 是为了防止溢出
                    return Image.asset(
                      widget.checked ? widget.checkedImgAsset : widget.unCheckedImgAsset,
                      width: _animation.value * (widget.size.width - widget.padding.horizontal) / 1.55,
                      height: _animation.value * (widget.size.height - widget.padding.vertical) / 1.55,
                    );
                  }
              ),
            ),
          ),
        );
      }
    
      void _playAnimation() {
        _controller.forward(from: 0.0);
      }
    }
    

    心路历程

    思路

    要实现这么一个按钮,简单了说,就是一张图片,点击之后让这张图片的尺寸由小到大变化即可。如果要达到"灵动"的效果,就只要将图片的放大过程是一个非线性变化即可,有一个回弹的效果。

    1.实现线性的变化

    我们先来实现一个Icon的点击放大动画(别问我为什么不用FlutterLogo,因为用FlutterLogo翻车了。有兴趣的小伙伴可以研究下为什么把Icon替换成FlutterLogo之后,点击放大效果就会小时,取而代之的是效果是,FlutterLogo会先变小一下下,然后恢复成正常的尺寸。可能是与FlutterLogo本来就带动画的原因。这也算是一个坑吧。)。

    需要用到Animation的知识,本人参考的是简书 Animation

    思路很简单,就是通过AnimationBuilder,触发动画时,将一个Icon的尺寸从0到40变化。

    具体代码如下。

    具体实现效果贴在代码下。

    lass JellyButton extends StatefulWidget {
      @override
      _JellyButtonState createState() => _JellyButtonState();
    }
    
    class _JellyButtonState extends State<JellyButton> with SingleTickerProviderStateMixin {
    
      final double SIZE = 40.0;
    
      // 动画控制器 用来控制动画的播放
      AnimationController _controller;
      // 自己定义的动画
      Animation<double> _animation;
    
      @override
      void initState() {
        super.initState();
        // 创建一个controller
        _controller = AnimationController(vsync: this, duration: Duration(seconds: 1));
        // 创建一个动画
        _animation = Tween(begin: 0.0, end: 1.0).animate(_controller);
            // 一开始不播放动画 直接显示原始大小
        _controller.forward(from: 1.0);
      }
    
      @override
      void dispose() {
        // 不要忘记释放资源
        _controller.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        // 使用GestureDetector来检测点击事件
        return GestureDetector(
          // 使用AnimationBuilder来实现动画
          onTap: () {
            // 点击之后播放动画
            _playAnimation();
          },
          child: AnimatedBuilder(
            animation: _animation,
            builder: (BuildContext context, Widget child) {
              print('${_animation.value}');
              return Icon(
                Icons.ac_unit,
                size: SIZE * _animation.value,
              );
            },
          ),
        );
      }
    
      void _playAnimation() {
        _controller.forward(from: 0.0);
      }
    }
    
    线性变化.gif

    2.线性动画转变为非线性动画

    第一步做完之后,动画完全没有灵动的效果,接下来我们就来把代码附上灵魂。

    具体的非线性动画细节可以查看Curves

    做法很简单,需要修改的代码如下。

    class _JellyButtonState extends State<JellyButton> with SingleTickerProviderStateMixin {
      
      // 将我们之前申明的_animation改为CurvedAnimation
      // Animation<double> _animation;
      CurvedAnimation _animation;
      
      // ... 此处省略其他代码
      
      // initState中改写为如下代码
      @override
      void initState() {
        super.initState();
        // 创建一个controller
        _controller = AnimationController(vsync: this, duration: Duration(seconds: 1));
        // 线性动画 可以让按钮从小到大变化
        Animation<double> linearAnimation = Tween(begin: 0.0, end: 1.0).animate(_controller);
        // 将线性动画转化成非线性动画 让按钮点击效果更加灵动
        _animation = CurvedAnimation(parent: linearAnimation, curve: Curves.elasticOut);
        // 一开始不播放动画 直接显示原始大小
        _controller.forward(from: 1.0);
      }
      
    }
    
    非线性变化.gif

    3.完善细节

    核心的内容已经写完了,但是这还没完。我们看到第一张gif图中,按钮还有选中和未选中的情况,这就需要添加按钮的状态来实现。然后还要支持不同按钮样式,这样一来,Icon就不够用了,于是我将Icon换成了Image。

    修改完的代码如下所示。

    class JellyButton extends StatefulWidget {
    
      // 主要是添加了这些属性,用来方便按钮的定制化
      // 动画的时间
      final Duration duration;
      // 动画图标的大小
      final Size size;
      // 点击后的回调
      final VoidCallback onTap;
      // 未选中时的图片
      final String unCheckedImgAsset;
      // 选中时的图片
      final String checkedImgAsset;
      // 是否选中
      final bool checked;
      final EdgeInsetsGeometry padding;
    
      const JellyButton({
        this.duration = const Duration(milliseconds: 500),
        this.size = const Size(40.0, 40.0),
        this.onTap,
        @required this.unCheckedImgAsset,
        @required this.checkedImgAsset,
        this.checked = false,
        this.padding = const EdgeInsets.all(8.0)
      });
    
      @override
      _JellyButtonState createState() => _JellyButtonState();
    }
    
    class _JellyButtonState extends State<JellyButton> with SingleTickerProviderStateMixin {
      
      // 变量代码没有修改 省略
    
        // initState 代码没有修改 省略
    
      // dispose 没有修改 省略
    
      @override
      Widget build(BuildContext context) {
        return GestureDetector(
          onTap: () {
            // 点击之后播放动画
            _playAnimation();
            // 执行外部传入的onTap回调
            if (widget.onTap != null) {
              widget.onTap();
            }
          },
          child: Container(
            padding: widget.padding,
            child: AnimatedBuilder(
                animation: _animation,
                builder: (context, child) {
                  return Image.asset(
                    widget.checked ? widget.checkedImgAsset : widget.unCheckedImgAsset,
                    width: _animation.value * (widget.size.width - widget.padding.horizontal),
                    height: _animation.value * (widget.size.height - widget.padding.vertical),
                  );
                }
            ),
          ),
        );
      }
        
      // _playAnimation 方法代码没有修改 省略
    }
    

    改好了,放到页面中,走你~

    页面代码如下。

    class TestPage extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Column(
            mainAxisAlignment: MainAxisAlignment.end,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: <Widget>[
              Row(
                children: <Widget>[
                  Expanded(
                    child: TestButton(),
                  ),
                ],
              )
            ],
          ),
        );
      }
    }
    
    class TestButton extends JellyButton {
      const TestButton({
        VoidCallback onTap,
        bool checked = false,
      }) : super(
        unCheckedImgAsset: 'images/page_one_normal.png',
        checkedImgAsset: 'images/page_one_selected.png',
        size: const Size(48.0, 48.0),
        onTap: onTap,
        checked: checked,
      );
    }
    

    看下效果。

    缺陷一.gif

    为什么不是从中间变大?

    改改改,光加个Center肯定是不行的。

    我们需要先规定这个按钮的高度,然后让图片Center居中,这样就可以实现从中间放大的效果。

    // 修改 GestureDetector 的child为如下代码
    child: Container(
      width: widget.size.width,
      height: widget.size.height,
      padding: widget.padding,
      child: Center(
        child: AnimatedBuilder(
          animation: _animation,
          builder: (context, child) {
            return Image.asset(
              widget.checked ? widget.checkedImgAsset : widget.unCheckedImgAsset,
              width: _animation.value * (widget.size.width - widget.padding.horizontal),
              height: _animation.value * (widget.size.height - widget.padding.vertical),
            );
          }
        ),
      ),
    ),
    

    上图。

    缺陷二.gif

    看起来好像没有之前那么灵动呢?有点卡住了的感觉。

    因为图标会有溢出然后再缩小的效果,但是我们给控件Container指定了大小之后,就会导致图标无法溢出。

    我们在Image的width和height上除以一个系数,让图标缩小,这样就不会溢出了。作者是除以1.55的。(这个方法比较low,但是很管用。其他我能想到的,就是使用Stack的overflow属性,设置为Overflow.visible,让超出Stack的部分也能显示,但是经过好几次尝试,还是没有成功。所以就放弃了Orz)。

    跑起来~

    缺陷三.gif

    诶?为什么必须点击图标才能出发效果呢,点击空白的地方就不行?我明明使用了Expand。添加背景色之后发现,只有按钮那一块的颜色有背景色。那我们就手动指定约束吧。

    // 为Container添加背景和约束
    Container(
      constraints: BoxConstraints(minWidth: widget.size.width, minHeight: widget.size.height),
      width: widget.size.width,
      height: widget.size.height,
      padding: widget.padding,
      color: Colors.red,
        // ... 
    ),
    

    满分!

    缺陷三效果.gif

    好了,虽然是本命年,但是为了美观,还是把背景色去掉。

    去掉背景色之后,又变回原来的了效果了???

    查看源码之后发现,我们设置了width,又设置了constraints属性,这时,Container会将contraints属性设置为尽可能小。所以我们的constraints无效。

    附上源码

    constraints =
      (width != null || height != null)
        ? constraints?.tighten(width: width, height: height)
          ?? BoxConstraints.tightFor(width: width, height: height)
        : constraints,
    

    可是为什么设置了背景颜色,就可以了呢?于是笔者曲线救国,偷偷设置了一个透明的背景颜色。

    最后,完成的代码请看"具体实现"。

    相关文章

      网友评论

        本文标题:Flutter 实现一个灵动的按钮

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