Flutter/Sample

[Flutter] photo picker - ②

찌김이 2022. 8. 14. 23:10
728x90
반응형

이전 포스팅에서 photo_manager 를 통해 기기의 이미지 정보들을 불러와서 일부 기능들을 구현했습니다.

 

 

[Flutter] photo picker - ①

photo_manager 를 이용하여 간단한 photo picker 를 구현하려고 합니다. photo_manager 의 기본 셋팅과 사용법은 아래 링크에서 확인해주세요. [Flutter] photo_manager 디바이스의 이미지 정보를 불러오고 싶을..

dalgoodori.tistory.com

 

파란 부분까지가 이전 포스팅에서 구현했던 부분이고 이번 포스팅에서는 이미지 선택, 해제 부분을 구현하겠습니다.

  • 기기에 저장되어있는 이미지 목록을 불러온다.
  • 이때 이미지는 그리드 형태로 한 줄에 3개 보여줍니다.
  • 드롭다운 형태로 앨범 이동이 가능해야 하며 '모든 사진'을 볼 수 있어야 한다.
  • '모든 사진' 선택 시 첫 번째 칸에는 카메라 촬영을 통해 이미지를 가지고 올 수 있어야 한다.
  • 이미지 선택 시 순서가 매겨지며 dim 처리가 되고 상단에 작은 이미지로 보인다.
  • 이미지 선택 해제는 선택된 이미지를 누르거나 상단의 이미지의 x 버튼을 눌렀을 때 해제된다.
  • 이미지 선택 개수 제한을 정할 수 있어야 한다.
  • 확인 버튼을 누르면 선택한 이미지 정보를 이전 페이지에 전달한다.
  • 이미지를 선택하지 않고 확인 버튼을 누르면 이미지를 선택해달라는 alert 을 띄운다.

 

필요한 기능들에 대해 정리해봅니다.

  • 카메라 촬영으로 이미지 정보를 가져오는 라이브러리가 필요합니다.
  • 이미지 선택 액션을 정의해야 합니다.
  • 이미지 선택 시 보이는 수평 스크롤 뷰가 필요합니다.

 

카메라 촬영 라이브러리는 image_picker 를 이용하여 구현하려고 합니다.

image_pikcer 로도 이미지 선택하는 기능을 만들 수 있지만 위에 적은 요구사항들을 충족시키기엔 어렵습니다.

기본 사용법은 아래 링크에서 확인해주세요.

 

[Flutter] image_picker

간단하게 기기의 이미지를 가져올 수 있는 image_picker 에 대해서 간단하게 포스팅 합니다. image_picker | Flutter Package Flutter plugin for selecting images from the Android and iOS image library, and..

dalgoodori.tistory.com

 

두 개의 라이브러리를 사용하려고 하니 문제점이 생깁니다.

  • 이미지를 선택하거나 카메라 촬영을 통해 가져온 이미지를 보여주는 수평 스크롤 뷰입니다.
  • image_picker 를 이용하여 카메라 촬영을 통해 가져오는 이미지는 XFile 입니다.
  • photo_manager 를 이용하여 가져오는 이미지는 AssetEntity 입니다.
  • 서로 다른 객체지만 보이는 화면은 같아야 합니다.
  • 그냥 쉽게 생각해서 두 개를 포함하는 객체를 만들었습니다.

 

selected_image.dart

  • 카메라를 촬영하거나 이미지를 선택하면 저장되는 객체입니다.
  • 카메라를 이용해서 가져온 이미지는 entity 가 null 입니다.
  • 이미지를 선택해서 가져온 이미지는 file 이 null 입니다.

 

import 'package:image_picker/image_picker.dart';
import 'package:photo_manager/photo_manager.dart';

class SelectedImage {
  AssetEntity? entity;
  XFile? file;

  SelectedImage({
    required this.entity,
    required this.file,
  });
}

 

sample_screen.dart

  • 선택한 이미지의 상태를 저 정할 변수를 선언해줍니다.
  • 이 상태를 이용하여 수평 스크롤 뷰를 만듭니다.
final List<SelectedImage> _selectedImages = [];

 

horizon_photo.dart

  • 선택된 사진을 표현하는 수평 스크롤 뷰를 만듭니다.
  • 현재 선택된 이미지의 상태, 스크롤 컨트롤러, 삭제를 처리하는 함수를 받습니다.
import 'dart:io';

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

import 'selected_image.dart';

class HorizonPhoto extends StatelessWidget {
  List<SelectedImage> selectedImages;
  ScrollController scrollController;
  ValueChanged<SelectedImage> deleteTap;

  HorizonPhoto({
    required this.selectedImages,
    required this.scrollController,
    required this.deleteTap,
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return selectedImages.isNotEmpty
        ? SizedBox(
            height: 120,
            child: ListView(
              controller: scrollController,
              physics: const BouncingScrollPhysics(),
              scrollDirection: Axis.horizontal,
              children: selectedImages
                  .map((e) => Stack(
                        alignment: Alignment.center,
                        children: [
                          const SizedBox(
                            width: 100,
                            height: 100,
                          ),
                          _horizonPhotoItem(e),
                          _deleteButton(e),
                        ],
                      ))
                  .toList(),
            ),
          )
        : const SizedBox();
  }

  Widget _horizonPhotoItem(SelectedImage image) {
    return Align(
      alignment: Alignment.center,
      child: SizedBox(
        width: 80,
        height: 80,
        child: image.entity != null
            ? AssetEntityImage(
                image.entity!,
                fit: BoxFit.cover,
              )
            : Image.file(
                File(image.file!.path),
                fit: BoxFit.cover,
              ),
      ),
    );
  }

  Widget _deleteButton(SelectedImage image) {
    return Positioned(
      top: 10,
      right: 1,
      child: GestureDetector(
        onTap: () => deleteTap(image),
        child: const Icon(
          Icons.cancel,
          color: Colors.black87,
          size: 25,
        ),
      ),
    );
  }
}

 

grid_photo.dart

  • 이미지 선택, 해제를 구현하기 위해 기존 코드를 수정합니다.
  • 현재 선택된 이미지인 selectedImages 와 선택/해제를 처리하는 함수를 받습니다.
  • GridView 에서 카메라 버튼을 만드는 부분이 있는데 AseetEntity 의 id 가 'camera' 로 구분하여 만듭니다.
  • 카메라 버튼을 누르면 _loadCamera() 에서 image_picker 를 이용하여 카메라를 이용해 이미지 정보를 가져옵니다.
  • photo_manager 로 만든 _gridPhotoItem 에서는 selectedImages 에 해당된다면 dim처리와 카운팅을 합니다.
  • 이미지 선택/해제, 카메라로 이미지를 가져오면 onTap 을 실행합니다.
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:photo_manager/photo_manager.dart';

import 'selected_image.dart';

class GridPhoto extends StatelessWidget {
  List<AssetEntity> images;
  List<SelectedImage> selectedImages;
  ValueChanged<SelectedImage> onTap;

  GridPhoto({
    required this.images,
    required this.selectedImages,
    required this.onTap,
    Key? key,
  }) : super(key: key);

  final picker = ImagePicker();

  void _loadCamera() async {
    final file = await picker.pickImage(source: ImageSource.camera);
    if (file != null) {
      final item = SelectedImage(entity: null, file: file);
      onTap(item);
    }
  }

  void _selectImage(AssetEntity e) {
    final item = SelectedImage(entity: e, file: null);
    onTap(item);
  }

  @override
  Widget build(BuildContext context) {
    return GridView(
      gridDelegate:
          const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
      children: images.map((e) {
        if (e.id == 'camera') {
          return _cameraButton();
        } else {
          return _gridPhotoItem(e);
        }
      }).toList(),
    );
  }

  Widget _cameraButton() {
    return GestureDetector(
      onTap: _loadCamera,
      child: Container(
        color: Colors.black,
        child: const Icon(
          CupertinoIcons.camera,
          color: Colors.white,
          size: 50,
        ),
      ),
    );
  }

  Widget _gridPhotoItem(AssetEntity e) {
    return GestureDetector(
      onTap: () {
        _selectImage(e);
      },
      child: Padding(
        padding: const EdgeInsets.all(1.0),
        child: Stack(
          children: [
            Positioned.fill(
              child: AssetEntityImage(
                e,
                isOriginal: false,
                fit: BoxFit.cover,
              ),
            ),
            _dimContainer(e),
            _selectNumberContainer(e)
          ],
        ),
      ),
    );
  }

  Widget _dimContainer(AssetEntity e) {
    final isSelected = selectedImages.any((element) => element.entity == e);
    return Positioned.fill(
      child: Container(
        decoration: BoxDecoration(
          color: isSelected ? Colors.black38 : Colors.transparent,
          border: Border.all(
            color: isSelected ? Colors.lightBlue : Colors.transparent,
            width: 5,
          ),
        ),
      ),
    );
  }

  Widget _selectNumberContainer(AssetEntity e) {
    final num = selectedImages.indexWhere((element) => element.entity == e) + 1;
    return Positioned(
        right: 10,
        top: 10,
        child: num != 0
            ? Container(
                padding: const EdgeInsets.all(10.0),
                decoration: const BoxDecoration(
                  color: Colors.blue,
                  shape: BoxShape.circle,
                ),
                child: Text(
                  '$num',
                  style: const TextStyle(color: Colors.white),
                ),
              )
            : const SizedBox());
  }
}

 

sample_screen.dart

  • 이미지 목록을 불러오는 getPhotos 의 변동사항입니다.
  • 선택한 앨범이 '모든 사진' 을 나타내는 isAll 속성을 확인합니다.
  • isAll 속성이 true 라면 카메라 버튼을 추가하기 위해 AssetEntity id 가 'camera' 인 더미를 만듭니다. 
Future<void> getPhotos(
    Album album, {
      bool albumChange = false,
    }) async {
  _currentAlbum = album;
  albumChange ? _currentPage = 0 : _currentPage++;

  final getAlbum = _paths!.singleWhere((element) => element.id == album.id);
  final loadImages = await getAlbum.getAssetListPaged(
    page: _currentPage,
    size: 20,
  );

  setState(() {
    if (albumChange) {
      _images = _isAllCheck(loadImages, getAlbum.isAll);
    } else {
      _images.addAll(loadImages);
    }
  });
}

List<AssetEntity> _isAllCheck(List<AssetEntity> loadImages, bool isAll) {
  if (isAll) {
    const dummy = AssetEntity(id: 'camera', typeInt: 0, width: 0, height: 0);
    loadImages.insert(0, dummy);
  }
  return loadImages;
}

 

  • HorizonPhoto 의 deleteTap 과 GridPhoto 의 onTap 이 호출하는 _selectImage 가 추가되었습니다.
  • 선택한 이미지가 이미 추가된 이미지인지 확인하기 위해 _addedImageCheck 를 호출합니다.
  • _addedImageCheck 가 true 라면 삭제하고 false 면 추가합니다.
  • 이미지 추가를 한다면 _scrollController 를 이용해 수평 스크롤 끝으로 이동시킵니다.
void _selectImage(SelectedImage image) {
  final addedImageCheck =
  _selectedImages.any((e) => _addedImageCheck(image, e));

  setState(() {
    if (addedImageCheck) {
      _selectedImages.removeWhere((e) => _addedImageCheck(image, e));
    } else {
      final item = SelectedImage(entity: image.entity, file: image.file);
      _selectedImages.add(item);
    }
  });

  if (!addedImageCheck && _scrollController.hasClients) {
    _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
  }
}

bool _addedImageCheck(SelectedImage image, SelectedImage compareImage) {
  return image.entity == compareImage.entity &&
      image.file == compareImage.file;
}

 

  • sample_screen.dart 의 전체 코드입니다
import 'package:flutter/material.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_picker/horizon_photo.dart';

import 'album.dart';
import 'grid_photo.dart';
import 'selected_image.dart';

class SampleScreen extends StatefulWidget {
  const SampleScreen({Key? key}) : super(key: key);

  @override
  State<SampleScreen> createState() => _SampleScreenState();
}

class _SampleScreenState extends State<SampleScreen> {
  List<AssetPathEntity>? _paths;
  List<Album> _albums = [];
  late List<AssetEntity> _images;
  int _currentPage = 0;
  late Album _currentAlbum;
  final List<SelectedImage> _selectedImages = [];
  final _scrollController = ScrollController();

  Future<void> checkPermission() async {
    final PermissionState ps = await PhotoManager.requestPermissionExtend();
    if (ps.isAuth) {
      await getAlbum();
    } else {
      await PhotoManager.openSetting();
    }
  }

  Future<void> getAlbum() async {
    _paths = await PhotoManager.getAssetPathList(
      type: RequestType.image,
    );

    _albums = _paths!.map((e) {
      return Album(
        id: e.id,
        name: e.isAll ? '모든 사진' : e.name,
      );
    }).toList();

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

  List<AssetEntity> _isAllCheck(List<AssetEntity> loadImages, bool isAll) {
    if (isAll) {
      const dummy = AssetEntity(id: 'camera', typeInt: 0, width: 0, height: 0);
      loadImages.insert(0, dummy);
    }
    return loadImages;
  }


  Future<void> getPhotos(
      Album album, {
        bool albumChange = false,
      }) async {
    _currentAlbum = album;
    albumChange ? _currentPage = 0 : _currentPage++;

    final getAlbum = _paths!.singleWhere((element) => element.id == album.id);
    final loadImages = await getAlbum.getAssetListPaged(
      page: _currentPage,
      size: 20,
    );

    setState(() {
      if (albumChange) {
        _images = _isAllCheck(loadImages, getAlbum.isAll);
      } else {
        _images.addAll(loadImages);
      }
    });
  }

  void _selectImage(SelectedImage image) {
    final addedImageCheck =
    _selectedImages.any((e) => _addedImageCheck(image, e));

    setState(() {
      if (addedImageCheck) {
        _selectedImages.removeWhere((e) => _addedImageCheck(image, e));
      } else {
        final item = SelectedImage(entity: image.entity, file: image.file);
        _selectedImages.add(item);
      }
    });

    if (!addedImageCheck && _scrollController.hasClients) {
      _scrollController.jumpTo(_scrollController.position.maxScrollExtent);
    }
  }

  bool _addedImageCheck(SelectedImage image, SelectedImage compareImage) {
    return image.entity == compareImage.entity &&
        image.file == compareImage.file;
  }


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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Container(
            child: _albums.isNotEmpty
                ? DropdownButton(
                    value: _currentAlbum,
                    items: _albums
                        .map((e) => DropdownMenuItem(
                              value: e,
                              child: Text(e.name),
                            ))
                        .toList(),
                    onChanged: (value) => getPhotos(value!, albumChange: true),
                  )
                : const SizedBox()),
      ),
      body: SafeArea(
        child: Stack(
          children: [
            Column(
              children: [
                HorizonPhoto(
                  selectedImages: _selectedImages,
                  scrollController: _scrollController,
                  deleteTap: _selectImage,
                ),
                Expanded(
                  child: NotificationListener<ScrollNotification>(
                    onNotification: (ScrollNotification scroll) {
                      final scrollPixels = scroll.metrics.pixels /
                          scroll.metrics.maxScrollExtent;
                      if (scrollPixels > 0.7) getPhotos(_currentAlbum);

                      return false;
                    },
                    child: _paths == null
                        ? const Center(child: CircularProgressIndicator())
                        : GridPhoto(
                            images: _images,
                            selectedImages: _selectedImages,
                            onTap: _selectImage,
                          ),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

 

  • 디자인은... 좀 그렇지만 원하는 대로 작동이 되는 걸 볼 수 있습니다.

728x90
반응형