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

Flutter Hero动画测试:无法跟踪图片动画过程位置的问题

Flutter Hero动画测试:无法跟踪动画过程中图片位置的问题

问题背景

开发Flutter的Hero动画时,编写测试用例验证点击消息列表页的图片后,能否导航至灯箱页且图片实现平滑动画。但测试过程中无法跟踪Hero动画过程中的图片位置,日志显示每个时间帧中获取的当前位置均为动画完成后的最终位置(Offset(0.0, 0.0)),与初始位置(Offset(37.0, 385.0))无变化。

相关代码

初始化代码

late PerAccountStore store;
late FakeApiConnection connection;

final channel = eg.stream();
final message = eg.streamMessage(stream: channel,
  contentMarkdown: ContentExample.imageSingle.html, topic: 'test topic');

Future<void> setupMessageListPage(WidgetTester tester) async {
  addTearDown(testBinding.reset);
  final subscription = eg.subscription(channel);
  await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(
    streams: [channel], subscriptions: [subscription]));
  store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
  connection = store.connection as FakeApiConnection;
  await store.addUser(eg.selfUser);

  connection.prepare(json:
    eg.newestGetMessagesResult(foundOldest: true, messages: [message]).toJson());

  await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
    child: MessageListPage(initNarrow: const CombinedFeedNarrow())));

  await tester.pumpAndSettle();
}

测试代码

testWidgets('hero animation occurs when opening lightbox from message list', (tester) async {
  prepareBoringImageHttpClient();
  await setupMessageListPage(tester);

  // Find the initial message image
  final messageContentFinder = find.byWidgetPredicate((widget) =>
    widget is MessageContent && widget.message.id == message.id
  );
  expect(messageContentFinder, findsOneWidget);

  final imageFinder = find.descendant(
    of: messageContentFinder,
    matching: find.byType(RealmContentNetworkImage)
  );
  expect(imageFinder, findsOneWidget);

  // Find the Hero and get its initial position via RenderObject
  final heroFinder = find.ancestor(
    of: imageFinder,
    matching: find.byType(Hero)
  );
  expect(heroFinder, findsOneWidget);
  final initialElement = tester.element(heroFinder);
  final initialRenderBox = initialElement.renderObject as RenderBox;
  final initialPosition = initialRenderBox.localToGlobal(Offset.zero);

  // Get the Hero's tag - this will be consistent throughout the animation
  final heroTag = tester.widget<Hero>(heroFinder).tag;

  // Start the animation
  await tester.tap(imageFinder);
  await tester.pump(); // Build the new page

  // Wait a bit to let the animation start
  await tester.pump(const Duration(milliseconds: 50));

  // Find the destination Hero Element by looking in the lightbox route
  final heroElement = tester.elementList(find.byType(Hero))
    .where((element) => (element.widget as Hero).tag == heroTag)
    .where((element) =>
      element.findAncestorWidgetOfExactType<InteractiveViewer>() != null)
    .single;
  final heroRenderBox = heroElement.renderObject as RenderBox;

  // Pump incremental frames during the 300ms animation
  final animationDuration = const Duration(milliseconds: 250); // Remaining time
  final steps = 5; // Check 5 points during animation

  for (int i = 1; i <= steps; i++) {
    await tester.pump(animationDuration ~/ steps);

    // Look up the hero by its element identity
    final currentElement = tester.element(
      find.byElementPredicate((element) => element == heroElement));
    expect(currentElement, same(heroElement)); // Element should stay the same

    // Get current position via RenderObject
    final currentPosition = heroRenderBox.localToGlobal(Offset.zero);
    print('Step $i position: $currentPosition (initial: $initialPosition)');

    // By this point in the animation, the position should definitely be changing
    check(currentPosition).anyOf([
      (it) => it.dy.not((it) => it.equals(initialPosition.dy)),
      (it) => it.dx.not((it) => it.equals(initialPosition.dx))
    ]);
  }

  await tester.pumpAndSettle();
  debugNetworkImageHttpClientProvider = null;
});

日志输出

LightboxHero hero animation occurs when opening lightbox from message list                                                                    
Step 1 position: Offset(0.0, 0.0) (initial: Offset(37.0, 385.0))
Step 2 position: Offset(0.0, 0.0) (initial: Offset(37.0, 385.0))
Step 3 position: Offset(0.0, 0.0) (initial: Offset(37.0, 385.0))
Step 4 position: Offset(0.0, 0.0) (initial: Offset(37.0, 385.0))
Step 5 position: Offset(0.0, 0.0) (initial: Offset(37.0, 385.0))

问题原因

你当前获取的是目标页面的Hero元素,而非Flutter在Hero动画过程中创建的临时飞行Hero Widget:

  • Hero动画的过渡逻辑由Flutter框架内部处理,动画期间会在Overlay层创建一个临时的Hero实例(称为Flight Widget),负责执行从源位置到目标位置的平滑过渡。
  • 源页面的Hero会被隐藏,目标页面的Hero从一开始就处于最终位置(只是被飞行Hero覆盖),因此你获取的heroRenderBox位置始终是最终位置,不会随动画变化。

解决方法

需要在动画的每一帧中,找到Overlay层中的临时飞行Hero,而非目标页面的Hero。每次pump后重新获取该Hero的RenderBox,以获取当前动画帧的位置。

修改后的测试代码关键部分

// ... 保留之前的初始化和点击逻辑 ...

// Pump incremental frames during the animation
final totalAnimationDuration = const Duration(milliseconds: 300);
final steps = 5;
final stepDuration = totalAnimationDuration ~/ steps;

for (int i = 1; i <= steps; i++) {
  await tester.pump(stepDuration);

  // 查找Overlay层中与目标tag匹配的临时飞行Hero
  final flightHeroElements = tester.elementList(find.byType(Hero))
      .where((element) {
        final hero = element.widget as Hero;
        // 飞行Hero的tag会关联原tag,这里通过字符串匹配(或根据实际tag类型调整)
        final tagMatches = hero.tag.toString().contains(heroTag.toString());
        // 确认Hero位于Overlay层
        final isInOverlay = element.findAncestorWidgetOfExactType<Overlay>() != null;
        return tagMatches && isInOverlay;
      })
      .toList();

  // 确保找到唯一的飞行Hero
  expect(flightHeroElements, hasLength(1));
  
  // 获取当前帧的位置
  final flightRenderBox = flightHeroElements.first.renderObject as RenderBox;
  final currentPosition = flightRenderBox.localToGlobal(Offset.zero);
  
  print('Step $i position: $currentPosition (initial: $initialPosition)');
  
  // 验证位置已发生变化
  expect(currentPosition, isNot(equals(initialPosition)));
}

await tester.pumpAndSettle();
debugNetworkImageHttpClientProvider = null;

补充说明

  • 如果你的heroTag是自定义类型,可调整tag匹配逻辑,无需依赖字符串匹配。
  • 确保测试环境中动画未被禁用:Flutter测试默认会执行动画,若有全局禁用动画的配置需移除。
  • 若仍无法找到飞行Hero,可通过debugDumpApp()在测试中打印当前Widget树,确认飞行Hero的结构和tag特征。

内容的提问来源于stack exchange,提问作者Lakshya Goel

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.13 19:47:33