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
相关产品推荐
相关产品推荐

