magic_widget 0.1.0 copy "magic_widget: ^0.1.0" to clipboard
magic_widget: ^0.1.0 copied to clipboard

An over-engineered magical reveal for any widget: a glowing sweep or lens flare with twinkling rainbow sparkles, powered by a single fragment shader. Works on iOS, Android and Web.

example/lib/main.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:magic_widget/magic_widget.dart';

void main() => runApp(const MagicExampleApp());

const Map<String, Curve> kCurveChoices = <String, Curve>{
  'linear': Curves.linear,
  'easeInOut': Curves.easeInOut,
  'easeOutCubic': Curves.easeOutCubic,
  'easeInCubic': Curves.easeInCubic,
  'elasticOut': Curves.elasticOut,
};

const List<Color> kGlowChoices = <Color>[
  Color(0xCCFFC94D),
  Color(0xCCFFFFFF),
  Color(0xCCFF5DDB),
  Color(0xCC4DE3FF),
  Color(0xCC7CFC52),
  Color(0xCCB388FF),
];

class MagicExampleApp extends StatelessWidget {
  const MagicExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'MagicWidget Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        brightness: Brightness.dark,
        colorSchemeSeed: const Color(0xFFFFA726),
        scaffoldBackgroundColor: const Color(0xFF14101F),
      ),
      home: const _HomePage(),
    );
  }
}

class _HomePage extends StatelessWidget {
  const _HomePage();

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('MagicWidget demo'),
          backgroundColor: Colors.transparent,
          bottom: const TabBar(
            tabs: [
              Tab(icon: Icon(Icons.tune), text: 'Playground'),
              Tab(icon: Icon(Icons.image), text: 'Card'),
            ],
          ),
        ),
        body: const TabBarView(
          children: [
            _PlaygroundTab(),
            _CardDemoTab(),
          ],
        ),
      ),
    );
  }
}

class _PlaygroundTab extends StatefulWidget {
  const _PlaygroundTab();

  @override
  State<_PlaygroundTab> createState() => _PlaygroundTabState();
}

class _PlaygroundTabState extends State<_PlaygroundTab>
    with AutomaticKeepAliveClientMixin {
  final MagicWidgetController _controller = MagicWidgetController();
  MagicStyle _style = const MagicStyle();
  MagicRevealDirection _revealDirection = MagicRevealDirection.leftToRight;
  double _durationMs = 1800;
  double _sparkleDurationMs = 2600;
  String _curveName = 'linear';
  bool _isLooping = false;

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  void _updateStyle(MagicStyle style) => setState(() => _style = style);

  void _setLoop(bool value) {
    setState(() => _isLooping = value);
    if (value) {
      _controller.play();
    }
  }

  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Column(
      children: [
        SizedBox(
          height: 400,
          child: Center(
            child: FittedBox(
              fit: BoxFit.scaleDown,
              child: GestureDetector(
                onTap: _controller.play,
                child: MagicWidget(
                  controller: _controller,
                  style: _style,
                  duration: Duration(milliseconds: _durationMs.round()),
                  sparkleDuration:
                      Duration(milliseconds: _sparkleDurationMs.round()),
                  loop: _isLooping,
                  curve: kCurveChoices[_curveName]!,
                  revealDirection: _revealDirection,
                  sparklePadding:
                      const EdgeInsets.symmetric(horizontal: 56, vertical: 40),
                  child: const _MagicText(),
                ),
              ),
            ),
          ),
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            IconButton(
              tooltip: 'Replay',
              onPressed: _controller.play,
              icon: const Icon(Icons.auto_awesome),
            ),
            _StatusBanner(status: _controller.status),
            IconButton(
              tooltip: 'Reset (hide)',
              onPressed: _controller.reset,
              icon: const Icon(Icons.replay),
            ),
          ],
        ),
        const Divider(height: 1),
        Expanded(
          child: _ControlPanel(
            style: _style,
            onStyleChanged: _updateStyle,
            revealDirection: _revealDirection,
            onRevealDirectionChanged: (MagicRevealDirection d) =>
                setState(() => _revealDirection = d),
            durationMs: _durationMs,
            onDurationChanged: (double v) => setState(() => _durationMs = v),
            sparkleDurationMs: _sparkleDurationMs,
            onSparkleDurationChanged: (double v) =>
                setState(() => _sparkleDurationMs = v),
            curveName: _curveName,
            onCurveChanged: (String name) => setState(() => _curveName = name),
            isLooping: _isLooping,
            onLoopChanged: _setLoop,
          ),
        ),
      ],
    );
  }
}

class _CardDemoTab extends StatefulWidget {
  const _CardDemoTab();

  @override
  State<_CardDemoTab> createState() => _CardDemoTabState();
}

class _CardDemoTabState extends State<_CardDemoTab>
    with AutomaticKeepAliveClientMixin {
  final MagicWidgetController _controller = MagicWidgetController();
  final TextEditingController _textController =
      TextEditingController(text: 'Abracadabra');
  static const List<BoxFit> _fitChoices = <BoxFit>[
    BoxFit.cover,
    BoxFit.contain,
    BoxFit.fill,
    BoxFit.fitWidth,
    BoxFit.fitHeight,
    BoxFit.scaleDown,
    BoxFit.none,
  ];
  final ImagePicker _imagePicker = ImagePicker();
  Uint8List? _imageBytes;
  BoxFit _imageFit = BoxFit.cover;
  String _cardText = 'Abracadabra';
  String? _errorMessage;

  @override
  bool get wantKeepAlive => true;

  @override
  void dispose() {
    _controller.dispose();
    _textController.dispose();
    super.dispose();
  }

  Future<void> _pickImage() async {
    try {
      final XFile? file = await _imagePicker.pickImage(
        source: ImageSource.gallery,
        maxWidth: 1200,
      );
      if (file == null) {
        return;
      }
      final Uint8List bytes = await file.readAsBytes();
      if (!mounted) {
        return;
      }
      setState(() {
        _imageBytes = bytes;
        _errorMessage = null;
      });
      _controller.play();
    } catch (e) {
      if (!mounted) {
        return;
      }
      setState(() => _errorMessage = 'Could not pick the image: $e');
    }
  }

  void _updateCardText(String value) => setState(() => _cardText = value);

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return ListView(
      padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
      children: [
        Center(
          child: FittedBox(
            fit: BoxFit.scaleDown,
            child: GestureDetector(
              onTap: _controller.play,
              child: MagicWidget(
                controller: _controller,
                style: const MagicStyle(
                  sparkleDrift: 0.6,
                  driftDirection: MagicDriftDirection.centerOut,
                  waveStyle: MagicWaveStyle.lensFlare,
                ),
                sparklePadding: const EdgeInsets.all(40),
                child: _MagicCard(
                  imageBytes: _imageBytes,
                  imageFit: _imageFit,
                  text: _cardText,
                ),
              ),
            ),
          ),
        ),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            OutlinedButton.icon(
              onPressed: _pickImage,
              icon: const Icon(Icons.photo_library),
              label: const Text('Pick an image'),
            ),
            const SizedBox(width: 12),
            FilledButton.icon(
              onPressed: _controller.play,
              icon: const Icon(Icons.auto_awesome),
              label: const Text('Replay'),
            ),
          ],
        ),
        const SizedBox(height: 8),
        _DropdownTile<BoxFit>(
          label: 'Image fit',
          value: _imageFit,
          values: _fitChoices,
          nameOf: (BoxFit fit) => fit.name,
          onChanged: (BoxFit fit) => setState(() => _imageFit = fit),
        ),
        const SizedBox(height: 8),
        TextField(
          controller: _textController,
          decoration: const InputDecoration(
            labelText: 'Card text',
            border: OutlineInputBorder(),
          ),
          textCapitalization: TextCapitalization.sentences,
          keyboardType: TextInputType.text,
          textInputAction: TextInputAction.done,
          onChanged: _updateCardText,
          onSubmitted: (_) => _controller.play(),
        ),
        if (_errorMessage != null)
          Padding(
            padding: const EdgeInsets.only(top: 12),
            child: SelectableText.rich(
              TextSpan(
                text: _errorMessage,
                style: const TextStyle(color: Colors.redAccent),
              ),
            ),
          ),
      ],
    );
  }
}

class _MagicCard extends StatelessWidget {
  const _MagicCard({
    required this.imageBytes,
    required this.imageFit,
    required this.text,
  });

  final Uint8List? imageBytes;
  final BoxFit imageFit;
  final String text;

  @override
  Widget build(BuildContext context) {
    final Uint8List? bytes = imageBytes;
    return Card(
      clipBehavior: Clip.antiAlias,
      child: SizedBox(
        width: 640,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            SizedBox(
              height: 360,
              child: bytes != null
                  ? Image.memory(bytes, fit: imageFit)
                  : const _ImagePlaceholder(),
            ),
            Padding(
              padding: const EdgeInsets.all(24),
              child: Text(
                text.isEmpty ? 'Your text here' : text,
                style: Theme.of(context).textTheme.headlineMedium,
                textAlign: TextAlign.center,
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _ImagePlaceholder extends StatelessWidget {
  const _ImagePlaceholder();

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: const BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: [Color(0xFF3A2A6B), Color(0xFF7A3A8F)],
        ),
      ),
      child: Icon(
        Icons.add_photo_alternate_outlined,
        size: 112,
        color: Colors.white.withValues(alpha: 0.8),
      ),
    );
  }
}

class _StatusBanner extends StatelessWidget {
  const _StatusBanner({required this.status});

  final ValueListenable<MagicStatus> status;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<MagicStatus>(
      valueListenable: status,
      builder: (BuildContext context, MagicStatus value, _) => Padding(
        padding: const EdgeInsets.only(bottom: 8),
        child: Text(
          'Status: ${value.name}',
          style: Theme.of(context).textTheme.labelSmall,
        ),
      ),
    );
  }
}

class _ControlPanel extends StatelessWidget {
  const _ControlPanel({
    required this.style,
    required this.onStyleChanged,
    required this.revealDirection,
    required this.onRevealDirectionChanged,
    required this.durationMs,
    required this.onDurationChanged,
    required this.sparkleDurationMs,
    required this.onSparkleDurationChanged,
    required this.curveName,
    required this.onCurveChanged,
    required this.isLooping,
    required this.onLoopChanged,
  });

  final MagicStyle style;
  final ValueChanged<MagicStyle> onStyleChanged;
  final MagicRevealDirection revealDirection;
  final ValueChanged<MagicRevealDirection> onRevealDirectionChanged;
  final double durationMs;
  final ValueChanged<double> onDurationChanged;
  final double sparkleDurationMs;
  final ValueChanged<double> onSparkleDurationChanged;
  final String curveName;
  final ValueChanged<String> onCurveChanged;
  final bool isLooping;
  final ValueChanged<bool> onLoopChanged;

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
      children: [
        const _SectionHeader('Sparkles'),
        _DropdownTile<MagicSparkleShape>(
          label: 'Shape',
          value: style.sparkleShape,
          values: MagicSparkleShape.values,
          nameOf: (MagicSparkleShape s) => s.name,
          onChanged: (MagicSparkleShape s) =>
              onStyleChanged(style.copyWith(sparkleShape: s)),
        ),
        _DropdownTile<MagicSparkleColorMode>(
          label: 'Color mode',
          value: style.sparkleColorMode,
          values: MagicSparkleColorMode.values,
          nameOf: (MagicSparkleColorMode m) => m.name,
          onChanged: (MagicSparkleColorMode m) =>
              onStyleChanged(style.copyWith(sparkleColorMode: m)),
        ),
        _SliderTile(
          label: 'Size',
          value: style.sparkleSize,
          min: 0.3,
          max: 2.5,
          onChanged: (double v) =>
              onStyleChanged(style.copyWith(sparkleSize: v)),
        ),
        _SliderTile(
          label: 'Density',
          value: style.sparkleDensity,
          min: 0.05,
          max: 1,
          onChanged: (double v) =>
              onStyleChanged(style.copyWith(sparkleDensity: v)),
        ),
        _SliderTile(
          label: 'Twinkle speed',
          value: style.twinkleSpeed,
          min: 0.2,
          max: 3,
          onChanged: (double v) =>
              onStyleChanged(style.copyWith(twinkleSpeed: v)),
        ),
        _SliderTile(
          label: 'Drift',
          value: style.sparkleDrift,
          min: 0,
          max: 2,
          onChanged: (double v) =>
              onStyleChanged(style.copyWith(sparkleDrift: v)),
        ),
        _DropdownTile<MagicDriftDirection>(
          label: 'Drift direction',
          value: style.driftDirection,
          values: MagicDriftDirection.values,
          nameOf: (MagicDriftDirection d) => d.name,
          onChanged: (MagicDriftDirection d) =>
              onStyleChanged(style.copyWith(driftDirection: d)),
        ),
        _SliderTile(
          label: 'Star arms',
          value: style.armStrength,
          min: 0,
          max: 1,
          onChanged: (double v) =>
              onStyleChanged(style.copyWith(armStrength: v)),
        ),
        _DropdownTile<int>(
          label: 'Layers',
          value: style.sparkleLayers,
          values: const [1, 2, 3],
          nameOf: (int v) => '$v',
          onChanged: (int v) =>
              onStyleChanged(style.copyWith(sparkleLayers: v)),
        ),
        const _SectionHeader('Wave'),
        _DropdownTile<MagicWaveStyle>(
          label: 'Wave style',
          value: style.waveStyle,
          values: MagicWaveStyle.values,
          nameOf: (MagicWaveStyle s) => s.name,
          onChanged: (MagicWaveStyle s) =>
              onStyleChanged(style.copyWith(waveStyle: s)),
        ),
        _ColorSwatchTile(
          label: 'Glow color',
          value: style.glowColor,
          choices: kGlowChoices,
          onChanged: (Color c) => onStyleChanged(style.copyWith(glowColor: c)),
        ),
        _SliderTile(
          label: 'Glow width',
          value: style.glowWidth,
          min: 0.2,
          max: 3,
          onChanged: (double v) => onStyleChanged(style.copyWith(glowWidth: v)),
        ),
        _SliderTile(
          label: 'Glow intensity',
          value: style.glowIntensity,
          min: 0,
          max: 2.5,
          onChanged: (double v) =>
              onStyleChanged(style.copyWith(glowIntensity: v)),
        ),
        if (style.waveStyle == MagicWaveStyle.lensFlare) ...[
          _SliderTile(
            label: 'Flare streak',
            value: style.flareStreak,
            min: 0.2,
            max: 3,
            onChanged: (double v) =>
                onStyleChanged(style.copyWith(flareStreak: v)),
          ),
          _SliderTile(
            label: 'Flare ring',
            value: style.flareRing,
            min: 0,
            max: 3,
            onChanged: (double v) =>
                onStyleChanged(style.copyWith(flareRing: v)),
          ),
          _DropdownTile<int>(
            label: 'Flare ghosts',
            value: style.flareGhosts,
            values: const [0, 1, 2, 3],
            nameOf: (int v) => '$v',
            onChanged: (int v) =>
                onStyleChanged(style.copyWith(flareGhosts: v)),
          ),
        ],
        _SliderTile(
          label: 'Wave wobble',
          value: style.waveWobble,
          min: 0,
          max: 3,
          onChanged: (double v) =>
              onStyleChanged(style.copyWith(waveWobble: v)),
        ),
        _SliderTile(
          label: 'Edge softness',
          value: style.edgeSoftness,
          min: 0.2,
          max: 3,
          onChanged: (double v) =>
              onStyleChanged(style.copyWith(edgeSoftness: v)),
        ),
        _DropdownTile<MagicRevealDirection>(
          label: 'Reveal direction',
          value: revealDirection,
          values: MagicRevealDirection.values,
          nameOf: (MagicRevealDirection d) => d.name,
          onChanged: onRevealDirectionChanged,
        ),
        const _SectionHeader('Timing'),
        _SliderTile(
          label: 'Duration (ms)',
          value: durationMs,
          min: 400,
          max: 4000,
          decimals: 0,
          onChanged: onDurationChanged,
        ),
        _SliderTile(
          label: 'Sparkle time (ms)',
          value: sparkleDurationMs,
          min: 0,
          max: 5000,
          decimals: 0,
          onChanged: onSparkleDurationChanged,
        ),
        _DropdownTile<String>(
          label: 'Curve',
          value: curveName,
          values: kCurveChoices.keys.toList(),
          nameOf: (String name) => name,
          onChanged: onCurveChanged,
        ),
        SwitchListTile(
          title: const Text('Loop'),
          contentPadding: EdgeInsets.zero,
          value: isLooping,
          onChanged: onLoopChanged,
        ),
      ],
    );
  }
}

class _MagicText extends StatelessWidget {
  const _MagicText();

  static const TextStyle _baseStyle = TextStyle(
    fontSize: 128,
    fontWeight: FontWeight.w900,
    letterSpacing: 8,
    height: 1,
  );

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Text(
          'MAGIC',
          style: _baseStyle.copyWith(
            foreground: Paint()
              ..style = PaintingStyle.stroke
              ..strokeWidth = 20
              ..color = const Color(0xFF6D3410),
          ),
        ),
        Text(
          'MAGIC',
          style: _baseStyle.copyWith(color: const Color(0xFFFFB433)),
        ),
      ],
    );
  }
}

class _SectionHeader extends StatelessWidget {
  const _SectionHeader(this.title);

  final String title;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(top: 16, bottom: 4),
      child: Text(
        title,
        style: Theme.of(context).textTheme.titleMedium?.copyWith(
              color: const Color(0xFFFFB433),
            ),
      ),
    );
  }
}

class _SliderTile extends StatelessWidget {
  const _SliderTile({
    required this.label,
    required this.value,
    required this.min,
    required this.max,
    required this.onChanged,
    this.decimals = 2,
  });

  final String label;
  final double value;
  final double min;
  final double max;
  final int decimals;
  final ValueChanged<double> onChanged;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        SizedBox(width: 140, child: Text(label)),
        Expanded(
          child: Slider(value: value, min: min, max: max, onChanged: onChanged),
        ),
        SizedBox(
          width: 52,
          child: Text(
            value.toStringAsFixed(decimals),
            textAlign: TextAlign.right,
          ),
        ),
      ],
    );
  }
}

class _DropdownTile<T> extends StatelessWidget {
  const _DropdownTile({
    required this.label,
    required this.value,
    required this.values,
    required this.nameOf,
    required this.onChanged,
  });

  final String label;
  final T value;
  final List<T> values;
  final String Function(T) nameOf;
  final ValueChanged<T> onChanged;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Row(
        children: [
          SizedBox(width: 140, child: Text(label)),
          Expanded(
            child: DropdownButton<T>(
              value: value,
              isExpanded: true,
              items: [
                for (final T item in values)
                  DropdownMenuItem<T>(value: item, child: Text(nameOf(item))),
              ],
              onChanged: (T? item) {
                if (item != null) {
                  onChanged(item);
                }
              },
            ),
          ),
        ],
      ),
    );
  }
}

class _ColorSwatchTile extends StatelessWidget {
  const _ColorSwatchTile({
    required this.label,
    required this.value,
    required this.choices,
    required this.onChanged,
  });

  final String label;
  final Color value;
  final List<Color> choices;
  final ValueChanged<Color> onChanged;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4),
      child: Row(
        children: [
          SizedBox(width: 140, child: Text(label)),
          for (final Color color in choices)
            Padding(
              padding: const EdgeInsets.only(right: 8),
              child: GestureDetector(
                onTap: () => onChanged(color),
                child: Container(
                  width: 28,
                  height: 28,
                  decoration: BoxDecoration(
                    color: color,
                    shape: BoxShape.circle,
                    border: Border.all(
                      color: value == color ? Colors.white : Colors.white24,
                      width: value == color ? 2.5 : 1,
                    ),
                  ),
                ),
              ),
            ),
        ],
      ),
    );
  }
}
0
likes
160
points
7
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

An over-engineered magical reveal for any widget: a glowing sweep or lens flare with twinkling rainbow sparkles, powered by a single fragment shader. Works on iOS, Android and Web.

Repository (GitHub)
View/report issues

Topics

#animation #shader #effects #sparkles #reveal

License

MIT (license)

Dependencies

flutter, flutter_shaders

More

Packages that depend on magic_widget