diff --git a/ExampleApp/ViewController.swift b/ExampleApp/ViewController.swift index e5ddbca..28d76ef 100644 --- a/ExampleApp/ViewController.swift +++ b/ExampleApp/ViewController.swift @@ -52,7 +52,7 @@ let sectionsData: [(String?, String?)] = [ ("Assets Source", nil), ("Asset Items in a row", nil), ("Capture mode", nil), - ("Save Assets", "Assets will be saved to Photo Library") + ("Save Assets", "Assets will be saved to Photo Library. This applies to photos only. Live photos and videos are always saved.") ] /// @@ -227,7 +227,7 @@ class ViewController: UITableViewController { } // save capture assets to photo library? - imagePicker.captureSettings.savesCapturedAssetToPhotoLibrary = savesCapturedAssets + imagePicker.captureSettings.savesCapturedPhotosToPhotoLibrary = savesCapturedAssets // presentation // before we present VC we can ask for authorization to photo library, diff --git a/ImagePicker.xcodeproj/project.pbxproj b/ImagePicker.xcodeproj/project.pbxproj index b6ba4de..e648760 100644 --- a/ImagePicker.xcodeproj/project.pbxproj +++ b/ImagePicker.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 425777531F98D110000824F0 /* ActionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 425777511F98D10F000824F0 /* ActionCell.swift */; }; 425777541F98D119000824F0 /* ActionCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 425777521F98D10F000824F0 /* ActionCell.xib */; }; 425BEC851F7D351A0091D008 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 425BEC841F7D351A0091D008 /* Assets.xcassets */; }; + 427271701FA1D304008AC2B4 /* CarvedLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4272716F1FA1D304008AC2B4 /* CarvedLabel.swift */; }; 427925F31F9636D700B6D55F /* StationaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427925F21F9636D700B6D55F /* StationaryButton.swift */; }; 427925F51F96381400B6D55F /* ShutterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427925F41F96381400B6D55F /* ShutterButton.swift */; }; 427925F71F96388000B6D55F /* RecordButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427925F61F96388000B6D55F /* RecordButton.swift */; }; @@ -40,6 +41,7 @@ 427925FE1F963B2800B6D55F /* VideoCameraCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 427925FC1F963A8C00B6D55F /* VideoCameraCell.xib */; }; 427926011F963E9C00B6D55F /* LivePhotoCameraCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427925FF1F963E9B00B6D55F /* LivePhotoCameraCell.swift */; }; 427926021F96407900B6D55F /* LivePhotoCameraCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 427926001F963E9C00B6D55F /* LivePhotoCameraCell.xib */; }; + 42990DBF1FA07AF7001658C4 /* RecordDurationLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42990DBE1FA07AF7001658C4 /* RecordDurationLabel.swift */; }; 429BFDAA1F68161D00029440 /* ImagePickerAssetModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429BFDA91F68161D00029440 /* ImagePickerAssetModel.swift */; }; 42A037E01F66C9E700534350 /* CustomVideoCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 42A037DF1F66C9E700534350 /* CustomVideoCell.xib */; }; 42D097991F7BD6E200A66E33 /* UIImageEffects.m in Sources */ = {isa = PBXBuildFile; fileRef = 42D097971F7BD6E100A66E33 /* UIImageEffects.m */; }; @@ -110,6 +112,7 @@ 425777511F98D10F000824F0 /* ActionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionCell.swift; sourceTree = ""; }; 425777521F98D10F000824F0 /* ActionCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ActionCell.xib; sourceTree = ""; }; 425BEC841F7D351A0091D008 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 4272716F1FA1D304008AC2B4 /* CarvedLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarvedLabel.swift; sourceTree = ""; }; 427925F21F9636D700B6D55F /* StationaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StationaryButton.swift; sourceTree = ""; }; 427925F41F96381400B6D55F /* ShutterButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShutterButton.swift; sourceTree = ""; }; 427925F61F96388000B6D55F /* RecordButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordButton.swift; sourceTree = ""; }; @@ -117,6 +120,7 @@ 427925FC1F963A8C00B6D55F /* VideoCameraCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = VideoCameraCell.xib; sourceTree = ""; }; 427925FF1F963E9B00B6D55F /* LivePhotoCameraCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LivePhotoCameraCell.swift; sourceTree = ""; }; 427926001F963E9C00B6D55F /* LivePhotoCameraCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LivePhotoCameraCell.xib; sourceTree = ""; }; + 42990DBE1FA07AF7001658C4 /* RecordDurationLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordDurationLabel.swift; sourceTree = ""; }; 429BFDA91F68161D00029440 /* ImagePickerAssetModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePickerAssetModel.swift; sourceTree = ""; }; 42A037DF1F66C9E700534350 /* CustomVideoCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = CustomVideoCell.xib; sourceTree = ""; }; 42D097971F7BD6E100A66E33 /* UIImageEffects.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UIImageEffects.m; sourceTree = ""; }; @@ -190,6 +194,7 @@ 420C24181F5D82F9008935D4 /* ImagePicker.h */, 42D7036F1F7909100057D557 /* Public */, 42D703701F790A3F0057D557 /* Media */, + 42990DBD1FA07A6C001658C4 /* Views */, 420C24351F5ED4AA008935D4 /* ImagePickerLayout.swift */, 429BFDA91F68161D00029440 /* ImagePickerAssetModel.swift */, 420C24271F5D925A008935D4 /* ImagePickerDataSource.swift */, @@ -197,16 +202,6 @@ 420C243E1F5EEA19008935D4 /* LayoutModel.swift */, 4214426E1F604498006BA45A /* ImagePickerSelectionPolicy.swift */, 42EDD4151F712B2A00EAD2F5 /* Miscellaneous.swift */, - 42D0979B1F7BF6B300A66E33 /* AssetCell.swift */, - 427925F21F9636D700B6D55F /* StationaryButton.swift */, - 427925F41F96381400B6D55F /* ShutterButton.swift */, - 427925F61F96388000B6D55F /* RecordButton.swift */, - 427925FB1F963A8C00B6D55F /* VideoCameraCell.swift */, - 427925FC1F963A8C00B6D55F /* VideoCameraCell.xib */, - 427925FF1F963E9B00B6D55F /* LivePhotoCameraCell.swift */, - 427926001F963E9C00B6D55F /* LivePhotoCameraCell.xib */, - 425777511F98D10F000824F0 /* ActionCell.swift */, - 425777521F98D10F000824F0 /* ActionCell.xib */, 425BEC841F7D351A0091D008 /* Assets.xcassets */, 42D097981F7BD6E100A66E33 /* UIImageEffects.h */, 42D097971F7BD6E100A66E33 /* UIImageEffects.m */, @@ -228,6 +223,25 @@ path = "Custom Views"; sourceTree = ""; }; + 42990DBD1FA07A6C001658C4 /* Views */ = { + isa = PBXGroup; + children = ( + 425777511F98D10F000824F0 /* ActionCell.swift */, + 425777521F98D10F000824F0 /* ActionCell.xib */, + 42D0979B1F7BF6B300A66E33 /* AssetCell.swift */, + 427925FF1F963E9B00B6D55F /* LivePhotoCameraCell.swift */, + 427926001F963E9C00B6D55F /* LivePhotoCameraCell.xib */, + 427925F61F96388000B6D55F /* RecordButton.swift */, + 42990DBE1FA07AF7001658C4 /* RecordDurationLabel.swift */, + 427925F41F96381400B6D55F /* ShutterButton.swift */, + 427925F21F9636D700B6D55F /* StationaryButton.swift */, + 427925FB1F963A8C00B6D55F /* VideoCameraCell.swift */, + 427925FC1F963A8C00B6D55F /* VideoCameraCell.xib */, + 4272716F1FA1D304008AC2B4 /* CarvedLabel.swift */, + ); + name = Views; + sourceTree = ""; + }; 42D7036F1F7909100057D557 /* Public */ = { isa = PBXGroup; children = ( @@ -399,6 +413,7 @@ 42E736521F8510B70060E24D /* VideoCaptureDelegate.swift in Sources */, 429BFDAA1F68161D00029440 /* ImagePickerAssetModel.swift in Sources */, 42D7036B1F7908B10057D557 /* CaptureSettings.swift in Sources */, + 42990DBF1FA07AF7001658C4 /* RecordDurationLabel.swift in Sources */, 42EDD41A1F716F6300EAD2F5 /* PhotoCaptureDelegate.swift in Sources */, 420C24341F5DA022008935D4 /* ImagePickerDelegate.swift in Sources */, 420C24281F5D925A008935D4 /* ImagePickerDataSource.swift in Sources */, @@ -413,6 +428,7 @@ 42EDD4161F712B2A00EAD2F5 /* Miscellaneous.swift in Sources */, 427926011F963E9C00B6D55F /* LivePhotoCameraCell.swift in Sources */, 42311FFC1F73DBBF00B1AEB4 /* VideoOuptutSampleBufferDelegate.swift in Sources */, + 427271701FA1D304008AC2B4 /* CarvedLabel.swift in Sources */, 42F7D8011F7A5B72009D378A /* Appearance.swift in Sources */, 420C24261F5D8393008935D4 /* ImagePickerController.swift in Sources */, 42EDD4051F7125C700EAD2F5 /* CaptureSession.swift in Sources */, diff --git a/ImagePicker/CaptureSession.swift b/ImagePicker/CaptureSession.swift index 152590c..68d406a 100644 --- a/ImagePicker/CaptureSession.swift +++ b/ImagePicker/CaptureSession.swift @@ -106,11 +106,6 @@ final class CaptureSession : NSObject { var presetConfiguration: SessionPresetConfiguration = .photos - /// - /// Save assets to library or not. Appropriate delegate is called in all cases. - /// - var saveCapturedAssetsToPhotoLibrary = false - /// /// Set this method to orientation that mathches UI orientation before `prepare()` /// method is called. If you need to update orientation when session is running, @@ -699,7 +694,7 @@ extension CaptureSession { extension CaptureSession { - func capturePhoto(livePhotoMode: LivePhotoMode) { + func capturePhoto(livePhotoMode: LivePhotoMode, saveToPhotoLibrary: Bool) { /* Retrieve the video preview layer's video orientation on the main queue before entering the session queue. We do this to ensure UI elements are accessed on @@ -795,7 +790,7 @@ extension CaptureSession { } }) - photoCaptureDelegate.savesPhotoToLibrary = self.saveCapturedAssetsToPhotoLibrary + photoCaptureDelegate.savesPhotoToLibrary = saveToPhotoLibrary /* The Photo Output keeps a weak reference to the photo capture delegate so @@ -811,7 +806,7 @@ extension CaptureSession { extension CaptureSession { - func startVideoRecording() { + func startVideoRecording(saveToPhotoLibrary: Bool) { guard let movieFileOutput = self.videoFileOutput else { return log("capture session: trying to record a video but no movie file output is set") @@ -884,7 +879,7 @@ extension CaptureSession { } } }) - recordingDelegate.savesPhotoToLibrary = strongSelf.saveCapturedAssetsToPhotoLibrary + recordingDelegate.savesVideoToLibrary = saveToPhotoLibrary // start recording movieFileOutput.startRecording(to: outputURL, recordingDelegate: recordingDelegate) diff --git a/ImagePicker/CaptureSettings.swift b/ImagePicker/CaptureSettings.swift index 39e22b2..f6983b6 100644 --- a/ImagePicker/CaptureSettings.swift +++ b/ImagePicker/CaptureSettings.swift @@ -36,16 +36,23 @@ public struct CaptureSettings { public var cameraMode: CameraMode /// - /// Return true if captured assets will be saved to photo library. Image picker - /// will prompt user with request for permisssions when needed. Default value is false. + /// Return true if captured photos will be saved to photo library. Image picker + /// will prompt user with request for permisssions when needed. Default value is false + /// for photos. Live photos and videos are always true. /// - public var savesCapturedAssetToPhotoLibrary: Bool + /// - note: please note, that at current implementation this applies to photos only. For + /// live photos and videos this is always true. + /// + public var savesCapturedPhotosToPhotoLibrary: Bool + + let savesCapturedLivePhotosToPhotoLibrary: Bool = true + let savesCapturedVideosToPhotoLibrary: Bool = true /// Default configuration public static var `default`: CaptureSettings { return CaptureSettings( cameraMode: .photo, - savesCapturedAssetToPhotoLibrary: false + savesCapturedPhotosToPhotoLibrary: false ) } } diff --git a/ImagePicker/CarvedLabel.swift b/ImagePicker/CarvedLabel.swift new file mode 100644 index 0000000..4f2d3c1 --- /dev/null +++ b/ImagePicker/CarvedLabel.swift @@ -0,0 +1,113 @@ +// +// CarvedLabel.swift +// ImagePicker +// +// Created by Peter Stajger on 26/10/2017. +// Copyright © 2017 Inloop. All rights reserved. +// + +import UIKit + +fileprivate typealias TextAttributes = [NSAttributedStringKey: Any] + +/// +/// A label whose transparent text is carved into solid color. +/// +/// - please note that text is always aligned to center +/// +@IBDesignable +final class CarvedLabel : UIView { + + @IBInspectable var text: String? { + didSet { + invalidateIntrinsicContentSize() + setNeedsDisplay() + } + } + + var font: UIFont? { + didSet { + invalidateIntrinsicContentSize() + setNeedsDisplay() + } + } + + @IBInspectable var cornerRadius: CGFloat = 0 { + didSet { setNeedsDisplay() } + } + + @IBInspectable var verticalInset: CGFloat = 0 { + didSet { + invalidateIntrinsicContentSize() + setNeedsDisplay() + } + } + + @IBInspectable var horizontalInset: CGFloat = 0 { + didSet { + invalidateIntrinsicContentSize() + setNeedsDisplay() + } + + } + + override init(frame: CGRect) { + super.init(frame: frame) + _ = backgroundColor + isOpaque = false + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + _ = backgroundColor + isOpaque = false + } + + override var backgroundColor: UIColor? { + get { return UIColor.clear } + set { super.backgroundColor = UIColor.clear } + } + + fileprivate var textAttributes: TextAttributes { + let activeFont = font ?? UIFont.systemFont(ofSize: 12, weight: .regular) + return [ + NSAttributedStringKey.font: activeFont + ] + } + + fileprivate var attributedString: NSAttributedString { + return NSAttributedString(string: text ?? "", attributes: textAttributes) + } + + override func draw(_ rect: CGRect) { + let color = tintColor! + color.setFill() + + let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius) + path.fill() + + guard let context = UIGraphicsGetCurrentContext(), (text?.characters.count ?? 0) > 0 else { + return + } + + let attributedString = self.attributedString + let stringSize = attributedString.size() + + let xOrigin: CGFloat = max(horizontalInset, (rect.width - stringSize.width)/2) + let yOrigin: CGFloat = max(verticalInset, (rect.height - stringSize.height)/2) + + context.saveGState() + context.setBlendMode(.destinationOut) + attributedString.draw(at: CGPoint(x: xOrigin, y: yOrigin)) + context.restoreGState() + } + + override func sizeThatFits(_ size: CGSize) -> CGSize { + let stringSize = attributedString.size() + return CGSize(width: stringSize.width + horizontalInset*2, height: stringSize.height + verticalInset*2) + } + + override var intrinsicContentSize: CGSize { + return sizeThatFits(.zero) + } +} diff --git a/ImagePicker/ImagePickerController.swift b/ImagePicker/ImagePickerController.swift index 4cd5bb9..4152960 100644 --- a/ImagePicker/ImagePickerController.swift +++ b/ImagePicker/ImagePickerController.swift @@ -35,6 +35,13 @@ public protocol ImagePickerControllerDelegate : class { /// func imagePicker(controller: ImagePickerController, didTake image: UIImage) + /// + /// Called when user takes new photo. + /// + //TODO: + //func imagePicker(controller: ImagePickerController, didCaptureVideo url: UIImage) + //func imagePicker(controller: ImagePickerController, didTake livePhoto: UIImage, videoUrl: UIImage) + /// /// Called right before an action item collection view cell is displayed. Use this method /// to configure your cell. @@ -303,7 +310,7 @@ open class ImagePickerController : UIViewController { collectionViewDataSource.cellRegistrator = cellRegistrator collectionViewDelegate.delegate = self collectionViewDelegate.layout = ImagePickerLayout(configuration: layoutConfiguration) - + //register for photo library updates - this is needed when changing permissions to photo library //TODO: this is expensive (loading library for the first time) PHPhotoLibrary.shared().register(self) @@ -316,7 +323,6 @@ open class ImagePickerController : UIViewController { let session = CaptureSession() captureSession = session session.presetConfiguration = captureSettings.cameraMode.captureSessionPresetConfiguration - session.saveCapturedAssetsToPhotoLibrary = captureSettings.savesCapturedAssetToPhotoLibrary session.videoOrientation = UIApplication.shared.statusBarOrientation.captureVideoOrientation session.delegate = self session.videoRecordingDelegate = self @@ -678,15 +684,15 @@ extension ImagePickerController : CaptureSessionVideoRecordingDelegate { extension ImagePickerController: CameraCollectionViewCellDelegate { func takePicture() { - captureSession?.capturePhoto(livePhotoMode: .off) + captureSession?.capturePhoto(livePhotoMode: .off, saveToPhotoLibrary: captureSettings.savesCapturedPhotosToPhotoLibrary) } func takeLivePhoto() { - captureSession?.capturePhoto(livePhotoMode: .on) + captureSession?.capturePhoto(livePhotoMode: .on, saveToPhotoLibrary: captureSettings.savesCapturedLivePhotosToPhotoLibrary) } func startVideoRecording() { - captureSession?.startVideoRecording() + captureSession?.startVideoRecording(saveToPhotoLibrary: captureSettings.savesCapturedVideosToPhotoLibrary) } func stopVideoRecording() { diff --git a/ImagePicker/ImagePickerLayout.swift b/ImagePicker/ImagePickerLayout.swift index 45c3a36..115c02f 100644 --- a/ImagePicker/ImagePickerLayout.swift +++ b/ImagePicker/ImagePickerLayout.swift @@ -58,14 +58,40 @@ final class ImagePickerLayout { case configuration.sectionIndexForActions: //this will make sure that action item is either square if there are 2 items, //or a recatangle if there is only 1 item - let width = sizeForItem(numberOfItemsInRow: 2, preferredWidthOrHeight: nil, collectionView: collectionView, scrollDirection: layout.scrollDirection).width + //let width = sizeForItem(numberOfItemsInRow: 2, preferredWidthOrHeight: nil, collectionView: collectionView, scrollDirection: layout.scrollDirection).width + let ratio: CGFloat = 0.25 + let width = collectionView.frame.width * ratio return sizeForItem(numberOfItemsInRow: layoutModel.numberOfItems(in: configuration.sectionIndexForActions), preferredWidthOrHeight: width, collectionView: collectionView, scrollDirection: layout.scrollDirection) case configuration.sectionIndexForCamera: //lets keep this ratio so camera item is a nice rectangle - let ratio: CGFloat = 0.734 - let width: CGFloat = collectionView.frame.height * ratio - return sizeForItem(numberOfItemsInRow: layoutModel.numberOfItems(in: configuration.sectionIndexForCamera), preferredWidthOrHeight: width, collectionView: collectionView, scrollDirection: layout.scrollDirection) + + let traitCollection = collectionView.traitCollection + + var ratio: CGFloat = 160/212 + + //for iphone in landscape we need different ratio + switch traitCollection.userInterfaceIdiom { + case .phone: + switch (traitCollection.horizontalSizeClass, traitCollection.verticalSizeClass) { + //iphones in landscape + case (.unspecified, .compact): + fallthrough + //iphones+ in landscape + case (.regular, .compact): + fallthrough + //iphones in landscape except iphone + + case (.compact, .compact): + ratio = 1/ratio + default: break + } + + default: + break + } + + let widthOrHeight: CGFloat = collectionView.frame.height * ratio + return sizeForItem(numberOfItemsInRow: layoutModel.numberOfItems(in: configuration.sectionIndexForCamera), preferredWidthOrHeight: widthOrHeight, collectionView: collectionView, scrollDirection: layout.scrollDirection) case configuration.sectionIndexForAssets: //make sure there is at least 1 item, othewise invalid layout diff --git a/ImagePicker/LivePhotoCameraCell.swift b/ImagePicker/LivePhotoCameraCell.swift index 98090b6..96df69c 100644 --- a/ImagePicker/LivePhotoCameraCell.swift +++ b/ImagePicker/LivePhotoCameraCell.swift @@ -13,26 +13,12 @@ class LivePhotoCameraCell : CameraCollectionViewCell { @IBOutlet weak var snapButton: UIButton! @IBOutlet weak var enableLivePhotosButton: StationaryButton! - @IBOutlet weak var liveIndicator: UILabel! + @IBOutlet weak var liveIndicator: CarvedLabel! override func awakeFromNib() { super.awakeFromNib() liveIndicator.alpha = 0 - liveIndicator.layer.cornerRadius = 2 - liveIndicator.layer.masksToBounds = true - liveIndicator.textColor = liveIndicator.backgroundColor - - //TODO: we need to do text layer reversed, it appears that best way is to - //render in image mask using core graphics - let textMask = CATextLayer() - textMask.contentsScale = UIScreen.main.scale - textMask.frame = liveIndicator.bounds - textMask.foregroundColor = UIColor.white.cgColor - textMask.string = "Live" - textMask.font = UIFont.systemFont(ofSize: UIFont.smallSystemFontSize, weight: .medium) - textMask.fontSize = UIFont.smallSystemFontSize - textMask.alignmentMode = kCAAlignmentCenter - liveIndicator.layer.mask = textMask + liveIndicator.tintColor = UIColor(red: 245/255, green: 203/255, blue: 47/255, alpha: 1) enableLivePhotosButton.unselectedTintColor = UIColor.white enableLivePhotosButton.selectedTintColor = UIColor(red: 245/255, green: 203/255, blue: 47/255, alpha: 1) diff --git a/ImagePicker/LivePhotoCameraCell.xib b/ImagePicker/LivePhotoCameraCell.xib index 85c46fd..61a0d58 100644 --- a/ImagePicker/LivePhotoCameraCell.xib +++ b/ImagePicker/LivePhotoCameraCell.xib @@ -30,10 +30,10 @@ - + + + + + + + + + + + + + + + + + + - - + + - + - + diff --git a/ImagePicker/RecordDurationLabel.swift b/ImagePicker/RecordDurationLabel.swift new file mode 100644 index 0000000..da32bb1 --- /dev/null +++ b/ImagePicker/RecordDurationLabel.swift @@ -0,0 +1,137 @@ +// +// RecordDurationLabel.swift +// ImagePicker +// +// Created by Peter Stajger on 25/10/2017. +// Copyright © 2017 Inloop. All rights reserved. +// + +import UIKit + +/// +/// Label that can be used to show duration during recording or just any +/// duration in general. +/// +final class RecordDurationLabel : UILabel { + + private var indicatorLayer: CALayer = { + let layer = CALayer() + layer.masksToBounds = true + layer.backgroundColor = UIColor(red: 234/255, green: 53/255, blue: 52/255, alpha: 1).cgColor + layer.frame.size = CGSize(width: 6, height: 6) + layer.cornerRadius = layer.frame.width/2 + layer.opacity = 0 //by default hidden + return layer + }() + + override init(frame: CGRect) { + super.init(frame: frame) + commonInit() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + commonInit() + } + + override func layoutSubviews() { + super.layoutSubviews() + indicatorLayer.position = CGPoint(x: -7, y: bounds.height/2) + } + + // MARK: Public Methods + + private var backingSeconds: TimeInterval = 10000 { + didSet { + updateLabel() + } + } + + func start() { + + guard secondTimer == nil else { + return + } + + secondTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self] (timer) in + self?.backingSeconds += 1 + }) + secondTimer?.tolerance = 0.1 + + indicatorTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { [weak self] (timer) in + self?.updateIndicator(appearDelay: 0.2) + }) + indicatorTimer?.tolerance = 0.1 + + updateIndicator(appearDelay: 0) + } + + func stop() { + secondTimer?.invalidate() + secondTimer = nil + backingSeconds = 0 + updateLabel() + + indicatorTimer?.invalidate() + indicatorTimer = nil + indicatorLayer.removeAllAnimations() + indicatorLayer.opacity = 0 + } + + // MARK: Private Methods + + private var secondTimer: Timer? + private var indicatorTimer: Timer? + + private func updateLabel() { + + //we are not using DateComponentsFormatter because it does not pad zero to hours component + //so it regurns pattern 0:00:00, we need 00:00:00 + let hours = Int(backingSeconds) / 3600 + let minutes = Int(backingSeconds) / 60 % 60 + let seconds = Int(backingSeconds) % 60 + text = String(format:"%02i:%02i:%02i", hours, minutes, seconds) + } + + private func updateIndicator(appearDelay: CFTimeInterval = 0) { + + let disappearDelay: CFTimeInterval = 0.25 + + let appear = appearAnimation(delay: appearDelay) + let disappear = disappearAnimation(delay: appear.beginTime + appear.duration + disappearDelay) + + let animation = CAAnimationGroup() + animation.animations = [appear, disappear] + animation.duration = appear.duration + disappear.duration + appearDelay + disappearDelay + animation.isRemovedOnCompletion = true + + indicatorLayer.add(animation, forKey: "blinkAnimationKey") + } + + private func commonInit() { + layer.addSublayer(indicatorLayer) + clipsToBounds = false + } + + private func appearAnimation(delay: CFTimeInterval = 0) -> CAAnimation { + let appear = CABasicAnimation(keyPath: "opacity") + appear.fromValue = indicatorLayer.presentation()?.opacity + appear.toValue = 1 + appear.duration = 0.15 + appear.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut) + appear.beginTime = delay + appear.fillMode = kCAFillModeForwards + return appear + } + + private func disappearAnimation(delay: CFTimeInterval = 0) -> CAAnimation { + let disappear = CABasicAnimation(keyPath: "opacity") + disappear.fromValue = indicatorLayer.presentation()?.opacity + disappear.toValue = 0 + disappear.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn) + disappear.beginTime = delay + disappear.duration = 0.25 + return disappear + } + +} diff --git a/ImagePicker/VideoCameraCell.swift b/ImagePicker/VideoCameraCell.swift index 20c89ce..3f04ac7 100644 --- a/ImagePicker/VideoCameraCell.swift +++ b/ImagePicker/VideoCameraCell.swift @@ -12,6 +12,7 @@ import UIKit //TODO: add a recording indicator (red dot with timer) class VideoCameraCell : CameraCollectionViewCell { + @IBOutlet weak var recordLabel: RecordDurationLabel! @IBOutlet weak var recordButton: RecordVideoButton! @IBOutlet weak var flipButton: UIButton! @@ -41,6 +42,9 @@ class VideoCameraCell : CameraCollectionViewCell { //update button state recordButton.isSelected = isRecording + //update duration label + isRecording ? recordLabel.start() : recordLabel.stop() + //update other buttons let updates: () -> Void = { self.flipButton.alpha = isRecording ? 0 : 1 diff --git a/ImagePicker/VideoCameraCell.xib b/ImagePicker/VideoCameraCell.xib index 2ba38b7..687037a 100644 --- a/ImagePicker/VideoCameraCell.xib +++ b/ImagePicker/VideoCameraCell.xib @@ -4,6 +4,7 @@ + @@ -11,18 +12,18 @@ - + - + + + + + + + + - + + + + + + + - + diff --git a/ImagePicker/VideoCaptureDelegate.swift b/ImagePicker/VideoCaptureDelegate.swift index c292e7e..023daea 100644 --- a/ImagePicker/VideoCaptureDelegate.swift +++ b/ImagePicker/VideoCaptureDelegate.swift @@ -17,8 +17,8 @@ final class VideoCaptureDelegate: NSObject, AVCaptureFileOutputRecordingDelegate // MARK: Public Methods - /// set this to false if you dont wish to save taken picture to photo library - var savesPhotoToLibrary = true + /// set this to false if you dont wish to save video to photo library + var savesVideoToLibrary = true /// true if user manually requested to cancel recording (stop without saving) var isBeingCancelled = false @@ -56,6 +56,22 @@ final class VideoCaptureDelegate: NSObject, AVCaptureFileOutputRecordingDelegate private func cleanUp(deleteFile: Bool, saveToAssets: Bool, outputFileURL: URL) { + func deleteFileIfNeeded() { + + guard deleteFile == true else { return } + + let path = outputFileURL.path + if FileManager.default.fileExists(atPath: path) { + do { + try FileManager.default.removeItem(atPath: path) + } + catch let error { + log("capture session: could not remove recording at url: \(outputFileURL)") + log("capture session: error: \(error)") + } + } + } + if let currentBackgroundRecordingID = backgroundRecordingID { backgroundRecordingID = UIBackgroundTaskInvalid if currentBackgroundRecordingID != UIBackgroundTaskInvalid { @@ -69,30 +85,22 @@ final class VideoCaptureDelegate: NSObject, AVCaptureFileOutputRecordingDelegate PHPhotoLibrary.shared().performChanges({ let creationRequest = PHAssetCreationRequest.forAsset() let videoResourceOptions = PHAssetResourceCreationOptions() - videoResourceOptions.shouldMoveFile = false + videoResourceOptions.shouldMoveFile = true creationRequest.addResource(with: .video, fileURL: outputFileURL, options: videoResourceOptions) }, completionHandler: { success, error in if let error = error { log("capture session: Error occurered while saving video to photo library: \(error)") + deleteFileIfNeeded() } - } - ) + }) } - } - } - - if deleteFile { - let path = outputFileURL.path - if FileManager.default.fileExists(atPath: path) { - do { - try FileManager.default.removeItem(atPath: path) - } - catch let error { - log("capture session: could not remove recording at url: \(outputFileURL)") - log("capture session: error: \(error)") + else { + deleteFileIfNeeded() } } - + } + else { + deleteFileIfNeeded() } } @@ -114,7 +122,7 @@ final class VideoCaptureDelegate: NSObject, AVCaptureFileOutputRecordingDelegate if successfullyFinished { recordingWasInterrupted = true - cleanUp(deleteFile: false, saveToAssets: savesPhotoToLibrary, outputFileURL: outputFileURL) + cleanUp(deleteFile: true, saveToAssets: savesVideoToLibrary, outputFileURL: outputFileURL) didFail(self, error) } else { @@ -127,7 +135,7 @@ final class VideoCaptureDelegate: NSObject, AVCaptureFileOutputRecordingDelegate didFinish(self) } else { - cleanUp(deleteFile: false, saveToAssets: savesPhotoToLibrary, outputFileURL: outputFileURL) + cleanUp(deleteFile: true, saveToAssets: savesVideoToLibrary, outputFileURL: outputFileURL) didFinish(self) } diff --git a/README.md b/README.md index 767ab72..bfb429e 100644 --- a/README.md +++ b/README.md @@ -71,14 +71,14 @@ Currently Image Picker supports capturing *photos*, *live photos* and *videos*. To configure Image Picker to support desired media type use `CaptureSettings` struct. Use property `cameraMode` to specify what kind of output you are interested in. If you don't intend to support live photos at all, please use value `photo`, otherwise `photoAndLivePhoto`. If you wish to capture photos and videos use `photoAndVideo`. Capturing videos and live photos at the same time is not supported and you nor can't switch between presets after it's been configrued. -By default, all captured assets are not saved to photo library but rather provided to you by the delegate right away. However if you wish to save assets to photo library set `savesCapturedAssetToPhotoLibrary` to *true*. +By default, all captured photos are not saved to Photo Library but rather provided to you by the delegate right away. However if you wish to save photos to photo library set `savesCapturedPhotosToPhotoLibrary` to *true*. Live photos and videos are saved to Photo Library automatically. An example of configuration for taking photos and live photos and saving them to photo library: ```swift let imagePicker = ImagePickerController() imagePicker.captureSettings.cameraMode = .photoAndLivePhoto -imagePicker.captureSettings.savesCapturedAssetToPhotoLibrary = true +imagePicker.captureSettings.savesCapturedPhotosToPhotoLibrary = true ``` Please refer to `CaptureSettings` public header for more information. @@ -306,7 +306,9 @@ Optionaly, before presenting image picker, you can check if user has granted acc ## Accessing, selecting and deselecting asset items -Image Picker has several convinience methods to work with asset items. +All user actions such as selecting/deselecting of assets, taking new photos or livephotos or capturing vides are advertised using `ImagePickerControllerDelegate` delegate methods. For list and more detail explanation please see public header. + +Sometimes you will need to manage selected assets programatically. Image Picker provides several convinience methods to work with asset items. - `selectedAssets` property returns an array of currently selected `PHAsset` items - to access asset items at certain indexes, use `assets(at:)` and `asset(at:)` @@ -314,20 +316,6 @@ Image Picker has several convinience methods to work with asset items. - to programatically deselect an asset item use `deselectAsset(at:animated:)` - to programatically deselect all selected items use `deselectAllAssets(_:)` -## Features to add - -1. landscape layout for camera cell - video is already in landscape but cell must be wider to properly display it -2. [ok] flip cameras -3. [ok] blur/unblur camera cell video layer when capture session is suspended/unsuspended -4. [ok] blur/unblur camera cell video layer when capture session is interrupted, failed or app goes to background/unactive -5. [ok] add public API for recording videos -6. [ok] add public API for enabling/disabling live photos -7. [ok] add public API for setting if taken pictures should be saved in camera roll or just directly provided through delegate -8. [ok] when user denies access to camera, show that access is denied -9. implement image pre-caching based on visible rectangle bounds -10. [ok] add default features for base CameraCollectionViewCell - tap to take photo -11. [ok] support styling through appearance -12. [ok] configuration-less default implementation ## Known Issues @@ -338,13 +326,13 @@ Image Picker has several convinience methods to work with asset items. 2. taking screenshot of AVVideoPreviewLayer is not possible - it returns transparent empty image used solution: use image buffer from AVVideoCaptureOutupt, blur it and add it as subview to the cell [fixed] need to transform image from front camera horizontally - it's mirrored so the blurring effect is not 100% nice when flipping camera -4. [fixed] when camera cell will be blurred first time it lags - need to use instruments to find out why it's lagging +4. when camera cell will be blurred first time it lags - need to use instruments to find out why it's lagging reproduce: simple scroll camera cell so it's not visible, you will notice a lag (iPhone SE), this lag might be caused by Photos framework when loading first buch of images 5. when rotating device, there is a little lag in video when changing orientation of outputs - it should be smooth though 6. [fixed] when flipping from front camera to back camera, latest sample buffer image that is used does not have proper transform, you can see that it is rotated horizontally so it creates unpleasant effect durring unblur animation when flipping cameras 7. [fixed] when user defines layout configuration without camera - image picker still initializes capture session wich asks for permissions, crashes if no privacy key in info.plist is set and this is all not necessary -8. when `video` preset is used, blurring of camera cell is turned off because capture session does not support both video data output and movie file output at the same time - possible solution: only use movie file output when recording is about to begin and remove it when it ended +8. [fixed] when `video` preset is used, blurring of camera cell is turned off because capture session does not support both video data output and movie file output at the same time + used solution: for this case, UIVisualEffect view with blur effect is used ## Technologies used