커스텀 이미지 피커 뷰 화면 구성
주요 구현 View 3가지
1. 사용자 이미지 목록 View
- 이미지 썸네일 클릭시 클릭 순서에 맞게 데이터를 보관할 필요가 있다.
- 살짝 이미지를 어둡게 가리고 순서에 맞는 숫자를 보여줄 필요가 있다.
2. 선택한 이미지 목록 View
- 선택한 이미지를 순서에 맞게 보여줄 필요가 있다.
- 클릭하면 이미지를 삭제시키고 보관한 데이터를 변경해 줄 필요가 있다.
3. 사용자 앨범 목록 View
- 네이게이션 타이틀을 클릭하면 아래로 내려왔다가 다시 올라가는 화면 이동을 보여줘야한다.
특징
- DiffableDataSource 사용 (사용자 이미지 목록 뷰, 선택한 이미지 목록 뷰)👉 선택한 이미지 목록에서 추가, 삭제에 기본 애니메이션 적용이 가능하다.
- 👉 rxDataSource, delegate는 전체 데이터를 계속 뷰에 다시 넣는 작업이 들어간다… (reloadData)
snapshot방식으로 변경 사항만 뷰를 다시 그리는 diffableDataSource를 사용해 연산을 줄인다. - rxSwift와 viewModel 이용
👉 각각의 세부 구현 collectionView 내부에서 viewModel의 데이터 바인딩을 통해서 뷰를 그려내도록 함 - 👉 View(ViewController)에 rxCocoa 사용을 가능하게 하기 위함.
이미지 피커 Model, ViewModel 제작
💡 추후 포스팅에 PhotoKit을 사용해 사용자 이미지를 가져오는 내용 포스팅
이미지 아이템 모델
struct AlbumItem:Identifiable,Hashable{
var id:String{ photoAsset.identifier }
let photoAsset: PhotoAsset
var selectedIdx: Int
init(photoAsset: PhotoAsset, selectedIdx: Int) {
self.photoAsset = photoAsset
self.selectedIdx = selectedIdx
}
init(photoAsset:PhotoAsset) {
self.init(photoAsset: photoAsset, selectedIdx: -1)
}
}
Identifiable 준수해 Dictionary, DiffableDataSource에서 고유 ID로 인스턴스에 접근할 수 있도록 설계
- photoAsset: 사진 정보를 담은 구조체 → 다음 글에 자세한 정보 업로드
- selectedIdx: 데이터를 담은 순서 인덱스
ViewModel 프로퍼티 설정
final class CreatingPinVM{
let photoCollection = PhotoCollection(smartAlbum: .smartAlbumUserLibrary)
private(set) var images: AnyModelStore<AlbumItem> = AnyModelStore([])
private(set) var selectedImage = OrderedDictionary<AlbumItem.ID, AlbumItem>()
private var nowSelected = 0
let limitedSelectCnt = 5
let albums:BehaviorSubject<[AlbumItem]> = BehaviorSubject(value: [])
let selectedAlbums: BehaviorSubject<[AlbumItem]> = .init(value: [])
let updatedAlbums: BehaviorSubject<[AlbumItem]> = .init(value: [])
...
}
- PhotoCollection: PhotoKit을 통해 사용자 사진을 가져오게 돕는 도구 → 다음 포스팅에 업로드
- images: PhotoCollection을 통해서 가져온 이미지 정보를 담는 딕셔너리
- selectedImage: 선택한 이미지의 순서가 있는 딕셔너리
✅ 위에 전체 이미지 정보를 담은 images 인스턴스에 접근 할 수 있어서 OrderedSet을 써도 된다… 그러나, 전체 이미지 정보를 담은 images 인스턴스가 같은 뷰 모델 내부에 없이 구현하는 경우도 있어서 OrderedDict로 구현함
- nowSelected: 현재 선택한 이미지 데이터 개수
- limitedSelectCnt: 최대 이미지 제한 계수
- albums: 전체 사용자 이미지 데이터에 대한 Subject
- updatedAlbums: 사용자 이미지 목록 뷰에서 클릭한 이미지 데이터의 변경사항을 보내는 Subject, 이미지 선택 취소할 데이터(selectedIdx가 -1)와 나머지 이미지 선택 순서를 변경한 데이터를 함께 방출한다.
- selectedAlbums: 사용자가 선택한 이미지 데이터에 대한 Subject
ViewModel 이미지 선택 처리 메서드
final class CreatingPinVM{
...
func toggleCheckItem(_ item:AlbumItem){
var item = item
var reItems:[AlbumItem] = []
if item.selectedIdx < 0 && nowSelected >= limitedSelectCnt{ return }
if item.selectedIdx < 0{
nowSelected += 1
item.selectedIdx = nowSelected
selectedImage[item.id] = item
}else{ //false면 지워줘야하지
nowSelected -= 1
selectedImage.removeValue(forKey: item.id)
item.selectedIdx = -1
images.insertModel(item: item) // 전체 이미지 데이터 변경
reItems.append(item)
}
// 해봤자 5번 돈다...
for (idx,(key, val)) in selectedImage.enumerated(){
var val = val
val.selectedIdx = idx + 1
selectedImage[key] = val
images.insertModel(item: val) // 전체 이미지 데이터 변경
}
reItems.append(contentsOf: selectedImage.values.elements)
updatedAlbums.onNext(reItems)
selectedAlbums.onNext(selectedImage.values.elements)
}
...
}
사용자 이미지 목록 View
다른 뷰 구성도 비슷해서 생략한다.
CollectionView 구성
셀 아이템 적용 CellRegistration
var gridCellRegistration: UICollectionView.CellRegistration<CreatingPinMainCell,PhotoAsset.ID>{
UICollectionView.CellRegistration {[weak self] cell, indexPath, itemIdentifier in
Task{[weak self] in
guard let self else {return}
guard let item = vm.images.fetchByID(itemIdentifier) else {return}
cell.albumItem = item
await self.vm.photoCollection.cache.requestImage(for: item.photoAsset, targetSize: .init(width: 360, height: 360)) { completion in
if let image = completion?.image{
cell.thumbnailImage = image
}else{
print("이미지 가져오기 오류")
}
}
}
}
}
이미지 셀 클릭
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let itemIdentifier = dataSource.itemIdentifier(for: indexPath) else {return}
guard let item = vm.images.fetchByID(itemIdentifier) else {return}
vm.toggleCheckItem(item)
}
DataSource 구성
final class DataSource: UICollectionViewDiffableDataSource<String,AlbumItem.ID>{
weak var vm: CreatingPinVM!
var disposeBag = DisposeBag()
init(vm: CreatingPinVM,collectionView: UICollectionView, cellProvider: @escaping UICollectionViewDiffableDataSource<String, AlbumItem.ID>.CellProvider){
super.init(collectionView: collectionView, cellProvider: cellProvider)
self.vm = vm
vm.albums.bind(with: self) { owner, item in
owner.initSnapshot(albumItems: item.map{$0.id})
}.disposed(by: disposeBag)
vm.updatedAlbums.bind(with: self) { owner, item in
owner.reloadSnapshot(albumItems: item.map{$0.id})
}.disposed(by: disposeBag)
}
@MainActor func initSnapshot(albumItems: [AlbumItem.ID]){
var snapshot = NSDiffableDataSourceSnapshot<String,PhotoAsset.ID>()
// 단일 구성으로 섹션 아무거나 지정
snapshot.appendSections(["wow"])
snapshot.appendItems(albumItems)
apply(snapshot,animatingDifferences: false)
}
@MainActor func reloadSnapshot(albumItems: [AlbumItem.ID]){
var snapshot = self.snapshot()
snapshot.reconfigureItems(albumItems)
apply(snapshot,animatingDifferences: false)
}
}
Cell 구성
final class CreatingPinMainCell: UICollectionViewCell {
var albumItem: AlbumItem?{
didSet{
guard let albumItem else {return}
if albumItem.selectedIdx < 0{
maskLayer.removeFromSuperlayer()
selectedLabel.text = ""
selectedLabel.isHidden = true
}else{
imageView.layer.addSublayer(maskLayer)
selectedLabel.text = "\(albumItem.selectedIdx)"
selectedLabel.isHidden = false
}
}
}
@MainActor var thumbnailImage: UIImage? {
didSet {
Task{@MainActor in
imageView.image = thumbnailImage
}
}
}
@MainActor let imageView = UIImageView()
var representedAssetIdentifier: String?
let selectedLabel = UILabel()
// 이미지 뷰를 어둡게 처리하는 레이어
var maskLayer = CALayer()
override init(frame: CGRect) {
super.init(frame: .zero)
contentView.addSubview(imageView)
contentView.addSubview(selectedLabel)
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
selectedLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
maskLayer.backgroundColor = UIColor.black.withAlphaComponent(0.2).cgColor
Task{
maskLayer.frame = self.bounds
}
selectedLabel.font = .systemFont(ofSize: 18, weight: .heavy)
selectedLabel.textColor = .white
}
required init?(coder: NSCoder) {
fatalError("Don't use storyboard")
}
}
선택한 이미지 목록 DataSource Snapshot apply
@MainActor func resetSnapshot(albumIDs:[AlbumItem.ID]){
var snapshot = NSDiffableDataSourceSnapshot<String,AlbumItem.ID>()
snapshot.appendSections(["hello"])
snapshot.appendItems(albumIDs)
Task{@MainActor in
if albumIDs.isEmpty{
try await Task.sleep(for: .seconds(0.66))
}
await apply(snapshot,animatingDifferences: true)
}
}
- 선택한 이미지 목록 전체를 받아와 완전히 새로운 Snapshot을 적용한다. (최대 5개 셀 만 그리면 된다…)
'이모저모 > UIKit' 카테고리의 다른 글
UIImage 메모리 최적화 (0) | 2023.12.11 |
---|---|
커스텀 이미지 피커 뷰 만들기 #2 (0) | 2023.12.02 |
싱글톤 앨범 이미지 서비스 만들기 with Rx or Combine (0) | 2023.11.28 |
UIButton State 애니메이션용 모듈 만들고 적용하기 (0) | 2023.11.25 |
CustomDiffableDataSource와 MVVM으로 Cell 데이터 관리하기 #0 (0) | 2023.11.18 |
댓글