dev/flutter

flutter 커스텀 앨범

wlrn566 2023. 11. 8. 18:21

 

photo manager 라이브러리를 활용해 기기 앨범의 이미지를 뿌려주는 화면을 만든다.

 

 

photo_manager | Flutter Package

A Flutter plugin that provides assets abstraction management APIs on Android, iOS, and macOS.

pub.dev

 

 

1. 앨범 접근 권한을 얻기 위한 권한 관련 함수 작성

 

IOS에서 선택된 사진만 허락한 경우 해당 사진만 불러와진다.

만약 사진을 더 추가하고 싶다면 PhotoManager.presentLimited()를 사용해서 추가할 수 있도록 코드를 짜면 된다.

 

  @override
  void initState() {
    checkPermission();
    super.initState();
  }


// 앨범 접근 권한 확인
  Future<void> checkPermission() async {
    var status = await Permission.photos.request();
    log("status: $status");

    if (status.isGranted) { // 승인
      await getAlbum();

    // IOS 14 이후 모든 사진이 아닌 '선택된 사진'인 경우 limited 권한을 가짐
    } else if(status.isLimited) {
      // await PhotoManager.presentLimited(); // 선택된 사진에서 또 다른 사진 추가 시
      await getAlbum();

    } else { // 거절
      await openAppSettings();
    }
  }

 

 

 

2. 앨범을 불러와서 앨범의 사진들을 가져온다.

 

PhotoManager.getAssetPathList(type: RequestType.image) 을 이용해 이미지인 데이터만 불러온다.

getAssetListPaged(page: , size: ) 를 이용해 size의 개수만큼 페이지를 이용해 사진을 불러온다. 

 

size를 1로 1장만 불러와서 비어있는 앨범인 경우는 제외하고 불러오도록 했다.

앨범을 변경한 경우 새로 불러오도록 albumChange를 활용한다.

 

/// 앨범 불러오기
  Future<void> getAlbum() async {
    _pathList = await PhotoManager.getAssetPathList(type: RequestType.image);

    // 비어있는 앨범 제외
    for (int i = 0; i < _pathList.length; i++) {
      var images = await _pathList[i].getAssetListPaged(page: _currentPage, size: 1);
      if (images.isNotEmpty) {
        Album album = Album(
          id: _pathList[i].id,
          name: _pathList[i].isAll ? '최근' : _pathList[i].name,
          albumThumnail: images.first
        );
        _albumList.add(album);
      }
    }

    await getPhotos(_albumList[0], albumChange: true);
  }

  /// 앨범 내 사진 불러오기
  Future<void> getPhotos(
    Album album, {
    bool albumChange = false,
  }) async {
    _currentAlbum = album;
    albumChange ? _currentPage = 0 : _currentPage++;

    final loadImages = await _pathList
      .singleWhere((AssetPathEntity e) => e.id == album.id)
      .getAssetListPaged(page: _currentPage, size: 20); // 페이지당 size 수만큼 가져옴

    setState(() {
      if (albumChange) {
        _albumImageList = loadImages;
      } else {
        _albumImageList.addAll(loadImages);
      }
    });
  }

 

 

 

3. 화면 구상

 

인스타그램을 따라 만들었다.
상단에는 선택한 사진의 미리보리가 뜨고 사진 선택시 선택한 순번과 함께 리스트로 저장한다.

스크롤을 이용해 page가 1씩 늘어나면서 해당 앨범의 사진을 더 불러오게 화면을 만든다.

 

@override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('게시물'),
        centerTitle: false,
        actions: [
          ElevatedButton(
            onPressed: () {
              Navigator.pop(
                context
              );
            },
            child: Text("완료")
          )
        ],
      ),
      body: Column(
        children: [
          if (_selectedImageList.isEmpty)
          Container(
            height: MediaQuery.of(context).size.height * 0.5,
            width: MediaQuery.of(context).size.width,
            color: Colors.black,
          ),
          if (_selectedImageList.isNotEmpty)
          SizedBox(
            height: MediaQuery.of(context).size.height * 0.5,
            width: MediaQuery.of(context).size.width,
            child: AssetEntityImage(
              _selectedImageList.last,
              isOriginal: true,
              fit: BoxFit.cover,
            ),
          ),
          if (_currentAlbum != null)
          Row(
            children: [
              InkWell(
                onTap: () {
                  showAlbumTitleBottomSheet();
                },
                child: Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Text(_currentAlbum!.name, style: const TextStyle(fontSize: 20)),
                      const Icon(Icons.keyboard_arrow_down_outlined)
                    ],
                  ),
                ),
              ),
            ],
          ),
          Expanded(
            child: NotificationListener<ScrollNotification>(
              onNotification: (ScrollNotification scroll) {
                if (scroll.metrics.pixels > scroll.metrics.maxScrollExtent * 0.7) {
                  getPhotos(_currentAlbum!);
                } 
                return false;
              },
              child: SafeArea(
                child: _pathList.isEmpty
                  ? const Center(child: CircularProgressIndicator())
                  : GridView.builder(
                      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 4),
                      itemBuilder: (context, index) => GestureDetector(
                        onTap: () async {
                          setState(() {
                            if (_selectedImageList.contains(_albumImageList[index])) {
                              _selectedImageList.remove(_albumImageList[index]);
                            } else {
                              _selectedImageList.add(_albumImageList[index]);
                            }
                          });
                        },
                        child: Stack(
                          children: [
                            AssetEntityImage(
                              height: MediaQuery.of(context).size.height,
                              width: MediaQuery.of(context).size.width,
                              _albumImageList[index],
                              isOriginal: false,
                              fit: BoxFit.cover,
                            ),
                            if (_selectedImageList.contains(_albumImageList[index])) // 선택된 사진 음영표시
                            Container(
                              color: Colors.white.withOpacity(0.8),
                            ),
                            if (_selectedImageList.contains(_albumImageList[index])) // 선택된 사진 순번표시
                            Positioned(
                              right: 0,
                              child: Container(
                                decoration: BoxDecoration(
                                  borderRadius: BorderRadius.circular(100),
                                  color: Colors.amber,
                                ),
                                width: 30,
                                height: 30,
                                child: Center(child: Text((_selectedImageList.indexOf(_albumImageList[index]) + 1).toString())),
                              )
                            )
                          ],
                        ),
                      ),
                      itemCount: _albumImageList.length,
                    ),
              ),
            ),
          ),
        ],
      ),
    );
  }

 

 

불러온 이미지는 AssetEntityImage 위젯을 활용하면 된다.

 

AssetEntityImage(
      height: MediaQuery.of(context).size.height,
      width: MediaQuery.of(context).size.width,
      _albumImageList[index],
      isOriginal: false, // 원본 사진으로 보여주기 (썸네일로 보여줄 경우 false처리)
      fit: BoxFit.cover,
),

 

 

 

4. 앨범 바꾸기

 

중간에 버튼을 클릭해 앨범을 변경할 수 있게 한다.

드래그를 할 수 있는 바텀시트를 위해 DraggableScrollableSheet 위젯을 사용하면 된다.

 

void showAlbumTitleBottomSheet() {
    showModalBottomSheet(
      context: context, 
      // shape: const RoundedRectangleBorder(
      //   borderRadius: BorderRadius.only(
      //     topLeft: Radius.circular(20.0),
      //     topRight: Radius.circular(20.0),
      //   ),
      // ),
      clipBehavior: Clip.hardEdge,
      showDragHandle: true,
      useSafeArea: true,
      isScrollControlled: true,
      builder: (context) => DraggableScrollableSheet( // 드래그 가능한 바텀 시트
        expand: false,
        initialChildSize: 0.5,
        maxChildSize: 1,
        minChildSize: 0.5,
        builder: (context, scrollController) => CustomScrollView( // 스크롤 가능한 내부 위젯
          controller: scrollController,
          slivers: [
            SliverGrid.count(
              crossAxisCount: 2,
              children: [
                for (int i = 0; i < _albumList.length; i++)
                Column(
                  children: [
                    GestureDetector(
                      onTap: () async {
                        Navigator.pop(context);
                        await getPhotos(_albumList[i], albumChange: true);
                      },
                      child: Card(
                        shape: RoundedRectangleBorder(  //모서리를 둥글게 하기 위해 사용
                          borderRadius: BorderRadius.circular(20.0),
                        ),
                        elevation: 5.0,
                        child: ClipRRect(
                          borderRadius: BorderRadius.circular(20.0),
                          child: AssetEntityImage(
                            width: MediaQuery.of(context).size.width * 0.3,
                            height: MediaQuery.of(context).size.width * 0.3,
                            _albumList[i].albumThumnail,
                            isOriginal: false,
                            fit: BoxFit.cover,
                          ),
                        ),
                      ),
                    ),
                    Expanded(
                      child: Center(child: Text(_albumList[i].name))
                    ),
                    const Spacer(),
                  ],
                ),
              ],
            )
          ],
        ),
      ),
    );
  }

 

 

 

전체 코드

 

import 'dart:developer';

import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart';

class MyGallery extends StatefulWidget {
  const MyGallery({super.key});

  @override
  State<MyGallery> createState() => _MyGalleryState();
}

class _MyGalleryState extends State<MyGallery> with TickerProviderStateMixin {
  List<AssetPathEntity> _pathList = []; // 모든 파일 정보
  List<Album> _albumList = []; // 가져온 앨범 목록
  List<AssetEntity> _albumImageList = []; // 앨범의 이미지 목록
  Album? _currentAlbum; // 선택된 앨범
  int _currentPage = 0; // 현재 페이지
  List<AssetEntity> _selectedImageList = []; // 선택한 이미지들

  @override
  void initState() {
    checkPermission();
    super.initState();
  }

  // 앨범 접근 권한 확인
  Future<void> checkPermission() async {
    var status = await Permission.photos.request();
    log("status: $status");

    if (status.isGranted) { // 승인
      await getAlbum();

    // IOS 14 이후 모든 사진이 아닌 '선택된 사진'인 경우 limited 권한을 가짐
    } else if(status.isLimited) {
      // await PhotoManager.presentLimited(); // 선택된 사진에서 또 다른 사진 추가 시
      await getAlbum();

    } else { // 거절
      await openAppSettings();
    }
  }

  /// 앨범 불러오기
  Future<void> getAlbum() async {
    _pathList = await PhotoManager.getAssetPathList(type: RequestType.image);

    // 비어있는 앨범 제외
    for (int i = 0; i < _pathList.length; i++) {
      var images = await _pathList[i].getAssetListPaged(page: _currentPage, size: 1);
      if (images.isNotEmpty) {
        Album album = Album(
          id: _pathList[i].id,
          name: _pathList[i].isAll ? '최근' : _pathList[i].name,
          albumThumnail: images.first
        );
        _albumList.add(album);
      }
    }

    await getPhotos(_albumList[0], albumChange: true);
  }

  /// 앨범 내 사진 불러오기
  Future<void> getPhotos(
    Album album, {
    bool albumChange = false,
  }) async {
    _currentAlbum = album;
    albumChange ? _currentPage = 0 : _currentPage++;

    final loadImages = await _pathList
      .singleWhere((AssetPathEntity e) => e.id == album.id)
      .getAssetListPaged(page: _currentPage, size: 20); // 페이지당 size 수만큼 가져옴

    setState(() {
      if (albumChange) {
        _albumImageList = loadImages;
      } else {
        _albumImageList.addAll(loadImages);
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('게시물'),
        centerTitle: false,
        actions: [
          ElevatedButton(
            onPressed: () {
              Navigator.pop(
                context,
                _selectedImageList
              );
            },
            child: Text("완료")
          )
        ],
      ),
      body: Column(
        children: [
          if (_selectedImageList.isEmpty)
          Container(
            height: MediaQuery.of(context).size.height * 0.5,
            width: MediaQuery.of(context).size.width,
            color: Colors.black,
          ),
          if (_selectedImageList.isNotEmpty)
          SizedBox(
            height: MediaQuery.of(context).size.height * 0.5,
            width: MediaQuery.of(context).size.width,
            child: AssetEntityImage(
              _selectedImageList.last,
              isOriginal: true,
              fit: BoxFit.cover,
            ),
          ),
          if (_currentAlbum != null)
          Row(
            children: [
              InkWell(
                onTap: () {
                  showAlbumTitleBottomSheet();
                },
                child: Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Text(_currentAlbum!.name, style: const TextStyle(fontSize: 20)),
                      const Icon(Icons.keyboard_arrow_down_outlined)
                    ],
                  ),
                ),
              ),
            ],
          ),
          Expanded(
            child: NotificationListener<ScrollNotification>(
              onNotification: (ScrollNotification scroll) {
                if (scroll.metrics.pixels > scroll.metrics.maxScrollExtent * 0.7) {
                  getPhotos(_currentAlbum!);
                } 
                return false;
              },
              child: SafeArea(
                child: _pathList.isEmpty
                  ? const Center(child: CircularProgressIndicator())
                  : GridView.builder(
                      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 4),
                      itemBuilder: (context, index) => GestureDetector(
                        onTap: () async {
                          setState(() {
                            if (_selectedImageList.contains(_albumImageList[index])) {
                              _selectedImageList.remove(_albumImageList[index]);
                            } else {
                              _selectedImageList.add(_albumImageList[index]);
                            }
                          });
                        },
                        child: Stack(
                          children: [
                            AssetEntityImage(
                              height: MediaQuery.of(context).size.height,
                              width: MediaQuery.of(context).size.width,
                              _albumImageList[index],
                              isOriginal: false, // 원본 사진으로 보여주기 (썸네일로 보여줄 경우 false처리)
                              fit: BoxFit.cover,
                            ),
                            if (_selectedImageList.contains(_albumImageList[index])) // 선택된 사진 음영표시
                            Container(
                              color: Colors.white.withOpacity(0.8),
                            ),
                            if (_selectedImageList.contains(_albumImageList[index])) // 선택된 사진 순번표시
                            Positioned(
                              right: 0,
                              child: Container(
                                decoration: BoxDecoration(
                                  borderRadius: BorderRadius.circular(100),
                                  color: Colors.amber,
                                ),
                                width: 30,
                                height: 30,
                                child: Center(child: Text((_selectedImageList.indexOf(_albumImageList[index]) + 1).toString())),
                              )
                            )
                          ],
                        ),
                      ),
                      itemCount: _albumImageList.length,
                    ),
              ),
            ),
          ),
        ],
      ),
    );
  }

  void showAlbumTitleBottomSheet() {
    showModalBottomSheet(
      context: context, 
      // shape: const RoundedRectangleBorder(
      //   borderRadius: BorderRadius.only(
      //     topLeft: Radius.circular(20.0),
      //     topRight: Radius.circular(20.0),
      //   ),
      // ),
      clipBehavior: Clip.hardEdge,
      showDragHandle: true,
      useSafeArea: true,
      isScrollControlled: true,
      builder: (context) => DraggableScrollableSheet( // 드래그 가능한 바텀 시트
        expand: false,
        initialChildSize: 0.5,
        maxChildSize: 1,
        minChildSize: 0.5,
        builder: (context, scrollController) => CustomScrollView( // 스크롤 가능한 내부 위젯
          controller: scrollController,
          slivers: [
            SliverGrid.count(
              crossAxisCount: 2,
              children: [
                for (int i = 0; i < _albumList.length; i++)
                Column(
                  children: [
                    GestureDetector(
                      onTap: () async {
                        Navigator.pop(context);
                        await getPhotos(_albumList[i], albumChange: true);
                      },
                      child: Card(
                        shape: RoundedRectangleBorder(  //모서리를 둥글게 하기 위해 사용
                          borderRadius: BorderRadius.circular(20.0),
                        ),
                        elevation: 5.0,
                        child: ClipRRect(
                          borderRadius: BorderRadius.circular(20.0),
                          child: AssetEntityImage(
                            width: MediaQuery.of(context).size.width * 0.3,
                            height: MediaQuery.of(context).size.width * 0.3,
                            _albumList[i].albumThumnail,
                            isOriginal: false,
                            fit: BoxFit.cover,
                          ),
                        ),
                      ),
                    ),
                    Expanded(
                      child: Center(child: Text(_albumList[i].name))
                    ),
                    const Spacer(),
                  ],
                ),
              ],
            )
          ],
        ),
      ),
    );
  }

  /// 사진을 파일로 만들어주기
  Future<void> convertAssetEntityToFile(AssetEntity assetEntity) async {
    await assetEntity.file; 
  }
}

class Album {
  String id;
  String name;
  AssetEntity albumThumnail;

  Album({
    required this.id,
    required this.name,
    required this.albumThumnail,
  });
}