Storage management: add media grid view

This commit is contained in:
Ali 2022-12-26 21:43:26 +04:00
parent 0156554569
commit 8a348da927
3 changed files with 633 additions and 8 deletions

View File

@ -1371,7 +1371,7 @@ public func mediaGridMessagePhoto(account: Account, userLocation: MediaResourceU
let fullSizeData = value._1
let fullSizeComplete = value._3
return { arguments in
guard let context = DrawingContext(size: arguments.drawingSize, clear: true) else {
guard let context = DrawingContext(size: arguments.drawingSize, opaque: arguments.corners.isEmpty && arguments.intrinsicInsets == .zero, clear: true) else {
return nil
}
@ -1951,7 +1951,7 @@ public func chatWebpageSnippetFile(account: Account, userLocation: MediaResource
}
if let fullSizeImage = fullSizeImage ?? (blurredImage?.cgImage) {
guard let context = DrawingContext(size: arguments.drawingSize, clear: true) else {
guard let context = DrawingContext(size: arguments.drawingSize, opaque: arguments.corners.isEmpty && arguments.intrinsicInsets == .zero, clear: true) else {
return nil
}
@ -1980,7 +1980,7 @@ public func chatWebpageSnippetFile(account: Account, userLocation: MediaResource
return context
} else {
if let emptyColor = arguments.emptyColor {
guard let context = DrawingContext(size: arguments.drawingSize, clear: true) else {
guard let context = DrawingContext(size: arguments.drawingSize, opaque: arguments.corners.isEmpty && arguments.intrinsicInsets == .zero, clear: true) else {
return nil
}

View File

@ -0,0 +1,550 @@
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<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: 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<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)
}
}

View File

@ -22,6 +22,70 @@ import AnimatedStickerNode
import TelegramAnimatedStickerNode
import TelegramStringFormatting
#if DEBUG
import os.signpost
private class SignpostContext {
enum EventType {
case begin
case end
}
class OpaqueData {
}
static var shared: SignpostContext? = {
if #available(iOS 15.0, *) {
return SignpostContextImpl()
} else {
return nil
}
}()
func begin(name: StaticString) -> OpaqueData {
preconditionFailure()
}
func end(name: StaticString, data: OpaqueData) {
}
}
@available(iOS 15.0, *)
private final class SignpostContextImpl: SignpostContext {
final class OpaqueDataImpl: OpaqueData {
let state: OSSignpostIntervalState
let timestamp: Double
init(state: OSSignpostIntervalState, timestamp: Double) {
self.state = state
self.timestamp = timestamp
}
}
private let signpost = OSSignposter(subsystem: "org.telegram.Telegram-iOS", category: "StorageUsageScreen")
private let id: OSSignpostID
override init() {
self.id = self.signpost.makeSignpostID()
super.init()
}
override func begin(name: StaticString) -> OpaqueData {
let result = self.signpost.beginInterval(name, id: self.id)
return OpaqueDataImpl(state: result, timestamp: CFAbsoluteTimeGetCurrent())
}
override func end(name: StaticString, data: OpaqueData) {
if let data = data as? OpaqueDataImpl {
self.signpost.endInterval(name, data.state)
print("Signpost \(name): \((CFAbsoluteTimeGetCurrent() - data.timestamp) * 1000.0) ms")
}
}
}
#endif
private extension StorageUsageScreenComponent.Category {
init(_ category: StorageUsageStats.CategoryKey) {
switch category {
@ -225,7 +289,7 @@ final class StorageUsageScreenComponent: Component {
private var cacheSettingsExceptionCount: [CacheStorageSettings.PeerStorageCategory: Int32]?
private var peerItems: StoragePeerListPanelComponent.Items?
private var imageItems: StorageFileListPanelComponent.Items?
private var imageItems: StorageMediaGridPanelComponent.Items?
private var fileItems: StorageFileListPanelComponent.Items?
private var musicItems: StorageFileListPanelComponent.Items?
@ -629,11 +693,21 @@ final class StorageUsageScreenComponent: Component {
return
}
if self.selectionState == nil {
#if DEBUG
let signpostState = SignpostContext.shared?.begin(name: "edit")
#endif
self.selectionState = SelectionState(
selectedPeers: Set(),
selectedMessages: Set()
)
self.state?.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .spring)))
#if DEBUG
if let signpostState {
SignpostContext.shared?.end(name: "edit", data: signpostState)
}
#endif
}
}
).minSize(CGSize(width: 16.0, height: environment.navigationHeight - environment.statusBarHeight))),
@ -1483,7 +1557,7 @@ final class StorageUsageScreenComponent: Component {
panelItems.append(StorageUsagePanelContainerComponent.Item(
id: "images",
title: environment.strings.StorageManagement_TabMedia,
panel: AnyComponent(StorageFileListPanelComponent(
panel: AnyComponent(StorageMediaGridPanelComponent(
context: component.context,
items: self.imageItems,
selectionState: self.selectionState,
@ -1716,7 +1790,7 @@ final class StorageUsageScreenComponent: Component {
class RenderResult {
var messages: [MessageId: Message] = [:]
var imageItems: [StorageFileListPanelComponent.Item] = []
var imageItems: [StorageMediaGridPanelComponent.Item] = []
var fileItems: [StorageFileListPanelComponent.Item] = []
var musicItems: [StorageFileListPanelComponent.Item] = []
}
@ -1755,7 +1829,7 @@ final class StorageUsageScreenComponent: Component {
}
if matches {
result.imageItems.append(StorageFileListPanelComponent.Item(
result.imageItems.append(StorageMediaGridPanelComponent.Item(
message: message,
size: messageSize
))
@ -1846,7 +1920,7 @@ final class StorageUsageScreenComponent: Component {
self.currentMessages = result.messages
self.imageItems = StorageFileListPanelComponent.Items(items: result.imageItems)
self.imageItems = StorageMediaGridPanelComponent.Items(items: result.imageItems)
self.fileItems = StorageFileListPanelComponent.Items(items: result.fileItems)
self.musicItems = StorageFileListPanelComponent.Items(items: result.musicItems)
@ -1905,6 +1979,7 @@ final class StorageUsageScreenComponent: Component {
}
actionSheet.setItemGroups([ActionSheetItemGroup(items: [
ActionSheetTextItem(title: presentationData.strings.StorageManagement_ClearConfirmationText, parseMarkdown: true),
ActionSheetButtonItem(title: clearTitle, color: .destructive, action: { [weak self, weak actionSheet] in
actionSheet?.dismissAnimated()