├── .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://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FFlineDev%2FHandySwiftUI%2Fbadge%3Ftype%3Dplatforms)](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 | 30 | 31 | 32 | 33 | 34 | 39 | 46 | 47 | 48 | 49 | 54 | 61 | 62 | 63 | 64 | 69 | 76 | 77 | 78 | 79 | 84 | 91 | 92 | 93 | 94 | 99 | 106 | 107 | 108 | 109 | 114 | 121 | 122 | 123 | 124 | 129 | 136 | 137 | 138 |
App IconApp Name & DescriptionSupported Platforms
35 | 36 | 37 | 38 | 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 |
Mac
50 | 51 | 52 | 53 | 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 |
iPhone, iPad, Mac, Vision
65 | 66 | 67 | 68 | 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 |
Mac
80 | 81 | 82 | 83 | 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 |
iPhone, iPad, Mac, Vision
95 | 96 | 97 | 98 | 100 | 101 | CrossCraft: Custom Crosswords 102 | 103 |
104 | Create themed & personalized crosswords. Solve them yourself or share them to challenge others. 105 |
iPhone, iPad, Mac, Vision
110 | 111 | 112 | 113 | 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 |
iPhone, iPad, Mac, Vision
125 | 126 | 127 | 128 | 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 |
Vision
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, boldsub insert delete another italic semibold & italicsup 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 | ![](Last30Days) 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 | ![](SettingsView) 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 | ![](OpenPanel) 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 | ![](SideTabView) 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 | ![](ButtonStyles) 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 | ![](VerticalLabeledContent) 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 | ![](MuteLabelFalse) 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 | ![](CheckboxUniversal) 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 | ![](YellowWithContrast) 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 | ![](StateBadges) 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 | ![](ConfirmDelete) 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 | --------------------------------------------------------------------------------