349 lines
10 KiB
Dart
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;
|
|
}
|
|
}
|