piksel 0.2.1 copy "piksel: ^0.2.1" to clipboard
piksel: ^0.2.1 copied to clipboard

A type-safe, extensible image loading and caching pipeline for Flutter — network, SVG, AVIF, GIF, video-frame and blurhash behind one API.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:piksel/piksel.dart';
import 'package:piksel_avif/piksel_avif.dart';
import 'package:piksel_blurhash/piksel_blurhash.dart';
import 'package:piksel_svg/piksel_svg.dart';
import 'package:piksel_video_frame/piksel_video_frame.dart';

void main() {
  // Configure the global loader once, registering every format plugin. Each is
  // a separate package; the core imports none of them — this is the whole
  // extension surface.
  Piksel.configure(
    Piksel(
      memoryCache: MemoryCache.lru(maxSizeBytes: 64 << 20),
      // AvifDecoder uses the OS-native AVIF decoder (Android 12+/iOS 16+/
      // macOS 13+) — our own plugin, no bundled codec.
      components: const ComponentRegistry(
        decoders: [SvgDecoder(), AvifDecoder()],
        fetchers: [VideoFrameFetcher()],
      ),
    ),
  );
  PikselBlurhash.register();
  runApp(const PikselDemoApp());
}

const _svgLogo =
    'https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg';

String photo(int seed, {int size = 600}) =>
    // Unsplash: reliable CDN (picsum.photos drops connections on some networks).
    'https://images.unsplash.com/photo-${_unsplash[seed % _unsplash.length]}'
    '?w=$size&h=$size&fit=crop&q=70&auto=format&sig=$seed';

const _unsplash = [
  '1506744038136-46273834b3fb',
  '1470071459604-3b5ec3a7fe05',
  '1441974231531-c6227db76b6e',
  '1501785888041-af3ef285b470',
  '1469474968028-56623f02e42e',
  '1447752875215-b2761acb3c5d',
  '1433086966358-54859d0ed716',
  '1518241353330-0f7941c2d9b5',
];

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'piksel demo',
      theme: ThemeData(
        colorSchemeSeed: const Color(0xFF3A6EA5),
        useMaterial3: true,
      ),
      home: const HomeScreen(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: 3,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('piksel'),
          bottom: const TabBar(
            tabs: [
              Tab(text: 'Showcase', icon: Icon(Icons.auto_awesome)),
              Tab(text: 'Grid', icon: Icon(Icons.grid_view)),
              Tab(text: 'Benchmark', icon: Icon(Icons.speed)),
            ],
          ),
        ),
        body: const TabBarView(
          children: [ShowcaseTab(), GridTab(), BenchmarkTab()],
        ),
      ),
    );
  }
}

/// Every feature: transforms, state images, custom builder, ImageProvider interop.
class ShowcaseTab extends StatelessWidget {
  const ShowcaseTab({super.key});

  @override
  Widget build(BuildContext context) {
    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        _section('One-liner: PikselImage.network'),
        SizedBox(
          height: 160,
          child: PikselImage.network(photo(1), fit: BoxFit.cover),
        ),
        _section('Transformations: rounded + blur'),
        SizedBox(
          height: 160,
          child: PikselImage(
            ImageRequest.network(
              photo(2),
              transformations: const [RoundedCorners(48), Blur(2)],
              placeholder: const ColorStateImage(Color(0xFFEEEEEE)),
            ),
          ),
        ),
        _section('Format plugin: SVG through the SAME widget + pipeline'),
        SizedBox(
          height: 120,
          child: PikselImage.network(_svgLogo, fit: BoxFit.contain),
        ),
        _section('Format plugin: AVIF via the OS-native decoder'),
        SizedBox(
          height: 200,
          child: PikselImage(
            const ImageRequest(source: AssetSource('assets/kimono.avif')),
            fit: BoxFit.contain,
          ),
        ),
        _section('Animated GIF: plays automatically (animate: false pins it)'),
        SizedBox(
          height: 160,
          child: PikselImage.network(
            'https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif',
            fit: BoxFit.contain,
          ),
        ),
        _section('Blurhash placeholder: paints before the first byte arrives'),
        SizedBox(
          height: 160,
          child: PikselImage(
            ImageRequest.network(
              photo(11, size: 1200),
              placeholder: const BlurhashStateImage(
                r'LEHV6nWB2yk8pyo0adR*.7kCMdnj',
              ),
            ),
          ),
        ),
        _section('Circle crop + placeholder + error state'),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            SizedBox(
              width: 120,
              height: 120,
              child: PikselImage(
                ImageRequest.network(
                  photo(3),
                  transformations: const [CircleCrop()],
                  placeholder: const ColorStateImage(Color(0xFFE0E0E0)),
                ),
              ),
            ),
            SizedBox(
              width: 120,
              height: 120,
              child: PikselImage(
                ImageRequest.network(
                  'https://invalid.example.com/nope.jpg',
                  error: const IconStateImage(Icons.broken_image, size: 40),
                ),
              ),
            ),
          ],
        ),
        _section('Custom builder: exhaustive LoadState switch + progress'),
        SizedBox(
          height: 160,
          child: PikselImage(
            ImageRequest.network(photo(4)),
            builder: (context, state) => switch (state) {
              LoadLoading(:final progress) => Center(
                child: CircularProgressIndicator(value: progress),
              ),
              LoadSuccess(:final result) => RawImage(
                image: result.image,
                fit: BoxFit.cover,
                width: double.infinity,
              ),
              LoadError() => const Center(child: Icon(Icons.error)),
            },
          ),
        ),
        _section('ImageProvider interop: drops into stock Flutter widgets'),
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            CircleAvatar(
              radius: 40,
              backgroundImage: ImageRequest.network(photo(5)).toImageProvider(),
            ),
            Container(
              width: 90,
              height: 90,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(12),
                image: DecorationImage(
                  image: ImageRequest.network(photo(6)).toImageProvider(),
                  fit: BoxFit.cover,
                ),
              ),
            ),
            Image(
              image: ImageRequest.network(photo(7)).toImageProvider(),
              width: 90,
              height: 90,
              fit: BoxFit.cover,
              errorBuilder: (context, error, stack) => const SizedBox(
                width: 90,
                height: 90,
                child: Icon(Icons.broken_image, size: 32),
              ),
            ),
          ],
        ),
        const SizedBox(height: 24),
      ],
    );
  }

  Widget _section(String title) => Padding(
    padding: const EdgeInsets.only(top: 24, bottom: 8),
    child: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
  );
}

/// Size-aware decoding + scroll cancellation under an image-heavy grid, with
/// viewport-ahead prefetch warming tiles before they scroll in.
class GridTab extends StatefulWidget {
  const GridTab({super.key});

  @override
  State<GridTab> createState() => _GridTabState();
}

class _GridTabState extends State<GridTab> {
  static const _count = 90;

  double _dpr = 1.0;

  // Sized so the prefetch decode matches a ~3-column cell instead of the
  // source's natural resolution.
  late final _prefetcher = PikselPrefetcher(
    requestFor: (i) => i < _count
        ? ImageRequest.network(
            photo(100 + i, size: 300),
            size: SizeResolver.fixed(130, 130, devicePixelRatio: _dpr),
          )
        : null,
  );

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _dpr = MediaQuery.devicePixelRatioOf(context);
  }

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

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      padding: const EdgeInsets.all(8),
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3,
        crossAxisSpacing: 8,
        mainAxisSpacing: 8,
      ),
      itemCount: _count,
      itemBuilder: (context, i) {
        _prefetcher.didBuildIndex(i);
        return ClipRRect(
          borderRadius: BorderRadius.circular(10),
          // No explicit size → piksel resolves the cell size from layout and
          // decodes down to it, so the grid never holds full-res bitmaps.
          child: PikselImage.network(
            photo(100 + i, size: 300),
            placeholder: const ColorStateImage(Color(0xFFEEEEEE)),
          ),
        );
      },
    );
  }
}

class _Row {
  _Row(this.label, this.ms, this.from);
  final String label;
  final double ms;
  final DataFrom from;
}

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

  @override
  State<BenchmarkTab> createState() => _BenchmarkTabState();
}

class _BenchmarkTabState extends State<BenchmarkTab> {
  final List<_Row> _rows = [];
  bool _running = false;
  String _status = 'Tap "Run" to benchmark against live network images.';

  Piksel get _p => Piksel.instance;

  Future<(double, DataFrom)> _time(ImageRequest req) async {
    final sw = Stopwatch()..start();
    final r = await _p.execute(req);
    sw.stop();
    final from = r is ImageSuccess ? r.from : DataFrom.network;
    if (r is ImageSuccess) r.image.dispose();
    return (sw.elapsedMicroseconds / 1000.0, from);
  }

  Future<void> _run() async {
    setState(() {
      _running = true;
      _rows.clear();
      _status = 'Clearing caches…';
    });
    await _p.clear();

    final req = ImageRequest.network(photo(777));

    setState(() => _status = 'Cold load (network + decode)…');
    final cold = await _time(req);
    _rows.add(_Row('Cold (network)', cold.$1, cold.$2));

    final warm = await _time(req);
    _rows.add(_Row('Warm (memory)', warm.$1, warm.$2));

    // Drop memory only → re-decode from the disk result cache.
    _p.memoryCache.clear();
    final disk = await _time(req);
    _rows.add(_Row('Disk (result cache)', disk.$1, disk.$2));

    setState(() => _status = 'Dedup: 40 concurrent identical requests…');
    await _p.clear();
    final dedupReq = ImageRequest.network(photo(888));
    final sw = Stopwatch()..start();
    final results = await Future.wait(
      List.generate(40, (_) => _p.execute(dedupReq)),
    );
    sw.stop();
    for (final r in results) {
      if (r is ImageSuccess) r.image.dispose();
    }
    _rows.add(
      _Row(
        'Dedup 40x (total)',
        sw.elapsedMicroseconds / 1000.0,
        DataFrom.network,
      ),
    );

    setState(() {
      _running = false;
      _status =
          'Done. memory vs cold = '
          '${(cold.$1 / warm.$1).clamp(1, 99999).toStringAsFixed(0)}x, '
          'disk vs cold = ${(cold.$1 / disk.$1).clamp(1, 99999).toStringAsFixed(1)}x.';
    });
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          FilledButton.icon(
            onPressed: _running ? null : _run,
            icon: _running
                ? const SizedBox(
                    width: 16,
                    height: 16,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : const Icon(Icons.play_arrow),
            label: Text(_running ? 'Running…' : 'Run benchmark'),
          ),
          const SizedBox(height: 12),
          Text(_status, style: Theme.of(context).textTheme.bodySmall),
          const SizedBox(height: 16),
          Expanded(
            child: ListView(
              children: [
                for (final r in _rows)
                  Card(
                    child: ListTile(
                      dense: true,
                      title: Text(r.label),
                      subtitle: Text('served from: ${r.from.name}'),
                      trailing: Text(
                        '${r.ms.toStringAsFixed(1)} ms',
                        style: const TextStyle(
                          fontWeight: FontWeight.bold,
                          fontSize: 16,
                        ),
                      ),
                    ),
                  ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}
0
likes
160
points
111
downloads
screenshot

Documentation

API reference

Publisher

verified publisherandroidpoet.dev

Weekly Downloads

A type-safe, extensible image loading and caching pipeline for Flutter — network, SVG, AVIF, GIF, video-frame and blurhash behind one API.

Repository (GitHub)
View/report issues

Topics

#image #cache #image-loading #svg #avif

License

MIT (license)

Dependencies

collection, crypto, flutter, http, meta, path, path_provider, web

More

Packages that depend on piksel