Swiftgram/TelegramUI/WebSearchControllerNode.swift
2018-12-07 23:01:02 +04:00

382 lines
19 KiB
Swift

import Foundation
import AsyncDisplayKit
import Postbox
import SwiftSignalKit
import Display
import TelegramCore
private struct WebSearchContextResultStableId: Hashable {
let result: ChatContextResult
var hashValue: Int {
return result.id.hashValue
}
static func ==(lhs: WebSearchContextResultStableId, rhs: WebSearchContextResultStableId) -> Bool {
return lhs.result == rhs.result
}
}
private struct WebSearchEntry: Comparable, Identifiable {
let index: Int
let result: ChatContextResult
var stableId: WebSearchContextResultStableId {
return WebSearchContextResultStableId(result: self.result)
}
static func ==(lhs: WebSearchEntry, rhs: WebSearchEntry) -> Bool {
return lhs.index == rhs.index && lhs.result == rhs.result
}
static func <(lhs: WebSearchEntry, rhs: WebSearchEntry) -> Bool {
return lhs.index < rhs.index
}
func item(account: Account, theme: PresentationTheme, interfaceState: WebSearchInterfaceState, controllerInteraction: WebSearchControllerInteraction) -> GridItem {
return WebSearchItem(account: account, theme: theme, interfaceState: interfaceState, result: self.result, controllerInteraction: controllerInteraction)
}
}
private struct WebSearchTransition {
let deleteItems: [Int]
let insertItems: [GridNodeInsertItem]
let updateItems: [GridNodeUpdateItem]
let entryCount: Int
let hasMore: Bool
}
private final class HorizontalListContextResultsOpaqueState {
let entryCount: Int
let hasMore: Bool
init(entryCount: Int, hasMore: Bool) {
self.entryCount = entryCount
self.hasMore = hasMore
}
}
private func preparedTransition(from fromEntries: [WebSearchEntry], to toEntries: [WebSearchEntry], hasMore: Bool, account: Account, theme: PresentationTheme, interfaceState: WebSearchInterfaceState, controllerInteraction: WebSearchControllerInteraction) -> WebSearchTransition {
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
let insertions = indicesAndItems.map { GridNodeInsertItem(index: $0.0, item: $0.1.item(account: account, theme: theme, interfaceState: interfaceState, controllerInteraction: controllerInteraction), previousIndex: $0.2) }
let updates = updateIndices.map { GridNodeUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(account: account, theme: theme, interfaceState: interfaceState, controllerInteraction: controllerInteraction)) }
return WebSearchTransition(deleteItems: deleteIndices, insertItems: insertions, updateItems: updates, entryCount: toEntries.count, hasMore: hasMore)
}
private func gridNodeLayoutForContainerLayout(size: CGSize) -> GridNodeLayoutType {
let side = floorToScreenPixels((size.width - 3.0) / 4.0)
return .fixed(itemSize: CGSize(width: side, height: side), fillWidth: true, lineSpacing: 1.0, itemSpacing: 1.0)
}
class WebSearchControllerNode: ASDisplayNode {
private let account: Account
private var theme: PresentationTheme
private var strings: PresentationStrings
private let controllerInteraction: WebSearchControllerInteraction
private var webSearchInterfaceState: WebSearchInterfaceState
private let webSearchInterfaceStatePromise: ValuePromise<WebSearchInterfaceState>
private let segmentedBackgroundNode: ASDisplayNode
private let segmentedSeparatorNode: ASDisplayNode
private let segmentedControl: UISegmentedControl
private let toolbarBackgroundNode: ASDisplayNode
private let toolbarSeparatorNode: ASDisplayNode
private let cancelButton: HighlightableButtonNode
private let sendButton: HighlightableButtonNode
private let attributionNode: ASImageNode
private let gridNode: GridNode
private var enqueuedTransitions: [(WebSearchTransition, Bool)] = []
private var dequeuedInitialTransitionOnLayout = false
private var currentExternalResults: ChatContextResultCollection?
private var currentProcessedResults: ChatContextResultCollection?
private var currentEntries: [WebSearchEntry]?
private var isLoadingMore = false
private let results = ValuePromise<ChatContextResultCollection>(ignoreRepeated: true)
private let disposable = MetaDisposable()
private let loadMoreDisposable = MetaDisposable()
private var containerLayout: (ContainerViewLayout, CGFloat)?
var requestUpdateInterfaceState: (Bool, (WebSearchInterfaceState) -> WebSearchInterfaceState) -> Void = { _, _ in }
var cancel: (() -> Void)?
init(account: Account, theme: PresentationTheme, strings: PresentationStrings, controllerInteraction: WebSearchControllerInteraction) {
self.account = account
self.theme = theme
self.strings = strings
self.controllerInteraction = controllerInteraction
self.webSearchInterfaceState = WebSearchInterfaceState(presentationData: account.telegramApplicationContext.currentPresentationData.with { $0 })
self.webSearchInterfaceStatePromise = ValuePromise(self.webSearchInterfaceState, ignoreRepeated: true)
self.segmentedBackgroundNode = ASDisplayNode()
self.segmentedSeparatorNode = ASDisplayNode()
self.segmentedControl = UISegmentedControl(items: [strings.WebSearch_Images, strings.WebSearch_GIFs])
self.segmentedControl.selectedSegmentIndex = 0
self.toolbarBackgroundNode = ASDisplayNode()
self.toolbarSeparatorNode = ASDisplayNode()
self.attributionNode = ASImageNode()
self.cancelButton = HighlightableButtonNode()
self.sendButton = HighlightableButtonNode()
self.gridNode = GridNode()
self.gridNode.backgroundColor = theme.list.plainBackgroundColor
super.init()
self.setViewBlock({
return UITracingLayerView()
})
self.addSubnode(self.gridNode)
self.addSubnode(self.segmentedBackgroundNode)
self.addSubnode(self.segmentedSeparatorNode)
self.view.addSubview(self.segmentedControl)
self.addSubnode(self.toolbarBackgroundNode)
self.addSubnode(self.toolbarSeparatorNode)
self.addSubnode(self.cancelButton)
self.addSubnode(self.sendButton)
self.addSubnode(self.attributionNode)
self.segmentedControl.addTarget(self, action: #selector(self.indexChanged), for: .valueChanged)
self.cancelButton.addTarget(self, action: #selector(self.cancelPressed), forControlEvents: .touchUpInside)
self.sendButton.addTarget(self, action: #selector(self.sendPressed), forControlEvents: .touchUpInside)
self.applyPresentationData()
self.disposable.set((combineLatest(self.results.get(), self.webSearchInterfaceStatePromise.get())
|> deliverOnMainQueue).start(next: { [weak self] results, interfaceState in
if let strongSelf = self {
strongSelf.updateInternalResults(results, interfaceState: interfaceState)
}
}))
self.gridNode.visibleItemsUpdated = { [weak self] visibleItems in
//state.hasMore &&
//if visibleItems.bottom.0 <= state.entryCount - 10 {
// strongSelf.loadMore()
//}
}
// let selectorRecogizner = ChatGridLiveSelectorRecognizer(target: self, action: #selector(self.panGesture(_:)))
// selectorRecogizner.shouldBegin = { [weak controllerInteraction] in
// return controllerInteraction?.selectionState != nil
// }
// self.view.addGestureRecognizer(selectorRecogizner)
}
deinit {
self.loadMoreDisposable.dispose()
}
func updatePresentationData(theme: PresentationTheme, strings: PresentationStrings) {
let themeUpdated = theme !== self.theme
self.theme = theme
self.strings = strings
self.applyPresentationData(themeUpdated: themeUpdated)
}
func applyPresentationData(themeUpdated: Bool = true) {
self.cancelButton.setTitle(self.strings.Common_Cancel, with: Font.regular(17.0), with: self.theme.rootController.navigationBar.accentTextColor, for: .normal)
self.sendButton.setTitle(self.strings.MediaPicker_Send, with: Font.medium(17.0), with: self.theme.rootController.navigationBar.accentTextColor, for: .normal)
if themeUpdated {
self.backgroundColor = self.theme.chatList.backgroundColor
self.segmentedBackgroundNode.backgroundColor = self.theme.rootController.navigationBar.backgroundColor
self.segmentedSeparatorNode.backgroundColor = self.theme.rootController.navigationBar.separatorColor
self.segmentedControl.tintColor = self.theme.rootController.navigationBar.accentTextColor
self.toolbarBackgroundNode.backgroundColor = self.theme.rootController.navigationBar.backgroundColor
self.toolbarSeparatorNode.backgroundColor = self.theme.rootController.navigationBar.separatorColor
self.attributionNode.image = generateTintedImage(image: UIImage(bundleImageName: "Media Grid/Giphy"), color: self.theme.list.itemSecondaryTextColor)
}
}
func animateIn(completion: (() -> Void)? = nil) {
self.layer.animatePosition(from: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), to: self.layer.position, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring)
}
func animateOut(completion: (() -> Void)? = nil) {
self.layer.animatePosition(from: self.layer.position, to: CGPoint(x: self.layer.position.x, y: self.layer.position.y + self.layer.bounds.size.height), duration: 0.2, timingFunction: kCAMediaTimingFunctionEaseInEaseOut, removeOnCompletion: false, completion: { _ in
completion?()
})
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.containerLayout = (layout, navigationBarHeight)
var insets = layout.insets(options: [.input])
insets.top += navigationBarHeight
let segmentedHeight: CGFloat = 40.0
let panelY: CGFloat = insets.top - UIScreenPixel - 4.0
transition.updateFrame(node: self.segmentedBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: panelY), size: CGSize(width: layout.size.width, height: segmentedHeight)))
transition.updateFrame(node: self.segmentedSeparatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: panelY + segmentedHeight), size: CGSize(width: layout.size.width, height: UIScreenPixel)))
var controlSize = self.segmentedControl.sizeThatFits(layout.size)
controlSize.width = layout.size.width - layout.safeInsets.left - layout.safeInsets.right - 8.0 * 2.0
transition.updateFrame(view: self.segmentedControl, frame: CGRect(origin: CGPoint(x: layout.safeInsets.left + floor((layout.size.width - layout.safeInsets.left - layout.safeInsets.right - controlSize.width) / 2.0), y: panelY + floor((segmentedHeight - controlSize.height) / 2.0)), size: controlSize))
let toolbarHeight: CGFloat = 44.0
let toolbarY = layout.size.height - toolbarHeight - insets.bottom
transition.updateFrame(node: self.toolbarBackgroundNode, frame: CGRect(origin: CGPoint(x: 0.0, y: toolbarY), size: CGSize(width: layout.size.width, height: toolbarHeight + insets.bottom)))
transition.updateFrame(node: self.toolbarSeparatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: toolbarY), size: CGSize(width: layout.size.width, height: UIScreenPixel)))
if let image = self.attributionNode.image {
transition.updateFrame(node: self.attributionNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - image.size.width) / 2.0), y: toolbarY + floor((toolbarHeight - image.size.height) / 2.0)), size: image.size))
transition.updateAlpha(node: self.attributionNode, alpha: self.webSearchInterfaceState.state?.mode == .gifs ? 1.0 : 0.0)
}
let toolbarPadding: CGFloat = 15.0
let cancelSize = self.cancelButton.measure(CGSize(width: layout.size.width, height: toolbarHeight))
transition.updateFrame(node: self.cancelButton, frame: CGRect(origin: CGPoint(x: toolbarPadding + layout.safeInsets.left, y: toolbarY), size: CGSize(width: cancelSize.width, height: toolbarHeight)))
let sendSize = self.sendButton.measure(CGSize(width: layout.size.width, height: toolbarHeight))
transition.updateFrame(node: self.sendButton, frame: CGRect(origin: CGPoint(x: layout.size.width - toolbarPadding - layout.safeInsets.right - sendSize.width, y: toolbarY), size: CGSize(width: sendSize.width, height: toolbarHeight)))
let previousBounds = self.gridNode.bounds
self.gridNode.bounds = CGRect(x: previousBounds.origin.x, y: previousBounds.origin.y, width: layout.size.width, height: layout.size.height)
self.gridNode.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0)
insets.top += segmentedHeight
insets.bottom += toolbarHeight
self.gridNode.transaction(GridNodeTransaction(deleteItems: [], insertItems: [], updateItems: [], scrollToItem: nil, updateLayout: GridNodeUpdateLayout(layout: GridNodeLayout(size: layout.size, insets: insets, preloadSize: 400.0, type: gridNodeLayoutForContainerLayout(size: layout.size)), transition: .immediate), itemTransition: .immediate, stationaryItems: .none,updateFirstIndexInSectionOffset: nil), completion: { _ in })
if !self.dequeuedInitialTransitionOnLayout {
self.dequeuedInitialTransitionOnLayout = true
self.dequeueTransition()
}
}
func updateInterfaceState(_ interfaceState: WebSearchInterfaceState, animated: Bool) {
self.webSearchInterfaceState = interfaceState
self.webSearchInterfaceStatePromise.set(self.webSearchInterfaceState)
if let state = interfaceState.state {
self.segmentedControl.selectedSegmentIndex = Int(state.mode.rawValue)
}
if let validLayout = self.containerLayout {
self.containerLayoutUpdated(validLayout.0, navigationBarHeight: validLayout.1, transition: animated ? .animated(duration: 0.4, curve: .spring) : .immediate)
}
}
func updateResults(_ results: ChatContextResultCollection) {
if self.currentExternalResults == results {
return
}
self.currentExternalResults = results
self.currentProcessedResults = results
self.isLoadingMore = false
self.loadMoreDisposable.set(nil)
self.results.set(results)
}
private func loadMore() {
guard !self.isLoadingMore, let currentProcessedResults = self.currentProcessedResults, let nextOffset = currentProcessedResults.nextOffset else {
return
}
self.isLoadingMore = true
self.loadMoreDisposable.set((requestChatContextResults(account: self.account, botId: currentProcessedResults.botId, peerId: currentProcessedResults.peerId, query: currentProcessedResults.query, location: .single(currentProcessedResults.geoPoint), offset: nextOffset)
|> deliverOnMainQueue).start(next: { [weak self] nextResults in
guard let strongSelf = self, let nextResults = nextResults else {
return
}
strongSelf.isLoadingMore = false
var results: [ChatContextResult] = []
for result in currentProcessedResults.results {
results.append(result)
}
for result in nextResults.results {
results.append(result)
}
let mergedResults = ChatContextResultCollection(botId: currentProcessedResults.botId, peerId: currentProcessedResults.peerId, query: currentProcessedResults.query, geoPoint: currentProcessedResults.geoPoint, queryId: nextResults.queryId, nextOffset: nextResults.nextOffset, presentation: currentProcessedResults.presentation, switchPeer: currentProcessedResults.switchPeer, results: results, cacheTimeout: currentProcessedResults.cacheTimeout)
strongSelf.currentProcessedResults = mergedResults
strongSelf.results.set(mergedResults)
}))
}
private func updateInternalResults(_ results: ChatContextResultCollection, interfaceState: WebSearchInterfaceState) {
var entries: [WebSearchEntry] = []
var index = 0
var resultIds = Set<WebSearchContextResultStableId>()
for result in results.results {
let entry = WebSearchEntry(index: index, result: result)
if resultIds.contains(entry.stableId) {
continue
} else {
resultIds.insert(entry.stableId)
}
entries.append(entry)
index += 1
}
let firstTime = self.currentEntries == nil
let transition = preparedTransition(from: self.currentEntries ?? [], to: entries, hasMore: results.nextOffset != nil, account: self.account, theme: interfaceState.presentationData.theme, interfaceState: interfaceState, controllerInteraction: self.controllerInteraction)
self.currentEntries = entries
self.enqueueTransition(transition, firstTime: firstTime)
}
private func enqueueTransition(_ transition: WebSearchTransition, firstTime: Bool) {
self.enqueuedTransitions.append((transition, firstTime))
if self.containerLayout != nil {
while !self.enqueuedTransitions.isEmpty {
self.dequeueTransition()
}
}
}
private func dequeueTransition() {
if let (transition, firstTime) = self.enqueuedTransitions.first {
self.enqueuedTransitions.remove(at: 0)
let completion: (GridNodeDisplayedItemRange) -> Void = { [weak self] visibleRange in
if let strongSelf = self {
}
}
self.gridNode.transaction(GridNodeTransaction(deleteItems: transition.deleteItems, insertItems: transition.insertItems, updateItems: transition.updateItems, scrollToItem: nil, updateLayout: nil, itemTransition: .immediate, stationaryItems: .none, updateFirstIndexInSectionOffset: nil), completion: completion)
}
}
@objc private func indexChanged() {
self.requestUpdateInterfaceState(true) { current in
if let mode = WebSearchMode(rawValue: Int32(self.segmentedControl.selectedSegmentIndex)) {
return current.withUpdatedMode(mode)
}
return current
}
}
@objc private func cancelPressed() {
self.cancel?()
}
@objc private func sendPressed() {
if let results = self.currentProcessedResults {
self.controllerInteraction.sendSelected(results)
}
self.cancel?()
}
}