import Foundation import Display import SwiftSignalKit import TelegramCore typealias ItemListSectionId = Int32 protocol ItemListNodeEntry: Equatable, Comparable, Identifiable { associatedtype ItemGenerationArguments var section: ItemListSectionId { get } func item(_ arguments: ItemGenerationArguments) -> ListViewItem } private struct ItemListNodeEntryTransition { let deletions: [ListViewDeleteItem] let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] } private func preparedItemListNodeEntryTransition(from fromEntries: [Entry], to toEntries: [Entry], arguments: Entry.ItemGenerationArguments) -> ItemListNodeEntryTransition { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(arguments), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(arguments), directionHint: nil) } return ItemListNodeEntryTransition(deletions: deletions, insertions: insertions, updates: updates) } enum ItemListStyle { case plain case blocks } private struct ItemListNodeTransition { let theme: PresentationTheme let entries: ItemListNodeEntryTransition let updateStyle: ItemListStyle? let emptyStateItem: ItemListControllerEmptyStateItem? let focusItemTag: ItemListItemTag? let firstTime: Bool let animated: Bool let animateAlpha: Bool let mergedEntries: [Entry] } struct ItemListNodeState { let entries: [Entry] let style: ItemListStyle let emptyStateItem: ItemListControllerEmptyStateItem? let animateChanges: Bool let focusItemTag: ItemListItemTag? init(entries: [Entry], style: ItemListStyle, focusItemTag: ItemListItemTag? = nil, emptyStateItem: ItemListControllerEmptyStateItem? = nil, animateChanges: Bool = true) { self.entries = entries self.style = style self.emptyStateItem = emptyStateItem self.animateChanges = animateChanges self.focusItemTag = focusItemTag } } private final class ItemListNodeOpaqueState { let mergedEntries: [Entry] init(mergedEntries: [Entry]) { self.mergedEntries = mergedEntries } } final class ItemListNodeVisibleEntries: Sequence { let iterate: () -> Entry? init(iterate: @escaping () -> Entry?) { self.iterate = iterate } func makeIterator() -> AnyIterator { return AnyIterator { () -> Entry? in return self.iterate() } } } class ItemListControllerNode: ASDisplayNode, UIScrollViewDelegate { private var _ready = ValuePromise() public var ready: Signal { return self._ready.get() } private var didSetReady = false let listNode: ListView private var emptyStateItem: ItemListControllerEmptyStateItem? private var emptyStateNode: ItemListControllerEmptyStateItemNode? private let scrollNode: ASScrollNode private let transitionDisposable = MetaDisposable() private var enqueuedTransitions: [ItemListNodeTransition] = [] private var validLayout: (ContainerViewLayout, CGFloat)? private var theme: PresentationTheme? private var listStyle: ItemListStyle? let updateNavigationOffset: (CGFloat) -> Void var dismiss: (() -> Void)? var visibleEntriesUpdated: ((ItemListNodeVisibleEntries) -> Void)? var enableInteractiveDismiss = false { didSet { self.scrollNode.view.isScrollEnabled = self.enableInteractiveDismiss } } init(updateNavigationOffset: @escaping (CGFloat) -> Void, state: Signal<(PresentationTheme, (ItemListNodeState, Entry.ItemGenerationArguments)), NoError>) { self.updateNavigationOffset = updateNavigationOffset self.listNode = ListView() self.scrollNode = ASScrollNode() super.init() self.setViewBlock({ return UITracingLayerView() }) self.backgroundColor = nil self.isOpaque = false self.scrollNode.view.showsVerticalScrollIndicator = false self.scrollNode.view.showsHorizontalScrollIndicator = false self.scrollNode.view.alwaysBounceHorizontal = false self.scrollNode.view.alwaysBounceVertical = false self.scrollNode.view.clipsToBounds = false self.scrollNode.view.delegate = self self.scrollNode.view.scrollsToTop = false self.scrollNode.view.isScrollEnabled = false self.addSubnode(self.scrollNode) self.scrollNode.backgroundColor = nil self.scrollNode.isOpaque = false self.scrollNode.addSubnode(self.listNode) self.addSubnode(self.scrollNode) self.listNode.backgroundColor = UIColor(rgb: 0xefeff4) self.listNode.displayedItemRangeChanged = { [weak self] displayedRange, opaqueTransactionState in if let strongSelf = self, let visibleEntriesUpdated = strongSelf.visibleEntriesUpdated, let mergedEntries = (opaqueTransactionState as? ItemListNodeOpaqueState)?.mergedEntries { if let visible = displayedRange.visibleRange { let indexRange = (visible.firstIndex, visible.lastIndex) var index = indexRange.0 let iterator = ItemListNodeVisibleEntries(iterate: { var item: Entry? if index <= indexRange.1 { item = mergedEntries[index] } index += 1 return item }) visibleEntriesUpdated(iterator) } } } let previousState = Atomic?>(value: nil) self.transitionDisposable.set(((state |> map { theme, stateAndArguments -> ItemListNodeTransition in let (state, arguments) = stateAndArguments assert(state.entries == state.entries.sorted()) let previous = previousState.swap(state) let transition = preparedItemListNodeEntryTransition(from: previous?.entries ?? [], to: state.entries, arguments: arguments) var updatedStyle: ItemListStyle? if previous?.style != state.style { updatedStyle = state.style } return ItemListNodeTransition(theme: theme, entries: transition, updateStyle: updatedStyle, emptyStateItem: state.emptyStateItem, focusItemTag: state.focusItemTag, firstTime: previous == nil, animated: previous != nil && state.animateChanges, animateAlpha: previous != nil && !state.animateChanges, mergedEntries: state.entries) }) |> deliverOnMainQueue).start(next: { [weak self] transition in if let strongSelf = self { strongSelf.enqueueTransition(transition) } })) } deinit { self.transitionDisposable.dispose() } func animateIn() { 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: { [weak self] _ in if let strongSelf = self { strongSelf.dismiss?() } completion?() }) } func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { let previousContentHeight = self.scrollNode.view.contentSize.height let previousVerticalOffset = self.scrollNode.view.contentOffset.y self.scrollNode.frame = CGRect(origin: CGPoint(), size: layout.size) self.scrollNode.view.contentSize = CGSize(width: 0.0, height: layout.size.height * 3.0) if previousContentHeight.isEqual(to: 0.0) { self.scrollNode.view.contentOffset = CGPoint(x: 0.0, y: self.scrollNode.view.contentSize.height / 3.0) } else { self.scrollNode.view.contentOffset = CGPoint(x: 0.0, y: previousVerticalOffset * self.scrollNode.view.contentSize.height / previousContentHeight) } var duration: Double = 0.0 var curve: UInt = 0 switch transition { case .immediate: break case let .animated(animationDuration, animationCurve): duration = animationDuration switch animationCurve { case .easeInOut: break case .spring: curve = 7 } } let listViewCurve: ListViewAnimationCurve if curve == 7 { listViewCurve = .Spring(duration: duration) } else { listViewCurve = .Default } var insets = layout.insets(options: [.input]) insets.top += navigationBarHeight self.listNode.bounds = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) self.listNode.position = CGPoint(x: layout.size.width / 2.0, y: layout.size.height + layout.size.height / 2.0) self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, duration: duration, curve: listViewCurve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) if let emptyStateNode = self.emptyStateNode { emptyStateNode.updateLayout(layout: layout, navigationBarHeight: navigationBarHeight, transition: transition) } let dequeue = self.validLayout == nil self.validLayout = (layout, navigationBarHeight) if dequeue { self.dequeueTransitions() } } private func enqueueTransition(_ transition: ItemListNodeTransition) { self.enqueuedTransitions.append(transition) if self.validLayout != nil { self.dequeueTransitions() } } private func dequeueTransitions() { while !self.enqueuedTransitions.isEmpty { let transition = self.enqueuedTransitions.removeFirst() if transition.theme !== self.theme { self.theme = transition.theme if let listStyle = self.listStyle { switch listStyle { case .plain: self.listNode.backgroundColor = transition.theme.list.plainBackgroundColor case .blocks: self.listNode.backgroundColor = transition.theme.list.blocksBackgroundColor } } } if let updateStyle = transition.updateStyle { self.listStyle = updateStyle if let _ = self.theme { switch updateStyle { case .plain: self.listNode.backgroundColor = transition.theme.list.plainBackgroundColor case .blocks: self.listNode.backgroundColor = transition.theme.list.blocksBackgroundColor } } } var options = ListViewDeleteAndInsertOptions() if transition.firstTime { options.insert(.Synchronous) options.insert(.LowLatency) } else if transition.animated { options.insert(.AnimateInsertion) } else if transition.animateAlpha { options.insert(.PreferSynchronousResourceLoading) options.insert(.PreferSynchronousDrawing) options.insert(.AnimateAlpha) } let focusItemTag = transition.focusItemTag self.listNode.transaction(deleteIndices: transition.entries.deletions, insertIndicesAndItems: transition.entries.insertions, updateIndicesAndItems: transition.entries.updates, options: options, updateOpaqueState: ItemListNodeOpaqueState(mergedEntries: transition.mergedEntries), completion: { [weak self] _ in if let strongSelf = self { if !strongSelf.didSetReady { strongSelf.didSetReady = true strongSelf._ready.set(true) } if let focusItemTag = focusItemTag { strongSelf.listNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ItemListItemNode, let itemTag = itemNode.tag, itemTag.isEqual(to: focusItemTag) { if let focusableNode = itemNode as? ItemListItemFocusableNode { focusableNode.focus() } } } } } }) var updateEmptyStateItem = false if let emptyStateItem = self.emptyStateItem, let updatedEmptyStateItem = transition.emptyStateItem { updateEmptyStateItem = !emptyStateItem.isEqual(to: updatedEmptyStateItem) } else if (self.emptyStateItem != nil) != (transition.emptyStateItem != nil) { updateEmptyStateItem = true } if updateEmptyStateItem { self.emptyStateItem = transition.emptyStateItem if let emptyStateItem = transition.emptyStateItem { let updatedNode = emptyStateItem.node(current: self.emptyStateNode) if let emptyStateNode = self.emptyStateNode, updatedNode !== emptyStateNode { emptyStateNode.removeFromSupernode() } if self.emptyStateNode !== updatedNode { self.emptyStateNode = updatedNode if let validLayout = self.validLayout { updatedNode.updateLayout(layout: validLayout.0, navigationBarHeight: validLayout.1, transition: .immediate) } self.addSubnode(updatedNode) } } else if let emptyStateNode = self.emptyStateNode { emptyStateNode.removeFromSupernode() self.emptyStateNode = nil } } } } func scrollToTop() { self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .Top, animated: true, curve: .Default, directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) } func scrollViewDidScroll(_ scrollView: UIScrollView) { let distanceFromEquilibrium = scrollView.contentOffset.y - scrollView.contentSize.height / 3.0 let transition = 1.0 - min(1.0, max(0.0, abs(distanceFromEquilibrium) / 50.0)) self.updateNavigationOffset(-distanceFromEquilibrium) /*if let toolbarNode = toolbarNode { toolbarNode.layer.position = CGPoint(x: toolbarNode.layer.position.x, y: self.bounds.size.height - toolbarNode.bounds.size.height / 2.0 + (1.0 - transition) * toolbarNode.bounds.size.height) }*/ } func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { targetContentOffset.pointee = scrollView.contentOffset let scrollVelocity = scrollView.panGestureRecognizer.velocity(in: scrollView) if abs(scrollVelocity.y) > 200.0 { self.animateOut() } else { self.scrollNode.view.setContentOffset(CGPoint(x: 0.0, y: self.scrollNode.view.contentSize.height / 3.0), animated: true) } } }