diff --git a/CHANGELOG.md b/CHANGELOG.md index 149a6ce..b495128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Add opt-in `MorphContourTransition.linear` for gradual, symmetric appearance + and disappearance of unmatched contours. The default `legacy` mode preserves + existing trajectories. Widgets, precaching and cache keys share the setting. - Precompute contour metrics and pair costs during assignment; skip assignment work when each role has exactly one contour. - Add `precacheMorph`, including optional reverse-pair preparation using the diff --git a/README.md b/README.md index 4f5a915..50ace26 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,26 @@ target to settle on its final vector frame. `MorphSprings.smooth` for a critically damped transition, or pass Flutter's `SpringDescription` directly for custom physics. +The original morph trajectory remains the default. To make contours that +appear or disappear change size gradually, opt in on either widget: + +```dart +AnimatedMorphIcon( + icon: open ? Icons.favorite : Icons.favorite_border, + contourTransition: MorphContourTransition.linear, +) +``` + +`MorphContourTransition.legacy` preserves existing visuals. `linear` affects +only contours without a matching endpoint (including holes); it does not +change matched contours' rotation or correspondence. Their size changes +linearly with progress, while the spring still controls progress over time. +Unmatched contours clamp overshoot so a vanished contour cannot reappear. +Changing the mode during an implicit transition starts from the visible shape. +This is independent of `Curves.linear` in the example playground: that curve +controls progress over time, while `contourTransition` controls contour shape +at a given progress value. + The widgets accept the same visual font controls as Flutter's `Icon`: `fill`, `weight`, `grade`, `opticalSize`, `shadows`, `blendMode`, and `fontWeight`. Values not supplied directly are inherited from `IconTheme`. @@ -139,6 +159,9 @@ necessary again. The returned future completes with an error if preparation fails; catch it if unavailable fonts are expected. Equal icons require no loading. +Pass the same `contourTransition` to `precacheMorph` as to the widget. Plans +with different contour-transition modes occupy separate cache entries. + Both widgets accept `onFallback` for diagnostics in debug and release: ```dart diff --git a/example/lib/main.dart b/example/lib/main.dart index 2e7f068..13226c1 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -453,6 +453,7 @@ class _MorphPlaygroundPageState extends State IconData _heroTo = Icons.close; var _heroShowingTo = false; var _heroMotion = _HeroMotion.spring; + var _contourTransition = MorphContourTransition.legacy; var _heroSize = 184.0; var _heroProgress = 0.0; Color? _heroColor; @@ -721,6 +722,7 @@ class _MorphPlaygroundPageState extends State ), icon: target, spring: _effectiveHeroSpring, + contourTransition: _contourTransition, size: _heroSize, color: _heroColor, semanticLabel: 'Primary morph preview', @@ -732,6 +734,7 @@ class _MorphPlaygroundPageState extends State from: pair.from, to: pair.to, progress: AlwaysStoppedAnimation(_heroProgress), + contourTransition: _contourTransition, size: _heroSize, color: _heroColor, semanticLabel: 'Primary morph preview', @@ -1205,6 +1208,26 @@ class _MorphPlaygroundPageState extends State ), const SizedBox(height: 10), _swatchRow(), + const SizedBox(height: 22), + _sectionLabel('CONTOUR APPEARANCE'), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final mode in MorphContourTransition.values) + ChoiceChip( + key: ValueKey('contour-${mode.name}'), + label: Text( + mode == MorphContourTransition.legacy + ? 'Original' + : 'Linear size', + ), + selected: _contourTransition == mode, + showCheckmark: false, + onSelected: (_) => setState(() => _contourTransition = mode), + ), + ], + ), const SizedBox(height: 24), if (_heroMotion == _HeroMotion.spring) ...[ _sectionLabel('SPRING PHYSICS'), @@ -1287,6 +1310,7 @@ class _MorphPlaygroundPageState extends State ), ), const SizedBox(height: 4), + _sectionLabel('TIMING CURVE'), Wrap( spacing: 8, runSpacing: 8, @@ -1318,6 +1342,7 @@ class _MorphPlaygroundPageState extends State return 'AnimatedMorphIcon(\n' ' icon: ${_iconCode(_heroShowingTo ? _heroTo : _heroFrom)},\n' ' size: ${_heroSize.round()},\n' + '${_contourTransition == MorphContourTransition.linear ? ' contourTransition: MorphContourTransition.linear,\n' : ''}' ' spring: SpringDescription(\n' ' mass: ${spring.mass.toStringAsFixed(2)},\n' ' stiffness: ${spring.stiffness.toStringAsFixed(1)},\n' @@ -1337,6 +1362,7 @@ class _MorphPlaygroundPageState extends State ' from: ${_iconCode(_heroFrom)},\n' ' to: ${_iconCode(_heroTo)},\n' ' size: ${_heroSize.round()},\n' + '${_contourTransition == MorphContourTransition.linear ? ' contourTransition: MorphContourTransition.linear,\n' : ''}' ' progress: CurvedAnimation(\n' ' parent: controller, // ${_manualDuration.inMilliseconds} ms\n' ' curve: Curves.$curve,\n' diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index c51dc05..7e98ca2 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -492,6 +492,64 @@ void main() { expect(preview.to, Icons.close); expect(preview.progress.value, 0.625); }); + + testWidgets('linear timing and contour appearance are independent', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1200, 1000)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + const MorphnextExampleApp( + initialLocation: '/playground', + loadLiveMetadata: false, + useGoogleFonts: false, + ), + ); + + final preview = find.byWidgetPredicate( + (widget) => + widget is AnimatedMorphIcon && + widget.semanticLabel == 'Primary morph preview', + ); + expect( + tester.widget(preview).contourTransition, + MorphContourTransition.legacy, + ); + final contourMode = find.byKey(const ValueKey('contour-linear')); + await _reveal(tester, contourMode); + await tester.tap(contourMode); + await tester.pump(); + expect( + tester.widget(preview).contourTransition, + MorphContourTransition.linear, + ); + + final manual = find.text('Manual'); + await _reveal(tester, manual); + await tester.tap(manual); + await tester.pump(); + final linearCurve = find.byKey(const ValueKey('curve-linear')); + await _reveal(tester, linearCurve); + await tester.tap(linearCurve); + await tester.pump(); + + final manualPreview = find.byWidgetPredicate( + (widget) => + widget is MorphIcon && + widget.semanticLabel == 'Primary morph preview', + ); + expect( + tester.widget(manualPreview).contourTransition, + MorphContourTransition.linear, + ); + final code = tester + .widget( + find.byKey(const ValueKey('playground-code')), + ) + .data!; + expect(code, contains('contourTransition: MorphContourTransition.linear')); + expect(code, contains('curve: Curves.linear')); + }); } Future _reveal(WidgetTester tester, Finder finder) async { diff --git a/lib/morphnext.dart b/lib/morphnext.dart index d8a96b9..b56f496 100644 --- a/lib/morphnext.dart +++ b/lib/morphnext.dart @@ -3,6 +3,7 @@ library; export 'package:flutter/physics.dart' show SpringDescription; export 'src/cache/morph_cache.dart' show MorphCache; +export 'src/morph_contour_transition.dart' show MorphContourTransition; export 'src/morph_fallback_details.dart' show MorphFallbackDetails; export 'src/morph_spring.dart' show MorphSprings; export 'src/precache_morph.dart' show precacheMorph; diff --git a/lib/src/cache/morph_cache.dart b/lib/src/cache/morph_cache.dart index bc6c406..0cf819f 100644 --- a/lib/src/cache/morph_cache.dart +++ b/lib/src/cache/morph_cache.dart @@ -5,7 +5,7 @@ import 'sized_lru_cache.dart'; /// /// The cache is shared by all morphnext widgets in the current Dart isolate. /// A completed morph is identified by its ordered icon pair, text direction, -/// font parameters, and asset bundle. Morphs built from an interrupted +/// font parameters, contour-transition mode, and asset bundle. Morphs built from an interrupted /// intermediate shape are one-off values and are not retained. abstract final class MorphCache { /// The default maximum number of retained completed morphs. diff --git a/lib/src/geometry/morph_plan.dart b/lib/src/geometry/morph_plan.dart index 440911f..c12566c 100644 --- a/lib/src/geometry/morph_plan.dart +++ b/lib/src/geometry/morph_plan.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'dart:typed_data'; +import '../morph_contour_transition.dart'; import 'resample.dart'; import 'shape.dart'; @@ -79,8 +80,10 @@ final class MorphPlanItem { /// A reusable, allocation-free interpolation plan between two filled shapes. final class MorphPlan { - MorphPlan(List items) - : items = List.unmodifiable(items) { + MorphPlan( + List items, { + this.contourTransition = MorphContourTransition.legacy, + }) : items = List.unmodifiable(items) { if (items.isEmpty) { throw const FormatException('A morph plan needs at least one contour'); } @@ -89,6 +92,9 @@ final class MorphPlan { /// Aligned contour pairs. final List items; + /// The interpolation policy for appearing and disappearing contours. + final MorphContourTransition contourTransition; + /// Allocates one correctly sized output buffer per contour pair. List allocateOutput() => [ for (final item in items) Float64List(item.source.length), @@ -109,7 +115,17 @@ final class MorphPlan { if (points.length != item.source.length) { throw ArgumentError.value(points.length, 'output[$index].length'); } - _interpolateItem(item, boundedT, points); + if (contourTransition == MorphContourTransition.linear && + (item.sourceCollapsed || item.targetCollapsed)) { + final lifecycleT = boundedT.clamp(0.0, 1.0); + for (var point = 0; point < points.length; point++) { + points[point] = + item.source[point] + + (item.orientedTarget[point] - item.source[point]) * lifecycleT; + } + } else { + _interpolateItem(item, boundedT, points); + } } } @@ -128,7 +144,11 @@ final class MorphPlan { } /// Builds a topology-aware morph plan between [source] and [target]. -MorphPlan buildMorphPlan(MorphShape source, MorphShape target) { +MorphPlan buildMorphPlan( + MorphShape source, + MorphShape target, { + MorphContourTransition contourTransition = MorphContourTransition.legacy, +}) { _validateShapes(source, target); final pairs = _matchContours(source, target); final items = []; @@ -171,7 +191,7 @@ MorphPlan buildMorphPlan(MorphShape source, MorphShape target) { items.every((item) => !item.sourceCollapsed && !item.targetCollapsed)) { _applyGlobalAlignment(items); } - return MorphPlan(items); + return MorphPlan(items, contourTransition: contourTransition); } void _validateShapes(MorphShape source, MorphShape target) { diff --git a/lib/src/morph_contour_transition.dart b/lib/src/morph_contour_transition.dart new file mode 100644 index 0000000..ee3646c --- /dev/null +++ b/lib/src/morph_contour_transition.dart @@ -0,0 +1,16 @@ +/// How contours without a corresponding endpoint appear or disappear. +/// +/// Matching contours retain the usual rotation/scale morph in either mode. +enum MorphContourTransition { + /// Preserves the original polar interpolation, including rapid shrinkage + /// when a contour disappears. This is the compatibility default. + legacy, + + /// Changes unmatched contours' size linearly with morph progress. + /// + /// Appearance and disappearance use symmetric interpolation. Progress for + /// these contours is clamped to 0–1 so spring overshoot cannot bring a + /// disappeared contour back or reflect a newly appearing contour. + /// The spring still controls progress over time; this is not a linear timer. + linear, +} diff --git a/lib/src/morph_repository.dart b/lib/src/morph_repository.dart index 5f664f6..b04a353 100644 --- a/lib/src/morph_repository.dart +++ b/lib/src/morph_repository.dart @@ -9,6 +9,7 @@ import 'font/font_selection.dart'; import 'geometry/morph_plan.dart'; import 'geometry/resample.dart'; import 'geometry/shape.dart'; +import 'morph_contour_transition.dart'; const _maximumCachedShapes = 256; const _maximumRetainedShapeBytes = 8 << 20; @@ -72,15 +73,27 @@ final class MorphRepository { IconData to, TextDirection direction, [ MorphFontSelection fontSelection = defaultMorphFontSelection, + MorphContourTransition contourTransition = MorphContourTransition.legacy, ]) { _synchronizeCacheGeneration(); - final key = _MorphCacheKey(_bundle, from, to, direction, fontSelection); + final key = _MorphCacheKey( + _bundle, + from, + to, + direction, + fontSelection, + contourTransition, + ); return MorphCacheStore.instance.load(key, () async { final shapes = await Future.wait(>[ shapeFor(from, direction, fontSelection), shapeFor(to, direction, fontSelection), ]); - return buildMorphPlan(shapes[0], shapes[1]); + return buildMorphPlan( + shapes[0], + shapes[1], + contourTransition: contourTransition, + ); }, _planBytes); } @@ -89,9 +102,14 @@ final class MorphRepository { IconData to, TextDirection direction, [ MorphFontSelection fontSelection = defaultMorphFontSelection, + MorphContourTransition contourTransition = MorphContourTransition.legacy, ]) async { _synchronizeCacheGeneration(); - return buildMorphPlan(source, await shapeFor(to, direction, fontSelection)); + return buildMorphPlan( + source, + await shapeFor(to, direction, fontSelection), + contourTransition: contourTransition, + ); } void _synchronizeCacheGeneration() { @@ -191,6 +209,7 @@ final class _MorphCacheKey { this.to, this.direction, this.fontSelection, + this.contourTransition, ); final AssetBundle bundle; @@ -198,6 +217,7 @@ final class _MorphCacheKey { final IconData to; final TextDirection direction; final MorphFontSelection fontSelection; + final MorphContourTransition contourTransition; @override bool operator ==(Object other) => @@ -206,9 +226,16 @@ final class _MorphCacheKey { from == other.from && to == other.to && direction == other.direction && - fontSelection == other.fontSelection; + fontSelection == other.fontSelection && + contourTransition == other.contourTransition; @override - int get hashCode => - Object.hash(identityHashCode(bundle), from, to, direction, fontSelection); + int get hashCode => Object.hash( + identityHashCode(bundle), + from, + to, + direction, + fontSelection, + contourTransition, + ); } diff --git a/lib/src/precache_morph.dart b/lib/src/precache_morph.dart index a44b2d1..96b4940 100644 --- a/lib/src/precache_morph.dart +++ b/lib/src/precache_morph.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import 'cache/morph_cache.dart'; import 'morph_repository.dart'; +import 'morph_contour_transition.dart'; import 'widgets/morph_icon_frame.dart'; /// Prepares a bundled-font icon pair in the widgets' existing bounded cache. @@ -10,6 +11,8 @@ import 'widgets/morph_icon_frame.dart'; /// bundle, direction and font axes are captured synchronously from [context]. /// Use the same overrides as the widget that will display the transition. /// Set [bidirectional] to also prepare the reverse transition. +/// Set [contourTransition] to the same value used by the consuming widget; +/// different modes use separate completed-plan cache entries. /// /// Completes with an error if the pair cannot be prepared; callers can catch /// that error while widgets continue to use their native-icon fallback. @@ -22,6 +25,7 @@ Future precacheMorph( required IconData from, required IconData to, bool bidirectional = false, + MorphContourTransition contourTransition = MorphContourTransition.legacy, TextDirection? textDirection, double? fill, double? weight, @@ -49,8 +53,20 @@ Future precacheMorph( ); final repository = MorphRepository.forBundle(DefaultAssetBundle.of(context)); return Future.wait(>[ - repository.planFor(from, to, style.textDirection, style.fontSelection), + repository.planFor( + from, + to, + style.textDirection, + style.fontSelection, + contourTransition, + ), if (bidirectional) - repository.planFor(to, from, style.textDirection, style.fontSelection), + repository.planFor( + to, + from, + style.textDirection, + style.fontSelection, + contourTransition, + ), ]).then((_) {}); } diff --git a/lib/src/widgets/animated_morph_icon.dart b/lib/src/widgets/animated_morph_icon.dart index 1f446f8..295bf07 100644 --- a/lib/src/widgets/animated_morph_icon.dart +++ b/lib/src/widgets/animated_morph_icon.dart @@ -7,6 +7,7 @@ import '../geometry/morph_plan.dart'; import '../font/font_selection.dart'; import '../morph_repository.dart'; import '../morph_fallback_details.dart'; +import '../morph_contour_transition.dart'; import '../morph_spring.dart'; import '../rendering/morph_painter.dart'; import 'morph_diagnostics.dart'; @@ -31,6 +32,7 @@ final class AnimatedMorphIcon extends StatefulWidget { this.spring = MorphSprings.snappy, this.onEnd, this.onFallback, + this.contourTransition = MorphContourTransition.legacy, this.semanticLabel, this.textDirection, this.applyTextScaling, @@ -84,6 +86,11 @@ final class AnimatedMorphIcon extends StatefulWidget { /// still settles normally and calls [onEnd]. final ValueChanged? onFallback; + /// How unmatched contours appear or disappear. Defaults to the original + /// interpolation. Changing this during a transition replans from the visible + /// shape, preserving position continuity. + final MorphContourTransition contourTransition; + /// The semantic label announced for this icon. /// /// When null, the icon is excluded from the semantics tree. @@ -180,6 +187,9 @@ final class _AnimatedMorphIconState extends State _settleImmediately(widget.icon); } else if (directionChanged || fontSelectionChanged) { _restartForContext(); + } else if (oldWidget.contourTransition != widget.contourTransition && + _transitioning) { + _beginTransition(widget.icon); } } @@ -248,6 +258,7 @@ final class _AnimatedMorphIconState extends State target, direction, _fontSelection, + widget.contourTransition, ); } else { final snapshot = currentPlan.snapshot( @@ -261,6 +272,7 @@ final class _AnimatedMorphIconState extends State target, direction, _fontSelection, + widget.contourTransition, ); } _transitioning = true; diff --git a/lib/src/widgets/morph_icon.dart b/lib/src/widgets/morph_icon.dart index 97a3c86..31a3d83 100644 --- a/lib/src/widgets/morph_icon.dart +++ b/lib/src/widgets/morph_icon.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import '../font/font_selection.dart'; import '../morph_fallback_details.dart'; +import '../morph_contour_transition.dart'; import '../geometry/morph_plan.dart'; import '../morph_repository.dart'; import '../rendering/morph_painter.dart'; @@ -34,6 +35,7 @@ final class MorphIcon extends StatefulWidget { this.blendMode, this.fontWeight, this.onFallback, + this.contourTransition = MorphContourTransition.legacy, super.key, }) : assert(fill == null || 0 <= fill && fill <= 1), assert(weight == null || weight > 0), @@ -101,6 +103,11 @@ final class MorphIcon extends StatefulWidget { /// usable regardless of whether a callback is supplied. final ValueChanged? onFallback; + /// How unmatched contours appear or disappear. Defaults to the original + /// interpolation. Set [MorphContourTransition.linear] to opt in to gradual, + /// symmetric size changes. Matching contours are unaffected. + final MorphContourTransition contourTransition; + @override State createState() => _MorphIconState(); } @@ -137,6 +144,7 @@ final class _MorphIconState extends State { if (oldWidget.from != widget.from || oldWidget.to != widget.to || oldWidget.textDirection != widget.textDirection || + oldWidget.contourTransition != widget.contourTransition || fontSelection != _fontSelection) { _direction = widget.textDirection ?? Directionality.of(context); _fontSelection = fontSelection; @@ -179,7 +187,7 @@ final class _MorphIconState extends State { try { final plan = await MorphRepository.forBundle( bundle, - ).planFor(from, to, direction, fontSelection); + ).planFor(from, to, direction, fontSelection, widget.contourTransition); if (!mounted || requestId != _requestId) return; setState(() => _plan = plan); } catch (error, stack) { diff --git a/test/contour_transition_test.dart b/test/contour_transition_test.dart new file mode 100644 index 0000000..2214c19 --- /dev/null +++ b/test/contour_transition_test.dart @@ -0,0 +1,217 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:morphnext/morphnext.dart'; +import 'package:morphnext/src/font/font_selection.dart'; +import 'package:morphnext/src/morph_repository.dart'; +import 'package:morphnext/src/rendering/morph_painter.dart'; + +import 'animated_morph_icon_test.dart' + show pumpUntilTransitionStart, currentPainter, expectPointListsClose; +import 'morph_icon_test.dart' show testHost; +import 'support/test_icons.dart'; +import 'support/test_asset_bundle.dart'; +import 'support/test_font_builder.dart'; + +void main() { + setUp(MorphCache.reset); + tearDown(MorphCache.reset); + + testWidgets('pending legacy result cannot replace the newly selected mode', ( + tester, + ) async { + final bundle = DelayedTestAssetBundle.fonts( + manifest: { + testFontFamily: ['icons.ttf'], + }, + assets: {'icons.ttf': TestFontBuilder.trueType()}, + )..delay('icons.ttf'); + await tester.pumpWidget( + testHost(bundle, const AnimatedMorphIcon(icon: testSquareWithHoleIcon)), + ); + await tester.pumpWidget( + testHost(bundle, const AnimatedMorphIcon(icon: testCompositeIcon)), + ); + await tester.pump(); + await tester.pumpWidget( + testHost( + bundle, + const AnimatedMorphIcon( + icon: testCompositeIcon, + contourTransition: MorphContourTransition.linear, + ), + ), + ); + bundle.release('icons.ttf'); + await pumpUntilTransitionStart(tester); + expect( + currentPainter(tester).plan.contourTransition, + MorphContourTransition.linear, + ); + await tester.pumpAndSettle(); + final settled = currentPainter(tester).plan; + await tester.pumpWidget( + testHost(bundle, const AnimatedMorphIcon(icon: testCompositeIcon)), + ); + await tester.pumpAndSettle(); + // An idle policy change only affects the next transition. It does not + // replace the settled vector frame with a differently rasterized Icon. + expect(identical(currentPainter(tester).plan, settled), isTrue); + }); + + test('cache separates modes and reuses each mode', () async { + final repository = MorphRepository.forBundle(fixtureBundle()); + final legacy = await repository.planFor( + testSquareWithHoleIcon, + testCompositeIcon, + TextDirection.ltr, + ); + final linear = await repository.planFor( + testSquareWithHoleIcon, + testCompositeIcon, + TextDirection.ltr, + defaultMorphFontSelection, + MorphContourTransition.linear, + ); + expect(identical(legacy, linear), isFalse); + expect(legacy.contourTransition, MorphContourTransition.legacy); + expect(linear.contourTransition, MorphContourTransition.linear); + expect(MorphCache.currentMorphs, 2); + expect( + identical( + linear, + await repository.planFor( + testSquareWithHoleIcon, + testCompositeIcon, + TextDirection.ltr, + defaultMorphFontSelection, + MorphContourTransition.linear, + ), + ), + isTrue, + ); + }); + + testWidgets('precache and controlled widget use the selected mode', ( + tester, + ) async { + final bundle = fixtureBundle(); + late BuildContext context; + await tester.pumpWidget( + testHost( + bundle, + Builder( + builder: (value) { + context = value; + return const SizedBox(); + }, + ), + ), + ); + final ready = precacheMorph( + context, + from: testSquareWithHoleIcon, + to: testCompositeIcon, + bidirectional: true, + contourTransition: MorphContourTransition.linear, + ); + await tester.pumpAndSettle(); + await ready; + expect(MorphCache.currentMorphs, 2); + for (final mode in [ + MorphContourTransition.linear, + MorphContourTransition.legacy, + ]) { + await tester.pumpWidget( + testHost( + bundle, + MorphIcon( + from: testSquareWithHoleIcon, + to: testCompositeIcon, + progress: const AlwaysStoppedAnimation(0.25), + contourTransition: mode, + ), + ), + ); + await tester.pumpAndSettle(); + final painter = + tester.widget(find.byType(CustomPaint)).painter + as MorphPainter; + expect(painter.plan.contourTransition, mode); + expect( + MorphCache.currentMorphs, + mode == MorphContourTransition.linear ? 2 : 3, + ); + } + }); + + testWidgets('changing mode during a spring preserves the visible shape', ( + tester, + ) async { + final bundle = fixtureBundle(); + var completed = 0; + await tester.pumpWidget( + testHost(bundle, const AnimatedMorphIcon(icon: testSquareWithHoleIcon)), + ); + await tester.pumpWidget( + testHost( + bundle, + AnimatedMorphIcon(icon: testCompositeIcon, onEnd: () => completed++), + ), + ); + await pumpUntilTransitionStart(tester); + await tester.pump(const Duration(milliseconds: 35)); + final before = currentPainter(tester).debugOutput; + await tester.pumpWidget( + testHost( + bundle, + AnimatedMorphIcon( + icon: testCompositeIcon, + contourTransition: MorphContourTransition.linear, + onEnd: () => completed++, + ), + ), + ); + await tester.pump(); + expect( + currentPainter(tester).plan.contourTransition, + MorphContourTransition.linear, + ); + expectPointListsClose(currentPainter(tester).debugOutput, before); + expect(completed, 0); + await tester.pump(const Duration(milliseconds: 20)); + final second = currentPainter(tester).debugOutput; + await tester.pumpWidget( + testHost( + bundle, + AnimatedMorphIcon( + icon: testSquareWithHoleIcon, + contourTransition: MorphContourTransition.linear, + onEnd: () => completed++, + ), + ), + ); + await tester.pump(); + expectPointListsClose(currentPainter(tester).debugOutput, second); + await tester.pumpAndSettle(); + expect(completed, 1); + }); + + testWidgets('linear mode still respects reduced motion', (tester) async { + final bundle = fixtureBundle(); + Widget host(IconData icon) => testHost( + bundle, + MediaQuery( + data: const MediaQueryData(disableAnimations: true), + child: AnimatedMorphIcon( + icon: icon, + contourTransition: MorphContourTransition.linear, + ), + ), + ); + await tester.pumpWidget(host(testSquareWithHoleIcon)); + await tester.pumpWidget(host(testCompositeIcon)); + await tester.pumpAndSettle(); + expect(find.byIcon(testCompositeIcon), findsOneWidget); + expect(bundle.loadCount('FontManifest.json'), 0); + }); +} diff --git a/test/src/geometry/contour_transition_test.dart b/test/src/geometry/contour_transition_test.dart new file mode 100644 index 0000000..332bdcc --- /dev/null +++ b/test/src/geometry/contour_transition_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:morphnext/morphnext.dart'; +import 'package:morphnext/src/geometry/morph_plan.dart'; + +import 'morph_plan_test.dart' + show diskShapeWithHoles, signedArea, expectPointsClose; + +void main() { + final filled = diskShapeWithHoles(0); + final outlined = diskShapeWithHoles(1); + + test( + 'linear disappearing hole shrinks gradually and reverses symmetrically', + () { + final forward = buildMorphPlan( + outlined, + filled, + contourTransition: MorphContourTransition.linear, + ); + final reverse = buildMorphPlan( + filled, + outlined, + contourTransition: MorphContourTransition.linear, + ); + final disappearing = forward.items.indexWhere( + (item) => item.targetCollapsed, + ); + final appearing = reverse.items.indexWhere( + (item) => item.sourceCollapsed, + ); + final originalArea = signedArea(forward.items[disappearing].source).abs(); + for (final t in [0.0, 0.25, 0.5, 0.75, 1.0]) { + final points = forward.interpolatedCopy(t)[disappearing]; + expect( + signedArea(points).abs(), + closeTo(originalArea * (1 - t) * (1 - t), 1e-9), + ); + expectPointsClose(points, reverse.interpolatedCopy(1 - t)[appearing]); + } + }, + ); + + test('linear lifecycle does not reflect or reappear during overshoot', () { + for (final shapes in [(outlined, filled), (filled, outlined)]) { + final plan = buildMorphPlan( + shapes.$1, + shapes.$2, + contourTransition: MorphContourTransition.linear, + ); + final index = plan.items.indexWhere( + (item) => item.sourceCollapsed || item.targetCollapsed, + ); + expectPointsClose( + plan.interpolatedCopy(-0.25)[index], + plan.items[index].source, + ); + expectPointsClose( + plan.interpolatedCopy(1.25)[index], + plan.items[index].orientedTarget, + ); + } + }); + + test('default remains legacy and matching contours are unaffected', () { + final implicit = buildMorphPlan(outlined, filled); + final legacy = buildMorphPlan( + outlined, + filled, + contourTransition: MorphContourTransition.legacy, + ); + final linear = buildMorphPlan( + outlined, + filled, + contourTransition: MorphContourTransition.linear, + ); + for (final t in [-0.25, 0.0, 0.25, 0.5, 0.75, 1.0, 1.25]) { + expect(implicit.interpolatedCopy(t), legacy.interpolatedCopy(t)); + for (var i = 0; i < legacy.items.length; i++) { + if (!legacy.items[i].sourceCollapsed && + !legacy.items[i].targetCollapsed) { + expect(linear.interpolatedCopy(t)[i], legacy.interpolatedCopy(t)[i]); + } + } + } + final index = legacy.items.indexWhere((item) => item.targetCollapsed); + expect( + signedArea(legacy.interpolatedCopy(0.25)[index]).abs(), + lessThan(signedArea(linear.interpolatedCopy(0.25)[index]).abs() / 100), + ); + }); +} diff --git a/tool/visual_ab_test.dart b/tool/visual_ab_test.dart index b3ec04a..77fe46a 100644 --- a/tool/visual_ab_test.dart +++ b/tool/visual_ab_test.dart @@ -17,6 +17,9 @@ import '../test/support/test_font_builder.dart'; import '../test/support/test_icons.dart'; const _output = String.fromEnvironment('VISUAL_OUTPUT'); +const _contourTransition = bool.fromEnvironment('VISUAL_LINEAR') + ? MorphContourTransition.linear + : MorphContourTransition.legacy; const _boundary = ValueKey('capture'); const _cell = 128.0; const _progress = [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0]; @@ -79,7 +82,7 @@ void main() { grade: 0.0, opticalSize: 48.0, fontWeight: null, - )), + ), _contourTransition), ); geometry['${entry.name}-${direction.name}'] = plan!.items.length; } @@ -108,6 +111,7 @@ void main() { _tile( entry, MorphIcon( + contourTransition: _contourTransition, from: entry.from, to: entry.to, size: size, @@ -151,6 +155,7 @@ void main() { _tile( entry, AnimatedMorphIcon( + contourTransition: _contourTransition, key: ValueKey(entry.name), icon: to ? entry.to : entry.from, size: 80,