import Foundation import UIKit import AsyncDisplayKit private let titleFont = Font.bold(11.0) public final class CollectionIndexNode: ASDisplayNode { public static let searchIndex: String = "_$search$_" private var currentSize: CGSize? private var currentSections: [String] = [] private var currentColor: UIColor? private var titleNodes: [String: (node: ImmediateTextNode, size: CGSize)] = [:] private var scrollFeedback: HapticFeedback? private var currentSelectedIndex: String? public var indexSelected: ((String) -> Void)? override public init() { super.init() } override public func didLoad() { super.didLoad() self.view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))) } public func update(size: CGSize, color: UIColor, sections: [String], transition: ContainedViewLayoutTransition) { if self.currentColor == nil || !color.isEqual(self.currentColor) { self.currentColor = color for (title, nodeAndSize) in self.titleNodes { nodeAndSize.node.attributedText = NSAttributedString(string: title, font: titleFont, textColor: color) let _ = nodeAndSize.node.updateLayout(CGSize(width: 100.0, height: 100.0)) } } if self.currentSize == size && self.currentSections == sections { return } self.currentSize = size self.currentSections = sections let itemHeight: CGFloat = 15.0 let verticalInset: CGFloat = 10.0 let maxHeight = size.height - verticalInset * 2.0 let maxItemCount = min(sections.count, Int(floor(maxHeight / itemHeight))) let skipCount: Int if sections.isEmpty { skipCount = 1 } else { skipCount = Int(ceil(CGFloat(sections.count) / CGFloat(maxItemCount))) } let actualCount: CGFloat = ceil(CGFloat(sections.count) / CGFloat(skipCount)) let totalHeight = actualCount * itemHeight let verticalOrigin = verticalInset + floor((maxHeight - totalHeight) / 2.0) var validTitles = Set() var currentIndex = 0 var displayIndex = 0 var addedLastTitle = false let addTitle: (Int) -> Void = { index in let title = sections[index] let nodeAndSize: (node: ImmediateTextNode, size: CGSize) var animate = false if let current = self.titleNodes[title] { animate = true nodeAndSize = current } else { let node = ImmediateTextNode() node.attributedText = NSAttributedString(string: title, font: titleFont, textColor: color) let nodeSize = node.updateLayout(CGSize(width: 100.0, height: 100.0)) nodeAndSize = (node, nodeSize) self.addSubnode(node) self.titleNodes[title] = nodeAndSize } validTitles.insert(title) let previousPosition = nodeAndSize.node.position nodeAndSize.node.frame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - nodeAndSize.size.width) / 2.0), y: verticalOrigin + itemHeight * CGFloat(displayIndex) + floor((itemHeight - nodeAndSize.size.height) / 2.0)), size: nodeAndSize.size) if animate { transition.animatePosition(node: nodeAndSize.node, from: previousPosition) } currentIndex += skipCount displayIndex += 1 } while currentIndex < sections.count { if currentIndex == sections.count - 1 { addedLastTitle = true } addTitle(currentIndex) } if !addedLastTitle && sections.count > 0 { addTitle(sections.count - 1) } var removeTitles: [String] = [] for title in self.titleNodes.keys { if !validTitles.contains(title) { removeTitles.append(title) } } for title in removeTitles { self.titleNodes.removeValue(forKey: title)?.node.removeFromSupernode() } } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if self.isUserInteractionEnabled, self.bounds.insetBy(dx: -5.0, dy: 0.0).contains(point) { return self.view } else { return nil } } @objc private func panGesture(_ recognizer: UIPanGestureRecognizer) { var locationTitleAndPosition: (String, CGFloat)? let location = recognizer.location(in: self.view) for (title, nodeAndSize) in self.titleNodes { let nodeFrame = nodeAndSize.node.frame if location.y >= nodeFrame.minY - 5.0 && location.y <= nodeFrame.maxY + 5.0 { if let currentTitleAndPosition = locationTitleAndPosition { let distance = abs(nodeFrame.midY - location.y) let previousDistance = abs(currentTitleAndPosition.1 - location.y) if distance < previousDistance { locationTitleAndPosition = (title, nodeFrame.midY) } } else { locationTitleAndPosition = (title, nodeFrame.midY) } } } let locationTitle = locationTitleAndPosition?.0 switch recognizer.state { case .began: self.currentSelectedIndex = locationTitle if let locationTitle = locationTitle { self.indexSelected?(locationTitle) } case .changed: if locationTitle != self.currentSelectedIndex { self.currentSelectedIndex = locationTitle if let locationTitle = locationTitle { self.indexSelected?(locationTitle) if self.scrollFeedback == nil { self.scrollFeedback = HapticFeedback() } self.scrollFeedback?.tap() } } case .cancelled, .ended: self.currentSelectedIndex = nil default: break } } }