import Foundation import UIKit import AsyncDisplayKit import TelegramCore import Postbox import SwiftSignalKit import Display import TelegramPresentationData import MergeLists import AccountContext private let cancelFont = Font.regular(17.0) private let subtitleFont = Font.regular(12.0) private enum ShareSearchRecentEntryStableId: Hashable { case topPeers case peerId(EnginePeer.Id) static func ==(lhs: ShareSearchRecentEntryStableId, rhs: ShareSearchRecentEntryStableId) -> Bool { switch lhs { case .topPeers: if case .topPeers = rhs { return true } else { return false } case let .peerId(peerId): if case .peerId(peerId) = rhs { return true } else { return false } } } } private enum ShareSearchRecentEntry: Comparable, Identifiable { case topPeers(PresentationTheme, PresentationStrings) case peer(index: Int, theme: PresentationTheme, peer: EnginePeer, associatedPeer: EnginePeer?, presence: EnginePeer.Presence?, requiresPremiumForMessaging: Bool, strings: PresentationStrings) var stableId: ShareSearchRecentEntryStableId { switch self { case .topPeers: return .topPeers case let .peer(_, _, peer, _, _, _, _): return .peerId(peer.id) } } static func ==(lhs: ShareSearchRecentEntry, rhs: ShareSearchRecentEntry) -> Bool { switch lhs { case let .topPeers(lhsTheme, lhsStrings): if case let .topPeers(rhsTheme, rhsStrings) = rhs { if lhsTheme !== rhsTheme { return false } if lhsStrings !== rhsStrings { return false } return true } else { return false } case let .peer(lhsIndex, lhsTheme, lhsPeer, lhsAssociatedPeer, lhsPresence, lhsRequiresPremiumForMessaging, lhsStrings): if case let .peer(rhsIndex, rhsTheme, rhsPeer, rhsAssociatedPeer, rhsPresence, rhsRequiresPremiumForMessaging, rhsStrings) = rhs, lhsPeer == rhsPeer && lhsAssociatedPeer == rhsAssociatedPeer && lhsIndex == rhsIndex && lhsStrings === rhsStrings && lhsTheme === rhsTheme && lhsPresence == rhsPresence && lhsRequiresPremiumForMessaging == rhsRequiresPremiumForMessaging { return true } else { return false } } } static func <(lhs: ShareSearchRecentEntry, rhs: ShareSearchRecentEntry) -> Bool { switch lhs { case .topPeers: return true case let .peer(lhsIndex, _, _, _, _, _, _): switch rhs { case .topPeers: return false case let .peer(rhsIndex, _, _, _, _, _, _): return lhsIndex <= rhsIndex } } } func item(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, interfaceInteraction: ShareControllerInteraction) -> GridItem { switch self { case let .topPeers(theme, strings): return ShareControllerRecentPeersGridItem(environment: environment, context: context, theme: theme, strings: strings, controllerInteraction: interfaceInteraction) case let .peer(_, theme, peer, associatedPeer, presence, requiresPremiumForMessaging, strings): var peers: [EnginePeer.Id: EnginePeer] = [peer.id: peer] if let associatedPeer = associatedPeer { peers[associatedPeer.id] = associatedPeer } let peer = EngineRenderedPeer(peerId: peer.id, peers: peers, associatedMedia: [:]) return ShareControllerPeerGridItem(environment: environment, context: context, theme: theme, strings: strings, item: .peer(peer: peer, presence: presence, topicId: nil, threadData: nil, requiresPremiumForMessaging: requiresPremiumForMessaging), controllerInteraction: interfaceInteraction, sectionTitle: strings.DialogList_SearchSectionRecent, search: true) } } } private struct ShareSearchPeerEntry: Comparable, Identifiable { let index: Int32 let peer: EngineRenderedPeer? let presence: EnginePeer.Presence? let requiresPremiumForMessaging: Bool let theme: PresentationTheme let strings: PresentationStrings var stableId: Int64 { if let peer = self.peer { return peer.peerId.toInt64() } else { return Int64(index) } } static func ==(lhs: ShareSearchPeerEntry, rhs: ShareSearchPeerEntry) -> Bool { if lhs.index != rhs.index { return false } if lhs.peer != rhs.peer { return false } if lhs.presence != rhs.presence { return false } if lhs.requiresPremiumForMessaging != rhs.requiresPremiumForMessaging { return false } if lhs.theme !== rhs.theme { return false } return true } static func <(lhs: ShareSearchPeerEntry, rhs: ShareSearchPeerEntry) -> Bool { return lhs.index < rhs.index } func item(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, interfaceInteraction: ShareControllerInteraction) -> GridItem { // let item: ShareControllerPeerGridItem.ShareItem // item = self.peer.flatMap { .peer(peer: $0, presence: self.presence, topicId: nil, threadData: nil) } return ShareControllerPeerGridItem(environment: environment, context: context, theme: self.theme, strings: self.strings, item: self.peer.flatMap({ .peer(peer: $0, presence: self.presence, topicId: nil, threadData: nil, requiresPremiumForMessaging: self.requiresPremiumForMessaging) }), controllerInteraction: interfaceInteraction, search: true) } } private struct ShareSearchGridTransaction { let deletions: [Int] let insertions: [GridNodeInsertItem] let updates: [GridNodeUpdateItem] let animated: Bool let crossFade: Bool } private func preparedGridEntryTransition(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, from fromEntries: [ShareSearchPeerEntry], to toEntries: [ShareSearchPeerEntry], interfaceInteraction: ShareControllerInteraction, crossFade: Bool) -> ShareSearchGridTransaction { 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 ShareSearchGridTransaction(deletions: deletions, insertions: insertions, updates: updates, animated: false, crossFade: crossFade) } private func preparedRecentEntryTransition(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, from fromEntries: [ShareSearchRecentEntry], to toEntries: [ShareSearchRecentEntry], interfaceInteraction: ShareControllerInteraction) -> ShareSearchGridTransaction { 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 ShareSearchGridTransaction(deletions: deletions, insertions: insertions, updates: updates, animated: false, crossFade: false) } final class ShareSearchContainerNode: ASDisplayNode, ShareContentContainerNode { private let environment: ShareControllerEnvironment private let context: ShareControllerAccountContext private var theme: PresentationTheme private let themePromise: Promise private let strings: PresentationStrings private let controllerInteraction: ShareControllerInteraction private var entries: [ShareSearchPeerEntry] = [] private var recentEntries: [ShareSearchRecentEntry] = [] private var enqueuedTransitions: [(ShareSearchGridTransaction, Bool)] = [] private var enqueuedRecentTransitions: [(ShareSearchGridTransaction, Bool)] = [] let contentGridNode: GridNode private let recentGridNode: GridNode private let contentSeparatorNode: ASDisplayNode private let searchNode: ShareSearchBarNode private let cancelButtonNode: HighlightableButtonNode private var contentOffsetUpdated: ((CGFloat, ContainedViewLayoutTransition) -> Void)? var cancel: (() -> Void)? private var ensurePeerVisibleOnLayout: EnginePeer.Id? private var validLayout: (CGSize, CGFloat)? private var overrideGridOffsetTransition: ContainedViewLayoutTransition? private let recentDisposable = MetaDisposable() private let searchQuery = ValuePromise("", ignoreRepeated: true) private let searchDisposable = MetaDisposable() init(environment: ShareControllerEnvironment, context: ShareControllerAccountContext, theme: PresentationTheme, strings: PresentationStrings, controllerInteraction: ShareControllerInteraction, recentPeers recentPeerList: [(peer: EngineRenderedPeer, requiresPremiumForMessaging: Bool)]) { self.environment = environment self.context = context self.theme = theme self.themePromise = Promise() self.themePromise.set(.single(theme)) self.strings = strings self.controllerInteraction = controllerInteraction self.recentGridNode = GridNode() self.contentGridNode = GridNode() self.contentGridNode.isHidden = true self.searchNode = ShareSearchBarNode(theme: theme, placeholder: strings.Common_Search) self.cancelButtonNode = HighlightableButtonNode() self.cancelButtonNode.setTitle(strings.Common_Cancel, with: cancelFont, with: theme.actionSheet.controlAccentColor, for: []) self.cancelButtonNode.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0) self.contentSeparatorNode = ASDisplayNode() self.contentSeparatorNode.isLayerBacked = true self.contentSeparatorNode.displaysAsynchronously = false self.contentSeparatorNode.backgroundColor = theme.actionSheet.opaqueItemSeparatorColor super.init() self.addSubnode(self.recentGridNode) self.addSubnode(self.contentGridNode) self.addSubnode(self.searchNode) self.addSubnode(self.cancelButtonNode) self.addSubnode(self.contentSeparatorNode) self.recentGridNode.presentationLayoutUpdated = { [weak self] presentationLayout, transition in if let strongSelf = self, !strongSelf.recentGridNode.isHidden { strongSelf.gridPresentationLayoutUpdated(presentationLayout, transition: transition) } } self.contentGridNode.presentationLayoutUpdated = { [weak self] presentationLayout, transition in if let strongSelf = self, !strongSelf.contentGridNode.isHidden { strongSelf.gridPresentationLayoutUpdated(presentationLayout, transition: transition) } } self.cancelButtonNode.addTarget(self, action: #selector(self.cancelPressed), forControlEvents: .touchUpInside) let foundItems = combineLatest(self.searchQuery.get(), self.themePromise.get()) |> mapToSignal { query, theme -> Signal<([ShareSearchPeerEntry]?, Bool), NoError> in if !query.isEmpty { let accountPeer = context.stateManager.postbox.loadedPeerWithId(context.accountPeerId) |> take(1) let foundLocalPeers = context.stateManager.postbox.searchPeers(query: query.lowercased()) let foundRemotePeers: Signal<([FoundPeer], [FoundPeer], Bool), NoError> = .single(([], [], true)) |> then( _internal_searchPeers(accountPeerId: context.accountPeerId, postbox: context.stateManager.postbox, network: context.stateManager.network, query: query) |> delay(0.2, queue: Queue.concurrentDefaultQueue()) |> map { a, b -> ([FoundPeer], [FoundPeer], Bool) in return (a, b, false) } ) struct FoundPeers { var foundLocalPeers: [RenderedPeer] var foundRemotePeers: ([FoundPeer], [FoundPeer], Bool) } let foundPeers = Promise() foundPeers.set(combineLatest( foundLocalPeers, foundRemotePeers ) |> map { foundLocalPeers, foundRemotePeers -> FoundPeers in return FoundPeers( foundLocalPeers: foundLocalPeers, foundRemotePeers: foundRemotePeers ) }) let peerRequiresPremiumForMessaging: Signal<[EnginePeer.Id: Bool], NoError> peerRequiresPremiumForMessaging = foundPeers.get() |> map { foundPeers -> Set in var result = Set() for peer in foundPeers.foundLocalPeers { if let user = peer.peer as? TelegramUser, user.flags.contains(.requirePremium) { result.insert(user.id) } } for peer in foundPeers.foundRemotePeers.0 { if let user = peer.peer as? TelegramUser, user.flags.contains(.requirePremium) { result.insert(user.id) } } for peer in foundPeers.foundRemotePeers.1 { if let user = peer.peer as? TelegramUser, user.flags.contains(.requirePremium) { result.insert(user.id) } } return result } |> distinctUntilChanged |> mapToSignal { peerIds -> Signal<[EnginePeer.Id: Bool], NoError> in if let context = context as? ShareControllerAppAccountContext { context.context.account.viewTracker.refreshCanSendMessagesForPeerIds(peerIds: Array(peerIds)) } return context.engineData.subscribe( EngineDataMap( peerIds.map(TelegramEngine.EngineData.Item.Peer.IsPremiumRequiredForMessaging.init(id:)) ) ) } return combineLatest(accountPeer, foundPeers.get(), peerRequiresPremiumForMessaging) |> map { accountPeer, foundPeers, peerRequiresPremiumForMessaging -> ([ShareSearchPeerEntry]?, Bool) in let foundLocalPeers = foundPeers.foundLocalPeers let foundRemotePeers = foundPeers.foundRemotePeers var entries: [ShareSearchPeerEntry] = [] var index: Int32 = 0 var existingPeerIds = Set() let lowercasedQuery = query.lowercased() if strings.DialogList_SavedMessages.lowercased().hasPrefix(lowercasedQuery) || "saved messages".hasPrefix(lowercasedQuery) { if !existingPeerIds.contains(accountPeer.id) { existingPeerIds.insert(accountPeer.id) entries.append(ShareSearchPeerEntry(index: index, peer: EngineRenderedPeer(peer: EnginePeer(accountPeer)), presence: nil, requiresPremiumForMessaging: false, theme: theme, strings: strings)) index += 1 } } for renderedPeer in foundLocalPeers { if let peer = renderedPeer.peers[renderedPeer.peerId], peer.id != accountPeer.id { if !existingPeerIds.contains(renderedPeer.peerId) && canSendMessagesToPeer(peer) { existingPeerIds.insert(renderedPeer.peerId) entries.append(ShareSearchPeerEntry(index: index, peer: EngineRenderedPeer(renderedPeer), presence: nil, requiresPremiumForMessaging: peerRequiresPremiumForMessaging[peer.id] ?? false, theme: theme, strings: strings)) index += 1 } } } var isPlaceholder = false if foundRemotePeers.2 { isPlaceholder = true for _ in 0 ..< 4 { entries.append(ShareSearchPeerEntry(index: index, peer: nil, presence: nil, requiresPremiumForMessaging: false, theme: theme, strings: strings)) index += 1 } } else { for foundPeer in foundRemotePeers.0 { let peer = foundPeer.peer if !existingPeerIds.contains(peer.id) && canSendMessagesToPeer(peer) { existingPeerIds.insert(peer.id) entries.append(ShareSearchPeerEntry(index: index, peer: EngineRenderedPeer(peer: EnginePeer(foundPeer.peer)), presence: nil, requiresPremiumForMessaging: peerRequiresPremiumForMessaging[peer.id] ?? false, theme: theme, strings: strings)) index += 1 } } for foundPeer in foundRemotePeers.1 { let peer = foundPeer.peer if !existingPeerIds.contains(peer.id) && canSendMessagesToPeer(peer) { existingPeerIds.insert(peer.id) entries.append(ShareSearchPeerEntry(index: index, peer: EngineRenderedPeer(peer: EnginePeer(peer)), presence: nil, requiresPremiumForMessaging: peerRequiresPremiumForMessaging[peer.id] ?? false, theme: theme, strings: strings)) index += 1 } } } return (entries, isPlaceholder) } } else { return .single((nil, false)) } } let previousSearchItemsAndIsPlaceholder = Atomic<([ShareSearchPeerEntry]?, Bool)>(value: (nil, false)) self.searchDisposable.set((foundItems |> deliverOnMainQueue).start(next: { [weak self] entriesAndIsPlaceholder in if let strongSelf = self { let (entries, isPlaceholder) = entriesAndIsPlaceholder let previousEntries = previousSearchItemsAndIsPlaceholder.swap(entriesAndIsPlaceholder) strongSelf.entries = entries ?? [] let firstTime = previousEntries.0 == nil let crossFade = !firstTime && previousEntries.1 && !isPlaceholder let transition = preparedGridEntryTransition(environment: environment, context: context, from: previousEntries.0 ?? [], to: entries ?? [], interfaceInteraction: controllerInteraction, crossFade: crossFade) strongSelf.enqueueTransition(transition, firstTime: firstTime) if (previousEntries.0 == nil) != (entries == nil) { if previousEntries.0 == nil { strongSelf.recentGridNode.isHidden = true strongSelf.contentGridNode.isHidden = false strongSelf.transitionToContentGridLayout() } else { strongSelf.recentGridNode.isHidden = false strongSelf.contentGridNode.isHidden = true strongSelf.transitionToRecentGridLayout() } } } })) self.searchNode.textUpdated = { [weak self] text in self?.searchQuery.set(text) } let hasRecentPeers = _internal_recentPeers(accountPeerId: context.accountPeerId, postbox: context.stateManager.postbox) |> map { value -> Bool in switch value { case let .peers(peers): return !peers.isEmpty case .disabled: return false } } |> distinctUntilChanged let recentItems: Signal<[ShareSearchRecentEntry], NoError> = combineLatest(hasRecentPeers, self.themePromise.get()) |> map { hasRecentPeers, theme -> [ShareSearchRecentEntry] in var recentItemList: [ShareSearchRecentEntry] = [] if hasRecentPeers { recentItemList.append(.topPeers(theme, strings)) } var index = 0 for (peer, requiresPremiumForMessaging) in recentPeerList { if let mainPeer = peer.peers[peer.peerId], canSendMessagesToPeer(mainPeer._asPeer()) { recentItemList.append(.peer(index: index, theme: theme, peer: mainPeer, associatedPeer: mainPeer._asPeer().associatedPeerId.flatMap { peer.peers[$0] }, presence: nil, requiresPremiumForMessaging: requiresPremiumForMessaging, strings: strings)) index += 1 } } return recentItemList } let previousRecentItems = Atomic<[ShareSearchRecentEntry]?>(value: nil) self.recentDisposable.set((recentItems |> deliverOnMainQueue).start(next: { [weak self] entries in if let strongSelf = self { let previousEntries = previousRecentItems.swap(entries) strongSelf.recentEntries = entries let firstTime = previousEntries == nil let transition = preparedRecentEntryTransition(environment: environment, context: context, from: previousEntries ?? [], to: entries, interfaceInteraction: controllerInteraction) strongSelf.enqueueRecentTransition(transition, firstTime: firstTime) } })) } deinit { self.searchDisposable.dispose() self.recentDisposable.dispose() } func setEnsurePeerVisibleOnLayout(_ peerId: EnginePeer.Id?) { self.ensurePeerVisibleOnLayout = peerId } func setContentOffsetUpdated(_ f: ((CGFloat, ContainedViewLayoutTransition) -> Void)?) { self.contentOffsetUpdated = f } func activate() { self.searchNode.activateInput() } func deactivate() { self.searchNode.deactivateInput() } func updateTheme(_ theme: PresentationTheme) { self.theme = theme self.themePromise.set(.single(theme)) self.searchNode.updateTheme(theme) self.contentSeparatorNode.backgroundColor = theme.actionSheet.opaqueItemSeparatorColor self.cancelButtonNode.setTitle(self.strings.Common_Cancel, with: cancelFont, with: self.theme.actionSheet.controlAccentColor, for: []) } private func calculateMetrics(size: CGSize) -> (topInset: CGFloat, itemWidth: CGFloat) { let itemCount: Int if self.contentGridNode.isHidden { itemCount = self.recentEntries.count } else { itemCount = self.entries.count } let itemInsets = UIEdgeInsets(top: 0.0, left: 12.0, bottom: 0.0, right: 12.0) let minimalItemWidth: CGFloat = 70.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 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) var scrollToItem: GridNodeScrollToItem? if !self.contentGridNode.isHidden, let ensurePeerVisibleOnLayout = self.ensurePeerVisibleOnLayout { self.ensurePeerVisibleOnLayout = nil if let index = self.entries.firstIndex(where: { $0.peer?.peerId == ensurePeerVisibleOnLayout }) { scrollToItem = GridNodeScrollToItem(index: index, position: .visible, transition: transition, directionHint: .up, adjustForSection: false) } } var scrollToRecentItem: GridNodeScrollToItem? if !self.recentGridNode.isHidden, let ensurePeerVisibleOnLayout = self.ensurePeerVisibleOnLayout { self.ensurePeerVisibleOnLayout = nil if let index = self.recentEntries.firstIndex(where: { switch $0 { case .topPeers: return false case let .peer(_, _, peer, _, _, _, _): return peer.id == ensurePeerVisibleOnLayout } }) { scrollToRecentItem = GridNodeScrollToItem(index: index, position: .visible, transition: transition, directionHint: .up, adjustForSection: false) } } let gridSize = CGSize(width: size.width, height: size.height - 5.0) self.recentGridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: scrollToRecentItem, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: gridSize, insets: UIEdgeInsets(top: gridTopInset, left: 6.0, bottom: bottomInset, right: 6.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.recentGridNode, frame: CGRect(origin: CGPoint(x: floor((size.width - gridSize.width) / 2.0), y: 5.0), size: gridSize)) self.contentGridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: scrollToItem, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: gridSize, insets: UIEdgeInsets(top: gridTopInset, left: 6.0, bottom: bottomInset, right: 6.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: 5.0), size: gridSize)) if firstLayout { self.animateIn() while !self.enqueuedTransitions.isEmpty { self.dequeueTransition() } while !self.enqueuedRecentTransitions.isEmpty { self.dequeueRecentTransition() } } } private func transitionToRecentGridLayout(_ transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .spring)) { if let (size, bottomInset) = self.validLayout { let (gridTopInset, itemWidth) = self.calculateMetrics(size: size) let offset = self.recentGridNode.scrollView.contentOffset.y - self.contentGridNode.scrollView.contentOffset.y let gridSize = CGSize(width: size.width, height: size.height - 5.0) self.recentGridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: gridSize, insets: UIEdgeInsets(top: gridTopInset, left: 6.0, bottom: bottomInset, right: 6.0), preloadSize: 80.0, type: .fixed(itemSize: CGSize(width: itemWidth, height: itemWidth + 25.0), fillWidth: nil, lineSpacing: 0.0, itemSpacing: nil)), transition: transition), itemTransition: .immediate, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in }) transition.animatePositionAdditive(node: self.recentGridNode, offset: CGPoint(x: 0.0, y: offset)) } } private func transitionToContentGridLayout(_ transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .spring)) { if let (size, bottomInset) = self.validLayout { let (gridTopInset, itemWidth) = self.calculateMetrics(size: size) let offset = self.recentGridNode.scrollView.contentOffset.y - self.contentGridNode.scrollView.contentOffset.y let gridSize = CGSize(width: size.width, height: size.height - 5.0) self.contentGridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: gridSize, insets: UIEdgeInsets(top: gridTopInset, left: 6.0, bottom: bottomInset, right: 6.0), preloadSize: 80.0, type: .fixed(itemSize: CGSize(width: itemWidth, height: itemWidth + 25.0), fillWidth: nil, lineSpacing: 0.0, itemSpacing: nil)), transition: transition), itemTransition: .immediate, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in }) transition.animatePositionAdditive(node: self.contentGridNode, offset: CGPoint(x: 0.0, y: -offset)) } } private func gridPresentationLayoutUpdated(_ presentationLayout: GridNodeCurrentPresentationLayout, transition: ContainedViewLayoutTransition) { let actualTransition = self.overrideGridOffsetTransition ?? transition self.overrideGridOffsetTransition = nil let titleAreaHeight: CGFloat = 64.0 let size = self.bounds.size let rawTitleOffset = -titleAreaHeight - presentationLayout.contentOffset.y let titleOffset = max(-titleAreaHeight, rawTitleOffset) let cancelButtonSize = self.cancelButtonNode.measure(CGSize(width: 320.0, height: 100.0)) let cancelButtonFrame = CGRect(origin: CGPoint(x: size.width - cancelButtonSize.width - 12.0, y: titleOffset + 25.0), size: cancelButtonSize) transition.updateFrame(node: self.cancelButtonNode, frame: cancelButtonFrame) let searchNodeFrame = CGRect(origin: CGPoint(x: 16.0, y: titleOffset + 16.0), size: CGSize(width: cancelButtonFrame.minX - 16.0 - 10.0, height: 40.0)) transition.updateFrame(node: self.searchNode, frame: searchNodeFrame) self.searchNode.updateLayout(width: searchNodeFrame.size.width, transition: transition) transition.updateFrame(node: self.contentSeparatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: titleOffset + titleAreaHeight + 5.0), size: CGSize(width: size.width, height: UIScreenPixel))) if rawTitleOffset.isLess(than: -titleAreaHeight) { self.contentSeparatorNode.alpha = 1.0 } else { self.contentSeparatorNode.alpha = 0.0 } self.contentOffsetUpdated?(presentationLayout.contentOffset.y, actualTransition) } func animateIn() { } func updateSelectedPeers(animated: Bool) { self.contentGridNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ShareControllerPeerGridItemNode { itemNode.updateSelection(animated: true) } } self.recentGridNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ShareControllerPeerGridItemNode { itemNode.updateSelection(animated: true) } else if let itemNode = itemNode as? ShareControllerRecentPeersGridItemNode { itemNode.updateSelection(animated: true) } } } @objc func cancelPressed() { self.cancel?() } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let nodes: [ASDisplayNode] = [self.searchNode, self.cancelButtonNode] for node in nodes { let nodeFrame = node.frame 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 enqueueTransition(_ transition: ShareSearchGridTransaction, 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) } if transition.crossFade { if let snapshotView = self.contentGridNode.view.snapshotView(afterScreenUpdates: false) { self.contentGridNode.view.superview?.insertSubview(snapshotView, aboveSubview: self.contentGridNode.view) snapshotView.frame = self.contentGridNode.frame snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak snapshotView] _ in snapshotView?.removeFromSuperview() }) } } self.contentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil, synchronousLoads: true), completion: { _ in }) } } private func enqueueRecentTransition(_ transition: ShareSearchGridTransaction, firstTime: Bool) { self.enqueuedRecentTransitions.append((transition, firstTime)) if self.validLayout != nil { while !self.enqueuedRecentTransitions.isEmpty { self.dequeueRecentTransition() } } } private func dequeueRecentTransition() { if let (transition, _) = self.enqueuedRecentTransitions.first { self.enqueuedRecentTransitions.remove(at: 0) var itemTransition: ContainedViewLayoutTransition = .immediate if transition.animated { itemTransition = .animated(duration: 0.3, curve: .spring) } self.recentGridNode.transaction(GridNodeTransaction(deleteItems: transition.deletions, insertItems: transition.insertions, updateItems: transition.updates, scrollToItem: nil, updateLayout: nil, itemTransition: itemTransition, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: { _ in }) } } func frameForPeerId(_ peerId: EnginePeer.Id) -> CGRect? { var node: ASDisplayNode? if !self.recentGridNode.isHidden { self.recentGridNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId { node = itemNode } } } else { self.contentGridNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId { node = itemNode } } } if let node = node { return node.frame.offsetBy(dx: 0.0, dy: -10.0) } else { return nil } } func animateIn(peerId: EnginePeer.Id, scrollDelta: CGFloat) -> CGRect? { self.searchNode.alpha = 1.0 self.searchNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.searchNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.cancelButtonNode.alpha = 1.0 self.cancelButtonNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.cancelButtonNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.contentGridNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) if let targetFrame = self.frameForPeerId(peerId), let (size, bottomInset) = self.validLayout { let clippedNode = ASDisplayNode() clippedNode.clipsToBounds = true clippedNode.cornerRadius = 16.0 clippedNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.searchNode.frame.minY - 15.0), size: CGSize(width: size.width, height: size.height - bottomInset)) self.contentGridNode.view.superview?.insertSubview(clippedNode.view, aboveSubview: self.contentGridNode.view) clippedNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -scrollDelta), to: .zero, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) let maskView = UIView() maskView.frame = clippedNode.bounds let maskImageView = UIImageView() maskImageView.image = generatePeersMaskImage() maskImageView.frame = maskView.bounds.offsetBy(dx: 0.0, dy: 36.0) maskView.addSubview(maskImageView) clippedNode.view.mask = maskView self.contentGridNode.alpha = 1.0 self.contentGridNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId { itemNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, removeOnCompletion: false) itemNode.layer.animateScale(from: 1.35, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak clippedNode] _ in clippedNode?.view.removeFromSuperview() }) } else if let snapshotView = itemNode.view.snapshotView(afterScreenUpdates: false) { snapshotView.frame = itemNode.view.convert(itemNode.bounds, to: clippedNode.view) clippedNode.view.addSubview(snapshotView) itemNode.alpha = 0.0 let angle = targetFrame.center.angle(to: itemNode.position) let distance = targetFrame.center.distance(to: itemNode.position) let newDistance = distance * 2.8 let newPosition = snapshotView.center.offsetBy(distance: newDistance, inDirection: angle) snapshotView.layer.animatePosition(from: newPosition, to: snapshotView.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) snapshotView.layer.animateScale(from: 1.35, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak itemNode] _ in itemNode?.alpha = 1.0 }) snapshotView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, removeOnCompletion: false) } } return targetFrame } else { return nil } } func animateOut(peerId: EnginePeer.Id, scrollDelta: CGFloat) -> CGRect? { self.searchNode.alpha = 0.0 self.searchNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) self.searchNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.cancelButtonNode.alpha = 0.0 self.cancelButtonNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2) self.cancelButtonNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) self.contentGridNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) if let sourceFrame = self.frameForPeerId(peerId), let (size, bottomInset) = self.validLayout { let clippedNode = ASDisplayNode() clippedNode.clipsToBounds = true clippedNode.cornerRadius = 16.0 clippedNode.frame = CGRect(origin: CGPoint(x: 0.0, y: self.searchNode.frame.minY - 15.0), size: CGSize(width: size.width, height: size.height - bottomInset)) self.contentGridNode.view.superview?.insertSubview(clippedNode.view, aboveSubview: self.contentGridNode.view) clippedNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: -scrollDelta), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, additive: true) let maskView = UIView() maskView.frame = clippedNode.bounds let maskImageView = UIImageView() maskImageView.image = generatePeersMaskImage() maskImageView.frame = maskView.bounds.offsetBy(dx: 0.0, dy: 36.0) maskView.addSubview(maskImageView) clippedNode.view.mask = maskView self.contentGridNode.forEachItemNode { itemNode in if let snapshotView = itemNode.view.snapshotView(afterScreenUpdates: false) { snapshotView.frame = itemNode.view.convert(itemNode.bounds, to: clippedNode.view) clippedNode.view.addSubview(snapshotView) if let itemNode = itemNode as? ShareControllerPeerGridItemNode, itemNode.peerId == peerId { } else { let angle = sourceFrame.center.angle(to: itemNode.position) let distance = sourceFrame.center.distance(to: itemNode.position) let newDistance = distance * 2.8 let newPosition = snapshotView.center.offsetBy(distance: newDistance, inDirection: angle) snapshotView.layer.animatePosition(from: snapshotView.center, to: newPosition, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) } snapshotView.layer.animateScale(from: 1.0, to: 1.35, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) } } clippedNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak clippedNode] _ in clippedNode?.view.removeFromSuperview() }) self.contentGridNode.alpha = 0.0 return sourceFrame } else { return nil } } }