import Foundation import AVFoundation import UIKit import Display import ComponentFlow import SwiftSignalKit import Camera import ContextUI import AccountContext import MetalEngine import TelegramPresentationData import Photos final class CameraCollage { final class CaptureResult { enum Content { case pending(CameraCollage.State.Item.Content.Placeholder?) case image(UIImage) case video(asset: AVAsset, thumbnail: UIImage?, duration: Double, source: CameraCollage.State.Item.Content.VideoSource) case failed } private var internalContent: Content private var disposable: Disposable? init(result: Signal, snapshotView: UIView?, contentUpdated: @escaping () -> Void) { self.internalContent = .pending(snapshotView.flatMap { .view($0) }) self.disposable = (result |> deliverOnMainQueue).start(next: { [weak self] value in guard let self else { return } switch value { case .pendingImage: contentUpdated() case let .image(image): self.internalContent = .image(image.image) contentUpdated() case let .video(video): self.internalContent = .pending(video.coverImage.flatMap { .image($0) }) contentUpdated() Queue.mainQueue().after(0.05, { let asset = AVURLAsset(url: URL(fileURLWithPath: video.videoPath)) self.internalContent = .video(asset: asset, thumbnail: video.coverImage, duration: video.duration, source: .file(video.videoPath)) contentUpdated() }) case let .asset(asset): let targetSize = CGSize(width: 256.0, height: 256.0) let options = PHImageRequestOptions() let deliveryMode: PHImageRequestOptionsDeliveryMode = .highQualityFormat options.deliveryMode = deliveryMode options.isNetworkAccessAllowed = true PHImageManager.default().requestImage( for: asset, targetSize: targetSize, contentMode: .aspectFit, options: options, resultHandler: { [weak self] image, info in if let image, let self { if let info { if let cancelled = info[PHImageCancelledKey] as? Bool, cancelled { return } } self.internalContent = .pending(.image(image)) contentUpdated() PHImageManager.default().requestAVAsset(forVideo: asset, options: nil, resultHandler: { [weak self] avAsset, _, _ in if let avAsset, let self { Queue.mainQueue().async { self.internalContent = .video(asset: avAsset, thumbnail: nil, duration: 0.0, source: .asset(asset)) contentUpdated() } } }) } } ) default: break } }) } deinit { self.disposable?.dispose() } var content: State.Item.Content? { switch self.internalContent { case let .pending(placeholder): return .pending(placeholder) case let .image(image): return .image(image) case let .video(asset, thumbnail, duration, source): return .video(asset, thumbnail, duration, source) case .failed: return nil } } } struct State { struct Item { enum Content { enum VideoSource { case file(String) case asset(PHAsset) } enum Placeholder { case view(UIView) case image(UIImage) } case empty case camera case pending(Placeholder?) case image(UIImage) case video(AVAsset, UIImage?, Double, VideoSource) } var uniqueId: Int64 var content: Content var isReady: Bool { switch self.content { case .image, .video: return true default: return false } } var isAlmostReady: Bool { switch self.content { case .image, .video, .pending: return true default: return false } } } struct Row { let items: [Item] } var grid: Camera.CollageGrid var progress: Float var innerProgress: Float var rows: [Row] } private var _state: State private var _statePromise = Promise() var state: Signal { return self._statePromise.get() } var grid: Camera.CollageGrid { didSet { if self.grid != oldValue { self._state.grid = self.grid if let cameraIndex = self.cameraIndex, cameraIndex > self.grid.count - 1 { self.cameraIndex = self.grid.count - 1 } self.updateState() } } } var results: [CaptureResult] var uniqueIds: [Int64] private(set) var cameraIndex: Int? init(grid: Camera.CollageGrid) { self.grid = grid self.results = [] self.uniqueIds = (0 ..< 6).map { _ in Int64.random(in: .min ... .max) } self._state = State( grid: grid, progress: 0.0, innerProgress: 0.0, rows: CameraCollage.computeRows(grid: grid, results: [], uniqueIds: self.uniqueIds, cameraIndex: self.cameraIndex) ) self.updateState() } func addResult(_ signal: Signal, snapshotView: UIView?) { guard self.results.count < self.grid.count else { return } let result = CaptureResult(result: signal, snapshotView: snapshotView, contentUpdated: { [weak self] in self?.checkResults() self?.updateState() }) if let cameraIndex = self.cameraIndex { self.cameraIndex = nil self.results.insert(result, at: cameraIndex) } else { self.results.append(result) } self.updateState() } func addResults(signals: [Signal]) { guard self.results.count < self.grid.count else { return } self.results.append(contentsOf: signals.map { CaptureResult(result: $0, snapshotView: nil, contentUpdated: { [weak self] in self?.checkResults() self?.updateState() }) }) self.updateState() } func moveItem(fromId: Int64, toId: Int64) { guard let fromIndex = self.uniqueIds.firstIndex(where: { $0 == fromId }), let toIndex = self.uniqueIds.firstIndex(where: { $0 == toId }), toIndex < self.results.count else { return } let fromItem = self.results[fromIndex] let toItem = self.results[toIndex] self.results[fromIndex] = toItem self.uniqueIds[fromIndex] = toId self.results[toIndex] = fromItem self.uniqueIds[toIndex] = fromId self.updateState() } func retakeItem(id: Int64) { guard let index = self.uniqueIds.firstIndex(where: { $0 == id }) else { return } self.cameraIndex = index self.results.remove(at: index) self.updateState() } func deleteItem(id: Int64) { guard let index = self.uniqueIds.firstIndex(where: { $0 == id }) else { return } self.results.remove(at: index) self.uniqueIds.removeAll(where: { $0 == id }) self.uniqueIds.append(Int64.random(in: .min ... .max)) } func getItem(id: Int64) -> CaptureResult? { guard let index = self.uniqueIds.firstIndex(where: { $0 == id }) else { return nil } return self.results[index] } private func checkResults() { self.results = self.results.filter { $0.content != nil } } private static func computeRows(grid: Camera.CollageGrid, results: [CaptureResult], uniqueIds: [Int64], cameraIndex: Int?) -> [State.Row] { var rows: [State.Row] = [] var index = 0 var contentIndex = 0 var addedCamera = false for row in grid.rows { var items: [State.Item] = [] for _ in 0 ..< row.columns { if index == cameraIndex { items.append(State.Item(uniqueId: uniqueIds[index], content: .camera)) addedCamera = true contentIndex -= 1 } else if contentIndex < results.count { if let content = results[contentIndex].content { items.append(State.Item(uniqueId: uniqueIds[index], content: content)) } else { items.append(State.Item(uniqueId: uniqueIds[index], content: .empty)) } } else if index == results.count && !addedCamera { items.append(State.Item(uniqueId: uniqueIds[index], content: .camera)) } else { items.append(State.Item(uniqueId: uniqueIds[index], content: .empty)) } index += 1 contentIndex += 1 } rows.append(State.Row(items: items)) } return rows } private static func computeProgress(rows: [State.Row], inner: Bool) -> Float { var readyCount: Int = 0 var totalCount: Int = 0 for row in rows { for item in row.items { if inner { if item.isAlmostReady { readyCount += 1 } } else { if item.isReady { readyCount += 1 } } totalCount += 1 } } guard totalCount > 0 else { return 0.0 } return Float(readyCount) / Float(totalCount) } private func updateState() { self._state.rows = CameraCollage.computeRows(grid: self._state.grid, results: self.results, uniqueIds: self.uniqueIds, cameraIndex: self.cameraIndex) self._state.progress = CameraCollage.computeProgress(rows: self._state.rows, inner: false) self._state.innerProgress = CameraCollage.computeProgress(rows: self._state.rows, inner: true) self._statePromise.set(.single(self._state)) } var isComplete: Bool { return self._state.progress > 1.0 - .ulpOfOne } func result(itemViews: [Int64 : CameraCollageView.ItemView]) -> Signal { guard self.isComplete else { return .complete() } var hasVideo = false let state = self._state outer: for row in state.rows { for item in row.items { if case .video = item.content { hasVideo = true break outer } } } let size = CGSize(width: 1080.0, height: 1920.0) let rowHeight: CGFloat = ceil(size.height / CGFloat(state.rows.count)) if hasVideo { var items: [CameraScreenImpl.Result.VideoCollage.Item] = [] var itemFrame: CGRect = .zero for row in state.rows { let columnWidth: CGFloat = floor(size.width / CGFloat(row.items.count)) itemFrame = CGRect(origin: itemFrame.origin, size: CGSize(width: columnWidth, height: rowHeight)) for item in row.items { let scale = itemViews[item.uniqueId]?.contentScale ?? 1.0 let offset = itemViews[item.uniqueId]?.contentOffset ?? .zero let content: CameraScreenImpl.Result.VideoCollage.Item.Content switch item.content { case let .image(image): content = .image(image) case let .video(_, _, duration, source): switch source { case let .file(path): content = .video(path, duration) case let .asset(asset): content = .asset(asset) } default: fatalError() } items.append(CameraScreenImpl.Result.VideoCollage.Item( content: content, frame: itemFrame, contentScale: scale, contentOffset: offset )) itemFrame.origin.x += columnWidth } itemFrame.origin.x = 0.0 itemFrame.origin.y += rowHeight } return .single(.videoCollage(CameraScreenImpl.Result.VideoCollage(items: items))) } else { let image = generateImage(size, contextGenerator: { size, context in var itemFrame: CGRect = .zero for row in state.rows { let columnWidth: CGFloat = ceil(size.width / CGFloat(row.items.count)) itemFrame = CGRect(origin: itemFrame.origin, size: CGSize(width: columnWidth, height: rowHeight)) for item in row.items { let scale = itemViews[item.uniqueId]?.contentScale ?? 1.0 let offset = itemViews[item.uniqueId]?.contentOffset ?? .zero let mappedItemFrame = CGRect(origin: CGPoint(x: itemFrame.minX, y: size.height - itemFrame.origin.y - rowHeight), size: CGSize(width: columnWidth, height: rowHeight)) if case let .image(image) = item.content { context.clip(to: mappedItemFrame) let drawingSize = image.size.aspectFilled(mappedItemFrame.size) let center = mappedItemFrame.center.offsetBy(dx: offset.x * mappedItemFrame.width, dy: offset.y * mappedItemFrame.height) let imageFrame = CGSize(width: drawingSize.width * scale, height: drawingSize.height * scale).centered(around: center) if let cgImage = image.cgImage { context.draw(cgImage, in: imageFrame, byTiling: false) } context.resetClip() } itemFrame.origin.x += columnWidth } itemFrame.origin.x = 0.0 itemFrame.origin.y += rowHeight } }, opaque: true, scale: 1.0) if let image { return .single(.image(CameraScreenImpl.Result.Image(image: image, additionalImage: nil, additionalImagePosition: .topLeft))) } else { return .single(.pendingImage) } } } } final class CameraCollageView: UIView, UIGestureRecognizerDelegate { final class PreviewLayer: SimpleLayer { var dispose: () -> Void = {} let contentLayer: MetalEngineSubjectLayer init(contentLayer: MetalEngineSubjectLayer) { self.contentLayer = contentLayer super.init() self.addSublayer(contentLayer) } required init?(coder: NSCoder) { preconditionFailure() } func update(size: CGSize, transition: ComponentTransition) { let filledSize = CGSize(width: 320.0, height: 568.0).aspectFilled(size) transition.setFrame(layer: self.contentLayer, frame: filledSize.centered(around: CGPoint(x: size.width / 2.0, y: size.height / 2.0))) } } final class ItemView: ContextControllerSourceView, UIScrollViewDelegate { private let extractedContainerView = ContextExtractedContentContainingView() private let scrollView = UIScrollView() private let clippingView = UIView() private let contentView = UIView() private var snapshotView: UIView? private var cameraContainerView: UIView? private var imageView: UIImageView? private var previewLayer: PreviewLayer? private var videoPlayer: AVPlayer? private var videoLayer: AVPlayerLayer? private var didPlayToEndTimeObserver: NSObjectProtocol? private var originalCameraTransform: CATransform3D? private var originalCameraFrame: CGRect? var contextAction: ((Int64, ContextExtractedContentContainingView, ContextGesture?) -> Void)? var contentScale: CGFloat { return self.scrollView.zoomScale } var contentOffset: CGPoint { return self.scrollView.offsetFromCenter } var isCamera: Bool { if case .camera = self.item?.content { return true } return false } var isReady: Bool { if case .image = self.item?.content { return true } if case .video = self.item?.content { return true } return false } var isPlaying: Bool { if let videoPlayer = self.videoPlayer { return videoPlayer.rate > 0.0 } else { return false } } var didPlayToEnd: (() -> Void) = {} override init(frame: CGRect) { super.init(frame: frame) self.scrollView.delegate = self self.scrollView.contentInsetAdjustmentBehavior = .never self.scrollView.contentInset = .zero self.scrollView.showsHorizontalScrollIndicator = false self.scrollView.showsVerticalScrollIndicator = false self.scrollView.decelerationRate = .fast //self.scrollView.panGestureRecognizer.minimumNumberOfTouches = 2 self.clippingView.clipsToBounds = true self.clippingView.isUserInteractionEnabled = false self.addSubview(self.extractedContainerView) self.isGestureEnabled = false self.targetViewForActivationProgress = self.extractedContainerView.contentView self.clipsToBounds = true self.extractedContainerView.contentView.clipsToBounds = true self.extractedContainerView.contentView.addSubview(self.scrollView) self.extractedContainerView.contentView.addSubview(self.clippingView) self.scrollView.addSubview(self.contentView) self.extractedContainerView.willUpdateIsExtractedToContextPreview = { [weak self] value, _ in guard let self else { return } let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut) if value { self.clippingView.layer.cornerRadius = 12.0 self.scrollView.layer.cornerRadius = 12.0 transition.updateSublayerTransformScale(layer: self.extractedContainerView.contentView.layer, scale: CGPoint(x: 0.9, y: 0.9)) } else { self.clippingView.layer.cornerRadius = 0.0 self.clippingView.layer.animate(from: NSNumber(value: Float(12.0)), to: NSNumber(value: Float(0.0)), keyPath: "cornerRadius", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2) self.scrollView.layer.cornerRadius = 0.0 self.scrollView.layer.animate(from: NSNumber(value: Float(12.0)), to: NSNumber(value: Float(0.0)), keyPath: "cornerRadius", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2) transition.updateSublayerTransformScale(layer: self.extractedContainerView.contentView.layer, scale: CGPoint(x: 1.0, y: 1.0)) } } self.activated = { [weak self] gesture, _ in guard let self, let item = self.item else { gesture.cancel() return } self.contextAction?(item.uniqueId, self.extractedContainerView, gesture) } } required init?(coder aDecoder: NSCoder) { preconditionFailure() } func requestContextAction() { guard let item = self.item, self.isReady else { return } self.contextAction?(item.uniqueId, self.extractedContainerView, nil) } func stopPlayback() { self.videoPlayer?.pause() } func resetPlayback() { self.videoPlayer?.seek(to: .zero) self.videoPlayer?.play() } var getPreviewLayer: () -> PreviewLayer? = { return nil } private var item: CameraCollage.State.Item? func update(item: CameraCollage.State.Item, size: CGSize, cameraContainerView: UIView?, transition: ComponentTransition) { self.item = item let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0) let bounds = CGRect(origin: .zero, size: size) var sizeUpdated = false if self.scrollView.frame.size.width > 0.0 && self.scrollView.frame.size != size { sizeUpdated = true } transition.setFrame(view: self.scrollView, frame: CGRect(origin: .zero, size: size)) switch item.content { case let .pending(placeholder): if let placeholder { let snapshotView: UIView switch placeholder { case let .view(view): snapshotView = view case let .image(image): if let current = self.snapshotView as? UIImageView { snapshotView = current } else { snapshotView = UIImageView(image: image) snapshotView.contentMode = .scaleAspectFill snapshotView.isUserInteractionEnabled = false snapshotView.frame = image.size.aspectFilled(size).centered(in: CGRect(origin: .zero, size: size)) } } self.snapshotView = snapshotView var snapshotTransition = transition if snapshotView.superview !== self.clippingView { snapshotTransition = .immediate self.clippingView.addSubview(snapshotView) let shutterLayer = SimpleLayer() shutterLayer.backgroundColor = UIColor.white.cgColor shutterLayer.frame = CGRect(origin: .zero, size: size) self.layer.addSublayer(shutterLayer) shutterLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in shutterLayer.removeFromSuperlayer() }) } let scale = max(size.width / snapshotView.bounds.width, size.height / snapshotView.bounds.height) snapshotTransition.setPosition(layer: snapshotView.layer, position: center) snapshotTransition.setTransform(layer: snapshotView.layer, transform: CATransform3DMakeScale(scale, scale, 1.0)) if let previewLayer = self.previewLayer { previewLayer.dispose() previewLayer.removeFromSuperlayer() self.previewLayer = nil } if let cameraContainerView = self.cameraContainerView { cameraContainerView.removeFromSuperview() self.cameraContainerView = nil } } case .camera: if let cameraContainerView { self.cameraContainerView = cameraContainerView if cameraContainerView.superview !== self.extractedContainerView.contentView { self.originalCameraTransform = CATransform3DIdentity self.originalCameraFrame = cameraContainerView.bounds self.clippingView.addSubview(cameraContainerView) } if let originalCameraFrame = self.originalCameraFrame { let scale = max(size.width / originalCameraFrame.width, size.height / originalCameraFrame.height) transition.setPosition(layer: cameraContainerView.layer, position: center) transition.setTransform(layer: cameraContainerView.layer, transform: CATransform3DMakeScale(scale, scale, 1.0)) } } if let imageView = self.imageView { imageView.superview?.bringSubviewToFront(imageView) imageView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in imageView.removeFromSuperview() }) self.imageView = nil } if let videoLayer = self.videoLayer { self.videoPlayer?.pause() self.videoPlayer = nil videoLayer.superlayer?.addSublayer(videoLayer) videoLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in videoLayer.removeFromSuperlayer() }) self.videoLayer = nil } if let snapshotView = self.snapshotView { snapshotView.removeFromSuperview() self.snapshotView = nil } if let previewLayer = self.previewLayer { cameraContainerView?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, completion: { _ in previewLayer.removeFromSuperlayer() }) self.previewLayer = nil } case .empty: if let cameraContainerView = self.cameraContainerView { cameraContainerView.removeFromSuperview() self.cameraContainerView = nil } if let snapshotView = self.snapshotView { snapshotView.removeFromSuperview() self.snapshotView = nil } var imageTransition = transition if self.previewLayer == nil, let previewLayer = self.getPreviewLayer() { imageTransition = .immediate self.previewLayer = previewLayer self.clippingView.layer.addSublayer(previewLayer) } if let imageView = self.imageView { imageView.superview?.bringSubviewToFront(imageView) imageView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in imageView.removeFromSuperview() }) self.imageView = nil } if let videoLayer = self.videoLayer { self.videoPlayer?.pause() self.videoPlayer = nil videoLayer.superlayer?.addSublayer(videoLayer) videoLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in videoLayer.removeFromSuperlayer() }) self.videoLayer = nil } if let previewLayer = self.previewLayer { previewLayer.update(size: size, transition: imageTransition) imageTransition.setFrame(layer: previewLayer, frame: CGRect(origin: .zero, size: size)) } case let .image(image): if let cameraContainerView = self.cameraContainerView { cameraContainerView.removeFromSuperview() self.cameraContainerView = nil } if let snapshotView = self.snapshotView { snapshotView.removeFromSuperview() self.snapshotView = nil } if let previewLayer = self.previewLayer { previewLayer.dispose() previewLayer.removeFromSuperlayer() self.previewLayer = nil } var added = false var imageTransition = transition var imageView: UIImageView if let current = self.imageView { imageView = current } else { imageTransition = .immediate imageView = UIImageView() imageView.contentMode = .scaleAspectFill self.imageView = imageView self.contentView.addSubview(imageView) added = true } imageView.image = image let dimensions = image.size.aspectFilled(size) imageTransition.setFrame(view: imageView, frame: CGRect(origin: .zero, size: dimensions)) if added || sizeUpdated { self.contentView.bounds = CGRect(origin: .zero, size: dimensions) self.scrollView.contentSize = dimensions self.scrollView.resetZooming() } case let .video(asset, _, _, _): if let cameraContainerView = self.cameraContainerView { cameraContainerView.removeFromSuperview() self.cameraContainerView = nil } var delayAppearance = false if let snapshotView = self.snapshotView { if snapshotView is UIImageView { } else { delayAppearance = true } Queue.mainQueue().after(0.2, { snapshotView.removeFromSuperview() }) self.snapshotView = nil } if let previewLayer = self.previewLayer { previewLayer.dispose() previewLayer.removeFromSuperlayer() self.previewLayer = nil } var added = false var imageTransition = transition if self.videoLayer == nil { imageTransition = .immediate let playerItem = AVPlayerItem(asset: asset) let player = AVPlayer(playerItem: playerItem) player.isMuted = true if self.didPlayToEndTimeObserver == nil { self.didPlayToEndTimeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: nil, using: { [weak self] notification in if let self { self.didPlayToEnd() } }) } let videoLayer = AVPlayerLayer(player: player) videoLayer.videoGravity = .resizeAspectFill if delayAppearance { videoLayer.opacity = 0.0 Queue.mainQueue().after(0.15, { videoLayer.opacity = 1.0 }) } self.videoLayer = videoLayer self.videoPlayer = player self.contentView.layer.addSublayer(videoLayer) player.playImmediately(atRate: 1.0) added = true } let dimensions = (asset.videoDimensions ?? CGSize(width: 1.0, height: 1.0)).aspectFilled(size) if let videoLayer = self.videoLayer { imageTransition.setFrame(layer: videoLayer, frame: CGRect(origin: .zero, size: dimensions)) } if added || sizeUpdated { self.contentView.bounds = CGRect(origin: .zero, size: dimensions) self.scrollView.contentSize = dimensions self.scrollView.resetZooming() } } self.adjustPreviewZoom(updating: true) transition.setFrame(view: self.extractedContainerView, frame: bounds) transition.setFrame(view: self.extractedContainerView.contentView, frame: bounds) transition.setBounds(view: self.clippingView, bounds: bounds) transition.setPosition(view: self.clippingView, position: bounds.center) self.extractedContainerView.contentRect = bounds } func animateIn(from size: CGSize, transition: ComponentTransition) { guard let cameraContainerView = self.cameraContainerView, let originalCameraFrame = self.originalCameraFrame else { return } self.extractedContainerView.contentView.clipsToBounds = false self.clippingView.clipsToBounds = false let scale = self.bounds.width / originalCameraFrame.width transition.animateScale(view: cameraContainerView, from: 1.0, to: scale) transition.animatePosition(view: cameraContainerView, from: originalCameraFrame.center, to: cameraContainerView.center, completion: { _ in self.extractedContainerView.contentView.clipsToBounds = true self.clippingView.clipsToBounds = true }) } func animateOut(to size: CGSize, transition: ComponentTransition, completion: @escaping () -> Void) { guard let cameraContainerView = self.cameraContainerView, let originalCameraFrame = self.originalCameraFrame else { return } self.extractedContainerView.contentView.clipsToBounds = false self.clippingView.clipsToBounds = false let scale = max(self.frame.width / originalCameraFrame.width, self.frame.height / originalCameraFrame.height) cameraContainerView.transform = CGAffineTransform.identity transition.animateScale(view: cameraContainerView, from: scale, to: 1.0) transition.setPosition(view: cameraContainerView, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0), completion: { _ in self.extractedContainerView.contentView.clipsToBounds = true self.clippingView.clipsToBounds = true completion() }) } private func adjustPreviewZoom(updating: Bool = false) { let minScale: CGFloat = 1.0 let maxScale: CGFloat = 3.5 if self.scrollView.minimumZoomScale != minScale { self.scrollView.minimumZoomScale = minScale } if self.scrollView.maximumZoomScale != maxScale { self.scrollView.maximumZoomScale = maxScale } let boundsSize = self.scrollView.frame.size var contentFrame = self.contentView.frame if boundsSize.width > contentFrame.size.width { contentFrame.origin.x = (boundsSize.width - contentFrame.size.width) / 2.0 } else { contentFrame.origin.x = 0.0 } if boundsSize.height > contentFrame.size.height { contentFrame.origin.y = (boundsSize.height - contentFrame.size.height) / 2.0 } else { contentFrame.origin.y = 0.0 } self.contentView.frame = contentFrame } func scrollViewDidZoom(_ scrollView: UIScrollView) { self.adjustPreviewZoom() } func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { self.adjustPreviewZoom() if scrollView.zoomScale < 1.0 { scrollView.setZoomScale(1.0, animated: true) } } func viewForZooming(in scrollView: UIScrollView) -> UIView? { return self.contentView } } private let context: AccountContext private let collage: CameraCollage private weak var camera: Camera? private weak var cameraContainerView: UIView? private var cameraVideoSource: CameraVideoSource? private var cameraVideoDisposable: Disposable? private let cameraVideoLayer = CameraVideoLayer() private let cloneLayers: [MetalEngineSubjectLayer] private var itemViews: [Int64: ItemView] = [:] private var state: CameraCollage.State? private var disposable: Disposable? private var reorderRecognizer: ReorderGestureRecognizer? private var reorderingItem: (id: Int64, initialPosition: CGPoint, position: CGPoint)? private var tapRecognizer: UITapGestureRecognizer? private var validLayout: CGSize? var getOverlayViews: (() -> [UIView])? var requestGridReduce: (() -> Void)? var isEnabled: Bool = true var result: Signal { return self.collage.result(itemViews: self.itemViews) } init(context: AccountContext, collage: CameraCollage, camera: Camera?, cameraContainerView: UIView?) { self.context = context self.collage = collage self.cameraContainerView = cameraContainerView self.cloneLayers = (0 ..< 6).map { _ in MetalEngineSubjectLayer() } self.cameraVideoLayer.blurredLayer.cloneLayers = self.cloneLayers super.init(frame: .zero) self.backgroundColor = .black self.disposable = (collage.state |> deliverOnMainQueue).start(next: { [weak self] state in guard let self else { return } let previousState = self.state self.state = state if let size = self.validLayout { var transition: ComponentTransition = .spring(duration: 0.3) if let previousState, previousState.grid == state.grid && previousState.innerProgress != state.innerProgress { transition = .immediate } var progressUpdated = false if let previousState, previousState.progress != state.progress { progressUpdated = true } self.updateLayout(size: size, transition: transition) if progressUpdated { self.resetPlayback() } } }) let reorderRecognizer = ReorderGestureRecognizer( shouldBegin: { [weak self] point in guard let self, let item = self.item(at: point) else { return (allowed: false, requiresLongPress: false, item: nil) } return (allowed: true, requiresLongPress: true, item: item) }, willBegin: { point in }, began: { [weak self] item in guard let self else { return } self.setReorderingItem(item: item) }, ended: { [weak self] in guard let self else { return } self.setReorderingItem(item: nil) }, moved: { [weak self] distance in guard let self else { return } self.moveReorderingItem(distance: distance) }, isActiveUpdated: { _ in } ) reorderRecognizer.delegate = self self.reorderRecognizer = reorderRecognizer self.addGestureRecognizer(reorderRecognizer) let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.handleTap)) self.tapRecognizer = tapRecognizer self.addGestureRecognizer(tapRecognizer) if let cameraVideoSource = CameraVideoSource() { self.cameraVideoLayer.video = cameraVideoSource.currentOutput camera?.setPreviewOutput(cameraVideoSource.cameraVideoOutput) self.cameraVideoSource = cameraVideoSource self.cameraVideoDisposable = cameraVideoSource.addOnUpdated { [weak self] in guard let self, let videoSource = self.cameraVideoSource, self.isEnabled else { return } self.cameraVideoLayer.video = videoSource.currentOutput } } let videoSize = CGSize(width: 160.0 * 2.0, height: 284.0 * 2.0) self.cameraVideoLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: videoSize) self.cameraVideoLayer.blurredLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: videoSize) self.cameraVideoLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(videoSize.width), height: Int(videoSize.height)), edgeInset: 2) } required init?(coder: NSCoder) { preconditionFailure() } deinit { self.disposable?.dispose() self.cameraVideoDisposable?.dispose() self.camera?.setPreviewOutput(nil) } func getPreviewLayer() -> PreviewLayer { var contentLayer = MetalEngineSubjectLayer() for layer in self.cloneLayers { if layer.superlayer?.superlayer == nil { contentLayer = layer break } } return PreviewLayer(contentLayer: contentLayer) } @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { self.reorderRecognizer?.isEnabled = false self.reorderRecognizer?.isEnabled = true let location = gestureRecognizer.location(in: self) if let itemView = self.item(at: location) { itemView.requestContextAction() } } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { if otherGestureRecognizer is UITapGestureRecognizer { return true } if otherGestureRecognizer is UIPanGestureRecognizer { if gestureRecognizer === self.reorderRecognizer, ![.began, .changed].contains(gestureRecognizer.state) { gestureRecognizer.isEnabled = false gestureRecognizer.isEnabled = true return true } else { return false } } return false } func item(at point: CGPoint) -> ItemView? { for (_, itemView) in self.itemViews { if itemView.frame.contains(point), itemView.isReady { return itemView } } return nil } func setReorderingItem(item: ItemView?) { self.tapRecognizer?.isEnabled = false self.tapRecognizer?.isEnabled = true var mappedItem: (Int64, ItemView)? if let item { for (id, visibleItem) in self.itemViews { if visibleItem === item { mappedItem = (id, visibleItem) break } } } if self.reorderingItem?.id != mappedItem?.0 { if let (id, itemView) = mappedItem { self.addSubview(itemView) self.reorderingItem = (id, itemView.center, itemView.center) } else { self.reorderingItem = nil } if let size = self.validLayout { self.updateLayout(size: size, transition: .spring(duration: 0.4)) } } } func moveReorderingItem(distance: CGPoint) { if let (id, initialPosition, _) = self.reorderingItem { let targetPosition = CGPoint(x: initialPosition.x + distance.x, y: initialPosition.y + distance.y) self.reorderingItem = (id, initialPosition, targetPosition) if let size = self.validLayout { self.updateLayout(size: size, transition: .immediate) } if let visibleReorderingItem = self.itemViews[id] { for (visibleId, visibleItem) in self.itemViews { if visibleItem === visibleReorderingItem { continue } if visibleItem.frame.contains(targetPosition) { self.collage.moveItem(fromId: id, toId: visibleId) break } } } } } func stopPlayback() { for (_, itemView) in self.itemViews { itemView.stopPlayback() } } func resetPlayback() { for (_, itemView) in self.itemViews { itemView.resetPlayback() } } func maybeResetPlayback() { var shouldResetPlayback = true for (_, itemView) in self.itemViews { if itemView.isPlaying { shouldResetPlayback = false break } } if shouldResetPlayback { self.resetPlayback() } } func animateIn(transition: ComponentTransition) { guard let size = self.validLayout, let (_, cameraItemView) = self.itemViews.first(where: { $0.value.isCamera }) else { return } let targetFrame = cameraItemView.frame let sourceFrame = CGRect(origin: .zero, size: size) cameraItemView.frame = sourceFrame transition.setFrame(view: cameraItemView, frame: targetFrame) cameraItemView.animateIn(from: sourceFrame.size, transition: transition) } func animateOut(transition: ComponentTransition, completion: @escaping () -> Void) { guard let size = self.validLayout else { completion() return } guard let (_, cameraItemView) = self.itemViews.first(where: { $0.value.isCamera }) else { if let cameraContainerView = self.cameraContainerView { cameraContainerView.transform = CGAffineTransform.identity cameraContainerView.frame = CGRect(origin: .zero, size: size) cameraContainerView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) cameraContainerView.layer.animateScale(from: 0.02, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in completion() }) self.addSubview(cameraContainerView) } return } cameraItemView.superview?.bringSubviewToFront(cameraItemView) let targetFrame = CGRect(origin: .zero, size: size) cameraItemView.animateOut(to: targetFrame.size, transition: transition, completion: completion) transition.setFrame(view: cameraItemView, frame: targetFrame) } var presentController: ((ViewController) -> Void)? func contextGesture(id: Int64, sourceView: ContextExtractedContentContainingView, gesture: ContextGesture?) { let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } var itemList: [ContextMenuItem] = [] if self.collage.cameraIndex == nil { itemList.append(.action(ContextMenuActionItem(text: presentationData.strings.Camera_CollageRetake, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Camera"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in f(.default) self?.collage.retakeItem(id: id) }))) } if self.itemViews.count > 2 { if itemList.count > 0 { itemList.append(.separator) } itemList.append(.action(ContextMenuActionItem(text: presentationData.strings.Camera_CollageDelete, textColor: .destructive, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { [weak self] _, f in f(.dismissWithoutContent) self?.collage.deleteItem(id: id) self?.requestGridReduce?() }))) } guard !itemList.isEmpty else { return } let items = ContextController.Items(content: .list(itemList), tip: .collageReordering) let controller = ContextController( presentationData: presentationData.withUpdated(theme: defaultDarkColorPresentationTheme), source: .extracted(CollageContextExtractedContentSource(contentView: sourceView)), items: .single(items), recognizer: nil, gesture: gesture ) controller.getOverlayViews = self.getOverlayViews self.presentController?(controller) } func updateLayout(size: CGSize, transition: ComponentTransition) { self.validLayout = size guard let state = self.state else { return } var validIds = Set() let rowHeight: CGFloat = ceil(size.height / CGFloat(state.rows.count)) var previousItemFrame: CGRect? var itemFrame: CGRect = .zero for row in state.rows { let columnWidth: CGFloat = floor(size.width / CGFloat(row.items.count)) itemFrame = CGRect(origin: itemFrame.origin, size: CGSize(width: columnWidth, height: rowHeight)) for item in row.items { let id = item.uniqueId validIds.insert(id) var effectiveItemFrame = itemFrame let itemScale: CGFloat let itemCornerRadius: CGFloat if let reorderingItem = self.reorderingItem, item.uniqueId == reorderingItem.id { itemScale = 0.9 itemCornerRadius = 12.0 effectiveItemFrame = itemFrame.size.centered(around: reorderingItem.position) } else { itemScale = 1.0 itemCornerRadius = 0.0 } var itemTransition = transition let itemView: ItemView if let current = self.itemViews[id] { itemView = current previousItemFrame = itemFrame } else { itemView = ItemView(frame: effectiveItemFrame) itemView.clipsToBounds = true itemView.getPreviewLayer = { [weak self] in return self?.getPreviewLayer() } itemView.didPlayToEnd = { [weak self] in self?.maybeResetPlayback() } self.insertSubview(itemView, at: 0) self.itemViews[id] = itemView if !transition.animation.isImmediate, let previousItemFrame { itemView.frame = previousItemFrame } else { itemTransition = .immediate } } itemView.update(item: item, size: effectiveItemFrame.size, cameraContainerView: self.cameraContainerView, transition: itemTransition) itemView.contextAction = { [weak self] id, sourceView, gesture in guard let self else { return } self.contextGesture(id: id, sourceView: sourceView, gesture: gesture) } itemTransition.setBounds(view: itemView, bounds: CGRect(origin: .zero, size: effectiveItemFrame.size)) itemTransition.setPosition(view: itemView, position: effectiveItemFrame.center) itemTransition.setScale(view: itemView, scale: itemScale) if !itemTransition.animation.isImmediate { let cornerTransition: ComponentTransition if itemCornerRadius > 0.0 { cornerTransition = ComponentTransition(animation: .curve(duration: 0.1, curve: .linear)) } else { cornerTransition = .easeInOut(duration: 0.4) } cornerTransition.setCornerRadius(layer: itemView.layer, cornerRadius: itemCornerRadius) } else { itemTransition.setCornerRadius(layer: itemView.layer, cornerRadius: itemCornerRadius) } itemFrame.origin.x += columnWidth } itemFrame.origin.x = 0.0 itemFrame.origin.y += rowHeight } var removeIds: [Int64] = [] for (id, itemView) in self.itemViews { if !validIds.contains(id) { removeIds.append(id) transition.setAlpha(view: itemView, alpha: 0.0, completion: { [weak itemView] _ in itemView?.removeFromSuperview() }) } } for id in removeIds { self.itemViews.removeValue(forKey: id) } } } private final class ReorderGestureRecognizer: UIGestureRecognizer { private let shouldBegin: (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, item: CameraCollageView.ItemView?) private let willBegin: (CGPoint) -> Void private let began: (CameraCollageView.ItemView) -> Void private let ended: () -> Void private let moved: (CGPoint) -> Void private let isActiveUpdated: (Bool) -> Void private var initialLocation: CGPoint? private var longTapTimer: SwiftSignalKit.Timer? private var longPressTimer: SwiftSignalKit.Timer? private var itemView: CameraCollageView.ItemView? public init(shouldBegin: @escaping (CGPoint) -> (allowed: Bool, requiresLongPress: Bool, item: CameraCollageView.ItemView?), willBegin: @escaping (CGPoint) -> Void, began: @escaping (CameraCollageView.ItemView) -> Void, ended: @escaping () -> Void, moved: @escaping (CGPoint) -> Void, isActiveUpdated: @escaping (Bool) -> Void) { self.shouldBegin = shouldBegin self.willBegin = willBegin self.began = began self.ended = ended self.moved = moved self.isActiveUpdated = isActiveUpdated super.init(target: nil, action: nil) } deinit { self.longTapTimer?.invalidate() self.longPressTimer?.invalidate() } private func startLongTapTimer() { self.longTapTimer?.invalidate() let longTapTimer = SwiftSignalKit.Timer(timeout: 0.25, repeat: false, completion: { [weak self] in self?.longTapTimerFired() }, queue: Queue.mainQueue()) self.longTapTimer = longTapTimer longTapTimer.start() } private func stopLongTapTimer() { self.itemView = nil self.longTapTimer?.invalidate() self.longTapTimer = nil } private func startLongPressTimer() { self.longPressTimer?.invalidate() let longPressTimer = SwiftSignalKit.Timer(timeout: 0.6, repeat: false, completion: { [weak self] in self?.longPressTimerFired() }, queue: Queue.mainQueue()) self.longPressTimer = longPressTimer longPressTimer.start() } private func stopLongPressTimer() { self.itemView = nil self.longPressTimer?.invalidate() self.longPressTimer = nil } override public func reset() { super.reset() self.itemView = nil self.stopLongTapTimer() self.stopLongPressTimer() self.initialLocation = nil self.isActiveUpdated(false) } private func longTapTimerFired() { guard let location = self.initialLocation else { return } self.longTapTimer?.invalidate() self.longTapTimer = nil self.willBegin(location) } private func longPressTimerFired() { guard let _ = self.initialLocation else { return } self.isActiveUpdated(true) self.state = .began self.longPressTimer?.invalidate() self.longPressTimer = nil self.longTapTimer?.invalidate() self.longTapTimer = nil if let itemView = self.itemView { self.began(itemView) } self.isActiveUpdated(true) } override public func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) if self.numberOfTouches > 1 { self.isActiveUpdated(false) self.state = .failed self.ended() return } if self.state == .possible { if let location = touches.first?.location(in: self.view) { let (allowed, requiresLongPress, itemView) = self.shouldBegin(location) if allowed { self.isActiveUpdated(true) self.itemView = itemView self.initialLocation = location if requiresLongPress { self.startLongTapTimer() self.startLongPressTimer() } else { self.state = .began if let itemView = self.itemView { self.began(itemView) } } } else { self.isActiveUpdated(false) self.state = .failed } } else { self.isActiveUpdated(false) self.state = .failed } } } override public func touchesEnded(_ touches: Set, with event: UIEvent) { super.touchesEnded(touches, with: event) self.initialLocation = nil self.stopLongTapTimer() if self.longPressTimer != nil { self.stopLongPressTimer() self.isActiveUpdated(false) self.state = .failed } if self.state == .began || self.state == .changed { self.isActiveUpdated(false) self.ended() self.state = .failed } } override public func touchesCancelled(_ touches: Set, with event: UIEvent) { super.touchesCancelled(touches, with: event) self.initialLocation = nil self.stopLongTapTimer() if self.longPressTimer != nil { self.isActiveUpdated(false) self.stopLongPressTimer() self.state = .failed } if self.state == .began || self.state == .changed { self.isActiveUpdated(false) self.ended() self.state = .failed } } override public func touchesMoved(_ touches: Set, with event: UIEvent) { super.touchesMoved(touches, with: event) if (self.state == .began || self.state == .changed), let initialLocation = self.initialLocation, let location = touches.first?.location(in: self.view) { self.state = .changed let offset = CGPoint(x: location.x - initialLocation.x, y: location.y - initialLocation.y) self.moved(offset) } else if let touch = touches.first, let initialTapLocation = self.initialLocation, self.longPressTimer != nil { let touchLocation = touch.location(in: self.view) let dX = touchLocation.x - initialTapLocation.x let dY = touchLocation.y - initialTapLocation.y if dX * dX + dY * dY > 3.0 * 3.0 { self.stopLongTapTimer() self.stopLongPressTimer() self.initialLocation = nil self.isActiveUpdated(false) self.state = .failed } } } } private final class CollageContextExtractedContentSource: ContextExtractedContentSource { let keepInPlace: Bool = false let ignoreContentTouches: Bool = false let blurBackground: Bool = true private let contentView: ContextExtractedContentContainingView init(contentView: ContextExtractedContentContainingView) { self.contentView = contentView } func takeView() -> ContextControllerTakeViewInfo? { return ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: UIScreen.main.bounds) } func putBack() -> ContextControllerPutBackViewInfo? { return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) } } private extension AVAsset { var videoDimensions: CGSize? { if let videoTrack = self.tracks(withMediaType: .video).first { let size = videoTrack.naturalSize let transform = videoTrack.preferredTransform let isPortrait = transform.a == 0 && abs(transform.b) == 1 && abs(transform.c) == 1 && transform.d == 0 return isPortrait ? CGSize(width: size.height, height: size.width) : size } return nil } } private extension UIScrollView { func resetZooming() { guard let contentView = self.delegate?.viewForZooming?(in: self) else { return } let scrollViewSize = self.bounds.size let contentSize = contentView.frame.size let offsetX = (scrollViewSize.width - contentSize.width) * 0.5 let offsetY = (scrollViewSize.height - contentSize.height) * 0.5 contentView.center = CGPoint( x: scrollViewSize.width / 2.0 + self.contentOffset.x, y: scrollViewSize.height / 2.0 + self.contentOffset.y ) self.contentOffset = CGPoint(x: -offsetX, y: -offsetY) } var offsetFromCenter: CGPoint { let contentCenterX = (self.contentSize.width - self.bounds.width) / 2.0 let contentCenterY = (self.contentSize.height - self.bounds.height) / 2.0 let contentCenter = CGPoint(x: contentCenterX, y: contentCenterY) let currentOffset = self.contentOffset let deltaX = currentOffset.x - contentCenter.x let deltaY = currentOffset.y - contentCenter.y return CGPoint(x: -(deltaX / self.bounds.width), y: (deltaY / self.bounds.height)) } }