├── .editorconfig
├── .github
└── workflows
│ └── main.yml
├── .gitignore
├── .spi.yml
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Images
├── Apps
│ ├── CrossCraft.webp
│ ├── FocusBeats.webp
│ ├── FreelanceKit.webp
│ ├── FreemiumKit.webp
│ ├── GuidedGuestMode.webp
│ ├── PleydiaOrganizer.webp
│ ├── Posters.webp
│ └── TranslateKit.webp
├── Docs.jpeg
└── HandySwiftUI.png
├── LICENSE
├── Package.resolved
├── Package.swift
├── README.md
└── Sources
└── HandySwiftUI
├── Extensions
├── BindingExt.swift
├── ButtonExt.swift
├── CGFloatExt.swift
├── ColorExt.swift
├── CustomStringConvertibleExt.swift
├── ImageExt.swift
├── LabelExt.swift
├── NotificationNameExt.swift
├── PersistentModelExt.swift
├── PickerExt.swift
├── StringExt.swift
└── TextExt.swift
├── HandySwiftUI.docc
├── Essentials
│ ├── Extensions.md
│ ├── New Types.md
│ ├── Styles.md
│ └── View Modifiers.md
├── HandySwiftUI.md
├── Resources
│ ├── Extensions.jpeg
│ ├── Extensions
│ │ ├── ColorfulView.png
│ │ ├── CommonTranslations.jpeg
│ │ └── FormattedText.png
│ ├── HandySwiftUI.png
│ ├── NewTypes.jpeg
│ ├── NewTypes
│ │ ├── Last30Days.jpeg
│ │ ├── OpenPanel.jpeg
│ │ ├── SettingsView.gif
│ │ └── SideTabView.png
│ ├── Styles.jpeg
│ ├── Styles
│ │ ├── ButtonStyles.gif
│ │ ├── CheckboxUniversal.png
│ │ ├── MuteLabelFalse.jpeg
│ │ └── VerticalLabeledContent.jpeg
│ ├── ViewModifiers.jpeg
│ └── ViewModifiers
│ │ ├── ConfirmDelete.jpeg
│ │ ├── StateBadges.png
│ │ └── YellowWithContrast.png
└── theme-settings.json
├── Localizable.xcstrings
├── Modifiers
├── FirstAppearModifier.swift
├── ForegroundStyleMinContrast.swift
├── ProgressOverlay.swift
├── ThrowingTask.swift
└── ViewExt.swift
├── Styles
├── CheckboxUniversalToggleStyle.swift
├── FixedIconWidthLabelStyle.swift
├── HorizontalLabelStyle.swift
├── HorizontalLabeledContentStyle.swift
├── PrimaryButtonStyle.swift
├── PulsatingButtonStyle.swift
├── SecondaryButtonStyle.swift
├── VerticalLabelStyle.swift
└── VerticalLabeledContentStyle.swift
└── Types
├── Models
├── Emoji.swift
└── SFSymbol.swift
├── Other
├── AsyncResult.swift
├── AsyncState.swift
├── ColorSpaces.swift
├── OpenPanel.swift
├── Platform.swift
└── Xcode.swift
├── Protocols
├── CustomLabelConvertible.swift
└── CustomSymbolConvertible.swift
└── Views
├── AsyncButton.swift
├── AsyncView.swift
├── CachedAsyncImage.swift
├── DisclosureSection.swift
├── HPicker.swift
├── LimitedTextField.swift
├── MultiSelectionView.swift
├── MultiSelector.swift
├── SearchableGridPicker.swift
├── SideTabView.swift
├── VPicker.swift
└── WebView.swift
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: https://editorconfig.org
2 | root = true
3 |
4 | [*]
5 |
6 | indent_style = space
7 | tab_width = 6
8 | indent_size = 3
9 |
10 | end_of_line = lf
11 | insert_final_newline = true
12 |
13 | max_line_length = 160
14 | trim_trailing_whitespace = true
15 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | branches: [main]
6 |
7 | jobs:
8 | test-macos:
9 | runs-on: macos-15
10 |
11 | steps:
12 | - uses: actions/checkout@v4
13 |
14 | - name: Build package
15 | run: swift build
16 |
17 | - name: Run tests
18 | if: hashFiles('Tests/**') != ''
19 | run: swift test
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 |
--------------------------------------------------------------------------------
/.spi.yml:
--------------------------------------------------------------------------------
1 | version: 1
2 | builder:
3 | configs:
4 | - documentation_targets: [HandySwiftUI]
5 | swift_version: 6.0
6 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Images/Apps/CrossCraft.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Images/Apps/CrossCraft.webp
--------------------------------------------------------------------------------
/Images/Apps/FocusBeats.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Images/Apps/FocusBeats.webp
--------------------------------------------------------------------------------
/Images/Apps/FreelanceKit.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Images/Apps/FreelanceKit.webp
--------------------------------------------------------------------------------
/Images/Apps/FreemiumKit.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Images/Apps/FreemiumKit.webp
--------------------------------------------------------------------------------
/Images/Apps/GuidedGuestMode.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Images/Apps/GuidedGuestMode.webp
--------------------------------------------------------------------------------
/Images/Apps/PleydiaOrganizer.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Images/Apps/PleydiaOrganizer.webp
--------------------------------------------------------------------------------
/Images/Apps/Posters.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Images/Apps/Posters.webp
--------------------------------------------------------------------------------
/Images/Apps/TranslateKit.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Images/Apps/TranslateKit.webp
--------------------------------------------------------------------------------
/Images/Docs.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Images/Docs.jpeg
--------------------------------------------------------------------------------
/Images/HandySwiftUI.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Images/HandySwiftUI.png
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright (c) 2020-2024 FlineDev (alias Cihat Gündüz)
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "originHash" : "e36a5c8f6d235ea9fc5cb60d89bf61bfc85193db03674fe4ad2e3f0276cddea5",
3 | "pins" : [
4 | {
5 | "identity" : "handyswift",
6 | "kind" : "remoteSourceControl",
7 | "location" : "https://github.com/FlineDev/HandySwift.git",
8 | "state" : {
9 | "revision" : "20b51652a71eca85c6c5502aac870c6708fa148b",
10 | "version" : "4.3.0"
11 | }
12 | }
13 | ],
14 | "version" : 3
15 | }
16 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 6.0
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "HandySwiftUI",
6 | defaultLocalization: "en",
7 | platforms: [.iOS(.v16), .macOS(.v13), .tvOS(.v16), .visionOS(.v1), .watchOS(.v9)],
8 | products: [.library(name: "HandySwiftUI", targets: ["HandySwiftUI"])],
9 | dependencies: [.package(url: "https://github.com/FlineDev/HandySwift.git", from: "4.3.0")],
10 | targets: [
11 | .target(
12 | name: "HandySwiftUI",
13 | dependencies: [.product(name: "HandySwift", package: "HandySwift")],
14 | resources: [.process("Localizable.xcstrings")]
15 | )
16 | ]
17 | )
18 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | [](https://swiftpackageindex.com/FlineDev/HandySwiftUI)
4 |
5 | # HandySwiftUI
6 |
7 | The goal of this library is to **provide handy UI features** that didn't make it into the SwiftUI (yet).
8 |
9 | Checkout [HandySwift](https://github.com/FlineDev/HandySwift) for handy Swift features that should have been part of the Swift standard library in the first place.
10 |
11 |
12 | ## Documentation
13 |
14 | Learn how you can make the most of HandySwiftUI by reading the guides inside the documentation:
15 |
16 | [📖 Open HandySwiftUI Documentation](https://swiftpackageindex.com/FlineDev/HandySwiftUI/documentation/handyswiftui)
17 |
18 |
19 |
20 |
21 |
22 |
23 | ## Showcase
24 |
25 | I created this library for my own Indie apps (download & rate them to show your appreciation):
26 |
27 |
28 |
29 | App Icon
30 | App Name & Description
31 | Supported Platforms
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | TranslateKit: App Localization
42 |
43 |
44 | AI-powered app localization with unmatched accuracy. Fast & easy: AI & proofreading, 125+ languages, market insights. Budget-friendly, free to try.
45 |
46 | Mac
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | FreemiumKit: In-App Purchases for Indies
57 |
58 |
59 | Simple In-App Purchases and Subscriptions: Automation, Paywalls, A/B Testing, Live Notifications, PPP, and more.
60 |
61 | iPhone, iPad, Mac, Vision
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | Pleydia Organizer: Movie & Series Renamer
72 |
73 |
74 | Simple, fast, and smart media management for your Movie, TV Show and Anime collection.
75 |
76 | Mac
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | FreelanceKit: Project Time Tracking
87 |
88 |
89 | Simple & affordable time tracking with a native experience for all devices. iCloud sync & CSV export included.
90 |
91 | iPhone, iPad, Mac, Vision
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | CrossCraft: Custom Crosswords
102 |
103 |
104 | Create themed & personalized crosswords. Solve them yourself or share them to challenge others.
105 |
106 | iPhone, iPad, Mac, Vision
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 | FocusBeats: Pomodoro + Music
117 |
118 |
119 | Deep Focus with proven Pomodoro method & select Apple Music playlists & themes. Automatically pauses music during breaks.
120 |
121 | iPhone, iPad, Mac, Vision
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 | Posters: Discover Movies at Home
132 |
133 |
134 | Auto-updating & interactive posters for your home with trailers, showtimes, and links to streaming services.
135 |
136 | Vision
137 |
138 |
139 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Extensions/BindingExt.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// Negates the value of a `Binding`. Useful for toggling booleans directly in SwiftUI views.
4 | ///
5 | /// ## Usage Example:
6 | /// ```swift
7 | /// struct ContentView: View {
8 | /// @State private var isOff = false
9 | ///
10 | /// var body: some View {
11 | /// Toggle("Toggle", isOn: !$isOff) // This will invert the boolean value
12 | /// }
13 | /// }
14 | /// ```
15 | /// - Parameter value: A binding to a boolean value.
16 | /// - Returns: A new binding with the negated boolean value.
17 | public prefix func ! (value: Binding) -> Binding {
18 | Binding(
19 | get: { !value.wrappedValue },
20 | set: { value.wrappedValue = $0 }
21 | )
22 | }
23 |
24 | /// Provides a default value for a `Binding`, returning a non-optional `Binding`.
25 | ///
26 | /// If the binding's value is `nil`, it returns the right-hand side `nilPlaceholderValue`.
27 | /// If the value is updated to match the `nilPlaceholderValue`, it resets the binding to `nil`.
28 | ///
29 | /// ## Usage Example:
30 | /// ```swift
31 | /// struct ProfileView: View {
32 | /// @State private var username: String? = nil
33 | ///
34 | /// var body: some View {
35 | /// TextField("Enter your name", text: self.$username ?? "")
36 | /// }
37 | /// }
38 | /// ```
39 | /// - Parameters:
40 | /// - binding: The optional binding.
41 | /// - nilPlaceholderValue: The default value used when the binding is `nil`. When this value is set, the binding is set to `nil`.
42 | /// - Returns: A binding to a non-optional value, using the provided placeholder value if `nil`.
43 | @MainActor
44 | public func ?? (binding: Binding, nilPlaceholderValue: T) -> Binding {
45 | Binding(
46 | get: { binding.wrappedValue ?? nilPlaceholderValue },
47 | set: { binding.wrappedValue = ($0 == nilPlaceholderValue ? nil : $0) }
48 | )
49 | }
50 |
51 | extension Binding where Value: ExpressibleByNilLiteral {
52 | /// Converts an optional `Binding` into a `Binding` that can be used for SwiftUI presentation logic.
53 | /// For example, it can be used for views that require `isPresented`, such as `confirmationDialog`, when no `item:` overload exists.
54 | ///
55 | /// - Note: Setting the value to `true` is a no-op since SwiftUI APIs only reset the binding to `false` when the view is dismissed.
56 | ///
57 | /// ## Usage Example:
58 | /// ```swift
59 | /// struct ContentView: View {
60 | /// @State private var optionalItem: String? = nil
61 | ///
62 | /// var body: some View {
63 | /// Button("Show Dialog") {
64 | /// optionalItem = "Some Item"
65 | /// }
66 | /// .confirmationDialog("Title", isPresented: $optionalItem.isPresent(wrappedType: String.self)) {
67 | /// Text("Dialog Content")
68 | /// }
69 | /// }
70 | /// }
71 | /// ```
72 | /// - Parameter wrappedType: The type to check if the wrapped value is present.
73 | /// - Returns: A binding that is `true` if the value is non-nil, and `false` otherwise.
74 | @MainActor
75 | public func isPresent(wrappedType: T.Type) -> Binding {
76 | Binding {
77 | if let typedWrappedValue = self.wrappedValue as? Optional {
78 | switch typedWrappedValue {
79 | case .none: return false
80 | default: return true
81 | }
82 | }
83 | return false
84 | } set: { newValue in
85 | if !newValue {
86 | self.wrappedValue = nil
87 | }
88 | }
89 | }
90 | }
91 |
92 | extension Binding where Value: SetAlgebra {
93 | /// Creates a binding to track whether a specific element is contained within a set.
94 | ///
95 | /// This method is particularly useful when working with SwiftUI `Toggle` controls that need to
96 | /// modify a set of selected items. Instead of manually creating complex bindings with get/set closures,
97 | /// this method provides a simple, declarative way to bind set membership to a toggle control.
98 | ///
99 | /// Example usage:
100 | /// ```swift
101 | /// struct ContentView: View {
102 | /// // A set to track selected categories in a filter UI
103 | /// @State private var selectedCategories: Set = []
104 | ///
105 | /// let availableCategories = ["Sports", "News", "Entertainment", "Technology"]
106 | ///
107 | /// var body: some View {
108 | /// List {
109 | /// ForEach(availableCategories, id: \.self) { category in
110 | /// // Simple, declarative binding for the toggle
111 | /// Toggle(category, isOn: $selectedCategories.contains(category))
112 | /// }
113 | /// }
114 | /// .onChange(of: selectedCategories) { newSelection in
115 | /// print("Selected categories: \(newSelection)")
116 | /// }
117 | /// }
118 | /// }
119 | /// ```
120 | ///
121 | /// - Parameter element: The element to check for containment in the set.
122 | /// - Returns: A binding that can be used directly with SwiftUI toggle controls.
123 | /// The binding's value is `true` when the element is in the set and `false` otherwise.
124 | /// When the binding is modified, the element is either inserted into or removed from the set.
125 | @MainActor
126 | public func contains(_ element: T) -> Binding where T: Hashable, Value.Element == T {
127 | Binding(
128 | get: { wrappedValue.contains(element) },
129 | set: { contains in
130 | if contains {
131 | wrappedValue.insert(element)
132 | } else {
133 | wrappedValue.remove(element)
134 | }
135 | }
136 | )
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Extensions/ButtonExt.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension Button where Label == Text {
4 | /// Creates a `Button` with a `Text` label using the `description` property of the provided `CustomStringConvertible` instance.
5 | ///
6 | /// - Parameters:
7 | /// - stringConvertible: An instance conforming to `CustomStringConvertible` that provides the text for the button's label.
8 | /// - action: The closure to execute when the button is tapped.
9 | ///
10 | /// ## Usage Example:
11 | /// ```swift
12 | /// struct MyStringConvertible: CustomStringConvertible {
13 | /// var description: String { "Click Me" }
14 | /// }
15 | ///
16 | /// Button(stringConvertible: MyStringConvertible(), action: {
17 | /// print("Button tapped")
18 | /// })
19 | /// ```
20 | public init(stringConvertible: CustomStringConvertible, action: @escaping () -> Void) {
21 | self.init(action: action, label: { Text(stringConvertible.description) })
22 | }
23 | }
24 |
25 | extension Button where Label == Image {
26 | /// Creates a `Button` with an `Image` label using the `symbolName` property of the provided `CustomSymbolConvertible` instance.
27 | ///
28 | /// - Parameters:
29 | /// - symbolConvertible: An instance conforming to `CustomSymbolConvertible` that provides the SF Symbol name for the button's image.
30 | /// - action: The closure to execute when the button is tapped.
31 | ///
32 | /// ## Usage Example:
33 | /// ```swift
34 | /// struct MySymbolConvertible: CustomSymbolConvertible {
35 | /// var symbolName: String { "star.fill" }
36 | /// }
37 | ///
38 | /// Button(symbolConvertible: MySymbolConvertible(), action: {
39 | /// print("Button tapped")
40 | /// })
41 | /// ```
42 | public init(symbolConvertible: CustomSymbolConvertible, action: @escaping () -> Void) {
43 | self.init(action: action, label: { Image(systemName: symbolConvertible.symbolSystemName) })
44 | }
45 | }
46 |
47 | extension Button where Label == SwiftUI.Label {
48 | /// Creates a `Button` with a `Label` combining `Text` and `Image` using the `CustomLabelConvertible` instance.
49 | ///
50 | /// - Parameters:
51 | /// - labelConvertible: An instance conforming to `CustomLabelConvertible` that provides both the text and image for the button's label.
52 | /// - action: The closure to execute when the button is tapped.
53 | ///
54 | /// ## Usage Example:
55 | /// ```swift
56 | /// struct MyLabelConvertible: CustomLabelConvertible {
57 | /// var description: String { "Star" }
58 | /// var symbolSystemName: String { "star.fill" }
59 | /// }
60 | ///
61 | /// Button(labelConvertible: MyLabelConvertible(), action: {
62 | /// print("Button tapped")
63 | /// })
64 | /// ```
65 | public init(labelConvertible: CustomLabelConvertible, action: @escaping () -> Void) {
66 | self.init(action: action, label: { labelConvertible.label })
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Extensions/CGFloatExt.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension CGFloat {
4 | /// Returns the default spacing value based on the current platform.
5 | ///
6 | /// - Returns: A `CGFloat` value representing the default spacing:
7 | /// - `30` for `tvOS`
8 | /// - `8` for `macOS` and `watchOS`
9 | /// - `16` for other platforms (e.g., `iOS`)
10 | ///
11 | /// ## Usage Example:
12 | /// ```swift
13 | /// SomeView("...")
14 | /// .padding(.platformDefaultSpacing)
15 | /// ```
16 | public static var platformDefaultSpacing: Self {
17 | #if os(tvOS)
18 | return 30
19 | #elseif os(macOS) || os(watchOS)
20 | return 8
21 | #else
22 | return 16
23 | #endif
24 | }
25 |
26 | /// Returns the default text height value based on the current platform.
27 | ///
28 | /// - Returns: A `CGFloat` value representing the default text height:
29 | /// - `45.5` for `tvOS`
30 | /// - `18` for `macOS`
31 | /// - `20.5` for other platforms (e.g., `iOS`)
32 | ///
33 | /// ## Usage Example:
34 | /// ```swift
35 | /// Text("...")
36 | /// .frame(height: .platformDefaultTextHeight)
37 | /// ```
38 | public static var platformDefaultTextHeight: Self {
39 | #if os(tvOS)
40 | return 45.5
41 | #elseif os(macOS)
42 | return 18
43 | #else
44 | return 20.5
45 | #endif
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Extensions/CustomStringConvertibleExt.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension CustomStringConvertible {
4 | /// Creates a SwiftUI `Text` view using the `description` of the instance.
5 | ///
6 | /// This property provides a convenient way to create a `Text` view
7 | /// from any type that conforms to `CustomStringConvertible`.
8 | ///
9 | /// - Returns: A `Text` view containing the `description` of the instance.
10 | ///
11 | /// - Example:
12 | /// ```swift
13 | /// struct Person: CustomStringConvertible {
14 | /// let name: String
15 | /// var description: String { "Person named \(name)" }
16 | /// }
17 | ///
18 | /// let john = Person(name: "John")
19 | /// var body: some View {
20 | /// john.text // Creates a Text view with "Person named John"
21 | /// }
22 | /// ```
23 | public var text: Text {
24 | Text(self.description)
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Extensions/ImageExt.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import UniformTypeIdentifiers
3 |
4 | extension Image {
5 | /// Creates an `Image` view using the `symbolName` of the provided `CustomSymbolConvertible` instance.
6 | ///
7 | /// - Parameters:
8 | /// - convertible: An instance conforming to `CustomSymbolConvertible`.
9 | /// - variableValue: An optional value for variable value symbols.
10 | ///
11 | /// - Returns: An `Image` view representing the system symbol.
12 | ///
13 | /// - Example:
14 | /// ```swift
15 | /// struct CustomSymbol: CustomSymbolConvertible {
16 | /// var symbolName: String { "star.fill" }
17 | /// }
18 | ///
19 | /// let symbol = CustomSymbol()
20 | /// let imageView = Image(convertible: symbol)
21 | /// ```
22 | public init(convertible: CustomSymbolConvertible, variableValue: Double? = nil) {
23 | self.init(systemName: convertible.symbolName, variableValue: variableValue)
24 | }
25 | }
26 |
27 | #if os(macOS)
28 | import AppKit
29 |
30 | extension NSImage {
31 | /// Converts the image to PNG data.
32 | ///
33 | /// - Returns: PNG data representation of the image, or `nil` if conversion fails.
34 | public func pngData() -> Data? {
35 | guard
36 | let tiffRepresentation = self.tiffRepresentation,
37 | let bitmapImage = NSBitmapImageRep(data: tiffRepresentation)
38 | else { return nil }
39 |
40 | return bitmapImage.representation(using: .png, properties: [:])
41 | }
42 |
43 | /// Converts the image to JPEG data with specified compression quality.
44 | ///
45 | /// - Parameter compressionQuality: The compression quality to use (0.0 to 1.0).
46 | /// - Returns: JPEG data representation of the image, or `nil` if conversion fails.
47 | public func jpegData(compressionQuality: Double) -> Data? {
48 | guard
49 | let tiffRepresentation = self.tiffRepresentation,
50 | let bitmapImage = NSBitmapImageRep(data: tiffRepresentation)
51 | else { return nil }
52 |
53 | return bitmapImage.representation(
54 | using: .jpeg2000,
55 | properties: [NSBitmapImageRep.PropertyKey.compressionFactor: NSNumber(value: compressionQuality)]
56 | )
57 | }
58 |
59 | /// Resizes the image to fit within the specified maximum width and height.
60 | ///
61 | /// - Parameters:
62 | /// - maxWidth: The maximum width for the resized image.
63 | /// - maxHeight: The maximum height for the resized image.
64 | /// - Returns: A new `NSImage` instance with the resized dimensions, or `nil` if resizing fails.
65 | public func resized(maxWidth: CGFloat, maxHeight: CGFloat) -> NSImage? {
66 | let size = self.size
67 | if size.width <= maxWidth && size.height <= maxHeight { return self }
68 |
69 | let screenScale = NSScreen.main?.backingScaleFactor ?? 1.0
70 | let widthRatio = (maxWidth / screenScale) / size.width
71 | let heightRatio = (maxHeight / screenScale) / size.height
72 | let scaleFactor = min(widthRatio, heightRatio)
73 |
74 | let newSize = CGSize(width: size.width * scaleFactor, height: size.height * scaleFactor)
75 |
76 | let newImage = NSImage(size: newSize)
77 | newImage.lockFocus()
78 | let context = NSGraphicsContext.current
79 | context?.imageInterpolation = .high
80 | self.draw(in: NSRect(origin: .zero, size: newSize), from: NSRect(origin: .zero, size: self.size), operation: .copy, fraction: 1.0)
81 | newImage.unlockFocus()
82 |
83 | return newImage
84 | }
85 |
86 | /// Converts the image to HEIC data with specified compression quality.
87 | ///
88 | /// - Parameter compressionQuality: The compression quality to use (0.0 to 1.0).
89 | /// - Returns: HEIC data representation of the image, or `nil` if conversion fails.
90 | public func heicData(compressionQuality: CGFloat) -> Data? {
91 | if let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) {
92 | let mutableData = NSMutableData()
93 |
94 | if let destination = CGImageDestinationCreateWithData(mutableData, UTType.heic.identifier as CFString, 1, nil) {
95 | CGImageDestinationAddImage(destination, cgImage, [kCGImageDestinationLossyCompressionQuality: compressionQuality] as CFDictionary)
96 | if CGImageDestinationFinalize(destination) {
97 | return mutableData as Data
98 | }
99 | }
100 | }
101 |
102 | return nil
103 | }
104 | }
105 | #else
106 | import UIKit
107 |
108 | extension UIImage {
109 | /// Resizes the image to fit within the specified maximum width and height.
110 | ///
111 | /// - Parameters:
112 | /// - maxWidth: The maximum width for the resized image.
113 | /// - maxHeight: The maximum height for the resized image.
114 | /// - Returns: A new `UIImage` instance with the resized dimensions, or `nil` if resizing fails.
115 | @MainActor
116 | public func resized(maxWidth: CGFloat, maxHeight: CGFloat) -> UIImage? {
117 | let size = self.size
118 | if size.width <= maxWidth && size.height <= maxHeight { return self }
119 |
120 | #if os(visionOS)
121 | let screenScale = 1.0
122 | #else
123 | let screenScale = UIScreen.main.scale
124 | #endif
125 | let widthRatio = (maxWidth / screenScale) / size.width
126 | let heightRatio = (maxHeight / screenScale) / size.height
127 | let scaleFactor = min(widthRatio, heightRatio)
128 |
129 | let newSize = CGSize(width: size.width * scaleFactor, height: size.height * scaleFactor)
130 |
131 | let renderer = UIGraphicsImageRenderer(size: newSize)
132 | let image = renderer.image { _ in
133 | self.draw(in: CGRect(origin: .zero, size: newSize))
134 | }
135 |
136 | return image.withRenderingMode(self.renderingMode)
137 | }
138 |
139 | /// Converts the image to HEIC data with specified compression quality.
140 | ///
141 | /// - Parameter compressionQuality: The compression quality to use (0.0 to 1.0).
142 | /// - Returns: HEIC data representation of the image, or `nil` if conversion fails.
143 | public func heicData(compressionQuality: CGFloat) -> Data? {
144 | if let cgImage = self.cgImage {
145 | let mutableData = NSMutableData()
146 |
147 | if let destination = CGImageDestinationCreateWithData(mutableData, UTType.heic.identifier as CFString, 1, nil) {
148 | CGImageDestinationAddImage(destination, cgImage, [kCGImageDestinationLossyCompressionQuality: compressionQuality] as CFDictionary)
149 | if CGImageDestinationFinalize(destination) {
150 | return mutableData as Data
151 | }
152 | }
153 | }
154 |
155 | return nil
156 | }
157 | }
158 | #endif
159 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Extensions/LabelExt.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension Label {
4 | /// Creates a `Label` view using the `description` and `symbolName` of the provided `CustomLabelConvertible` instance.
5 | ///
6 | /// This initializer provides a convenient way to create a `Label` directly from a `CustomLabelConvertible` object.
7 | ///
8 | /// - Parameter convertible: An instance conforming to `CustomLabelConvertible`.
9 | ///
10 | /// - Example:
11 | /// ```swift
12 | /// struct MyModel: CustomLabelConvertible {
13 | /// var description: String { "Settings" }
14 | /// var symbolName: String { "gear" }
15 | /// }
16 | ///
17 | /// let info = MyModel()
18 | /// let label = Label(convertible: info)
19 | /// // Creates a Label with "Settings" text and a gear icon
20 | /// ```
21 | public init(convertible: CustomLabelConvertible) {
22 | self.init(convertible.description, systemImage: convertible.symbolName)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Extensions/NotificationNameExt.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension Notification.Name {
4 | /// Returns the publisher of the notification on the default notification center.
5 | ///
6 | /// This property provides a convenient way to access a publisher for the notification
7 | /// without explicitly specifying the notification center.
8 | ///
9 | /// - Returns: A `NotificationCenter.Publisher` for this notification name.
10 | ///
11 | /// - Example:
12 | /// ```swift
13 | /// let notificationName = Notification.Name("CustomNotification")
14 | /// let cancellable = notificationName.publisher
15 | /// .sink { notification in
16 | /// print("Received notification: \(notification)")
17 | /// }
18 | /// ```
19 | public var publisher: NotificationCenter.Publisher {
20 | NotificationCenter.default.publisher(for: self)
21 | }
22 |
23 | /// Posts the notification on the given center using the given object.
24 | ///
25 | /// This method provides a convenient way to post a notification without
26 | /// explicitly creating a `Notification` object.
27 | ///
28 | /// - Parameters:
29 | /// - object: The object posting the notification. Defaults to `nil`.
30 | /// - center: The notification center on which to post the notification. Defaults to `.default`.
31 | ///
32 | /// - Example:
33 | /// ```swift
34 | /// let notificationName = Notification.Name("CustomNotification")
35 | /// notificationName.post(object: self)
36 | /// ```
37 | public func post(object: Any? = nil, on center: NotificationCenter = .default) {
38 | center.post(name: self, object: object)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Extensions/PersistentModelExt.swift:
--------------------------------------------------------------------------------
1 | #if canImport(SwiftData)
2 | import SwiftData
3 |
4 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
5 | extension PersistentModel {
6 | /// Returns whether this model instance is currently managed by a ModelContext.
7 | ///
8 | /// Use this property to determine if a model instance has ever been inserted into
9 | /// a ModelContext (i.e., exists in the database) or if it's just an in-memory instance
10 | /// that hasn't been saved yet.
11 | ///
12 | /// Example:
13 | /// ```swift
14 | /// let task = Task(title: "New Task")
15 | /// print(task.isPersisted) // false
16 | ///
17 | /// try modelContext.insert(task)
18 | /// print(task.isPersisted) // true
19 | /// ```
20 | public var isPersisted: Bool {
21 | self.modelContext != nil
22 | }
23 | }
24 | #endif
25 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Extensions/PickerExt.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension Picker where
4 | Label == Text,
5 | SelectionValue: CaseIterable & Hashable & Identifiable & CustomLabelConvertible,
6 | SelectionValue.AllCases: RandomAccessCollection,
7 | Content == ForEach
8 | {
9 | /// Creates a picker that displays all cases of an enum conforming to `CustomLabelConvertible`,
10 | /// automatically creating labeled menu items from the enum's description and symbol.
11 | ///
12 | /// This initializer simplifies picker creation by eliminating the need for an explicit `ForEach`.
13 | ///
14 | /// Example usage:
15 | /// ```swift
16 | /// enum Theme: String, CaseIterable, Identifiable, CustomLabelConvertible {
17 | /// case light, dark, system
18 | ///
19 | /// var id: String { rawValue }
20 | /// var description: String { rawValue.capitalized }
21 | /// var symbolSystemName: String {
22 | /// switch self {
23 | /// case .light: "sun.max.fill"
24 | /// case .dark: "moon.fill"
25 | /// case .system: "gearshape.fill"
26 | /// }
27 | /// }
28 | /// }
29 | ///
30 | /// struct ThemeSelector: View {
31 | /// @Binding var selectedTheme: Theme
32 | ///
33 | /// var body: some View {
34 | /// Picker("Theme", selection: $selectedTheme)
35 | /// .pickerStyle(.menu)
36 | /// }
37 | /// }
38 | /// ```
39 | ///
40 | /// - Parameters:
41 | /// - titleKey: The key for the picker's label.
42 | /// - selection: A binding to the currently selected value.
43 | public init(_ titleKey: LocalizedStringKey, selection: Binding) {
44 | self.init(titleKey, selection: selection) {
45 | ForEach(SelectionValue.allCases) { selectionValue in
46 | Label(convertible: selectionValue)
47 | }
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Extensions/StringExt.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension String {
4 | /// Converts a hex color string to RGBA values.
5 | ///
6 | /// This method supports both 6-digit (RRGGBB) and 8-digit (RRGGBBAA) hex color strings,
7 | /// with or without a leading '#' character.
8 | ///
9 | /// - Returns: A tuple containing red, green, blue, and alpha values as CGFloat (0.0 to 1.0).
10 | ///
11 | /// - Example:
12 | /// ```swift
13 | /// let hexColor = "#FF8000"
14 | /// let (r, g, b, a) = hexColor.toRGBA()
15 | /// // r ≈ 1.0, g ≈ 0.5, b ≈ 0.0, a = 1.0
16 | /// ```
17 | public func toRGBA() -> (r: CGFloat, g: CGFloat, b: CGFloat, alpha: CGFloat) {
18 | var normalizedHex = self.trimmingCharacters(in: .whitespacesAndNewlines)
19 | normalizedHex = normalizedHex.replacingOccurrences(of: "#", with: "")
20 |
21 | var rgb: UInt64 = 0
22 |
23 | var r: CGFloat = 0.0
24 | var g: CGFloat = 0.0
25 | var b: CGFloat = 0.0
26 | var a: CGFloat = 1.0
27 |
28 | let length = normalizedHex.count
29 |
30 | Scanner(string: normalizedHex).scanHexInt64(&rgb)
31 |
32 | if length == 6 {
33 | r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0
34 | g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0
35 | b = CGFloat(rgb & 0x0000FF) / 255.0
36 | } else if length == 8 {
37 | r = CGFloat((rgb & 0xFF000000) >> 24) / 255.0
38 | g = CGFloat((rgb & 0x00FF0000) >> 16) / 255.0
39 | b = CGFloat((rgb & 0x0000FF00) >> 8) / 255.0
40 | a = CGFloat(rgb & 0x000000FF) / 255.0
41 | }
42 |
43 | return (r, g, b, a)
44 | }
45 |
46 | /// Highlights matching tokenized prefixes in the string based on a search text.
47 | ///
48 | /// This method creates an `AttributedString` with matching prefixes highlighted using the specified font.
49 | ///
50 | /// - Parameters:
51 | /// - searchText: The text to search for within the string.
52 | /// - locale: The locale to use for string comparisons. Defaults to `nil`.
53 | /// - applytingAttributes: A closure that gets passed an `AttributedSubstring` you can customize to define how to highlight. By default, a yellow background and bold black foreground text is used.
54 | ///
55 | /// - Returns: An `AttributedString` with matching prefixes highlighted.
56 | ///
57 | /// - Example:
58 | /// ```swift
59 | /// let text = "Hello, World!"
60 | /// let highlighted = text.highlightMatchingTokenizedPrefixes(in: "He Wo")
61 | /// // Returns an AttributedString with "He" and "Wo" in bold
62 | /// ```
63 | @available(iOS 15, macOS 12, tvOS 15, visionOS 1, watchOS 8, *)
64 | public func highlightMatchingTokenizedPrefixes(
65 | in searchText: String,
66 | locale: Locale? = nil,
67 | applyingAttributes: (inout AttributedSubstring) -> Void = {
68 | $0.font = $0.font?.bold()
69 | $0.backgroundColor = .yellow
70 | $0.foregroundColor = .black
71 | }
72 | ) -> AttributedString {
73 | var attributedSelf = AttributedString(self)
74 | let normalizedSelf = self.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: locale)
75 |
76 | for token in searchText.tokenized() {
77 | if let range = AttributedString(normalizedSelf).range(of: token) {
78 | applyingAttributes(&attributedSelf[range])
79 | }
80 | }
81 |
82 | return attributedSelf
83 | }
84 | }
85 |
86 | #if DEBUG
87 | #Preview {
88 | VStack(spacing: 10) {
89 | Text("Hello, World!".highlightMatchingTokenizedPrefixes(in: "hewo"))
90 | Text("Hello, World!".highlightMatchingTokenizedPrefixes(in: "ello", applyingAttributes: {
91 | $0.font = .body.bold()
92 | $0.backgroundColor = .yellow
93 | $0.foregroundColor = .black
94 | }))
95 | Text("Hello, World!".highlightMatchingTokenizedPrefixes(in: "wo"))
96 | Text("Hello, World!".highlightMatchingTokenizedPrefixes(in: "hello world"))
97 | }
98 | .macOSOnlyPadding()
99 | }
100 | #endif
101 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Extensions/TextExt.swift:
--------------------------------------------------------------------------------
1 | import HandySwift
2 | import SwiftUI
3 |
4 | extension Text {
5 | /// Creates a ``Text`` view using the `description` parameter of the provided ``CustomStringConvertible`` instance.
6 | public init(convertible: CustomStringConvertible) {
7 | self.init(convertible.description)
8 | }
9 |
10 | /// Initializes a Text from custom HTML-like markings by applying the given formatters on each marked substring. Supports SF Symbol replacement via ` `.
11 | ///
12 | /// For example:
13 | /// ```
14 | /// Text(
15 | /// format: "Text with custom colored substring , or highlighted in other ways.",
16 | /// partialStyling: [
17 | /// "red": { $0.foregroundColor(.red) },
18 | /// "highlight": { $0.bold().italic() },
19 | /// "checkmark.seal": { $0.foregroundColor(.gray) }
20 | /// ]
21 | /// )
22 | /// ```
23 | ///
24 | /// - NOTE: Does not support nesting of formatted substrings for performance reasons.
25 | /// - TIP: There's a `.htmlTags` pre-defined `partialStyling` you can use with support for some basic modifiers.
26 | ///
27 | /// - Parameters:
28 | /// - format: The string to be rendered as a Text.
29 | /// - partialStyling: A dictionary with keys serving as names for HTML-like tags and values creating custom formatted `Text` objects for substrings.
30 | public init(
31 | format formatString: String,
32 | partialStyling: [String: (Text) -> Text] = [:]
33 | ) {
34 | var subtexts: [Text] = []
35 | var previousRange: Range?
36 |
37 | let regex = try! HandyRegex(#"<([^<>]+)>([^<>]+)([^<>]+)>|<([^<>]+)/>"#)
38 | for match in regex.matches(in: formatString) {
39 | let prefix = formatString[(previousRange?.upperBound ?? formatString.startIndex)..bold` => ["b", "bold", "b"]
47 | guard
48 | captures[0] == captures[2],
49 | let style = partialStyling[captures[0]]
50 | else {
51 | subtexts.append(Text(match.string))
52 | previousRange = match.range
53 | continue
54 | }
55 |
56 | subtexts.append(style(Text(captures[1])))
57 | previousRange = match.range
58 |
59 | case 1: // the second part matched, e.g. ` ` => ["lock.filled"]
60 | if let style = partialStyling[captures[0]] {
61 | subtexts.append(style(Text(Image(systemName: captures[0]))))
62 | } else {
63 | subtexts.append(Text(Image(systemName: captures[0])))
64 | }
65 | previousRange = match.range
66 |
67 | default:
68 | fatalError("A match should have exactly 1 or 3 captures, found: \(captures).")
69 | }
70 | }
71 |
72 | let suffix = String(formatString[(previousRange?.upperBound ?? formatString.startIndex).. Text {
80 | /// Pre-defined `Text` formatters using HTML tags for usage with `Text(format:partialStyling:)`.
81 | /// Inspiration: https://www.w3schools.com/html/html_formatting.asp
82 | ///
83 | /// Supported tags:
84 | /// - "b": Applies `.bold()` modifier.
85 | /// - "sb": Applies `.fontWeight(.semibold)` modifier.
86 | /// - "i": Applies `.italic()` modifier.
87 | /// - "bi": Applies both the `.bold()` and the `.italic()` modifiers.
88 | /// - "sbi": Applies both the `.fontWeight(.semibold)` and the `.italic()` modifiers.
89 | /// - "del": Applies `.strikethrough()` modifier.
90 | /// - "ins": Applies `.underline()` modifier.
91 | /// - "sub": Applies `.baselineOffset(5)` modifier.
92 | /// - "sup": Applies `.baselineOffset(-5)` modifier.
93 | public static var htmlLike: Self {
94 | [
95 | "b": { $0.bold() },
96 | "sb": { $0.fontWeight(.semibold) },
97 | "i": { $0.italic() },
98 | "bi": { $0.bold().italic() },
99 | "sbi": { $0.fontWeight(.semibold).italic() },
100 | "del": { $0.strikethrough() },
101 | "ins": { $0.underline() },
102 | "sub": { $0.baselineOffset(-4) },
103 | "sup": { $0.baselineOffset(6) },
104 | ]
105 | }
106 | }
107 |
108 | #if DEBUG
109 | #Preview {
110 | VStack(spacing: 30) {
111 | Text(format: "Test without any matches.")
112 | Text(format: "A B LOST C D", partialStyling: .htmlLike)
113 | Text(
114 | format:
115 | "Normal bold semibold italic , bold sub insert delete another italic semibold & italic sup custom colored & bold .",
116 | partialStyling: Dictionary.htmlLike.merging(
117 | [
118 | "cb": { $0.bold().foregroundColor(.systemOrange) },
119 | "checkmark.seal": { $0.foregroundColor(.green) },
120 | "chart.bar.fill": { $0 },
121 | ]
122 | ) { $1 }
123 | )
124 | }
125 | .macOSOnlyPadding()
126 | }
127 | #endif
128 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Essentials/New Types.md:
--------------------------------------------------------------------------------
1 | # New Types
2 |
3 | Adding missing views & related types commonly needed.
4 |
5 | @Metadata {
6 | @PageImage(purpose: icon, source: "HandySwiftUI")
7 | @PageImage(purpose: card, source: "NewTypes")
8 | }
9 |
10 | ## Highlights
11 |
12 | HandySwiftUI provides a collection of views and types that fill common gaps in SwiftUI development. While you can find a full list of all types at the [Topics](#topics) section of this page, I want to highlight the ones I use most often in my apps:
13 |
14 | ### Platform-Specific Values
15 |
16 | HandySwiftUI provides an elegant way to handle platform-specific values:
17 |
18 | ```swift
19 | struct AdaptiveView: View {
20 | enum TextStyle {
21 | case compact, regular, expanded
22 | }
23 |
24 | var body: some View {
25 | VStack {
26 | // Different number values per platform
27 | Text("Welcome")
28 | .padding(Platform.value(default: 20.0, phone: 12.0))
29 |
30 | // Different colors per platform
31 | Circle()
32 | .fill(Platform.value(default: .blue, mac: .indigo, pad: .purple, vision: .cyan))
33 | }
34 | }
35 | }
36 | ```
37 |
38 | 
39 |
40 | > Image: Getting a similar look across platforms for a title in [FreemiumKit] via:
41 | > ```swift
42 | > .font(Platform.value(default: .title2, phone: .headline))
43 | > ```
44 |
45 | `Platform.value` works with any type - from simple numbers to colors, fonts, or your own custom types. Just provide a default and override specific platforms as needed. This can be enormously useful, especially given that it even has a specific case for iPad named `pad`, so you can even address phones and tablets separately.
46 |
47 | This is by far my most-used HandySwiftUI helper saving me a ton of boilerplate `#if` checks. It's simple but so powerful!
48 |
49 | ### Readable Preview Detection
50 |
51 | Provide fake data and simulate loading states during development:
52 |
53 | ```swift
54 | Task {
55 | loadState = .inProgress
56 |
57 | if Xcode.isRunningForPreviews {
58 | // Simulate network delay in previews
59 | try await Task.sleep(for: .seconds(1))
60 | self.data = Data()
61 | loadState = .successful
62 | } else {
63 | do {
64 | self.data = try await loadFromAPI()
65 | loadState = .successful
66 | } catch {
67 | loadState = .failed(error: error.localizedDescription)
68 | }
69 | }
70 | }
71 | ```
72 |
73 | `Xcode.isRunningForPreviews` allows you to bypass actual network requests and instead provide instant or delayed fake responses in SwiftUI previews only, making it perfect for prototyping and UI development. It's also useful to avoid consuming limited resources during development, such as API rate limits, analytics events that could distort statistics, or services that charge per request – just wrap these in a `if !Xcode.isRunningForPreviews` check.
74 |
75 |
76 | ### Efficient Image Loading
77 |
78 | `CachedAsyncImage` provides efficient image loading with built-in caching:
79 |
80 | ```swift
81 | struct ProductView: View {
82 | let product: Product
83 |
84 | var body: some View {
85 | VStack {
86 | CachedAsyncImage(url: product.imageURL)
87 | .frame(width: 200, height: 200)
88 | .clipShape(RoundedRectangle(cornerRadius: 10))
89 |
90 | Text(product.name)
91 | .font(.headline)
92 | }
93 | }
94 | }
95 | ```
96 |
97 | Note that `.resizable()` and `.aspectRatio(contentMode: .fill)` are already applied to the `Image` view inside it.
98 |
99 |
100 | ### Enhanced Selection Controls
101 |
102 | Multiple sophisticated picker types for different use cases:
103 |
104 | ```swift
105 | struct SettingsView: View {
106 | @State private var selectedMood: Mood?
107 | @State private var selectedColors: Set = []
108 | @State private var selectedEmoji: Emoji?
109 |
110 | var body: some View {
111 | Form {
112 | // Vertical option picker with icons
113 | VPicker("Select Mood", selection: $selectedMood)
114 |
115 | // Horizontal picker with custom styling
116 | HPicker("Rate your experience", selection: $selectedMood)
117 |
118 | // Multi-selection with platform-adaptive UI
119 | MultiSelector(
120 | label: { Text("Colors") },
121 | optionsTitle: "Select Colors",
122 | options: [.red, .blue, .green],
123 | selected: $selectedColors,
124 | optionToString: \.description
125 | )
126 |
127 | // Searchable grid picker for emoji or SF Symbol selection
128 | SearchableGridPicker(
129 | title: "Choose Emoji",
130 | options: Emoji.allCases,
131 | selection: $selectedEmoji
132 | )
133 | }
134 | }
135 | }
136 | ```
137 |
138 | 
139 |
140 | HandySwiftUI includes `Emoji` and `SFSymbol` enums that contain common emoji and symbols. You can also create custom enums by conforming to `SearchableOption` and providing `searchTerms` for each case to power the search functionality.
141 |
142 |
143 | ### Async State Management
144 |
145 | Track async operations with type-safe state handling using `ProgressState`:
146 |
147 | ```swift
148 | struct DocumentView: View {
149 | @State private var loadState: ProgressState = .notStarted
150 |
151 | var body: some View {
152 | Group {
153 | switch loadState {
154 | case .notStarted:
155 | Color.clear.onAppear {
156 | loadDocument()
157 | }
158 |
159 | case .inProgress:
160 | ProgressView("Loading document...")
161 |
162 | case .failed(let errorMessage):
163 | VStack {
164 | Text("Failed to load document:")
165 | .foregroundStyle(.secondary)
166 | Text(errorMessage)
167 | .foregroundStyle(.red)
168 |
169 | Button("Try Again") {
170 | loadDocument()
171 | }
172 | }
173 |
174 | case .successful:
175 | VStack {
176 | DocumentContent()
177 | }
178 | }
179 | }
180 | }
181 |
182 | func loadDocument() {
183 | loadState = .inProgress
184 | Task {
185 | do {
186 | try await loadDocumentFromStorage()
187 | loadState = .successful
188 | } catch {
189 | loadState = .failed(error: error.localizedDescription)
190 | }
191 | }
192 | }
193 | }
194 | ```
195 |
196 | The example demonstrates handling all states in a type-safe way:
197 | - `.notStarted` starts loading immediately (alternatively, you could use a button)
198 | - `.inProgress` displays a loading indicator
199 | - `.failed` shows the error with a retry option
200 | - `.successful` presents the loaded content
201 |
202 |
203 | ### Bring `NSOpenPanel` to SwiftUI
204 |
205 | Bridging native macOS file access into SwiftUI, particularly useful for handling security-scoped resources:
206 |
207 | ```swift
208 | struct SecureFileLoader {
209 | @State private var apiKey = ""
210 |
211 | func loadKeyFile(at fileURL: URL) async {
212 | #if os(macOS)
213 | // On macOS, we need user consent to access the file
214 | let panel = OpenPanel(
215 | filesWithMessage: "Provide access to read key file",
216 | buttonTitle: "Allow Access",
217 | contentType: .data,
218 | initialDirectoryUrl: fileURL
219 | )
220 | guard let url = await panel.showAndAwaitSingleSelection() else { return }
221 | #else
222 | let url = fileURL
223 | #endif
224 |
225 | guard url.startAccessingSecurityScopedResource() else { return }
226 | defer { url.stopAccessingSecurityScopedResource() }
227 |
228 | do {
229 | apiKey = try String(contentsOf: url)
230 | } catch {
231 | print("Failed to load file: \(error.localizedDescription)")
232 | }
233 | }
234 | }
235 | ```
236 |
237 | 
238 |
239 | The example taken right out of [FreemiumKit] demonstrates how `OpenPanel` simplifies handling security-scoped file access for dragged items on macOS while maintaining cross-platform compatibility.
240 |
241 |
242 | ### Vertical Tab Navigation
243 |
244 | An alternative to SwiftUI's `TabView` that implements sidebar-style navigation commonly seen in macOS and iPadOS apps:
245 |
246 | ```swift
247 | struct MainView: View {
248 | enum Tab: String, CaseIterable, Identifiable, CustomLabelConvertible {
249 | case documents, recents, settings
250 |
251 | var id: Self { self }
252 | var description: String {
253 | rawValue.capitalized
254 | }
255 | var symbolName: String {
256 | switch self {
257 | case .documents: "folder"
258 | case .recents: "clock"
259 | case .settings: "gear"
260 | }
261 | }
262 | }
263 |
264 | @State private var selectedTab: Tab = .documents
265 |
266 | var body: some View {
267 | SideTabView(
268 | selection: $selectedTab,
269 | bottomAlignedTabs: 1 // Places settings at the bottom
270 | ) { tab in
271 | switch tab {
272 | case .documents:
273 | DocumentList()
274 | case .recents:
275 | RecentsList()
276 | case .settings:
277 | SettingsView()
278 | }
279 | }
280 | }
281 | }
282 | ```
283 |
284 | 
285 |
286 | `SideTabView` provides a vertical sidebar with icons and labels, optimized for larger screens with support for bottom-aligned tabs. The view automatically handles platform-specific styling and hover effects.
287 |
288 |
289 | ## Topics
290 |
291 | ### Views
292 |
293 | - ``AsyncButton``
294 | - ``CachedAsyncImage``
295 | - ``DisclosureSection``
296 | - ``HPicker``
297 | - ``LimitedTextField``
298 | - ``MultiSelector``
299 | - ``VPicker``
300 | - ``SearchableGridPicker``
301 | - ``SideTabView``
302 | - ``WebView``
303 |
304 | ### Other
305 |
306 | - ``Emoji``
307 | - ``OpenPanel``
308 | - ``Platform``
309 | - ``ProgressState``
310 | - ``SFSymbol``
311 | - ``Xcode``
312 |
313 |
314 | [TranslateKit]: https://translatekit.app
315 | [FreemiumKit]: https://freemiumkit.app
316 | [FreelanceKit]: https://apps.apple.com/app/apple-store/id6480134993?pt=549314&ct=swiftpackageindex.com&mt=8
317 | [CrossCraft]: https://crosscraft.app
318 | [FocusBeats]: https://apps.apple.com/app/apple-store/id6477829138?pt=549314&ct=swiftpackageindex.com&mt=8
319 | [Guided Guest Mode]: https://apps.apple.com/app/apple-store/id6479207869?pt=549314&ct=swiftpackageindex.com&mt=8
320 | [Posters]: https://apps.apple.com/app/apple-store/id6478062053?pt=549314&ct=swiftpackageindex.com&mt=8
321 | [Pleydia Organizer]: https://apps.apple.com/app/apple-store/id6587583340?pt=549314&ct=swiftpackageindex.com&mt=8
322 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Essentials/Styles.md:
--------------------------------------------------------------------------------
1 | # Styles
2 |
3 | Adding missing styles commonly needed in SwiftUI views.
4 |
5 | @Metadata {
6 | @PageImage(purpose: icon, source: "HandySwiftUI")
7 | @PageImage(purpose: card, source: "Styles")
8 | }
9 |
10 | ## Highlights
11 |
12 | HandySwiftUI provides a collection of styles that enhance SwiftUI's standard views. While you can find a full list of all styles at the [Topics](#topics) section of this page, I want to highlight the ones I use most often in my apps:
13 |
14 | ### Primary, Secondary, and Pulsating Buttons
15 |
16 | Create visually appealing buttons with pre-made styles for different use cases:
17 |
18 | ```swift
19 | struct ButtonShowcase: View {
20 | var body: some View {
21 | VStack(spacing: 20) {
22 | // Primary button with prominent background
23 | Button("Get Started") {}
24 | .buttonStyle(.primary())
25 |
26 | // Secondary button with border
27 | Button("Learn More") {}
28 | .buttonStyle(.secondary())
29 |
30 | // Attention-grabbing pulsating button
31 | Button {} label: {
32 | Label("Updates", systemImage: "bell.fill")
33 | .padding(15)
34 | }
35 | .buttonStyle(.pulsating(color: .blue, cornerRadius: 20, glowRadius: 8, duration: 2))
36 | }
37 | }
38 | }
39 | ```
40 |
41 | 
42 |
43 |
44 | ### Horizontal, Vertical, Fixed Icon-Width Labels
45 |
46 | Multiple label styles for different layout needs:
47 |
48 | ```swift
49 | struct LabelShowcase: View {
50 | var body: some View {
51 | VStack(spacing: 20) {
52 | // Horizontal layout with trailing icon
53 | Label("Settings", systemImage: "gear")
54 | .labelStyle(.horizontal(spacing: 8, iconIsTrailing: true, iconColor: .blue))
55 |
56 | // Fixed-width icon for alignment
57 | Label("Profile", systemImage: "person")
58 | .labelStyle(.fixedIconWidth(30, iconColor: .green, titleColor: .primary))
59 |
60 | // Vertical stack layout
61 | Label("Messages", systemImage: "message.fill")
62 | .labelStyle(.vertical(spacing: 8, iconColor: .blue, iconFont: .title))
63 | }
64 | }
65 | }
66 | ```
67 |
68 | All parameters are optional with sensible defaults, so you can use them like `.vertical()`. You only need to specify what you want to customize.
69 |
70 |
71 | ### Vertically Labeled Contents
72 |
73 | Structured form inputs with vertical labels, as used in [FreemiumKit]'s API configuration:
74 |
75 | ```swift
76 | struct APIConfigView: View {
77 | @State private var keyID = ""
78 | @State private var apiKey = ""
79 |
80 | var body: some View {
81 | Form {
82 | HStack {
83 | VStack {
84 | LabeledContent("Key ID") {
85 | TextField("e.g. 2X9R4HXF34", text: $keyID)
86 | .textFieldStyle(.roundedBorder)
87 | }
88 | .labeledContentStyle(.vertical())
89 |
90 | LabeledContent("API Key") {
91 | TextEditor(text: $apiKey)
92 | .frame(height: 80)
93 | .textFieldStyle(.roundedBorder)
94 | }
95 | .labeledContentStyle(.vertical())
96 | }
97 | }
98 | }
99 | }
100 | }
101 | ```
102 |
103 | 
104 |
105 | The `.vertical` style allows customizing alignment (defaults to `leading`) and spacing (defaults to 4). Pass `muteLabel: false` if you're providing a custom label style, as by default labels are automatically styled smaller and grayed out.
106 |
107 | For example, in [FreemiumKit]'s feature localization form, I want the vertical label to have a larger font:
108 |
109 | ```swift
110 | LabeledContent {
111 | LimitedTextField("English \(self.title)", text: self.$localizedString.fallback, characterLimit: self.characterLimit)
112 | .textFieldStyle(.roundedBorder)
113 | } label: {
114 | Text("English \(self.title) (\(self.isRequired ? "Required" : "Optional"))")
115 | .font(.title3)
116 | }
117 | .labeledContentStyle(.vertical(muteLabel: false))
118 | ```
119 |
120 | 
121 |
122 |
123 | ### Multi-Platform Toggle Style
124 |
125 | While SwiftUI provides a `.checkbox` toggle style, it's only available on macOS. HandySwiftUI adds `.checkboxUniversal` that brings checkbox-style toggles to all platforms (rendering as `.checkbox` on macOS):
126 |
127 | ```swift
128 | struct ProductRow: View {
129 | @State private var isEnabled: Bool = true
130 |
131 | var body: some View {
132 | HStack {
133 | Toggle("", isOn: $isEnabled)
134 | .toggleStyle(.checkboxUniversal)
135 |
136 | Text("Pro Monthly")
137 |
138 | Spacer()
139 | }
140 | }
141 | }
142 | ```
143 |
144 | 
145 |
146 | The example is extracted from [FreemiumKit]'s products screen, which is optimized for macOS but also supports other platforms.
147 |
148 | ## Topics
149 |
150 | ### ButtonStyle
151 |
152 | - ``SwiftUI/ButtonStyle/pulsating(color:cornerRadius:glowRadius:duration:)``
153 | - ``SwiftUI/ButtonStyle/primary(disabled:compact:)``
154 | - ``SwiftUI/ButtonStyle/secondary(disabled:compact:)``
155 |
156 | ### LabelStyle
157 |
158 | - ``SwiftUI/LabelStyle/fixedIconWidth(_:iconColor:titleColor:)``
159 | - ``SwiftUI/LabelStyle/horizontal(spacing:iconIsTrailing:iconColor:iconFont:iconAngle:iconAmount:)``
160 | - ``SwiftUI/LabelStyle/vertical(spacing:iconColor:iconFont:iconAngle:iconAmount:)``
161 |
162 | ### LabeledContentStyle
163 |
164 | - ``SwiftUI/LabeledContentStyle/horizontal(muteContent:)``
165 | - ``SwiftUI/LabeledContentStyle/vertical(alignment:spacing:muteLabel:)``
166 |
167 | ### ToggleStyle
168 |
169 | - ``SwiftUI/ToggleStyle/checkboxUniversal``
170 |
171 |
172 | [TranslateKit]: https://translatekit.app
173 | [FreemiumKit]: https://freemiumkit.app
174 | [FreelanceKit]: https://apps.apple.com/app/apple-store/id6480134993?pt=549314&ct=swiftpackageindex.com&mt=8
175 | [CrossCraft]: https://crosscraft.app
176 | [FocusBeats]: https://apps.apple.com/app/apple-store/id6477829138?pt=549314&ct=swiftpackageindex.com&mt=8
177 | [Guided Guest Mode]: https://apps.apple.com/app/apple-store/id6479207869?pt=549314&ct=swiftpackageindex.com&mt=8
178 | [Posters]: https://apps.apple.com/app/apple-store/id6478062053?pt=549314&ct=swiftpackageindex.com&mt=8
179 | [Pleydia Organizer]: https://apps.apple.com/app/apple-store/id6587583340?pt=549314&ct=swiftpackageindex.com&mt=8
180 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Essentials/View Modifiers.md:
--------------------------------------------------------------------------------
1 | # View Modifiers
2 |
3 | Adding missing view modifiers often needed in SwiftUI.
4 |
5 | @Metadata {
6 | @PageImage(purpose: icon, source: "HandySwiftUI")
7 | @PageImage(purpose: card, source: "ViewModifiers")
8 | }
9 |
10 | ## Highlights
11 |
12 | HandySwiftUI provides a collection of view modifiers that enhance SwiftUI and multi-platform development. While you can find a full list of all modifiers at the [Topics](#topics) section of this page, I want to highlight the ones I use most often in my apps:
13 |
14 | ### Smart Color Contrast
15 |
16 | The `foregroundStyle(_:minContrast:)` modifier ensures text remains readable by automatically adjusting color contrast. This is useful for dynamic colors or system colors like `.yellow` that might have poor contrast in certain color schemes:
17 |
18 | ```swift
19 | struct AdaptiveText: View {
20 | @State private var dynamicColor: Color = .yellow
21 |
22 | var body: some View {
23 | HStack {
24 | // Without contrast adjustment
25 | Text("Maybe hard to read")
26 | .foregroundStyle(dynamicColor)
27 |
28 | // With automatic contrast adjustment
29 | Text("Always readable")
30 | .foregroundStyle(dynamicColor, minContrast: 0.5)
31 | }
32 | }
33 | }
34 | ```
35 |
36 | 
37 |
38 | > Image: This warning indicator in [TranslateKit] uses `.yellow` but ensures a good contrast even in light mode to be legible.
39 |
40 | The `minContrast` parameter (ranging from 0 to 1) determines the minimum contrast ratio against either white (in dark mode) or black (in light mode) using the luminance value (perceived brightness). This ensures text stays readable regardless of the current color scheme.
41 |
42 |
43 | ### Error-Handling Tasks
44 |
45 | The `throwingTask` modifier streamlines async error handling in SwiftUI views. Unlike SwiftUI's built-in `.task` modifier which requires manual `do-catch` blocks, `throwingTask` provides a dedicated error handler closure:
46 |
47 | ```swift
48 | struct DataView: View {
49 | @State private var error: Error?
50 |
51 | var body: some View {
52 | ContentView()
53 | .throwingTask {
54 | try await loadData()
55 | } catchError: { error in
56 | self.error = error
57 | }
58 | }
59 | }
60 | ```
61 |
62 | The task behaves similarly to `.task` – starting when the view appears and canceling when it disappears. The `catchError` closure is optional, so you can omit it if you don't need to handle errors.
63 |
64 |
65 | ### Platform-Specific Styling
66 |
67 | A comprehensive set of platform modifiers enables precise control over cross-platform UI:
68 |
69 | ```swift
70 | struct AdaptiveInterface: View {
71 | var body: some View {
72 | ContentView()
73 | // Add padding only on macOS
74 | .macOSOnlyPadding(.all, 20)
75 | // Platform-specific styles
76 | .macOSOnly { $0.frame(minWidth: 800) }
77 | .iOSOnly { $0.navigationViewStyle(.stack) }
78 | }
79 | }
80 | ```
81 |
82 | The example showcases modifiers for platform-specific styling:
83 | - `.macOSOnlyPadding` adds padding only on macOS where containers like `Form` lack default padding
84 | - `.macOSOnlyFrame` sets minimum window sizes needed on macOS
85 | - Platform modifiers (`.iOSOnly`, `.macOSOnly`, `.iOSExcluded`, etc.) available for iOS, macOS, tvOS, visionOS, and watchOS allow selective application of view modifications on specific platforms
86 |
87 | These modifiers help create platform-appropriate interfaces while keeping the code clean and maintainable.
88 |
89 |
90 | ### Border with Corner Radius
91 |
92 | SwiftUI doesn't provide a straightforward way to add a border to a view with corner radius. The standard approach requires verbose overlay code that is hard to remember:
93 |
94 | ```swift
95 | Text("Without HandySwiftUI")
96 | .padding()
97 | .overlay(
98 | RoundedRectangle(cornerRadius: 12)
99 | .strokeBorder(.blue, lineWidth: 2)
100 | )
101 | ```
102 |
103 | HandySwiftUI simplifies this with a convenient border modifier:
104 |
105 | ```swift
106 | Text("With HandySwiftUI")
107 | .padding()
108 | .roundedRectangleBorder(.blue, cornerRadius: 12, lineWidth: 2)
109 | ```
110 |
111 | 
112 |
113 | > Image: Badges in [TranslateKit] use this for rounded borders, for example.
114 |
115 |
116 | ### Conditional Modifiers
117 |
118 | A suite of modifiers for handling conditional view modifications cleanly:
119 |
120 | ```swift
121 | struct DynamicContent: View {
122 | @State private var isEditMode = false
123 | @State private var accentColor: Color?
124 |
125 | var body: some View {
126 | ContentView()
127 | // Apply different modifiers based on condition
128 | .applyIf(isEditMode) {
129 | $0.overlay(EditingTools())
130 | } else: {
131 | $0.overlay(ViewingTools())
132 | }
133 |
134 | // Apply modifier only if optional exists
135 | .ifLet(accentColor) { view, color in
136 | view.tint(color)
137 | }
138 | }
139 | }
140 | ```
141 |
142 | The example demonstrates `.applyIf` which applies different view modifications based on a boolean condition, and `.ifLet` which works like Swift's `if let` statement – providing non-optional access to optional values inside its closure. Both modifiers help reduce boilerplate code in SwiftUI views.
143 |
144 |
145 | ### App Lifecycle Handling
146 |
147 | Respond to app state changes elegantly:
148 |
149 | ```swift
150 | struct MediaPlayerView: View {
151 | @StateObject private var player = VideoPlayer()
152 |
153 | var body: some View {
154 | PlayerContent(player: player)
155 | .onAppResignActive {
156 | // Pause playback when app goes to background
157 | player.pause()
158 | }
159 | .onAppBecomeActive {
160 | // Resume state when app becomes active
161 | player.checkPlaybackState()
162 | }
163 | }
164 | }
165 | ```
166 |
167 | These modifiers work together to create a more fluid and maintainable SwiftUI development experience, reducing boilerplate code while enhancing the quality and consistency of your user interface.
168 |
169 |
170 | ### Delete Confirmation Dialogs
171 |
172 | SwiftUI's confirmation dialogs require repetitive boilerplate code for delete actions, especially when deleting items from a list:
173 |
174 | ```swift
175 | struct TodoView: View {
176 | @State private var showDeleteConfirmation = false
177 | @State private var todos = ["Buy milk", "Walk dog"]
178 | @State private var todoToDelete: String?
179 |
180 | var body: some View {
181 | List {
182 | ForEach(todos, id: \.self) { todo in
183 | Text(todo)
184 | .swipeActions {
185 | Button("Delete", role: .destructive) {
186 | todoToDelete = todo
187 | showDeleteConfirmation = true
188 | }
189 | }
190 | }
191 | }
192 | .confirmationDialog("Are you sure?", isPresented: $showDeleteConfirmation) {
193 | Button("Delete", role: .destructive) {
194 | if let todo = todoToDelete {
195 | todos.removeAll { $0 == todo }
196 | todoToDelete = nil
197 | }
198 | }
199 | Button("Cancel", role: .cancel) {
200 | todoToDelete = nil
201 | }
202 | } message: {
203 | Text("This delete action cannot be undone. Continue?")
204 | }
205 | }
206 | }
207 | ```
208 |
209 | HandySwiftUI simplifies this with a dedicated modifier:
210 |
211 | ```swift
212 | struct TodoView: View {
213 | @State private var todoToDelete: String?
214 | @State private var todos = ["Buy milk", "Walk dog"]
215 |
216 | var body: some View {
217 | List {
218 | ForEach(todos, id: \.self) { todo in
219 | Text(todo)
220 | .swipeActions {
221 | Button("Delete", role: .destructive) {
222 | todoToDelete = todo
223 | }
224 | }
225 | }
226 | }
227 | .confirmDeleteDialog(item: $todoToDelete) { item in
228 | todos.removeAll { $0 == item }
229 | }
230 | }
231 | }
232 | ```
233 |
234 | 
235 |
236 | > Image: Puzzle deletion in [CrossCraft] with a confirmation dialog to avoid accidental deletes.
237 |
238 | The example shows how `.confirmDeleteDialog` handles the entire deletion flow – from confirmation to execution – with a single modifier. The dialog is automatically localized in ~40 languages and follows platform design guidelines. You can provide an optional `message` parameter in case you need to provide a different message. There's also an overload that takes a boolean for situations where no list is involved.
239 |
240 | ## Topics
241 |
242 | ### View
243 |
244 | - ``SwiftUICore/View/onAppBecomeActive(_:)``
245 | - ``SwiftUICore/View/onAppResignActive(_:)``
246 | - ``SwiftUICore/View/applyIf(_:modifier:)``
247 | - ``SwiftUICore/View/applyIf(_:modifier:else:)``
248 | - ``SwiftUICore/View/applyIfNot(_:modifier:)``
249 | - ``SwiftUICore/View/capsuleBorder(_:lineWidth:)``
250 | - ``SwiftUICore/View/confirmDeleteDialog(isPresented:performDelete:)``
251 | - ``SwiftUICore/View/confirmDeleteDialog(message:isPresented:performDelete:)``
252 | - ``SwiftUICore/View/confirmDeleteDialog(item:performDelete:)``
253 | - ``SwiftUICore/View/confirmDeleteDialog(message:item:performDelete:)``
254 | - ``SwiftUICore/View/eraseToAnyView()``
255 | - ``SwiftUICore/View/foregroundStyle(_:minContrast:)``
256 | - ``SwiftUICore/View/iOSExcluded(modifier:)``
257 | - ``SwiftUICore/View/iOSOnly(modifier:)``
258 | - ``SwiftUICore/View/ifLet(_:modifier:)``
259 | - ``SwiftUICore/View/ifLet(_:modifier:else:)``
260 | - ``SwiftUICore/View/macOSExcluded(modifier:)``
261 | - ``SwiftUICore/View/macOSOnly(modifier:)``
262 | - ``SwiftUICore/View/macOSOnlyPadding(_:_:)``
263 | - ``SwiftUICore/View/macOSOnlyPadding(insets:)``
264 | - ``SwiftUICore/View/macOSOnlyFrame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)``
265 | - ``SwiftUICore/View/onFirstAppear(perform:)``
266 | - ``SwiftUICore/View/progressOverlay(type:)``
267 | - ``SwiftUICore/View/roundedRectangleBorder(_:cornerRadius:lineWidth:)``
268 | - ``SwiftUICore/View/throwingTask(asyncAction:catchError:)``
269 | - ``SwiftUICore/View/tvOSExcluded(modifier:)``
270 | - ``SwiftUICore/View/tvOSOnly(modifier:)``
271 | - ``SwiftUICore/View/visionOSExcluded(modifier:)``
272 | - ``SwiftUICore/View/visionOSOnly(modifier:)``
273 | - ``SwiftUICore/View/watchOSExcluded(modifier:)``
274 | - ``SwiftUICore/View/watchOSOnly(modifier:)``
275 |
276 |
277 | [TranslateKit]: https://translatekit.app
278 | [FreemiumKit]: https://freemiumkit.app
279 | [FreelanceKit]: https://apps.apple.com/app/apple-store/id6480134993?pt=549314&ct=swiftpackageindex.com&mt=8
280 | [CrossCraft]: https://crosscraft.app
281 | [FocusBeats]: https://apps.apple.com/app/apple-store/id6477829138?pt=549314&ct=swiftpackageindex.com&mt=8
282 | [Guided Guest Mode]: https://apps.apple.com/app/apple-store/id6479207869?pt=549314&ct=swiftpackageindex.com&mt=8
283 | [Posters]: https://apps.apple.com/app/apple-store/id6478062053?pt=549314&ct=swiftpackageindex.com&mt=8
284 | [Pleydia Organizer]: https://apps.apple.com/app/apple-store/id6587583340?pt=549314&ct=swiftpackageindex.com&mt=8
285 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/HandySwiftUI.md:
--------------------------------------------------------------------------------
1 | # ``HandySwiftUI``
2 |
3 | Handy UI features that didn't make it into SwiftUI (yet).
4 |
5 | @Metadata {
6 | @PageImage(purpose: icon, source: "HandySwiftUI")
7 | }
8 |
9 | ## Essentials
10 |
11 | Learn how you can make the most of HandySwiftUI with these guides:
12 |
13 | @Links(visualStyle: detailedGrid) {
14 | -
15 | -
16 | -
17 | -
18 | }
19 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Extensions.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Extensions.jpeg
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Extensions/ColorfulView.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Extensions/ColorfulView.png
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Extensions/CommonTranslations.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Extensions/CommonTranslations.jpeg
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Extensions/FormattedText.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Extensions/FormattedText.png
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/HandySwiftUI.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/HandySwiftUI.png
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/NewTypes.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/NewTypes.jpeg
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/NewTypes/Last30Days.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/NewTypes/Last30Days.jpeg
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/NewTypes/OpenPanel.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/NewTypes/OpenPanel.jpeg
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/NewTypes/SettingsView.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/NewTypes/SettingsView.gif
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/NewTypes/SideTabView.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/NewTypes/SideTabView.png
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Styles.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Styles.jpeg
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Styles/ButtonStyles.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Styles/ButtonStyles.gif
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Styles/CheckboxUniversal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Styles/CheckboxUniversal.png
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Styles/MuteLabelFalse.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Styles/MuteLabelFalse.jpeg
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Styles/VerticalLabeledContent.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/Styles/VerticalLabeledContent.jpeg
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/ViewModifiers.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/ViewModifiers.jpeg
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/ViewModifiers/ConfirmDelete.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/ViewModifiers/ConfirmDelete.jpeg
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/ViewModifiers/StateBadges.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/ViewModifiers/StateBadges.png
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/ViewModifiers/YellowWithContrast.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlineDev/HandySwiftUI/68ad391c612d2bc1198d4e69ae29f02d5f922881/Sources/HandySwiftUI/HandySwiftUI.docc/Resources/ViewModifiers/YellowWithContrast.png
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/HandySwiftUI.docc/theme-settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "theme": {
3 | "color": {
4 | "header": "#002B7D",
5 | "documentation-intro-title": "#FFFFFF",
6 | "documentation-intro-figure": "#FFFFFF",
7 | "documentation-intro-fill": "radial-gradient(circle at top, var(--color-header) 30%, #000 100%)",
8 | "documentation-intro-accent": "var(--color-header)"
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Modifiers/FirstAppearModifier.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct FirstAppearModifier: ViewModifier {
4 | @State private var hasAppeared = false
5 | let perform: () -> Void
6 |
7 | func body(content: Content) -> some View {
8 | content.onAppear {
9 | guard !hasAppeared else { return }
10 | self.hasAppeared = true
11 | self.perform()
12 | }
13 | }
14 | }
15 |
16 | extension View {
17 | /// Adds a modifier that runs a closure only on the first appearance of a view,
18 | /// even across navigation stack transitions or view reconstructions.
19 | ///
20 | /// This is particularly useful in SwiftUI navigation scenarios where you want to perform
21 | /// an action only once when a view is initially displayed, but not when navigating back
22 | /// to it in a NavigationStack.
23 | ///
24 | /// Example usage:
25 | /// ```swift
26 | /// struct ProductListView: View {
27 | /// @State private var selectedProduct: Product?
28 | /// let products: [Product]
29 | ///
30 | /// var body: some View {
31 | /// List(products) { product in
32 | /// ProductRow(product: product)
33 | /// }
34 | /// .onFirstAppear {
35 | /// // Auto-select first product only once,
36 | /// // not when navigating back
37 | /// if let firstProduct = products.first {
38 | /// selectedProduct = firstProduct
39 | /// }
40 | /// }
41 | /// }
42 | /// }
43 | /// ```
44 | ///
45 | /// Common use cases include:
46 | /// - Initial data loading or setup
47 | /// - One-time animations or tutorials
48 | /// - Analytics events that should only fire once
49 | /// - Auto-selection of initial values
50 | ///
51 | /// - Parameter perform: The closure to execute on first appearance.
52 | /// - Returns: A view with the first-appear modifier applied.
53 | public func onFirstAppear(perform: @escaping () -> Void) -> some View {
54 | self.modifier(FirstAppearModifier(perform: perform))
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Modifiers/ForegroundStyleMinContrast.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ForegroundStyleMinContrast: ViewModifier {
4 | @Environment(\.colorScheme) private var colorScheme
5 |
6 | let requestedColor: Color
7 | let minContrast: Double
8 |
9 | /// Returns `.white` if color scheme is `.light` and `.black` if color scheme is `.dark`.
10 | private var background: Color {
11 | if self.colorScheme == .dark {
12 | return .gray.change(.luminance, to: 0)
13 | } else {
14 | return .gray.change(.luminance, to: 1)
15 | }
16 | }
17 |
18 | func body(content: Content) -> some View {
19 | content
20 | .foregroundStyle(self.requestedColorWithMinContrast)
21 | }
22 |
23 | var requestedColorWithMinContrast: Color {
24 | let requestedColorLuminance = self.requestedColor.hlco.luminance
25 | let backgroundLuminance = self.background.hlco.luminance
26 |
27 | guard abs(requestedColorLuminance - backgroundLuminance) < self.minContrast else { return requestedColor }
28 |
29 | if backgroundLuminance > 0.5 {
30 | return self.requestedColor.change(.luminance, to: max(backgroundLuminance - self.minContrast, 0))
31 | } else {
32 | return self.requestedColor.change(.luminance, to: min(backgroundLuminance + self.minContrast, 1))
33 | }
34 | }
35 | }
36 |
37 | extension View {
38 | /// Applies a foreground color with a minimum contrast to the background color.
39 | ///
40 | /// This modifier ensures that the foreground color has sufficient contrast against
41 | /// the background color, making it more legible.
42 | ///
43 | /// - Parameters:
44 | /// - color: The desired foreground color.
45 | /// - minContrast: The minimum contrast ratio between the foreground and background colors.
46 | ///
47 | /// - Returns: A view with the modified foreground color.
48 | public func foregroundStyle(_ color: Color, minContrast: Double) -> some View {
49 | self.modifier(ForegroundStyleMinContrast(requestedColor: color, minContrast: minContrast))
50 | }
51 | }
52 |
53 | #if DEBUG
54 | #Preview {
55 | VStack(spacing: 10) {
56 | Text(".green").foregroundColor(.green)
57 | Text(".green, minContrast: 0.5").foregroundStyle(.green, minContrast: 0.66)
58 | Text(".red").foregroundColor(.red)
59 | Text(".red, minContrast: 0.5").foregroundStyle(.red, minContrast: 0.66)
60 | }
61 | }
62 | #endif
63 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Modifiers/ProgressOverlay.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// The type of progress for the overlay.
4 | public enum ProgressType {
5 | /// An indeterminate progress indicator to be used to indicate an ongoing process without detailed progress information.
6 | case indeterminate(running: Bool, title: LocalizedStringKey? = nil)
7 |
8 | /// A determinate progress indicator indicating the current state of progress.
9 | case determinate(progress: Progress)
10 | }
11 |
12 | fileprivate struct ProgressOverlay: ViewModifier {
13 | let progressType: ProgressType
14 |
15 | func body(content: Content) -> some View {
16 | content
17 | .overlay(
18 | Group {
19 | switch progressType {
20 | case let .indeterminate(running, title):
21 | if running {
22 | if let title = title {
23 | ProgressView(title)
24 | }
25 | else {
26 | ProgressView()
27 | }
28 | }
29 | else {
30 | EmptyView()
31 | }
32 |
33 | case let .determinate(progress):
34 | ProgressView(progress)
35 | }
36 | }
37 | )
38 | }
39 | }
40 |
41 | extension View {
42 | /// Show a progress overlay on the current view of the given type.
43 | ///
44 | /// **Example:**
45 | ///
46 | /// ```swift
47 | /// struct MyView: View {
48 | /// @State private var isProgressRunning = false
49 | /// @State private var progress = Progress(totalUnitCount: 100)
50 | ///
51 | /// var body: some View {
52 | /// VStack {
53 | /// Button("Start Progress") {
54 | /// isProgressRunning = true
55 | /// progress.completedUnitCount = 0
56 | /// Task {
57 | /// for _ in 0..<100 {
58 | /// progress.completedUnitCount += 1
59 | /// MainActor.run {
60 | /// if progress.isFinished {
61 | /// isProgressRunning = false
62 | /// }
63 | /// }
64 | /// Thread.sleep(for: .milliseconds(100))
65 | /// }
66 | /// }
67 | /// }
68 | /// }
69 | /// .progressOverlay(type: isProgressRunning ? .indeterminate(running: true) : .determinate(progress: progress))
70 | /// }
71 | /// }
72 | /// ```
73 | public func progressOverlay(type: ProgressType) -> some View {
74 | modifier(ProgressOverlay(progressType: type))
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Modifiers/ThrowingTask.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ThrowingTaskModifier: ViewModifier {
4 | let asyncAction: () async throws -> Void
5 | let catchError: (Error) -> Void
6 |
7 | init(
8 | asyncAction: @escaping () async throws -> Void,
9 | catchError: @escaping (Error) -> Void
10 | ) {
11 | self.asyncAction = asyncAction
12 | self.catchError = catchError
13 | }
14 |
15 | func body(content: Content) -> some View {
16 | content
17 | .task {
18 | do {
19 | try await self.asyncAction()
20 | } catch {
21 | self.catchError(error)
22 | }
23 | }
24 | }
25 | }
26 |
27 | extension View {
28 | /// Applies a `ThrowingTaskModifier` to the view, allowing you to execute an asynchronous task and handle errors.
29 | ///
30 | /// See `ThrowingTaskModifier` for more details and an example.
31 | ///
32 | /// - Parameters:
33 | /// - asyncAction: The asynchronous task to execute.
34 | /// - catchError: A closure that is called if an error occurs during the task execution.
35 | ///
36 | /// - Returns: A view with the `ThrowingTaskModifier` applied.
37 | public func throwingTask(
38 | asyncAction: @escaping () async throws -> Void,
39 | catchError: @escaping (Error) -> Void = { _ in }
40 | ) -> some View {
41 | self.modifier(ThrowingTaskModifier(asyncAction: asyncAction, catchError: catchError))
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Styles/CheckboxUniversalToggleStyle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A custom toggle style that works like ``CheckboxToggleStyle`` but supports all Apple platforms, not just macOS.
4 | public struct CheckboxUniversalToggleStyle: ToggleStyle {
5 | let spacing: Double
6 |
7 | public func makeBody(configuration: Configuration) -> some View {
8 | #if os(macOS)
9 | Toggle(isOn: configuration.$isOn) {
10 | configuration.label
11 | }
12 | .toggleStyle(.checkbox)
13 | #elseif os(tvOS)
14 | Toggle(isOn: configuration.$isOn) {
15 | configuration.label
16 | }
17 | #else
18 | Button {
19 | withAnimation {
20 | configuration.isOn.toggle()
21 | }
22 | } label: {
23 | HStack(spacing: self.spacing) {
24 | Image(systemName: configuration.isOn ? "checkmark.square.fill" : "square")
25 | .foregroundStyle(Color.accentColor)
26 | .font(.title2)
27 | .frame(width: 32, height: 32, alignment: .center)
28 | .padding(.leading, -2)
29 |
30 | configuration.label
31 | }
32 | }
33 | .buttonStyle(.plain)
34 | #endif
35 | }
36 | }
37 |
38 | extension ToggleStyle where Self == CheckboxUniversalToggleStyle {
39 | /// A custom toggle style that works like ``ToggleStyle.checkbox`` but supports all Apple platforms, not just macOS.
40 | public static func checkboxUniversal(spacing: Double = 14) -> CheckboxUniversalToggleStyle {
41 | CheckboxUniversalToggleStyle(spacing: spacing)
42 | }
43 | }
44 |
45 | #if DEBUG && swift(>=6.0)
46 | @available(iOS 17, macOS 14, tvOS 17, visionOS 1, watchOS 10, *)
47 | #Preview {
48 | @Previewable @State var isOn: Bool = false
49 |
50 | Form {
51 | Toggle("Default Toggle Style", isOn: $isOn)
52 |
53 | Toggle("Checkbox Universal Style", isOn: $isOn)
54 | .toggleStyle(.checkboxUniversal())
55 |
56 | Label("Checkbox Label", systemImage: "square.fill")
57 | }
58 | .macOSOnlyPadding()
59 | }
60 | #endif
61 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Styles/FixedIconWidthLabelStyle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A label style that fixes the width of the icon and allows for custom icon and title colors.
4 | public struct FixedIconWidthLabelStyle: LabelStyle {
5 | let iconColor: Color?
6 | let titleColor: Color?
7 | let iconWidth: CGFloat
8 |
9 | /// Initializes a new `FixedIconWidthLabelStyle` with the specified icon width, icon color, and title color.
10 | ///
11 | /// - Parameters:
12 | /// - iconWidth: The fixed width of the icon.
13 | /// - iconColor: The optional color of the icon.
14 | /// - titleColor: The optional color of the title.
15 | public init(iconWidth: CGFloat, iconColor: Color?, titleColor: Color?) {
16 | self.iconWidth = iconWidth
17 | self.iconColor = iconColor
18 | self.titleColor = titleColor
19 | }
20 |
21 | public func makeBody(configuration: Configuration) -> some View {
22 | HStack(spacing: 10) {
23 | HStack {
24 | configuration.icon
25 | Spacer()
26 | }
27 | .applyIf(self.iconColor != nil) { $0.foregroundStyle(self.iconColor!) }
28 | .frame(width: self.iconWidth)
29 |
30 | configuration.title
31 | .applyIf(self.titleColor != nil) { $0.foregroundStyle(self.titleColor!) }
32 |
33 | Spacer()
34 | }
35 | }
36 | }
37 |
38 | extension LabelStyle where Self == FixedIconWidthLabelStyle {
39 | /// Creates a `FixedIconWidthLabelStyle` with the specified icon width, icon color, and title color.
40 | ///
41 | /// - Parameters:
42 | /// - iconWidth: The fixed width of the icon. Defaults to 22.
43 | /// - iconColor: The optional color of the icon.
44 | /// - titleColor: The optional color of the title.
45 | ///
46 | /// - Returns: A new `FixedIconWidthLabelStyle` instance.
47 | public static func fixedIconWidth(_ iconWidth: CGFloat = 22, iconColor: Color? = nil, titleColor: Color? = nil) -> FixedIconWidthLabelStyle {
48 | FixedIconWidthLabelStyle(iconWidth: iconWidth, iconColor: iconColor, titleColor: titleColor)
49 | }
50 | }
51 |
52 | #if DEBUG
53 | #Preview {
54 | Label("Hello World!", systemImage: "person")
55 | .labelStyle(.fixedIconWidth(iconColor: .green, titleColor: .secondary))
56 | .macOSOnlyPadding()
57 | }
58 | #endif
59 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Styles/HorizontalLabelStyle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A label style that arranges the icon and title horizontally with customizable spacing, position, and formatting.
4 | public struct HorizontalLabelStyle: LabelStyle {
5 | var spacing: CGFloat
6 | var iconIsTrailing: Bool
7 | var iconColor: Color?
8 | var iconFont: Font?
9 | var iconAngle: Angle?
10 | var iconAmount: Int
11 |
12 |
13 | /// Initializes a new `HorizontalLabelStyle` with the specified properties.
14 | ///
15 | /// - Parameters:
16 | /// - spacing: The spacing between the icon and title.
17 | /// - iconIsTrailing: Whether the icon should be positioned before or after the title (default: false - before).
18 | /// - iconColor: The optional color of the icon.
19 | /// - iconFont: The optional font of the icon.
20 | /// - iconAngle: The optional rotation angle of the icon.
21 | /// - iconAmount: The number of icons to display (default: 1).
22 | public init(spacing: CGFloat, iconIsTrailing: Bool, iconColor: Color?, iconFont: Font?, iconAngle: Angle?, iconAmount: Int) {
23 | self.spacing = spacing
24 | self.iconIsTrailing = iconIsTrailing
25 | self.iconColor = iconColor
26 | self.iconFont = iconFont
27 | self.iconAngle = iconAngle
28 | self.iconAmount = iconAmount
29 | }
30 |
31 | public func makeBody(configuration: Configuration) -> some View {
32 | HStack(alignment: .center, spacing: self.spacing) {
33 | if !self.iconIsTrailing {
34 | self.iconView(configuration: configuration)
35 | }
36 |
37 | configuration.title
38 |
39 | if self.iconIsTrailing {
40 | self.iconView(configuration: configuration)
41 | }
42 | }
43 | }
44 |
45 | private func iconView(configuration: Configuration) -> some View {
46 | HStack(spacing: 0) {
47 | ForEach(0.. HorizontalLabelStyle {
77 | HorizontalLabelStyle(
78 | spacing: spacing,
79 | iconIsTrailing: iconIsTrailing,
80 | iconColor: iconColor,
81 | iconFont: iconFont,
82 | iconAngle: iconAngle,
83 | iconAmount: iconAmount
84 | )
85 | }
86 | }
87 |
88 | #if DEBUG
89 | #Preview("Default") {
90 | Label("Hogwarts", systemImage: "graduationcap")
91 | .labelStyle(.horizontal())
92 | .macOSOnlyPadding()
93 | }
94 |
95 | #Preview("Trailing") {
96 | Label("Hogwarts", systemImage: "graduationcap")
97 | .labelStyle(.horizontal(iconIsTrailing: true))
98 | .macOSOnlyPadding()
99 | }
100 |
101 | #Preview("Custom") {
102 | Label("Hogwarts", systemImage: "plus.circle")
103 | .labelStyle(.horizontal(spacing: 20, iconColor: .orange, iconFont: .title, iconAmount: 2))
104 | .font(.footnote)
105 | .macOSOnlyPadding()
106 | }
107 | #endif
108 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Styles/HorizontalLabeledContentStyle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A labeled content style that arranges the label and content horizontally. Useful for getting the iOS-like behavior on macOS when outside a grouped form.
4 | public struct HorizontalLabeledContentStyle: LabeledContentStyle {
5 | let muteContent: Bool
6 |
7 | public func makeBody(configuration: Configuration) -> some View {
8 | HStack {
9 | configuration.label
10 |
11 | Spacer()
12 |
13 | configuration.content
14 | .foregroundStyle(.secondary)
15 | }
16 | }
17 | }
18 |
19 | extension LabeledContentStyle where Self == HorizontalLabeledContentStyle {
20 | /// Creates a `HorizontalLabeledContentStyle` with customizable properties.
21 | ///
22 | /// - Parameters:
23 | /// - muteContent: Whether to mute the content (default: true).
24 | /// - Returns: A new `HorizontalLabeledContentStyle` instance.
25 | public static func horizontal(muteContent: Bool = true) -> HorizontalLabeledContentStyle {
26 | HorizontalLabeledContentStyle(muteContent: muteContent)
27 | }
28 | }
29 |
30 | #if DEBUG
31 | #Preview {
32 | VStack {
33 | Form {
34 | LabeledContent("Default Key", value: "Some Value")
35 |
36 | LabeledContent("Horizontal Key", value: "Some Value")
37 | .labeledContentStyle(.horizontal())
38 | }
39 | .formStyle(.grouped)
40 |
41 | GroupBox {
42 | LabeledContent("Default Key", value: "Some Value")
43 | .padding(5)
44 |
45 | Divider()
46 |
47 | LabeledContent("Horizontal Key", value: "Some Value")
48 | .labeledContentStyle(.horizontal())
49 | .padding(5)
50 | }
51 | .macOSOnlyPadding()
52 | }
53 | }
54 | #endif
55 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Styles/PrimaryButtonStyle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A primary button style with customizable appearances for default, disabled, and compact states.
4 | public struct PrimaryButtonStyle: ButtonStyle {
5 | let disabled: Bool
6 | let compact: Bool
7 |
8 | /// Creates a new `PrimaryButtonStyle`.
9 | ///
10 | /// - Parameters:
11 | /// - disabled: Whether the button should be disabled. Defaults to `false`.
12 | /// - compact: Whether the button should use a compact style. Defaults to `false`.
13 | public init(disabled: Bool = false, compact: Bool = false) {
14 | self.disabled = disabled
15 | self.compact = compact
16 | }
17 |
18 | public func makeBody(configuration: Configuration) -> some View {
19 | configuration.label
20 | .padding(.vertical, self.compact ? 4 : 8)
21 | .padding(.horizontal, self.compact ? 8 : 16)
22 | .font(self.compact ? .body : .title3)
23 | .foregroundStyle(self.disabled ? .white.opacity(0.66) : .white)
24 | .background(self.backgroundColor(configuration: configuration))
25 | .clipShape(.rect(cornerRadius: self.compact ? 6 : 11))
26 | }
27 |
28 | func backgroundColor(configuration: Configuration) -> Color {
29 | if self.disabled {
30 | Color.gray
31 | } else if configuration.role == .destructive {
32 | Color.red.opacity(configuration.isPressed ? 0.66 : 1)
33 | } else {
34 | Color.accentColor.opacity(configuration.isPressed ? 0.66 : 1)
35 | }
36 | }
37 | }
38 |
39 | extension ButtonStyle where Self == PrimaryButtonStyle {
40 | /// Creates a primary button style.
41 | ///
42 | /// - Parameters:
43 | /// - disabled: Whether the button should be disabled. Defaults to `false`.
44 | /// - compact: Whether the button should use a compact style. Defaults to `false`.
45 | ///
46 | /// **Example:**
47 | /// ```swift
48 | /// Button("Primary Button") {}
49 | /// .buttonStyle(.primary())
50 | /// ```
51 | public static func primary(disabled: Bool = false, compact: Bool = false) -> Self {
52 | PrimaryButtonStyle(disabled: disabled, compact: compact)
53 | }
54 | }
55 |
56 | #if DEBUG
57 | #Preview {
58 | VStack {
59 | Button("Default", systemImage: "person") {}
60 | .buttonStyle(.primary())
61 |
62 | Button("Disabled", systemImage: "person") {}
63 | .buttonStyle(.primary(disabled: true))
64 |
65 | Button("Compact", systemImage: "person") {}
66 | .buttonStyle(.primary(compact: true))
67 |
68 | Button("System", systemImage: "person") {}
69 | .buttonStyle(.borderedProminent)
70 | }
71 | .padding()
72 | }
73 | #endif
74 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Styles/PulsatingButtonStyle.swift:
--------------------------------------------------------------------------------
1 | import HandySwift
2 | import SwiftUI
3 |
4 | /// A button style that creates a pulsating effect around the button's background.
5 | public struct PulsatingButtonStyle: ButtonStyle {
6 | let color: Color
7 | let cornerRadius: CGFloat
8 | let glowRadius: CGFloat
9 | let duration: TimeInterval
10 |
11 | @State private var onBeat = false
12 |
13 | /// Initializes a new `PulsatingButtonStyle` with the specified properties.
14 | ///
15 | /// - Parameters:
16 | /// - color: The color of the button and its pulsating effect.
17 | /// - cornerRadius: The corner radius of the button.
18 | /// - glowRadius: The radius of the pulsating effect's glow (default: 5).
19 | /// - duration: The duration of a single pulsation cycle (default: 2 seconds).
20 | public init(color: Color, cornerRadius: CGFloat, glowRadius: CGFloat = 5, duration: TimeInterval = .seconds(2)) {
21 | self.color = color
22 | self.cornerRadius = cornerRadius
23 | self.glowRadius = glowRadius
24 | self.duration = duration
25 | }
26 |
27 | public func makeBody(configuration: Configuration) -> some View {
28 | configuration.label
29 | .background(
30 | RoundedRectangle(cornerRadius: self.onBeat ? self.cornerRadius + self.glowRadius : self.cornerRadius)
31 | .stroke(self.color, lineWidth: self.onBeat ? 2 * self.glowRadius : 0)
32 | .padding(self.onBeat ? -self.glowRadius : 0)
33 | .opacity(self.onBeat ? 0 : 0.5)
34 | .animation(.easeOut(duration: self.duration).repeatForever(autoreverses: false), value: self.onBeat)
35 | .onAppear {
36 | self.onBeat.toggle()
37 | }
38 | )
39 | }
40 | }
41 |
42 | extension ButtonStyle where Self == PulsatingButtonStyle {
43 | /// Creates a `PulsatingButtonStyle` with customizable properties.
44 | ///
45 | /// - Parameters:
46 | /// - color: The color of the button and its pulsating effect.
47 | /// - cornerRadius: The corner radius of the button.
48 | /// - glowRadius: The radius of the pulsating effect's glow (default: 5).
49 | /// - duration: The duration of a single pulsation cycle (default: 2 seconds).
50 | ///
51 | /// - Returns: A new `PulsatingButtonStyle` instance.
52 | public static func pulsating(color: Color, cornerRadius: CGFloat, glowRadius: CGFloat = 5, duration: TimeInterval = .seconds(2)) -> PulsatingButtonStyle {
53 | PulsatingButtonStyle(color: color, cornerRadius: cornerRadius, glowRadius: glowRadius, duration: duration)
54 | }
55 | }
56 |
57 | #if DEBUG
58 | #Preview {
59 | VStack {
60 | Button {
61 |
62 | } label: {
63 | Image(systemName: "person.circle")
64 | .font(.largeTitle)
65 | .foregroundStyle(Color.accentColor)
66 | }
67 | .buttonStyle(.pulsating(color: Color.accentColor, cornerRadius: 25))
68 | }
69 | .padding(20)
70 | }
71 | #endif
72 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Styles/SecondaryButtonStyle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A secondary button style with customizable appearances for default, disabled, and compact states.
4 | public struct SecondaryButtonStyle: ButtonStyle {
5 | let disabled: Bool
6 | let compact: Bool
7 |
8 | /// Creates a new `SecondaryButtonStyle`.
9 | ///
10 | /// - Parameters:
11 | /// - disabled: Whether the button should be disabled. Defaults to `false`.
12 | /// - compact: Whether the button should use a compact style. Defaults to `false`.
13 | public init(disabled: Bool = false, compact: Bool = false) {
14 | self.disabled = disabled
15 | self.compact = compact
16 | }
17 |
18 | public func makeBody(configuration: Configuration) -> some View {
19 | configuration.label
20 | .padding(.vertical, self.compact ? 4 : 8)
21 | .padding(.horizontal, self.compact ? 8 : 16)
22 | .font(self.compact ? .body : .title3)
23 | .foregroundStyle(self.disabled ? .secondary : (configuration.isPressed ? Color.white : Color.accentColor))
24 | .background(self.backgroundColor(configuration: configuration))
25 | .clipShape(.rect(cornerRadius: self.compact ? 6 : 11))
26 | .roundedRectangleBorder(self.disabled ? .clear : .secondary, cornerRadius: self.compact ? 6 : 12, lineWidth: 0.5)
27 | }
28 |
29 | func backgroundColor(configuration: Configuration) -> Color {
30 | if self.disabled {
31 | Color.secondary.opacity(0.1618)
32 | } else if configuration.role == .destructive {
33 | Color.red.opacity(configuration.isPressed ? 0.66 : 1)
34 | } else {
35 | configuration.isPressed ? Color.accentColor : .clear
36 | }
37 | }
38 | }
39 |
40 | extension ButtonStyle where Self == SecondaryButtonStyle {
41 | /// Creates a secondary button style.
42 | ///
43 | /// - Parameters:
44 | /// - disabled: Whether the button should be disabled. Defaults to `false`.
45 | /// - compact: Whether the button should use a compact style. Defaults to `false`.
46 | ///
47 | /// **Example:**
48 | /// ```swift
49 | /// Button("Secondary Button") {}
50 | /// .buttonStyle(.secondary())
51 | /// ```
52 | public static func secondary(disabled: Bool = false, compact: Bool = false) -> Self {
53 | SecondaryButtonStyle(disabled: disabled, compact: compact)
54 | }
55 | }
56 |
57 | #if DEBUG
58 | #Preview {
59 | VStack {
60 | Button("Default", systemImage: "person") {}
61 | .buttonStyle(.secondary())
62 |
63 | Button("Disabled", systemImage: "person") {}
64 | .buttonStyle(.secondary(disabled: true))
65 |
66 | Button("Compact", systemImage: "person") {}
67 | .buttonStyle(.secondary(compact: true))
68 |
69 | Button("System", systemImage: "person") {}
70 | .buttonStyle(.bordered)
71 | }
72 | .padding()
73 | }
74 | #endif
75 |
76 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Styles/VerticalLabelStyle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A label style that arranges the icon and title vertically with customizable spacing and formatting.
4 | public struct VerticalLabelStyle: LabelStyle {
5 | let spacing: CGFloat
6 | let iconColor: Color?
7 | let iconFont: Font?
8 | let iconAngle: Angle?
9 | let iconAmount: Int
10 |
11 | /// Initializes a new `VerticalLabelStyle` with the specified properties.
12 | ///
13 | /// - Parameters:
14 | /// - spacing: The spacing between the icon and title.
15 | /// - iconColor: The optional color of the icon (default: accentColor).
16 | /// - iconFont: The optional font of the icon.
17 | /// - iconAngle: The optional rotation angle of the icon.
18 | /// - iconAmount: The number of icons to display (default: 1).
19 | public init(spacing: CGFloat, iconColor: Color? = .accentColor, iconFont: Font? = nil, iconAngle: Angle? = nil, iconAmount: Int = 1) {
20 | self.spacing = spacing
21 | self.iconColor = iconColor
22 | self.iconFont = iconFont
23 | self.iconAngle = iconAngle
24 | self.iconAmount = iconAmount
25 | }
26 |
27 | public func makeBody(configuration: Configuration) -> some View {
28 | VStack(alignment: .center, spacing: self.spacing) {
29 | HStack(spacing: 0) {
30 | ForEach(0.. VerticalLabelStyle {
61 | VerticalLabelStyle(spacing: spacing, iconColor: iconColor, iconFont: iconFont, iconAngle: iconAngle, iconAmount: iconAmount)
62 | }
63 | }
64 |
65 | #if DEBUG
66 | #Preview("Default") {
67 | Label("Hogwarts", systemImage: "graduationcap")
68 | .labelStyle(.vertical())
69 | .macOSOnlyPadding()
70 | }
71 |
72 | #Preview("Custom") {
73 | Label("Hogwarts", systemImage: "plus.circle")
74 | .labelStyle(.vertical(spacing: 20, iconColor: .orange, iconFont: .title, iconAmount: 2))
75 | .font(.footnote)
76 | .macOSOnlyPadding()
77 | }
78 | #endif
79 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Styles/VerticalLabeledContentStyle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A labeled content style that arranges the label and content vertically.
4 | public struct VerticalLabeledContentStyle: LabeledContentStyle {
5 | let alignment: HorizontalAlignment
6 | let spacing: CGFloat
7 | let muteLabel: Bool
8 |
9 | /// Initializes a new `VerticalLabeledContentStyle` with the specified properties.
10 | ///
11 | /// - Parameters:
12 | /// - alignment: The horizontal alignment of the label and content (default: `.leading`).
13 | /// - spacing: The spacing between the label and content (default: 4).
14 | /// - muteLabel: Whether to mute the label's appearance (default: true).
15 | public init(alignment: HorizontalAlignment, spacing: CGFloat, muteLabel: Bool) {
16 | self.alignment = alignment
17 | self.spacing = spacing
18 | self.muteLabel = muteLabel
19 | }
20 |
21 | public func makeBody(configuration: Configuration) -> some View {
22 | VStack(alignment: self.alignment, spacing: self.spacing) {
23 | configuration.label
24 | .applyIf(self.muteLabel) { label in
25 | label
26 | .font(.footnote)
27 | .foregroundStyle(Color.secondaryLabel)
28 | .minimumScaleFactor(0.75)
29 | }
30 |
31 | configuration.content
32 | }
33 | }
34 | }
35 |
36 | extension LabeledContentStyle where Self == VerticalLabeledContentStyle {
37 | /// Creates a `VerticalLabeledContentStyle` with customizable properties.
38 | ///
39 | /// - Parameters:
40 | /// - alignment: The horizontal alignment of the label and content (default: `.leading`).
41 | /// - spacing: The spacing between the label and content (default: 4).
42 | /// - muteLabel: Whether to mute the label's appearance (default: true).
43 | ///
44 | /// - Returns: A new `VerticalLabeledContentStyle` instance.
45 | public static func vertical(
46 | alignment: HorizontalAlignment = .leading,
47 | spacing: CGFloat = 4,
48 | muteLabel: Bool = true
49 | ) -> VerticalLabeledContentStyle {
50 | VerticalLabeledContentStyle(alignment: alignment, spacing: spacing, muteLabel: muteLabel)
51 | }
52 | }
53 |
54 | #if DEBUG
55 | #Preview {
56 | LabeledContent("Some Key", value: "Some Value")
57 | .labeledContentStyle(.vertical())
58 | .macOSOnlyPadding()
59 | }
60 | #endif
61 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Other/AsyncResult.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// Represents the state of an asynchronous operation that produces a value when successful.
4 | ///
5 | /// Use this type to track the progression of asynchronous operations through their lifecycle:
6 | /// - Initial state (not started)
7 | /// - Loading state (in progress)
8 | /// - Final state (either successful with a value or failed with an error)
9 | ///
10 | /// Example usage:
11 | /// ```swift
12 | /// var state = AsyncResult.notStarted
13 | /// state = .inProgress
14 | /// do {
15 | /// let value = try await perform()
16 | /// state = .successful(value: value)
17 | /// } catch {
18 | /// state = .failed(error: error)
19 | /// }
20 | /// ```
21 | ///
22 | /// For operations that don't produce any value when successful, use ``AsyncState`` instead.
23 | ///
24 | /// - Note: Both the value and error types must conform to `Sendable` and `CustomStringConvertible` respectively to ensure thread-safety and proper error reporting.
25 | public enum AsyncResult {
26 | /// The operation has not started yet.
27 | case notStarted
28 |
29 | /// The operation is currently in progress.
30 | case inProgress
31 |
32 | /// The operation failed with an error.
33 | case failed(error: ErrorType)
34 |
35 | /// The operation completed successfully.
36 | case successful(value: ValueType)
37 | }
38 |
39 | extension AsyncResult: Equatable where ValueType: Equatable, ErrorType: Equatable {}
40 | extension AsyncResult: Hashable where ValueType: Hashable, ErrorType: Hashable {}
41 | extension AsyncResult: Encodable where ValueType: Encodable, ErrorType: Encodable {}
42 | extension AsyncResult: Decodable where ValueType: Decodable, ErrorType: Decodable {}
43 | extension AsyncResult: Sendable where ValueType: Sendable, ErrorType: Sendable {}
44 |
45 | extension AsyncResult where ErrorType == String {
46 | /// Returns the error message if the state is `.failed`, or `nil` otherwise.
47 | public var failedErrorMessage: String? {
48 | guard case .failed(let errorMessage) = self else { return nil }
49 | return errorMessage
50 | }
51 | }
52 |
53 | extension AsyncResult where ErrorType: Error {
54 | /// Returns the error description if the state is `.failed`, or `nil` otherwise.
55 | public var failedErrorDescription: String? {
56 | guard case .failed(let error) = self else { return nil }
57 | return error.localizedDescription
58 | }
59 | }
60 |
61 | extension AsyncResult {
62 | /// Returns the successful results unwrapped value if the async operation was successful. Else, returns `nil`.
63 | public var successValue: ValueType? {
64 | switch self {
65 | case .successful(let value): value
66 | default: nil
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Other/AsyncState.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | @available(*, deprecated, renamed: "AsyncState")
4 | public typealias ProgressState = AsyncState
5 |
6 | /// Represents the state of an asynchronous operation.
7 | ///
8 | /// Use this type to track the progression of asynchronous operations through their lifecycle:
9 | /// - Initial state (not started)
10 | /// - Loading state (in progress)
11 | /// - Final state (either successful or failed with an error)
12 | ///
13 | /// Example usage:
14 | /// ```swift
15 | /// var state = AsyncState.notStarted
16 | /// state = .inProgress
17 | /// do {
18 | /// try await perform()
19 | /// state = .successful
20 | /// } catch {
21 | /// state = .failed(error: error)
22 | /// }
23 | /// ```
24 | ///
25 | /// For operations that produce a value when successful, use ``AsyncResult`` instead.
26 | ///
27 | /// - Note: The error type must conform to `CustomStringConvertible` to ensure proper error reporting.
28 | public enum AsyncState {
29 | /// The operation has not started yet.
30 | case notStarted
31 |
32 | /// The operation is currently in progress.
33 | case inProgress
34 |
35 | /// The operation failed with an error.
36 | case failed(error: ErrorType)
37 |
38 | /// The operation completed successfully.
39 | case successful
40 | }
41 |
42 | extension AsyncState: Equatable where ErrorType: Equatable {}
43 | extension AsyncState: Hashable where ErrorType: Hashable {}
44 | extension AsyncState: Encodable where ErrorType: Encodable {}
45 | extension AsyncState: Decodable where ErrorType: Decodable {}
46 | extension AsyncState: Sendable where ErrorType: Sendable {}
47 |
48 | extension AsyncState where ErrorType == String {
49 | /// Returns the error message if the state is `.failed`, or `nil` otherwise.
50 | public var failedErrorMessage: String? {
51 | guard case .failed(let errorMessage) = self else { return nil }
52 | return errorMessage
53 | }
54 | }
55 |
56 | extension AsyncState where ErrorType: Error {
57 | /// Returns the error description if the state is `.failed`, or `nil` otherwise.
58 | public var failedErrorDescription: String? {
59 | guard case .failed(let error) = self else { return nil }
60 | return error.localizedDescription
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Other/ColorSpaces.swift:
--------------------------------------------------------------------------------
1 | // Original source: https://github.com/timrwood/ColorSpaces
2 |
3 | // swiftlint:disable all
4 |
5 | import SwiftUI
6 |
7 | private struct ColorSpaces {}
8 |
9 | // MARK: - Constants
10 | private let RAD_TO_DEG = 180 / Double.pi
11 |
12 | private let LAB_E: Double = 0.008856
13 | private let LAB_16_116: Double = 0.1379310
14 | private let LAB_K_116: Double = 7.787036
15 | private let LAB_X: Double = 0.95047
16 | private let LAB_Y: Double = 1
17 | private let LAB_Z: Double = 1.088_83
18 |
19 | // MARK: - RGB
20 | struct RGBColor {
21 | let r: Double // 0..1
22 | let g: Double // 0..1
23 | let b: Double // 0..1
24 | let alpha: Double // 0..1
25 |
26 | init(
27 | r: Double,
28 | g: Double,
29 | b: Double,
30 | alpha: Double
31 | ) {
32 | self.r = r
33 | self.g = g
34 | self.b = b
35 | self.alpha = alpha
36 | }
37 |
38 | fileprivate func sRGBCompand(_ v: Double) -> Double {
39 | let absV = abs(v)
40 | let out = absV > 0.040_45 ? pow((absV + 0.055) / 1.055, 2.4) : absV / 12.92
41 | return v > 0 ? out : -out
42 | }
43 |
44 | func toXYZ() -> XYZColor {
45 | let R = sRGBCompand(r)
46 | let G = sRGBCompand(g)
47 | let B = sRGBCompand(b)
48 | let x: Double = (R * 0.412_456_4) + (G * 0.357_576_1) + (B * 0.180_437_5)
49 | let y: Double = (R * 0.212_672_9) + (G * 0.715_152_2) + (B * 0.072_175_0)
50 | let z: Double = (R * 0.019_333_9) + (G * 0.119_192_0) + (B * 0.950_304_1)
51 | return XYZColor(x: x, y: y, z: z, alpha: alpha)
52 | }
53 |
54 | func toLAB() -> LABColor {
55 | return toXYZ().toLAB()
56 | }
57 |
58 | func toLCH() -> LCHColor {
59 | return toXYZ().toLCH()
60 | }
61 |
62 | func lerp(_ other: RGBColor, t: Double) -> RGBColor {
63 | return RGBColor(
64 | r: r + (other.r - r) * t,
65 | g: g + (other.g - g) * t,
66 | b: b + (other.b - b) * t,
67 | alpha: alpha + (other.alpha - alpha) * t
68 | )
69 | }
70 | }
71 |
72 | extension Color {
73 | func rgbColor() -> RGBColor {
74 | let localRgbo = self.rgbo
75 | return RGBColor(r: localRgbo.red, g: localRgbo.green, b: localRgbo.blue, alpha: localRgbo.opacity)
76 | }
77 | }
78 |
79 | // MARK: - XYZ
80 |
81 | struct XYZColor {
82 | let x: Double // 0..0.95047
83 | let y: Double // 0..1
84 | let z: Double // 0..1.08883
85 | let alpha: Double // 0..1
86 |
87 | init(
88 | x: Double,
89 | y: Double,
90 | z: Double,
91 | alpha: Double
92 | ) {
93 | self.x = x
94 | self.y = y
95 | self.z = z
96 | self.alpha = alpha
97 | }
98 |
99 | fileprivate func sRGBCompand(_ v: Double) -> Double {
100 | let absV = abs(v)
101 | let out = absV > 0.003_130_8 ? 1.055 * pow(absV, 1 / 2.4) - 0.055 : absV * 12.92
102 | return v > 0 ? out : -out
103 | }
104 |
105 | func toRGB() -> RGBColor {
106 | let r = (x * 3.240_454_2) + (y * -1.537_138_5) + (z * -0.498_531_4)
107 | let g = (x * -0.969_266_0) + (y * 1.876_010_8) + (z * 0.041_556_0)
108 | let b = (x * 0.055_643_4) + (y * -0.204_025_9) + (z * 1.057_225_2)
109 | let R = sRGBCompand(r)
110 | let G = sRGBCompand(g)
111 | let B = sRGBCompand(b)
112 | return RGBColor(r: R, g: G, b: B, alpha: alpha)
113 | }
114 |
115 | fileprivate func labCompand(_ v: Double) -> Double {
116 | return v > LAB_E ? pow(v, 1.0 / 3.0) : (LAB_K_116 * v) + LAB_16_116
117 | }
118 |
119 | func toLAB() -> LABColor {
120 | let fx = labCompand(x / LAB_X)
121 | let fy = labCompand(y / LAB_Y)
122 | let fz = labCompand(z / LAB_Z)
123 | return LABColor(
124 | l: 116 * fy - 16,
125 | a: 500 * (fx - fy),
126 | b: 200 * (fy - fz),
127 | alpha: alpha
128 | )
129 | }
130 |
131 | func toLCH() -> LCHColor {
132 | return toLAB().toLCH()
133 | }
134 |
135 | func lerp(_ other: XYZColor, t: Double) -> XYZColor {
136 | return XYZColor(
137 | x: x + (other.x - x) * t,
138 | y: y + (other.y - y) * t,
139 | z: z + (other.z - z) * t,
140 | alpha: alpha + (other.alpha - alpha) * t
141 | )
142 | }
143 | }
144 |
145 | // MARK: - LAB
146 |
147 | struct LABColor {
148 | let l: Double // 0..100
149 | let a: Double // -128..128
150 | let b: Double // -128..128
151 | let alpha: Double // 0..1
152 |
153 | init(
154 | l: Double,
155 | a: Double,
156 | b: Double,
157 | alpha: Double
158 | ) {
159 | self.l = l
160 | self.a = a
161 | self.b = b
162 | self.alpha = alpha
163 | }
164 |
165 | fileprivate func xyzCompand(_ v: Double) -> Double {
166 | let v3 = v * v * v
167 | return v3 > LAB_E ? v3 : (v - LAB_16_116) / LAB_K_116
168 | }
169 |
170 | func toXYZ() -> XYZColor {
171 | let y = (l + 16) / 116
172 | let x = y + (a / 500)
173 | let z = y - (b / 200)
174 | return XYZColor(
175 | x: xyzCompand(x) * LAB_X,
176 | y: xyzCompand(y) * LAB_Y,
177 | z: xyzCompand(z) * LAB_Z,
178 | alpha: alpha
179 | )
180 | }
181 |
182 | func toLCH() -> LCHColor {
183 | let c = sqrt(a * a + b * b)
184 | let angle = atan2(b, a) * RAD_TO_DEG
185 | let h = angle < 0 ? angle + 360 : angle
186 | return LCHColor(l: l, c: c, h: h, alpha: alpha)
187 | }
188 |
189 | func toRGB() -> RGBColor {
190 | return toXYZ().toRGB()
191 | }
192 |
193 | func lerp(_ other: LABColor, t: Double) -> LABColor {
194 | return LABColor(
195 | l: l + (other.l - l) * t,
196 | a: a + (other.a - a) * t,
197 | b: b + (other.b - b) * t,
198 | alpha: alpha + (other.alpha - alpha) * t
199 | )
200 | }
201 | }
202 |
203 | // MARK: - LCH
204 |
205 | struct LCHColor {
206 | let l: Double // 0..100
207 | let c: Double // 0..128
208 | let h: Double // 0..360
209 | let alpha: Double // 0..1
210 |
211 | init(
212 | l: Double,
213 | c: Double,
214 | h: Double,
215 | alpha: Double
216 | ) {
217 | self.l = l
218 | self.c = c
219 | self.h = h
220 | self.alpha = alpha
221 | }
222 |
223 | func toLAB() -> LABColor {
224 | let rad = h / RAD_TO_DEG
225 | let a = cos(rad) * c
226 | let b = sin(rad) * c
227 | return LABColor(l: l, a: a, b: b, alpha: alpha)
228 | }
229 |
230 | func toXYZ() -> XYZColor {
231 | return toLAB().toXYZ()
232 | }
233 |
234 | func toRGB() -> RGBColor {
235 | return toXYZ().toRGB()
236 | }
237 |
238 | func lerp(_ other: LCHColor, t: Double) -> LCHColor {
239 | let angle =
240 | (((((other.h - h).truncatingRemainder(dividingBy: 360)) + 540).truncatingRemainder(dividingBy: 360)) - 180) * t
241 | return LCHColor(
242 | l: l + (other.l - l) * t,
243 | c: c + (other.c - c) * t,
244 | h: (h + angle + 360).truncatingRemainder(dividingBy: 360),
245 | alpha: alpha + (other.alpha - alpha) * t
246 | )
247 | }
248 | }
249 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Other/OpenPanel.swift:
--------------------------------------------------------------------------------
1 | #if os(macOS)
2 | import AppKit
3 | import UniformTypeIdentifiers
4 |
5 | /// A wrapper around NSOpenPanel that provides a simplified interface for selecting files or directories in macOS applications.
6 | /// This brings OpenPanel to SwiftUI views without a need to import AppKit or deal with legacy-style APIs.
7 | ///
8 | /// This struct provides two main initialization paths:
9 | /// 1. File selection with specified content type restrictions
10 | /// 2. Directory selection
11 | ///
12 | /// All panel operations are performed on the main actor to ensure thread safety with UI operations.
13 | ///
14 | /// The type maintains a cache of URLs that the application has been granted write access to during the current session.
15 | /// Use `hasWriteAccess(to:)` to check if write access is available for a specific URL.
16 | ///
17 | /// Example usage:
18 | /// ```swift
19 | /// // Select a PDF file
20 | /// let pdfPanel = OpenPanel(
21 | /// fileWithMessage: "Choose a PDF file",
22 | /// buttonTitle: "Select PDF",
23 | /// contentType: .pdf,
24 | /// initialDirectoryUrl: FileManager.default.documentsDirectory
25 | /// )
26 | /// if let selectedPDF = await pdfPanel.showAndAwaitSingleSelection() {
27 | /// // Process the selected PDF file
28 | /// print("Selected PDF at: \(selectedPDF.path)")
29 | /// }
30 | ///
31 | /// // Select multiple image files
32 | /// let imagePanel = OpenPanel(
33 | /// fileWithMessage: "Choose images",
34 | /// buttonTitle: "Select Images",
35 | /// contentType: .image,
36 | /// initialDirectoryUrl: nil
37 | /// )
38 | /// let selectedImages = await imagePanel.showAndAwaitMultipleSelection()
39 | /// print("Selected \(selectedImages.count) images")
40 | ///
41 | /// // Select a directory
42 | /// let directoryPanel = OpenPanel(
43 | /// directoryWithMessage: "Choose output folder",
44 | /// buttonTitle: "Select Folder",
45 | /// initialDirectoryUrl: nil
46 | /// )
47 | /// if let selectedDirectory = await directoryPanel.showAndAwaitSingleSelection() {
48 | /// print("Selected directory: \(selectedDirectory.path)")
49 | /// }
50 | ///
51 | /// // Check if we have write access to a specific URL
52 | /// if OpenPanel.hasWriteAccess(to: someURL) {
53 | /// // Perform write operations...
54 | /// }
55 | /// ```
56 | @MainActor
57 | public struct OpenPanel {
58 | private let openPanel: NSOpenPanel = .init()
59 |
60 | /// URLs that the application has been granted write access to during the current session.
61 | static var urlsWithWriteAccess: Set = []
62 |
63 | /// Checks if the application currently has write access to the specified URL.
64 | ///
65 | /// This method checks both direct access (if the URL itself was selected) and indirect access
66 | /// (if any parent directory of the URL was selected). The access information is maintained
67 | /// only for the current application session.
68 | ///
69 | /// - Parameter url: The URL to check for write access
70 | /// - Returns: `true` if write access is available, `false` otherwise
71 | public static func hasWriteAccess(to url: URL) -> Bool {
72 | // Check if we have direct access to the URL
73 | if urlsWithWriteAccess.contains(where: { url.normalizedPath == $0.normalizedPath }) {
74 | return true
75 | } else {
76 | // Check if we have access to any parent directory
77 | return urlsWithWriteAccess.contains { $0.isParent(of: url) }
78 | }
79 | }
80 |
81 | /// Creates an OpenPanel configured for selecting a files of a specific type.
82 | ///
83 | /// - Parameters:
84 | /// - message: The informative message shown in the panel
85 | /// - buttonTitle: The title of the confirmation button
86 | /// - contentType: The UTType restricting the types of files that can be selected (defaults to `.item` which allows any file)
87 | /// - initialDirectoryUrl: The directory to start browsing from (defaults to `nil`)
88 | /// - showHiddenFiles: Whether hidden files should be visible in the panel (defaults to `false`)
89 | public init(
90 | filesWithMessage message: String,
91 | buttonTitle: String,
92 | contentType: UTType = .item,
93 | initialDirectoryUrl: URL? = nil,
94 | showHiddenFiles: Bool = false
95 | ) {
96 | self.init()
97 |
98 | self.openPanel.prompt = buttonTitle
99 | self.openPanel.message = message
100 | self.openPanel.canChooseDirectories = false
101 | self.openPanel.canChooseFiles = true
102 | self.openPanel.allowedContentTypes = [contentType]
103 | self.openPanel.showsHiddenFiles = showHiddenFiles
104 |
105 | if let initialDirectoryUrl {
106 | self.openPanel.directoryURL = initialDirectoryUrl
107 | }
108 | }
109 |
110 | /// Creates an OpenPanel configured for selecting directories.
111 | ///
112 | /// - Parameters:
113 | /// - message: The informative message shown in the panel
114 | /// - buttonTitle: The title of the confirmation button
115 | /// - initialDirectoryUrl: The directory to start browsing from (defaults to `nil`)
116 | /// - showHiddenFiles: Whether hidden files should be visible in the panel (defaults to `false`)
117 | public init(
118 | directoriesWithMessage message: String,
119 | buttonTitle: String,
120 | initialDirectoryUrl: URL? = nil,
121 | showHiddenFiles: Bool = false
122 | ) {
123 | self.init()
124 |
125 | self.openPanel.prompt = buttonTitle
126 | self.openPanel.message = message
127 | self.openPanel.canChooseDirectories = true
128 | self.openPanel.canChooseFiles = false
129 | self.openPanel.showsHiddenFiles = showHiddenFiles
130 |
131 | if let initialDirectoryUrl {
132 | self.openPanel.directoryURL = initialDirectoryUrl
133 | }
134 | }
135 |
136 | /// Private initializer setting common default values
137 | private init() {
138 | self.openPanel.canCreateDirectories = false
139 | self.openPanel.isExtensionHidden = false
140 | }
141 |
142 | /// Shows the panel and waits for the user to select a single file or directory.
143 | ///
144 | /// This method blocks the current actor until the user makes a selection or cancels the panel.
145 | ///
146 | /// - Returns: The URL of the selected file or directory, or nil if the user cancelled
147 | public func showAndAwaitSingleSelection() -> URL? {
148 | self.openPanel.allowsMultipleSelection = false
149 |
150 | let modalResponse: NSApplication.ModalResponse = self.openPanel.runModal()
151 | switch modalResponse {
152 | case .OK:
153 | guard let url = self.openPanel.url else { return nil }
154 |
155 | Self.urlsWithWriteAccess.insert(url)
156 | return url
157 |
158 | default:
159 | return nil
160 | }
161 | }
162 |
163 | /// Shows the panel and waits for the user to select one or more files or directories.
164 | ///
165 | /// This method blocks the current actor until the user makes a selection or cancels the panel.
166 | ///
167 | /// - Returns: An array of selected URLs, or an empty array if the user cancelled
168 | public func showAndAwaitMultipleSelection() -> [URL] {
169 | self.openPanel.allowsMultipleSelection = true
170 |
171 | let modalResponse: NSApplication.ModalResponse = self.openPanel.runModal()
172 | switch modalResponse {
173 | case .OK:
174 | self.openPanel.urls.forEach { Self.urlsWithWriteAccess.insert($0) }
175 | return self.openPanel.urls
176 |
177 | default:
178 | return []
179 | }
180 | }
181 | }
182 |
183 | extension URL {
184 | /// Returns the path with leading slashes removed.
185 | fileprivate var normalizedPath: String {
186 | return self.path(percentEncoded: false).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
187 | }
188 |
189 | /// Returns true if self is a parent path of the provided URL.
190 | fileprivate func isParent(of url: URL) -> Bool {
191 | let parentPath = self.normalizedPath + "/"
192 | let childPath = url.normalizedPath
193 | return childPath.hasPrefix(parentPath)
194 | }
195 | }
196 | #endif
197 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Other/Platform.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import HandySwift
3 | #if os(iOS)
4 | import UIKit
5 | #endif
6 |
7 | /// Represents the current platform on which the code is running.
8 | public enum Platform: AutoConforming {
9 | /// macOS platform.
10 | case mac
11 |
12 | /// iPad platform.
13 | case pad
14 |
15 | /// Generic PC platform (includes Windows and Linux).
16 | case pc
17 |
18 | /// iPhone platform.
19 | case phone
20 |
21 | /// tvOS platform.
22 | case tv
23 |
24 | /// visionOS platform.
25 | case vision
26 |
27 | /// watchOS platform.
28 | case watch
29 |
30 | /// Returns the current platform based on the device's user interface idiom.
31 | @MainActor
32 | public static var current: Platform {
33 | #if os(iOS)
34 | return UIDevice.current.userInterfaceIdiom == .phone ? .phone : .pad
35 | #elseif os(macOS)
36 | return .mac
37 | #elseif os(tvOS)
38 | return .tv
39 | #elseif os(visionOS)
40 | return .vision
41 | #elseif os(watchOS)
42 | return .watch
43 | #elseif os(Linux)
44 | return .pc
45 | #elseif os(Windows)
46 | return .pc
47 | #else
48 | fatalError("Unsupported operating system")
49 | #endif
50 | }
51 |
52 | /// Returns the value associated with the specified platform, or the default value if no platform-specific value is provided.
53 | ///
54 | /// - Parameters:
55 | /// - defaultValue: The default value to return if no platform-specific value is provided.
56 | /// - mac: The value for the macOS platform.
57 | /// - pad: The value for the iPad platform.
58 | /// - pc: The value for the PC platform.
59 | /// - phone: The value for the iPhone platform.
60 | /// - tv: The value for the tvOS platform.
61 | /// - vision: The value for the visionOS platform.
62 | /// - watch: The value for the watchOS platform.
63 | ///
64 | /// - Returns: The value associated with the current platform, or the default value.
65 | @MainActor
66 | public static func value(
67 | default defaultValue: T,
68 | mac: T? = nil,
69 | pad: T? = nil,
70 | pc: T? = nil,
71 | phone: T? = nil,
72 | tv: T? = nil,
73 | vision: T? = nil,
74 | watch: T? = nil
75 | ) -> T {
76 | switch Self.current {
77 | case .mac: mac ?? defaultValue
78 | case .pad: pad ?? defaultValue
79 | case .pc: pc ?? defaultValue
80 | case .phone: phone ?? defaultValue
81 | case .tv: tv ?? defaultValue
82 | case .vision: vision ?? defaultValue
83 | case .watch: watch ?? defaultValue
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Other/Xcode.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// Provides information about the current Xcode environment.
4 | public enum Xcode {
5 | /// Indicates whether the code is running in Xcode's preview environment.
6 | ///
7 | /// This property can be used to conditionally adjust behavior or logic based on whether the code is being previewed in Xcode.
8 | public static var isRunningForPreviews: Bool {
9 | ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Protocols/CustomLabelConvertible.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A protocol that combines `CustomStringConvertible` and `CustomSymbolConvertible` to represent an object that can be converted into a SwiftUI `Label`.
4 | ///
5 | /// Types conforming to this protocol must provide both a textual description (`description`) and a symbol system name (`symbolSystemName`) to create SwiftUI `Label` views.
6 | public protocol CustomLabelConvertible: CustomStringConvertible, CustomSymbolConvertible {}
7 |
8 | extension CustomLabelConvertible {
9 | /// Creates a SwiftUI `Label` view using the instance's `description` and `symbolSystemName`.
10 | ///
11 | /// Any metadata after `:` or `*` in the `symbolSystemName` will be ignored to adhere to SwiftUI's system image syntax.
12 | ///
13 | /// - Returns: A `Label` view with the instance's description as text and symbol as image.
14 | ///
15 | /// - Example:
16 | /// ```swift
17 | /// struct MyLabel: CustomLabelConvertible {
18 | /// var description: String { "Example" }
19 | /// var symbolSystemName: String { "star.fill" }
20 | /// }
21 | ///
22 | /// let labelView = MyLabel().label
23 | /// // This creates a SwiftUI Label with text "Example" and the system image of a filled star.
24 | /// ```
25 | public var label: Label {
26 | Label(self.description, systemImage: self.symbolSystemName)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Protocols/CustomSymbolConvertible.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A protocol for types that provide an SF Symbol name for use in SwiftUI views.
4 | ///
5 | /// The symbol name can include metadata after `:` or `*`. This metadata will be ignored when creating the `Image` view.
6 | ///
7 | /// ## Example:
8 | /// ```swift
9 | /// struct Icon: CustomSymbolConvertible {
10 | /// var symbolName: String { "scribble:30*2" }
11 | /// }
12 | ///
13 | /// let iconImage = Icon().image
14 | /// // This creates an `Image` view with the SF Symbol named "scribble".
15 | /// ```
16 | public protocol CustomSymbolConvertible {
17 | /// The SF Symbol name of the icon to show, including optional customization metadata.
18 | ///
19 | /// Metadata after `:` or `*` will be ignored when the image is created.
20 | var symbolName: String { get }
21 | }
22 |
23 | extension CustomSymbolConvertible {
24 | /// Creates a SwiftUI `Image` view using the `symbolName` property of the instance.
25 | /// Metadata after `:` or `*` in the `symbolName` is ignored.
26 | ///
27 | /// ## Usage Example:
28 | /// ```swift
29 | /// struct Icon: CustomSymbolConvertible {
30 | /// var symbolName: String { "star.fill" }
31 | /// }
32 | ///
33 | /// let iconImage = Icon().image
34 | /// // This creates an `Image` view with the SF Symbol "star.fill".
35 | /// ```
36 | public var image: Image {
37 | Image(systemName: self.symbolSystemName)
38 | }
39 |
40 | /// Returns the base SF Symbol name, with any metadata (e.g., `:` or `*` and following content) removed.
41 | ///
42 | /// ## Usage Example:
43 | /// ```swift
44 | /// struct Icon: CustomSymbolConvertible {
45 | /// var symbolName: String { "star.fill:30*2" }
46 | /// }
47 | ///
48 | /// let baseName = Icon().symbolSystemName
49 | /// print(baseName) // Outputs: "star.fill"
50 | /// ```
51 | public var symbolSystemName: String {
52 | self.symbolName.components(separatedBy: ":")[0].components(separatedBy: "*")[0]
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Views/AsyncButton.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A ``Button`` that creates a ``Task`` in its action closure which gets automatically cancelled ``.onDisappear``.
4 | /// The button shows both progress while the task is in progress and indicates success or failure when the task is completed or throws an error.
5 | ///
6 | /// This button is useful for performing asynchronous operations with visual feedback, such as network requests or lengthy computations.
7 | ///
8 | /// # Features
9 | /// - Displays a progress indicator while the task is running
10 | /// - Shows a checkmark for successful completion
11 | /// - Shows an X mark for task failure
12 | /// - Automatically cancels the task when the view disappears
13 | ///
14 | /// # Example usage:
15 | /// ```swift
16 | /// struct ContentView: View {
17 | /// @State private var result: String = ""
18 | ///
19 | /// var body: some View {
20 | /// VStack {
21 | /// Text(result)
22 | ///
23 | /// AsyncButton("Fetch Data", systemImage: "arrow.down.circle") {
24 | /// // Simulating a network request
25 | /// try await Task.sleep(for: .seconds(2))
26 | /// self.result = "Data fetched successfully!"
27 | /// } catchError: { error in
28 | /// self.result = "Error: \(error.localizedDescription)"
29 | /// }
30 | /// }
31 | /// }
32 | /// }
33 | /// ```
34 | public struct AsyncButton: View {
35 | /// The localized string key for the button's title.
36 | let titleKey: LocalizedStringKey
37 |
38 | /// An optional system image name to display alongside the title.
39 | let systemImage: String?
40 |
41 | /// The asynchronous action to perform when the button is tapped.
42 | let asyncAction: () async throws -> Void
43 |
44 | /// A closure to handle any errors thrown by the async action.
45 | let catchError: (Error) -> Void
46 |
47 | /// Indicates whether the task just completed successfully.
48 | @State private var justCompleted: Bool = false
49 |
50 | /// Indicates whether the task just failed.
51 | @State private var justFailed: Bool = false
52 |
53 | /// The current task being executed, if any.
54 | @State private var task: Task?
55 |
56 | /// Computes the appropriate system image based on the button's state.
57 | var statefulSystemImage: String? {
58 | if self.justCompleted {
59 | return "checkmark"
60 | } else if self.justFailed {
61 | return "xmark"
62 | } else {
63 | return self.systemImage
64 | }
65 | }
66 |
67 | /// Computes the appropriate image color based on the button's state.
68 | var statefulImageColor: Color? {
69 | if self.justCompleted {
70 | return Color.green
71 | } else if self.justFailed {
72 | return Color.red
73 | } else {
74 | return nil
75 | }
76 | }
77 |
78 | /// Creates a new `AsyncButton` with the specified title, optional system image, async action, and error handler.
79 | ///
80 | /// - Parameters:
81 | /// - titleKey: The localized string key for the button's title.
82 | /// - systemImage: An optional system image name to display alongside the title.
83 | /// - asyncAction: The asynchronous closure to execute when the button is tapped.
84 | /// - catchError: A closure to handle any errors thrown by the async action. Defaults to doing nothing.
85 | public init(
86 | _ titleKey: LocalizedStringKey,
87 | systemImage: String? = nil,
88 | asyncAction: @escaping () async throws -> Void,
89 | catchError: @escaping (Error) -> Void = { _ in }
90 | ) {
91 | self.titleKey = titleKey
92 | self.systemImage = systemImage
93 | self.asyncAction = asyncAction
94 | self.catchError = catchError
95 | }
96 |
97 | public var body: some View {
98 | Button {
99 | withAnimation {
100 | self.task = Task {
101 | do {
102 | try await self.asyncAction()
103 |
104 | withAnimation {
105 | self.task = nil
106 | self.justCompleted = true
107 | }
108 |
109 | Task {
110 | try await Task.sleep(for: .seconds(2))
111 | withAnimation {
112 | self.justCompleted = false
113 | }
114 | }
115 | } catch {
116 | withAnimation {
117 | self.task = nil
118 | self.justFailed = true
119 | }
120 |
121 | Task {
122 | try await Task.sleep(for: .seconds(2))
123 | withAnimation {
124 | self.justFailed = false
125 | }
126 | }
127 |
128 | self.catchError(error)
129 | }
130 | }
131 | }
132 | } label: {
133 | if self.task != nil {
134 | HStack(spacing: .platformDefaultSpacing / 2) {
135 | ProgressView()
136 | .frame(width: .platformDefaultTextHeight)
137 | .macOSOnly {
138 | $0.scaleEffect(0.5).frame(height: .platformDefaultTextHeight - 2)
139 | }
140 | Text(self.titleKey)
141 | }
142 | } else {
143 | HStack(spacing: .platformDefaultSpacing / 2) {
144 | if let statefulSystemImage {
145 | Image(systemName: statefulSystemImage)
146 | .applyIf(self.statefulImageColor != nil) {
147 | $0.foregroundStyle(self.statefulImageColor!)
148 | }
149 | .frame(width: .platformDefaultTextHeight)
150 | } else if self.systemImage != nil {
151 | Color.clear.frame(width: .platformDefaultTextHeight)
152 | }
153 |
154 | Text(self.titleKey)
155 | }
156 | }
157 | }
158 | .disabled(self.task != nil)
159 | .onDisappear {
160 | self.task?.cancel()
161 | }
162 | }
163 | }
164 |
165 | #if DEBUG && swift(>=6.0)
166 | @available(iOS 17, macOS 14, tvOS 17, visionOS 1, watchOS 10, *)
167 | #Preview {
168 | @Previewable @State var errorMessage: String?
169 |
170 | VStack(spacing: 20) {
171 | AsyncButton("Succeed after 1 sec", systemImage: "play") {
172 | try await Task.sleep(for: .seconds(1))
173 | }
174 |
175 | AsyncButton("Fail after 1 sec") {
176 | try await Task.sleep(for: .seconds(1))
177 | throw CancellationError()
178 | } catchError: { error in
179 | withAnimation {
180 | errorMessage = error.localizedDescription
181 | }
182 | }
183 |
184 | Group {
185 | if let errorMessage {
186 | #if os(tvOS)
187 | Text("Failed with error:\n\(errorMessage)")
188 | #else
189 | GroupBox {
190 | Text("Failed with error:\n\(errorMessage)")
191 | }
192 | #endif
193 | } else {
194 | Color.clear
195 | }
196 | }.frame(height: 100)
197 | }
198 | .padding()
199 | }
200 | #endif
201 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Views/AsyncView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // TODO: make view more customizable with customizable in progress view (keep the default) and a customizable failed view (keep the default)
4 | // TODO: document all public APIs in detail with practical real-world examples (and also improve the preview example to be more realistic)
5 |
6 | public struct AsyncView: View {
7 | enum SuccessContentCallback {
8 | case withResult((ResultType) -> SuccessContent)
9 | case withoutResult(() -> SuccessContent)
10 |
11 | func perform(result: ResultType) -> SuccessContent {
12 | switch self {
13 | case .withResult(let callback): return callback(result)
14 | case .withoutResult(let callback): return callback()
15 | }
16 | }
17 | }
18 |
19 | @State private var progressState: AsyncResult = .notStarted
20 |
21 | let successContentCallback: SuccessContentCallback
22 | let performTask: () async throws -> ResultType
23 |
24 | let resultOptionalStorage: Binding?
25 | let resultDefaultValueStorage: Binding?
26 |
27 | @State private var task: Task?
28 |
29 | // Note that a parameter is provided in the success closure here because the storage is an optional and having a non-optional type can be more convenient for read access. Make sure to use the storage for write access.
30 | public init(
31 | performTask: @escaping () async throws -> ResultType,
32 | storeResultIn resultOptionalStorage: Binding,
33 | @ViewBuilder successContent: @escaping (ResultType) -> SuccessContent
34 | ) {
35 | self.successContentCallback = .withResult(successContent)
36 | self.performTask = performTask
37 | self.resultOptionalStorage = resultOptionalStorage
38 | self.resultDefaultValueStorage = nil
39 | }
40 |
41 | // Note that no parameter is provided in the success closure here because the result is available from storage.
42 | public init(
43 | performTask: @escaping () async throws -> ResultType,
44 | storeResultIn resultDefaultValueStorage: Binding,
45 | @ViewBuilder successContent: @escaping () -> SuccessContent
46 | ) {
47 | self.successContentCallback = .withoutResult(successContent)
48 | self.performTask = performTask
49 | self.resultOptionalStorage = nil
50 | self.resultDefaultValueStorage = resultDefaultValueStorage
51 | }
52 |
53 | public init(
54 | performTask: @escaping () async throws -> ResultType,
55 | @ViewBuilder successContent: @escaping (ResultType) -> SuccessContent
56 | ) {
57 | self.successContentCallback = .withResult(successContent)
58 | self.performTask = performTask
59 | self.resultOptionalStorage = nil
60 | self.resultDefaultValueStorage = nil
61 | }
62 |
63 | public var body: some View {
64 | Group {
65 | switch self.progressState {
66 | case .notStarted:
67 | Color.clear
68 | .onAppear {
69 | self.startPerformingTask()
70 | }
71 |
72 | case .inProgress:
73 | ProgressView(String(localized: "Loading…", bundle: .module))
74 | .padding()
75 |
76 | case .failed(let errorMessage):
77 | VStack {
78 | Text("Failed to load with error: \(errorMessage)", bundle: .module)
79 |
80 | Button(String(localized: "Try again", bundle: .module)) {
81 | self.startPerformingTask()
82 | }
83 | }
84 | .padding()
85 |
86 | case .successful(let result):
87 | self.successContentCallback.perform(result: result)
88 | }
89 | }
90 | .onDisappear {
91 | self.task?.cancel()
92 | }
93 | }
94 |
95 | private func startPerformingTask() {
96 | self.progressState = .inProgress
97 |
98 | self.task = Task {
99 | do {
100 | let value = try await self.performTask()
101 |
102 | await MainActor.run {
103 | self.resultDefaultValueStorage?.wrappedValue = value
104 | self.resultOptionalStorage?.wrappedValue = value
105 | self.progressState = .successful(value: value)
106 | }
107 | } catch {
108 | await MainActor.run {
109 | self.progressState = .failed(error: error.localizedDescription)
110 | }
111 | }
112 | }
113 | }
114 | }
115 |
116 | #if DEBUG && swift(>=6.0)
117 | func previewLoadingTask() async throws -> String {
118 | try await Task.sleep(for: .seconds(1))
119 | return "/fake/path/to/project"
120 | }
121 |
122 | @available(iOS 17, macOS 14, tvOS 17, visionOS 1, watchOS 10, *)
123 | #Preview {
124 | @Previewable @State var projectPath: String = ""
125 |
126 | AsyncView(performTask: previewLoadingTask, storeResultIn: $projectPath) {
127 | Text(verbatim: "Project Path: \(projectPath)")
128 | }
129 | }
130 | #endif
131 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Views/CachedAsyncImage.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A SwiftUI view that asynchronously loads and displays an image from a URL with caching support.
4 | /// The view shows a loading indicator while the image is being fetched and automatically caches
5 | /// successful downloads for faster subsequent loads.
6 | ///
7 | /// This view is particularly useful for scenarios where the same images might be loaded repeatedly,
8 | /// such as in lists, grids, or carousel interfaces.
9 | ///
10 | /// Note that `.resizable()` and `.aspectRatio(contentMode: .fill)` are already applied to the `Image` view.
11 | ///
12 | /// Example usage in a product list:
13 | /// ```swift
14 | /// struct ProductView: View {
15 | /// let product: Product
16 | ///
17 | /// var body: some View {
18 | /// VStack {
19 | /// CachedAsyncImage(url: product.imageURL)
20 | /// .frame(width: 200, height: 200)
21 | /// .clipShape(RoundedRectangle(cornerRadius: 10))
22 | /// Text(product.name)
23 | /// }
24 | /// }
25 | /// }
26 | ///
27 | /// struct ProductList: View {
28 | /// let products: [Product]
29 | ///
30 | /// var body: some View {
31 | /// ScrollView {
32 | /// LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))]) {
33 | /// ForEach(products) { product in
34 | /// ProductView(product: product)
35 | /// }
36 | /// }
37 | /// }
38 | /// }
39 | /// }
40 | /// ```
41 | public struct CachedAsyncImage: View {
42 | #if canImport(UIKit)
43 | typealias NativeImage = UIImage
44 | #elseif canImport(AppKit)
45 | typealias NativeImage = NSImage
46 | #endif
47 |
48 | /// Internal cache manager that provides thread-safe storage and retrieval of downloaded images.
49 | /// Uses `NSCache` for automatic memory management of cached images.
50 | class ImageCache: @unchecked Sendable {
51 | static let shared = ImageCache()
52 | private var cache = NSCache()
53 |
54 | private init() {}
55 |
56 | func image(for url: NSURL) -> NativeImage? {
57 | self.cache.object(forKey: url)
58 | }
59 |
60 | func insertImage(_ image: NativeImage?, for url: NSURL) {
61 | guard let image = image else { return }
62 | self.cache.setObject(image, forKey: url)
63 | }
64 | }
65 |
66 | let url: URL
67 |
68 | @State var image: NativeImage?
69 | @State var isLoading = false
70 |
71 | /// Creates a new cached async image view that loads and displays an image from the specified URL.
72 | ///
73 | /// The view will first check its cache for a previously downloaded image. If not found,
74 | /// it will download the image asynchronously while showing a loading indicator.
75 | ///
76 | /// Example usage in a profile view:
77 | /// ```swift
78 | /// struct ProfileView: View {
79 | /// let user: User
80 | ///
81 | /// var body: some View {
82 | /// VStack {
83 | /// CachedAsyncImage(url: user.avatarURL)
84 | /// .frame(width: 100, height: 100)
85 | /// .clipShape(Circle())
86 | /// .overlay(Circle().stroke(Color.blue, lineWidth: 2))
87 | /// Text(user.name)
88 | /// .font(.headline)
89 | /// Text(user.bio)
90 | /// .font(.subheadline)
91 | /// }
92 | /// .padding()
93 | /// }
94 | /// }
95 | /// ```
96 | ///
97 | /// - Parameter url: The URL of the image to load and display
98 | public init(url: URL) {
99 | self.url = url
100 | }
101 |
102 | #warning("🧑💻 add an init overload that accepts a custom placeholder view to be shown while loading instead of the progress view")
103 |
104 | public var body: some View {
105 | Group {
106 | if let image = image {
107 | #if canImport(UIKit)
108 | Image(uiImage: image)
109 | .resizable()
110 | .aspectRatio(contentMode: .fill)
111 | #else
112 | Image(nsImage: image)
113 | .resizable()
114 | .aspectRatio(contentMode: .fill)
115 | #endif
116 | } else {
117 | if self.isLoading {
118 | ProgressView()
119 | } else {
120 | Color.clear
121 | .onAppear(perform: self.loadImage)
122 | }
123 | }
124 | }
125 | .onChange(of: self.url) { _ in
126 | self.loadImage()
127 | }
128 | }
129 |
130 | private func loadImage() {
131 | if let cachedImage = ImageCache.shared.image(for: url as NSURL) {
132 | withAnimation {
133 | self.image = cachedImage
134 | }
135 | return
136 | }
137 |
138 | withAnimation {
139 | self.isLoading = true
140 | }
141 |
142 | URLSession.shared.dataTask(with: url) { data, response, error in
143 | guard let data = data, let downloadedImage = NativeImage(data: data) else { return }
144 |
145 | DispatchQueue.main.async {
146 | withAnimation {
147 | self.isLoading = false
148 | self.image = downloadedImage
149 | }
150 | ImageCache.shared.insertImage(downloadedImage, for: url as NSURL)
151 | }
152 | }.resume()
153 | }
154 | }
155 |
156 | #if DEBUG
157 | #Preview {
158 | CachedAsyncImage(url: URL(string: "https://picsum.photos/200")!)
159 | }
160 | #endif
161 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Views/DisclosureSection.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A more flexible alternative to `DisclosureGroup` that allows toggling by tapping anywhere on the label, supports custom headers and even footers.
4 | public struct DisclosureSection: View {
5 | @State private var isExpanded: Bool = false
6 |
7 | private let content: () -> Content
8 | private let label: () -> Label
9 | private let footer: () -> Footer
10 |
11 | /// Initializes a `DisclosureSection` with a localized title and content.
12 | ///
13 | /// - Parameters:
14 | /// - titleKey: The localized key for the section title.
15 | /// - content: A closure that returns the content to be displayed when the section is expanded.
16 | ///
17 | /// **Example:**
18 | /// ```swift
19 | /// DisclosureSection("Account Settings") {
20 | /// // Content to be displayed when the section is expanded
21 | /// }
22 | /// ```
23 | public init(
24 | _ titleKey: LocalizedStringKey,
25 | @ViewBuilder content: @escaping () -> Content
26 | ) where Label == Text, Footer == EmptyView {
27 | self.content = content
28 | self.label = { Text(titleKey) }
29 | self.footer = { EmptyView() }
30 | }
31 |
32 | /// Initializes a `DisclosureSection` with a localized title, content, and footer.
33 | ///
34 | /// - Parameters:
35 | /// - titleKey: The localized key for the section title.
36 | /// - content: A closure that returns the content to be displayed when the section is expanded.
37 | /// - footer: A closure that returns the footer content to be displayed when the section is expanded.
38 | ///
39 | /// **Example:**
40 | /// ```swift
41 | /// DisclosureSection("Account Settings") {
42 | /// // Content to be displayed when the section is expanded
43 | /// } footer: {
44 | /// // Footer content to be displayed when the section is expanded
45 | /// }
46 | /// ```
47 | public init(
48 | _ titleKey: LocalizedStringKey,
49 | @ViewBuilder content: @escaping () -> Content,
50 | @ViewBuilder footer: @escaping () -> Footer
51 | ) where Label == Text {
52 | self.content = content
53 | self.label = { Text(titleKey) }
54 | self.footer = footer
55 | }
56 |
57 | /// Initializes a `DisclosureSection` with custom label and content.
58 | ///
59 | /// - Parameters:
60 | /// - content: A closure that returns the content to be displayed when the section is expanded.
61 | /// - label: A closure that returns the custom label view.
62 | ///
63 | /// **Example:**
64 | /// ```swift
65 | /// DisclosureSection {
66 | /// // Content to be displayed when the section is expanded
67 | /// } label: {
68 | /// HStack {
69 | /// Image(systemName: "gear")
70 | /// Text("Settings")
71 | /// }
72 | /// }
73 | /// ```
74 | public init(
75 | @ViewBuilder content: @escaping () -> Content,
76 | @ViewBuilder label: @escaping () -> Label
77 | ) where Footer == EmptyView {
78 | self.content = content
79 | self.label = label
80 | self.footer = { EmptyView() }
81 | }
82 |
83 | /// Initializes a `DisclosureSection` with custom label, content, and footer.
84 | ///
85 | /// - Parameters:
86 | /// - content: A closure that returns the content to be displayed when the section is expanded.
87 | /// - label: A closure that returns the custom label view.
88 | /// - footer: A closure that returns the custom footer view.
89 | ///
90 | /// **Example:**
91 | /// ```swift
92 | /// DisclosureSection {
93 | /// // Content to be displayed when the section is expanded
94 | /// } label: {
95 | /// HStack {
96 | /// Image(systemName: "gear")
97 | /// Text("Settings")
98 | /// }
99 | /// } footer: {
100 | /// // Custom footer view
101 | /// }
102 | /// ```
103 | public init(
104 | @ViewBuilder content: @escaping () -> Content,
105 | @ViewBuilder label: @escaping () -> Label,
106 | @ViewBuilder footer: @escaping () -> Footer
107 | ) {
108 | self.content = content
109 | self.label = label
110 | self.footer = footer
111 | }
112 |
113 | public var body: some View {
114 | Section {
115 | Button {
116 | withAnimation {
117 | self.isExpanded.toggle()
118 | }
119 | } label: {
120 | HStack {
121 | Image(systemName: self.isExpanded ? "chevron.down" : "chevron.forward")
122 | .frame(width: 6)
123 | .font(.footnote.weight(.bold))
124 |
125 | self.label()
126 |
127 | Color.gray.frame(maxWidth: .infinity).opacity(0.001)
128 | }
129 | .font(.body.weight(.medium))
130 | .foregroundStyle(self.isExpanded ? .secondary : .primary)
131 | }
132 | .buttonStyle(.plain)
133 |
134 | if self.isExpanded {
135 | self.content()
136 | }
137 | } footer: {
138 | if self.isExpanded {
139 | self.footer()
140 | }
141 | }
142 | }
143 | }
144 |
145 | #if DEBUG && !os(tvOS)
146 | #Preview {
147 | Form {
148 | DisclosureGroup("Original Disclosure Group") {
149 | Text("Test")
150 | }
151 |
152 | DisclosureSection("Custom Disclosure Section") {
153 | Text("Test")
154 | Text("Test")
155 | }
156 | }
157 | .formStyle(.grouped)
158 | }
159 | #endif
160 |
161 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Views/HPicker.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import HandySwift
3 |
4 | /// A custom picker that displays options as a horizontal row of buttons with icons and labels.
5 | /// It supports locked options, custom labels, and accessibility features.
6 | ///
7 | /// `HPicker` is designed to provide a visually appealing and interactive way to select from a list of options,
8 | /// each represented by an icon and a label.
9 | ///
10 | /// # Features
11 | /// - Horizontal layout with customizable icons and labels
12 | /// - Support for locked (disabled) options
13 | /// - Custom label for the picker
14 | /// - Accessibility support with a standard picker representation
15 | /// - Flexible initialization options for different use cases
16 | ///
17 | /// # Example usage:
18 | /// ```swift
19 | /// struct ContentView: View {
20 | /// enum Mood: String, Identifiable, CustomLabelConvertible, CaseIterable {
21 | /// case superHappy, happy, neutral, sad
22 | ///
23 | /// var description: String { rawValue.capitalized }
24 | /// var symbolName: String {
25 | /// switch self {
26 | /// case .superHappy: "hand.thumbsup*2" // show icon twice
27 | /// case .happy: "hand.thumbsup"
28 | /// case .neutral: "face.thumbsup:90" // rotate icon by 90 degrees
29 | /// case .sad: "hand.thumbsdown"
30 | /// }
31 | /// }
32 | /// var id: Self { self }
33 | /// }
34 | ///
35 | /// @State private var selectedMood: Mood?
36 | ///
37 | /// var body: some View {
38 | /// Form {
39 | /// HPicker("How are you feeling?", selection: $selectedMood)
40 | /// }
41 | /// }
42 | /// }
43 | /// ```
44 | public struct HPicker: View {
45 | /// The array of selectable options.
46 | let options: [T]
47 |
48 | /// A set of options that should be displayed as locked (non-selectable).
49 | let locked: Set
50 |
51 | /// A closure that returns the label view for the picker.
52 | let label: () -> L
53 |
54 | /// A binding to the currently selected option.
55 | let selection: Binding
56 |
57 | @Environment(\.colorScheme) var colorScheme
58 |
59 | /// Creates a new `HPicker` with the specified options, locked items, selection binding, and label.
60 | ///
61 | /// - Parameters:
62 | /// - options: An array of selectable options.
63 | /// - locked: A set of options that should be displayed as locked (non-selectable).
64 | /// - selection: A binding to the currently selected option.
65 | /// - label: A closure that returns the label view for the picker.
66 | public init(options: [T], locked: Set = [], selection: Binding, label: @escaping () -> L) {
67 | self.options = options
68 | self.locked = locked
69 | self.selection = selection
70 | self.label = label
71 | }
72 |
73 | public var body: some View {
74 | VStack(spacing: 10) {
75 | self.label().padding(.top, 10)
76 |
77 | HStack {
78 | ForEach(self.options) { option in
79 | Button {
80 | self.selection.wrappedValue = option
81 | } label: {
82 | Label(option.description, systemImage: self.locked.contains(option) ? "lock" : option.symbolSystemName)
83 | .labelStyle(
84 | .vertical(
85 | spacing: 10,
86 | iconColor: self.selection.wrappedValue == option ? .accentColor : .secondary,
87 | iconFont: .system(size: self.options.count > 3 ? 20 : 25, weight: .regular),
88 | iconAngle: self.iconAngle(option: option),
89 | iconAmount: self.iconAmount(option: option)
90 | )
91 | )
92 | .font(.body)
93 | .minimumScaleFactor(0.66)
94 | .multilineTextAlignment(.center)
95 | .padding(.vertical)
96 | .padding(.horizontal, 5)
97 | .frame(maxWidth: .infinity, maxHeight: .infinity)
98 | .background(OS.current == .iOS ? .thinMaterial : .regularMaterial)
99 | .applyIf(self.selection.wrappedValue == option) {
100 | $0
101 | .background(Color.accentColor.opacity(0.33))
102 | .overlay {
103 | RoundedRectangle(cornerRadius: 12.5)
104 | .strokeBorder(Color.accentColor, lineWidth: 2)
105 | }
106 | .fontWeight(.semibold)
107 | }
108 | .clipShape(.rect(cornerRadius: 12.5))
109 | .shadow(color: .black.opacity(self.colorScheme == .dark ? 0.33 : 0.1), radius: 6)
110 | #if os(iOS) || os(visionOS)
111 | .contentShape(.hoverEffect, .rect(cornerRadius: 12.5)).hoverEffect()
112 | #endif
113 | }
114 | .padding(.vertical)
115 | .buttonStyle(.plain)
116 | }
117 | }
118 | }
119 | .accessibilityRepresentation {
120 | Picker(selection: self.selection) {
121 | Text(verbatim: "–").tag(T?.none)
122 |
123 | ForEach(self.options) { option in
124 | option.label.tag(option as T?)
125 | }
126 | } label: {
127 | self.label()
128 | }
129 | }
130 | .frame(maxHeight: 190)
131 | }
132 |
133 | func iconAngle(option: T) -> Angle? {
134 | guard
135 | !self.locked.contains(option),
136 | let degreesString = option.symbolName.components(separatedBy: "*")[0].components(separatedBy: ":")[safe: 1],
137 | let degrees = Double(degreesString)
138 | else { return nil }
139 | return Angle(degrees: degrees)
140 | }
141 |
142 | func iconAmount(option: T) -> Int {
143 | guard
144 | !self.locked.contains(option),
145 | let amountString = option.symbolName.components(separatedBy: "*")[safe: 1],
146 | let amount = Int(amountString)
147 | else { return 1 }
148 | return amount
149 | }
150 | }
151 |
152 | /// Convenience initializer for `HPicker` that uses a `Text` view as the label.
153 | extension HPicker where L == Text {
154 | /// Creates a new `HPicker` with a `Text` label using a localized string key.
155 | ///
156 | /// - Parameters:
157 | /// - titleKey: The localized string key for the picker's title.
158 | /// - options: An array of selectable options.
159 | /// - locked: A set of options that should be displayed as locked (non-selectable).
160 | /// - selection: A binding to the currently selected option.
161 | public init(_ titleKey: LocalizedStringKey, options: [T], locked: Set, selection: Binding) {
162 | self.label = { Text(titleKey).font(.headline) }
163 | self.locked = locked
164 | self.options = options
165 | self.selection = selection
166 | }
167 | }
168 |
169 | /// Convenience initializer for `HPicker` when `T` conforms to `CaseIterable`.
170 | extension HPicker where T: CaseIterable {
171 | /// Creates a new `HPicker` using all cases of an enumeration that conforms to `CaseIterable`.
172 | ///
173 | /// - Parameters:
174 | /// - locked: A set of options that should be displayed as locked (non-selectable).
175 | /// - selection: A binding to the currently selected option.
176 | /// - label: A closure that returns the label view for the picker.
177 | public init(locked: Set, selection: Binding, label: @escaping () -> L) {
178 | self.options = T.allCases as! [T]
179 | self.locked = locked
180 | self.selection = selection
181 | self.label = label
182 | }
183 | }
184 |
185 | /// Convenience initializer for `HPicker` when `T` conforms to `CaseIterable` and using a `Text` label.
186 | extension HPicker where T: CaseIterable, L == Text {
187 | /// Creates a new `HPicker` using all cases of an enumeration that conforms to `CaseIterable`, with a `Text` label.
188 | ///
189 | /// - Parameters:
190 | /// - titleKey: The localized string key for the picker's title.
191 | /// - locked: A set of options that should be displayed as locked (non-selectable).
192 | /// - selection: A binding to the currently selected option.
193 | public init(_ titleKey: LocalizedStringKey, locked: Set, selection: Binding) {
194 | self.label = { Text(titleKey).font(.headline) }
195 | self.locked = locked
196 | self.options = T.allCases as! [T]
197 | self.selection = selection
198 | }
199 | }
200 |
201 | #if DEBUG
202 | #Preview {
203 | struct Preview: View {
204 | enum HogwartsHouse: String, Identifiable, CustomLabelConvertible, CaseIterable {
205 | case gryffindor, ravenclaw, hufflepuff, slytherin
206 |
207 | var description: String { [self.rawValue.firstUppercased, "(Some additional information!)"].joined(separator: "\n") }
208 | var symbolName: String {
209 | switch self {
210 | case .gryffindor: "cat"
211 | case .ravenclaw: "bird:-30"
212 | case .hufflepuff: "dog*2"
213 | case .slytherin: "lizard:90*2"
214 | }
215 | }
216 | var id: String { self.rawValue }
217 | }
218 |
219 | @State var selectedHouse: HogwartsHouse? = .gryffindor
220 |
221 | var body: some View {
222 | Form {
223 | HPicker("Hogwarts House", locked: [.gryffindor, .slytherin], selection: self.$selectedHouse)
224 | }
225 | .macOSOnly { $0.padding().frame(minWidth: 700) }
226 | }
227 | }
228 |
229 | return Preview()
230 | }
231 | #endif
232 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Views/LimitedTextField.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A custom `TextField` view that limits the number of characters and displays a counter indicating the remaining characters.
4 | public struct LimitedTextField: View {
5 | let title: LocalizedStringKey
6 | @Binding var text: String
7 |
8 | let characterLimit: Int
9 |
10 | /// Initializes a `LimitedTextFieldWithCounter` view.
11 | ///
12 | /// - Parameters:
13 | /// - text: A binding to the `String` value representing the text input.
14 | /// - characterLimit: The maximum number of characters allowed.
15 | public init(_ title: LocalizedStringKey, text: Binding, characterLimit: Int) {
16 | self.title = title
17 | self._text = text
18 | self.characterLimit = characterLimit
19 | }
20 |
21 | public var body: some View {
22 | VStack(alignment: .trailing, spacing: 4) {
23 | TextField(self.title, text: self.$text)
24 | .onChange(of: self.text) { _ in
25 | if self.text.count > characterLimit {
26 | self.text = String(self.text.prefix(characterLimit))
27 | }
28 | }
29 | #if !os(tvOS)
30 | .textFieldStyle(.roundedBorder)
31 | #endif
32 |
33 | Text(String(localized: "\(characterLimit - self.text.count) chars left", bundle: .module))
34 | .font(.caption)
35 | .foregroundColor(.gray)
36 | }
37 | }
38 | }
39 |
40 | #if DEBUG && swift(>=6.0)
41 | @available(iOS 17, macOS 14, tvOS 17, visionOS 1, watchOS 10, *)
42 | #Preview {
43 | @Previewable @State var previewText = ""
44 |
45 | Form {
46 | LimitedTextField("Enter text", text: $previewText, characterLimit: 10)
47 | }
48 | .formStyle(.grouped)
49 | }
50 | #endif
51 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Views/MultiSelectionView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A view that presents a list of selectable options, allowing multiple selections.
4 | /// This view adapts its appearance based on the platform (iOS/tvOS vs macOS).
5 | ///
6 | /// `MultiSelectionView` provides a consistent interface for multi-selection across different Apple platforms,
7 | /// using a list with checkmarks on iOS/tvOS and switches on macOS.
8 | ///
9 | /// # Features
10 | /// - Supports any `Identifiable` and `Hashable` type as selectable options
11 | /// - Custom string representation for each option
12 | /// - Platform-specific UI (checkmarks for iOS/tvOS, switches for macOS)
13 | /// - Automatic dismissal support on iOS/tvOS
14 | /// - Customizable height on macOS
15 | ///
16 | /// # Example usage:
17 | /// ```swift
18 | /// struct ContentView: View {
19 | /// struct Fruit: Identifiable, Hashable {
20 | /// let name: String
21 | /// var id: String { name }
22 | /// }
23 | ///
24 | /// @State private var selectedFruits: Set = []
25 | /// let allFruits = [
26 | /// Fruit(name: "Apple"),
27 | /// Fruit(name: "Banana"),
28 | /// Fruit(name: "Cherry"),
29 | /// Fruit(name: "Date")
30 | /// ]
31 | ///
32 | /// var body: some View {
33 | /// NavigationStack {
34 | /// MultiSelectionView(
35 | /// options: allFruits,
36 | /// optionToString: { $0.name },
37 | /// selected: $selectedFruits
38 | /// )
39 | /// .navigationTitle("Select Fruits")
40 | /// }
41 | /// }
42 | /// }
43 | /// ```
44 | struct MultiSelectionView: View {
45 | /// The array of selectable options.
46 | let options: [Selectable]
47 |
48 | /// A closure that converts a `Selectable` item to its string representation.
49 | let optionToString: (Selectable) -> String
50 |
51 | /// An environment value for dismissing the view (used on iOS/tvOS).
52 | @Environment(\.dismiss)
53 | var dismiss
54 |
55 | /// A binding to the set of currently selected items.
56 | @Binding
57 | var selected: Set
58 |
59 | var body: some View {
60 | List {
61 | Section {
62 | ForEach(self.options) { selectable in
63 | #if !os(macOS)
64 | Button(action: { toggleSelection(selectable: selectable) }) {
65 | HStack {
66 | Text(optionToString(selectable)).foregroundColor(.label)
67 | Spacer()
68 | if selected.contains(where: { $0.id == selectable.id }) {
69 | Image(systemName: "checkmark").foregroundColor(.accentColor)
70 | }
71 | }
72 | }
73 | .tag(selectable.id)
74 | #else
75 | Toggle(optionToString(selectable), isOn: self.boolBinding(selectable: selectable))
76 | .toggleStyle(.switch)
77 | .frame(height: 22)
78 | .tag(selectable.id)
79 | #endif
80 | }
81 | }
82 |
83 | #if os(macOS)
84 | Section {
85 | Button("Done") {
86 | self.dismiss()
87 | }
88 | .frame(maxWidth: .infinity, alignment: .trailing)
89 | }
90 | #endif
91 | }
92 | #if os(macOS)
93 | .frame(height: min((NSScreen.main?.frame.height ?? 100_000) - 54, Double(self.options.count) * 22 + 104))
94 | #endif
95 | }
96 |
97 | /// Creates a boolean binding for a given selectable item.
98 | ///
99 | /// This is used for creating toggles on macOS.
100 | ///
101 | /// - Parameter selectable: The item to create a binding for.
102 | /// - Returns: A `Binding` representing whether the item is selected.
103 | private func boolBinding(selectable: Selectable) -> Binding {
104 | Binding(
105 | get: { self.selected.contains(selectable) },
106 | set: { isOn in
107 | if isOn {
108 | self.selected.insert(selectable)
109 | } else {
110 | self.selected.remove(selectable)
111 | }
112 | }
113 | )
114 | }
115 |
116 | /// Toggles the selection state of a given item.
117 | ///
118 | /// This is used for handling selection on iOS/tvOS.
119 | ///
120 | /// - Parameter selectable: The item to toggle.
121 | private func toggleSelection(selectable: Selectable) {
122 | if let existingIndex = selected.firstIndex(where: { $0.id == selectable.id }) {
123 | selected.remove(at: existingIndex)
124 | }
125 | else {
126 | selected.insert(selectable)
127 | }
128 | }
129 | }
130 |
131 | #if DEBUG
132 | #Preview {
133 | struct Preview: View {
134 | /// A simple `Identifiable` and `Hashable` struct for preview purposes.
135 | struct IdentifiableString: Identifiable, Hashable {
136 | let string: String
137 | var id: String { string }
138 | }
139 |
140 | @State var selected: Set = Set(["A", "C"].map { IdentifiableString(string: $0) })
141 |
142 | var body: some View {
143 | NavigationStack {
144 | MultiSelectionView(
145 | options: ["A", "B", "C", "D"].map { IdentifiableString(string: $0) },
146 | optionToString: { $0.string },
147 | selected: $selected
148 | )
149 | }
150 | }
151 | }
152 |
153 | return Preview()
154 | }
155 | #endif
156 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Views/MultiSelector.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 |
4 | /// A selector that supports choosing multiple options across a given set of options.
5 | /// Use `Picker` for choosing exactly one option.
6 | ///
7 | /// `MultiSelector` provides a consistent interface for multi-selection across different Apple platforms,
8 | /// using a navigation link on iOS/tvOS and a button that presents a sheet on macOS.
9 | ///
10 | /// # Features
11 | /// - Supports any `Identifiable` and `Hashable` type as selectable options
12 | /// - Custom label view
13 | /// - Custom string representation for each option
14 | /// - Platform-specific UI (navigation link for iOS/tvOS, sheet for macOS)
15 | /// - Formatted string representation of selected options
16 | ///
17 | /// # Example usage:
18 | /// ```swift
19 | /// struct ContentView: View {
20 | /// struct Place: Identifiable, Hashable {
21 | /// let name: String
22 | /// var id: String { name }
23 | /// var description: String { name }
24 | /// }
25 | ///
26 | /// @State private var preferredPlaces: Set = []
27 | /// let availablePlaces = [
28 | /// Place(name: "Beach"),
29 | /// Place(name: "Mountain"),
30 | /// Place(name: "City"),
31 | /// Place(name: "Countryside")
32 | /// ]
33 | ///
34 | /// var body: some View {
35 | /// Form {
36 | /// MultiSelector(
37 | /// label: { Text("Selected Preferred Places") },
38 | /// optionsTitle: "Preferred Places",
39 | /// options: availablePlaces,
40 | /// selected: $preferredPlaces,
41 | /// optionToString: \.description
42 | /// )
43 | /// }
44 | /// }
45 | /// }
46 | /// ```
47 | public struct MultiSelector: View {
48 | /// The view to use as the label for the multi-selector.
49 | public let label: AnyView
50 |
51 | /// The title to show in the detail view where the options can be selected.
52 | public let optionsTitle: String
53 |
54 | /// The possible options to choose from.
55 | public let options: [Selectable]
56 |
57 | /// A closure that converts the given options to their String representation.
58 | public let optionToString: (Selectable) -> String
59 |
60 | /// A binding to the set of currently selected options.
61 | public var selected: Binding>
62 |
63 | /// A formatted string representation of the currently selected options.
64 | private var formattedSelectedListString: String {
65 | ListFormatter.localizedString(byJoining: selected.wrappedValue.map { optionToString($0) })
66 | }
67 |
68 | #if os(macOS)
69 | /// State variable to control the presentation of the selector sheet on macOS.
70 | @State private var showSelectorSheet: Bool = false
71 | #endif
72 |
73 | public var body: some View {
74 | #if !os(macOS)
75 | NavigationLink {
76 | MultiSelectionView(options: options, optionToString: optionToString, selected: selected)
77 | .navigationTitle(self.optionsTitle)
78 | #if os(iOS)
79 | .navigationBarTitleDisplayMode(.inline)
80 | #endif
81 | } label: {
82 | LabeledContent {
83 | Text(self.formattedSelectedListString)
84 | } label: {
85 | self.label
86 | }
87 | }
88 | #else
89 | HStack(spacing: 10) {
90 | self.label
91 |
92 | Spacer()
93 |
94 | Text(self.selected.wrappedValue.isEmpty ? "--" : self.formattedSelectedListString)
95 | .foregroundStyle(Color.secondaryLabel)
96 |
97 | Button(String(localized: "Edit", bundle: .module)) {
98 | self.showSelectorSheet = true
99 | }
100 | }
101 | .sheet(isPresented: self.$showSelectorSheet) {
102 | MultiSelectionView(options: options, optionToString: optionToString, selected: selected)
103 | .navigationTitle(self.optionsTitle)
104 | }
105 | #endif
106 | }
107 |
108 | /// Creates a new `MultiSelector` with the specified label, options title, options, selection binding, and option-to-string conversion.
109 | ///
110 | /// - Parameters:
111 | /// - label: A closure that returns the view to use as the label for the multi-selector.
112 | /// - optionsTitle: The title to show in the detail view where the options can be selected.
113 | /// - options: The possible options to choose from.
114 | /// - selected: A binding to the set of currently selected options.
115 | /// - optionToString: A closure that converts the given options to their String representation.
116 | public init(
117 | label: () -> some View,
118 | optionsTitle: String,
119 | options: [Selectable],
120 | selected: Binding>,
121 | optionToString: @escaping (Selectable) -> String
122 | ) {
123 | self.label = label().eraseToAnyView()
124 | self.options = options
125 | self.optionsTitle = optionsTitle
126 | self.selected = selected
127 | self.optionToString = optionToString
128 | }
129 | }
130 |
131 | #if DEBUG
132 | #Preview {
133 | struct Preview: View {
134 | /// A simple `Identifiable` and `Hashable` struct for preview purposes.
135 | struct IdentifiableString: Identifiable, Hashable {
136 | let string: String
137 | var id: String { string }
138 | }
139 |
140 | @State var selected: Set = Set(["A", "C"].map { IdentifiableString(string: $0) })
141 |
142 | var body: some View {
143 | NavigationStack {
144 | Form {
145 | MultiSelector(
146 | label: { Text("MOCK: Multiselect").foregroundStyle(Color.green) },
147 | optionsTitle: "MOCK: Multiselect",
148 | options: ["A", "B", "C", "D"].map { IdentifiableString(string: $0) },
149 | selected: self.$selected,
150 | optionToString: { $0.string }
151 | )
152 | }
153 | .navigationTitle("MOCK: Title")
154 | }
155 | .macOSOnly {
156 | $0.padding().frame(minWidth: 300, minHeight: 400)
157 | }
158 | }
159 | }
160 |
161 | return Preview()
162 | }
163 | #endif
164 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Views/SearchableGridPicker.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A protocol that defines a searchable option to be used in a `SearchableGridPicker`.
4 | /// Conforming types must provide a unique identifier, a view to represent the option,
5 | /// and a list of search terms that can be used to filter the option.
6 | ///
7 | /// - Example:
8 | /// A custom emoji picker where each emoji is represented by a `Text` view and searchable by related terms.
9 | public protocol SearchableOption: Identifiable {
10 | associatedtype ContentView: View
11 |
12 | /// The view representing the searchable option.
13 | var view: ContentView { get }
14 |
15 | /// An array of search terms associated with this option.
16 | /// These terms will be used to filter the option during a search.
17 | var searchTerms: [String] { get }
18 | }
19 |
20 | /// A customizable grid picker that allows users to search and select from a grid of options.
21 | /// Each option conforms to `SearchableOption` and can be filtered using a search bar.
22 | ///
23 | /// This component is useful for building searchable, grid-based selection interfaces such as emoji pickers
24 | /// or icon selectors in apps.
25 | ///
26 | /// - Example:
27 | /// An emoji picker that lets users search for emojis based on terms like "hands", "eyes", etc.
28 | /// Users can select one emoji from the displayed grid of options.
29 | ///
30 | /// - Parameters:
31 | /// - title: A localized title for the picker.
32 | /// - options: An array of selectable options conforming to `SearchableOption`.
33 | /// - selection: A binding to the currently selected option.
34 | @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
35 | public struct SearchableGridPicker: View {
36 | let title: LocalizedStringKey
37 | var options: [Option]
38 |
39 | @Binding var selection: Option?
40 |
41 | @State var showPicker: Bool = false
42 | @State var searchText: String = ""
43 |
44 | /// Filters the available options based on the entered search text.
45 | /// If the search text is empty, all options are displayed.
46 | var filteredOptions: [Option] {
47 | if self.searchText.isBlank {
48 | return self.options
49 | } else {
50 | let searchTokens = self.searchText.tokenized()
51 | return self.options.filter { option in
52 | searchTokens.allSatisfy { searchToken in
53 | option.searchTerms.map { searchTerm in
54 | searchTerm.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: Locale.current)
55 | }.contains { $0.hasPrefix(searchToken) }
56 | }
57 | }
58 | }
59 | }
60 |
61 | /// Initializes a `SearchableGridPicker` with a title, a list of options, and a binding to the selection.
62 | ///
63 | /// - Parameters:
64 | /// - title: A localized string key to represent the title of the picker.
65 | /// - options: An array of options conforming to `SearchableOption`.
66 | /// - selection: A binding to the selected option. The selection will be updated when the user selects an option.
67 | public init(title: LocalizedStringKey, options: [Option], selection: Binding) {
68 | self.title = title
69 | self.options = options
70 | self._selection = selection
71 | }
72 |
73 | public var body: some View {
74 | Button {
75 | self.showPicker = true
76 | } label: {
77 | ZStack {
78 | HStack {
79 | Text(self.title)
80 | .foregroundStyle(Platform.value(default: .primary, mac: .secondary))
81 |
82 | Spacer()
83 |
84 | HStack {
85 | if let selection {
86 | selection.view.scaleEffect(0.75)
87 | } else {
88 | Text("None")
89 | }
90 |
91 | Image(systemName: "chevron.right")
92 | .font(.system(size: 13.5).weight(.semibold))
93 | .opacity(0.55)
94 | }
95 | .foregroundStyle(.secondary)
96 | }
97 |
98 | Color.gray.opacity(0.001)
99 | }
100 | }
101 | .buttonStyle(.plain)
102 | .navigationDestination(isPresented: self.$showPicker) {
103 | Group {
104 | if self.filteredOptions.isEmpty {
105 | ContentUnavailableView("No matches", systemImage: "exclamationmark.magnifyingglass", description: Text("No emojis match '\(self.searchText)'"))
106 | .frame(maxHeight: .infinity, alignment: .center)
107 | } else {
108 | ScrollView {
109 | LazyVGrid(columns: [GridItem(.adaptive(minimum: Platform.value(default: 44, mac: 38, vision: 60)))]) {
110 | ForEach(filteredOptions) { option in
111 | Button {
112 | self.selection = option
113 | self.showPicker = false
114 | } label: {
115 | ZStack {
116 | option.view
117 | .padding(Platform.value(default: 12, mac: 8))
118 | #if os(visionOS)
119 | .contentShape(.hoverEffect, .rect(cornerRadius: 12))
120 | .hoverEffect()
121 | #endif
122 |
123 | Color.gray.opacity(0.001)
124 | }
125 | }
126 | .buttonStyle(.plain)
127 | }
128 | }
129 | }
130 | .contentMargins(.horizontal, Platform.value(default: 10, vision: 25), for: .scrollContent)
131 | }
132 | }
133 | .navigationTitle(self.title)
134 | #if os(macOS) || os(tvOS)
135 | .searchable(text: self.$searchText)
136 | #else
137 | .searchable(text: self.$searchText, placement: .navigationBarDrawer)
138 | #endif
139 | }
140 | }
141 | }
142 |
143 | #if DEBUG && swift(>=6.0)
144 | struct FakeOption: SearchableOption {
145 | let id = UUID()
146 |
147 | var view: some View {
148 | Text(verbatim: ["👇", "🚀", "🙏", "🚨", "🤷♂️"].randomElement()!)
149 | }
150 |
151 | var searchTerms: [String] = [
152 | ["one", "two", "three", "four"].randomElement()!,
153 | ["hands", "eyes", "ears", "rockets", "legs"].randomElement()!
154 | ]
155 | }
156 |
157 | @available(iOS 17, macOS 14, tvOS 17, visionOS 1, watchOS 10, *)
158 | #Preview(traits: .fixedLayout(width: 500, height: 100)) {
159 | @Previewable @State var selection: FakeOption?
160 |
161 | NavigationStack {
162 | Form {
163 | SearchableGridPicker(
164 | title: "Pick Emoji",
165 | options: [
166 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
167 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
168 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
169 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
170 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
171 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
172 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
173 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
174 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
175 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
176 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
177 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
178 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
179 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
180 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
181 | FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(), FakeOption(),
182 | ],
183 | selection: $selection
184 | )
185 | }
186 | .formStyle(.grouped)
187 | }
188 | #if os(visionOS) || os(macOS)
189 | .frame(minWidth: 600, minHeight: 400)
190 | #endif
191 | }
192 | #endif
193 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Views/SideTabView.swift:
--------------------------------------------------------------------------------
1 | #if !os(tvOS)
2 | import SwiftUI
3 |
4 | /// A navigation container that presents a vertical sidebar of tabs optimized for larger screens.
5 | ///
6 | /// `SideTabView` is designed as a more sophisticated alternative to SwiftUI's `TabView` for applications
7 | /// that need a sidebar-style navigation, commonly used on macOS and iPadOS. It provides a consistent
8 | /// navigation experience across Apple platforms while being optimized for larger displays.
9 | ///
10 | /// Key features:
11 | /// - Vertical tab layout with icons and labels
12 | /// - Support for bottom-aligned tabs (e.g., for settings)
13 | /// - Platform-specific styling and spacing
14 | /// - Hover effects on visionOS/iOS/iPadOS
15 | /// - Full customization of tab content
16 | ///
17 | /// Example usage for a document-based app:
18 | /// ```swift
19 | /// enum NavigationTab: String, Identifiable, CustomLabelConvertible, CaseIterable {
20 | /// case documents = "folder"
21 | /// case recents = "clock"
22 | /// case favorites = "star"
23 | /// case tags = "tag"
24 | /// case settings = "gear"
25 | ///
26 | /// var id: Self { self }
27 | /// var description: String {
28 | /// switch self {
29 | /// case .documents: "Documents"
30 | /// case .recents: "Recents"
31 | /// case .favorites: "Favorites"
32 | /// case .tags: "Tags"
33 | /// case .settings: "Settings"
34 | /// }
35 | /// }
36 | /// var symbolName: String { rawValue }
37 | /// }
38 | ///
39 | /// struct ContentView: View {
40 | /// @State private var selectedTab: NavigationTab = .documents
41 | ///
42 | /// var body: some View {
43 | /// SideTabView(
44 | /// selection: $selectedTab,
45 | /// bottomAlignedTabs: 1 // Place settings tab at the bottom
46 | /// ) { tab in
47 | /// switch tab {
48 | /// case .documents:
49 | /// DocumentsList()
50 | /// case .recents:
51 | /// RecentDocuments()
52 | /// case .favorites:
53 | /// FavoritesList()
54 | /// case .tags:
55 | /// TagsView()
56 | /// case .settings:
57 | /// SettingsView()
58 | /// }
59 | /// }
60 | /// }
61 | /// }
62 | /// ```
63 | public struct SideTabView: View {
64 | @Binding private var selectedTab: Tab
65 | private let bottomAlignedTabs: Int
66 | private let content: (Tab) -> Content
67 |
68 | /// Creates a new side tab navigation view.
69 | ///
70 | /// - Parameters:
71 | /// - selection: A binding to the currently selected tab
72 | /// - bottomAlignedTabs: The number of tabs to align at the bottom of the sidebar.
73 | /// This is commonly used to place settings or configuration tabs at the bottom
74 | /// of the navigation, separated from the main navigation items.
75 | /// - content: A view builder that creates the content view for each tab.
76 | /// The view builder receives the currently selected tab as a parameter.
77 | ///
78 | /// Example creating a side tab view with settings at the bottom:
79 | /// ```swift
80 | /// SideTabView(
81 | /// selection: $selectedTab,
82 | /// bottomAlignedTabs: 1 // Places the last tab at the bottom
83 | /// ) { tab in
84 | /// switch tab {
85 | /// case .inbox:
86 | /// InboxView()
87 | /// case .drafts:
88 | /// DraftsView()
89 | /// case .sent:
90 | /// SentView()
91 | /// case .settings: // This tab will appear at the bottom
92 | /// SettingsView()
93 | /// }
94 | /// }
95 | /// ```
96 | public init(
97 | selection: Binding,
98 | bottomAlignedTabs: Int = 0,
99 | @ViewBuilder content: @escaping (Tab) -> Content
100 | ) {
101 | self._selectedTab = selection
102 | self.bottomAlignedTabs = bottomAlignedTabs
103 | self.content = content
104 | }
105 |
106 | public var body: some View {
107 | HStack(spacing: 0) {
108 | GroupBox {
109 | VStack(spacing: 0) {
110 | ForEach(Array(Tab.allCases)) { tab in
111 | let tabIndex: Int = Tab.allCases.firstIndex(where: { $0.id == tab.id })! as! Int
112 | let selectedTabIndex: Int = Tab.allCases.firstIndex(where: { $0.id == self.selectedTab.id })! as! Int
113 | let isSelected: Bool = self.selectedTab.id == tab.id
114 |
115 | Button {
116 | self.selectedTab = tab
117 | } label: {
118 | VStack(spacing: Platform.value(default: 10.0, phone: 5.0)) {
119 | Image(convertible: tab)
120 | .symbolVariant(isSelected ? .fill : .none)
121 | .font(Platform.value(default: .title, phone: .title2))
122 |
123 |
124 | Text(convertible: tab)
125 | .font(Platform.value(default: .body, phone: .footnote))
126 | .fontWeight(isSelected ? .medium : .regular)
127 | .multilineTextAlignment(.center)
128 | .minimumScaleFactor(0.75)
129 | .lineLimit(2)
130 | }
131 | .padding(Platform.value(default: 10.0, phone: 6.0))
132 | .frame(maxWidth: .infinity)
133 | .applyIf(self.selectedTab.id == tab.id) {
134 | $0.background {
135 | Color.accentColor.opacity(0.4).clipShape(.rect(cornerRadius: 3))
136 | }
137 | }
138 | .background(Color.systemBackground.opacity(0.001))
139 | #if !os(macOS)
140 | .contentShape(.hoverEffect, .rect(cornerRadius: 3))
141 | .hoverEffect()
142 | #endif
143 | }
144 | .buttonStyle(.plain)
145 |
146 | if tabIndex == (Tab.allCases.count - 1 - self.bottomAlignedTabs) {
147 | Spacer()
148 | } else if !isSelected, tabIndex + 1 != selectedTabIndex, tabIndex + 1 != Tab.allCases.count {
149 | Divider()
150 | .opacity(Platform.value(default: 0.5, mac: 1.0))
151 | .padding(.horizontal, 3)
152 | }
153 | }
154 | }
155 | .padding(Platform.value(default: -10, mac: 0))
156 | }
157 | .frame(maxWidth: Platform.value(default: 100.0, phone: 76.0))
158 | .padding(10)
159 |
160 | self.content(self.selectedTab)
161 | .id(self.selectedTab.id)
162 | .frame(maxWidth: .infinity)
163 | }
164 | }
165 | }
166 |
167 | #if DEBUG
168 | #Preview {
169 | struct Preview: View {
170 | enum Tab: String, Identifiable, CustomLabelConvertible, CaseIterable {
171 | case house, pencil, gear
172 |
173 | var id: Self { self }
174 | var description: String { self.rawValue.firstUppercased }
175 | var symbolName: String { self.rawValue }
176 | }
177 |
178 | @State private var selectedTab: Tab = .house
179 |
180 | var body: some View {
181 | SideTabView(selection: self.$selectedTab, bottomAlignedTabs: 1) { selectedTab in
182 | Text(verbatim: "Content of \(selectedTab.rawValue.firstUppercased)")
183 | }
184 | }
185 | }
186 |
187 | return Preview().macOSOnlyFrame(minWidth: 800, minHeight: 600)
188 | }
189 | #endif
190 | #endif
191 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Views/VPicker.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import HandySwift
3 |
4 | /// A custom vertical picker that displays options as a stack of buttons with icons and labels.
5 | ///
6 | /// `VPicker` provides a visually appealing way to select a single option from a list,
7 | /// presenting choices vertically with customizable icons and labels.
8 | ///
9 | /// # Features
10 | /// - Supports any `Hashable`, `Identifiable`, and `CustomLabelConvertible` type as options
11 | /// - Custom label view for the picker
12 | /// - Vertical layout with icon and text for each option
13 | /// - Customizable icon angles and amounts
14 | /// - Accessibility support with a standard picker representation
15 | ///
16 | /// # Example usage:
17 | /// ```swift
18 | /// struct ContentView: View {
19 | /// enum Mood: String, Identifiable, CustomLabelConvertible, CaseIterable {
20 | /// case happy, neutral, sad
21 | ///
22 | /// var description: String { rawValue.capitalized }
23 | /// var symbolName: String {
24 | /// switch self {
25 | /// case .happy: "hand.thumbsup"
26 | /// case .neutral: "face.thumbsup:90" // rotate icon by 90 degrees
27 | /// case .sad: "hand.thumbsdown"
28 | /// }
29 | /// }
30 | /// var id: Self { self }
31 | /// }
32 | ///
33 | /// @State private var selectedMood: Mood?
34 | ///
35 | /// var body: some View {
36 | /// Form {
37 | /// VPicker("How are you feeling?", selection: $selectedMood)
38 | /// }
39 | /// }
40 | /// }
41 | /// ```
42 | public struct VPicker: View {
43 | /// The array of selectable options.
44 | let options: [T]
45 |
46 | /// A closure that returns the label view for the picker.
47 | let label: () -> L
48 |
49 | /// A binding to the currently selected option.
50 | let selection: Binding
51 |
52 | /// Creates a new `VPicker` with the specified options, selection binding, and label.
53 | ///
54 | /// - Parameters:
55 | /// - options: An array of selectable options.
56 | /// - selection: A binding to the currently selected option.
57 | /// - label: A closure that returns the label view for the picker.
58 | public init(options: [T], selection: Binding, label: @escaping () -> L) {
59 | self.options = options
60 | self.selection = selection
61 | self.label = label
62 | }
63 |
64 | @Environment(\.colorScheme) var colorScheme
65 |
66 | public var body: some View {
67 | VStack(spacing: 10) {
68 | self.label().padding(.top, 10)
69 |
70 | VStack {
71 | ForEach(self.options) { option in
72 | Button {
73 | self.selection.wrappedValue = option
74 | } label: {
75 | option.label
76 | .labelStyle(
77 | // TODO: add customizability of the label style for horizontal picker variation
78 | .vertical(
79 | iconColor: self.selection.wrappedValue == option ? .accentColor : .secondary,
80 | iconFont: .system(size: 25, weight: .regular),
81 | iconAngle: self.iconAngle(option: option),
82 | iconAmount: self.iconAmount(option: option)
83 | )
84 | )
85 | .font(.body)
86 | .padding()
87 | .frame(maxWidth: .infinity)
88 | .background(OS.current == .iOS ? .thinMaterial : .regularMaterial)
89 | .applyIf(self.selection.wrappedValue == option) {
90 | $0
91 | .background(Color.accentColor.opacity(0.33))
92 | .overlay {
93 | RoundedRectangle(cornerRadius: 12.5)
94 | .strokeBorder(Color.accentColor, lineWidth: 2)
95 | }
96 | .fontWeight(.semibold)
97 | }
98 | .overlay {
99 | RoundedRectangle(cornerRadius: 12.5).strokeBorder(.gray, lineWidth: 0.5)
100 | }
101 | .clipShape(.rect(cornerRadius: 12.5))
102 | #if os(iOS) || os(visionOS)
103 | .contentShape(.hoverEffect, .rect(cornerRadius: 12.5)).hoverEffect()
104 | #endif
105 | }
106 | .buttonStyle(.plain)
107 | }
108 | }
109 | .padding(.vertical, 10)
110 | .padding(.bottom, 10)
111 | }
112 | .accessibilityRepresentation {
113 | Picker(selection: self.selection) {
114 | Text(verbatim: "–").tag(T?.none)
115 |
116 | ForEach(self.options) { option in
117 | option.label.tag(option as T?)
118 | }
119 | } label: {
120 | self.label()
121 | }
122 | }
123 | }
124 |
125 | /// Calculates the angle for the icon of a given option.
126 | ///
127 | /// - Parameter option: The option to calculate the icon angle for.
128 | /// - Returns: An `Angle` if specified in the option's `symbolName`, otherwise `nil`.
129 | func iconAngle(option: T) -> Angle? {
130 | guard
131 | let degreesString = option.symbolName.components(separatedBy: "*")[0].components(separatedBy: ":")[safe: 1],
132 | let degrees = Double(degreesString)
133 | else { return nil }
134 | return Angle(degrees: degrees)
135 | }
136 |
137 | /// Calculates the amount of icons to display for a given option.
138 | ///
139 | /// - Parameter option: The option to calculate the icon amount for.
140 | /// - Returns: The number of icons to display, defaulting to 1 if not specified.
141 | func iconAmount(option: T) -> Int {
142 | guard
143 | let amountString = option.symbolName.components(separatedBy: "*")[safe: 1],
144 | let amount = Int(amountString)
145 | else { return 1 }
146 | return amount
147 | }
148 | }
149 |
150 | /// Convenience initializer for `VPicker` that uses a `Text` view as the label.
151 | extension VPicker where L == Text {
152 | /// Creates a new `VPicker` with a `Text` label using a localized string key.
153 | ///
154 | /// - Parameters:
155 | /// - titleKey: The localized string key for the picker's title.
156 | /// - options: An array of selectable options.
157 | /// - selection: A binding to the currently selected option.
158 | public init(_ titleKey: LocalizedStringKey, options: [T], selection: Binding) {
159 | self.label = { Text(titleKey).font(.headline) }
160 | self.options = options
161 | self.selection = selection
162 | }
163 | }
164 |
165 | /// Convenience initializer for `VPicker` when `T` conforms to `CaseIterable`.
166 | extension VPicker where T: CaseIterable {
167 | /// Creates a new `VPicker` using all cases of an enumeration that conforms to `CaseIterable`.
168 | ///
169 | /// - Parameters:
170 | /// - selection: A binding to the currently selected option.
171 | /// - label: A closure that returns the label view for the picker.
172 | public init(selection: Binding, label: @escaping () -> L) {
173 | self.options = T.allCases as! [T]
174 | self.selection = selection
175 | self.label = label
176 | }
177 | }
178 |
179 | /// Convenience initializer for `VPicker` when `T` conforms to `CaseIterable` and using a `Text` label.
180 | extension VPicker where T: CaseIterable, L == Text {
181 | /// Creates a new `VPicker` using all cases of an enumeration that conforms to `CaseIterable`, with a `Text` label.
182 | ///
183 | /// - Parameters:
184 | /// - titleKey: The localized string key for the picker's title.
185 | /// - selection: A binding to the currently selected option.
186 | public init(_ titleKey: LocalizedStringKey, selection: Binding) {
187 | self.label = { Text(titleKey).font(.headline) }
188 | self.options = T.allCases as! [T]
189 | self.selection = selection
190 | }
191 | }
192 |
193 | #if DEBUG
194 | #Preview {
195 | struct Preview: View {
196 | enum HogwartsHouse: String, Identifiable, CustomLabelConvertible, CaseIterable {
197 | case gryffindor, ravenclaw, hufflepuff, slytherin
198 |
199 | var description: String { self.rawValue.firstUppercased }
200 | var symbolName: String {
201 | switch self {
202 | case .gryffindor: "cat"
203 | case .ravenclaw: "bird"
204 | case .hufflepuff: "dog"
205 | case .slytherin: "lizard"
206 | }
207 | }
208 | var id: String { self.rawValue }
209 | }
210 |
211 | @State var selectedHouse: HogwartsHouse? = .gryffindor
212 |
213 | var body: some View {
214 | Form {
215 | VPicker("Hogwarts House", selection: self.$selectedHouse)
216 | }
217 | .macOSOnly { $0.padding().frame(minHeight: 450) }
218 | }
219 | }
220 |
221 | return Preview()
222 | }
223 | #endif
224 |
--------------------------------------------------------------------------------
/Sources/HandySwiftUI/Types/Views/WebView.swift:
--------------------------------------------------------------------------------
1 | #if canImport(WebKit)
2 | import SwiftUI
3 | import HandySwift
4 | import WebKit
5 | #if canImport(UIKit)
6 | import UIKit
7 | #else
8 | import AppKit
9 | #endif
10 |
11 | /// A cross-platform WebView wrapper for SwiftUI that handles URL loading and external link navigation.
12 | /// Supports iOS, macOS, and iPadOS with consistent behavior across platforms.
13 | ///
14 | /// Example usage:
15 | /// ```swift
16 | /// struct ContentView: View {
17 | /// var body: some View {
18 | /// WebView(
19 | /// url: URL(string: "https://example.com")!,
20 | /// scrollOffsetOnPageLoad: CGPoint(x: 0, y: 200)
21 | /// ) { externalURL in
22 | /// print("Opening external URL: \(externalURL)")
23 | /// }
24 | /// }
25 | /// }
26 | /// ```
27 | public struct WebView: View {
28 | #if canImport(UIKit)
29 | typealias NativeViewRepresentable = UIViewRepresentable
30 | #else
31 | typealias NativeViewRepresentable = NSViewRepresentable
32 | #endif
33 |
34 | struct WebViewRepresentable: NativeViewRepresentable {
35 | let url: URL
36 | let scrollOffsetOnPageLoad: CGPoint?
37 | let onOpenExternalURL: (URL) -> Void
38 |
39 | #if canImport(UIKit)
40 | func makeUIView(context: Context) -> WKWebView {
41 | WKWebView()
42 | }
43 |
44 | func updateUIView(_ webView: WKWebView, context: Context) {
45 | webView.load(URLRequest(url: url))
46 |
47 | // Add an observer to know when the web content has finished loading
48 | webView.navigationDelegate = context.coordinator
49 | }
50 | #else
51 | func makeNSView(context: Context) -> WKWebView {
52 | WKWebView()
53 | }
54 |
55 | func updateNSView(_ webView: WKWebView, context: Context) {
56 | webView.load(URLRequest(url: url))
57 |
58 | // Add an observer to know when the web content has finished loading
59 | webView.navigationDelegate = context.coordinator
60 | }
61 | #endif
62 |
63 | // Coordinator to handle WKNavigationDelegate methods
64 | func makeCoordinator() -> Coordinator {
65 | Coordinator(parent: self)
66 | }
67 |
68 | final class Coordinator: NSObject, WKNavigationDelegate {
69 | var parent: WebViewRepresentable
70 |
71 | init(parent: WebViewRepresentable) {
72 | self.parent = parent
73 | }
74 |
75 | func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
76 | if let scrollOffsetOnPageLoad = parent.scrollOffsetOnPageLoad {
77 | Task {
78 | try await Task.sleep(for: .milliseconds(250))
79 | let scrollScript = "window.scrollBy({ top: \(scrollOffsetOnPageLoad.y), left: \(scrollOffsetOnPageLoad.x), behavior: 'smooth' });"
80 | try await webView.evaluateJavaScript(scrollScript)
81 | }
82 | }
83 | }
84 |
85 | func webView(
86 | _ webView: WKWebView,
87 | decidePolicyFor navigationAction: WKNavigationAction,
88 | decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void
89 | ) {
90 | if let url = navigationAction.request.url, navigationAction.navigationType == .linkActivated {
91 | // Open in Safari
92 | #if canImport(UIKit)
93 | UIApplication.shared.open(url)
94 | #else
95 | NSWorkspace.shared.open(url)
96 | #endif
97 | self.parent.onOpenExternalURL(url)
98 | decisionHandler(.cancel) // Prevent the webView from loading the URL
99 | } else {
100 | // Allow other navigations within the webView
101 | decisionHandler(.allow)
102 | }
103 | }
104 | }
105 | }
106 |
107 | private let url: URL
108 | private let scrollOffsetOnPageLoad: CGPoint?
109 | private let onOpenExternalURL: (URL) -> Void
110 |
111 | /// Creates a new WebView that loads and displays content from the specified URL.
112 | /// - Parameters:
113 | /// - url: The URL to load in the WebView
114 | /// - scrollOffsetOnPageLoad: Optional initial scroll position after the page loads
115 | /// - onOpenExternalURL: Callback closure executed when an external link is tapped
116 | public init(
117 | url: URL,
118 | scrollOffsetOnPageLoad: CGPoint? = nil,
119 | onOpenExternalURL: @escaping (URL) -> Void = { _ in }
120 | ) {
121 | self.url = url
122 | self.scrollOffsetOnPageLoad = scrollOffsetOnPageLoad
123 | self.onOpenExternalURL = onOpenExternalURL
124 | }
125 |
126 | public var body: some View {
127 | WebViewRepresentable(
128 | url: self.url,
129 | scrollOffsetOnPageLoad: self.scrollOffsetOnPageLoad,
130 | onOpenExternalURL: self.onOpenExternalURL
131 | )
132 | }
133 | }
134 |
135 | #if DEBUG
136 | #Preview {
137 | WebView(url: URL(string: "https://www.justwatch.com/de/Film/Avatar-2")!, scrollOffsetOnPageLoad: .init(x: 0, y: 200))
138 | }
139 | #endif
140 | #endif
141 |
--------------------------------------------------------------------------------