528 lines
18 KiB
Dart
528 lines
18 KiB
Dart
// // A Flutter widget that renders an animated single-kanji stroke drawing using
|
|
// // the project's SVG path parser. This widget loads the Kanji SVG from
|
|
// // `assets/kanjivg/kanji/<hex>.svg` (the same assets used by the exporter),
|
|
// // parses the path `d` attributes, converts them into `ui.Path` objects and
|
|
// // animates the stroke draw using an AnimationController.
|
|
// //
|
|
// // Notes:
|
|
// // - This implementation uses the repository's SVG path parser (`parsePath`)
|
|
// // and the custom `Path` representation in `lib/svg/path.dart`. We convert
|
|
// // those into Flutter `ui.Path` by sampling points along the sampled SVG path.
|
|
// // - The SVGs in KanjiVG typically use a 109x109 viewBox. We detect the
|
|
// // `viewBox` attribute (if present) and scale paths to fit the widget size.
|
|
// //
|
|
// // Public API (Kanimaji widget parameters):
|
|
// // - `kanji` (required): the single character Kanji to display.
|
|
// // - `size`: target width/height of the widget (square).
|
|
// // - `strokeColor`, `strokeUnfilledColor`, `strokeWidth`.
|
|
// // - `backgroundColor`.
|
|
// // - `speed`: multiplier to speed up (>1) or slow down (<1) the animation.
|
|
// // - `loop`: whether to repeat the animation.
|
|
// // - `showBrush`: whether to show a small circular "brush" tip following the stroke.
|
|
// // - `durationOverride`: optionally override the computed animation duration.
|
|
// //
|
|
// // Example:
|
|
// // Kanimaji(
|
|
// // kanji: '水',
|
|
// // size: 240,
|
|
// // strokeColor: Colors.black,
|
|
// // strokeUnfilledColor: Colors.grey.shade300,
|
|
// // strokeWidth: 3.0,
|
|
// // backgroundColor: Colors.white,
|
|
// // speed: 1.0,
|
|
// // loop: true,
|
|
// // );
|
|
|
|
|
|
// import 'dart:math' as math;
|
|
// import 'dart:ui' as ui;
|
|
// import 'package:flutter/material.dart';
|
|
// import 'package:flutter/services.dart' show rootBundle;
|
|
// import 'package:xml/xml.dart';
|
|
|
|
// import 'svg/parser.dart' show parsePath;
|
|
// import 'svg/path.dart' as svgpath;
|
|
// import 'point.dart' as kpt;
|
|
// import 'animator.dart' show strokeLengthToDuration;
|
|
|
|
// class Kanimaji extends StatefulWidget {
|
|
// final String kanji;
|
|
// final double size;
|
|
// final Color strokeColor;
|
|
// final Color strokeUnfilledColor;
|
|
// final double strokeWidth;
|
|
// final Color backgroundColor;
|
|
// final double speed;
|
|
// final bool loop;
|
|
// final bool showBrush;
|
|
// final Duration? durationOverride;
|
|
|
|
// const Kanimaji({
|
|
// super.key,
|
|
// required this.kanji,
|
|
// this.size = 150,
|
|
// this.strokeColor = Colors.black,
|
|
// this.strokeUnfilledColor = const Color(0xFFEEEEEE),
|
|
// this.strokeWidth = 3.0,
|
|
// this.backgroundColor = Colors.transparent,
|
|
// this.speed = 1.0,
|
|
// this.loop = true,
|
|
// this.showBrush = true,
|
|
// this.durationOverride,
|
|
// });
|
|
|
|
// @override
|
|
// State<Kanimaji> createState() => _KanimajiState();
|
|
// }
|
|
|
|
// class _KanimajiState extends State<Kanimaji> with SingleTickerProviderStateMixin {
|
|
// bool _loaded = false;
|
|
// String? _error;
|
|
// late AnimationController _controller;
|
|
|
|
// // Converted UI paths and per-path lengths
|
|
// List<ui.Path> _paths = [];
|
|
// List<double> _pathLengths = [];
|
|
// // per-stroke durations (in seconds) computed using animator.strokeLengthToDuration
|
|
// List<double> _pathDurations = [];
|
|
|
|
// // viewBox width/height from svg for scaling
|
|
// double _viewBoxWidth = 109.0;
|
|
// double _viewBoxHeight = 109.0;
|
|
|
|
// // total length summation (for drawing metrics)
|
|
// double _totalLength = 0.0;
|
|
|
|
// @override
|
|
// void initState() {
|
|
// super.initState();
|
|
// // Dummy controller; real duration will be set after loading
|
|
// _controller = AnimationController(vsync: this);
|
|
// // Add a single listener once to avoid duplicates when reconfiguring the controller.
|
|
// _controller.addListener(_onTick);
|
|
// _loadAndParseSvg();
|
|
// }
|
|
|
|
// @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();
|
|
// }
|
|
|
|
// String _codepointHex(String s) {
|
|
// // Support multi-code-unit characters by taking the first Unicode rune
|
|
// final runes = s.runes.toList();
|
|
// if (runes.isEmpty) return '00000';
|
|
// return runes[0].toRadixString(16).padLeft(5, '0');
|
|
// }
|
|
|
|
// Future<void> _loadAndParseSvg() async {
|
|
// setState(() {
|
|
// _loaded = false;
|
|
// _error = null;
|
|
// _paths = [];
|
|
// _pathLengths = [];
|
|
// _pathDurations = [];
|
|
// _totalLength = 0.0;
|
|
// });
|
|
|
|
// final hex = _codepointHex(widget.kanji);
|
|
// final assetPath = 'assets/kanjivg/kanji/$hex.svg';
|
|
|
|
// try {
|
|
// final svgString = await rootBundle.loadString(assetPath);
|
|
|
|
// final doc = XmlDocument.parse(svgString);
|
|
|
|
// // parse viewBox: "minX minY width height"
|
|
// final viewBoxAttr = doc.rootElement.getAttribute('viewBox');
|
|
// if (viewBoxAttr != null) {
|
|
// final parts = viewBoxAttr.split(RegExp(r'[\s,]+')).where((p) => p.isNotEmpty).toList();
|
|
// if (parts.length >= 4) {
|
|
// final w = double.tryParse(parts[2]);
|
|
// final h = double.tryParse(parts[3]);
|
|
// if (w != null && h != null && w > 0 && h > 0) {
|
|
// _viewBoxWidth = w;
|
|
// _viewBoxHeight = h;
|
|
// }
|
|
// }
|
|
// } else {
|
|
// // fallback to width/height attributes if present (may include "px")
|
|
// final widthAttr = doc.rootElement.getAttribute('width');
|
|
// final heightAttr = doc.rootElement.getAttribute('height');
|
|
// if (widthAttr != null && heightAttr != null) {
|
|
// final w = double.tryParse(widthAttr.replaceAll('px', '').trim());
|
|
// final h = double.tryParse(heightAttr.replaceAll('px', '').trim());
|
|
// if (w != null && h != null && w > 0 && h > 0) {
|
|
// _viewBoxWidth = w;
|
|
// _viewBoxHeight = h;
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
// // Find all <path ... d="..."> elements inside the SVG. KanjiVG groups strokes in <g>.
|
|
// final pathElements = doc.findAllElements('path', namespace: doc.rootElement.name.namespaceUri).toList();
|
|
|
|
// if (pathElements.isEmpty) {
|
|
// throw Exception('No <path> elements found in $assetPath');
|
|
// }
|
|
|
|
// final uiPaths = <ui.Path>[];
|
|
// final lengths = <double>[];
|
|
|
|
// for (final p in pathElements) {
|
|
// final d = p.getAttribute('d');
|
|
// if (d == null || d.trim().isEmpty) continue;
|
|
|
|
// // Parse the SVG path using existing parser
|
|
// final svgParsed = parsePath(d); // returns svgpath.Path (project type)
|
|
|
|
// // Convert to ui.Path using direct segment conversion (no sampling)
|
|
// final uiPath = _svgPathToUiPath(svgParsed);
|
|
// if (uiPath == null) continue;
|
|
|
|
// // Compute its drawing length using PathMetrics (for extractPath)
|
|
// final pm = uiPath.computeMetrics();
|
|
// double pathLen = 0.0;
|
|
// for (final metric in pm) {
|
|
// pathLen += metric.length;
|
|
// }
|
|
// // If the path metric yields zero (rare), skip
|
|
// if (pathLen <= 0) continue;
|
|
|
|
// // Compute logical stroke length using the parser (precise geometry) and convert to duration
|
|
// final double logicalLen = svgParsed.size(error: 1e-8).toDouble();
|
|
// final double durationSec = strokeLengthToDuration(logicalLen);
|
|
|
|
// uiPaths.add(uiPath);
|
|
// lengths.add(pathLen);
|
|
// _pathDurations.add(durationSec);
|
|
// _totalLength += pathLen;
|
|
// }
|
|
|
|
// if (uiPaths.isEmpty) {
|
|
// throw Exception('No drawable paths found in $assetPath');
|
|
// }
|
|
|
|
// setState(() {
|
|
// _paths = uiPaths;
|
|
// _pathLengths = lengths;
|
|
// });
|
|
|
|
// _configureController();
|
|
|
|
// setState(() {
|
|
// _loaded = true;
|
|
// });
|
|
// } catch (e) {
|
|
// setState(() {
|
|
// _error = 'Error loading/ parsing $assetPath: $e';
|
|
// _loaded = true; // mark loaded so we can show error
|
|
// });
|
|
// }
|
|
// }
|
|
|
|
// // Convert a parsed SVG path (project Path) into a Flutter ui.Path without sampling.
|
|
// // Uses the concrete segment types (Move, Line, CubicBezier, QuadraticBezier, Arc, Close)
|
|
// // and maps them to the corresponding ui.Path methods for native, accurate drawing.
|
|
// ui.Path? _svgPathToUiPath(svgpath.Path svgP) {
|
|
// try {
|
|
// final ui.Path p = ui.Path();
|
|
// bool started = false;
|
|
|
|
// for (final seg in svgP) {
|
|
// if (seg is svgpath.Move) {
|
|
// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble());
|
|
// started = true;
|
|
// } else if (seg is svgpath.Line) {
|
|
// if (!started) {
|
|
// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble());
|
|
// started = true;
|
|
// }
|
|
// p.lineTo(seg.end.x.toDouble(), seg.end.y.toDouble());
|
|
// } else if (seg is svgpath.Close) {
|
|
// p.close();
|
|
// } else if (seg is svgpath.CubicBezier) {
|
|
// if (!started) {
|
|
// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble());
|
|
// started = true;
|
|
// }
|
|
// p.cubicTo(
|
|
// seg.control1.x.toDouble(),
|
|
// seg.control1.y.toDouble(),
|
|
// seg.control2.x.toDouble(),
|
|
// seg.control2.y.toDouble(),
|
|
// seg.end.x.toDouble(),
|
|
// seg.end.y.toDouble(),
|
|
// );
|
|
// } else if (seg is svgpath.QuadraticBezier) {
|
|
// if (!started) {
|
|
// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble());
|
|
// started = true;
|
|
// }
|
|
// p.quadraticBezierTo(
|
|
// seg.control.x.toDouble(),
|
|
// seg.control.y.toDouble(),
|
|
// seg.end.x.toDouble(),
|
|
// seg.end.y.toDouble(),
|
|
// );
|
|
// } else if (seg is svgpath.Arc) {
|
|
// if (!started) {
|
|
// p.moveTo(seg.start.x.toDouble(), seg.start.y.toDouble());
|
|
// started = true;
|
|
// }
|
|
// // Map SVG Arc to Flutter arcToPoint. rotation is in degrees in svg; arcToPoint expects radians.
|
|
// final radius = Radius.elliptical(seg.radius.x.toDouble(), seg.radius.y.toDouble());
|
|
// p.arcToPoint(
|
|
// Offset(seg.end.x.toDouble(), seg.end.y.toDouble()),
|
|
// radius: radius,
|
|
// rotation: seg.rotation.toDouble() * (math.pi / 180.0),
|
|
// largeArc: seg.arc,
|
|
// clockwise: seg.sweep,
|
|
// );
|
|
// } else {
|
|
// // Unknown segment fallback: try to move to end
|
|
// try {
|
|
// p.moveTo(seg.end.x.toDouble(), seg.end.y.toDouble());
|
|
// started = true;
|
|
// } catch (_) {}
|
|
// }
|
|
// }
|
|
|
|
// return p;
|
|
// } catch (e) {
|
|
// return null;
|
|
// }
|
|
// }
|
|
|
|
// void _configureController() {
|
|
// // Use per-stroke durations computed from the parsed geometry (strokeLengthToDuration)
|
|
// // Sum durations and scale by widget.speed. If durationOverride is provided use it.
|
|
// 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());
|
|
|
|
// final bool shouldRepeat = widget.loop;
|
|
|
|
// _controller.stop();
|
|
// _controller.duration = duration;
|
|
// if (shouldRepeat) {
|
|
// _controller.repeat();
|
|
// } else {
|
|
// _controller.forward(from: 0);
|
|
// }
|
|
// // Repaint handled by the single listener added in initState.
|
|
// }
|
|
|
|
// @override
|
|
// Widget build(BuildContext context) {
|
|
// if (!_loaded) {
|
|
// return SizedBox(
|
|
// width: widget.size,
|
|
// height: widget.size,
|
|
// child: Center(child: CircularProgressIndicator()),
|
|
// );
|
|
// }
|
|
// if (_error != null) {
|
|
// return SizedBox(
|
|
// width: widget.size,
|
|
// height: widget.size,
|
|
// child: Container(
|
|
// color: widget.backgroundColor,
|
|
// alignment: Alignment.center,
|
|
// child: Text(
|
|
// 'Error',
|
|
// style: TextStyle(color: Colors.red, fontSize: math.max(10, widget.size * 0.08)),
|
|
// ),
|
|
// ),
|
|
// );
|
|
// }
|
|
|
|
// return SizedBox(
|
|
// width: widget.size,
|
|
// height: widget.size,
|
|
// child: CustomPaint(
|
|
// painter: _KanimajiPainter(
|
|
// paths: _paths,
|
|
// 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,
|
|
// ),
|
|
// ),
|
|
// );
|
|
// }
|
|
// }
|
|
|
|
// class _KanimajiPainter extends CustomPainter {
|
|
// final List<ui.Path> paths;
|
|
// final List<double> pathLengths;
|
|
// final List<double> pathDurations; // per-stroke durations in seconds
|
|
// final double progress; // 0..1
|
|
// final Color strokeColor;
|
|
// final Color strokeUnfilledColor;
|
|
// final double strokeWidth;
|
|
// final Color backgroundColor;
|
|
// final double viewBoxWidth;
|
|
// final double viewBoxHeight;
|
|
// final bool showBrush;
|
|
|
|
// _KanimajiPainter({
|
|
// required this.paths,
|
|
// required this.pathLengths,
|
|
// required this.pathDurations,
|
|
// required this.progress,
|
|
// required this.strokeColor,
|
|
// required this.strokeUnfilledColor,
|
|
// required this.strokeWidth,
|
|
// required this.backgroundColor,
|
|
// required this.viewBoxWidth,
|
|
// required this.viewBoxHeight,
|
|
// required this.showBrush,
|
|
// });
|
|
|
|
// @override
|
|
// void paint(Canvas canvas, Size size) {
|
|
// // Paint background
|
|
// final bgPaint = Paint()..color = backgroundColor;
|
|
// canvas.drawRect(Offset.zero & size, bgPaint);
|
|
|
|
// if (paths.isEmpty) return;
|
|
|
|
// // compute scale to fit viewBox into size, preserving aspect ratio
|
|
// final double sx = size.width / viewBoxWidth;
|
|
// final double sy = size.height / viewBoxHeight;
|
|
// final double scale = math.min(sx, sy);
|
|
|
|
// // compute translation to center the drawing
|
|
// 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);
|
|
|
|
// // paints
|
|
// 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;
|
|
|
|
// // 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 metrics2 = path.computeMetrics().toList();
|
|
// double rem = coverage * len;
|
|
// for (final metric in metrics2) {
|
|
// if (rem <= 0) break;
|
|
// if (rem <= metric.length + 1e-9) {
|
|
// final tangent = metric.getTangentForOffset(rem.clamp(0.0, metric.length));
|
|
// if (tangent != null) {
|
|
// final Offset pos = tangent.position;
|
|
// final double brushRadius = (strokeWidth * 0.9) / scale;
|
|
// final Paint brushPaint = Paint()..color = strokeColor;
|
|
// canvas.drawCircle(pos, brushRadius, brushPaint);
|
|
// }
|
|
// break;
|
|
// } else {
|
|
// rem -= metric.length;
|
|
// }
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
// 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;
|
|
// }
|
|
// }
|