FlutterFlutter相关

Flutter 实现一个灵动的按钮

2019-05-19  本文已影响0人  kengou

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,

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

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

上一篇下一篇

猜你喜欢

热点阅读