import UIKit final class RDEPUBAttachmentTooltipView: UIView { enum ArrowPlacement { case top case bottom } private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20) private let arrowSize = CGSize(width: 20, height: 10) private let cornerRadius: CGFloat = 18 private(set) var minimumArrowX: CGFloat = 28 private var arrowTipX: CGFloat? private var arrowPlacement: ArrowPlacement = .bottom private let textLabel: UILabel = { let label = UILabel() label.numberOfLines = 0 label.textColor = .white label.font = .systemFont(ofSize: 16, weight: .regular) label.lineBreakMode = .byWordWrapping return label }() private let shapeLayer = CAShapeLayer() override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear isOpaque = false layer.addSublayer(shapeLayer) addSubview(textLabel) accessibilityIdentifier = "epub.reader.attachment.tooltip" } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() shapeLayer.frame = bounds shapeLayer.path = bubblePath(in: bounds).cgPath shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor let topInset = contentInsets.top + (arrowPlacement == .top ? arrowSize.height : 0) let bottomInset = contentInsets.bottom + (arrowPlacement == .bottom ? arrowSize.height : 0) let labelFrame = bounds.inset(by: UIEdgeInsets( top: topInset, left: contentInsets.left, bottom: bottomInset, right: contentInsets.right )) textLabel.frame = labelFrame } func setArrowTipX(_ value: CGFloat, placement: ArrowPlacement) { arrowTipX = value arrowPlacement = placement setNeedsLayout() } func configure(text: String, maxWidth: CGFloat) { textLabel.text = text let labelMaxWidth = max(maxWidth - contentInsets.left - contentInsets.right, 120) let labelSize = textLabel.sizeThatFits(CGSize(width: labelMaxWidth, height: .greatestFiniteMagnitude)) frame.size = CGSize( width: min(maxWidth, labelSize.width + contentInsets.left + contentInsets.right), height: labelSize.height + contentInsets.top + contentInsets.bottom + arrowSize.height ) setNeedsLayout() layoutIfNeeded() } private func bubblePath(in rect: CGRect) -> UIBezierPath { let bubbleRect: CGRect switch arrowPlacement { case .bottom: bubbleRect = CGRect( x: rect.minX, y: rect.minY, width: rect.width, height: rect.height - arrowSize.height ) case .top: bubbleRect = CGRect( x: rect.minX, y: rect.minY + arrowSize.height, width: rect.width, height: rect.height - arrowSize.height ) } let arrowMidX = min( max(arrowTipX ?? bubbleRect.midX, minimumArrowX), bubbleRect.width - minimumArrowX ) let arrowHalfWidth = arrowSize.width / 2 let path = UIBezierPath() switch arrowPlacement { case .bottom: path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY)) path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY)) path.addArc( withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius), radius: cornerRadius, startAngle: -.pi / 2, endAngle: 0, clockwise: true ) path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius)) path.addArc( withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius), radius: cornerRadius, startAngle: 0, endAngle: .pi / 2, clockwise: true ) path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY)) path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height)) path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY)) path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY)) path.addArc( withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius), radius: cornerRadius, startAngle: .pi / 2, endAngle: .pi, clockwise: true ) path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius)) path.addArc( withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius), radius: cornerRadius, startAngle: .pi, endAngle: -.pi / 2, clockwise: true ) case .top: path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY)) path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.minY)) path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.minY - arrowSize.height)) path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.minY)) path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY)) path.addArc( withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius), radius: cornerRadius, startAngle: -.pi / 2, endAngle: 0, clockwise: true ) path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius)) path.addArc( withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius), radius: cornerRadius, startAngle: 0, endAngle: .pi / 2, clockwise: true ) path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY)) path.addArc( withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius), radius: cornerRadius, startAngle: .pi / 2, endAngle: .pi, clockwise: true ) path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius)) path.addArc( withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius), radius: cornerRadius, startAngle: .pi, endAngle: -.pi / 2, clockwise: true ) } path.close() return path } }