Tired of native Media Pickers on iOS & Android? Want a Media Picker that matches your App Theme? Then you are at the right place!
I have kept the UI simple and focused more on the functionality, the UI can be changed according to your preferences :)
This tutorial is divided into two parts:
Media Picker component which involves browsing and selecting images.
Media Preview component which involved displaying the selected image.
Prerequisites
This tutorial uses photo_manager & video_player packages to create the media picker and display media respectively.
At the timing of writing this blog, the photo_manager & video_player packages are at version 3.2.3 and 2.9.1 respectively.
Add the packages to your pubspec.yaml file and run flutter pub get
Part 1: Media Picker
The photo_manager package is a powerful package which allows us to access & manipulate media(images, video, audio, etc) on user device.
It provides an abstraction over native asset management apis and provides a clean api to work with media files.
The media picker will work in the following way,
First off, the user is displayed all the available Albums on the AlbumListScreen. Once the user selects an album they are navigated to MediaListScreen that displays all the available media files in that specific album.
After selecting a media the user is then navigated to MediaPreviewScreen.
HomeScreen
The HomeScreen is a simple stateless widget containing a single centered button that navigates the user to AlbumListScreen.
import 'package:custom_media_picker/screens/album_list_screen.dart';
import 'package:flutter/material.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Media Picker"),
),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const AlbumListScreen(),
),
);
},
child: const Text("Browse Media")),
),
);
}
}
AlbumListScreen
This screen will display all the available albums to the user.
Lets work on getting the all the albums first.
Create a async method _getAlbumList() to fetch the data.
Before fetching the data we first check for storage permission using,
PermissionState permissionState = await PhotoManager.requestPermissionExtend();
Once the user provides storage access we can proceed with fetching the data, if the permission is denied we can navigate the user to system settings of the current app for the permission.
await PhotoManager.openSetting();
Once we have the permission we can fetch albums using,
List<AssetPathEntity> albumList =
await PhotoManager.getAssetPathList(type: RequestType.common);
AssetPathEntity
contains all the details of a album and has methods to fetch all the images contained within.
The ReturnType.common
is used to specify that we only require albums containing Images and Videos.
The complete method will look like this,
Future<List<AssetPathEntity>> _getAlbumList() async {
PermissionState permissionState =
await PhotoManager.requestPermissionExtend();
if (permissionState.hasAccess || permissionState.isAuth) {
List<AssetPathEntity> albumList =
await PhotoManager.getAssetPathList(type: RequestType.common);
return albumList;
} else {
await PhotoManager.openSetting();
return await _getAlbumList();
}
}
We use this method with a FutureBuilder to display all the albums in a ListView. Incase the Future is not yet completed we show a progress indicator. Once the user taps on an album tile, they will be navigated to MediaListScreen.
The album is passed to the MediaListScreen.
AlbumListScreen
import 'package:custom_media_picker/screens/media_list_screen.dart';
import 'package:flutter/material.dart';
import 'package:photo_manager/photo_manager.dart';
class AlbumListScreen extends StatelessWidget {
const AlbumListScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Album List"),
),
body: FutureBuilder(
future: _getAlbumList(),
builder: (context, snapshot) {
if (snapshot.hasData) {
List<AssetPathEntity> albumList = snapshot.data!;
return ListView.builder(
shrinkWrap: true,
itemCount: albumList.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
onTap: () {
// Navigate to MediaListScreen to display Album Contents.
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MediaListScreen(
album: albumList[index],
),
),
);
},
title: Text(albumList[index].name),
),
);
});
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
),
);
}
Future<List<AssetPathEntity>> _getAlbumList() async {
PermissionState permissionState =
await PhotoManager.requestPermissionExtend();
if (permissionState.hasAccess || permissionState.isAuth) {
List<AssetPathEntity> albumList =
await PhotoManager.getAssetPathList(type: RequestType.common);
return albumList;
} else {
await PhotoManager.openSetting();
return await _getAlbumList();
}
}
}
MediaListScreen
This screen will display all the media files present in the album.
MediaListScreen will be a StatefulWidget with multiple fields that track the currentPage and the loading status of the screen.
Create local variables for loading, imagelist and pageCount.
int currentPage = 0;
List<AssetEntity> mediaList = [];
bool isLoading = false;
Similar to AlbumListScreen we create an async method
_getMediaList({int page = 0})
that fetches the media according to page count.
Media in the album can be fetched by calling
List<AssetEntity> media =
await widget.album.getAssetListPaged(page: page, size: 50);
The page count is passed to the getAssetListPaged
whenever the user requires.
Once we have the media we can update mediaList
.
Make sure to start & stop the loading animation before and after fetching the data and updating the local variables.
The method will look like this,
Future<void> _getMediaList({int page = 0}) async {
isLoading = true;
if (mounted) {
setState(() {});
}
List<AssetEntity> media =
await widget.album.getAssetListPaged(page: page, size: 50);
mediaList.addAll(media);
isLoading = false;
if (mounted) {
setState(() {});
}
}
The media thumbnails will be displayed using FutureBuilder.
We can get the thumbnail according to our required size using,
media.thumbnailDataWithSize(ThumbnailSize(imageSize.toInt(), imageSize.toInt()))
MediaThumbnailPreview
class MediaThumbnailPreview extends StatelessWidget {
const MediaThumbnailPreview({super.key, required this.media});
final AssetEntity media;
@override
Widget build(BuildContext context) {
final double imageSize = MediaQuery.of(context).size.width / 2;
return FutureBuilder(
future: media.thumbnailDataWithSize(
ThumbnailSize(imageSize.toInt(), imageSize.toInt())),
builder: (context, snapshot) {
if (snapshot.hasData) {
return GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MediaPreviewScreen(
media: media,
),
),
);
},
child: Image.memory(
snapshot.data!,
height: imageSize,
width: imageSize,
fit: BoxFit.cover,
),
);
}
return const Center(child: CircularProgressIndicator());
});
}
}
The MediaListScreen will display the all the MediaThumbnailPreviews in a GridView of 2 columns, below the GridView will be a +
Button to load more images.
MediaListScreen
class MediaListScreen extends StatefulWidget {
const MediaListScreen({super.key, required this.album});
final AssetPathEntity album;
@override
State<MediaListScreen> createState() => _MediaListScreenState();
}
class _MediaListScreenState extends State<MediaListScreen> {
@override
void initState() {
_getMediaList();
super.initState();
}
int currentPage = 0;
List<AssetEntity> mediaList = [];
bool isLoading = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.album.name),
),
body: SingleChildScrollView(
child: Column(
children: [
GridView.builder(
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
),
shrinkWrap: true,
itemCount: mediaList.length,
itemBuilder: (BuildContext context, int index) {
return MediaThumbnailPreview(media: mediaList[index]);
},
),
const SizedBox(height: 20),
if (isLoading) const CircularProgressIndicator(),
if (!isLoading || mediaList.isNotEmpty)
IconButton(
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
onPressed: () {
setState(() {
currentPage++;
_getMediaList(page: currentPage);
});
},
icon: const Icon(Icons.add),
)
],
),
),
);
}
Future<void> _getMediaList({int page = 0}) async {
isLoading = true;
if (mounted) {
setState(() {});
}
List<AssetEntity> media =
await widget.album.getAssetListPaged(page: page, size: 50);
mediaList.addAll(media);
isLoading = false;
if (mounted) {
setState(() {});
}
}
}
Once the user clicks on a media, they will be navigated to MediaPreviewScreen and the AssetEntity will be passed to it.
Part 2: Media Preview
MediaPreviewScreen will is divided into two part depending upon the type of media.
Image
For Images we will use FutureBuilder that will fetch the Image as File using the .file
getter.
FutureBuilder(
future: widget.media.file,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.file(
snapshot.data!,
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height * 2 / 3,
fit: BoxFit.fill,
),
const SizedBox(height: 10),
Text(widget.media.title!),
],
);
},
)
Video
Incase of a Video, we will have to initialize a VideoPlayerController in the initState of the Screen and addListeners to it.
Add a variable to display the loading animation before the VideoPlayer is initialized.
late final VideoPlayerController videoPlayerController;
bool isLoading = false;
We can use VideoPlayerController.file() constructor to initialize the controller using the video File obtained from .file
getter.
initState(),
@override
void initState() {
if (widget.media.type == AssetType.video) {
isLoading = true;
widget.media.file.then((File? file) {
videoPlayerController = VideoPlayerController.file(file!)
..initialize().then((value) {
isLoading = false;
if (mounted) {
setState(() {});
}
});
videoPlayerController.addListener(() {
setState(() {});
});
});
}
super.initState();
}
While the VideoPlayerController is being initialized we will display a loading animation, once its initialized we will display the VideoPlayer with a progress indicator and play/pause buttons at the bottom.
MediaPreviewScreen,
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:video_player/video_player.dart';
class MediaPreviewScreen extends StatefulWidget {
const MediaPreviewScreen({super.key, required this.media});
final AssetEntity media;
@override
State<MediaPreviewScreen> createState() => _MediaPreviewScreenState();
}
class _MediaPreviewScreenState extends State<MediaPreviewScreen> {
late final VideoPlayerController videoPlayerController;
bool isLoading = false;
@override
void initState() {
if (widget.media.type == AssetType.video) {
isLoading = true;
widget.media.file.then((File? file) {
videoPlayerController = VideoPlayerController.file(file!)
..initialize().then((value) {
isLoading = false;
if (mounted) {
setState(() {});
}
});
videoPlayerController.addListener(() {
setState(() {});
});
});
}
super.initState();
}
@override
void dispose() {
videoPlayerController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Preview"),
),
body: widget.media.type == AssetType.image
? FutureBuilder(
future: widget.media.file,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.file(
snapshot.data!,
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height * 2 / 3,
fit: BoxFit.fill,
),
const SizedBox(height: 10),
Text(widget.media.title!),
],
);
},
)
: isLoading
? const Center(
child: CircularProgressIndicator(),
)
: Column(
children: [
SizedBox(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height * 2 / 3,
child: Stack(
children: [
SizedBox(child: VideoPlayer(videoPlayerController)),
Align(
alignment: Alignment.bottomCenter,
child: Container(
width: MediaQuery.of(context).size.width,
height: 60,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black38, Colors.black54],
)),
child: Column(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
VideoProgressIndicator(videoPlayerController,
allowScrubbing: true),
IconButton(
onPressed: () async {
if (videoPlayerController
.value.isPlaying) {
await videoPlayerController.pause();
} else if (!videoPlayerController
.value.isPlaying) {
await videoPlayerController.play();
} else {
await videoPlayerController
.seekTo(const Duration(seconds: 0));
}
setState(() {});
},
icon: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
shape: BoxShape.circle,
border:
Border.all(color: Colors.white)),
child: Icon(
videoPlayerController.value.isCompleted
? Icons.restore
: videoPlayerController
.value.isPlaying
? Icons.pause
: Icons.play_arrow,
color: Colors.white,
),
),
),
],
),
),
)
],
),
),
const SizedBox(height: 10),
Text(widget.media.title!),
],
),
);
}
}
As mentioned earlier, the UI is kept simple to encourage readers to implement it in their own style.
Source Code: https://github.com/AlsoShantanuBorkar/custom_media_picker
Socials:
Thank you for reading!
Top comments (0)