From d71707c31b1395bffe54699c02a238a9010f8d0c Mon Sep 17 00:00:00 2001 From: Peter Iakovlev Date: Tue, 11 Dec 2018 17:51:48 +0400 Subject: [PATCH] Added CollectionIndexNode --- Display.xcodeproj/project.pbxproj | 4 + Display/CollectionIndexNode.swift | 141 ++++++++++++++++++++ Display/ContainedViewLayoutTransition.swift | 16 +-- Display/GridNode.swift | 19 ++- Display/ImmediateTextNode.swift | 12 ++ Display/TextNode.swift | 12 +- 6 files changed, 191 insertions(+), 13 deletions(-) create mode 100644 Display/CollectionIndexNode.swift diff --git a/Display.xcodeproj/project.pbxproj b/Display.xcodeproj/project.pbxproj index 7a46bba81a..114bc5c179 100644 --- a/Display.xcodeproj/project.pbxproj +++ b/Display.xcodeproj/project.pbxproj @@ -76,6 +76,7 @@ D03E7DF91C96C5F200C07816 /* NSWeakReference.m in Sources */ = {isa = PBXBuildFile; fileRef = D03E7DF71C96C5F200C07816 /* NSWeakReference.m */; }; D03E7DFF1C96F7B400C07816 /* StatusBarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03E7DFE1C96F7B400C07816 /* StatusBarManager.swift */; }; D03E7E011C974AB300C07816 /* DisplayLinkDispatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = D03E7E001C974AB300C07816 /* DisplayLinkDispatcher.swift */; }; + D04554AA21BDB93E007A6DD9 /* CollectionIndexNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04554A921BDB93E007A6DD9 /* CollectionIndexNode.swift */; }; D04C468E1F4C97BE00D30FE1 /* PageControlNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04C468D1F4C97BE00D30FE1 /* PageControlNode.swift */; }; D05174B31EAA833200A1BF36 /* CASeeThroughTracingLayer.h in Headers */ = {isa = PBXBuildFile; fileRef = D05174B11EAA833200A1BF36 /* CASeeThroughTracingLayer.h */; settings = {ATTRIBUTES = (Public, ); }; }; D05174B41EAA833200A1BF36 /* CASeeThroughTracingLayer.m in Sources */ = {isa = PBXBuildFile; fileRef = D05174B21EAA833200A1BF36 /* CASeeThroughTracingLayer.m */; }; @@ -254,6 +255,7 @@ D03E7DF71C96C5F200C07816 /* NSWeakReference.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NSWeakReference.m; sourceTree = ""; }; D03E7DFE1C96F7B400C07816 /* StatusBarManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StatusBarManager.swift; sourceTree = ""; }; D03E7E001C974AB300C07816 /* DisplayLinkDispatcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DisplayLinkDispatcher.swift; sourceTree = ""; }; + D04554A921BDB93E007A6DD9 /* CollectionIndexNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionIndexNode.swift; sourceTree = ""; }; D04C468D1F4C97BE00D30FE1 /* PageControlNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageControlNode.swift; sourceTree = ""; }; D05174B11EAA833200A1BF36 /* CASeeThroughTracingLayer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CASeeThroughTracingLayer.h; sourceTree = ""; }; D05174B21EAA833200A1BF36 /* CASeeThroughTracingLayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CASeeThroughTracingLayer.m; sourceTree = ""; }; @@ -508,6 +510,7 @@ D0FA08C32048803C00DD23FC /* TextNode.swift */, D0FA08C5204880C900DD23FC /* ImmediateTextNode.swift */, D0CA3F892073F7650042D2B6 /* LinkHighlightingNode.swift */, + D04554A921BDB93E007A6DD9 /* CollectionIndexNode.swift */, ); name = Nodes; sourceTree = ""; @@ -1012,6 +1015,7 @@ D0CA3F8A2073F7650042D2B6 /* LinkHighlightingNode.swift in Sources */, D0C2DFCB1CC4431D0044FF83 /* ListViewAnimation.swift in Sources */, D0BE93191E8ED71100DCC1E6 /* NativeWindowHostView.swift in Sources */, + D04554AA21BDB93E007A6DD9 /* CollectionIndexNode.swift in Sources */, D05CC3251B695B0700E235A3 /* NavigationBarProxy.m in Sources */, D05174B41EAA833200A1BF36 /* CASeeThroughTracingLayer.m in Sources */, D03AA4D9202D8E5E0056C405 /* GlobalOverlayPresentationContext.swift in Sources */, diff --git a/Display/CollectionIndexNode.swift b/Display/CollectionIndexNode.swift new file mode 100644 index 0000000000..d00d1abd6b --- /dev/null +++ b/Display/CollectionIndexNode.swift @@ -0,0 +1,141 @@ +import Foundation +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 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(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 index = 0 + var displayIndex = 0 + while index < sections.count { + 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) + } + + index += skipCount + displayIndex += 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.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) + } + } + case .cancelled, .ended: + self.currentSelectedIndex = nil + default: + break + } + } +} diff --git a/Display/ContainedViewLayoutTransition.swift b/Display/ContainedViewLayoutTransition.swift index b277b8df4b..ab5301fb29 100644 --- a/Display/ContainedViewLayoutTransition.swift +++ b/Display/ContainedViewLayoutTransition.swift @@ -137,16 +137,16 @@ public extension ContainedViewLayoutTransition { func animatePosition(node: ASDisplayNode, from position: CGPoint, completion: ((Bool) -> Void)? = nil) { switch self { - case .immediate: - if let completion = completion { - completion(true) - } - case let .animated(duration, curve): - node.layer.animatePosition(from: position, to: node.position, duration: duration, timingFunction: curve.timingFunction, completion: { result in + case .immediate: if let completion = completion { - completion(result) + completion(true) } - }) + case let .animated(duration, curve): + node.layer.animatePosition(from: position, to: node.position, duration: duration, timingFunction: curve.timingFunction, completion: { result in + if let completion = completion { + completion(result) + } + }) } } diff --git a/Display/GridNode.swift b/Display/GridNode.swift index d4e63aa7c4..9da4dd68eb 100644 --- a/Display/GridNode.swift +++ b/Display/GridNode.swift @@ -227,6 +227,12 @@ open class GridNode: GridNodeScroller, UIScrollViewDelegate { } } + public var indicatorStyle: UIScrollViewIndicatorStyle = .default { + didSet { + self.scrollView.indicatorStyle = self.indicatorStyle + } + } + public private(set) var opaqueState: Any? public override init() { @@ -755,6 +761,9 @@ open class GridNode: GridNodeScroller, UIScrollViewDelegate { private func applyPresentaionLayoutTransition(_ presentationLayoutTransition: GridNodePresentationLayoutTransition, removedNodes: [GridItemNode], updateLayoutTransition: ContainedViewLayoutTransition?, customScrollToItem: Bool, itemTransition: ContainedViewLayoutTransition, synchronousLoads: Bool, updatingLayout: Bool, completion: (GridNodeDisplayedItemRange) -> Void) { let boundsTransition: ContainedViewLayoutTransition = updateLayoutTransition ?? .immediate + var addedNodes = false + let verticalIndicator = self.scrollView.subviews.last as? UIImageView + var previousItemFrames: [WrappedGridItemNode: CGRect]? var saveItemFrames = false switch presentationLayoutTransition.transition { @@ -782,7 +791,7 @@ open class GridNode: GridNodeScroller, UIScrollViewDelegate { previousItemFrames = itemFrames } - applyingContentOffset = true + self.applyingContentOffset = true let previousBounds = self.bounds self.scrollView.contentSize = presentationLayoutTransition.layout.contentSize @@ -819,6 +828,7 @@ open class GridNode: GridNodeScroller, UIScrollViewDelegate { let itemNode = self.items[item.index].node(layout: presentationLayoutTransition.layout.layout, synchronousLoad: synchronousLoads) itemNode.frame = item.frame self.addItemNode(index: item.index, itemNode: itemNode, lowestSectionNode: lowestSectionNode) + addedNodes = true itemNode.updateLayout(item: self.items[item.index], size: item.frame.size, isVisible: bounds.intersects(item.frame), synchronousLoads: synchronousLoads) } } @@ -845,6 +855,7 @@ open class GridNode: GridNodeScroller, UIScrollViewDelegate { let sectionNode = section.section.node() sectionNode.frame = sectionFrame self.addSectionNode(section: wrappedSection, sectionNode: sectionNode) + addedNodes = true } } @@ -1100,6 +1111,12 @@ open class GridNode: GridNodeScroller, UIScrollViewDelegate { } } + if addedNodes { + if let verticalIndicator = verticalIndicator, self.scrollView.subviews.last !== verticalIndicator { + verticalIndicator.superview?.bringSubview(toFront: verticalIndicator) + } + } + if let presentationLayoutUpdated = self.presentationLayoutUpdated { presentationLayoutUpdated(GridNodeCurrentPresentationLayout(layout: presentationLayoutTransition.layout.layout, contentOffset: presentationLayoutTransition.layout.contentOffset, contentSize: presentationLayoutTransition.layout.contentSize), updateLayoutTransition ?? presentationLayoutTransition.transition) } diff --git a/Display/ImmediateTextNode.swift b/Display/ImmediateTextNode.swift index 7b21b1e79a..65094245e4 100644 --- a/Display/ImmediateTextNode.swift +++ b/Display/ImmediateTextNode.swift @@ -1,5 +1,10 @@ import Foundation +public struct ImmediateTextNodeLayoutInfo { + public let size: CGSize + public let truncated: Bool +} + public class ImmediateTextNode: TextNode { public var attributedText: NSAttributedString? public var textAlignment: NSTextAlignment = .natural @@ -31,6 +36,13 @@ public class ImmediateTextNode: TextNode { return layout.size } + public func updateLayoutInfo(_ constrainedSize: CGSize) -> ImmediateTextNodeLayoutInfo { + let makeLayout = TextNode.asyncLayout(self) + let (layout, apply) = makeLayout(TextNodeLayoutArguments(attributedString: self.attributedText, backgroundColor: nil, maximumNumberOfLines: self.maximumNumberOfLines, truncationType: self.truncationType, constrainedSize: constrainedSize, alignment: self.textAlignment, lineSpacing: self.lineSpacing, cutout: nil, insets: self.insets)) + let _ = apply() + return ImmediateTextNodeLayoutInfo(size: layout.size, truncated: layout.truncated) + } + override public func didLoad() { super.didLoad() diff --git a/Display/TextNode.swift b/Display/TextNode.swift index e55480d261..96a34bbae9 100644 --- a/Display/TextNode.swift +++ b/Display/TextNode.swift @@ -97,11 +97,12 @@ public final class TextNodeLayout: NSObject { fileprivate let cutout: TextNodeCutout? fileprivate let insets: UIEdgeInsets public let size: CGSize + public let truncated: Bool fileprivate let firstLineOffset: CGFloat fileprivate let lines: [TextNodeLine] public let hasRTL: Bool - fileprivate init(attributedString: NSAttributedString?, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, constrainedSize: CGSize, alignment: NSTextAlignment, lineSpacing: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, size: CGSize, firstLineOffset: CGFloat, lines: [TextNodeLine], backgroundColor: UIColor?) { + fileprivate init(attributedString: NSAttributedString?, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, constrainedSize: CGSize, alignment: NSTextAlignment, lineSpacing: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets, size: CGSize, truncated: Bool, firstLineOffset: CGFloat, lines: [TextNodeLine], backgroundColor: UIColor?) { self.attributedString = attributedString self.maximumNumberOfLines = maximumNumberOfLines self.truncationType = truncationType @@ -111,6 +112,7 @@ public final class TextNodeLayout: NSObject { self.cutout = cutout self.insets = insets self.size = size + self.truncated = truncated self.firstLineOffset = firstLineOffset self.lines = lines self.backgroundColor = backgroundColor @@ -327,7 +329,7 @@ public class TextNode: ASDisplayNode { var maybeTypesetter: CTTypesetter? maybeTypesetter = CTTypesetterCreateWithAttributedString(attributedString as CFAttributedString) if maybeTypesetter == nil { - return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, alignment: alignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), firstLineOffset: 0.0, lines: [], backgroundColor: backgroundColor) + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, alignment: alignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], backgroundColor: backgroundColor) } let typesetter = maybeTypesetter! @@ -364,6 +366,7 @@ public class TextNode: ASDisplayNode { let firstLineOffset = floorToScreenPixels(fontDescent) + var truncated = false var first = true while true { var lineConstrainedWidth = constrainedSize.width @@ -419,6 +422,7 @@ public class TextNode: ASDisplayNode { let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString) coreTextLine = CTLineCreateTruncatedLine(originalLine, Double(constrainedSize.width), truncationType, truncationToken) ?? truncationToken + truncated = true } let lineWidth = min(constrainedSize.width, ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine)))) @@ -485,9 +489,9 @@ public class TextNode: ASDisplayNode { } } - return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, alignment: alignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(width: ceil(layoutSize.width) + insets.left + insets.right, height: ceil(layoutSize.height) + insets.top + insets.bottom), firstLineOffset: firstLineOffset, lines: lines, backgroundColor: backgroundColor) + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, alignment: alignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(width: ceil(layoutSize.width) + insets.left + insets.right, height: ceil(layoutSize.height) + insets.top + insets.bottom), truncated: truncated, firstLineOffset: firstLineOffset, lines: lines, backgroundColor: backgroundColor) } else { - return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, alignment: alignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), firstLineOffset: 0.0, lines: [], backgroundColor: backgroundColor) + return TextNodeLayout(attributedString: attributedString, maximumNumberOfLines: maximumNumberOfLines, truncationType: truncationType, constrainedSize: constrainedSize, alignment: alignment, lineSpacing: lineSpacingFactor, cutout: cutout, insets: insets, size: CGSize(), truncated: false, firstLineOffset: 0.0, lines: [], backgroundColor: backgroundColor) } }