diff --git a/Package.swift b/Package.swift index 1670fb9..6c3beaf 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let package = Package( .library( name: "ToolTipKit", targets: ["ToolTipKit"] - ) + ), ], dependencies: [], targets: [ diff --git a/README.md b/README.md index ee501b0..0bc73b1 100644 --- a/README.md +++ b/README.md @@ -132,10 +132,180 @@ or you can create your own `Config` ) ToolTipManager.shared.config = toolTipConfig ``` - -## What's next -- [] SwiftUI representable code example. -- [] Backward compatibility for Apple's [TipKit](https://developer.apple.com/videos/play/wwdc2023/10229/). +--- + +# TooltipKitUI + +A simple and customizable tooltip library for SwiftUI applications. TooltipKitUI allows you to easily add beautiful tooltips to any view with extensive customization options. + +## Features + +- 🎨 Highly customizable appearance +- 📱 iOS 14+ +- ✨ Smooth animations +- 🎭 Highlight effect around target views +- 📐 Flexible arrow positioning (top/bottom) + +## Installation + +### Swift Package Manager + +Add TooltipKitUI to your project using Swift Package Manager: + +1. In Xcode, go to **File** → **Add Packages...** +2. Enter the repository URL +3. Select the version you want to use +4. Add the package to your target + +## Quick Start + +### 1. Wrap your content in `TooltipContainer` + +```swift +import SwiftUI +import TooltipKitUI + +struct ContentView: View { + var body: some View { + TooltipContainer { + // Your app content here + } + } +} +``` + +### 2. Add tooltip to any view + +```swift +struct ContentView: View { + @State private var showTooltip = false + + var body: some View { + TooltipContainer { + Button("Show Tooltip") { + showTooltip = true + } + .tooltip( + isPresented: $showTooltip, + title: "Welcome!", + description: "This is a tooltip example" + ) + } + } +} +``` + +## Configuration Parameters + +The `.tooltip()` modifier accepts the following parameters: + +### Required Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `isPresented` | `Binding` | Controls tooltip visibility | +| `title` | `String` | Tooltip title text | +| `description` | `String` | Tooltip description text | + +### Optional Parameters + +#### Arrow & Layout + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `arrowDirection` | `ArrowDirection` | `.top` | Arrow position (`.top` or `.bottom`) | +| `tooltipWidth` | `CGFloat` | `350` | Width of the tooltip | +| `spacing` | `CGFloat` | `16` | Spacing between title and description | +| `highlightCornerRadius` | `CGFloat` | `8` | Corner radius of the highlight effect | + +#### Typography + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `titleFont` | `Font` | `.system(size: 12)` | Font for the title | +| `descriptionFont` | `Font` | `.system(size: 12)` | Font for the description | +| `titleColor` | `Color` | `.black` | Color of the title text | +| `descriptionColor` | `Color` | `.gray` | Color of the description text | + +#### Padding + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `titlePadding` | `EdgeInsets` | `EdgeInsets(top: 16, leading: 16, bottom: 0, trailing: 16)` | Padding around the title | +| `descriptionPadding` | `EdgeInsets` | `EdgeInsets(top: 0, leading: 32, bottom: 32, trailing: 32)` | Padding around the description | + +#### Appearance + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `backgroundColor` | `Color` | `.white` | Background color of the tooltip | +| `shadowRadius` | `CGFloat` | `10` | Shadow radius around the tooltip | +| `cornerRadius` | `CGFloat` | `8` | Corner radius of the tooltip | + +## Usage Examples + +### Basic Tooltip + +```swift +Button("Info") { + showTooltip = true +} +.tooltip( + isPresented: $showTooltip, + title: "Information", + description: "This button provides additional information" +) +``` + +### Customized Tooltip + +```swift +Button("Custom Tooltip") { + showTooltip = true +} +.tooltip( + isPresented: $showTooltip, + title: "Custom Style", + description: "This tooltip has custom colors and fonts", + arrowDirection: .bottom, + titleFont: .system(size: 16, weight: .bold), + descriptionFont: .system(size: 14), + titleColor: .blue, + descriptionColor: .secondary, + tooltipWidth: 300, + backgroundColor: .systemBackground, + shadowRadius: 15 +) +``` + +### Tooltip with Bottom Arrow + +```swift +Image(systemName: "questionmark.circle") + .tooltip( + isPresented: $showTooltip, + title: "Help", + description: "Tap anywhere to dismiss", + arrowDirection: .bottom + ) +``` + +## Dismissal + +Tooltips are automatically dismissed when: +- User taps anywhere on the screen (outside or inside the tooltip) +- The `isPresented` binding is set to `false` programmatically + +## Requirements + +- iOS 14.0+ / macOS 11.0+ +- Swift 5.0+ +- Xcode 12.0+ + +## License + +Copyright © 2025 Mobven. All rights reserved. + --- Developed with 🖤 at [Mobven](https://mobven.com/) for [MAC+](https://apps.apple.com/tr/app/mac-online-fitness-deneyimi/id1573778936/) diff --git a/Sample/Sample.xcodeproj/project.pbxproj b/Sample/Sample.xcodeproj/project.pbxproj index 54b421a..5a46017 100644 --- a/Sample/Sample.xcodeproj/project.pbxproj +++ b/Sample/Sample.xcodeproj/project.pbxproj @@ -7,7 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 1989F0162EE1BA50000AF66B /* TooltipPreviewScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1989F0152EE1BA50000AF66B /* TooltipPreviewScreen.swift */; }; 1B9624932A38A870005CA52B /* ToolTipKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1B9624922A38A870005CA52B /* ToolTipKit */; }; + 1B9624952A38A88000A8BD94 /* TooltipKitUI in Frameworks */ = {isa = PBXBuildFile; productRef = 1B9624942A38A88000A8BD94 /* TooltipKitUI */; }; 1BE7B8ED2A38A47B00A8BD94 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BE7B8EC2A38A47B00A8BD94 /* AppDelegate.swift */; }; 1BE7B8EF2A38A47B00A8BD94 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BE7B8EE2A38A47B00A8BD94 /* SceneDelegate.swift */; }; 1BE7B8F12A38A47B00A8BD94 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BE7B8F02A38A47B00A8BD94 /* ViewController.swift */; }; @@ -17,6 +19,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 1989F0152EE1BA50000AF66B /* TooltipPreviewScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TooltipPreviewScreen.swift; sourceTree = ""; }; 1BE7B8E92A38A47B00A8BD94 /* Sample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1BE7B8EC2A38A47B00A8BD94 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 1BE7B8EE2A38A47B00A8BD94 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -34,12 +37,21 @@ buildActionMask = 2147483647; files = ( 1B9624932A38A870005CA52B /* ToolTipKit in Frameworks */, + 1B9624952A38A88000A8BD94 /* TooltipKitUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1989F0142EE1BA3A000AF66B /* SwiftUI */ = { + isa = PBXGroup; + children = ( + 1989F0152EE1BA50000AF66B /* TooltipPreviewScreen.swift */, + ); + path = SwiftUI; + sourceTree = ""; + }; 1B9624912A38A870005CA52B /* Frameworks */ = { isa = PBXGroup; children = ( @@ -68,6 +80,7 @@ 1BE7B8EB2A38A47B00A8BD94 /* Sample */ = { isa = PBXGroup; children = ( + 1989F0142EE1BA3A000AF66B /* SwiftUI */, 1BE7B8EC2A38A47B00A8BD94 /* AppDelegate.swift */, 1BE7B8EE2A38A47B00A8BD94 /* SceneDelegate.swift */, 1BE7B8F02A38A47B00A8BD94 /* ViewController.swift */, @@ -105,6 +118,7 @@ name = Sample; packageProductDependencies = ( 1B9624922A38A870005CA52B /* ToolTipKit */, + 1B9624942A38A88000A8BD94 /* TooltipKitUI */, ); productName = Sample; productReference = 1BE7B8E92A38A47B00A8BD94 /* Sample.app */; @@ -163,6 +177,7 @@ files = ( 1BE7B8F12A38A47B00A8BD94 /* ViewController.swift in Sources */, 1BE7B8ED2A38A47B00A8BD94 /* AppDelegate.swift in Sources */, + 1989F0162EE1BA50000AF66B /* TooltipPreviewScreen.swift in Sources */, 1BE7B8EF2A38A47B00A8BD94 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -387,6 +402,10 @@ isa = XCSwiftPackageProductDependency; productName = ToolTipKit; }; + 1B9624942A38A88000A8BD94 /* TooltipKitUI */ = { + isa = XCSwiftPackageProductDependency; + productName = TooltipKitUI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 1BE7B8E12A38A47B00A8BD94 /* Project object */; diff --git a/Sample/Sample/SwiftUI/TooltipPreviewScreen.swift b/Sample/Sample/SwiftUI/TooltipPreviewScreen.swift new file mode 100644 index 0000000..5a66e4b --- /dev/null +++ b/Sample/Sample/SwiftUI/TooltipPreviewScreen.swift @@ -0,0 +1,238 @@ +// +// TooltipPreviewScreen.swift +// TooltipKitUI +// +// Created by Akin Ozcan on 4.12.2025. +// + +import SwiftUI +import TooltipKitUI + +struct TooltipPreviewScreen: View { + @Environment(\.dismiss) private var dismiss + @State private var showTooltip = false + + // Text Content + @State private var tooltipTitle = "Tooltip Title" + @State private var tooltipDescription = "This is the tooltip description text. You can test all features from here." + + // Colors + @State private var titleColor = Color.black + @State private var descriptionColor = Color.gray + @State private var backgroundColor = Color.white + + // Typography + @State private var titleFontSize: CGFloat = 12 + @State private var titleFontWeight: Font.Weight = .regular + @State private var descriptionFontSize: CGFloat = 12 + @State private var descriptionFontWeight: Font.Weight = .regular + + // Layout + @State private var tooltipWidth: CGFloat = 350 + @State private var spacing: CGFloat = 16 + + var body: some View { + NavigationView { + TooltipContainer { + GeometryReader { proxy in + ZStack { + Color(.systemBackground) + .ignoresSafeArea() + + VStack(spacing: 0) { + // Test Button + testButton + .frame(maxWidth: .infinity, maxHeight: .infinity) + + // Control Panel + controlPanel + .frame(height: proxy.size.height * 0.5) + } + } + } + } + .navigationTitle("Tooltip Customization") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundColor(.gray) + } + } + } + } + } + + private var testButton: some View { + Button { + withAnimation { + showTooltip.toggle() + } + } label: { + VStack(spacing: 8) { + Image(systemName: "info.circle.fill") + .font(.system(size: 40)) + Text("Show Tooltip") + .font(.headline) + } + .foregroundColor(.blue) + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(12) + } + .tooltip( + isPresented: $showTooltip, + title: tooltipTitle, + description: tooltipDescription, + titleFont: .system(size: titleFontSize, weight: titleFontWeight), + descriptionFont: .system(size: descriptionFontSize, weight: descriptionFontWeight), + titleColor: titleColor, + descriptionColor: descriptionColor, + tooltipWidth: tooltipWidth, + spacing: spacing, + backgroundColor: backgroundColor + ) + } + + private var controlPanel: some View { + ScrollView { + VStack(spacing: 20) { + // Text Content Section + sectionHeader("Text Content") + textContentSection + + // Colors Section + sectionHeader("Colors") + colorsSection + + // Typography Section + sectionHeader("Typography") + typographySection + + // Layout Section + sectionHeader("Layout") + layoutSection + + Spacer(minLength: 20) + } + .padding() + } + .background(.ultraThinMaterial) + } + + private func sectionHeader(_ title: String) -> some View { + HStack { + Text(title) + .font(.headline) + .foregroundColor(.secondary) + Spacer() + } + } + + private var textContentSection: some View { + VStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Title") + .font(.caption) + TextField("Title", text: $tooltipTitle) + .textFieldStyle(.roundedBorder) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Description") + .font(.caption) + TextField("Description", text: $tooltipDescription, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(3...6) + } + } + } + + private var colorsSection: some View { + VStack(spacing: 12) { + colorPicker("Title Color", selection: $titleColor) + colorPicker("Description Color", selection: $descriptionColor) + colorPicker("Background Color", selection: $backgroundColor) + } + } + + private func colorPicker(_ label: String, selection: Binding) -> some View { + HStack { + Text(label) + .font(.subheadline) + Spacer() + ColorPicker("", selection: selection) + .labelsHidden() + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(Color.secondary.opacity(0.1)) + .cornerRadius(8) + } + + private var typographySection: some View { + VStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Title Font Size: \(Int(titleFontSize))") + .font(.caption) + Slider(value: $titleFontSize, in: 8...24, step: 1) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Title Font Weight") + .font(.caption) + Picker("", selection: $titleFontWeight) { + Text("Regular").tag(Font.Weight.regular) + Text("Medium").tag(Font.Weight.medium) + Text("Semibold").tag(Font.Weight.semibold) + Text("Bold").tag(Font.Weight.bold) + } + .pickerStyle(.segmented) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Description Font Size: \(Int(descriptionFontSize))") + .font(.caption) + Slider(value: $descriptionFontSize, in: 8...20, step: 1) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Description Font Weight") + .font(.caption) + Picker("", selection: $descriptionFontWeight) { + Text("Regular").tag(Font.Weight.regular) + Text("Medium").tag(Font.Weight.medium) + Text("Semibold").tag(Font.Weight.semibold) + Text("Bold").tag(Font.Weight.bold) + } + .pickerStyle(.segmented) + } + } + } + + private var layoutSection: some View { + VStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Tooltip Width: \(Int(tooltipWidth))") + .font(.caption) + Slider(value: $tooltipWidth, in: 200...500, step: 10) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Title-Description Spacing: \(Int(spacing))") + .font(.caption) + Slider(value: $spacing, in: 0...32, step: 2) + } + } + } +} + +#Preview { + TooltipPreviewScreen() +} + + diff --git a/Sample/Sample/ViewController.swift b/Sample/Sample/ViewController.swift index 34f2fd1..5807353 100644 --- a/Sample/Sample/ViewController.swift +++ b/Sample/Sample/ViewController.swift @@ -7,12 +7,30 @@ import ToolTipKit import UIKit +import SwiftUI class ViewController: UIViewController { @IBOutlet var topView: UIImageView! @IBOutlet var bottomView: UIImageView! @IBOutlet var topButton: UIButton! @IBOutlet var bottomButton: UIButton! + + private let previewButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle("SwiftUI Preview", for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 17, weight: .semibold) + button.backgroundColor = .systemGreen + button.setTitleColor(.white, for: .normal) + button.layer.cornerRadius = 12 + button.contentEdgeInsets = UIEdgeInsets(top: 12, left: 24, bottom: 12, right: 24) + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + setupPreviewButton() + } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -51,4 +69,21 @@ class ViewController: UIViewController { ]) toolTipHandler.present() } + + private func setupPreviewButton() { + view.addSubview(previewButton) + previewButton.addTarget(self, action: #selector(openSwiftUIPreview), for: .touchUpInside) + + NSLayoutConstraint.activate([ + previewButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + previewButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20) + ]) + } + + @objc private func openSwiftUIPreview() { + let swiftUIView = TooltipPreviewScreen() + let hostingController = UIHostingController(rootView: swiftUIView) + hostingController.modalPresentationStyle = .fullScreen + present(hostingController, animated: true) + } } diff --git a/Sources/TooltipKitUI/Manager/TooltipManager.swift b/Sources/TooltipKitUI/Manager/TooltipManager.swift new file mode 100644 index 0000000..2e0f7f4 --- /dev/null +++ b/Sources/TooltipKitUI/Manager/TooltipManager.swift @@ -0,0 +1,51 @@ +// +// TooltipManager.swift +// TooltipKitUI +// +// Created by Akin Ozcan on 3.12.2025. +// Copyright © 2025 Mobven. All rights reserved. +// + +import SwiftUI + +/// A manager class that coordinates tooltip dismissal across the view hierarchy. +/// +/// `TooltipManager` acts as a centralized coordinator for tooltip lifecycle management, +/// allowing multiple tooltip views to register dismissal actions. This enables consistent +/// dismissal behavior when users interact with tooltips or the overlay background. +/// +/// The manager uses SwiftUI's `ObservableObject` protocol to enable reactive updates +/// and is typically provided via `environmentObject` to child views. +/// +/// - Note: This class is available on iOS 14.0 and later. +@available(iOS 14.0, *) +class TooltipManager: ObservableObject { + /// A closure that, when executed, dismisses the currently active tooltip. + /// + /// This action is registered by tooltip views and invoked when dismissal is requested. + var dismissAction: (() -> Void)? + + /// Dismisses the currently active tooltip with animation. + /// + /// This method executes the registered dismissal action (if any) and clears it. + /// The dismissal is performed within an animation block to provide smooth transitions. + /// + /// - Note: Safe to call even if no tooltip is currently displayed. + func dismiss() { + withAnimation { + dismissAction?() + dismissAction = nil + } + } + + /// Registers a dismissal action that will be called when the tooltip should be dismissed. + /// + /// This method should be called by tooltip views to register their dismissal logic. + /// Only one dismissal action can be registered at a time; registering a new action + /// replaces any previously registered action. + /// + /// - Parameter dismiss: A closure that performs the tooltip dismissal when executed. + func register(dismiss: @escaping () -> Void) { + dismissAction = dismiss + } +} diff --git a/Sources/TooltipKitUI/Models/TooltipConfig.swift b/Sources/TooltipKitUI/Models/TooltipConfig.swift new file mode 100644 index 0000000..e623973 --- /dev/null +++ b/Sources/TooltipKitUI/Models/TooltipConfig.swift @@ -0,0 +1,175 @@ +// +// TooltipConfig.swift +// TooltipKitUI +// +// Created by Akin Ozcan on 3.12.2025. +// Copyright © 2025 Mobven. All rights reserved. +// + +import SwiftUI + +/// A configuration structure that defines the appearance, layout, and behavior of a tooltip. +/// +/// `TooltipConfig` encapsulates all customizable properties for tooltips, including typography, +/// spacing, colors, dimensions, and arrow direction. This allows for consistent tooltip styling +/// across the application while providing flexibility for customization. +/// +/// - Note: This struct is available on iOS 14.0 and later. +@available(iOS 14.0, *) +public struct TooltipConfig { + /// A unique identifier for this tooltip configuration. + public let id: String + + /// The primary text displayed at the top of the tooltip. + public let title: String + + /// The secondary descriptive text displayed below the title. + public let description: String + + /// An identifier for the target view element that this tooltip is associated with. + public let targetId: String + + /// The direction in which the tooltip arrow points relative to the target element. + /// + /// Defaults to `.top`, indicating the arrow points upward. + public var arrowDirection: ArrowDirection = .top + + /// The corner radius used for highlighting the target element's rounded rectangle. + /// + /// This value affects the visual appearance of the overlay mask that highlights + /// the target element when a tooltip is displayed. + public var highlightCornerRadius: CGFloat = 8 + + // MARK: - Typography + + /// The font used for rendering the tooltip title text. + /// + /// Defaults to a system font of size 12 points. + public var titleFont: Font = .system(size: 12) + + /// The font used for rendering the tooltip description text. + /// + /// Defaults to a system font of size 12 points. + public var descriptionFont: Font = .system(size: 12) + + /// The text color used for the tooltip title. + /// + /// Defaults to black. + public var titleColor: Color = .black + + /// The text color used for the tooltip description. + /// + /// Defaults to gray. + public var descriptionColor: Color = .gray + + // MARK: - Layout + + /// The width of the tooltip bubble in points. + /// + /// The actual rendered width may be adjusted to fit within the container bounds. + /// Defaults to 350 points. + public var tooltipWidth: CGFloat = 350 + + /// The vertical spacing between the title and description text. + /// + /// Defaults to 16 points. + public var spacing: CGFloat = 16 + + /// The padding applied around the title text. + /// + /// Defaults to 16 points on top and leading, 0 on bottom and trailing. + public var titlePadding: EdgeInsets = EdgeInsets(top: 16, leading: 16, bottom: 0, trailing: 16) + + /// The padding applied around the description text. + /// + /// Defaults to 32 points on leading, bottom, and trailing edges, 0 on top. + public var descriptionPadding: EdgeInsets = EdgeInsets(top: 0, leading: 32, bottom: 32, trailing: 32) + + // MARK: - Appearance + + /// The background color of the tooltip bubble. + /// + /// Defaults to white. + public var backgroundColor: Color = .white + + /// The blur radius of the shadow applied to the tooltip bubble. + /// + /// Defaults to 10 points. A value of 0 disables the shadow. + public var shadowRadius: CGFloat = 10 + + /// The corner radius of the tooltip bubble's rounded rectangle. + /// + /// Defaults to 8 points. This does not affect the arrow shape. + public var cornerRadius: CGFloat = 8 + + /// Creates a new tooltip configuration with the specified parameters. + /// + /// - Parameters: + /// - id: A unique identifier for this tooltip configuration. + /// - title: The primary text displayed at the top of the tooltip. + /// - description: The secondary descriptive text displayed below the title. + /// - targetId: An identifier for the target view element associated with this tooltip. + /// - arrowDirection: The direction in which the tooltip arrow points. Defaults to `.top`. + /// - highlightCornerRadius: The corner radius for the target element highlight. Defaults to 8. + /// - titleFont: The font for the title text. Defaults to system font size 12. + /// - descriptionFont: The font for the description text. Defaults to system font size 12. + /// - titleColor: The text color for the title. Defaults to black. + /// - descriptionColor: The text color for the description. Defaults to gray. + /// - tooltipWidth: The width of the tooltip bubble in points. Defaults to 350. + /// - spacing: The vertical spacing between title and description. Defaults to 16. + /// - titlePadding: The padding around the title text. Defaults to 16/16/0/16. + /// - descriptionPadding: The padding around the description text. Defaults to 0/32/32/32. + /// - backgroundColor: The background color of the tooltip bubble. Defaults to white. + /// - shadowRadius: The blur radius of the tooltip shadow. Defaults to 10. + /// - cornerRadius: The corner radius of the tooltip bubble. Defaults to 8. + public init( + id: String, + title: String, + description: String, + targetId: String, + arrowDirection: ArrowDirection = .top, + highlightCornerRadius: CGFloat = 8, + titleFont: Font = .system(size: 12), + descriptionFont: Font = .system(size: 12), + titleColor: Color = .black, + descriptionColor: Color = .gray, + tooltipWidth: CGFloat = 350, + spacing: CGFloat = 16, + titlePadding: EdgeInsets = EdgeInsets(top: 16, leading: 16, bottom: 0, trailing: 16), + descriptionPadding: EdgeInsets = EdgeInsets(top: 0, leading: 32, bottom: 32, trailing: 32), + backgroundColor: Color = .white, + shadowRadius: CGFloat = 10, + cornerRadius: CGFloat = 8 + ) { + self.id = id + self.title = title + self.description = description + self.targetId = targetId + self.arrowDirection = arrowDirection + self.highlightCornerRadius = highlightCornerRadius + self.titleFont = titleFont + self.descriptionFont = descriptionFont + self.titleColor = titleColor + self.descriptionColor = descriptionColor + self.tooltipWidth = tooltipWidth + self.spacing = spacing + self.titlePadding = titlePadding + self.descriptionPadding = descriptionPadding + self.backgroundColor = backgroundColor + self.shadowRadius = shadowRadius + self.cornerRadius = cornerRadius + } +} + +/// Defines the direction in which a tooltip arrow points relative to its target element. +/// +/// The arrow direction determines where the tooltip bubble appears in relation to the +/// highlighted target view. The system may automatically adjust this based on available +/// screen space. +public enum ArrowDirection: Sendable { + /// The arrow points upward, with the tooltip bubble positioned below the target element. + case top + + /// The arrow points downward, with the tooltip bubble positioned above the target element. + case bottom +} diff --git a/Sources/TooltipKitUI/Models/TooltipPreferenceData.swift b/Sources/TooltipKitUI/Models/TooltipPreferenceData.swift new file mode 100644 index 0000000..49c7d96 --- /dev/null +++ b/Sources/TooltipKitUI/Models/TooltipPreferenceData.swift @@ -0,0 +1,44 @@ +// +// TooltipPreferenceData.swift +// TooltipKitUI +// +// Created by Akin Ozcan on 3.12.2025. +// Copyright © 2025 Mobven. All rights reserved. +// + +import SwiftUI + +/// A data structure that combines tooltip configuration with its target view's frame information. +/// +/// `TooltipPreferenceData` is used to pass tooltip information through SwiftUI's preference +/// value system, allowing child views to communicate their tooltip requirements and layout +/// information up to a parent container view. +/// +/// This structure implements `Equatable` to enable efficient change detection and prevent +/// unnecessary re-renders when tooltip data remains unchanged. +/// +/// - Note: This struct is available on iOS 14.0 and later. +@available(iOS 14.0, *) +struct TooltipPreferenceData: Equatable { + /// The configuration object that defines the tooltip's appearance and content. + let config: TooltipConfig + + /// The frame rectangle of the target view in global coordinate space. + /// + /// This frame is used to position the tooltip relative to the target element and + /// to create the highlight overlay that dims the background around the target. + let frame: CGRect + + /// Determines equality between two `TooltipPreferenceData` instances. + /// + /// Two instances are considered equal if they have the same tooltip configuration ID + /// and the same target frame. This comparison is used to optimize view updates. + /// + /// - Parameters: + /// - lhs: The left-hand side instance to compare. + /// - rhs: The right-hand side instance to compare. + /// - Returns: `true` if both instances have the same ID and frame; otherwise, `false`. + static func == (lhs: TooltipPreferenceData, rhs: TooltipPreferenceData) -> Bool { + lhs.config.id == rhs.config.id && lhs.frame == rhs.frame + } +} diff --git a/Sources/TooltipKitUI/Modifiers/TooltipModifier.swift b/Sources/TooltipKitUI/Modifiers/TooltipModifier.swift new file mode 100644 index 0000000..b5ecdc0 --- /dev/null +++ b/Sources/TooltipKitUI/Modifiers/TooltipModifier.swift @@ -0,0 +1,181 @@ +// +// TooltipModifier.swift +// TooltipKitUI +// +// Created by Akin Ozcan on 3.12.2025. +// Copyright © 2025 Mobven. All rights reserved. +// + +import SwiftUI + +/// A view modifier that attaches tooltip functionality to any SwiftUI view. +/// +/// `TooltipModifier` enables any view to display a tooltip by monitoring its presentation state +/// and communicating its frame information up the view hierarchy via preference values. When +/// `isPresented` becomes `true`, the modifier registers the view's geometry and tooltip +/// configuration with the parent `TooltipContainer`. +/// +/// The modifier uses a stable ID to prevent unnecessary re-creation of tooltip configurations +/// when the view updates, ensuring smooth animations and consistent behavior. +/// +/// - Note: This struct is available on iOS 14.0 and later. +@available(iOS 14.0, *) +struct TooltipModifier: ViewModifier { + /// A binding that controls whether the tooltip is currently presented. + @Binding var isPresented: Bool + + /// The configuration that defines the tooltip's appearance and content. + let config: TooltipConfig + + /// The tooltip manager obtained from the environment, used to coordinate dismissal. + @EnvironmentObject var manager: TooltipManager + + /// A stable unique identifier for this tooltip instance. + /// + /// This ID persists across view updates to ensure the tooltip is recognized as the same + /// instance, preventing unnecessary re-renders and maintaining smooth transitions. + @State private var stableId = UUID().uuidString + + /// Applies the tooltip modifier to the content view. + /// + /// This method wraps the content with a geometry reader to capture the view's frame + /// in global coordinates, then uses preference values to communicate tooltip data to + /// parent containers. It also registers dismissal actions with the tooltip manager. + /// + /// - Parameter content: The view content to which the tooltip modifier is applied. + /// - Returns: A modified view that supports tooltip display functionality. + func body(content: Content) -> some View { + let stableConfig = TooltipConfig( + id: stableId, + title: config.title, + description: config.description, + targetId: config.targetId, + arrowDirection: config.arrowDirection, + highlightCornerRadius: config.highlightCornerRadius, + titleFont: config.titleFont, + descriptionFont: config.descriptionFont, + titleColor: config.titleColor, + descriptionColor: config.descriptionColor, + tooltipWidth: config.tooltipWidth, + spacing: config.spacing, + titlePadding: config.titlePadding, + descriptionPadding: config.descriptionPadding, + backgroundColor: config.backgroundColor, + shadowRadius: config.shadowRadius, + cornerRadius: config.cornerRadius + ) + + return content + .background( + GeometryReader { geo in + let globalFrame = geo.frame(in: .global) + Color.clear + .preference( + key: TooltipPreferenceKey.self, + value: isPresented ? TooltipPreferenceData( + config: stableConfig, + frame: globalFrame + ) : nil + ) + } + ) + .onChange(of: isPresented) { newValue in + if newValue { + manager.register { + isPresented = false + } + } + } + .onAppear { + if isPresented { + manager.register { + isPresented = false + } + } + } + } +} + +@available(iOS 14.0, *) +extension View { + /// Adds a tooltip to the view that appears when the binding becomes `true`. + /// + /// This convenience method creates a tooltip modifier with the specified configuration + /// and applies it to the view. The tooltip will be displayed when `isPresented` is `true`, + /// and can be dismissed by tapping the overlay or calling the manager's `dismiss()` method. + /// + /// The tooltip must be used within a `TooltipContainer` to function properly, as the + /// container is responsible for rendering the tooltip overlay and managing its presentation. + /// + /// - Parameters: + /// - isPresented: A binding that controls the tooltip's presentation state. + /// - title: The primary text displayed at the top of the tooltip. + /// - description: The secondary descriptive text displayed below the title. + /// - arrowDirection: The direction in which the tooltip arrow points. Defaults to `.top`. + /// - highlightCornerRadius: The corner radius for the target element highlight. Defaults to 8. + /// - titleFont: The font for the title text. Defaults to system font size 12. + /// - descriptionFont: The font for the description text. Defaults to system font size 12. + /// - titleColor: The text color for the title. Defaults to black. + /// - descriptionColor: The text color for the description. Defaults to gray. + /// - tooltipWidth: The width of the tooltip bubble in points. Defaults to 350. + /// - spacing: The vertical spacing between title and description. Defaults to 16. + /// - titlePadding: The padding around the title text. Defaults to 16/16/0/16. + /// - descriptionPadding: The padding around the description text. Defaults to 0/32/32/32. + /// - backgroundColor: The background color of the tooltip bubble. Defaults to white. + /// - shadowRadius: The blur radius of the tooltip shadow. Defaults to 10. + /// - cornerRadius: The corner radius of the tooltip bubble. Defaults to 8. + /// - Returns: A view modified with tooltip functionality. + /// + /// ## Example + /// ```swift + /// @State private var showTooltip = false + /// + /// Button("Tap me") { + /// showTooltip = true + /// } + /// .tooltip(isPresented: $showTooltip, + /// title: "Welcome", + /// description: "This is a helpful tooltip") + /// ``` + public func tooltip( + isPresented: Binding, + title: String, + description: String, + arrowDirection: ArrowDirection = .top, + highlightCornerRadius: CGFloat = 8, + titleFont: Font = .system(size: 12), + descriptionFont: Font = .system(size: 12), + titleColor: Color = .black, + descriptionColor: Color = .gray, + tooltipWidth: CGFloat = 350, + spacing: CGFloat = 16, + titlePadding: EdgeInsets = EdgeInsets(top: 16, leading: 16, bottom: 0, trailing: 16), + descriptionPadding: EdgeInsets = EdgeInsets(top: 0, leading: 32, bottom: 32, trailing: 32), + backgroundColor: Color = .white, + shadowRadius: CGFloat = 10, + cornerRadius: CGFloat = 8 + ) -> some View { + modifier(TooltipModifier( + isPresented: isPresented, + config: TooltipConfig( + id: UUID().uuidString, + title: title, + description: description, + targetId: "", + arrowDirection: arrowDirection, + highlightCornerRadius: highlightCornerRadius, + titleFont: titleFont, + descriptionFont: descriptionFont, + titleColor: titleColor, + descriptionColor: descriptionColor, + tooltipWidth: tooltipWidth, + spacing: spacing, + titlePadding: titlePadding, + descriptionPadding: descriptionPadding, + backgroundColor: backgroundColor, + shadowRadius: shadowRadius, + cornerRadius: cornerRadius + ) + )) + } +} diff --git a/Sources/TooltipKitUI/Shapes/TooltipShape.swift b/Sources/TooltipKitUI/Shapes/TooltipShape.swift new file mode 100644 index 0000000..a857197 --- /dev/null +++ b/Sources/TooltipKitUI/Shapes/TooltipShape.swift @@ -0,0 +1,173 @@ +// +// TooltipShape.swift +// TooltipKitUI +// +// Created by Akin Ozcan on 3.12.2025. +// Copyright © 2025 Mobven. All rights reserved. +// + +import SwiftUI + +/// A custom shape that draws a tooltip bubble with a triangular arrow. +/// +/// `TooltipShape` creates a rounded rectangle with an arrow pointing in a specified direction. +/// The arrow can be positioned at any horizontal location along the tooltip's edge, allowing +/// precise alignment with target elements. The shape supports both top and bottom arrow directions +/// and includes corner radius support for smooth, modern aesthetics. +/// +/// The shape is used as the background for tooltip views, providing the visual container +/// that distinguishes tooltips from regular views. +/// +/// - Note: This struct is available on iOS 14.0 and later. +@available(iOS 14.0, *) +struct TooltipShape: Shape { + /// The height of the arrow triangle in points. + /// + /// Defaults to 9 points. This determines how far the arrow extends from the tooltip body. + var arrowHeight: CGFloat = 9 + + /// The width of the arrow triangle at its base in points. + /// + /// Defaults to 18 points. This determines the width of the arrow's base. + var arrowWidth: CGFloat = 18 + + /// The corner radius of the rounded rectangle body in points. + /// + /// Defaults to 8 points. This value is applied to all four corners of the tooltip bubble. + var cornerRadius: CGFloat = 8 + + /// The horizontal position of the arrow as a ratio of the tooltip's width. + /// + /// Values range from 0.0 (left edge) to 1.0 (right edge), with 0.5 being centered. + /// The arrow position is calculated to point at the target element's center. + var arrowPosition: CGFloat = 0.5 + + /// The direction in which the arrow points relative to the tooltip body. + /// + /// `.top` indicates the arrow points upward (tooltip below target), + /// `.bottom` indicates the arrow points downward (tooltip above target). + var direction: ArrowDirection = .top + + /// Creates the path for the tooltip shape within the given rectangle. + /// + /// This method constructs a path that draws a rounded rectangle with a triangular arrow + /// positioned according to the specified parameters. The path handles edge cases where + /// the arrow might intersect with corner radii, ensuring smooth visual appearance. + /// + /// - Parameter rect: The rectangle in which to draw the shape. + /// - Returns: A `Path` object representing the tooltip shape with arrow. + func path(in rect: CGRect) -> Path { + var path = Path() + + let width = rect.width + let height = rect.height + + let arrowPos = arrowPosition * width + + if direction == .top { + path.move(to: CGPoint(x: cornerRadius, y: arrowHeight)) + + let arrowLeft = arrowPos - arrowWidth / 2 + if arrowLeft > cornerRadius { + path.addLine(to: CGPoint(x: arrowLeft, y: arrowHeight)) + } + + path.addLine(to: CGPoint(x: arrowPos, y: 0)) + + let arrowRight = arrowPos + arrowWidth / 2 + path.addLine(to: CGPoint(x: arrowRight, y: arrowHeight)) + + if arrowRight < width - cornerRadius { + path.addLine(to: CGPoint(x: width - cornerRadius, y: arrowHeight)) + } + + path.addArc( + center: CGPoint(x: width - cornerRadius, y: arrowHeight + cornerRadius), + radius: cornerRadius, + startAngle: Angle(degrees: -90), + endAngle: Angle(degrees: 0), + clockwise: false + ) + + path.addLine(to: CGPoint(x: width, y: height - cornerRadius)) + path.addArc( + center: CGPoint(x: width - cornerRadius, y: height - cornerRadius), + radius: cornerRadius, + startAngle: Angle(degrees: 0), + endAngle: Angle(degrees: 90), + clockwise: false + ) + + path.addLine(to: CGPoint(x: cornerRadius, y: height)) + path.addArc( + center: CGPoint(x: cornerRadius, y: height - cornerRadius), + radius: cornerRadius, + startAngle: Angle(degrees: 90), + endAngle: Angle(degrees: 180), + clockwise: false + ) + + path.addLine(to: CGPoint(x: 0, y: arrowHeight + cornerRadius)) + path.addArc( + center: CGPoint(x: cornerRadius, y: arrowHeight + cornerRadius), + radius: cornerRadius, + startAngle: Angle(degrees: 180), + endAngle: Angle(degrees: 270), + clockwise: false + ) + + } else { + path.move(to: CGPoint(x: cornerRadius, y: 0)) + path.addLine(to: CGPoint(x: width - cornerRadius, y: 0)) + path.addArc( + center: CGPoint(x: width - cornerRadius, y: cornerRadius), + radius: cornerRadius, + startAngle: Angle(degrees: -90), + endAngle: Angle(degrees: 0), + clockwise: false + ) + + path.addLine(to: CGPoint(x: width, y: height - arrowHeight - cornerRadius)) + path.addArc( + center: CGPoint(x: width - cornerRadius, y: height - arrowHeight - cornerRadius), + radius: cornerRadius, + startAngle: Angle(degrees: 0), + endAngle: Angle(degrees: 90), + clockwise: false + ) + + let arrowRight = arrowPos + arrowWidth / 2 + if arrowRight < width - cornerRadius { + path.addLine(to: CGPoint(x: arrowRight, y: height - arrowHeight)) + } + + path.addLine(to: CGPoint(x: arrowPos, y: height)) + + let arrowLeft = arrowPos - arrowWidth / 2 + path.addLine(to: CGPoint(x: arrowLeft, y: height - arrowHeight)) + + if arrowLeft > cornerRadius { + path.addLine(to: CGPoint(x: cornerRadius, y: height - arrowHeight)) + } + + path.addArc( + center: CGPoint(x: cornerRadius, y: height - arrowHeight - cornerRadius), + radius: cornerRadius, + startAngle: Angle(degrees: 90), + endAngle: Angle(degrees: 180), + clockwise: false + ) + + path.addLine(to: CGPoint(x: 0, y: cornerRadius)) + path.addArc( + center: CGPoint(x: cornerRadius, y: cornerRadius), + radius: cornerRadius, + startAngle: Angle(degrees: 180), + endAngle: Angle(degrees: 270), + clockwise: false + ) + } + + return path + } +} diff --git a/Sources/TooltipKitUI/Utils/TooltipPreferenceKey.swift b/Sources/TooltipKitUI/Utils/TooltipPreferenceKey.swift new file mode 100644 index 0000000..e27037c --- /dev/null +++ b/Sources/TooltipKitUI/Utils/TooltipPreferenceKey.swift @@ -0,0 +1,46 @@ +// +// TooltipPreferenceKey.swift +// TooltipKitUI +// +// Created by Akin Ozcan on 3.12.2025. +// Copyright © 2025 Mobven. All rights reserved. +// + +import SwiftUI + +/// A preference key that enables tooltip views to communicate their data up the view hierarchy. +/// +/// `TooltipPreferenceKey` implements SwiftUI's `PreferenceKey` protocol to facilitate +/// the propagation of tooltip information from child views to parent containers. This allows +/// the `TooltipContainer` to receive and manage tooltip display requests from any descendant view. +/// +/// The preference value type is `TooltipPreferenceData?`, where `nil` indicates no active tooltip. +/// +/// - Note: This struct is available on iOS 14.0 and later. +@available(iOS 14.0, *) +struct TooltipPreferenceKey: PreferenceKey { + /// The type of value associated with this preference key. + typealias Value = TooltipPreferenceData? + + /// The default value when no preference has been set. + /// + /// Returns `nil`, indicating no tooltip is active by default. + static var defaultValue: TooltipPreferenceData? { + nil + } + + /// Combines multiple preference values from child views into a single value. + /// + /// This method is called by SwiftUI when multiple views in the hierarchy set preference + /// values. The implementation prioritizes the most recent non-nil value, allowing the + /// latest tooltip to take precedence. + /// + /// - Parameters: + /// - value: The current accumulated preference value, modified in place. + /// - nextValue: A closure that returns the next preference value from the hierarchy. + static func reduce(value: inout TooltipPreferenceData?, nextValue: () -> TooltipPreferenceData?) { + if let next = nextValue() { + value = next + } + } +} diff --git a/Sources/TooltipKitUI/Views/TooltipContainer.swift b/Sources/TooltipKitUI/Views/TooltipContainer.swift new file mode 100644 index 0000000..e0b532d --- /dev/null +++ b/Sources/TooltipKitUI/Views/TooltipContainer.swift @@ -0,0 +1,100 @@ +// +// TooltipContainer.swift +// TooltipKitUI +// +// Created by Akin Ozcan on 3.12.2025. +// Copyright © 2025 Mobven. All rights reserved. +// + +import SwiftUI + +/// A container view that manages tooltip presentation and overlay rendering. +/// +/// `TooltipContainer` serves as the root coordinator for tooltip functionality in a view hierarchy. +/// It provides a `TooltipManager` instance to child views via the environment, collects tooltip +/// preference data from descendant views, and renders the tooltip overlay when a tooltip is active. +/// +/// The container creates a dimmed overlay with a highlighted cutout around the target view, +/// and positions the tooltip bubble relative to the target element. Users can dismiss tooltips +/// by tapping anywhere on the overlay. +/// +/// - Note: All views that use the `.tooltip()` modifier must be wrapped in a `TooltipContainer`. +/// - Note: This struct is available on iOS 14.0 and later. +/// +/// ## Example +/// ```swift +/// TooltipContainer { +/// YourContentView() +/// } +/// ``` +@available(iOS 14.0, *) +public struct TooltipContainer: View { + /// The tooltip manager instance that coordinates dismissal across the hierarchy. + /// + /// This manager is provided to child views via `environmentObject` and enables + /// centralized control of tooltip lifecycle. + @StateObject private var manager = TooltipManager() + + /// The content view builder that defines the view hierarchy to display. + @ViewBuilder let content: Content + + /// The currently active tooltip data, if any tooltip is being presented. + /// + /// This state is updated when preference values change, triggering the overlay to appear. + @State private var activeTooltipData: TooltipPreferenceData? + + /// Creates a new tooltip container with the specified content. + /// + /// - Parameter content: A view builder closure that returns the content to display. + public init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + /// The body of the container view. + /// + /// This view renders the content hierarchy and overlays the tooltip UI (dimmed background, + /// highlight cutout, and tooltip bubble) when a tooltip is active. The overlay is dismissed + /// when users tap on it or when the manager's `dismiss()` method is called. + public var body: some View { + ZStack { + content + .environmentObject(manager) + .onPreferenceChange(TooltipPreferenceKey.self) { preferenceData in + activeTooltipData = preferenceData + } + + if let tooltipData = activeTooltipData { + ZStack { + Color(.gray).opacity(0.8) + .mask( + ZStack { + Rectangle().fill(Color.white) + + RoundedRectangle(cornerRadius: tooltipData.config.highlightCornerRadius) + .frame(width: tooltipData.frame.width, height: tooltipData.frame.height) + .position(x: tooltipData.frame.midX, y: tooltipData.frame.midY) + .blendMode(.destinationOut) + } + .compositingGroup() + ) + .ignoresSafeArea() + .onTapGesture { + manager.dismiss() + } + + TooltipOverlay( + tooltip: tooltipData.config, + targetFrame: tooltipData.frame, + containerSize: UIScreen.main.bounds.size + ) + .ignoresSafeArea() + .onTapGesture { + manager.dismiss() + } + } + .id(tooltipData.config.id) + .transition(.opacity.animation(.easeInOut(duration: 0.2))) + } + } + } +} diff --git a/Sources/TooltipKitUI/Views/TooltipKitUI.swift b/Sources/TooltipKitUI/Views/TooltipKitUI.swift new file mode 100644 index 0000000..e3b6401 --- /dev/null +++ b/Sources/TooltipKitUI/Views/TooltipKitUI.swift @@ -0,0 +1,62 @@ +// +// TooltipView.swift +// TooltipKitUI +// +// Created by Akin Ozcan on 3.12.2025. +// Copyright © 2025 Mobven. All rights reserved. +// + +import SwiftUI + +/// A view that displays the visual content of a tooltip bubble. +/// +/// `TooltipView` renders the title and description text within a styled container that includes +/// a custom shape background with an arrow. The view applies typography, spacing, and padding +/// according to the provided configuration, and positions the arrow based on the specified ratio. +/// +/// This view is used internally by `TooltipOverlay` to render the actual tooltip content +/// and should not be instantiated directly by users of the framework. +/// +/// - Note: This struct is available on iOS 14.0 and later. +@available(iOS 14.0, *) +struct TooltipView: View { + /// The configuration object that defines the tooltip's appearance, content, and layout. + let config: TooltipConfig + + /// The horizontal position of the arrow as a ratio of the tooltip's width. + /// + /// Values range from 0.0 (left edge) to 1.0 (right edge). This ratio is used to position + /// the arrow so it points at the target element's center. + let arrowPosition: CGFloat + + /// The body of the tooltip view. + /// + /// This view renders a vertical stack containing the title and description text, applies + /// custom styling and padding, and wraps everything in a `TooltipShape` background with + /// shadow effects. The view includes padding to accommodate the arrow direction. + var body: some View { + VStack(alignment: .center, spacing: config.spacing) { + Text(config.title) + .font(config.titleFont) + .foregroundColor(config.titleColor) + .multilineTextAlignment(.center) + .padding(config.titlePadding) + + Text(config.description) + .font(config.descriptionFont) + .foregroundColor(config.descriptionColor) + .fixedSize(horizontal: false, vertical: true) + .multilineTextAlignment(.center) + .padding(config.descriptionPadding) + } + .frame(width: config.tooltipWidth) + .padding(.top, config.arrowDirection == .top ? 9 : 0) + .padding(.bottom, config.arrowDirection == .bottom ? 9 : 0) + .background( + TooltipShape(arrowPosition: arrowPosition, direction: config.arrowDirection) + .fill(config.backgroundColor) + .shadow(radius: config.shadowRadius) + ) + .onTapGesture {} + } +} diff --git a/Sources/TooltipKitUI/Views/TooltipOverlay.swift b/Sources/TooltipKitUI/Views/TooltipOverlay.swift new file mode 100644 index 0000000..08080fa --- /dev/null +++ b/Sources/TooltipKitUI/Views/TooltipOverlay.swift @@ -0,0 +1,119 @@ +// +// TooltipOverlay.swift +// TooltipKitUI +// +// Created by Akin Ozcan on 3.12.2025. +// Copyright © 2025 Mobven. All rights reserved. +// + +import SwiftUI + +/// A view that positions and renders a tooltip bubble relative to a target element. +/// +/// `TooltipOverlay` is responsible for calculating the optimal position for the tooltip bubble +/// based on available screen space, the target element's frame, and the tooltip's configuration. +/// It automatically determines whether to place the tooltip above or below the target, adjusts +/// horizontal positioning to keep the tooltip within screen bounds, and calculates the arrow +/// position to point at the target element's center. +/// +/// The overlay handles edge cases such as insufficient space and ensures the tooltip remains +/// fully visible and properly aligned with its target. +/// +/// - Note: This struct is available on iOS 14.0 and later. +@available(iOS 14.0, *) +struct TooltipOverlay: View { + /// The configuration that defines the tooltip's appearance and content. + let tooltip: TooltipConfig + + /// The frame rectangle of the target view in global coordinate space. + let targetFrame: CGRect + + /// The size of the container view, used for bounds checking and positioning calculations. + let containerSize: CGSize + + /// The measured size of the tooltip content, used for accurate positioning. + /// + /// This state is updated when the tooltip view's geometry changes, allowing the overlay + /// to position the tooltip correctly relative to the target element. + @State private var contentSize: CGSize = .zero + + /// The body of the overlay view. + /// + /// This view calculates the optimal tooltip position, adjusts the configuration based on + /// available space, and renders the tooltip bubble with the correct arrow positioning. + /// + /// Positioning logic: + /// - Determines arrow direction based on available space (prefers space below if > 200pt) + /// - Calculates horizontal center aligned with target, with edge constraints + /// - Computes arrow position as a ratio of tooltip width for proper pointing + var body: some View { + let minX: CGFloat = 8 + let maxAvailableWidth = containerSize.width - (minX * 2) + let tooltipWidth = min(tooltip.tooltipWidth, maxAvailableWidth) + let spaceBelow = containerSize.height - targetFrame.maxY + let renderBelow = spaceBelow > 200 + + let direction: ArrowDirection = renderBelow ? .top : .bottom + + let targetCenterX = targetFrame.midX + + var tooltipCenterX = targetCenterX + var tooltipX = tooltipCenterX - (tooltipWidth / 2) + + let maxX = containerSize.width - tooltipWidth - minX + + if tooltipX < minX { + tooltipX = minX + tooltipCenterX = tooltipX + (tooltipWidth / 2) + } else if tooltipX > maxX { + tooltipX = maxX + tooltipCenterX = tooltipX + (tooltipWidth / 2) + } + + let arrowX = targetCenterX - tooltipX + + let arrowRatio = arrowX / tooltipWidth + + let updatedConfig = TooltipConfig( + id: tooltip.id, + title: tooltip.title, + description: tooltip.description, + targetId: tooltip.targetId, + arrowDirection: direction, + highlightCornerRadius: tooltip.highlightCornerRadius, + titleFont: tooltip.titleFont, + descriptionFont: tooltip.descriptionFont, + titleColor: tooltip.titleColor, + descriptionColor: tooltip.descriptionColor, + tooltipWidth: tooltipWidth, + spacing: tooltip.spacing, + titlePadding: tooltip.titlePadding, + descriptionPadding: tooltip.descriptionPadding, + backgroundColor: tooltip.backgroundColor, + shadowRadius: tooltip.shadowRadius, + cornerRadius: tooltip.cornerRadius + ) + + return TooltipView( + config: updatedConfig, + arrowPosition: arrowRatio + ) + .background( + GeometryReader { geo in + Color.clear + .onAppear { + contentSize = geo.size + } + .onChange(of: geo.size) { newSize in + contentSize = newSize + } + } + ) + .position( + x: tooltipCenterX, + y: renderBelow + ? targetFrame.maxY + 10 + (contentSize.height / 2) + : targetFrame.minY - 10 - (contentSize.height / 2) + ) + } +}