import Foundation import UIKit import Display import AsyncDisplayKit import ComponentFlow import SwiftSignalKit import ViewControllerComponent import ComponentDisplayAdapters import TelegramPresentationData import AccountContext import TelegramCore import PresentationDataUtils import Camera import MultilineTextComponent import BlurredBackgroundComponent import Photos import LottieAnimationComponent import TooltipUI import MediaEditor import BundleIconComponent import CameraButtonComponent import VolumeButtons import TelegramNotices let videoRedColor = UIColor(rgb: 0xff3b30) enum CameraMode: Equatable { case photo case video } private struct CameraState { enum Recording: Equatable { case none case holding case handsFree } let mode: CameraMode let position: Camera.Position let flashMode: Camera.FlashMode let flashModeDidChange: Bool let recording: Recording let duration: Double let isDualCamEnabled: Bool func updatedMode(_ mode: CameraMode) -> CameraState { return CameraState(mode: mode, position: self.position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, recording: self.recording, duration: self.duration, isDualCamEnabled: self.isDualCamEnabled) } func updatedPosition(_ position: Camera.Position) -> CameraState { return CameraState(mode: self.mode, position: position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, recording: self.recording, duration: self.duration, isDualCamEnabled: self.isDualCamEnabled) } func updatedFlashMode(_ flashMode: Camera.FlashMode) -> CameraState { return CameraState(mode: self.mode, position: self.position, flashMode: flashMode, flashModeDidChange: self.flashMode != flashMode, recording: self.recording, duration: self.duration, isDualCamEnabled: self.isDualCamEnabled) } func updatedRecording(_ recording: Recording) -> CameraState { return CameraState(mode: self.mode, position: self.position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, recording: recording, duration: self.duration, isDualCamEnabled: self.isDualCamEnabled) } func updatedDuration(_ duration: Double) -> CameraState { return CameraState(mode: self.mode, position: self.position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, recording: self.recording, duration: duration, isDualCamEnabled: self.isDualCamEnabled) } func updatedIsDualCamEnabled(_ isDualCamEnabled: Bool) -> CameraState { return CameraState(mode: self.mode, position: self.position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, recording: self.recording, duration: self.duration, isDualCamEnabled: isDualCamEnabled) } } enum CameraScreenTransition { case animateIn case animateOut case finishedAnimateIn } private let cancelButtonTag = GenericComponentViewTag() private let flashButtonTag = GenericComponentViewTag() private let zoomControlTag = GenericComponentViewTag() private let captureControlsTag = GenericComponentViewTag() private let modeControlTag = GenericComponentViewTag() private let galleryButtonTag = GenericComponentViewTag() private let dualButtonTag = GenericComponentViewTag() private final class CameraScreenComponent: CombinedComponent { typealias EnvironmentType = ViewControllerComponentContainer.Environment final class ExternalState { fileprivate(set) var isRecording: Bool = false public init() { } } let context: AccountContext let externalState: ExternalState let camera: Camera let updateState: ActionSlot let hasAppeared: Bool let isVisible: Bool let panelWidth: CGFloat let animateFlipAction: ActionSlot let animateShutter: () -> Void let toggleCameraPositionAction: ActionSlot let dismissAllTooltips: () -> Void let present: (ViewController) -> Void let push: (ViewController) -> Void let completion: ActionSlot> init( context: AccountContext, externalState: ExternalState, camera: Camera, updateState: ActionSlot, hasAppeared: Bool, isVisible: Bool, panelWidth: CGFloat, animateFlipAction: ActionSlot, animateShutter: @escaping () -> Void, toggleCameraPositionAction: ActionSlot, dismissAllTooltips: @escaping () -> Void, present: @escaping (ViewController) -> Void, push: @escaping (ViewController) -> Void, completion: ActionSlot> ) { self.context = context self.externalState = externalState self.camera = camera self.updateState = updateState self.hasAppeared = hasAppeared self.isVisible = isVisible self.panelWidth = panelWidth self.animateFlipAction = animateFlipAction self.animateShutter = animateShutter self.toggleCameraPositionAction = toggleCameraPositionAction self.dismissAllTooltips = dismissAllTooltips self.present = present self.push = push self.completion = completion } static func ==(lhs: CameraScreenComponent, rhs: CameraScreenComponent) -> Bool { if lhs.context !== rhs.context { return false } if lhs.hasAppeared != rhs.hasAppeared { return false } if lhs.isVisible != rhs.isVisible { return false } if lhs.panelWidth != rhs.panelWidth { return false } return true } final class State: ComponentState { enum ImageKey: Hashable { case cancel case flip } private var cachedImages: [ImageKey: UIImage] = [:] func image(_ key: ImageKey) -> UIImage { if let image = self.cachedImages[key] { return image } else { var image: UIImage switch key { case .cancel: image = UIImage(bundleImageName: "Camera/CloseIcon")! case .flip: image = UIImage(bundleImageName: "Camera/FlipIcon")! } cachedImages[key] = image return image } } private var cameraAuthorizationStatus: AVAuthorizationStatus = .notDetermined private var microphoneAuthorizationStatus: AVAuthorizationStatus = .notDetermined private var galleryAuthorizationStatus: PHAuthorizationStatus = .notDetermined private let context: AccountContext fileprivate let camera: Camera private let present: (ViewController) -> Void private let completion: ActionSlot> private let updateState: ActionSlot private let toggleCameraPositionAction: ActionSlot private let animateShutter: () -> Void private let animateFlipAction: ActionSlot private let dismissAllTooltips: () -> Void private var cameraStateDisposable: Disposable? private var resultDisposable = MetaDisposable() private var mediaAssetsContext: MediaAssetsContext? fileprivate var lastGalleryAsset: PHAsset? private var lastGalleryAssetsDisposable: Disposable? private var volumeButtonsListener: VolumeButtonsListener? private let volumeButtonsListenerShouldBeActive = ValuePromise(false, ignoreRepeated: true) var cameraState = CameraState(mode: .photo, position: .unspecified, flashMode: .off, flashModeDidChange: false, recording: .none, duration: 0.0, isDualCamEnabled: false) { didSet { self.updateState.invoke(self.cameraState) } } var swipeHint: CaptureControlsComponent.SwipeHint = .none var isTransitioning = false private let hapticFeedback = HapticFeedback() init( context: AccountContext, camera: Camera, present: @escaping (ViewController) -> Void, completion: ActionSlot>, updateState: ActionSlot, animateShutter: @escaping () -> Void = {}, animateFlipAction: ActionSlot, toggleCameraPositionAction: ActionSlot, dismissAllTooltips: @escaping () -> Void = {} ) { self.context = context self.camera = camera self.present = present self.completion = completion self.updateState = updateState self.animateShutter = animateShutter self.animateFlipAction = animateFlipAction self.toggleCameraPositionAction = toggleCameraPositionAction self.dismissAllTooltips = dismissAllTooltips super.init() self.cameraStateDisposable = combineLatest(queue: Queue.mainQueue(), camera.flashMode, camera.position) .start(next: { [weak self] flashMode, position in guard let self else { return } let previousState = self.cameraState self.cameraState = self.cameraState.updatedPosition(position).updatedFlashMode(flashMode) self.updated(transition: .easeInOut(duration: 0.2)) if previousState.position != self.cameraState.position { UserDefaults.standard.set((self.cameraState.position == .front) as NSNumber, forKey: "TelegramStoryCameraUseFrontPosition") } }) Queue.concurrentDefaultQueue().async { self.setupRecentAssetSubscription() } self.setupVolumeButtonsHandler() self.toggleCameraPositionAction.connect({ [weak self] in if let self { self.togglePosition(self.animateFlipAction) } }) } deinit { self.cameraStateDisposable?.dispose() self.lastGalleryAssetsDisposable?.dispose() self.resultDisposable.dispose() } func setupRecentAssetSubscription() { let mediaAssetsContext = MediaAssetsContext() self.mediaAssetsContext = mediaAssetsContext self.lastGalleryAssetsDisposable = (mediaAssetsContext.recentAssets() |> map { fetchResult in return fetchResult?.lastObject } |> deliverOnMainQueue).start(next: { [weak self] asset in guard let self else { return } self.lastGalleryAsset = asset self.updated(transition: .easeInOut(duration: 0.2)) }) } func setupVolumeButtonsHandler() { guard self.volumeButtonsListener == nil else { return } self.volumeButtonsListener = VolumeButtonsListener( shouldBeActive: self.volumeButtonsListenerShouldBeActive.get(), upPressed: { [weak self] in if let self { self.handleVolumePressed() } }, upReleased: { [weak self] in if let self { self.handleVolumeReleased() } }, downPressed: { [weak self] in if let self { self.handleVolumePressed() } }, downReleased: { [weak self] in if let self { self.handleVolumeReleased() } } ) } var volumeButtonsListenerActive = false { didSet { self.volumeButtonsListenerShouldBeActive.set(self.volumeButtonsListenerActive) } } private var buttonPressTimestamp: Double? private var buttonPressTimer: SwiftSignalKit.Timer? private func handleVolumePressed() { self.buttonPressTimestamp = CACurrentMediaTime() self.buttonPressTimer = SwiftSignalKit.Timer(timeout: 0.3, repeat: false, completion: { [weak self] in if let self, let _ = self.buttonPressTimestamp { if case .none = self.cameraState.recording { self.startVideoRecording(pressing: true) } self.buttonPressTimestamp = nil } }, queue: Queue.mainQueue()) self.buttonPressTimer?.start() } private func handleVolumeReleased() { if case .none = self.cameraState.recording { switch self.cameraState.mode { case .photo: self.animateShutter() self.takePhoto() case .video: self.startVideoRecording(pressing: false) } } else { self.stopVideoRecording() } self.buttonPressTimer?.invalidate() self.buttonPressTimer = nil self.buttonPressTimestamp = nil } func updateCameraMode(_ mode: CameraMode) { self.cameraState = self.cameraState.updatedMode(mode) self.updated(transition: .spring(duration: 0.3)) } func toggleFlashMode() { if self.cameraState.flashMode == .off { self.camera.setFlashMode(.on) } else if self.cameraState.flashMode == .on { self.camera.setFlashMode(.auto) } else { self.camera.setFlashMode(.off) } self.hapticFeedback.impact(.light) } private var lastFlipTimestamp: Double? func togglePosition(_ action: ActionSlot) { let currentTimestamp = CACurrentMediaTime() if let lastFlipTimestamp = self.lastFlipTimestamp, currentTimestamp - lastFlipTimestamp < 1.0 { return } if let lastDualCameraTimestamp = self.lastDualCameraTimestamp, currentTimestamp - lastDualCameraTimestamp < 1.5 { return } self.lastFlipTimestamp = currentTimestamp self.camera.togglePosition() self.hapticFeedback.impact(.light) action.invoke(Void()) } private var lastDualCameraTimestamp: Double? func toggleDualCamera() { let currentTimestamp = CACurrentMediaTime() if let lastDualCameraTimestamp = self.lastDualCameraTimestamp, currentTimestamp - lastDualCameraTimestamp < 1.5 { return } if let lastFlipTimestamp = self.lastFlipTimestamp, currentTimestamp - lastFlipTimestamp < 1.0 { return } self.lastDualCameraTimestamp = currentTimestamp let isEnabled = !self.cameraState.isDualCamEnabled self.camera.setDualCamEnabled(isEnabled) self.cameraState = self.cameraState.updatedIsDualCamEnabled(isEnabled) self.updated(transition: .easeInOut(duration: 0.1)) self.dismissAllTooltips() let _ = ApplicationSpecificNotice.incrementStoriesDualCameraTip(accountManager: self.context.sharedContext.accountManager).start() self.hapticFeedback.impact(.light) } func updateSwipeHint(_ hint: CaptureControlsComponent.SwipeHint) { guard hint != self.swipeHint else { return } self.swipeHint = hint self.updated(transition: .easeInOut(duration: 0.2)) } private var isTakingPhoto = false func takePhoto() { guard !self.isTakingPhoto else { return } self.isTakingPhoto = true self.dismissAllTooltips() let takePhoto = self.camera.takePhoto() |> mapToSignal { value -> Signal in switch value { case .began: return .single(.pendingImage) case let .finished(image, additionalImage, _): return .single(.image(CameraScreen.Result.Image(image: image, additionalImage: additionalImage, additionalImagePosition: .topRight))) case .failed: return .complete() } } self.completion.invoke(takePhoto) Queue.mainQueue().after(1.0) { self.isTakingPhoto = false } } func startVideoRecording(pressing: Bool) { guard case .none = self.cameraState.recording else { return } self.dismissAllTooltips() self.cameraState = self.cameraState.updatedDuration(0.0).updatedRecording(pressing ? .holding : .handsFree) self.resultDisposable.set((self.camera.startRecording() |> deliverOnMainQueue).start(next: { [weak self] duration in if let self { self.cameraState = self.cameraState.updatedDuration(duration) self.updated(transition: .easeInOut(duration: 0.1)) if duration > 59.0 { self.stopVideoRecording() } } })) self.updated(transition: .spring(duration: 0.4)) } func stopVideoRecording() { self.cameraState = self.cameraState.updatedRecording(.none).updatedDuration(0.0) self.resultDisposable.set((self.camera.stopRecording() |> deliverOnMainQueue).start(next: { [weak self] result in if let self, case let .finished(mainResult, additionalResult, duration, positionChangeTimestamps, _) = result { self.completion.invoke(.single(.video(CameraScreen.Result.Video(videoPath: mainResult.0, coverImage: mainResult.1, mirror: mainResult.2, additionalVideoPath: additionalResult?.0, additionalCoverImage: additionalResult?.1, dimensions: PixelDimensions(width: 1080, height: 1920), duration: duration, positionChangeTimestamps: positionChangeTimestamps, additionalVideoPosition: .topRight)))) } })) self.isTransitioning = true Queue.mainQueue().after(1.25, { self.isTransitioning = false self.updated(transition: .immediate) }) self.updated(transition: .spring(duration: 0.4)) } func lockVideoRecording() { self.cameraState = self.cameraState.updatedRecording(.handsFree) self.updated(transition: .spring(duration: 0.4)) } func updateZoom(fraction: CGFloat) { self.camera.setZoomLevel(fraction) } } func makeState() -> State { return State(context: self.context, camera: self.camera, present: self.present, completion: self.completion, updateState: self.updateState, animateShutter: self.animateShutter, animateFlipAction: self.animateFlipAction, toggleCameraPositionAction: self.toggleCameraPositionAction, dismissAllTooltips: self.dismissAllTooltips) } static var body: Body { let cancelButton = Child(CameraButton.self) let captureControls = Child(CaptureControlsComponent.self) let zoomControl = Child(ZoomComponent.self) let flashButton = Child(CameraButton.self) let flipButton = Child(CameraButton.self) let dualButton = Child(CameraButton.self) let modeControl = Child(ModeComponent.self) let hintLabel = Child(HintLabelComponent.self) let timeBackground = Child(RoundedRectangle.self) let timeLabel = Child(MultilineTextComponent.self) return { context in let environment = context.environment[ViewControllerComponentContainer.Environment.self].value let component = context.component let state = context.state let controller = environment.controller let availableSize = context.availableSize state.volumeButtonsListenerActive = component.hasAppeared && component.isVisible component.externalState.isRecording = state.cameraState.recording != .none let isTablet: Bool if case .regular = environment.metrics.widthClass { isTablet = true } else { isTablet = false } let smallPanelWidth = min(component.panelWidth, 88.0) let panelWidth = min(component.panelWidth, 185.0) var controlsBottomInset: CGFloat = 0.0 if !isTablet { let previewHeight = floorToScreenPixels(availableSize.width * 1.77778) if availableSize.height < previewHeight + 30.0 { controlsBottomInset = -48.0 } } if case .holding = state.cameraState.recording { } else { let _ = zoomControl // let zoomControl = zoomControl.update( // component: ZoomComponent( // availableValues: state.camera.metrics.zoomLevels, // value: 1.0, // tag: zoomControlTag // ), // availableSize: context.availableSize, // transition: context.transition // ) // context.add(zoomControl // .position(CGPoint(x: context.availableSize.width / 2.0, y: availableSize.height - zoomControl.size.height / 2.0 - 114.0 - environment.safeInsets.bottom)) // .appear(.default(alpha: true)) // .disappear(.default(alpha: true)) // ) } let shutterState: ShutterButtonState if state.isTransitioning { shutterState = .transition } else { switch state.cameraState.recording { case .handsFree: shutterState = .stopRecording case .holding: shutterState = .holdRecording(progress: min(1.0, Float(state.cameraState.duration / 60.0))) case .none: switch state.cameraState.mode { case .photo: shutterState = .generic case .video: shutterState = .video } } } let animateFlipAction = component.animateFlipAction let captureControlsAvailableSize: CGSize if isTablet { captureControlsAvailableSize = CGSize(width: panelWidth, height: availableSize.height) } else { captureControlsAvailableSize = availableSize } let animateShutter = component.animateShutter let captureControls = captureControls.update( component: CaptureControlsComponent( isTablet: isTablet, hasAppeared: component.hasAppeared, shutterState: shutterState, lastGalleryAsset: state.lastGalleryAsset, tag: captureControlsTag, galleryButtonTag: galleryButtonTag, shutterTapped: { [weak state] in guard let state else { return } if case .none = state.cameraState.recording { if state.cameraState.mode == .photo { animateShutter() state.takePhoto() } else if state.cameraState.mode == .video { state.startVideoRecording(pressing: false) } } else { state.stopVideoRecording() } }, shutterPressed: { [weak state] in guard let state, case .none = state.cameraState.recording else { return } state.startVideoRecording(pressing: true) }, shutterReleased: { [weak state] in guard let state, state.cameraState.recording != .none else { return } state.stopVideoRecording() }, lockRecording: { [weak state] in guard let state, state.cameraState.recording != .none else { return } state.lockVideoRecording() }, flipTapped: { [weak state] in guard let state else { return } state.togglePosition(animateFlipAction) }, galleryTapped: { guard let controller = environment.controller() as? CameraScreen else { return } controller.presentGallery() }, swipeHintUpdated: { hint in state.updateSwipeHint(hint) }, zoomUpdated: { fraction in state.updateZoom(fraction: fraction) }, flipAnimationAction: animateFlipAction ), availableSize: captureControlsAvailableSize, transition: context.transition ) let captureControlsPosition: CGPoint if isTablet { captureControlsPosition = CGPoint(x: availableSize.width - panelWidth / 2.0, y: availableSize.height / 2.0) } else { captureControlsPosition = CGPoint(x: availableSize.width / 2.0, y: availableSize.height - captureControls.size.height / 2.0 - environment.safeInsets.bottom - 5.0 + floorToScreenPixels(controlsBottomInset * 0.66)) } context.add(captureControls .position(captureControlsPosition) ) let topControlInset: CGFloat = 20.0 if case .none = state.cameraState.recording, !state.isTransitioning { let cancelButton = cancelButton.update( component: CameraButton( content: AnyComponentWithIdentity( id: "cancel", component: AnyComponent( Image( image: state.image(.cancel), size: CGSize(width: 40.0, height: 40.0) ) ) ), action: { guard let controller = controller() as? CameraScreen else { return } controller.requestDismiss(animated: true) } ).tagged(cancelButtonTag), availableSize: CGSize(width: 40.0, height: 40.0), transition: .immediate ) context.add(cancelButton .position(CGPoint(x: isTablet ? smallPanelWidth / 2.0 : topControlInset + cancelButton.size.width / 2.0, y: max(environment.statusBarHeight + 5.0, environment.safeInsets.top + topControlInset) + cancelButton.size.height / 2.0)) .appear(.default(scale: true)) .disappear(.default(scale: true)) ) let flashContentComponent: AnyComponentWithIdentity if component.hasAppeared { let flashIconName: String switch state.cameraState.flashMode { case .off: flashIconName = "flash_off" case .on: flashIconName = "flash_on" case .auto: flashIconName = "flash_auto" @unknown default: flashIconName = "flash_off" } flashContentComponent = AnyComponentWithIdentity( id: "animatedIcon", component: AnyComponent( LottieAnimationComponent( animation: LottieAnimationComponent.AnimationItem( name: flashIconName, mode: !state.cameraState.flashModeDidChange ? .still(position: .end) : .animating(loop: false), range: nil, waitForCompletion: false ), colors: [:], size: CGSize(width: 40.0, height: 40.0) ) ) ) } else { flashContentComponent = AnyComponentWithIdentity( id: "staticIcon", component: AnyComponent( BundleIconComponent( name: "Camera/FlashOffIcon", tintColor: nil ) ) ) } let flashButton = flashButton.update( component: CameraButton( content: flashContentComponent, action: { [weak state] in guard let state else { return } state.toggleFlashMode() } ).tagged(flashButtonTag), availableSize: CGSize(width: 40.0, height: 40.0), transition: .immediate ) context.add(flashButton .position(CGPoint(x: isTablet ? availableSize.width - smallPanelWidth / 2.0 : availableSize.width - topControlInset - flashButton.size.width / 2.0 - 5.0, y: max(environment.statusBarHeight + 5.0, environment.safeInsets.top + topControlInset) + flashButton.size.height / 2.0)) .appear(.default(scale: true)) .disappear(.default(scale: true)) ) if !isTablet && Camera.isDualCamSupported { let dualButton = dualButton.update( component: CameraButton( content: AnyComponentWithIdentity( id: "dual", component: AnyComponent( DualIconComponent(isSelected: state.cameraState.isDualCamEnabled) ) ), action: { [weak state] in guard let state else { return } state.toggleDualCamera() } ).tagged(dualButtonTag), availableSize: CGSize(width: 40.0, height: 40.0), transition: .immediate ) context.add(dualButton .position(CGPoint(x: availableSize.width - topControlInset - flashButton.size.width / 2.0 - 52.0, y: max(environment.statusBarHeight + 5.0, environment.safeInsets.top + topControlInset) + dualButton.size.height / 2.0 + 1.0)) .appear(.default(scale: true)) .disappear(.default(scale: true)) ) } } if isTablet { let flipButton = flipButton.update( component: CameraButton( content: AnyComponentWithIdentity( id: "flip", component: AnyComponent( FlipButtonContentComponent( action: animateFlipAction, maskFrame: .zero ) ) ), minSize: CGSize(width: 44.0, height: 44.0), action: { state.togglePosition(animateFlipAction) } ), availableSize: availableSize, transition: context.transition ) context.add(flipButton .position(CGPoint(x: smallPanelWidth / 2.0, y: availableSize.height / 2.0)) ) } var isVideoRecording = false if case .video = state.cameraState.mode { isVideoRecording = true } else if state.cameraState.recording != .none { isVideoRecording = true } if isVideoRecording && !state.isTransitioning { let duration = Int(state.cameraState.duration) let durationString = String(format: "%02d:%02d", (duration / 60) % 60, duration % 60) let timeLabel = timeLabel.update( component: MultilineTextComponent( text: .plain(NSAttributedString(string: durationString, font: Font.with(size: 21.0, design: .camera), textColor: .white)), horizontalAlignment: .center, textShadowColor: UIColor(rgb: 0x000000, alpha: 0.2) ), availableSize: context.availableSize, transition: context.transition ) let timePosition: CGPoint if isTablet { timePosition = CGPoint(x: availableSize.width - panelWidth / 2.0, y: availableSize.height / 2.0 - 97.0) } else { timePosition = CGPoint(x: availableSize.width / 2.0, y: max(environment.statusBarHeight + 5.0 + 20.0, environment.safeInsets.top + topControlInset + 20.0)) } if state.cameraState.recording != .none { let timeBackground = timeBackground.update( component: RoundedRectangle(color: videoRedColor, cornerRadius: 4.0), availableSize: CGSize(width: timeLabel.size.width + 8.0, height: 28.0), transition: context.transition ) context.add(timeBackground .position(timePosition) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) } context.add(timeLabel .position(timePosition) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) if case .holding = state.cameraState.recording, !isTablet { let hintText: String? switch state.swipeHint { case .none: hintText = " " case .zoom: hintText = "Swipe up to zoom" case .lock: hintText = "Swipe left to lock" case .releaseLock: hintText = "Release to lock" case .flip: hintText = "Swipe right to flip" } if let hintText { let hintLabel = hintLabel.update( component: HintLabelComponent(text: hintText), availableSize: availableSize, transition: .immediate ) context.add(hintLabel .position(CGPoint(x: availableSize.width / 2.0, y: availableSize.height - environment.safeInsets.bottom - 136.0)) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) } } } if case .none = state.cameraState.recording, !state.isTransitioning { let availableModeControlSize: CGSize if isTablet { availableModeControlSize = CGSize(width: panelWidth, height: 120.0) } else { availableModeControlSize = availableSize } let modeControl = modeControl.update( component: ModeComponent( isTablet: isTablet, availableModes: [.photo, .video], currentMode: state.cameraState.mode, updatedMode: { [weak state] mode in if let state { state.updateCameraMode(mode) } }, tag: modeControlTag ), availableSize: availableModeControlSize, transition: context.transition ) let modeControlPosition: CGPoint if isTablet { modeControlPosition = CGPoint(x: availableSize.width - panelWidth / 2.0, y: availableSize.height / 2.0 + modeControl.size.height + 26.0) } else { modeControlPosition = CGPoint(x: availableSize.width / 2.0, y: availableSize.height - environment.safeInsets.bottom + modeControl.size.height / 2.0 + controlsBottomInset) } context.add(modeControl .clipsToBounds(true) .position(modeControlPosition) .appear(.default(alpha: true)) .disappear(.default(alpha: true)) ) } return availableSize } } } private class BlurView: UIVisualEffectView { private func setup() { for subview in self.subviews { if subview.description.contains("VisualEffectSubview") { subview.isHidden = true } } if let sublayer = self.layer.sublayers?[0], let filters = sublayer.filters { sublayer.backgroundColor = nil sublayer.isOpaque = false let allowedKeys: [String] = [ "gaussianBlur" ] sublayer.filters = filters.filter { filter in guard let filter = filter as? NSObject else { return true } let filterName = String(describing: filter) if !allowedKeys.contains(filterName) { return false } return true } } } override var effect: UIVisualEffect? { get { return super.effect } set { super.effect = newValue self.setup() } } override func didAddSubview(_ subview: UIView) { super.didAddSubview(subview) self.setup() } } public class CameraScreen: ViewController { public enum Mode { case generic case story case instantVideo } public enum PIPPosition { case topLeft case topRight case bottomLeft case bottomRight } public enum Result { public struct Image { public let image: UIImage public let additionalImage: UIImage? public let additionalImagePosition: CameraScreen.PIPPosition } public struct Video { public let videoPath: String public let coverImage: UIImage? public let mirror: Bool public let additionalVideoPath: String? public let additionalCoverImage: UIImage? public let dimensions: PixelDimensions public let duration: Double public let positionChangeTimestamps: [(Bool, Double)] public let additionalVideoPosition: CameraScreen.PIPPosition } case pendingImage case image(Image) case video(Video) case asset(PHAsset) case draft(MediaEditorDraft) func withPIPPosition(_ position: CameraScreen.PIPPosition) -> Result { switch self { case let .image(result): return .image(Image(image: result.image, additionalImage: result.additionalImage, additionalImagePosition: position)) case let .video(result): return .video(Video(videoPath: result.videoPath, coverImage: result.coverImage, mirror: result.mirror, additionalVideoPath: result.additionalVideoPath, additionalCoverImage: result.additionalCoverImage, dimensions: result.dimensions, duration: result.duration, positionChangeTimestamps: result.positionChangeTimestamps, additionalVideoPosition: position)) default: return self } } } public final class TransitionIn { public weak var sourceView: UIView? public let sourceRect: CGRect public let sourceCornerRadius: CGFloat public init( sourceView: UIView, sourceRect: CGRect, sourceCornerRadius: CGFloat ) { self.sourceView = sourceView self.sourceRect = sourceRect self.sourceCornerRadius = sourceCornerRadius } } public final class TransitionOut { public weak var destinationView: UIView? public let destinationRect: CGRect public let destinationCornerRadius: CGFloat public init( destinationView: UIView, destinationRect: CGRect, destinationCornerRadius: CGFloat ) { self.destinationView = destinationView self.destinationRect = destinationRect self.destinationCornerRadius = destinationCornerRadius } } fileprivate final class Node: ViewControllerTracingNode, UIGestureRecognizerDelegate { private weak var controller: CameraScreen? private let context: AccountContext private let updateState: ActionSlot private let toggleCameraPositionAction: ActionSlot fileprivate let backgroundView: UIView fileprivate let containerView: UIView private let componentExternalState = CameraScreenComponent.ExternalState() fileprivate let componentHost: ComponentView private let previewContainerView: UIView private let mainPreviewContainerView: UIView fileprivate var mainPreviewView: CameraSimplePreviewView private let additionalPreviewContainerView: UIView fileprivate var additionalPreviewView: CameraSimplePreviewView fileprivate let previewBlurView: BlurView private var mainPreviewSnapshotView: UIView? private var additionalPreviewSnapshotView: UIView? fileprivate let previewFrameLeftDimView: UIView fileprivate let previewFrameRightDimView: UIView fileprivate let transitionDimView: UIView fileprivate let transitionCornersView: UIImageView fileprivate let camera: Camera private var changingPositionDisposable: Disposable? private var isDualCamEnabled = false private var appliedDualCam = false private var cameraPosition: Camera.Position = .back private var pipPosition: PIPPosition = .topRight fileprivate var previewBlurPromise = ValuePromise(false) private let animateFlipAction = ActionSlot() private let idleTimerExtensionDisposable = MetaDisposable() fileprivate var cameraIsActive = true { didSet { if self.cameraIsActive { self.idleTimerExtensionDisposable.set(self.context.sharedContext.applicationBindings.pushIdleTimerExtension()) } else { self.idleTimerExtensionDisposable.set(nil) } } } fileprivate var hasGallery = false private var presentationData: PresentationData private var validLayout: ContainerViewLayout? private let completion = ActionSlot>() init(controller: CameraScreen) { self.controller = controller self.context = controller.context self.updateState = ActionSlot() self.toggleCameraPositionAction = ActionSlot() self.presentationData = self.context.sharedContext.currentPresentationData.with { $0 } self.backgroundView = UIView() self.backgroundView.backgroundColor = UIColor(rgb: 0x000000) self.containerView = UIView() self.containerView.clipsToBounds = true self.componentHost = ComponentView() self.previewContainerView = UIView() self.previewContainerView.clipsToBounds = true self.previewContainerView.layer.cornerRadius = 12.0 if #available(iOS 13.0, *) { self.previewContainerView.layer.cornerCurve = .continuous } self.previewBlurView = BlurView() self.previewBlurView.isUserInteractionEnabled = false self.mainPreviewContainerView = UIView() self.mainPreviewContainerView.clipsToBounds = true self.mainPreviewView = CameraSimplePreviewView(frame: .zero, main: true) self.additionalPreviewContainerView = UIView() self.additionalPreviewContainerView.clipsToBounds = true self.additionalPreviewView = CameraSimplePreviewView(frame: .zero, main: false) var cameraFrontPosition = false if let useCameraFrontPosition = UserDefaults.standard.object(forKey: "TelegramStoryCameraUseFrontPosition") as? NSNumber, useCameraFrontPosition.boolValue { cameraFrontPosition = true } self.mainPreviewView.resetPlaceholder(front: cameraFrontPosition) self.cameraPosition = cameraFrontPosition ? .front : .back self.camera = Camera(configuration: Camera.Configuration(preset: .hd1920x1080, position: self.cameraPosition, audio: true, photo: true, metadata: false, preferredFps: 60.0), previewView: self.mainPreviewView, secondaryPreviewView: self.additionalPreviewView) self.previewFrameLeftDimView = UIView() self.previewFrameLeftDimView.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.6) self.previewFrameLeftDimView.isHidden = true self.previewFrameRightDimView = UIView() self.previewFrameRightDimView.backgroundColor = UIColor(rgb: 0x000000, alpha: 0.6) self.previewFrameRightDimView.isHidden = true self.transitionDimView = UIView() self.transitionDimView.backgroundColor = UIColor(rgb: 0x000000) self.transitionDimView.isUserInteractionEnabled = false self.transitionCornersView = UIImageView() super.init() self.backgroundColor = .clear self.view.addSubview(self.backgroundView) self.view.addSubview(self.containerView) self.containerView.addSubview(self.previewContainerView) self.previewContainerView.addSubview(self.mainPreviewContainerView) self.previewContainerView.addSubview(self.additionalPreviewContainerView) self.previewContainerView.addSubview(self.previewBlurView) self.previewContainerView.addSubview(self.previewFrameLeftDimView) self.previewContainerView.addSubview(self.previewFrameRightDimView) self.containerView.addSubview(self.transitionDimView) self.view.addSubview(self.transitionCornersView) self.mainPreviewContainerView.addSubview(self.mainPreviewView) self.additionalPreviewContainerView.addSubview(self.additionalPreviewView) self.changingPositionDisposable = combineLatest( queue: Queue.mainQueue(), self.camera.modeChange, self.previewBlurPromise.get() ).start(next: { [weak self] modeChange, forceBlur in if let self { if modeChange != .none { if case .dualCamera = modeChange, self.cameraPosition == .front { } else { if let snapshot = self.mainPreviewView.snapshotView(afterScreenUpdates: false) { self.mainPreviewView.addSubview(snapshot) self.mainPreviewSnapshotView = snapshot } } if case .position = modeChange { UIView.transition(with: self.previewContainerView, duration: 0.4, options: [.transitionFlipFromLeft, .curveEaseOut], animations: { self.previewBlurView.effect = UIBlurEffect(style: .dark) }) } else { self.previewContainerView.insertSubview(self.previewBlurView, belowSubview: self.additionalPreviewContainerView) UIView.animate(withDuration: 0.4) { self.previewBlurView.effect = UIBlurEffect(style: .dark) } } } else if forceBlur { UIView.animate(withDuration: 0.4) { self.previewBlurView.effect = UIBlurEffect(style: .dark) } } else { if self.previewBlurView.effect != nil { UIView.animate(withDuration: 0.4, animations: { self.previewBlurView.effect = nil }, completion: { _ in self.previewContainerView.insertSubview(self.previewBlurView, aboveSubview: self.additionalPreviewContainerView) }) } if let previewSnapshotView = self.mainPreviewSnapshotView { self.mainPreviewSnapshotView = nil UIView.animate(withDuration: 0.25, animations: { previewSnapshotView.alpha = 0.0 }, completion: { _ in previewSnapshotView.removeFromSuperview() }) } if let previewSnapshotView = self.additionalPreviewSnapshotView { self.additionalPreviewSnapshotView = nil UIView.animate(withDuration: 0.25, animations: { previewSnapshotView.alpha = 0.0 }, completion: { _ in previewSnapshotView.removeFromSuperview() }) } if self.isDualCamEnabled { self.mainPreviewView.removePlaceholder() self.additionalPreviewView.removePlaceholder() } } } }) self.completion.connect { [weak self] result in if let self { let pipPosition = self.pipPosition self.animateOutToEditor() self.controller?.completion( result |> map { result in return result.withPIPPosition(pipPosition) } |> beforeNext { [weak self] value in guard let self else { return } if case .pendingImage = value { Queue.mainQueue().async { self.mainPreviewView.isEnabled = false self.additionalPreviewView.isEnabled = false } } else { Queue.mainQueue().async { if case .image = value { Queue.mainQueue().after(0.3) { self.previewBlurPromise.set(true) } } self.mainPreviewView.isEnabled = false self.additionalPreviewView.isEnabled = false self.camera.stopCapture() } } }, nil, {} ) } } self.updateState.connect { [weak self] state in if let self { let previousPosition = self.cameraPosition let newPosition = state.position != .unspecified ? state.position : previousPosition self.cameraPosition = newPosition let dualCamWasEnabled = self.isDualCamEnabled self.isDualCamEnabled = state.isDualCamEnabled if self.isDualCamEnabled != dualCamWasEnabled && newPosition == .front { if self.isDualCamEnabled { if let cloneView = self.mainPreviewView.snapshotView(afterScreenUpdates: false) { self.mainPreviewSnapshotView = cloneView self.mainPreviewContainerView.addSubview(cloneView) } } else { if let cloneView = self.mainPreviewView.snapshotView(afterScreenUpdates: false) { cloneView.frame = self.mainPreviewView.frame self.additionalPreviewSnapshotView = cloneView self.additionalPreviewContainerView.addSubview(cloneView) } if let cloneView = self.additionalPreviewView.snapshotView(afterScreenUpdates: false) { cloneView.frame = self.additionalPreviewView.frame self.mainPreviewSnapshotView = cloneView self.mainPreviewContainerView.addSubview(cloneView) } } } if self.isDualCamEnabled && previousPosition != newPosition { self.animateDualCameraPositionSwitch() } else if dualCamWasEnabled != self.isDualCamEnabled { self.requestUpdateLayout(hasAppeared: false, transition: .spring(duration: 0.4)) } } } self.idleTimerExtensionDisposable.set(self.context.sharedContext.applicationBindings.pushIdleTimerExtension()) } deinit { self.changingPositionDisposable?.dispose() self.idleTimerExtensionDisposable.dispose() } private var pipPanGestureRecognizer: UIPanGestureRecognizer? override func didLoad() { super.didLoad() self.view.disablesInteractiveModalDismiss = true self.view.disablesInteractiveKeyboardGestureRecognizer = true let pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(self.handlePinch(_:))) self.previewContainerView.addGestureRecognizer(pinchGestureRecognizer) let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.handlePan(_:))) panGestureRecognizer.delegate = self panGestureRecognizer.maximumNumberOfTouches = 1 self.previewContainerView.addGestureRecognizer(panGestureRecognizer) let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.handleTap(_:))) self.previewContainerView.addGestureRecognizer(tapGestureRecognizer) let doubleGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.handleDoubleTap(_:))) doubleGestureRecognizer.numberOfTapsRequired = 2 self.previewContainerView.addGestureRecognizer(doubleGestureRecognizer) let pipPanGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.handlePipPan(_:))) pipPanGestureRecognizer.delegate = self self.previewContainerView.addGestureRecognizer(pipPanGestureRecognizer) self.pipPanGestureRecognizer = pipPanGestureRecognizer self.camera.focus(at: CGPoint(x: 0.5, y: 0.5), autoFocus: true) self.camera.startCapture() } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer { return false } return true } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { let location = gestureRecognizer.location(in: gestureRecognizer.view) if gestureRecognizer === self.pipPanGestureRecognizer { if !self.isDualCamEnabled { return false } return self.additionalPreviewContainerView.frame.contains(location) } return self.hasAppeared } @objc private func handlePinch(_ gestureRecognizer: UIPinchGestureRecognizer) { switch gestureRecognizer.state { case .changed: let scale = gestureRecognizer.scale self.camera.setZoomDelta(scale) gestureRecognizer.scale = 1.0 default: break } } private var isDismissing = false @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { guard let controller = self.controller, let layout = self.validLayout else { return } let translation = gestureRecognizer.translation(in: gestureRecognizer.view) switch gestureRecognizer.state { case .began: break case .changed: if self.componentExternalState.isRecording { } else if case .compact = layout.metrics.widthClass { if translation.x < -10.0 || self.isDismissing { self.isDismissing = true let transitionFraction = 1.0 - max(0.0, translation.x * -1.0) / self.frame.width controller.updateTransitionProgress(transitionFraction, transition: .immediate) } else if translation.y < -10.0 && abs(translation.y) > abs(translation.x) { controller.presentGallery(fromGesture: true) gestureRecognizer.isEnabled = false gestureRecognizer.isEnabled = true } } case .ended, .cancelled: let velocity = gestureRecognizer.velocity(in: self.view) let transitionFraction = 1.0 - max(0.0, translation.x * -1.0) / self.frame.width controller.completeWithTransitionProgress(transitionFraction, velocity: abs(velocity.x), dismissing: true) self.isDismissing = false default: break } } @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { let location = gestureRecognizer.location(in: mainPreviewView) let point = mainPreviewView.cameraPoint(for: location) self.camera.focus(at: point, autoFocus: false) } @objc private func handleDoubleTap(_ gestureRecognizer: UITapGestureRecognizer) { self.toggleCameraPositionAction.invoke(Void()) } private var pipTranslation: CGPoint? @objc private func handlePipPan(_ gestureRecognizer: UIPanGestureRecognizer) { guard let layout = self.validLayout else { return } let translation = gestureRecognizer.translation(in: self.view) let location = gestureRecognizer.location(in: self.view) let velocity = gestureRecognizer.velocity(in: self.view) switch gestureRecognizer.state { case .began, .changed: self.pipTranslation = translation self.containerLayoutUpdated(layout: layout, transition: .immediate) case .ended, .cancelled: self.pipTranslation = nil self.pipPosition = pipPositionForLocation(layout: layout, position: location, velocity: velocity) self.containerLayoutUpdated(layout: layout, transition: .spring(duration: 0.4)) default: break } } func animateDualCameraPositionSwitch() { let duration: Double = 0.5 let timingFunction = kCAMediaTimingFunctionSpring var snapshotView: UIView? if let mainSnapshot = self.mainPreviewContainerView.snapshotView(afterScreenUpdates: false) { mainSnapshot.frame = self.mainPreviewContainerView.frame self.mainPreviewContainerView.superview?.insertSubview(mainSnapshot, belowSubview: self.mainPreviewContainerView) snapshotView = mainSnapshot } CATransaction.begin() CATransaction.setDisableActions(true) self.requestUpdateLayout(hasAppeared: false, transition: .immediate) CATransaction.commit() self.additionalPreviewContainerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.additionalPreviewContainerView.layer.animateScale(from: 0.01, to: 1.0, duration: duration, timingFunction: timingFunction) self.mainPreviewContainerView.layer.animate( from: self.additionalPreviewContainerView.layer.cornerRadius as NSNumber, to: 12.0 as NSNumber, keyPath: "cornerRadius", timingFunction: timingFunction, duration: duration ) self.mainPreviewContainerView.layer.animatePosition( from: self.additionalPreviewContainerView.center, to: self.mainPreviewContainerView.center, duration: duration, timingFunction: timingFunction ) let scale = self.additionalPreviewContainerView.frame.width / self.mainPreviewContainerView.frame.width self.mainPreviewContainerView.layer.animateScale( from: scale, to: 1.0, duration: duration, timingFunction: timingFunction ) self.mainPreviewContainerView.layer.animateBounds( from: CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((self.mainPreviewContainerView.bounds.height - self.mainPreviewContainerView.bounds.width) / 2.0)), size: CGSize(width: self.mainPreviewContainerView.bounds.width, height: self.mainPreviewContainerView.bounds.width)), to: self.mainPreviewContainerView.bounds, duration: duration, timingFunction: timingFunction, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() } ) } func animateIn() { self.transitionDimView.alpha = 0.0 self.backgroundView.alpha = 0.0 UIView.animate(withDuration: 0.4, animations: { self.backgroundView.alpha = 1.0 }) if let layout = self.validLayout, case .regular = layout.metrics.widthClass { self.controller?.statusBar.updateStatusBarStyle(.Hide, animated: true) } if let transitionIn = self.controller?.transitionIn, let sourceView = transitionIn.sourceView { let sourceLocalFrame = sourceView.convert(transitionIn.sourceRect, to: self.view) let sourceScale = sourceLocalFrame.width / self.previewContainerView.frame.width self.previewContainerView.layer.animatePosition(from: sourceLocalFrame.center, to: self.previewContainerView.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in self.requestUpdateLayout(hasAppeared: true, transition: .immediate) }) self.previewContainerView.layer.animateScale(from: sourceScale, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) let minSide = min(self.previewContainerView.bounds.width, self.previewContainerView.bounds.height) self.previewContainerView.layer.animateBounds(from: CGRect(origin: CGPoint(x: (self.previewContainerView.bounds.width - minSide) / 2.0, y: (self.previewContainerView.bounds.height - minSide) / 2.0), size: CGSize(width: minSide, height: minSide)), to: self.previewContainerView.bounds, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) self.previewContainerView.layer.animate( from: minSide / 2.0 as NSNumber, to: self.previewContainerView.layer.cornerRadius as NSNumber, keyPath: "cornerRadius", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.3 ) if let view = self.componentHost.view { view.layer.animatePosition(from: sourceLocalFrame.center, to: view.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) } } } func animateOut(completion: @escaping () -> Void) { self.camera.stopCapture(invalidate: true) UIView.animate(withDuration: 0.25, animations: { self.backgroundView.alpha = 0.0 }) if let transitionOut = self.controller?.transitionOut(false), let destinationView = transitionOut.destinationView { let destinationLocalFrame = destinationView.convert(transitionOut.destinationRect, to: self.view) let targetScale = destinationLocalFrame.width / self.previewContainerView.frame.width self.previewContainerView.layer.animatePosition(from: self.previewContainerView.center, to: destinationLocalFrame.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in completion() }) self.previewContainerView.layer.animateScale(from: 1.0, to: targetScale, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) let minSide = min(self.previewContainerView.bounds.width, self.previewContainerView.bounds.height) self.previewContainerView.layer.animateBounds(from: self.previewContainerView.bounds, to: CGRect(origin: CGPoint(x: (self.previewContainerView.bounds.width - minSide) / 2.0, y: (self.previewContainerView.bounds.height - minSide) / 2.0), size: CGSize(width: minSide, height: minSide)), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) self.previewContainerView.layer.animate( from: self.previewContainerView.layer.cornerRadius as NSNumber, to: minSide / 2.0 as NSNumber, keyPath: "cornerRadius", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.3, removeOnCompletion: false ) if let view = self.componentHost.view { view.layer.animatePosition(from: view.center, to: destinationLocalFrame.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) view.layer.animateScale(from: 1.0, to: targetScale, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) } } else { completion() } self.componentHost.view?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) self.previewContainerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.35, removeOnCompletion: false) } func animateOutToEditor() { self.cameraIsActive = false self.requestUpdateLayout(hasAppeared: self.hasAppeared, transition: .immediate) let transition = Transition(animation: .curve(duration: 0.2, curve: .easeInOut)) if let view = self.componentHost.findTaggedView(tag: cancelButtonTag) { view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2) transition.setAlpha(view: view, alpha: 0.0) } if let view = self.componentHost.findTaggedView(tag: dualButtonTag) { view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2) transition.setAlpha(view: view, alpha: 0.0) } if let view = self.componentHost.findTaggedView(tag: flashButtonTag) { view.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2) transition.setAlpha(view: view, alpha: 0.0) } if let view = self.componentHost.findTaggedView(tag: zoomControlTag) { transition.setAlpha(view: view, alpha: 0.0) } if let view = self.componentHost.findTaggedView(tag: captureControlsTag) as? CaptureControlsComponent.View { view.animateOutToEditor(transition: transition) } if let view = self.componentHost.findTaggedView(tag: modeControlTag) as? ModeComponent.View { view.animateOutToEditor(transition: transition) } } func pauseCameraCapture() { self.mainPreviewView.isEnabled = false self.additionalPreviewView.isEnabled = false Queue.mainQueue().after(0.3) { self.previewBlurPromise.set(true) } self.camera.stopCapture() self.cameraIsActive = false self.requestUpdateLayout(hasAppeared: self.hasAppeared, transition: .immediate) } func resumeCameraCapture() { if !self.mainPreviewView.isEnabled { if let snapshot = self.mainPreviewView.snapshotView(afterScreenUpdates: false) { self.mainPreviewView.addSubview(snapshot) self.mainPreviewSnapshotView = snapshot } if let snapshot = self.additionalPreviewView.snapshotView(afterScreenUpdates: false) { self.additionalPreviewView.addSubview(snapshot) self.additionalPreviewSnapshotView = snapshot } self.mainPreviewView.isEnabled = true self.additionalPreviewView.isEnabled = true self.camera.startCapture() if #available(iOS 13.0, *) { let _ = (self.mainPreviewView.isPreviewing |> filter { $0 } |> take(1)).start(next: { [weak self] _ in if let self { self.previewBlurPromise.set(false) } }) } else { Queue.mainQueue().after(1.0) { self.previewBlurPromise.set(false) } } self.cameraIsActive = true self.requestUpdateLayout(hasAppeared: self.hasAppeared, transition: .immediate) } } func animateInFromEditor(toGallery: Bool) { if !toGallery { self.resumeCameraCapture() self.cameraIsActive = true self.requestUpdateLayout(hasAppeared: self.hasAppeared, transition: .immediate) let transition = Transition(animation: .curve(duration: 0.2, curve: .easeInOut)) if let view = self.componentHost.findTaggedView(tag: cancelButtonTag) { view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) transition.setAlpha(view: view, alpha: 1.0) } if let view = self.componentHost.findTaggedView(tag: dualButtonTag) { view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) transition.setAlpha(view: view, alpha: 1.0) } if let view = self.componentHost.findTaggedView(tag: flashButtonTag) { view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) transition.setAlpha(view: view, alpha: 1.0) } if let view = self.componentHost.findTaggedView(tag: zoomControlTag) { view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) transition.setAlpha(view: view, alpha: 1.0) } if let view = self.componentHost.findTaggedView(tag: captureControlsTag) as? CaptureControlsComponent.View { view.animateInFromEditor(transition: transition) } if let view = self.componentHost.findTaggedView(tag: modeControlTag) as? ModeComponent.View { view.animateInFromEditor(transition: transition) } } } func updateModalTransitionFactor(_ value: CGFloat, transition: ContainedViewLayoutTransition) { guard let layout = self.validLayout, case .compact = layout.metrics.widthClass else { return } let progress = 1.0 - value let maxScale = (layout.size.width - 16.0 * 2.0) / layout.size.width let topInset: CGFloat = (layout.statusBarHeight ?? 0.0) + 5.0 let targetTopInset = ceil((layout.statusBarHeight ?? 0.0) - (layout.size.height - layout.size.height * maxScale) / 2.0) let deltaOffset = (targetTopInset - topInset) let scale = 1.0 * progress + (1.0 - progress) * maxScale let offset = (1.0 - progress) * deltaOffset transition.updateSublayerTransformScaleAndOffset(layer: self.containerView.layer, scale: scale, offset: CGPoint(x: 0.0, y: offset), beginWithCurrentState: true) } func presentDraftTooltip() { guard let sourceView = self.componentHost.findTaggedView(tag: galleryButtonTag), let absoluteLocation = sourceView.superview?.convert(sourceView.center, to: self.view) else { return } let location = CGRect(origin: CGPoint(x: absoluteLocation.x, y: absoluteLocation.y - 29.0), size: CGSize()) let controller = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: "Draft Saved"), location: .point(location, .bottom), displayDuration: .default, inset: 16.0, shouldDismissOnTouch: { _, _ in return .ignore }) self.controller?.present(controller, in: .current) } func presentDualCameraTooltip() { guard let sourceView = self.componentHost.findTaggedView(tag: dualButtonTag) else { return } let parentFrame = self.view.convert(self.bounds, to: nil) let absoluteFrame = sourceView.convert(sourceView.bounds, to: nil).offsetBy(dx: -parentFrame.minX, dy: 0.0) let location = CGRect(origin: CGPoint(x: absoluteFrame.midX, y: absoluteFrame.maxY + 3.0), size: CGSize()) let accountManager = self.context.sharedContext.accountManager let tooltipController = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: "Enable Dual Camera Mode"), location: .point(location, .top), displayDuration: .manual, inset: 16.0, shouldDismissOnTouch: { point, containerFrame in if containerFrame.contains(point) { let _ = ApplicationSpecificNotice.incrementStoriesDualCameraTip(accountManager: accountManager).start() return .dismiss(consume: true) } return .ignore }) self.controller?.present(tooltipController, in: .current) } func presentCameraTooltip() { guard let sourceView = self.componentHost.findTaggedView(tag: captureControlsTag) else { return } let parentFrame = self.view.convert(self.bounds, to: nil) let absoluteFrame = sourceView.convert(sourceView.bounds, to: nil).offsetBy(dx: -parentFrame.minX, dy: 0.0) let location = CGRect(origin: CGPoint(x: absoluteFrame.midX, y: absoluteFrame.minY + 3.0), size: CGSize()) let accountManager = self.context.sharedContext.accountManager let tooltipController = TooltipScreen(account: self.context.account, sharedContext: self.context.sharedContext, text: .plain(text: "Take photos or videos to share with all\nyour contacts or close friends at once."), textAlignment: .center, location: .point(location, .bottom), displayDuration: .custom(4.5), inset: 16.0, shouldDismissOnTouch: { point, containerFrame in if containerFrame.contains(point) { let _ = ApplicationSpecificNotice.incrementStoriesCameraTip(accountManager: accountManager).start() return .dismiss(consume: true) } return .ignore }) self.controller?.present(tooltipController, in: .current) } func maybePresentTooltips() { guard let layout = self.validLayout, case .compact = layout.metrics.widthClass else { return } let _ = (ApplicationSpecificNotice.incrementStoriesCameraTip(accountManager: self.context.sharedContext.accountManager) |> deliverOnMainQueue).start(next: { [weak self] count in guard let self else { return } if count > 1 { let _ = (ApplicationSpecificNotice.getStoriesDualCameraTip(accountManager: self.context.sharedContext.accountManager) |> deliverOnMainQueue).start(next: { [weak self] count in guard let self else { return } if count < 1 { self.presentDualCameraTooltip() } }) return } else { self.presentCameraTooltip() } }) } fileprivate func dismissAllTooltips() { guard let controller = self.controller else { return } controller.window?.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss() } }) controller.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.dismiss() } return true }) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let result = super.hitTest(point, with: event) if result == self.componentHost.view { if self.additionalPreviewContainerView.bounds.contains(self.view.convert(point, to: self.additionalPreviewContainerView)) { return self.additionalPreviewContainerView } else { return self.mainPreviewView } } return result } func requestUpdateLayout(hasAppeared: Bool, transition: Transition) { if let layout = self.validLayout { self.containerLayoutUpdated(layout: layout, forceUpdate: true, hasAppeared: hasAppeared, transition: transition) } } fileprivate var hasAppeared = false func containerLayoutUpdated(layout: ContainerViewLayout, forceUpdate: Bool = false, hasAppeared: Bool = false, transition: Transition) { guard let _ = self.controller else { return } let isFirstTime = self.validLayout == nil self.validLayout = layout let isTablet: Bool if case .regular = layout.metrics.widthClass { isTablet = true } else { isTablet = false } var topInset: CGFloat = (layout.statusBarHeight ?? 0.0) + 5.0 let previewSize: CGSize if isTablet { previewSize = CGSize(width: floorToScreenPixels(layout.size.height / 1.77778), height: layout.size.height) } else { previewSize = CGSize(width: layout.size.width, height: floorToScreenPixels(layout.size.width * 1.77778)) if layout.size.height < previewSize.height + 30.0 { topInset = 0.0 } } let bottomInset = layout.size.height - previewSize.height - topInset let panelWidth: CGFloat let previewFrame: CGRect let viewfinderFrame: CGRect if isTablet { previewFrame = CGRect(origin: .zero, size: layout.size) viewfinderFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - previewSize.width) / 2.0), y: 0.0), size: previewSize) panelWidth = viewfinderFrame.minX } else { previewFrame = CGRect(origin: CGPoint(x: 0.0, y: topInset), size: previewSize) viewfinderFrame = previewFrame panelWidth = 0.0 } let environment = ViewControllerComponentContainer.Environment( statusBarHeight: layout.statusBarHeight ?? 0.0, navigationHeight: 0.0, safeInsets: UIEdgeInsets( top: topInset, left: layout.safeInsets.left, bottom: bottomInset, right: layout.safeInsets.right ), inputHeight: layout.inputHeight ?? 0.0, metrics: layout.metrics, deviceMetrics: layout.deviceMetrics, orientation: nil, isVisible: true, theme: self.presentationData.theme, strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat, controller: { [weak self] in return self?.controller } ) var transition = transition if isFirstTime { transition = transition.withUserData(CameraScreenTransition.animateIn) } else if hasAppeared && !self.hasAppeared { self.hasAppeared = hasAppeared transition = transition.withUserData(CameraScreenTransition.finishedAnimateIn) self.maybePresentTooltips() } let componentSize = self.componentHost.update( transition: transition, component: AnyComponent( CameraScreenComponent( context: self.context, externalState: self.componentExternalState, camera: self.camera, updateState: self.updateState, hasAppeared: self.hasAppeared, isVisible: self.cameraIsActive && !self.hasGallery, panelWidth: panelWidth, animateFlipAction: self.animateFlipAction, animateShutter: { [weak self] in self?.mainPreviewContainerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) }, toggleCameraPositionAction: self.toggleCameraPositionAction, dismissAllTooltips: { [weak self] in self?.dismissAllTooltips() }, present: { [weak self] c in self?.controller?.present(c, in: .window(.root)) }, push: { [weak self] c in self?.controller?.push(c) }, completion: self.completion ) ), environment: { environment }, forceUpdate: forceUpdate, containerSize: layout.size ) if let componentView = self.componentHost.view { if componentView.superview == nil { self.containerView.insertSubview(componentView, belowSubview: transitionDimView) componentView.clipsToBounds = true } let componentFrame = CGRect(origin: .zero, size: componentSize) transition.setFrame(view: componentView, frame: componentFrame) } if let view = self.componentHost.findTaggedView(tag: flashButtonTag), view.layer.shadowOpacity.isZero { view.layer.shadowOffset = CGSize(width: 0.0, height: 0.0) view.layer.shadowRadius = 3.0 view.layer.shadowColor = UIColor.black.cgColor view.layer.shadowOpacity = 0.25 } transition.setPosition(view: self.backgroundView, position: CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0)) transition.setBounds(view: self.backgroundView, bounds: CGRect(origin: .zero, size: layout.size)) if !self.hasGallery { transition.setPosition(view: self.containerView, position: CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0)) } transition.setBounds(view: self.containerView, bounds: CGRect(origin: .zero, size: layout.size)) transition.setFrame(view: self.transitionDimView, frame: CGRect(origin: .zero, size: layout.size)) let previewContainerFrame: CGRect if isTablet { previewContainerFrame = CGRect(origin: .zero, size: layout.size) } else { previewContainerFrame = previewFrame } transition.setFrame(view: self.previewContainerView, frame: previewContainerFrame) transition.setFrame(view: self.mainPreviewContainerView, frame: CGRect(origin: .zero, size: previewContainerFrame.size)) transition.setFrame(view: self.previewBlurView, frame: CGRect(origin: .zero, size: previewContainerFrame.size)) let dualCamUpdated = self.appliedDualCam != self.isDualCamEnabled self.appliedDualCam = self.isDualCamEnabled let circleSide = floorToScreenPixels(previewSize.width * 160.0 / 430.0) let circleOffset = CGPoint(x: previewSize.width * 224.0 / 1080.0, y: previewSize.width * 477.0 / 1080.0) var origin: CGPoint switch self.pipPosition { case .topLeft: origin = CGPoint(x: circleOffset.x, y: circleOffset.y) if !self.isDualCamEnabled { origin = origin.offsetBy(dx: -180.0, dy: 0.0) } case .topRight: origin = CGPoint(x: previewFrame.width - circleOffset.x, y: circleOffset.y) if !self.isDualCamEnabled { origin = origin.offsetBy(dx: 180.0, dy: 0.0) } case .bottomLeft: origin = CGPoint(x: circleOffset.x, y: previewFrame.height - circleOffset.y) if !self.isDualCamEnabled { origin = origin.offsetBy(dx: -180.0, dy: 0.0) } case .bottomRight: origin = CGPoint(x: previewFrame.width - circleOffset.x, y: previewFrame.height - circleOffset.y) if !self.isDualCamEnabled { origin = origin.offsetBy(dx: 180.0, dy: 0.0) } } if let pipTranslation = self.pipTranslation { origin = origin.offsetBy(dx: pipTranslation.x, dy: pipTranslation.y) } let additionalPreviewInnerSize = previewFrame.size.aspectFilled(CGSize(width: circleSide, height: circleSide)) let additionalPreviewFrame = CGRect(origin: CGPoint(x: origin.x - circleSide / 2.0, y: origin.y - circleSide / 2.0), size: CGSize(width: circleSide, height: circleSide)) transition.setPosition(view: self.additionalPreviewContainerView, position: additionalPreviewFrame.center) transition.setBounds(view: self.additionalPreviewContainerView, bounds: CGRect(origin: .zero, size: additionalPreviewFrame.size)) self.additionalPreviewContainerView.layer.cornerRadius = additionalPreviewFrame.width / 2.0 transition.setScale(view: self.additionalPreviewContainerView, scale: self.isDualCamEnabled ? 1.0 : 0.1) transition.setAlpha(view: self.additionalPreviewContainerView, alpha: self.isDualCamEnabled ? 1.0 : 0.0) if dualCamUpdated && self.isDualCamEnabled { if self.cameraPosition == .back { self.additionalPreviewView.resetPlaceholder(front: true) } else { self.mainPreviewView.resetPlaceholder(front: false) } } var mainPreviewInnerSize = previewFrame.size let mainPreviewView: CameraSimplePreviewView let additionalPreviewView: CameraSimplePreviewView if self.cameraPosition == .front && self.isDualCamEnabled { mainPreviewView = self.additionalPreviewView additionalPreviewView = self.mainPreviewView mainPreviewInnerSize = CGSize(width: floorToScreenPixels(mainPreviewInnerSize.height / 3.0 * 4.0), height: mainPreviewInnerSize.height) } else { mainPreviewView = self.mainPreviewView additionalPreviewView = self.additionalPreviewView } let mainPreviewInnerFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((previewFrame.width - mainPreviewInnerSize.width) / 2.0), y: floorToScreenPixels((previewFrame.height - mainPreviewInnerSize.height) / 2.0)), size: mainPreviewInnerSize) let additionalPreviewInnerFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((circleSide - additionalPreviewInnerSize.width) / 2.0), y: floorToScreenPixels((circleSide - additionalPreviewInnerSize.height) / 2.0)), size: additionalPreviewInnerSize) if mainPreviewView.superview != self.mainPreviewContainerView { self.mainPreviewContainerView.insertSubview(mainPreviewView, at: 0) } if additionalPreviewView.superview != self.additionalPreviewContainerView { self.additionalPreviewContainerView.insertSubview(additionalPreviewView, at: 0) } mainPreviewView.frame = mainPreviewInnerFrame additionalPreviewView.frame = additionalPreviewInnerFrame self.previewFrameLeftDimView.isHidden = !isTablet transition.setFrame(view: self.previewFrameLeftDimView, frame: CGRect(origin: .zero, size: CGSize(width: viewfinderFrame.minX, height: viewfinderFrame.height))) self.previewFrameRightDimView.isHidden = !isTablet transition.setFrame(view: self.previewFrameRightDimView, frame: CGRect(origin: CGPoint(x: viewfinderFrame.maxX, y: 0.0), size: CGSize(width: viewfinderFrame.minX + 1.0, height: viewfinderFrame.height))) let screenCornerRadius = layout.deviceMetrics.screenCornerRadius if screenCornerRadius > 0.0, self.transitionCornersView.image == nil { self.transitionCornersView.image = generateImage(CGSize(width: screenCornerRadius, height: screenCornerRadius * 3.0), rotatedContext: { size, context in context.setFillColor(UIColor.black.cgColor) context.fill(CGRect(origin: .zero, size: size)) context.setBlendMode(.clear) let path = UIBezierPath(roundedRect: CGRect(origin: .zero, size: CGSize(width: size.width * 2.0, height: size.height)), cornerRadius: size.width) context.addPath(path.cgPath) context.fillPath() })?.stretchableImage(withLeftCapWidth: 0, topCapHeight: Int(screenCornerRadius)) } self.transitionCornersView.isHidden = isTablet transition.setPosition(view: self.transitionCornersView, position: CGPoint(x: layout.size.width + screenCornerRadius / 2.0, y: layout.size.height / 2.0)) transition.setBounds(view: self.transitionCornersView, bounds: CGRect(origin: .zero, size: CGSize(width: screenCornerRadius, height: layout.size.height))) if isTablet && isFirstTime { self.animateIn() } } } fileprivate var node: Node { return self.displayNode as! Node } private let context: AccountContext fileprivate let mode: Mode fileprivate let holder: CameraHolder? fileprivate let transitionIn: TransitionIn? fileprivate let transitionOut: (Bool) -> TransitionOut? public final class ResultTransition { public weak var sourceView: UIView? public let sourceRect: CGRect public let sourceImage: UIImage? public let transitionOut: (Bool?) -> (UIView, CGRect)? public init( sourceView: UIView, sourceRect: CGRect, sourceImage: UIImage?, transitionOut: @escaping (Bool?) -> (UIView, CGRect)? ) { self.sourceView = sourceView self.sourceRect = sourceRect self.sourceImage = sourceImage self.transitionOut = transitionOut } } fileprivate let completion: (Signal, ResultTransition?, @escaping () -> Void) -> Void public var transitionedIn: () -> Void = {} private var audioSessionDisposable: Disposable? private let hapticFeedback = HapticFeedback() private var validLayout: ContainerViewLayout? public init( context: AccountContext, mode: Mode, holder: CameraHolder? = nil, transitionIn: TransitionIn?, transitionOut: @escaping (Bool) -> TransitionOut?, completion: @escaping (Signal, ResultTransition?, @escaping () -> Void) -> Void ) { self.context = context self.mode = mode self.holder = holder self.transitionIn = transitionIn self.transitionOut = transitionOut self.completion = completion super.init(navigationBarPresentationData: nil) self.statusBar.statusBarStyle = .Ignore self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait) self.navigationPresentation = .flatModal self.requestAudioSession() } required public init(coder: NSCoder) { preconditionFailure() } deinit { self.audioSessionDisposable?.dispose() if #available(iOS 13.0, *) { try? AVAudioSession.sharedInstance().setAllowHapticsAndSystemSoundsDuringRecording(false) } } override public func loadDisplayNode() { self.displayNode = Node(controller: self) super.displayNodeDidLoad() } private func requestAudioSession() { self.audioSessionDisposable = self.context.sharedContext.mediaManager.audioSession.push(audioSessionType: .recordWithOthers, activate: { _ in if #available(iOS 13.0, *) { try? AVAudioSession.sharedInstance().setAllowHapticsAndSystemSoundsDuringRecording(true) } }, deactivate: { _ in return .single(Void()) }) } private var galleryController: ViewController? public func returnFromEditor() { self.node.animateInFromEditor(toGallery: self.galleryController?.displayNode.supernode != nil) } private var didStopCameraCapture = false func presentGallery(fromGesture: Bool = false) { if !fromGesture { self.hapticFeedback.impact(.light) } self.node.dismissAllTooltips() self.node.hasGallery = true self.didStopCameraCapture = false let stopCameraCapture = { [weak self] in guard let self, !self.didStopCameraCapture else { return } self.didStopCameraCapture = true self.node.pauseCameraCapture() } let resumeCameraCapture = { [weak self] in guard let self, self.didStopCameraCapture else { return } self.didStopCameraCapture = false self.node.resumeCameraCapture() } let controller: ViewController if let current = self.galleryController { controller = current } else { controller = self.context.sharedContext.makeStoryMediaPickerScreen(context: self.context, getSourceRect: { [weak self] in if let self { if let galleryButton = self.node.componentHost.findTaggedView(tag: galleryButtonTag) { return galleryButton.convert(galleryButton.bounds, to: self.view).offsetBy(dx: 0.0, dy: -15.0) } else { return .zero } } else { return .zero } }, completion: { [weak self] result, transitionView, transitionRect, transitionImage, transitionOut, dismissed in if let self { stopCameraCapture() let resultTransition = ResultTransition( sourceView: transitionView, sourceRect: transitionRect, sourceImage: transitionImage, transitionOut: transitionOut ) if let asset = result as? PHAsset { self.completion(.single(.asset(asset)), resultTransition, dismissed) } else if let draft = result as? MediaEditorDraft { self.completion(.single(.draft(draft)), resultTransition, dismissed) } } }, dismissed: { [weak self] in resumeCameraCapture() if let self { self.node.hasGallery = false self.node.requestUpdateLayout(hasAppeared: self.node.hasAppeared, transition: .immediate) } }) self.galleryController = controller } controller.customModalStyleOverlayTransitionFactorUpdated = { [weak self, weak controller] transition in if let self, let controller { let transitionFactor = controller.modalStyleOverlayTransitionFactor if transitionFactor > 0.1 { stopCameraCapture() } self.node.updateModalTransitionFactor(transitionFactor, transition: transition) } } self.push(controller) self.requestLayout(transition: .immediate) } public func presentDraftTooltip() { self.node.presentDraftTooltip() } private var isDismissed = false fileprivate func requestDismiss(animated: Bool, interactive: Bool = false) { guard !self.isDismissed else { return } self.node.dismissAllTooltips() if !interactive { self.hapticFeedback.impact(.light) } self.node.camera.stopCapture(invalidate: true) self.isDismissed = true if animated { if let layout = self.validLayout, case .regular = layout.metrics.widthClass { self.statusBar.updateStatusBarStyle(.Ignore, animated: true) self.node.animateOut(completion: { self.dismiss(animated: false) }) } else { self.statusBar.updateStatusBarStyle(.Ignore, animated: true) if !interactive { if let navigationController = self.navigationController as? NavigationController { navigationController.updateRootContainerTransitionOffset(self.node.frame.width, transition: .immediate) } } self.updateTransitionProgress(0.0, transition: .animated(duration: 0.4, curve: .spring), completion: { [weak self] in self?.dismiss(animated: false) }) } } else { self.dismiss(animated: false) } } public func updateTransitionProgress(_ transitionFraction: CGFloat, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void = {}) { if let layout = self.validLayout, case .regular = layout.metrics.widthClass { return } if self.node.hasAppeared { self.node.dismissAllTooltips() } let transitionFraction = max(0.0, min(1.0, transitionFraction)) let offsetX = floorToScreenPixels((1.0 - transitionFraction) * self.node.frame.width * -1.0) transition.updateTransform(layer: self.node.backgroundView.layer, transform: CGAffineTransform(translationX: offsetX, y: 0.0)) let scale: CGFloat = max(0.8, min(1.0, 0.8 + 0.2 * transitionFraction)) if !self.node.hasGallery { transition.updateTransform(layer: self.node.containerView.layer, transform: CGAffineTransform(translationX: offsetX, y: 0.0)) transition.updateSublayerTransformScaleAndOffset(layer: self.node.containerView.layer, scale: scale, offset: CGPoint(x: -offsetX * 1.0 / scale * 0.5, y: 0.0), completion: { _ in completion() }) } else { completion() } let dimAlpha = 0.6 * (1.0 - transitionFraction) transition.updateAlpha(layer: self.node.transitionDimView.layer, alpha: dimAlpha) transition.updateTransform(layer: self.node.transitionCornersView.layer, transform: CGAffineTransform(translationX: offsetX, y: 0.0)) let sublayerOffsetX = offsetX * 1.0 / scale * 0.5 self.window?.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.view.layer.sublayerTransform = CATransform3DMakeTranslation(sublayerOffsetX, 0.0, 0.0) } }) self.forEachController({ controller in if let controller = controller as? TooltipScreen { controller.view.layer.sublayerTransform = CATransform3DMakeTranslation(sublayerOffsetX, 0.0, 0.0) } return true }) if let navigationController = self.navigationController as? NavigationController { let offsetX = floorToScreenPixels(transitionFraction * self.node.frame.width) navigationController.updateRootContainerTransitionOffset(offsetX, transition: transition) } } public func completeWithTransitionProgress(_ transitionFraction: CGFloat, velocity: CGFloat, dismissing: Bool) { if let layout = self.validLayout, case .regular = layout.metrics.widthClass { return } if dismissing { if transitionFraction < 0.7 || velocity < -1000.0 { self.statusBar.updateStatusBarStyle(.Ignore, animated: true) self.requestDismiss(animated: true, interactive: true) } else { self.statusBar.updateStatusBarStyle(.White, animated: true) self.updateTransitionProgress(1.0, transition: .animated(duration: 0.4, curve: .spring), completion: { [weak self] in if let self, let navigationController = self.navigationController as? NavigationController { navigationController.updateRootContainerTransitionOffset(0.0, transition: .immediate) } }) } } else { if transitionFraction > 0.33 || velocity > 1000.0 { self.statusBar.updateStatusBarStyle(.White, animated: true) self.updateTransitionProgress(1.0, transition: .animated(duration: 0.4, curve: .spring), completion: { [weak self] in if let self, let navigationController = self.navigationController as? NavigationController { navigationController.updateRootContainerTransitionOffset(0.0, transition: .immediate) self.node.requestUpdateLayout(hasAppeared: true, transition: .immediate) self.transitionedIn() } }) } else { self.statusBar.updateStatusBarStyle(.Ignore, animated: true) self.requestDismiss(animated: true, interactive: true) } } } public override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { if !flag { self.galleryController?.dismiss(animated: false) } super.dismiss(animated: flag, completion: completion) } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { self.validLayout = layout super.containerLayoutUpdated(layout, transition: transition) if !self.isDismissed { (self.displayNode as! Node).containerLayoutUpdated(layout: layout, transition: Transition(transition)) } } } private final class DualIconComponent: Component { typealias EnvironmentType = Empty let isSelected: Bool init( isSelected: Bool ) { self.isSelected = isSelected } static func ==(lhs: DualIconComponent, rhs: DualIconComponent) -> Bool { if lhs.isSelected != rhs.isSelected { return false } return true } final class View: UIView { private let iconView = UIImageView() private var component: DualIconComponent? private weak var state: EmptyComponentState? override init(frame: CGRect) { super.init(frame: frame) let image = generateImage(CGSize(width: 36.0, height: 36.0), contextGenerator: { size, context in context.clear(CGRect(origin: .zero, size: size)) if let image = UIImage(bundleImageName: "Camera/DualIcon"), let cgImage = image.cgImage { context.draw(cgImage, in: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - image.size.width) / 2.0), y: floorToScreenPixels((size.height - image.size.height) / 2.0) - 1.0), size: image.size)) } }) let selectedImage = generateImage(CGSize(width: 36.0, height: 36.0), contextGenerator: { size, context in context.clear(CGRect(origin: .zero, size: size)) context.setFillColor(UIColor.white.cgColor) context.fillEllipse(in: CGRect(origin: .zero, size: size)) if let image = UIImage(bundleImageName: "Camera/DualIcon"), let cgImage = image.cgImage { context.setBlendMode(.clear) context.clip(to: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - image.size.width) / 2.0), y: floorToScreenPixels((size.height - image.size.height) / 2.0) - 1.0), size: image.size), mask: cgImage) context.fill(CGRect(origin: .zero, size: size)) } }) self.iconView.image = image self.iconView.highlightedImage = selectedImage self.iconView.layer.shadowOffset = CGSize(width: 0.0, height: 0.0) self.iconView.layer.shadowRadius = 3.0 self.iconView.layer.shadowColor = UIColor.black.cgColor self.iconView.layer.shadowOpacity = 0.25 self.addSubview(self.iconView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(component: DualIconComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { self.component = component self.state = state let size = CGSize(width: 36.0, height: 36.0) self.iconView.frame = CGRect(origin: .zero, size: size) self.iconView.isHighlighted = component.isSelected return size } } public func makeView() -> View { return View(frame: CGRect()) } public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } } private func pipPositionForLocation(layout: ContainerViewLayout, position: CGPoint, velocity: CGPoint) -> CameraScreen.PIPPosition { var layoutInsets = layout.insets(options: [.input]) layoutInsets.bottom += 48.0 var result = CGPoint() if position.x < layout.size.width / 2.0 { result.x = 0.0 } else { result.x = 1.0 } if position.y < layoutInsets.top + (layout.size.height - layoutInsets.bottom - layoutInsets.top) / 2.0 { result.y = 0.0 } else { result.y = 1.0 } let currentPosition = result let angleEpsilon: CGFloat = 30.0 var shouldHide = false if (velocity.x * velocity.x + velocity.y * velocity.y) >= 500.0 * 500.0 { let x = velocity.x let y = velocity.y var angle = atan2(y, x) * 180.0 / CGFloat.pi * -1.0 if angle < 0.0 { angle += 360.0 } if currentPosition.x.isZero && currentPosition.y.isZero { if ((angle > 0 && angle < 90 - angleEpsilon) || angle > 360 - angleEpsilon) { result.x = 1.0 result.y = 0.0 } else if (angle > 180 + angleEpsilon && angle < 270 + angleEpsilon) { result.x = 0.0 result.y = 1.0 } else if (angle > 270 + angleEpsilon && angle < 360 - angleEpsilon) { result.x = 1.0 result.y = 1.0 } else { shouldHide = true } } else if !currentPosition.x.isZero && currentPosition.y.isZero { if (angle > 90 + angleEpsilon && angle < 180 + angleEpsilon) { result.x = 0.0 result.y = 0.0 } else if (angle > 270 - angleEpsilon && angle < 360 - angleEpsilon) { result.x = 1.0 result.y = 1.0 } else if (angle > 180 + angleEpsilon && angle < 270 - angleEpsilon) { result.x = 0.0 result.y = 1.0 } else { shouldHide = true } } else if currentPosition.x.isZero && !currentPosition.y.isZero { if (angle > 90 - angleEpsilon && angle < 180 - angleEpsilon) { result.x = 0.0 result.y = 0.0 } else if (angle < angleEpsilon || angle > 270 + angleEpsilon) { result.x = 1.0 result.y = 1.0 } else if (angle > angleEpsilon && angle < 90 - angleEpsilon) { result.x = 1.0 result.y = 0.0 } else if (!shouldHide) { shouldHide = true } } else if !currentPosition.x.isZero && !currentPosition.y.isZero { if (angle > angleEpsilon && angle < 90 + angleEpsilon) { result.x = 1.0 result.y = 0.0 } else if (angle > 180 - angleEpsilon && angle < 270 - angleEpsilon) { result.x = 0.0 result.y = 1.0 } else if (angle > 90 + angleEpsilon && angle < 180 - angleEpsilon) { result.x = 0.0 result.y = 0.0 } else if (!shouldHide) { shouldHide = true } } } var position: CameraScreen.PIPPosition = .topRight if result.x == 0.0 && result.y == 0.0 { position = .topLeft } else if result.x == 1.0 && result.y == 0.0 { position = .topRight } else if result.x == 0.0 && result.y == 1.0 { position = .bottomLeft } else if result.x == 1.0 && result.y == 1.0 { position = .bottomRight } return position }