import Foundation import UIKit import AsyncDisplayKit import Display import SwiftSignalKit import TelegramCore import TelegramPresentationData import MergeLists public protocol ItemListHeaderItemNode: AnyObject { func updateTheme(theme: PresentationTheme) } public typealias ItemListSectionId = Int32 public protocol ItemListNodeAnyEntry { var anyId: AnyHashable { get } var tag: ItemListItemTag? { get } func isLessThan(_ rhs: ItemListNodeAnyEntry) -> Bool func isEqual(_ rhs: ItemListNodeAnyEntry) -> Bool func item(presentationData: ItemListPresentationData, arguments: Any) -> ListViewItem } public protocol ItemListNodeEntry: Comparable, Identifiable, ItemListNodeAnyEntry { var section: ItemListSectionId { get } } public extension ItemListNodeEntry { var anyId: AnyHashable { return self.stableId } func isLessThan(_ rhs: ItemListNodeAnyEntry) -> Bool { return self < (rhs as! Self) } func isEqual(_ rhs: ItemListNodeAnyEntry) -> Bool { return self == (rhs as! Self) } } public extension ItemListNodeEntry { var tag: ItemListItemTag? { return nil } } private struct ItemListNodeEntryTransition { let deletions: [ListViewDeleteItem] let insertions: [ListViewInsertItem] let updates: [ListViewUpdateItem] } private func preparedItemListNodeEntryTransition(from fromEntries: [ItemListNodeAnyEntry], to toEntries: [ItemListNodeAnyEntry], presentationData: ItemListPresentationData, arguments: Any, presentationDataUpdated: Bool) -> ItemListNodeEntryTransition { let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries, isLess: { lhs, rhs in return lhs.isLessThan(rhs) }, isEqual: { lhs, rhs in return lhs.isEqual(rhs) }, getId: { value in return value.anyId }, allUpdated: presentationDataUpdated) let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(presentationData: presentationData, arguments: arguments), directionHint: nil) } let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(presentationData: presentationData, arguments: arguments), directionHint: nil) } return ItemListNodeEntryTransition(deletions: deletions, insertions: insertions, updates: updates) } public enum ItemListStyle { case plain case blocks } open class ItemListToolbarItem { public struct Action { public let title: String public let isEnabled: Bool public let action: () -> Void public init(title: String, isEnabled: Bool, action: @escaping () -> Void) { self.title = title self.isEnabled = isEnabled self.action = action } } let actions: [Action] public init(actions: [Action]) { self.actions = actions } open func isEqual(to: ItemListToolbarItem) -> Bool { return false } var toolbar: Toolbar { var leftAction: ToolbarAction? var middleAction: ToolbarAction? var rightAction: ToolbarAction? if self.actions.count == 1 { if let action = self.actions.first { middleAction = ToolbarAction(title: action.title, isEnabled: action.isEnabled) } } else if self.actions.count == 2 { if let action = self.actions.first { leftAction = ToolbarAction(title: action.title, isEnabled: action.isEnabled) } if let action = self.actions.last { rightAction = ToolbarAction(title: action.title, isEnabled: action.isEnabled) } } else if self.actions.count == 3 { leftAction = ToolbarAction(title: self.actions[0].title, isEnabled: self.actions[0].isEnabled) middleAction = ToolbarAction(title: self.actions[1].title, isEnabled: self.actions[1].isEnabled) rightAction = ToolbarAction(title: self.actions[2].title, isEnabled: self.actions[2].isEnabled) } return Toolbar(leftAction: leftAction, rightAction: rightAction, middleAction: middleAction) } } private struct ItemListNodeTransition { let theme: PresentationTheme let strings: PresentationStrings let entries: ItemListNodeEntryTransition let updateStyle: ItemListStyle? let emptyStateItem: ItemListControllerEmptyStateItem? let searchItem: ItemListControllerSearch? let toolbarItem: ItemListToolbarItem? let headerItem: ItemListControllerHeaderItem? let footerItem: ItemListControllerFooterItem? let focusItemTag: ItemListItemTag? let ensureVisibleItemTag: ItemListItemTag? let scrollToItem: ListViewScrollToItem? let firstTime: Bool let animated: Bool let animateAlpha: Bool let crossfade: Bool let mergedEntries: [ItemListNodeAnyEntry] let scrollEnabled: Bool } public final class ItemListNodeState { let presentationData: ItemListPresentationData let entries: [ItemListNodeAnyEntry] let style: ItemListStyle let emptyStateItem: ItemListControllerEmptyStateItem? let searchItem: ItemListControllerSearch? let toolbarItem: ItemListToolbarItem? let headerItem: ItemListControllerHeaderItem? let footerItem: ItemListControllerFooterItem? let animateChanges: Bool let crossfadeState: Bool let scrollEnabled: Bool let focusItemTag: ItemListItemTag? let ensureVisibleItemTag: ItemListItemTag? let initialScrollToItem: ListViewScrollToItem? public init(presentationData: ItemListPresentationData, entries: [T], style: ItemListStyle, focusItemTag: ItemListItemTag? = nil, ensureVisibleItemTag: ItemListItemTag? = nil, emptyStateItem: ItemListControllerEmptyStateItem? = nil, searchItem: ItemListControllerSearch? = nil, toolbarItem: ItemListToolbarItem? = nil, headerItem: ItemListControllerHeaderItem? = nil, footerItem: ItemListControllerFooterItem? = nil, initialScrollToItem: ListViewScrollToItem? = nil, crossfadeState: Bool = false, animateChanges: Bool = true, scrollEnabled: Bool = true) { self.presentationData = presentationData self.entries = entries.map { $0 } self.style = style self.emptyStateItem = emptyStateItem self.searchItem = searchItem self.toolbarItem = toolbarItem self.headerItem = headerItem self.footerItem = footerItem self.crossfadeState = crossfadeState self.animateChanges = animateChanges self.focusItemTag = focusItemTag self.ensureVisibleItemTag = ensureVisibleItemTag self.initialScrollToItem = initialScrollToItem self.scrollEnabled = scrollEnabled } } private final class ItemListNodeOpaqueState { let mergedEntries: [ItemListNodeAnyEntry] init(mergedEntries: [ItemListNodeAnyEntry]) { self.mergedEntries = mergedEntries } } public final class ItemListNodeVisibleEntries: Sequence { let iterate: () -> ItemListNodeAnyEntry? init(iterate: @escaping () -> ItemListNodeAnyEntry?) { self.iterate = iterate } public func makeIterator() -> AnyIterator { return AnyIterator { () -> ItemListNodeAnyEntry? in return self.iterate() } } } public final class ItemListControllerNodeView: UITracingLayerView { var onLayout: (() -> Void)? init(controller: ItemListController?) { self.controller = controller super.init(frame: CGRect()) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override public func layoutSubviews() { super.layoutSubviews() self.onLayout?() } private var inHitTest = false var hitTestImpl: ((CGPoint, UIEvent?) -> UIView?)? override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if self.inHitTest { return super.hitTest(point, with: event) } else { self.inHitTest = true let result = self.hitTestImpl?(point, event) self.inHitTest = false return result } } weak var controller: ItemListController? } open class ItemListControllerNode: ASDisplayNode, ASGestureRecognizerDelegate { private weak var controller: ItemListController? private var _ready = ValuePromise() open var ready: Signal { return self._ready.get() } private var didSetReady = false private let navigationBar: NavigationBar private let listNodeContainer: ASDisplayNode public let listNode: ListView private let leftOverlayNode: ASDisplayNode private let rightOverlayNode: ASDisplayNode private var emptyStateItem: ItemListControllerEmptyStateItem? private var emptyStateNode: ItemListControllerEmptyStateItemNode? private var toolbarNode: ToolbarNode? private var searchItem: ItemListControllerSearch? private var searchNode: ItemListControllerSearchNode? private var toolbarItem: ItemListToolbarItem? private var headerItem: ItemListControllerHeaderItem? private var headerItemNode: ItemListControllerHeaderItemNode? private var footerItem: ItemListControllerFooterItem? private var footerItemNode: ItemListControllerFooterItemNode? private let transitionDisposable = MetaDisposable() private var enqueuedTransitions: [ItemListNodeTransition] = [] private var validLayout: (ContainerViewLayout, CGFloat, UIEdgeInsets)? private var theme: PresentationTheme? private var listStyle: ItemListStyle? private var appliedFocusItemTag: ItemListItemTag? private var appliedEnsureVisibleItemTag: ItemListItemTag? private(set) var panRecognizer: InteractiveTransitionGestureRecognizer? private var afterLayoutActions: [() -> Void] = [] public var dismiss: (() -> Void)? public var visibleEntriesUpdated: ((ItemListNodeVisibleEntries) -> Void)? public var visibleBottomContentOffsetChanged: ((ListViewVisibleContentOffset) -> Void)? public var beganInteractiveDragging: (() -> Void)? public var contentOffsetChanged: ((ListViewVisibleContentOffset, Bool) -> Void)? public var contentScrollingEnded: ((ListView) -> Bool)? public var searchActivated: ((Bool) -> Void)? public var reorderEntry: ((Int, Int, [ItemListNodeAnyEntry]) -> Signal)? public var reorderCompleted: (([ItemListNodeAnyEntry]) -> Void)? public var afterTransactionCompleted: (() -> Void)? public var requestLayout: ((ContainedViewLayoutTransition) -> Void)? public var enableInteractiveDismiss = false { didSet { } } var alwaysSynchronous = false private var previousContentOffset: ListViewVisibleContentOffset? public init(controller: ItemListController?, navigationBar: NavigationBar, state: Signal<(ItemListPresentationData, (ItemListNodeState, Any)), NoError>) { self.controller = controller self.navigationBar = navigationBar self.listNodeContainer = ASDisplayNode() self.listNode = ListView() self.leftOverlayNode = ASDisplayNode() self.leftOverlayNode.isUserInteractionEnabled = false self.rightOverlayNode = ASDisplayNode() self.rightOverlayNode.isUserInteractionEnabled = false super.init() self.setViewBlock({ [weak controller] in return ItemListControllerNodeView(controller: controller) }) self.backgroundColor = nil self.isOpaque = false self.addSubnode(self.listNodeContainer) self.listNodeContainer.addSubnode(self.listNode) 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: ItemListNodeAnyEntry? if index <= indexRange.1 { item = mergedEntries[index] } index += 1 return item }) visibleEntriesUpdated(iterator) } } } self.listNode.reorderItem = { [weak self] fromIndex, toIndex, opaqueTransactionState in if let strongSelf = self, let reorderEntry = strongSelf.reorderEntry, let mergedEntries = (opaqueTransactionState as? ItemListNodeOpaqueState)?.mergedEntries { if fromIndex >= 0 && fromIndex < mergedEntries.count && toIndex >= 0 && toIndex < mergedEntries.count { return reorderEntry(fromIndex, toIndex, mergedEntries) } } return .single(false) } self.listNode.reorderCompleted = { [weak self] opaqueTransactionState in if let strongSelf = self, let reorderCompleted = strongSelf.reorderCompleted, let mergedEntries = (opaqueTransactionState as? ItemListNodeOpaqueState)?.mergedEntries { reorderCompleted(mergedEntries) } } self.listNode.visibleBottomContentOffsetChanged = { [weak self] offset in self?.visibleBottomContentOffsetChanged?(offset) if let strongSelf = self { strongSelf.updateFooterBackgroundAlpha() } } self.listNode.visibleContentOffsetChanged = { [weak self] offset in guard let strongSelf = self else { return } var inVoiceOver = false if let validLayout = self?.validLayout { inVoiceOver = validLayout.0.inVoiceOver } strongSelf.contentOffsetChanged?(offset, inVoiceOver) if let navigationContentNode = strongSelf.navigationBar.contentNode, case .expansion = navigationContentNode.mode { strongSelf.navigationBar.updateBackgroundAlpha(1.0, transition: .immediate) } else { var previousContentOffsetValue: CGFloat? if let previousContentOffset = strongSelf.previousContentOffset { if case let .known(value) = previousContentOffset { previousContentOffsetValue = value } else { previousContentOffsetValue = 30.0 } } switch offset { case let .known(value): let transition: ContainedViewLayoutTransition if let previousContentOffsetValue = previousContentOffsetValue, value <= 0.0, previousContentOffsetValue >= 30.0 { transition = .animated(duration: 0.2, curve: .easeInOut) } else { transition = .immediate } if let headerItemNode = strongSelf.headerItemNode { headerItemNode.updateContentOffset(value, transition: transition) strongSelf.navigationBar.updateBackgroundAlpha(0.0, transition: .immediate) } else { strongSelf.navigationBar.updateBackgroundAlpha(min(30.0, value) / 30.0, transition: transition) } case .unknown, .none: if let headerItemNode = strongSelf.headerItemNode { headerItemNode.updateContentOffset(1000.0, transition: .immediate) strongSelf.navigationBar.updateBackgroundAlpha(0.0, transition: .immediate) } else { strongSelf.navigationBar.updateBackgroundAlpha(1.0, transition: .immediate) } } } strongSelf.previousContentOffset = offset } self.listNode.beganInteractiveDragging = { [weak self] _ in if let strongSelf = self { strongSelf.beganInteractiveDragging?() } } self.listNode.didEndScrolling = { [weak self] _ in if let strongSelf = self { let _ = strongSelf.contentScrollingEnded?(strongSelf.listNode) } } self.listNode.itemNodeHitTest = { [weak self] point in if let strongSelf = self { return point.x > strongSelf.leftOverlayNode.frame.maxX && point.x < strongSelf.rightOverlayNode.frame.minX } else { return true } } let previousState = Atomic(value: nil) self.transitionDisposable.set(((state |> map { presentationData, stateAndArguments -> ItemListNodeTransition in let (state, arguments) = stateAndArguments if state.entries.count > 1 { for i in 1 ..< state.entries.count { assert(state.entries[i - 1].isLessThan(state.entries[i])) } } let previous = previousState.swap(state) let transition = preparedItemListNodeEntryTransition(from: previous?.entries ?? [], to: state.entries, presentationData: presentationData, arguments: arguments, presentationDataUpdated: previous?.presentationData != presentationData) var updatedStyle: ItemListStyle? if previous?.style != state.style { updatedStyle = state.style } var scrollToItem: ListViewScrollToItem? if previous == nil { scrollToItem = state.initialScrollToItem } return ItemListNodeTransition(theme: presentationData.theme, strings: presentationData.strings, entries: transition, updateStyle: updatedStyle, emptyStateItem: state.emptyStateItem, searchItem: state.searchItem, toolbarItem: state.toolbarItem, headerItem: state.headerItem, footerItem: state.footerItem, focusItemTag: state.focusItemTag, ensureVisibleItemTag: state.ensureVisibleItemTag, scrollToItem: scrollToItem, firstTime: previous == nil, animated: previous != nil && state.animateChanges, animateAlpha: previous != nil && state.animateChanges, crossfade: state.crossfadeState, mergedEntries: state.entries, scrollEnabled: state.scrollEnabled) }) |> deliverOnMainQueue).start(next: { [weak self] transition in if let strongSelf = self { strongSelf.enqueueTransition(transition) } })) } deinit { self.transitionDisposable.dispose() } override open func didLoad() { super.didLoad() self.navigationBar.updateBackgroundAlpha(0.0, transition: .immediate) (self.view as? ItemListControllerNodeView)?.onLayout = { [weak self] in guard let strongSelf = self else { return } if !strongSelf.afterLayoutActions.isEmpty { let afterLayoutActions = strongSelf.afterLayoutActions strongSelf.afterLayoutActions = [] for f in afterLayoutActions { f() } } } (self.view as? ItemListControllerNodeView)?.hitTestImpl = { [weak self] point, event in return self?.hitTest(point, with: event) } let panRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.panGesture(_:)), allowedDirections: { [weak self] _ in guard let self, let directions = self.panGestureAllowedDirections?() else { return [] } return directions }, edgeWidth: .widthMultiplier(factor: 1.0 / 6.0, min: 22.0, max: 80.0)) panRecognizer.delegate = self.wrappedGestureRecognizerDelegate panRecognizer.delaysTouchesBegan = false panRecognizer.cancelsTouchesInView = true self.panRecognizer = panRecognizer // self.view.addGestureRecognizer(panRecognizer) } public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool { if let _ = otherGestureRecognizer as? InteractiveTransitionGestureRecognizer { return false } if let _ = otherGestureRecognizer as? UIPanGestureRecognizer { return true } return false } var panGestureAllowedDirections: (() -> InteractiveTransitionGestureRecognizerDirections)? var panTransitionFractionChanged: ((CGFloat?) -> Void)? private var panSnapshotView: UIView? private var panTransitionFraction: CGFloat = 0.0 private var panCurrentAllowedDirections: InteractiveTransitionGestureRecognizerDirections = [.leftCenter, .rightCenter] @objc private func panGesture(_ gestureRecognizer: UIPanGestureRecognizer) { let translation = gestureRecognizer.translation(in: self.view).x let velocity = gestureRecognizer.velocity(in: self.view).x switch gestureRecognizer.state { case .began, .changed: if case .began = gestureRecognizer.state { self.panCurrentAllowedDirections = self.panGestureAllowedDirections?() ?? [.leftCenter, .rightCenter] } if self.panSnapshotView == nil, let panSnapshotView = self.listNodeContainer.view.snapshotView(afterScreenUpdates: false) { self.panSnapshotView = panSnapshotView self.listNodeContainer.view.superview?.insertSubview(panSnapshotView, aboveSubview: self.listNodeContainer.view) } self.panTransitionFraction = -translation / self.view.bounds.width if !self.panCurrentAllowedDirections.contains(.leftCenter) { self.panTransitionFraction = min(0.0, self.panTransitionFraction) } if !self.panCurrentAllowedDirections.contains(.rightCenter) { self.panTransitionFraction = max(0.0, self.panTransitionFraction) } if let panSnapshotView = self.panSnapshotView { panSnapshotView.frame = panSnapshotView.bounds.offsetBy(dx: -self.panTransitionFraction * self.view.bounds.width, dy: 0.0) } var initialOffset: CGFloat = 0.0 if self.panTransitionFraction > 0.0 { initialOffset = self.view.bounds.width } else { initialOffset = -self.view.bounds.width } self.listNodeContainer.frame = CGRect(origin: CGPoint(x: initialOffset - self.view.bounds.width * self.panTransitionFraction, y: 0.0), size: self.listNodeContainer.frame.size) self.panTransitionFractionChanged?(self.panTransitionFraction) case .ended, .cancelled: if let panSnapshotView = self.panSnapshotView { self.panSnapshotView = nil var directionIsToRight: Bool? if abs(velocity) > 10.0 { if translation > 0.0 { if velocity <= 0.0 { directionIsToRight = nil } else { directionIsToRight = true } } else { if velocity >= 0.0 { directionIsToRight = nil } else { directionIsToRight = false } } } else { if abs(translation) > self.view.bounds.width / 2.0 { directionIsToRight = translation > self.view.bounds.width / 2.0 } } if !self.panCurrentAllowedDirections.contains(.rightCenter) && directionIsToRight == true { directionIsToRight = nil } if !self.panCurrentAllowedDirections.contains(.leftCenter) && directionIsToRight == false { directionIsToRight = nil } let center = CGPoint(x: self.view.bounds.width / 2.0, y: self.view.bounds.height / 2.0) let previousPosition = self.listNodeContainer.position self.listNodeContainer.position = center if let directionIsToRight { let targetPosition: CGFloat if directionIsToRight { targetPosition = self.view.bounds.width + self.view.bounds.width / 2.0 } else { targetPosition = -self.view.bounds.width + self.view.bounds.width / 2.0 } panSnapshotView.layer.animatePosition(from: panSnapshotView.center, to: CGPoint(x: targetPosition, y: self.view.bounds.height / 2.0), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in panSnapshotView.removeFromSuperview() }) self.listNodeContainer.layer.animatePosition(from: previousPosition, to: center, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) } else { let direction = center.x - panSnapshotView.center.x panSnapshotView.layer.animatePosition(from: panSnapshotView.center, to: center, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in panSnapshotView.removeFromSuperview() }) self.listNodeContainer.layer.animatePosition(from: previousPosition, to: CGPoint(x: direction > 0.0 ? self.view.bounds.width + self.view.bounds.width / 2.0 : -self.view.bounds.width + self.view.bounds.width / 2.0, y: self.view.bounds.height / 2.0), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) } } self.panTransitionFractionChanged?(nil) default: break } } open 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, completion: { _ in completion?() }) } open 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: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in if let strongSelf = self { strongSelf.dismiss?() } completion?() }) } func updateFooterBackgroundAlpha() { guard let footerItemNode = self.footerItemNode else { return } switch self.listNode.visibleBottomContentOffset() { case let .known(value): let backgroundAlpha: CGFloat = min(30.0, value) / 30.0 footerItemNode.updateBackgroundAlpha(backgroundAlpha, transition: .immediate) case .unknown, .none: footerItemNode.updateBackgroundAlpha(1.0, transition: .immediate) } } open func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition, additionalInsets: UIEdgeInsets) { var insets = layout.insets(options: [.input]) insets.top += navigationBarHeight insets.bottom = max(insets.bottom, additionalInsets.bottom) let inset = max(16.0, floor((layout.size.width - 674.0) / 2.0)) if layout.size.width >= 375.0 { insets.left += inset insets.right += inset } if self.rightOverlayNode.supernode == nil { self.listNodeContainer.insertSubnode(self.rightOverlayNode, aboveSubnode: self.listNode) } if self.leftOverlayNode.supernode == nil { self.listNodeContainer.insertSubnode(self.leftOverlayNode, aboveSubnode: self.listNode) } if let toolbarItem = self.toolbarItem { var tabBarHeight: CGFloat let bottomInset: CGFloat = insets.bottom if !layout.safeInsets.left.isZero { tabBarHeight = 34.0 + bottomInset insets.bottom += 34.0 } else { tabBarHeight = 49.0 + bottomInset insets.bottom += 49.0 } let toolbarFrame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - tabBarHeight), size: CGSize(width: layout.size.width, height: tabBarHeight)) if let toolbarNode = self.toolbarNode { transition.updateFrame(node: toolbarNode, frame: toolbarFrame) toolbarNode.updateLayout(size: toolbarFrame.size, leftInset: layout.safeInsets.left, rightInset: layout.safeInsets.right, additionalSideInsets: layout.additionalInsets, bottomInset: layout.intrinsicInsets.bottom, toolbar: toolbarItem.toolbar, transition: transition) } else if let theme = self.theme { let toolbarNode = ToolbarNode(theme: ToolbarTheme(rootControllerTheme: theme), displaySeparator: true) toolbarNode.frame = toolbarFrame toolbarNode.updateLayout(size: toolbarFrame.size, leftInset: layout.safeInsets.left, rightInset: layout.safeInsets.right, additionalSideInsets: layout.additionalInsets, bottomInset: layout.intrinsicInsets.bottom, toolbar: toolbarItem.toolbar, transition: .immediate) self.addSubnode(toolbarNode) self.toolbarNode = toolbarNode if case let .animated(duration, curve) = transition { toolbarNode.layer.animatePosition(from: CGPoint(x: 0.0, y: toolbarFrame.height), to: CGPoint(), duration: duration, mediaTimingFunction: curve.mediaTimingFunction, additive: true) } } self.toolbarNode?.left = { toolbarItem.actions[0].action() } self.toolbarNode?.right = { if toolbarItem.actions.count == 2 { toolbarItem.actions[1].action() } else if toolbarItem.actions.count == 3 { toolbarItem.actions[2].action() } } self.toolbarNode?.middle = { if toolbarItem.actions.count == 1 { toolbarItem.actions[0].action() } else if toolbarItem.actions.count == 3 { toolbarItem.actions[1].action() } } } else if let toolbarNode = self.toolbarNode { self.toolbarNode = nil if case let .animated(duration, curve) = transition { toolbarNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: toolbarNode.frame.size.height), duration: duration, mediaTimingFunction: curve.mediaTimingFunction, removeOnCompletion: false, additive: true, completion: { [weak toolbarNode] _ in toolbarNode?.removeFromSupernode() }) } else { toolbarNode.removeFromSupernode() } } if let headerItemNode = self.headerItemNode { let headerHeight = headerItemNode.updateLayout(layout: layout, transition: transition) headerItemNode.frame = CGRect(origin: .zero, size: CGSize(width: layout.size.width, height: 56.0)) if headerHeight > 300.0 { insets.top = headerHeight } else { insets.top += headerHeight } } var footerHeight: CGFloat = 0.0 if let footerItemNode = self.footerItemNode { footerHeight = footerItemNode.updateLayout(layout: layout, transition: transition) insets.bottom = max(footerHeight, insets.bottom) } self.listNodeContainer.frame = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) 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 / 2.0) let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) self.leftOverlayNode.frame = CGRect(x: 0.0, y: 0.0, width: insets.left, height: layout.size.height) self.rightOverlayNode.frame = CGRect(x: layout.size.width - insets.right, y: 0.0, width: insets.right, height: layout.size.height) if let emptyStateNode = self.emptyStateNode { emptyStateNode.updateLayout(layout: layout, navigationBarHeight: navigationBarHeight, transition: transition) } if let searchNode = self.searchNode { var layout = layout layout = layout.addedInsets(insets: additionalInsets) searchNode.updateLayout(layout: layout, navigationBarHeight: navigationBarHeight, transition: transition) } let dequeue = self.validLayout == nil self.validLayout = (layout, navigationBarHeight, additionalInsets) if dequeue { self.dequeueTransitions() } var layout = layout layout.intrinsicInsets.left = 4.0 layout.intrinsicInsets.right = 4.0 layout.intrinsicInsets.bottom = insets.bottom self.controller?.presentationContext.containerLayoutUpdated(layout, transition: transition) if !self.afterLayoutActions.isEmpty { let afterLayoutActions = self.afterLayoutActions self.afterLayoutActions = [] for f in afterLayoutActions { f() } } } 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.backgroundColor = transition.theme.list.plainBackgroundColor self.listNode.backgroundColor = transition.theme.list.plainBackgroundColor self.leftOverlayNode.backgroundColor = transition.theme.list.plainBackgroundColor self.rightOverlayNode.backgroundColor = transition.theme.list.plainBackgroundColor case .blocks: self.backgroundColor = transition.theme.list.blocksBackgroundColor self.listNode.backgroundColor = transition.theme.list.blocksBackgroundColor self.leftOverlayNode.backgroundColor = transition.theme.list.blocksBackgroundColor self.rightOverlayNode.backgroundColor = transition.theme.list.blocksBackgroundColor } } self.listNode.forEachItemHeaderNode({ itemHeaderNode in if let itemHeaderNode = itemHeaderNode as? ItemListHeaderItemNode { itemHeaderNode.updateTheme(theme: transition.theme) } }) } if let updateStyle = transition.updateStyle { self.listStyle = updateStyle if let _ = self.theme { switch updateStyle { case .plain: self.backgroundColor = transition.theme.list.plainBackgroundColor self.listNode.backgroundColor = transition.theme.list.plainBackgroundColor self.leftOverlayNode.backgroundColor = transition.theme.list.plainBackgroundColor self.rightOverlayNode.backgroundColor = transition.theme.list.plainBackgroundColor self.leftOverlayNode.isHidden = true self.rightOverlayNode.isHidden = true case .blocks: self.backgroundColor = transition.theme.list.blocksBackgroundColor self.listNode.backgroundColor = transition.theme.list.blocksBackgroundColor self.leftOverlayNode.backgroundColor = transition.theme.list.blocksBackgroundColor self.rightOverlayNode.backgroundColor = transition.theme.list.blocksBackgroundColor self.leftOverlayNode.isHidden = false self.rightOverlayNode.isHidden = false } } } var options = ListViewDeleteAndInsertOptions() if transition.firstTime { options.insert(.Synchronous) options.insert(.LowLatency) options.insert(.PreferSynchronousResourceLoading) options.insert(.PreferSynchronousDrawing) } else if transition.animated { options.insert(.AnimateInsertion) } else if transition.animateAlpha { options.insert(.PreferSynchronousResourceLoading) options.insert(.PreferSynchronousDrawing) options.insert(.AnimateAlpha) } else if transition.crossfade { options.insert(.PreferSynchronousResourceLoading) options.insert(.PreferSynchronousDrawing) options.insert(.AnimateCrossfade) } else { options.insert(.Synchronous) options.insert(.PreferSynchronousResourceLoading) options.insert(.PreferSynchronousDrawing) } if self.alwaysSynchronous { options.insert(.Synchronous) options.insert(.LowLatency) } let focusItemTag = transition.focusItemTag let ensureVisibleItemTag = transition.ensureVisibleItemTag var scrollToItem: ListViewScrollToItem? if let item = transition.scrollToItem { scrollToItem = item } else if self.listNode.experimentalSnapScrollToItem, let ensureVisibleItemTag = ensureVisibleItemTag { for i in 0 ..< transition.mergedEntries.count { if let tag = transition.mergedEntries[i].tag, tag.isEqual(to: ensureVisibleItemTag) { scrollToItem = ListViewScrollToItem(index: i, position: ListViewScrollPosition.visible, animated: true, curve: .Default(duration: nil), directionHint: .Down) } } } var updateSearchItem = false if let searchItem = self.searchItem, let updatedSearchItem = transition.searchItem { updateSearchItem = !searchItem.isEqual(to: updatedSearchItem) } else if (self.searchItem != nil) != (transition.searchItem != nil) { updateSearchItem = true } if updateSearchItem { self.searchItem = transition.searchItem if let searchItem = transition.searchItem { let updatedTitleContentNode = searchItem.titleContentNode(current: self.navigationBar.contentNode as? (NavigationBarContentNode & ItemListControllerSearchNavigationContentNode)) if updatedTitleContentNode !== self.navigationBar.contentNode { if let titleContentNode = self.navigationBar.contentNode as? ItemListControllerSearchNavigationContentNode { titleContentNode.deactivate() } updatedTitleContentNode.setQueryUpdated { [weak self] query in if let strongSelf = self { strongSelf.searchNode?.queryUpdated(query) } } self.navigationBar.setContentNode(updatedTitleContentNode, animated: true) updatedTitleContentNode.activate() } let updatedNode = searchItem.node(current: self.searchNode, titleContentNode: updatedTitleContentNode) if let searchNode = self.searchNode, updatedNode !== searchNode { searchNode.removeFromSupernode() } if self.searchNode !== updatedNode { self.searchNode = updatedNode if let validLayout = self.validLayout { updatedNode.updateLayout(layout: validLayout.0, navigationBarHeight: validLayout.1, transition: .immediate) } self.addSubnode(updatedNode) updatedNode.activate() } } else { if let searchNode = self.searchNode { self.searchNode = nil searchNode.deactivate() } if let titleContentNode = self.navigationBar.contentNode { if let titleContentNode = titleContentNode as? ItemListControllerSearchNavigationContentNode { titleContentNode.deactivate() } self.navigationBar.setContentNode(nil, animated: true) } } } self.listNode.accessibilityPageScrolledString = { row, count in return transition.strings.VoiceOver_ScrollStatus(row, count).string } var updateToolbarItem = false if let toolbarItem = self.toolbarItem, let updatedToolbarItem = transition.toolbarItem { updateToolbarItem = !toolbarItem.isEqual(to: updatedToolbarItem) } else if (self.toolbarItem != nil) != (transition.toolbarItem != nil) { updateToolbarItem = true } if updateToolbarItem { self.toolbarItem = transition.toolbarItem } var updateFooterItem = false if let footerItem = self.footerItem, let updatedFooterItem = transition.footerItem { updateFooterItem = !footerItem.isEqual(to: updatedFooterItem) } else if (self.footerItem != nil) != (transition.footerItem != nil) { updateFooterItem = true } if updateFooterItem { let hadFooter = self.footerItem != nil self.footerItem = transition.footerItem if let footerItem = transition.footerItem { let updatedNode = footerItem.node(current: self.footerItemNode) if let footerItemNode = self.footerItemNode, updatedNode !== footerItemNode { footerItemNode.removeFromSupernode() } if self.footerItemNode !== updatedNode { self.footerItemNode = updatedNode let footerHeight: CGFloat if let validLayout = self.validLayout { footerHeight = updatedNode.updateLayout(layout: validLayout.0, transition: .immediate) } else { footerHeight = 100.0 } self.addSubnode(updatedNode) if !hadFooter && !transition.firstTime { updatedNode.layer.animatePosition(from: CGPoint(x: 0.0, y: footerHeight), to: .zero, duration: 0.25, additive: true) } if !hadFooter, let (layout, navigationBarHeight, _) = self.validLayout { var insets = layout.insets(options: [.input]) insets.top += navigationBarHeight insets.bottom = footerHeight let inset = max(16.0, floor((layout.size.width - 674.0) / 2.0)) if layout.size.width >= 375.0 { insets.left += inset insets.right += inset } let (duration, curve) = listViewAnimationDurationAndCurve(transition: .immediate) self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: layout.size, insets: insets, duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) } } } else if let footerItemNode = self.footerItemNode { let footerHeight: CGFloat if let validLayout = self.validLayout { footerHeight = footerItemNode.updateLayout(layout: validLayout.0, transition: .immediate) } else { footerHeight = 100.0 } footerItemNode.layer.animatePosition(from: .zero, to: CGPoint(x: 0.0, y: footerHeight), duration: 0.25, removeOnCompletion: false, additive: true, completion: { [weak footerItemNode] _ in footerItemNode?.removeFromSupernode() }) self.footerItemNode = nil } } self.listNode.transaction(deleteIndices: transition.entries.deletions, insertIndicesAndItems: transition.entries.insertions, updateIndicesAndItems: transition.entries.updates, options: options, scrollToItem: scrollToItem, updateOpaqueState: ItemListNodeOpaqueState(mergedEntries: transition.mergedEntries), completion: { [weak self] _ in if let strongSelf = self { if !strongSelf.didSetReady { strongSelf.didSetReady = true strongSelf._ready.set(true) } var updatedFocusItemTag = false if let appliedFocusItemTag = strongSelf.appliedFocusItemTag, let focusItemTag = focusItemTag { updatedFocusItemTag = !appliedFocusItemTag.isEqual(to: focusItemTag) } else if (strongSelf.appliedFocusItemTag != nil) != (focusItemTag != nil) { updatedFocusItemTag = true } if updatedFocusItemTag { if let focusItemTag = focusItemTag { strongSelf.listNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ItemListItemNode { if let itemTag = itemNode.tag { if itemTag.isEqual(to: focusItemTag) { if let focusableNode = itemNode as? ItemListItemFocusableNode { focusableNode.focus() } } } } } strongSelf.appliedFocusItemTag = focusItemTag } } var updatedEnsureVisibleItemTag = false if let appliedEnsureVisibleTag = strongSelf.appliedEnsureVisibleItemTag, let ensureVisibleItemTag = ensureVisibleItemTag { updatedEnsureVisibleItemTag = !appliedEnsureVisibleTag.isEqual(to: ensureVisibleItemTag) } else if (strongSelf.appliedEnsureVisibleItemTag != nil) != (ensureVisibleItemTag != nil) { updatedEnsureVisibleItemTag = true } if updatedEnsureVisibleItemTag { if let ensureVisibleItemTag = ensureVisibleItemTag { var applied = false strongSelf.listNode.forEachItemNode { itemNode in if let itemNode = itemNode as? ItemListItemNode { if let itemTag = itemNode.tag { if itemTag.isEqual(to: ensureVisibleItemTag) { if let itemNode = itemNode as? ListViewItemNode { strongSelf.listNode.ensureItemNodeVisible(itemNode) applied = true } } } } } if applied { strongSelf.appliedEnsureVisibleItemTag = ensureVisibleItemTag } } } strongSelf.afterTransactionCompleted?() } }) 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.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak emptyStateNode] _ in emptyStateNode?.removeFromSupernode() }) self.emptyStateNode = nil } } var updateHeaderItem = false if let headerItem = self.headerItem, let updatedHeaderItem = transition.headerItem { updateHeaderItem = !headerItem.isEqual(to: updatedHeaderItem) } else if (self.headerItem != nil) != (transition.headerItem != nil) { updateHeaderItem = true } if updateHeaderItem { self.headerItem = transition.headerItem if let headerItem = transition.headerItem { let updatedNode = headerItem.node(current: self.headerItemNode) if let headerItemNode = self.headerItemNode, updatedNode !== headerItemNode { headerItemNode.removeFromSupernode() } if self.headerItemNode !== updatedNode { self.headerItemNode = updatedNode let headerHeight: CGFloat if let validLayout = self.validLayout { headerHeight = updatedNode.updateLayout(layout: validLayout.0, transition: .immediate) } else { headerHeight = 100.0 } let _ = headerHeight self.addSubnode(updatedNode) } } else if let headerItemNode = self.headerItemNode { let headerHeight: CGFloat if let validLayout = self.validLayout { headerHeight = headerItemNode.updateLayout(layout: validLayout.0, transition: .immediate) } else { headerHeight = 100.0 } let _ = headerHeight headerItemNode.removeFromSupernode() self.headerItemNode = nil } } self.listNode.scrollEnabled = transition.scrollEnabled if updateSearchItem { self.requestLayout?(.animated(duration: 0.3, curve: .spring)) } else if updateToolbarItem || updateHeaderItem || updateFooterItem, let (layout, navigationBarHeight, additionalInsets) = self.validLayout { self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: updateHeaderItem ? .immediate : .animated(duration: 0.3, curve: .spring), additionalInsets: additionalInsets) } } } open func scrollToTop() { self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) self.searchNode?.scrollToTop() } open 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() } } override open func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if let searchNode = self.searchNode { if !self.navigationBar.isHidden && self.navigationBar.supernode != nil { if let result = self.navigationBar.hitTest(self.view.convert(point, to: self.navigationBar.view), with: event) { return result } } if let result = searchNode.hitTest(point, with: event) { return result } } return super.hitTest(point, with: event) } open func afterLayout(_ f: @escaping () -> Void) { self.afterLayoutActions.append(f) self.view.setNeedsLayout() } }