2024-12-22 17:16:14 +04:00

1604 lines
64 KiB
Swift

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<CameraScreenImpl.Result, NoError>, 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<State>()
var state: Signal<State, NoError> {
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<CameraScreenImpl.Result, NoError>, 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<CameraScreenImpl.Result, NoError>]) {
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<CameraScreenImpl.Result, NoError> {
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<CameraScreenImpl.Result, NoError> {
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<Int64>()
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<UITouch>, 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<UITouch>, 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<UITouch>, 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<UITouch>, 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))
}
}