diff --git a/Display.xcodeproj/project.pbxproj b/Display.xcodeproj/project.pbxproj index 1c641e2b50..387e79182f 100644 --- a/Display.xcodeproj/project.pbxproj +++ b/Display.xcodeproj/project.pbxproj @@ -169,6 +169,9 @@ D0F1132F1D6F3C20008C3597 /* ContextMenuActionNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F1132E1D6F3C20008C3597 /* ContextMenuActionNode.swift */; }; D0F7AB371DCFF6F8009AD9A1 /* ListViewItemHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F7AB361DCFF6F8009AD9A1 /* ListViewItemHeader.swift */; }; D0F8C3932014FB7C00236FC5 /* ListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C2DFBE1CC4431D0044FF83 /* ListView.swift */; }; + D0FA08C220487A8600DD23FC /* HapticFeedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA08C120487A8600DD23FC /* HapticFeedback.swift */; }; + D0FA08C42048803C00DD23FC /* TextNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA08C32048803C00DD23FC /* TextNode.swift */; }; + D0FA08C6204880C900DD23FC /* ImmediateTextNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA08C5204880C900DD23FC /* ImmediateTextNode.swift */; }; D0FF9B301E7196F6000C66DB /* KeyboardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FF9B2F1E7196F6000C66DB /* KeyboardManager.swift */; }; /* End PBXBuildFile section */ @@ -337,6 +340,9 @@ D0E8175C2014ED7D00B82BBB /* CADisplayLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CADisplayLink.swift; sourceTree = ""; }; D0F1132E1D6F3C20008C3597 /* ContextMenuActionNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContextMenuActionNode.swift; sourceTree = ""; }; D0F7AB361DCFF6F8009AD9A1 /* ListViewItemHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListViewItemHeader.swift; sourceTree = ""; }; + D0FA08C120487A8600DD23FC /* HapticFeedback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HapticFeedback.swift; sourceTree = ""; }; + D0FA08C32048803C00DD23FC /* TextNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextNode.swift; sourceTree = ""; }; + D0FA08C5204880C900DD23FC /* ImmediateTextNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImmediateTextNode.swift; sourceTree = ""; }; D0FF9B2F1E7196F6000C66DB /* KeyboardManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -468,6 +474,8 @@ D00C7CD11E3657570080C3D5 /* TextFieldNode.swift */, D0A749941E3A9E7B00AD786E /* SwitchNode.swift */, D04C468D1F4C97BE00D30FE1 /* PageControlNode.swift */, + D0FA08C32048803C00DD23FC /* TextNode.swift */, + D0FA08C5204880C900DD23FC /* ImmediateTextNode.swift */, ); name = Nodes; sourceTree = ""; @@ -513,6 +521,7 @@ D05174B11EAA833200A1BF36 /* CASeeThroughTracingLayer.h */, D05174B21EAA833200A1BF36 /* CASeeThroughTracingLayer.m */, D01847651FFA72E000075256 /* ContainedViewLayoutTransition.swift */, + D0FA08C120487A8600DD23FC /* HapticFeedback.swift */, ); name = Utils; sourceTree = ""; @@ -950,8 +959,10 @@ buildActionMask = 2147483647; files = ( D08E903C1D2417E000533158 /* ActionSheetButtonItem.swift in Sources */, + D0FA08C220487A8600DD23FC /* HapticFeedback.swift in Sources */, D03AA5162030C5F80056C405 /* ListViewTempItemNode.swift in Sources */, D087BFB51F75181D003FD209 /* ChildWindowHostView.swift in Sources */, + D0FA08C6204880C900DD23FC /* ImmediateTextNode.swift in Sources */, D0E49C881B83A3580099E553 /* ImageCache.swift in Sources */, D0078A681C92B21400DF6D92 /* StatusBar.swift in Sources */, D05CC2F81B6955D000E235A3 /* UIViewController+Navigation.m in Sources */, @@ -1032,6 +1043,7 @@ D03AA4E9202E02070056C405 /* ListViewReorderingItemNode.swift in Sources */, D05CC2EC1B69558A00E235A3 /* RuntimeUtils.m in Sources */, D0E35A031DE473B900BC6096 /* HighlightableButton.swift in Sources */, + D0FA08C42048803C00DD23FC /* TextNode.swift in Sources */, D0CD12161CCFEB4E000DE7BC /* ScrollToTopProxyView.swift in Sources */, D0AA840E1FEBFB72005C6E91 /* ListViewFloatingHeaderNode.swift in Sources */, D03AA4EB202E02B10056C405 /* ListViewReorderingGestureRecognizer.swift in Sources */, diff --git a/Display/ContainedViewLayoutTransition.swift b/Display/ContainedViewLayoutTransition.swift index 0a8f51cd1c..a63dc7e556 100644 --- a/Display/ContainedViewLayoutTransition.swift +++ b/Display/ContainedViewLayoutTransition.swift @@ -487,6 +487,10 @@ public extension ContainedViewLayoutTransition { } func updateSublayerTransformScale(node: ASDisplayNode, scale: CGFloat, completion: ((Bool) -> Void)? = nil) { + if !node.isNodeLoaded { + node.subnodeTransform = CATransform3DMakeScale(scale, scale, 1.0) + return + } let t = node.layer.sublayerTransform let currentScale = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13)) if currentScale.isEqual(to: scale) { @@ -513,8 +517,77 @@ public extension ContainedViewLayoutTransition { } } + func updateSublayerTransformScale(node: ASDisplayNode, scale: CGPoint, completion: ((Bool) -> Void)? = nil) { + if !node.isNodeLoaded { + node.subnodeTransform = CATransform3DMakeScale(scale.x, scale.y, 1.0) + return + } + let t = node.layer.sublayerTransform + let currentScaleX = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13)) + var currentScaleY = sqrt((t.m21 * t.m21) + (t.m22 * t.m22) + (t.m23 * t.m23)) + if t.m22 < 0.0 { + currentScaleY = -currentScaleY + } + if CGPoint(x: currentScaleX, y: currentScaleY) == scale { + if let completion = completion { + completion(true) + } + return + } + + switch self { + case .immediate: + node.layer.sublayerTransform = CATransform3DMakeScale(scale.x, scale.y, 1.0) + if let completion = completion { + completion(true) + } + case let .animated(duration, curve): + node.layer.sublayerTransform = CATransform3DMakeScale(scale.x, scale.y, 1.0) + node.layer.animate(from: NSValue(caTransform3D: t), to: NSValue(caTransform3D: node.layer.sublayerTransform), keyPath: "sublayerTransform", timingFunction: curve.timingFunction, duration: duration, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: false, completion: { + result in + if let completion = completion { + completion(result) + } + }) + } + } + + func updateTransformScale(node: ASDisplayNode, scale: CGPoint, completion: ((Bool) -> Void)? = nil) { + if !node.isNodeLoaded { + node.subnodeTransform = CATransform3DMakeScale(scale.x, scale.y, 1.0) + return + } + let t = node.layer.transform + var currentScaleX = sqrt((t.m11 * t.m11) + (t.m12 * t.m12) + (t.m13 * t.m13)) + var currentScaleY = sqrt((t.m21 * t.m21) + (t.m22 * t.m22) + (t.m23 * t.m23)) + if t.m22 < 0.0 { + currentScaleY = -currentScaleY + } + if CGPoint(x: currentScaleX, y: currentScaleY) == scale { + if let completion = completion { + completion(true) + } + return + } + + switch self { + case .immediate: + node.layer.transform = CATransform3DMakeScale(scale.x, scale.y, 1.0) + if let completion = completion { + completion(true) + } + case let .animated(duration, curve): + node.layer.transform = CATransform3DMakeScale(scale.x, scale.y, 1.0) + node.layer.animate(from: NSValue(caTransform3D: t), to: NSValue(caTransform3D: node.layer.transform), keyPath: "transform", timingFunction: curve.timingFunction, duration: duration, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: false, completion: { + result in + if let completion = completion { + completion(result) + } + }) + } + } + func updateSublayerTransformOffset(layer: CALayer, offset: CGPoint, completion: ((Bool) -> Void)? = nil) { - print("update to \(offset) animated: \(self.isAnimated)") let t = layer.transform let currentOffset = CGPoint(x: t.m41, y: t.m42) if currentOffset == offset { diff --git a/Display/ContextMenuController.swift b/Display/ContextMenuController.swift index a845ca5a34..9fa6eb662d 100644 --- a/Display/ContextMenuController.swift +++ b/Display/ContextMenuController.swift @@ -2,9 +2,9 @@ import Foundation import AsyncDisplayKit public final class ContextMenuControllerPresentationArguments { - fileprivate let sourceNodeAndRect: () -> (ASDisplayNode, CGRect)? + fileprivate let sourceNodeAndRect: () -> (ASDisplayNode, CGRect, ASDisplayNode, CGRect)? - public init(sourceNodeAndRect: @escaping () -> (ASDisplayNode, CGRect)?) { + public init(sourceNodeAndRect: @escaping () -> (ASDisplayNode, CGRect, ASDisplayNode, CGRect)?) { self.sourceNodeAndRect = sourceNodeAndRect } } @@ -15,13 +15,15 @@ public final class ContextMenuController: ViewController { } private let actions: [ContextMenuAction] + private let catchTapsOutside: Bool private var layout: ContainerViewLayout? public var dismissed: (() -> Void)? - public init(actions: [ContextMenuAction]) { + public init(actions: [ContextMenuAction], catchTapsOutside: Bool = false) { self.actions = actions + self.catchTapsOutside = catchTapsOutside super.init(navigationBarTheme: nil) } @@ -33,10 +35,10 @@ public final class ContextMenuController: ViewController { open override func loadDisplayNode() { self.displayNode = ContextMenuNode(actions: self.actions, dismiss: { [weak self] in self?.dismissed?() - self?.contextMenuNode.animateOut { [weak self] in + self?.contextMenuNode.animateOut { self?.presentingViewController?.dismiss(animated: false) } - }) + }, catchTapsOutside: self.catchTapsOutside) self.displayNodeDidLoad() } @@ -46,6 +48,13 @@ public final class ContextMenuController: ViewController { self.contextMenuNode.animateIn() } + override public func dismiss(completion: (() -> Void)? = nil) { + self.dismissed?() + self.contextMenuNode.animateOut { [weak self] in + self?.presentingViewController?.dismiss(animated: false) + } + } + override open func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) @@ -57,10 +66,12 @@ public final class ContextMenuController: ViewController { } else { self.layout = layout - if let presentationArguments = self.presentationArguments as? ContextMenuControllerPresentationArguments, let (sourceNode, sourceRect) = presentationArguments.sourceNodeAndRect() { + if let presentationArguments = self.presentationArguments as? ContextMenuControllerPresentationArguments, let (sourceNode, sourceRect, containerNode, containerRect) = presentationArguments.sourceNodeAndRect() { self.contextMenuNode.sourceRect = sourceNode.view.convert(sourceRect, to: nil) + self.contextMenuNode.containerRect = containerNode.view.convert(containerRect, to: nil) } else { self.contextMenuNode.sourceRect = nil + self.contextMenuNode.containerRect = nil } self.contextMenuNode.containerLayoutUpdated(layout, transition: transition) diff --git a/Display/ContextMenuNode.swift b/Display/ContextMenuNode.swift index 21f7fadd53..a20d03af4d 100644 --- a/Display/ContextMenuNode.swift +++ b/Display/ContextMenuNode.swift @@ -138,13 +138,16 @@ final class ContextMenuNode: ASDisplayNode { private let actionNodes: [ContextMenuActionNode] var sourceRect: CGRect? + var containerRect: CGRect? var arrowOnBottom: Bool = true private var dismissedByTouchOutside = false + private let catchTapsOutside: Bool - init(actions: [ContextMenuAction], dismiss: @escaping () -> Void) { + init(actions: [ContextMenuAction], dismiss: @escaping () -> Void, catchTapsOutside: Bool) { self.actions = actions self.dismiss = dismiss + self.catchTapsOutside = catchTapsOutside self.containerNode = ContextMenuContainerNode() self.scrollNode = ContextMenuContentScrollNode() @@ -183,15 +186,16 @@ final class ContextMenuNode: ASDisplayNode { let actionsWidth = min(unboundActionsWidth, maxActionsWidth) let sourceRect: CGRect = self.sourceRect ?? CGRect(origin: CGPoint(x: layout.size.width / 2.0, y: layout.size.height / 2.0), size: CGSize()) + let containerRect: CGRect = self.containerRect ?? self.bounds let insets = layout.insets(options: [.statusBar, .input]) let verticalOrigin: CGFloat var arrowOnBottom = true - if sourceRect.minY - 54.0 > insets.top { + if sourceRect.minY - 54.0 > containerRect.minY + insets.top { verticalOrigin = sourceRect.minY - 54.0 } else { - verticalOrigin = min(layout.size.height - insets.bottom - 54.0, sourceRect.maxY) + verticalOrigin = min(containerRect.maxY - insets.bottom - 54.0, sourceRect.maxY) arrowOnBottom = false } self.arrowOnBottom = arrowOnBottom @@ -235,6 +239,9 @@ final class ContextMenuNode: ASDisplayNode { self.dismissedByTouchOutside = true self.dismiss() } + if self.catchTapsOutside { + return self.view + } return nil } } diff --git a/Display/HapticFeedback.swift b/Display/HapticFeedback.swift new file mode 100644 index 0000000000..8255642ee8 --- /dev/null +++ b/Display/HapticFeedback.swift @@ -0,0 +1,111 @@ +import Foundation +import UIKit + +@available(iOSApplicationExtension 10.0, *) +private final class HapticFeedbackImpl { + private lazy var impactGenerator = { UIImpactFeedbackGenerator(style: .medium) }() + private lazy var selectionGenerator = { UISelectionFeedbackGenerator() }() + private lazy var notificationGenerator = { UINotificationFeedbackGenerator() }() + + func prepareTap() { + self.selectionGenerator.prepare() + } + + func tap() { + self.selectionGenerator.selectionChanged() + } + + func prepareImpact() { + self.impactGenerator.prepare() + } + + func impact() { + self.impactGenerator.impactOccurred() + } + + func success() { + self.notificationGenerator.notificationOccurred(.success) + } + + func error() { + self.notificationGenerator.notificationOccurred(.error) + } + + @objc dynamic func f() { + } +} + +public final class HapticFeedback { + private var impl: AnyObject? + + public init() { + } + + deinit { + let impl = self.impl + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.0, execute: { + if #available(iOSApplicationExtension 10.0, *) { + if let impl = impl as? HapticFeedbackImpl { + impl.f() + } + } + }) + } + + @available(iOSApplicationExtension 10.0, *) + private func withImpl(_ f: (HapticFeedbackImpl) -> Void) { + if self.impl == nil { + self.impl = HapticFeedbackImpl() + } + f(self.impl as! HapticFeedbackImpl) + } + + public func prepareTap() { + if #available(iOSApplicationExtension 10.0, *) { + self.withImpl { impl in + impl.prepareTap() + } + } + } + + public func tap() { + if #available(iOSApplicationExtension 10.0, *) { + self.withImpl { impl in + impl.tap() + } + } + } + + public func prepareImpact() { + if #available(iOSApplicationExtension 10.0, *) { + self.withImpl { impl in + impl.prepareImpact() + } + } + } + + public func impact() { + if #available(iOSApplicationExtension 10.0, *) { + self.withImpl { impl in + impl.impact() + } + } + } + + public func success() { + if #available(iOSApplicationExtension 10.0, *) { + self.withImpl { impl in + impl.success() + } + } + } + + public func error() { + if #available(iOSApplicationExtension 10.0, *) { + self.withImpl { impl in + impl.error() + } + } + } +} + diff --git a/Display/ImmediateTextNode.swift b/Display/ImmediateTextNode.swift new file mode 100644 index 0000000000..828191286f --- /dev/null +++ b/Display/ImmediateTextNode.swift @@ -0,0 +1,13 @@ +import Foundation + +public final class ImmediateTextNode: TextNode { + public var attributedText: NSAttributedString? + public var textAlignment: NSTextAlignment = .natural + + public func updateLayout(_ constrainedSize: CGSize) -> CGSize { + let makeLayout = TextNode.asyncLayout(self) + let (layout, apply) = makeLayout(TextNodeLayoutArguments(attributedString: self.attributedText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: constrainedSize, alignment: self.textAlignment, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets())) + let _ = apply() + return layout.size + } +} diff --git a/Display/ListView.swift b/Display/ListView.swift index 4ede1d78bd..1218fcc63c 100644 --- a/Display/ListView.swift +++ b/Display/ListView.swift @@ -113,6 +113,7 @@ open class ListView: ASDisplayNode, UIScrollViewDelegate, UIGestureRecognizerDel private final let scroller: ListViewScroller private final var visibleSize: CGSize = CGSize() public private(set) final var insets = UIEdgeInsets() + private final var ensureTopInsetForOverlayHighlightedItems: CGFloat? private final var lastContentOffset: CGPoint = CGPoint() private final var lastContentOffsetTimestamp: CFAbsoluteTime = 0.0 private final var ignoreScrollingEvents: Bool = false @@ -163,6 +164,8 @@ open class ListView: ASDisplayNode, UIScrollViewDelegate, UIGestureRecognizerDel private var topItemOverscrollBackground: ListViewOverscrollBackgroundNode? private var bottomItemOverscrollBackground: ASDisplayNode? + private var itemHighlightOverlayBackground: ASDisplayNode? + private var touchesPosition = CGPoint() public private(set) var isTracking = false public private(set) var trackingOffset: CGFloat = 0.0 @@ -369,7 +372,7 @@ open class ListView: ASDisplayNode, UIScrollViewDelegate, UIGestureRecognizerDel if let reorderNode = self.reorderNode { self.reorderNode = nil if let itemNode = reorderNode.itemNode, itemNode.supernode == self { - self.view.bringSubview(toFront: itemNode.view) + self.reorderItemNodeToFront(itemNode) reorderNode.animateCompletion(completion: { [weak itemNode, weak reorderNode] in //itemNode?.isHidden = false reorderNode?.removeFromSupernode() @@ -944,8 +947,47 @@ open class ListView: ASDisplayNode, UIScrollViewDelegate, UIGestureRecognizerDel } } + private func updateOverlayHighlight(transition: ContainedViewLayoutTransition) { + var lowestOverlayNode: ListViewItemNode? + + for itemNode in self.itemNodes { + if itemNode.isHighligtedInOverlay { + lowestOverlayNode = itemNode + itemNode.view.superview?.bringSubview(toFront: itemNode.view) + } + } + + if let lowestOverlayNode = lowestOverlayNode { + let itemHighlightOverlayBackground: ASDisplayNode + if let current = self.itemHighlightOverlayBackground { + itemHighlightOverlayBackground = current + } else { + itemHighlightOverlayBackground = ASDisplayNode() + itemHighlightOverlayBackground.frame = CGRect(origin: CGPoint(x: 0.0, y: -self.visibleSize.height), size: CGSize(width: self.visibleSize.width, height: self.visibleSize.height * 3.0)) + itemHighlightOverlayBackground.backgroundColor = UIColor(white: 0.0, alpha: 0.5) + self.itemHighlightOverlayBackground = itemHighlightOverlayBackground + self.insertSubnode(itemHighlightOverlayBackground, belowSubnode: lowestOverlayNode) + itemHighlightOverlayBackground.alpha = 0.0 + transition.updateAlpha(node: itemHighlightOverlayBackground, alpha: 1.0) + } + } else if let itemHighlightOverlayBackground = self.itemHighlightOverlayBackground { + self.itemHighlightOverlayBackground = nil + transition.updateAlpha(node: itemHighlightOverlayBackground, alpha: 0.0, completion: { [weak itemHighlightOverlayBackground] _ in + itemHighlightOverlayBackground?.removeFromSupernode() + }) + } + + /*if let ensureInset = self.ensureTopInsetForOverlayHighlightedItems { + transition.updateSublayerTransformOffset(layer: self.layer, offset: CGPoint(x: 0.0, y: -ensureInset)) + } else { + transition.updateSublayerTransformOffset(layer: self.layer, offset: CGPoint()) + }*/ + } + private func updateScroller(transition: ContainedViewLayoutTransition) { - if itemNodes.count == 0 { + self.updateOverlayHighlight(transition: transition) + + if self.itemNodes.count == 0 { return } @@ -1116,6 +1158,7 @@ open class ListView: ASDisplayNode, UIScrollViewDelegate, UIGestureRecognizerDel if let updateSizeAndInsets = updateSizeAndInsets , (self.items.count == 0 || (updateSizeAndInsets.size == self.visibleSize && updateSizeAndInsets.insets == self.insets)) { self.visibleSize = updateSizeAndInsets.size self.insets = updateSizeAndInsets.insets + self.ensureTopInsetForOverlayHighlightedItems = updateSizeAndInsets.ensureTopInsetForOverlayHighlightedItems let wasIgnoringScrollingEvents = self.ignoreScrollingEvents self.ignoreScrollingEvents = true @@ -1988,19 +2031,23 @@ open class ListView: ASDisplayNode, UIScrollViewDelegate, UIGestureRecognizerDel node.addInsetsAnimationToValue(updatedInsets, duration: insertionAnimationDuration * UIView.animationDurationFactor(), beginAt: timestamp) } - if abs(updatedApparentHeight - previousApparentHeight) > CGFloat.ulpOfOne { - node.apparentHeight = previousApparentHeight - node.animateFrameTransition(0.0, previousApparentHeight) - node.addApparentHeightAnimation(updatedApparentHeight, duration: insertionAnimationDuration * UIView.animationDurationFactor(), beginAt: timestamp, update: { [weak node] progress, currentValue in - if let node = node { - node.animateFrameTransition(progress, currentValue) + if !abs(updatedApparentHeight - previousApparentHeight).isZero { + let currentAnimation = node.animationForKey("apparentHeight") + if let currentAnimation = currentAnimation, let toFloat = currentAnimation.to as? CGFloat, toFloat.isEqual(to: updatedApparentHeight) { + } else { + node.apparentHeight = previousApparentHeight + node.animateFrameTransition(0.0, previousApparentHeight) + node.addApparentHeightAnimation(updatedApparentHeight, duration: insertionAnimationDuration * UIView.animationDurationFactor(), beginAt: timestamp, update: { [weak node] progress, currentValue in + if let node = node { + node.animateFrameTransition(progress, currentValue) + } + }) + + if node.rotated && currentAnimation == nil { + let insetPart: CGFloat = previousInsets.bottom - layout.insets.bottom + node.transitionOffset += previousApparentHeight - layout.size.height - insetPart + node.addTransitionOffsetAnimation(0.0, duration: insertionAnimationDuration * UIView.animationDurationFactor(), beginAt: timestamp) } - }) - - if node.rotated { - let insetPart: CGFloat = previousInsets.bottom - layout.insets.bottom - node.transitionOffset += previousApparentHeight - layout.size.height - insetPart - node.addTransitionOffsetAnimation(0.0, duration: insertionAnimationDuration * UIView.animationDurationFactor(), beginAt: timestamp) } } else { if node.shouldAnimateHorizontalFrameTransition() { @@ -2141,6 +2188,7 @@ open class ListView: ASDisplayNode, UIScrollViewDelegate, UIGestureRecognizerDel offsetFix += additionalScrollDistance self.insets = updateSizeAndInsets.insets + self.ensureTopInsetForOverlayHighlightedItems = updateSizeAndInsets.ensureTopInsetForOverlayHighlightedItems self.visibleSize = updateSizeAndInsets.size for itemNode in self.itemNodes { @@ -2865,7 +2913,7 @@ open class ListView: ASDisplayNode, UIScrollViewDelegate, UIGestureRecognizerDel if let index = self.itemNodes[i].index { let frame = self.itemNodes[i].apparentFrame if frame.maxY >= self.insets.top && frame.minY < self.visibleSize.height + self.insets.bottom { - firstVisibleIndex = (index, frame.minY >= self.insets.top) + firstVisibleIndex = (index, frame.minY >= self.insets.top - 10.0) break } } @@ -3060,9 +3108,9 @@ open class ListView: ASDisplayNode, UIScrollViewDelegate, UIGestureRecognizerDel if itemNode.index == index && itemNode.canBeSelected { if true { if !itemNode.isLayerBacked { - strongSelf.view.bringSubview(toFront: itemNode.view) + strongSelf.reorderItemNodeToFront(itemNode) for (_, headerNode) in strongSelf.itemHeaderNodes { - strongSelf.view.bringSubview(toFront: headerNode.view) + strongSelf.reorderHeaderNodeToFront(headerNode) } } let itemNodeFrame = itemNode.frame @@ -3115,6 +3163,11 @@ open class ListView: ASDisplayNode, UIScrollViewDelegate, UIGestureRecognizerDel self.highlightedItemIndex = nil } + public func updateNodeHighlightsAnimated(_ animated: Bool) { + let transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.35, curve: .spring) : .immediate + self.updateOverlayHighlight(transition: transition) + } + private func itemIndexAtPoint(_ point: CGPoint) -> Int? { for itemNode in self.itemNodes { if itemNode.apparentContentFrame.contains(point) { @@ -3187,9 +3240,9 @@ open class ListView: ASDisplayNode, UIScrollViewDelegate, UIGestureRecognizerDel if itemNode.index == index { if itemNode.canBeSelected { if !itemNode.isLayerBacked { - self.view.bringSubview(toFront: itemNode.view) + self.reorderItemNodeToFront(itemNode) for (_, headerNode) in self.itemHeaderNodes { - self.view.bringSubview(toFront: headerNode.view) + self.reorderHeaderNodeToFront(headerNode) } } let itemNodeFrame = itemNode.frame @@ -3263,4 +3316,18 @@ open class ListView: ASDisplayNode, UIScrollViewDelegate, UIGestureRecognizerDel } return true } + + private func reorderItemNodeToFront(_ itemNode: ListViewItemNode) { + itemNode.view.superview?.bringSubview(toFront: itemNode.view) + if let itemHighlightOverlayBackground = self.itemHighlightOverlayBackground { + itemHighlightOverlayBackground.view.superview?.bringSubview(toFront: itemHighlightOverlayBackground.view) + } + } + + private func reorderHeaderNodeToFront(_ headerNode: ListViewItemHeaderNode) { + headerNode.view.superview?.bringSubview(toFront: headerNode.view) + if let itemHighlightOverlayBackground = self.itemHighlightOverlayBackground { + itemHighlightOverlayBackground.view.superview?.bringSubview(toFront: itemHighlightOverlayBackground.view) + } + } } diff --git a/Display/ListViewIntermediateState.swift b/Display/ListViewIntermediateState.swift index 92412b4d6b..966fa3a1ef 100644 --- a/Display/ListViewIntermediateState.swift +++ b/Display/ListViewIntermediateState.swift @@ -133,12 +133,14 @@ public struct ListViewUpdateSizeAndInsets { public let insets: UIEdgeInsets public let duration: Double public let curve: ListViewAnimationCurve + public let ensureTopInsetForOverlayHighlightedItems: CGFloat? - public init(size: CGSize, insets: UIEdgeInsets, duration: Double, curve: ListViewAnimationCurve) { + public init(size: CGSize, insets: UIEdgeInsets, duration: Double, curve: ListViewAnimationCurve, ensureTopInsetForOverlayHighlightedItems: CGFloat? = nil) { self.size = size self.insets = insets self.duration = duration self.curve = curve + self.ensureTopInsetForOverlayHighlightedItems = ensureTopInsetForOverlayHighlightedItems } } diff --git a/Display/ListViewItemNode.swift b/Display/ListViewItemNode.swift index 1bae95320d..8260768d83 100644 --- a/Display/ListViewItemNode.swift +++ b/Display/ListViewItemNode.swift @@ -72,6 +72,8 @@ open class ListViewItemNode: ASDisplayNode { let rotated: Bool final var index: Int? + public var isHighligtedInOverlay: Bool = false + public private(set) var accessoryItemNode: ListViewAccessoryItemNode? func setAccessoryItemNode(_ accessoryItemNode: ListViewAccessoryItemNode?, leftInset: CGFloat, rightInset: CGFloat) { diff --git a/Display/NavigationBarBadge.swift b/Display/NavigationBarBadge.swift index e52e565149..eb5c6e4849 100644 --- a/Display/NavigationBarBadge.swift +++ b/Display/NavigationBarBadge.swift @@ -6,7 +6,7 @@ final class NavigationBarBadgeNode: ASDisplayNode { private var strokeColor: UIColor private var textColor: UIColor - private let textNode: ASTextNode2 + private let textNode: ASTextNode private let backgroundNode: ASImageNode private let font: UIFont = Font.regular(13.0) @@ -23,7 +23,7 @@ final class NavigationBarBadgeNode: ASDisplayNode { self.strokeColor = strokeColor self.textColor = textColor - self.textNode = ASTextNode2() + self.textNode = ASTextNode() self.textNode.isLayerBacked = true self.textNode.displaysAsynchronously = false diff --git a/Display/NavigationTitleNode.swift b/Display/NavigationTitleNode.swift index 4064f61498..5ef72daed8 100644 --- a/Display/NavigationTitleNode.swift +++ b/Display/NavigationTitleNode.swift @@ -43,7 +43,7 @@ public class NavigationTitleNode: ASDisplayNode { titleAttributes[NSAttributedStringKey.font] = UIFont.boldSystemFont(ofSize: 17.0) titleAttributes[NSAttributedStringKey.foregroundColor] = self.color let titleString = NSAttributedString(string: text as String, attributes: titleAttributes) - self.label.attributedString = titleString + self.label.attributedText = titleString self.invalidateCalculatedLayout() } diff --git a/Display/PeekController.swift b/Display/PeekController.swift index fb968c77aa..091e253a70 100644 --- a/Display/PeekController.swift +++ b/Display/PeekController.swift @@ -26,7 +26,7 @@ public final class PeekController: ViewController { private let theme: PeekControllerTheme private let content: PeekControllerContent - private let sourceNode: () -> ASDisplayNode? + var sourceNode: () -> ASDisplayNode? private var animatedIn = false diff --git a/Display/PeekControllerContent.swift b/Display/PeekControllerContent.swift index 871b3cb18e..68a6d546eb 100644 --- a/Display/PeekControllerContent.swift +++ b/Display/PeekControllerContent.swift @@ -16,6 +16,8 @@ public protocol PeekControllerContent { func menuActivation() -> PeerkControllerMenuActivation func menuItems() -> [PeekControllerMenuItem] func node() -> PeekControllerContentNode & ASDisplayNode + + func isEqual(to: PeekControllerContent) -> Bool } public protocol PeekControllerContentNode { diff --git a/Display/PeekControllerGestureRecognizer.swift b/Display/PeekControllerGestureRecognizer.swift index 2a4d69e3ec..307d1310f1 100644 --- a/Display/PeekControllerGestureRecognizer.swift +++ b/Display/PeekControllerGestureRecognizer.swift @@ -18,20 +18,28 @@ private func traceDeceleratingScrollView(_ view: UIView, at point: CGPoint) -> B public final class PeekControllerGestureRecognizer: UIPanGestureRecognizer { private let contentAtPoint: (CGPoint) -> Signal<(ASDisplayNode, PeekControllerContent)?, NoError>? private let present: (PeekControllerContent, ASDisplayNode) -> PeekController? + private let updateContent: (PeekControllerContent?) -> Void + private let activateBySingleTap: Bool private var tapLocation: CGPoint? private var longTapTimer: SwiftSignalKit.Timer? private var pressTimer: SwiftSignalKit.Timer? private let candidateContentDisposable = MetaDisposable() - private var candidateContent: (ASDisplayNode, PeekControllerContent)? + private var candidateContent: (ASDisplayNode, PeekControllerContent)? { + didSet { + self.updateContent(self.candidateContent?.1) + } + } private var menuActivation: PeerkControllerMenuActivation? private weak var presentedController: PeekController? - public init(contentAtPoint: @escaping (CGPoint) -> Signal<(ASDisplayNode, PeekControllerContent)?, NoError>?, present: @escaping (PeekControllerContent, ASDisplayNode) -> PeekController?) { + public init(contentAtPoint: @escaping (CGPoint) -> Signal<(ASDisplayNode, PeekControllerContent)?, NoError>?, present: @escaping (PeekControllerContent, ASDisplayNode) -> PeekController?, updateContent: @escaping (PeekControllerContent?) -> Void = { _ in }, activateBySingleTap: Bool = false) { self.contentAtPoint = contentAtPoint self.present = present + self.updateContent = updateContent + self.activateBySingleTap = activateBySingleTap super.init(target: nil, action: nil) } @@ -156,17 +164,22 @@ public final class PeekControllerGestureRecognizer: UIPanGestureRecognizer { override public func touchesEnded(_ touches: Set, with event: UIEvent) { super.touchesEnded(touches, with: event) - let velocity = self.velocity(in: self.view) - - if let presentedController = self.presentedController, presentedController.isNodeLoaded { - (presentedController.displayNode as? PeekControllerNode)?.endDraggingWithVelocity(velocity.y) - self.presentedController = nil - self.menuActivation = nil + if self.activateBySingleTap, candidateContent != nil { + self.longTapTimerFired() + self.pressTimerFired() + } else { + let velocity = self.velocity(in: self.view) + + if let presentedController = self.presentedController, presentedController.isNodeLoaded { + (presentedController.displayNode as? PeekControllerNode)?.endDraggingWithVelocity(velocity.y) + self.presentedController = nil + self.menuActivation = nil + } + + self.tapLocation = nil + self.candidateContent = nil + self.state = .failed } - - self.tapLocation = nil - self.candidateContent = nil - self.state = .failed } override public func touchesCancelled(_ touches: Set, with event: UIEvent) { @@ -210,7 +223,19 @@ public final class PeekControllerGestureRecognizer: UIPanGestureRecognizer { } } } - break + + if self.pressTimer != nil { + let dX = touchLocation.x - initialTapLocation.x + let dY = touchLocation.y - initialTapLocation.y + + if dX * dX + dY * dY > 3.0 * 3.0 { + self.startPressTimer() + } + } + + if self.presentedController != nil { + self.checkCandidateContent(at: touchLocation) + } } } else { let dX = touchLocation.x - initialTapLocation.x @@ -225,4 +250,35 @@ public final class PeekControllerGestureRecognizer: UIPanGestureRecognizer { } } } + + private func checkCandidateContent(at touchLocation: CGPoint) { + if let contentSignal = self.contentAtPoint(touchLocation) { + self.candidateContentDisposable.set((contentSignal |> deliverOnMainQueue).start(next: { [weak self] result in + if let strongSelf = self { + switch strongSelf.state { + case .possible, .changed: + if let (sourceNode, content) = result, let currentContent = strongSelf.candidateContent, !currentContent.1.isEqual(to: content) { + strongSelf.tapLocation = touchLocation + strongSelf.candidateContent = (sourceNode, content) + strongSelf.menuActivation = content.menuActivation() + if let presentedController = strongSelf.presentedController, presentedController.isNodeLoaded { + presentedController.sourceNode = { + return sourceNode + } + (presentedController.displayNode as? PeekControllerNode)?.updateContent(content: content) + } else { + strongSelf.startLongTapTimer() + } + } else if strongSelf.presentedController == nil { + strongSelf.state = .failed + } + default: + break + } + } + })) + } else if self.presentedController == nil { + self.state = .failed + } + } } diff --git a/Display/PeekControllerNode.swift b/Display/PeekControllerNode.swift index 2ca82a1fad..9b16ce4cf9 100644 --- a/Display/PeekControllerNode.swift +++ b/Display/PeekControllerNode.swift @@ -13,13 +13,17 @@ final class PeekControllerNode: ViewControllerTracingNode { private var validLayout: ContainerViewLayout? private var containerOffset: CGFloat = 0.0 + private var panInitialContainerOffset: CGFloat? - private let content: PeekControllerContent - private let contentNode: PeekControllerContentNode & ASDisplayNode + private var content: PeekControllerContent + private var contentNode: PeekControllerContentNode & ASDisplayNode + private var contentNodeHasValidLayout = false - private let menuNode: PeekControllerMenuNode? + private var menuNode: PeekControllerMenuNode? private var displayingMenu = false + private var hapticFeedback: HapticFeedback? + init(theme: PeekControllerTheme, content: PeekControllerContent, requestDismiss: @escaping () -> Void) { self.theme = theme self.requestDismiss = requestDismiss @@ -43,8 +47,6 @@ final class PeekControllerNode: ViewControllerTracingNode { self.containerBackgroundNode.displaysAsynchronously = false self.containerNode = ASDisplayNode() - self.containerNode.clipsToBounds = true - self.containerNode.cornerRadius = 16.0 self.content = content self.contentNode = content.node() @@ -61,6 +63,13 @@ final class PeekControllerNode: ViewControllerTracingNode { super.init() + if content.presentation() == .freeform { + self.containerNode.isUserInteractionEnabled = false + } else { + self.containerNode.clipsToBounds = true + self.containerNode.cornerRadius = 16.0 + } + self.addSubnode(self.dimNode) self.view.addSubview(self.blurView) self.containerNode.addSubnode(self.contentNode) @@ -73,6 +82,9 @@ final class PeekControllerNode: ViewControllerTracingNode { activatedActionImpl = { [weak self] in self?.requestDismiss() } + + self.hapticFeedback = HapticFeedback() + self.hapticFeedback?.prepareTap() } deinit { @@ -96,8 +108,12 @@ final class PeekControllerNode: ViewControllerTracingNode { var menuSize: CGSize? - let contentSize = self.contentNode.updateLayout(size: maxContainerSize, transition: transition) - transition.updateFrame(node: self.contentNode, frame: CGRect(origin: CGPoint(), size: contentSize)) + let contentSize = self.contentNode.updateLayout(size: maxContainerSize, transition: self.contentNodeHasValidLayout ? transition : .immediate) + if self.contentNodeHasValidLayout { + transition.updateFrame(node: self.contentNode, frame: CGRect(origin: CGPoint(), size: contentSize)) + } else { + self.contentNode.frame = CGRect(origin: CGPoint(), size: contentSize) + } var containerFrame: CGRect switch self.content.presentation() { @@ -113,7 +129,14 @@ final class PeekControllerNode: ViewControllerTracingNode { menuSize = CGSize(width: menuWidth, height: menuHeight) if self.displayingMenu { - containerFrame.origin.y = min(containerFrame.origin.y, layout.size.height - layoutInsets.bottom - menuHeight - 14.0 * 2.0 - containerFrame.height) + let upperBound = layout.size.height - layoutInsets.bottom - menuHeight - 14.0 * 2.0 - containerFrame.height + if containerFrame.origin.y > upperBound { + var offset = upperBound - containerFrame.origin.y + let delta = abs(offset) + let factor: CGFloat = 60.0 + offset = (-((1.0 - (1.0 / (((delta) * 0.55 / (factor)) + 1.0))) * factor)) * (offset < 0.0 ? 1.0 : -1.0) + containerFrame.origin.y = upperBound - offset + } transition.updateAlpha(layer: self.blurView.layer, alpha: 1.0) } @@ -129,8 +152,16 @@ final class PeekControllerNode: ViewControllerTracingNode { menuY = layout.size.height + 14.0 } - transition.updateFrame(node: menuNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - menuSize.width) / 2.0), y: menuY), size: menuSize)) + let menuFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - menuSize.width) / 2.0), y: menuY), size: menuSize) + + if self.contentNodeHasValidLayout { + transition.updateFrame(node: menuNode, frame: menuFrame) + } else { + menuNode.frame = menuFrame + } } + + self.contentNodeHasValidLayout = true } func animateIn(from rect: CGRect) { @@ -140,6 +171,12 @@ final class PeekControllerNode: ViewControllerTracingNode { self.containerNode.layer.animateSpring(from: NSValue(cgPoint: CGPoint(x: rect.midX - self.containerNode.position.x, y: rect.midY - self.containerNode.position.y)), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: 0.4, initialVelocity: 0.0, damping: 110.0, additive: true) self.containerNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4, initialVelocity: 0.0, damping: 110.0) self.containerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + + if case .press = self.content.menuActivation() { + self.hapticFeedback?.tap() + } else { + self.hapticFeedback?.impact() + } } func animateOut(to rect: CGRect, completion: @escaping () -> Void) { @@ -162,18 +199,40 @@ final class PeekControllerNode: ViewControllerTracingNode { } @objc func panGesture(_ recognizer: UIPanGestureRecognizer) { + guard case .drag = self.content.menuActivation() else { + return + } + switch recognizer.state { + case .began: + self.panInitialContainerOffset = self.containerOffset case .changed: - break + if let panInitialContainerOffset = self.panInitialContainerOffset { + let translation = recognizer.translation(in: self.view) + var offset = panInitialContainerOffset + translation.y + if offset < 0.0 { + let delta = abs(offset) + let factor: CGFloat = 60.0 + offset = (-((1.0 - (1.0 / (((delta) * 0.55 / (factor)) + 1.0))) * factor)) * (offset < 0.0 ? 1.0 : -1.0) + } + self.applyDraggingOffset(offset) + } case .cancelled, .ended: - break + if let _ = self.panInitialContainerOffset { + self.panInitialContainerOffset = nil + if self.containerOffset < 0.0 { + self.activateMenu() + } else { + self.requestDismiss() + } + } default: break } } func applyDraggingOffset(_ offset: CGFloat) { - self.containerOffset = min(0.0, offset) + self.containerOffset = offset if self.containerOffset < -25.0 { //self.displayingMenu = true } else { @@ -185,6 +244,9 @@ final class PeekControllerNode: ViewControllerTracingNode { } func activateMenu() { + if case .press = self.content.menuActivation() { + self.hapticFeedback?.impact() + } if let layout = self.validLayout { self.displayingMenu = true self.containerOffset = 0.0 @@ -203,4 +265,47 @@ final class PeekControllerNode: ViewControllerTracingNode { self.requestDismiss() } } + + func updateContent(content: PeekControllerContent) { + let contentNode = self.contentNode + contentNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak contentNode] _ in + contentNode?.removeFromSupernode() + }) + contentNode.layer.animateScale(from: 1.0, to: 0.2, duration: 0.15, removeOnCompletion: false) + + self.menuNode?.removeFromSupernode() + self.menuNode = nil + + self.content = content + self.contentNode = content.node() + self.containerNode.addSubnode(self.contentNode) + self.contentNodeHasValidLayout = false + + var activatedActionImpl: (() -> Void)? + let menuItems = content.menuItems() + if menuItems.isEmpty { + self.menuNode = nil + } else { + self.menuNode = PeekControllerMenuNode(theme: self.theme, items: menuItems, activatedAction: { + activatedActionImpl?() + }) + } + + if let menuNode = self.menuNode { + self.addSubnode(menuNode) + } + + activatedActionImpl = { [weak self] in + self?.requestDismiss() + } + + self.contentNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) + self.contentNode.layer.animateSpring(from: 0.35 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.5) + + if let layout = self.validLayout { + self.containerLayoutUpdated(layout, transition: .animated(duration: 0.15, curve: .easeInOut)) + } + + self.hapticFeedback?.tap() + } } diff --git a/Display/TextNode.swift b/Display/TextNode.swift new file mode 100644 index 0000000000..cb6291687b --- /dev/null +++ b/Display/TextNode.swift @@ -0,0 +1,493 @@ +import Foundation +import AsyncDisplayKit + +private let defaultFont = UIFont.systemFont(ofSize: 15.0) + +private final class TextNodeLine { + let line: CTLine + let frame: CGRect + let range: NSRange + + init(line: CTLine, frame: CGRect, range: NSRange) { + self.line = line + self.frame = frame + self.range = range + } +} + +public enum TextNodeCutoutPosition { + case TopLeft + case TopRight +} + +public struct TextNodeCutout: Equatable { + public let position: TextNodeCutoutPosition + public let size: CGSize + + public init(position: TextNodeCutoutPosition, size: CGSize) { + self.position = position + self.size = size + } + + public static func ==(lhs: TextNodeCutout, rhs: TextNodeCutout) -> Bool { + return lhs.position == rhs.position && lhs.size == rhs.size + } +} + +public final class TextNodeLayoutArguments { + public let attributedString: NSAttributedString? + public let backgroundColor: UIColor? + public let maximumNumberOfLines: Int + public let truncationType: CTLineTruncationType + public let constrainedSize: CGSize + public let alignment: NSTextAlignment + public let lineSpacing: CGFloat + public let cutout: TextNodeCutout? + public let insets: UIEdgeInsets + + public init(attributedString: NSAttributedString?, backgroundColor: UIColor? = nil, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, constrainedSize: CGSize, alignment: NSTextAlignment = .natural, lineSpacing: CGFloat = 0.12, cutout: TextNodeCutout? = nil, insets: UIEdgeInsets = UIEdgeInsets()) { + self.attributedString = attributedString + self.backgroundColor = backgroundColor + self.maximumNumberOfLines = maximumNumberOfLines + self.truncationType = truncationType + self.constrainedSize = constrainedSize + self.alignment = alignment + self.lineSpacing = lineSpacing + self.cutout = cutout + self.insets = insets + } +} + +public final class TextNodeLayout: NSObject { + fileprivate let attributedString: NSAttributedString? + fileprivate let maximumNumberOfLines: Int + fileprivate let truncationType: CTLineTruncationType + fileprivate let backgroundColor: UIColor? + fileprivate let constrainedSize: CGSize + fileprivate let alignment: NSTextAlignment + fileprivate let lineSpacing: CGFloat + fileprivate let cutout: TextNodeCutout? + fileprivate let insets: UIEdgeInsets + public let size: CGSize + fileprivate let firstLineOffset: CGFloat + fileprivate let lines: [TextNodeLine] + + 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?) { + self.attributedString = attributedString + self.maximumNumberOfLines = maximumNumberOfLines + self.truncationType = truncationType + self.constrainedSize = constrainedSize + self.alignment = alignment + self.lineSpacing = lineSpacing + self.cutout = cutout + self.insets = insets + self.size = size + self.firstLineOffset = firstLineOffset + self.lines = lines + self.backgroundColor = backgroundColor + } + + public var numberOfLines: Int { + return self.lines.count + } + + public var trailingLineWidth: CGFloat { + if let lastLine = self.lines.last { + return lastLine.frame.width + } else { + return 0.0 + } + } + + public func attributesAtPoint(_ point: CGPoint) -> (Int, [NSAttributedStringKey: Any])? { + if let attributedString = self.attributedString { + let transformedPoint = CGPoint(x: point.x - self.insets.left, y: point.y - self.insets.top) + for line in self.lines { + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + self.firstLineOffset), size: line.frame.size) + switch self.alignment { + case .center: + lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0) + default: + break + } + if lineFrame.contains(transformedPoint) { + var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: transformedPoint.y - lineFrame.minY)) + if index == attributedString.length { + index -= 1 + } else if index != 0 { + var glyphStart: CGFloat = 0.0 + CTLineGetOffsetForStringIndex(line.line, index, &glyphStart) + if transformedPoint.x < glyphStart { + index -= 1 + } + } + if index >= 0 && index < attributedString.length { + return (index, attributedString.attributes(at: index, effectiveRange: nil)) + } + break + } + } + for line in self.lines { + var lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + self.firstLineOffset), size: line.frame.size) + switch self.alignment { + case .center: + lineFrame.origin.x = floor((self.size.width - lineFrame.size.width) / 2.0) + default: + break + } + if lineFrame.offsetBy(dx: 0.0, dy: -lineFrame.size.height).insetBy(dx: -3.0, dy: -3.0).contains(transformedPoint) { + var index = CTLineGetStringIndexForPosition(line.line, CGPoint(x: transformedPoint.x - lineFrame.minX, y: transformedPoint.y - lineFrame.minY)) + if index == attributedString.length { + index -= 1 + } else if index != 0 { + var glyphStart: CGFloat = 0.0 + CTLineGetOffsetForStringIndex(line.line, index, &glyphStart) + if transformedPoint.x < glyphStart { + index -= 1 + } + } + if index >= 0 && index < attributedString.length { + return (index, attributedString.attributes(at: index, effectiveRange: nil)) + } + break + } + } + } + return nil + } + + public func linesRects() -> [CGRect] { + var rects: [CGRect] = [] + for line in self.lines { + rects.append(line.frame) + } + return rects + } + + public func lineAndAttributeRects(name: String, at index: Int) -> [(CGRect, CGRect)]? { + if let attributedString = self.attributedString { + var range = NSRange() + let _ = attributedString.attribute(NSAttributedStringKey(rawValue: name), at: index, effectiveRange: &range) + if range.length != 0 { + var rects: [(CGRect, CGRect)] = [] + for line in self.lines { + let lineRange = NSIntersectionRange(range, line.range) + if lineRange.length != 0 { + var leftOffset: CGFloat = 0.0 + if lineRange.location != line.range.location { + leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil)) + } + var rightOffset: CGFloat = line.frame.width + if lineRange.location + lineRange.length != line.range.length { + rightOffset = ceil(CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, nil)) + } + let lineFrame = CGRect(origin: CGPoint(x: line.frame.origin.x, y: line.frame.origin.y - line.frame.size.height + self.firstLineOffset), size: line.frame.size) + rects.append((lineFrame, CGRect(origin: CGPoint(x: lineFrame.minX + leftOffset + self.insets.left, y: lineFrame.minY + self.insets.top), size: CGSize(width: rightOffset - leftOffset, height: lineFrame.size.height)))) + } + } + if !rects.isEmpty { + return rects + } + } + } + return nil + } +} + +public class TextNode: ASDisplayNode { + public private(set) var cachedLayout: TextNodeLayout? + + override public init() { + super.init() + + self.backgroundColor = UIColor.clear + self.isOpaque = false + self.clipsToBounds = false + } + + public func attributesAtPoint(_ point: CGPoint) -> (Int, [NSAttributedStringKey: Any])? { + if let cachedLayout = self.cachedLayout { + return cachedLayout.attributesAtPoint(point) + } else { + return nil + } + } + + public func attributeRects(name: String, at index: Int) -> [CGRect]? { + if let cachedLayout = self.cachedLayout { + return cachedLayout.lineAndAttributeRects(name: name, at: index)?.map { $0.1 } + } else { + return nil + } + } + + public func lineAndAttributeRects(name: String, at index: Int) -> [(CGRect, CGRect)]? { + if let cachedLayout = self.cachedLayout { + return cachedLayout.lineAndAttributeRects(name: name, at: index) + } else { + return nil + } + } + + private class func calculateLayout(attributedString: NSAttributedString?, maximumNumberOfLines: Int, truncationType: CTLineTruncationType, backgroundColor: UIColor?, constrainedSize: CGSize, alignment: NSTextAlignment, lineSpacingFactor: CGFloat, cutout: TextNodeCutout?, insets: UIEdgeInsets) -> TextNodeLayout { + if let attributedString = attributedString { + let stringLength = attributedString.length + + let font: CTFont + if stringLength != 0 { + if let stringFont = attributedString.attribute(NSAttributedStringKey.font, at: 0, effectiveRange: nil) { + font = stringFont as! CTFont + } else { + font = defaultFont + } + } else { + font = defaultFont + } + + let fontAscent = CTFontGetAscent(font) + let fontDescent = CTFontGetDescent(font) + let fontLineHeight = floor(fontAscent + fontDescent) + let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor) + + var lines: [TextNodeLine] = [] + + 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) + } + + let typesetter = maybeTypesetter! + + var lastLineCharacterIndex: CFIndex = 0 + var layoutSize = CGSize() + + var cutoutEnabled = false + var cutoutMinY: CGFloat = 0.0 + var cutoutMaxY: CGFloat = 0.0 + var cutoutWidth: CGFloat = 0.0 + var cutoutOffset: CGFloat = 0.0 + if let cutout = cutout { + cutoutMinY = -fontLineSpacing + cutoutMaxY = cutout.size.height + fontLineSpacing + cutoutWidth = cutout.size.width + if case .TopLeft = cutout.position { + cutoutOffset = cutoutWidth + } + cutoutEnabled = true + } + + let firstLineOffset = floorToScreenPixels(fontLineSpacing * 2.0) + + var first = true + while true { + var lineConstrainedWidth = constrainedSize.width + //var lineOriginY = floorToScreenPixels(layoutSize.height + fontLineHeight - fontLineSpacing * 2.0) + var lineOriginY = floorToScreenPixels(layoutSize.height + fontAscent) + if !first { + lineOriginY += fontLineSpacing + } + var lineCutoutOffset: CGFloat = 0.0 + var lineAdditionalWidth: CGFloat = 0.0 + + if cutoutEnabled { + if lineOriginY - fontLineHeight < cutoutMaxY && lineOriginY + fontLineHeight > cutoutMinY { + lineConstrainedWidth = max(1.0, lineConstrainedWidth - cutoutWidth) + lineCutoutOffset = cutoutOffset + lineAdditionalWidth = cutoutWidth + } + } + + let lineCharacterCount = CTTypesetterSuggestLineBreak(typesetter, lastLineCharacterIndex, Double(lineConstrainedWidth)) + + var isLastLine = false + if maximumNumberOfLines != 0 && lines.count == maximumNumberOfLines - 1 && lineCharacterCount > 0 { + isLastLine = true + } else if layoutSize.height + (fontLineSpacing + fontLineHeight) * 2.0 > constrainedSize.height { + isLastLine = true + } + + if isLastLine { + if first { + first = false + } else { + layoutSize.height += fontLineSpacing + } + + let lineRange = CFRange(location: lastLineCharacterIndex, length: stringLength - lastLineCharacterIndex) + + if lineRange.length == 0 { + break + } + + let coreTextLine: CTLine + + let originalLine = CTTypesetterCreateLineWithOffset(typesetter, lineRange, 0.0) + + if CTLineGetTypographicBounds(originalLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(originalLine) < Double(constrainedSize.width) { + coreTextLine = originalLine + } else { + var truncationTokenAttributes: [NSAttributedStringKey : AnyObject] = [:] + truncationTokenAttributes[NSAttributedStringKey.font] = font + truncationTokenAttributes[NSAttributedStringKey(rawValue: kCTForegroundColorFromContextAttributeName as String)] = true as NSNumber + let tokenString = "\u{2026}" + let truncatedTokenString = NSAttributedString(string: tokenString, attributes: truncationTokenAttributes) + let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString) + + coreTextLine = CTLineCreateTruncatedLine(originalLine, Double(constrainedSize.width), truncationType, truncationToken) ?? truncationToken + } + + let lineWidth = min(constrainedSize.width, ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine)))) + let lineFrame = CGRect(x: lineCutoutOffset, y: lineOriginY, width: lineWidth, height: fontLineHeight) + layoutSize.height += fontLineHeight + fontLineSpacing + layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth) + + lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length))) + + break + } else { + if lineCharacterCount > 0 { + if first { + first = false + } else { + layoutSize.height += fontLineSpacing + } + + let lineRange = CFRangeMake(lastLineCharacterIndex, lineCharacterCount) + let coreTextLine = CTTypesetterCreateLineWithOffset(typesetter, lineRange, 100.0) + lastLineCharacterIndex += lineCharacterCount + + let lineWidth = ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine))) + let lineFrame = CGRect(x: lineCutoutOffset, y: lineOriginY, width: lineWidth, height: fontLineHeight) + layoutSize.height += fontLineHeight + layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth) + + lines.append(TextNodeLine(line: coreTextLine, frame: lineFrame, range: NSMakeRange(lineRange.location, lineRange.length))) + } else { + if !lines.isEmpty { + layoutSize.height += fontLineSpacing + } + break + } + } + } + + 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) + } 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) + } + } + + override public func drawParameters(forAsyncLayer layer: _ASDisplayLayer) -> NSObjectProtocol? { + return self.cachedLayout + } + + @objc override public class func draw(_ bounds: CGRect, withParameters parameters: Any?, isCancelled: () -> Bool, isRasterizing: Bool) { + if isCancelled() { + return + } + + let context = UIGraphicsGetCurrentContext()! + + context.setAllowsAntialiasing(true) + + context.setAllowsFontSmoothing(false) + context.setShouldSmoothFonts(false) + + context.setAllowsFontSubpixelPositioning(false) + context.setShouldSubpixelPositionFonts(false) + + context.setAllowsFontSubpixelQuantization(true) + context.setShouldSubpixelQuantizeFonts(true) + + if let layout = parameters as? TextNodeLayout { + if !isRasterizing || layout.backgroundColor != nil { + context.setBlendMode(.copy) + context.setFillColor((layout.backgroundColor ?? UIColor.clear).cgColor) + context.fill(bounds) + } + + let textMatrix = context.textMatrix + let textPosition = context.textPosition + //CGContextSaveGState(context) + + context.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0) + + //let clipRect = CGContextGetClipBoundingBox(context) + + let alignment = layout.alignment + let offset = CGPoint(x: layout.insets.left, y: layout.insets.top) + + for i in 0 ..< layout.lines.count { + let line = layout.lines[i] + let lineOffset: CGFloat + if alignment == .center { + lineOffset = floor((bounds.size.width - line.frame.size.width) / 2.0) + } else { + lineOffset = 0.0 + } + context.textPosition = CGPoint(x: line.frame.origin.x + lineOffset + offset.x, y: line.frame.origin.y + offset.y) + CTLineDraw(line.line, context) + } + + //CGContextRestoreGState(context) + context.textMatrix = textMatrix + context.textPosition = CGPoint(x: textPosition.x, y: textPosition.y) + } + + context.setBlendMode(.normal) + } + + public static func asyncLayout(_ maybeNode: TextNode?) -> (TextNodeLayoutArguments) -> (TextNodeLayout, () -> TextNode) { + let existingLayout: TextNodeLayout? = maybeNode?.cachedLayout + + return { arguments in + let layout: TextNodeLayout + + var updated = false + if let existingLayout = existingLayout, existingLayout.constrainedSize == arguments.constrainedSize && existingLayout.maximumNumberOfLines == arguments.maximumNumberOfLines && existingLayout.truncationType == arguments.truncationType && existingLayout.cutout == arguments.cutout && existingLayout.alignment == arguments.alignment && existingLayout.lineSpacing.isEqual(to: arguments.lineSpacing) { + let stringMatch: Bool + + var colorMatch: Bool = true + if let backgroundColor = arguments.backgroundColor, let previousBackgroundColor = existingLayout.backgroundColor { + if !backgroundColor.isEqual(previousBackgroundColor) { + colorMatch = false + } + } else if (arguments.backgroundColor != nil) != (existingLayout.backgroundColor != nil) { + colorMatch = false + } + + if !colorMatch { + stringMatch = false + } else if let existingString = existingLayout.attributedString, let string = arguments.attributedString { + stringMatch = existingString.isEqual(to: string) + } else if existingLayout.attributedString == nil && arguments.attributedString == nil { + stringMatch = true + } else { + stringMatch = false + } + + if stringMatch { + layout = existingLayout + } else { + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets) + updated = true + } + } else { + layout = TextNode.calculateLayout(attributedString: arguments.attributedString, maximumNumberOfLines: arguments.maximumNumberOfLines, truncationType: arguments.truncationType, backgroundColor: arguments.backgroundColor, constrainedSize: arguments.constrainedSize, alignment: arguments.alignment, lineSpacingFactor: arguments.lineSpacing, cutout: arguments.cutout, insets: arguments.insets) + updated = true + } + + let node = maybeNode ?? TextNode() + + return (layout, { + node.cachedLayout = layout + if updated { + node.setNeedsDisplay() + } + + return node + }) + } + } +} diff --git a/Display/TooltipControllerNode.swift b/Display/TooltipControllerNode.swift index a41d943946..e753f25fa0 100644 --- a/Display/TooltipControllerNode.swift +++ b/Display/TooltipControllerNode.swift @@ -8,7 +8,7 @@ final class TooltipControllerNode: ASDisplayNode { private var validLayout: ContainerViewLayout? private let containerNode: ContextMenuContainerNode - private let textNode: ASTextNode + private let textNode: ImmediateTextNode var sourceRect: CGRect? var arrowOnBottom: Bool = true @@ -19,7 +19,7 @@ final class TooltipControllerNode: ASDisplayNode { self.containerNode = ContextMenuContainerNode() self.containerNode.backgroundColor = UIColor(white: 0.0, alpha: 0.8) - self.textNode = ASTextNode() + self.textNode = ImmediateTextNode() self.textNode.attributedText = NSAttributedString(string: text, font: Font.regular(14.0), textColor: .white, paragraphAlignment: .center) self.textNode.isLayerBacked = true self.textNode.displaysAsynchronously = false @@ -53,7 +53,7 @@ final class TooltipControllerNode: ASDisplayNode { let maxActionsWidth = layout.size.width - 20.0 - var textSize = self.textNode.measure(CGSize(width: maxActionsWidth, height: CGFloat.greatestFiniteMagnitude)) + var textSize = self.textNode.updateLayout(CGSize(width: maxActionsWidth, height: CGFloat.greatestFiniteMagnitude)) textSize.width = ceil(textSize.width / 2.0) * 2.0 textSize.height = ceil(textSize.height / 2.0) * 2.0 let contentSize = CGSize(width: textSize.width + 12.0, height: textSize.height + 34.0) diff --git a/Display/UIKitUtils.swift b/Display/UIKitUtils.swift index 9299e63fee..bcf5edde7e 100644 --- a/Display/UIKitUtils.swift +++ b/Display/UIKitUtils.swift @@ -95,6 +95,20 @@ public extension CGSize { return CGSize(width: floor(self.width * scale), height: floor(self.height * scale)) } + public func aspectFittedWithOverflow(_ size: CGSize, leeway: CGFloat) -> CGSize { + let scale = min(size.width / max(1.0, self.width), size.height / max(1.0, self.height)) + var result = CGSize(width: floor(self.width * scale), height: floor(self.height * scale)) + if result.width < size.width && result.width > size.width - leeway { + result.height += size.width - result.width + result.width = size.width + } + if result.height < size.height && result.height > size.height - leeway { + result.width += size.height - result.height + result.height = size.height + } + return result + } + public func fittedToWidthOrSmaller(_ width: CGFloat) -> CGSize { let scale = min(1.0, width / max(1.0, self.width)) return CGSize(width: floor(self.width * scale), height: floor(self.height * scale)) diff --git a/Display/UIViewController+Navigation.h b/Display/UIViewController+Navigation.h index 5926a69e3e..3d3ba93667 100644 --- a/Display/UIViewController+Navigation.h +++ b/Display/UIViewController+Navigation.h @@ -7,6 +7,7 @@ typedef NS_OPTIONS(NSUInteger, UIResponderDisableAutomaticKeyboardHandling) { @interface UIViewController (Navigation) +- (void)setHintWillBePresentedInPreviewingContext:(BOOL)value; - (BOOL)isPresentedInPreviewingContext; - (void)setIgnoreAppearanceMethodInvocations:(BOOL)ignoreAppearanceMethodInvocations; - (BOOL)ignoreAppearanceMethodInvocations; diff --git a/Display/UIViewController+Navigation.m b/Display/UIViewController+Navigation.m index 332d4fc0f1..4293086dc9 100644 --- a/Display/UIViewController+Navigation.m +++ b/Display/UIViewController+Navigation.m @@ -38,6 +38,7 @@ static const void *disablesInteractiveTransitionGestureRecognizerKey = &disables static const void *disableAutomaticKeyboardHandlingKey = &disableAutomaticKeyboardHandlingKey; static const void *setNeedsStatusBarAppearanceUpdateKey = &setNeedsStatusBarAppearanceUpdateKey; static const void *inputAccessoryHeightProviderKey = &inputAccessoryHeightProviderKey; +static const void *UIViewControllerHintWillBePresentedInPreviewingContextKey = &UIViewControllerHintWillBePresentedInPreviewingContextKey; static bool notyfyingShiftState = false; @@ -106,8 +107,16 @@ static bool notyfyingShiftState = false; }); } +- (void)setHintWillBePresentedInPreviewingContext:(BOOL)value { + [self setAssociatedObject:@(value) forKey:UIViewControllerHintWillBePresentedInPreviewingContextKey]; +} + - (BOOL)isPresentedInPreviewingContext { - return ![self.presentingViewController isKindOfClass:[UIViewControllerPresentingProxy class]]; + if ([[self associatedObjectForKey:UIViewControllerHintWillBePresentedInPreviewingContextKey] boolValue]) { + return true; + } else { + return false; + } } - (void)setIgnoreAppearanceMethodInvocations:(BOOL)ignoreAppearanceMethodInvocations diff --git a/Display/ViewControllerPreviewing.swift b/Display/ViewControllerPreviewing.swift index 137384e6dc..1ad481ae40 100644 --- a/Display/ViewControllerPreviewing.swift +++ b/Display/ViewControllerPreviewing.swift @@ -35,6 +35,14 @@ private final class ViewControllerPeekContent: PeekControllerContent { func node() -> PeekControllerContentNode & ASDisplayNode { return ViewControllerPeekContentNode(controller: self.controller) } + + func isEqual(to: PeekControllerContent) -> Bool { + if let to = to as? ViewControllerPeekContent { + return self.controller === to.controller + } else { + return false + } + } } private final class ViewControllerPeekContentNode: ASDisplayNode, PeekControllerContentNode { diff --git a/Display/WindowContent.swift b/Display/WindowContent.swift index a085e496ac..586f3820c2 100644 --- a/Display/WindowContent.swift +++ b/Display/WindowContent.swift @@ -165,15 +165,21 @@ private func containedLayoutForWindowLayout(_ layout: WindowLayout) -> Container var standardInputHeight: CGFloat = 216.0 var predictiveHeight: CGFloat = 42.0 - if layout.size.width.isEqual(to: 320.0) || layout.size.width.isEqual(to: 375.0) { + if layout.size.width.isEqual(to: 320.0) { standardInputHeight = 216.0 predictiveHeight = 42.0 + } else if layout.size.width.isEqual(to: 375.0) { + standardInputHeight = 291.0 + predictiveHeight = 42.0 } else if layout.size.width.isEqual(to: 414.0) { standardInputHeight = 226.0 predictiveHeight = 42.0 } else if layout.size.width.isEqual(to: 480.0) || layout.size.width.isEqual(to: 568.0) || layout.size.width.isEqual(to: 667.0) || layout.size.width.isEqual(to: 736.0) { standardInputHeight = 162.0 predictiveHeight = 38.0 + } else if layout.size.width.isEqual(to: 812.0) { + standardInputHeight = 171.0 + predictiveHeight = 38.0 } else if layout.size.width.isEqual(to: 768.0) || layout.size.width.isEqual(to: 1024.0) { standardInputHeight = 264.0 predictiveHeight = 42.0