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 Postbox 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: Message? 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: Message, 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: Message let size: Int64 init( message: Message, 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 peerAction: (EngineMessage.Id) -> Void init( context: AccountContext, items: Items?, selectionState: StorageUsageScreenComponent.SelectionState?, peerAction: @escaping (EngineMessage.Id) -> Void ) { self.context = context self.items = items self.selectionState = selectionState self.peerAction = peerAction } 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? { 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: UIView, 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? 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(_:)))) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @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.peerAction(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() 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, 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, transition: Transition) -> CGSize { return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) } }