관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 3. 갤러리 화면 만들기, 사진 첨부 - 갤러리 화면 UI 구현 방법 본문

UI 컴포넌트 (swift)

[iOS - swift] 3. 갤러리 화면 만들기, 사진 첨부 - 갤러리 화면 UI 구현 방법

jake-kim 2023. 6. 29. 23:58

1. 갤러리 화면 만들기, 사진 첨부 - 앨범 가져오기 (PHFetchResult, PHAsset)

2. 갤러리 화면 만들기, 사진 첨부 - 사진 가져오기 (PHCachingImageManager, PHImageRequestOptions)

3. 갤러리 화면 만들기, 사진 첨부 - 갤러리 화면 UI 구현 방법

직접 구현한 사진 선택 화면

앨범과 사진 가져오는 방법

(이전글에 있는 내용 복습)

  • Photos 모듈에서 제공하는 API를 사용
    • 디바이스의 앨범을 먼저 가져오기 (PHFetchResult가 앨범을 의미)
    • 앨범에 담긴 이미지 정보 가져오기 (PHAsset이 이미지나 비디오 정보를 의미)
    • PHAsset을 가지고 UIImage 이미지 가져오기 (PHCachingImageManager가 요청한 크기에 맞추어 PHAsset으로부터 이미지를 가져옴)
    • 쿼리는 모두 PHImageRequestOptions를 작성
  • 위에서 얻은 이미지(UIImage)를 UICollectionView와 UICollectionViewFlowLayout으로 쉽게 구현이 가능

이전글에서 살펴본 내용

  • 구현한 내용
    • 앨범을 가져오는 albumService
    • 앨범 정보로부터 PHAssets와 UIImage를 가져오는 PhotoService
// PhotoViewController.swift

private let albumService: AlbumService = MyAlbumService()
private let photoService: PhotoService = MyPhotoService()
private var albums = [PHFetchResult<PHAsset>]()

override func viewDidLoad() {
    super.viewDidLoad()
    
    setupUI()
    loadAlbums(completion: { [weak self] in
        self?.loadImages()
    })
}

private func loadAlbums(completion: @escaping () -> Void) {
    albumService.getAlbums(mediaType: .image) { [weak self] albumInfos in
        self?.albums = albumInfos.map(\.album)
        completion()
    }
}

private func loadPHAssetsFromAlbums() {
    let album = albums[currentAlbumIndex]
    photoService.convertAlbumToPHAssets(album: album) { [weak self] phAssets in
        print(phAssets)
    }
}
  • 현재 PHAssets까지 얻어왔으므로 이 값을 토대로 UIImage를 얻어서 UICollectionView에 그려줄 것

UI 구현 예제에 사용한 라이브러리

  • Cocoapod 사용
    • Then: sugar 프로그래밍에 도움을 주는 라이브러리
    • SnapKit: 코드베이스로 오토레이아웃을 쉽게 적용하기 위한 라이브러리
pod 'Then'
pod 'SnapKit'

갤러리 UI 구성

  • 상단에 완료 버튼이 하나 
  • 하단에 UICollectionView 하나

(view hierarchy)

PhotoCell 구현

  • UICollectionView에 들어갈 셀 구현
    • SelectionOrder 모델: 셀이 선택되었을때와 선택되지 않았을때의 UI에 필요한 데이터
    • PhotoCellInfo 모델: 셀을 그릴때 필요한 데이터
import UIKit
import Then
import Photos

enum SelectionOrder {
    case none
    case selected(Int)
}

struct PhotoCellInfo {
    let phAsset: PHAsset
    let image: UIImage?
    let selectedOrder: SelectionOrder
}

final class PhotoCell: UICollectionViewCell {
}
  • PhotoCell 내부를 구현
    • imageView: 앨범에서 가져온 이미지 정보가 담긴 뷰
    • highlightedView: 선택했을때 테두리에 초록색이 뜨고 내부는 dimmed되는 효과를 주기 위한 view 
    • orderLabel: 이미지 선택 순서를 왼쪽 상단에 띄워주기 위한 label
private let imageView = UIImageView().then {
    $0.isUserInteractionEnabled = false
    $0.contentMode = .scaleAspectFill
}
private let highlightedView = UIView().then {
    $0.backgroundColor = .clear
    $0.layer.borderWidth = 2.0
    $0.backgroundColor = .black.withAlphaComponent(0.5)
    $0.layer.borderColor = UIColor.green.cgColor
    $0.isUserInteractionEnabled = false
}
private let orderLabel = UILabel().then {
    $0.textColor = .green
}
  • 뷰 레이아웃
override init(frame: CGRect) {
    super.init(frame: frame)
    layer.masksToBounds = true // 주의: 이값을 안주면 이미지가 셀의 다른 영역을 침범하는 영향을 주는것
    contentView.addSubview(imageView)
    imageView.addSubview(highlightedView)
    highlightedView.addSubview(orderLabel)
    
    imageView.snp.makeConstraints {
        $0.edges.equalToSuperview()
    }
    highlightedView.snp.makeConstraints {
        $0.edges.equalToSuperview()
    }
    orderLabel.snp.makeConstraints {
        $0.leading.top.equalToSuperview().inset(4)
    }
}
  • UICollectionView의 데이터소스 중 cellForItemAt에서 접근할때 사용될 메소드 prepare() 구현
func prepare(info: PhotoCellInfo?) {
    imageView.image = info?.image
    
    if case let .selected(order) = info?.selectedOrder {
        highlightedView.isHidden = false
        orderLabel.text = String(order)
    } else {
        highlightedView.isHidden = true
    }
}

갤러리 화면 PhotoViewController 구현

  • PhotoViewController 선언
import UIKit
import Then
import SnapKit
import Photos

final class PhotoViewController: UIViewController {

}
  • 뷰의 위치, 크기와 관련된 상수 선언
    private enum Const {
        static let numberOfColumns = 3.0
        static let cellSpace = 1.0
        static let length = (UIScreen.main.bounds.size.width - cellSpace * (numberOfColumns - 1)) / numberOfColumns
        static let cellSize = CGSize(width: length, height: length)
        static let scale = UIScreen.main.scale
    }
  • 완료 버튼, UICollectionView 선언
private let submitButton = UIButton(type: .system).then {
    $0.setTitle("완료", for: .normal)
    $0.setTitleColor(.blue, for: .normal)
    $0.setTitleColor(.systemBlue, for: [.normal, .highlighted])
}
private let collectionView = UICollectionView(
    frame: .zero,
    collectionViewLayout: UICollectionViewFlowLayout().then {
        $0.scrollDirection = .vertical
        $0.minimumLineSpacing = 1
        $0.minimumInteritemSpacing = 0
        $0.itemSize = Const.cellSize
    }
).then {
    $0.isScrollEnabled = true
    $0.showsHorizontalScrollIndicator = false
    $0.showsVerticalScrollIndicator = true
    $0.contentInset = .zero
    $0.backgroundColor = .clear
    $0.clipsToBounds = true
    $0.register(PhotoCell.self, forCellWithReuseIdentifier: PhotoCell.id)
}
  • 레이아웃 설정
override func viewDidLoad() {
    super.viewDidLoad()
    
    setupUI()
}

private func setupUI() {
    view.backgroundColor = .white
    collectionView.dataSource = self
    collectionView.delegate = self
    
    view.addSubview(submitButton)
    submitButton.snp.makeConstraints {
        $0.top.equalTo(view.safeAreaLayoutGuide).offset(20)
        $0.centerX.equalToSuperview()
    }
    
    view.addSubview(collectionView)
    collectionView.snp.makeConstraints {
        $0.top.equalTo(submitButton.snp.bottom)
        $0.leading.trailing.bottom.equalToSuperview()
    }
}
  • 앨범(PHFetchResult<PHAsset>), 이미지 및 비디오 데이터(PHAsset) 정보 가져오기
// MARK: Property
private let albumService: AlbumService = MyAlbumService()
private let photoService: PhotoService = MyPhotoService()
private var selectedIndexArray = [Int]() // Index: count

// album 여러개에 대한 예시는 생략 (UIPickerView와 같은 것을 이용하여 currentAlbumIndex를 바꾸어주면 됨)
private var albums = [PHFetchResult<PHAsset>]()
private var dataSource = [PhotoCellInfo]()
private var currentAlbumIndex = 0 {
    didSet { loadPHAssetsFromAlbums() }
}

override func viewDidLoad() {
    super.viewDidLoad()
    
    setupUI()
    loadAlbums(completion: { [weak self] in
        self?.loadPHAssetsFromAlbums()
    })
}

private func loadAlbums(completion: @escaping () -> Void) {
    albumService.getAlbums(mediaType: .image) { [weak self] albumInfos in
        self?.albums = albumInfos.map(\.album)
        completion()
    }
}

private func loadPHAssetsFromAlbums() {
    guard currentAlbumIndex < albums.count else { return }
    let album = albums[currentAlbumIndex]
    photoService.convertAlbumToPHAssets(album: album) { [weak self] phAssets in
        self?.dataSource = phAssets.map { .init(phAsset: $0, image: nil, selectedOrder: .none) }
        self?.collectionView.reloadData()
    }
}
  • UICollectionView의 dataSource 구현
    • 핵심은 PHAsset 정보를 가지고 UIImage를 얻어서 cell을 그리는 것
extension PhotoViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        dataSource.count
    }
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotoCell.id, for: indexPath) as? PhotoCell
        else { return UICollectionViewCell() }
        let imageInfo = dataSource[indexPath.item]
        let phAsset = imageInfo.phAsset
        let imageSize = CGSize(width: Const.cellSize.width * Const.scale, height: Const.cellSize.height * Const.scale)
        
        photoService.fetchImage(
            phAsset: phAsset,
            size: imageSize,
            contentMode: .aspectFit,
            completion: { [weak cell] image in
                cell?.prepare(info: .init(phAsset: phAsset, image: image, selectedOrder: imageInfo.selectedOrder))
            }
        )
        return cell
    }
}

셀 선택 처리

  • 이미지를 누르면 왼쪽 상단에 카운트가 보여져야함
    • 만약 선택된 1, 2, 3 셀에서 2를 다시 누르면 왼쪽 카운트 값 1, 31, 2로 변경되어야 하는 로직이 필요
    • selectedIndexArray를 사용하여 이전에 선택한 셀의 indexPath.item을 기록하고 있다가 이 값을 업데이트 치는 방식으로 구현
extension PhotoViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let info = dataSource[indexPath.item]
        let updatingIndexPaths: [IndexPath]
        
        if case .selected = info.selectedOrder {
            dataSource[indexPath.item] = .init(phAsset: info.phAsset, image: info.image, selectedOrder: .none)
            
            selectedIndexArray
                .removeAll(where: { $0 == indexPath.item })
            
            selectedIndexArray
                .enumerated()
                .forEach { order, index in
                    let order = order + 1
                    let prev = dataSource[index]
                    dataSource[index] = .init(phAsset: prev.phAsset, image: prev.image, selectedOrder: .selected(order))
                }
            updatingIndexPaths = [indexPath] + selectedIndexArray
                .map { IndexPath(row: $0, section: 0) }
        } else {
            selectedIndexArray
                .append(indexPath.item)
            
            selectedIndexArray
                .enumerated()
                .forEach { order, selectedIndex in
                    let order = order + 1
                    let prev = dataSource[selectedIndex]
                    dataSource[selectedIndex] = .init(phAsset: prev.phAsset, image: prev.image, selectedOrder: .selected(order))
                }
            
            updatingIndexPaths = selectedIndexArray
                .map { IndexPath(row: $0, section: 0) }
        }
        
        update(indexPaths: updatingIndexPaths)
    }
    
    private func update(indexPaths: [IndexPath]) {
        collectionView.performBatchUpdates {
            collectionView.reloadItems(at: indexPaths)
        }
    }
}

(구현 완료)

직접 구현한 사진 선택 화면

* 전체 코드: https://github.com/JK0369/ExPhoto.git

Comments