import Foundation import UIKit import AsyncDisplayKit import Display import SwiftSignalKit import TelegramPresentationData import AnimatedStickerNode import TelegramAnimatedStickerNode import AppBundle import TelegramCore import TextFormat import UrlEscaping import AccountContext import AvatarNode import ComponentFlow import AvatarStoryIndicatorComponent import AccountContext import Markdown import BalancedTextComponent public enum TooltipActiveTextItem { case url(String, Bool) case mention(EnginePeer.Id, String) case textMention(String) case botCommand(String) case hashtag(String) } public enum TooltipActiveTextAction { case tap case longTap } private func generateArrowImage() -> UIImage? { return generateImage(CGSize(width: 14.0, height: 8.0), rotatedContext: { size, context in let bounds = CGRect(origin: .zero, size: size) context.clear(bounds) context.setStrokeColor(UIColor.white.cgColor) context.setLineWidth(1.0 + UIScreenPixel) context.setLineCap(.round) let arrowBounds = bounds.insetBy(dx: 1.0, dy: 1.0) context.move(to: arrowBounds.origin) context.addLine(to: CGPoint(x: arrowBounds.midX, y: arrowBounds.maxY)) context.addLine(to: CGPoint(x: arrowBounds.maxX, y: arrowBounds.minY)) context.strokePath() }) } private class DownArrowsIconNode: ASDisplayNode { private let topArrow: ASImageNode private let bottomArrow: ASImageNode override init() { self.topArrow = ASImageNode() self.topArrow.displaysAsynchronously = false self.topArrow.image = generateArrowImage() self.bottomArrow = ASImageNode() self.bottomArrow.displaysAsynchronously = false self.bottomArrow.image = self.topArrow.image super.init() self.addSubnode(self.topArrow) self.addSubnode(self.bottomArrow) if let image = self.topArrow.image { self.topArrow.frame = CGRect(origin: .zero, size: image.size) self.bottomArrow.frame = CGRect(origin: CGPoint(x: 0.0, y: 7.0), size: image.size) } } func setupAnimations() { guard self.bottomArrow.layer.animation(forKey: "position") == nil else { return } self.supernode?.layer.animateKeyframes(values: [ NSValue(cgPoint: CGPoint(x: 0.0, y: 0.0)), NSValue(cgPoint: CGPoint(x: 0.0, y: 1.0)), NSValue(cgPoint: CGPoint(x: 0.0, y: -0.5)), NSValue(cgPoint: CGPoint(x: 0.0, y: 1.0)), NSValue(cgPoint: CGPoint(x: 0.0, y: 0.0)) ], duration: 1.1, keyPath: "position", additive: true) self.bottomArrow.layer.animateKeyframes(values: [ NSValue(cgPoint: CGPoint(x: 0.0, y: 0.0)), NSValue(cgPoint: CGPoint(x: 0.0, y: 4.0)), NSValue(cgPoint: CGPoint(x: 0.0, y: -0.5)), NSValue(cgPoint: CGPoint(x: 0.0, y: 4.0)), NSValue(cgPoint: CGPoint(x: 0.0, y: 0.0)) ], duration: 1.1, keyPath: "position", additive: true, completion: { [weak self] _ in Queue.mainQueue().after(2.9) { self?.setupAnimations() } }) self.topArrow.layer.animateKeyframes(values: [ NSValue(cgPoint: CGPoint(x: 0.0, y: 0.0)), NSValue(cgPoint: CGPoint(x: 0.0, y: 6.0)), NSValue(cgPoint: CGPoint(x: 0.0, y: -0.5)), NSValue(cgPoint: CGPoint(x: 0.0, y: 6.0)), NSValue(cgPoint: CGPoint(x: 0.0, y: 0.0)) ], duration: 1.1, keyPath: "position", additive: true) } } private final class TooltipScreenNode: ViewControllerTracingNode { private let text: TooltipScreen.Text private let textAlignment: TooltipScreen.Alignment private let balancedTextLayout: Bool private let constrainWidth: CGFloat? private let tooltipStyle: TooltipScreen.Style private let arrowStyle: TooltipScreen.ArrowStyle private let icon: TooltipScreen.Icon? private let action: TooltipScreen.Action? var location: TooltipScreen.Location { didSet { if let layout = self.validLayout { self.updateLayout(layout: layout, transition: .immediate) } } } private let displayDuration: TooltipScreen.DisplayDuration private let shouldDismissOnTouch: (CGPoint, CGRect) -> TooltipScreen.DismissOnTouch private let requestDismiss: () -> Void private let openActiveTextItem: ((TooltipActiveTextItem, TooltipActiveTextAction) -> Void)? private let scrollingContainer: ASDisplayNode private let containerNode: ASDisplayNode private let backgroundContainerNode: ASDisplayNode private let backgroundClipNode: ASDisplayNode private let backgroundMaskNode: ASDisplayNode private var effectNode: NavigationBackgroundNode? private var gradientNode: ASDisplayNode? private var arrowGradientNode: ASDisplayNode? private let arrowNode: ASImageNode private let arrowContainer: ASDisplayNode private let animatedStickerNode: DefaultAnimatedStickerNodeImpl private var downArrowsNode: DownArrowsIconNode? private var avatarNode: AvatarNode? private var avatarStoryIndicator: ComponentView? private let textView = ComponentView() private var closeButtonNode: HighlightableButtonNode? private var actionButtonNode: HighlightableButtonNode? private var isArrowInverted: Bool = false private let fontSize: CGFloat private let inset: CGFloat private var validLayout: ContainerViewLayout? init( context: AccountContext?, account: Account, sharedContext: SharedAccountContext, text: TooltipScreen.Text, textAlignment: TooltipScreen.Alignment, balancedTextLayout: Bool, constrainWidth: CGFloat?, style: TooltipScreen.Style, arrowStyle: TooltipScreen.ArrowStyle, icon: TooltipScreen.Icon? = nil, action: TooltipScreen.Action? = nil, location: TooltipScreen.Location, displayDuration: TooltipScreen.DisplayDuration, inset: CGFloat = 12.0, cornerRadius: CGFloat? = nil, shouldDismissOnTouch: @escaping (CGPoint, CGRect) -> TooltipScreen.DismissOnTouch, requestDismiss: @escaping () -> Void, openActiveTextItem: ((TooltipActiveTextItem, TooltipActiveTextAction) -> Void)?) { self.tooltipStyle = style self.arrowStyle = arrowStyle self.icon = icon self.action = action self.location = location self.displayDuration = displayDuration self.inset = inset self.shouldDismissOnTouch = shouldDismissOnTouch self.requestDismiss = requestDismiss self.openActiveTextItem = openActiveTextItem self.containerNode = ASDisplayNode() self.backgroundContainerNode = ASDisplayNode() self.backgroundMaskNode = ASDisplayNode() self.backgroundClipNode = ASDisplayNode() self.backgroundClipNode.backgroundColor = .white let fillColor = UIColor(white: 0.0, alpha: 0.8) self.scrollingContainer = ASDisplayNode() let theme = sharedContext.currentPresentationData.with { $0 }.theme func svgPath(_ path: StaticString, scale: CGPoint = CGPoint(x: 1.0, y: 1.0), offset: CGPoint = CGPoint()) throws -> UIBezierPath { var index: UnsafePointer = path.utf8Start let end = path.utf8Start.advanced(by: path.utf8CodeUnitCount) let path = UIBezierPath() while index < end { let c = index.pointee index = index.successor() if c == 77 { // M let x = try readCGFloat(&index, end: end, separator: 44) * scale.x + offset.x let y = try readCGFloat(&index, end: end, separator: 32) * scale.y + offset.y path.move(to: CGPoint(x: x, y: y)) } else if c == 76 { // L let x = try readCGFloat(&index, end: end, separator: 44) * scale.x + offset.x let y = try readCGFloat(&index, end: end, separator: 32) * scale.y + offset.y path.addLine(to: CGPoint(x: x, y: y)) } else if c == 67 { // C let x1 = try readCGFloat(&index, end: end, separator: 44) * scale.x + offset.x let y1 = try readCGFloat(&index, end: end, separator: 32) * scale.y + offset.y let x2 = try readCGFloat(&index, end: end, separator: 44) * scale.x + offset.x let y2 = try readCGFloat(&index, end: end, separator: 32) * scale.y + offset.y let x = try readCGFloat(&index, end: end, separator: 44) * scale.x + offset.x let y = try readCGFloat(&index, end: end, separator: 32) * scale.y + offset.y path.addCurve(to: CGPoint(x: x, y: y), controlPoint1: CGPoint(x: x1, y: y1), controlPoint2: CGPoint(x: x2, y: y2)) } else if c == 32 { // space continue } } path.close() return path } let arrowSize: CGSize switch self.arrowStyle { case .default: arrowSize = CGSize(width: 29.0, height: 10.0) self.arrowNode = ASImageNode() self.arrowNode.image = generateImage(arrowSize, rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(fillColor.cgColor) context.scaleBy(x: 0.333, y: 0.333) let _ = try? drawSvgPath(context, path: "M85.882251,0 C79.5170552,0 73.4125613,2.52817247 68.9116882,7.02834833 L51.4264069,24.5109211 C46.7401154,29.1964866 39.1421356,29.1964866 34.4558441,24.5109211 L16.9705627,7.02834833 C12.4696897,2.52817247 6.36519576,0 0,0 L85.882251,0 ") context.fillPath() }) case .small: arrowSize = CGSize(width: 18.0, height: 7.0) self.arrowNode = ASImageNode() self.arrowNode.image = generateImage(arrowSize, rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(fillColor.cgColor) context.scaleBy(x: 0.333, y: 0.333) context.scaleBy(x: 0.62, y: 0.62) let _ = try? drawSvgPath(context, path: "M85.882251,0 C79.5170552,0 73.4125613,2.52817247 68.9116882,7.02834833 L51.4264069,24.5109211 C46.7401154,29.1964866 39.1421356,29.1964866 34.4558441,24.5109211 L16.9705627,7.02834833 C12.4696897,2.52817247 6.36519576,0 0,0 L85.882251,0 ") context.fillPath() }) } self.arrowContainer = ASDisplayNode() var hasArrow = true if case .top = location { hasArrow = false } else if case .bottom = location { hasArrow = false } let fontSize: CGFloat if !hasArrow { let backgroundColor: UIColor var enableSaturation = true if case let .customBlur(color, _) = style { backgroundColor = color enableSaturation = false } else { if theme.overallDarkAppearance { backgroundColor = theme.rootController.navigationBar.blurredBackgroundColor } else { backgroundColor = UIColor(rgb: 0x000000, alpha: 0.6) } } self.effectNode = NavigationBackgroundNode(color: backgroundColor, enableSaturation: enableSaturation) self.backgroundMaskNode.addSubnode(self.backgroundClipNode) self.backgroundClipNode.clipsToBounds = true if case .bottom = location { self.backgroundClipNode.cornerRadius = 8.5 } else { self.backgroundClipNode.cornerRadius = 14.0 } if #available(iOS 13.0, *) { self.backgroundClipNode.layer.cornerCurve = .continuous } fontSize = 14.0 } else if case let .gradient(leftColor, rightColor) = style { self.gradientNode = ASDisplayNode() self.gradientNode?.setLayerBlock({ let layer = CAGradientLayer() layer.colors = [leftColor.cgColor, rightColor.cgColor] layer.startPoint = CGPoint() layer.endPoint = CGPoint(x: 1.0, y: 0.0) return layer }) self.arrowGradientNode = ASDisplayNode() self.arrowGradientNode?.setLayerBlock({ let layer = CAGradientLayer() layer.colors = [leftColor.cgColor, rightColor.cgColor] layer.startPoint = CGPoint() layer.endPoint = CGPoint(x: 1.0, y: 0.0) return layer }) self.backgroundContainerNode.clipsToBounds = true self.backgroundContainerNode.cornerRadius = 14.0 if #available(iOS 13.0, *) { self.backgroundContainerNode.layer.cornerCurve = .continuous } fontSize = 17.0 self.arrowContainer.addSubnode(self.arrowGradientNode!) let maskLayer = CAShapeLayer() let arrowScale: CGFloat switch self.arrowStyle { case .default: arrowScale = 1.0 case .small: arrowScale = 0.62 } if let path = try? svgPath("M85.882251,0 C79.5170552,0 73.4125613,2.52817247 68.9116882,7.02834833 L51.4264069,24.5109211 C46.7401154,29.1964866 39.1421356,29.1964866 34.4558441,24.5109211 L16.9705627,7.02834833 C12.4696897,2.52817247 6.36519576,0 0,0 L85.882251,0 ", scale: CGPoint(x: 0.333333 * arrowScale, y: 0.333333 * arrowScale), offset: CGPoint()) { maskLayer.path = path.cgPath } maskLayer.frame = CGRect(origin: CGPoint(), size: arrowSize) self.arrowContainer.layer.mask = maskLayer } else { var enableSaturation = true let backgroundColor: UIColor if case let .customBlur(color, _) = style { backgroundColor = color enableSaturation = false } else if case .light = style { backgroundColor = theme.rootController.navigationBar.blurredBackgroundColor } else { if theme.overallDarkAppearance { backgroundColor = theme.rootController.navigationBar.blurredBackgroundColor } else { backgroundColor = UIColor(rgb: 0x000000, alpha: 0.6) } } self.effectNode = NavigationBackgroundNode(color: backgroundColor, enableBlur: true, enableSaturation: enableSaturation) self.backgroundMaskNode.addSubnode(self.backgroundClipNode) self.backgroundClipNode.clipsToBounds = true if case let .point(_, arrowPosition) = location, case .right = arrowPosition { self.backgroundClipNode.cornerRadius = 8.5 } else { self.backgroundClipNode.cornerRadius = cornerRadius ?? 12.5 } if #available(iOS 13.0, *) { self.backgroundClipNode.layer.cornerCurve = .continuous } self.backgroundMaskNode.addSubnode(self.arrowContainer) fontSize = 14.0 let maskLayer = CAShapeLayer() let arrowScale: CGFloat switch self.arrowStyle { case .default: arrowScale = 1.0 case .small: arrowScale = 0.62 } if let path = try? svgPath("M85.882251,0 C79.5170552,0 73.4125613,2.52817247 68.9116882,7.02834833 L51.4264069,24.5109211 C46.7401154,29.1964866 39.1421356,29.1964866 34.4558441,24.5109211 L16.9705627,7.02834833 C12.4696897,2.52817247 6.36519576,0 0,0 L85.882251,0 ", scale: CGPoint(x: 0.333333 * arrowScale, y: 0.333333 * arrowScale), offset: CGPoint()) { maskLayer.path = path.cgPath } maskLayer.frame = CGRect(origin: CGPoint(), size: arrowSize) maskLayer.fillColor = UIColor.white.cgColor self.arrowContainer.layer.addSublayer(maskLayer) self.backgroundMaskNode.layer.shouldRasterize = true self.backgroundMaskNode.layer.rasterizationScale = UIScreen.main.scale } self.fontSize = fontSize self.text = text self.textAlignment = textAlignment self.balancedTextLayout = balancedTextLayout self.constrainWidth = constrainWidth self.animatedStickerNode = DefaultAnimatedStickerNodeImpl() switch icon { case .none: break case let .animation(animationName, _, animationTintColor): self.animatedStickerNode.setup(source: AnimatedStickerNodeLocalFileSource(name: animationName), width: Int(70 * UIScreenScale), height: Int(70 * UIScreenScale), playbackMode: .once, mode: .direct(cachePathPrefix: nil)) self.animatedStickerNode.automaticallyLoadFirstFrame = true self.animatedStickerNode.dynamicColor = animationTintColor case .downArrows: self.downArrowsNode = DownArrowsIconNode() case let .peer(peer, _): self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 15.0)) if let context { self.avatarNode?.setPeer(context: context, theme: defaultDarkPresentationTheme, peer: peer) } } if case .manual = displayDuration { self.closeButtonNode = HighlightableButtonNode() self.closeButtonNode?.setImage(UIImage(bundleImageName: "Components/Close"), for: .normal) } super.init() self.containerNode.addSubnode(self.backgroundContainerNode) if let gradientNode = self.gradientNode { self.backgroundContainerNode.addSubnode(gradientNode) self.containerNode.addSubnode(self.arrowContainer) } else if let effectNode = self.effectNode { self.backgroundContainerNode.addSubnode(effectNode) self.backgroundContainerNode.layer.mask = self.backgroundMaskNode.layer } self.containerNode.addSubnode(self.animatedStickerNode) if let closeButtonNode = self.closeButtonNode { self.containerNode.addSubnode(closeButtonNode) } if let downArrowsNode = self.downArrowsNode { self.containerNode.addSubnode(downArrowsNode) } if let avatarNode = self.avatarNode { self.containerNode.addSubnode(avatarNode) } self.scrollingContainer.addSubnode(self.containerNode) self.addSubnode(self.scrollingContainer) if let action { let actionColor = theme.list.itemAccentColor.withMultiplied(hue: 1.0, saturation: 0.64, brightness: 1.08) let actionButtonNode = HighlightableButtonNode() actionButtonNode.hitTestSlop = UIEdgeInsets(top: -16.0, left: -16.0, bottom: -16.0, right: -16.0) actionButtonNode.setAttributedTitle(NSAttributedString(string: action.title, font: Font.regular(17.0), textColor: actionColor), for: .normal) self.containerNode.addSubnode(actionButtonNode) self.actionButtonNode = actionButtonNode } self.actionButtonNode?.addTarget(self, action: #selector(self.actionPressed), forControlEvents: .touchUpInside) self.closeButtonNode?.addTarget(self, action: #selector(self.closePressed), forControlEvents: .touchUpInside) } @objc private func actionPressed() { if let action = self.action { action.action() self.requestDismiss() } } @objc private func closePressed() { self.requestDismiss() } func updateLayout(layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { self.validLayout = layout self.scrollingContainer.frame = CGRect(origin: CGPoint(), size: layout.size) let sideInset: CGFloat = self.inset + layout.safeInsets.left let bottomInset: CGFloat = 10.0 let contentInset: CGFloat = 11.0 let contentVerticalInset: CGFloat = 8.0 let animationSize: CGSize var animationInset: CGFloat = 0.0 var animationSpacing: CGFloat = 0.0 var animationOffset: CGFloat = 0.0 switch self.icon { case .none: animationSize = CGSize() case .downArrows: animationSize = CGSize(width: 24.0, height: 32.0) animationInset = (40.0 - animationSize.width) / 2.0 case let .animation(animationName, _, _): if animationName == "premium_unlock" { animationSize = CGSize(width: 34.0, height: 34.0) } else { animationSize = CGSize(width: 32.0, height: 32.0) } if ["anim_autoremove_on", "anim_autoremove_off"].contains(animationName) { animationOffset = -3.0 } else if animationName == "ChatListFoldersTooltip" { animationInset = (70.0 - animationSize.width) / 2.0 } else { animationInset = 0.0 } animationSpacing = 8.0 case .peer: animationSize = CGSize(width: 32.0, height: 32.0) animationInset = 0.0 animationSpacing = 8.0 } var containerWidth = max(100.0, min(layout.size.width - sideInset * 2.0, 614.0)) if let constrainWidth = self.constrainWidth, constrainWidth > 100.0 { containerWidth = constrainWidth } var actionSize: CGSize = .zero var buttonInset: CGFloat = 0.0 if let actionButtonNode = self.actionButtonNode { actionSize = actionButtonNode.measure(CGSize(width: containerWidth, height: .greatestFiniteMagnitude)) buttonInset += actionSize.width + 32.0 } if self.closeButtonNode != nil { buttonInset += 24.0 } let baseFont = Font.regular(self.fontSize) let boldFont = Font.semibold(14.0) let italicFont = Font.italic(self.fontSize) let boldItalicFont = Font.semiboldItalic(self.fontSize) let fixedFont = Font.monospace(self.fontSize) let textColor: UIColor = .white let attributedText: NSAttributedString switch self.text { case let .plain(text): attributedText = NSAttributedString(string: text, font: baseFont, textColor: textColor) case let .entities(text, entities): attributedText = stringWithAppliedEntities(text, entities: entities, baseColor: textColor, linkColor: textColor, baseFont: baseFont, linkFont: baseFont, boldFont: boldFont, italicFont: italicFont, boldItalicFont: boldItalicFont, fixedFont: fixedFont, blockQuoteFont: baseFont, underlineLinks: true, external: false, message: nil) case let .markdown(text): let linkColor = UIColor(rgb: 0x64d2ff) let markdownAttributes = MarkdownAttributes( body: MarkdownAttributeSet(font: baseFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldFont, textColor: textColor), link: MarkdownAttributeSet(font: boldFont, textColor: linkColor), linkAttribute: { _ in return nil } ) attributedText = parseMarkdownIntoAttributedString(text, attributes: markdownAttributes) } let highlightColor: UIColor? = UIColor.white.withAlphaComponent(0.5) let highlightAction: (([NSAttributedString.Key: Any]) -> NSAttributedString.Key?)? = { attributes in let highlightedAttributes = [ TelegramTextAttributes.URL, TelegramTextAttributes.PeerMention, TelegramTextAttributes.PeerTextMention, TelegramTextAttributes.BotCommand, TelegramTextAttributes.Hashtag ] for attribute in highlightedAttributes { if let _ = attributes[NSAttributedString.Key(rawValue: attribute)] { return NSAttributedString.Key(rawValue: attribute) } } return nil } let tapAction: (([NSAttributedString.Key: Any], Int) -> Void)? = { [weak self] attributes, index in guard let strongSelf = self else { return } if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { var concealed = true if let (attributeText, fullText) = (strongSelf.textView.view as? BalancedTextComponent.View)?.attributeSubstring(name: TelegramTextAttributes.URL, index: index) { concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText) } strongSelf.openActiveTextItem?(.url(url, concealed), .tap) } else if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention { strongSelf.openActiveTextItem?(.mention(mention.peerId, mention.mention), .tap) } else if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String { strongSelf.openActiveTextItem?(.textMention(mention), .tap) } else if let command = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String { strongSelf.openActiveTextItem?(.botCommand(command), .tap) } else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag { strongSelf.openActiveTextItem?(.hashtag(hashtag.hashtag), .tap) } } let longTapAction: (([NSAttributedString.Key: Any], Int) -> Void)? = { [weak self] attributes, index in guard let strongSelf = self else { return } if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { var concealed = true if let (attributeText, fullText) = (strongSelf.textView.view as? BalancedTextComponent.View)?.attributeSubstring(name: TelegramTextAttributes.URL, index: index) { concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText) } strongSelf.openActiveTextItem?(.url(url, concealed), .longTap) } else if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention { strongSelf.openActiveTextItem?(.mention(mention.peerId, mention.mention), .longTap) } else if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String { strongSelf.openActiveTextItem?(.textMention(mention), .longTap) } else if let command = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String { strongSelf.openActiveTextItem?(.botCommand(command), .longTap) } else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag { strongSelf.openActiveTextItem?(.hashtag(hashtag.hashtag), .longTap) } } let textSize = self.textView.update( transition: .immediate, component: AnyComponent(BalancedTextComponent( text: .plain(attributedText), balanced: self.balancedTextLayout, horizontalAlignment: self.textAlignment == .center ? .center : .left, maximumNumberOfLines: 0, highlightColor: highlightColor, highlightAction: highlightAction, tapAction: tapAction, longTapAction: longTapAction )), environment: {}, containerSize: CGSize(width: containerWidth - contentInset * 2.0 - animationSize.width - animationSpacing - buttonInset, height: 1000000.0) ) var backgroundFrame: CGRect var backgroundHeight: CGFloat switch self.tooltipStyle { case .default, .gradient: backgroundHeight = max(animationSize.height, textSize.height) + contentVerticalInset * 2.0 case .wide: backgroundHeight = max(animationSize.height, textSize.height) + contentVerticalInset * 2.0 + 4.0 case let .customBlur(_, inset): backgroundHeight = max(animationSize.height, textSize.height) + contentVerticalInset * 2.0 + inset * 2.0 case .light: backgroundHeight = max(28.0, max(animationSize.height, textSize.height) + 4.0 * 2.0) } if self.actionButtonNode != nil { backgroundHeight += 4.0 } var invertArrow = false switch self.location { case let .point(rect, arrowPosition): var backgroundWidth = textSize.width + contentInset * 2.0 + animationSize.width + animationSpacing if self.closeButtonNode != nil || self.actionButtonNode != nil { backgroundWidth += buttonInset } if self.actionButtonNode != nil, case .compact = layout.metrics.widthClass { backgroundWidth = containerWidth } switch arrowPosition { case .bottom, .top: backgroundFrame = CGRect(origin: CGPoint(x: rect.midX - backgroundWidth / 2.0, y: rect.minY - bottomInset - backgroundHeight), size: CGSize(width: backgroundWidth, height: backgroundHeight)) case .right: backgroundFrame = CGRect(origin: CGPoint(x: rect.minX - backgroundWidth - bottomInset, y: rect.midY - backgroundHeight / 2.0), size: CGSize(width: backgroundWidth, height: backgroundHeight)) } if backgroundFrame.minX < sideInset { backgroundFrame.origin.x = sideInset } if backgroundFrame.maxX > layout.size.width - sideInset { backgroundFrame.origin.x = layout.size.width - sideInset - backgroundFrame.width } if backgroundFrame.minY < layout.insets(options: .statusBar).top { backgroundFrame.origin.y = rect.maxY + bottomInset invertArrow = true } if case .top = arrowPosition, !invertArrow { invertArrow = true backgroundFrame.origin.y = rect.maxY + bottomInset } self.isArrowInverted = invertArrow case .top: let backgroundWidth = containerWidth backgroundFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - backgroundWidth) / 2.0), y: layout.insets(options: [.statusBar]).top + 13.0), size: CGSize(width: backgroundWidth, height: backgroundHeight)) case .bottom: let backgroundWidth = containerWidth backgroundFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - backgroundWidth) / 2.0), y: layout.size.height - layout.insets(options: []).bottom - 12.0 - backgroundHeight), size: CGSize(width: backgroundWidth, height: backgroundHeight)) } transition.updateFrame(node: self.containerNode, frame: backgroundFrame) transition.updateFrame(node: self.backgroundContainerNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size)) transition.updateFrame(node: self.backgroundMaskNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size).insetBy(dx: -10.0, dy: -10.0)) transition.updateFrame(node: self.backgroundClipNode, frame: CGRect(origin: CGPoint(x: 10.0, y: 10.0), size: backgroundFrame.size)) if let effectNode = self.effectNode { let effectFrame = CGRect(origin: CGPoint(), size: backgroundFrame.size).insetBy(dx: -10.0, dy: -10.0) transition.updateFrame(node: effectNode, frame: effectFrame) effectNode.update(size: effectFrame.size, transition: transition) } if let gradientNode = self.gradientNode { transition.updateFrame(node: gradientNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size)) } if let image = self.arrowNode.image, case let .point(rect, arrowPosition) = self.location { let arrowSize = image.size let arrowCenterX = rect.midX let arrowFrame: CGRect switch arrowPosition { case .bottom, .top: if invertArrow { arrowFrame = CGRect(origin: CGPoint(x: floor(arrowCenterX - arrowSize.width / 2.0), y: -arrowSize.height), size: arrowSize) } else { arrowFrame = CGRect(origin: CGPoint(x: floor(arrowCenterX - arrowSize.width / 2.0), y: backgroundFrame.height), size: arrowSize) } ContainedViewLayoutTransition.immediate.updateTransformScale(node: self.arrowContainer, scale: CGPoint(x: 1.0, y: invertArrow ? -1.0 : 1.0)) if case .gradient = self.tooltipStyle { transition.updateFrame(node: self.arrowContainer, frame: arrowFrame.offsetBy(dx: -backgroundFrame.minX, dy: 0.0)) } else { transition.updateFrame(node: self.arrowContainer, frame: arrowFrame.offsetBy(dx: -backgroundFrame.minX + 10.0, dy: 10.0)) } let arrowBounds = CGRect(origin: CGPoint(), size: arrowSize) self.arrowNode.frame = arrowBounds self.arrowGradientNode?.frame = CGRect(origin: CGPoint(x: -arrowFrame.minX + backgroundFrame.minX, y: 0.0), size: backgroundFrame.size) case .right: let arrowCenterY = floorToScreenPixels(rect.midY - arrowSize.height / 2.0) arrowFrame = CGRect(origin: CGPoint(x: backgroundFrame.width + arrowSize.height, y: self.view.convert(CGPoint(x: 0.0, y: arrowCenterY), to: self.arrowContainer.supernode?.view).y), size: CGSize(width: arrowSize.height, height: arrowSize.width)) ContainedViewLayoutTransition.immediate.updateTransformRotation(node: self.arrowContainer, angle: -CGFloat.pi / 2.0) transition.updateFrame(node: self.arrowContainer, frame: arrowFrame.offsetBy(dx: 8.0 - UIScreenPixel, dy: 0.0)) let arrowBounds = CGRect(origin: .zero, size: arrowSize) self.arrowNode.frame = arrowBounds self.arrowGradientNode?.frame = arrowBounds } } else { self.arrowNode.isHidden = true } let textFrame = CGRect(origin: CGPoint(x: contentInset + animationSize.width + animationSpacing, y: floor((backgroundHeight - textSize.height) / 2.0)), size: textSize) if let textComponentView = self.textView.view { if textComponentView.superview == nil { textComponentView.layer.anchorPoint = CGPoint() self.containerNode.view.addSubview(textComponentView) } transition.updatePosition(layer: textComponentView.layer, position: textFrame.origin) transition.updateBounds(layer: textComponentView.layer, bounds: CGRect(origin: CGPoint(), size: textFrame.size)) } if let closeButtonNode = self.closeButtonNode { let closeSize = CGSize(width: 44.0, height: 44.0) transition.updateFrame(node: closeButtonNode, frame: CGRect(origin: CGPoint(x: textFrame.maxX - 6.0, y: floor((backgroundHeight - closeSize.height) / 2.0)), size: closeSize)) } if let actionButtonNode = self.actionButtonNode { transition.updateFrame(node: actionButtonNode, frame: CGRect(origin: CGPoint(x: backgroundFrame.width - actionSize.width - 16.0, y: floor((backgroundHeight - actionSize.height) / 2.0)), size: actionSize)) } let animationFrame = CGRect(origin: CGPoint(x: contentInset - animationInset, y: floorToScreenPixels((backgroundHeight - animationSize.height - animationInset * 2.0) / 2.0) + animationOffset), size: CGSize(width: animationSize.width + animationInset * 2.0, height: animationSize.height + animationInset * 2.0)) transition.updateFrame(node: self.animatedStickerNode, frame: animationFrame) self.animatedStickerNode.updateLayout(size: CGSize(width: animationSize.width + animationInset * 2.0, height: animationSize.height + animationInset * 2.0)) if let downArrowsNode = self.downArrowsNode { let arrowsSize = CGSize(width: 16.0, height: 16.0) transition.updateFrame(node: downArrowsNode, frame: CGRect(origin: CGPoint(x: animationFrame.midX - arrowsSize.width / 2.0, y: animationFrame.midY - arrowsSize.height / 2.0), size: arrowsSize)) downArrowsNode.setupAnimations() } if let avatarNode = self.avatarNode { var avatarFrame = animationFrame if let icon, case let .peer(_, isStory) = icon, isStory { let indicatorTransition: Transition = .immediate let avatarStoryIndicator: ComponentView if let current = self.avatarStoryIndicator { avatarStoryIndicator = current } else { avatarStoryIndicator = ComponentView() self.avatarStoryIndicator = avatarStoryIndicator } let storyIndicatorScale: CGFloat = 1.0 var indicatorFrame = CGRect(origin: CGPoint(x: avatarFrame.minX + 4.0, y: avatarFrame.minY + 4.0), size: CGSize(width: avatarFrame.width - 4.0 - 4.0, height: avatarFrame.height - 4.0 - 4.0)) indicatorFrame.origin.x -= (avatarFrame.width - avatarFrame.width * storyIndicatorScale) * 0.5 let _ = avatarStoryIndicator.update( transition: indicatorTransition, component: AnyComponent(AvatarStoryIndicatorComponent( hasUnseen: true, hasUnseenCloseFriendsItems: false, colors: AvatarStoryIndicatorComponent.Colors(theme: defaultDarkPresentationTheme), activeLineWidth: 1.0 + UIScreenPixel, inactiveLineWidth: 1.0 + UIScreenPixel, counters: nil )), environment: {}, containerSize: indicatorFrame.size ) if let avatarStoryIndicatorView = avatarStoryIndicator.view { if avatarStoryIndicatorView.superview == nil { avatarStoryIndicatorView.isUserInteractionEnabled = false self.containerNode.view.addSubview(avatarStoryIndicatorView) } indicatorTransition.setPosition(view: avatarStoryIndicatorView, position: indicatorFrame.center) indicatorTransition.setBounds(view: avatarStoryIndicatorView, bounds: CGRect(origin: CGPoint(), size: indicatorFrame.size)) indicatorTransition.setScale(view: avatarStoryIndicatorView, scale: storyIndicatorScale) } avatarFrame = avatarFrame.insetBy(dx: 4.0, dy: 4.0) } else { if let avatarStoryIndicator = self.avatarStoryIndicator { self.avatarStoryIndicator = nil avatarStoryIndicator.view?.removeFromSuperview() } } transition.updateFrame(node: avatarNode, frame: avatarFrame) avatarNode.updateSize(size: avatarFrame.size) } } private var didRequestDismiss = false override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if let event = event { if let _ = self.openActiveTextItem, let textComponentView = self.textView.view, let result = textComponentView.hitTest(self.view.convert(point, to: textComponentView), with: event) { return result } if let closeButtonNode = self.closeButtonNode, let result = closeButtonNode.hitTest(self.view.convert(point, to: closeButtonNode.view), with: event) { return result } var eventIsPresses = false if #available(iOSApplicationExtension 9.0, iOS 9.0, *) { eventIsPresses = event.type == .presses } if event.type == .touches || eventIsPresses { if let actionButtonNode = self.actionButtonNode, let result = actionButtonNode.hitTest(self.convert(point, to: actionButtonNode), with: event) { return result } if !self.didRequestDismiss { switch self.shouldDismissOnTouch(point, self.containerNode.frame) { case .ignore: break case let .dismiss(consume): self.requestDismiss() if consume { self.didRequestDismiss = true return self.view } } } else { return self.view } return nil } } return super.hitTest(point, with: event) } func animateIn() { switch self.location { case .top, .bottom: self.containerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) self.containerNode.layer.animateScale(from: 0.96, to: 1.0, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring) if let _ = self.validLayout, case .top = self.location { let offset: CGFloat if case .top = self.location { offset = -13.0 - self.backgroundContainerNode.frame.height } else { offset = 13.0 + self.backgroundContainerNode.frame.height } self.containerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: offset), to: CGPoint(), duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, additive: true) } case let .point(_, arrowPosition): self.containerNode.layer.animateSpring(from: NSNumber(value: Float(0.01)), to: NSNumber(value: Float(1.0)), keyPath: "transform.scale", duration: 0.4, damping: 105.0) let startPoint: CGPoint switch arrowPosition { case .bottom, .top: let arrowY: CGFloat = self.isArrowInverted ? self.arrowContainer.frame.minY : self.arrowContainer.frame.maxY startPoint = CGPoint(x: self.arrowContainer.frame.midX - self.containerNode.bounds.width / 2.0, y: arrowY - self.containerNode.bounds.height / 2.0) case .right: startPoint = CGPoint(x: self.arrowContainer.frame.maxX - self.containerNode.bounds.width / 2.0, y: self.arrowContainer.frame.minY - self.containerNode.bounds.height / 2.0) } self.containerNode.layer.animateSpring(from: NSValue(cgPoint: startPoint), to: NSValue(cgPoint: CGPoint()), keyPath: "position", duration: 0.4, damping: 105.0, additive: true) self.containerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } let animationDelay: Double switch self.icon { case let .animation(_, delay, _): animationDelay = delay case .none, .downArrows: animationDelay = 0.0 case .peer: animationDelay = 0.0 } DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + animationDelay, execute: { [weak self] in self?.animatedStickerNode.visibility = true }) } func animateOut(inPlace: Bool, completion: @escaping () -> Void) { switch self.location { case .top, .bottom: self.containerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { _ in completion() }) self.containerNode.layer.animateScale(from: 1.0, to: 0.96, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) if let _ = self.validLayout, case .top = self.location, !inPlace { let offset: CGFloat if case .top = self.location { offset = -13.0 - self.backgroundContainerNode.frame.height } else { offset = 13.0 + self.backgroundContainerNode.frame.height } self.containerNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: offset), duration: 0.3, removeOnCompletion: false, additive: true) } case let .point(_, arrowPosition): self.containerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in completion() }) self.containerNode.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) let targetPoint: CGPoint switch arrowPosition { case .bottom, .top: let arrowY: CGFloat = self.isArrowInverted ? self.arrowContainer.frame.minY : self.arrowContainer.frame.maxY targetPoint = CGPoint(x: self.arrowContainer.frame.midX - self.containerNode.bounds.width / 2.0, y: arrowY - self.containerNode.bounds.height / 2.0) case .right: targetPoint = CGPoint(x: self.arrowContainer.frame.maxX - self.containerNode.bounds.width / 2.0, y: self.arrowContainer.frame.minY - self.containerNode.bounds.height / 2.0) } self.containerNode.layer.animatePosition(from: CGPoint(), to: targetPoint, duration: 0.2, removeOnCompletion: false, additive: true) } } func addRelativeScrollingOffset(_ value: CGFloat, transition: ContainedViewLayoutTransition) { self.scrollingContainer.bounds = self.scrollingContainer.bounds.offsetBy(dx: 0.0, dy: value) transition.animateOffsetAdditive(node: self.scrollingContainer, offset: -value) if let layout = self.validLayout { let projectedContainerFrame = self.containerNode.frame.offsetBy(dx: 0.0, dy: -self.scrollingContainer.bounds.origin.y) if projectedContainerFrame.minY - 30.0 < layout.insets(options: .statusBar).top { self.requestDismiss() } } } } public final class TooltipScreen: ViewController { public enum Text: Equatable { case plain(text: String) case entities(text: String, entities: [MessageTextEntity]) case markdown(text: String) } public class Action { public let title: String public let action: () -> Void public init( title: String, action: @escaping () -> Void ) { self.title = title self.action = action } } public enum Icon { case animation(name: String, delay: Double, tintColor: UIColor?) case peer(peer: EnginePeer, isStory: Bool) case downArrows } public enum DismissOnTouch { case ignore case dismiss(consume: Bool) } public enum ArrowPosition { case top case right case bottom } public enum ArrowStyle { case `default` case small } public enum Location { case point(CGRect, ArrowPosition) case top case bottom } public enum DisplayDuration { case `default` case custom(Double) case infinite case manual } public enum Style { case `default` case light case customBlur(UIColor, CGFloat) case gradient(UIColor, UIColor) case wide } public enum Alignment { case natural case center } private let context: AccountContext? private let account: Account private let sharedContext: SharedAccountContext public let text: TooltipScreen.Text public let textAlignment: TooltipScreen.Alignment private let balancedTextLayout: Bool private let constrainWidth: CGFloat? private let style: TooltipScreen.Style private let arrowStyle: TooltipScreen.ArrowStyle private let icon: TooltipScreen.Icon? private let action: TooltipScreen.Action? public var location: TooltipScreen.Location { didSet { if self.isNodeLoaded { self.controllerNode.location = self.location } } } private let displayDuration: DisplayDuration private let inset: CGFloat private let cornerRadius: CGFloat? private let shouldDismissOnTouch: (CGPoint, CGRect) -> TooltipScreen.DismissOnTouch private let openActiveTextItem: ((TooltipActiveTextItem, TooltipActiveTextAction) -> Void)? private var controllerNode: TooltipScreenNode { return self.displayNode as! TooltipScreenNode } private var validLayout: ContainerViewLayout? private var isDismissed: Bool = false public var willBecomeDismissed: ((TooltipScreen) -> Void)? public var becameDismissed: ((TooltipScreen) -> Void)? private var dismissTimer: Foundation.Timer? public var alwaysVisible = false public init( context: AccountContext? = nil, account: Account, sharedContext: SharedAccountContext, text: TooltipScreen.Text, textAlignment: TooltipScreen.Alignment = .natural, balancedTextLayout: Bool = false, constrainWidth: CGFloat? = nil, style: TooltipScreen.Style = .default, arrowStyle: TooltipScreen.ArrowStyle = .default, icon: TooltipScreen.Icon? = nil, action: TooltipScreen.Action? = nil, location: TooltipScreen.Location, displayDuration: DisplayDuration = .default, inset: CGFloat = 12.0, cornerRadius: CGFloat? = nil, shouldDismissOnTouch: @escaping (CGPoint, CGRect) -> TooltipScreen.DismissOnTouch, openActiveTextItem: ((TooltipActiveTextItem, TooltipActiveTextAction) -> Void)? = nil ) { self.context = context self.account = account self.sharedContext = sharedContext self.text = text self.textAlignment = textAlignment self.balancedTextLayout = balancedTextLayout self.constrainWidth = constrainWidth self.style = style self.arrowStyle = arrowStyle self.icon = icon self.action = action self.location = location self.displayDuration = displayDuration self.inset = inset self.cornerRadius = cornerRadius self.shouldDismissOnTouch = shouldDismissOnTouch self.openActiveTextItem = openActiveTextItem super.init(navigationBarPresentationData: nil) self.statusBar.statusBarStyle = .Ignore } required init(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.dismissTimer?.invalidate() } override public func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) self.controllerNode.animateIn() self.resetDismissTimeout(duration: self.displayDuration) } public func resetDismissTimeout(duration: TooltipScreen.DisplayDuration? = nil) { self.dismissTimer?.invalidate() self.dismissTimer = nil let timeout: Double switch duration ?? self.displayDuration { case .default: timeout = 5.0 case let .custom(value): timeout = value case .infinite, .manual: return } final class TimerTarget: NSObject { private let f: () -> Void init(_ f: @escaping () -> Void) { self.f = f } @objc func timerEvent() { self.f() } } let dismissTimer = Foundation.Timer(timeInterval: timeout, target: TimerTarget { [weak self] in guard let strongSelf = self else { return } strongSelf.dismiss() }, selector: #selector(TimerTarget.timerEvent), userInfo: nil, repeats: false) self.dismissTimer = dismissTimer RunLoop.main.add(dismissTimer, forMode: .common) } override public func loadDisplayNode() { self.displayNode = TooltipScreenNode(context: self.context, account: self.account, sharedContext: self.sharedContext, text: self.text, textAlignment: self.textAlignment, balancedTextLayout: self.balancedTextLayout, constrainWidth: self.constrainWidth, style: self.style, arrowStyle: self.arrowStyle, icon: self.icon, action: self.action, location: self.location, displayDuration: self.displayDuration, inset: self.inset, cornerRadius: self.cornerRadius, shouldDismissOnTouch: self.shouldDismissOnTouch, requestDismiss: { [weak self] in guard let strongSelf = self else { return } strongSelf.dismiss() }, openActiveTextItem: self.openActiveTextItem) self.displayNodeDidLoad() } override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { super.containerLayoutUpdated(layout, transition: transition) if let validLayout = self.validLayout, !self.alwaysVisible { if validLayout.size.width != layout.size.width { self.dismiss() } } self.validLayout = layout self.controllerNode.updateLayout(layout: layout, transition: transition) } public func addRelativeScrollingOffset(_ value: CGFloat, transition: ContainedViewLayoutTransition) { self.controllerNode.addRelativeScrollingOffset(value, transition: transition) } public func dismiss(inPlace: Bool, completion: (() -> Void)? = nil) { if self.isDismissed { return } self.isDismissed = true self.willBecomeDismissed?(self) self.controllerNode.animateOut(inPlace: inPlace, completion: { [weak self] in guard let strongSelf = self else { return } let becameDismissed = strongSelf.becameDismissed strongSelf.presentingViewController?.dismiss(animated: false, completion: nil) becameDismissed?(strongSelf) }) } override public func dismiss(completion: (() -> Void)? = nil) { self.dismiss(inPlace: false, completion: completion) } }