Skip to content

Flutter hero transitions#782

Open
johndpope wants to merge 17 commits intoHeroTransitions:developfrom
johndpope:flutter-hero-transitions
Open

Flutter hero transitions#782
johndpope wants to merge 17 commits intoHeroTransitions:developfrom
johndpope:flutter-hero-transitions

Conversation

@johndpope
Copy link

No description provided.

johndpope and others added 17 commits January 30, 2026 19:07
Complete port of the iOS Hero transition library to Flutter as
flutter_hero_transitions package. Includes independent transition
engine (not using Flutter's built-in Hero), 50+ modifiers, 14
built-in animation types, interactive gestures, cascade animations,
arc motion paths, spring physics, and plugin system.

- 35 library source files with zero external dependencies
- 15 example screens (5 modern + 9 legacy + 1 tablet)
- 8 test files with 516 passing tests
- 66 image assets ported from iOS xcassets

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Major animation quality improvements to match iOS Hero library:

PHYSICS:
- Add HeroSpringSimulation implementing real damped harmonic oscillator
- Support underdamped, overdamped, and critically damped spring regimes
- iOS defaults: mass=1.0, stiffness=230.0, dampingRatio=0.825
- Add spring presets: ios, bouncy, stiff

CURVES:
- Add iOS-accurate cubic-bezier curves (iosEaseInOut, iosEaseOut, iosEaseIn)
- Replace Material Design curves with iOS Core Animation equivalents
- iosEaseInOut: cubic-bezier(0.42, 0, 0.58, 1)

TIMING:
- Update duration calculation to match iOS (300ms-500ms range)
- Formula: 0.3 + (distance/400) * 0.2 seconds
- Previous: 208ms-700ms Material Design timing

API CHANGES:
- HeroSpringConfig: change 'damping' parameter to 'dampingRatio'
- HeroModifier.spring: update signature to use dampingRatio
- Update app_store_card_example to use new spring API

FIXES:
- Fix Stack layout constraints in main.dart (add StackFit.expand)
- Fix Positioned widget parent data chain in hero_overlay.dart
- Fix size change calculation in hero_default_animator.dart

FILES ADDED:
- lib/src/animator/hero_spring_simulation.dart

FILES MODIFIED:
- lib/src/extensions/curve_extensions.dart
- lib/src/types/hero_target_state.dart
- lib/src/animator/hero_default_animator.dart
- lib/src/animator/hero_animation_entry.dart
- lib/src/modifiers/hero_modifier.dart
- lib/flutter_hero_transitions.dart
- example/lib/main.dart
- example/lib/screens/app_store_card_example.dart
- lib/src/widgets/hero_overlay.dart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- HeroView hides itself (opacity 0) when its ID has an active animation
  entry, so only the overlay proxy is visible during transitions
- HeroPageRoute hides only the upper route (dest on push, source on pop)
  and sets opaque=false so the source route stays painted underneath
- MatchPreprocessor clears opacity/transform for matched views so they
  morph visibly instead of fading in from invisible
- HeroDefaultAnimator handles zero-dimension dest rects by computing a
  fallback from the source aspect ratio, and prefers the source widget
  as the snapshot for matched views (already loaded/rendered)
- Expanded debug plugin with custom slider, 3D view, and arc overlays

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
HeroPageRoute uses opaque: false so the source route stays painted
during hero transitions. However, when no transition is active, the
source route's content was visible behind destination screens that
lack explicit backgrounds (e.g., main menu text showing behind the
Match in Collection grid).

Wrap the child widget in a ColoredBox using the scaffold background
color (or transitionBackgroundColor if set) so the destination route
is visually opaque at rest while still allowing the source to be
painted during active transitions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Resolve 'auto' animation type to push-left/pull-right instead of
  returning early with no modifiers (root cause of scrubber showing
  no animation when dragged)
- Default debug plugin to OFF so transitions play normally
- Add Debug ON/OFF toggle pill on main menu for easy switching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The blur background (unmatched dest view with fade modifier) was being
painted ON TOP of the matched card in the overlay Stack, causing the card
to appear to fade out during the App Store Card transition. Reordered
entry creation: unmatched source -> unmatched dest -> matched, so matched
(morphing) views are always painted last and remain fully visible.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Wrap overlay snapshot widgets in DefaultTextStyle to prevent yellow
underlines that appear when Text widgets render outside Material ancestors.
Update card title to 32pt and subtitle to 17pt to match iOS exactly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace binary route visibility (ValueListenableBuilder + Opacity 0/1)
with Flutter's built-in route animations for smooth cross-dissolve.
This matches iOS Hero behavior where both view controllers cross-fade
while the overlay animates matched hero views on top.

Also clear overlay modifier on matched views in MatchPreprocessor to
prevent darkening from DefaultAnimationPreprocessor leaking through.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Move Positioned.fill() outside HeroView so it remains a direct child
of Stack. HeroView wraps its child in Opacity for hide/show during
transitions, which breaks Positioned's requirement to be a direct
Stack child.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Light blue background, "Adventure awaits in CANADA" header
- Horizontal scrolling 200x298 city cards with photos and shadows
- Full-screen detail pager with 50% dark overlay and city descriptions
- Hero IDs on text elements with fade+translate modifiers
- City data model updated with shortDescription matching iOS City.swift

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ero views

Previously, matched hero transitions only showed the source widget during
the entire animation, causing a visual "pop" when the destination widget
appeared at the end. This adds iOS-matching cross-fade behavior where both
source and destination snapshots are rendered simultaneously with opposing
opacity during the morph animation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
… size

Previously, matched hero view snapshots were re-laid out at each
intermediate animation size, causing distortion when source and
destination had different aspect ratios (e.g., 200x298 card vs
full-screen detail). Now each snapshot renders at its native captured
size and uses FittedBox(fit: BoxFit.cover) to scale into the current
animated rect, mimicking iOS CALayer bitmap snapshot behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
BoxFit.cover caused clipping at intermediate animation sizes when source
and destination had different aspect ratios. BoxFit.fill stretches the
snapshot to exactly fill the animated rect, matching how iOS stretches
CALayer bitmap snapshots during morph animations. The cross-fade between
source and destination masks any intermediate distortion.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…rendering

Source snapshots are bitmap-scaled via FittedBox.fill (like iOS CALayer
snapshots) since they have fixed sizes and are fading out. Destination
snapshots render directly at the animated rect size, allowing widgets
with StackFit.expand to adapt naturally without FittedBox distortion.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The AnimationController's value listener and status listener both fire
in the same animation tick. The value listener sets progress to 1.0 and
notifies the overlay, but the status listener immediately calls
_complete() which clears the overlay entries. Since Flutter only rebuilds
once per frame with the latest value, the final frame at progress=1.0
was never rendered — causing the animation to appear to "zoom past" its
target before snapping to the destination.

Fix: defer _complete() via addPostFrameCallback so the overlay paints
the final position before being cleared. Added a state guard to handle
edge cases where the deferred callback fires after the engine resets.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
HeroPageRoute now implements complete iOS-parity route transitions:
push, pull, slide, zoomSlide, cover, uncover, pageIn, pageOut, fade,
zoom, zoomOut, none, selectBy, and auto. Each type drives both the
appearing route's primary animation and the covered route's secondary
animation with matched iOS curves.

Fix a critical tap-blocking bug where after one push+pop cycle, all
taps on the source screen became non-functional. Root cause was
_coveredByAnimationType being set permanently by setCoveredByType()
but never cleared, causing stale transition wrappers to remain in
the widget tree. Fixed by returning plain opaqueChild when both
animations are settled and skipping secondary wrappers when
secondaryAnimation is dismissed.

Also fixes List-to-Grid example crash and removes debug logging.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@JoeMatt
Copy link
Collaborator

JoeMatt commented Feb 4, 2026

What even is this?

The .md's look AI generated.

Care to provide any details on what I'm even looking at here?

(obviously something with flutter, but I don't use flutter nor do I even know where to start with a review this large with an empty description)

@johndpope
Copy link
Author

johndpope commented Feb 4, 2026

Simulator.Screen.Recording.-.iPhone.16.Pro.-.2026-02-01.at.11.57.15.mp4
Simulator.Screen.Recording.-.iPhone.16.Pro.-.2026-02-05.at.09.15.58.mp4

I got claude 4.5 to read all code - and convert to flutter - in the first pass - it got all the fundamental animations wrong

  • but i persevered and got it to review the debug slider and get parity - that helped it get over the line. all the code / methods is based off this code - so it should be familiar -

the official flutter hero package is a botched code - this actually works using the same fundamental id tag cross fade between pages -
this code also works cross platform linux / android / ios / web.

i dont expect this to be merged to main - and you can dismiss - but consider merging it into an orphaned fork - and then others can benefit. (there's also a rough edge here and there that needs more work / help)

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a Flutter port of the iOS Hero transitions library, aiming for 100% example parity with the original iOS implementation. The library provides declarative custom view transitions with support for advanced features like spring animations, cascade effects, and interactive gestures.

Changes:

  • Complete Flutter hero transitions library implementation with core widgets, transition engine, animators, and preprocessors
  • Comprehensive example application demonstrating various transition types
  • iOS project updates to support legacy examples

Reviewed changes

Copilot reviewed 113 out of 203 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
flutter_hero_transitions/pubspec.yaml Package configuration with dependencies
flutter_hero_transitions/lib/ Core library implementation (widgets, transitions, types, extensions)
flutter_hero_transitions/example/ Example app demonstrating library features
Podfile iOS deployment target updated to 15.0
LegacyExamples/ UIColor extension replacing ChameleonFramework dependency
Files not reviewed (2)
  • flutter_hero_transitions/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: Language not supported
  • flutter_hero_transitions/example/ios/Runner.xcworkspace/contents.xcworkspacedata: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

errorBuilder: (_, __, ___) => Container(
width: isGrid ? double.infinity : 80,
height: isGrid ? double.infinity : 80,
color: color.withValues(alpha: 0.7),
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Color.withValues method with named parameter 'alpha' is not available in Flutter versions before 3.27. Since the pubspec specifies 'flutter: ">=3.10.0"', this will fail on older Flutter versions. Use Color.withOpacity() instead.

Copilot uses AI. Check for mistakes.
// --- Widgets ---
export 'src/widgets/hero_view.dart';
export 'src/widgets/hero_overlay.dart';
export 'src/widgets/hero_overlay_v2.dart'; // Optimized overlay
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The export refers to 'hero_overlay_v2.dart' but the actual file in this PR is named 'hero_overlay_optimized.dart'. This will cause a compilation error.

Copilot uses AI. Check for mistakes.
Comment on lines +29 to +45
export 'src/transition/hero_page_route.dart';
export 'src/transition/hero_navigator_observer.dart';
export 'src/transition/hero_context.dart';
export 'src/transition/hero_registry.dart';

// --- Animator ---
export 'src/animator/hero_default_animator.dart';
export 'src/animator/hero_animation_entry.dart';
export 'src/animator/arc_tween.dart';
export 'src/animator/hero_progress_runner.dart';
export 'src/animator/hero_spring_simulation.dart';
export 'src/animator/hero_spring_v2.dart'; // Improved spring with settling detection
export 'src/animator/hero_display_link.dart'; // CADisplayLink-style timing

// --- Plugins ---
export 'src/plugins/hero_plugin.dart';
export 'src/plugins/hero_debug_plugin.dart';
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exports reference files that don't exist in this PR: 'hero_default_animator.dart', 'hero_spring_v2.dart', 'hero_debug_plugin.dart', and 'hero_page_route.dart'. These missing exports will cause compilation failures.

Copilot uses AI. Check for mistakes.
// (3D perspective, arc visualization, scrub slider).
// When HeroDebugPlugin.isEnabled is false, it behaves identically
// to a plain HeroOverlayV2.
const HeroDebugWrapper(),
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code references 'HeroDebugWrapper' which is imported but the corresponding file doesn't exist in this PR. This will cause a compilation error when trying to run the example app.

Copilot uses AI. Check for mistakes.
void main() {
// Debug plugin is off by default. Toggle it from the main menu
// (bug icon) to inspect transitions with the scrub slider.
HeroDebugPlugin.isEnabled = false;
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code references 'HeroDebugPlugin.isEnabled' which is imported from a file that doesn't exist in this PR. This will cause a compilation error.

Copilot uses AI. Check for mistakes.
],
);
},
home: const MainMenuScreen(),
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code references 'MainMenuScreen' which is imported from 'screens/main_menu_screen.dart', but this file doesn't exist in this PR. This will cause a compilation error when building the example app.

Copilot uses AI. Check for mistakes.
rrect.shift(entry.currentShadowOffset),
Paint()
..color = entry.currentShadowColor
.withValues(alpha: entry.currentShadowOpacity * entry.currentOpacity)
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Color.withValues method with named parameter 'alpha' is not available in the current stable Flutter SDK. This method was introduced in Flutter 3.27. Since the pubspec specifies 'flutter: ">=3.10.0"', this code will fail on Flutter versions before 3.27. Use Color.withOpacity() instead for broader compatibility.

Copilot uses AI. Check for mistakes.
Comment on lines +119 to +175
if (t < 1.0)
Opacity(
opacity: (1.0 - t).clamp(0.0, 1.0),
child: _buildScaledSnapshot(
entry.snapshotWidget, entry.sourceSnapshotSize, w, h,
),
),
],
);
} else {
// Single snapshot (unmatched views) — bitmap-scale to fill animated rect
contentWidget = _buildScaledSnapshot(
entry.snapshotWidget, entry.sourceSnapshotSize, w, h,
);
}

return Positioned(
left: entry.currentRect.left,
top: entry.currentRect.top,
width: w,
height: h,
child: Transform(
transform: entry.currentTransform,
alignment: Alignment.center,
child: Opacity(
opacity: entry.currentOpacity.clamp(0.0, 1.0),
child: Stack(
clipBehavior: Clip.none,
children: [
// Main content
SizedBox(
width: w,
height: h,
child: ClipRRect(
borderRadius: borderRadius,
child: DecoratedBox(
decoration: BoxDecoration(
color: entry.currentBackgroundColor,
borderRadius: borderRadius,
boxShadow: entry.currentBoxShadow != null
? [entry.currentBoxShadow!]
: null,
),
child: contentWidget,
),
),
),
// Overlay
if (entry.overlayOpacity != null &&
entry.overlayOpacity! > 0 &&
entry.overlayColor != null)
Positioned.fill(
child: ClipRRect(
borderRadius: borderRadius,
child: ColoredBox(
color: entry.overlayColor!
.withValues(alpha: entry.overlayOpacity!.clamp(0.0, 1.0)),
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Color.withValues method with named parameter 'alpha' is not available in Flutter versions before 3.27. Since the pubspec specifies 'flutter: ">=3.10.0"', this will cause compilation errors. Use Color.withOpacity() instead for compatibility with Flutter 3.10+.

Copilot uses AI. Check for mistakes.
platform :ios, '10.0'
platform :ios, '15.0'
use_frameworks!
pod 'CollectionKit', :inhibit_warnings => true
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pod 'CollectionKit', :inhibit_warnings => true dependency is declared without a version constraint, so CocoaPods will always fetch the latest available release of this third-party library at build time. This mutable reference creates a supply-chain risk: if the CollectionKit pod or its distribution channel is compromised in the future, your app could silently pick up and execute malicious code during build or at runtime. Pin this pod to a specific, vetted version (or commit / vendored copy) to ensure builds only consume a known-good artifact.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants