20 Flutter for OpenHarmony 动画效果

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

目录

  • [1. animated_builder.dart](#1. animated_builder.dart)
    • [1.1. 技术要点](#1.1. 技术要点)
    • [1.2. 程序实现](#1.2. 程序实现)
  • [2. curved_animation.dart](#2. curved_animation.dart)
    • [2.1. 技术要点](#2.1. 技术要点)
    • [2.2. 程序实现](#2.2. 程序实现)
  • [3. focus_image.dart](#3. focus_image.dart)
    • [3.1. 技术要点](#3.1. 技术要点)
    • [3.2. 程序实现](#3.2. 程序实现)
  • [4. page_route_builder.dart](#4. page_route_builder.dart)
    • [4.1. 技术要点](#4.1. 技术要点)
    • [4.2. 程序实现](#4.2. 程序实现)
  • [5. 效果演示](#5. 效果演示)

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

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. 程序实现

bash 复制代码
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. 程序实现

bash 复制代码
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. 程序实现

bash 复制代码
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. 程序实现

bash 复制代码
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. 效果演示

动画效果

相关推荐
Swift社区3 小时前
Flutter 项目如何做好性能监控与问题定位?
flutter
LawrenceLan3 小时前
36.Flutter 零基础入门(三十六):StatefulWidget 与 setState 进阶 —— 动态页面必学
开发语言·前端·flutter·dart
weixin_443478513 小时前
flutter组件学习之Stack 组件详解
学习·flutter
迪士尼在逃游客3 小时前
OpenClaw + 智谱 AI (GLM) + 飞书机器人 完整部署指南
开源
程序员Ctrl喵4 小时前
分层架构的协同艺术——解构 Flutter 的心脏
flutter·架构
Hello.Reader4 小时前
Flutter IM 桌面端消息发送、ACK 回执、SQLite 本地缓存与断线重连设计
flutter·缓存·sqlite
Hello.Reader4 小时前
Flutter IM 桌面端项目架构、聊天窗口布局与 WebSocket 长连接设计
websocket·flutter·架构
前端不太难4 小时前
Flutter Web / Desktop 为什么“能跑但不好用”?
前端·flutter·状态模式
前端不太难4 小时前
Flutter 国际化和主题系统如何避免后期大改?
flutter·状态模式