[Flutter] photo_manager를 이용한 커스텀 앨범 구현

박재빈·2025년 3월 25일
0

기존에는 image_picker를 사용하여 사진 선택 처리를 하였는데 image_picker는 기존 네이티브 갤러리 UI를 제공한다. UI를 커스텀하고자 image_picker가 아닌 photo_manager를 이용하여 구현하고자 한다.

구현 기능

  • 앨범 선택
  • 해당 앨범의 이미지 목록을 불러오기
  • 이미지 선택 (single or multi)
  • 선택한 이미지 표시 (보더, 체크박스 처리)

사용한 패키지

권한 설정

android

AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- 카메라 권한 -->
    <uses-permission android:name="android.permission.CAMERA"/>
    <!-- Devices running Android (API level 33) or higher -->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <!-- Devices running Android 12L (API level 32) or lower  -->
    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

    <uses-feature android:name="android.hardware.camera" android:required="false"/>

ios

info.plist

<key>NSCameraUsageDescription</key>
<string>카메라 권한 허용을 해주세요</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>포토 라이브러리 권한을 허용해주세요.</string>

화면 구현

구현 코드

PhotoPickerModal 변수

  List<AssetEntity> _imageList = [];
  List<AssetEntity> _selectedImageList = [];
  List<AssetPathEntity> _albumList = [];
  AssetPathEntity? _selectedAlbum;

  int _currentPage = 0; // 현재 페이지
  final int _pageSize = 30; // 한 번에 로드할 이미지 개수

  final ScrollController _scrollController = ScrollController();

  bool _isRefresh = true;
  • _imageList : 선택된 앨범의 이미지 목록
  • _selectedImageList : 선택한 이미지 목록
  • _albumList : 앨범 목록
  • _selectedAlbum : 선택한 앨범
  • _scrollController : 스크롤 감지를 위한 컨트롤러
  • _isRefresh : 전체 로딩 (앨범을 선택해서 이미지를 새로 불러오기 등)

앨범 불러오기

  Future<void> _loadAlbumList() async {
    final fetchedAlbumList =
        await PhotoManager.getAssetPathList(type: RequestType.image);

    if (fetchedAlbumList.isEmpty) return;

    setState(() {
      _albumList = fetchedAlbumList;
      _selectedAlbum = _albumList.first;
    });
    _loadImageList(_selectedAlbum!, albumChanged: true);
  }
  • getAssetPathList : 앨범 주소를 불러오는 메서드
    • RequestType.image 이미지 타입으로 한정
  • _loadImageList(_selectedAlbum!, albumChanged: true) : 선택한 앨범의 이미지 목록 불러오는 메서드

이미지 불러오기

  Future<void> _loadImageList(AssetPathEntity album,
      {bool albumChanged = false}) async {
    if (albumChanged) {
      _isRefresh = true;
      _currentPage = 0;
    } else {
      // 앨범 변경을 안한 경우는 스크롤을 내려서 불러오는 경우
      final totalImagesCount = await album.assetCountAsync;
      if (_imageList.length == totalImagesCount) {
        return;
      }
    }

    final assets =
        await album.getAssetListPaged(page: _currentPage, size: _pageSize);

    setState(() {
      if (albumChanged) {
        _imageList = assets;
      } else {
        _imageList.addAll(assets);
      }
      _currentPage++;
      _isRefresh = false;
    });
  }
  • album : 선택한 앨범
  • albumChanged : 앨범을 선택하여 처음부터 불러오기 여부
    • albumChanged = true : 해당 앨범의 이미지를 처음부터 불러오기
    • albumChanged = false : 해당 앨범의 다음 페이지 이미지 불러오기
  • getAssetListPaged : 이미지 목록 불러오기 (페이징)

앱바 구현

class _AppBar extends StatelessWidget {
  final AssetPathEntity? selectedAlbum;
  final List<AssetPathEntity> albumList;
  final ValueChanged<AssetPathEntity?> onAlbumChanged;
  final VoidCallback onClose;
  final VoidCallback? onComplete;

  const _AppBar({
    super.key,
    this.selectedAlbum,
    required this.albumList,
    required this.onAlbumChanged,
    required this.onClose,
    this.onComplete,
  });

  
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            IconButton(
              onPressed: onClose,
              icon: const Icon(Icons.close, size: 24),
            ),
            TextButton(
              onPressed: onComplete,
              child: Text('완료'),
            ),
          ],
        ),
        Align(
          alignment: Alignment.center,
          child: Container(
            child: albumList.isNotEmpty
                ? DropdownButton(
                    value: selectedAlbum,
                    dropdownColor: Colors.white,
                    items: albumList.map((album) {
                      return DropdownMenuItem(
                        value: album,
                        child: Text(
                          album.isAll ? '최근항목' : album.name,
                        ),
                      );
                    }).toList(),
                    onChanged: onAlbumChanged,
                    underline: const SizedBox.shrink(),
                  )
                : const SizedBox(),
          ),
        ),
      ],
    );
  }
}
  • 모달 닫기 버튼, 드롭다운(앨범선택), 완료 버튼으로 구성

그리드 뷰 구현

class _PhotoGridView extends StatelessWidget {
  final List<AssetEntity> imageList;
  final List<AssetEntity> selectedImageList;
  final ScrollController scrollController;
  final Function(AssetEntity) onImageTap;
  final VoidCallback onCameraTap;

  const _PhotoGridView({
    super.key,
    required this.imageList,
    required this.selectedImageList,
    required this.scrollController,
    required this.onImageTap,
    required this.onCameraTap,
  });

  
  Widget build(BuildContext context) {
    return GridView.builder(
      padding: const EdgeInsets.all(8.5),
      controller: scrollController,
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3,
        crossAxisSpacing: 2,
        mainAxisSpacing: 2,
      ),
      itemCount: imageList.length + 1,
      itemBuilder: (context, index) {
        if (index == 0) {
          return GestureDetector(
            onTap: () => onCameraTap.call(),
            child: ClipRRect(
              borderRadius: BorderRadius.circular(12),
              child: Container(
                decoration: const BoxDecoration(
                  color: Colors.grey,
                ),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    const Icon(Icons.camera_alt),
                    Text('촬영하기')
                  ],
                ),
              ),
            ),
          );
        }

        final image = imageList[index - 1];
        final isSelected = selectedImageList.contains(image);

        return GestureDetector(
          onTap: () => onImageTap(image),
          child: Stack(
            fit: StackFit.expand,
            children: [
              ClipRRect(
                borderRadius: BorderRadius.circular(12),
                child: AssetEntityImage(
                  image,
                  isOriginal: false,
                  thumbnailSize: const ThumbnailSize.square(200),
                  fit: BoxFit.cover,
                ),
              ),
              if (isSelected)
                Container(
                  decoration: BoxDecoration(
                      color: Colors.transparent,
                      borderRadius: BorderRadius.circular(12),
                      border: Border.all(color: Colors.amber, width: 3)),
                ),
              Positioned(
                top: 0,
                right: 0,
                child: Checkbox(
                  value: isSelected,
                  onChanged: (value) {},
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(10)
                  ),
                  checkColor: Colors.white,
                  activeColor: Colors.amber,
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}
  • 그리드뷰에서 처음 아이템은 카메라로 사진 찍어서 선택
  • 나머지 아이템들은 불러온 이미지 출력
  • 선택한 사진은 보더 처리 및 오른쪽 위에 체크박스 처리
  • AssetEntity를 쉽게 썸네일 표시를 하기 위해 AssetEntityImage를 사용

전체 화면 구성

Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      padding: EdgeInsets.only(
        top: widget.mediaQuery.padding.top,
        bottom: widget.mediaQuery.padding.bottom,
      ),
      child: Column(
        children: [
          _AppBar(
            albumList: _albumList,
            selectedAlbum: _selectedAlbum,
            onComplete: _selectedImageList.isNotEmpty ? _onComplete : null,
            onAlbumChanged: (value) {
              if (value != null) {
                setState(() {
                  _selectedAlbum = value;
                });
                _loadImageList(value, albumChanged: true);
              }
            },
            onClose: () => Navigator.of(context).pop(),
          ),
          Expanded(
            child: _isRefresh
                ? Center(child: CircularProgressIndicator())
                : _PhotoGridView(
                    imageList: _imageList,
                    selectedImageList: _selectedImageList,
                    scrollController: _scrollController,
                    onImageTap: _onImageTap,
                    onCameraTap: _onCameraTap,
                  ),
          ),
        ],
      ),
    );
  }

사진 선택 이벤트

  void _onImageTap(AssetEntity image) {
    if (widget.multiSelect) {
      setState(() {
        _selectedImageList.contains(image)
            ? _selectedImageList.remove(image)
            : _selectedImageList.add(image);
      });
    } else {
      setState(() {
        _selectedImageList.contains(image)
            ? _selectedImageList.remove(image)
            : _selectedImageList = [image];
      });
    }
  }

카메라 선택 이벤트

  void _onCameraTap() async {
    final picker = ImagePicker();
    final pickedFile = await picker.pickImage(source: ImageSource.camera);

    if (pickedFile != null) {
      widget.onSelectImages.call([File(pickedFile.path)]);
      Navigator.of(context).pop();
    }
  }
  • 카메라 촬영 선택은 ImagePicker를 사용

선택 완료 이벤트

  void _onComplete() async {
    List<File> imageFiles = [];
    for (var image in _selectedImageList) {
      final file = await image.file;
      if (file != null) {
        imageFiles.add(file);
      }
    }
    widget.onSelectImages.call(imageFiles);

    Navigator.of(context).pop();
  }
  • 앱바의 완료 버튼을 클릭하면 발생하는 이벤트

모달을 띄우는 함수

void showPhotoPickerModal(BuildContext context,
    {bool multiSelect = false,
    required Function(List<File>) onSelectedImages}) {
  final mediaQuery = MediaQuery.of(context);
  showModalBottomSheet(
    context: context,
    isScrollControlled: true,
    backgroundColor: Colors.white,
    builder: (context) {
      return DraggableScrollableSheet(
        initialChildSize: 1,
        builder: (context, scrollController) {
          return PhotoPickerModal(
            mediaQuery: mediaQuery,
            multiSelect: multiSelect,
            onSelectImages: onSelectedImages,
          );
        },
      );
    },
  );
}

구현한 깃 주소

https://github.com/qwq140/flutter-photo-picker

0개의 댓글