DEV Community

Cover image for Your own Media Picker in Flutter!
ShantanuBorkar
ShantanuBorkar

Posted on

Your own Media Picker in Flutter!

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:

  1. Media Picker component which involves browsing and selecting images.

  2. 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")),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

HomeScreen:
HomeScreen

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Once we have the permission we can fetch albums using,

List<AssetPathEntity> albumList =
          await PhotoManager.getAssetPathList(type: RequestType.common);
Enter fullscreen mode Exit fullscreen mode

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();
    }
  }
Enter fullscreen mode Exit fullscreen mode

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();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

AlbumListScreen:
AlbumListScreen

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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(() {});
    }
  }
Enter fullscreen mode Exit fullscreen mode

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()))
Enter fullscreen mode Exit fullscreen mode

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());
        });
  }
}
Enter fullscreen mode Exit fullscreen mode

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(() {});
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

MediaListScreen:
MediaListScreen

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!),
                  ],
                );
              },
            )
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
  }
Enter fullscreen mode Exit fullscreen mode

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!),
                  ],
                ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

MediaPreviewScreen
ImagePreview

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)