mirror of https://github.com/oxen-io/session-ios
Sketch out the photo collection picker.
parent
9641edbfd2
commit
ea080eda72
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "navbar_disclosure_down_small@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "navbar_disclosure_down_small@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "navbar_disclosure_down_small@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "navbar_disclosure_up_small@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "navbar_disclosure_up_small@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "navbar_disclosure_up_small@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,146 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Photos
|
||||||
|
import PromiseKit
|
||||||
|
|
||||||
|
protocol PhotoCollectionPickerDelegate: class {
|
||||||
|
func photoCollectionPicker(_ photoCollectionPicker: PhotoCollectionPickerController, didPickCollection collection: PhotoCollection)
|
||||||
|
}
|
||||||
|
|
||||||
|
class PhotoCollectionPickerController: OWSTableViewController, PhotoLibraryDelegate {
|
||||||
|
|
||||||
|
private weak var collectionDelegate: PhotoCollectionPickerDelegate?
|
||||||
|
|
||||||
|
private let library: PhotoLibrary
|
||||||
|
private let lastPhotoCollection: PhotoCollection
|
||||||
|
private var photoCollections: PhotoCollections
|
||||||
|
|
||||||
|
required init(library: PhotoLibrary,
|
||||||
|
lastPhotoCollection: PhotoCollection,
|
||||||
|
collectionDelegate: PhotoCollectionPickerDelegate) {
|
||||||
|
self.library = library
|
||||||
|
self.lastPhotoCollection = lastPhotoCollection
|
||||||
|
self.photoCollections = library.allPhotoCollections()
|
||||||
|
self.collectionDelegate = collectionDelegate
|
||||||
|
super.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder aDecoder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: View Lifecycle
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = lastPhotoCollection.localizedTitle()
|
||||||
|
titleLabel.textColor = Theme.primaryColor
|
||||||
|
titleLabel.font = UIFont.ows_dynamicTypeBody.ows_mediumWeight()
|
||||||
|
|
||||||
|
let titleIconView = UIImageView()
|
||||||
|
titleIconView.tintColor = Theme.primaryColor
|
||||||
|
titleIconView.image = UIImage(named: "navbar_disclosure_up")?.withRenderingMode(.alwaysTemplate)
|
||||||
|
|
||||||
|
let titleView = UIStackView(arrangedSubviews: [titleLabel, titleIconView])
|
||||||
|
titleView.axis = .horizontal
|
||||||
|
titleView.alignment = .center
|
||||||
|
titleView.spacing = 5
|
||||||
|
titleView.isUserInteractionEnabled = true
|
||||||
|
titleView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(titleTapped)))
|
||||||
|
navigationItem.titleView = titleView
|
||||||
|
|
||||||
|
library.add(delegate: self)
|
||||||
|
|
||||||
|
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel,
|
||||||
|
target: self,
|
||||||
|
action: #selector(didPressCancel))
|
||||||
|
|
||||||
|
updateContents()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateContents() {
|
||||||
|
photoCollections = library.allPhotoCollections()
|
||||||
|
|
||||||
|
let section = OWSTableSection()
|
||||||
|
let count = photoCollections.count
|
||||||
|
for index in 0..<count {
|
||||||
|
let collection = photoCollections.collection(at: index)
|
||||||
|
section.add(OWSTableItem.init(customCellBlock: { () -> UITableViewCell in
|
||||||
|
let cell = OWSTableItem.newCell()
|
||||||
|
|
||||||
|
let imageView = UIImageView()
|
||||||
|
let kImageSize = 50
|
||||||
|
imageView.autoSetDimensions(to: CGSize(width: kImageSize, height: kImageSize))
|
||||||
|
|
||||||
|
let contents = collection.contents()
|
||||||
|
if contents.count > 0 {
|
||||||
|
let photoMediaSize = PhotoMediaSize(thumbnailSize: CGSize(width: kImageSize, height: kImageSize))
|
||||||
|
let assetItem = contents.assetItem(at: 0, photoMediaSize: photoMediaSize)
|
||||||
|
imageView.image = assetItem.asyncThumbnail { [weak imageView] image in
|
||||||
|
guard let strongImageView = imageView else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let image = image else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
strongImageView.image = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let titleLabel = UILabel()
|
||||||
|
titleLabel.text = collection.localizedTitle()
|
||||||
|
titleLabel.font = UIFont.ows_regularFont(withSize: 18)
|
||||||
|
titleLabel.textColor = Theme.primaryColor
|
||||||
|
|
||||||
|
let stackView = UIStackView(arrangedSubviews: [imageView, titleLabel])
|
||||||
|
stackView.axis = .horizontal
|
||||||
|
stackView.alignment = .center
|
||||||
|
stackView.spacing = 10
|
||||||
|
|
||||||
|
cell.contentView.addSubview(stackView)
|
||||||
|
stackView.ows_autoPinToSuperviewMargins()
|
||||||
|
|
||||||
|
return cell
|
||||||
|
},
|
||||||
|
customRowHeight: UITableViewAutomaticDimension,
|
||||||
|
actionBlock: { [weak self] in
|
||||||
|
guard let strongSelf = self else { return }
|
||||||
|
strongSelf.didSelectCollection(collection: collection)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
let contents = OWSTableContents()
|
||||||
|
contents.addSection(section)
|
||||||
|
self.contents = contents
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Actions
|
||||||
|
|
||||||
|
@objc
|
||||||
|
func didPressCancel(sender: UIBarButtonItem) {
|
||||||
|
self.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didSelectCollection(collection: PhotoCollection) {
|
||||||
|
collectionDelegate?.photoCollectionPicker(self, didPickCollection: collection)
|
||||||
|
|
||||||
|
self.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func titleTapped(sender: UIGestureRecognizer) {
|
||||||
|
guard sender.state == .recognized else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: PhotoLibraryDelegate
|
||||||
|
|
||||||
|
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
||||||
|
updateContents()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,288 @@
|
|||||||
|
//
|
||||||
|
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Photos
|
||||||
|
import PromiseKit
|
||||||
|
|
||||||
|
protocol PhotoLibraryDelegate: class {
|
||||||
|
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary)
|
||||||
|
}
|
||||||
|
|
||||||
|
class PhotoMediaSize {
|
||||||
|
var thumbnailSize: CGSize
|
||||||
|
|
||||||
|
init() {
|
||||||
|
self.thumbnailSize = .zero
|
||||||
|
}
|
||||||
|
|
||||||
|
init(thumbnailSize: CGSize) {
|
||||||
|
self.thumbnailSize = thumbnailSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PhotoPickerAssetItem: PhotoGridItem {
|
||||||
|
|
||||||
|
let asset: PHAsset
|
||||||
|
let album: PhotoCollectionContents
|
||||||
|
let photoMediaSize: PhotoMediaSize
|
||||||
|
|
||||||
|
init(asset: PHAsset, album: PhotoCollectionContents, photoMediaSize: PhotoMediaSize) {
|
||||||
|
self.asset = asset
|
||||||
|
self.album = album
|
||||||
|
self.photoMediaSize = photoMediaSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: PhotoGridItem
|
||||||
|
|
||||||
|
var type: PhotoGridItemType {
|
||||||
|
if asset.mediaType == .video {
|
||||||
|
return .video
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO show GIF badge?
|
||||||
|
|
||||||
|
return .photo
|
||||||
|
}
|
||||||
|
|
||||||
|
func asyncThumbnail(completion: @escaping (UIImage?) -> Void) -> UIImage? {
|
||||||
|
album.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in
|
||||||
|
completion(image)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PhotoCollectionContents {
|
||||||
|
|
||||||
|
let fetchResult: PHFetchResult<PHAsset>
|
||||||
|
let localizedTitle: String?
|
||||||
|
|
||||||
|
enum PhotoLibraryError: Error {
|
||||||
|
case assertionError(description: String)
|
||||||
|
case unsupportedMediaType
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
init(fetchResult: PHFetchResult<PHAsset>, localizedTitle: String?) {
|
||||||
|
self.fetchResult = fetchResult
|
||||||
|
self.localizedTitle = localizedTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
return fetchResult.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private let imageManager = PHCachingImageManager()
|
||||||
|
|
||||||
|
func asset(at index: Int) -> PHAsset {
|
||||||
|
return fetchResult.object(at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assetItem(at index: Int, photoMediaSize: PhotoMediaSize) -> PhotoPickerAssetItem {
|
||||||
|
let mediaAsset = asset(at: index)
|
||||||
|
return PhotoPickerAssetItem(asset: mediaAsset, album: self, photoMediaSize: photoMediaSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: ImageManager
|
||||||
|
|
||||||
|
func requestThumbnail(for asset: PHAsset, thumbnailSize: CGSize, resultHandler: @escaping (UIImage?, [AnyHashable: Any]?) -> Void) {
|
||||||
|
_ = imageManager.requestImage(for: asset, targetSize: thumbnailSize, contentMode: .aspectFill, options: nil, resultHandler: resultHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestImageDataSource(for asset: PHAsset) -> Promise<(dataSource: DataSource, dataUTI: String)> {
|
||||||
|
return Promise { resolver in
|
||||||
|
_ = imageManager.requestImageData(for: asset, options: nil) { imageData, dataUTI, _, _ in
|
||||||
|
guard let imageData = imageData else {
|
||||||
|
resolver.reject(PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let dataUTI = dataUTI else {
|
||||||
|
resolver.reject(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let dataSource = DataSourceValue.dataSource(with: imageData, utiType: dataUTI) else {
|
||||||
|
resolver.reject(PhotoLibraryError.assertionError(description: "dataSource was unexpectedly nil"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver.fulfill((dataSource: dataSource, dataUTI: dataUTI))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestVideoDataSource(for asset: PHAsset) -> Promise<(dataSource: DataSource, dataUTI: String)> {
|
||||||
|
return Promise { resolver in
|
||||||
|
|
||||||
|
_ = imageManager.requestExportSession(forVideo: asset, options: nil, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, _ in
|
||||||
|
|
||||||
|
guard let exportSession = exportSession else {
|
||||||
|
resolver.reject(PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exportSession.outputFileType = AVFileType.mp4
|
||||||
|
exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing()
|
||||||
|
|
||||||
|
let exportPath = OWSFileSystem.temporaryFilePath(withFileExtension: "mp4")
|
||||||
|
let exportURL = URL(fileURLWithPath: exportPath)
|
||||||
|
exportSession.outputURL = exportURL
|
||||||
|
|
||||||
|
Logger.debug("starting video export")
|
||||||
|
exportSession.exportAsynchronously {
|
||||||
|
Logger.debug("Completed video export")
|
||||||
|
|
||||||
|
guard let dataSource = DataSourcePath.dataSource(with: exportURL, shouldDeleteOnDeallocation: true) else {
|
||||||
|
resolver.reject(PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resolver.fulfill((dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func outgoingAttachment(for asset: PHAsset) -> Promise<SignalAttachment> {
|
||||||
|
switch asset.mediaType {
|
||||||
|
case .image:
|
||||||
|
return requestImageDataSource(for: asset).map { (dataSource: DataSource, dataUTI: String) in
|
||||||
|
return SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium)
|
||||||
|
}
|
||||||
|
case .video:
|
||||||
|
return requestVideoDataSource(for: asset).map { (dataSource: DataSource, dataUTI: String) in
|
||||||
|
return SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return Promise(error: PhotoLibraryError.unsupportedMediaType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PhotoCollection {
|
||||||
|
private let collection: PHAssetCollection
|
||||||
|
|
||||||
|
init(collection: PHAssetCollection) {
|
||||||
|
self.collection = collection
|
||||||
|
}
|
||||||
|
|
||||||
|
func localizedTitle() -> String {
|
||||||
|
guard let localizedTitle = collection.localizedTitle?.stripped,
|
||||||
|
localizedTitle.count > 0 else {
|
||||||
|
return NSLocalizedString("PHOTO_PICKER_UNNAMED_COLLECTION", comment: "label for system photo collections which have no name.")
|
||||||
|
}
|
||||||
|
return localizedTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
func contents() -> PhotoCollectionContents {
|
||||||
|
let options = PHFetchOptions()
|
||||||
|
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
|
||||||
|
let fetchResult = PHAsset.fetchAssets(in: collection, options: options)
|
||||||
|
|
||||||
|
return PhotoCollectionContents(fetchResult: fetchResult, localizedTitle: localizedTitle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PhotoCollections {
|
||||||
|
let collections: [PhotoCollection]
|
||||||
|
|
||||||
|
init(collections: [PhotoCollection]) {
|
||||||
|
self.collections = collections
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
return collections.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func collection(at index: Int) -> PhotoCollection {
|
||||||
|
return collections[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PhotoLibrary: NSObject, PHPhotoLibraryChangeObserver {
|
||||||
|
final class WeakDelegate {
|
||||||
|
weak var delegate: PhotoLibraryDelegate?
|
||||||
|
init(_ value: PhotoLibraryDelegate) {
|
||||||
|
delegate = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var delegates = [WeakDelegate]()
|
||||||
|
|
||||||
|
public func add(delegate: PhotoLibraryDelegate) {
|
||||||
|
delegates.append(WeakDelegate(delegate))
|
||||||
|
}
|
||||||
|
|
||||||
|
var assetCollection: PHAssetCollection!
|
||||||
|
|
||||||
|
func photoLibraryDidChange(_ changeInstance: PHChange) {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
for weakDelegate in self.delegates {
|
||||||
|
weakDelegate.delegate?.photoLibraryDidChange(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override init() {
|
||||||
|
super.init()
|
||||||
|
PHPhotoLibrary.shared().register(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
PHPhotoLibrary.shared().unregisterChangeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPhotoCollection() -> PhotoCollection {
|
||||||
|
guard let photoCollection = allPhotoCollections().collections.first else {
|
||||||
|
owsFail("Could not locate Camera Roll.")
|
||||||
|
}
|
||||||
|
return photoCollection
|
||||||
|
}
|
||||||
|
|
||||||
|
func allPhotoCollections() -> PhotoCollections {
|
||||||
|
var collections = [PhotoCollection]()
|
||||||
|
var collectionIds = Set<String>()
|
||||||
|
|
||||||
|
let processPHCollection: (PHCollection) -> Void = { (collection) in
|
||||||
|
// De-duplicate by id.
|
||||||
|
let collectionId = collection.localIdentifier
|
||||||
|
guard !collectionIds.contains(collectionId) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collectionIds.insert(collectionId)
|
||||||
|
|
||||||
|
guard let assetCollection = collection as? PHAssetCollection else {
|
||||||
|
owsFailDebug("Asset collection has unexpected type: \(type(of: collection))")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let photoCollection = PhotoCollection(collection: assetCollection)
|
||||||
|
// Hide empty collections.
|
||||||
|
guard photoCollection.contents().count > 0 else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
collections.append(photoCollection)
|
||||||
|
}
|
||||||
|
let processPHAssetCollections: (PHFetchResult<PHAssetCollection>) -> Void = { (fetchResult) in
|
||||||
|
for index in 0..<fetchResult.count {
|
||||||
|
processPHCollection(fetchResult.object(at: index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let processPHCollections: (PHFetchResult<PHCollection>) -> Void = { (fetchResult) in
|
||||||
|
for index in 0..<fetchResult.count {
|
||||||
|
processPHCollection(fetchResult.object(at: index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fetchOptions = PHFetchOptions()
|
||||||
|
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "endDate", ascending: true)]
|
||||||
|
// Try to add "Camera Roll" first.
|
||||||
|
processPHAssetCollections(PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .smartAlbumUserLibrary, options: fetchOptions))
|
||||||
|
// User-created albums.
|
||||||
|
processPHCollections(PHAssetCollection.fetchTopLevelUserCollections(with: fetchOptions))
|
||||||
|
// Smart albums.
|
||||||
|
processPHAssetCollections(PHAssetCollection.fetchAssetCollections(with: .smartAlbum, subtype: .albumRegular, options: fetchOptions))
|
||||||
|
|
||||||
|
return PhotoCollections(collections: collections)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue