Skip to main content

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

Fast, smooth steering-behavior particle field - particles form shapes/text from sampled atoms, morph, and flee touch.

example/lib/main.dart

import 'dart:ui' as ui show ImageFilter;

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:animated_particles/animated_particles.dart';

const bool kLogFrameStats = false;

void main() {
  if (kLogFrameStats) {
    WidgetsFlutterBinding.ensureInitialized();
    _FrameStats().attach();
  }
  runApp(const ParticleApp());
}

class _FrameStats {
  static const double _budgetMs = 16.7;
  int _frames = 0;
  int _jankUi = 0;
  int _jankRaster = 0;
  double _maxUi = 0;
  double _maxRaster = 0;
  double _sumUi = 0;
  double _sumRaster = 0;

  void attach() {
    SchedulerBinding.instance.addTimingsCallback(_onTimings);
  }

  void _onTimings(List<FrameTiming> timings) {
    for (final t in timings) {
      final ui = t.buildDuration.inMicroseconds / 1000.0;
      final raster = t.rasterDuration.inMicroseconds / 1000.0;
      _frames++;
      _sumUi += ui;
      _sumRaster += raster;
      if (ui > _maxUi) _maxUi = ui;
      if (raster > _maxRaster) _maxRaster = raster;
      if (ui > _budgetMs) _jankUi++;
      if (raster > _budgetMs) _jankRaster++;
    }
    if (_frames >= 120) {
      debugPrint(
        'FRAMESTATS frames=$_frames '
        'uiAvg=${(_sumUi / _frames).toStringAsFixed(2)} uiMax=${_maxUi.toStringAsFixed(2)} '
        'rasterAvg=${(_sumRaster / _frames).toStringAsFixed(2)} rasterMax=${_maxRaster.toStringAsFixed(2)} '
        'jankUI=$_jankUi jankRaster=$_jankRaster (budget ${_budgetMs}ms)',
      );
      _frames = 0;
      _jankUi = 0;
      _jankRaster = 0;
      _maxUi = 0;
      _maxRaster = 0;
      _sumUi = 0;
      _sumRaster = 0;
    }
  }
}

const Color _kCanvas = Color(0xFF07080B);

const Color _kParticleIce = Color(0xFFEAF2FF);

const Color _kAccent = Color(0xFF4DE3C2);

const Color _kGlassFill = Color(0x14FFFFFF);
const Color _kGlassHairline = Color(0x24FFFFFF);
const Color _kTextSecondary = Color(0x99FFFFFF);

const Color _kTextTertiary = Color(0x66FFFFFF);
const Color _kShadow = Color(0x66000000);

const List<String> _kMonoFallback = <String>[
  'SF Mono',
  'Menlo',
  'Roboto Mono',
  'monospace',
];

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

  @override
  Widget build(BuildContext context) {
    final base = ThemeData.dark(useMaterial3: true);
    return MaterialApp(
      title: 'animated_particles',
      debugShowCheckedModeBanner: false,
      theme: base.copyWith(
        colorScheme: base.colorScheme.copyWith(
          primary: _kAccent,
          surfaceTint: _kAccent,
        ),
      ),
      home: const ParticleDemoPage(),
    );
  }
}

class ParticleDemoPage extends StatefulWidget {
  const ParticleDemoPage({super.key});

  @override
  State<ParticleDemoPage> createState() => _ParticleDemoPageState();
}

class _ParticleDemoPageState extends State<ParticleDemoPage> {
  late final ParticleFieldController _controller;

  EscapePosition _escape = EscapePosition.bottom;

  static const TextStyle _textStyle = TextStyle(
    fontSize: 130,
    fontWeight: FontWeight.w800,
    letterSpacing: 2,
    height: 1.0,
  );

  static const List<_GalleryEntry> _entries = <_GalleryEntry>[
    _GalleryEntry(key: 'flutter', label: 'Flutter'),
    _GalleryEntry(key: 'impeller', label: 'impeller'),
    _GalleryEntry(key: 'render', label: 'render'),
    _GalleryEntry(key: 'dash', icon: Icons.flutter_dash),
    _GalleryEntry(key: 'heart', icon: Icons.favorite),
    _GalleryEntry(key: 'bolt', icon: Icons.bolt),
    _GalleryEntry(key: 'snow', icon: CupertinoIcons.snow),
  ];

  @override
  void initState() {
    super.initState();
    _controller = ParticleFieldController(
      config: const ParticleFieldConfig(
        color: _kParticleIce,
        particleSize: 1.4,
        maxSpeed: 12,
        maxForce: 3,
        arrivalRadius: 100,
        fleeRadius: 50,
        escapePosition: EscapePosition.bottom,
        shape: ParticleShape.circle,
        antiAlias: true,
        fillFraction: 0.72,
        alignment: Alignment(0.0, -0.35),
      ),
    );

    _loadTemplates();
  }

  Future<void> _loadTemplates() async {
    await _controller.addTargets(<String, ParticleTarget>{
      'flutter': ParticleTarget.text('Flutter', style: _textStyle, stride: 2),
      'impeller':
          ParticleTarget.text('impeller', style: _textStyle, stride: 2),
      'render': ParticleTarget.text('render', style: _textStyle, stride: 2),
      'dash': ParticleTarget.icon(Icons.flutter_dash, size: 260, stride: 2),
      'heart': ParticleTarget.icon(Icons.favorite, size: 260, stride: 2),
      'bolt': ParticleTarget.icon(Icons.bolt, size: 260, stride: 2),
      'snow': ParticleTarget.icon(CupertinoIcons.snow, size: 260, stride: 2),
    });
    if (!mounted) return;
    setState(() {});
    await Future<void>.delayed(const Duration(milliseconds: 700));
    if (mounted && _controller.keys.isNotEmpty) {
      setState(() => _controller.changeTarget('flutter'));
    }
  }

  void _select(String key) {
    if (_controller.keys.isEmpty) return;
    setState(() => _controller.changeTarget(key));
  }

  void _next() {
    if (_controller.keys.isEmpty) return;
    final cur = _controller.activeKey;
    final i = cur == null ? -1 : _entries.indexWhere((e) => e.key == cur);
    _select(_entries[(i + 1) % _entries.length].key);
  }

  void _onEscapeChanged(EscapePosition value) {
    setState(() => _escape = value);
    _controller.config =
        _controller.config.copyWith(escapePosition: value);
    if (_controller.keys.isNotEmpty) _controller.escape();
  }

  String _fmt(int n) => n.toString().replaceAllMapped(
        RegExp(r'(\d)(?=(\d{3})+$)'),
        (m) => '${m[1]},',
      );

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: _kCanvas,
      body: Stack(
        children: [
          Positioned.fill(
            child: ParticleField(
              controller: _controller,
              handleDrag: false,
              backgroundColor: _kCanvas,
            ),
          ),

          const Positioned.fill(
            child: IgnorePointer(
              child: DecoratedBox(
                decoration: BoxDecoration(
                  gradient: RadialGradient(
                    center: Alignment(0, -0.35),
                    radius: 1.1,
                    colors: <Color>[Color(0x00000000), Color(0xAA000000)],
                    stops: <double>[0.55, 1.0],
                  ),
                ),
              ),
            ),
          ),

          SafeArea(
            child: Align(
              alignment: Alignment.topCenter,
              child: Padding(
                padding: const EdgeInsets.only(left: 20, right: 20, top: 14),
                child: _buildHeader(),
              ),
            ),
          ),

          Align(
            alignment: Alignment.bottomCenter,
            child: SafeArea(
              top: false,
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: _buildControlDeck(),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildHeader() {
    return Row(
      children: [
        Container(
          width: 18,
          height: 18,
          decoration: BoxDecoration(
            shape: BoxShape.circle,
            border: Border.all(color: const Color(0x44FFFFFF)),
          ),
        ),
        const SizedBox(width: 8),
        const Text(
          'animated_particles',
          style: TextStyle(
            fontSize: 13,
            fontFamilyFallback: _kMonoFallback,
            color: Color(0xFFEAECEF),
            letterSpacing: 0.2,
          ),
        ),
        const Spacer(),
        _buildCountChip(),
      ],
    );
  }

  Widget _buildCountChip() {
    return ClipRRect(
      borderRadius: BorderRadius.circular(20),
      child: BackdropFilter(
        filter: ui.ImageFilter.blur(sigmaX: 12, sigmaY: 12),
        child: Container(
          padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
          decoration: BoxDecoration(
            color: _kGlassFill,
            borderRadius: BorderRadius.circular(20),
            border: Border.all(color: _kGlassHairline),
          ),
          child: ListenableBuilder(
            listenable: _controller,
            builder: (context, _) {
              final count = _controller.particleCount;
              return AnimatedSwitcher(
                duration: const Duration(milliseconds: 150),
                transitionBuilder: (child, animation) {
                  return SlideTransition(
                    position: Tween<Offset>(
                      begin: const Offset(0, 0.4),
                      end: Offset.zero,
                    ).animate(animation),
                    child: FadeTransition(opacity: animation, child: child),
                  );
                },
                child: Text(
                  '${_fmt(count)} particles',
                  key: ValueKey<int>(count),
                  style: const TextStyle(
                    fontSize: 12,
                    fontFamilyFallback: _kMonoFallback,
                    color: Color(0xCCFFFFFF),
                    fontFeatures: <FontFeature>[FontFeature.tabularFigures()],
                  ),
                ),
              );
            },
          ),
        ),
      ),
    );
  }

  Widget _buildControlDeck() {
    return ClipRRect(
      borderRadius: BorderRadius.circular(28),
      child: BackdropFilter(
        filter: ui.ImageFilter.blur(sigmaX: 24, sigmaY: 24),
        child: Container(
          decoration: BoxDecoration(
            color: _kGlassFill,
            borderRadius: BorderRadius.circular(28),
            border: Border.all(color: _kGlassHairline),
            boxShadow: const <BoxShadow>[
              BoxShadow(
                color: _kShadow,
                blurRadius: 30,
                offset: Offset(0, 12),
              ),
            ],
          ),
          child: ConstrainedBox(
            constraints: BoxConstraints(
              maxHeight: MediaQuery.sizeOf(context).height * 0.62,
            ),
            child: SingleChildScrollView(
              child: Padding(
                padding:
                    const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    _buildGalleryCluster(),
                    const SizedBox(height: 18),
                    _buildEscapeCluster(),
                    const SizedBox(height: 18),
                    _buildNextButton(),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }

  Widget _label(String text) {
    return Text(
      text,
      style: const TextStyle(
        fontSize: 11,
        letterSpacing: 1.5,
        fontWeight: FontWeight.w600,
        color: _kTextSecondary,
      ),
    );
  }

  Widget _buildGalleryCluster() {
    final hasKeys = _controller.keys.isNotEmpty;
    final active = _controller.activeKey;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _label('GALLERY'),
        const SizedBox(height: 8),
        SizedBox(
          height: 60,
          child: ListView.separated(
            scrollDirection: Axis.horizontal,
            itemCount: _entries.length,
            separatorBuilder: (context, index) => const SizedBox(width: 10),
            itemBuilder: (context, i) {
              final entry = _entries[i];
              return _GalleryChip(
                entry: entry,
                selected: entry.key == active,
                onTap: hasKeys ? () => _select(entry.key) : null,
              );
            },
          ),
        ),
      ],
    );
  }

  Widget _buildEscapeCluster() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _label('ESCAPE'),
        const SizedBox(height: 8),
        SizedBox(
          width: double.infinity,
          child: SegmentedButton<EscapePosition>(
            showSelectedIcon: false,
            segments: const <ButtonSegment<EscapePosition>>[
              ButtonSegment(
                value: EscapePosition.left,
                icon: Icon(Icons.west),
                tooltip: 'Left',
              ),
              ButtonSegment(
                value: EscapePosition.top,
                icon: Icon(Icons.north),
                tooltip: 'Top',
              ),
              ButtonSegment(
                value: EscapePosition.bottom,
                icon: Icon(Icons.south),
                tooltip: 'Bottom',
              ),
              ButtonSegment(
                value: EscapePosition.right,
                icon: Icon(Icons.east),
                tooltip: 'Right',
              ),
            ],
            selected: {_escape},
            onSelectionChanged: (s) => _onEscapeChanged(s.first),
          ),
        ),
      ],
    );
  }

  Widget _buildNextButton() {
    final hasKeys = _controller.keys.isNotEmpty;
    return SizedBox(
      width: double.infinity,
      child: FilledButton.icon(
        onPressed: hasKeys ? _next : null,
        icon: const Icon(Icons.skip_next_rounded, size: 20),
        label: const Text('Next shape'),
        style: FilledButton.styleFrom(
          backgroundColor: _kAccent.withValues(alpha: 0.22),
          foregroundColor: _kAccent,
          disabledBackgroundColor: _kGlassFill,
          disabledForegroundColor: _kTextTertiary,
          padding: const EdgeInsets.symmetric(vertical: 16),
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(16),
          ),
          textStyle:
              const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
        ),
      ),
    );
  }

}

class _GalleryEntry {
  const _GalleryEntry({required this.key, this.label, this.icon})
      : assert(label != null || icon != null);

  final String key;
  final String? label;
  final IconData? icon;
}

class _GalleryChip extends StatelessWidget {
  const _GalleryChip({
    required this.entry,
    required this.selected,
    required this.onTap,
  });

  final _GalleryEntry entry;
  final bool selected;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    final Widget content = entry.icon != null
        ? Icon(
            entry.icon,
            size: 22,
            color: selected ? Colors.white : const Color(0xDDFFFFFF),
          )
        : Text(
            entry.label!,
            style: TextStyle(
              fontSize: 14,
              fontWeight: FontWeight.w600,
              color: selected ? Colors.white : const Color(0xCCFFFFFF),
            ),
          );

    return GestureDetector(
      onTap: onTap,
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 200),
        curve: Curves.easeOut,
        alignment: Alignment.center,
        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
        decoration: BoxDecoration(
          color: selected ? const Color(0x1FFFFFFF) : const Color(0x10FFFFFF),
          borderRadius: BorderRadius.circular(14),
          border: Border.all(
            color: selected ? _kAccent : const Color(0x00000000),
            width: 1.5,
          ),
          boxShadow: selected
              ? <BoxShadow>[
                  BoxShadow(
                    color: _kAccent.withValues(alpha: 0.5),
                    blurRadius: 8,
                  ),
                ]
              : const <BoxShadow>[],
        ),
        child: Opacity(
          opacity: onTap == null ? 0.4 : 1.0,
          child: content,
        ),
      ),
    );
  }
}
7
likes
160
points
39
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Fast, smooth steering-behavior particle field - particles form shapes/text from sampled atoms, morph, and flee touch.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter

More

Packages that depend on animated_particles