From babc0d47b6a25af5c75677274a4c4090865ad5fc Mon Sep 17 00:00:00 2001 From: Kevin <141606011+kevinneko@users.noreply.github.com> Date: Wed, 10 Jul 2024 16:43:29 +0900 Subject: [PATCH] Feat UIKit Tooltip (#232) * Add Tooltip view * Refactor Tooltip style * Can adjust tooltip position * Add tooltip playground for testing * Refine layout tips logic * Refine layout logic * Update CharcoalTooltip.swift * Refine tooltip spacing * Add CharcoalIdentifiableOverlayView * Use Actor to prevent Data Race * Remove CharcoalIdentifiableOverlayView out * Clean code * Only update view when it is isPresenting * Clean access control * Add TooltipsView * Fix tooltipY layout logic * Use main actor and remove CharcoalContainerManagerKey * Fix access control on CharcoalContainerManager * Make viewID as @State * Use EnviromentObject to create CharcoalContainerManager for each container * Use ObservedObject on CharcoalContainerManager * Add use charcoal button as demo trigger * Add arrow logic on tooltip * Refine arrow logic * Refine arrow layout logic * Use StateObject to prevent unexpected reinit * Refactor TooltipBubbleShape * Fix edge layout logic * Add comment * Format code * Use new approach to remove adaptiveMaxWidth * Fix the tip bubble's position latency * Add dismiss when interaction * Reformat * Init CharcoalTooltipView * Init Bubble shape * Refine tooltip preview * Rename as Charcoal Bubble Shape * Add Label to tooltip * Update text frame when traitCollection did change * Update CharcoalTooltipView.swift * Add CharcoalTooltip * Can debug show on method * Can layout point * Can redraw target point * Update CharcoalTooltip.swift * Refine tooltip display * Share the logic * Use interaction mode * Use CharcoalOverlayContainerView * Update CharcoalOverlay.swift * Refactor to ChacoalOverlayManager * Makes CharcoalIdentifiableOverlayView Identifiable * Refine layout logic * Add display(view: CharcoalIdentifiableOverlayView) * Add tooltip to uikit example * Update Tooltips.swift * Add CharcoalIdentifiableOverlayDelegate * Reformat * Update StringExtension.swift * Add to UIKitSample * Fix public requirements * Reformat * Use touch began to handle dismiss on touch * Update CharcoalIdentifiableOverlayView.swift * Move show logic out * Reformat * Refine dismiss method * Add ActionContent and ActionComplete callback * Update CharcoalBubbleShape_UIKit.swift * Make use of Autolayout logic * Reformat * Refine deinit * Move config cell to TooltipTableViewCell * guard backgroundView == nil * Remove public of ChacoalOverlayManager * Make tooltipXY private * Reformat * refactor backgroundView --- .../ContentViewController.swift | 3 + .../Views/Tooltips/TooltipTableViewCell.swift | 78 ++++++++++ .../Views/Tooltips/Tooltips.swift | 135 ++++++++++++++++ README.md | 2 +- Sources/Charcoal/Charcoal.docc/Charcoal.md | 2 +- .../Overlay/ChacoalOverlayManager.swift | 139 +++++++++++++++++ .../CharcoalIdentifiableOverlayView.swift | 66 ++++++++ .../CharcoalOverlayInteractionMode.swift | 7 + .../Tooltip/CharcoalAnchorable.swift | 6 + .../Tooltip/CharcoalBubbleShape_UIKit.swift | 143 +++++++++++++++++ .../Components/Tooltip/CharcoalTooltip.swift | 145 ++++++++++++++++++ .../Tooltip/CharcoalTooltipView.swift | 101 ++++++++++++ .../Extensions/StringExtension.swift | 11 ++ 13 files changed, 836 insertions(+), 2 deletions(-) create mode 100644 CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Tooltips/TooltipTableViewCell.swift create mode 100644 CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Tooltips/Tooltips.swift create mode 100644 Sources/CharcoalUIKit/Components/Overlay/ChacoalOverlayManager.swift create mode 100644 Sources/CharcoalUIKit/Components/Overlay/CharcoalIdentifiableOverlayView.swift create mode 100644 Sources/CharcoalUIKit/Components/Overlay/CharcoalOverlayInteractionMode.swift create mode 100644 Sources/CharcoalUIKit/Components/Tooltip/CharcoalAnchorable.swift create mode 100644 Sources/CharcoalUIKit/Components/Tooltip/CharcoalBubbleShape_UIKit.swift create mode 100644 Sources/CharcoalUIKit/Components/Tooltip/CharcoalTooltip.swift create mode 100644 Sources/CharcoalUIKit/Components/Tooltip/CharcoalTooltipView.swift create mode 100644 Sources/CharcoalUIKit/Extensions/StringExtension.swift diff --git a/CharcoalUIKitSample/Sources/CharcoalUIKitSample/ContentViewController.swift b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/ContentViewController.swift index 64debb9d5..ef7f73a5a 100644 --- a/CharcoalUIKitSample/Sources/CharcoalUIKitSample/ContentViewController.swift +++ b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/ContentViewController.swift @@ -49,6 +49,7 @@ public final class ContentViewController: UIViewController { case colors = "Colors" case typographies = "Typographies" case icons = "Icons" + case tooltips = "Tooltips" case spinners = "Spinners" var viewController: UIViewController { @@ -65,6 +66,8 @@ public final class ContentViewController: UIViewController { return SelectionsViewController() case .textFields: return TextFieldsViewController() + case .tooltips: + return TooltipsViewController() case .spinners: return SpinnersViewController() } diff --git a/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Tooltips/TooltipTableViewCell.swift b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Tooltips/TooltipTableViewCell.swift new file mode 100644 index 000000000..c1d4ebc6a --- /dev/null +++ b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Tooltips/TooltipTableViewCell.swift @@ -0,0 +1,78 @@ +import CharcoalShared +import UIKit + +class TooltipTableViewCell: UITableViewCell { + static let identifier = "TooltipCell" + + let titleLabel: UILabel = { + let label = UILabel() + label.font = UIFont.systemFont(ofSize: 16, weight: .medium) + label.textColor = UIColor.black + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + let leadingImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + let accessoryImageView: UIImageView = { + let imageView = UIImageView() + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(titleLabel) + contentView.addSubview(leadingImageView) + contentView.addSubview(accessoryImageView) + + NSLayoutConstraint.activate([ + leadingImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 10), + leadingImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) + ]) + + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: leadingImageView.trailingAnchor, constant: 10), + titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) + ]) + + NSLayoutConstraint.activate([ + accessoryImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10), + accessoryImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) + ]) + } + + func configCell(type: TooltipTitles) { + titleLabel.text = type.rawValue + switch type { + case .leading: + leadingImageView.image = CharcoalAsset.Images.info24.image + case .trailing: + accessoryImageView.image = CharcoalAsset.Images.info24.image + case .bottom: + break + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + } + + override func prepareForReuse() { + super.prepareForReuse() + titleLabel.text = nil + leadingImageView.image = nil + } +} diff --git a/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Tooltips/Tooltips.swift b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Tooltips/Tooltips.swift new file mode 100644 index 000000000..a73b6583b --- /dev/null +++ b/CharcoalUIKitSample/Sources/CharcoalUIKitSample/Views/Tooltips/Tooltips.swift @@ -0,0 +1,135 @@ +import Charcoal +import UIKit + +enum TooltipTitles: String, CaseIterable { + case leading = "Leading" + case trailing = "Trailing" + case bottom = "Bottom" + + var text: String { + switch self { + case .leading: + return "Hello World" + case .trailing: + return "Hello World This is a tooltip with mutiple line" + case .bottom: + return "こんにちは This is a tooltip and here is testing it's multiple line feature" + } + } +} + +public final class TooltipsViewController: UIViewController { + private lazy var tableView: UITableView = { + let view = UITableView(frame: .zero, style: .insetGrouped) + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + lazy var bottomInfoImage: UIImageView = { + let imageView = UIImageView(image: CharcoalAsset.Images.info16.image) + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private enum Sections: Int, CaseIterable { + case components + + var title: String { + switch self { + case .components: + return "Tooltips" + } + } + + var items: [any CaseIterable] { + switch self { + case .components: + return TooltipTitles.allCases + } + } + } + + private enum SettingsTitles: String, CaseIterable { + case darkMode = "Dark Mode" + case fixedSizeCategory = "Fixed Size Category" + } + + override public func viewDidLoad() { + super.viewDidLoad() + setupNavigationBar() + setupUI() + } + + private func setupNavigationBar() { + navigationItem.title = "Charcoal" + } + + private func setupUI() { + view.addSubview(tableView) + + NSLayoutConstraint.activate([ + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + tableView.dataSource = self + tableView.delegate = self + + view.addSubview(bottomInfoImage) + + NSLayoutConstraint.activate([ + bottomInfoImage.centerXAnchor.constraint(equalTo: view.centerXAnchor), + bottomInfoImage.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -20) + ]) + } +} + +extension TooltipsViewController: UITableViewDelegate, UITableViewDataSource { + public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let section = Sections.allCases[indexPath.section] + + let cellIdentifier = TooltipTableViewCell.identifier + let cell: TooltipTableViewCell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier) as? TooltipTableViewCell ?? TooltipTableViewCell(style: .default, reuseIdentifier: cellIdentifier) + + switch section { + case .components: + let titleCase = TooltipTitles.allCases[indexPath.row] + cell.configCell(type: titleCase) + return cell + } + } + + public func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { + return Sections.allCases[section].items.count + } + + public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let cell = tableView.cellForRow(at: indexPath) as! TooltipTableViewCell + tableView.deselectRow(at: indexPath, animated: true) + let titleCase = TooltipTitles.allCases[indexPath.row] + switch titleCase { + case .leading: + CharcoalTooltip.show(text: titleCase.text, anchorView: cell.leadingImageView) + case .trailing: + CharcoalTooltip.show(text: titleCase.text, anchorView: cell.accessoryImageView) + case .bottom: + CharcoalTooltip.show(text: titleCase.text, anchorView: bottomInfoImage) + } + } + + public func numberOfSections(in tableView: UITableView) -> Int { + return Sections.allCases.count + } + + public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return Sections.allCases[section].title + } +} + +@available(iOS 17.0, *) +#Preview { + let viewController = TooltipsViewController() + return viewController +} diff --git a/README.md b/README.md index 5e6850d46..9267d0010 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ let label = CharcoalTypography20() label.isBold = true label.text = "Hello" -let buttton = CharcoalPrimaryMButton() +let button = CharcoalPrimaryMButton() button.setTitle("OK", for: .normal) ``` diff --git a/Sources/Charcoal/Charcoal.docc/Charcoal.md b/Sources/Charcoal/Charcoal.docc/Charcoal.md index 694d6650d..01bd49316 100644 --- a/Sources/Charcoal/Charcoal.docc/Charcoal.md +++ b/Sources/Charcoal/Charcoal.docc/Charcoal.md @@ -30,7 +30,7 @@ let label = CharcoalTypography20() label.isBold = true label.text = "Hello" -let buttton = CharcoalPrimaryMButton() +let button = CharcoalPrimaryMButton() button.setTitle("OK", for: .normal) ``` diff --git a/Sources/CharcoalUIKit/Components/Overlay/ChacoalOverlayManager.swift b/Sources/CharcoalUIKit/Components/Overlay/ChacoalOverlayManager.swift new file mode 100644 index 000000000..ddb7b6acd --- /dev/null +++ b/Sources/CharcoalUIKit/Components/Overlay/ChacoalOverlayManager.swift @@ -0,0 +1,139 @@ +import UIKit + +/** + Displays a overlay on the screen. + */ +class ChacoalOverlayManager: UIView { + /// The window to display the overlays in. + var mainView: UIView! + /// The background view of the overlays. + var backgroundView: UIView? + /// The container view array of the overlays. + var overlayContainerViews: [CharcoalIdentifiableOverlayView] = [] + /// Shared instance of the overlay manager. + static let shared = ChacoalOverlayManager() +} + +// MARK: - Window + +extension ChacoalOverlayManager { + /// Initializes the spinner with the given window. + func setupSuperView(view: UIView?) { + if let view = view { + mainView = view + } else { + if mainView == nil { + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .first + mainView = scene?.windows.filter { $0.isKeyWindow }.first ?? + UIApplication.shared.windows.first + } + } + } +} + +// MARK: - Background + +extension ChacoalOverlayManager { + private func removeBackground() { + backgroundView?.removeFromSuperview() + backgroundView = nil + } + + private func setupBackground() { + guard backgroundView == nil else { + return + } + + let backgroundView = UIView(frame: .zero) + backgroundView.isUserInteractionEnabled = false + backgroundView.translatesAutoresizingMaskIntoConstraints = false + mainView.addSubview(backgroundView) + + let constraints: [NSLayoutConstraint] = [ + backgroundView.leadingAnchor.constraint(equalTo: mainView.leadingAnchor, constant: 0), + backgroundView.topAnchor.constraint(equalTo: mainView.topAnchor, constant: 0), + backgroundView.bottomAnchor.constraint(equalTo: mainView.bottomAnchor, constant: 0), + backgroundView.rightAnchor.constraint(equalTo: mainView.rightAnchor, constant: 0) + ] + + NSLayoutConstraint.activate(constraints) + + self.backgroundView = backgroundView + } +} + +// MARK: - Container + +extension ChacoalOverlayManager { + private func setupContainer(_ interactionMode: CharcoalOverlayInteractionMode) -> CharcoalIdentifiableOverlayView { + let containerView = CharcoalIdentifiableOverlayView(interactionMode: interactionMode) + containerView.alpha = 0 + containerView.translatesAutoresizingMaskIntoConstraints = false + mainView.addSubview(containerView) + overlayContainerViews.append(containerView) + let constraints: [NSLayoutConstraint] = [ + containerView.leadingAnchor.constraint(equalTo: mainView.leadingAnchor), + containerView.topAnchor.constraint(equalTo: mainView.topAnchor), + containerView.heightAnchor.constraint(equalTo: mainView.heightAnchor), + containerView.widthAnchor.constraint(equalTo: mainView.widthAnchor) + ] + + NSLayoutConstraint.activate(constraints) + + return containerView + } +} + +// MARK: - Show, Dismiss + +extension ChacoalOverlayManager { + @discardableResult + func layout( + view: UIView, + transparentBackground: Bool = false, + interactionMode: CharcoalOverlayInteractionMode = .dimissOnTouch, + on superView: UIView? = nil + ) -> CharcoalIdentifiableOverlayView { + setupSuperView(view: superView) + setupBackground() + let containerView = setupContainer(interactionMode) + containerView.addSubview(view) + return containerView + } + + func display() { + for containerView in overlayContainerViews { + containerView.display() + } + } + + @objc func dismiss() { + for containerView in overlayContainerViews { + containerView.dismiss() + } + } + + /// Displays the overlay. + func display(view: CharcoalIdentifiableOverlayView) { + view.display() + } + + /// Dismisses the overlay with the given identifier. + func dismiss(id: CharcoalIdentifiableOverlayView.IDValue) { + let containerView = overlayContainerViews.first { $0.id == id } + containerView?.dismiss() + } +} + +// MARK: - CharcoalIdentifiableOverlayDelegate + +extension ChacoalOverlayManager: CharcoalIdentifiableOverlayDelegate { + func overlayViewDidDismiss(_ overlayView: CharcoalIdentifiableOverlayView) { + overlayContainerViews = overlayContainerViews.filter { $0.id != overlayView.id } + if overlayContainerViews.isEmpty { + removeBackground() + } + } +} diff --git a/Sources/CharcoalUIKit/Components/Overlay/CharcoalIdentifiableOverlayView.swift b/Sources/CharcoalUIKit/Components/Overlay/CharcoalIdentifiableOverlayView.swift new file mode 100644 index 000000000..373f9d8a9 --- /dev/null +++ b/Sources/CharcoalUIKit/Components/Overlay/CharcoalIdentifiableOverlayView.swift @@ -0,0 +1,66 @@ +import UIKit + +protocol CharcoalIdentifiableOverlayDelegate: AnyObject { + func overlayViewDidDismiss(_ overlayView: CharcoalIdentifiableOverlayView) +} + +public class CharcoalIdentifiableOverlayView: UIView, Identifiable { + public typealias IDValue = UUID + + public typealias ActionComplete = (Bool) -> Void + + public typealias ActionContent = (ActionComplete?) -> Void + + public let id = IDValue() + + let interactionMode: CharcoalOverlayInteractionMode + + /// Action to show the overlay. + var showAction: ActionContent? + + /// Action to dismiss the overlay. + var dismissAction: ActionContent? + + weak var delegate: CharcoalIdentifiableOverlayDelegate? + + init(interactionMode: CharcoalOverlayInteractionMode) { + self.interactionMode = interactionMode + super.init(frame: .zero) + backgroundColor = UIColor.clear + + switch interactionMode { + case .block: + isUserInteractionEnabled = true + case .dimissOnTouch: + isUserInteractionEnabled = true + case .passThrough: + isUserInteractionEnabled = false + } + } + + override public func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + + // Dismiss on tap or scroll + if interactionMode == .dimissOnTouch { + dismiss() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func display() { + showAction?(nil) + } + + @objc func dismiss() { + dismissAction?() { [weak self] _ in + guard let self = self else { return } + self.removeFromSuperview() + self.delegate?.overlayViewDidDismiss(self) + } + } +} diff --git a/Sources/CharcoalUIKit/Components/Overlay/CharcoalOverlayInteractionMode.swift b/Sources/CharcoalUIKit/Components/Overlay/CharcoalOverlayInteractionMode.swift new file mode 100644 index 000000000..13974a6d5 --- /dev/null +++ b/Sources/CharcoalUIKit/Components/Overlay/CharcoalOverlayInteractionMode.swift @@ -0,0 +1,7 @@ +import Foundation + +enum CharcoalOverlayInteractionMode { + case passThrough + case block + case dimissOnTouch +} diff --git a/Sources/CharcoalUIKit/Components/Tooltip/CharcoalAnchorable.swift b/Sources/CharcoalUIKit/Components/Tooltip/CharcoalAnchorable.swift new file mode 100644 index 000000000..0565fd402 --- /dev/null +++ b/Sources/CharcoalUIKit/Components/Tooltip/CharcoalAnchorable.swift @@ -0,0 +1,6 @@ +import Foundation + +protocol CharcoalAnchorable { + var arrowHeight: CGFloat { get } + func updateTargetPoint(point: CGPoint) +} diff --git a/Sources/CharcoalUIKit/Components/Tooltip/CharcoalBubbleShape_UIKit.swift b/Sources/CharcoalUIKit/Components/Tooltip/CharcoalBubbleShape_UIKit.swift new file mode 100644 index 000000000..1441ad6f0 --- /dev/null +++ b/Sources/CharcoalUIKit/Components/Tooltip/CharcoalBubbleShape_UIKit.swift @@ -0,0 +1,143 @@ +import UIKit + +enum CharcoalTooltipLayoutPriority: Codable { + case bottom + case top + case right + case left +} + +class CharcoalBubbleShape: CAShapeLayer { + var targetPoint: CGPoint + var arrowHeight: CGFloat + var bubbleRadius: CGFloat + var arrowWidth: CGFloat + + init(targetPoint: CGPoint, arrowHeight: CGFloat, bubbleRadius: CGFloat, arrowWidth: CGFloat) { + self.targetPoint = targetPoint + self.arrowHeight = arrowHeight + self.bubbleRadius = bubbleRadius + self.arrowWidth = arrowWidth + super.init() + fillColor = CharcoalAsset.ColorPaletteGenerated.surface8.color.cgColor + updatePath() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updatePath() { + let rect = bounds + + var pointPosition: CharcoalTooltipLayoutPriority + + if targetPoint.x < rect.minX && targetPoint.y > rect.minY { + pointPosition = .left + } else if targetPoint.x > rect.maxX && targetPoint.y > rect.minY { + pointPosition = .right + } else if targetPoint.y < rect.minY { + pointPosition = .top + } else { + pointPosition = .bottom + } + + let p = UIBezierPath() + p.move(to: .init(x: rect.minX + bubbleRadius, y: rect.minY)) + if pointPosition == .top { + let arrowY = rect.minY - arrowHeight + let arrowBaseY = rect.minY + + // The minimum and maximum x position of the arrow + let minX = rect.minX + bubbleRadius + arrowWidth + let maxX = rect.maxX - bubbleRadius - arrowWidth + + // The x position of the arrow + let arrowMidX = min(max(minX, targetPoint.x), maxX) + + let arrowMaxX = arrowMidX + arrowWidth + + let arrowMinX = arrowMidX - arrowWidth + + p.addLine(to: CGPoint(x: arrowMinX, y: arrowBaseY)) + p.addLine(to: CGPoint(x: arrowMidX, y: arrowY)) + p.addLine(to: CGPoint(x: arrowMaxX, y: arrowBaseY)) + } + + p.addLine(to: .init(x: rect.maxX - bubbleRadius, y: rect.minY)) + p.addArc(withCenter: .init(x: rect.maxX - bubbleRadius, y: rect.minY + bubbleRadius), radius: bubbleRadius, startAngle: CGFloat.pi * 3 / 2, endAngle: 0, clockwise: true) + + if pointPosition == .right { + let arrowX = rect.maxX + arrowHeight + let arrowBaseX = rect.maxX + + // The minimum and maximum x position of the arrow + let minY = rect.minY + bubbleRadius + arrowWidth + let maxY = rect.maxY - bubbleRadius - arrowWidth + + // The x position of the arrow + let arrowMidY = min(max(minY, targetPoint.y), maxY) + + let arrowMaxY = arrowMidY + arrowWidth + + let arrowMinY = arrowMidY - arrowWidth + + p.addLine(to: CGPoint(x: arrowBaseX, y: arrowMinY)) + p.addLine(to: CGPoint(x: arrowX, y: arrowMidY)) + p.addLine(to: CGPoint(x: arrowBaseX, y: arrowMaxY)) + } + + p.addLine(to: .init(x: rect.maxX, y: rect.maxY - bubbleRadius)) + p.addArc(withCenter: .init(x: rect.maxX - bubbleRadius, y: rect.maxY - bubbleRadius), radius: bubbleRadius, startAngle: 0, endAngle: CGFloat.pi / 2, clockwise: true) + + if pointPosition == .bottom { + let arrowY = rect.maxY + arrowHeight + let arrowBaseY = rect.maxY + + // The minimum and maximum x position of the arrow + let minX = rect.minX + bubbleRadius + arrowWidth + let maxX = rect.maxX - bubbleRadius - arrowWidth + + // The x position of the arrow + let arrowMidX = min(max(minX, targetPoint.x), maxX) + + let arrowMaxX = arrowMidX - arrowWidth + let arrowMinX = arrowMidX + arrowWidth + p.addLine(to: CGPoint(x: arrowMinX, y: arrowBaseY)) + p.addLine(to: CGPoint(x: arrowMidX, y: arrowY)) + p.addLine(to: CGPoint(x: arrowMaxX, y: arrowBaseY)) + } + p.addLine(to: .init(x: rect.minX + bubbleRadius, y: rect.maxY)) + p.addArc(withCenter: .init(x: rect.minX + bubbleRadius, y: rect.maxY - bubbleRadius), radius: bubbleRadius, startAngle: CGFloat.pi / 2, endAngle: CGFloat.pi, clockwise: true) + if pointPosition == .left { + let arrowX = rect.minX - arrowHeight + let arrowBaseX = rect.minX + + // The minimum and maximum x position of the arrow + let minY = rect.minY + bubbleRadius + arrowWidth + let maxY = rect.maxY - bubbleRadius - arrowWidth + + // The x position of the arrow + let arrowMidY = min(max(minY, targetPoint.y), maxY) + + let arrowMaxY = arrowMidY + arrowWidth + + let arrowMinY = arrowMidY - arrowWidth + + p.addLine(to: CGPoint(x: arrowBaseX, y: arrowMaxY)) + p.addLine(to: CGPoint(x: arrowX, y: arrowMidY)) + p.addLine(to: CGPoint(x: arrowBaseX, y: arrowMinY)) + } + p.addLine(to: .init(x: rect.minX, y: rect.minY + bubbleRadius)) + p.addArc(withCenter: .init(x: rect.minX + bubbleRadius, y: rect.minX + bubbleRadius), radius: bubbleRadius, startAngle: CGFloat.pi, endAngle: CGFloat.pi * 3 / 2, clockwise: true) + p.close() + + path = p.cgPath + } + + override func layoutSublayers() { + super.layoutSublayers() + updatePath() // Update the path when the layer is resized + } +} diff --git a/Sources/CharcoalUIKit/Components/Tooltip/CharcoalTooltip.swift b/Sources/CharcoalUIKit/Components/Tooltip/CharcoalTooltip.swift new file mode 100644 index 000000000..003d9d459 --- /dev/null +++ b/Sources/CharcoalUIKit/Components/Tooltip/CharcoalTooltip.swift @@ -0,0 +1,145 @@ +import UIKit + +public class CharcoalTooltip {} + +public extension CharcoalTooltip { + /** + Show a tooltip anchored to a view. + + - Parameters: + - text: The text to be displayed in the tooltip. + - anchorView: The view to which the tooltip will be anchored. + - on: The view on which the tooltip will be displayed. If not provided, the tooltip will be displayed on the window. + + # Example # + ```swift + CharcoalTooltip.show(text: "This is a tooltip", anchorView: someView) + ``` + */ + @discardableResult + static func show(text: String, anchorView: UIView, on: UIView? = nil) -> CharcoalIdentifiableOverlayView.IDValue { + let tooltip = CharcoalTooltipView(text: text, targetPoint: .zero) + + tooltip.translatesAutoresizingMaskIntoConstraints = false + + let containerView = ChacoalOverlayManager.shared.layout(view: tooltip, interactionMode: .dimissOnTouch, on: on) + containerView.delegate = ChacoalOverlayManager.shared + + let mainView = ChacoalOverlayManager.shared.mainView! + let spacingToScreen: CGFloat = 16 + let gap: CGFloat = 4 + let viewSize = tooltip.intrinsicContentSize + + let anchorPoint = anchorView.superview!.convert(anchorView.frame.origin, to: containerView) + let targetPoint = anchorView.superview!.convert(anchorView.center, to: tooltip) + let newAnchorRect = CGRect(x: anchorPoint.x, y: anchorPoint.y, width: anchorView.frame.width, height: anchorView.frame.height) + + let viewLeadingConstant = tooltipX(anchorFrame: newAnchorRect, tooltipSize: viewSize, canvasGeometrySize: mainView.frame.size, spacingToScreen: spacingToScreen) + + let viewTopConstant = tooltipY(anchorFrame: newAnchorRect, arrowHeight: tooltip.arrowHeight, tooltipSize: viewSize, canvasGeometrySize: mainView.frame.size, spacingToTarget: gap) + + let newTargetPoint = CGPoint(x: targetPoint.x - viewLeadingConstant, y: targetPoint.y - viewTopConstant) + tooltip.updateTargetPoint(point: newTargetPoint) + + let constraints: [NSLayoutConstraint] = [ + tooltip.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: viewLeadingConstant), + tooltip.topAnchor.constraint(equalTo: containerView.topAnchor, constant: viewTopConstant) + ] + NSLayoutConstraint.activate(constraints) + + containerView.showAction = { [weak containerView] actionCallback in + UIView.animate(withDuration: 0.25, animations: { + containerView?.alpha = 1 + }) { completion in + actionCallback?(completion) + } + } + + containerView.dismissAction = { [weak containerView] actionCallback in + UIView.animate(withDuration: 0.25, animations: { + containerView?.alpha = 0 + }) { completion in + actionCallback?(completion) + } + } + + ChacoalOverlayManager.shared.display(view: containerView) + + return containerView.id + } + + /// Dismisses the tooltip with the given identifier. + static func dismiss(id: CharcoalIdentifiableOverlayView.IDValue) { + ChacoalOverlayManager.shared.dismiss(id: id) + } + + private static func tooltipX(anchorFrame: CGRect, tooltipSize: CGSize, canvasGeometrySize: CGSize, spacingToScreen: CGFloat) -> CGFloat { + let minX = anchorFrame.midX - (tooltipSize.width / 2.0) + + var edgeLeft = minX + + if edgeLeft + tooltipSize.width >= canvasGeometrySize.width { + edgeLeft = canvasGeometrySize.width - tooltipSize.width - spacingToScreen + } else if edgeLeft < spacingToScreen { + edgeLeft = spacingToScreen + } + + return edgeLeft + } + + private static func tooltipY(anchorFrame: CGRect, arrowHeight: CGFloat, tooltipSize: CGSize, canvasGeometrySize: CGSize, spacingToTarget: CGFloat) -> CGFloat { + let minX = anchorFrame.maxY + spacingToTarget + arrowHeight + var edgeBottom = anchorFrame.maxY + spacingToTarget + anchorFrame.height + if edgeBottom + tooltipSize.height >= canvasGeometrySize.height { + edgeBottom = anchorFrame.minY - tooltipSize.height - spacingToTarget - arrowHeight + } + + return min(minX, edgeBottom) + } +} + +@available(iOS 17.0, *) +#Preview() { + let view = UIView() + view.backgroundColor = UIColor.lightGray + + let button = CharcoalPrimaryMButton() + button.setTitle("OK", for: .normal) + button.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(button) + + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: view.centerXAnchor), + button.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + let button2 = CharcoalPrimaryMButton() + button2.setTitle("OK", for: .normal) + button2.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(button2) + + NSLayoutConstraint.activate([ + button2.topAnchor.constraint(equalTo: view.topAnchor), + button2.leadingAnchor.constraint(equalTo: view.leadingAnchor) + ]) + + let button3 = CharcoalPrimaryMButton() + button3.setTitle("OK", for: .normal) + button3.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(button3) + + NSLayoutConstraint.activate([ + button3.bottomAnchor.constraint(equalTo: view.bottomAnchor), + button3.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + DispatchQueue.main.async { + CharcoalTooltip.show(text: "Hello World", anchorView: button) + + CharcoalTooltip.show(text: "Hello World This is a tooltip", anchorView: button2) + + CharcoalTooltip.show(text: "こんにちは This is a tooltip and here is testing it's multiple line feature", anchorView: button3) + } + + return view +} diff --git a/Sources/CharcoalUIKit/Components/Tooltip/CharcoalTooltipView.swift b/Sources/CharcoalUIKit/Components/Tooltip/CharcoalTooltipView.swift new file mode 100644 index 000000000..49b34d71b --- /dev/null +++ b/Sources/CharcoalUIKit/Components/Tooltip/CharcoalTooltipView.swift @@ -0,0 +1,101 @@ +import UIKit + +class CharcoalTooltipView: UIView, CharcoalAnchorable { + lazy var label: CharcoalTypography12 = { + let label = CharcoalTypography12() + label.numberOfLines = 0 + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = CharcoalAsset.ColorPaletteGenerated.text5.color + return label + }() + + let text: String + + let bubbleShape: CharcoalBubbleShape + + /// The corner radius of the tooltip + let cornerRadius: CGFloat = 4 + + /// The height of the arrow + let arrowHeight: CGFloat = 3 + + /// The width of the arrow + let arrowWidth: CGFloat = 5 + + /// The max width of the tooltip + let maxWidth: CGFloat + + /// Padding around the bubble + let padding = UIEdgeInsets(top: 4, left: 12, bottom: 4, right: 12) + + init(text: String, targetPoint: CGPoint, maxWidth: CGFloat = 184) { + bubbleShape = CharcoalBubbleShape(targetPoint: targetPoint, arrowHeight: arrowHeight, bubbleRadius: cornerRadius, arrowWidth: arrowWidth) + self.maxWidth = maxWidth + self.text = text + super.init(frame: .zero) + setupLayer() + } + + func updateTargetPoint(point: CGPoint) { + bubbleShape.targetPoint = point + setNeedsLayout() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayer() { + // Setup Bubble Shape + layer.addSublayer(bubbleShape) + // Setup Label + addSubview(label) + label.text = text + + label.preferredMaxLayoutWidth = maxWidth - padding.left - padding.right + + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: padding.left), + label.topAnchor.constraint(equalTo: topAnchor, constant: padding.top), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -padding.right), + label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -padding.bottom) + ]) + + setNeedsLayout() + } + + override var intrinsicContentSize: CGSize { + let labelSize = label.intrinsicContentSize + return CGSize(width: padding.left + labelSize.width + padding.right, height: padding.top + labelSize.height + padding.bottom) + } + + override func layoutSubviews() { + super.layoutSubviews() + bubbleShape.frame = bounds + } +} + +@available(iOS 17.0, *) +#Preview(traits: .sizeThatFitsLayout) { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.distribution = .fill + stackView.alignment = .center + stackView.spacing = 8.0 + + let tooltip = CharcoalTooltipView(text: "Hello World", targetPoint: CGPoint(x: 15, y: -5)) + + let tooltip2 = CharcoalTooltipView(text: "Hello World This is a tooltip", targetPoint: CGPoint(x: 110, y: 10)) + + let tooltip3 = CharcoalTooltipView(text: "here is testing it's multiple line feature", targetPoint: CGPoint(x: 50, y: 55)) + + let tooltip4 = CharcoalTooltipView(text: "こんにちは This is a tooltip and here is testing it's multiple line feature", targetPoint: CGPoint(x: -10, y: 25)) + + stackView.addArrangedSubview(tooltip) + stackView.addArrangedSubview(tooltip2) + stackView.addArrangedSubview(tooltip3) + stackView.addArrangedSubview(tooltip4) + + return stackView +} diff --git a/Sources/CharcoalUIKit/Extensions/StringExtension.swift b/Sources/CharcoalUIKit/Extensions/StringExtension.swift new file mode 100644 index 000000000..6662659e3 --- /dev/null +++ b/Sources/CharcoalUIKit/Extensions/StringExtension.swift @@ -0,0 +1,11 @@ +import UIKit + +extension String { + func calculateFrame(font: UIFont, maxWidth: CGFloat) -> CGSize { + let size = CGSize(width: maxWidth, height: .greatestFiniteMagnitude) + let options: NSStringDrawingOptions = [.usesLineFragmentOrigin, .usesFontLeading] + let attributes = [NSAttributedString.Key.font: font] + let rect = self.boundingRect(with: size, options: options, attributes: attributes, context: nil) + return rect.size + } +}