Last active
April 8, 2025 04:04
-
-
Save ltvu93/cf896869e73d8df74283e48b8bc16e43 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:math'; | |
import 'dart:ui'; | |
import 'package:flutter/material.dart'; | |
void main() { | |
runApp( | |
const MaterialApp( | |
home: TextAnimatedTextScreen(), | |
), | |
); | |
} | |
class TextAnimatedTextScreen extends StatelessWidget { | |
const TextAnimatedTextScreen({super.key}); | |
final text = | |
'This is a very long text that will be broken into multiple lines based on the screen width. Each line will be positioned individually in a Stack widget using LineMetrics to calculate the exact position. You can add more text here to see how it wraps and positions each line.'; | |
@override | |
Widget build(BuildContext context) { | |
return Scaffold( | |
appBar: AppBar( | |
title: const Text('Animated Pharse Text'), | |
), | |
body: SafeArea( | |
child: Padding( | |
padding: const EdgeInsets.all(16.0), | |
child: Center( | |
child: AnimatedPharseText( | |
text: text, | |
startStyle: TextStyle(fontSize: 16), | |
endStyle: TextStyle(fontSize: 20), | |
), | |
), | |
), | |
), | |
); | |
} | |
} | |
class AnimatedPharseText extends StatefulWidget { | |
final String text; | |
final TextStyle startStyle; | |
final TextStyle endStyle; | |
const AnimatedPharseText({ | |
super.key, | |
required this.text, | |
required this.startStyle, | |
required this.endStyle, | |
}); | |
@override | |
State<AnimatedPharseText> createState() => _AnimatedPharseTextState(); | |
} | |
class _AnimatedPharseTextState extends State<AnimatedPharseText> { | |
late TextPainter _startTextPainter; | |
late TextPainter _endTextPainter; | |
List<AnimatedPharse> _animatedPharses = []; | |
double _animatedValue = 0; | |
@override | |
void didChangeDependencies() { | |
super.didChangeDependencies(); | |
_startTextPainter = TextPainter( | |
text: TextSpan( | |
text: widget.text, | |
style: _mergeTextStyle(context, widget.startStyle), | |
), | |
textDirection: TextDirection.ltr, | |
); | |
_endTextPainter = TextPainter( | |
text: TextSpan( | |
text: widget.text, | |
style: _mergeTextStyle(context, widget.endStyle), | |
), | |
textDirection: TextDirection.ltr, | |
); | |
} | |
@override | |
void dispose() { | |
_startTextPainter.dispose(); | |
_endTextPainter.dispose(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return LayoutBuilder( | |
builder: (context, constraints) { | |
final width = constraints.maxWidth; | |
_startTextPainter.layout(maxWidth: width); | |
final startTextRanges = _calculateLineTextRanges(_startTextPainter); | |
_endTextPainter.layout(maxWidth: width); | |
final endTextRanges = _calculateLineTextRanges(_endTextPainter); | |
final intersectTextRanges = _calculateIntersectTextRanges(startTextRanges, endTextRanges); | |
_animatedPharses = _calculateAnimatedPharses(intersectTextRanges); | |
final widgetWidth = max(_startTextPainter.width, _endTextPainter.width); | |
final widgetHeight = max(_startTextPainter.height, _endTextPainter.height); | |
return Column( | |
children: [ | |
SizedBox( | |
width: widgetWidth, | |
height: widgetHeight, | |
child: Stack( | |
children: [ | |
..._animatedPharses.map((pharse) { | |
return AnimatedPositioned( | |
duration: const Duration(milliseconds: 500), | |
left: lerpDouble( | |
pharse.startRect.left, | |
pharse.endRect.left, | |
_animatedValue / 100, | |
), | |
top: lerpDouble( | |
pharse.startRect.top, | |
pharse.endRect.top, | |
_animatedValue / 100, | |
), | |
child: Text( | |
pharse.text, | |
style: TextStyle.lerp( | |
widget.startStyle, | |
widget.endStyle, | |
_animatedValue / 100, | |
), | |
), | |
); | |
}), | |
], | |
), | |
), | |
const SizedBox(height: 10), | |
Slider( | |
value: _animatedValue, | |
min: 0, | |
max: 100, | |
divisions: 100, | |
label: _animatedValue.round().toString(), | |
onChanged: (value) { | |
setState(() { | |
_animatedValue = value; | |
}); | |
}, | |
), | |
], | |
); | |
}, | |
); | |
} | |
TextStyle _mergeTextStyle(BuildContext context, TextStyle style) { | |
if (style.inherit) { | |
return DefaultTextStyle.of(context).style.merge(style); | |
} | |
return style; | |
} | |
List<TextRange> _calculateLineTextRanges(TextPainter textPainter) { | |
final lineMetrics = textPainter.computeLineMetrics(); | |
final List<TextRange> textRanges = []; | |
for (final lineMetric in lineMetrics) { | |
final baseline = lineMetric.baseline; | |
final left = lineMetric.left; | |
final right = left + lineMetric.width; | |
final startPosition = textPainter.getPositionForOffset(Offset(left, baseline)); | |
final endPosition = textPainter.getPositionForOffset(Offset(right, baseline)); | |
textRanges.add(TextRange(start: startPosition.offset, end: endPosition.offset)); | |
} | |
return textRanges; | |
} | |
List<TextRange> _calculateIntersectTextRanges(List<TextRange> textRanges1, List<TextRange> textRanges2) { | |
List<TextRange> result = []; | |
int i = 0, j = 0; | |
while (i < textRanges1.length && j < textRanges2.length) { | |
final textRange1 = textRanges1[i]; | |
final textRange2 = textRanges2[j]; | |
if (textRange1.end >= textRange2.start && textRange2.end >= textRange1.start) { | |
result.add(TextRange( | |
start: max(textRange1.start, textRange2.start), | |
end: min(textRange1.end, textRange2.end), | |
)); | |
} | |
if (textRange1.end < textRange2.end) { | |
i++; | |
} else { | |
j++; | |
} | |
} | |
return result; | |
} | |
List<AnimatedPharse> _calculateAnimatedPharses(List<TextRange> intersectTextRanges) { | |
final animatedPharses = <AnimatedPharse>[]; | |
for (final intersection in intersectTextRanges) { | |
final startTextBoxes = _startTextPainter.getBoxesForSelection( | |
TextSelection(baseOffset: intersection.start, extentOffset: intersection.end), | |
boxHeightStyle: BoxHeightStyle.max, | |
boxWidthStyle: BoxWidthStyle.max, | |
); | |
final endTextBoxes = _endTextPainter.getBoxesForSelection( | |
TextSelection(baseOffset: intersection.start, extentOffset: intersection.end), | |
boxHeightStyle: BoxHeightStyle.max, | |
boxWidthStyle: BoxWidthStyle.max, | |
); | |
final startRect = startTextBoxes.first.toRect(); | |
final endRect = endTextBoxes.first.toRect(); | |
animatedPharses.add( | |
AnimatedPharse( | |
startRect: startRect, | |
endRect: endRect, | |
text: widget.text.substring(intersection.start, intersection.end), | |
), | |
); | |
} | |
return animatedPharses; | |
} | |
} | |
class AnimatedPharse { | |
final Rect startRect; | |
final Rect endRect; | |
final String text; | |
AnimatedPharse({ | |
required this.startRect, | |
required this.endRect, | |
required this.text, | |
}); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment