이 프로젝트는 개발하는남자님의 유튜브 영상을 참고하여 제작하였습니다. 허나, 원본 영상에서 제작하는 방법과는 다를 수 있습니다.
이전에 구현한 UI는 갤러리의 이미지를 가져오지 않고 단순하게 UI만 구현했습니다. 하지만, 실제로 디바이스의 미디어 파일들을 가져와야 하죠. 그래서 이번에는 디바이스의 미디어파일 정보를 가져오는 기능을 구현할겁니다.
pub.dev에는 미디어 파일로 접근할 수 있는 라이브러리 중 가장 유용한 것들이 image_picker, file_picker 등등이 있습니다만, 해당 라이브러리들 말고 photo_manager라는 라이브러리를 사용해서 만들겠습니다.
이 라이브러리는 클라이언트 디바이스의 미디어파일들을 불러올 수 있게 해주는 라이브러리에요. 이 라이브러리에서는 아래와 같은 사항들을 미리 알아야 기능을 구현할 수 있습니다.
1. 권한 확인
PhotoManager.requestPermissionExtend().then((ps) { if (ps.isAuth) { //권한이 이미 승인되었다면 동작하는 코드 } else { //권한이 없다면 설정을 열음. PhotoManager.openSetting(); } });
미디어파일에 접근하기 위해서는 권한을 반드시 체크해야합니다. photo_manager는 requestPermissionExtend 메소드를 이용해서 권한을 확인할 수 있어요. Future 타입이기 때문에, then메소드로 함수형 프로그래밍을 작성하면 손쉽게 권한확인이 가능합니다. 권한이 없다면 openSetting 메소드로 설정을 열 수 있습니다.
2. AssetPathEntity
미디어 파일은 미디어 파일이 속한 앨범이 있습니다. AssetPathEntity는 앨범의 경로를 나타낸다고 생각하면 쉽게 이해할 수 있습니다. 따라서 앨범의 미디어파일들을 불러오기 위해선 일단 모든 AssetPathEntity들을 불러와야 하고, 각 AssetPathEntity를 이용하여 앨범의 미디어 파일들을 불러올 수 있는겁니다.
await PhotoManager .getAssetPathList(type: RequestType.image) .then((paths) { //paths는 List<AssetPathEntity>를 반환 });
getAssetPathList 메소드를 이용해서 모든 AssetPathEntity를 불러올 수 있습니다.
3. AssetEntity
AssetEntity는 AssetPathEntity로 접근할 수 있는 각각의 미디어 파일이 반환되는 타입입니다. 이게 곧 미디어 파일 본체인 것이죠.
await PhotoManager .getAssetPathList(type: RequestType.image) .then((paths) { for (AssetPathEntity asset in paths) { asset .getAssetListRange(start: 0, end: 10000) .then((images) { // images는 List<AssetEntity>를 반환 }); } });
getAssetListRange 메소드를 이용해서 각 앨범의 경로에 있는 이미지들을 불러올 수 있어요. 이 방식은 범위를 이용한 방식입니다. 범위 말고 페이지를 이용한 메소드도 있으니, 해당 패키지의 문서를 참조해주세요.
이제 본격적으로 앨범의 미디어에 접근하는 기능을 만들어보겠습니다.
업로드화면에서는 앨범에 접근하여 여러 미디어 파일들을 가져올 수 있어야 합니다. 따라서, UploadController와 UploadBiding을 생성해서 해당 로직을 제작하겠습니다.
// upload_controller.dart
import ...
class UploadController extends GetxController {}
// upload_biding.dart
import ...
class UploadBinding implements Bindings {
void dependencies() {
Get.put(UploadController());
}
}
업로드화면에서 동작할 로직인 UploadController는 업로드 화면으로 라우팅될 때, UploadBiding을 통해서 생성되어야 합니다. 따라서, bottom_nav_cotroller.dart파일에서 라우팅될 때, 바인딩을 빌드하겠습니다.
...
void moveToUpload() {
Get.to(() => const Upload(), binding: UploadBinding());
}
이제 업로드 화면으로 라우팅되면 UploadController가 생성됩니다. UploadController가 생성되는 동시에 앨범에 미디어 파일로 접근할 수 있어야 됩니다. 이를 위해서 Album 정보가 담길 수 있는 AlbumModel을 만들겠습니다. album_model.dart 파일을 생성합니다.
import 'package:photo_manager/photo_manager.dart';
class AlbumModel {
String? id;
String? name;
List<AssetEntity>? images;
AlbumModel({required this.id, required this.name, required this.images});
factory AlbumModel.fromGallery(
String id, String name, List<AssetEntity> images) {
return AlbumModel(id: id, name: name, images: images);
}
}
이 AlbumModel은 앨범의 id, 이름, 앨범의 이미지 목록을 담을 모델입니다. factory 메소드를 통해서 named constructor를 만들어 낼 수 있습니다. factory 키워드는 dart에서 팩토리 패턴을 사용할 수 있도록 사용되는 키워드 정도로 알아주세요. 이제 UploadController에서 로직을 생성하겠습니다.
...
class UploadController extends GetxController {
//앨범을 담는 Rx변수
final Rx<List<AlbumModel>> _albums = Rx<List<AlbumModel>>([]);
final RxInt _index = 0.obs;
List<AlbumModel> get albums => _albums.value;
int get index => _index.value;
void onReady() {
super.onReady();
// 권한 확인
_checkPermission();
}
void _checkPermission() {
PhotoManager.requestPermissionExtend().then((ps) {
if (ps.isAuth) {
//권한이 승인되었으면 getAlbum 실행
getAlbums();
} else {
//권한이 없으면 설정을 열음.
PhotoManager.openSetting();
}
});
}
업로드 화면으로 이동하자마자 미디어 파일을 가지고 오기 전 권한이 없으면 미디어 파일을 가져올 수 없습니다. 따라서, _checkPermission 메소드를 생성해서 권한을 확인합니다. 이후, 권한이 설정되면 getAlbum 메소드를 실행합니다.
Future<void> getAlbums() async {
await PhotoManager.getAssetPathList(type: RequestType.image).then((paths) {
for (AssetPathEntity asset in paths) {
asset.getAssetListRange(start: 0, end: 10000).then((images) {
if (images.isNotEmpty) {
final album = AlbumModel.fromGallery(asset.id, asset.name, images);
_albums.value.add(album);
_albums.refresh();
}
});
}
});
}
getAlbum 메소드는 필요한 앨범의 목록을 반환할 수 있도록 합니다. 최종적으로는 각 앨범마다 모델에 담아서 _album에 추가해줍니다. refresh()메소드를 사용해야 앨범을 갱신할 수 있어요. 이렇게 하면 업로드 화면으로 이동되는 즉시, 미디어 파일들을 불러올 수 있습니다.
이제 upload.dart파일에서 두 부분을 수정하겠습니다. 하나는 드롭다운 영역이고, 하나는 해당하는 앨범의 이미지 목록 영역입니다.
import ...
//GetView 사용
class Upload extends GetView<UploadController> {
const Upload({super.key});
...
// Obx를 통해서 화면을 갱신함.
body: Obx(
() => Column(
children: [
_preview(),
_header(),
_images(),
],
),
),
);
}
...
Widget _header() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
// 앨범의 이름을 표시하는 영역
(controller.albums.isNotEmpty)
? controller.albums[controller.index].name!
: '',
style: const TextStyle(
fontSize: 18,
color: Colors.black,
fontWeight: FontWeight.bold),
),
...
Widget _images() {
return Expanded(
child: (controller.albums.isNotEmpty)
? GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4, mainAxisSpacing: 1, crossAxisSpacing: 1),
itemCount: controller.albums[controller.index].images!.length,
itemBuilder: (context, index) =>
// 앨범의 이미지를 보여주는 위젯
UploadImage(
entity: controller.albums[controller.index].images![index],
fit: BoxFit.cover))
: const Center(child: Text('텅')),
);
}
}
GetView를 사용하면 쉽게 컨트롤러와 연결할 수 있습니다. Obx를 사용해서 화면 전체를 커버한 후, 드롭다운 영역과 앨범 이미지 영역에 컨트롤러에서 불러온 앨범에 대한 정보를 전달했습니다. UploadImage는 AssetEntity를 랜더링 하기 위해 제작한 위젯입니다.
import 'package:flutter/material.dart';
import 'package:photo_manager/photo_manager.dart';
class UploadImage extends StatelessWidget {
final void Function()? onTap;
final AssetEntity entity;
final BoxFit fit;
const UploadImage(
{super.key, this.onTap, required this.entity, required this.fit});
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AssetEntityImage(
entity,
fit: fit,
isOriginal: false,
),
);
}
}
AssetEntity는 AssetEntityImage 위젯을 사용해서 이미지를 화면에 나타낼 수 있어요. GestureDetector로 감싼 이유는 이후에 나옵니다. 완성된 결과물을 볼까요?
앨범에서 이미지를 선택하면 해당 이미지를 preview 영역에 띄어야 합니다. 아까 GestureDetector로 감싼 이유가 바로 이것을 위해서이죠. 탭 하면 해당 이미지를 컨트롤러에 selectIamge 변수에 넣고, 그 변수의 이미지를 preview 영역의 위젯에 표현하겠습니다. 일단 변수부터 만들죠.
class UploadController extends GetxController {
final Rx<List<AlbumModel>> _albums = Rx<List<AlbumModel>>([]);
// 선택한 이미지를 담는 변수
final Rxn<AssetEntity> _selectedImage = Rxn<AssetEntity>();
final RxInt _index = 0.obs;
List<AlbumModel> get albums => _albums.value;
//선택한 이미지 getter
AssetEntity? get selectedImage => _selectedImage.value;
int get index => _index.value;
초기에 선택한 이미지는 존재하지 않습니다. 따라서 이번에는 Rxn 타입을 사용했습니다. Rxn은 초기값에 null을 넣을 수 있거든요. 이제 이미지를 탭하면 selectImage를 갱신하게끔 로직을 작성하겠습니다.
void select(AssetEntity e) {
_selectedImage(e);
_selectedImage.refresh();
}
해당 메소드를 온탭에 전달하면 돼요.
itemBuilder: (context, index) => UploadImage(
onTap: () {
// 이미지를 탭하면 갱신됨.
controller.select(
controller.albums[controller.index].images![index]);
},
entity: controller.albums[controller.index].images![index],
fit: BoxFit.cover))
: const Center(child: Text('텅')),
이제 이미지를 탭하면 selectImage에 해당 이미지의 AssetEntity가 전달됩니다. 이를 preview 영역에 전달할게요.
...
Widget _preview() {
return (controller.selectedImage != null)
? SizedBox(
height: Get.size.width,
width: Get.size.width,
child: AssetEntityImage(
controller.selectedImage!,
isOriginal: false,
fit: BoxFit.contain,
))
: Container(
height: Get.size.width,
width: Get.size.width,
color: Colors.black,
);
}
preview 영역은 삼항 연산을 사용해서 선택한 이미지가 null이 아닌경우는 AssetEntityImage위젯을 사용해서 이미지를 표현할 수 있도록 했습니다. fit에는 Boxfit.contain을 넣어야 인스타그램처럼 이미지의 크기를 정사각형으로 고정하지 않을 수 있습니다. 결과물을 보겠습니다.
앨범의 드롭다운을 터치하면 앨범의 목록을 볼 수 있는 화면으로 이동해서 앨범을 선택하면 앨범이 변경됩니다. 이를 구현하겠습니다. 앨범의 이름이 표시되면 드롭다운 영역을 GestureDetector를 사용해서 화면을 이동시키겠습니다.
child: GestureDetector(
// 앨범 목록 화면으로 이동
onTap: controller.moveToChoose,
child: Text(
(controller.albums.isNotEmpty)
? controller.albums[controller.index].name!
: '',
style: const TextStyle(
fontSize: 18,
color: Colors.black,
fontWeight: FontWeight.bold),
),
),
moveToChoose 메소드는 목록화면으로 이동하는 함수에요. 컨트롤러에서 생성하겠습니다.
void moveToChoose() {
Get.to(() => const UploadChoice(),
transition: Transition.downToUp, popGesture: false);
}
목록은 아래에서 위로 나타나더라고요. 그래서 transition을 이용해서 표현했습니다. 게다가 목록화면에서는 popGesture 즉, 아이폰에서 왼쪽으로 스와이프 등으로 나올 수 없어요. 그래서 popGesture를 false로 비활성화했습니다.
import ...
class UploadChoice extends GetView<UploadController> {
const UploadChoice({super.key});
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
// 취소버튼을 이용해서 뒤로감
leading: TextButton(
onPressed: Get.back,
child: const Text(
'취소',
style: TextStyle(color: Colors.black, fontSize: 18),
)),
title: const Text('사진첩 선택'),
titleTextStyle: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 18, color: Colors.black),
),
body: Obx(() => ListView.builder(
itemCount: controller.albums.length,
itemBuilder: (context, index) => GestureDetector(
onTap: () {
controller.changeIndex(index);
},
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
children: [
SizedBox(
width: 100,
height: 100,
child: AssetEntityImage(
controller.albums[index].images![0],
fit: BoxFit.cover,
isOriginal: true,
),
),
const SizedBox(
width: 10,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
controller.albums[index].name!,
style: const TextStyle(
fontSize: 20,
),
),
Text('${controller.albums[index].images!.length}')
],
),
],
),
),
))),
);
}
}
앨범 목록 화면입니다. 마찬가지로 GetView를 이용해서 _album을 이용하여 제작했습니다. 앨범 목록은 GestureDetector를 사용해서 해당 앨범의 인덱스를 컨트롤러의 인덱스에 전달해주는 방식으로 앨범을 변경할 수 있습니다. 그러면 컨트롤러에 해당 로직을 작성해야겠죠.
void changeIndex(int value) {
_index(value);
Get.back();
}
앨범을 선택하면 다시 뒤로 이동해서 앨범이 변경된 것을 확인합니다. 결과를 확인하겠습니다.
구현하고자 하는 업로드 화면을 모두 구현하였습니다.
좋은 글 감사합니다 :)