A profile-photo screen should survive more than the perfect gallery selection: the user may open the camera, cancel the system interface, deny access, or choose an image large enough to exhaust memory. In this tutorial, you will build a picker that handles those outcomes and previews the resulting photo with Image.file on Android and iOS.
You will use pickImage for camera and gallery access, add multi-selection with pickMultiImage, and reduce oversized results through imageQuality and maxWidth. Platform configuration, Android activity recovery, and a browser-safe preview are covered separately so their constraints remain clear.
Add the current image_picker release to an existing Flutter application before running the examples. A physical device is preferable for camera testing; an iOS Simulator does not provide the same camera behaviour as real hardware.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowAdd image_picker and understand the XFile result
Install the package with Flutter's package command. This records a compatible current version in pubspec.yaml rather than tying the tutorial to an ageing version number.
flutter pub add image_picker
The plugin returns an XFile, an abstraction supplied by the cross-platform file APIs used by Flutter plugins. Its path can be passed to File on Android and iOS, while methods such as readAsBytes also work in browser builds. A single-image request returns XFile? because closing the native picker is an ordinary, valid outcome.
Offer camera and gallery actions from one screen
The following mobile example keeps one ImagePicker instance in state and routes both buttons through the same method. It catches platform failures without confusing them with cancellation, checks mounted after the asynchronous operation, and renders the selected path through Image.file.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
void main() {
runApp(const MaterialApp(home: PhotoPickerPage()));
}
class PhotoPickerPage extends StatefulWidget {
const PhotoPickerPage({super.key});
@override
State<PhotoPickerPage> createState() => _PhotoPickerPageState();
}
class _PhotoPickerPageState extends State<PhotoPickerPage> {
final ImagePicker _picker = ImagePicker();
XFile? _selectedImage;
String? _message;
bool _picking = false;
Future<void> _pickImage(ImageSource source) async {
if (_picking) return;
setState(() {
_picking = true;
_message = null;
});
try {
final XFile? image = await _picker.pickImage(
source: source,
maxWidth: 1600,
imageQuality: 85,
);
if (!mounted) return;
if (image == null) {
setState(() {
_message = 'No image was selected.';
});
return;
}
setState(() {
_selectedImage = image;
_message = null;
});
} on PlatformException catch (error) {
if (!mounted) return;
setState(() {
_message = 'The image picker could not open: ${error.message ?? error.code}';
});
} catch (error) {
if (!mounted) return;
setState(() {
_message = 'Could not load the selected image: $error';
});
} finally {
if (mounted) {
setState(() {
_picking = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Choose a photo')),
body: ListView(
padding: const EdgeInsets.all(24),
children: [
AspectRatio(
aspectRatio: 4 / 3,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(16),
),
child: _selectedImage == null
? const Center(child: Text('No photo selected'))
: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.file(
File(_selectedImage!.path),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Text('This image could not be displayed.'),
);
},
),
),
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: _picking
? null
: () => _pickImage(ImageSource.camera),
icon: const Icon(Icons.camera_alt),
label: const Text('Take a photo'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _picking
? null
: () => _pickImage(ImageSource.gallery),
icon: const Icon(Icons.photo_library),
label: const Text('Choose from gallery'),
),
if (_picking) ...[
const SizedBox(height: 16),
const Center(child: CircularProgressIndicator()),
],
if (_message != null) ...[
const SizedBox(height: 16),
Text(
_message!,
semanticsLabel: 'Image picker status: $_message',
),
],
],
),
);
}
}
The guard against concurrent requests matters because repeated taps can launch overlapping native activities. The preview uses BoxFit.cover, which crops visually but does not alter the saved image. Perform any permanent crop with a dedicated image-processing workflow.
Treat cancellation as a normal branch
Both the Android and iOS pickers let the user return without selecting anything. In that case, pickImage resolves to null; force-unwrapping the result immediately is therefore unsafe.
final XFile? image = await ImagePicker().pickImage(
source: ImageSource.gallery,
);
if (image == null) {
// The user cancelled or left the system picker.
return;
}
debugPrint('Selected image: ${image.path}');
Cancellation usually needs no error banner. Preserve the previous preview and quietly return unless the surrounding task requires explicit status. Permission failures and native picker faults may arrive as PlatformException, so catch them separately and present a useful route to the app's settings when access has been denied.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowSelect several gallery images with pickMultiImage
A product gallery or attachment composer often needs more than one result. pickMultiImage returns a non-null List<XFile>; an empty list represents a picker closed without usable selections.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
void main() {
runApp(const MaterialApp(home: MultiImagePage()));
}
class MultiImagePage extends StatefulWidget {
const MultiImagePage({super.key});
@override
State<MultiImagePage> createState() => _MultiImagePageState();
}
class _MultiImagePageState extends State<MultiImagePage> {
final ImagePicker _picker = ImagePicker();
List<XFile> _images = const [];
String? _error;
Future<void> _chooseImages() async {
try {
final List<XFile> images = await _picker.pickMultiImage(
maxWidth: 1600,
imageQuality: 82,
);
if (!mounted || images.isEmpty) return;
setState(() {
_images = images;
_error = null;
});
} on PlatformException catch (error) {
if (!mounted) return;
setState(() {
_error = error.message ?? 'Gallery access failed.';
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Product images')),
floatingActionButton: FloatingActionButton.extended(
onPressed: _chooseImages,
icon: const Icon(Icons.add_photo_alternate),
label: const Text('Add images'),
),
body: _images.isEmpty
? Center(child: Text(_error ?? 'Choose one or more images.'))
: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _images.length,
itemBuilder: (context, index) {
return Image.file(
File(_images[index].path),
fit: BoxFit.cover,
);
},
),
);
}
}
Multi-selection is for the gallery rather than the camera. If users must take several photographs, call the single camera flow repeatedly and append each successful result. Consider imposing your own attachment count and byte-size limits before uploading, since platform selection interfaces do not guarantee identical limits.
Reduce dimensions and compression before previewing
Modern cameras can produce images far larger than a profile picture or listing thumbnail requires. Passing maxWidth asks the picker to scale down wider images while preserving their aspect ratio; imageQuality requests lossy compression on a scale from 0 to 100.
final XFile? uploadImage = await ImagePicker().pickImage(
source: ImageSource.gallery,
maxWidth: 1920,
maxHeight: 1920,
imageQuality: 80,
);
if (uploadImage != null) {
final int bytes = await uploadImage.length();
debugPrint('Prepared image size: $bytes bytes');
}
These options are useful for controlling memory, bandwidth, and upload time, but they do not promise an exact file size. Compression support also depends on the source format and platform implementation. Validate the resulting byte length on your upload boundary, and reject or process the file again if a server limit is strict. Avoid quality values near zero merely to satisfy a limit; visible artefacts can make text and faces unusable.
Declare camera and photo-library reasons on iOS
Apple requires human-readable purpose strings for protected resources. Add the relevant keys inside the main <dict> in ios/Runner/Info.plist. The wording is shown in the system permission prompt, so describe the feature rather than writing a vague request.
<key>NSCameraUsageDescription</key>
<string>Take a profile photograph for your account.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose a profile photograph from your library.</string>
Omit the camera key only when the application never calls ImageSource.camera. Video capture has an additional microphone-purpose requirement, but it is unnecessary for the still-image methods used here. Test denial as well as approval: once a user has denied access, iOS may require them to change the permission in Settings instead of showing the prompt again.
Know what Android handles and what your app must preserve
Current image_picker integrations do not normally require a storage permission in AndroidManifest.xml for this image-selection flow. Supported Android releases use the system Photo Picker where available or an appropriate system selection activity. Adding broad storage permissions can create unnecessary review and privacy work.
Camera results are placed in application cache and should not be treated as permanent originals. Copy a photograph into durable app storage or upload it after selection if it must survive cache eviction. Android may also destroy the Flutter activity while the external picker is open under memory pressure. Recover that result during start-up with retrieveLostData:
final ImagePicker picker = ImagePicker();
Future<List<XFile>> recoverAndroidImages() async {
final LostDataResponse response = await picker.retrieveLostData();
if (response.isEmpty) {
return const [];
}
final List<XFile>? files = response.files;
if (files != null) {
return files;
}
if (response.exception != null) {
debugPrint('Lost picker result: ${response.exception}');
}
return const [];
}
Call this recovery path when the screen or application state is initialised, then merge recovered files into the same state used by ordinary selections. It is harmless to receive an empty response. Do not wait until the user opens the picker again, because the pending result belongs to the earlier activity.
Render XFile bytes when targeting the web
The mobile example imports dart:io, so it cannot be compiled unchanged for Flutter web. Browser-selected files do not expose normal operating-system paths. A portable preview can read the XFileImage.memory.
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
class CrossPlatformPreview extends StatelessWidget {
const CrossPlatformPreview({
required this.image,
super.key,
});
final XFile image;
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List>(
future: image.readAsBytes(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return const Text('The selected image could not be read.');
}
final Uint8List? bytes = snapshot.data;
if (bytes == null) {
return const CircularProgressIndicator();
}
return Image.memory(
bytes,
fit: BoxFit.cover,
gaplessPlayback: true,
);
},
);
}
}
On the web, camera availability and the exact chooser interface depend on the browser, device, and installed cameras. Treat ImageSource.camera as a request rather than a guarantee about which physical camera opens. Keep server-side validation in place because browser MIME types, filenames, and extensions are user-controlled inputs.
Avoid image-picker failures that only appear outside development
- Assuming a result exists: check the nullable single-image result and the empty multi-image list before reading a path.
- Keeping only a cache path: copy or upload camera output when the image must persist beyond the current session.
- Updating a disposed screen: check
mountedafter each awaited picker call before invokingsetState. - Launching twice: disable picker controls while a native request is active.
- Ignoring Android lost data: restore interrupted picker results during initialisation.
- Using Image.file in a browser build: prefer
readAsBytesandImage.memoryfor shared web code. - Trusting file extensions: validate decoded content, byte size, and acceptable formats before storage or upload.
Permission handling should also match the feature. Do not request camera access at application launch when the user only wants the gallery; let the relevant action trigger its own system flow and explain any denial in context.
Verify camera, gallery, cancellation, and recovery separately
Run the screen on at least one physical Android device and one physical iPhone before release. Take a photograph, choose a gallery image, select several images, and inspect whether the preview orientation and quality remain acceptable. Then repeat each picker action and cancel it: the existing screen must remain usable, with no null assertion or permanent loading indicator.
- Deny camera access and confirm the failure is explained without crashing.
- Deny photo-library access and verify that the application offers a sensible route to Settings.
- Select a very large image and record the resulting dimensions or byte length after resizing.
- On Android, enable “Don't keep activities” in Developer options, open the picker, return with an image, and confirm the lost-data recovery path restores it.
- Build the web target and use the byte-based preview rather than importing
dart:io.
Finally, run flutter analyze and exercise a release-mode build. Native permission configuration and activity recreation can behave differently from a hot-reloaded development session, so a successful debug selection is only the first check.
Further reads
Keep going with these related tutorials from this site.
- Flutter: The Complete Guide — continue through the full Flutter learning path
- Flutter Material Widgets Catalogue — build accessible buttons, previews, and progress states around the picker
- Flutter Layout Widgets Guide — arrange responsive image grids and profile-photo interfaces
- Dart Language Guide for Flutter — strengthen asynchronous, nullable, and exception-handling code used by plugins