perf(kanji): the stroke order draws only the stroke being traced each frame

Flutter's renderer on phones (Impeller) works a stroked path out anew at
every draw (flutter/flutter#192676), and each frame of the stroke order
drew every stroke twice, faint and in ink, with the dashed guides, and
measured the stroke being traced again. Now the square, the faint strokes
and those traced are painted into an image once, again only as a stroke
ends; each frame draws that and the stroke being traced, measured once.
Only the square repaints, not the sheet around it, and without rebuilding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-12 15:56:10 +02:00
commit 43bf1d2987
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -1,3 +1,6 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/feature/kanji/model/character_strokes.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@ -36,6 +39,12 @@ class _PathWriter(final Path _path) implements PathProxy {
/// [strokes] written one after another on a square [size] wide, with the
/// lines through its middle: each stroke traced in turn over the ones to
/// come, drawn faint; Play again starts over.
///
/// Flutter's renderer on phones (Impeller) works a stroked path out anew at
/// every draw (flutter/flutter#192676, open in 2026-10), so the square, the
/// faint strokes and those already traced are painted once into an image
/// ([SettledLayer]), again only as a stroke ends; each frame draws that and
/// the stroke being traced.
class const StrokeOrderView({
required final CharacterStrokes strokes,
final double size = 200,
@ -56,9 +65,10 @@ class _StrokeOrderViewState()
vsync: this,
duration: StrokeOrderView.perStroke * _paths.length,
);
final _settled = SettledLayer();
static List<Path> _pathsOf(CharacterStrokes strokes) => [
for (final path in strokes.paths) strokePath(path),
static List<TracedPath> _pathsOf(CharacterStrokes strokes) => [
for (final path in strokes.paths) TracedPath(strokePath(path)),
];
@override
@ -81,6 +91,7 @@ class _StrokeOrderViewState()
@override
void dispose() {
_controller.dispose();
_settled.dispose();
super.dispose();
}
@ -93,15 +104,17 @@ class _StrokeOrderViewState()
children: [
SizedBox.square(
dimension: widget.size,
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) => CustomPaint(
// Only the square repaints as it plays, not the sheet around.
child: RepaintBoundary(
child: CustomPaint(
painter: StrokesPainter(
paths: _paths,
progress: _controller.value,
progress: _controller,
ink: colors.onSurface,
faint: colors.outlineVariant,
guide: colors.outlineVariant,
settled: _settled,
pixelRatio: MediaQuery.devicePixelRatioOf(context),
),
),
),
@ -117,61 +130,118 @@ class _StrokeOrderViewState()
}
}
/// A stroke's path with its measures, worked out once, to trace part of it
/// frame by frame.
class TracedPath(
/// The whole stroke.
final Path path,
) {
/// Its contours, to take the first part of each.
final List<ui.PathMetric> metrics = path.computeMetrics().toList();
}
/// What stays the same over many frames, painted once into an image of the
/// canvas's pixels and drawn as that until it changes.
class SettledLayer() {
ui.Image? _image;
Object? _key;
/// Draws the layer on [canvas], [size] large at [pixelRatio]: as it was
/// while [key] is the same, else [paint] anew.
void draw(
Canvas canvas,
Size size,
double pixelRatio,
Object key,
void Function(Canvas canvas) paint,
) {
final fullKey = (key, size, pixelRatio);
var image = _image;
if (image == null || fullKey != _key) {
final recorder = ui.PictureRecorder();
paint(Canvas(recorder)..scale(pixelRatio));
final picture = recorder.endRecording();
image = picture.toImageSync(
math.max(1, (size.width * pixelRatio).ceil()),
math.max(1, (size.height * pixelRatio).ceil()),
);
picture.dispose();
_image?.dispose();
_image = image;
_key = fullKey;
}
canvas.drawImageRect(
image,
Offset.zero & Size(image.width.toDouble(), image.height.toDouble()),
Offset.zero & Size(image.width / pixelRatio, image.height / pixelRatio),
Paint()..filterQuality = FilterQuality.medium,
);
}
/// Lets the image go.
void dispose() {
_image?.dispose();
_image = null;
}
}
/// Paints strokes in KanjiVG's square, scaled to the canvas: the square's
/// border and the dashed lines through its middle in [guide], every stroke
/// in [faint], and over them the strokes written so far in [ink], so far
/// as [progress] (0 to 1, each stroke an equal share) has come; all of
/// them at 1.
class const StrokesPainter({
required final List<Path> paths,
required final double progress,
/// them at 1. What only changes as a stroke ends is drawn from [settled].
class StrokesPainter({
required final List<TracedPath> paths,
required final Animation<double> progress,
required final Color ink,
required final Color faint,
required final Color guide,
/// Strokes drawn whole in [ink] besides, as a person wrote them.
final List<Path> written = const [],
/// Where what changes only as a stroke ends is kept.
required final SettledLayer settled,
/// The screen's pixels to a logical pixel, for [settled]'s image.
required final double pixelRatio,
}) extends CustomPainter {
this : super(repaint: progress);
@override
void paint(Canvas canvas, Size size) {
paintGuides(canvas, size, guide);
canvas
..save()
..scale(size.shortestSide / CharacterStrokes.box);
final faintPaint = strokePaint(faint);
final done = progress.value * paths.length;
final whole = math.min(done.floor(), paths.length);
final scale = size.shortestSide / CharacterStrokes.box;
final inkPaint = strokePaint(ink);
for (final path in paths) {
canvas.drawPath(path, faintPaint);
}
final done = progress * paths.length;
for (final (i, path) in paths.indexed) {
if (i + 1 <= done) {
canvas.drawPath(path, inkPaint);
} else if (i < done) {
// The stroke being traced, as far as it has come.
final part = done - i;
for (final metric in path.computeMetrics()) {
canvas.drawPath(
metric.extractPath(0, metric.length * part),
inkPaint,
);
}
settled.draw(canvas, size, pixelRatio, (paths, whole, ink, faint, guide), (
canvas,
) {
paintGuides(canvas, size, guide);
canvas.scale(scale);
final faintPaint = strokePaint(faint);
for (final (i, traced) in paths.indexed) {
canvas.drawPath(traced.path, i < whole ? inkPaint : faintPaint);
}
});
if (whole < paths.length && done > whole) {
// The stroke being traced, as far as it has come.
final part = done - whole;
canvas
..save()
..scale(scale);
for (final metric in paths[whole].metrics) {
canvas.drawPath(metric.extractPath(0, metric.length * part), inkPaint);
}
canvas.restore();
}
for (final path in written) {
canvas.drawPath(path, inkPaint);
}
canvas.restore();
}
@override
bool shouldRepaint(StrokesPainter oldDelegate) =>
oldDelegate.progress != progress ||
!identical(oldDelegate.progress, progress) ||
!identical(oldDelegate.paths, paths) ||
!identical(oldDelegate.written, written) ||
oldDelegate.ink != ink ||
oldDelegate.faint != faint ||
oldDelegate.guide != guide;
oldDelegate.guide != guide ||
oldDelegate.pixelRatio != pixelRatio;
}
/// How strokes are drawn in KanjiVG's square: [color], round at their ends