Files
kanimaji-dart/lib/widget.dart
T
2026-02-22 15:06:48 +09:00

349 lines
10 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);
}
class Kanimaji extends StatefulWidget {
final String kanji;
// TODO: add support for drawing stipled cross
final bool loop;
final bool showBrush;
final Color brushColor;
final double brushRadius;
final Color strokeColor;
final Color strokeUnfilledColor;
final double strokeWidth;
final Color backgroundColor;
final FutureOr<KanjiVGItem> Function(String kanji) kanjiDataProvider;
// TODO: add support for configuring a kanji data provider
// final double speed;
// final bool loop;
// final Duration? durationOverride;
const Kanimaji({
super.key,
required this.kanji,
this.loop = true,
this.strokeColor = Colors.black,
this.strokeUnfilledColor = const Color(0xFFEEEEEE),
this.strokeWidth = 3.0,
this.backgroundColor = Colors.transparent,
this.showBrush = true,
this.brushColor = Colors.red,
this.brushRadius = 4.0,
this.kanjiDataProvider = _defaultKanjiDataProvider,
});
@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;
// // total length summation (for drawing metrics)
// double _totalLength = 0.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 kanji changed, re-load
// if (oldWidget.kanji != widget.kanji) {
// _loadAndParseSvg();
// } else if (oldWidget.speed != widget.speed ||
// oldWidget.durationOverride != widget.durationOverride ||
// oldWidget.loop != widget.loop) {
// // Update controller if animation parameters changed
// _configureController();
// }
// }
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);
// final double scaledTotalSec = (widget.speed <= 0) ? totalSec : (totalSec / widget.speed);
// final Duration duration = widget.durationOverride ??
// Duration(milliseconds: (math.max(0.001, scaledTotalSec) * 1000.0).round());
_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,
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,
),
),
),
);
}
}
class _KanimajiPainter extends CustomPainter {
final double progress; // 0..1
final List<ui.Path> paths;
final List<double> pathLengths;
final List<double> pathDurations;
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;
_KanimajiPainter({
required this.progress,
required this.paths,
required this.pathLengths,
required this.pathDurations,
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,
});
@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;
canvas.save();
canvas.translate(dx, dy);
canvas.scale(scale, scale);
final Paint unfilledPaint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..strokeWidth =
strokeWidth /
scale // scale stroke width so it looks consistent
..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.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..strokeWidth = brushRadius / scale
..color = brushColor;
// // 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);
double elapsedTime = 0.0; // seconds accumulated so far
for (int i = 0; i < paths.length; i++) {
final ui.Path path = paths[i];
final double len = pathLengths[i];
final double dur = (i < pathDurations.length) ? pathDurations[i] : 0.0;
final double startFraction = totalTime == 0
? 0.0
: (elapsedTime / totalTime);
final double endFraction = totalTime == 0
? 1.0
: ((elapsedTime + dur) / totalTime);
// Draw background (unfilled) fully for this stroke
canvas.drawPath(path, unfilledPaint);
// Determine coverage for this stroke based on global progress (time-based)
double coverage = 0.0;
if (p >= endFraction) {
coverage = 1.0;
} else if (p <= startFraction || dur <= 0.0) {
coverage = 0.0;
} else {
coverage = (p - startFraction) / (endFraction - startFraction);
}
if (coverage > 0.0) {
// extract subpath up to coverage * len (length-based extraction for drawing)
final metrics = path.computeMetrics().toList();
final ui.Path drawn = ui.Path();
double remain = coverage * len;
for (final metric in metrics) {
if (remain <= 0) break;
final take = math.min(metric.length, remain);
final sub = metric.extractPath(0.0, take);
drawn.addPath(sub, Offset.zero);
remain -= take;
}
// draw filled portion
canvas.drawPath(drawn, filledPaint);
// brush tip
if (showBrush && coverage > 0.0 && coverage < 1.0) {
// find the tangent at the very end of the drawn part
final lastMetric = metrics.lastWhere(
(m) => m.length >= coverage * len,
);
final tangent = lastMetric.getTangentForOffset(coverage * len);
if (tangent != null) {
canvas.drawCircle(
tangent.position,
brushRadius / scale,
brushPaint,
);
}
}
}
elapsedTime += dur;
}
canvas.restore();
}
@override
bool shouldRepaint(covariant _KanimajiPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.paths != paths ||
oldDelegate.strokeColor != strokeColor ||
oldDelegate.strokeUnfilledColor != strokeUnfilledColor ||
oldDelegate.strokeWidth != strokeWidth ||
oldDelegate.backgroundColor != backgroundColor ||
oldDelegate.showBrush != showBrush ||
oldDelegate.brushColor != brushColor ||
oldDelegate.brushRadius != brushRadius;
}
}