If you need to create your own PhotoPicker, it is not easy to create because you need to implement many of the features (UI, business logic) needed to implement PhotoPicker. So EasyMakePhotoPicker provides an abstraction layer of PhotoPicker. EasyMakePhotoPicker implements all the business logic required for PhotoPicker so you can focus on the UI.
EasyMakePhotoPicker makes it easy to implement things like FacebookPhotoPicker.
EasyMakePhotoPicker provides three components (PhotosView, PhotoCollectionsView, PhotoManager).
PhotosView
is a grid-like view of photos from photoLibrary.
- Custom Layout
- Custom Cell(Camera, Photo, LivePhoto, Video)
- Like Facebook`s PhotoPicker, When you stop scrolling, it runs livePhoto, video. and When LivePhotoCell or VideCell is selected, play.
- Scrolling performance optimization - Automatically cache and destroy photos.
- Selected Order index.
- Multiple selection.
- Camera selection.
- Automatically update the UI When PhotoLibrary changes(such as inserting, deleteing, updating, moving photos).
init(configure: PhotosViewConfigure, photoAssetCollection: PhotoAssetCollection)
init(configure: PhotosViewConfigure, collectionType: PHAssetCollectionSubtype)
// Note: 'selectedPhotosDidComplete' reacts when the signal come from selectionDidComplete.
var selectionDidComplete: PublishSubject<Void>
var photoDidSelected: PublishSubject<PhotoAsset>
// Note: 'selectedPhotosDidComplete' reacts when the signal come from selectionDidComplete.
// only support when PhotosViewConfigure`s 'allowsMultipleSelection' property is true.
var selectedPhotosDidComplete: PublishSubject<[PhotoAsset]>
// only support when PhotosViewConfigure`s 'allowsMultipleSelection' property is true.
var selectedPhotosCount: PublishSubject<Int>
// only support when PhotosViewConfigure`s 'allowsMultipleSelection' property is true.
var photoDidDeselected: PublishSubject<PhotoAsset>
// only support when PhotosViewConfigure`s 'allowsCameraSelection' property is true.
var cameraDidClick: PublishSubject<Void>
func change(photoAssetCollection: PhotoAssetCollection)
PhotosView is configured through PhotosViewConfigure.
protocol PhotosViewConfigure {
var fetchOptions: PHFetchOptions { get }
var allowsMultipleSelection: Bool { get }
var allowsCameraSelection: Bool { get }
// .video, .livePhoto
var allowsPlayTypes: [AssetType] { get }
var messageWhenMaxCountSelectedPhotosIsExceeded: String { get }
var maxCountSelectedPhotos: Int { get }
// get item image from PHCachingImageManager
// based on the UICollectionViewFlowLayout`s itemSize,
// therefore must set well itemSize in UICollectionViewFlowLayout.
var layout: UICollectionViewFlowLayout { get }
var photoCellTypeConverter: PhotoCellTypeConverter { get }
var livePhotoCellTypeConverter: LivePhotoCellTypeConverter { get }
var videoCellTypeConverter: VideoCellTypeConverter { get }
var cameraCellTypeConverter: CameraCellTypeConverter { get }
}
// example
class FacebookPhotosViewConfigure: PhotosViewConfigure {
var fetchOptions: PHFetchOptions = PHFetchOptions()
var allowsMultipleSelection: Bool = true
var allowsCameraSelection: Bool = true
// .video, .livePhoto
var allowsPlayTypes: [AssetType] = [.video, .livePhoto]
var messageWhenMaxCountSelectedPhotosIsExceeded: String = "over!!!"
var maxCountSelectedPhotos: Int = 15
var layout: UICollectionViewFlowLayout = FacebookPhotosLayout()
var cameraCellTypeConverter = CameraCellTypeConverter(type: FacebookCameraCell.self)
var photoCellTypeConverter = PhotoCellTypeConverter(type: FacebookPhotoCell.self)
var livePhotoCellTypeConverter = LivePhotoCellTypeConverter(type: FacebookLivePhotoCell.self)
var videoCellTypeConverter = VideoCellTypeConverter(type: FacebookVideoCell.self)
}
PhotosViewConfigure provides Cells (PhotoCell, VideoCell, LivePhotoCell, and CameraCell) to be displayed in PhotosView.
- To provide PhotoCell,
UICollectionViewCell
must conform thePhotoCellable
protocol. - To provide LivePhotoCell, the
UICollectionViewCell
must conform theLivePhotoCellable
protocol. - To provide VideoCell,
UICollectionViewCell
must inheritVideoCellable
protocol. - To provide CameraCell, the
UICollectionViewCell
must conform theCameraCellable
protocol.
Note: one of the cells must conform
PhotoCellable
,LivePhotoCellable
, orVideoCellable
. This is becausePhotosView
is implemented in theMVVM architecture
and the Protocol determines what kind ofCellViewModel
it is. If cell conform thePhotoCellable
protocol, cell are provided withPhotoViewModel
. if the cell conform theLivePhotoCellable
protocol, cell are provided withLivePhotoCellViewModel
. if the cell conform theVideoCellable
protocol, cell are provided withVideoCellViewModel
. Thanks to the MVVM architecture, you can easily create a UI for the desired cell using the state values of the CellViewModel.
protocol PhotoCellable: class {
var viewModel: PhotoCellViewModel? { get set }
}
protocol LivePhotoCellable: PhotoCellable { }
protocol VideoCellable: PhotoCellable { }
protocol CameraCellable: class { }
class PhotoCellViewModel {
var image: Variable<UIImage?>
var isSelect: BehaviorSubject<Bool>
var selectedOrder: BehaviorSubject<Int>
...
}
class LivePhotoCellViewModel: PhotoCellViewModel {
...
var livePhoto: PHLivePhoto?
var playEvent: PublishSubject<PlayEvent>
var badgeImage: UIImage
}
class VideoCellViewModel: PhotoCellViewModel {
...
var playerItem: AVPlayerItem?
var duration: TimeInterval
}
// example
class FacebookPhotoCell: UICollectionViewCell, PhotoCellable {
// MARK: - Properties
var selectedView = UIView()
var orderLabel = FacebookNumberLabel()
var imageView = UIImageView()
var disposeBag: DisposeBag = DisposeBag()
var viewModel: PhotoCellViewModel? {
didSet {
guard let viewModel = viewModel else { return }
bind(viewModel: viewModel)
}
}
// MARK: - Set up views
...
func addSubviews() {
...
}
func setupConstraints() {
...
}
// MARK: - Bind
func bind(viewModel: PhotoCellViewModel) {
viewModel.isSelect
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self, weak viewModel] isSelect in
guard let `self` = self,
let `viewModel` = viewModel else { return }
self.selectedView.isHidden = !isSelect
if viewModel.configure.allowsMultipleSelection {
self.orderLabel.isHidden = !isSelect
}
else {
self.orderLabel.isHidden = true
}
})
.disposed(by: disposeBag)
viewModel.isSelect
.skip(1)
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] isSelect in
guard let `self` = self else { return }
if isSelect {
self.cellAnimationWhenSelectedCell()
}
else {
self.cellAnimationWhenDeselectedCell()
}
})
.disposed(by: disposeBag)
viewModel.selectedOrder
.subscribe(onNext: { [weak self] selectedOrder in
guard let `self` = self else { return }
self.orderLabel.text = "\(selectedOrder)"
})
.disposed(by: disposeBag)
viewModel.image.asObservable()
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] image in
guard let `self` = self else { return }
self.imageView.image = image
})
.disposed(by: disposeBag)
}
}
class FacebookVideoCell: FacebookPhotoCell, VideoCellable {
var durationLabel: UILabel = DurationLabel()
var playerView = PlayerView()
fileprivate var player: AVPlayer? {
didSet {
if let player = player {
playerView.playerLayer.player = player
NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: player.currentItem,
queue: nil) { _ in
DispatchQueue.main.async {
player.seek(to: kCMTimeZero)
player.play()
}
}
}
else {
playerView.playerLayer.player = nil
NotificationCenter.default.removeObserver(self)
}
}
}
var durationBackgroundView = UIView()
var videoIconImageView = UIImageView(image: #imageLiteral(resourceName: "video"))
var duration: TimeInterval = 0.0 {
didSet {
durationLabel.text = timeFormatted(timeInterval: duration)
}
}
// MARK: - Life Cycle
override func addSubviews() {
super.addSubviews()
...
}
override func setupConstraints() {
super.setupConstraints()
...
}
// MARK: - Bind
override func bind(viewModel: PhotoCellViewModel) {
super.bind(viewModel: viewModel)
if let viewModel = viewModel as? VideoCellViewModel {
duration = viewModel.duration
viewModel.playEvent.asObserver()
.subscribe(onNext: { [weak self] playEvent in
guard let `self` = self else { return }
switch playEvent {
case .play: self.play()
case .stop: self.stop()
}
})
.disposed(by: disposeBag)
viewModel.isSelect
.subscribe(onNext: { [weak self] isSelect in
guard let `self` = self else { return }
if isSelect {
self.durationBackgroundView.backgroundColor =
Color.selectedDurationBackgroundViewBGColor
}
else {
self.durationBackgroundView.backgroundColor =
Color.deselectedDurationBackgroundViewBGColor
}
})
.disposed(by: disposeBag)
}
}
fileprivate func play() {
guard let viewModel = viewModel as? VideoCellViewModel,
let playerItem = viewModel.playerItem else { return }
self.player = AVPlayer(playerItem: playerItem)
if let player = player {
playerView.isHidden = false
player.play()
}
}
fileprivate func stop() {
if let player = player {
player.pause();
self.player = nil
playerView.isHidden = true
}
}
}
...
By providing PhotosViewConfigure's layout (UICollectionViewFlowLayout), PhotosView shows the cells with the layout provided.
// example
class FacebookPhotosLayout: UICollectionViewFlowLayout {
// MARK: - Constant
fileprivate struct Constant {
static let padding = CGFloat(5)
static let numberOfColumns = CGFloat(3)
}
override var itemSize: CGSize {
set { }
get {
guard let collectionView = collectionView else { return .zero }
let collectionViewWidth = (collectionView.bounds.width)
let columnWidth = (collectionViewWidth -
Constant.padding * (Constant.numberOfColumns - 1)) / Constant.numberOfColumns
return CGSize(width: columnWidth, height: columnWidth)
}
}
override init() {
super.init()
setupLayout()
}
required init?(coder aDecoder: NSCoder) {
super.init()
setupLayout()
}
func setupLayout() {
minimumLineSpacing = Constant.padding
minimumInteritemSpacing = Constant.padding
}
}
class FacebookPhotoPickerVC: UIViewController {
...
var photosViewConfigure = FacebookPhotosViewConfigure()
lazy var photosView: PhotosView = { [unowned] self
let pv = PhotosView(
configure: self.photosViewConfigure,
collectionType: .smartAlbumUserLibrary)
return pv
}()
override func viewDidLoad() {
super.viewDidLoad()
// MARK: - add view
...
// MARK: - bind
...
doneButton.rx.tap
.observeOn(MainScheduler.instance)
.subscribe(onNext: { _ in
self.photosView.selectionDidComplete.onNext()
self.dismiss(animated: true, completion: nil)
})
.disposed(by: disposeBag)
photosView.selectedPhotosDidComplete
.subscribe(onNext: { [weak self] photoAssets in
guard let `self` = self else { return }
self.selectedPhotoAssetsDidComplete.onNext(photoAssets)
})
.disposed(by: disposeBag)
photosView.outputs.cameraDidClick
.observeOn(MainScheduler.instance)
.subscribe(onNext: { [weak self] in
guard let `self` = self else { return }
self.showCamera()
})
.disposed(by: disposeBag)
...
}
....
}
PhotoCollectionsView
is a view that show a list of albums from photoLibrary.
- Custom Cell(PhotoCollection)
- Custom Layout
- Automatically update the UI When PhotoLibrary changes.
init(frame: CGRect, configure: PhotoCollectionsViewConfigure)
init(configure: PhotoCollectionsViewConfigure)
// force cell selection.
var cellDidSelect: PublishSubject<IndexPath>
var selectedPhotoCollectionWhenCellDidSelect: PublishSubject<(IndexPath, PhotoAssetCollection)>
PhotoCollectionsView is configured through PhotoCollectionsViewConfigure.
protocol PhotoCollectionsViewConfigure {
var fetchOptions: PHFetchOptions { get }
// to show collection types.
var showsCollectionTypes: [PHAssetCollectionSubtype] { get }
// size of view to show thumbnailImage in your cell and
// photoCollectionThumbnailSize must be the same
// because get photo collection thumbnail image from PHCachingImageManager
// based on the 'photoCollectionThumbnailSize'
var photoCollectionThumbnailSize: CGSize { get }
var layout: UICollectionViewFlowLayout { get }
var photoCollectionCellTypeConverter: PhotoCollectionCellTypeConverter { get }
}
// example
struct FacebookPhotoCollectionsViewConfigure: PhotoCollectionsViewConfigure {
var fetchOptions = PHFetchOptions()
// to show collection types.
var showsCollectionTypes: [PHAssetCollectionSubtype] = [
.smartAlbumUserLibrary,
.smartAlbumGeneric,
.smartAlbumFavorites,
.smartAlbumRecentlyAdded,
.smartAlbumVideos,
.smartAlbumPanoramas,
.smartAlbumBursts,
.smartAlbumScreenshots
]
var photoCollectionThumbnailSize = CGSize(width: 54, height: 54)
var layout: UICollectionViewFlowLayout = FacebookPhotoCollectionsLayout()
var photoCollectionCellTypeConverter =
PhotoCollectionCellTypeConverter(type: FacebookPhotoCollectionCell.self)
}
PhotoCollectionsViewConfigure
provides Cell(PhotoCollectionCell) to be displayed in PhotoCollectionsView
.
- To provide PhotoCollectionCell,
UICollectionViewCell
must inheritPhotoCollectionCellable
protocol.
Note: cell must conform
PhotoCollectionCellable
. This is becausePhotoCollectionsView
is implemented in theMVVM architecture
and the Protocol determines what kind ofCellViewModel
it is. Thanks to the MVVM architecture, you can easily create a UI for the desired cell using the state values of the CellViewModel.
protocol PhotoCollectionCellable {
var viewModel: PhotoCollectionCellViewModel? { get set }
}
class PhotoCollectionCellViewModel {
var count: BehaviorSubject<Int>
var thumbnail = BehaviorSubject<UIImage?>
var title: BehaviorSubject<String>
var isSelect: Variable<Bool>
}
// example
class FacebookPhotoCollectionCell: BaseCollectionViewCell, PhotoCollectionCellable {
var checkView: UIView = CheckImageView()
var thumbnailImageView = UIImageView()
var titleLabel = UILabel()
var countLabel = UILabel()
var lineView = UIView()
var disposeBag = DisposeBag()
var viewModel: PhotoCollectionCellViewModel? {
didSet {
guard let viewModel = viewModel else { return }
bind(viewModel: viewModel)
}
}
// MARK: - Life Cycle
override func setupViews() {
...
}
override func setupConstraints() {
...
}
// MARK: - Bind
func bind(viewModel: PhotoCollectionCellViewModel) {
viewModel.isSelect.asObservable()
.subscribe(onNext: { [weak self] isSelect in
guard let`self` = self else { return }
if isSelect {
self.checkView.isHidden = false
}
else {
self.checkView.isHidden = true
}
})
.disposed(by: disposeBag)
viewModel.count
.subscribe(onNext: { [weak self] count in
guard let `self` = self else { return }
self.countLabel.text = "\(count)"
})
.disposed(by: disposeBag)
viewModel.thumbnail
.subscribe(onNext: { [weak self] thumbnail in
guard let `self` = self else { return }
self.thumbnailImageView.image = thumbnail
})
.disposed(by: disposeBag)
viewModel.title
.subscribe(onNext: { [weak self] title in
guard let `self` = self else { return }
self.titleLabel.text = title
})
.disposed(by: disposeBag)
}
}
By providing PhotoCollectionsViewConfigure's layout (UICollectionViewFlowLayout), PhotoCollectionsView shows the cells with the layout provided.
// example
class FacebookPhotoCollectionsLayout: UICollectionViewFlowLayout {
override var itemSize: CGSize {
set { }
get {
guard let collectionView = collectionView else { return .zero }
return CGSize(width: collectionView.frame.width, height: 80)
}
}
override init() {
super.init()
setupLayout()
}
required init?(coder aDecoder: NSCoder) {
super.init()
setupLayout()
}
func setupLayout() {
minimumInteritemSpacing = 0
minimumLineSpacing = 0
scrollDirection = .vertical
}
}
class FacebookPhotoPickerVC: UIViewController {
...
var photoCollectionsViewConfigure = FacebookPhotoCollectionsViewConfigure()
lazy var photoCollectionsView: PhotoCollectionsView = { [unowned] self
let pv = PhotoCollectionsView(
configure: self.photoCollectionsViewConfigure)
return pv
}()
override func viewDidLoad() {
super.viewDidLoad()
// MARK: - set up views
...
// MARK: - bind
...
photoCollectionsView.selectedPhotoCollectionWhenCellDidSelect
.subscribe(onNext: { [weak self] (selectedIndexPath, selectedPhotoAssetCollection) in
guard let `self` = self else { return }
...
self.photosView.change(photoAssetCollection: selectedPhotoAssetCollection)
})
.disposed(by: disposeBag)
....
}
PhotoManager
is a wrapper class for PhotoCacheImageManager, it provides the functions of PhotoCacheImageManager
(fetch photos, fetch albums, cache...etc) as Observable.
func startCaching(assets: [PHAsset], targetSize: CGSize, contentMode: PHImageContentMode, options: PHImageRequestOptions?)
func stopCaching(assets: [PHAsset], targetSize: CGSize, contentMode: PHImageContentMode, options: PHImageRequestOptions?)
func stopCachingForAllAssets()
func cancel(imageRequest requestID: PHImageRequestID)
func photoLibraryDidChange(_ changeInstance: PHChange)
func performChanges(changeBlock: @escaping () -> Void) -> Observable<PerformChangesEvent>
func fetchCollections(assetCollectionTypes: [PHAssetCollectionSubtype], thumbnailImageSize: CGSize, options: PHFetchOptions? = nil) -> Observable<[PhotoAssetCollection]>
func image(for asset: PHAsset, size: CGSize = CGSize(width: 720, height: 1280), options: PHImageRequestOptions? = nil) -> Observable<UIImage>
func livePhoto(for asset: PHAsset, size: CGSize = CGSize(width: 720, height: 1280)) -> Observable<LivePhotoDownloadEvent>
func video(for asset: PHAsset, size: CGSize = CGSize(width: 720, height: 1280)) -> Observable<VideoDownloadEvent>
func cloudImage(for asset: PHAsset, size: CGSize = PHImageManagerMaximumSize) -> Observable<CloudPhotoDownLoadEvent>
func fullResolutionImage(for asset: PHAsset) -> Observable<UIImage>
func checkPhotoLibraryPermission() -> Observable<Bool>
func checkCameraPermission() -> Observable<Bool>
iOS 9.1
EasyMakePhotoPicker is available through CocoaPods. To install it, simply add the following line to your Podfile:
platform :ios, '9.1'
pod "EasyMakePhotoPicker"
Myung gi son, audrl1010@naver.com
EasyMakePhotoPicker is available under the MIT license. See the LICENSE file for more info.