欢迎加入开源鸿蒙跨平台社区: https://openharmonycrossplatform.csdn.net


本节我们针对常用的动画效果做一个补充,方便日后项目中使用。

1. animated_builder.dart

1.1. 技术要点

实现了一个使用 AnimatedBuilder 的 Flutter 示例页面,核心功能是:点击按钮时,按钮的背景色会在紫色(Colors.deepPurple)和橙色(Colors.deepOrange)之间平滑过渡动画,动画时长为 800 毫秒。

  1. 基础类定义
  • 这是一个有状态的 Widget(StatefulWidget),因为需要处理动画状态的变化
  • routeName 是路由名称,用于导航跳转时标识这个页面。
  1. 状态类核心逻辑
  • SingleTickerProviderStateMixin:提供动画帧回调(vsync),确保动画只在页面可见时运行,节省性能
  • AnimationController:动画控制器,控制动画的播放、暂停、反向等
  • ColorTween:颜色补间动画,定义从起始色到结束色的过渡
  • initState:初始化动画控制器和颜色动画
  • dispose:销毁控制器,这是 Flutter 动画开发的必做操作,否则会内存泄漏
  1. 构建 UI 部分
  • AnimatedBuilder:Flutter 中高效的动画封装 Widget,核心优势是只重建需要动画的部分
    animation:绑定要监听的动画对象,动画值变化时会触发 builder 重建
    builder:动画值变化时执行的构建函数,这里只重建按钮的样式,不重建整个页面
    child:预构建的静态子 Widget(这里是文字),不会随动画重建,提升性能
  • 按钮点击逻辑:根据动画当前状态(controller.status)切换播放方向
    AnimationStatus.completed:动画正向播放完成(已到橙色)
    controller.forward():正向播放(紫→橙)
    controller.reverse():反向播放(橙→紫)

运行效果
页面初始化后,按钮默认是紫色(beginColor)
第一次点击按钮:按钮背景色从紫色平滑过渡到橙色(800 毫秒)
再次点击按钮:按钮背景色从橙色平滑过渡回紫色(800 毫秒)
重复点击会在两种颜色之间循环切换

总结
AnimatedBuilder 的核心价值:只重建动画相关的 Widget 部分,而非整个页面,提升性能;支持传入静态 child 进一步优化性能。
动画开发必做步骤:使用 AnimationController 必须配合 SingleTickerProviderStateMixin,且在 dispose 中销毁控制器,防止内存泄漏。
动画控制逻辑:通过 controller.forward()/reverse() 控制动画方向,通过 AnimationStatus 判断当前动画状态。

1.2. 程序实现

import 'package:flutter/material.dart';

class AnimatedBuilderDemo extends StatefulWidget {
  const AnimatedBuilderDemo({super.key});
  static const String routeName = 'basics/animated_builder';

  @override
  State<AnimatedBuilderDemo> createState() => _AnimatedBuilderDemoState();
}

class _AnimatedBuilderDemoState extends State<AnimatedBuilderDemo>
    with SingleTickerProviderStateMixin {
  static const Color beginColor = Colors.deepPurple;
  static const Color endColor = Colors.deepOrange;
  Duration duration = const Duration(milliseconds: 800);
  late AnimationController controller;
  late Animation<Color?> animation;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(vsync: this, duration: duration);
    animation =
        ColorTween(begin: beginColor, end: endColor).animate(controller);
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('AnimatedBuilder'),
      ),
      body: Center(
        // AnimatedBuilder handles listening to a given animation and calling the builder
        // whenever the value of the animation change. This can be useful when a Widget
        // tree contains some animated and non-animated elements, as only the subtree
        // created by the builder needs to be re-built when the animation changes.
        child: AnimatedBuilder(
          animation: animation,
          builder: (context, child) {
            return ElevatedButton(
              style: ElevatedButton.styleFrom(
                backgroundColor: animation.value,
              ),
              child: child,
              onPressed: () {
                switch (controller.status) {
                  case AnimationStatus.completed:
                    controller.reverse();
                  default:
                    controller.forward();
                }
              },
            );
          },
          // AnimatedBuilder can also accept a pre-built child Widget which is useful
          // if there is a non-animated Widget contained within the animated widget.
          // This can improve performance since this widget doesn't need to be rebuilt
          // when the animation changes.
          child: const Text(
            'Change Color',
            style: TextStyle(color: Colors.white),
          ),
        ),
      ),
    );
  }
}

2. curved_animation.dart

2.1. 技术要点

代码整体功能
这段代码实现了一个曲线动画演示页面,核心功能是:
提供下拉选择框,可分别选择正向动画曲线和反向动画曲线。
点击「Animate」按钮后,Flutter Logo 会同时执行旋转动画和水平平移动画
动画完成后自动反向播放,且正 / 反向动画可以使用不同的缓动曲线(如弹跳、弹性、立方曲线等)

  1. 基础结构与数据定义
    CurveChoice 是一个数据模型类,用于封装「动画曲线」和「曲线名称」,方便下拉框展示和选择
    页面继承 StatefulWidget 是因为需要动态切换曲线、更新动画状态

  2. 状态类核心成员
    关键概念:
    Curve:Flutter 中的动画曲线,决定动画的速率变化规律(比如匀速、先慢后快、弹跳、弹性等)
    CurvedAnimation:将普通的 AnimationController 包装成带曲线的动画,让动画按指定曲线执行
    SingleTickerProviderStateMixin:提供动画帧回调,保证动画性能

  3. 初始化动画逻辑
    核心要点:
    CurvedAnimation 是「包装器」:它不直接产生动画值,而是修改 parent(AnimationController)的动画值变化速率
    curve:正向播放(forward)时使用的曲线
    reverseCurve:反向播放(reverse)时使用的曲线(如果不设置,反向会用 curve 的反转)
    addListener(() { setState(() {}); }):监听动画值变化,触发 UI 重建(这是手动监听动画的方式,也可以用 AnimatedBuilder 优化)
    addStatusListener:监听动画状态,完成后自动反向播放

  4. 构建 UI 部分
    关键 Widget 解析:
    DropdownButton:下拉选择框,用于切换正 / 反向动画曲线
    Transform.rotate:旋转变换,angle 参数接收弧度值(2*math.pi = 360 度)
    FractionalTranslation:百分比平移动画,Offset(1,0) 表示向右平移 1 倍自身宽度
    动态修改曲线:curvedAnimation.curve = newCurve 可以实时修改动画曲线,无需重建控制器

  5. 资源释放
    这是 Flutter 动画开发的必做操作,AnimationController 持有资源,必须手动销毁

运行效果
页面初始化后,默认选中「Bounce In」曲线
点击「Animate」按钮:
上方 Logo 开始 360 度旋转,下方 Logo 从左向右平移
动画速率遵循选中的正向曲线(比如 Bounce In 会有「弹跳进入」的效果)
动画 4 秒后完成(_duration),自动反向播放(旋转回 0 度、平移回左侧)
反向播放的速率遵循选中的反向曲线
可随时通过下拉框切换正 / 反向曲线,新曲线会在下次动画生效

总结
CurvedAnimation 核心作用:为基础动画(AnimationController)添加「缓动曲线」,控制动画的速率变化(匀速、弹跳、弹性等),支持正 / 反向使用不同曲线。
动画复用:一个 CurvedAnimation 可以被多个 Tween 动画(旋转、平移)共享,实现多属性同步动画。
关键注意点:AnimationController 必须在 dispose 中销毁;动态修改曲线无需重建控制器,直接赋值即可。

2.2. 程序实现

import 'dart:math' as math;
import 'package:flutter/material.dart';

class CurvedAnimationDemo extends StatefulWidget {
  const CurvedAnimationDemo({super.key});
  static const String routeName = 'misc/curved_animation';

  @override
  State<CurvedAnimationDemo> createState() => _CurvedAnimationDemoState();
}

class CurveChoice {
  final Curve curve;
  final String name;

  const CurveChoice({required this.curve, required this.name});
}

class _CurvedAnimationDemoState extends State<CurvedAnimationDemo>
    with SingleTickerProviderStateMixin {
  late final AnimationController controller;
  late final Animation<double> animationRotation;
  late final Animation<Offset> animationTranslation;
  static const _duration = Duration(seconds: 4);
  List<CurveChoice> curves = const [
    CurveChoice(curve: Curves.bounceIn, name: 'Bounce In'),
    CurveChoice(curve: Curves.bounceOut, name: 'Bounce Out'),
    CurveChoice(curve: Curves.easeInCubic, name: 'Ease In Cubic'),
    CurveChoice(curve: Curves.easeOutCubic, name: 'Ease Out Cubic'),
    CurveChoice(curve: Curves.easeInExpo, name: 'Ease In Expo'),
    CurveChoice(curve: Curves.easeOutExpo, name: 'Ease Out Expo'),
    CurveChoice(curve: Curves.elasticIn, name: 'Elastic In'),
    CurveChoice(curve: Curves.elasticOut, name: 'Elastic Out'),
    CurveChoice(curve: Curves.easeInQuart, name: 'Ease In Quart'),
    CurveChoice(curve: Curves.easeOutQuart, name: 'Ease Out Quart'),
    CurveChoice(curve: Curves.easeInCirc, name: 'Ease In Circle'),
    CurveChoice(curve: Curves.easeOutCirc, name: 'Ease Out Circle'),
  ];
  late CurveChoice selectedForwardCurve, selectedReverseCurve;
  late final CurvedAnimation curvedAnimation;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: _duration,
      vsync: this,
    );
    selectedForwardCurve = curves[0];
    selectedReverseCurve = curves[0];
    curvedAnimation = CurvedAnimation(
      parent: controller,
      curve: selectedForwardCurve.curve,
      reverseCurve: selectedReverseCurve.curve,
    );
    animationRotation = Tween<double>(
      begin: 0,
      end: 2 * math.pi,
    ).animate(curvedAnimation)
      ..addListener(() {
        setState(() {});
      })
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          controller.reverse();
        }
      });
    animationTranslation = Tween<Offset>(
      begin: const Offset(-1, 0),
      end: const Offset(1, 0),
    ).animate(curvedAnimation)
      ..addListener(() {
        setState(() {});
      })
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          controller.reverse();
        }
      });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Curved Animation'),
      ),
      body: Column(
        children: [
          const SizedBox(height: 20.0),
          Text(
            'Select Curve for forward motion',
            style: Theme.of(context).textTheme.titleLarge,
          ),
          DropdownButton<CurveChoice>(
            items: curves.map((curve) {
              return DropdownMenuItem<CurveChoice>(
                  value: curve, child: Text(curve.name));
            }).toList(),
            onChanged: (newCurve) {
              if (newCurve != null) {
                setState(() {
                  selectedForwardCurve = newCurve;
                  curvedAnimation.curve = selectedForwardCurve.curve;
                });
              }
            },
            value: selectedForwardCurve,
          ),
          const SizedBox(height: 15.0),
          Text(
            'Select Curve for reverse motion',
            style: Theme.of(context).textTheme.titleLarge,
          ),
          DropdownButton<CurveChoice>(
            items: curves.map((curve) {
              return DropdownMenuItem<CurveChoice>(
                  value: curve, child: Text(curve.name));
            }).toList(),
            onChanged: (newCurve) {
              if (newCurve != null) {
                setState(() {
                  selectedReverseCurve = newCurve;
                  curvedAnimation.reverseCurve = selectedReverseCurve.curve;
                });
              }
            },
            value: selectedReverseCurve,
          ),
          const SizedBox(height: 35.0),
          Transform.rotate(
            angle: animationRotation.value,
            child: const Center(
              child: FlutterLogo(
                size: 100,
              ),
            ),
          ),
          const SizedBox(height: 35.0),
          FractionalTranslation(
            translation: animationTranslation.value,
            child: const FlutterLogo(
              size: 100,
            ),
          ),
          const SizedBox(height: 25.0),
          ElevatedButton(
            onPressed: () {
              controller.forward();
            },
            child: const Text('Animate'),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }
}

3. focus_image.dart

3.1. 技术要点

实现了一个「图片网格预览 + 点击放大查看」的功能,核心亮点是:
展示 4 列共 40 张图片的网格布局(前 20 张和后 20 张分别用不同图片)
点击任意图片时,图片会从原位置平滑过渡放大到全屏展示
点击放大后的图片,会反向过渡回到原网格位置
过渡动画使用自定义的 PositionedTransition,实现「焦点缩放」的视觉效果

  1. 入口页面与网格布局
    关键知识点:
    GridView.builder:懒加载网格布局,只构建可见区域的子项,性能更优
    SliverGridDelegateWithFixedCrossAxisCount:固定列数的网格代理,crossAxisCount: 4 表示 4 列布局
    SmallCard:封装了单个图片卡片的样式和点击逻辑

  2. 单个图片卡片(SmallCard)
    关键知识点:
    InkWell:带水波纹效果的点击组件,替代 GestureDetector 更符合 Material 设计
    Image.asset:加载本地图片资源,fit: BoxFit.cover 保证图片填满容器且不变形
    点击时调用 _createRoute 创建自定义过渡的路由

  3. 核心:自定义页面过渡动画
    3.1 创建自定义路由(_createRoute)
    关键知识点:
    PageRouteBuilder:自定义页面路由的核心类,可自定义页面内容和过渡动画
    transitionsBuilder:过渡动画的构建函数,参数说明:
    animation:正向过渡动画(进入新页面)
    secondaryAnimation:反向过渡动画(返回原页面)
    child:新页面的 Widget(这里是 _SecondPage)
    CurveTween(curve: Curves.ease):添加缓动曲线,让动画更自然
    PositionedTransition:基于 RelativeRect 的位置过渡组件,控制 Widget 的位置和大小
    3.2 坐标计算(_createTween)
    MediaQuery.of(context).size:获取设备屏幕的宽高
    context.findRenderObject():获取当前 Widget(SmallCard)的渲染对象,包含布局信息
    box.localToGlobal(Offset.zero):将 Widget 的本地坐标(相对父容器)转换为屏幕绝对坐标
    & box.size:组合坐标和大小,得到 Widget 在屏幕上的矩形区域
    RelativeRect.fromSize:将绝对矩形转换为相对屏幕的 RelativeRect(格式:left, top, right, bottom)
    RelativeRectTween:创建补间动画,定义从「原位置」到「全屏」的过渡

  4. 放大后的图片页面(_SecondPage)
    AspectRatio(aspectRatio: 1):强制图片按 1:1 比例展示,避免拉伸
    Navigator.of(context).pop():返回上一页时,PageRouteBuilder 会自动反向执行过渡动画(从全屏缩回到原位置)
    黑色背景:增强图片的视觉聚焦效果

运行效果
进入页面后,展示 4 列共 40 张图片的网格布局
点击任意图片:
该图片会从原网格位置平滑放大,同时移动到屏幕中心
动画过程中图片的位置和大小连续变化,视觉上像是「从网格中飞出来放大」
点击放大后的图片:
图片会平滑缩小并回到原网格位置,同时关闭放大页面

总结
核心技术点:PageRouteBuilder 自定义过渡动画 + PositionedTransition 位置过渡 + RelativeRectTween 坐标补间,实现「焦点缩放」的视觉效果。
坐标计算逻辑:通过渲染对象获取 Widget 绝对位置,转换为相对屏幕的坐标,作为动画的起始值。
反向动画特性:Flutter 路由过渡动画默认支持反向执行,无需重复编写反向逻辑,只需调用 pop() 即可。

3.2. 程序实现


import 'package:flutter/material.dart';

class FocusImageDemo extends StatelessWidget {
  const FocusImageDemo({super.key});
  static String routeName = 'misc/focus_image';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Focus Image')),
      body: const Grid(),
    );
  }
}

class Grid extends StatelessWidget {
  const Grid({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GridView.builder(
        itemCount: 40,
        gridDelegate:
            const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 4),
        itemBuilder: (context, index) {
          return (index >= 20)
              ? const SmallCard(
                  imageAssetName: 'assets/eat_cape_town_sm.jpg',
                )
              : const SmallCard(
                  imageAssetName: 'assets/eat_new_orleans_sm.jpg',
                );
        },
      ),
    );
  }
}

Route _createRoute(BuildContext parentContext, String image) {
  return PageRouteBuilder<void>(
    pageBuilder: (context, animation, secondaryAnimation) {
      return _SecondPage(image);
    },
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      var rectAnimation = _createTween(parentContext)
          .chain(CurveTween(curve: Curves.ease))
          .animate(animation);

      return Stack(
        children: [
          PositionedTransition(rect: rectAnimation, child: child),
        ],
      );
    },
  );
}

Tween<RelativeRect> _createTween(BuildContext context) {
  var windowSize = MediaQuery.of(context).size;
  var box = context.findRenderObject() as RenderBox;
  var rect = box.localToGlobal(Offset.zero) & box.size;
  var relativeRect = RelativeRect.fromSize(rect, windowSize);

  return RelativeRectTween(
    begin: relativeRect,
    end: RelativeRect.fill,
  );
}

class SmallCard extends StatelessWidget {
  const SmallCard({required this.imageAssetName, super.key});
  final String imageAssetName;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Material(
        child: InkWell(
          onTap: () {
            var nav = Navigator.of(context);
            nav.push<void>(_createRoute(context, imageAssetName));
          },
          child: Image.asset(
            imageAssetName,
            fit: BoxFit.cover,
          ),
        ),
      ),
    );
  }
}

class _SecondPage extends StatelessWidget {
  final String imageAssetName;

  const _SecondPage(this.imageAssetName);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      body: Center(
        child: Material(
          child: InkWell(
            onTap: () => Navigator.of(context).pop(),
            child: AspectRatio(
              aspectRatio: 1,
              child: Image.asset(
                imageAssetName,
                fit: BoxFit.cover,
              ),
            ),
          ),
        ),
      ),
    );
  }
}

4. page_route_builder.dart

4.1. 技术要点

实现了两个页面的跳转功能,核心亮点是:
页面 1(Page 1)有一个「Go!」按钮,点击后跳转到页面 2(Page 2)
跳转时使用自定义的滑动过渡动画:页面 2 从屏幕底部(Offset(0.0, 1.0))向上滑入屏幕,动画带有 Curves.ease 缓动效果
返回页面 1 时,动画会自动反向执行(页面 2 向下滑出屏幕)

  1. 入口页面(PageRouteBuilderDemo)
    这是无状态 Widget(StatelessWidget),因为页面逻辑简单,无状态变化
    Navigator.of(context).push(_createRoute()):通过 push 方法跳转到自定义路由返回的页面
    push 中的 表示跳转时不传递返回值

  2. 核心:自定义路由(_createRoute)
    点击「Go!」后,animation 会从 0 → 1 执行
    curveTween 先将 animation 的值按 Curves.ease 曲线转换
    tween 再将转换后的值映射为 Offset(从 (0,1) → (0,0))
    SlideTransition 根据 Offset 控制 _Page2() 从底部滑入屏幕

  3. 目标页面(_Page2)
    类名前的 _ 表示私有类,只能在当前文件中使用
    Theme.of(context).textTheme.headlineMedium:使用系统主题的文字样式,符合 Material 设计规范
    返回页面 1 时,Flutter 会自动反向执行 transitionsBuilder 中的动画(animation 从 1 → 0),页面 2 会向下滑出屏幕

运行效果
初始页面显示「Page 1」和「Go!」按钮
点击「Go!」按钮:
页面 2 从屏幕底部开始向上滑动
动画速率遵循 Curves.ease(先慢→快→慢)
最终页面 2 完全显示在屏幕中
点击页面 2 的返回按钮(AppBar 左侧箭头):
页面 2 向下滑动,从屏幕底部消失
回到页面 1,动画反向执行,无需额外代码

总结
PageRouteBuilder 核心作用:替代 Flutter 默认的页面跳转动画,完全自定义页面的进入 / 退出过渡效果,支持任意动画类型(滑动、缩放、渐变等)。
关键组合:PageRouteBuilder + transitionsBuilder + Tween + Transition组件(如 SlideTransition)是自定义页面动画的标准写法。
反向动画特性:Flutter 会自动处理反向动画(返回页面时),只需定义正向动画即可,无需重复编写反向逻辑。

4.2. 程序实现


import 'package:flutter/material.dart';

class PageRouteBuilderDemo extends StatelessWidget {
  const PageRouteBuilderDemo({super.key});
  static const String routeName = 'basics/page_route_builder';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Page 1'),
      ),
      body: Center(
        child: ElevatedButton(
          child: const Text('Go!'),
          onPressed: () {
            Navigator.of(context).push<void>(_createRoute());
          },
        ),
      ),
    );
  }
}

Route _createRoute() {
  return PageRouteBuilder<SlideTransition>(
    pageBuilder: (context, animation, secondaryAnimation) => _Page2(),
    transitionsBuilder: (context, animation, secondaryAnimation, child) {
      var tween =
          Tween<Offset>(begin: const Offset(0.0, 1.0), end: Offset.zero);
      var curveTween = CurveTween(curve: Curves.ease);

      return SlideTransition(
        position: animation.drive(curveTween).drive(tween),
        child: child,
      );
    },
  );
}

class _Page2 extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Page 2'),
      ),
      body: Center(
        child:
            Text('Page 2!', style: Theme.of(context).textTheme.headlineMedium),
      ),
    );
  }
}

5. 效果演示

动画效果

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐