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:
parent
e6580e7812
commit
43bf1d2987
1 changed files with 110 additions and 40 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue