piksel 0.2.1
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.
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,
),
),
),
),
],
),
),
],
),
);
}
}