Swiftgram/submodules/TooltipUI/Sources/TooltipScreen.swift
2023-06-27 21:07:43 +02:00

1063 lines
49 KiB
Swift

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
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 tooltipStyle: TooltipScreen.Style
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) -> 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: AnimatedStickerNode
private var downArrowsNode: DownArrowsIconNode?
private var avatarNode: AvatarNode?
private var avatarStoryIndicator: ComponentView<Empty>?
private let textNode: ImmediateTextNode
private var closeButtonNode: HighlightableButtonNode?
private var actionButtonNode: HighlightableButtonNode?
private var isArrowInverted: Bool = false
private let inset: CGFloat
private var validLayout: ContainerViewLayout?
init(
context: AccountContext?,
account: Account,
sharedContext: SharedAccountContext,
text: TooltipScreen.Text,
textAlignment: TooltipScreen.Alignment,
style: TooltipScreen.Style,
icon: TooltipScreen.Icon? = nil,
action: TooltipScreen.Action? = nil,
location: TooltipScreen.Location,
displayDuration: TooltipScreen.DisplayDuration,
inset: CGFloat = 13.0,
cornerRadius: CGFloat? = nil,
shouldDismissOnTouch: @escaping (CGPoint) -> TooltipScreen.DismissOnTouch, requestDismiss: @escaping () -> Void, openActiveTextItem: ((TooltipActiveTextItem, TooltipActiveTextAction) -> Void)?)
{
self.tooltipStyle = style
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<UInt8> = 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(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()
})
self.arrowContainer = ASDisplayNode()
let fontSize: CGFloat
if case .top = location {
let backgroundColor: UIColor
if theme.overallDarkAppearance {
backgroundColor = theme.rootController.navigationBar.blurredBackgroundColor
} else {
backgroundColor = UIColor(rgb: 0x000000, alpha: 0.6)
}
self.effectNode = NavigationBackgroundNode(color: backgroundColor)
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 = 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()
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, y: 0.333333), 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()
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, y: 0.333333), 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.textNode = ImmediateTextNode()
self.textNode.displaysAsynchronously = false
self.textNode.maximumNumberOfLines = 0
let baseFont = Font.regular(fontSize)
let boldFont = Font.semibold(14.0)
let italicFont = Font.italic(fontSize)
let boldItalicFont = Font.semiboldItalic(fontSize)
let fixedFont = Font.monospace(fontSize)
let textColor: UIColor = .white
let attributedText: NSAttributedString
switch 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 markdownAttributes = MarkdownAttributes(
body: MarkdownAttributeSet(font: baseFont, textColor: textColor),
bold: MarkdownAttributeSet(font: boldFont, textColor: textColor),
link: MarkdownAttributeSet(font: baseFont, textColor: textColor),
linkAttribute: { _ in
return nil
}
)
attributedText = parseMarkdownIntoAttributedString(text, attributes: markdownAttributes)
}
self.textNode.attributedText = attributedText
self.textNode.textAlignment = textAlignment == .center ? .center : .natural
self.animatedStickerNode = DefaultAnimatedStickerNodeImpl()
switch icon {
case .none:
break
case let .animation(animationName, _):
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
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.textNode)
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.textNode.linkHighlightColor = UIColor.white.withAlphaComponent(0.5)
self.textNode.highlightAttributeAction = { 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
}
self.textNode.tapAttributeAction = { [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.textNode.attributeSubstring(name: TelegramTextAttributes.URL, index: index) {
concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText)
}
openActiveTextItem?(.url(url, concealed), .tap)
} else if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention {
openActiveTextItem?(.mention(mention.peerId, mention.mention), .tap)
} else if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String {
openActiveTextItem?(.textMention(mention), .tap)
} else if let command = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String {
openActiveTextItem?(.botCommand(command), .tap)
} else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag {
openActiveTextItem?(.hashtag(hashtag.hashtag), .tap)
}
}
self.textNode.longTapAttributeAction = { [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.textNode.attributeSubstring(name: TelegramTextAttributes.URL, index: index) {
concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText)
}
openActiveTextItem?(.url(url, concealed), .longTap)
} else if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention {
openActiveTextItem?(.mention(mention.peerId, mention.mention), .longTap)
} else if let mention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String {
openActiveTextItem?(.textMention(mention), .longTap)
} else if let command = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String {
openActiveTextItem?(.botCommand(command), .longTap)
} else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag {
openActiveTextItem?(.hashtag(hashtag.hashtag), .longTap)
}
}
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
let animationInset: CGFloat
let animationSpacing: CGFloat
switch self.icon {
case .none:
animationSize = CGSize()
animationInset = 0.0
animationSpacing = 0.0
case .downArrows:
animationSize = CGSize(width: 24.0, height: 32.0)
animationInset = (40.0 - animationSize.width) / 2.0
animationSpacing = 8.0
case let .animation(animationName, _):
animationSize = CGSize(width: 32.0, height: 32.0)
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
}
let containerWidth = max(100.0, min(layout.size.width, 614.0) - (sideInset + layout.safeInsets.left) * 2.0)
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 textSize = self.textNode.updateLayout(CGSize(width: containerWidth - contentInset * 2.0 - animationSize.width - animationSpacing - buttonInset, height: .greatestFiniteMagnitude))
var backgroundFrame: CGRect
var backgroundHeight: CGFloat
switch self.tooltipStyle {
case .default, .gradient, .customBlur:
backgroundHeight = max(animationSize.height, textSize.height) + contentVerticalInset * 2.0
if self.actionButtonNode != nil {
backgroundHeight += 2.0
}
case .light:
backgroundHeight = max(28.0, max(animationSize.height, textSize.height) + 4.0 * 2.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))
}
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:
arrowFrame = CGRect(origin: CGPoint(x: backgroundFrame.width + arrowSize.height, y: rect.midY), 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: 16.0 + -backgroundFrame.minY - floorToScreenPixels((backgroundFrame.height + 20.0 - arrowSize.width) / 2.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)
transition.updateFrame(node: self.textNode, frame: textFrame)
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)), 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<Empty>
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,
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)
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if let event = event {
if let _ = self.openActiveTextItem, let result = self.textNode.hitTest(self.view.convert(point, to: self.textNode.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 case .manual = self.displayDuration {
if self.containerNode.frame.contains(point) {
self.requestDismiss()
return self.view
} else if case .manual(false) = self.displayDuration {
return nil
}
}
if let actionButtonNode = self.actionButtonNode, let result = actionButtonNode.hitTest(self.convert(point, to: actionButtonNode), with: event) {
return result
}
switch self.shouldDismissOnTouch(point) {
case .ignore:
break
case let .dismiss(consume):
self.requestDismiss()
if consume {
return self.view
}
}
return nil
}
}
return super.hitTest(point, with: event)
}
func animateIn() {
switch self.location {
case .top:
self.containerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
self.containerNode.layer.animateScale(from: 0.96, to: 1.0, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring)
if let _ = self.validLayout {
self.containerNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -13.0 - self.backgroundContainerNode.frame.height), 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(completion: @escaping () -> Void) {
switch self.location {
case .top:
self.containerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, 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 {
self.containerNode.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: -13.0 - self.backgroundContainerNode.frame.height), 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)
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 Location {
case point(CGRect, ArrowPosition)
case top
}
public enum DisplayDuration {
case `default`
case custom(Double)
case infinite
case manual(Bool)
}
public enum Style {
case `default`
case light
case customBlur(UIColor)
case gradient(UIColor, UIColor)
}
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 style: TooltipScreen.Style
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) -> 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,
style: TooltipScreen.Style = .default,
icon: TooltipScreen.Icon? = nil,
action: TooltipScreen.Action? = nil,
location: TooltipScreen.Location,
displayDuration: DisplayDuration = .default,
inset: CGFloat = 13.0,
cornerRadius: CGFloat? = nil,
shouldDismissOnTouch: @escaping (CGPoint) -> TooltipScreen.DismissOnTouch,
openActiveTextItem: ((TooltipActiveTextItem, TooltipActiveTextAction) -> Void)? = nil
) {
self.context = context
self.account = account
self.sharedContext = sharedContext
self.text = text
self.textAlignment = textAlignment
self.style = style
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, style: self.style, 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)
}
override public func dismiss(completion: (() -> Void)? = nil) {
if self.isDismissed {
return
}
self.isDismissed = true
self.willBecomeDismissed?(self)
self.controllerNode.animateOut(completion: { [weak self] in
guard let strongSelf = self else {
return
}
let becameDismissed = strongSelf.becameDismissed
strongSelf.presentingViewController?.dismiss(animated: false, completion: nil)
becameDismissed?(strongSelf)
})
}
}