본문 바로가기
이모저모/UIKit

커스텀 이미지 피커 뷰 만들기 #1

by ARpple 2023. 12. 2.

커스텀 이미지 피커 뷰 화면 구성

주요 구현 View 3가지

1. 사용자 이미지 목록 View

  • 이미지 썸네일 클릭시 클릭 순서에 맞게 데이터를 보관할 필요가 있다.
  • 살짝 이미지를 어둡게 가리고 순서에 맞는 숫자를 보여줄 필요가 있다.

2. 선택한 이미지 목록 View

  • 선택한 이미지를 순서에 맞게 보여줄 필요가 있다.
  • 클릭하면 이미지를 삭제시키고 보관한 데이터를 변경해 줄 필요가 있다.

3. 사용자 앨범 목록 View

  • 네이게이션 타이틀을 클릭하면 아래로 내려왔다가 다시 올라가는 화면 이동을 보여줘야한다.

특징

  1. DiffableDataSource 사용 (사용자 이미지 목록 뷰, 선택한 이미지 목록 뷰)👉 선택한 이미지 목록에서 추가, 삭제에 기본 애니메이션 적용이 가능하다.
  2. 👉 rxDataSource, delegate는 전체 데이터를 계속 뷰에 다시 넣는 작업이 들어간다… (reloadData)
    snapshot방식으로 변경 사항만 뷰를 다시 그리는 diffableDataSource를 사용해 연산을 줄인다.
  3. rxSwift와 viewModel 이용
    👉 각각의 세부 구현 collectionView 내부에서 viewModel의 데이터 바인딩을 통해서 뷰를 그려내도록 함
  4. 👉 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개 셀 만 그리면 된다…)

댓글