Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ class _MorphPlaygroundPageState extends State<MorphPlaygroundPage>
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;
Expand Down Expand Up @@ -721,6 +722,7 @@ class _MorphPlaygroundPageState extends State<MorphPlaygroundPage>
),
icon: target,
spring: _effectiveHeroSpring,
contourTransition: _contourTransition,
size: _heroSize,
color: _heroColor,
semanticLabel: 'Primary morph preview',
Expand All @@ -732,6 +734,7 @@ class _MorphPlaygroundPageState extends State<MorphPlaygroundPage>
from: pair.from,
to: pair.to,
progress: AlwaysStoppedAnimation<double>(_heroProgress),
contourTransition: _contourTransition,
size: _heroSize,
color: _heroColor,
semanticLabel: 'Primary morph preview',
Expand Down Expand Up @@ -1205,6 +1208,26 @@ class _MorphPlaygroundPageState extends State<MorphPlaygroundPage>
),
const SizedBox(height: 10),
_swatchRow(),
const SizedBox(height: 22),
_sectionLabel('CONTOUR APPEARANCE'),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
for (final mode in MorphContourTransition.values)
ChoiceChip(
key: ValueKey<String>('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) ...<Widget>[
_sectionLabel('SPRING PHYSICS'),
Expand Down Expand Up @@ -1287,6 +1310,7 @@ class _MorphPlaygroundPageState extends State<MorphPlaygroundPage>
),
),
const SizedBox(height: 4),
_sectionLabel('TIMING CURVE'),
Wrap(
spacing: 8,
runSpacing: 8,
Expand Down Expand Up @@ -1318,6 +1342,7 @@ class _MorphPlaygroundPageState extends State<MorphPlaygroundPage>
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'
Expand All @@ -1337,6 +1362,7 @@ class _MorphPlaygroundPageState extends State<MorphPlaygroundPage>
' 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'
Expand Down
58 changes: 58 additions & 0 deletions example/test/widget_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnimatedMorphIcon>(preview).contourTransition,
MorphContourTransition.legacy,
);
final contourMode = find.byKey(const ValueKey<String>('contour-linear'));
await _reveal(tester, contourMode);
await tester.tap(contourMode);
await tester.pump();
expect(
tester.widget<AnimatedMorphIcon>(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<String>('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<MorphIcon>(manualPreview).contourTransition,
MorphContourTransition.linear,
);
final code = tester
.widget<SelectableText>(
find.byKey(const ValueKey<String>('playground-code')),
)
.data!;
expect(code, contains('contourTransition: MorphContourTransition.linear'));
expect(code, contains('curve: Curves.linear'));
});
}

Future<void> _reveal(WidgetTester tester, Finder finder) async {
Expand Down
1 change: 1 addition & 0 deletions lib/morphnext.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion lib/src/cache/morph_cache.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 25 additions & 5 deletions lib/src/geometry/morph_plan.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'dart:math' as math;
import 'dart:typed_data';

import '../morph_contour_transition.dart';
import 'resample.dart';
import 'shape.dart';

Expand Down Expand Up @@ -79,8 +80,10 @@ final class MorphPlanItem {

/// A reusable, allocation-free interpolation plan between two filled shapes.
final class MorphPlan {
MorphPlan(List<MorphPlanItem> items)
: items = List<MorphPlanItem>.unmodifiable(items) {
MorphPlan(
List<MorphPlanItem> items, {
this.contourTransition = MorphContourTransition.legacy,
}) : items = List<MorphPlanItem>.unmodifiable(items) {
if (items.isEmpty) {
throw const FormatException('A morph plan needs at least one contour');
}
Expand All @@ -89,6 +92,9 @@ final class MorphPlan {
/// Aligned contour pairs.
final List<MorphPlanItem> items;

/// The interpolation policy for appearing and disappearing contours.
final MorphContourTransition contourTransition;

/// Allocates one correctly sized output buffer per contour pair.
List<Float64List> allocateOutput() => <Float64List>[
for (final item in items) Float64List(item.source.length),
Expand All @@ -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);
}
}
}

Expand All @@ -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 = <MorphPlanItem>[];
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions lib/src/morph_contour_transition.dart
Original file line number Diff line number Diff line change
@@ -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,
}
39 changes: 33 additions & 6 deletions lib/src/morph_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<MorphShape>(<Future<MorphShape>>[
shapeFor(from, direction, fontSelection),
shapeFor(to, direction, fontSelection),
]);
return buildMorphPlan(shapes[0], shapes[1]);
return buildMorphPlan(
shapes[0],
shapes[1],
contourTransition: contourTransition,
);
}, _planBytes);
}

Expand All @@ -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() {
Expand Down Expand Up @@ -191,13 +209,15 @@ final class _MorphCacheKey {
this.to,
this.direction,
this.fontSelection,
this.contourTransition,
);

final AssetBundle bundle;
final IconData from;
final IconData to;
final TextDirection direction;
final MorphFontSelection fontSelection;
final MorphContourTransition contourTransition;

@override
bool operator ==(Object other) =>
Expand All @@ -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,
);
}
Loading
Loading