Swiftgram/submodules/TelegramUI/Components/StorageUsageScreen/Sources/StorageMediaGridPanelComponent.swift
2023-04-20 18:43:29 +04:00

664 lines
27 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import ComponentFlow
import SwiftSignalKit
import ViewControllerComponent
import ComponentDisplayAdapters
import TelegramPresentationData
import AccountContext
import TelegramCore
import MultilineTextComponent
import EmojiStatusComponent
import TelegramStringFormatting
import CheckNode
import AvatarNode
import PhotoResources
import SemanticStatusNode
private let badgeFont = Font.regular(12.0)
private let videoIcon = generateTintedImage(image: UIImage(bundleImageName: "Chat List/MiniThumbnailPlay"), color: .white)
private final class MediaGridLayer: SimpleLayer {
enum SelectionState: Equatable {
case none
case editing(isSelected: Bool)
}
private(set) var message: EngineMessage?
private var disposable: Disposable?
private var size: CGSize?
private var selectionState: SelectionState = .none
private var theme: PresentationTheme?
private var checkLayer: CheckLayer?
private let badgeOverlay: SimpleLayer
override init() {
self.badgeOverlay = SimpleLayer()
self.badgeOverlay.contentsScale = UIScreenScale
self.badgeOverlay.contentsGravity = .topRight
super.init()
self.isOpaque = true
self.masksToBounds = true
self.contentsGravity = .resizeAspectFill
self.addSublayer(self.badgeOverlay)
}
override init(layer: Any) {
self.badgeOverlay = SimpleLayer()
guard let other = layer as? MediaGridLayer else {
preconditionFailure()
}
super.init(layer: other)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.disposable?.dispose()
}
func prepareForReuse() {
self.message = nil
if let disposable = self.disposable {
self.disposable = nil
disposable.dispose()
}
}
func setup(context: AccountContext, strings: PresentationStrings, message: EngineMessage, size: Int64) {
self.message = message
var isVideo = false
var dimensions: CGSize?
var signal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>?
for media in message.media {
if let file = media as? TelegramMediaFile, let representation = file.previewRepresentations.last {
isVideo = file.isVideo
signal = chatWebpageSnippetFile(
account: context.account,
userLocation: .peer(message.id.peerId),
mediaReference: FileMediaReference.standalone(media: file).abstract,
representation: representation,
automaticFetch: false
)
dimensions = representation.dimensions.cgSize
} else if let image = media as? TelegramMediaImage, let representation = image.representations.last {
signal = mediaGridMessagePhoto(
account: context.account,
userLocation: .peer(message.id.peerId),
photoReference: ImageMediaReference.standalone(media: image),
automaticFetch: false
)
dimensions = representation.dimensions.cgSize
}
}
if let signal, let dimensions {
self.disposable = (signal
|> map { generator -> UIImage? in
return generator(TransformImageArguments(corners: ImageCorners(radius: 0.0), imageSize: dimensions, boundingSize: CGSize(width: 100.0, height: 100.0), intrinsicInsets: UIEdgeInsets()))?.generateImage()
}
|> deliverOnMainQueue).start(next: { [weak self] image in
guard let self, let image else {
return
}
self.contents = image.cgImage
})
}
let text: String = dataSizeString(Int(size), formatting: DataSizeStringFormatting(strings: strings, decimalSeparator: "."))
let attributedText = NSAttributedString(string: text, font: badgeFont, textColor: .white)
let textBounds = attributedText.boundingRect(with: CGSize(width: 100.0, height: 100.0), options: .usesLineFragmentOrigin, context: nil)
let textSize = CGSize(width: ceil(textBounds.width), height: ceil(textBounds.height))
let textLeftInset: CGFloat
let textRightInset: CGFloat = 6.0
if isVideo {
textLeftInset = 18.0
} else {
textLeftInset = textRightInset
}
let badgeSize = CGSize(width: textLeftInset + textRightInset + textSize.width, height: 18.0)
self.badgeOverlay.contents = generateImage(badgeSize, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(UIColor(white: 0.0, alpha: 0.5).cgColor)
context.setBlendMode(.copy)
context.fillEllipse(in: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.height, height: size.height)))
context.fillEllipse(in: CGRect(origin: CGPoint(x: size.width - size.height, y: 0.0), size: CGSize(width: size.height, height: size.height)))
context.fill(CGRect(origin: CGPoint(x: size.height * 0.5, y: 0.0), size: CGSize(width: size.width - size.height, height: size.height)))
context.setBlendMode(.normal)
UIGraphicsPushContext(context)
if isVideo, let videoIcon {
videoIcon.draw(at: CGPoint(x: 2.0, y: floor((size.height - videoIcon.size.height) / 2.0)))
}
attributedText.draw(in: textBounds.offsetBy(dx: textLeftInset, dy: UIScreenPixel + floor((size.height - textSize.height) * 0.5)))
UIGraphicsPopContext()
})?.cgImage
}
func updateSelection(size: CGSize, selectionState: SelectionState, theme: PresentationTheme, transition: Transition) {
if self.size == size && self.selectionState == selectionState && self.theme === theme {
return
}
self.selectionState = selectionState
self.size = size
let themeUpdated = self.theme !== theme
self.theme = theme
switch selectionState {
case .none:
if let checkLayer = self.checkLayer {
self.checkLayer = nil
if !transition.animation.isImmediate {
checkLayer.animateScale(from: 1.0, to: 0.001, duration: 0.2, removeOnCompletion: false)
checkLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak checkLayer] _ in
checkLayer?.removeFromSuperlayer()
})
} else {
checkLayer.removeFromSuperlayer()
}
}
case let .editing(isSelected):
let checkWidth: CGFloat
if size.width <= 60.0 {
checkWidth = 22.0
} else {
checkWidth = 28.0
}
let checkSize = CGSize(width: checkWidth, height: checkWidth)
let checkFrame = CGRect(origin: CGPoint(x: self.bounds.size.width - checkSize.width - 2.0, y: 2.0), size: checkSize)
if let checkLayer = self.checkLayer {
if checkLayer.bounds.size != checkFrame.size {
checkLayer.setNeedsDisplay()
}
transition.setFrame(layer: checkLayer, frame: checkFrame)
if themeUpdated {
checkLayer.theme = CheckNodeTheme(theme: theme, style: .overlay)
}
checkLayer.setSelected(isSelected, animated: !transition.animation.isImmediate)
} else {
let checkLayer = CheckLayer(theme: CheckNodeTheme(theme: theme, style: .overlay))
self.checkLayer = checkLayer
self.addSublayer(checkLayer)
checkLayer.frame = checkFrame
checkLayer.setSelected(isSelected, animated: false)
checkLayer.setNeedsDisplay()
if !transition.animation.isImmediate {
checkLayer.animateScale(from: 0.001, to: 1.0, duration: 0.2)
checkLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
}
self.badgeOverlay.frame = CGRect(origin: CGPoint(x: size.width - 3.0, y: size.height - 3.0), size: CGSize(width: 0.0, height: 0.0))
}
}
private final class MediaGridLayerDataContext {
}
final class StorageMediaGridPanelComponent: Component {
typealias EnvironmentType = StorageUsagePanelEnvironment
final class Item: Equatable {
let message: EngineMessage
let size: Int64
init(
message: EngineMessage,
size: Int64
) {
self.message = message
self.size = size
}
static func ==(lhs: Item, rhs: Item) -> Bool {
if lhs.message.id != rhs.message.id {
return false
}
if lhs.size != rhs.size {
return false
}
return true
}
}
final class Items: Equatable {
let items: [Item]
init(items: [Item]) {
self.items = items
}
static func ==(lhs: Items, rhs: Items) -> Bool {
if lhs === rhs {
return true
}
return lhs.items == rhs.items
}
}
let context: AccountContext
let items: Items?
let selectionState: StorageUsageScreenComponent.SelectionState?
let action: (EngineMessage.Id) -> Void
let contextAction: (EngineMessage.Id, UIView, CGRect, ContextGesture) -> Void
init(
context: AccountContext,
items: Items?,
selectionState: StorageUsageScreenComponent.SelectionState?,
action: @escaping (EngineMessage.Id) -> Void,
contextAction: @escaping (EngineMessage.Id, UIView, CGRect, ContextGesture) -> Void
) {
self.context = context
self.items = items
self.selectionState = selectionState
self.action = action
self.contextAction = contextAction
}
static func ==(lhs: StorageMediaGridPanelComponent, rhs: StorageMediaGridPanelComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.items != rhs.items {
return false
}
if lhs.selectionState != rhs.selectionState {
return false
}
return true
}
private struct ItemLayout: Equatable {
var width: CGFloat
var itemCount: Int
var nativeItemSize: CGFloat
let visibleItemSize: CGFloat
var itemInsets: UIEdgeInsets
var itemSpacing: CGFloat
var itemsPerRow: Int
var contentSize: CGSize
init(
width: CGFloat,
containerInsets: UIEdgeInsets,
itemCount: Int
) {
self.width = width
self.itemCount = itemCount
let minItemsPerRow: Int = 3
let itemSpacing: CGFloat = UIScreenPixel
self.itemSpacing = itemSpacing
let itemInsets: UIEdgeInsets = UIEdgeInsets(top: containerInsets.top, left: containerInsets.left, bottom: containerInsets.bottom, right: containerInsets.right)
self.nativeItemSize = 120.0
self.itemInsets = itemInsets
let itemHorizontalSpace = width - self.itemInsets.left - self.itemInsets.right
self.itemsPerRow = max(minItemsPerRow, Int((itemHorizontalSpace + itemSpacing) / (self.nativeItemSize + itemSpacing)))
let proposedItemSize = floor((itemHorizontalSpace - itemSpacing * (CGFloat(self.itemsPerRow) - 1.0)) / CGFloat(self.itemsPerRow))
self.visibleItemSize = proposedItemSize
let numRows = (itemCount + (self.itemsPerRow - 1)) / self.itemsPerRow
self.contentSize = CGSize(
width: width,
height: self.itemInsets.top + self.itemInsets.bottom + CGFloat(numRows) * self.visibleItemSize + CGFloat(max(0, numRows - 1)) * self.itemSpacing
)
}
func frame(itemIndex: Int) -> CGRect {
let row = itemIndex / self.itemsPerRow
let column = itemIndex % self.itemsPerRow
var result = CGRect(
origin: CGPoint(
x: self.itemInsets.left + CGFloat(column) * (self.visibleItemSize + self.itemSpacing),
y: self.itemInsets.top + CGFloat(row) * (self.visibleItemSize + self.itemSpacing)
),
size: CGSize(
width: self.visibleItemSize,
height: self.visibleItemSize
)
)
if column == self.itemsPerRow - 1 {
result.size.width = max(result.size.width, self.width - self.itemInsets.right - result.minX)
}
return result
}
func visibleItems(for rect: CGRect) -> Range<Int>? {
let offsetRect = rect.offsetBy(dx: -self.itemInsets.left, dy: -self.itemInsets.top)
var minVisibleRow = Int(floor((offsetRect.minY - self.itemSpacing) / (self.visibleItemSize + self.itemSpacing)))
minVisibleRow = max(0, minVisibleRow)
let maxVisibleRow = Int(ceil((offsetRect.maxY - self.itemSpacing) / (self.visibleItemSize + self.itemSpacing)))
let minVisibleIndex = minVisibleRow * self.itemsPerRow
let maxVisibleIndex = min(self.itemCount - 1, (maxVisibleRow + 1) * self.itemsPerRow - 1)
return maxVisibleIndex >= minVisibleIndex ? (minVisibleIndex ..< (maxVisibleIndex + 1)) : nil
}
}
class View: ContextControllerSourceView, UIScrollViewDelegate {
private let scrollView: UIScrollView
private var visibleLayers: [EngineMessage.Id: MediaGridLayer] = [:]
private var layersAvailableForReuse: [MediaGridLayer] = []
private var ignoreScrolling: Bool = false
private var component: StorageMediaGridPanelComponent?
private var environment: StorageUsagePanelEnvironment?
private var itemLayout: ItemLayout?
private weak var currentGestureItemLayer: MediaGridLayer?
override init(frame: CGRect) {
self.scrollView = UIScrollView()
super.init(frame: frame)
self.scrollView.delaysContentTouches = true
self.scrollView.canCancelContentTouches = true
self.scrollView.clipsToBounds = false
if #available(iOSApplicationExtension 11.0, iOS 11.0, *) {
self.scrollView.contentInsetAdjustmentBehavior = .never
}
if #available(iOS 13.0, *) {
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
}
self.scrollView.showsVerticalScrollIndicator = true
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.scrollsToTop = false
self.scrollView.delegate = self
self.scrollView.clipsToBounds = true
self.addSubview(self.scrollView)
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))))
self.shouldBegin = { [weak self] point in
guard let self else {
return false
}
var itemLayer: MediaGridLayer?
let scrollPoint = self.convert(point, to: self.scrollView)
for (_, itemLayerValue) in self.visibleLayers {
if itemLayerValue.frame.contains(scrollPoint) {
itemLayer = itemLayerValue
break
}
}
guard let itemLayer else {
return false
}
self.currentGestureItemLayer = itemLayer
return true
}
self.customActivationProgress = { [weak self] progress, update in
guard let self, let itemLayer = self.currentGestureItemLayer else {
return
}
let targetContentRect = CGRect(origin: CGPoint(), size: itemLayer.bounds.size)
let scaleSide = itemLayer.bounds.width
let minScale: CGFloat = max(0.7, (scaleSide - 15.0) / scaleSide)
let currentScale = 1.0 * (1.0 - progress) + minScale * progress
let originalCenterOffsetX: CGFloat = itemLayer.bounds.width / 2.0 - targetContentRect.midX
let scaledCenterOffsetX: CGFloat = originalCenterOffsetX * currentScale
let originalCenterOffsetY: CGFloat = itemLayer.bounds.height / 2.0 - targetContentRect.midY
let scaledCenterOffsetY: CGFloat = originalCenterOffsetY * currentScale
let scaleMidX: CGFloat = scaledCenterOffsetX - originalCenterOffsetX
let scaleMidY: CGFloat = scaledCenterOffsetY - originalCenterOffsetY
switch update {
case .update:
let sublayerTransform = CATransform3DTranslate(CATransform3DScale(CATransform3DIdentity, currentScale, currentScale, 1.0), scaleMidX, scaleMidY, 0.0)
itemLayer.transform = sublayerTransform
case .begin:
let sublayerTransform = CATransform3DTranslate(CATransform3DScale(CATransform3DIdentity, currentScale, currentScale, 1.0), scaleMidX, scaleMidY, 0.0)
itemLayer.transform = sublayerTransform
case .ended:
let sublayerTransform = CATransform3DTranslate(CATransform3DScale(CATransform3DIdentity, currentScale, currentScale, 1.0), scaleMidX, scaleMidY, 0.0)
let previousTransform = itemLayer.transform
itemLayer.transform = sublayerTransform
itemLayer.animate(from: NSValue(caTransform3D: previousTransform), to: NSValue(caTransform3D: sublayerTransform), keyPath: "transform", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: 0.2)
}
}
self.activated = { [weak self] gesture, _ in
guard let self, let component = self.component, let itemLayer = self.currentGestureItemLayer else {
return
}
self.currentGestureItemLayer = nil
guard let message = itemLayer.message else {
return
}
let rect = self.convert(itemLayer.frame, from: self.scrollView)
component.contextAction(message.id, self, rect, gesture)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func transitionNodeForGallery(messageId: EngineMessage.Id, media: EngineMedia) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? {
var foundItemLayer: MediaGridLayer?
for (_, itemLayer) in self.visibleLayers {
if let message = itemLayer.message, message.id == messageId {
foundItemLayer = itemLayer
}
}
guard let itemLayer = foundItemLayer else {
return nil
}
let itemFrame = self.convert(itemLayer.frame, from: self.scrollView)
let proxyNode = ASDisplayNode()
proxyNode.frame = itemFrame
if let contents = itemLayer.contents {
if let image = contents as? UIImage {
proxyNode.contents = image.cgImage
} else {
proxyNode.contents = contents
}
}
proxyNode.isHidden = true
self.addSubnode(proxyNode)
let escapeNotification = EscapeNotification {
proxyNode.removeFromSupernode()
}
return (proxyNode, proxyNode.bounds, {
let view = UIView()
view.frame = proxyNode.frame
view.layer.contents = proxyNode.layer.contents
escapeNotification.keep()
return (view, nil)
})
}
@objc private func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
guard let component = self.component else {
return
}
let point = recognizer.location(in: self.scrollView)
for (id, itemLayer) in self.visibleLayers {
if itemLayer.frame.contains(point) {
component.action(id)
break
}
}
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if !self.ignoreScrolling {
self.updateScrolling(transition: .immediate)
}
}
private func updateScrolling(transition: Transition) {
guard let component = self.component, let environment = self.environment, let items = component.items, let itemLayout = self.itemLayout else {
return
}
let _ = environment
var validIds = Set<EngineMessage.Id>()
let visibleBounds = self.scrollView.bounds.insetBy(dx: 0.0, dy: -100.0)
if let visibleItems = itemLayout.visibleItems(for: visibleBounds) {
for index in visibleItems.lowerBound ..< visibleItems.upperBound {
if index >= items.items.count {
continue
}
let item = items.items[index]
let id = item.message.id
validIds.insert(id)
}
var removeIds: [EngineMessage.Id] = []
for (id, itemLayer) in self.visibleLayers {
if !validIds.contains(id) {
removeIds.append(id)
itemLayer.isHidden = true
self.layersAvailableForReuse.append(itemLayer)
itemLayer.prepareForReuse()
}
}
for id in removeIds {
self.visibleLayers.removeValue(forKey: id)
}
for index in visibleItems.lowerBound ..< visibleItems.upperBound {
if index >= items.items.count {
continue
}
let item = items.items[index]
let id = item.message.id
var setupItemLayer = false
let itemLayer: MediaGridLayer
if let current = self.visibleLayers[id] {
itemLayer = current
} else if !self.layersAvailableForReuse.isEmpty {
setupItemLayer = true
itemLayer = self.layersAvailableForReuse.removeLast()
itemLayer.isHidden = false
self.visibleLayers[id] = itemLayer
} else {
setupItemLayer = true
itemLayer = MediaGridLayer()
self.visibleLayers[id] = itemLayer
self.scrollView.layer.addSublayer(itemLayer)
}
let itemFrame = itemLayout.frame(itemIndex: index)
itemLayer.frame = itemFrame
if setupItemLayer {
itemLayer.setup(context: component.context, strings: environment.strings, message: item.message, size: item.size)
}
let itemSelectionState: MediaGridLayer.SelectionState
if let selectionState = component.selectionState {
itemSelectionState = .editing(isSelected: selectionState.selectedMessages.contains(id))
} else {
itemSelectionState = .none
}
itemLayer.updateSelection(size: itemFrame.size, selectionState: itemSelectionState, theme: environment.theme, transition: transition)
}
}
}
func update(component: StorageMediaGridPanelComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<StorageUsagePanelEnvironment>, transition: Transition) -> CGSize {
self.component = component
let environment = environment[StorageUsagePanelEnvironment.self].value
self.environment = environment
let itemLayout = ItemLayout(
width: availableSize.width,
containerInsets: environment.containerInsets,
itemCount: component.items?.items.count ?? 0
)
self.itemLayout = itemLayout
self.ignoreScrolling = true
let contentOffset = self.scrollView.bounds.minY
transition.setPosition(view: self.scrollView, position: CGRect(origin: CGPoint(), size: availableSize).center)
var scrollBounds = self.scrollView.bounds
scrollBounds.size = availableSize
if !environment.isScrollable {
scrollBounds.origin = CGPoint()
}
transition.setBounds(view: self.scrollView, bounds: scrollBounds)
self.scrollView.isScrollEnabled = environment.isScrollable
let contentSize = CGSize(width: availableSize.width, height: itemLayout.contentSize.height)
if self.scrollView.contentSize != contentSize {
self.scrollView.contentSize = contentSize
}
self.scrollView.scrollIndicatorInsets = environment.containerInsets
if !transition.animation.isImmediate && self.scrollView.bounds.minY != contentOffset {
let deltaOffset = self.scrollView.bounds.minY - contentOffset
transition.animateBoundsOrigin(view: self.scrollView, from: CGPoint(x: 0.0, y: -deltaOffset), to: CGPoint(), additive: true)
}
self.ignoreScrolling = false
self.updateScrolling(transition: transition)
return availableSize
}
}
func makeView() -> View {
return View(frame: CGRect())
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<StorageUsagePanelEnvironment>, transition: Transition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}