Improvements to in-app camera's zooming UI and zooming behavior.
Make it look and behave like iOS Camera app.
This commit is contained in:
parent
578ea9204f
commit
9c16b82e34
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user