Swiftgram/submodules/ShareController/Sources/ShareTopicsContainerNode.swift
2023-08-15 23:57:04 +04:00

466 lines
22 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import TelegramCore
import SwiftSignalKit
import Display
import TelegramPresentationData
import TelegramUIPreferences
import MergeLists
import AvatarNode
import AccountContext
import PeerPresenceStatusManager
import AppBundle
import SegmentedControlNode
import ContextUI
private let subtitleFont = Font.regular(12.0)
private struct ShareTopicEntry: Comparable, Identifiable {
let index: Int32
let peer: EngineRenderedPeer
let id: Int64
let threadData: MessageHistoryThreadData
let theme: PresentationTheme
let strings: PresentationStrings
var stableId: Int64 {
return self.id
}
static func ==(lhs: ShareTopicEntry, rhs: ShareTopicEntry) -> Bool {
if lhs.index != rhs.index {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.id != rhs.id {
return false
}
if lhs.threadData != rhs.threadData {
return false
}
if lhs.theme !== rhs.theme {
return false
}
return true
}
static func <(lhs: ShareTopicEntry, rhs: ShareTopicEntry) -> Bool {
return lhs.index < rhs.index
}
func item(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, interfaceInteraction: ShareControllerInteraction) -> GridItem {
return ShareTopicGridItem(environment: environment, context: context, theme: self.theme, strings: self.strings, peer: self.peer, id: self.id, threadInfo: self.threadData, controllerInteraction: interfaceInteraction)
}
}
private struct ShareGridTransaction {
let deletions: [Int]
let insertions: [GridNodeInsertItem]
let updates: [GridNodeUpdateItem]
let animated: Bool
}
private func preparedGridEntryTransition(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, from fromEntries: [ShareTopicEntry], to toEntries: [ShareTopicEntry], interfaceInteraction: ShareControllerInteraction) -> ShareGridTransaction {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let deletions = deleteIndices
let insertions = indicesAndItems.map { GridNodeInsertItem(index: $0.0, item: $0.1.item(environment: environment, context: context, interfaceInteraction: interfaceInteraction), previousIndex: $0.2) }
let updates = updateIndices.map { GridNodeUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(environment: environment, context: context, interfaceInteraction: interfaceInteraction)) }
return ShareGridTransaction(deletions: deletions, insertions: insertions, updates: updates, animated: false)
}
private class CancelButtonNode: ASDisplayNode {
let buttonNode: HighlightTrackingButtonNode
private let arrowNode: ASImageNode
private let labelNode: ImmediateTextNode
var theme: PresentationTheme {
didSet {
self.updateThemeAndStrings()
}
}
private let strings: PresentationStrings
init(theme: PresentationTheme, strings: PresentationStrings) {
self.theme = theme
self.strings = strings
self.buttonNode = HighlightTrackingButtonNode()
self.arrowNode = ASImageNode()
self.arrowNode.displaysAsynchronously = false
self.arrowNode.isUserInteractionEnabled = false
self.labelNode = ImmediateTextNode()
self.labelNode.isUserInteractionEnabled = false
super.init()
self.addSubnode(self.buttonNode)
self.buttonNode.addSubnode(self.arrowNode)
self.buttonNode.addSubnode(self.labelNode)
self.buttonNode.highligthedChanged = { [weak self] highlighted in
guard let strongSelf = self else {
return
}
if highlighted {
strongSelf.arrowNode.layer.removeAnimation(forKey: "opacity")
strongSelf.arrowNode.alpha = 0.4
strongSelf.labelNode.layer.removeAnimation(forKey: "opacity")
strongSelf.labelNode.alpha = 0.4
} else {
strongSelf.arrowNode.alpha = 1.0
strongSelf.arrowNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
strongSelf.labelNode.alpha = 1.0
strongSelf.labelNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
self.updateThemeAndStrings()
}
func updateThemeAndStrings() {
self.labelNode.attributedText = NSAttributedString(string: self.strings.Common_Back, font: Font.regular(17.0), textColor: self.theme.rootController.navigationBar.accentTextColor)
let labelSize = self.labelNode.updateLayout(CGSize(width: 120.0, height: 56.0))
self.buttonNode.frame = CGRect(origin: .zero, size: CGSize(width: labelSize.width + 16.0, height: self.buttonNode.frame.height))
self.arrowNode.image = NavigationBarTheme.generateBackArrowImage(color: self.theme.rootController.navigationBar.accentTextColor)
if let image = self.arrowNode.image {
self.arrowNode.frame = CGRect(origin: self.arrowNode.frame.origin, size: image.size)
}
self.labelNode.frame = CGRect(origin: self.labelNode.frame.origin, size: labelSize)
self.buttonNode.subnodeTransform = CATransform3DMakeTranslation(11.0, 0.0, 0.0)
}
override public func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize {
self.buttonNode.frame = CGRect(origin: .zero, size: CGSize(width: self.buttonNode.frame.width, height: constrainedSize.height))
self.arrowNode.frame = CGRect(origin: CGPoint(x: -19.0, y: floorToScreenPixels((constrainedSize.height - self.arrowNode.frame.size.height) / 2.0)), size: self.arrowNode.frame.size)
self.labelNode.frame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((constrainedSize.height - self.labelNode.frame.size.height) / 2.0)), size: self.labelNode.frame.size)
return CGSize(width: self.buttonNode.frame.width, height: 56.0)
}
}
final class ShareTopicsContainerNode: ASDisplayNode, ShareContentContainerNode {
func setEnsurePeerVisibleOnLayout(_ peerId: TelegramCore.EnginePeer.Id?) {
}
func updateSelectedPeers(animated: Bool) {
}
private let environment: ShareControllerEnvironment
private let context: ShareControllerAccountContext
private var theme: PresentationTheme
private let themePromise: Promise<PresentationTheme>
private let strings: PresentationStrings
private let controllerInteraction: ShareControllerInteraction
private let disposable = MetaDisposable()
private var entries: [ShareTopicEntry] = []
private var enqueuedTransitions: [(ShareGridTransaction, Bool)] = []
let contentGridNode: GridNode
private let headerNode: ASDisplayNode
private let contentTitleNode: ASTextNode
private let contentSubtitleNode: ASTextNode
private let backNode: CancelButtonNode
private var contentOffsetUpdated: ((CGFloat, ContainedViewLayoutTransition) -> Void)?
private var validLayout: (CGSize, CGFloat)?
private var overrideGridOffsetTransition: ContainedViewLayoutTransition?
let topicsValue = Promise<[EngineChatList.Item]>()
var backPressed: () -> Void = {}
init(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, theme: PresentationTheme, strings: PresentationStrings, peer: EnginePeer, topics: Signal<EngineChatList, NoError>, controllerInteraction: ShareControllerInteraction) {
self.environment = environment
self.context = context
self.theme = theme
self.themePromise = Promise()
self.themePromise.set(.single(theme))
self.strings = strings
self.controllerInteraction = controllerInteraction
self.topicsValue.set(topics
|> map {
return $0.items
})
let items: Signal<[ShareTopicEntry], NoError> = (combineLatest(self.topicsValue.get(), self.themePromise.get()))
|> map { topics, theme -> [ShareTopicEntry] in
var entries: [ShareTopicEntry] = []
var index: Int32 = 0
for topic in topics {
if case let .forum(_, _, threadId, _, _) = topic.index, let threadData = topic.threadData {
entries.append(ShareTopicEntry(index: index, peer: EngineRenderedPeer(peer: peer), id: threadId, threadData: threadData, theme: theme, strings: strings))
index += 1
}
}
return entries
}
self.contentGridNode = GridNode()
self.headerNode = ASDisplayNode()
self.contentTitleNode = ASTextNode()
self.contentTitleNode.maximumNumberOfLines = 1
self.contentTitleNode.attributedText = NSAttributedString(string: peer.compactDisplayTitle, font: Font.medium(20.0), textColor: self.theme.actionSheet.primaryTextColor)
self.contentTitleNode.textAlignment = .center
self.contentSubtitleNode = ASTextNode()
self.contentSubtitleNode.maximumNumberOfLines = 1
self.contentSubtitleNode.isUserInteractionEnabled = false
self.contentSubtitleNode.displaysAsynchronously = false
self.contentSubtitleNode.truncationMode = .byTruncatingTail
self.contentSubtitleNode.attributedText = NSAttributedString(string: strings.ShareMenu_SelectTopic, font: subtitleFont, textColor: self.theme.actionSheet.secondaryTextColor)
self.backNode = CancelButtonNode(theme: theme, strings: strings)
super.init()
self.addSubnode(self.contentGridNode)
self.addSubnode(self.headerNode)
self.headerNode.addSubnode(self.contentTitleNode)
self.headerNode.addSubnode(self.contentSubtitleNode)
self.headerNode.addSubnode(self.backNode)
let previousItems = Atomic<[ShareTopicEntry]?>(value: [])
self.disposable.set((items
|> deliverOnMainQueue).start(next: { [weak self] entries in
if let strongSelf = self {
let previousEntries = previousItems.swap(entries)
strongSelf.entries = entries
let firstTime = previousEntries == nil
let transition = preparedGridEntryTransition(environment: environment, context: context, from: previousEntries ?? [], to: entries, interfaceInteraction: controllerInteraction)
strongSelf.enqueueTransition(transition, firstTime: firstTime)
}
}))
self.contentGridNode.presentationLayoutUpdated = { [weak self] presentationLayout, transition in
self?.gridPresentationLayoutUpdated(presentationLayout, transition: transition)
}
self.backNode.buttonNode.addTarget(self, action: #selector(self.backButtonPressed), forControlEvents: .touchUpInside)
}
deinit {
self.disposable.dispose()
}
@objc private func backButtonPressed() {
self.backPressed()
}
private func enqueueTransition(_ transition: ShareGridTransaction, firstTime: Bool) {
self.enqueuedTransitions.append((transition, firstTime))
if self.validLayout != nil {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func dequeueTransition() {
if let (transition, _) = self.enqueuedTransitions.first {
self.enqueuedTransitions.remove(at: 0)
var itemTransition: ContainedViewLayoutTransition = .immediate
if transition.animated {
itemTransition = .animated(duration: 0.3, curve: .spring)
}
self.contentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in })
}
}
func setContentOffsetUpdated(_ f: ((CGFloat, ContainedViewLayoutTransition) -> Void)?) {
self.contentOffsetUpdated = f
}
private func calculateMetrics(size: CGSize) -> (topInset: CGFloat, itemWidth: CGFloat) {
let itemCount = self.entries.count
let itemInsets = UIEdgeInsets(top: 0.0, left: 12.0, bottom: 0.0, right: 12.0)
let minimalItemWidth: CGFloat = size.width > 301.0 ? 70.0 : 60.0
let effectiveWidth = size.width - itemInsets.left - itemInsets.right
let itemsPerRow = Int(effectiveWidth / minimalItemWidth)
let itemWidth = floor(effectiveWidth / CGFloat(itemsPerRow))
var rowCount = itemCount / itemsPerRow + (itemCount % itemsPerRow != 0 ? 1 : 0)
rowCount = max(rowCount, 4)
let minimallyRevealedRowCount: CGFloat = 3.7
let initiallyRevealedRowCount = min(minimallyRevealedRowCount, CGFloat(rowCount))
let gridTopInset = max(0.0, size.height - floor(initiallyRevealedRowCount * itemWidth) - 14.0)
return (gridTopInset, itemWidth)
}
func activate() {
}
func deactivate() {
}
func animateIn(sourceFrame: CGRect, scrollDelta: CGFloat) {
self.headerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
self.backNode.alpha = 1.0
self.backNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.backNode.layer.animatePosition(from: CGPoint(x: 20.0, y: 0.0), to: .zero, duration: 0.2, additive: true)
self.contentTitleNode.alpha = 1.0
self.contentTitleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.contentTitleNode.layer.animatePosition(from: CGPoint(x: 0.0, y: 10.0), to: .zero, duration: 0.2, additive: true)
self.contentTitleNode.layer.animateScale(from: 0.85, to: 1.0, duration: 0.2)
self.contentSubtitleNode.alpha = 1.0
self.contentSubtitleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.contentSubtitleNode.layer.animatePosition(from: CGPoint(x: 0.0, y: 10.0), to: .zero, duration: 0.2, additive: true)
self.contentSubtitleNode.layer.animateScale(from: 0.85, to: 1.0, duration: 0.2)
self.contentGridNode.layer.animatePosition(from: CGPoint(x: 0.0, y: scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
self.contentGridNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.contentGridNode.forEachItemNode { itemNode in
itemNode.layer.animatePosition(from: sourceFrame.center, to: itemNode.position, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring)
itemNode.layer.animateScale(from: 0.2, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
}
}
func animateOut(targetFrame: CGRect, scrollDelta: CGFloat, completion: @escaping () -> Void = {}) {
self.headerNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
self.backNode.alpha = 0.0
self.backNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.backNode.layer.animatePosition(from: .zero, to: CGPoint(x: 20.0, y: 0.0), duration: 0.2, additive: true)
self.contentTitleNode.alpha = 0.0
self.contentTitleNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.contentTitleNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: 10.0), duration: 0.2, additive: true)
self.contentTitleNode.layer.animateScale(from: 1.0, to: 0.85, duration: 0.2)
self.contentSubtitleNode.alpha = 0.0
self.contentSubtitleNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2)
self.contentSubtitleNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: 10.0), duration: 0.2, additive: true)
self.contentSubtitleNode.layer.animateScale(from: 1.0, to: 0.85, duration: 0.2)
self.contentGridNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true)
self.contentGridNode.alpha = 0.0
self.contentGridNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, completion: { _ in
completion()
})
self.contentGridNode.forEachItemNode { itemNode in
itemNode.layer.animatePosition(from: itemNode.position, to: targetFrame.center, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring)
itemNode.layer.animateScale(from: 1.0, to: 0.2, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
}
}
public func updateTheme(_ theme: PresentationTheme) {
self.theme = theme
self.themePromise.set(.single(theme))
self.contentTitleNode.attributedText = NSAttributedString(string: self.contentTitleNode.attributedText?.string ?? "", font: Font.medium(20.0), textColor: self.theme.actionSheet.primaryTextColor)
self.contentSubtitleNode.attributedText = NSAttributedString(string: self.contentSubtitleNode.attributedText?.string ?? "", font: subtitleFont, textColor: self.theme.actionSheet.secondaryTextColor)
}
func updateLayout(size: CGSize, isLandscape: Bool, bottomInset: CGFloat, transition: ContainedViewLayoutTransition) {
let firstLayout = self.validLayout == nil
self.validLayout = (size, bottomInset)
let gridLayoutTransition: ContainedViewLayoutTransition
if firstLayout {
gridLayoutTransition = .immediate
self.overrideGridOffsetTransition = transition
} else {
gridLayoutTransition = transition
self.overrideGridOffsetTransition = nil
}
let (gridTopInset, itemWidth) = self.calculateMetrics(size: size)
let scrollToItem: GridNodeScrollToItem? = nil
let delta = bottomInset
var gridSize = CGSize(width: size.width - 12.0, height: size.height)
gridSize.height -= delta
self.contentGridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: scrollToItem, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: gridSize, insets: UIEdgeInsets(top: gridTopInset, left: 0.0, bottom: 0.0, right: 0.0), preloadSize: 80.0, type: .fixed(itemSize: CGSize(width: itemWidth, height: itemWidth + 25.0), fillWidth: nil, lineSpacing: 0.0, itemSpacing: nil)), transition: gridLayoutTransition), itemTransition: .immediate, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in })
gridLayoutTransition.updateFrame(node: self.contentGridNode, frame: CGRect(origin: CGPoint(x: floor((size.width - gridSize.width) / 2.0), y: 0.0), size: gridSize))
if firstLayout {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let nodes: [ASDisplayNode] = [self.backNode]
for node in nodes {
let nodeFrame = node.frame
if node.isHidden {
continue
}
if let result = node.hitTest(point.offsetBy(dx: -nodeFrame.minX, dy: -nodeFrame.minY), with: event) {
return result
}
}
return super.hitTest(point, with: event)
}
private func gridPresentationLayoutUpdated(_ presentationLayout: GridNodeCurrentPresentationLayout, transition: ContainedViewLayoutTransition) {
guard let (size, _) = self.validLayout else {
return
}
let actualTransition = self.overrideGridOffsetTransition ?? transition
self.overrideGridOffsetTransition = nil
let titleAreaHeight: CGFloat = 64.0
let rawTitleOffset = -titleAreaHeight - presentationLayout.contentOffset.y
let titleOffset = max(-titleAreaHeight, rawTitleOffset)
let headerFrame = CGRect(origin: CGPoint(x: 0.0, y: titleOffset), size: CGSize(width: size.width, height: 64.0))
transition.updateFrame(node: self.headerNode, frame: headerFrame)
let backSize = self.backNode.measure(CGSize(width: size.width, height: 56.0))
let backFrame = CGRect(origin: CGPoint(x: 20.0, y: 6.0), size: CGSize(width: backSize.width, height: 56.0))
transition.updateFrame(node: self.backNode, frame: backFrame)
let titleSize = self.contentTitleNode.measure(CGSize(width: size.width - (backSize.width * 2.0 + 40.0), height: size.height))
let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) / 2.0), y: 15.0), size: titleSize)
transition.updateFrame(node: self.contentTitleNode, frame: titleFrame)
let subtitleSize = self.contentSubtitleNode.measure(CGSize(width: size.width - 44.0 * 2.0 - 8.0 * 2.0, height: titleAreaHeight))
let subtitleFrame = CGRect(origin: CGPoint(x: floor((size.width - subtitleSize.width) / 2.0), y: 40.0), size: subtitleSize)
var originalSubtitleFrame = self.contentSubtitleNode.frame
originalSubtitleFrame.origin.x = subtitleFrame.origin.x
originalSubtitleFrame.size = subtitleFrame.size
self.contentSubtitleNode.frame = originalSubtitleFrame
transition.updateFrame(node: self.contentSubtitleNode, frame: subtitleFrame)
self.contentOffsetUpdated?(presentationLayout.contentOffset.y, actualTransition)
}
}