From ceaa808fadf119b8bdef181a45abf6ff06ccc04e Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Sun, 11 Jun 2023 22:17:00 +0400 Subject: [PATCH] Camera and editor improvements --- .../TelegramUI/Components/CameraScreen/BUILD | 1 + .../CameraScreen/Sources/CameraScreen.swift | 48 ++--- .../Sources/CaptureControlsComponent.swift | 21 ++- .../CameraScreen/Sources/ModeComponent.swift | 81 +++++++++ .../Sources/ShutterBlobView.swift | 164 +++++++++++++----- 5 files changed, 247 insertions(+), 68 deletions(-) diff --git a/submodules/TelegramUI/Components/CameraScreen/BUILD b/submodules/TelegramUI/Components/CameraScreen/BUILD index acf39becc8..91519fbd9c 100644 --- a/submodules/TelegramUI/Components/CameraScreen/BUILD +++ b/submodules/TelegramUI/Components/CameraScreen/BUILD @@ -73,6 +73,7 @@ swift_library( "//submodules/Components/BundleIconComponent:BundleIconComponent", "//submodules/TooltipUI", "//submodules/TelegramUI/Components/MediaEditor", + "//submodules/Components/MetalImageView:MetalImageView", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUI/Components/CameraScreen/Sources/CameraScreen.swift b/submodules/TelegramUI/Components/CameraScreen/Sources/CameraScreen.swift index c5bff678c3..228f829136 100644 --- a/submodules/TelegramUI/Components/CameraScreen/Sources/CameraScreen.swift +++ b/submodules/TelegramUI/Components/CameraScreen/Sources/CameraScreen.swift @@ -148,6 +148,7 @@ private final class CameraScreenComponent: CombinedComponent { var cameraState = CameraState(mode: .photo, position: .unspecified, flashMode: .off, flashModeDidChange: false, recording: .none, duration: 0.0) var swipeHint: CaptureControlsComponent.SwipeHint = .none + var isTransitioning = false private let hapticFeedback = HapticFeedback() @@ -267,6 +268,12 @@ private final class CameraScreenComponent: CombinedComponent { self.completion.invoke(.single(.video(path, transitionImage, PixelDimensions(width: 1080, height: 1920)))) } })) + self.isTransitioning = true + Queue.mainQueue().after(0.8, { + self.isTransitioning = false + self.updated(transition: .immediate) + }) + self.updated(transition: .spring(duration: 0.4)) } @@ -290,7 +297,7 @@ private final class CameraScreenComponent: CombinedComponent { let zoomControl = Child(ZoomComponent.self) let flashButton = Child(CameraButton.self) let modeControl = Child(ModeComponent.self) - let hintLabel = Child(MultilineTextComponent.self) + let hintLabel = Child(HintLabelComponent.self) let timeBackground = Child(RoundedRectangle.self) let timeLabel = Child(MultilineTextComponent.self) @@ -308,7 +315,7 @@ private final class CameraScreenComponent: CombinedComponent { state?.updateCameraMode(mode) }) - if case .none = state.cameraState.recording { + if case .none = state.cameraState.recording, !state.isTransitioning { let cancelButton = cancelButton.update( component: CameraButton( content: AnyComponentWithIdentity( @@ -420,17 +427,21 @@ private final class CameraScreenComponent: CombinedComponent { } let shutterState: ShutterButtonState - 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 + 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 + } } } @@ -505,7 +516,7 @@ private final class CameraScreenComponent: CombinedComponent { isVideoRecording = true } - if isVideoRecording { + 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( @@ -541,7 +552,7 @@ private final class CameraScreenComponent: CombinedComponent { let hintText: String? switch state.swipeHint { case .none: - hintText = nil + hintText = " " case .zoom: hintText = "Swipe up to zoom" case .lock: @@ -553,10 +564,7 @@ private final class CameraScreenComponent: CombinedComponent { } if let hintText { let hintLabel = hintLabel.update( - component: MultilineTextComponent( - text: .plain(NSAttributedString(string: hintText.uppercased(), font: Font.with(size: 14.0, design: .camera, weight: .semibold), textColor: .white)), - horizontalAlignment: .center - ), + component: HintLabelComponent(text: hintText), availableSize: availableSize, transition: .immediate ) @@ -569,7 +577,7 @@ private final class CameraScreenComponent: CombinedComponent { } } - if case .none = state.cameraState.recording { + if case .none = state.cameraState.recording, !state.isTransitioning { let modeControl = modeControl.update( component: ModeComponent( availableModes: [.photo, .video], diff --git a/submodules/TelegramUI/Components/CameraScreen/Sources/CaptureControlsComponent.swift b/submodules/TelegramUI/Components/CameraScreen/Sources/CaptureControlsComponent.swift index 9d332c01ed..fbc9b4f74e 100644 --- a/submodules/TelegramUI/Components/CameraScreen/Sources/CaptureControlsComponent.swift +++ b/submodules/TelegramUI/Components/CameraScreen/Sources/CaptureControlsComponent.swift @@ -11,6 +11,7 @@ enum ShutterButtonState: Equatable { case video case stopRecording case holdRecording(progress: Float) + case transition } private let maximumShutterSize = CGSize(width: 96.0, height: 96.0) @@ -141,6 +142,12 @@ private final class ShutterButtonContentComponent: Component { innerCornerRadius = innerSize.height / 2.0 ringSize = CGSize(width: 92.0, height: 92.0) recordingProgress = progress + case .transition: + innerColor = videoRedColor + innerSize = CGSize(width: 60.0, height: 60.0) + innerCornerRadius = innerSize.height / 2.0 + ringSize = CGSize(width: 68.0, height: 68.0) + recordingProgress = 0.0 } self.ringLayer.fillColor = UIColor.clear.cgColor @@ -573,6 +580,7 @@ final class CaptureControlsComponent: Component { let buttonSideInset: CGFloat = 28.0 //let buttonMaxOffset: CGFloat = 100.0 + var isTransitioning = false var isRecording = false var isHolding = false if case .stopRecording = component.shutterState { @@ -580,6 +588,8 @@ final class CaptureControlsComponent: Component { } else if case .holdRecording = component.shutterState { isRecording = true isHolding = true + } else if case .transition = component.shutterState { + isTransitioning = true } let galleryButtonSize = self.galleryButtonView.update( @@ -615,8 +625,8 @@ final class CaptureControlsComponent: Component { transition.setBounds(view: galleryButtonView, bounds: CGRect(origin: .zero, size: galleryButtonFrame.size)) transition.setPosition(view: galleryButtonView, position: galleryButtonFrame.center) - transition.setScale(view: galleryButtonView, scale: isRecording ? 0.1 : 1.0) - transition.setAlpha(view: galleryButtonView, alpha: isRecording ? 0.0 : 1.0) + transition.setScale(view: galleryButtonView, scale: isRecording || isTransitioning ? 0.1 : 1.0) + transition.setAlpha(view: galleryButtonView, alpha: isRecording || isTransitioning ? 0.0 : 1.0) } let _ = self.lockView.update( @@ -678,13 +688,16 @@ final class CaptureControlsComponent: Component { } 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) } var blobState: ShutterBlobView.BlobState switch component.shutterState { case .generic: blobState = .generic - case .video: + case .video, .transition: blobState = .video case .stopRecording: blobState = .stopVideo @@ -732,6 +745,8 @@ final class CaptureControlsComponent: Component { } 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) } let guideSpacing: CGFloat = 9.0 diff --git a/submodules/TelegramUI/Components/CameraScreen/Sources/ModeComponent.swift b/submodules/TelegramUI/Components/CameraScreen/Sources/ModeComponent.swift index 106cb5f70a..5214c9a816 100644 --- a/submodules/TelegramUI/Components/CameraScreen/Sources/ModeComponent.swift +++ b/submodules/TelegramUI/Components/CameraScreen/Sources/ModeComponent.swift @@ -2,6 +2,7 @@ import Foundation import UIKit import Display import ComponentFlow +import MultilineTextComponent extension CameraMode { var title: String { @@ -161,3 +162,83 @@ final class ModeComponent: Component { return view.update(component: self, availableSize: availableSize, transition: transition) } } + +final class HintLabelComponent: Component { + let text: String + + init( + text: String + ) { + self.text = text + } + + static func ==(lhs: HintLabelComponent, rhs: HintLabelComponent) -> Bool { + if lhs.text != rhs.text { + return false + } + return true + } + + final class View: UIView { + private var component: HintLabelComponent? + private var componentView = ComponentView() + + init() { + super.init(frame: CGRect()) + } + + required init?(coder aDecoder: NSCoder) { + preconditionFailure() + } + + func update(component: HintLabelComponent, availableSize: CGSize, transition: Transition) -> CGSize { + let previousComponent = self.component + self.component = component + + if let previousText = previousComponent?.text, !previousText.isEmpty && previousText != component.text { + if let componentView = self.componentView.view, let snapshotView = componentView.snapshotView(afterScreenUpdates: false) { + snapshotView.frame = componentView.frame + self.addSubview(snapshotView) + snapshotView.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2, removeOnCompletion: false) + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak snapshotView] _ in + snapshotView?.removeFromSuperview() + }) + } + + self.componentView.view?.removeFromSuperview() + self.componentView = ComponentView() + } + + let textSize = self.componentView.update( + transition: .immediate, + component: AnyComponent( + MultilineTextComponent( + text: .plain(NSAttributedString(string: component.text.uppercased(), font: Font.with(size: 14.0, design: .camera, weight: .semibold), textColor: .white)), + horizontalAlignment: .center + ) + ), + environment: {}, + containerSize: availableSize + ) + if let view = self.componentView.view { + if view.superview == nil { + view.layer.animateScale(from: 0.1, to: 1.0, duration: 0.2) + view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.addSubview(view) + } + + view.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((availableSize.width - textSize.width) / 2.0), y: 0.0), size: textSize) + } + + return CGSize(width: availableSize.width, height: textSize.height) + } + } + + 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) + } +} diff --git a/submodules/TelegramUI/Components/CameraScreen/Sources/ShutterBlobView.swift b/submodules/TelegramUI/Components/CameraScreen/Sources/ShutterBlobView.swift index b0948b9674..46cffa39d7 100644 --- a/submodules/TelegramUI/Components/CameraScreen/Sources/ShutterBlobView.swift +++ b/submodules/TelegramUI/Components/CameraScreen/Sources/ShutterBlobView.swift @@ -3,6 +3,7 @@ import Metal import MetalKit import ComponentFlow import Display +import MetalImageView private final class PropertyAnimation { let from: T @@ -63,6 +64,7 @@ private final class AnimatableProperty { } func tick(timestamp: Double) -> Bool { + guard let animation = self.animation, case let .curve(duration, curve) = animation.animation else { return false } @@ -73,8 +75,7 @@ private final class AnimatableProperty { case .easeInOut: t = listViewAnimationCurveEaseInOut(t) case .spring: - t = listViewAnimationCurveEaseInOut(t) - //t = listViewAnimationCurveSystem(t) + t = lookupSpringValue(t) case let .custom(x1, y1, x2, y2): t = bezierPoint(CGFloat(x1), CGFloat(y1), CGFloat(x2), CGFloat(y2), t) } @@ -88,7 +89,69 @@ private final class AnimatableProperty { } } -final class ShutterBlobView: MTKView, MTKViewDelegate { +private func lookupSpringValue(_ t: CGFloat) -> CGFloat { + let table: [(CGFloat, CGFloat)] = [ + (0.0, 0.0), + (0.0625, 0.1123005598783493), + (0.125, 0.31598418951034546), + (0.1875, 0.5103585720062256), + (0.25, 0.6650152802467346), + (0.3125, 0.777747631072998), + (0.375, 0.8557760119438171), + (0.4375, 0.9079672694206238), + (0.5, 0.942038357257843), + (0.5625, 0.9638798832893372), + (0.625, 0.9776856303215027), + (0.6875, 0.9863143563270569), + (0.75, 0.991658091545105), + (0.8125, 0.9949421286582947), + (0.875, 0.9969474077224731), + (0.9375, 0.9981651306152344), + (1.0, 1.0) + ] + + for i in 0 ..< table.count - 2 { + let lhs = table[i] + let rhs = table[i + 1] + + if t >= lhs.0 && t <= rhs.0 { + let fraction = (t - lhs.0) / (rhs.0 - lhs.0) + let value = lhs.1 + fraction * (rhs.1 - lhs.1) + return value + } + } + return 1.0 +// print("---start---") +// for i in 0 ..< 16 { +// let j = Double(i) * 1.0 / 16.0 +// print("\(j) \(listViewAnimationCurveSystem(j))") +// } +// print("---end---") +} + +private class ShutterBlobLayer: MetalImageLayer { + override public init() { + super.init() + + self.renderer.imageUpdated = { [weak self] image in + self?.contents = image + } + } + + override public init(layer: Any) { + super.init() + + if let layer = layer as? ShutterBlobLayer { + self.contents = layer.contents + } + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +final class ShutterBlobView: UIView { enum BlobState { case generic case video @@ -147,7 +210,6 @@ final class ShutterBlobView: MTKView, MTKViewDelegate { private let commandQueue: MTLCommandQueue private let drawPassthroughPipelineState: MTLRenderPipelineState - private var viewportDimensions = CGSize(width: 1, height: 1) private var displayLink: SharedDisplayLinkDriver.Link? @@ -162,6 +224,10 @@ final class ShutterBlobView: MTKView, MTKViewDelegate { private(set) var state: BlobState = .generic + static override var layerClass: AnyClass { + return ShutterBlobLayer.self + } + public init?(test: Bool) { let mainBundle = Bundle(for: ShutterBlobView.self) @@ -207,16 +273,12 @@ final class ShutterBlobView: MTKView, MTKViewDelegate { self.drawPassthroughPipelineState = try! device.makeRenderPipelineState(descriptor: pipelineStateDescriptor) - super.init(frame: CGRect(), device: device) + super.init(frame: CGRect()) + + (self.layer as! ShutterBlobLayer).renderer.device = device self.isOpaque = false self.backgroundColor = .clear - - self.colorPixelFormat = .bgra8Unorm - self.framebufferOnly = true - - self.isPaused = true - self.delegate = self self.displayLink = SharedDisplayLinkDriver.shared.add { [weak self] in self?.tick() @@ -232,10 +294,6 @@ final class ShutterBlobView: MTKView, MTKViewDelegate { self.displayLink?.invalidate() } - public func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { - self.viewportDimensions = size - } - func updateState(_ state: BlobState, transition: Transition = .immediate) { guard self.state != state else { return @@ -297,40 +355,56 @@ final class ShutterBlobView: MTKView, MTKViewDelegate { self.draw() } - override public func draw(_ rect: CGRect) { - self.redraw(drawable: self.currentDrawable!) + override func layoutSubviews() { + super.layoutSubviews() + + self.tick() } - - private func redraw(drawable: MTLDrawable) { - guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { + + private func getNextDrawable(layer: MetalImageLayer, drawableSize: CGSize) -> MetalImageLayer.Drawable? { + layer.renderer.drawableSize = drawableSize + return layer.renderer.nextDrawable() + } + + func draw() { + guard let layer = self.layer as? MetalImageLayer else { + return + } + self.updateAnimations() + + let drawableSize = CGSize(width: self.bounds.width * UIScreen.main.scale, height: self.bounds.height * UIScreen.main.scale) + + guard let drawable = self.getNextDrawable(layer: layer, drawableSize: drawableSize) else { return } - let renderPassDescriptor = self.currentRenderPassDescriptor! + let renderPassDescriptor = MTLRenderPassDescriptor() + renderPassDescriptor.colorAttachments[0].texture = drawable.texture renderPassDescriptor.colorAttachments[0].loadAction = .clear - renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0.0) + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) + + guard let commandBuffer = self.commandQueue.makeCommandBuffer() else { + return + } guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { return } - let viewportDimensions = self.viewportDimensions - renderEncoder.setViewport(MTLViewport(originX: 0.0, originY: 0.0, width: viewportDimensions.width, height: viewportDimensions.height, znear: -1.0, zfar: 1.0)) + renderEncoder.setViewport(MTLViewport(originX: 0.0, originY: 0.0, width: drawableSize.width, height: drawableSize.height, znear: -1.0, zfar: 1.0)) renderEncoder.setRenderPipelineState(self.drawPassthroughPipelineState) - let w = Float(1) - let h = Float(1) var vertices: [Float] = [ - w, -h, - -w, -h, - -w, h, - w, -h, - -w, h, - w, h + 1, -1, + -1, -1, + -1, 1, + 1, -1, + -1, 1, + 1, 1 ] renderEncoder.setVertexBytes(&vertices, length: 4 * vertices.count, index: 0) - var resolution = simd_uint2(UInt32(viewportDimensions.width), UInt32(viewportDimensions.height)) + var resolution = simd_uint2(UInt32(drawableSize.width), UInt32(drawableSize.height)) renderEncoder.setFragmentBytes(&resolution, length: MemoryLayout.size * 2, index: 0) var primaryParameters = simd_float4( @@ -340,7 +414,7 @@ final class ShutterBlobView: MTKView, MTKViewDelegate { Float(self.primaryCornerRadius.presentationValue) ) renderEncoder.setFragmentBytes(&primaryParameters, length: MemoryLayout.size, index: 1) - + var secondaryParameters = simd_float3( Float(self.secondarySize.presentationValue), Float(self.secondaryOffset.presentationValue), @@ -350,17 +424,17 @@ final class ShutterBlobView: MTKView, MTKViewDelegate { renderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6, instanceCount: 1) renderEncoder.endEncoding() - commandBuffer.present(drawable) + + var storedDrawable: MetalImageLayer.Drawable? = drawable + commandBuffer.addCompletedHandler { _ in + DispatchQueue.main.async { + autoreleasepool { + storedDrawable?.present(completion: {}) + storedDrawable = nil + } + } + } + commandBuffer.commit() } - - override func layoutSubviews() { - super.layoutSubviews() - - self.tick() - } - - func draw(in view: MTKView) { - - } }