티스토리 뷰

안녕하세요 Diana 입니다.

근래에 iPhone의 기본앱인 앨범을 구경하고 있는데 계속 보다보니 생각보다 다양한 기능들이 들어가 있더라구요.

그래서 한번 만들어보면 좋겠다 싶어 앨범앱 구현을 시작하게 되었고 그 과정에서 학습하고 겪은 일들을 기록해보고자 합니다.

 

시작할게요!

 


 

✅  Info.plist 앨범 권한 설정

앨범 구현에 앞서 우리는 사진을 가져오기 위해 아이폰의 앨범에 접근을 해야 하는데 이때 권한 설정이 필요합니다.

 

권한설정을 위해 Info.plist에 Privacy - Photo Library Usage Description 와 Privacy - Camera Usage Description 값을 추가해줍니다.

 

이후 유저에게 권한을 요청하는 창을 구현해야하는데 저는 앨범앱 구현을 위해서는 권한이 반드시 필요했기에 앱의 첫 화면인 MainViewController에 진입하면 무조건 권한 상태를 체크하였고 권한 설정이 안되있는 경우 권한을 요청하고 거절된 경우 설정창으로 리다이렉팅 하는 방식을 선택하였습니다.

 

우선 앨범의 권한에 관련된 작업만을 담당하는 AlbumPermissionManager.swift를 아래와 같이 구현하였습니다.

 

import Photos
import RxSwift
import RxCocoa

final class AlbumPermissionManager {
    // 앨범 권한 상태
    var authorizationStatus: PublishRelay<Bool> = PublishRelay()

    // 권한 체크
    func checkPermission() {
        let status = PHPhotoLibrary.authorizationStatus()
        switch status {
        case .authorized:
            authorizationStatus.accept(true)
        case .notDetermined: // 권한 설정이 안되어있는 경우 권한 요청
            requestPermission()
        case .denied, .restricted, .limited:
            authorizationStatus.accept(false)
        @unknown default:
            authorizationStatus.accept(false)
        }
    }
    
    // 권한 요청
    private func requestPermission() {
        PHPhotoLibrary.requestAuthorization { status in
            DispatchQueue.main.async {
                self.authorizationStatus.accept(status == .authorized)
            }
        }
    }
}

 

PHPhotoLibrary의 authorizationStatus를 통해 현재의 권한을 체크 한 뒤 권한 허용이 필요한 경우, PHPhotoLibrary의 requestAuthorization을 통해 권한을 요청하였고 권한이 허용되었거나 거절된 경우는 authorizationStatus 프로퍼티의 상태를 바꿔 viewModel에서 권한 상태를 파악할 수 있도록 구현하였습니다.

 

MainViewModel.swift에서는 이렇게 구현한 AlbumPermissionManager을 사용하여 권한 체크를 진행하였습니다.

 

import RxSwift
import RxCocoa
import Photos
import UIKit

final class MainViewModel {
    private let permissionManager = AlbumPermissionManager()
    ...

    var albumAuthorizationOK: PublishRelay<Bool> { permissionManager.authorizationStatus }
    ...

    func checkAlbumAccess() {
        permissionManager.checkPermission()
    }
    ...
}

 

MainViewController.swift 에서는 위와 같이 구현된 ViewModel을 사용하여 ViewWillAppear에서 권한 체크를 하였고 권한을 거절하는 경우 showAlbumAccessAlert() 함수를 통해 설정창으로 유저를 유도하였습니다.

 

추가로 여기서 유저가 설정창에서 권한을 허용하지 않고 돌아올 경우를 고려하여 앱이 forground 상태로 돌아오는 경우 Notification Center를 추가하여 다시 권한 체크를 하도록 구현하였습니다.

 

이제 권한 설정을 허용하지 않은 유저는 무조건 설정창으로 보내지게 됩니다.

 

import UIKit
import RxSwift

final class MainViewController: UIViewController, BaseViewController {
    var viewModel: MainViewModel
    
    ...
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .white
        
        ...
        
        NotificationCenter.default.addObserver(
            self,
            selector: #selector(appWillEnterForeground),
            name: UIApplication.willEnterForegroundNotification,
            object: nil
        )
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        viewModel.checkAlbumAccess()
    }
    
    @objc private func appWillEnterForeground() {
        viewModel.checkAlbumAccess()
    }
    
    ...
    
    
    private func showAlbumAccessAlert() {
        DispatchQueue.main.async { [weak self] in
            guard let self = self, self.view.window != nil else { return }
            
            let alert = UIAlertController(
                title: "사진 접근 권한 필요",
                message: "앱에서 사진에 접근하려면 설정에서 사진 접근을 허용해주세요.",
                preferredStyle: .alert
            )
            alert.addAction(UIAlertAction(title: "설정 열기", style: .default) { _ in
                guard let url = URL(string: UIApplication.openSettingsURLString),
                      UIApplication.shared.canOpenURL(url) else { return }
                UIApplication.shared.open(url)
            })
            
            self.present(alert, animated: true)
        }
    }
    
    deinit {
        NotificationCenter.default.removeObserver(self)
    }
}

...

 

✅  앨범 사진 가져오기

이제 권한설정을 했으니 본격적으로 앨범에서 사진을 가져와보려고 합니다.

 

권한설정을 위해 AlbumPermissionManager을 구현했던것 같이 사진을 가져오기 위해서 AlbumFetcher을 구현하였습니다.

AlbumFetcher.swift 코드는 아래와 같습니다.

 

import Photos
import RxSwift
import UIKit

final class AlbumFetcher {
    private let imageManager = PHCachingImageManager()

    func fetchAlbumContents(mediaType: PHAssetMediaType, targetSize: CGSize) -> Observable<[AlbumInfo]> {
        return Observable.create { observer in
            // 앨범 불러올때 옵션 설정
            let options = PHFetchOptions()
            options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
            
            // 옵션에 맞게 메타데이터 불러오기
            let fetchResult = PHAsset.fetchAssets(with: options)
            var albumInfos: [AlbumInfo] = []
            // PHCachingIImageManager에 이미지 요청할때 옵션
            let imageOptions = PHImageRequestOptions()
            imageOptions.isSynchronous = false

            // 각각의 메타데이터를 사용하여 PHCachingImageManager에 이미지 요청
            fetchResult.enumerateObjects { asset, _, _ in
                self.imageManager.requestImage(for: asset,
                                               targetSize: targetSize,
                                               contentMode: .aspectFill,
                                               options: imageOptions) { image, _ in
                    // 받아온 이미지를 원하는 포멧으로 변경
                    let info = AlbumInfo(asset: asset,
                                         thumbnail: image,
                                         creationDate: asset.creationDate,
                                         mediaType: asset.mediaType)
                    albumInfos.append(info)
                }
            }

            observer.onNext(albumInfos)
            observer.onCompleted()

            return Disposables.create()
        }
    }
}

 

앨범 라이브러리에 접근하는 방법에는 PHImageManager와 PHChachingImageManager 두 가지가 있으나 저는 PHCachingImageManager을 사용했습니다.

 

fetchAlbumContents 함수를 구현하여 우선 PHFetchOptions()를 통해 불러올때 원하는 옵션을 설정해주었습니다. 

(저는 "creationDate"를 ascending false로 설정하여 마지막으로 생성된 이미지, 즉 최신 이미지 부터 불러오도록 설정해주었습니다.)

 

그리고 PHAsset.fetchAssets(with:) 함수를 사용하여 앨범 이미지들의 메타데이터(PHAsset)를 받아왔습니다.

PHAsset.fetchAssets의 리턴 타입은 PHFetchResult<PHAsset>로 PHAsset 내부에는 mediaType, creationDate, modificationDate, location 등의 메타 데이터가 있으며 이미지를 받아오기 위해서는 해당 메타데이터를 사용하여 PHCachingManager에 요청해야 합니다.

 

저는 이렇게 받아온 이미지를 커스텀 타입(AlbumInfo)으로 변경하여 포멧팅 해주었습니다.

 

내부 구현은 이렇고 이 구체적인 로직을 담은 AlbumFetcher는 앞의 AlbumPermissionManager와 동일하게 MainViewModel에서 관리하도록 구현하였습니다.

 

import RxSwift
import RxCocoa
import Photos
import UIKit

final class MainViewModel {
    private let permissionManager = AlbumPermissionManager()
    private let albumFetcher = AlbumFetcher()
	// 권한이 변경되는 경우 ViewController에서 확인 가능하도록 변수로 관리
    var albumAuthorizationOK: PublishRelay<Bool> { permissionManager.authorizationStatus }
    // 앨범 사진이 변경되는 경우 ViewController에서 확인 가능하도록 변수로 관리
    var albumContents: PublishRelay<[AlbumInfo]> = PublishRelay() 

    private let disposeBag = DisposeBag()
    
    var selectedCell: AlbumCell?

    func checkAlbumAccess() {
        permissionManager.checkPermission()
    }

	// 앨범 로드를 위해 구체적인 로직을 담은 albumFetcher의 fetchAlbumContents 함수 호출
    func loadAlbum(mediaType: PHAssetMediaType, targetSize: CGSize) {
        albumFetcher.fetchAlbumContents(mediaType: mediaType, targetSize: targetSize)
            .bind(to: albumContents)
            .disposed(by: disposeBag)
    }
}

 

이렇게 받아온 데이터들을 가지고 MainViewController에서는 CollectionView에 뿌려주었는데 코드는 아래와 같습니다.

 

import UIKit
import RxSwift

final class MainViewController: UIViewController, BaseViewController, UIViewControllerTransitioningDelegate {
    var viewModel: MainViewModel
    var disposeBag = DisposeBag()
    
    private var albumInfos = [AlbumInfo]()
    
    private lazy var collectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        let itemSize = (view.bounds.width - 3 * 4) / 4
        layout.itemSize = CGSize(width: itemSize, height: itemSize)
        layout.minimumLineSpacing = 4
        layout.minimumInteritemSpacing = 4
        
        let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
        collectionView.register(AlbumCell.self, forCellWithReuseIdentifier: "AlbumCell")
        
        collectionView.dataSource = self
        collectionView.delegate = self
        
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.backgroundColor = .white
        
        return collectionView
    }()
    
    ...
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .white
        
        bind()
        setUI()
        
        ...
    }
    
    ...
    
    func bind() {
        viewModel.albumAuthorizationOK
            .observe(on: MainScheduler.instance)
            .bind(onNext: { [weak self] isAuth in
                if isAuth {
                    self?.viewModel.loadAlbum(mediaType: .image, targetSize: CGSize(width: 200, height: 200))
                } else {
                    print("권한 거절")
                    self?.showAlbumAccessAlert()
                }
            })
            .disposed(by: disposeBag)
        
        viewModel.albumContents
            .bind(onNext: { [weak self] albumInfos in
                self?.albumInfos = albumInfos
                self?.collectionView.reloadData()
            })
            .disposed(by: disposeBag)
    }
    
    ...
}

extension MainViewController: UICollectionViewDelegate, UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return albumInfos.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "AlbumCell", for: indexPath) as? AlbumCell else {
            return UICollectionViewCell()
        }
        
        cell.configure(with: albumInfos[indexPath.item])
        
        return cell
    }
}

 

이렇게 구현한 결과 앱을 실행하면 제법 앨범의 첫 화면과 비슷한 결과물이 나오게 됩니다.

하지만 생긴것만 멀쩡할 뿐 여러 문제들이 발생하였는데 해결과정에 대해서는 다음 게시물에서 다뤄보도록 하겠습니다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/11   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30
글 보관함