native_image_compress 0.2.4
native_image_compress: ^0.2.4 copied to clipboard
Compress images to JPEG, PNG, WebP, HEIC, or AVIF using the platform's native encoders.
example/lib/main.dart
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:native_image_compress/image_compress.dart';
void main() {
runApp(const MyApp());
}
enum _Format { jpeg, png, webp, heic, avif }
extension on _Format {
String get label => switch (this) {
_Format.jpeg => 'JPEG',
_Format.png => 'PNG',
_Format.webp => 'WebP',
_Format.heic => 'HEIC',
_Format.avif => 'AVIF',
};
bool get supportsQuality => this != _Format.png;
double get defaultQuality => switch (this) {
_Format.jpeg => 80,
_Format.webp => 75,
_Format.heic => 70,
_Format.avif => 55,
_Format.png => 100,
};
}
Future<Size?> _decodeImageSize(Uint8List bytes) async {
try {
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
final size = Size(
frame.image.width.toDouble(),
frame.image.height.toDouble(),
);
frame.image.dispose();
codec.dispose();
return size;
} catch (_) {
return null;
}
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final _picker = ImagePicker();
Uint8List? _original;
Size? _originalSize;
Uint8List? _compressed;
_Format _format = _Format.jpeg;
final Map<_Format, double> _quality = {
for (final f in _Format.values) f: f.defaultQuality,
};
bool _cropEnabled = false;
double _cropX = 0;
double _cropY = 0;
double _cropWidth = 1;
double _cropHeight = 1;
bool _resizeEnabled = false;
double _resizeMaxWidth = 1;
double _resizeMaxHeight = 1;
bool _isCompressing = false;
String? _error;
Future<void> _pickImage() async {
final picked = await _picker.pickImage(source: ImageSource.gallery);
if (picked == null) return;
final bytes = await picked.readAsBytes();
final size = await _decodeImageSize(bytes);
setState(() {
_original = bytes;
_originalSize = size;
_compressed = null;
_error = null;
_cropEnabled = false;
_resizeEnabled = false;
if (size != null) {
_cropX = 0;
_cropY = 0;
_cropWidth = size.width;
_cropHeight = size.height;
_resizeMaxWidth = (size.width / 2).clamp(1, size.width);
_resizeMaxHeight = (size.height / 2).clamp(1, size.height);
}
});
}
Future<void> _compress() async {
final original = _original;
if (original == null) return;
setState(() {
_isCompressing = true;
_error = null;
});
try {
final quality = _quality[_format]!.round();
final cropOptions = _cropEnabled
? CropOptions(
x: _cropX.round(),
y: _cropY.round(),
width: _cropWidth.round(),
height: _cropHeight.round(),
)
: null;
final resizeOptions = _resizeEnabled
? ResizeOptions(
maxWidth: _resizeMaxWidth.round(),
maxHeight: _resizeMaxHeight.round(),
)
: null;
final result = switch (_format) {
_Format.jpeg => await compressToJpeg(
original,
quality: quality,
cropOptions: cropOptions,
resizeOptions: resizeOptions,
),
_Format.png => await compressToPng(
original,
cropOptions: cropOptions,
resizeOptions: resizeOptions,
),
_Format.webp => await compressToWebp(
original,
quality: quality,
cropOptions: cropOptions,
resizeOptions: resizeOptions,
),
_Format.heic => await compressToHeic(
original,
quality: quality,
cropOptions: cropOptions,
resizeOptions: resizeOptions,
),
_Format.avif => await compressToAvif(
original,
quality: quality,
cropOptions: cropOptions,
resizeOptions: resizeOptions,
),
};
setState(() => _compressed = result);
} catch (e) {
setState(() => _error = e.toString());
} finally {
setState(() => _isCompressing = false);
}
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
final kb = bytes / 1024;
if (kb < 1024) return '${kb.toStringAsFixed(1)} KB';
return '${(kb / 1024).toStringAsFixed(2)} MB';
}
Widget _valueSlider({
required String label,
required double value,
required double min,
required double max,
required ValueChanged<double> onChanged,
}) {
return Row(
children: [
SizedBox(width: 56, child: Text(label)),
Expanded(
child: Slider(
value: value.clamp(min, max),
min: min,
max: max,
label: value.round().toString(),
onChanged: onChanged,
),
),
SizedBox(width: 48, child: Text(value.round().toString())),
],
);
}
@override
Widget build(BuildContext context) {
final original = _original;
final originalSize = _originalSize;
final compressed = _compressed;
final reduction = (original != null && compressed != null)
? 100 * (1 - compressed.length / original.length)
: null;
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('native_image_compress example')),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.icon(
onPressed: _pickImage,
icon: const Icon(Icons.image),
label: const Text('Pick image'),
),
const SizedBox(height: 16),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SegmentedButton<_Format>(
segments: _Format.values
.map(
(f) => ButtonSegment(value: f, label: Text(f.label)),
)
.toList(),
selected: {_format},
onSelectionChanged: (selection) =>
setState(() => _format = selection.first),
),
),
const SizedBox(height: 8),
if (_format.supportsQuality)
Row(
children: [
const Text('Quality'),
Expanded(
child: Slider(
value: _quality[_format]!,
min: 1,
max: 100,
divisions: 99,
label: _quality[_format]!.round().toString(),
onChanged: (v) =>
setState(() => _quality[_format] = v),
),
),
Text(_quality[_format]!.round().toString()),
],
),
if (originalSize != null) ...[
const Divider(height: 24),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Crop'),
value: _cropEnabled,
onChanged: (v) => setState(() => _cropEnabled = v),
),
if (_cropEnabled) ...[
_valueSlider(
label: 'X',
value: _cropX,
min: 0,
max: originalSize.width,
onChanged: (v) => setState(() => _cropX = v),
),
_valueSlider(
label: 'Y',
value: _cropY,
min: 0,
max: originalSize.height,
onChanged: (v) => setState(() => _cropY = v),
),
_valueSlider(
label: 'Width',
value: _cropWidth,
min: 1,
max: originalSize.width,
onChanged: (v) => setState(() => _cropWidth = v),
),
_valueSlider(
label: 'Height',
value: _cropHeight,
min: 1,
max: originalSize.height,
onChanged: (v) => setState(() => _cropHeight = v),
),
],
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Resize'),
value: _resizeEnabled,
onChanged: (v) => setState(() => _resizeEnabled = v),
),
if (_resizeEnabled) ...[
_valueSlider(
label: 'Max W',
value: _resizeMaxWidth,
min: 1,
max: originalSize.width,
onChanged: (v) => setState(() => _resizeMaxWidth = v),
),
_valueSlider(
label: 'Max H',
value: _resizeMaxHeight,
min: 1,
max: originalSize.height,
onChanged: (v) => setState(() => _resizeMaxHeight = v),
),
],
const Divider(height: 24),
],
const SizedBox(height: 8),
FilledButton.icon(
onPressed: (_original == null || _isCompressing)
? null
: _compress,
icon: _isCompressing
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.compress),
label: Text(_isCompressing ? 'Compressing...' : 'Compress'),
),
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: _ImagePreview(
title: 'Original',
bytes: original,
subtitle: original == null
? null
: _formatBytes(original.length),
),
),
const SizedBox(width: 16),
Expanded(
child: _ImagePreview(
title: 'Compressed',
bytes: compressed,
subtitle: compressed == null
? null
: _formatBytes(compressed.length),
),
),
],
),
if (reduction != null) ...[
const SizedBox(height: 16),
Text(
reduction >= 0
? '${reduction.toStringAsFixed(1)}% smaller'
: '${(-reduction).toStringAsFixed(1)}% larger',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
],
],
),
),
),
),
);
}
}
class _ImagePreview extends StatelessWidget {
const _ImagePreview({
required this.title,
required this.bytes,
this.subtitle,
});
final String title;
final Uint8List? bytes;
final String? subtitle;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(title, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
AspectRatio(
aspectRatio: 1,
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
),
child: switch (bytes) {
null => const Icon(Icons.image_outlined, size: 48),
final b => Image.memory(
b,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.insert_drive_file_outlined, size: 48),
),
},
),
),
if (subtitle != null) ...[const SizedBox(height: 4), Text(subtitle!)],
],
);
}
}