import 'dart:math'; import 'package:aves/utils/diff_match.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import 'package:tuple/tuple.dart'; class AnimatedDiffText extends StatefulWidget { final String text; final TextStyle? textStyle; final StrutStyle? strutStyle; final Curve curve; final Duration duration; const AnimatedDiffText( this.text, { super.key, this.textStyle, this.strutStyle, this.curve = Curves.easeInOutCubic, required this.duration, }); @override State createState() => _AnimatedDiffTextState(); } class _AnimatedDiffTextState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _animation; final List<_TextDiff> _diffs = []; @override void initState() { super.initState(); _controller = AnimationController( duration: widget.duration, vsync: this, ); _animation = CurvedAnimation(parent: _controller, curve: widget.curve); } @override void didChangeDependencies() { super.didChangeDependencies(); _computeDiff(widget.text, widget.text); } @override void didUpdateWidget(covariant AnimatedDiffText oldWidget) { super.didUpdateWidget(oldWidget); final oldText = oldWidget.text; final newText = widget.text; if (oldText != newText) { _computeDiff(oldText, newText); _controller.forward(from: 0); } } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _animation, builder: (context, child) { return Text.rich( TextSpan( children: _diffs.map((diff) { final oldText = diff.item1; final newText = diff.item2; final oldSize = diff.item3; final newSize = diff.item4; final text = (_animation.value == 0 ? oldText : newText) ?? ''; return WidgetSpan( child: AnimatedSize( key: ValueKey(diff), curve: widget.curve, duration: widget.duration, child: AnimatedSwitcher( duration: widget.duration, switchInCurve: widget.curve, switchOutCurve: widget.curve, layoutBuilder: (currentChild, previousChildren) { return Stack( alignment: Alignment.center, children: [ ...previousChildren.map( (child) => ConstrainedBox( constraints: BoxConstraints.tight(Size( min(oldSize.width, newSize.width), min(oldSize.height, newSize.height), )), child: child, ), ), if (currentChild != null) currentChild, ], ); }, child: Text( text, key: Key(text), ), ), ), ); }).toList(), ), strutStyle: widget.strutStyle, ); }, ); } Size textSize(String text) { final para = RenderParagraph( TextSpan(text: text, style: widget.textStyle), textDirection: Directionality.of(context), textScaleFactor: MediaQuery.textScaleFactorOf(context), strutStyle: widget.strutStyle, )..layout(const BoxConstraints(), parentUsesSize: true); final width = para.getMaxIntrinsicWidth(double.infinity); final height = para.getMaxIntrinsicHeight(double.infinity); return Size(width, height); } // use an adaptation of Google's `Diff Match and Patch` // as package `diffutil_dart` (as of v3.0.0) is unreliable void _computeDiff(String oldText, String newText) { final oldCharacters = oldText.characters.join(); final newCharacters = newText.characters.join(); final dmp = DiffMatchPatch(); final d = dmp.diff_main(oldCharacters, newCharacters); dmp.diff_cleanupSemantic(d); _diffs ..clear() ..addAll(d.map((diff) { final text = diff.text; final size = textSize(text); switch (diff.operation) { case Operation.delete: return Tuple4(text, null, size, Size.zero); case Operation.insert: return Tuple4(null, text, Size.zero, size); case Operation.equal: default: return Tuple4(text, text, size, size); } }).fold>([], (prev, v) { if (prev.isNotEmpty) { final last = prev.last; final prevNewText = last.item2; if (prevNewText == null) { // previous diff is a deletion final thisOldText = v.item1; if (thisOldText == null) { // this diff is an insertion // merge deletion and insertion as a change operation final change = Tuple4(last.item1, v.item2, last.item3, v.item4); return [...prev.take(prev.length - 1), change]; } } } return [...prev, v]; })); } } typedef _TextDiff = Tuple4;