485 lines
15 KiB
Dart
485 lines
15 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui' as ui;
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'package:kanimaji/animator.dart';
|
|
import 'package:kanimaji/kanjivg_parser.dart';
|
|
|
|
Future<KanjiVGItem> _defaultKanjiDataProvider(String kanji) async {
|
|
final hex = kanji.runes.isEmpty
|
|
? '00000'
|
|
: kanji.runes.first.toRadixString(16).padLeft(5, '0');
|
|
final assetPath = 'assets/kanjivg/kanji/$hex.svg';
|
|
final svgString = await rootBundle.loadString(assetPath);
|
|
return KanjiVGItem.parseFromXml(svgString);
|
|
}
|
|
|
|
/// A widget that animates the stroke order of a given kanji character using KanjiVG data.
|
|
class Kanimaji extends StatefulWidget {
|
|
final String kanji;
|
|
final FutureOr<KanjiVGItem> Function(String kanji) kanjiDataProvider;
|
|
|
|
// Animation parameters
|
|
final bool loop;
|
|
// final Duration delayBetweenStrokes;
|
|
// final Duration delayBetweenLoops;
|
|
// TODO: add support for specifying animation bezier curve
|
|
// final Cubic animationCurve;
|
|
// final double speed;
|
|
|
|
// Brush parameters
|
|
final bool showBrush;
|
|
final Color brushColor;
|
|
final double brushRadius;
|
|
|
|
// Stroke parameters
|
|
final Color strokeColor;
|
|
final Color strokeUnfilledColor;
|
|
final double strokeWidth;
|
|
final Color backgroundColor;
|
|
|
|
// Stroke number parameters
|
|
final bool showStrokeNumbers;
|
|
final Color strokeNumberColor;
|
|
final Color currentStrokeNumberColor;
|
|
final double strokeNumberFontSize;
|
|
final String? strokeNumberFontFamily;
|
|
|
|
// Cross parameters
|
|
final bool showCross;
|
|
final Color crossColor;
|
|
final double crossStrokeWidth;
|
|
final double crossStipleLength;
|
|
final double crossStipleGap;
|
|
|
|
const Kanimaji({
|
|
super.key,
|
|
required this.kanji,
|
|
this.kanjiDataProvider = _defaultKanjiDataProvider,
|
|
this.loop = true,
|
|
this.strokeColor = Colors.black,
|
|
this.strokeUnfilledColor = const Color(0xA0DDDDDD),
|
|
this.strokeWidth = 3.0,
|
|
this.backgroundColor = Colors.transparent,
|
|
this.showBrush = true,
|
|
this.brushColor = Colors.red,
|
|
this.brushRadius = 4.0,
|
|
this.showStrokeNumbers = true,
|
|
this.strokeNumberColor = Colors.grey,
|
|
this.currentStrokeNumberColor = Colors.red,
|
|
this.strokeNumberFontSize = 4.0,
|
|
this.strokeNumberFontFamily,
|
|
this.showCross = true,
|
|
this.crossColor = const Color(0x40AAAAAA),
|
|
this.crossStrokeWidth = 0.8,
|
|
this.crossStipleLength = 5.0,
|
|
this.crossStipleGap = 3.0,
|
|
});
|
|
|
|
@override
|
|
State<Kanimaji> createState() => _KanimajiState();
|
|
}
|
|
|
|
class _KanimajiState extends State<Kanimaji>
|
|
with SingleTickerProviderStateMixin {
|
|
KanjiVGItem? _kanjiData;
|
|
String? _error;
|
|
late AnimationController _controller;
|
|
|
|
List<double> get _pathLengths =>
|
|
_kanjiData?.paths
|
|
.map((p) => p.svgPath.size(error: 1e-8).toDouble())
|
|
.toList() ??
|
|
[];
|
|
|
|
List<double> get _pathDurations =>
|
|
_pathLengths.map((len) => strokeLengthToDuration(len)).toList();
|
|
|
|
static const double _viewBoxWidth = 109.0;
|
|
static const double _viewBoxHeight = 109.0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(vsync: this);
|
|
_controller.addListener(_onTick);
|
|
_loadAndParseSvg().then((_) => _configureController());
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant Kanimaji oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.kanji != widget.kanji) {
|
|
_loadAndParseSvg().then((_) => _configureController());
|
|
} else if (oldWidget.loop != widget.loop ||
|
|
oldWidget.strokeColor != widget.strokeColor ||
|
|
oldWidget.strokeUnfilledColor != widget.strokeUnfilledColor ||
|
|
oldWidget.strokeWidth != widget.strokeWidth ||
|
|
oldWidget.backgroundColor != widget.backgroundColor ||
|
|
oldWidget.showBrush != widget.showBrush ||
|
|
oldWidget.brushColor != widget.brushColor ||
|
|
oldWidget.brushRadius != widget.brushRadius ||
|
|
oldWidget.showStrokeNumbers != widget.showStrokeNumbers ||
|
|
oldWidget.strokeNumberColor != widget.strokeNumberColor ||
|
|
oldWidget.currentStrokeNumberColor != widget.currentStrokeNumberColor ||
|
|
oldWidget.strokeNumberFontSize != widget.strokeNumberFontSize ||
|
|
oldWidget.strokeNumberFontFamily != widget.strokeNumberFontFamily ||
|
|
oldWidget.showCross != widget.showCross ||
|
|
oldWidget.crossColor != widget.crossColor ||
|
|
oldWidget.crossStrokeWidth != widget.crossStrokeWidth ||
|
|
oldWidget.crossStipleLength != widget.crossStipleLength ||
|
|
oldWidget.crossStipleGap != widget.crossStipleGap) {
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
void _onTick() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.removeListener(_onTick);
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadAndParseSvg() async {
|
|
try {
|
|
final data = await widget.kanjiDataProvider(widget.kanji);
|
|
setState(() {
|
|
_kanjiData = data;
|
|
_error = null;
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_kanjiData = null;
|
|
_error = 'Error loading/parsing kanji data: $e';
|
|
});
|
|
}
|
|
}
|
|
|
|
void _configureController() {
|
|
if (_kanjiData == null) return;
|
|
final double totalSec = _pathDurations.fold(0.0, (a, b) => a + b);
|
|
|
|
_controller.stop();
|
|
_controller.duration = Duration(milliseconds: (totalSec * 1000.0).round());
|
|
if (widget.loop) {
|
|
_controller.repeat();
|
|
} else {
|
|
_controller.forward(from: 0);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (_kanjiData == null && _error == null) {
|
|
return SizedBox.expand(child: Center(child: CircularProgressIndicator()));
|
|
}
|
|
|
|
if (_error != null) {
|
|
return SizedBox.expand(child: ErrorWidget(_error!));
|
|
}
|
|
|
|
return FittedBox(
|
|
child: SizedBox(
|
|
width: _viewBoxWidth,
|
|
height: _viewBoxHeight,
|
|
child: CustomPaint(
|
|
painter: _KanimajiPainter(
|
|
paths: _kanjiData!.paths.map((p) => p.svgPath.toUiPath()).toList(),
|
|
pathLengths: _pathLengths,
|
|
pathDurations: _pathDurations,
|
|
strokeNumbers: _kanjiData!.strokeNumbers,
|
|
progress: _controller.value,
|
|
strokeColor: widget.strokeColor,
|
|
strokeUnfilledColor: widget.strokeUnfilledColor,
|
|
strokeWidth: widget.strokeWidth,
|
|
backgroundColor: widget.backgroundColor,
|
|
viewBoxWidth: _viewBoxWidth,
|
|
viewBoxHeight: _viewBoxHeight,
|
|
showBrush: widget.showBrush,
|
|
brushColor: widget.brushColor,
|
|
brushRadius: widget.brushRadius,
|
|
showStrokeNumbers: widget.showStrokeNumbers,
|
|
strokeNumberColor: widget.strokeNumberColor,
|
|
currentStrokeNumberColor: widget.currentStrokeNumberColor,
|
|
strokeNumberFontSize: widget.strokeNumberFontSize,
|
|
strokeNumberFontFamily: widget.strokeNumberFontFamily,
|
|
showCross: widget.showCross,
|
|
crossColor: widget.crossColor,
|
|
crossStrokeWidth: widget.crossStrokeWidth,
|
|
crossStipleLength: widget.crossStipleLength,
|
|
crossStipleGap: widget.crossStipleGap,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _KanimajiPainter extends CustomPainter {
|
|
final double progress; // 0..1
|
|
|
|
final List<ui.Path> paths;
|
|
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 Color strokeColor;
|
|
final Color strokeUnfilledColor;
|
|
final double strokeWidth;
|
|
final Color backgroundColor;
|
|
|
|
final bool showBrush;
|
|
final Color brushColor;
|
|
final double brushRadius;
|
|
|
|
final bool showStrokeNumbers;
|
|
final Color strokeNumberColor;
|
|
final Color currentStrokeNumberColor;
|
|
final double strokeNumberFontSize;
|
|
final String? strokeNumberFontFamily;
|
|
|
|
final bool showCross;
|
|
final Color crossColor;
|
|
final double crossStrokeWidth;
|
|
final double crossStipleLength;
|
|
final double crossStipleGap;
|
|
|
|
_KanimajiPainter({
|
|
required this.progress,
|
|
|
|
required this.paths,
|
|
required this.pathLengths,
|
|
required this.pathDurations,
|
|
required this.strokeNumbers,
|
|
|
|
required this.viewBoxWidth,
|
|
required this.viewBoxHeight,
|
|
|
|
required this.strokeColor,
|
|
required this.strokeUnfilledColor,
|
|
required this.strokeWidth,
|
|
required this.backgroundColor,
|
|
|
|
required this.showBrush,
|
|
required this.brushColor,
|
|
required this.brushRadius,
|
|
|
|
required this.showStrokeNumbers,
|
|
required this.strokeNumberColor,
|
|
required this.currentStrokeNumberColor,
|
|
required this.strokeNumberFontSize,
|
|
required this.strokeNumberFontFamily,
|
|
|
|
required this.showCross,
|
|
required this.crossColor,
|
|
required this.crossStrokeWidth,
|
|
required this.crossStipleLength,
|
|
required this.crossStipleGap,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final bgPaint = Paint()..color = backgroundColor;
|
|
canvas.drawRect(Offset.zero & size, bgPaint);
|
|
|
|
if (paths.isEmpty) return;
|
|
|
|
final double sx = size.width / viewBoxWidth;
|
|
final double sy = size.height / viewBoxHeight;
|
|
final double scale = math.min(sx, sy);
|
|
|
|
final double dx = (size.width - viewBoxWidth * scale) / 2.0;
|
|
final double dy = (size.height - viewBoxHeight * scale) / 2.0;
|
|
|
|
final Paint unfilledPaint = Paint()
|
|
..style = PaintingStyle.stroke
|
|
..strokeCap = StrokeCap.round
|
|
..strokeJoin = StrokeJoin.round
|
|
..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 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
|
|
for (
|
|
double y = 0;
|
|
y < size.height;
|
|
y += crossStipleLength + crossStipleGap
|
|
) {
|
|
canvas.drawLine(
|
|
Offset(size.width / 2, y),
|
|
Offset(size.width / 2, math.min(y + crossStipleLength, size.height)),
|
|
crossPaint,
|
|
);
|
|
}
|
|
// Draw horizontal stipled line
|
|
for (
|
|
double x = 0;
|
|
x < size.width;
|
|
x += crossStipleLength + crossStipleGap
|
|
) {
|
|
canvas.drawLine(
|
|
Offset(x, size.height / 2),
|
|
Offset(math.min(x + crossStipleLength, size.width), size.height / 2),
|
|
crossPaint,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Draw all unfilled paths
|
|
for (final path in paths) {
|
|
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 = (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, filledPaint);
|
|
|
|
if (showBrush) {
|
|
final ui.Tangent? tangent = metric.getTangentForOffset(drawLength);
|
|
if (tangent != null) {
|
|
canvas.drawCircle(tangent.position, brushRadius / scale, brushPaint);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw stroke numbers
|
|
if (showStrokeNumbers) {
|
|
final textPainter = TextPainter(
|
|
textAlign: TextAlign.center,
|
|
textDirection: TextDirection.ltr,
|
|
);
|
|
|
|
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,
|
|
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
|
|
bool shouldRepaint(covariant _KanimajiPainter oldDelegate) {
|
|
return oldDelegate.progress != progress ||
|
|
oldDelegate.paths != paths ||
|
|
oldDelegate.strokeNumbers != strokeNumbers ||
|
|
oldDelegate.strokeColor != strokeColor ||
|
|
oldDelegate.strokeUnfilledColor != strokeUnfilledColor ||
|
|
oldDelegate.strokeWidth != strokeWidth ||
|
|
oldDelegate.backgroundColor != backgroundColor ||
|
|
oldDelegate.showBrush != showBrush ||
|
|
oldDelegate.brushColor != brushColor ||
|
|
oldDelegate.brushRadius != brushRadius ||
|
|
oldDelegate.showStrokeNumbers != showStrokeNumbers ||
|
|
oldDelegate.strokeNumberColor != strokeNumberColor ||
|
|
oldDelegate.currentStrokeNumberColor != currentStrokeNumberColor ||
|
|
oldDelegate.strokeNumberFontSize != strokeNumberFontSize ||
|
|
oldDelegate.strokeNumberFontFamily != strokeNumberFontFamily ||
|
|
oldDelegate.showCross != showCross ||
|
|
oldDelegate.crossColor != crossColor ||
|
|
oldDelegate.crossStrokeWidth != crossStrokeWidth ||
|
|
oldDelegate.crossStipleLength != crossStipleLength ||
|
|
oldDelegate.crossStipleGap != crossStipleGap;
|
|
}
|
|
}
|