【Flutter】使用HeroAnimation(主动画)、自定义补间动画createRectTween 和 GestureDetector 实现丝滑的页面过渡效果

前言: 在AppStore里面,首页的app推荐点击后有一个丝滑的到详情页面的过渡动画,并且关闭的时候也会存在一个可打断的向下划动关闭的交互,今天我来尝试实现一下

在实现之前,首先看一下我们的目标效果
在这里插入图片描述
在页面1导航到页面2的过程中,有个一点对点的形变动画过渡,而在关闭页面2的时候,不仅包含过渡,而且有一个通过向下划动而且中途可打断的缩放动画,是根据捕捉滑动路径来决定的,要实现如上的交互效果,我们可以把步骤简化为3步

  • 实现页面之间的Navigate过渡
  • 实现过渡之间的弹性动画
  • 实现下滑关闭页面时页面的缩放与控制边缘角度的渐变
Section1 HeroAnimation

虽然,Flutter没有提供SwiftUI里可以一步到位的组件以实现上面的效果,但是我们仍然可以自己去尝试实现,Flutter提供了Hero Animation(主动画)以让开发者实现在页面之间的穿梭,简单说一下原理
基本上 一次HeroAnimation的变换要经历四个步骤:
过渡前 > 页面被推送到Navigator 触发动画 > 过渡完成 目标hero来到最终的位置
因此,要实现一个主动画很简单,确定目标hero,确定hero最终的位置,给hero打上标签,一个简单的主动画就完成了,来简单实现一下,创建一个新项目并创建两个页面:

  1. home兼main页面
import 'package:flutter/material.dart';
import 'CustomRectTween.dart';
import 'FlutterHeroAnimationSecondPage.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Hero(
                tag: "HeroAnimationTag",
                child: GestureDetector(
                  onTap: () {
                    _startHeroAnimation(context);
                  },
                  child: Container(
                    margin: EdgeInsets.symmetric(horizontal: 10),
                    width: double.infinity,
                    height: 250,
                    clipBehavior: Clip.hardEdge,
                    decoration: BoxDecoration(
                        color: Color(0xffBB8045),
                        borderRadius: BorderRadius.circular(10),
                        boxShadow: [
                          BoxShadow(color: Colors.black26, blurRadius: 5)
                        ]),
                    child: Stack(
                      children: [
                        const Image(
                            image: AssetImage('assets/room.jpeg'),
                            fit: BoxFit.cover),
                        Container(
                            margin: EdgeInsets.only(top: 80),
                            child: Container(
                                width: double.infinity,
                                height: 100,
                                decoration: const BoxDecoration(
                                    gradient: LinearGradient(
                                        begin: Alignment.topCenter,
                                        end: Alignment.bottomCenter,
                                        colors: [
                                      Colors.transparent,
                                      Color(0xffBB8045)
                                    ])))),
                        const Positioned(
                            top: 120,
                            left: 10,
                            child: Text(
                              '今日作品',
                              style: TextStyle(
                                  color: Colors.white,
                                  fontSize: 30,
                                  fontWeight: FontWeight.bold),
                            )),
                      ],
                    ),
                  ),
                )),
          ],
        ),
      ),
    );
  }
// heroAnimation的实现
  void _startHeroAnimation(BuildContext context) {
    Navigator.push(context, PageRouteBuilder(pageBuilder: (BuildContext context,
        Animation<double> animation, Animation<double> secondaryAnimation) {
      return FadeTransition(
        opacity: animation,
        child: FlutterHeroAnimationSecondPage(),
      );
    }));
  }
}


  1. 详情页面 FlutterHeroAniamtionSecondPage
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

import 'CustomRectTween.dart';

class FlutterHeroAnimationSecondPage extends StatefulWidget {
  const FlutterHeroAnimationSecondPage({Key? key}) : super(key: key);

  @override
  State<FlutterHeroAnimationSecondPage> createState() =>
      _FlutterHeroAnimationSecondPageState();
}

class _FlutterHeroAnimationSecondPageState
    extends State<FlutterHeroAnimationSecondPage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Center(
          child: SizedBox(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.start,
              children: [
                Hero(
                    tag: "HeroAnimationTag",
                    child: Container(
                      height: 290,
                      child: Stack(
                        children: [
                          Container(
                              height: 250,
                              child: Image(
                                image: AssetImage('assets/room.jpeg'),
                                fit: BoxFit.cover,
                              )),
                          Positioned(
                              top: 50,
                              right: 10,
                              child: GestureDetector(
                                onTap: () {
                                  Navigator.pop(context);
                                },
                                child: Container(
                                  width: 40,
                                  height: 40,
                                  decoration: BoxDecoration(
                                      borderRadius: BorderRadius.circular(40),
                                      color: Color(0x5feeeeee)),
                                  child: Icon(Icons.close),
                                ),
                              )),
                          Positioned(
                              top: 250,
                              left: 10,
                              child: Text('今日作品',
                                  style: TextStyle(
                                      fontSize: 30,
                                      color: Colors.black,
                                      fontWeight: FontWeight.bold)))
                        ],
                      ),
                    )),
                Expanded(
                    child: Container(
                  child: Text('.....'),
                ))
              ],
            ),
          ),
        ),
      // ),
    );
  }
}

如此,实现了一个简单的heroAnimation效果
在这里插入图片描述
可以看到,基础的过渡跳转已经实现,但是动画是线性的,没有结束时的弹跳感,这时候我们需要为项目的hero() widge 添加一个属性: createRectTween,创建重写一个属于自己的动画效果。

Section2 createRectTween

要创建自定义补间动画,首先看一下hero里面的创建动画都需要什么参数

 Hero(
      tag: "HeroAnimationTag",
      createRectTween: (begin, end) {
           return MaterialRectCenterArcTween(begin: begin, end: end);
     }
)

createRectTween 接受一个函数,以Rect类型的开始和结束作为参数,返回值便是其需要执行的动画。如上的代码是flutter自定义好的一个补间动画方式MaterialRectCenterArcTween 用以让动画流畅的在矩形和圆形间变动,如果不使用这个方法,则会出现问题,变化期间动画会很奇怪,产生椭圆形

接下来,让我们实现自己的自定义动画
为页面创建一个新的dart文件,命名为CustomRectTween.dart ,并在其中创建一个类,接收参数

import 'dart:ui';

import 'package:flutter/animation.dart';

class CustomRectTween extends RectTween {

  CustomRectTween({ Rect? begin, Rect? end})
      : super(begin: begin, end: end);

  @override
  Rect lerp(double t) {
    double transformT = Curves.easeInOutBack.transform(t);
    // print(transformT);
    var rect = Rect.fromLTRB(
        _rectMove(begin!.left, end!.left, transformT),
        _rectMove(begin!.top, end!.top, transformT),
        _rectMove(end!.right, end!.right, transformT),
        _rectMove(begin!.bottom, end!.bottom, transformT));

    return rect;
  }

  double _rectMove(double begin, double end, double t) {
    print('${begin *  (1 - t) + end * t}');
    return begin *  (1 - t) + end * t;
  }
}

实际上,我按照源码的重写方法来实现了这个类,通过对lerp的override,并通过easeInOut的方法来创建补间动画值,使得原先的动画从

-1 → 0 → 1 → 0

的线性变化到现在的

-1.2 → -1 → 0 → 1.2 → -1 -0

添加了为弹性而存在的量,从上面的代码可以看出动画是对于不断更新形状的LTRB值,也就是一个矩形的四条边,来实现动画的过渡。

如此,我们将这个重写类添加到页面中,注意,如果想要两个页面都有过渡效果,则两个页面都要添加,这也就是我为什么要把这个重写方法抽离出来单写成一个类的原因。

  Hero(
      tag: "HeroAnimationTag",
      createRectTween: (begin, end) {
            return CustomRectTween(begin: begin!, end: end!);
      },
      child: .......
)

在这里插入图片描述
接下来,就来探讨一下最后一步,如何通过捕捉触摸动作来实现关闭页面

Section3 GestureDetector

gestureDetector 这个组件大伙应该非常熟悉,为widget绑定触摸监听之类的会频繁运用到。不过今天我们不探讨onTap,我们来试试它的onDrag事件。
在js中,为页面的滚动事件添加监听的时候,我们经常使用onScroll、clientHeight、offsetHeight、scrollHeight等方法,并且通过简单的计算来得出偏移量,实现对页面做出滚动动画的实现,不过触屏设备我们只需要考虑触摸事件就好,这次我打算通过下滑来实现页面的关闭,介绍这四个包含在gestureDetector的方法。

onVerticalDragStart : 纵向滑动开始
onVerticalDragEnd: 纵向滑动结束
onVerticalDragUpdate: 纵向滑动更新

我需要一个在0~1之间不断变化的值来控制我的页面过渡动画,通过计算偏移量就可以得出。如图
在这里插入图片描述
可计算:
偏移量 = ( 触摸结束点的 y 值 − 触摸开始点的 y 值 ) / ( 屏幕高度 − 触摸开始点的 y 值 ) 偏移量 = ( 触摸结束点的y值 - 触摸开始点的y值 ) / (屏幕高度 - 触摸开始点的y值) 偏移量=(触摸结束点的y触摸开始点的y)/(屏幕高度触摸开始点的y)

现在开始实现,在详情页面中添加相应的值

....
 double vertivalDragStart = 0.0;
 double verticalDragUpdate = 0.0;
 double screenHeight = 0.0;
 bool loopControl = true;
....

在build 中获取屏幕高度,并为避免死循环做处理

if(loopContrl){
	setState((){
		screenHeight = MediaQuery.of(context).size.height;
		loopContorl = false;
	};)
}

计算页面的各种值,并实时更新偏移量,并且在偏移量超过一定值的时候关闭页面

return GestureDetector(
 onVerticalDragStart: (detail) {
        setState(() {
          verticalDragStart = detail.localPosition.dy;
        });
      },
      onVerticalDragEnd: (detail) {
        setState(() {
          verticalDragUpdate = 0.0;
        });
      },
      onVerticalDragUpdate: (DragUpdateDetails detail) {
       var offsetNum =  (detail.localPosition.dy - verticalDragStart) / ( screenHeight - verticalDragStart);
       print('offsetNum: $offsetNum');
       if(offsetNum > 0.4){
         Navigator.pop(context);
       }else{
         setState(() {
           verticalDragUpdate = offsetNum;
         });
       }
       child: .......
    )

对页面的尺寸进行实时计算以进行缩放

 child: SizedBox(
            width: MediaQuery.of(context).size.width - verticalDragUpdate*100,
            height: MediaQuery.of(context).size.height - verticalDragUpdate*100,
)

对页面的角也进行实时的变化

Container(
                      height: 290,
                      clipBehavior: Clip.hardEdge,
                      decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(100 * ( verticalDragUpdate))
                      ),
           child:......
           )

看一下滑动关闭的最终效果
在这里插入图片描述
可以看到 我们实现了:

  1. 按照滑动的偏移量对页面进行缩放 对页面的角进行角度的变化
  2. 动画是可以打断的,并且打断时会恢复到原来的大小
  3. 滑动到一定的位置时,触发页面的关闭

以下,贴上两个页面的全部代码, 动画类的代码在上面可以找到哦

1.main.dart

import 'package:flutter/material.dart';
import 'CustomRectTween.dart';
import 'FlutterHeroAnimationSecondPage.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Hero(
                tag: "HeroAnimationTag",
                createRectTween: (begin, end) {
                  return CustomRectTween(begin: begin!, end: end!);
                },
                child: GestureDetector(
                  onTap: () {
                    _startHeroAnimation(context);
                  },
                  child: Container(
                    margin: EdgeInsets.symmetric(horizontal: 10),
                    width: double.infinity,
                    height: 250,
                    clipBehavior: Clip.hardEdge,
                    decoration: BoxDecoration(
                        color: Color(0xffBB8045),
                        borderRadius: BorderRadius.circular(10),
                        boxShadow: [
                          BoxShadow(color: Colors.black26, blurRadius: 5)
                        ]),
                    child: Stack(
                      children: [
                        const Image(
                            image: AssetImage('assets/room.jpeg'),
                            fit: BoxFit.cover),
                        Container(
                            margin: EdgeInsets.only(top: 80),
                            child: Container(
                                width: double.infinity,
                                height: 100,
                                decoration: const BoxDecoration(
                                    gradient: LinearGradient(
                                        begin: Alignment.topCenter,
                                        end: Alignment.bottomCenter,
                                        colors: [
                                      Colors.transparent,
                                      Color(0xffBB8045)
                                    ])))),
                        const Positioned(
                            top: 120,
                            left: 10,
                            child: Text(
                              '今日作品',
                              style: TextStyle(
                                  color: Colors.white,
                                  fontSize: 30,
                                  fontWeight: FontWeight.bold),
                            )),
                      ],
                    ),
                  ),
                )),
          ],
        ),
      ),
    );
  }

  void _startHeroAnimation(BuildContext context) {
    Navigator.push(context, PageRouteBuilder(pageBuilder: (BuildContext context,
        Animation<double> animation, Animation<double> secondaryAnimation) {
      return FadeTransition(
        opacity: animation,
        child: FlutterHeroAnimationSecondPage(),
      );
    }));
  }
}


  1. FlutterHeroAnimationSecondPage.dart
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

import 'CustomRectTween.dart';

class FlutterHeroAnimationSecondPage extends StatefulWidget {
  const FlutterHeroAnimationSecondPage({Key? key}) : super(key: key);

  @override
  State<FlutterHeroAnimationSecondPage> createState() =>
      _FlutterHeroAnimationSecondPageState();
}

class _FlutterHeroAnimationSecondPageState
    extends State<FlutterHeroAnimationSecondPage> {

  double verticalDragStart = 0.0;
  double verticalDragUpdate = 0.0;
  double screenHeight = 0.0;
  bool loopControl = true;

  @override
  Widget build(BuildContext context) {

    if(loopControl){
      setState(() {
        screenHeight =  MediaQuery.of(context).size.height;
        loopControl = false;
      });

    }

    return GestureDetector(
      onVerticalDragStart: (detail) {
        setState(() {
          verticalDragStart = detail.localPosition.dy;
        });
      },
      onVerticalDragEnd: (detail) {
        setState(() {
          verticalDragUpdate = 0.0;
        });
      },
      onVerticalDragUpdate: (DragUpdateDetails detail) {
       var offsetNum =  (detail.localPosition.dy - verticalDragStart) / ( screenHeight - verticalDragStart);
       print('offsetNum: $offsetNum');
       if(offsetNum > 0.4){
         Navigator.pop(context);
       }else{
         setState(() {
           verticalDragUpdate = offsetNum;
         });
       }

        // print('update: ${detail.localPosition}');
      },
      child: Scaffold(
        body: Center(
          child: SizedBox(
            width: MediaQuery.of(context).size.width - verticalDragUpdate*100,
            height: MediaQuery.of(context).size.height - verticalDragUpdate*100,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.start,
              children: [
                Hero(
                    tag: "HeroAnimationTag",
                    createRectTween: (begin, end) {
                      return CustomRectTween(begin: begin!, end: end!);
                    },
                    child: Container(
                      height: 290,
                      clipBehavior: Clip.hardEdge,
                      decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(100 * ( verticalDragUpdate))
                      ),
                      // width: ,
                      child: Stack(
                        children: [
                          Container(
                              height: 250,
                              child: Image(
                                image: AssetImage('assets/room.jpeg'),
                                fit: BoxFit.cover,
                              )),
                          Positioned(
                              top: 50,
                              right: 10,
                              child: GestureDetector(
                                onTap: () {
                                  Navigator.pop(context);
                                },
                                child: Container(
                                  width: 40,
                                  height: 40,
                                  decoration: BoxDecoration(
                                      borderRadius: BorderRadius.circular(40),
                                      color: Color(0x5feeeeee)),
                                  child: Icon(Icons.close),
                                ),
                              )),
                          Positioned(
                              top: 250,
                              left: 10,
                              child: Text('今日作品',
                                  style: TextStyle(
                                      fontSize: 30,
                                      color: Colors.black,
                                      fontWeight: FontWeight.bold)))
                        ],
                      ),
                    )),
                Expanded(
                    child: Container(
                  child: Text('.....'),
                ))
              ],
            ),
          ),
        ),
      ),
    );
  }
}

总结
通过对Flutter的HeroAnimation实现页面的跳转与过渡
通过自己创建类实现非线性弹性动画
通过gestureDetector实现对页面的缩放效果


欢迎讨论、指错、提出更好的方法、文章仅发在CSDN 账号名称:Vicissitidues,不允许任何形式的转载,谢谢🙏

END

在这里插入图片描述

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值