text_morph_flutter 1.0.0 copy "text_morph_flutter: ^1.0.0" to clipboard
text_morph_flutter: ^1.0.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.

example/lib/main.dart

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)),
        ),
      ],
    );
  }
}
0
likes
160
points
117
downloads
screenshot

Documentation

API reference

Publisher

verified publishercrossapplication.members.co.jp

Weekly Downloads

Shape-level text morphing animations for Flutter, built on glyph_path and glyph_path_flutter — animates one string's glyph outlines into another's.

Repository (GitHub)
View/report issues

Topics

#animation #text #transition #shape #vector-graphics

License

BSD-3-Clause (license)

Dependencies

cacherine, flutter, glyph_path, glyph_path_flutter, meta

More

Packages that depend on text_morph_flutter