diff --git a/Signal/src/ViewControllers/Photos/MediaControls.swift b/Signal/src/ViewControllers/Photos/MediaControls.swift index 08f3ef0407..053cb2bc88 100644 --- a/Signal/src/ViewControllers/Photos/MediaControls.swift +++ b/Signal/src/ViewControllers/Photos/MediaControls.swift @@ -384,8 +384,7 @@ class CameraCaptureControl: UIView { } }() - let distanceForFullZoom = referenceDistance / 4 - let ratio = currentDistance / distanceForFullZoom + let ratio = currentDistance / referenceDistance zoomLevel = ratio.clamp(0, 1) delegate?.cameraCaptureControl(self, didUpdateZoomLevel: zoomLevel) } else { diff --git a/Signal/src/ViewControllers/Photos/PhotoCapture.swift b/Signal/src/ViewControllers/Photos/PhotoCapture.swift index 1ca853ce0c..8d4ea32e52 100644 --- a/Signal/src/ViewControllers/Photos/PhotoCapture.swift +++ b/Signal/src/ViewControllers/Photos/PhotoCapture.swift @@ -2,8 +2,11 @@ // Copyright (c) 2022 Open Whisper Systems. All rights reserved. // +import AVFoundation import CoreServices import Foundation +import SignalCoreKit +import UIKit protocol PhotoCaptureDelegate: AnyObject { @@ -23,6 +26,7 @@ protocol PhotoCaptureDelegate: AnyObject { // MARK: Utility func photoCapture(_ photoCapture: PhotoCapture, didChangeOrientation: AVCaptureVideoOrientation) + func photoCapture(_ photoCapture: PhotoCapture, didChangeVideoZoomFactor: CGFloat) func photoCaptureCanCaptureMoreItems(_ photoCapture: PhotoCapture) -> Bool func photoCaptureDidTryToCaptureTooMany(_ photoCapture: PhotoCapture) var zoomScaleReferenceDistance: CGFloat? { get } @@ -36,7 +40,6 @@ protocol PhotoCaptureDelegate: AnyObject { // MARK: - -@objc class PhotoCapture: NSObject { weak var delegate: PhotoCaptureDelegate? @@ -185,7 +188,7 @@ class PhotoCapture: NSObject { self.captureOrientation = initialCaptureOrientation self.session.sessionPreset = .high - try self.updateCurrentInput(position: .back) + try self.reconfigureCaptureInput() guard let photoOutput = self.captureOutput.photoOutput else { owsFailDebug("Missing photoOutput.") @@ -258,7 +261,6 @@ class PhotoCapture: NSObject { @unknown default: owsFailDebug("Unexpected enum value.") newPosition = .front - break } desiredPosition = newPosition @@ -267,16 +269,18 @@ class PhotoCapture: NSObject { self.session.beginConfiguration() defer { self.session.commitConfiguration() } - try self.updateCurrentInput(position: newPosition) + try self.reconfigureCaptureInput() } } - // This method should be called on the serial queue, - // and between calls to session.beginConfiguration/commitConfiguration - public func updateCurrentInput(position: AVCaptureDevice.Position) throws { + // This method should be called on the serial queue, and between calls to session.beginConfiguration/commitConfiguration + func reconfigureCaptureInput() throws { assertIsOnSessionQueue() - guard let device = captureOutput.videoDevice(position: position) else { + let avCaptureDeviceType = desiredPosition == .back ? avCaptureDeviceType(forCameraSystem: bestAvailableRearCameraSystem) : .builtInWideAngleCamera + let avCaptureDevicePosition = desiredPosition + + guard let device = captureOutput.videoDevice(for: avCaptureDeviceType, position: avCaptureDevicePosition) else { throw PhotoCaptureError.assertionError(description: description) } @@ -311,6 +315,11 @@ class PhotoCapture: NSObject { currentCaptureInput = newInput + // Camera by default has zoom factor of 1, which would be UW camera on triple camera systems. + // Also resetting rear camera to "1x" when switching between front and rear matches Camera app behavior. + if avCaptureDevicePosition == .back { + resetRearCameraZoomFactor(device) + } resetFocusAndExposure() } @@ -378,74 +387,232 @@ class PhotoCapture: NSObject { resetFocusAndExposure() } + // MARK: - Rear Camera Selection + + // Order must be the same as it appears in the in-app camera UI. + enum CameraType: Comparable { + case ultraWide + case wideAngle + case telephoto + } + + enum CameraSystem { + case wide // Single-camera devices. + case dual // W + T + case triple // UW + W + T + } + + private lazy var availableRearCameras: [CameraType] = { + let avTypes = captureOutput.imageOutput.availableRearDeviceTypes + var cameras: [CameraType] = [] + + // AVCaptureDevice.DiscoverySession returns devices in an arbitrary order, explicit ordering is required + if #available(iOS 13, *), avTypes.contains(.builtInUltraWideCamera) { + cameras.append(.ultraWide) + } + + if avTypes.contains(.builtInWideAngleCamera) { + cameras.append(.wideAngle) + } + + if avTypes.contains(.builtInTelephotoCamera) { + cameras.append(.telephoto) + } + + return cameras + }() + + lazy var bestAvailableRearCameraSystem: CameraSystem = { + let avTypes = captureOutput.imageOutput.availableRearDeviceTypes + + // No iOS 12 device can have a triple camera system. + if #available(iOS 13, *), avTypes.contains(.builtInTripleCamera) { + return .triple + } + if avTypes.contains(.builtInDualCamera) { + return .dual + } + return .wide + }() + + private func avCaptureDeviceType(forCameraSystem cameraSystem: CameraSystem) -> AVCaptureDevice.DeviceType { + switch cameraSystem { + case .wide: + return .builtInWideAngleCamera + + case .dual: + return .builtInDualCamera + + case .triple: + if #available(iOS 13, *) { + return .builtInTripleCamera + } + fallthrough + + default: + owsFailDebug("Unsupported camera system.") + return .builtInWideAngleCamera + } + } + // MARK: - Zoom - private let minimumZoom: CGFloat = 1.0 - private let maximumZoom: CGFloat = 3.0 - private var previousZoomFactor: CGFloat = 1.0 - - public func updateZoom(alpha: CGFloat) { - owsAssertDebug(alpha >= 0 && alpha <= 1) - sessionQueue.async { - guard let captureDevice = self.captureDevice else { - owsFailDebug("captureDevice was unexpectedly nil") - return - } - - // we might want this to be non-linear - let scale = CGFloatLerp(self.minimumZoom, self.maximumZoom, alpha) - let zoomFactor = self.clampZoom(scale, device: captureDevice) - self.updateZoom(factor: zoomFactor) + private lazy var minVisibleVideoZoom: CGFloat = { + if availableRearCameras.contains(.ultraWide) { + return 0.5 } - } + return 1 + }() - public func updateZoom(scaleFromPreviousZoomFactor scale: CGFloat) { - sessionQueue.async { - guard let captureDevice = self.captureDevice else { - owsFailDebug("captureDevice was unexpectedly nil") - return - } + // 5x of the "zoom factor" of the camera with the longest focal length + private lazy var maximumZoomFactor: CGFloat = { + let maxVisibleZoomFactor = 5 * (rearCameraZoomFactorMap.values.max() ?? 1) + return maxVisibleZoomFactor / rearCameraZoomFactorMultiplier + }() - let zoomFactor = self.clampZoom(scale * self.previousZoomFactor, device: captureDevice) - self.updateZoom(factor: zoomFactor) + private(set) lazy var rearCameraZoomFactorMap: [CameraType: CGFloat] = { + let zoomFactors = captureOutput.imageOutput.rearCameraSwitchOverZoomFactors + let avTypes = captureOutput.imageOutput.availableRearDeviceTypes + + var cameraMap: [CameraType: CGFloat] = [:] + if #available(iOS 13, *), avTypes.contains(.builtInUltraWideCamera) { + owsAssertDebug(rearCameraZoomFactorMultiplier != 1, "rearCameraZoomFactorMultiplier should not be 1") + cameraMap[.ultraWide] = rearCameraZoomFactorMultiplier } - } - - public func completeZoom(scaleFromPreviousZoomFactor scale: CGFloat) { - sessionQueue.async { - guard let captureDevice = self.captureDevice else { - owsFailDebug("captureDevice was unexpectedly nil") - return - } - - let zoomFactor = self.clampZoom(scale * self.previousZoomFactor, device: captureDevice) - - Logger.debug("ended with scaleFactor: \(zoomFactor)") - - self.previousZoomFactor = zoomFactor - self.updateZoom(factor: zoomFactor) + if avTypes.contains(.builtInTelephotoCamera), let lastZoomFactor = zoomFactors.last { + cameraMap[.telephoto] = rearCameraZoomFactorMultiplier * CGFloat(lastZoomFactor.floatValue) } - } + if !Platform.isSimulator { + owsAssertDebug(avTypes.contains(.builtInWideAngleCamera)) + } + cameraMap[.wideAngle] = 1 // wide angle is the default camera used with 1x zoom. - private func updateZoom(factor: CGFloat) { - assertIsOnSessionQueue() + return cameraMap + }() - guard let captureDevice = self.captureDevice else { - owsFailDebug("captureDevice was unexpectedly nil") + // If device has an ultra-wide camera then API zoom factor of "1" means + // full FOV of the ultra-wide camera which is "0.5" in the UI. + private(set) lazy var rearCameraZoomFactorMultiplier: CGFloat = { + if availableRearCameras.contains(.ultraWide) { + return 0.5 + } + return 1 + }() + + func switchRearCamera(to camera: CameraType, animated: Bool) { + AssertIsOnMainThread() + + guard let visibleZoomFactor = rearCameraZoomFactorMap[camera] else { + owsFailDebug("Requested an unsupported device type") return } - do { - try captureDevice.lockForConfiguration() - captureDevice.videoZoomFactor = factor - captureDevice.unlockForConfiguration() - } catch { - owsFailDebug("error: \(error)") + let zoomFactor = visibleZoomFactor / rearCameraZoomFactorMultiplier + sessionQueue.async { [weak self] in + guard let self = self else { return } + guard let captureDevice = self.captureDevice else { + owsFailDebug("captureDevice was unexpectedly nil") + return + } + + self.update(captureDevice: captureDevice, zoomFactor: zoomFactor, animated: animated) } } - private func clampZoom(_ factor: CGFloat, device: AVCaptureDevice) -> CGFloat { - return min(factor.clamp(minimumZoom, maximumZoom), device.activeFormat.videoMaxZoomFactor) + private func resetRearCameraZoomFactor(_ captureDevice: AVCaptureDevice) { + assertIsOnSessionQueue() + + guard let defaultZoomFactor = rearCameraZoomFactorMap[.wideAngle] else { + owsFailDebug("Requested an unsupported device type") + return + } + + let zoomFactor = defaultZoomFactor / rearCameraZoomFactorMultiplier + update(captureDevice: captureDevice, zoomFactor: zoomFactor, animated: false) + } + + private var initialSlideZoomFactor: CGFloat? + + func updateZoom(alpha: CGFloat) { + owsAssertDebug(alpha >= 0 && alpha <= 1) + sessionQueue.async { [weak self] in + guard let self = self else { return } + guard let captureDevice = self.captureDevice else { + owsFailDebug("captureDevice was unexpectedly nil") + return + } + + let zoomFactor = CGFloatLerp(self.initialSlideZoomFactor!, self.maximumZoomFactor, alpha) + self.update(captureDevice: captureDevice, zoomFactor: zoomFactor) + } + } + + private var initialPinchZoomFactor: CGFloat = 1.0 + + func beginPinchZoom() { + sessionQueue.async { [weak self] in + guard let self = self else { return } + guard let captureDevice = self.captureDevice else { + owsFailDebug("captureDevice was unexpectedly nil") + return + } + + self.initialPinchZoomFactor = captureDevice.videoZoomFactor + Logger.debug("began pinch zoom with factor: \(self.initialPinchZoomFactor)") + } + } + + func updatePinchZoom(withScale scale: CGFloat) { + sessionQueue.async { [weak self] in + guard let self = self else { return } + guard let captureDevice = self.captureDevice else { + owsFailDebug("captureDevice was unexpectedly nil") + return + } + + let zoomFactor = scale * self.initialPinchZoomFactor + self.update(captureDevice: captureDevice, zoomFactor: zoomFactor) + } + } + + func completePinchZoom(withScale scale: CGFloat) { + sessionQueue.async { [weak self] in + guard let self = self else { return } + guard let captureDevice = self.captureDevice else { + owsFailDebug("captureDevice was unexpectedly nil") + return + } + + let zoomFactor = scale * self.initialPinchZoomFactor + self.update(captureDevice: captureDevice, zoomFactor: zoomFactor) + Logger.debug("ended pitch zoom with factor: \(zoomFactor)") + } + } + + private func update(captureDevice: AVCaptureDevice, zoomFactor: CGFloat, animated: Bool = false) { + assertIsOnSessionQueue() + + do { + try captureDevice.lockForConfiguration() + + let minimumZoomFactor = minVisibleVideoZoom / rearCameraZoomFactorMultiplier + let clampedZoomFactor = min(zoomFactor.clamp(minimumZoomFactor, maximumZoomFactor), captureDevice.activeFormat.videoMaxZoomFactor) + if animated { + captureDevice.ramp(toVideoZoomFactor: clampedZoomFactor, withRate: 16) + } else { + captureDevice.videoZoomFactor = clampedZoomFactor + } + + captureDevice.unlockForConfiguration() + + let visibleZoomFactor = clampedZoomFactor * rearCameraZoomFactorMultiplier + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.delegate?.photoCapture(self, didChangeVideoZoomFactor: visibleZoomFactor) + } + } catch { + owsFailDebug("error: \(error)") + } } // MARK: - Photo @@ -678,6 +845,9 @@ extension PhotoCapture: CameraCaptureControlDelegate { } func cameraCaptureControlDidRequestStartVideoRecording(_ control: CameraCaptureControl) { + if let captureDevice = captureDevice { + self.initialSlideZoomFactor = captureDevice.videoZoomFactor + } beginMovieCapture() } @@ -698,6 +868,7 @@ extension PhotoCapture: CameraCaptureControlDelegate { } func cameraCaptureControl(_ control: CameraCaptureControl, didUpdateZoomLevel zoomLevel: CGFloat) { + owsAssertDebug(initialSlideZoomFactor != nil, "initialSlideZoomFactor is not set") updateZoom(alpha: zoomLevel) } } @@ -798,10 +969,11 @@ protocol CaptureOutputDelegate: AnyObject { // MARK: - protocol ImageCaptureOutput: AnyObject { + var availableRearDeviceTypes: [AVCaptureDevice.DeviceType] { get } + var rearCameraSwitchOverZoomFactors: [NSNumber] { get } var avOutput: AVCaptureOutput { get } var flashMode: AVCaptureDevice.FlashMode { get set } - func videoDevice(position: AVCaptureDevice.Position) -> AVCaptureDevice? - + func videoDevice(for deviceType: AVCaptureDevice.DeviceType, position: AVCaptureDevice.Position) -> AVCaptureDevice? func takePhoto(delegate: CaptureOutputDelegate, captureRect: CGRect) } @@ -891,8 +1063,8 @@ class CaptureOutput: NSObject { set { imageOutput.flashMode = newValue } } - func videoDevice(position: AVCaptureDevice.Position) -> AVCaptureDevice? { - return imageOutput.videoDevice(position: position) + func videoDevice(for deviceType: AVCaptureDevice.DeviceType, position: AVCaptureDevice.Position) -> AVCaptureDevice? { + return imageOutput.videoDevice(for: deviceType, position: position) } func takePhoto(delegate: CaptureOutputDelegate, captureRect: CGRect) { @@ -1135,6 +1307,35 @@ class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput { return photoOutput } + private lazy var availableRearDeviceMap: [AVCaptureDevice.DeviceType: AVCaptureDevice] = { + return availableDevices(forPosition: .back) + }() + + private lazy var availableFrontDeviceMap: [AVCaptureDevice.DeviceType: AVCaptureDevice] = { + return availableDevices(forPosition: .front) + }() + + var availableRearDeviceTypes: [AVCaptureDevice.DeviceType] { + return availableRearDeviceMap.keys.compactMap { $0 } + } + + lazy var rearCameraSwitchOverZoomFactors: [NSNumber] = { + let deviceMap = availableRearDeviceMap + + guard #available(iOS 13, *) else { + // No iOS 12 device can have triple camera system. + if deviceMap[.builtInDualCamera] != nil { + return UIDevice.current.isPlusSizePhone ? [ 2.5 ] : [ 2 ] + } + return [] + } + + if let multiCameraDevice = deviceMap[.builtInTripleCamera] ?? deviceMap[.builtInDualCamera] { + return multiCameraDevice.virtualDeviceSwitchOverVideoZoomFactors + } + return [] + }() + var flashMode: AVCaptureDevice.FlashMode = .off override init() { @@ -1156,9 +1357,16 @@ class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput { photoOutput.capturePhoto(with: settings, delegate: photoProcessor) } - func videoDevice(position: AVCaptureDevice.Position) -> AVCaptureDevice? { - // use dual camera where available - return AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) + func videoDevice(for deviceType: AVCaptureDevice.DeviceType, position: AVCaptureDevice.Position) -> AVCaptureDevice? { + switch position { + case .back: + return availableRearDeviceMap[deviceType] + case .front: + return availableFrontDeviceMap[deviceType] + default: + owsFailDebug("Requested invalid camera position") + return nil + } } // MARK: - @@ -1174,6 +1382,18 @@ class PhotoCaptureOutputAdaptee: NSObject, ImageCaptureOutput { return photoSettings } + private func availableDevices(forPosition position: AVCaptureDevice.Position) -> [AVCaptureDevice.DeviceType: AVCaptureDevice] { + var queryDeviceTypes: [AVCaptureDevice.DeviceType] = [ .builtInWideAngleCamera, .builtInTelephotoCamera, .builtInDualCamera ] + if #available(iOS 13, *) { + queryDeviceTypes.append(contentsOf: [ .builtInUltraWideCamera, .builtInTripleCamera ]) + } + let session = AVCaptureDevice.DiscoverySession(deviceTypes: queryDeviceTypes, mediaType: .video, position: position) + let deviceMap = session.devices.reduce(into: [AVCaptureDevice.DeviceType: AVCaptureDevice]()) { deviceMap, device in + deviceMap[device.deviceType] = device + } + return deviceMap + } + private class PhotoProcessor: NSObject, AVCapturePhotoCaptureDelegate { weak var delegate: CaptureOutputDelegate? let captureRect: CGRect diff --git a/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift b/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift index a485dacf5e..2bb990297c 100644 --- a/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift +++ b/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift @@ -251,6 +251,10 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate private let bottomBar = BottomBar(frame: .zero) private var bottomBarVerticalPositionConstraint: NSLayoutConstraint! + private var cameraZoomControl: CameraZoomSelectionControl? + private var cameraZoomControlIPhoneConstraints: [NSLayoutConstraint]? + private var cameraZoomControlIPadConstraints: [NSLayoutConstraint]? + private var sideBar: SideBar? // Optional because most devices are iPhones and will never need this. private lazy var tapToFocusView: AnimationView = { @@ -309,6 +313,22 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate view.addConstraint(bottomBarVerticalPositionConstraint) } + let availableRearCameras = photoCapture.rearCameraZoomFactorMap + if availableRearCameras.count > 0 { + let cameras = availableRearCameras.sorted { $0.0 < $1.0 }.map { ($0.0, $0.1) } + + let cameraZoomControl = CameraZoomSelectionControl(availableCameras: cameras) + cameraZoomControl.delegate = self + view.addSubview(cameraZoomControl) + self.cameraZoomControl = cameraZoomControl + + let cameraZoomControlConstraints = + [ cameraZoomControl.centerXAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.centerXAnchor), + cameraZoomControl.bottomAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.topAnchor, constant: -32) ] + view.addConstraints(cameraZoomControlConstraints) + self.cameraZoomControlIPhoneConstraints = cameraZoomControlConstraints + } + view.addSubview(doneButton) doneButton.isHidden = true doneButton.translatesAutoresizingMaskIntoConstraints = false @@ -359,6 +379,12 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate doneButtonIPadConstraints = [ doneButton.centerXAnchor.constraint(equalTo: sideBar.centerXAnchor), doneButton.bottomAnchor.constraint(equalTo: sideBar.topAnchor, constant: -16)] + + if let cameraZoomControl = cameraZoomControl { + let constraints = [ cameraZoomControl.centerYAnchor.constraint(equalTo: sideBar.cameraCaptureControl.shutterButtonLayoutGuide.centerYAnchor), + cameraZoomControl.trailingAnchor.constraint(equalTo: sideBar.cameraCaptureControl.shutterButtonLayoutGuide.leadingAnchor, constant: -32)] + cameraZoomControlIPadConstraints = constraints + } } override func viewDidLayoutSubviews() { @@ -402,6 +428,21 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate view.addConstraints(doneButtonIPhoneConstraints) } + if let cameraZoomControl = cameraZoomControl { + cameraZoomControl.axis = isIPadUIInRegularMode ? .vertical : .horizontal + + if let iPhoneConstraints = cameraZoomControlIPhoneConstraints, + let iPadConstraints = cameraZoomControlIPadConstraints { + if isIPadUIInRegularMode { + view.removeConstraints(iPhoneConstraints) + view.addConstraints(iPadConstraints) + } else { + view.removeConstraints(iPadConstraints) + view.addConstraints(iPhoneConstraints) + } + } + } + if !isRecordingVideo { topBar.mode = isIPadUIInRegularMode ? .closeButton : .cameraControls } @@ -481,6 +522,10 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate photoCapture.switchCamera().catch { error in self.showFailureUI(error: error) } + + if let cameraZoomControl = cameraZoomControl { + cameraZoomControl.isHidden = photoCapture.desiredPosition != .back + } } @objc @@ -515,10 +560,13 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate @objc func didPinchZoom(pinchGesture: UIPinchGestureRecognizer) { switch pinchGesture.state { - case .began, .changed: - photoCapture.updateZoom(scaleFromPreviousZoomFactor: pinchGesture.scale) + case .began: + photoCapture.beginPinchZoom() + fallthrough + case .changed: + photoCapture.updatePinchZoom(withScale: pinchGesture.scale) case .ended: - photoCapture.completeZoom(scaleFromPreviousZoomFactor: pinchGesture.scale) + photoCapture.completePinchZoom(withScale: pinchGesture.scale) default: break } @@ -694,6 +742,13 @@ class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate } } +extension PhotoCaptureViewController: CameraZoomSelectionControlDelegate { + + fileprivate func cameraZoomControl(_ cameraZoomControl: CameraZoomSelectionControl, didSelect camera: PhotoCapture.CameraType) { + photoCapture.switchRearCamera(to: camera, animated: true) + } +} + private struct ButtonImages { static let close = UIImage(named: "media-composer-close") static let switchCamera = UIImage(named: "media-composer-switch-camera-24") @@ -971,6 +1026,11 @@ extension PhotoCaptureViewController: PhotoCaptureDelegate { return view.bounds.height } + func photoCapture(_ photoCapture: PhotoCapture, didChangeVideoZoomFactor zoomFactor: CGFloat) { + guard let cameraZoomControl = cameraZoomControl else { return } + cameraZoomControl.currentZoomFactor = zoomFactor + } + func beginCaptureButtonAnimation(_ duration: TimeInterval) { bottomBar.captureControl.setState(.recording, animationDuration: duration) if let sideBar = sideBar { @@ -1203,6 +1263,225 @@ private class RecordingTimerView: PillView { } } +private protocol CameraZoomSelectionControlDelegate: AnyObject { + func cameraZoomControl(_ cameraZoomControl: CameraZoomSelectionControl, didSelect camera: PhotoCapture.CameraType) +} + +private class CameraZoomSelectionControl: PillView { + + weak var delegate: CameraZoomSelectionControlDelegate? + + var selectedCamera: PhotoCapture.CameraType + var currentZoomFactor: CGFloat { + didSet { + var viewFound = false + for selectionView in selectionViews.reversed() { + if currentZoomFactor >= selectionView.defaultZoomFactor && !viewFound { + selectionView.isSelected = true + selectionView.currentZoomFactor = currentZoomFactor + selectionView.update(animated: true) + viewFound = true + } else if selectionView.isSelected { + selectionView.isSelected = false + selectionView.update(animated: true) + } + } + } + } + + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.spacing = 2 + stackView.axis = UIDevice.current.isIPad ? .vertical : .horizontal + stackView.preservesSuperviewLayoutMargins = true + stackView.isLayoutMarginsRelativeArrangement = true + return stackView + }() + private let selectionViews: [CameraSelectionCircleView] + + var axis: NSLayoutConstraint.Axis { + get { + stackView.axis + } + set { + stackView.axis = newValue + } + } + + required init(availableCameras: [(PhotoCapture.CameraType, CGFloat)]) { + owsAssertDebug(!availableCameras.isEmpty, "availableCameras must not be empty.") + + let (wideAngleCamera, wideAngleCameraZoomFactor) = availableCameras.first(where: { $0.0 == .wideAngle }) ?? availableCameras.first! + selectedCamera = wideAngleCamera + currentZoomFactor = wideAngleCameraZoomFactor + + selectionViews = availableCameras.map { (camera, zoomFactor) in + return CameraSelectionCircleView(camera: camera, defaultZoomFactor: zoomFactor) + } + + super.init(frame: .zero) + + backgroundColor = .ows_blackAlpha20 + layoutMargins = UIEdgeInsets(margin: 2) + + selectionViews.forEach { view in + view.isSelected = view.camera == selectedCamera + view.autoSetDimensions(to: .square(38)) + view.update(animated: false) + } + stackView.addArrangedSubviews(selectionViews) + addSubview(stackView) + stackView.autoPinEdgesToSuperviewEdges() + + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap(gesture:))) + addGestureRecognizer(tapGestureRecognizer) + } + + @available(*, unavailable, message: "Use init(availableCameras:) instead") + required init?(coder: NSCoder) { + notImplemented() + } + + // MARK: - Selection + + @objc + public func handleTap(gesture: UITapGestureRecognizer) { + guard gesture.state == .ended else { return } + + var tappedView: CameraSelectionCircleView? + for selectionView in selectionViews { + if selectionView.point(inside: gesture.location(in: selectionView), with: nil) { + tappedView = selectionView + break + } + } + + if let selectedView = tappedView { + selectionViews.forEach { view in + if view.isSelected && view != selectedView { + view.isSelected = false + view.update(animated: true) + } else if view == selectedView { + view.isSelected = true + view.update(animated: true) + } + } + selectedCamera = selectedView.camera + delegate?.cameraZoomControl(self, didSelect: selectedCamera) + } + } + + private class CameraSelectionCircleView: UIView { + + let camera: PhotoCapture.CameraType + let defaultZoomFactor: CGFloat + var currentZoomFactor: CGFloat = 1 + + private let circleView: CircleView = { + let circleView = CircleView() + circleView.backgroundColor = .ows_blackAlpha60 + return circleView + }() + + private let textLabel: UILabel = { + let label = UILabel() + label.textAlignment = .center + label.textColor = .ows_white + label.font = .ows_semiboldFont(withSize: 11) + return label + }() + + required init(camera: PhotoCapture.CameraType, defaultZoomFactor: CGFloat) { + self.camera = camera + self.defaultZoomFactor = defaultZoomFactor + self.currentZoomFactor = defaultZoomFactor + + super.init(frame: .zero) + + addSubview(circleView) + addSubview(textLabel) + textLabel.autoPinEdgesToSuperviewEdges() + } + + @available(*, unavailable, message: "Use init(frame:) instead") + required init?(coder: NSCoder) { + notImplemented() + } + + override func layoutSubviews() { + super.layoutSubviews() + circleView.bounds = CGRect(origin: .zero, size: CGSize(square: circleDiameter)) + circleView.center = bounds.center + } + + var isSelected: Bool = false { + didSet { + if !isSelected { + currentZoomFactor = defaultZoomFactor + } + } + } + + private var circleDiameter: CGFloat { + let circleDiameter = isSelected ? bounds.width : bounds.width * 24 / 38 + return ceil(circleDiameter) + } + + private static let numberFormatterNormal: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + numberFormatter.minimumIntegerDigits = 0 + numberFormatter.maximumFractionDigits = 1 + return numberFormatter + }() + + private static let numberFormatterSelected: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + numberFormatter.minimumIntegerDigits = 1 + numberFormatter.maximumFractionDigits = 1 + return numberFormatter + }() + + private class func cameraLabel(forZoomFactor zoomFactor: CGFloat, isSelected: Bool) -> String { + let numberFormatter = isSelected ? numberFormatterSelected : numberFormatterNormal + guard var scaleString = numberFormatter.string(for: zoomFactor) else { + return "" + } + if isSelected { + scaleString.append("×") + } + return scaleString + } + + static private let animationDuration: TimeInterval = 0.2 + func update(animated: Bool) { + textLabel.text = Self.cameraLabel(forZoomFactor: currentZoomFactor, isSelected: isSelected) + + let animations = { + if self.isSelected { + self.textLabel.layer.transform = CATransform3DMakeScale(1.2, 1.2, 1) + } else { + self.textLabel.layer.transform = CATransform3DIdentity + } + + self.setNeedsLayout() + self.layoutIfNeeded() + } + + if animated { + UIView.animate(withDuration: Self.animationDuration, + delay: 0, + options: [ .curveEaseInOut ]) { + animations() + } + } else { + animations() + } + } + } +} + private extension UIView { func embeddedInContainerView(layoutMargins: UIEdgeInsets = .zero) -> UIView {