How To: Create a custom media picker in Flutter to select photos and videos from the gallery

Max Stoller
10 min readApr 3, 2020

--

So you’re writing a Flutter app and now you want to allow the user to select photos and/or videos from their device? You don’t want to use the device’s default image picker because you think it’s lame?

Great news, this is the tutorial for you!

Prerequisites:

In order to create a custom media selector we must first find a way to access the images and videos stored on the device. Luckily, there is an amazing Flutter package that exists for doing this! The photo_manager package will allow us to read all of the media stored on the device (iOS and Android), and access important data from each image or video that we can use to create the UI.

At the time of writing the latest version of photo_manager is 0.5.0. Please refer to the photo_manager pub page and use the current latest version for the best experience.

Like any other package in Flutter, adding it is as simple as adding the dependency in the pubspec.yaml file.

After adding the dependency run this command in the project’s working directory to download the package (Some IDE’s will run this automatically):

flutter packages get

Now let’s start coding!

I’m going to iterate the code as we go along so that you can follow along and understand how all of the parts work together.

If you just want the final code, it’s at the bottom of this article :)

Import the photo_manager package

The first step to use the package is importing it into our dart file. You do this by adding the following import statement at the top of the dart file:

import 'package:photo_manager/photo_manager.dart';

List albums from the device

Create a new stateful widget, I’ll call it “MediaGrid”, which will contain the logic for this.

Inside of the MediaGrid widget we need to request device permissions to access the device’s storage (to retrieve the photos and videos). I will create a new method that I can call to fetch the media, aptly named “_fetchNewMedia”. Inside of the _fetchNewMedia method I will request storage permissions, and then access the device’s “Recent” or “All” album and read the first twenty files.

We can start by calling getAssetPathList and printing the result to see the list of albums on the device.

List<AssetPathEntity> albums = await PhotoManager.getAssetPathList();print(albums);

If you want to try this on your device, the full main.dart file looks like this right now:

import 'package:flutter/material.dart';
import 'package:photo_manager/photo_manager.dart';
void main() => runApp(MyApp());class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Media Picker Example',
theme: ThemeData(
// This is the theme of your application.
primarySwatch: Colors.red,
),
home: MyHomePage(title: 'Media Picker Example App'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: MediaGrid(),
);
}
}
class MediaGrid extends StatefulWidget {
@override
_MediaGridState createState() => _MediaGridState();
}
class _MediaGridState extends State<MediaGrid> {
@override
void initState() {
super.initState();
_fetchNewMedia();
}
_fetchNewMedia() async {
var result = await PhotoManager.requestPermission();
if (result) {
// success
//load the album list
List<AssetPathEntity> albums =
await PhotoManager.getAssetPathList(onlyAll: true);
print(albums);
} else {
// fail
/// if result is fail, you can call `PhotoManager.openSetting();` to open android/ios applicaton's setting to get permission
}
}
@override
Widget build(BuildContext context) {
return Container();
}
}

When you first run the app on your device you will get the prompt for the app to access your devices storage, like this:

After accepting you will see the list of albums printed to console, which on my device looks like this:

[AssetPathEntity{ name: Recent, id:isAll, length = 1704 }, AssetPathEntity{ name: Restored, id:-1366619224, length = 7 }, AssetPathEntity{ name: Camera, id:-1739773001, length = 857 }, AssetPathEntity{ name: Download, id:540528482, length = 825 }, AssetPathEntity{ name: Messages, id:325974348, length = 15 }]

There is a lot of information available if you decide you want to work with specific albums individually. For this tutorial, we only need the “Recent” album which you can see has an id of “isAll”. The photo_manager package provides a way to only return this one album, just pass “onlyAll: true” when calling the getAssetPathList method, like this:

List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(onlyAll: true);

Now the albums variable will be a list containing only one item, the “Recent” album on my device.

Access photos and videos from an album

So now that we have the album, let’s read photos and videos from it:

List<AssetEntity> photos = await albums[0].getAssetListPaged(0, 20);print(photos);

Restart the app, and you will see a list get printed to the console. It will look like this:

[AssetEntity{id:25066}, AssetEntity{id:25057}, AssetEntity{id:25056}, AssetEntity{id:25001}, AssetEntity{id:25000}, AssetEntity{id:24999}, AssetEntity{id:24998}, AssetEntity{id:24997}, AssetEntity{id:24996}, AssetEntity{id:24995}, AssetEntity{id:24991}, AssetEntity{id:24984}, AssetEntity{id:24983}, AssetEntity{id:24982}, AssetEntity{id:24979}, AssetEntity{id:24978}, AssetEntity{id:24977}, AssetEntity{id:24973}, AssetEntity{id:24972}, AssetEntity{id:24971}]

AssetEntity is a class that the photo_manager package provides for working with media. You can look at the “AssetEntity” section of the photo_manager readme to see all of the data that is available from this class.

Display device media in the app

Now that we can access the photos and videos in the album, we want to display them back to the user. I’m going to use a GridView to do this. I will create a variable in the MediaGrid state to hold a list of assets, and will add a setState call for when photo_manager has returned the data to our widget.

The photo_manager package also provides a method to retrieve thumbnails for each asset, and I will use a FutureBuilder to await those values and display the thumbnail when they are ready.

The MediaGrid class now looks like this:

class MediaGrid extends StatefulWidget {
@override
_MediaGridState createState() => _MediaGridState();
}
class _MediaGridState extends State<MediaGrid> {
List<AssetEntity> _mediaList = [];
@override
void initState() {
super.initState();
_fetchNewMedia();
}
_fetchNewMedia() async {
var result = await PhotoManager.requestPermission();
if (result) {
// success
//load the album list
List<AssetPathEntity> albums =
await PhotoManager.getAssetPathList(onlyAll: true);
print(albums);
List<AssetEntity> media = await albums[0].getAssetListPaged(0, 20);
print(media);
setState(() {
_mediaList = media;
});
} else {
// fail
/// if result is fail, you can call `PhotoManager.openSetting();` to open android/ios applicaton's setting to get permission
}
}
@override
Widget build(BuildContext context) {
return GridView.builder(
itemCount: _mediaList.length,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
itemBuilder: (BuildContext context, int index) {
return FutureBuilder(
future: _mediaList[index].thumbDataWithSize(200, 200),
builder: (BuildContext context, snapshot) {
if (snapshot.connectionState == ConnectionState.done)
return Image.memory(
snapshot.data,
);
return Container();
},
);
});
}
}

And on my device, the app looks like this:

Looks a little strange because the images don’t line up perfectly. We can change the BoxFit property of the Image class to make them all perfect squares in the grid.

Image.memory(
snapshot.data,
fit: BoxFit.cover,
);

Additionally, let’s create an overlay for videos so we can know which items are videos and which are images. We can do this by checking if the asset is a video, and adding a video icon by using a Stack if that’s true.

The FutureBuilder for the thumbnails now looks like this:

FutureBuilder(
future: _mediaList[index].thumbDataWithSize(200, 200),
builder: (BuildContext context, snapshot) {
if (snapshot.connectionState == ConnectionState.done)
return Stack(
children: <Widget>[
Positioned.fill(
child: Image.memory(
snapshot.data,
fit: BoxFit.cover,
),
),
if (_mediaList[index].type == AssetType.video)
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: EdgeInsets.only(right: 5, bottom: 5),
child: Icon(
Icons.videocam,
color: Colors.white,
),
),
),
],
);
return Container();
},
);

Now we have a more cohesive grid and can easily tell which items are videos:

Loading more assets!

So we can display the assets properly, but what’s the best way to load more assets? Since the results from getAssetListPaged are paginated, we can easily load more assets as the user scrolls down by wrapping the GridView in a NotificationListener and listening for scroll events.

You will have to be mindful during scroll events to not load the same page multiple times, and to not repeatedly request unnecessary pages during scroll events. I chose to handle these cases by creating two variables, an integer to track the current page number and an integer to track the previous page number. On each call to the _fetchNewMedia method I set the “lastPage” variable to the “currentPage” variable. I only increment the current page number after the results from the page have been returned. If the two values are equal to each other at any given time I know that the code is waiting for a page to be returned, and I don’t continue calling _fetchNewMedia. This is important, if you are confused by this explanation try to read the code.

With those changes included, the full MediaGrid widget now looks like this (I also increased the number of assets returned per page to 60):

class MediaGrid extends StatefulWidget {
@override
_MediaGridState createState() => _MediaGridState();
}
class _MediaGridState extends State<MediaGrid> {
List<AssetEntity> _mediaList = [];
int currentPage = 0;
int lastPage;
@override
void initState() {
super.initState();
_fetchNewMedia();
}
_handleScrollEvent(ScrollNotification scroll) {
if (scroll.metrics.pixels / scroll.metrics.maxScrollExtent > 0.33) {
if (currentPage != lastPage) {
_fetchNewMedia();
}
}
}
_fetchNewMedia() async {
lastPage = currentPage;
var result = await PhotoManager.requestPermission();
if (result) {
// success
//load the album list
List<AssetPathEntity> albums =
await PhotoManager.getAssetPathList(onlyAll: true);
print(albums);
List<AssetEntity> media =
await albums[0].getAssetListPaged(currentPage, 60);
print(media);
setState(() {
_mediaList.addAll(media);
currentPage++;
});
} else {
// fail
/// if result is fail, you can call `PhotoManager.openSetting();` to open android/ios applicaton's setting to get permission
}
}
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification scroll) {
_handleScrollEvent(scroll);
return;
},
child: GridView.builder(
itemCount: _mediaList.length,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
itemBuilder: (BuildContext context, int index) {
return FutureBuilder(
future: _mediaList[index].thumbDataWithSize(200, 200),
builder: (BuildContext context, snapshot) {
if (snapshot.connectionState == ConnectionState.done)
return Stack(
children: <Widget>[
Positioned.fill(
child: Image.memory(
snapshot.data,
fit: BoxFit.cover,
),
),
if (_mediaList[index].type == AssetType.video)
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: EdgeInsets.only(right: 5, bottom: 5),
child: Icon(
Icons.videocam,
color: Colors.white,
),
),
),
],
);
return Container();
},
);
}),
);
}

And, it works! When you scroll down the page in the app, more assets load into place.

But wait…what’s with the random flickering?!

Each time _fetchNewMedia completes, it calls setState, which executes the build method and causes the whole GridView to be rebuilt. Not very efficient, and as you can see it’s not very pretty either. To fix this, instead of _mediaList being a List of AssetEntity’s, we need to insert the widgets that will be rendered directly into that list. This way, they don’t have to be re-computed entirely during scroll events.

The code for those final changes is at the bottom of the article. Here is how the app now behaves after the changes:

The full final code (main.dart):

import 'package:flutter/material.dart';
import 'package:photo_manager/photo_manager.dart';
void main() => runApp(MyApp());class MyApp extends StatelessWidget {
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Media Picker Example',
theme: ThemeData(
// This is the theme of your application.
primarySwatch: Colors.red,
),
home: MyHomePage(title: 'Media Picker Example App'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: MediaGrid(),
);
}
}
class MediaGrid extends StatefulWidget {
@override
_MediaGridState createState() => _MediaGridState();
}
class _MediaGridState extends State<MediaGrid> {
List<Widget> _mediaList = [];
int currentPage = 0;
int lastPage;
@override
void initState() {
super.initState();
_fetchNewMedia();
}
_handleScrollEvent(ScrollNotification scroll) {
if (scroll.metrics.pixels / scroll.metrics.maxScrollExtent > 0.33) {
if (currentPage != lastPage) {
_fetchNewMedia();
}
}
}
_fetchNewMedia() async {
lastPage = currentPage;
var result = await PhotoManager.requestPermission();
if (result) {
// success
//load the album list
List<AssetPathEntity> albums =
await PhotoManager.getAssetPathList(onlyAll: true);
print(albums);
List<AssetEntity> media =
await albums[0].getAssetListPaged(currentPage, 60);
print(media);
List<Widget> temp = [];
for (var asset in media) {
temp.add(
FutureBuilder(
future: asset.thumbDataWithSize(200, 200),
builder: (BuildContext context, snapshot) {
if (snapshot.connectionState == ConnectionState.done)
return Stack(
children: <Widget>[
Positioned.fill(
child: Image.memory(
snapshot.data,
fit: BoxFit.cover,
),
),
if (asset.type == AssetType.video)
Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: EdgeInsets.only(right: 5, bottom: 5),
child: Icon(
Icons.videocam,
color: Colors.white,
),
),
),
],
);
return Container();
},
),
);
}
setState(() {
_mediaList.addAll(temp);
currentPage++;
});
} else {
// fail
/// if result is fail, you can call `PhotoManager.openSetting();` to open android/ios applicaton's setting to get permission
}
}
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification scroll) {
_handleScrollEvent(scroll);
return;
},
child: GridView.builder(
itemCount: _mediaList.length,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
itemBuilder: (BuildContext context, int index) {
return _mediaList[index];
}),
);
}
}

So, that’s it then! We now have a super smooth and cool media picker. You can customize the widget that gets returned from the FutureBuilder to add any functionality you need! Thank you for reading.

--

--