import Foundation import UIKit import Display import ComponentFlow import SwiftSignalKit import Photos import LocalMediaResources import CameraButtonComponent enum ShutterButtonState: Equatable { case disabled case generic case video case stopRecording case holdRecording(progress: Float) case transition } private let maximumShutterSize = CGSize(width: 96.0, height: 96.0) private extension SimpleShapeLayer { func animateStrokeStart(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: Bool = true, completion: ((Bool) -> ())? = nil) { self.animate(from: NSNumber(value: Float(from)), to: NSNumber(value: Float(to)), keyPath: "strokeStart", timingFunction: timingFunction, duration: duration, delay: delay, removeOnCompletion: removeOnCompletion, completion: completion) } func animateStrokeEnd(from: CGFloat, to: CGFloat, duration: Double, delay: Double = 0.0, timingFunction: String = CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: Bool = true, completion: ((Bool) -> ())? = nil) { self.animate(from: NSNumber(value: Float(from)), to: NSNumber(value: Float(to)), keyPath: "strokeEnd", timingFunction: timingFunction, duration: duration, delay: delay, removeOnCompletion: removeOnCompletion, completion: completion) } } private final class ShutterButtonContentComponent: Component { let isTablet: Bool let hasAppeared: Bool let shutterState: ShutterButtonState let blobState: ShutterBlobView.BlobState let highlightedAction: ActionSlot let updateOffsetX: ActionSlot<(CGFloat, Transition)> let updateOffsetY: ActionSlot<(CGFloat, Transition)> init( isTablet: Bool, hasAppeared: Bool, shutterState: ShutterButtonState, blobState: ShutterBlobView.BlobState, highlightedAction: ActionSlot, updateOffsetX: ActionSlot<(CGFloat, Transition)>, updateOffsetY: ActionSlot<(CGFloat, Transition)> ) { self.isTablet = isTablet self.hasAppeared = hasAppeared self.shutterState = shutterState self.blobState = blobState self.highlightedAction = highlightedAction self.updateOffsetX = updateOffsetX self.updateOffsetY = updateOffsetY } static func ==(lhs: ShutterButtonContentComponent, rhs: ShutterButtonContentComponent) -> Bool { if lhs.isTablet != rhs.isTablet { return false } if lhs.hasAppeared != rhs.hasAppeared { return false } if lhs.shutterState != rhs.shutterState { return false } if lhs.blobState != rhs.blobState { return false } return true } final class View: UIView { private var component: ShutterButtonContentComponent? private let ringLayer = SimpleShapeLayer() var blobView: ShutterBlobView? private let innerLayer = SimpleShapeLayer() private let progressLayer = SimpleShapeLayer() init() { super.init(frame: CGRect()) self.layer.allowsGroupOpacity = true self.progressLayer.strokeEnd = 0.0 self.layer.addSublayer(self.innerLayer) self.layer.addSublayer(self.ringLayer) self.layer.addSublayer(self.progressLayer) } required init?(coder aDecoder: NSCoder) { preconditionFailure() } func updateIsHighlighted(_ isHighlighted: Bool) { guard let blobView = self.blobView else { return } let scale: CGFloat = isHighlighted ? 0.8 : 1.0 let transition = Transition(animation: .curve(duration: 0.3, curve: .easeInOut)) transition.setTransform(view: blobView, transform: CATransform3DMakeScale(scale, scale, 1.0)) } func update(component: ShutterButtonContentComponent, availableSize: CGSize, transition: Transition) -> CGSize { self.component = component if component.hasAppeared && self.blobView == nil { self.blobView = ShutterBlobView(test: false) self.addSubview(self.blobView!) Queue.mainQueue().after(0.1) { self.innerLayer.removeFromSuperlayer() } } component.highlightedAction.connect { [weak self] highlighted in self?.updateIsHighlighted(highlighted) } func rubberBandingOffset(offset: CGFloat, bandingStart: CGFloat) -> CGFloat { let bandedOffset = offset - bandingStart let range: CGFloat = 60.0 let coefficient: CGFloat = 0.1 return bandingStart + (1.0 - (1.0 / ((bandedOffset * coefficient / range) + 1.0))) * range } component.updateOffsetX.connect { [weak self] offset, transition in if let self, let blobView = self.blobView { blobView.updateSecondaryOffsetX(offset, transition: transition) if abs(offset) < 60.0 { var bandedOffset = rubberBandingOffset(offset: abs(offset), bandingStart: 0.0) if offset < 0.0 { bandedOffset *= -1.0 } blobView.updatePrimaryOffsetX(bandedOffset, transition: transition) } else { blobView.updatePrimaryOffsetX(0.0, transition: .spring(duration: 0.2)) } } } component.updateOffsetY.connect { [weak self] offset, transition in if let self, let blobView = self.blobView { blobView.updateSecondaryOffsetY(offset, transition: transition) if abs(offset) < 60.0 { var bandedOffset = rubberBandingOffset(offset: abs(offset), bandingStart: 0.0) if offset < 0.0 { bandedOffset *= -1.0 } blobView.updatePrimaryOffsetY(bandedOffset, transition: transition) } else { blobView.updatePrimaryOffsetY(0.0, transition: .spring(duration: 0.2)) } } } let innerColor: UIColor let innerSize: CGSize let ringSize: CGSize let ringWidth: CGFloat = 3.0 var recordingProgress: Float? switch component.shutterState { case .generic, .disabled: innerColor = .white innerSize = CGSize(width: 60.0, height: 60.0) ringSize = CGSize(width: 68.0, height: 68.0) case .video: innerColor = videoRedColor innerSize = CGSize(width: 60.0, height: 60.0) ringSize = CGSize(width: 68.0, height: 68.0) case .stopRecording: innerColor = videoRedColor innerSize = CGSize(width: 26.0, height: 26.0) ringSize = CGSize(width: 68.0, height: 68.0) case let .holdRecording(progress): innerColor = videoRedColor innerSize = CGSize(width: 60.0, height: 60.0) ringSize = CGSize(width: 92.0, height: 92.0) recordingProgress = progress case .transition: innerColor = videoRedColor innerSize = CGSize(width: 60.0, height: 60.0) ringSize = CGSize(width: 68.0, height: 68.0) recordingProgress = 0.0 } self.ringLayer.fillColor = UIColor.clear.cgColor self.ringLayer.strokeColor = UIColor.white.cgColor self.ringLayer.lineWidth = ringWidth let ringPath = CGPath( ellipseIn: CGRect( origin: CGPoint( x: (maximumShutterSize.width - ringSize.width) / 2.0, y: (maximumShutterSize.height - ringSize.height) / 2.0), size: ringSize ), transform: nil ) transition.setShapeLayerPath(layer: self.ringLayer, path: ringPath) self.ringLayer.bounds = CGRect(origin: .zero, size: maximumShutterSize) self.ringLayer.position = CGPoint(x: maximumShutterSize.width / 2.0, y: maximumShutterSize.height / 2.0) if let blobView = self.blobView { blobView.updateState(component.blobState, transition: transition) if component.isTablet { blobView.bounds = CGRect(origin: .zero, size: CGSize(width: maximumShutterSize.width, height: 440.0)) } else { blobView.bounds = CGRect(origin: .zero, size: CGSize(width: availableSize.width, height: maximumShutterSize.height)) } blobView.center = CGPoint(x: maximumShutterSize.width / 2.0, y: maximumShutterSize.height / 2.0) } self.innerLayer.backgroundColor = innerColor.cgColor self.innerLayer.cornerRadius = innerSize.width / 2.0 self.innerLayer.bounds = CGRect(origin: .zero, size: innerSize) self.innerLayer.position = CGPoint(x: maximumShutterSize.width / 2.0, y: maximumShutterSize.height / 2.0) self.progressLayer.bounds = CGRect(origin: .zero, size: maximumShutterSize) self.progressLayer.position = CGPoint(x: maximumShutterSize.width / 2.0, y: maximumShutterSize.height / 2.0) transition.setShapeLayerPath(layer: self.progressLayer, path: ringPath) self.progressLayer.fillColor = UIColor.clear.cgColor self.progressLayer.strokeColor = videoRedColor.cgColor self.progressLayer.lineWidth = ringWidth + UIScreenPixel self.progressLayer.lineCap = .round self.progressLayer.transform = CATransform3DMakeRotation(-.pi / 2.0, 0.0, 0.0, 1.0) let previousValue = self.progressLayer.strokeEnd self.progressLayer.strokeEnd = CGFloat(recordingProgress ?? 0.0) self.progressLayer.animateStrokeEnd(from: previousValue, to: self.progressLayer.strokeEnd, duration: 0.33) return maximumShutterSize } } func makeView() -> View { return View() } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, transition: transition) } } final class FlipButtonContentComponent: Component { private let action: ActionSlot private let maskFrame: CGRect init(action: ActionSlot, maskFrame: CGRect) { self.action = action self.maskFrame = maskFrame } static func ==(lhs: FlipButtonContentComponent, rhs: FlipButtonContentComponent) -> Bool { return lhs.maskFrame == rhs.maskFrame } final class View: UIView { private var component: FlipButtonContentComponent? private let icon = SimpleLayer() let maskContainerView = UIView() private let maskLayer = SimpleLayer() private let darkIcon = SimpleLayer() init() { super.init(frame: CGRect()) self.layer.addSublayer(self.icon) self.maskContainerView.isUserInteractionEnabled = false self.maskContainerView.clipsToBounds = true self.maskContainerView.layer.addSublayer(self.maskLayer) self.maskLayer.addSublayer(self.darkIcon) self.maskLayer.masksToBounds = true self.maskLayer.cornerRadius = 16.0 self.icon.contents = UIImage(bundleImageName: "Camera/FlipIcon")?.cgImage self.darkIcon.contents = UIImage(bundleImageName: "Camera/FlipIcon")?.cgImage self.darkIcon.layerTintColor = UIColor.black.cgColor } required init?(coder aDecoder: NSCoder) { preconditionFailure() } func playAnimation() { let animation = CASpringAnimation(keyPath: "transform.rotation.z") animation.fromValue = 0.0 as NSNumber animation.toValue = CGFloat.pi as NSNumber animation.mass = 5.0 animation.stiffness = 900.0 animation.damping = 90.0 animation.duration = animation.settlingDuration if #available(iOS 15.0, *) { let maxFps = Float(UIScreen.main.maximumFramesPerSecond) animation.preferredFrameRateRange = CAFrameRateRange(minimum: 30.0, maximum: maxFps, preferred: maxFps) } self.icon.add(animation, forKey: "transform.rotation.z") let darkAnimation = CASpringAnimation(keyPath: "transform.rotation.z") darkAnimation.fromValue = 0.0 as NSNumber darkAnimation.toValue = CGFloat.pi as NSNumber darkAnimation.mass = 5.0 darkAnimation.stiffness = 900.0 darkAnimation.damping = 90.0 darkAnimation.duration = darkAnimation.settlingDuration if #available(iOS 15.0, *) { let maxFps = Float(UIScreen.main.maximumFramesPerSecond) darkAnimation.preferredFrameRateRange = CAFrameRateRange(minimum: 30.0, maximum: maxFps, preferred: maxFps) } self.darkIcon.add(darkAnimation, forKey: "transform.rotation.z") } func update(component: FlipButtonContentComponent, availableSize: CGSize, transition: Transition) -> CGSize { self.component = component component.action.connect { [weak self] _ in self?.playAnimation() } let size = CGSize(width: 48.0, height: 48.0) self.icon.position = CGPoint(x: size.width / 2.0, y: size.height / 2.0) self.icon.bounds = CGRect(origin: .zero, size: size) transition.setFrame(layer: self.maskLayer, frame: component.maskFrame) self.darkIcon.bounds = CGRect(origin: .zero, size: size) transition.setPosition(layer: self.darkIcon, position: CGPoint(x: -component.maskFrame.minX + size.width / 2.0, y: -component.maskFrame.minY + size.height / 2.0)) return size } } func makeView() -> View { return View() } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, transition: transition) } } final class LockContentComponent: Component { private let maskFrame: CGRect init(maskFrame: CGRect) { self.maskFrame = maskFrame } static func ==(lhs: LockContentComponent, rhs: LockContentComponent) -> Bool { return lhs.maskFrame == rhs.maskFrame } final class View: UIView { private var component: LockContentComponent? private let icon = SimpleLayer() let maskContainerView = UIView() private let maskLayer = SimpleLayer() private let darkIcon = SimpleLayer() init() { super.init(frame: CGRect()) self.layer.addSublayer(self.icon) self.maskContainerView.isUserInteractionEnabled = false self.maskContainerView.clipsToBounds = true self.maskContainerView.bounds = CGRect(origin: .zero, size: CGSize(width: 30.0, height: 30.0)) self.maskContainerView.layer.addSublayer(self.maskLayer) self.maskLayer.addSublayer(self.darkIcon) self.maskLayer.masksToBounds = true self.maskLayer.cornerRadius = 24.0 self.icon.contents = UIImage(bundleImageName: "Camera/LockIcon")?.cgImage self.darkIcon.contents = UIImage(bundleImageName: "Camera/LockedIcon")?.cgImage self.darkIcon.layerTintColor = UIColor.black.cgColor } required init?(coder aDecoder: NSCoder) { preconditionFailure() } func update(component: LockContentComponent, availableSize: CGSize, transition: Transition) -> CGSize { self.component = component let size = CGSize(width: 30.0, height: 30.0) self.icon.position = CGPoint(x: size.width / 2.0, y: size.height / 2.0) self.icon.bounds = CGRect(origin: .zero, size: size) transition.setFrame(layer: self.maskLayer, frame: component.maskFrame) self.darkIcon.bounds = CGRect(origin: .zero, size: size) transition.setPosition(layer: self.darkIcon, position: CGPoint(x: -component.maskFrame.minX + size.width / 2.0, y: -component.maskFrame.minY + size.height / 2.0)) return size } } func makeView() -> View { return View() } func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, transition: transition) } } private func lastStateImage() -> UIImage { let imagePath = NSTemporaryDirectory() + "galleryImage.jpg" if let data = try? Data(contentsOf: URL(fileURLWithPath: imagePath)), let image = UIImage(data: data) { return image } else { return UIImage(bundleImageName: "Camera/Placeholder")! } } private func saveLastStateImage(_ image: UIImage) { let imagePath = NSTemporaryDirectory() + "galleryImage.jpg" if let data = image.jpegData(compressionQuality: 0.6) { try? data.write(to: URL(fileURLWithPath: imagePath)) } } final class CaptureControlsComponent: Component { enum SwipeHint { case none case zoom case lock case releaseLock case flip } let isTablet: Bool let hasAppeared: Bool let shutterState: ShutterButtonState let lastGalleryAsset: PHAsset? let tag: AnyObject? let galleryButtonTag: AnyObject? let shutterTapped: () -> Void let shutterPressed: () -> Void let shutterReleased: () -> Void let lockRecording: () -> Void let flipTapped: () -> Void let galleryTapped: () -> Void let swipeHintUpdated: (SwipeHint) -> Void let zoomUpdated: (CGFloat) -> Void let flipAnimationAction: ActionSlot init( isTablet: Bool, hasAppeared: Bool, shutterState: ShutterButtonState, lastGalleryAsset: PHAsset?, tag: AnyObject?, galleryButtonTag: AnyObject?, shutterTapped: @escaping () -> Void, shutterPressed: @escaping () -> Void, shutterReleased: @escaping () -> Void, lockRecording: @escaping () -> Void, flipTapped: @escaping () -> Void, galleryTapped: @escaping () -> Void, swipeHintUpdated: @escaping (SwipeHint) -> Void, zoomUpdated: @escaping (CGFloat) -> Void, flipAnimationAction: ActionSlot ) { self.isTablet = isTablet self.hasAppeared = hasAppeared self.shutterState = shutterState self.lastGalleryAsset = lastGalleryAsset self.tag = tag self.galleryButtonTag = galleryButtonTag self.shutterTapped = shutterTapped self.shutterPressed = shutterPressed self.shutterReleased = shutterReleased self.lockRecording = lockRecording self.flipTapped = flipTapped self.galleryTapped = galleryTapped self.swipeHintUpdated = swipeHintUpdated self.zoomUpdated = zoomUpdated self.flipAnimationAction = flipAnimationAction } static func ==(lhs: CaptureControlsComponent, rhs: CaptureControlsComponent) -> Bool { if lhs.isTablet != rhs.isTablet { return false } if lhs.hasAppeared != rhs.hasAppeared { return false } if lhs.shutterState != rhs.shutterState { return false } if lhs.lastGalleryAsset?.localIdentifier != rhs.lastGalleryAsset?.localIdentifier { return false } return true } final class State: ComponentState { var cachedAssetImage: (String, UIImage)? private let assetDisposable = MetaDisposable() var lastGalleryAsset: PHAsset? { didSet { if self.cachedAssetImage?.0 != self.lastGalleryAsset?.localIdentifier { if self.cachedAssetImage?.0 != "" { self.cachedAssetImage = nil } if let lastGalleryAsset = self.lastGalleryAsset { self.assetDisposable.set((fetchPhotoLibraryImage(localIdentifier: lastGalleryAsset.localIdentifier, thumbnail: true) |> deliverOnMainQueue).start(next: { [weak self] imageAndDegraded in if let self, let (image, _) = imageAndDegraded { let updated = self.cachedAssetImage?.0 != lastGalleryAsset.localIdentifier self.cachedAssetImage = (lastGalleryAsset.localIdentifier, image) self.updated(transition: .easeInOut(duration: 0.2)) if updated { saveLastStateImage(image) } } })) } } } } override init() { self.cachedAssetImage = ("", lastStateImage()) super.init() } deinit { self.assetDisposable.dispose() } } func makeState() -> State { return State() } final class View: UIView, ComponentTaggedView, UIGestureRecognizerDelegate { private var component: CaptureControlsComponent? private var state: State? private var availableSize: CGSize? private let zoomView = ComponentView() private let lockView = ComponentView() private let galleryButtonView = ComponentView() private let shutterButtonView = ComponentView() private let flipButtonView = ComponentView() private let leftGuide = SimpleLayer() private let rightGuide = SimpleLayer() private let shutterUpdateOffsetX = ActionSlot<(CGFloat, Transition)>() private let shutterUpdateOffsetY = ActionSlot<(CGFloat, Transition)>() private let shutterHightlightedAction = ActionSlot() private let lockImage = UIImage(bundleImageName: "Camera/LockIcon") private let zoomImage = UIImage(bundleImageName: "Camera/ZoomIcon") private var didFlip = false private var wasBanding: Bool? private var panBlobState: ShutterBlobView.BlobState? private let hapticFeedback = HapticFeedback() public func matches(tag: Any) -> Bool { if let component = self.component, let componentTag = component.tag { let tag = tag as AnyObject if componentTag === tag { return true } } return false } init() { super.init(frame: CGRect()) self.leftGuide.backgroundColor = UIColor(rgb: 0xffffff, alpha: 0.2).cgColor self.rightGuide.backgroundColor = UIColor(rgb: 0xffffff, alpha: 0.2).cgColor self.layer.addSublayer(self.leftGuide) self.layer.addSublayer(self.rightGuide) } required init?(coder aDecoder: NSCoder) { preconditionFailure() } @objc private func handlePress(_ gestureRecognizer: UILongPressGestureRecognizer) { guard let component = self.component else { return } let location = gestureRecognizer.location(in: self) switch gestureRecognizer.state { case .began: component.shutterPressed() component.swipeHintUpdated(.zoom) if component.isTablet { self.shutterUpdateOffsetY.invoke((0.0, .immediate)) } else { self.shutterUpdateOffsetX.invoke((0.0, .immediate)) } case .ended, .cancelled: if component.isTablet { if location.y > self.frame.height / 2.0 + 60.0 { component.lockRecording() var blobOffset: CGFloat = 0.0 if let lockView = self.lockView.view { blobOffset = lockView.center.y - self.frame.height / 2.0 } self.updateShutterOffsetY(blobOffset, transition: .spring(duration: 0.35)) Queue.mainQueue().after(0.4) { self.updateShutterOffsetY(0.0, transition: .immediate) } } else { self.hapticFeedback.impact(.light) component.shutterReleased() self.updateShutterOffsetY(0.0, transition: .spring(duration: 0.25)) } } else { if location.x < self.frame.width / 2.0 - 60.0 { component.lockRecording() var blobOffset: CGFloat = 0.0 if let galleryButton = self.galleryButtonView.view { blobOffset = galleryButton.center.x - self.frame.width / 2.0 } self.updateShutterOffsetX(blobOffset, transition: .spring(duration: 0.35)) Queue.mainQueue().after(0.4) { self.updateShutterOffsetX(0.0, transition: .immediate) } } else { self.hapticFeedback.impact(.light) component.shutterReleased() self.updateShutterOffsetX(0.0, transition: .spring(duration: 0.25)) } } default: break } } private var shutterOffsetX: CGFloat = 0.0 private var shutterOffsetY: CGFloat = 0.0 private func updateShutterOffsetX(_ offsetX: CGFloat, transition: Transition) { self.shutterOffsetX = offsetX self.shutterUpdateOffsetX.invoke((offsetX, transition)) self.state?.updated(transition: transition) } private func updateShutterOffsetY(_ offsetY: CGFloat, transition: Transition) { self.shutterOffsetY = offsetY self.shutterUpdateOffsetY.invoke((offsetY, transition)) self.state?.updated(transition: transition) } @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { guard let component = self.component else { return } func rubberBandingOffset(offset: CGFloat, bandingStart: CGFloat) -> CGFloat { let bandedOffset = offset - bandingStart let range: CGFloat = 60.0 let coefficient: CGFloat = 0.4 return bandingStart + (1.0 - (1.0 / ((bandedOffset * coefficient / range) + 1.0))) * range } var scheduledXOffsetUpdate: (CGFloat, Transition)? var scheduledYOffsetUpdate: (CGFloat, Transition)? let previousPanBlobState = self.panBlobState let location = gestureRecognizer.location(in: self) switch gestureRecognizer.state { case .changed: guard case .holdRecording = component.shutterState else { return } var blobOffset: CGFloat = 0.0 if component.isTablet { if let shutterButton = self.shutterButtonView.view, let lockView = self.lockView.view { blobOffset = max(shutterButton.center.y - 10.0, min(lockView.center.y, location.y)) } blobOffset -= self.frame.height / 2.0 var isBanding = false if location.x < -10.0 { let fraction = 1.0 + min(8.0, ((abs(location.x) - 10.0) / 60.0)) component.zoomUpdated(fraction) } else { component.zoomUpdated(1.0) } if location.y > self.frame.height / 2.0 + 30.0 { if location.y > self.frame.height / 2.0 + 60.0 { component.swipeHintUpdated(.releaseLock) if location.y > self.frame.height / 2.0 + 130.0 { self.panBlobState = .lock } else { self.panBlobState = .transientToLock } } else { component.swipeHintUpdated(.lock) self.panBlobState = .video blobOffset = rubberBandingOffset(offset: -blobOffset, bandingStart: 0.0) * -1.0 isBanding = true } } else { blobOffset = rubberBandingOffset(offset: -blobOffset, bandingStart: 0.0) * -1.0 component.swipeHintUpdated(.zoom) self.panBlobState = .video isBanding = true } var transition: Transition = .immediate if let wasBanding = self.wasBanding, wasBanding != isBanding { //self.hapticFeedback.impact(.light) transition = .spring(duration: 0.35) } self.wasBanding = isBanding scheduledYOffsetUpdate = (blobOffset, transition) } else { if let galleryButton = self.galleryButtonView.view, let flipButton = self.flipButtonView.view { blobOffset = max(galleryButton.center.x, min(flipButton.center.x, location.x)) } blobOffset -= self.frame.width / 2.0 var isBanding = false if location.y < -10.0 { let fraction = 1.0 + min(8.0, ((abs(location.y) - 10.0) / 60.0)) component.zoomUpdated(fraction) } else { component.zoomUpdated(1.0) } if location.x < self.frame.width / 2.0 - 30.0 { if location.x < self.frame.width / 2.0 - 60.0 { component.swipeHintUpdated(.releaseLock) if location.x < 85.0 { self.panBlobState = .lock } else { self.panBlobState = .transientToLock } } else { component.swipeHintUpdated(.lock) self.panBlobState = .video blobOffset = rubberBandingOffset(offset: blobOffset, bandingStart: 0.0) isBanding = true } } else if location.x > self.frame.width / 2.0 + 30.0 { self.component?.swipeHintUpdated(.flip) if location.x > self.frame.width / 2.0 + 60.0 { self.panBlobState = .transientToFlip if self.didFlip && location.x < self.frame.width - 100.0 { self.didFlip = false } if !self.didFlip && location.x > self.frame.width - 70.0 { self.didFlip = true self.hapticFeedback.impact(.light) component.flipTapped() } } else { self.didFlip = false self.panBlobState = .video blobOffset = rubberBandingOffset(offset: -blobOffset, bandingStart: 0.0) * -1.0 isBanding = true } } else { blobOffset = rubberBandingOffset(offset: blobOffset, bandingStart: 0.0) component.swipeHintUpdated(.zoom) self.panBlobState = .video isBanding = true } var transition: Transition = .immediate if let wasBanding = self.wasBanding, wasBanding != isBanding { //self.hapticFeedback.impact(.light) transition = .spring(duration: 0.35) } self.wasBanding = isBanding scheduledXOffsetUpdate = (blobOffset, transition) } default: self.panBlobState = nil self.wasBanding = nil self.didFlip = false } if previousPanBlobState != self.panBlobState, let component = self.component, let state = self.state, let availableSize = self.availableSize { let _ = self.update(component: component, state: state, availableSize: availableSize, transition: .spring(duration: 0.5)) } if let (offset, transition) = scheduledXOffsetUpdate { self.updateShutterOffsetX(offset, transition: transition) } if let (offset, transition) = scheduledYOffsetUpdate { self.updateShutterOffsetY(offset, transition: transition) } } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { return true } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return true } private var animatedOut = false func animateOutToEditor(transition: Transition) { self.animatedOut = true if let view = self.galleryButtonView.view { transition.setScale(view: view, scale: 0.1) transition.setAlpha(view: view, alpha: 0.0) } if let view = self.flipButtonView.view { transition.setScale(view: view, scale: 0.1) transition.setAlpha(view: view, alpha: 0.0) } if let view = self.shutterButtonView.view { transition.setScale(view: view, scale: 0.1) transition.setAlpha(view: view, alpha: 0.0) } } func animateInFromEditor(transition: Transition) { self.animatedOut = false if let view = self.galleryButtonView.view { transition.setScale(view: view, scale: 1.0) transition.setAlpha(view: view, alpha: 1.0) } if let view = self.flipButtonView.view { transition.setScale(view: view, scale: 1.0) transition.setAlpha(view: view, alpha: 1.0) } if let view = self.shutterButtonView.view { transition.setScale(view: view, scale: 1.0) transition.setAlpha(view: view, alpha: 1.0) } } func update(component: CaptureControlsComponent, state: State, availableSize: CGSize, transition: Transition) -> CGSize { let previousShutterState = self.component?.shutterState ?? .generic self.component = component self.state = state self.availableSize = availableSize state.lastGalleryAsset = component.lastGalleryAsset let size = component.isTablet ? availableSize : CGSize(width: availableSize.width, height: maximumShutterSize.height) let buttonSideInset: CGFloat = 28.0 //let buttonMaxOffset: CGFloat = 100.0 var isTransitioning = false var isRecording = false var isHolding = false if case .stopRecording = component.shutterState { isRecording = true } else if case .holdRecording = component.shutterState { isRecording = true isHolding = true } else if case .transition = component.shutterState { isTransitioning = true } let gallerySize: CGSize let galleryCornerRadius: CGFloat if component.isTablet { gallerySize = CGSize(width: 72.0, height: 72.0) galleryCornerRadius = 16.0 } else { gallerySize = CGSize(width: 50.0, height: 50.0) galleryCornerRadius = 10.0 } let galleryButtonId: String if let (identifier, _) = state.cachedAssetImage, identifier == "" { galleryButtonId = "placeholder" } else { galleryButtonId = "gallery" } let galleryButtonSize = self.galleryButtonView.update( transition: transition, component: AnyComponent( CameraButton( content: AnyComponentWithIdentity( id: galleryButtonId, component: AnyComponent( Image( image: state.cachedAssetImage?.1, size: gallerySize, contentMode: .scaleAspectFill ) ) ), tag: component.galleryButtonTag, action: { component.galleryTapped() } ) ), environment: {}, containerSize: gallerySize ) let galleryButtonFrame: CGRect if component.isTablet { galleryButtonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - galleryButtonSize.width) / 2.0), y: size.height - galleryButtonSize.height - 56.0), size: galleryButtonSize) } else { galleryButtonFrame = CGRect(origin: CGPoint(x: buttonSideInset, y: floorToScreenPixels((size.height - galleryButtonSize.height) / 2.0)), size: galleryButtonSize) } if let galleryButtonView = self.galleryButtonView.view { galleryButtonView.clipsToBounds = true galleryButtonView.layer.cornerRadius = galleryCornerRadius if galleryButtonView.superview == nil { self.addSubview(galleryButtonView) } transition.setBounds(view: galleryButtonView, bounds: CGRect(origin: .zero, size: galleryButtonFrame.size)) transition.setPosition(view: galleryButtonView, position: galleryButtonFrame.center) transition.setScale(view: galleryButtonView, scale: isRecording || isTransitioning ? 0.1 : 1.0) transition.setAlpha(view: galleryButtonView, alpha: isRecording || isTransitioning ? 0.0 : 1.0) } if !component.isTablet { let flipButtonOriginX = availableSize.width - 48.0 - buttonSideInset let flipButtonMaskFrame: CGRect = CGRect(origin: CGPoint(x: availableSize.width / 2.0 - (flipButtonOriginX + 22.0) + 6.0 + self.shutterOffsetX, y: 8.0), size: CGSize(width: 32.0, height: 32.0)) let flipButtonSize = self.flipButtonView.update( transition: .immediate, component: AnyComponent( CameraButton( content: AnyComponentWithIdentity( id: "flip", component: AnyComponent( FlipButtonContentComponent( action: component.flipAnimationAction, maskFrame: flipButtonMaskFrame ) ) ), minSize: CGSize(width: 44.0, height: 44.0), action: { component.flipTapped() } ) ), environment: {}, containerSize: availableSize ) let flipButtonFrame = CGRect(origin: CGPoint(x: flipButtonOriginX, y: (size.height - flipButtonSize.height) / 2.0), size: flipButtonSize) if let flipButtonView = self.flipButtonView.view { if flipButtonView.superview == nil { self.addSubview(flipButtonView) } transition.setBounds(view: flipButtonView, bounds: CGRect(origin: .zero, size: flipButtonFrame.size)) transition.setPosition(view: flipButtonView, position: flipButtonFrame.center) transition.setScale(view: flipButtonView, scale: isTransitioning ? 0.01 : 1.0) transition.setAlpha(view: flipButtonView, alpha: isTransitioning ? 0.0 : 1.0) } } else if let flipButtonView = self.flipButtonView.view { flipButtonView.removeFromSuperview() } var blobState: ShutterBlobView.BlobState switch component.shutterState { case .generic, .disabled: blobState = .generic case .video, .transition: blobState = .video case .stopRecording: blobState = .stopVideo case .holdRecording: blobState = self.panBlobState ?? .video } let shutterButtonSize = self.shutterButtonView.update( transition: transition, component: AnyComponent( Button( content: AnyComponent( ShutterButtonContentComponent( isTablet: component.isTablet, hasAppeared: component.hasAppeared, shutterState: component.shutterState, blobState: blobState, highlightedAction: self.shutterHightlightedAction, updateOffsetX: self.shutterUpdateOffsetX, updateOffsetY: self.shutterUpdateOffsetY ) ), automaticHighlight: false, action: { [weak self] in self?.hapticFeedback.impact(.light) self?.shutterUpdateOffsetX.invoke((0.0, .immediate)) component.shutterTapped() }, highlightedAction: self.shutterHightlightedAction ).minSize(maximumShutterSize) ), environment: {}, containerSize: availableSize ) let shutterButtonFrame = CGRect(origin: CGPoint(x: (availableSize.width - shutterButtonSize.width) / 2.0, y: (size.height - shutterButtonSize.height) / 2.0), size: shutterButtonSize) let guideSpacing: CGFloat = 9.0 let guideSize = CGSize(width: isHolding ? component.isTablet ? 84.0 : 60.0 : 0.0, height: 1.0 + UIScreenPixel) let guideAlpha: CGFloat = isHolding ? 1.0 : 0.0 let leftGuideFrame = CGRect(origin: CGPoint(x: shutterButtonFrame.minX - guideSpacing - guideSize.width, y: floorToScreenPixels((size.height - guideSize.height) / 2.0)), size: guideSize) let rightGuideFrame: CGRect if component.isTablet { rightGuideFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - guideSize.height) / 2.0), y: shutterButtonFrame.maxY + guideSpacing), size: CGSize(width: guideSize.height, height: guideSize.width)) } else { rightGuideFrame = CGRect(origin: CGPoint(x: shutterButtonFrame.maxX + guideSpacing, y: (size.height - guideSize.height) / 2.0), size: guideSize) } transition.setFrame(layer: self.leftGuide, frame: leftGuideFrame) transition.setFrame(layer: self.rightGuide, frame: rightGuideFrame) var leftGuideAlpha = guideAlpha let rightGuideAlpha = guideAlpha if component.isTablet, availableSize.width < 185.0 { leftGuideAlpha = 0.0 } if previousShutterState == .generic || previousShutterState == .video { self.leftGuide.opacity = Float(leftGuideAlpha) self.rightGuide.opacity = Float(rightGuideAlpha) } else { transition.setAlpha(layer: self.leftGuide, alpha: leftGuideAlpha) transition.setAlpha(layer: self.rightGuide, alpha: rightGuideAlpha) } self.leftGuide.cornerRadius = guideSize.height / 2.0 self.rightGuide.cornerRadius = guideSize.height / 2.0 let hintIconSize = CGSize(width: 30.0, height: 30.0) if component.isTablet { let _ = self.zoomView.update( transition: .immediate, component: AnyComponent( Image( image: self.zoomImage, size: hintIconSize ) ), environment: {}, containerSize: hintIconSize ) let zoomFrame = CGRect(origin: CGPoint(x: availableSize.width / 2.0 - 150.0 - hintIconSize.width, y: floorToScreenPixels((availableSize.height - hintIconSize.height) / 2.0)), size: hintIconSize) if let zoomView = self.zoomView.view { if zoomView.superview == nil { self.addSubview(zoomView) } transition.setBounds(view: zoomView, bounds: CGRect(origin: .zero, size: zoomFrame.size)) transition.setPosition(view: zoomView, position: zoomFrame.center) transition.setScale(view: zoomView, scale: isHolding ? 1.0 : 0.1) transition.setAlpha(view: zoomView, alpha: isHolding && leftGuideAlpha > 0.0 ? 1.0 : 0.0) } } else if let zoomView = self.zoomView.view { zoomView.removeFromSuperview() } let lockFrame: CGRect var lockMaskFrame: CGRect if component.isTablet { lockFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - hintIconSize.width) / 2.0), y: availableSize.height / 2.0 + 152.0), size: hintIconSize) lockMaskFrame = CGRect(origin: CGPoint(x: -9.0, y: availableSize.height / 2.0 - lockFrame.midY - 9.0 + self.shutterOffsetY), size: CGSize(width: 48.0, height: 48.0)) if self.panBlobState == .transientToLock { lockMaskFrame = lockMaskFrame.offsetBy(dx: 0.0, dy: -8.0) } } else { lockFrame = galleryButtonFrame.insetBy(dx: (gallerySize.width - hintIconSize.width) / 2.0, dy: (gallerySize.height - hintIconSize.height) / 2.0) lockMaskFrame = CGRect(origin: CGPoint(x: availableSize.width / 2.0 - lockFrame.midX - 9.0 + self.shutterOffsetX, y: -9.0), size: CGSize(width: 48.0, height: 48.0)) if self.panBlobState == .transientToLock { lockMaskFrame = lockMaskFrame.offsetBy(dx: 8.0, dy: 0.0) } } let _ = self.lockView.update( transition: transition, component: AnyComponent( LockContentComponent( maskFrame: lockMaskFrame ) ), environment: {}, containerSize: hintIconSize ) if let lockView = self.lockView.view { if lockView.superview == nil { self.addSubview(lockView) } transition.setBounds(view: lockView, bounds: CGRect(origin: .zero, size: lockFrame.size)) transition.setPosition(view: lockView, position: lockFrame.center) transition.setScale(view: lockView, scale: isHolding ? 1.0 : 0.1) transition.setAlpha(view: lockView, alpha: isHolding ? 1.0 : 0.0) if let lockMaskView = lockView as? LockContentComponent.View { transition.setAlpha(view: lockMaskView.maskContainerView, alpha: isHolding ? 1.0 : 0.0) transition.setSublayerTransform(layer: lockMaskView.maskContainerView.layer, transform: isHolding ? CATransform3DIdentity : CATransform3DMakeScale(0.1, 0.1, 1.0)) } } if let shutterButtonView = self.shutterButtonView.view { if shutterButtonView.superview == nil { let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.handlePan(_:))) panGestureRecognizer.delegate = self shutterButtonView.addGestureRecognizer(panGestureRecognizer) let pressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.handlePress(_:))) pressGestureRecognizer.minimumPressDuration = 0.3 pressGestureRecognizer.delegate = self shutterButtonView.addGestureRecognizer(pressGestureRecognizer) self.addSubview(shutterButtonView) } transition.setBounds(view: shutterButtonView, bounds: CGRect(origin: .zero, size: shutterButtonFrame.size)) transition.setPosition(view: shutterButtonView, position: shutterButtonFrame.center) transition.setScale(view: shutterButtonView, scale: isTransitioning ? 0.01 : 1.0) transition.setAlpha(view: shutterButtonView, alpha: isTransitioning ? 0.0 : 1.0) } if let buttonView = self.flipButtonView.view as? CameraButton.View, let contentView = buttonView.contentView.componentView as? FlipButtonContentComponent.View { if contentView.maskContainerView.superview == nil { self.addSubview(contentView.maskContainerView) } contentView.maskContainerView.frame = contentView.convert(contentView.bounds, to: self) } if let lockView = self.lockView.view as? LockContentComponent.View { if lockView.maskContainerView.superview == nil { self.addSubview(lockView.maskContainerView) } lockView.maskContainerView.center = lockView.center } return size } } func makeView() -> View { return View() } func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, state: state, availableSize: availableSize, transition: transition) } }