You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter多元素Hero返回动画起始位置异常问题求助

多元素Flutter Hero动画返回时不同步问题

正向动画中背景卡片和图片能正常缩放完成动画,但返回时图片会突然跳转到中心,动画效果远不及正向流畅。如何让返回时两者同步回到网格中的原始位置,实现和正向动画一致的效果?


代码实现

main.dart

import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:flutter_playground/screen_two.dart';
import 'package:flutter_playground/transition/hero_dialog_route.dart';
import 'package:flutter_playground/transition/hero_page_route.dart';
import 'package:flutter_playground/transition_open_container_wrapper.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.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({super.key, required this.title});

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: GridView.builder(
          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 2,
            childAspectRatio: 1,
            crossAxisSpacing: 4,
            mainAxisSpacing: 4,
          ),
          shrinkWrap: true,
          itemCount: 100,
          itemBuilder: (context, index) {
            return getChild(index);
          }),
    );
  }

  Widget getChild(int index) {
    return GestureDetector(
      onTap: () {
        Navigator.push(
          context,
          HeroDialogRoute(builder: (context) => ScreenTwo(index: index)),
        );
      },
      child: getCard(index),
    );
  }

  Widget getCard2(int index) {
    return OpenContainerWrapper(
      isRootNavigator: true,
      closedBuilder: (context, voidCallback) {
        return InkWell(
          onTap: voidCallback,
          child: getCard(index),
        );
      },
      openBuilder: (context, voidCallback) {
        return Container(height: 300, width: 300, child: ScreenTwo(index: index),);
      },
      transitionType: ContainerTransitionType.fade,
      onClosed: (value) {},
    );
  }

  Widget getCard(int index) {
    return Stack(
      children: [
        Hero(
          createRectTween: (Rect? begin, Rect? end) {
            return CurvedRectArcTween(begin: begin, end: end);
          },
          tag: 'hero_card_${index}',
          child: Card(
            child: SizedBox(
              height: double.infinity,
              width: double.infinity,
            ),
          ),
        ),
        Hero(
          tag: 'hero_image_${index}',
          createRectTween: (Rect? begin, Rect? end) {
            return CurvedRectArcTween(begin: begin, end: end);
          },
          child: Container(
            padding: EdgeInsets.all(8),
            child: Image.network('https://picsum.photos/200', fit: BoxFit.contain,),
            height: double.infinity,
            width: double.infinity,
          ),
        )
      ],
    );
  }
}

screen_two.dart

import 'dart:ui';

import 'package:flutter/material.dart';

class ScreenTwo extends StatefulWidget {
  final int index;

  const ScreenTwo({super.key, required this.index});

  @override
  State<StatefulWidget> createState() {
    return _ScreenTwoState();
  }
}

class _ScreenTwoState extends State<ScreenTwo> {

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        Navigator.pop(context);
      },
      child: Padding(
        padding: EdgeInsets.symmetric(horizontal: 16),
        child: Scaffold(
          appBar: AppBar(
            automaticallyImplyLeading: false,
            elevation: 0,
            backgroundColor: Colors.transparent,
          ),
          backgroundColor: Colors.transparent,
          body: BackdropFilter(
              filter: ImageFilter.blur(sigmaX: 0, sigmaY: 0), child: Center(
            child: getContent(),
          )),
        ),
      ),
    );
  }

  Widget getContent() {
    return Stack(
      children: [
        Hero(
          tag: 'hero_card_${widget.index}',
          createRectTween: (Rect? begin, Rect? end) {
            return RectTween(begin: begin, end: end);
          },
          child: Container(
            width: 416,
            height: 416,
          ),
        ),
        Padding(
          padding: EdgeInsets.all(8),
          child: Hero(
            tag: 'hero_image_${widget.index}',
            createRectTween: (Rect? begin, Rect? end) {
              return RectTween(begin: begin, end: end);
            },
            child: Card(
              child: ListView(
                shrinkWrap: true,
                children: [
                  Padding(
                    padding: EdgeInsets.all(8),
                    child: Image.network(
                      'https://picsum.photos/200',
                      fit: BoxFit.contain,
                    ),
                  ),
                  Container(
                    height: 200,
                  )
                ],
              ),
            ),
          ),
        )
      ],
    );
  }
}

hero_page_route.dart

import 'package:flutter/material.dart';

class CurvedRectArcTween extends RectTween {

  late double a;
  late double b;
  late double c;
  late double d;

  CurvedRectArcTween({
    Rect? begin,
    Rect? end,
    double? a,
    double? b,
    double? c,
    double? d,
  }) : super(begin: begin, end: end) {
    this.a = a ?? 0;
    this.b = b ?? 0;
    this.c = c ?? 0;
    this.d = d ?? 0;
  }

  @override
  Rect? lerp(double t) {
    Cubic easeInOut = Cubic(a, b , c, d);
    double curvedT = easeInOut.transform(t);
    return super.lerp(curvedT);
  }
}

hero_dialog_route.dart

import 'package:animations/animations.dart';
import 'package:flutter/material.dart';

class HeroDialogRoute<T> extends PageRoute<T> {
  final WidgetBuilder builder;

  HeroDialogRoute({required this.builder}) : super();

  @override
  bool get opaque => false;

  @override
  bool get barrierDismissible => true;

  @override
  Duration get transitionDuration => const Duration(milliseconds: 1000);

  @override
  bool get maintainState => true;

  @override
  Color get barrierColor => Colors.black54;

  @override
  Widget buildTransitions(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation, Widget child) {
    return FadeScaleTransition(
      animation: animation,
      child: child,
    );
  }

  @override
  Widget buildPage(BuildContext context, Animation<double> animation,
      Animation<double> secondaryAnimation) {
    return builder(context);
  }

  @override
  String? get barrierLabel => null;
}

transition_open_container_wrapper.dart

class OpenContainerWrapper extends StatelessWidget {
  const OpenContainerWrapper({
    Key? key,
    required this.isRootNavigator,
    required this.closedBuilder,
    required this.openBuilder,
    required this.transitionType,
    required this.onClosed,
  }) :super(key: key);

  final CloseContainerBuilder closedBuilder;
  final OpenContainerBuilder<bool?> openBuilder;
  final ContainerTransitionType transitionType;
  final ClosedCallback<bool?> onClosed;
  final bool isRootNavigator;

  @override
  Widget build(BuildContext context) {
    return OpenContainer<bool>( 
      useRootNavigator: isRootNavigator,
      closedElevation: 0,
      openColor: Colors.transparent,
      transitionDuration: const Duration(milliseconds: 450),
      closedShape: const RoundedRectangleBorder(),
      openShape: const RoundedRectangleBorder(),
      transitionType: transitionType,
      openBuilder: openBuilder,
      onClosed: onClosed,
      tappable: false,
      closedBuilder: closedBuilder,
    );
  }
}

问题原因与修复方案

核心问题

  1. RectTween不统一:首页Hero使用自定义的CurvedRectArcTween,但第二页使用默认RectTween,导致正向/反向动画插值曲线不匹配。
  2. 布局约束不一致:首页图片Hero是带padding的Container包裹Image,第二页是Card+ListView+多层Padding的嵌套结构,布局边界计算差异导致返回时位置偏移。
  3. 路由过渡冲突:HeroDialogRoute添加的FadeScaleTransition会干扰Hero动画的原生过渡逻辑。

修复步骤

1. 统一全局Hero的RectTween

修改ScreenTwo的getContent方法,让两个Hero都使用自定义的CurvedRectArcTween:

Widget getContent() {
  return Stack(
    children: [
      Hero(
        tag: 'hero_card_${widget.index}',
        createRectTween: (Rect? begin, Rect? end) {
          return CurvedRectArcTween(begin: begin, end: end);
        },
        child: Container(
          width: 416,
          height: 416,
        ),
      ),
      Padding(
        padding: EdgeInsets.all(8),
        child: Hero(
          tag: 'hero_image_${widget.index}',
          createRectTween: (Rect? begin, Rect? end) {
            return CurvedRectArcTween(begin: begin, end: end);
          },
          child: // 后续布局调整
        ),
      )
    ],
  );
}

2. 对齐图片Hero的布局约束

调整第二页图片Hero的结构,和首页保持一致的padding与布局边界:

Hero(
  tag: 'hero_image_${widget.index}',
  createRectTween: (Rect? begin, Rect? end) {
    return CurvedRectArcTween(begin: begin, end: end);
  },
  child: Container(
    padding: EdgeInsets.all(8), // 和首页保持相同padding
    height: double.infinity,
    width: double.infinity,
    child: Card(
      child: ListView(
        shrinkWrap: true,
        children: [
          Image.network(
            'https://picsum.photos/200',
            fit: BoxFit.contain,
          ),
          Container(height: 200),
        ],
      ),
    ),
  ),
)

3. 移除路由的额外过渡效果

修改HeroDialogRoute的buildTransitions方法,让Hero动画主导页面过渡:

@override
Widget buildTransitions(BuildContext context, Animation<double> animation,
    Animation<double> secondaryAnimation, Widget child) {
  return child; // 移除FadeScaleTransition
}

内容的提问来源于stack exchange,提问作者Ali Yucel Akgul

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.07 07:50:18