Flutter 实现底部扩散模糊动画(二)页面交互

2,567 阅读7分钟

相关文章

前言

  在上一期,我们已经完成了点开动画的编写和执行,如果有仔细看完的小伙伴会发现,其中的动画效果不止扩散这么简单,本篇就来继续研究其余的动画交互。

简介

  作为一个炫(pin)酷(ru)的页面,页面中的交互也非常的重要。在本篇,我将进一步说明页面内各个位置的交互细节,从而带着各位做一个不将就的强迫症~

  效果图:

  完整demo及组件已上传至项目,走过路过留个star~

交互要素

  页面中的交互主要包含三个触发位置:

  • 点击空白的模糊处,页面会执行退出和退出动画;
  • 点击页面上的返回或关闭按钮,页面会执行退出和退出动画;
  • 元素渐显并带有其他效果。

  接下来将逐点说明如何实现。

实现过程

拦截返回操作

  我们知道在Flutter中,页面要返回时,会执行Navigator.maybePop的方法,使页面返回。为了拦截路由pop,Flutter提供了WillPopScope来拦截返回行为,我们只需要注册onWillPop方法,就可以在pop前执行代码。

bool _popping = false;

Future<bool> willPop() async {
    /// 等待返回动画的执行
    await backDropFilterAnimate(context, false);
    /// 判断_popping从而避免重复触发pop
    if (!_popping) {
        _popping = true;
        await Future.delayed(Duration(milliseconds: _animateDuration), () {
            Navigator.of(context).pop();
        });
    }
    return null;
}

@override
Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Colors.transparent,
        body: WillPopScope(
            /// 绑定willPop方法
            onWIllPop: willPop,
            child: wrapper(
                context,
                child: widget.child,
            ),
        ),
    );
}

  如此我们就轻松愉快地拦截了路由~

退出动画

  思考退出动画和跳转动画的关系,我们立马就可以想到,跳转和退出的动画是相反的,也就是说,逆向执行跳转的动画,就能得到一个退出动画。

  这时我们来回顾一下上一期的跳转动画:

void backDropFilterAnimate(BuildContext context) async {
    final Size s = MediaQuery.of(context).size;

    _backDropFilterController = AnimationController(
        duration: Duration(milliseconds: _animateDuration),
        vsync: this,
    );
    Animation _backDropFilterCurve = CurvedAnimation(
        parent: _backDropFilterController,
        curve: Curves.easeInOut,
    );
    _backDropFilterAnimation = Tween(
        begin: 0.0,
        end: pythagoreanTheorem(s.width, s.height) * 2,
    ).animate(_backDropFilterCurve)
        ..addListener(() {
            setState(() {
                _backdropFilterSize = _backDropFilterAnimation.value;
            });
        });
    _backDropFilterController.forward();
}
    

  要想以相反的方向执行动画,我们加入一个参数bool forward

void backDropFilterAnimate(BuildContext context, bool forward)

  使用forward来控制beginend,达到执行的效果。同时对forward进行判断,如果为false尝试暂停动画

void backDropFilterAnimate(BuildContext context, bool forward) {
    /.../
    if (!forward) _backDropFilterController?.stop();
    
    _backDropFilterAnimation = Tween(
        /// 三元运算赋值
        begin: forward ? 0.0 : _backdropFilterSize,
        end: forward ? pythagoreanTheorem(s.width, s.height) * 2 : 0.0,
    ).animate(_backDropFilterCurve)
        ..addListener(() {
            setState(() {
                _backdropFilterSize = _backDropFilterAnimation.value;
            });
        });
    
    /.../
}

  看到这里可能会有小伙伴问了,AnimateController明明提供了reverse方法用于反向,为什么还要使用一个bool来控制动画执行方向呢?

  原因在于当使用reverse时,控制器会将beginend对调来执行动画,但当我们执行退出动画时,圆形不一定已经完全覆盖,所以通过使用forward来判断方向,可以使未完全覆盖的动画从停止处反向执行,不会造成闪烁的情况。

  至此,跳转和退出动画已经完美完成。

"X" & 空白处返回

  根据效果图,在页面的底部,会提供一个带有旋转动画返回按钮,点击可以返回。

  由于我的页面时点击加号触发的,所以这里我引入了bottomHeight,用来确定加号的位置。从效果图可以看到我的底部导航栏,它的高度我们假设是60.0,那按钮的位置如何定义呢?

final double bottomHeight = 60.0;
/.../
Widget popButton() {
    return SizedBox(
        /// 此处假设为60.0
        width: widget.bottomHeight,
        height: widget.bottomHeight,
        child: Center(
            /// 套手势监听,并设定监听行为
            child: GestureDetector(
                behavior: HitTestBehavior.opaque,
                child: Icon(
                    Icons.add,
                    color: Colors.grey
                ),
                onTap: willPop,
            ),
        ),
    );
}

  将它放入布局中:

Stack(
    /.../
    children: <Widget>[
        Positioned(
            /// 将按钮控件固定在视图底部中央
            left: 0.0,
            right: 0.0,
            bottom: 0.0,
            child: popButton(),
        ),
    ],
)

  按钮定位完成,这时我们开始设计动画。按钮一共需要两组动画,一组是旋转,一组是淡入淡出。

/// 初始化按钮旋转的角度
final double bottomButtonRotateDegree = 45.0;

/// 旋转动画相关
Animation<double> _popButtonAnimation;
AnimationController _popButtonController;
/// 淡入淡出相关
Animation<double> _popButtonOpacityAnimation;
AnimationController _popButtonOpacityController;

void popButtonAnimate(context, bool forward) {
    /// 与背景相同,判断正反执行
    if (!forward) {
        _popButtonController?.stop();
        _popButtonOpacityController?.stop();
    }
    /// 转换按钮实际旋转角度
    final double rotateDegree =
        widget.bottomButtonRotateDegree * (math.pi / 180);
        
    /// 
    _popButtonOpacityController = _popButtonController = AnimationController(
        duration: Duration(milliseconds: _animateDuration),
        vsync: this,
    );
    Animation _popButtonCurve = CurvedAnimation(
        parent: _popButtonController,
        curve: Curves.easeInOut,
    );
    _popButtonAnimation = Tween(
        begin: forward ? 0.0 : _popButtonRotateAngle,
        end: forward ? rotateDegree : 0.0,
    ).animate(_popButtonCurve)
        ..addListener(() {
            setState(() {
                _popButtonRotateAngle = _popButtonAnimation.value;
            });
        });
    /// 设定透明度最小值为0.01,防止背景显示错误
    _popButtonOpacityAnimation = Tween(
        begin: forward ? 0.01 : _popButtonOpacity,
        end: forward ? 1.0 : 0.01,
    ).animate(_popButtonCurve)
        ..addListener(() {
            setState(() {
                _popButtonOpacity = _popButtonOpacityAnimation.value;
            });
        });
    _popButtonController.forward();
    _popButtonOpacityController.forward();
}

  按钮动画构建完成,我们将它放到背景动画中一起执行:

Future backDropFilterAnimate(BuildContext context, bool forward) async {
    /.../
    /// 使用相同的forward控制方向
    popButtonAnimate(context, forward);
    /.../
}

  至此,按钮的动画会跟着背景一起联动了,十分完美~

  但,别着急结束,我们还有内容的动画定制没有完成,如果不需要如效果图一般的元素动画,可以出门右转~

操作项动画

  从效果图我们可以看到,两个操作项是依次淡入出现,并且带有一定的垂直位移。这时问题出现了:我的操作项数量不确定,难道每一个操作项我都要专门写一个动画吗?

  答案是:对了一半。为什么这么说?我们确实需要写操作项的动画,但我们不需要重复地去写每一个操作项,只需要通过封装操作项的内容,将动画所有相关内容也组成数个List,问题就简单了很多。

  以效果图为例,我有两个操作项,先进行声明。

List<String> itemTitles = ["动态", "扫一扫"];
List<String> itemIcons = ["subscriptedAccount", "scan"];
List<Color> itemColors = [Colors.orange, Colors.teal];
List<Function> itemOnTap = [...];

  将操作项所有的信息存储在四个数组中。接下来我们创建两组动画共8个数组的相关变量。

/// 操作项垂直偏移量
List<double> _itemOffset;
/// 操作项偏移动画
List<Animation<double>> _itemAnimations;
/// 操作项偏移动画曲线
List<CurvedAnimation> _itemCurveAnimations;
/// 操作项偏移动画控制器
List<AnimationController> _itemAnimateControllers;
/// 操作项透明度
List<double> _itemOpacity;
/// 操作项透明度动画
List<Animation<double>> _itemOpacityAnimations;
/// 操作项透明度动画曲线
List<CurvedAnimation> _itemOpacityCurveAnimations;
/// 操作项透明度动画控制器
List<AnimationController> _itemOpacityAnimateControllers;

  那么,该怎么初始化动画呢?

void initItemsAnimation() {
    /// 根据操作项内容,初始化动画相关变量
    _itemOffset = <double>[for (int i=0; i<itemTitles.length; i++) 0.0];
    _itemAnimations = List<Animation<double>>(itemTitles.length);
    _itemCurveAnimations = List<CurvedAnimation>(itemTitles.length);
    _itemAnimateControllers = List<AnimationController>(itemTitles.length);
    _itemOpacity = <double>[for (int i=0; i<itemTitles.length; i++) 0.01];
    _itemOpacityAnimations = List<Animation<double>>(itemTitles.length);
    _itemOpacityCurveAnimations = List<CurvedAnimation>(itemTitles.length);    _itemOpacityAnimateControllers = List<AnimationController>(itemTitles.length);
    
    /// 遍历操作性,初始化每一个动画内容
    for (int i = 0; i < itemTitles.length; i++) {
        /// 垂直偏移动画的设定
        _itemAnimateControllers[i] = AnimationController(
            duration: Duration(milliseconds: _animateDuration),
            vsync: this,
        );
        _itemCurveAnimations[i] = CurvedAnimation(
            parent: _itemAnimateControllers[i],
            curve: Curves.ease,
        );
        /// 垂直偏移量设置为20
        _itemAnimations[i] = Tween(
            begin: -20.0,
            end: 0.0,
        ).animate(_itemCurveAnimations[i])                ..addListener(() {
                setState(() {
                    _itemOffset[i] = _itemAnimations[i].value;
                });
            });
        
        /// 透明度动画的设定
        _itemOpacityAnimateControllers[i] = AnimationController(
            duration: Duration(milliseconds: _animateDuration),
            vsync: this,
        );
        _itemOpacityCurveAnimations[i] = CurvedAnimation(
            parent: _itemOpacityAnimateControllers[i],
            curve: Curves.linear,
        );
        _itemOpacityAnimations[i] = Tween(
            begin: 0.01,
            end: 1.0,
        ).animate(_itemOpacityCurveAnimations[i])
            ..addListener(() {
                setState(() {
                    _itemOpacity[i] = _itemOpacityAnimations[i].value;
                });
            });
    }
}

/// 操作项动画的执行
void itemsAnimate(bool forward) {
    for (int i = 0; i < _itemAnimateControllers.length; i++) {
        /// 每个操作项依次增加延时,形成连续效果
        Future.delayed(Duration(milliseconds: 50 * i), () {
            if (forward) {
                _itemAnimateControllers[i]?.forward();
                _itemOpacityAnimateControllers[i]?.forward();
            } else {
                _itemAnimateControllers[i]?.reverse();
                _itemOpacityAnimateControllers[i]?.reverse();
            }
        });
    }
}

  创建操作项的widget,将动画值进行绑定:

Widget item(BuildContext context, int index) {
    return Stack(
        overflow: Overflow.visible,
        children: <Widget>[
            Positioned(
                left: 0.0, right: 0.0,
                /// 绑定垂直偏移
                top: _itemOffset[index],
                child: Opacity(
                    /// 绑定透明度
                    opacity: _itemOpacity[index],
                    child: ...
                ),
            ),
        ],
    );
}

  最后将动画初始化放进initState,动画执行添加至跳转动画。

@override
void initState() {
    initItemsAnimation();
    /.../
}

Future backDropFilterAnimate(BuildContext context, bool forward) async {
    /.../
    if (forward) {
        /// 以跳转动画二分之一的延时执行,效果更佳
        Future.delayed(
            Duration(milliseconds: _animateDuration ~/ 2),
            () { itemsAnimate(true); },
        );
    } else {
        itemsAnimate(false);
    }
}

  一切就绪,保存就可以看到精美的动画效果了~

结语

  这个动画个人耗时大约2小时,在思路非常清晰的情况下,将动画效果实现不是一件难事,这样的动画其实相对不难,接下来可能会有内容揭开、位置自定义等花式的需求,让我们拭目以待~

  最后欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果 (QQ群:181398081)