Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased

- 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
widgets' existing bounded caches.
- Add `onFallback` and `MorphFallbackDetails` to both widgets for per-request
diagnostics in debug and release, with stale-request and disposal guards.
- Add geometry microbenchmarks and a standalone example performance harness.

## 0.6.1

- Update the web showcase to Lucide 3.1.17.
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,49 @@ cache, `MorphCache.disable()` to empty and disable it, and `MorphCache.reset()`
to restore `defaultMaxMorphs` and `defaultMaxBytes`. `configure` throws
`ArgumentError` unless both limits are positive.

Prepare a known pair before the first interaction, for example from
`didChangeDependencies` or an event handler:

```dart
await precacheMorph(
context,
from: Icons.menu,
to: Icons.close,
bidirectional: true,
);
```

The helper shares the widgets' bounded caches and inherits the asset bundle,
direction, and font axes from the context. Pass the same `textDirection`,
`fill`, `weight`, `grade`, `opticalSize`, and `fontWeight` overrides as the
widget when applicable. Reverse plans are separate; `bidirectional` prepares
both. It respects `MorphCache` limits, clearing and disabling; with caching
disabled no completed plan is retained. Eviction can make later preparation
necessary again. The returned future completes with an error if preparation
fails; catch it if unavailable
fonts are expected. Equal icons require no loading.

Both widgets accept `onFallback` for diagnostics in debug and release:

```dart
AnimatedMorphIcon(
icon: currentIcon,
onFallback: (details) {
debugPrint('Morph to ${details.to} unavailable: ${details.error}');
},
)
```

The callback receives `MorphFallbackDetails` with the source, target, original
error, and stack trace. It runs once per failed current preparation request,
replaces default debug error reporting, and ignores superseded requests or
completion after disposal. Native-icon fallback still works and an implicit
transition still calls `onEnd` when it settles. No callback means the existing
fallback behavior is unchanged.

See [the performance guide](https://github.com/KickNext/morphnext/blob/main/tool/PERFORMANCE.md) for geometry benchmarks and
a standalone Flutter frame-timing harness.

## Limitations

morphnext preserves filled contour topology and keeps holes open, but arbitrary
Expand Down
179 changes: 179 additions & 0 deletions example/lib/performance.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:morphnext/morphnext.dart';

/// Standalone benchmark: flutter run --profile -t lib/performance.dart
void main() => runApp(const MaterialApp(home: MorphPerformancePage()));

/// Measures preparation separately from animated UI/raster frame times.
class MorphPerformancePage extends StatefulWidget {
const MorphPerformancePage({super.key});

@override
State<MorphPerformancePage> createState() => _MorphPerformancePageState();
}

class _MorphPerformancePageState extends State<MorphPerformancePage> {
late BuildContext _sceneContext;
int _count = 20;
int _sampleId = 0;
bool _running = false;
IconData _icon = Icons.menu;
String _result = 'Select a count and run a five-second sample.';
Timer? _timer;
bool _recording = false;
final List<FrameTiming> _frames = [];

void _record(List<FrameTiming> frames) => _frames.addAll(frames);

void _stopRecording() {
if (!_recording) return;
SchedulerBinding.instance.removeTimingsCallback(_record);
_recording = false;
}

Future<void> _run({required bool interrupt}) async {
MorphCache.clear();
setState(() {
_running = true;
// Dispose the previous animation before measuring a cold preparation.
_sampleId++;
_icon = Icons.menu;
_result = 'Preparing…';
});
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
try {
final watch = Stopwatch()..start();
await precacheMorph(
_sceneContext,
from: Icons.menu,
to: Icons.close,
bidirectional: true,
);
final cold = watch.elapsedMicroseconds;
if (!mounted) return;
watch.reset();
await precacheMorph(
_sceneContext,
from: Icons.menu,
to: Icons.close,
bidirectional: true,
);
final warm = watch.elapsedMicroseconds;
watch.stop();
if (!mounted) return;
// Do not mix the preparation phase into the frame sample.
_frames.clear();
SchedulerBinding.instance.addTimingsCallback(_record);
_recording = true;
setState(() {
_icon = Icons.close;
_result =
'Measuring ${interrupt ? 'interruptions' : 'cached transitions'}…';
});
_timer = Timer.periodic(Duration(milliseconds: interrupt ? 80 : 1000), (
_,
) {
if (!mounted) return;
setState(() => _icon = _icon == Icons.menu ? Icons.close : Icons.menu);
});
await Future<void>.delayed(const Duration(seconds: 5));
if (!mounted) return;
_timer?.cancel();
_stopRecording();
setState(() {
_result =
'$_count icons · ${interrupt ? '80 ms retarget' : '1 s toggles'}\n'
'Preparation, both directions: cold $cold µs / cached $warm µs\n'
'${_frames.length} frames · UI p95 ${_p95(false)} · raster p95 ${_p95(true)}\n'
'Profile/release only. Use DevTools for memory and Chrome DevTools on web.';
});
} catch (error) {
if (mounted) setState(() => _result = 'Benchmark failed: $error');
} finally {
_timer?.cancel();
_stopRecording();
if (mounted) setState(() => _running = false);
}
}

String _p95(bool raster) {
if (_frames.isEmpty) return 'unavailable';
final times = [
for (final frame in _frames)
(raster ? frame.rasterDuration : frame.buildDuration).inMicroseconds,
]..sort();
return '${(times[(times.length * 0.95).ceil() - 1] / 1000).toStringAsFixed(2)} ms';
}

@override
void dispose() {
_timer?.cancel();
_stopRecording();
super.dispose();
}

@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Morph performance')),
body: Builder(
builder: (context) {
_sceneContext = context;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SegmentedButton<int>(
segments: [
for (final count in [1, 20, 100])
ButtonSegment(value: count, label: Text('$count icons')),
],
selected: {_count},
onSelectionChanged: _running
? null
: (value) => setState(() => _count = value.single),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
children: [
FilledButton(
onPressed: _running ? null : () => _run(interrupt: false),
child: const Text('Cached transitions'),
),
OutlinedButton(
onPressed: _running ? null : () => _run(interrupt: true),
child: const Text('Rapid interruptions'),
),
],
),
const SizedBox(height: 12),
Text(_result),
const SizedBox(height: 24),
Expanded(
child: SingleChildScrollView(
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (var i = 0; i < _count; i++)
AnimatedMorphIcon(
key: ValueKey((_sampleId, i)),
icon: _icon,
size: 32,
),
],
),
),
),
],
),
);
},
),
);
}
29 changes: 29 additions & 0 deletions example/test/performance_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:morphnext_example/performance.dart';

void main() {
testWidgets('idle performance page can be disposed', (tester) async {
await tester.pumpWidget(const MaterialApp(home: MorphPerformancePage()));
await tester.pumpWidget(const SizedBox());
expect(tester.takeException(), isNull);
});

for (final mode in ['Cached transitions', 'Rapid interruptions']) {
testWidgets('$mode completes and releases timing callbacks', (
tester,
) async {
await tester.pumpWidget(const MaterialApp(home: MorphPerformancePage()));
await tester.tap(find.text(mode));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 6));
await tester.pumpAndSettle();
expect(
find.textContaining('Preparation, both directions:'),
findsOneWidget,
);
await tester.pumpWidget(const SizedBox());
expect(tester.takeException(), isNull);
});
}
}
2 changes: 2 additions & 0 deletions lib/morphnext.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ library;

export 'package:flutter/physics.dart' show SpringDescription;
export 'src/cache/morph_cache.dart' show MorphCache;
export 'src/morph_fallback_details.dart' show MorphFallbackDetails;
export 'src/morph_spring.dart' show MorphSprings;
export 'src/precache_morph.dart' show precacheMorph;
export 'src/widgets/animated_morph_icon.dart' show AnimatedMorphIcon;
export 'src/widgets/morph_icon.dart' show MorphIcon;
88 changes: 51 additions & 37 deletions lib/src/geometry/morph_plan.dart
Original file line number Diff line number Diff line change
Expand Up @@ -277,17 +277,31 @@ List<(int?, int?)> _matchRole(
for (final sourceIndex in sourceIndices) (sourceIndex, null),
];
}
if (sourceIndices.length == 1 && targetIndices.length == 1) {
return <(int?, int?)>[(sourceIndices.single, targetIndices.single)];
}

final sourceAreaScale = _maximumArea(source, sourceIndices);
final targetAreaScale = _maximumArea(target, targetIndices);
double cost(int sourcePosition, int targetPosition) => _contourCost(
source,
sourceIndices[sourcePosition],
sourceAreaScale,
target,
targetIndices[targetPosition],
targetAreaScale,
);
final sourceMetrics = <_ContourMetrics>[
for (final index in sourceIndices)
_contourMetrics(source, index, sourceAreaScale),
];
final targetMetrics = <_ContourMetrics>[
for (final index in targetIndices)
_contourMetrics(target, index, targetAreaScale),
];
// Assignment revisits the same pair in different branches. Compute each
// cost once, without changing traversal order or tie-breaking.
final costs = <Float64List>[
for (final sourceMetric in sourceMetrics)
Float64List.fromList(<double>[
for (final targetMetric in targetMetrics)
_contourCost(sourceMetric, targetMetric),
]),
];
double cost(int sourcePosition, int targetPosition) =>
costs[sourcePosition][targetPosition];

if (sourceIndices.length <= targetIndices.length) {
final assignment = _minimumInjection(
Expand Down Expand Up @@ -348,37 +362,37 @@ double _maximumArea(MorphShape shape, List<int> indices) => indices
.map((index) => shape.contours[index].signedArea.abs())
.fold<double>(_energyEpsilon, math.max);

double _contourCost(
MorphShape source,
int sourceIndex,
double sourceAreaScale,
MorphShape target,
int targetIndex,
double targetAreaScale,
) {
final sourceContour = source.contours[sourceIndex];
final targetContour = target.contours[targetIndex];
final sourceCentroid = _centroid(sourceContour.points);
final targetCentroid = _centroid(targetContour.points);
final centroidCost = _distance(sourceCentroid, targetCentroid);
final sourceLength =
_polygonLength(sourceContour.points) / math.sqrt(sourceAreaScale);
final targetLength =
_polygonLength(targetContour.points) / math.sqrt(targetAreaScale);
final lengthCost = (sourceLength - targetLength).abs();
final areaCost =
(sourceContour.signedArea.abs() / sourceAreaScale -
targetContour.signedArea.abs() / targetAreaScale)
.abs();
final depthCost = (sourceContour.depth - targetContour.depth).abs();
typedef _ContourMetrics = ({
(double, double) centroid,
double length,
double area,
int depth,
(double, double)? parentCentroid,
});

_ContourMetrics _contourMetrics(MorphShape shape, int index, double areaScale) {
final contour = shape.contours[index];
return (
centroid: _centroid(contour.points),
length: _polygonLength(contour.points) / math.sqrt(areaScale),
area: contour.signedArea.abs() / areaScale,
depth: contour.depth,
parentCentroid: contour.parent == null
? null
: _centroid(shape.contours[contour.parent!].points),
);
}

double _contourCost(_ContourMetrics source, _ContourMetrics target) {
final centroidCost = _distance(source.centroid, target.centroid);
final lengthCost = (source.length - target.length).abs();
final areaCost = (source.area - target.area).abs();
final depthCost = (source.depth - target.depth).abs();
var parentCost = 0.0;
if ((sourceContour.parent == null) != (targetContour.parent == null)) {
if ((source.parentCentroid == null) != (target.parentCentroid == null)) {
parentCost = 2;
} else if (sourceContour.parent != null) {
parentCost = _distance(
_centroid(source.contours[sourceContour.parent!].points),
_centroid(target.contours[targetContour.parent!].points),
);
} else if (source.parentCentroid != null) {
parentCost = _distance(source.parentCentroid!, target.parentCentroid!);
}
return centroidCost +
0.35 * lengthCost +
Expand Down
Loading
Loading