1 Commits
v0.6.0 ... main

Author SHA1 Message Date
26c2cab239 Add support for delay between strokes and loops
All checks were successful
Test / test (push) Successful in 1m21s
2026-02-26 13:59:47 +09:00

View File

@@ -64,8 +64,8 @@ class Kanimaji extends StatefulWidget {
// Animation parameters
final bool loop;
// final Duration delayBetweenStrokes;
// final Duration delayBetweenLoops;
final Duration delayBetweenStrokes;
final Duration delayBetweenLoops;
// TODO: add support for specifying animation bezier curve
final TimingFunction timingFunction;
// final Cubic animationCurve;
@@ -104,6 +104,8 @@ class Kanimaji extends StatefulWidget {
this.loop = true,
this.timingFunction = TimingFunction.ease,
this.delayBetweenLoops = const Duration(seconds: 1),
this.delayBetweenStrokes = const Duration(milliseconds: 100),
this.strokeColor = Colors.black,
this.currentStrokeColor = Colors.red,
@@ -168,6 +170,9 @@ class _KanimajiState extends State<Kanimaji>
if (oldWidget.kanji != widget.kanji) {
_loadAndParseSvg().then((_) => _configureController());
} else if (oldWidget.loop != widget.loop ||
oldWidget.timingFunction != widget.timingFunction ||
oldWidget.delayBetweenLoops != widget.delayBetweenLoops ||
oldWidget.delayBetweenStrokes != widget.delayBetweenStrokes ||
oldWidget.strokeColor != widget.strokeColor ||
oldWidget.currentStrokeColor != widget.currentStrokeColor ||
oldWidget.strokeUnfilledColor != widget.strokeUnfilledColor ||
@@ -218,10 +223,14 @@ class _KanimajiState extends State<Kanimaji>
void _configureController() {
if (_kanjiData == null) return;
final double totalSec = _pathDurations.fold(0.0, (a, b) => a + b);
final int totalSec =
(_pathDurations.fold(0.0, (a, b) => a + b) * 1000).round() +
widget.delayBetweenStrokes.inMilliseconds *
(_pathDurations.length - 1) +
widget.delayBetweenLoops.inMilliseconds;
_controller.stop();
_controller.duration = Duration(milliseconds: (totalSec * 1000.0).round());
_controller.duration = Duration(milliseconds: totalSec);
if (widget.loop) {
_controller.repeat();
} else {
@@ -256,6 +265,8 @@ class _KanimajiState extends State<Kanimaji>
viewBoxHeight: _viewBoxHeight,
timingFunction: widget.timingFunction,
delayBetweenStrokes: widget.delayBetweenStrokes,
delayBetweenLoops: widget.delayBetweenLoops,
strokeColor: widget.strokeColor,
currentStrokeColor: widget.currentStrokeColor,
@@ -292,23 +303,14 @@ class _KanimajiPainter extends CustomPainter {
final List<double> pathLengths;
final List<double> pathDurations;
// TODO: don't recalculate these all the time, compute once and cache
List<double> get absolutePathDurations {
final List<double> absolute = [];
double sum = 0.0;
for (final dur in pathDurations) {
absolute.add(sum);
sum += dur;
}
return absolute;
}
final List<KanjiStrokeNumber> strokeNumbers;
final double viewBoxWidth;
final double viewBoxHeight;
final TimingFunction timingFunction;
final Duration delayBetweenStrokes;
final Duration delayBetweenLoops;
final Color strokeColor;
final Color currentStrokeColor;
@@ -332,7 +334,7 @@ class _KanimajiPainter extends CustomPainter {
final double crossStipleLength;
final double crossStipleGap;
_KanimajiPainter({
const _KanimajiPainter({
required this.progress,
required this.paths,
@@ -344,6 +346,8 @@ class _KanimajiPainter extends CustomPainter {
required this.viewBoxHeight,
required this.timingFunction,
required this.delayBetweenStrokes,
required this.delayBetweenLoops,
required this.strokeColor,
required this.currentStrokeColor,
@@ -368,8 +372,79 @@ class _KanimajiPainter extends CustomPainter {
required this.crossStipleGap,
});
@override
void paint(Canvas canvas, Size size) {
int get elapsedTimeMilliseconds {
// TODO: only calculate the total time once
final int totalTimeMilliseconds =
(pathDurations.fold(0.0, (a, b) => a + b) * 1000).round() +
delayBetweenStrokes.inMilliseconds * (pathDurations.length - 1) +
delayBetweenLoops.inMilliseconds;
final double p = progress.clamp(0.0, 1.0);
return (p * totalTimeMilliseconds).round();
}
// TODO: cache the value of the previous paint iteration, to avoid having to recalculate the entire stroke index and progress on every frame
// fall back to recalculating if it does not step forward.
// The index of the currently drawing stroke if it is being drawn.
// Returns null if any of the following is true:
// - We are in the delay after a stroke
// - We are in the delay between loops
int? get _currentStrokeIndex {
int currentTime = 0;
for (int i = 0; i < pathDurations.length; i++) {
final int strokeTime = (pathDurations[i] * 1000).round();
if (elapsedTimeMilliseconds < currentTime + strokeTime) {
return i;
} else if (elapsedTimeMilliseconds <
currentTime + strokeTime + delayBetweenStrokes.inMilliseconds) {
return null;
}
currentTime += strokeTime;
currentTime += delayBetweenStrokes.inMilliseconds;
}
return null;
}
// TODO: optimize by caching the last stroke index and progress, and only recalculating if the elapsed time has moved past the next expected threshold (either the end of the current stroke or the end of the current delay)
/// The index of the last fully drawn stroke. Returns -1 if no stroke has been fully drawn yet.
int get _lastStrokeIndex {
int currentTime = 0;
for (int i = 0; i < pathDurations.length; i++) {
final int strokeTime = (pathDurations[i] * 1000).round();
if (elapsedTimeMilliseconds < currentTime + strokeTime) {
return i - 1;
}
currentTime += strokeTime;
currentTime += delayBetweenStrokes.inMilliseconds;
}
return pathDurations.length - 1;
}
/// The progress of the currently drawing stroke (0.0..1.0). Returns null if we are in a delay.
double? get _currentStrokeProgress {
final int? currentStrokeIndex = _currentStrokeIndex;
if (currentStrokeIndex == null) return null;
int currentTime = 0;
for (int i = 0; i < currentStrokeIndex; i++) {
final int strokeTime = (pathDurations[i] * 1000).round();
currentTime += strokeTime;
currentTime += delayBetweenStrokes.inMilliseconds;
}
final int strokeTime = (pathDurations[currentStrokeIndex] * 1000).round();
final int elapsedInCurrentStroke = elapsedTimeMilliseconds - currentTime;
return (elapsedInCurrentStroke / strokeTime).clamp(0.0, 1.0);
}
/// Draw the static parts of the canvas that do not change with each frame, such as the background, cross, and unfilled paths.
void _drawBaseCanvas(Canvas canvas, Size size) {
final bgPaint = Paint()..color = backgroundColor;
canvas.drawRect(Offset.zero & size, bgPaint);
@@ -382,6 +457,11 @@ class _KanimajiPainter extends CustomPainter {
final double dx = (size.width - viewBoxWidth * scale) / 2.0;
final double dy = (size.height - viewBoxHeight * scale) / 2.0;
final Paint crossPaint = Paint()
..style = PaintingStyle.stroke
..color = crossColor
..strokeWidth = crossStrokeWidth;
final Paint unfilledPaint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
@@ -389,29 +469,6 @@ class _KanimajiPainter extends CustomPainter {
..strokeWidth = strokeWidth / scale
..color = strokeUnfilledColor;
final Paint filledPaint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..strokeWidth = strokeWidth / scale
..color = strokeColor;
final Paint currentStrokePaint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..strokeWidth = strokeWidth / scale
..color = currentStrokeColor;
final Paint brushPaint = Paint()
..style = PaintingStyle.fill
..color = brushColor;
final Paint crossPaint = Paint()
..style = PaintingStyle.stroke
..color = crossColor
..strokeWidth = crossStrokeWidth;
// Draw cross if enabled
if (showCross) {
// Draw vertical stipled line
@@ -445,63 +502,7 @@ class _KanimajiPainter extends CustomPainter {
canvas.drawPath(path, unfilledPaint);
}
canvas.save();
canvas.translate(dx, dy);
canvas.scale(scale, scale);
// total animation time in seconds computed from durations
final double totalTime = pathDurations.isEmpty
? 1.0
: pathDurations.fold(0.0, (a, b) => a + b);
final double p = progress.clamp(0.0, 1.0);
final int currentlyDrawingIndex = absolutePathDurations.lastIndexWhere(
(t) => t <= p * totalTime,
);
if (currentlyDrawingIndex == -1) {
for (final path in paths) {
canvas.drawPath(path, filledPaint);
}
canvas.restore();
return;
}
// Draw all completed strokes fully filled
for (int i = 0; i < currentlyDrawingIndex; i++) {
canvas.drawPath(paths[i], filledPaint);
}
// Draw the currently drawing stroke with partial coverage
if (currentlyDrawingIndex >= 0 && currentlyDrawingIndex < paths.length) {
final ui.Path path = paths[currentlyDrawingIndex];
final double len = pathLengths[currentlyDrawingIndex];
final double dur = pathDurations[currentlyDrawingIndex];
final relativeElapsedTime =
p * totalTime -
(currentlyDrawingIndex > 0
? absolutePathDurations[currentlyDrawingIndex]
: 0.0);
final double strokeProgress = timingFunction.func(
(relativeElapsedTime / dur).clamp(0.0, 1.0),
);
final ui.PathMetrics metrics = path.computeMetrics();
final ui.PathMetric metric = metrics.first;
final double drawLength = len * strokeProgress;
final ui.Path partialPath = metric.extractPath(0, drawLength);
canvas.drawPath(partialPath, currentStrokePaint);
if (showBrush) {
final ui.Tangent? tangent = metric.getTangentForOffset(drawLength);
if (tangent != null) {
canvas.drawCircle(tangent.position, brushRadius / scale, brushPaint);
}
}
}
// Draw stroke numbers
// Draw stroke numbers if enabled
if (showStrokeNumbers) {
final textPainter = TextPainter(
textAlign: TextAlign.center,
@@ -509,13 +510,10 @@ class _KanimajiPainter extends CustomPainter {
);
for (final sn in strokeNumbers) {
final bool isCurrent =
sn.num ==
(currentlyDrawingIndex + 1).clamp(1, strokeNumbers.length);
final textSpan = TextSpan(
text: sn.num.toString(),
style: TextStyle(
color: isCurrent ? currentStrokeNumberColor : strokeNumberColor,
color: strokeNumberColor,
fontSize: strokeNumberFontSize / scale,
fontFamily: strokeNumberFontFamily,
),
@@ -533,7 +531,96 @@ class _KanimajiPainter extends CustomPainter {
}
}
canvas.restore();
// canvas.save();
canvas.translate(dx, dy);
canvas.scale(scale, scale);
}
@override
void paint(Canvas canvas, Size size) {
// TODO: see if we can optimize by storing the base canvas once and restoring it on each frame instead of redrawing it every time
_drawBaseCanvas(canvas, size);
final double sx = size.width / viewBoxWidth;
final double sy = size.height / viewBoxHeight;
final double scale = math.min(sx, sy);
final Paint filledPaint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..strokeWidth = strokeWidth / scale
..color = strokeColor;
final Paint currentStrokePaint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..strokeWidth = strokeWidth / scale
..color = currentStrokeColor;
final Paint brushPaint = Paint()
..style = PaintingStyle.fill
..color = brushColor;
final int currentlyDrawingIndex = _currentStrokeIndex ?? -1;
final int lastStrokeIndex = _lastStrokeIndex;
// Draw all completed strokes fully filled
for (int i = 0; i < lastStrokeIndex + 1; i++) {
canvas.drawPath(paths[i], filledPaint);
}
// Draw the currently drawing stroke with partial coverage
if (currentlyDrawingIndex >= 0) {
final ui.Path path = paths[currentlyDrawingIndex];
final double len = pathLengths[currentlyDrawingIndex];
final double strokeProgress = timingFunction.func(
_currentStrokeProgress!,
);
final ui.PathMetrics metrics = path.computeMetrics();
final ui.PathMetric metric = metrics.first;
final double drawLength = len * strokeProgress;
final ui.Path partialPath = metric.extractPath(0, drawLength);
canvas.drawPath(partialPath, currentStrokePaint);
if (showBrush) {
final ui.Tangent? tangent = metric.getTangentForOffset(drawLength);
if (tangent != null) {
canvas.drawCircle(tangent.position, brushRadius / scale, brushPaint);
}
}
}
// Color over the stroke number of the currently drawing stroke if enabled
if (showStrokeNumbers && currentlyDrawingIndex != -1) {
final textPainter = TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
final sn = strokeNumbers[currentlyDrawingIndex];
final textSpan = TextSpan(
text: sn.num.toString(),
style: TextStyle(
color: currentStrokeNumberColor,
fontSize: strokeNumberFontSize / scale,
fontFamily: strokeNumberFontFamily,
),
);
textPainter.text = textSpan;
textPainter.layout();
final Offset pos = Offset(
sn.position.x.toDouble(),
sn.position.y.toDouble(),
);
final Offset centeredPos =
pos - Offset(textPainter.width / 2, textPainter.height / 2);
textPainter.paint(canvas, centeredPos);
}
// canvas.restore();
}
@override
@@ -541,6 +628,9 @@ class _KanimajiPainter extends CustomPainter {
return oldDelegate.progress != progress ||
oldDelegate.paths != paths ||
oldDelegate.strokeNumbers != strokeNumbers ||
oldDelegate.timingFunction != timingFunction ||
oldDelegate.delayBetweenStrokes != delayBetweenStrokes ||
oldDelegate.delayBetweenLoops != delayBetweenLoops ||
oldDelegate.strokeColor != strokeColor ||
oldDelegate.strokeUnfilledColor != strokeUnfilledColor ||
oldDelegate.strokeWidth != strokeWidth ||