diff --git a/submodules/ComponentFlow/Source/Utils/EscapeGuard.swift b/submodules/ComponentFlow/Source/Utils/EscapeGuard.swift index 94d6b9cd25..0cfe38fc74 100644 --- a/submodules/ComponentFlow/Source/Utils/EscapeGuard.swift +++ b/submodules/ComponentFlow/Source/Utils/EscapeGuard.swift @@ -11,3 +11,18 @@ final class EscapeGuard { self.status.isDeallocated = true } } + +public final class EscapeNotification: NSObject { + let deallocated: () -> Void + + public init(_ deallocated: @escaping () -> Void) { + self.deallocated = deallocated + } + + deinit { + self.deallocated() + } + + public func keep() { + } +} diff --git a/submodules/ContextUI/Sources/ContextActionNode.swift b/submodules/ContextUI/Sources/ContextActionNode.swift index 48e5a961b0..973a12ba4c 100644 --- a/submodules/ContextUI/Sources/ContextActionNode.swift +++ b/submodules/ContextUI/Sources/ContextActionNode.swift @@ -17,9 +17,11 @@ public protocol ContextActionNodeProtocol: ASDisplayNode { } final class ContextActionNode: ASDisplayNode, ContextActionNodeProtocol { - private let action: ContextMenuActionItem + private var presentationData: PresentationData + private var action: ContextMenuActionItem private let getController: () -> ContextControllerProtocol? private let actionSelected: (ContextMenuActionResult) -> Void + private let requestLayout: () -> Void private let backgroundNode: ASDisplayNode private let highlightedBackgroundNode: ASDisplayNode @@ -38,10 +40,12 @@ final class ContextActionNode: ASDisplayNode, ContextActionNodeProtocol { return true } - init(presentationData: PresentationData, action: ContextMenuActionItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void) { + init(presentationData: PresentationData, action: ContextMenuActionItem, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void, requestLayout: @escaping () -> Void) { + self.presentationData = presentationData self.action = action self.getController = getController self.actionSelected = actionSelected + self.requestLayout = requestLayout let textFont = Font.regular(presentationData.listsFontSize.baseDisplaySize) @@ -267,6 +271,8 @@ final class ContextActionNode: ASDisplayNode, ContextActionNodeProtocol { } func updateTheme(presentationData: PresentationData) { + self.presentationData = presentationData + self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.itemBackgroundColor self.highlightedBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor @@ -315,9 +321,45 @@ final class ContextActionNode: ASDisplayNode, ContextActionNodeProtocol { guard let controller = self.getController() else { return } - self.action.action?(controller, { [weak self] result in - self?.actionSelected(result) - }) + self.action.action?(ContextMenuActionItem.Action( + controller: controller, + dismissWithResult: { [weak self] result in + self?.actionSelected(result) + }, + updateAction: { [weak self] updatedAction in + guard let strongSelf = self else { + return + } + strongSelf.action = updatedAction + + let textColor: UIColor + switch strongSelf.action.textColor { + case .primary: + textColor = strongSelf.presentationData.theme.contextMenu.primaryColor + case .destructive: + textColor = strongSelf.presentationData.theme.contextMenu.destructiveColor + case .disabled: + textColor = strongSelf.presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.4) + } + + let textFont = Font.regular(strongSelf.presentationData.listsFontSize.baseDisplaySize) + let titleFont: UIFont + switch strongSelf.action.textFont { + case .regular: + titleFont = textFont + case let .custom(customFont): + titleFont = customFont + } + + strongSelf.textNode.attributedText = NSAttributedString(string: strongSelf.action.text, font: titleFont, textColor: textColor) + + if strongSelf.action.iconSource == nil { + strongSelf.iconNode.image = strongSelf.action.icon(strongSelf.presentationData.theme) + } + + strongSelf.requestLayout() + } + )) } func setIsHighlighted(_ value: Bool) { diff --git a/submodules/ContextUI/Sources/ContextActionsContainerNode.swift b/submodules/ContextUI/Sources/ContextActionsContainerNode.swift index 9a40d003cc..404e66a356 100644 --- a/submodules/ContextUI/Sources/ContextActionsContainerNode.swift +++ b/submodules/ContextUI/Sources/ContextActionsContainerNode.swift @@ -69,7 +69,7 @@ private final class InnerActionsContainerNode: ASDisplayNode { } } - init(presentationData: PresentationData, items: [ContextMenuItem], getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void, feedbackTap: @escaping () -> Void, blurBackground: Bool) { + init(presentationData: PresentationData, items: [ContextMenuItem], getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void, requestLayout: @escaping () -> Void, feedbackTap: @escaping () -> Void, blurBackground: Bool) { self.presentationData = presentationData self.feedbackTap = feedbackTap self.blurBackground = blurBackground @@ -83,7 +83,7 @@ private final class InnerActionsContainerNode: ASDisplayNode { for i in 0 ..< items.count { switch items[i] { case let .action(action): - itemNodes.append(.action(ContextActionNode(presentationData: presentationData, action: action, getController: getController, actionSelected: actionSelected))) + itemNodes.append(.action(ContextActionNode(presentationData: presentationData, action: action, getController: getController, actionSelected: actionSelected, requestLayout: requestLayout))) if i != items.count - 1 { switch items[i + 1] { case .action, .custom: @@ -469,7 +469,7 @@ final class ContextActionsContainerNode: ASDisplayNode { return self.additionalActionsNode != nil } - init(presentationData: PresentationData, items: ContextController.Items, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void, feedbackTap: @escaping () -> Void, blurBackground: Bool) { + init(presentationData: PresentationData, items: ContextController.Items, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void, requestLayout: @escaping () -> Void, feedbackTap: @escaping () -> Void, blurBackground: Bool) { self.blurBackground = blurBackground self.shadowNode = ASImageNode() self.shadowNode.displaysAsynchronously = false @@ -488,14 +488,14 @@ final class ContextActionsContainerNode: ASDisplayNode { additionalShadowNode.isHidden = true self.additionalShadowNode = additionalShadowNode - self.additionalActionsNode = InnerActionsContainerNode(presentationData: presentationData, items: [firstItem], getController: getController, actionSelected: actionSelected, feedbackTap: feedbackTap, blurBackground: blurBackground) + self.additionalActionsNode = InnerActionsContainerNode(presentationData: presentationData, items: [firstItem], getController: getController, actionSelected: actionSelected, requestLayout: requestLayout, feedbackTap: feedbackTap, blurBackground: blurBackground) items.items.removeFirst() } else { self.additionalShadowNode = nil self.additionalActionsNode = nil } - self.actionsNode = InnerActionsContainerNode(presentationData: presentationData, items: items.items, getController: getController, actionSelected: actionSelected, feedbackTap: feedbackTap, blurBackground: blurBackground) + self.actionsNode = InnerActionsContainerNode(presentationData: presentationData, items: items.items, getController: getController, actionSelected: actionSelected, requestLayout: requestLayout, feedbackTap: feedbackTap, blurBackground: blurBackground) if let tip = items.tip { let textSelectionTipNode = InnerTextSelectionTipContainerNode(presentationData: presentationData, tip: tip) textSelectionTipNode.isUserInteractionEnabled = false diff --git a/submodules/ContextUI/Sources/ContextController.swift b/submodules/ContextUI/Sources/ContextController.swift index a164e9b7e7..9d66b69324 100644 --- a/submodules/ContextUI/Sources/ContextController.swift +++ b/submodules/ContextUI/Sources/ContextController.swift @@ -68,6 +68,18 @@ public struct ContextMenuActionBadge { } public final class ContextMenuActionItem { + public final class Action { + public let controller: ContextControllerProtocol + public let dismissWithResult: (ContextMenuActionResult) -> Void + public let updateAction: (ContextMenuActionItem) -> Void + + init(controller: ContextControllerProtocol, dismissWithResult: @escaping (ContextMenuActionResult) -> Void, updateAction: @escaping (ContextMenuActionItem) -> Void) { + self.controller = controller + self.dismissWithResult = dismissWithResult + self.updateAction = updateAction + } + } + public let text: String public let textColor: ContextMenuActionItemTextColor public let textFont: ContextMenuActionItemFont @@ -75,9 +87,44 @@ public final class ContextMenuActionItem { public let badge: ContextMenuActionBadge? public let icon: (PresentationTheme) -> UIImage? public let iconSource: ContextMenuActionItemIconSource? - public let action: ((ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void)? + public let action: ((Action) -> Void)? - public init(text: String, textColor: ContextMenuActionItemTextColor = .primary, textLayout: ContextMenuActionItemTextLayout = .twoLinesMax, textFont: ContextMenuActionItemFont = .regular, badge: ContextMenuActionBadge? = nil, icon: @escaping (PresentationTheme) -> UIImage?, iconSource: ContextMenuActionItemIconSource? = nil, action: ((ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void)?) { + convenience public init( + text: String, + textColor: ContextMenuActionItemTextColor = .primary, + textLayout: ContextMenuActionItemTextLayout = .twoLinesMax, + textFont: ContextMenuActionItemFont = .regular, + badge: ContextMenuActionBadge? = nil, + icon: @escaping (PresentationTheme) -> UIImage?, + iconSource: ContextMenuActionItemIconSource? = nil, + action: ((ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void)? + ) { + self.init( + text: text, + textColor: textColor, + textLayout: textLayout, + textFont: textFont, + badge: badge, + icon: icon, + iconSource: iconSource, + action: action.flatMap { action in + return { impl in + action(impl.controller, impl.dismissWithResult) + } + } + ) + } + + public init( + text: String, + textColor: ContextMenuActionItemTextColor = .primary, + textLayout: ContextMenuActionItemTextLayout = .twoLinesMax, + textFont: ContextMenuActionItemFont = .regular, + badge: ContextMenuActionBadge? = nil, + icon: @escaping (PresentationTheme) -> UIImage?, + iconSource: ContextMenuActionItemIconSource? = nil, + action: ((Action) -> Void)? + ) { self.text = text self.textColor = textColor self.textFont = textFont @@ -215,6 +262,7 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi self.contentContainerNode = ContextContentContainerNode() var feedbackTap: (() -> Void)? + var updateLayout: (() -> Void)? var blurBackground = true if case .reference = source { @@ -228,6 +276,8 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi return controller }, actionSelected: { result in beginDismiss(result) + }, requestLayout: { + updateLayout?() }, feedbackTap: { feedbackTap?() }, blurBackground: blurBackground) @@ -237,6 +287,10 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi feedbackTap = { [weak self] in self?.hapticFeedback.tap() } + + updateLayout = { [weak self] in + self?.updateLayout() + } self.scrollNode.view.delegate = self @@ -1059,6 +1113,8 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi return self?.getController() }, actionSelected: { [weak self] result in self?.beginDismiss(result) + }, requestLayout: { [weak self] in + self?.updateLayout() }, feedbackTap: { [weak self] in self?.hapticFeedback.tap() }, blurBackground: self.blurBackground) @@ -1086,6 +1142,12 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi self.updateLayout(layout: validLayout, transition: .immediate, previousActionsContainerNode: nil, previousActionsContainerFrame: nil) } } + + func updateLayout() { + if let layout = self.validLayout { + self.updateLayout(layout: layout, transition: .immediate, previousActionsContainerNode: nil) + } + } func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition, previousActionsContainerNode: ContextActionsContainerNode?, previousActionsContainerFrame: CGRect? = nil, previousActionsTransition: ContextController.PreviousActionsTransition = .scale) { if self.isAnimatingOut { @@ -1573,10 +1635,13 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi return nil } let mappedPoint = self.view.convert(point, to: self.scrollNode.view) + var maybePassthrough = false if let maybeContentNode = self.contentContainerNode.contentNode { switch maybeContentNode { case .reference: - break + if let controller = self.getController() as? ContextController { + maybePassthrough = controller.passthroughTouchEvents + } case let .extracted(contentParentNode, _): if case let .extracted(source) = self.source { if !source.ignoreContentTouches { @@ -1615,6 +1680,11 @@ private final class ContextControllerNode: ViewControllerTracingNode, UIScrollVi if self.actionsContainerNode.frame.contains(mappedPoint) { return self.actionsContainerNode.hitTest(self.view.convert(point, to: self.actionsContainerNode.view), with: event) } + + if maybePassthrough { + self.getController()?.dismiss(completion: nil) + return nil + } return self.dismissNode.view } @@ -1776,6 +1846,8 @@ public final class ContextController: ViewController, StandalonePresentableContr public var useComplexItemsTransitionAnimation = false public var immediateItemsTransitionAnimation = false + + public var passthroughTouchEvents = false private var shouldBeDismissedDisposable: Disposable? diff --git a/submodules/ContextUI/Sources/PeekControllerNode.swift b/submodules/ContextUI/Sources/PeekControllerNode.swift index 13156362aa..97b1eff33d 100644 --- a/submodules/ContextUI/Sources/PeekControllerNode.swift +++ b/submodules/ContextUI/Sources/PeekControllerNode.swift @@ -72,10 +72,13 @@ final class PeekControllerNode: ViewControllerTracingNode { var feedbackTapImpl: (() -> Void)? var activatedActionImpl: (() -> Void)? + var requestLayoutImpl: (() -> Void)? self.actionsContainerNode = ContextActionsContainerNode(presentationData: presentationData, items: ContextController.Items(items: content.menuItems()), getController: { [weak controller] in return controller }, actionSelected: { result in activatedActionImpl?() + }, requestLayout: { + requestLayoutImpl?() }, feedbackTap: { feedbackTapImpl?() }, blurBackground: true) @@ -86,6 +89,10 @@ final class PeekControllerNode: ViewControllerTracingNode { feedbackTapImpl = { [weak self] in self?.hapticFeedback.tap() } + + requestLayoutImpl = { [weak self] in + self?.updateLayout() + } if content.presentation() == .freeform { self.containerNode.isUserInteractionEnabled = false @@ -118,6 +125,12 @@ final class PeekControllerNode: ViewControllerTracingNode { self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimNodeTap(_:)))) self.view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))) } + + func updateLayout() { + if let layout = self.validLayout { + self.containerLayoutUpdated(layout, transition: .immediate) + } + } func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { self.validLayout = layout @@ -332,6 +345,8 @@ final class PeekControllerNode: ViewControllerTracingNode { return self?.controller }, actionSelected: { [weak self] result in self?.requestDismiss() + }, requestLayout: { [weak self] in + self?.updateLayout() }, feedbackTap: { [weak self] in self?.hapticFeedback.tap() }, blurBackground: true) diff --git a/submodules/DirectMediaImageCache/BUILD b/submodules/DirectMediaImageCache/BUILD new file mode 100644 index 0000000000..94553e3ce7 --- /dev/null +++ b/submodules/DirectMediaImageCache/BUILD @@ -0,0 +1,23 @@ +load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library") + +swift_library( + name = "DirectMediaImageCache", + module_name = "DirectMediaImageCache", + srcs = glob([ + "Sources/**/*.swift", + ]), + copts = [ + "-warnings-as-errors", + ], + deps = [ + "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", + "//submodules/Postbox:Postbox", + "//submodules/TelegramCore:TelegramCore", + "//submodules/TinyThumbnail:TinyThumbnail", + "//submodules/Display:Display", + "//submodules/FastBlur:FastBlur", + ], + visibility = [ + "//visibility:public", + ], +) diff --git a/submodules/DirectMediaImageCache/Sources/DirectMediaImageCache.swift b/submodules/DirectMediaImageCache/Sources/DirectMediaImageCache.swift new file mode 100644 index 0000000000..0c56a5ed15 --- /dev/null +++ b/submodules/DirectMediaImageCache/Sources/DirectMediaImageCache.swift @@ -0,0 +1,134 @@ +import Foundation +import SwiftSignalKit +import Postbox +import TelegramCore +import UIKit +import TinyThumbnail +import Display +import FastBlur + +private func generateBlurredThumbnail(image: UIImage) -> UIImage? { + let thumbnailContextSize = CGSize(width: 32.0, height: 32.0) + let thumbnailContext = DrawingContext(size: thumbnailContextSize, scale: 1.0) + + let filledSize = image.size.aspectFilled(thumbnailContextSize) + let imageRect = CGRect(origin: CGPoint(x: (thumbnailContextSize.width - filledSize.width) / 2.0, y: (thumbnailContextSize.height - filledSize.height) / 2.0), size: filledSize) + + thumbnailContext.withFlippedContext { c in + c.draw(image.cgImage!, in: imageRect) + } + telegramFastBlurMore(Int32(thumbnailContextSize.width), Int32(thumbnailContextSize.height), Int32(thumbnailContext.bytesPerRow), thumbnailContext.bytes) + + return thumbnailContext.generateImage() +} + +public final class DirectMediaImageCache { + public final class GetMediaResult { + public let image: UIImage? + public let loadSignal: Signal? + + init(image: UIImage?, loadSignal: Signal?) { + self.image = image + self.loadSignal = loadSignal + } + } + + private enum ImageType { + case blurredThumbnail + case square(width: Int) + } + + private let account: Account + + public init(account: Account) { + self.account = account + } + + private func getCachePath(resourceId: MediaResourceId, imageType: ImageType) -> String { + let representationId: String + switch imageType { + case .blurredThumbnail: + representationId = "blurred32" + case let .square(width): + representationId = "shm\(width)" + } + return self.account.postbox.mediaBox.cachedRepresentationPathForId(resourceId.stringRepresentation, representationId: representationId, keepDuration: .general) + } + + private func getLoadSignal(resource: MediaResourceReference, width: Int) -> Signal? { + let cachePath = self.getCachePath(resourceId: resource.resource.id, imageType: .square(width: width)) + return Signal { subscriber in + let fetch = fetchedMediaResource(mediaBox: self.account.postbox.mediaBox, reference: resource).start() + let data = (self.account.postbox.mediaBox.resourceData(resource.resource) + |> filter { data in + return data.complete + } + |> take(1)).start(next: { data in + if let dataValue = try? Data(contentsOf: URL(fileURLWithPath: data.path)), let image = UIImage(data: dataValue) { + if let scaledImage = generateImage(CGSize(width: CGFloat(width), height: CGFloat(width)), contextGenerator: { size, context in + let filledSize = image.size.aspectFilled(size) + let imageRect = CGRect(origin: CGPoint(x: (size.width - filledSize.width) / 2.0, y: (size.height - filledSize.height) / 2.0), size: filledSize) + context.draw(image.cgImage!, in: imageRect) + }, scale: 1.0) { + if let resultData = scaledImage.jpegData(compressionQuality: 0.7) { + let _ = try? resultData.write(to: URL(fileURLWithPath: cachePath)) + subscriber.putNext(scaledImage) + subscriber.putCompletion() + } + } + } + }) + + return ActionDisposable { + fetch.dispose() + data.dispose() + } + } + } + + private func getResource(message: Message, image: TelegramMediaImage) -> MediaResourceReference? { + guard let representation = image.representations.last else { + return nil + } + return MediaReference.message(message: MessageReference(message), media: image).resourceReference(representation.resource) + } + + private func getResource(message: Message, file: TelegramMediaFile) -> MediaResourceReference? { + if let representation = file.previewRepresentations.last { + return MediaReference.message(message: MessageReference(message), media: file).resourceReference(representation.resource) + } else { + return nil + } + } + + public func getImage(message: Message, media: Media, width: Int) -> GetMediaResult? { + var immediateThumbnailData: Data? + var resource: MediaResourceReference? + if let image = media as? TelegramMediaImage { + immediateThumbnailData = image.immediateThumbnailData + resource = self.getResource(message: message, image: image) + } else if let file = media as? TelegramMediaFile { + immediateThumbnailData = file.immediateThumbnailData + resource = self.getResource(message: message, file: file) + } + + if let resource = resource { + if let data = try? Data(contentsOf: URL(fileURLWithPath: self.getCachePath(resourceId: resource.resource.id, imageType: .square(width: width)))), let image = UIImage(data: data) { + return GetMediaResult(image: image, loadSignal: nil) + } + + var blurredImage: UIImage? + if let data = try? Data(contentsOf: URL(fileURLWithPath: self.getCachePath(resourceId: resource.resource.id, imageType: .blurredThumbnail))), let image = UIImage(data: data) { + blurredImage = image + } else if let data = immediateThumbnailData.flatMap(decodeTinyThumbnail), let image = UIImage(data: data) { + if let blurredImageValue = generateBlurredThumbnail(image: image) { + blurredImage = blurredImageValue + } + } + + return GetMediaResult(image: blurredImage, loadSignal: self.getLoadSignal(resource: resource, width: width)) + } else { + return nil + } + } +} diff --git a/submodules/Display/Source/ContainedViewLayoutTransition.swift b/submodules/Display/Source/ContainedViewLayoutTransition.swift index e1eebfc499..6d596d2020 100644 --- a/submodules/Display/Source/ContainedViewLayoutTransition.swift +++ b/submodules/Display/Source/ContainedViewLayoutTransition.swift @@ -865,6 +865,36 @@ public extension ContainedViewLayoutTransition { }) } } + + func updateTransform(node: ASDisplayNode, transform: CGAffineTransform, beginWithCurrentState: Bool = false, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) { + let transform = CATransform3DMakeAffineTransform(transform) + + if CATransform3DEqualToTransform(node.layer.transform, transform) { + if let completion = completion { + completion(true) + } + return + } + + switch self { + case .immediate: + node.layer.transform = transform + if let completion = completion { + completion(true) + } + case let .animated(duration, curve): + let previousTransform: CATransform3D + if beginWithCurrentState, let presentation = node.layer.presentation() { + previousTransform = presentation.transform + } else { + previousTransform = node.layer.transform + } + node.layer.transform = transform + node.layer.animate(from: NSValue(caTransform3D: previousTransform), to: NSValue(caTransform3D: transform), keyPath: "transform", timingFunction: curve.timingFunction, duration: duration, mediaTimingFunction: curve.mediaTimingFunction, completion: { value in + completion?(value) + }) + } + } func updateTransformScale(node: ASDisplayNode, scale: CGFloat, beginWithCurrentState: Bool = false, delay: Double = 0.0, completion: ((Bool) -> Void)? = nil) { let t = node.layer.transform diff --git a/submodules/SparseItemGrid/Sources/SparseItemGrid.swift b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift index a1f7869636..9bfd5ab1ba 100644 --- a/submodules/SparseItemGrid/Sources/SparseItemGrid.swift +++ b/submodules/SparseItemGrid/Sources/SparseItemGrid.swift @@ -2,7 +2,1091 @@ import Foundation import UIKit import Display import AsyncDisplayKit +import SwiftSignalKit + +private final class NullActionClass: NSObject, CAAction { + @objc func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) { + } +} + +private let nullAction = NullActionClass() + +public protocol SparseItemGridLayer: CALayer { + func update(size: CGSize) +} + +public protocol SparseItemGridBinding: AnyObject { + func createLayer() -> SparseItemGridLayer + func bindLayers(items: [SparseItemGrid.Item], layers: [SparseItemGridLayer]) + func unbindLayer(layer: SparseItemGridLayer) + func scrollerTextForTag(tag: Int32) -> String? + func loadHole(anchor: SparseItemGrid.HoleAnchor, at location: SparseItemGrid.HoleLocation) -> Signal + func onTap(item: SparseItemGrid.Item) + func onTagTap() + func didScroll() +} + +private func binarySearch(_ inputArr: [SparseItemGrid.Item], searchItem: Int) -> (index: Int?, lowerBound: Int?, upperBound: Int?) { + var lowerIndex = 0 + var upperIndex = inputArr.count - 1 + + if lowerIndex > upperIndex { + return (nil, nil, nil) + } + + while true { + let currentIndex = (lowerIndex + upperIndex) / 2 + let value = inputArr[currentIndex].index + + if value == searchItem { + return (currentIndex, nil, nil) + } else if lowerIndex > upperIndex { + return (nil, upperIndex >= 0 ? upperIndex : nil, lowerIndex < inputArr.count ? lowerIndex : nil) + } else { + if (value > searchItem) { + upperIndex = currentIndex - 1 + } else { + lowerIndex = currentIndex + 1 + } + } + } +} + +private func binarySearch(_ inputArr: [SparseItemGrid.HoleAnchor], searchItem: Int) -> (index: Int?, lowerBound: Int?, upperBound: Int?) { + var lowerIndex = 0 + var upperIndex = inputArr.count - 1 + + if lowerIndex > upperIndex { + return (nil, nil, nil) + } + + while true { + let currentIndex = (lowerIndex + upperIndex) / 2 + let value = inputArr[currentIndex].index + + if value == searchItem { + return (currentIndex, nil, nil) + } else if lowerIndex > upperIndex { + return (nil, upperIndex >= 0 ? upperIndex : nil, lowerIndex < inputArr.count ? lowerIndex : nil) + } else { + if (value > searchItem) { + upperIndex = currentIndex - 1 + } else { + lowerIndex = currentIndex + 1 + } + } + } +} public final class SparseItemGrid: ASDisplayNode { - -} \ No newline at end of file + public final class ShimmerLayer: CAGradientLayer { + override public init() { + super.init() + + self.backgroundColor = UIColor(white: 0.9, alpha: 1.0).cgColor + } + + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func action(forKey event: String) -> CAAction? { + return nullAction + } + + func update(size: CGSize) { + self.endPoint = CGPoint(x: 0.0, y: size.height) + } + } + + open class Item { + open var id: AnyHashable { + preconditionFailure() + } + + open var index: Int { + preconditionFailure() + } + + open var tag: Int32 { + preconditionFailure() + } + + public init() { + } + } + + public enum HoleLocation { + case around + case toLower + case toUpper + } + + open class HoleAnchor { + open var id: AnyHashable { + preconditionFailure() + } + + open var index: Int { + preconditionFailure() + } + + open var tag: Int32 { + preconditionFailure() + } + + public init() { + } + } + + public final class Items { + public let items: [Item] + public let holeAnchors: [HoleAnchor] + public let count: Int + public let itemBinding: SparseItemGridBinding + + public init(items: [Item], holeAnchors: [HoleAnchor], count: Int, itemBinding: SparseItemGridBinding) { + self.items = items + self.holeAnchors = holeAnchors + self.count = count + self.itemBinding = itemBinding + } + + func item(at index: Int) -> Item? { + if let itemIndex = binarySearch(self.items, searchItem: index).index { + return self.items[itemIndex] + } + return nil + } + + func itemOrLower(at index: Int) -> Item? { + let searchResult = binarySearch(self.items, searchItem: index) + if let itemIndex = searchResult.index { + return self.items[itemIndex] + } else if let lowerBound = searchResult.lowerBound { + return self.items[lowerBound] + } else { + return nil + } + } + + func tag(atIndexOrLower index: Int) -> Int32? { + var item: Item? + let itemsResult = binarySearch(self.items, searchItem: index) + if let itemIndex = itemsResult.index { + item = self.items[itemIndex] + } else if let lowerBound = itemsResult.lowerBound { + item = self.items[lowerBound] + } + + var holeAnchor: HoleAnchor? + let holeResult = binarySearch(self.holeAnchors, searchItem: index) + if let itemIndex = holeResult.index { + holeAnchor = self.holeAnchors[itemIndex] + } else if let lowerBound = holeResult.lowerBound { + holeAnchor = self.holeAnchors[lowerBound] + } + + if let item = item, let holeAnchor = holeAnchor { + if abs(index - item.index) < abs(index - holeAnchor.index) { + return item.tag + } else { + return holeAnchor.tag + } + } else if let item = item { + return item.tag + } else if let holeAnchor = holeAnchor { + return holeAnchor.tag + } else { + return nil + } + } + + func closestItem(at index: Int) -> Item? { + let searchResult = binarySearch(self.items, searchItem: index) + if let itemIndex = searchResult.index { + return self.items[itemIndex] + } else if let lowerBound = searchResult.lowerBound, let upperBound = searchResult.upperBound { + let lowerBoundIndex = self.items[lowerBound].index + let upperBoundIndex = self.items[upperBound].index + if abs(index - lowerBoundIndex) < abs(index - upperBoundIndex) { + return self.items[lowerBound] + } else { + return self.items[upperBound] + } + } else if let lowerBound = searchResult.lowerBound { + return self.items[lowerBound] + } else if let upperBound = searchResult.upperBound { + return self.items[upperBound] + } else { + return nil + } + } + + func closestHole(to index: Int) -> HoleAnchor? { + let searchResult = binarySearch(self.holeAnchors, searchItem: index) + if let itemIndex = searchResult.index { + return self.holeAnchors[itemIndex] + } else if let lowerBound = searchResult.lowerBound, let upperBound = searchResult.upperBound { + let lowerBoundIndex = self.holeAnchors[lowerBound].index + let upperBoundIndex = self.holeAnchors[upperBound].index + if abs(index - lowerBoundIndex) < abs(index - upperBoundIndex) { + return self.holeAnchors[lowerBound] + } else { + return self.holeAnchors[upperBound] + } + } else if let lowerBound = searchResult.lowerBound { + return self.holeAnchors[lowerBound] + } else if let upperBound = searchResult.upperBound { + return self.holeAnchors[upperBound] + } else { + return nil + } + } + } + + public struct ZoomLevel: Equatable, Comparable { + public var rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static func <(lhs: ZoomLevel, rhs: ZoomLevel) -> Bool { + return lhs.rawValue < rhs.rawValue + } + } + + private final class Viewport: ASDisplayNode, UIScrollViewDelegate { + final class VisibleItemLayer { + let layer: SparseItemGridLayer + + init(layer: SparseItemGridLayer) { + self.layer = layer + } + } + + final class Layout { + let containerLayout: ContainerLayout + let itemSize: CGFloat + let itemSpacing: CGFloat + let lastItemSize: CGFloat + let itemsPerRow: Int + + init(containerLayout: ContainerLayout, zoomLevel: ZoomLevel) { + self.containerLayout = containerLayout + self.itemSpacing = 1.0 + + let width = containerLayout.size.width + let baseItemWidth = floor(min(150.0, width / 3.0)) + let unclippedItemWidth = (CGFloat(zoomLevel.rawValue) / 100.0) * baseItemWidth + let itemsPerRow = floor(width / unclippedItemWidth) + self.itemsPerRow = Int(itemsPerRow) + self.itemSize = floorToScreenPixels((width - (self.itemSpacing * CGFloat(self.itemsPerRow - 1))) / itemsPerRow) + + self.lastItemSize = width - (self.itemSize + self.itemSpacing) * CGFloat(self.itemsPerRow - 1) + } + + func frame(at index: Int) -> CGRect { + let row = index / self.itemsPerRow + let column = index % self.itemsPerRow + + return CGRect(origin: CGPoint(x: CGFloat(column) * (self.itemSize + self.itemSpacing), y: CGFloat(row) * (self.itemSize + self.itemSpacing)), size: CGSize(width: column == (self.itemsPerRow - 1) ? self.lastItemSize : itemSize, height: itemSize)) + } + + func contentHeight(count: Int) -> CGFloat { + return self.frame(at: count - 1).maxY + } + + func visibleItemRange(for rect: CGRect, count: Int) -> (minIndex: Int, maxIndex: Int) { + var minVisibleRow = Int(floor((rect.minY - self.itemSpacing) / (self.itemSize + self.itemSpacing))) + minVisibleRow = max(0, minVisibleRow) + let maxVisibleRow = Int(ceil((rect.maxY - self.itemSpacing) / (self.itemSize + itemSpacing))) + + let minVisibleIndex = minVisibleRow * self.itemsPerRow + let maxVisibleIndex = min(count - 1, (maxVisibleRow + 1) * self.itemsPerRow - 1) + + return (minVisibleIndex, maxVisibleIndex) + } + } + + let zoomLevel: ZoomLevel + + private let scrollView: UIScrollView + + var layout: Layout? + var items: Items? + var visibleItems: [AnyHashable: VisibleItemLayer] = [:] + var visiblePlaceholders: [ShimmerLayer] = [] + + private var scrollingArea: SparseItemGridScrollingArea? + private let maybeLoadHoleAnchor: (HoleAnchor, HoleLocation) -> Void + + private var ignoreScrolling: Bool = false + + init(zoomLevel: ZoomLevel, maybeLoadHoleAnchor: @escaping (HoleAnchor, HoleLocation) -> Void) { + self.zoomLevel = zoomLevel + self.maybeLoadHoleAnchor = maybeLoadHoleAnchor + + self.scrollView = UIScrollView() + if #available(iOSApplicationExtension 11.0, iOS 11.0, *) { + self.scrollView.contentInsetAdjustmentBehavior = .never + } + self.scrollView.scrollsToTop = false + self.scrollView.showsVerticalScrollIndicator = false + self.scrollView.showsHorizontalScrollIndicator = false + self.scrollView.delaysContentTouches = false + self.scrollView.clipsToBounds = false + + super.init() + + self.anchorPoint = CGPoint() + + self.scrollView.delegate = self + self.view.addSubview(self.scrollView) + } + + func update(containerLayout: ContainerLayout, items: Items, restoreScrollPosition: (y: CGFloat, index: Int)?) { + if self.layout?.containerLayout != containerLayout || self.items !== items { + self.layout = Layout(containerLayout: containerLayout, zoomLevel: self.zoomLevel) + self.items = items + + self.updateVisibleItems(resetScrolling: true, restoreScrollPosition: restoreScrollPosition) + } + } + + @objc func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !self.ignoreScrolling { + self.updateVisibleItems(resetScrolling: false, restoreScrollPosition: nil) + } + } + + func item(at point: CGPoint) -> Item? { + guard let items = self.items, !items.items.isEmpty else { + return nil + } + + let localPoint = self.scrollView.convert(point, from: self.view) + + for (id, visibleItem) in self.visibleItems { + if visibleItem.layer.frame.contains(localPoint) { + for item in items.items { + if item.id == id { + return item + } + } + return nil + } + } + + return nil + } + + func anchorItem(at point: CGPoint) -> Item? { + guard let items = self.items, !items.items.isEmpty else { + return nil + } + + let localPoint = self.scrollView.convert(point, from: self.view) + + var closestItem: (CGFloat, AnyHashable)? + for (id, visibleItem) in self.visibleItems { + let itemCenter = visibleItem.layer.frame.center + let distanceX = itemCenter.x - localPoint.x + let distanceY = itemCenter.y - localPoint.y + let distance2 = distanceX * distanceX + distanceY * distanceY + + if let (currentDistance2, _) = closestItem { + if distance2 < currentDistance2 { + closestItem = (distance2, id) + } + } else { + closestItem = (distance2, id) + } + } + + if let (_, id) = closestItem { + for item in items.items { + if item.id == id { + return item + } + } + return nil + } else { + return nil + } + } + + func frameForItem(at index: Int) -> CGRect? { + guard let layout = self.layout else { + return nil + } + return self.scrollView.convert(layout.frame(at: index), to: self.view) + } + + func frameForItem(layer: SparseItemGridLayer) -> CGRect { + return self.scrollView.convert(layer.frame, to: self.view) + } + + func scrollToItem(at index: Int) { + guard let layout = self.layout, let _ = self.items else { + return + } + if layout.containerLayout.lockScrollingAtTop { + return + } + let itemFrame = layout.frame(at: index) + var contentOffset = itemFrame.minY + if contentOffset > self.scrollView.contentSize.height - self.scrollView.bounds.height { + contentOffset = self.scrollView.contentSize.height - self.scrollView.bounds.height + } + if contentOffset < 0.0 { + contentOffset = 0.0 + } + self.scrollView.setContentOffset(CGPoint(x: 0.0, y: contentOffset), animated: false) + } + + private func updateVisibleItems(resetScrolling: Bool, restoreScrollPosition: (y: CGFloat, index: Int)?) { + guard let layout = self.layout, let items = self.items else { + return + } + + let contentHeight = layout.contentHeight(count: items.count) + + if resetScrolling { + if !self.scrollView.bounds.isEmpty { + //get anchor item id + } + + self.ignoreScrolling = true + self.scrollView.frame = CGRect(origin: CGPoint(), size: layout.containerLayout.size) + self.scrollView.contentSize = CGSize(width: layout.containerLayout.size.width, height: contentHeight + layout.containerLayout.insets.bottom) + self.ignoreScrolling = false + } + + if layout.containerLayout.lockScrollingAtTop { + self.scrollView.isScrollEnabled = false + + self.ignoreScrolling = true + self.scrollView.setContentOffset(CGPoint(x: 0.0, y: 0.0), animated: false) + self.ignoreScrolling = false + } else { + self.scrollView.isScrollEnabled = true + if let (y, index) = restoreScrollPosition { + let itemFrame = layout.frame(at: index) + var contentOffset = itemFrame.minY - y + if contentOffset > self.scrollView.contentSize.height - self.scrollView.bounds.height { + contentOffset = self.scrollView.contentSize.height - self.scrollView.bounds.height + } + if contentOffset < 0.0 { + contentOffset = 0.0 + } + + self.ignoreScrolling = true + self.scrollView.setContentOffset(CGPoint(x: 0.0, y: contentOffset), animated: false) + self.ignoreScrolling = false + } + } + + let visibleBounds = self.scrollView.bounds + + var validIds = Set() + var usedPlaceholderCount = 0 + if !items.items.isEmpty { + var bindItems: [Item] = [] + var bindLayers: [SparseItemGridLayer] = [] + var updateLayers: [SparseItemGridLayer] = [] + + let visibleRange = layout.visibleItemRange(for: visibleBounds, count: items.count) + for index in visibleRange.minIndex ... visibleRange.maxIndex { + if let item = items.item(at: index) { + let itemLayer: VisibleItemLayer + if let current = self.visibleItems[item.id] { + itemLayer = current + updateLayers.append(itemLayer.layer) + } else { + itemLayer = VisibleItemLayer(layer: items.itemBinding.createLayer()) + self.visibleItems[item.id] = itemLayer + + bindItems.append(item) + bindLayers.append(itemLayer.layer) + + self.scrollView.layer.addSublayer(itemLayer.layer) + } + + validIds.insert(item.id) + + itemLayer.layer.frame = layout.frame(at: index) + } else { + let placeholderLayer: ShimmerLayer + if self.visiblePlaceholders.count > usedPlaceholderCount { + placeholderLayer = self.visiblePlaceholders[usedPlaceholderCount] + } else { + placeholderLayer = ShimmerLayer() + self.scrollView.layer.addSublayer(placeholderLayer) + self.visiblePlaceholders.append(placeholderLayer) + } + placeholderLayer.frame = layout.frame(at: index) + usedPlaceholderCount += 1 + } + } + + if !bindItems.isEmpty { + items.itemBinding.bindLayers(items: bindItems, layers: bindLayers) + } + + for layer in updateLayers { + layer.update(size: layer.bounds.size) + } + } + + var removeIds: [AnyHashable] = [] + for (id, _) in self.visibleItems { + if !validIds.contains(id) { + removeIds.append(id) + } + } + for id in removeIds { + if let itemLayer = self.visibleItems.removeValue(forKey: id) { + items.itemBinding.unbindLayer(layer: itemLayer.layer) + itemLayer.layer.removeFromSuperlayer() + } + } + + if self.visiblePlaceholders.count > usedPlaceholderCount { + for i in usedPlaceholderCount ..< self.visiblePlaceholders.count { + self.visiblePlaceholders[i].removeFromSuperlayer() + } + self.visiblePlaceholders.removeSubrange(usedPlaceholderCount...) + } + + self.updateScrollingArea() + self.updateHoleToLoad() + } + + func updateHoleToLoad() { + guard let layout = self.layout, let items = self.items else { + return + } + + if !items.items.isEmpty { + let visibleBounds = self.scrollView.bounds + let visibleRange = layout.visibleItemRange(for: visibleBounds, count: items.count) + for index in visibleRange.minIndex ... visibleRange.maxIndex { + if items.item(at: index) == nil { + if let holeAnchor = items.closestHole(to: index) { + let location: HoleLocation + if index < holeAnchor.index { + location = .toLower + } else { + location = .toUpper + } + self.maybeLoadHoleAnchor(holeAnchor, location) + } + break + } + } + } + } + + func setScrollingArea(scrollingArea: SparseItemGridScrollingArea?) { + if self.scrollingArea === scrollingArea { + return + } + self.scrollingArea = scrollingArea + + if let scrollingArea = self.scrollingArea { + scrollingArea.beginScrolling = { [weak self] in + guard let strongSelf = self else { + return nil + } + return strongSelf.scrollView + } + self.updateScrollingArea() + } + } + + private func updateScrollingArea() { + guard let layout = self.layout, let items = self.items else { + return + } + + let contentHeight = layout.contentHeight(count: items.count) + + var tag: Int32? + let visibleBounds = self.scrollView.bounds + let visibleRange = layout.visibleItemRange(for: visibleBounds, count: items.count) + for index in visibleRange.minIndex ... visibleRange.maxIndex { + if let tagValue = items.tag(atIndexOrLower: index) { + tag = tagValue + break + } + } + + if let scrollingArea = self.scrollingArea { + scrollingArea.update( + containerSize: layout.containerLayout.size, + containerInsets: layout.containerLayout.insets, + contentHeight: contentHeight, + contentOffset: self.scrollView.bounds.minY, + isScrolling: self.scrollView.isDragging || self.scrollView.isDecelerating, + dateString: tag.flatMap { items.itemBinding.scrollerTextForTag(tag: $0) } ?? "", + transition: .immediate + ) + } + } + } + + private final class ViewportTransition: ASDisplayNode { + struct InteractiveState { + var initialScale: CGFloat + var targetScale: CGFloat + } + + let interactiveState: InteractiveState? + let layout: ContainerLayout + let anchorItemIndex: Int + let fromViewport: Viewport + let toViewport: Viewport + + var currentProgress: CGFloat = 0.0 + + init(interactiveState: InteractiveState?, layout: ContainerLayout, anchorItemIndex: Int, from fromViewport: Viewport, to toViewport: Viewport) { + self.interactiveState = interactiveState + self.layout = layout + self.anchorItemIndex = anchorItemIndex + self.fromViewport = fromViewport + self.toViewport = toViewport + + super.init() + + self.addSubnode(fromViewport) + self.addSubnode(toViewport) + } + + func update(progress: CGFloat, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) { + guard var fromAnchorFrame = self.fromViewport.frameForItem(at: self.anchorItemIndex) else { + return + } + guard var toAnchorFrame = self.toViewport.frameForItem(at: self.anchorItemIndex) else { + return + } + + let previousProgress = self.currentProgress + self.currentProgress = progress + + if let fromItem = self.fromViewport.anchorItem(at: fromAnchorFrame.center), let fromFrame = self.fromViewport.frameForItem(at: fromItem.index) { + fromAnchorFrame.origin.x = fromFrame.midX + fromAnchorFrame.size.width = 0.0 + } + + if let toItem = self.toViewport.anchorItem(at: fromAnchorFrame.center), let toFrame = self.toViewport.frameForItem(at: toItem.index) { + toAnchorFrame.origin.x = toFrame.midX + toAnchorFrame.size.width = 0.0 + } + + let fromAnchorPoint = CGPoint(x: fromAnchorFrame.midX, y: fromAnchorFrame.midY) + let toAnchorPoint = CGPoint(x: toAnchorFrame.midX, y: toAnchorFrame.midY) + + let initialFromViewportScale: CGFloat = 1.0 + let targetFromViewportScale: CGFloat = toAnchorFrame.height / fromAnchorFrame.height + + let initialToViewportScale: CGFloat = fromAnchorFrame.height / toAnchorFrame.height + let targetToViewportScale: CGFloat = 1.0 + + let fromScale = initialFromViewportScale * (1.0 - progress) + targetFromViewportScale * progress + let toScale = initialToViewportScale * (1.0 - progress) + targetToViewportScale * progress + + let fromDeltaOffset = CGPoint(x: toAnchorPoint.x - fromAnchorPoint.x, y: toAnchorPoint.y - fromAnchorPoint.y) + let toDeltaOffset = CGPoint(x: -fromDeltaOffset.x, y: -fromDeltaOffset.y) + + let fromOffset = CGPoint(x: 0.0 * (1.0 - progress) + fromDeltaOffset.x * progress, y: 0.0 * (1.0 - progress) + fromDeltaOffset.y * progress) + let toOffset = CGPoint(x: toDeltaOffset.x * (1.0 - progress) + 0.0 * progress, y: toDeltaOffset.y * (1.0 - progress) + 0.0 * progress) + + var fromTransform = CGAffineTransform.identity + fromTransform = fromTransform.translatedBy(x: fromAnchorPoint.x, y: fromAnchorPoint.y) + fromTransform = fromTransform.translatedBy(x: fromOffset.x, y: fromOffset.y) + fromTransform = fromTransform.scaledBy(x: fromScale, y: fromScale) + fromTransform = fromTransform.translatedBy(x: -fromAnchorPoint.x, y: -fromAnchorPoint.y) + + + var toTransform = CGAffineTransform.identity + toTransform = toTransform.translatedBy(x: toAnchorPoint.x, y: toAnchorPoint.y) + toTransform = toTransform.translatedBy(x: toOffset.x, y: toOffset.y) + toTransform = toTransform.scaledBy(x: toScale, y: toScale) + toTransform = toTransform.translatedBy(x: -toAnchorPoint.x, y: -toAnchorPoint.y) + + transition.updateTransform(node: self.fromViewport, transform: fromTransform) + transition.updateTransform(node: self.toViewport, transform: toTransform) + + transition.updateAlpha(node: self.toViewport, alpha: progress, completion: { _ in + completion() + }) + + let fromAlphaStartProgress: CGFloat = 0.6 + let fromAlphaEndProgress: CGFloat = 1.0 + let fromAlphaProgress = max(0.0, progress - fromAlphaStartProgress) / (fromAlphaEndProgress - fromAlphaStartProgress) + + if previousProgress < fromAlphaStartProgress, progress == 1.0, case let .animated(duration, _) = transition { + transition.updateAlpha(node: self.fromViewport, alpha: 1.0 - fromAlphaProgress, delay: duration * 0.5) + } else { + transition.updateAlpha(node: self.fromViewport, alpha: 1.0 - fromAlphaProgress) + } + } + } + + private struct ContainerLayout: Equatable { + var size: CGSize + var insets: UIEdgeInsets + var scrollIndicatorInsets: UIEdgeInsets + var lockScrollingAtTop: Bool + } + + private var pinchRecognizer: UIPinchGestureRecognizer? + + private var containerLayout: ContainerLayout? + private var items: Items? + + private var currentViewport: Viewport? + private var currentViewportTransition: ViewportTransition? + private let scrollingArea: SparseItemGridScrollingArea + + private var isLoadingHole: Bool = false + private let loadingHoleDisposable = MetaDisposable() + + override public init() { + self.scrollingArea = SparseItemGridScrollingArea() + + super.init() + + self.clipsToBounds = true + + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) + self.view.addGestureRecognizer(tapRecognizer) + + let pinchRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(self.pinchGesture(_:))) + self.pinchRecognizer = pinchRecognizer + self.view.addGestureRecognizer(pinchRecognizer) + + self.addSubnode(self.scrollingArea) + self.scrollingArea.openCurrentDate = { [weak self] in + guard let strongSelf = self, let items = strongSelf.items else { + return + } + items.itemBinding.onTagTap() + } + } + + @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { + guard let currentViewport = self.currentViewport, let items = self.items else { + return + } + if self.currentViewportTransition != nil { + return + } + if case .ended = recognizer.state { + let location = recognizer.location(in: self.view) + if let item = currentViewport.item(at: self.view.convert(location, to: currentViewport.view)) { + items.itemBinding.onTap(item: item) + } + } + } + + @objc private func pinchGesture(_ recognizer: UIPinchGestureRecognizer) { + guard let containerLayout = self.containerLayout, let items = self.items else { + return + } + + switch recognizer.state { + case .began: + break + case .changed: + let scale = recognizer.scale + if let currentViewportTransition = self.currentViewportTransition, let interactiveState = currentViewportTransition.interactiveState { + + let progress = (scale - interactiveState.initialScale) / (interactiveState.targetScale - interactiveState.initialScale) + var replacedTransition = false + //print("progress: \(progress), scale: \(scale), initial: \(interactiveState.initialScale), target: \(interactiveState.targetScale)") + if progress < 0.0 || progress > 1.0 { + let boundaryViewport = progress > 1.0 ? currentViewportTransition.toViewport : currentViewportTransition.fromViewport + let zoomLevels = self.availableZoomLevels(startingAt: boundaryViewport.zoomLevel) + + let isZoomingIn = interactiveState.targetScale > interactiveState.initialScale + var nextZoomLevel: ZoomLevel? + let startScale = progress > 1.0 ? interactiveState.targetScale : interactiveState.initialScale + let nextScale: CGFloat + if isZoomingIn { + if progress > 1.0 { + nextZoomLevel = zoomLevels.increment + nextScale = startScale * 2.0 + } else { + nextZoomLevel = zoomLevels.decrement + nextScale = startScale * 0.5 + } + } else { + if progress > 1.0 { + nextZoomLevel = zoomLevels.decrement + nextScale = startScale * 0.5 + } else { + nextZoomLevel = zoomLevels.increment + nextScale = startScale * 2.0 + } + } + + if let nextZoomLevel = nextZoomLevel, let anchorItemFrame = boundaryViewport.frameForItem(at: currentViewportTransition.anchorItemIndex) { + replacedTransition = true + + let restoreScrollPosition: (y: CGFloat, index: Int)? = (anchorItemFrame.minY, currentViewportTransition.anchorItemIndex) + + let nextViewport = Viewport(zoomLevel: nextZoomLevel, maybeLoadHoleAnchor: { [weak self] holeAnchor, location in + guard let strongSelf = self else { + return + } + strongSelf.maybeLoadHoleAnchor(holeAnchor: holeAnchor, location: location) + }) + + nextViewport.frame = CGRect(origin: CGPoint(), size: containerLayout.size) + nextViewport.update(containerLayout: containerLayout, items: items, restoreScrollPosition: restoreScrollPosition) + + self.currentViewportTransition?.removeFromSupernode() + + let currentViewportTransition = ViewportTransition(interactiveState: ViewportTransition.InteractiveState(initialScale: startScale, targetScale: nextScale), layout: containerLayout, anchorItemIndex: currentViewportTransition.anchorItemIndex, from: boundaryViewport, to: nextViewport) + currentViewportTransition.frame = CGRect(origin: CGPoint(), size: containerLayout.size) + self.insertSubnode(currentViewportTransition, belowSubnode: self.scrollingArea) + self.currentViewportTransition = currentViewportTransition + currentViewportTransition.update(progress: progress, transition: .immediate, completion: {}) + } + } + + if !replacedTransition { + currentViewportTransition.update(progress: min(1.0, max(0.0, progress)), transition: .immediate, completion: {}) + } + } else if scale != 1.0 { + let zoomLevels = self.availableZoomLevels() + var nextZoomLevel: ZoomLevel? + if scale > 1.0 { + nextZoomLevel = zoomLevels.increment + } else { + nextZoomLevel = zoomLevels.decrement + } + if let previousViewport = self.currentViewport, let nextZoomLevel = nextZoomLevel { + let interactiveState = ViewportTransition.InteractiveState(initialScale: 1.0, targetScale: scale > 1.0 ? 2.0 : 0.5) + + var progress = (scale - interactiveState.initialScale) / (interactiveState.targetScale - interactiveState.initialScale) + progress = max(0.0, min(1.0, progress)) + + let anchorLocation = recognizer.location(in: self.view) + + if let anchorItem = previousViewport.anchorItem(at: anchorLocation), let anchorItemFrame = previousViewport.frameForItem(at: anchorItem.index) { + let restoreScrollPosition: (y: CGFloat, index: Int)? = (anchorItemFrame.minY, anchorItem.index) + let anchorItemIndex = anchorItem.index + + let nextViewport = Viewport(zoomLevel: nextZoomLevel, maybeLoadHoleAnchor: { [weak self] holeAnchor, location in + guard let strongSelf = self else { + return + } + strongSelf.maybeLoadHoleAnchor(holeAnchor: holeAnchor, location: location) + }) + + nextViewport.frame = CGRect(origin: CGPoint(), size: containerLayout.size) + nextViewport.update(containerLayout: containerLayout, items: items, restoreScrollPosition: restoreScrollPosition) + + let currentViewportTransition = ViewportTransition(interactiveState: interactiveState, layout: containerLayout, anchorItemIndex: anchorItemIndex, from: previousViewport, to: nextViewport) + currentViewportTransition.frame = CGRect(origin: CGPoint(), size: containerLayout.size) + self.insertSubnode(currentViewportTransition, belowSubnode: self.scrollingArea) + self.currentViewportTransition = currentViewportTransition + currentViewportTransition.update(progress: progress, transition: .immediate, completion: {}) + } + } + } + case .ended, .cancelled: + if let currentViewportTransition = self.currentViewportTransition, let interactiveState = currentViewportTransition.interactiveState { + let scale = recognizer.scale + var currentProgress = (scale - interactiveState.initialScale) / (interactiveState.targetScale - interactiveState.initialScale) + currentProgress = max(0.0, min(1.0, currentProgress)) + let progress = currentProgress < 0.3 ? 0.0 : 1.0 + + currentViewportTransition.update(progress: progress, transition: .animated(duration: 0.2, curve: .easeInOut), completion: { [weak self, weak currentViewportTransition] in + guard let strongSelf = self, let currentViewportTransition = currentViewportTransition else { + return + } + + let previousViewport = strongSelf.currentViewport + + strongSelf.currentViewport = progress < 0.5 ? currentViewportTransition.fromViewport : currentViewportTransition.toViewport + + if let previousViewport = previousViewport, previousViewport !== strongSelf.currentViewport { + previousViewport.removeFromSupernode() + } + + if let containerLayout = strongSelf.containerLayout, let currentViewport = strongSelf.currentViewport, let items = strongSelf.items { + strongSelf.insertSubnode(currentViewport, belowSubnode: strongSelf.scrollingArea) + strongSelf.scrollingArea.frame = CGRect(origin: CGPoint(), size: containerLayout.size) + currentViewport.setScrollingArea(scrollingArea: strongSelf.scrollingArea) + currentViewport.frame = CGRect(origin: CGPoint(), size: containerLayout.size) + currentViewport.update(containerLayout: containerLayout, items: items, restoreScrollPosition: nil) + } + + strongSelf.currentViewportTransition = nil + currentViewportTransition.removeFromSupernode() + }) + } + default: + break + } + } + + public func update(size: CGSize, insets: UIEdgeInsets, scrollIndicatorInsets: UIEdgeInsets, lockScrollingAtTop: Bool, items: Items) { + let containerLayout = ContainerLayout(size: size, insets: insets, scrollIndicatorInsets: scrollIndicatorInsets, lockScrollingAtTop: lockScrollingAtTop) + self.containerLayout = containerLayout + self.items = items + self.scrollingArea.isHidden = lockScrollingAtTop + + if self.currentViewport == nil { + let currentViewport = Viewport(zoomLevel: ZoomLevel(rawValue: 100), maybeLoadHoleAnchor: { [weak self] holeAnchor, location in + guard let strongSelf = self else { + return + } + strongSelf.maybeLoadHoleAnchor(holeAnchor: holeAnchor, location: location) + }) + self.currentViewport = currentViewport + self.insertSubnode(currentViewport, belowSubnode: self.scrollingArea) + + currentViewport.setScrollingArea(scrollingArea: self.scrollingArea) + } + + if let _ = self.currentViewportTransition { + } else if let currentViewport = self.currentViewport { + self.scrollingArea.frame = CGRect(origin: CGPoint(), size: size) + currentViewport.frame = CGRect(origin: CGPoint(), size: size) + currentViewport.update(containerLayout: containerLayout, items: items, restoreScrollPosition: nil) + } + } + + private func maybeLoadHoleAnchor(holeAnchor: HoleAnchor, location: HoleLocation) { + if self.isLoadingHole { + return + } + guard let items = self.items else { + return + } + + self.isLoadingHole = true + self.loadingHoleDisposable.set((items.itemBinding.loadHole(anchor: holeAnchor, at: location) + |> deliverOnMainQueue).start(completed: { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.isLoadingHole = false + if let currentViewport = strongSelf.currentViewport { + currentViewport.updateHoleToLoad() + } + })) + } + + public func availableZoomLevels() -> (decrement: ZoomLevel?, increment: ZoomLevel?) { + guard let currentViewport = self.currentViewport else { + return (nil, nil) + } + return self.availableZoomLevels(startingAt: currentViewport.zoomLevel) + } + + private func availableZoomLevels(startingAt zoomLevel: ZoomLevel) -> (decrement: ZoomLevel?, increment: ZoomLevel?) { + let zoomLevels: [ZoomLevel] = [ + ZoomLevel(rawValue: 25), + ZoomLevel(rawValue: 40), + ZoomLevel(rawValue: 75), + ZoomLevel(rawValue: 100), + ZoomLevel(rawValue: 150) + ] + if let index = zoomLevels.firstIndex(of: zoomLevel) { + return (index == 0 ? nil : zoomLevels[index - 1], index == (zoomLevels.count - 1) ? nil : zoomLevels[index + 1]) + } else { + return (nil, nil) + } + } + + public func setZoomLevel(level: ZoomLevel) { + guard let previousViewport = self.currentViewport else { + return + } + if self.currentViewportTransition != nil { + return + } + self.currentViewport = nil + previousViewport.removeFromSupernode() + + let currentViewport = Viewport(zoomLevel: level, maybeLoadHoleAnchor: { [weak self] holeAnchor, location in + guard let strongSelf = self else { + return + } + strongSelf.maybeLoadHoleAnchor(holeAnchor: holeAnchor, location: location) + }) + self.currentViewport = currentViewport + self.insertSubnode(currentViewport, belowSubnode: self.scrollingArea) + + if let containerLayout = self.containerLayout, let items = self.items { + let anchorLocation = CGPoint(x: 0.0, y: 10.0) + if let anchorItem = previousViewport.anchorItem(at: anchorLocation), let anchorItemFrame = previousViewport.frameForItem(at: anchorItem.index) { + let restoreScrollPosition: (y: CGFloat, index: Int)? = (anchorItemFrame.minY, anchorItem.index) + let anchorItemIndex = anchorItem.index + + self.scrollingArea.frame = CGRect(origin: CGPoint(), size: containerLayout.size) + currentViewport.frame = CGRect(origin: CGPoint(), size: containerLayout.size) + currentViewport.update(containerLayout: containerLayout, items: items, restoreScrollPosition: restoreScrollPosition) + + let currentViewportTransition = ViewportTransition(interactiveState: nil, layout: containerLayout, anchorItemIndex: anchorItemIndex, from: previousViewport, to: currentViewport) + currentViewportTransition.frame = CGRect(origin: CGPoint(), size: containerLayout.size) + self.insertSubnode(currentViewportTransition, belowSubnode: self.scrollingArea) + self.currentViewportTransition = currentViewportTransition + currentViewportTransition.update(progress: 0.0, transition: .immediate, completion: {}) + currentViewportTransition.update(progress: 1.0, transition: .animated(duration: 0.25, curve: .easeInOut), completion: { [weak self] in + guard let strongSelf = self else { + return + } + + if let containerLayout = strongSelf.containerLayout, let currentViewport = strongSelf.currentViewport, let items = strongSelf.items { + strongSelf.insertSubnode(currentViewport, belowSubnode: strongSelf.scrollingArea) + strongSelf.scrollingArea.frame = CGRect(origin: CGPoint(), size: containerLayout.size) + currentViewport.frame = CGRect(origin: CGPoint(), size: containerLayout.size) + currentViewport.update(containerLayout: containerLayout, items: items, restoreScrollPosition: nil) + } + + strongSelf.currentViewport?.setScrollingArea(scrollingArea: strongSelf.scrollingArea) + + if let currentViewportTransition = strongSelf.currentViewportTransition { + strongSelf.currentViewportTransition = nil + currentViewportTransition.removeFromSupernode() + } + }) + } + } + } + + public func forEachVisibleItem(_ f: (SparseItemGridLayer) -> Void) { + guard let currentViewport = self.currentViewport else { + return + } + for (_, itemLayer) in currentViewport.visibleItems { + f(itemLayer.layer) + } + } + + public func frameForItem(layer: SparseItemGridLayer) -> CGRect { + guard let currentViewport = self.currentViewport else { + return layer.bounds + } + return self.view.convert(currentViewport.frameForItem(layer: layer), from: currentViewport.view) + } + + public func scrollToItem(at index: Int) { + guard let currentViewport = self.currentViewport else { + return + } + currentViewport.scrollToItem(at: index) + } + + public func addToTransitionSurface(view: UIView) { + self.view.insertSubview(view, belowSubview: self.scrollingArea.view) + } +} diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/SparseMessageList.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/SparseMessageList.swift index 439b067ce6..cc5a5c27d3 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/SparseMessageList.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/SparseMessageList.swift @@ -83,7 +83,6 @@ public final class SparseMessageList { } private let loadHoleDisposable = MetaDisposable() private var loadingHole: LoadingHole? - private var scheduledLoadingHole: LoadingHole? private var loadingPlaceholders: [MessageId: Disposable] = [:] private var loadedPlaceholders: [MessageId: Message] = [:] @@ -169,7 +168,13 @@ public final class SparseMessageList { } private func resetTopSection() { - self.topItemsDisposable.set((self.account.postbox.aroundMessageHistoryViewForLocation(.peer(peerId), anchor: .upperBound, count: 200, fixedCombinedReadStates: nil, topTaggedMessageIdNamespaces: Set(), tagMask: self.messageTag, appendMessagesFromTheSameGroup: false, namespaces: .not(Set(Namespaces.Message.allScheduled)), orderStatistics: []) + let count: Int + #if DEBUG + count = 20 + #else + count = 200 + #endif + self.topItemsDisposable.set((self.account.postbox.aroundMessageHistoryViewForLocation(.peer(peerId), anchor: .upperBound, count: count, fixedCombinedReadStates: nil, topTaggedMessageIdNamespaces: Set(), tagMask: self.messageTag, appendMessagesFromTheSameGroup: false, namespaces: .not(Set(Namespaces.Message.allScheduled)), orderStatistics: []) |> deliverOn(self.queue)).start(next: { [weak self] view, updateType, _ in guard let strongSelf = self else { return @@ -337,14 +342,15 @@ public final class SparseMessageList { }) } - func loadHole(anchor: MessageId, direction: LoadHoleDirection) { + func loadHole(anchor: MessageId, direction: LoadHoleDirection, completion: @escaping () -> Void) { let loadingHole = LoadingHole(anchor: anchor, direction: direction) if self.loadingHole == loadingHole { + completion() return } if self.loadingHole != nil { - self.scheduledLoadingHole = loadingHole + completion() return } @@ -370,6 +376,7 @@ public final class SparseMessageList { } |> deliverOn(self.queue)).start(next: { [weak self] messages in guard let strongSelf = self else { + completion() return } @@ -490,12 +497,9 @@ public final class SparseMessageList { if strongSelf.loadingHole == loadingHole { strongSelf.loadingHole = nil - - if let scheduledLoadingHole = strongSelf.scheduledLoadingHole { - strongSelf.scheduledLoadingHole = nil - strongSelf.loadHole(anchor: scheduledLoadingHole.anchor, direction: scheduledLoadingHole.direction) - } } + + completion() })) } @@ -634,9 +638,9 @@ public final class SparseMessageList { } }*/ - public func loadHole(anchor: MessageId, direction: LoadHoleDirection) { + public func loadHole(anchor: MessageId, direction: LoadHoleDirection, completion: @escaping () -> Void) { self.impl.with { impl in - impl.loadHole(anchor: anchor, direction: direction) + impl.loadHole(anchor: anchor, direction: direction, completion: completion) } } } diff --git a/submodules/TelegramUI/BUILD b/submodules/TelegramUI/BUILD index 038449dcff..6c303a3abe 100644 --- a/submodules/TelegramUI/BUILD +++ b/submodules/TelegramUI/BUILD @@ -245,6 +245,7 @@ swift_library( "//submodules/CalendarMessageScreen:CalendarMessageScreen", "//submodules/LottieMeshSwift:LottieMeshSwift", "//submodules/MeshAnimationCache:MeshAnimationCache", + "//submodules/DirectMediaImageCache:DirectMediaImageCache", ] + select({ "@build_bazel_rules_apple//apple:ios_armv7": [], "@build_bazel_rules_apple//apple:ios_arm64": appcenter_targets, diff --git a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift b/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift index 7bce271816..273e2d526b 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/Panes/PeerInfoVisualMediaPaneNode.swift @@ -15,6 +15,9 @@ import ListMessageItem import ChatMessageInteractiveMediaBadge import SparseItemGrid import ShimmerEffect +import QuartzCore +import DirectMediaImageCache +import ComponentFlow private final class FrameSequenceThumbnailNode: ASDisplayNode { private let context: AccountContext @@ -133,7 +136,7 @@ private final class VisualMediaItemInteraction { } } -private final class VisualMediaItemNode: ASDisplayNode { +/*private final class VisualMediaItemNode: ASDisplayNode { private let context: AccountContext private let interaction: VisualMediaItemInteraction @@ -214,8 +217,6 @@ private final class VisualMediaItemNode: ASDisplayNode { } func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { - //var rect = rect - //rect.origin.y += self.insets.top self.absoluteLocation = (rect, containerSize) if let shimmerNode = self.placeholderNode { shimmerNode.updateAbsoluteRect(rect, within: containerSize) @@ -620,10 +621,36 @@ private final class VisualMediaItemNode: ASDisplayNode { self.displayLink?.isPaused = !self.hasVisibility || self.isHidden } } +*/ -private final class VisualMediaItem { - let index: Int - let id: MessageId +private final class VisualMediaHoleAnchor: SparseItemGrid.HoleAnchor { + let messageId: MessageId + override var id: AnyHashable { + return AnyHashable(self.messageId) + } + + let indexValue: Int + override var index: Int { + return self.indexValue + } + + let timestamp: Int32 + override var tag: Int32 { + return self.timestamp + } + + init(index: Int, messageId: MessageId, timestamp: Int32) { + self.indexValue = index + self.messageId = messageId + self.timestamp = timestamp + } +} + +private final class VisualMediaItem: SparseItemGrid.Item { + let indexValue: Int + override var index: Int { + return self.indexValue + } let timestamp: Int32 let message: Message? let isLocal: Bool @@ -638,28 +665,222 @@ private final class VisualMediaItem { if let message = self.message { return .message(message.stableId) } else { - return .placeholder(self.id) + preconditionFailure() + //return .placeholder(self.id) } } + + override var id: AnyHashable { + return AnyHashable(self.stableId) + } + + override var tag: Int32 { + return self.timestamp + } init(index: Int, message: Message, isLocal: Bool) { - self.index = index + self.indexValue = index self.message = message - self.id = message.id self.timestamp = message.timestamp self.isLocal = isLocal } +} - init(index: Int, id: MessageId, timestamp: Int32) { - self.index = index - self.id = id - self.timestamp = timestamp - self.message = nil - self.isLocal = false +private final class NullActionClass: NSObject, CAAction { + @objc func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) { } } -private struct VisualMediaItemCollection { +private let nullAction = NullActionClass() + +private final class ItemLayer: CALayer, SparseItemGridLayer { + var item: VisualMediaItem? + var shimmerLayer: SparseItemGrid.ShimmerLayer? + var disposable: Disposable? + + override init() { + super.init() + + self.contentsGravity = .resize + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.disposable?.dispose() + } + + override func action(forKey event: String) -> CAAction? { + return nullAction + } + + func bind(item: VisualMediaItem) { + self.item = item + + /*if self.contents == nil, let message = item.message { + self.backgroundColor = UIColor(rgb: UInt32(clamping: UInt(bitPattern: String("\(message.id)").hashValue) & 0xffffffff)).cgColor + }*/ + + self.updateShimmerLayer() + } + + func updateShimmerLayer() { + if self.contents == nil { + if self.shimmerLayer == nil { + let shimmerLayer = SparseItemGrid.ShimmerLayer() + self.shimmerLayer = shimmerLayer + shimmerLayer.frame = self.bounds + self.addSublayer(shimmerLayer) + } + } else if let shimmerLayer = self.shimmerLayer { + self.shimmerLayer = nil + shimmerLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak shimmerLayer] _ in + shimmerLayer?.removeFromSuperlayer() + }) + } + } + + func unbind() { + self.item = nil + } + + func update(size: CGSize) { + if let shimmerLayer = self.shimmerLayer { + shimmerLayer.frame = CGRect(origin: CGPoint(), size: size) + } + /*var dimensions: CGSize? + + if let item = self.item, let message = item.message { + for media in message.media { + if let image = media as? TelegramMediaImage, let representation = image.representations.last { + dimensions = representation.dimensions.cgSize + } else if let file = media as? TelegramMediaFile { + dimensions = file.dimensions?.cgSize ?? CGSize(width: 640.0, height: 480.0) + } + } + } + + if let dimensions = dimensions { + let scaledSize = dimensions.aspectFilled(size) + let scaledRect = CGRect(origin: CGPoint(x: (size.width - scaledSize.width) / 2.0, y: (size.height - scaledSize.height) / 2.0), size: scaledSize) + self.contentsRect = CGRect(origin: CGPoint(x: scaledRect.minX / size.width, y: scaledRect.minY / size.height), size: CGSize(width: scaledRect.width / size.width, height: scaledRect.height / size.height)) + } else { + self.contentsRect = CGRect(origin: CGPoint(), size: CGSize(width: 1.0, height: 1.0)) + }*/ + } +} + +private final class SparseItemGridBindingImpl: SparseItemGridBinding { + private let context: AccountContext + private let directMediaImageCache: DirectMediaImageCache + private let strings: PresentationStrings + + var loadHoleImpl: ((SparseItemGrid.HoleAnchor, SparseItemGrid.HoleLocation) -> Signal)? + var onTapImpl: ((VisualMediaItem) -> Void)? + var onTagTapImpl: (() -> Void)? + var didScrollImpl: (() -> Void)? + + init(context: AccountContext, directMediaImageCache: DirectMediaImageCache) { + self.context = context + self.directMediaImageCache = directMediaImageCache + self.strings = context.sharedContext.currentPresentationData.with({ $0 }).strings + } + + func createLayer() -> SparseItemGridLayer { + return ItemLayer() + } + + func bindLayers(items: [SparseItemGrid.Item], layers: [SparseItemGridLayer]) { + for i in 0 ..< items.count { + guard let item = items[i] as? VisualMediaItem, let layer = layers[i] as? ItemLayer else { + continue + } + if layer.bounds.isEmpty { + continue + } + + let imageWidthSpec: Int + if layer.bounds.width <= 50 { + imageWidthSpec = 64 + } else if layer.bounds.width <= 100 { + imageWidthSpec = 150 + } else if layer.bounds.width <= 140 { + imageWidthSpec = 200 + } else { + imageWidthSpec = 280 + } + + if let message = item.message { + var selectedMedia: Media? + for media in message.media { + if let image = media as? TelegramMediaImage { + selectedMedia = image + break + } else if let file = media as? TelegramMediaFile { + selectedMedia = file + break + } + } + if let selectedMedia = selectedMedia { + if let result = directMediaImageCache.getImage(message: message, media: selectedMedia, width: imageWidthSpec) { + layer.contents = result.image?.cgImage + if let loadSignal = result.loadSignal { + layer.disposable = (loadSignal + |> deliverOnMainQueue).start(next: { [weak layer] image in + guard let layer = layer else { + return + } + layer.contents = image?.cgImage + layer.updateShimmerLayer() + }) + } + } + } + } + + layer.bind(item: item) + } + } + + func unbindLayer(layer: SparseItemGridLayer) { + guard let layer = layer as? ItemLayer else { + return + } + layer.unbind() + } + + func scrollerTextForTag(tag: Int32) -> String? { + let (year, month) = listMessageDateHeaderInfo(timestamp: tag) + return stringForMonth(strings: self.strings, month: month, ofYear: year) + } + + func loadHole(anchor: SparseItemGrid.HoleAnchor, at location: SparseItemGrid.HoleLocation) -> Signal { + if let loadHoleImpl = self.loadHoleImpl { + return loadHoleImpl(anchor, location) + } else { + return .never() + } + } + + func onTap(item: SparseItemGrid.Item) { + guard let item = item as? VisualMediaItem else { + return + } + self.onTapImpl?(item) + } + + func onTagTap() { + self.onTagTapImpl?() + } + + func didScroll() { + self.didScrollImpl?() + } +} + +/*private struct VisualMediaItemCollection { var items: [VisualMediaItem] var totalCount: Int @@ -746,55 +967,6 @@ private struct VisualMediaItemCollection { return nil } } -} - -/*private final class FloatingHeaderNode: ASDisplayNode { - private let backgroundNode: ASImageNode - private let labelNode: ImmediateTextNode - - private var currentParams: (constrainedWidth: CGFloat, year: Int32, month: Int32, theme: PresentationTheme)? - private var currentSize: CGSize? - - override init() { - self.backgroundNode = ASImageNode() - self.backgroundNode.displaysAsynchronously = false - self.backgroundNode.displayWithoutProcessing = true - - self.labelNode = ImmediateTextNode() - self.labelNode.displaysAsynchronously = false - - super.init() - - self.addSubnode(self.backgroundNode) - self.addSubnode(self.labelNode) - } - - func update(constrainedWidth: CGFloat, year: Int32, month: Int32, theme: PresentationTheme, strings: PresentationStrings) -> CGSize { - if let currentParams = self.currentParams, let currentSize = self.currentSize { - if currentParams.constrainedWidth == constrainedWidth && - currentParams.year == year && - currentParams.month == month && - currentParams.theme === theme { - return currentSize - } - } - - if self.currentParams?.theme !== theme { - self.backgroundNode.image = generateStretchableFilledCircleImage(diameter: 27.0, color: mediaBadgeBackgroundColor) - } - - self.currentParams = (constrainedWidth, year, month, theme) - - self.labelNode.attributedText = NSAttributedString(string: stringForMonth(strings: strings, month: month, ofYear: year), font: Font.regular(14.0), textColor: .white) - let labelSize = self.labelNode.updateLayout(CGSize(width: constrainedWidth, height: .greatestFiniteMagnitude)) - - let sideInset: CGFloat = 10.0 - self.labelNode.frame = CGRect(origin: CGPoint(x: sideInset, y: floor((27.0 - labelSize.height) / 2.0)), size: labelSize) - self.backgroundNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: labelSize.width + sideInset * 2.0, height: 27.0)) - - let size = CGSize(width: labelSize.width + sideInset * 2.0, height: 27.0) - return size - } }*/ private func tagMaskForType(_ type: PeerInfoVisualMediaPaneNode.ContentType) -> MessageTags { @@ -810,7 +982,7 @@ private func tagMaskForType(_ type: PeerInfoVisualMediaPaneNode.ContentType) -> } } -private enum ItemsLayout { +/*private enum ItemsLayout { final class Grid { let containerWidth: CGFloat let itemCount: Int @@ -890,7 +1062,7 @@ private enum ItemsLayout { return grid.frame(forItemAt: index, sideInset: sideInset) } } -} +}*/ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScrollViewDelegate { enum ContentType { @@ -900,36 +1072,11 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro case gifs } - enum ZoomLevel { - case level2 - case level3 - case level4 - case level5 + struct ZoomLevel { + fileprivate var value: SparseItemGrid.ZoomLevel - func incremented() -> ZoomLevel { - switch self { - case .level2: - return .level3 - case .level3: - return .level4 - case .level4: - return .level5 - case .level5: - return .level5 - } - } - - func decremented() -> ZoomLevel { - switch self { - case .level2: - return .level2 - case .level3: - return .level2 - case .level4: - return .level3 - case .level5: - return .level4 - } + init(_ value: SparseItemGrid.ZoomLevel) { + self.value = value } } @@ -942,7 +1089,11 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro weak var parentController: ViewController? private let scrollingArea: SparseItemGridScrollingArea - private let scrollNode: ASScrollNode + //private let scrollNode: ASScrollNode + private let itemGrid: SparseItemGrid + private let itemGridBinding: SparseItemGridBindingImpl + private let directMediaImageCache: DirectMediaImageCache + private var items: SparseItemGrid.Items? private var isDeceleratingAfterTracking = false @@ -966,9 +1117,9 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro private let listDisposable = MetaDisposable() private var hiddenMediaDisposable: Disposable? - private var mediaItems = VisualMediaItemCollection(items: [], totalCount: 0) - private var itemsLayout: ItemsLayout? - private var visibleMediaItems: [VisualMediaItem.StableId: VisualMediaItemNode] = [:] + //private var mediaItems = VisualMediaItemCollection(items: [], totalCount: 0) + //private var itemsLayout: ItemsLayout? + //private var visibleMediaItems: [VisualMediaItem.StableId: VisualMediaItemNode] = [:] private var numberOfItemsToRequest: Int = 50 //private var currentView: MessageHistoryView? @@ -982,9 +1133,8 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro private var listSource: SparseMessageList private var requestedPlaceholderIds = Set() - private(set) var zoomLevel: ZoomLevel = .level3 - var openCurrentDate: (() -> Void)? + var paneDidScroll: (() -> Void)? init(context: AccountContext, chatControllerInteraction: ChatControllerInteraction, peerId: PeerId, contentType: ContentType) { self.context = context @@ -994,13 +1144,47 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro self.contentTypePromise = ValuePromise(contentType) self.scrollingArea = SparseItemGridScrollingArea() - self.scrollNode = ASScrollNode() + //self.scrollNode = ASScrollNode() + self.itemGrid = SparseItemGrid() + self.directMediaImageCache = DirectMediaImageCache(account: context.account) + self.itemGridBinding = SparseItemGridBindingImpl(context: context, directMediaImageCache: self.directMediaImageCache) self.listSource = self.context.engine.messages.sparseMessageList(peerId: self.peerId, tag: tagMaskForType(self.contentType)) super.init() - self.scrollingArea.beginScrolling = { [weak self] in + self.itemGridBinding.loadHoleImpl = { [weak self] hole, location in + guard let strongSelf = self else { + return .never() + } + return strongSelf.loadHole(anchor: hole, at: location) + } + + self.itemGridBinding.onTapImpl = { [weak self] item in + guard let strongSelf = self else { + return + } + guard let message = item.message else { + return + } + let _ = strongSelf.chatControllerInteraction.openMessage(message, .default) + } + + self.itemGridBinding.onTagTapImpl = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.openCurrentDate?() + } + + self.itemGridBinding.didScrollImpl = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.paneDidScroll?() + } + + /*self.scrollingArea.beginScrolling = { [weak self] in guard let strongSelf = self else { return nil } @@ -1012,7 +1196,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro return } strongSelf.openCurrentDate?() - } + }*/ self._itemInteraction = VisualMediaItemInteraction( openMessage: { [weak self] message in @@ -1027,7 +1211,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro ) self.itemInteraction.selectedMessageIds = chatControllerInteraction.selectionState.flatMap { $0.selectedIds } - self.scrollNode.view.delaysContentTouches = false + /*self.scrollNode.view.delaysContentTouches = false self.scrollNode.view.canCancelContentTouches = true self.scrollNode.view.showsVerticalScrollIndicator = false if #available(iOS 11.0, *) { @@ -1037,7 +1221,9 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro self.scrollNode.view.delegate = self self.addSubnode(self.scrollNode) - self.addSubnode(self.scrollingArea) + self.addSubnode(self.scrollingArea)*/ + + self.addSubnode(self.itemGrid) self.requestHistoryAroundVisiblePosition(synchronous: false, reloadAtTop: false) @@ -1052,12 +1238,10 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } } strongSelf.itemInteraction.hiddenMedia = hiddenMedia - for (_, itemNode) in strongSelf.visibleMediaItems { - itemNode.updateHiddenMedia() - } + strongSelf.updateHiddenMedia() }) - let animationTimer = SwiftSignalKit.Timer(timeout: 0.3, repeat: true, completion: { [weak self] in + /*let animationTimer = SwiftSignalKit.Timer(timeout: 0.3, repeat: true, completion: { [weak self] in guard let strongSelf = self else { return } @@ -1066,7 +1250,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } }, queue: .mainQueue()) self.animationTimer = animationTimer - animationTimer.start() + animationTimer.start()*/ self.statusPromise.set((self.contentTypePromise.get() |> distinctUntilChanged @@ -1171,6 +1355,29 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro self.animationTimer?.invalidate() } + func loadHole(anchor: SparseItemGrid.HoleAnchor, at location: SparseItemGrid.HoleLocation) -> Signal { + guard let anchor = anchor as? VisualMediaHoleAnchor else { + return .never() + } + let mappedDirection: SparseMessageList.LoadHoleDirection + switch location { + case .around: + mappedDirection = .around + case .toLower: + mappedDirection = .later + case .toUpper: + mappedDirection = .earlier + } + let listSource = self.listSource + return Signal { subscriber in + listSource.loadHole(anchor: anchor.messageId, direction: mappedDirection, completion: { + subscriber.putCompletion() + }) + + return EmptyDisposable + } + } + func updateContentType(contentType: ContentType) { if self.contentType == contentType { return @@ -1184,12 +1391,9 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } func updateZoomLevel(level: ZoomLevel) { - if self.zoomLevel == level { - return - } - self.zoomLevel = level + self.itemGrid.setZoomLevel(level: level.value) - if let (size, sideInset, bottomInset, visibleHeight, isScrollingLockedAtTop, expandProgress, presentationData) = self.currentParams { + /*if let (size, sideInset, bottomInset, visibleHeight, isScrollingLockedAtTop, expandProgress, presentationData) = self.currentParams { var currentTopVisibleItemFrame: CGRect? if let itemsLayout = self.itemsLayout { @@ -1249,11 +1453,11 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro let originalOffset = CGPoint(x: -self.scrollNode.bounds.width * (1.0 - 1.0 / widthFactor) * 0.5, y: -self.scrollNode.bounds.height * (1.0 - 1.0 / widthFactor) * 0.5)//.offsetBy(dx: additionalOffset.x, dy: additionalOffset.y) self.scrollNode.layer.animatePosition(from: originalOffset, to: CGPoint(), duration: 0.2, delay: 0.0, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: true, additive: true) } - } + }*/ } func ensureMessageIsVisible(id: MessageId) { - let activeRect = self.scrollNode.bounds + /*let activeRect = self.scrollNode.bounds for item in self.mediaItems.items { if let message = item.message, message.id == id { if let itemNode = self.visibleMediaItems[item.stableId] { @@ -1264,7 +1468,7 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } break } - } + }*/ } private func requestHistoryAroundVisiblePosition(synchronous: Bool, reloadAtTop: Bool) { @@ -1287,7 +1491,29 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } private func updateHistory(list: SparseMessageList.State, synchronous: Bool, reloadAtTop: Bool) { - self.mediaItems = VisualMediaItemCollection(items: [], totalCount: list.totalCount) + var mappedItems: [SparseItemGrid.Item] = [] + var mappeHoles: [SparseItemGrid.HoleAnchor] = [] + for item in list.items { + switch item.content { + case let .message(message, isLocal): + mappedItems.append(VisualMediaItem(index: item.index, message: message, isLocal: isLocal)) + case let .placeholder(id, timestamp): + mappeHoles.append(VisualMediaHoleAnchor(index: item.index, messageId: id, timestamp: timestamp)) + } + } + + self.items = SparseItemGrid.Items( + items: mappedItems, + holeAnchors: mappeHoles, + count: list.totalCount, + itemBinding: self.itemGridBinding + ) + + if let (size, sideInset, bottomInset, visibleHeight, isScrollingLockedAtTop, expandProgress, presentationData) = self.currentParams { + self.update(size: size, sideInset: sideInset, bottomInset: bottomInset, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, presentationData: presentationData, synchronous: false, transition: .immediate) + } + + /*self.mediaItems = VisualMediaItemCollection(items: [], totalCount: list.totalCount) for item in list.items { switch item.content { case let .message(message, isLocal): @@ -1317,24 +1543,32 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } self.update(size: size, sideInset: sideInset, bottomInset: bottomInset, visibleHeight: visibleHeight, isScrollingLockedAtTop: isScrollingLockedAtTop, expandProgress: expandProgress, presentationData: presentationData, synchronous: wasFirstHistoryView || synchronous, transition: .immediate) self.ignoreScrolling = false - if !self.didSetReady { - self.didSetReady = true - self.ready.set(.single(true)) - } + }*/ + + if !self.didSetReady { + self.didSetReady = true + self.ready.set(.single(true)) } } func scrollToTop() -> Bool { - if self.scrollNode.view.contentOffset.y > 0.0 { + /*if self.scrollNode.view.contentOffset.y > 0.0 { self.scrollNode.view.setContentOffset(CGPoint(), animated: true) return true } else { return false - } + }*/ + return false } func findLoadedMessage(id: MessageId) -> Message? { - for item in self.mediaItems.items { + guard let items = self.items else { + return nil + } + for item in items.items { + guard let item = item as? VisualMediaItem else { + continue + } if let message = item.message, message.id == id { return item.message } @@ -1343,14 +1577,24 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } func updateHiddenMedia() { - for (_, itemNode) in self.visibleMediaItems { - itemNode.updateHiddenMedia() + self.itemGrid.forEachVisibleItem { itemLayer in + guard let itemLayer = itemLayer as? ItemLayer else { + return + } + if let item = itemLayer.item, let message = item.message { + if self.itemInteraction.hiddenMedia[message.id] != nil { + itemLayer.isHidden = true + } else { + itemLayer.isHidden = false + } + } else { + itemLayer.isHidden = false + } } } func transferVelocity(_ velocity: CGFloat) { - if velocity > 0.0 { - //print("transferVelocity \(velocity)") + /*if velocity > 0.0 { self.decelerationAnimator?.isPaused = true let startTime = CACurrentMediaTime() var currentOffset = self.scrollNode.view.contentOffset @@ -1362,7 +1606,6 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } let t = CACurrentMediaTime() - startTime var currentVelocity = velocity * 15.0 * CGFloat(pow(Double(decelerationRate), 1000.0 * t)) - //print("value at \(t) = \(currentVelocity)") currentOffset.y += currentVelocity let maxOffset = strongSelf.scrollNode.view.contentSize.height - strongSelf.scrollNode.bounds.height if currentOffset.y >= maxOffset { @@ -1389,80 +1632,70 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro } }) self.decelerationAnimator?.isPaused = false - } + }*/ } func cancelPreviewGestures() { - for (_, itemNode) in self.visibleMediaItems { + /*for (_, itemNode) in self.visibleMediaItems { itemNode.cancelPreviewGesture() - } + }*/ } func transitionNodeForGallery(messageId: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { - for item in self.mediaItems.items { - if let message = item.message, message.id == messageId { - if let itemNode = self.visibleMediaItems[item.stableId] { - return itemNode.transitionNode() - } - break + var foundItemLayer: SparseItemGridLayer? + self.itemGrid.forEachVisibleItem { itemLayer in + guard let itemLayer = itemLayer as? ItemLayer else { + return } + if let item = itemLayer.item, let message = item.message, message.id == messageId { + foundItemLayer = itemLayer + } + } + if let itemLayer = foundItemLayer { + let itemFrame = self.view.convert(self.itemGrid.frameForItem(layer: itemLayer), from: self.itemGrid.view) + let proxyNode = ASDisplayNode() + proxyNode.frame = itemFrame + proxyNode.contents = itemLayer.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) + }) } return nil } func addToTransitionSurface(view: UIView) { - self.scrollNode.view.addSubview(view) + self.itemGrid.addToTransitionSurface(view: view) } func updateSelectedMessages(animated: Bool) { - self.itemInteraction.selectedMessageIds = self.chatControllerInteraction.selectionState.flatMap { $0.selectedIds } + /*self.itemInteraction.selectedMessageIds = self.chatControllerInteraction.selectionState.flatMap { $0.selectedIds } for (_, itemNode) in self.visibleMediaItems { itemNode.updateSelectionState(animated: animated) - } + }*/ } func update(size: CGSize, sideInset: CGFloat, bottomInset: CGFloat, visibleHeight: CGFloat, isScrollingLockedAtTop: Bool, expandProgress: CGFloat, presentationData: PresentationData, synchronous: Bool, transition: ContainedViewLayoutTransition) { - let previousParams = self.currentParams + //let previousParams = self.currentParams self.currentParams = (size, sideInset, bottomInset, visibleHeight, isScrollingLockedAtTop, expandProgress, presentationData) - - transition.updateFrame(node: self.scrollNode, frame: CGRect(origin: CGPoint(), size: size)) - - let availableWidth = size.width - sideInset * 2.0 - - let itemsLayout: ItemsLayout - if let current = self.itemsLayout, previousParams?.size.width == size.width { - itemsLayout = current - } else { - switch self.contentType { - case .photoOrVideo, .photo, .video, .gifs: - itemsLayout = .grid(ItemsLayout.Grid(containerWidth: availableWidth, zoomLevel: self.zoomLevel, itemCount: self.mediaItems.totalCount, bottomInset: bottomInset)) - } - self.itemsLayout = itemsLayout - } - - self.scrollNode.view.contentSize = CGSize(width: size.width, height: itemsLayout.contentHeight) - self.updateVisibleItems(size: size, sideInset: sideInset, bottomInset: bottomInset, visibleHeight: visibleHeight, theme: presentationData.theme, strings: presentationData.strings, synchronousLoad: synchronous) - transition.updateFrame(node: self.scrollingArea, frame: CGRect(origin: CGPoint(), size: size)) - self.updateScrollingArea(transition: transition) - - if isScrollingLockedAtTop { - if self.scrollNode.view.contentOffset.y > .ulpOfOne { - transition.updateBounds(node: self.scrollNode, bounds: CGRect(origin: CGPoint(), size: self.scrollNode.bounds.size)) - } + transition.updateFrame(node: self.itemGrid, frame: CGRect(origin: CGPoint(), size: size)) + if let items = self.items { + self.itemGrid.update(size: size, insets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: bottomInset, right: sideInset), scrollIndicatorInsets: UIEdgeInsets(top: 0.0, left: sideInset, bottom: bottomInset, right: sideInset), lockScrollingAtTop: isScrollingLockedAtTop, items: items) } - self.scrollNode.view.isScrollEnabled = !isScrollingLockedAtTop } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - self.decelerationAnimator?.isPaused = true - self.decelerationAnimator = nil - - for (_, itemNode) in self.visibleMediaItems { - itemNode.cancelPreviewGesture() - } - - self.updateHeaderFlashing(animated: true) } private var previousDidScrollTimestamp: Double = 0.0 @@ -1472,119 +1705,57 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro if self.ignoreScrolling { return } - if let (size, sideInset, bottomInset, visibleHeight, _, _, presentationData) = self.currentParams { - self.updateVisibleItems(size: size, sideInset: sideInset, bottomInset: bottomInset, visibleHeight: visibleHeight, theme: presentationData.theme, strings: presentationData.strings, synchronousLoad: false) - - /*if scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.bounds.height * 2.0, let currentView = self.currentView, currentView.earlierId != nil { - if !self.isRequestingView { - self.numberOfItemsToRequest += 50 - self.requestHistoryAroundVisiblePosition() - } - }*/ - - self.updateScrollingArea(transition: .immediate) - } } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - if decelerate { - self.isDeceleratingAfterTracking = true - self.updateHeaderFlashing(animated: true) - } else { - self.isDeceleratingAfterTracking = false - self.resetHeaderFlashTimer(start: true) - self.updateHeaderFlashing(animated: true) - } - - if !decelerate { - self.updateScrollingArea(transition: .animated(duration: 0.3, curve: .easeInOut)) - } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - self.isDeceleratingAfterTracking = false - self.resetHeaderFlashTimer(start: true) - self.updateHeaderFlashing(animated: true) - - self.updateScrollingArea(transition: .animated(duration: 0.3, curve: .easeInOut)) } private func updateScrollingArea(transition: ContainedViewLayoutTransition) { - guard let currentParams = self.currentParams, let itemsLayout = self.itemsLayout else { - return - } - - let headerItemMinY = self.scrollNode.view.bounds.minY + 20.0 - let activeRect = self.scrollNode.view.bounds - let visibleRect = activeRect.insetBy(dx: 0.0, dy: -400.0) - - let (minVisibleIndex, maxVisibleIndex) = itemsLayout.visibleRange(rect: visibleRect) - - var headerItem: Int32? - - if minVisibleIndex <= maxVisibleIndex { - for i in minVisibleIndex ... maxVisibleIndex { - let itemFrame = itemsLayout.frame(forItemAt: i, sideInset: currentParams.sideInset) - - if headerItem == nil && itemFrame.maxY > headerItemMinY { - if let item = self.mediaItems.closestItem(at: i) { - headerItem = item.timestamp - } - break - } - } - } - - var dateString: String = "" - - if let headerItem = headerItem { - let presentationData = self.context.sharedContext.currentPresentationData.with { $0 } - let (year, month) = listMessageDateHeaderInfo(timestamp: headerItem) - dateString = stringForMonth(strings: presentationData.strings, month: month, ofYear: year) - } - - self.scrollingArea.update( - containerSize: currentParams.size, - containerInsets: UIEdgeInsets(top: 0.0, left: currentParams.sideInset, bottom: currentParams.bottomInset, right: currentParams.sideInset), - contentHeight: itemsLayout.contentHeight, - contentOffset: self.scrollNode.view.contentOffset.y, - isScrolling: self.scrollNode.view.isDragging || self.scrollNode.view.isDecelerating || self.decelerationAnimator != nil, - dateString: dateString, - transition: transition - ) } func currentTopTimestamp() -> Int32? { - guard let currentParams = self.currentParams, let itemsLayout = self.itemsLayout else { - return nil - } - - let headerItemMinY = self.scrollNode.view.bounds.minY + 20.0 - let activeRect = self.scrollNode.view.bounds - let visibleRect = activeRect.insetBy(dx: 0.0, dy: -400.0) - - let (minVisibleIndex, maxVisibleIndex) = itemsLayout.visibleRange(rect: visibleRect) - - var headerItem: Int32? - - if minVisibleIndex <= maxVisibleIndex { - for i in minVisibleIndex ... maxVisibleIndex { - let itemFrame = itemsLayout.frame(forItemAt: i, sideInset: currentParams.sideInset) - - if headerItem == nil && itemFrame.maxY > headerItemMinY { - if let item = self.mediaItems.closestItem(at: i) { - headerItem = item.timestamp - } - break + var timestamp: Int32? + self.itemGrid.forEachVisibleItem { itemLayer in + if timestamp != nil { + return + } + guard let itemLayer = itemLayer as? ItemLayer else { + return + } + if let item = itemLayer.item, let message = item.message { + if let timestampValue = timestamp { + timestamp = max(timestampValue, message.timestamp) + } else { + timestamp = message.timestamp } } } - - return headerItem + return timestamp } func scrollToTimestamp(timestamp: Int32) { - guard let currentParams = self.currentParams else { + if let items = self.items, !items.items.isEmpty { + var previousIndex: Int? + for item in items.items { + guard let item = item as? VisualMediaItem, let message = item.message else { + continue + } + if message.timestamp <= timestamp { + break + } + previousIndex = item.index + } + if previousIndex == nil { + previousIndex = (items.items[0] as? VisualMediaItem)?.index + } + if let index = previousIndex { + self.itemGrid.scrollToItem(at: index) + } + } + /*guard let currentParams = self.currentParams else { return } guard let itemsLayout = self.itemsLayout else { @@ -1597,10 +1768,10 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro break } - } + }*/ } - private func updateVisibleItems(size: CGSize, sideInset: CGFloat, bottomInset: CGFloat, visibleHeight: CGFloat, theme: PresentationTheme, strings: PresentationStrings, synchronousLoad: Bool) { + /*private func updateVisibleItems(size: CGSize, sideInset: CGFloat, bottomInset: CGFloat, visibleHeight: CGFloat, theme: PresentationTheme, strings: PresentationStrings, synchronousLoad: Bool) { guard let itemsLayout = self.itemsLayout else { return } @@ -1678,72 +1849,23 @@ final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScro if let requestHole = requestHole { self.listSource.loadHole(anchor: requestHole.anchor, direction: requestHole.direction) } - } - - private func resetHeaderFlashTimer(start: Bool, duration: Double = 0.3) { - /*if let flashHeaderDelayTimer = self.flashHeaderDelayTimer { - flashHeaderDelayTimer.invalidate() - self.flashHeaderDelayTimer = nil - } - - if start { - final class TimerProxy: NSObject { - private let action: () -> () - - init(_ action: @escaping () -> ()) { - self.action = action - super.init() - } - - @objc func timerEvent() { - self.action() - } - } - - let timer = Timer(timeInterval: duration, target: TimerProxy { [weak self] in - if let strongSelf = self { - if let flashHeaderDelayTimer = strongSelf.flashHeaderDelayTimer { - flashHeaderDelayTimer.invalidate() - strongSelf.flashHeaderDelayTimer = nil - strongSelf.updateHeaderFlashing(animated: true) - } - } - }, selector: #selector(TimerProxy.timerEvent), userInfo: nil, repeats: false) - self.flashHeaderDelayTimer = timer - RunLoop.main.add(timer, forMode: RunLoop.Mode.common) - self.updateHeaderFlashing(animated: true) - }*/ - } - - private func headerIsFlashing() -> Bool { - return false - //return self.scrollNode.view.isDragging || self.isDeceleratingAfterTracking || self.flashHeaderDelayTimer != nil - } - - private func updateHeaderFlashing(animated: Bool) { - /*let flashing = self.headerIsFlashing() - let alpha: CGFloat = flashing ? 1.0 : 0.0 - let previousAlpha = self.floatingHeaderNode.alpha - - if !previousAlpha.isEqual(to: alpha) { - self.floatingHeaderNode.alpha = alpha - if animated { - let duration: Double = flashing ? 0.3 : 0.4 - self.floatingHeaderNode.layer.animateAlpha(from: previousAlpha, to: alpha, duration: duration) - } - }*/ - } + }*/ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { guard let result = super.hitTest(point, with: event) else { return nil } - if self.decelerationAnimator != nil { + /*if self.decelerationAnimator != nil { self.decelerationAnimator?.isPaused = true self.decelerationAnimator = nil return self.scrollNode.view - } + }*/ return result } + + func availableZoomLevels() -> (decrement: ZoomLevel?, increment: ZoomLevel?) { + let levels = self.itemGrid.availableZoomLevels() + return (levels.decrement.flatMap(ZoomLevel.init), levels.increment.flatMap(ZoomLevel.init)) + } } diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoPaneContainerNode.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoPaneContainerNode.swift index daf845233b..3a72efe7b8 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoPaneContainerNode.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoPaneContainerNode.swift @@ -386,7 +386,8 @@ private final class PeerInfoPendingPane { key: PeerInfoPaneKey, hasBecomeReady: @escaping (PeerInfoPaneKey) -> Void, parentController: ViewController?, - openMediaCalendar: @escaping () -> Void + openMediaCalendar: @escaping () -> Void, + paneDidScroll: @escaping () -> Void ) { let paneNode: PeerInfoPaneNode switch key { @@ -396,6 +397,9 @@ private final class PeerInfoPendingPane { visualPaneNode.openCurrentDate = { openMediaCalendar() } + visualPaneNode.paneDidScroll = { + paneDidScroll() + } case .files: paneNode = PeerInfoListPaneNode(context: context, updatedPresentationData: updatedPresentationData, chatControllerInteraction: chatControllerInteraction, peerId: peerId, tagMask: .file) case .links: @@ -410,6 +414,9 @@ private final class PeerInfoPendingPane { visualPaneNode.openCurrentDate = { openMediaCalendar() } + visualPaneNode.paneDidScroll = { + paneDidScroll() + } case .groupsInCommon: paneNode = PeerInfoGroupsInCommonPaneNode(context: context, peerId: peerId, chatControllerInteraction: chatControllerInteraction, openPeerContextAction: openPeerContextAction, groupsInCommonContext: data.groupsInCommon!) case .members: @@ -478,6 +485,7 @@ final class PeerInfoPaneContainerNode: ASDisplayNode, UIGestureRecognizerDelegat var requestExpandTabs: (() -> Bool)? var openMediaCalendar: (() -> Void)? + var paneDidScroll: (() -> Void)? private var currentAvailablePanes: [PeerInfoPaneKey]? private let updatedPresentationData: (initial: PresentationData, signal: Signal)? @@ -779,6 +787,9 @@ final class PeerInfoPaneContainerNode: ASDisplayNode, UIGestureRecognizerDelegat parentController: self.parentController, openMediaCalendar: { [weak self] in self?.openMediaCalendar?() + }, + paneDidScroll: { [weak self] in + self?.paneDidScroll?() } ) self.pendingPanes[key] = pane diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index 4505662ae1..c6d879b918 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -2310,6 +2310,16 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate } strongSelf.openMediaCalendar() } + + self.paneContainerNode.paneDidScroll = { [weak self] in + guard let strongSelf = self else { + return + } + if let mediaGalleryContextMenu = strongSelf.mediaGalleryContextMenu { + strongSelf.mediaGalleryContextMenu = nil + mediaGalleryContextMenu.dismiss() + } + } self.paneContainerNode.requestPerformPeerMemberAction = { [weak self] member, action in guard let strongSelf = self else { @@ -5983,6 +5993,9 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate } } + + private weak var mediaGalleryContextMenu: ContextController? + private func displayMediaGalleryContextMenu(source: ContextReferenceContentNode) { guard let controller = self.controller else { return @@ -5994,29 +6007,36 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate var items: [ContextMenuItem] = [] //TODO:localize - let canZoomIn = pane.zoomLevel.decremented() != pane.zoomLevel - let canZoomOut = pane.zoomLevel.incremented() != pane.zoomLevel - - items.append(.action(ContextMenuActionItem(text: "Zoom In", textColor: canZoomIn ? .primary : .disabled, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ZoomIn"), color: canZoomIn ? theme.contextMenu.primaryColor : theme.contextMenu.primaryColor.withMultipliedAlpha(0.4)) - }, action: canZoomIn ? { [weak pane] _, a in - a(.default) - - guard let pane = pane else { - return + var recurseGenerateAction: ((Bool) -> ContextMenuActionItem)? + let generateAction: (Bool) -> ContextMenuActionItem = { [weak pane] isZoomIn in + var canZoom: Bool = true + if !"".isEmpty { + canZoom = false } - pane.updateZoomLevel(level: pane.zoomLevel.decremented()) - } : nil))) - items.append(.action(ContextMenuActionItem(text: "Zoom Out", textColor : canZoomOut ? .primary : .disabled, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/ZoomOut"), color: canZoomOut ? theme.contextMenu.primaryColor : theme.contextMenu.primaryColor.withMultipliedAlpha(0.4)) - }, action: canZoomOut ? { [weak pane] _, a in - a(.default) + /*if isZoomIn { + canZoom = pane?.availableZoomLevels().increment != nil + } else { + canZoom = pane?.availableZoomLevels().decrement != nil + }*/ + return ContextMenuActionItem(text: isZoomIn ? "Zoom In" : "ZoomOut", textColor: canZoom ? .primary : .disabled, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: isZoomIn ? "Chat/Context Menu/ZoomIn" : "Chat/Context Menu/ZoomOut"), color: canZoom ? theme.contextMenu.primaryColor : theme.contextMenu.primaryColor.withMultipliedAlpha(0.4)) + }, action: canZoom ? { action in + guard let pane = pane, let zoomLevel = isZoomIn ? pane.availableZoomLevels().increment : pane.availableZoomLevels().decrement else { + return + } + pane.updateZoomLevel(level: zoomLevel) + if let recurseGenerateAction = recurseGenerateAction { + action.updateAction(recurseGenerateAction(isZoomIn)) + } + } : nil) + } + recurseGenerateAction = { isZoomIn in + return generateAction(isZoomIn) + } + + items.append(.action(generateAction(true))) + items.append(.action(generateAction(false))) - guard let pane = pane else { - return - } - pane.updateZoomLevel(level: pane.zoomLevel.incremented()) - } : nil))) items.append(.action(ContextMenuActionItem(text: "Show Calendar", icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Calendar"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, a in @@ -6090,6 +6110,8 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate pane.updateContentType(contentType: updatedContentType) }))) let contextController = ContextController(account: self.context.account, presentationData: self.presentationData, source: .reference(PeerInfoContextReferenceContentSource(controller: controller, sourceNode: source)), items: .single(ContextController.Items(items: items)), gesture: nil) + contextController.passthroughTouchEvents = true + self.mediaGalleryContextMenu = contextController controller.presentInGlobalOverlay(contextController) }