text_morph_flutter 1.1.0
text_morph_flutter: ^1.1.0 copied to clipboard
Shape-level text morphing animations for Flutter, built on glyph_path and glyph_path_flutter — animates one string's glyph outlines into another's.
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:glyph_path/glyph_path.dart';
import 'package:text_morph_flutter/text_morph_flutter.dart';
import 'app_logo_demo.dart';
import 'button_state_demo.dart';
import 'icon_morph_demo.dart';
import 'onboarding_demo.dart';
import 'quote_carousel_demo.dart';
import 'search_suggestion_demo.dart';
import 'ticker_demo.dart';
void main() {
runApp(const TextMorphDemoApp());
}
class TextMorphDemoApp extends StatelessWidget {
const TextMorphDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'text_morph_flutter Demo',
theme: ThemeData(colorSchemeSeed: Colors.deepPurple),
home: const DemoPage(),
);
}
}
/// One row in [DemoPage]'s gallery: an icon/title/subtitle plus the page it
/// opens.
class _DemoEntry {
const _DemoEntry(this.icon, this.title, this.subtitle, this.builder);
final IconData icon;
final String title;
final String subtitle;
final WidgetBuilder builder;
}
final List<_DemoEntry> _demoEntries = <_DemoEntry>[
_DemoEntry(
Icons.view_carousel,
'Onboarding walkthrough',
'Multi-line TextSource, textAlign, color morph',
(_) => const OnboardingDemoPage(),
),
_DemoEntry(
Icons.auto_fix_high,
'Icon / complex-path morph',
'ShapeSource.fromPath, GlyphAlignment.wholePath',
(_) => const IconMorphDemoPage(),
),
_DemoEntry(
Icons.touch_app,
'Send button state',
'TextSource ⇄ ShapeSource, wholePath',
(_) => const ButtonStateDemoPage(),
),
_DemoEntry(
Icons.speed,
'Dashboard ticker',
'GlyphAlignment.diff, MorphCache',
(_) => const TickerDemoPage(),
),
_DemoEntry(
Icons.search,
'Search suggestion',
'GlyphAlignment.diff, MorphCache',
(_) => const SearchSuggestionDemoPage(),
),
_DemoEntry(
Icons.format_quote,
'Quote carousel',
'Multi-line maxWidth, textAlign',
(_) => const QuoteCarouselDemoPage(),
),
_DemoEntry(
Icons.auto_awesome,
'App logo reveal',
'Shape-cloud burst, big ShapeSource mark',
(_) => const AppLogoDemoPage(),
),
_DemoEntry(
Icons.tune,
'Playground',
'Every MorphOptions knob, live presets',
(_) => const PlaygroundPage(),
),
];
/// The app's landing screen: a gallery of every demo (see [_demoEntries]),
/// including the raw options [PlaygroundPage] as its own entry rather than
/// the default view — so a first-time visitor sees the polished demos
/// before the "every slider" test harness.
class DemoPage extends StatelessWidget {
const DemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('text_morph_flutter Demo')),
body: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _demoEntries.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (BuildContext context, int index) {
final _DemoEntry entry = _demoEntries[index];
return ListTile(
leading: Icon(entry.icon),
title: Text(entry.title),
subtitle: Text(entry.subtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute(builder: entry.builder)),
);
},
),
);
}
}
/// One choice in the presets row: a way to build a [MorphSource] (text or
/// shape), plus the label shown on its chip. [text] is non-null only for
/// text presets, so tapping the chip can also sync the text field.
class _Preset {
const _Preset(this.label, this.build, {this.text});
final String label;
final MorphSource Function(Font font) build;
final String? text;
}
_Preset _textPreset(String text) => _Preset(
text,
(Font font) => TextSource(font: font, text: text),
text: text,
);
// A few pairs chosen to exercise specific behaviors: "Application"/"Company"
// and "cats"/"carts" show off GlyphAlignment.diff (shared letters stay put
// instead of morphing into something else — try the "Idle pulse" slider on
// "cat"/"cart" to give those static letters some shared motion); "Hi"/"World"
// and "i"/"a" show off pop-in/out and hole handling when glyph/contour
// counts differ. "★ star"/"◎ ring" show off ShapeSource: morphing text into
// an arbitrary vector shape (and back) through the exact same MorphSource
// interface, including a ring's hole being classified correctly.
final List<_Preset> _presets = <_Preset>[
_textPreset('Hello'),
_textPreset('World'),
_textPreset('Flutter'),
_textPreset('Morph'),
_textPreset('Application'),
_textPreset('Company'),
_textPreset('cats'),
_textPreset('carts'),
_textPreset('cat'),
_textPreset('cart'),
_textPreset('Hi'),
_textPreset('i'),
_textPreset('a'),
_Preset('★ star', (Font font) => _starShape),
_Preset('◎ ring', (Font font) => _ringShape),
];
const double _shapeRadius = 50;
final ShapeSource _starShape = _buildStarShape();
final ShapeSource _ringShape = _buildRingShape();
/// Builds a 5-pointed star as a single contour. Demonstrates the simplest
/// [ShapeSource] case: one solid contour, no holes.
ShapeSource _buildStarShape() {
const int points = 5;
const double innerRadius = _shapeRadius * 0.38;
// Offset.fromDirection measures its angle clockwise assuming a Y-down
// screen space, but ShapeSource contours use Y-up (matching glyph_path's
// font convention) — so +pi/2, not -pi/2, is what actually points the
// first vertex straight up here.
final List<Offset> vertices = <Offset>[
for (int i = 0; i < points * 2; i++)
Offset.fromDirection(
i * math.pi / points + math.pi / 2,
i.isEven ? _shapeRadius : innerRadius,
),
];
return ShapeSource(
<Contour>[_polygonContour(vertices)],
sourceBounds: const Rect.fromLTRB(
-_shapeRadius,
-_shapeRadius,
_shapeRadius,
_shapeRadius,
),
);
}
/// Builds a ring (an outer circle with a smaller circle cut out of it) as
/// two contours wound in opposite directions. Demonstrates that
/// [ShapeSource] contours are classified into solid/hole role groups (see
/// [PathMorph]) exactly like a font glyph's own contours are.
ShapeSource _buildRingShape() {
const double innerRadius = _shapeRadius * 0.55;
const int segments = 48;
return ShapeSource(
<Contour>[
_polygonContour(_circlePoints(_shapeRadius, segments)),
_polygonContour(_circlePoints(innerRadius, segments).reversed.toList()),
],
sourceBounds: const Rect.fromLTRB(
-_shapeRadius,
-_shapeRadius,
_shapeRadius,
_shapeRadius,
),
);
}
List<Offset> _circlePoints(double radius, int segments) {
return <Offset>[
for (int i = 0; i < segments; i++)
Offset.fromDirection(2 * math.pi * i / segments, radius),
];
}
Contour _polygonContour(List<Offset> vertices) {
final List<PathCommand> commands = <PathCommand>[
MoveTo(vertices.first.dx, vertices.first.dy),
for (final Offset v in vertices.skip(1)) LineTo(v.dx, v.dy),
ClosePath(),
];
final ({WindingDirection winding, double signedArea}) w =
computeWindingResult(commands);
return Contour(
commands: commands,
winding: w.winding,
signedArea: w.signedArea,
);
}
/// The raw options test harness — every [MorphOptions] knob on sliders, plus
/// preset text/shape pairs — reachable from [DemoPage]'s gallery rather than
/// being the app's default landing screen.
class PlaygroundPage extends StatefulWidget {
const PlaygroundPage({super.key});
@override
State<PlaygroundPage> createState() => _PlaygroundPageState();
}
class _PlaygroundPageState extends State<PlaygroundPage> {
final _controller = TextEditingController(text: _presets.first.text);
Font? _font;
// Exactly one of these is meaningful at a time: a selected preset
// (`_selectedPresetIndex`) or a submitted custom string (`_customText`).
int? _selectedPresetIndex = 0;
String? _customText;
double _fontSize = 96;
Duration _duration = const Duration(milliseconds: 600);
MorphStyle _style = MorphStyle.shape;
GlyphAlignment _alignment = GlyphAlignment.diff;
double _stagger = 0.0;
double _contourTimingOffset = 0.0;
double _holeAreaRatioThreshold = 0.02;
double _dissimilarityThreshold = 0.6;
bool _useAreaCorrectedPop = true;
// 0 means "disabled" (mapped to null below); the slider can't otherwise
// distinguish "off" from a real threshold value.
double _lengthMismatchFallback = 0;
double _idlePulseAmount = 0.0;
double _selfWobbleDetour = 0.0;
Color _fillColor = Colors.deepPurple;
bool _strokeEnabled = false;
Color _strokeColor = Colors.orange;
double _strokeWidth = 2.0;
Color _beforeColor = Colors.black;
Color _afterColor = Colors.blue;
final _beforeTextController = TextEditingController(text: 'Hello');
final _afterTextController = TextEditingController(text: 'World');
bool _colorMorphPlaying = false;
// Forces Morph's duration to zero for one update — see
// _playBeforeAfterDemo: switching to "before" should snap instantly, only
// the "before" -> "after" step should actually morph.
bool _snappingToBefore = false;
// Rolled forward on every text change so the wobble detour doesn't
// replay the identical lobe count/phase each time the same transition
// is triggered again — see MorphOptions.selfWobbleSeed.
int _wobbleSeed = 0;
final _wobbleRandom = math.Random();
MorphOptions get _options => MorphOptions(
staggerCurve: Curves.linear,
contourTimingOffset: _contourTimingOffset,
holeAreaRatioThreshold: _holeAreaRatioThreshold,
dissimilarityThreshold: _dissimilarityThreshold,
lengthMismatchFallback: _lengthMismatchFallback > 0
? _lengthMismatchFallback.round()
: null,
useAreaCorrectedPop: _useAreaCorrectedPop,
idlePulseAmount: _idlePulseAmount,
selfWobbleDetour: _selfWobbleDetour,
selfWobbleSeed: _wobbleSeed,
alignment: _alignment,
);
@override
void initState() {
super.initState();
_loadFont();
}
Future<void> _loadFont() async {
final ByteData data = await rootBundle.load(
'assets/fonts/NotoSans-Regular.ttf',
);
if (!mounted) return;
setState(() => _font = Font.parse(data.buffer.asUint8List()));
}
void _selectPreset(int index) {
final _Preset preset = _presets[index];
setState(() {
_selectedPresetIndex = index;
_customText = null;
// A literal, not `1 << 32`: that shift compiles to 0 under dart2js,
// which then fails Random.nextInt's `0 < max` precondition on web.
_wobbleSeed = _wobbleRandom.nextInt(4294967296);
});
if (preset.text != null) _controller.text = preset.text!;
}
void _submitCustomText(String text) {
if (text.isEmpty) return;
setState(() {
_customText = text;
_selectedPresetIndex = null;
// A literal, not `1 << 32`: that shift compiles to 0 under dart2js,
// which then fails Random.nextInt's `0 < max` precondition on web.
_wobbleSeed = _wobbleRandom.nextInt(4294967296);
});
}
/// Snaps *instantly* (via a one-off `duration: Duration.zero`, see
/// `_snappingToBefore`) to the "before" text/color — so the demo always
/// starts from a known state, however it was last left, without that snap
/// itself playing as a morph — then, once settled, changes *both* text and
/// color to their "after" values in the same `setState` so the real morph
/// plays with the shape and color animating on the same timeline. That
/// simultaneous change is the whole point of this demo: color alone
/// animating doesn't show that it's tied to the shape morph the way a
/// combined text+color change does.
Future<void> _playBeforeAfterDemo() async {
final String beforeText = _beforeTextController.text;
final String afterText = _afterTextController.text;
if (beforeText.isEmpty || afterText.isEmpty) return;
setState(() {
_colorMorphPlaying = true;
_snappingToBefore = true;
_customText = beforeText;
_selectedPresetIndex = null;
_fillColor = _beforeColor;
_wobbleSeed = _wobbleRandom.nextInt(4294967296);
});
_controller.text = beforeText;
// Hold on the snapped-to "before" state for a beat — long enough to
// actually register as "before" rather than flashing past it — before
// switching back to the real speed and starting the visible morph.
await Future.delayed(const Duration(milliseconds: 200));
if (!mounted) return;
setState(() {
_snappingToBefore = false;
_customText = afterText;
_fillColor = _afterColor;
_wobbleSeed = _wobbleRandom.nextInt(4294967296);
});
_controller.text = afterText;
await Future.delayed(_duration);
if (!mounted) return;
setState(() => _colorMorphPlaying = false);
}
@override
Widget build(BuildContext context) {
final font = _font;
return Scaffold(
appBar: AppBar(title: const Text('Playground')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The controls below can grow taller than the window (many
// sliders + an expanded options panel), so they scroll in
// their own bounded space instead of overflowing the preview.
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── Presets ────────────────────────────────────────
Wrap(
spacing: 8,
children: [
for (int i = 0; i < _presets.length; i++)
ChoiceChip(
label: Text(_presets[i].label),
selected: _selectedPresetIndex == i,
onSelected: (_) => _selectPreset(i),
),
],
),
const SizedBox(height: 12),
// ── Custom text ────────────────────────────────────────
TextField(
controller: _controller,
decoration: const InputDecoration(
labelText: 'Custom text',
border: OutlineInputBorder(),
),
onSubmitted: _submitCustomText,
),
const SizedBox(height: 8),
// ── Font size ──────────────────────────────────────────
_LabeledSlider(
label: 'Size',
value: _fontSize,
min: 32,
max: 160,
divisions: 16,
display: '${_fontSize.toStringAsFixed(0)} px',
onChanged: (v) => setState(() => _fontSize = v),
),
// ── Morph duration ───────────────────────────────────────
_LabeledSlider(
label: 'Speed',
value: _duration.inMilliseconds.toDouble(),
min: 150,
max: 1500,
divisions: 27,
display: '${_duration.inMilliseconds} ms',
onChanged: (v) => setState(
() => _duration = Duration(milliseconds: v.round()),
),
),
const SizedBox(height: 8),
// ── Color ────────────────────────────────────────────────
Theme(
data: Theme.of(
context,
).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
title: const Text('Color'),
initiallyExpanded: true,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_ColorSwatchRow(
label: 'Fill',
selected: _fillColor,
onSelected: (c) =>
setState(() => _fillColor = c),
),
const SizedBox(height: 4),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Stroke'),
value: _strokeEnabled,
onChanged: (v) =>
setState(() => _strokeEnabled = v),
),
_ColorSwatchRow(
label: 'Stroke',
selected: _strokeColor,
onSelected: _strokeEnabled
? (c) => setState(() => _strokeColor = c)
: null,
),
_LabeledSlider(
label: 'Stroke\nwidth',
value: _strokeWidth,
min: 0.5,
max: 8,
divisions: 15,
display: _strokeWidth.toStringAsFixed(1),
onChanged: _strokeEnabled
? (v) => setState(() => _strokeWidth = v)
: null,
),
const Divider(height: 24),
Text(
'Before → After demo (text + color '
'together)',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: TextField(
controller: _beforeTextController,
enabled: !_colorMorphPlaying,
decoration: const InputDecoration(
labelText: 'Before text',
isDense: true,
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _afterTextController,
enabled: !_colorMorphPlaying,
decoration: const InputDecoration(
labelText: 'After text',
isDense: true,
border: OutlineInputBorder(),
),
),
),
],
),
const SizedBox(height: 8),
_ColorSwatchRow(
label: 'Before',
selected: _beforeColor,
onSelected: _colorMorphPlaying
? null
: (c) => setState(() => _beforeColor = c),
),
_ColorSwatchRow(
label: 'After',
selected: _afterColor,
onSelected: _colorMorphPlaying
? null
: (c) => setState(() => _afterColor = c),
),
const SizedBox(height: 4),
FilledButton.icon(
onPressed: _colorMorphPlaying
? null
: _playBeforeAfterDemo,
icon: _colorMorphPlaying
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.play_arrow),
label: Text(
_colorMorphPlaying
? 'Playing…'
: 'Play Before → After',
),
),
],
),
),
],
),
),
const SizedBox(height: 8),
// ── Morph options ────────────────────────────────────────
Theme(
data: Theme.of(
context,
).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: EdgeInsets.zero,
title: const Text('Morph options'),
initiallyExpanded: true,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const SizedBox(
width: 90,
child: Text('Style'),
),
Expanded(
child: SegmentedButton<MorphStyle>(
segments: const [
ButtonSegment(
value: MorphStyle.shape,
label: Text('shape'),
),
ButtonSegment(
value: MorphStyle.crossFade,
label: Text('crossFade'),
),
ButtonSegment(
value: MorphStyle.auto,
label: Text('auto'),
),
],
selected: {_style},
onSelectionChanged: (s) =>
setState(() => _style = s.first),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
const SizedBox(
width: 90,
child: Text('Alignment'),
),
Expanded(
child: SegmentedButton<GlyphAlignment>(
segments: const [
ButtonSegment(
value: GlyphAlignment.byIndex,
label: Text('byIndex'),
),
ButtonSegment(
value: GlyphAlignment.diff,
label: Text('diff'),
),
ButtonSegment(
value: GlyphAlignment.wholePath,
label: Text('wholePath'),
),
],
selected: {_alignment},
onSelectionChanged: (s) => setState(
() => _alignment = s.first,
),
),
),
],
),
_LabeledSlider(
label: 'Stagger',
value: _stagger,
min: 0,
max: 1,
divisions: 20,
display: _stagger.toStringAsFixed(2),
onChanged:
_alignment != GlyphAlignment.wholePath
? (v) => setState(() => _stagger = v)
: null,
),
_LabeledSlider(
label: 'Contour\ntiming',
value: _contourTimingOffset,
min: -1,
max: 1,
divisions: 40,
display: _contourTimingOffset.toStringAsFixed(
2,
),
onChanged: (v) =>
setState(() => _contourTimingOffset = v),
),
_LabeledSlider(
label: 'Hole area\nratio',
value: _holeAreaRatioThreshold,
min: 0,
max: 0.3,
divisions: 30,
display: _holeAreaRatioThreshold
.toStringAsFixed(2),
onChanged: (v) => setState(
() => _holeAreaRatioThreshold = v,
),
),
_LabeledSlider(
label: 'Dissimilarity\n(auto style)',
value: _dissimilarityThreshold,
min: 0,
max: 1,
divisions: 20,
display: _dissimilarityThreshold
.toStringAsFixed(2),
onChanged: _style == MorphStyle.auto
? (v) => setState(
() => _dissimilarityThreshold = v,
)
: null,
),
_LabeledSlider(
label: 'Length\nfallback',
value: _lengthMismatchFallback,
min: 0,
max: 10,
divisions: 10,
display: _lengthMismatchFallback == 0
? 'off'
: _lengthMismatchFallback.toStringAsFixed(
0,
),
onChanged: (v) => setState(
() => _lengthMismatchFallback = v,
),
),
_LabeledSlider(
label: 'Idle\npulse',
value: _idlePulseAmount,
min: 0,
max: 0.3,
divisions: 30,
display: _idlePulseAmount.toStringAsFixed(2),
onChanged: (v) =>
setState(() => _idlePulseAmount = v),
),
_LabeledSlider(
label: 'Self\nwobble',
value: _selfWobbleDetour,
min: 0,
max: 1,
divisions: 20,
display: _selfWobbleDetour.toStringAsFixed(2),
onChanged: (v) =>
setState(() => _selfWobbleDetour = v),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Area-corrected pop'),
value: _useAreaCorrectedPop,
onChanged: (v) =>
setState(() => _useAreaCorrectedPop = v),
),
],
),
),
],
),
),
],
),
),
),
const SizedBox(height: 12),
// ── Morphing text ────────────────────────────────────────
SizedBox(
height: 260,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
color: Colors.white,
),
child: font == null
? const Center(child: CircularProgressIndicator())
: Morph(
font: font,
target: _selectedPresetIndex != null
? _presets[_selectedPresetIndex!].build(font)
: TextSource(font: font, text: _customText!),
fontSize: _fontSize,
// A literal zero `Duration` risks a 0/0 division
// inside AnimationController's own interpolation —
// 1ms is visually instant without courting that edge
// case.
duration: _snappingToBefore
? const Duration(milliseconds: 1)
: _duration,
color: _fillColor,
strokeColor: _strokeEnabled ? _strokeColor : null,
strokeWidth: _strokeWidth,
style: _style,
stagger: _stagger,
options: _options,
),
),
),
],
),
),
);
}
@override
void dispose() {
_controller.dispose();
_beforeTextController.dispose();
_afterTextController.dispose();
super.dispose();
}
}
const List<Color> _swatchPalette = [
Colors.black,
Colors.deepPurple,
Colors.red,
Colors.orange,
Colors.teal,
Colors.blue,
];
/// A labeled row of tappable color swatches — tapping one calls [onSelected]
/// with that color, letting [Morph.color]/[strokeColor] morph
/// smoothly to it. `null` [onSelected] greys the row out and disables
/// tapping (used while the stroke toggle is off).
class _ColorSwatchRow extends StatelessWidget {
const _ColorSwatchRow({
required this.label,
required this.selected,
required this.onSelected,
});
final String label;
final Color selected;
final ValueChanged<Color>? onSelected;
@override
Widget build(BuildContext context) {
final bool enabled = onSelected != null;
return Row(
children: [
SizedBox(width: 90, child: Text(label)),
Expanded(
child: Wrap(
spacing: 8,
children: [
for (final Color color in _swatchPalette)
GestureDetector(
onTap: enabled ? () => onSelected!(color) : null,
child: AnimatedOpacity(
opacity: enabled ? 1.0 : 0.35,
duration: const Duration(milliseconds: 150),
child: CircleAvatar(
radius: 14,
backgroundColor: color,
child: enabled && selected == color
? const Icon(
Icons.check,
size: 16,
color: Colors.white,
)
: null,
),
),
),
],
),
),
],
);
}
}
class _LabeledSlider extends StatelessWidget {
const _LabeledSlider({
required this.label,
required this.value,
required this.min,
required this.max,
required this.divisions,
required this.display,
required this.onChanged,
});
final String label;
final double value;
final double min;
final double max;
final int divisions;
final String display;
final ValueChanged<double>? onChanged;
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(width: 90, child: Text(label)),
Expanded(
child: Slider(
value: value,
min: min,
max: max,
divisions: divisions,
label: display,
onChanged: onChanged,
),
),
SizedBox(
width: 56,
child: Align(alignment: Alignment.centerRight, child: Text(display)),
),
],
);
}
}