├── .github ├── FUNDING.yml └── workflows │ ├── test.yml │ ├── build.yml │ ├── docc.yml │ └── version-bump.yml ├── .gitignore ├── Resources ├── Icon-Badge.png ├── Icon-Plain.png └── Fonts │ ├── Lobster │ └── Lobster-Regular.ttf │ ├── Luckiest_Guy │ └── LuckiestGuy-Regular.ttf │ └── Abril_Fatface │ └── AbrilFatface-Regular.ttf ├── Sources └── SwiftUIKit │ ├── SwiftUIKit.docc │ ├── Resources │ │ └── Logo.png │ └── SwiftUIKit.md │ ├── Resources │ └── Localizable.xcstrings │ ├── Collections │ ├── Collection+Content.swift │ ├── Sequence+Group.swift │ ├── Collection+Distinct.swift │ ├── Array+Range.swift │ ├── Sequence+Batch.swift │ └── Collection+Async.swift │ ├── Images │ ├── Image+Symbol.swift │ ├── Image+Resized.swift │ ├── ImageRepresentable.swift │ ├── ImageCache.swift │ ├── UIImage+Tinted.swift │ ├── UIImage+Photos.swift │ ├── ImageRepresentable+Data.swift │ ├── UIImage+Rotated.swift │ └── ImageRepresentable+Resized.swift │ ├── Extensions │ ├── View+macOS.swift │ ├── Optional+IsSet.swift │ ├── Url+Global.swift │ ├── EdgeInsets+UIKit.swift │ ├── View+Enabled.swift │ ├── EdgeInsets+Edge.swift │ ├── Text+Lines.swift │ ├── View+Frame.swift │ ├── ComparisonResult+Shortcuts.swift │ ├── View+DynamicType.swift │ ├── Comparable+Limit.swift │ ├── View+Border.swift │ ├── View+Autosave.swift │ ├── UserDefaults+Codable.swift │ ├── EdgeInsets+Init.swift │ ├── Label+Init.swift │ ├── View+Label.swift │ ├── View+Prefers.swift │ └── View+Conditionals.swift │ ├── String │ ├── String+Bool.swift │ ├── String+Trimmed.swift │ ├── String+Split.swift │ ├── String+Content.swift │ ├── String+Capitalize.swift │ ├── String+Contains.swift │ ├── String+Dictation.swift │ ├── String+Base64.swift │ ├── String+Characters.swift │ ├── String+UrlEncode.swift │ ├── String+Replace.swift │ ├── String+Paragraph.swift │ └── String+Subscript.swift │ ├── Colors │ ├── Color+List.swift │ ├── ColorRepresentable.swift │ ├── Color+Random.swift │ └── Color+Codable.swift │ ├── Fonts │ ├── FontRepresentable.swift │ └── Font+FontRepresentable.swift │ ├── _Deprecated │ ├── ListPadding.swift │ ├── ListSubtitle.swift │ └── ListButtonStyle.swift │ ├── Date │ ├── Date+Init.swift │ ├── Date+Compare.swift │ ├── Date+AddRemove.swift │ ├── Date+Difference.swift │ └── Date+Components.swift │ ├── Bundle │ └── Bundle+Information.swift │ ├── Views │ ├── EditableView.swift │ └── FetchedDataView.swift │ ├── Regex │ └── ValidationRegex.swift │ ├── Files │ ├── URL+iCloud.swift │ ├── FileManager+UniqueFileName.swift │ ├── BundleFileFinder.swift │ ├── DirectoryObservable.swift │ ├── DirectoryMonitor.swift │ └── iCloudDocumenSync.swift │ ├── Previews │ └── SwiftUIPreviewInspector.swift │ ├── Grid │ └── GridItem+Convenience.swift │ ├── Lists │ ├── ListDragHandle.swift │ ├── ListCardStyle.swift │ ├── ListCardButtonStyle.swift │ ├── SidebarListRowBackgroundModifier.swift │ ├── ListSectionTitle.swift │ ├── ListShelfSectionStyle.swift │ ├── ListButtonGroup.swift │ ├── ListCard.swift │ └── PlainListContent.swift │ ├── Sharing │ └── ShareSheet.swift │ ├── Data │ ├── Collection+Codable.swift │ ├── StorageValue.swift │ └── CsvParser.swift │ ├── Commands │ └── AboutPanelCommand.swift │ ├── Text │ ├── TextEditorStyle.swift │ ├── TextFieldClearButton.swift │ └── MultilineSubmitViewModifier.swift │ ├── Device │ └── DeviceIdentifier.swift │ ├── Keychain │ └── KeychainItemAccessibility.swift │ └── Styles │ └── ViewShadowStyle.swift ├── scripts ├── tools │ └── StringCatalogKeyBuilder │ │ ├── Sources │ │ └── StringCatalogKeyBuilder │ │ │ ├── main.swift │ │ │ └── StringCatalogParserCommand.swift │ │ ├── Package.resolved │ │ ├── Package.swift │ │ └── README.md ├── git-default-branch.sh ├── package-name.sh ├── version-number.sh ├── chmod-all.sh ├── sync-from.sh ├── sync-to.sh └── release-validate-git.sh ├── .swiftlint.yml ├── Tests └── SwiftUIKitTests │ ├── Collections │ ├── Collection+ContentTests.swift │ ├── Collection+DistinctTests.swift │ ├── Array+RangeTests.swift │ ├── Sequence+GroupedTests.swift │ └── Sequence+BatchedTests.swift │ ├── Extensions │ ├── Url+GlobalTests.swift │ ├── ComparisonResult+ShortcutsTests.swift │ ├── EdgeInsets+EdgeTests.swift │ ├── Optional+IsSetTests.swift │ ├── Comparable+LimitTests.swift │ └── UserDefaults+CodableTests.swift │ ├── String │ ├── String+SplitTests.swift │ ├── String+ContentTests.swift │ ├── String+UrlEncodeTests.swift │ ├── String+ReplaceTests.swift │ ├── String+BoolTests.swift │ ├── String+ContainsTests.swift │ ├── String+Base64Tests.swift │ └── String+ParagraphTests.swift │ ├── Images │ └── UIImage+TintedTests.swift │ ├── Styles │ └── ViewShadowStyleTests.swift │ ├── Bundle │ └── Bundle+InformationTests.swift │ ├── Date │ ├── Date+InitTests.swift │ ├── Date+CompareTests.swift │ └── Date+AddRemoveTests.swift │ ├── Regex │ └── ValidationRegexTests.swift │ ├── Data │ ├── CsvParserTests.swift │ ├── StorageCodableTests.swift │ └── MimeTypeTests.swift │ └── Device │ └── DeviceIdentifierTests.swift ├── LICENSE ├── Package.swift ├── RELEASE_NOTES.md └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [danielsaidi] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | .swiftpm/ 5 | xcuserdata/ 6 | DerivedData/ -------------------------------------------------------------------------------- /Resources/Icon-Badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/SwiftUIKit/HEAD/Resources/Icon-Badge.png -------------------------------------------------------------------------------- /Resources/Icon-Plain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/SwiftUIKit/HEAD/Resources/Icon-Plain.png -------------------------------------------------------------------------------- /Resources/Fonts/Lobster/Lobster-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/SwiftUIKit/HEAD/Resources/Fonts/Lobster/Lobster-Regular.ttf -------------------------------------------------------------------------------- /Resources/Fonts/Luckiest_Guy/LuckiestGuy-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/SwiftUIKit/HEAD/Resources/Fonts/Luckiest_Guy/LuckiestGuy-Regular.ttf -------------------------------------------------------------------------------- /Resources/Fonts/Abril_Fatface/AbrilFatface-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/SwiftUIKit/HEAD/Resources/Fonts/Abril_Fatface/AbrilFatface-Regular.ttf -------------------------------------------------------------------------------- /Sources/SwiftUIKit/SwiftUIKit.docc/Resources/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielsaidi/SwiftUIKit/HEAD/Sources/SwiftUIKit/SwiftUIKit.docc/Resources/Logo.png -------------------------------------------------------------------------------- /scripts/tools/StringCatalogKeyBuilder/Sources/StringCatalogKeyBuilder/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // Run the CLI tool 4 | StringCatalogParserCommand.main() 5 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - file_length 3 | - identifier_name 4 | - large_tuple 5 | - line_length 6 | - nesting 7 | - operator_whitespace 8 | - todo 9 | - trailing_whitespace 10 | - type_name 11 | - vertical_whitespace 12 | 13 | included: 14 | - Sources 15 | - Tests 16 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Resources/Localizable.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "" : { 5 | 6 | }, 7 | " " : { 8 | 9 | }, 10 | "%@" : { 11 | 12 | }, 13 | "%lld" : { 14 | 15 | }, 16 | "1" : { 17 | 18 | } 19 | }, 20 | "version" : "1.0" 21 | } -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Collections/Collection+Content.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+Content.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-06-09. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Collection { 12 | 13 | /// This is an inverse `isEmpty` check. 14 | var hasContent: Bool { !isEmpty } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Images/Image+Symbol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Image+Symbol.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-05-29. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension Image { 12 | 13 | /// Resolve an SF Symbols-based image, using `Image(systemName:)`. 14 | static func symbol(_ name: String) -> Image { 15 | .init(systemName: name) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/View+macOS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+macOS.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-12-19. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if os(macOS) 10 | import SwiftUI 11 | 12 | extension View { 13 | 14 | /// Open the macOS Settings Panel. 15 | func openAppSettings() { 16 | NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) 17 | } 18 | } 19 | #endif 20 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Images/Image+Resized.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Image+Resized.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-08-17. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension Image { 12 | 13 | /// Resize the image with a certain content mode. 14 | func resized(to mode: ContentMode) -> some View { 15 | self.resizable() 16 | .aspectRatio(contentMode: mode) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/Optional+IsSet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Optional+IsSet.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-06-09. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Optional { 12 | 13 | /// Whether or not the value is `nil`. 14 | var isNil: Bool { self == nil } 15 | 16 | /// Whether or not the value is set and not `nil`. 17 | var isSet: Bool { !isNil } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/Url+Global.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Url+Global.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-08-31. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension URL { 12 | 13 | /// The url to the App Store page for a certain app ID. 14 | static func appStoreUrl(forAppId appId: Int) -> URL? { 15 | URL(string: "https://itunes.apple.com/app/id\(appId)") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/String/String+Bool.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Bool.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-11-03. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension String { 12 | 13 | /// Parse a potential boolean value in the string. 14 | /// 15 | /// This handles 1/0, yes/no, YES/NO, etc. and is good to parsing .plist files. 16 | var boolValue: Bool { (self as NSString).boolValue } 17 | } 18 | -------------------------------------------------------------------------------- /scripts/tools/StringCatalogKeyBuilder/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "66815037dbf8ff7c30c7035965ef4f4e4b236aebc6b8a202f470391b382966af", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-argument-parser", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-argument-parser.git", 8 | "state" : { 9 | "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", 10 | "version" : "1.6.2" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/String/String+Trimmed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Trimmed.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-11-15. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension String { 12 | 13 | /// This is a shorthand for `trimmingCharacters(in:)`. 14 | func trimmed( 15 | for set: CharacterSet = .whitespacesAndNewlines 16 | ) -> String { 17 | self.trimmingCharacters(in: set) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/EdgeInsets+UIKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIEdgeInsets+SwiftUI.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-05-19. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if canImport(UIKit) 10 | import SwiftUI 11 | import UIKit 12 | 13 | public extension UIEdgeInsets { 14 | 15 | /// Map the insets to a SwiftUI-specific value. 16 | var insets: EdgeInsets { 17 | .init(top: top, leading: left, bottom: bottom, trailing: right) 18 | } 19 | } 20 | #endif 21 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/View+Enabled.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Enabled.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-11-26. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension View { 12 | 13 | /// Enable the view is a certain condition is met. 14 | /// 15 | /// This is an inverted `disabled` that is intended to increase readability. 16 | func enabled(_ condition: Bool) -> some View { 17 | disabled(!condition) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/String/String+Split.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Split.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-08-23. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension String { 12 | 13 | /// Split the string using a list of separators. 14 | func split(by separators: [String]) -> [String] { 15 | let separators = CharacterSet(charactersIn: separators.joined()) 16 | return components(separatedBy: separators) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Collections/Collection+ContentTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+ContentTests.swift 3 | // SwiftUIKitTest 4 | // 5 | // Created by Daniel Saidi on 2020-06-09. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class Collection_ContentTests: XCTestCase { 13 | 14 | func testHasContentOnlyReturnsTrueWhenCollectionHasContent() { 15 | XCTAssertTrue(["whatever"].hasContent) 16 | XCTAssertFalse([String]().hasContent) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Collections/Sequence+Group.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sequence+Group.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-06-04. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Sequence { 12 | 13 | /// Group the sequence into a dictionary. 14 | /// 15 | /// The operation can use any element property as dictionary key. 16 | func grouped(by grouper: (Element) -> T) -> [T: [Element]] { 17 | Dictionary(grouping: self, by: grouper) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Collections/Collection+Distinct.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+Distinct.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-06-09. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Collection where Element: Hashable { 12 | 13 | /// Get distinct elements from the collection. 14 | /// 15 | /// This operation will preserve the collection order. 16 | func distinct() -> [Element] { 17 | reduce([]) { $0.contains($1) ? $0 : $0 + [$1] } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Colors/Color+List.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+List.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-11-23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension Color { 11 | 12 | /// Get the standard list background color for a scheme. 13 | @ViewBuilder 14 | static func listBackground( 15 | forScheme scheme: ColorScheme 16 | ) -> some View { 17 | if scheme == .light { 18 | Color.primary.colorInvert() 19 | } else { 20 | Color.primary.opacity(0.102) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Fonts/FontRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FontRepresentable.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-05-06. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if os(macOS) 10 | import class AppKit.NSFont 11 | 12 | /// This typealias bridges platform-specific font types. 13 | public typealias FontRepresentable = NSFont 14 | #endif 15 | 16 | #if canImport(UIKit) 17 | import class UIKit.UIFont 18 | 19 | /// This typealias bridges platform-specific font types. 20 | public typealias FontRepresentable = UIFont 21 | #endif 22 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Extensions/Url+GlobalTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Url+GlobalTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2012-08-31. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUIKit 11 | import XCTest 12 | 13 | final class Url_GlobalTests: XCTestCase { 14 | 15 | func testAppStoreUrl() { 16 | let url = URL.appStoreUrl(forAppId: 123)?.absoluteString 17 | let expected = "https://itunes.apple.com/app/id\(123)" 18 | XCTAssertEqual(url, expected) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Colors/ColorRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorRepresentable.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-05-06. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if os(macOS) 10 | import class AppKit.NSColor 11 | 12 | /// This typealias bridges platform-specific color types. 13 | public typealias ColorRepresentable = NSColor 14 | #endif 15 | 16 | #if canImport(UIKit) 17 | import class UIKit.UIColor 18 | 19 | /// This typealias bridges platform-specific color types. 20 | public typealias ColorRepresentable = UIColor 21 | #endif 22 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/String/String+Content.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Content.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-06-05. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension String { 12 | 13 | /// Check if this string has any content. 14 | var hasContent: Bool { 15 | !isEmpty 16 | } 17 | 18 | /// Check if this string has any content after trimming. 19 | var hasTrimmedContent: Bool { 20 | !trimmingCharacters(in: .whitespacesAndNewlines).isEmpty 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Collections/Collection+DistinctTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+DistinctTests.swift 3 | // SwiftUIKitTest 4 | // 5 | // Created by Daniel Saidi on 2020-06-09. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class Collection_DistinctTests: XCTestCase { 13 | 14 | func testDistinctRemovesDuplicatesAndPreservesOrder() { 15 | let array = [1, 1, 1, 2, 2, 3, 1, 2, 3, 1, 1, 1, 3] 16 | let arrayUnique = array.distinct() 17 | XCTAssertEqual(arrayUnique, [1, 2, 3]) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/EdgeInsets+Edge.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EdgeInsets+Edge.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-09-15. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension EdgeInsets { 12 | 13 | /// Get the inset value for a certain edge. 14 | func inset(for edge: Edge) -> Double { 15 | switch edge { 16 | case.top: return top 17 | case.bottom: return bottom 18 | case.leading: return leading 19 | case.trailing: return trailing 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Extensions/ComparisonResult+ShortcutsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComparisonResult+ShortcutsTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2020-06-09. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUIKit 11 | import XCTest 12 | 13 | final class ComparisonResult_ShortcutsTests: XCTestCase { 14 | 15 | func testOrderShortcutHasCorrectValues() { 16 | XCTAssertEqual(ComparisonResult.ascending, .orderedAscending) 17 | XCTAssertEqual(ComparisonResult.descending, .orderedDescending) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/String/String+SplitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+SplitTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2021-09-08. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class String_SplitTests: XCTestCase { 13 | 14 | func testSplittingOnMultipleSeparators() { 15 | let string = "I.Love,Swift!Very much" 16 | let result = string.split(by: [".", ",", "!"]) 17 | let expected = ["I", "Love", "Swift", "Very much"] 18 | XCTAssertEqual(result, expected) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/Text+Lines.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Text+SingleLine.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-11-14. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension Text { 12 | 13 | /// Force text to render as multiline. 14 | func forceMultiline() -> some View { 15 | self.fixedSize(horizontal: false, vertical: true) 16 | } 17 | 18 | /// Force text to render as single line. 19 | func forceSingleLine() -> some View { 20 | self.fixedSize(horizontal: true, vertical: false) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/View+Frame.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Frame.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-01-05. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension View { 12 | 13 | /// Apply a size-based frame. 14 | func frame(_ size: CGSize) -> some View { 15 | self.frame(width: size.width, height: size.height) 16 | } 17 | 18 | /// Apply the same size to all sides of the view. 19 | func frame(square size: CGFloat) -> some View { 20 | self.frame(width: size, height: size) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/String/String+Capitalize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Capitalize.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-01-11. 6 | // Copyright © 2022 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension String { 12 | 13 | /// Capitalize the first letter in the string. 14 | func capitalizingFirstLetter() -> String { 15 | prefix(1).capitalized + dropFirst() 16 | } 17 | 18 | /// Capitalize the first letter in the string. 19 | mutating func capitalizeFirstLetter() { 20 | self = self.capitalizingFirstLetter() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Images/UIImage+TintedTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+TintedTests.swift 3 | // KeyboardKitTests 4 | // 5 | // Created by Daniel Saidi on 2019-05-06. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUIKit 11 | import UIKit 12 | import XCTest 13 | 14 | final class UIImage_TintedTests: XCTestCase { 15 | 16 | func testCanGenerateTintedImage() { 17 | let image = UIImage().resized(to: CGSize(width: 100, height: 100))! 18 | let tinted = image.tinted(with: .red, blendMode: .clear) 19 | XCTAssertNotNil(tinted) 20 | } 21 | } 22 | #endif 23 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/ComparisonResult+Shortcuts.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComparisonResult+Shortcuts.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-06-09. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension ComparisonResult { 12 | 13 | /// This is a shorthand for `.orderedAscending`. 14 | static var ascending: ComparisonResult { 15 | .orderedAscending 16 | } 17 | 18 | /// This is a shorthand for `.orderedDescending`. 19 | static var descending: ComparisonResult { 20 | .orderedDescending 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/String/String+Contains.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Contains.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2015-02-17. 6 | // Copyright © 2015-2025 Daniel Saidi. All rights reserved. 7 | // 8 | // https://danielsaidi.com/blog/2020/06/04/string-contains 9 | // 10 | 11 | import Foundation 12 | 13 | public extension String { 14 | 15 | /// Check if this string contains another string. 16 | func contains(_ string: String, caseSensitive: Bool) -> Bool { 17 | caseSensitive 18 | ? contains(string) 19 | : range(of: string, options: .caseInsensitive) != nil 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/View+DynamicType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+DynamicType.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-09-30. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension View { 12 | 13 | /// Disable dynamic type by applying a fixed size. 14 | /// 15 | /// - Parameters: 16 | /// - sizeCategory: The size to apply, by default `.medium`. 17 | func disableDynamicType( 18 | sizeCategory: ContentSizeCategory = .medium 19 | ) -> some View { 20 | self.environment(\.sizeCategory, sizeCategory) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/String/String+Dictation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Dictation.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-11-14. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension String { 12 | 13 | /// Clean up spaces and other characters that can be added during dictation, 14 | /// for instance when dictating text into a text field. 15 | func cleanedUpAfterDictation() -> String { 16 | self 17 | .replacingOccurrences(of: "\u{fffc}", with: "") 18 | .trimmingCharacters(in: .whitespaces) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/Comparable+Limit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Comparable+Limit.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2018-10-04. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Comparable { 12 | 13 | /// Limit the value to a closed range. 14 | mutating func limit(to range: ClosedRange) { 15 | self = limited(to: range) 16 | } 17 | 18 | /// Return the value limited to a closed range. 19 | func limited(to range: ClosedRange) -> Self { 20 | min(range.upperBound, max(range.lowerBound, self)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/_Deprecated/ListPadding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListPadding.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-08-30. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | @available(*, deprecated, message: "Add padding to the section header instead.") 12 | public struct ListPadding: View { 13 | 14 | public init(height: CGFloat) { 15 | self.height = height 16 | } 17 | 18 | private let height: CGFloat 19 | 20 | public var body: some View { 21 | Color.clear.frame(height: height) 22 | .listRowBackground(Color.clear) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/String/String+ContentTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+ContentTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2020-06-04. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class String_ContentTests: XCTestCase { 13 | 14 | func testHasContent() { 15 | XCTAssertFalse("".hasContent) 16 | XCTAssertTrue(" ".hasContent) 17 | } 18 | 19 | func testHasTrimmedContent() { 20 | XCTAssertFalse("".hasTrimmedContent) 21 | XCTAssertFalse(" ".hasTrimmedContent) 22 | XCTAssertTrue(" . ".hasTrimmedContent) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/View+Border.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Border.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-03-10. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension View { 12 | 13 | /// Adds a border with a certain content, width and corner radius. 14 | func border( 15 | _ content: Content, 16 | width: CGFloat = 1, 17 | cornerRadius: CGFloat = 0 18 | ) -> some View { 19 | overlay( 20 | RoundedRectangle(cornerRadius: cornerRadius) 21 | .strokeBorder(content, lineWidth: width) 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Collections/Array+RangeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array_RangeTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2020-06-12. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class Array_RangeTests: XCTestCase { 13 | 14 | func testArrayWithIntRangeHandlesSmallStepSize() { 15 | let result = Array(0...10, stepSize: 1) 16 | XCTAssertEqual(result, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 17 | } 18 | 19 | func testArrayWithIntRangeHandlesLargeStepSize() { 20 | let result = Array(0...10, stepSize: 3) 21 | XCTAssertEqual(result, [0, 3, 6, 9]) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Collections/Array+Range.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+Range.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2016-06-12. 6 | // Copyright © 2016-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Array where Element: Comparable & Strideable { 12 | 13 | /// Create an array from a `range` with `stepSize` between each value. 14 | init( 15 | _ range: ClosedRange, 16 | stepSize: Element.Stride 17 | ) { 18 | self = Array( 19 | stride( 20 | from: range.lowerBound, 21 | through: range.upperBound, 22 | by: stepSize 23 | ) 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Extensions/EdgeInsets+EdgeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EdgeInsets+EdgeTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2020-09-15. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import SwiftUIKit 12 | import XCTest 13 | 14 | final class EdgeInsets_EdgeTests: XCTestCase { 15 | 16 | func testEdges() { 17 | let insets = EdgeInsets(top: 1, leading: 2, bottom: 3, trailing: 4) 18 | XCTAssertEqual(insets.inset(for: .top), 1) 19 | XCTAssertEqual(insets.inset(for: .leading), 2) 20 | XCTAssertEqual(insets.inset(for: .bottom), 3) 21 | XCTAssertEqual(insets.inset(for: .trailing), 4) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/String/String+Base64.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Base64.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2016-12-12. 6 | // Copyright © 2016-2025 Daniel Saidi. All rights reserved. 7 | // 8 | // More info: 9 | // https://danielsaidi.com/blog/2020/06/04/string-base64 10 | // 11 | 12 | import Foundation 13 | 14 | public extension String { 15 | 16 | /// Base64 decode the string. 17 | func base64Decoded() -> String? { 18 | guard let data = Data(base64Encoded: self) else { return nil } 19 | return String(data: data, encoding: .utf8) 20 | } 21 | 22 | /// Base64 encode the string. 23 | func base64Encoded() -> String? { 24 | data(using: .utf8)?.base64EncodedString() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/String/String+UrlEncodeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+UrlEncodeTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2016-12-12. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class String_UrlEncodeTests: XCTestCase { 13 | 14 | func testUrlEncoding() { 15 | XCTAssertEqual("foo bar".urlEncoded(), "foo%20bar") 16 | XCTAssertEqual("?foo=bar".urlEncoded(), "%3Ffoo=bar") 17 | XCTAssertEqual("foo=bar&baz=123".urlEncoded(), "foo=bar%26baz=123") 18 | XCTAssertEqual("foo=[bar]".urlEncoded(), "foo=%5Bbar%5D") 19 | XCTAssertEqual("åäöÅÄÖ".urlEncoded(), "%C3%A5%C3%A4%C3%B6%C3%85%C3%84%C3%96") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Extensions/Optional+IsSetTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Optional+IsSetTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2020-06-09. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUIKit 11 | import XCTest 12 | 13 | final class Optional_HasValue: XCTestCase { 14 | 15 | var value: String? 16 | 17 | override func setUp() { 18 | value = nil 19 | } 20 | 21 | func testNilValue() { 22 | XCTAssertTrue(value.isNil) 23 | XCTAssertFalse(value.isSet) 24 | } 25 | 26 | func testNonNilValue() { 27 | value = "value" 28 | XCTAssertTrue(value.isSet) 29 | XCTAssertFalse(value.isNil) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Fonts/Font+FontRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Font+FontRepresentable.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-07-11. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension Font { 12 | 13 | /// Get a dynamic version of the provided font. 14 | /// 15 | /// This value scales when using dynamic type. 16 | static func dynamic(_ font: FontRepresentable) -> Font { 17 | .custom(font.fontName, size: font.pointSize) 18 | } 19 | 20 | /// Get a fixed version of the provided font. 21 | /// 22 | /// This value doesn't scale when using dynamic type. 23 | static func fixed(_ font: FontRepresentable) -> Font { 24 | .init(font) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/String/String+ReplaceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+ReplaceTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2016-12-13. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class String_ReplaceTests: XCTestCase { 13 | 14 | let string = "Hello, world!" 15 | 16 | func testReplacingCanPerformOptionsBasedReplacements() { 17 | XCTAssertEqual(string.replacing("World", with: "you"), "Hello, world!") 18 | XCTAssertEqual(string.replacing("world", with: "you"), "Hello, you!") 19 | XCTAssertEqual(string.replacing("World", with: "you", .caseInsensitive), "Hello, you!") 20 | XCTAssertEqual(string.replacing("Earth", with: "you"), "Hello, world!") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/View+Autosave.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Autosave.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-09-02. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import SwiftUI 11 | 12 | public extension View { 13 | 14 | /// This modifier can be applied to a view, to autosave. 15 | /// 16 | /// This will call the provided action whenever the provided publisher changes. 17 | func autosave( 18 | _ obj: Published.Publisher, 19 | debounceInterval: RunLoop.SchedulerTimeType.Stride = 2, 20 | action: @escaping () -> Void 21 | ) -> some View { 22 | self.onReceive(obj.debounce(for: debounceInterval, scheduler: RunLoop.main)) { _ in 23 | action() 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/UserDefaults+Codable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaults+Codable.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-09-23. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension UserDefaults { 12 | 13 | /// Try to decode a certain key to a decodable type. 14 | func codable(forKey key: String) -> T? { 15 | guard let data = object(forKey: key) as? Data else { return nil } 16 | let value = try? JSONDecoder().decode(T.self, from: data) 17 | return value 18 | } 19 | 20 | /// Persist a codable item. 21 | func setCodable(_ codable: T, forKey key: String) { 22 | let data = try? JSONEncoder().encode(codable) 23 | set(data, forKey: key) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/_Deprecated/ListSubtitle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListSubtitle.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-02-04. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | @available(*, deprecated, message: "Use LabeledContent instead.") 12 | public struct ListSubtitle: View { 13 | 14 | public init( 15 | _ text: LocalizedStringKey, 16 | bundle: Bundle? = nil 17 | ) { 18 | self.text = text 19 | self.bundle = bundle 20 | } 21 | 22 | private let text: LocalizedStringKey 23 | private let bundle: Bundle? 24 | 25 | public var body: some View { 26 | Text(text, bundle: bundle) 27 | .font(.footnote) 28 | .foregroundColor(.secondary) 29 | .lineLimit(1) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/String/String+Characters.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Characters.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-11-29. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension String.Element { 12 | 13 | static let backspace: String.Element = "\u{7f}" 14 | static let carriageReturn: String.Element = "\r" 15 | static let newLine: String.Element = "\n" 16 | static let space: String.Element = " " 17 | static let tab: String.Element = "\t" 18 | } 19 | 20 | public extension String { 21 | 22 | static let backspace = String(.backspace) 23 | static let carriageReturn = String(.carriageReturn) 24 | static let newLine = String(.newLine) 25 | static let space = String(.space) 26 | static let tab = String(.tab) 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # This workflow tests the project on all platforms. 2 | 3 | # For more information see: 4 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 5 | 6 | name: Test Runner 7 | 8 | on: 9 | push: 10 | branches: ["main"] 11 | pull_request: 12 | branches: ["main"] 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | build: 20 | runs-on: macos-latest # macos-15 21 | 22 | steps: 23 | - name: Checkout Code 24 | uses: actions/checkout@v5 25 | 26 | - name: Setup Xcode 27 | uses: maxim-lobanov/setup-xcode@v1 28 | with: 29 | xcode-version: latest-stable # 16.4 30 | 31 | - name: Tests All Platforms 32 | run: bash scripts/test.sh -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow builds the project for all platforms. 2 | 3 | # For more information see: 4 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 5 | 6 | name: Build Runner 7 | 8 | on: 9 | push: 10 | branches: ["main"] 11 | pull_request: 12 | branches: ["main"] 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | build: 20 | runs-on: macos-latest # macos-15 21 | 22 | steps: 23 | - name: Checkout Code 24 | uses: actions/checkout@v5 25 | 26 | - name: Setup Xcode 27 | uses: maxim-lobanov/setup-xcode@v1 28 | with: 29 | xcode-version: latest-stable # 16.4 30 | 31 | - name: Build All Platforms 32 | run: bash scripts/build.sh -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Collections/Sequence+Batch.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sequence+Batch.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2017-05-10. 6 | // Copyright © 2017-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Sequence { 12 | 13 | /// Batch the sequence into groups of a certain size. 14 | func batched(withBatchSize size: Int) -> [[Element]] { 15 | var result: [[Element]] = [] 16 | var batch: [Element] = [] 17 | 18 | forEach { 19 | batch.append($0) 20 | if batch.count == size { 21 | result.append(batch) 22 | batch = [] 23 | } 24 | } 25 | 26 | if !batch.isEmpty { 27 | result.append(batch) 28 | } 29 | 30 | return result 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/EdgeInsets+Init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIEdgeInsets+Insets.swift 3 | // KeyboardKit 4 | // 5 | // Created by Daniel Saidi on 2021-09-30. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension EdgeInsets { 12 | 13 | /// Create a value with the same insets everywhere. 14 | init(all: Double) { 15 | self.init( 16 | top: all, 17 | leading: all, 18 | bottom: all, 19 | trailing: all 20 | ) 21 | } 22 | 23 | /// Create a value with horizontal and vertical values. 24 | init(horizontal: Double, vertical: Double) { 25 | self.init( 26 | top: vertical, 27 | leading: horizontal, 28 | bottom: vertical, 29 | trailing: horizontal 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Extensions/Comparable+LimitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Comparable+LimitTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2018-10-04. 6 | // Copyright © 2018-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class Comparable_LimitTests: XCTestCase { 13 | 14 | func testLimitComparable() { 15 | var value = 5 16 | value.limit(to: 0...10) 17 | XCTAssertEqual(value, 5) 18 | value.limit(to: 6...10) 19 | XCTAssertEqual(value, 6) 20 | value.limit(to: 0...4) 21 | XCTAssertEqual(value, 4) 22 | } 23 | 24 | func testLimitedComparable() { 25 | XCTAssertEqual(5.limited(to: 0...10), 5) 26 | XCTAssertEqual((-1).limited(to: 0...10), 0) 27 | XCTAssertEqual(11.limited(to: 0...10), 10) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Date/Date+Init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+Init.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-08-05. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Date { 12 | 13 | /// Create a date value using the provided components. 14 | init?( 15 | year: Int, 16 | month: Int, 17 | day: Int, 18 | hour: Int = 0, 19 | minute: Int = 0, 20 | second: Int = 0, 21 | calendar: Calendar = .current 22 | ) { 23 | let components = DateComponents(year: year, month: month, day: day, hour: hour, minute: minute, second: second) 24 | guard let date = calendar.date(from: components) else { 25 | assertionFailure("Invalid date") 26 | return nil 27 | } 28 | self = date 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/String/String+BoolTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+BoolTests.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-11-02. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class String_BoolTests: XCTestCase { 13 | 14 | func result(for string: String) -> Bool { 15 | string.boolValue 16 | } 17 | 18 | func testBoolValueIsValidForManyDifferentTrueExpressions() { 19 | let expected = ["YES", "yes", "1"] 20 | expected.forEach { 21 | XCTAssertTrue(result(for: $0)) 22 | } 23 | } 24 | 25 | func testBoolValueIsValidForManyDifferentFalseExpressions() { 26 | let expected = ["NO", "no", "0"] 27 | expected.forEach { 28 | XCTAssertFalse(result(for: $0)) 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /scripts/tools/StringCatalogKeyBuilder/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.1.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "StringCatalogKeyBuilder", 6 | platforms: [ 7 | .macOS(.v13), 8 | ], 9 | products: [ 10 | .executable( 11 | name: "l10n-gen", 12 | targets: ["StringCatalogKeyBuilder"] 13 | ), 14 | ], 15 | dependencies: [ 16 | .package( 17 | name: "SwiftPackageScripts", 18 | path: "../../../" 19 | ), 20 | .package( 21 | url: "https://github.com/apple/swift-argument-parser.git", 22 | .upToNextMajor(from: "1.5.0") 23 | ), 24 | ], 25 | targets: [ 26 | .executableTarget( 27 | name: "StringCatalogKeyBuilder", 28 | dependencies: [ 29 | "SwiftPackageScripts", 30 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 31 | ] 32 | ) 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Styles/ViewShadowStyleTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewShadowStyleTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2020-03-05. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwiftUIKit 11 | import XCTest 12 | 13 | final class ViewShadowStyleTests: XCTestCase { 14 | 15 | func testSetup() { 16 | let style = ViewShadowStyle(color: .red, radius: 5, x: 7, y: 9) 17 | XCTAssertEqual(style.color, .red) 18 | XCTAssertEqual(style.radius, 5) 19 | XCTAssertEqual(style.x, 7) 20 | XCTAssertEqual(style.y, 9) 21 | } 22 | 23 | func testNoStyle() { 24 | let style = ViewShadowStyle.none 25 | XCTAssertEqual(style.color, .clear) 26 | XCTAssertEqual(style.radius, 0) 27 | XCTAssertEqual(style.x, 0) 28 | XCTAssertEqual(style.y, 0) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Images/ImageRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageRepresentable.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-05-06. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | #if canImport(UIKit) 12 | import class UIKit.UIImage 13 | 14 | /// This typealias bridges platform-specific image types. 15 | public typealias ImageRepresentable = UIImage 16 | #elseif canImport(AppKit) 17 | import class AppKit.NSImage 18 | 19 | /// This typealias bridges platform-specific image types. 20 | public typealias ImageRepresentable = NSImage 21 | #endif 22 | 23 | public extension Image { 24 | 25 | /// Create an image from a certain ``ImageRepresentable``. 26 | init(image: ImageRepresentable) { 27 | #if canImport(UIKit) 28 | self.init(uiImage: image) 29 | #elseif canImport(AppKit) 30 | self.init(nsImage: image) 31 | #endif 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Bundle/Bundle+InformationTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bundle+InformationTests.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-05-30. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUIKit 11 | import XCTest 12 | 13 | final class Bundle_InformationTests: XCTestCase { 14 | 15 | func testCanReadInfoDictionary() { 16 | let bundle = MyBundle() 17 | XCTAssertEqual(bundle.buildNumber, "123") 18 | XCTAssertEqual(bundle.displayName, "SwiftUIKit Tests") 19 | XCTAssertEqual(bundle.versionNumber, "1.2.3") 20 | } 21 | } 22 | 23 | private class MyBundle: Bundle, @unchecked Sendable { 24 | 25 | override var infoDictionary: [String: Any]? { 26 | [ 27 | String(kCFBundleVersionKey): "123", 28 | "CFBundleDisplayName": "SwiftUIKit Tests", 29 | "CFBundleShortVersionString": "1.2.3" 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Bundle/Bundle+Information.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Bundle+Information.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-06-09. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Bundle { 12 | 13 | /// Get the bundle build number string, e.g. `123`. 14 | var buildNumber: String { 15 | let key = String(kCFBundleVersionKey) 16 | let version = infoDictionary?[key] as? String 17 | return version ?? "" 18 | } 19 | 20 | /// Get the bundle display name, if any. 21 | var displayName: String { 22 | infoDictionary?["CFBundleDisplayName"] as? String ?? "-" 23 | } 24 | 25 | /// Get the bundle version number string, e.g. `1.2.3`. 26 | var versionNumber: String { 27 | let key = "CFBundleShortVersionString" 28 | let version = infoDictionary?[key] as? String 29 | return version ?? "0.0.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Images/ImageCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageCache.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-07-17. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This type can be used to cache images. 12 | public class ImageCache { 13 | 14 | /// Create a cache instance. 15 | public init() {} 16 | 17 | /// The cache dictionary that is used to store images. 18 | public var cache = [String: ImageRepresentable]() 19 | 20 | /// Store an image into the cache. 21 | public func cache(image: ImageRepresentable, for key: String) { 22 | cache[key] = image 23 | } 24 | 25 | /// Try to get an image from the cache. 26 | public func cachedImage(for key: String) -> ImageRepresentable? { 27 | cache[key] 28 | } 29 | 30 | /// Remove the cached image for a certain key. 31 | public func clearCache(for key: String) { 32 | cache[key] = nil 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/Label+Init.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Label+Init.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-11-10. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension Label where Icon == Image, Title == Text { 12 | 13 | @available(*, deprecated, renamed: "init(_:_:)") 14 | init(_ text: LocalizedStringKey, image: Image) { 15 | self.init { 16 | Text(text) 17 | } icon: { 18 | image 19 | } 20 | } 21 | 22 | /// Create a label with a string and a plain image icon. 23 | init( 24 | _ text: LocalizedStringKey, 25 | _ image: Image, 26 | _ bundle: Bundle? = nil 27 | ) { 28 | self.init { 29 | Text(text, bundle: bundle) 30 | } icon: { 31 | image 32 | } 33 | } 34 | } 35 | 36 | #Preview { 37 | 38 | Label("Preview.Label", .symbol("checkmark"), .module) 39 | } 40 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/String/String+UrlEncode.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+UrlEncode.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2016-12-12. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | // https://danielsaidi.com/blog/2020/06/04/string-urlencode 9 | // 10 | 11 | import Foundation 12 | 13 | public extension String { 14 | 15 | /// Encode the string for `x-www-form-urlencoded`. 16 | /// 17 | /// This uses `urlEncoded()` then replaces `+` with `%2B`. 18 | func formEncoded() -> String? { 19 | self.urlEncoded()? 20 | .replacingOccurrences(of: "+", with: "%2B") 21 | } 22 | 23 | /// Encode the string to work with quary parameters. 24 | /// 25 | /// This uses `addingPercentEncoding` and `.urlPathAllowed`, 26 | /// then replaces every `&` with `%26`. 27 | func urlEncoded() -> String? { 28 | self.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)? 29 | .replacingOccurrences(of: "&", with: "%26") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Images/UIImage+Tinted.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+Tint.swift 3 | // KeyboardKit 4 | // 5 | // Created by Daniel Saidi on 2015-02-09. 6 | // Copyright © 2015 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | import CoreGraphics 12 | 13 | public extension UIImage { 14 | 15 | /// Create a tinted copy of the image. 16 | func tinted(with color: UIColor, blendMode: CGBlendMode) -> UIImage? { 17 | UIGraphicsBeginImageContextWithOptions(size, false, scale) 18 | let context = UIGraphicsGetCurrentContext() 19 | let rect = CGRect(x: 0, y: 0, width: size.width, height: size.height) 20 | draw(in: rect, blendMode: blendMode, alpha: 1.0) 21 | context?.setBlendMode(blendMode) 22 | color.setFill() 23 | context?.fill(rect) 24 | draw(in: rect, blendMode: .destinationIn, alpha: 1.0) 25 | let tintedImage = UIGraphicsGetImageFromCurrentImageContext() 26 | UIGraphicsEndImageContext() 27 | return tintedImage 28 | } 29 | } 30 | #endif 31 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Date/Date+InitTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+InitTests.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-08-05. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUIKit 11 | import XCTest 12 | 13 | final class Date_InitTests: XCTestCase { 14 | 15 | var formatter: DateFormatter! 16 | 17 | override func setUp() { 18 | formatter = DateFormatter() 19 | formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 20 | } 21 | 22 | func testCanBeInitializedWithDateComponents() { 23 | let date = Date(year: 2011, month: 12, day: 10)! 24 | let string = formatter.string(from: date) 25 | XCTAssertEqual(string, "2011-12-10 00:00:00") 26 | } 27 | 28 | func testCanBeInitializedWithTimeComponents() { 29 | let date = Date(year: 2010, month: 03, day: 22, hour: 14, minute: 21, second: 32)! 30 | let string = formatter.string(from: date) 31 | XCTAssertEqual(string, "2010-03-22 14:21:32") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Regex/ValidationRegexTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ValidationRegexTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2020-06-09. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class ValidationRegexTests: XCTestCase { 13 | 14 | func testCanValidateEmail() { 15 | XCTAssertTrue("foobar@baz.com".isValid(.email)) 16 | XCTAssertTrue("foo1.bar2@baz.com".isValid(.email)) 17 | XCTAssertTrue("foo.bar@gmail.com".isValid(.email)) 18 | 19 | XCTAssertTrue("foobar@baz.co".isValid(.email)) 20 | XCTAssertTrue("foobar@baz.com".isValid(.email)) 21 | XCTAssertTrue("foo1.bar2@baz.comm".isValid(.email)) 22 | XCTAssertTrue("foo.bar@gmail.commmmmmmmmmmmmmmm".isValid(.email)) 23 | 24 | XCTAssertFalse("foobar".isValid(.email)) 25 | XCTAssertFalse("foo1.bar2@".isValid(.email)) 26 | XCTAssertFalse("foo.bar@gmail".isValid(.email)) 27 | XCTAssertFalse("foobar@baz.c".isValid(.email)) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Views/EditableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditableView.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-06-21. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if os(iOS) || os(tvOS) 10 | import SwiftUI 11 | 12 | /// This protocol can be implemented by any view that should be able to toggle its 13 | /// edit mode. 14 | /// 15 | /// To implement the protocol just add an `editMode` binding: 16 | /// 17 | /// ```swift 18 | /// @Environment(\.editMode) 19 | /// var editMode 20 | /// ``` 21 | /// 22 | /// You can check the ``isEditing``, and set it with ``setIsEditing(_:)``. 23 | public protocol EditableView: View { 24 | 25 | var editMode: Binding? { get } 26 | } 27 | 28 | public extension EditableView { 29 | 30 | /// Whether or not this view is currently being edited. 31 | var isEditing: Bool { 32 | editMode?.wrappedValue.isEditing ?? false 33 | } 34 | 35 | /// Set the edit mode of the view. 36 | func setIsEditing(_ isEditing: Bool) { 37 | editMode?.wrappedValue = isEditing ? .active : .inactive 38 | } 39 | } 40 | #endif 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2025 Daniel Saidi 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Collections/Sequence+GroupedTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+GroupTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2017-04-05. 6 | // Copyright © 2017-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class Array_GroupTests: XCTestCase { 13 | 14 | private var array: [TestItem] { 15 | let obj1 = TestItem(name: "Foo", age: 10) 16 | let obj2 = TestItem(name: "Foo", age: 20) 17 | let obj3 = TestItem(name: "Bar", age: 20) 18 | return [obj1, obj2, obj3] 19 | } 20 | 21 | func testGroupingArrayCanGroupByString() { 22 | let result = array.grouped { $0.name } 23 | XCTAssertEqual(result["Foo"]?.count, 2) 24 | XCTAssertEqual(result["Bar"]?.count, 1) 25 | } 26 | 27 | func testGroupingArrayCanGroupByInt() { 28 | let result = array.grouped { $0.age } 29 | XCTAssertEqual(result[10]?.count, 1) 30 | XCTAssertEqual(result[20]?.count, 2) 31 | } 32 | } 33 | 34 | private struct TestItem: Equatable { 35 | 36 | var name: String 37 | var age: Int 38 | } 39 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.1 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SwiftUIKit", 7 | defaultLocalization: "en", 8 | platforms: [ 9 | .iOS(.v15), 10 | .tvOS(.v15), 11 | .watchOS(.v8), 12 | .macOS(.v12), 13 | .visionOS(.v1) 14 | ], 15 | products: [ 16 | .library( 17 | name: "SwiftUIKit", 18 | targets: ["SwiftUIKit"] 19 | ) 20 | ], 21 | dependencies: [], 22 | targets: [ 23 | .target( 24 | name: "SwiftUIKit", 25 | dependencies: [], 26 | resources: [.process("Resources")], 27 | swiftSettings: [ 28 | // Enable library evolution 29 | // .unsafeFlags(["-enable-library-evolution"]), 30 | 31 | // Additional strict checks 32 | // .unsafeFlags(["-warnings-as-errors"]), 33 | // .unsafeFlags(["-strict-concurrency=complete"]) 34 | ] 35 | ), 36 | .testTarget( 37 | name: "SwiftUIKitTests", 38 | dependencies: ["SwiftUIKit"] 39 | ) 40 | ] 41 | ) 42 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Date/Date+CompareTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+CompareTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2020-05-28. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUIKit 11 | import XCTest 12 | 13 | final class DateComparingTests: XCTestCase { 14 | 15 | func testComparisonCanCheckIfDateIsBeforeAnother() { 16 | let date1 = Date(timeIntervalSince1970: 0) 17 | let date2 = Date(timeIntervalSince1970: 1) 18 | XCTAssertTrue(date1.isBefore(date2)) 19 | XCTAssertFalse(date2.isBefore(date1)) 20 | } 21 | 22 | func testComparisonCanCheckIfDateIsAfterAnother() { 23 | let date1 = Date(timeIntervalSince1970: 0) 24 | let date2 = Date(timeIntervalSince1970: 1) 25 | XCTAssertFalse(date1.isAfter(date2)) 26 | XCTAssertTrue(date2.isAfter(date1)) 27 | } 28 | 29 | func testComparisonCanCheckIfDateIsSameAsAnother() { 30 | let date1 = Date(timeIntervalSince1970: 0) 31 | let date2 = Date(timeIntervalSince1970: 1) 32 | let date3 = Date(timeIntervalSince1970: 0) 33 | XCTAssertFalse(date1.isSame(as: date2)) 34 | XCTAssertTrue(date1.isSame(as: date3)) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Extensions/UserDefaults+CodableTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserDefaults+CodableTests.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-09-23. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUIKit 11 | import XCTest 12 | 13 | final class UserDefaults_CodableTests: XCTestCase { 14 | 15 | var defaults: UserDefaults! 16 | let key = "user" 17 | 18 | override func setUp() { 19 | defaults = UserDefaults.standard 20 | } 21 | 22 | override func tearDown() { 23 | defaults.removeObject(forKey: key) 24 | } 25 | 26 | func testDefaultsCanSetAndGetCodableValues() { 27 | var result: User? = defaults.codable(forKey: key) 28 | XCTAssertNil(result) 29 | 30 | defaults.set("HEJ", forKey: key) 31 | result = defaults.codable(forKey: key) 32 | XCTAssertNil(result) 33 | 34 | let user = User(name: "Daniel", age: 40) 35 | defaults.setCodable(user, forKey: key) 36 | result = defaults.codable(forKey: key)! 37 | XCTAssertEqual(result, user) 38 | } 39 | } 40 | 41 | private struct User: Codable, Equatable { 42 | 43 | let name: String 44 | let age: Int 45 | } 46 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Regex/ValidationRegex.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ValidationRegex.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-06-09. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This type can be used for regex-based string validation. 12 | public struct ValidationRegex { 13 | 14 | public init(_ expression: String) { 15 | self.expression = expression 16 | } 17 | 18 | public let expression: String 19 | } 20 | 21 | public extension ValidationRegex { 22 | 23 | /// An e-mail validation regex. 24 | static var email: Self { 25 | .init("[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,} ?") 26 | } 27 | } 28 | 29 | public extension ValidationRegex { 30 | 31 | /// Check if the string is valid for this regex. 32 | func validateString( 33 | _ string: String 34 | ) -> Bool { 35 | let predicate = NSPredicate(format: "SELF MATCHES %@", expression) 36 | return predicate.evaluate(with: string) 37 | } 38 | } 39 | 40 | public extension String { 41 | 42 | /// Check if the string is valid for a certain regex. 43 | func isValid(_ regex: ValidationRegex) -> Bool { 44 | regex.validateString(self) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /scripts/git-default-branch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script outputs the default git branch name." 10 | 11 | echo 12 | echo "Usage: $0 [OPTIONS]" 13 | echo " -h, --help Show this help message" 14 | 15 | echo 16 | echo "Examples:" 17 | echo " $0" 18 | echo 19 | } 20 | 21 | # Function to display error message, show usage, and exit 22 | show_error_and_exit() { 23 | echo 24 | local error_message="$1" 25 | echo "Error: $error_message" 26 | show_usage 27 | exit 1 28 | } 29 | 30 | # Parse command line arguments 31 | while [[ $# -gt 0 ]]; do 32 | case $1 in 33 | -h|--help) 34 | show_usage; exit 0 ;; 35 | -*) 36 | show_error_and_exit "Unknown option $1" ;; 37 | *) 38 | show_error_and_exit "Unexpected argument '$1'" ;; 39 | esac 40 | shift 41 | done 42 | 43 | # Get the default git branch name 44 | if ! BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'); then 45 | echo "Failed to get default git branch" 46 | exit 1 47 | fi 48 | 49 | # Output the branch name 50 | echo $BRANCH 51 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/View+Label.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Label.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-12-19. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension View { 12 | 13 | /// Convert the view to a label. 14 | /// 15 | /// - Parameters: 16 | /// - text: The label text. 17 | /// - bundle: An optional bundle. 18 | func label( 19 | _ text: LocalizedStringKey, 20 | bundle: Bundle? = nil 21 | ) -> some View { 22 | Label { 23 | Text(text, bundle: bundle) 24 | } icon: { 25 | self 26 | } 27 | } 28 | 29 | @available(*, deprecated, renamed: "label(_:bundle:)") 30 | func localizedLabel( 31 | _ text: LocalizedStringKey, 32 | bundle: Bundle? = nil 33 | ) -> some View { 34 | Label { 35 | Text(text, bundle: bundle) 36 | } icon: { 37 | self 38 | } 39 | } 40 | } 41 | 42 | #Preview { 43 | 44 | VStack { 45 | Color.red 46 | .label("Preview.Label", bundle: .module) 47 | Image.symbol("checkmark") 48 | .label("Preview.Label", bundle: .module) 49 | } 50 | .frame(width: 200, height: 100) 51 | } 52 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/String/String+ContainsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+ContainsTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2016-12-13. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class String_ContainsTests: XCTestCase { 13 | 14 | func testCaseSensitiveCheckFindsExistingString() { 15 | let result = "foo".contains("foo", caseSensitive: true) 16 | XCTAssertTrue(result) 17 | } 18 | 19 | func testCaseSensitiveCheckDoesNotFindNonExistingString() { 20 | let result = "foo".contains("foO", caseSensitive: true) 21 | XCTAssertFalse(result) 22 | } 23 | 24 | 25 | func textCaseInsensitiveCheckFindsExistingStringCaseSensitively() { 26 | let result = "foo".contains("foo", caseSensitive: false) 27 | XCTAssertTrue(result) 28 | } 29 | 30 | func textCaseInsensitiveCheckFindsExistingStringCaseInsensitively() { 31 | let result = "foo".contains("foO", caseSensitive: false) 32 | XCTAssertTrue(result) 33 | } 34 | 35 | func textCaseInsensitiveCheckDoesNotFindNonExistingString() { 36 | let result = "foo".contains("foot", caseSensitive: false) 37 | XCTAssertFalse(result) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Colors/Color+Random.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+Random.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-08-17. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This makes `Color` able to create random colors. 12 | public extension Color { 13 | 14 | /// Generate a random color. 15 | /// 16 | /// - Parameters: 17 | /// - range: The random color range, by default `0...1`. 18 | /// - randomOpacity: Whether to randomize opacity, by default `false`. 19 | static func random( 20 | in range: ClosedRange = 0...1, 21 | randomOpacity: Bool = false 22 | ) -> Color { 23 | Color( 24 | red: .random(in: range), 25 | green: .random(in: range), 26 | blue: .random(in: range), 27 | opacity: randomOpacity ? .random(in: 0...1) : 1 28 | ) 29 | } 30 | } 31 | 32 | 33 | #Preview { 34 | 35 | func preview(for color: Color) -> some View { 36 | color.cornerRadius(10) 37 | } 38 | 39 | return VStack { 40 | preview(for: .random()) 41 | preview(for: .random(randomOpacity: true)) 42 | preview(for: .random(in: 0...0.3)) 43 | preview(for: .random(in: 0...0.3, randomOpacity: true)) 44 | }.padding() 45 | } 46 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/String/String+Base64Tests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Base64Tests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2016-12-12. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class String_Base64Tests: XCTestCase { 13 | 14 | func testCanEncodeEmptyString() { 15 | let string = "" 16 | let result = string.base64Encoded() 17 | XCTAssertEqual(result, "") 18 | } 19 | 20 | func canEncodeAndDecodeRegularString() { 21 | let string = "foo bar" 22 | let result = string.base64Encoded()?.base64Decoded() 23 | XCTAssertEqual(result, string) 24 | } 25 | 26 | func canEncodeAndDecodesSwedishChars() { 27 | let string = "ÅÄÖ åäö" 28 | let result = string.base64Encoded()?.base64Decoded() 29 | XCTAssertEqual(result, string) 30 | } 31 | 32 | func canEncodeAndDecodeUnicode() { 33 | let string = "saaaandii ♡" 34 | let result = string.base64Encoded()?.base64Decoded() 35 | XCTAssertEqual(result, string) 36 | } 37 | 38 | func canEncodesAndDecodesEmojis() { 39 | let string = "😁😂😃" 40 | let result = string.base64Encoded()?.base64Decoded() 41 | XCTAssertEqual(result, string) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Images/UIImage+Photos.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+Photos.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2018-02-01. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import UIKit 11 | 12 | public extension UIImage { 13 | 14 | /// Save the image to the user's photo album. 15 | /// 16 | /// This requires the correct permissions in `Info.plist`. Failing to add 17 | /// these permissions before calling this function will crash the app. 18 | func saveToPhotos(completion: @escaping (Error?) -> Void) { 19 | ImageService().saveImageToPhotos(self, completion: completion) 20 | } 21 | } 22 | 23 | private class ImageService: NSObject { 24 | 25 | public typealias Completion = (Error?) -> Void 26 | 27 | private var completions = [Completion]() 28 | 29 | public func saveImageToPhotos(_ image: UIImage, completion: @escaping (Error?) -> Void) { 30 | completions.append(completion) 31 | UIImageWriteToSavedPhotosAlbum(image, self, #selector(saveImageToPhotosDidComplete), nil) 32 | } 33 | 34 | @objc func saveImageToPhotosDidComplete(_ image: UIImage, error: NSError?, contextInfo: UnsafeRawPointer) { 35 | guard completions.count > 0 else { return } 36 | completions.removeFirst()(error) 37 | } 38 | } 39 | #endif 40 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Data/CsvParserTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CsvParserTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2018-10-23. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | class CsvParserTests: XCTestCase { 13 | 14 | let parser = CsvParser() 15 | 16 | func testCanParseSemicolonSeparatedString() { 17 | let result = parser.parseCsvString("foo;bar;baz\nenough", componentSeparator: ";") 18 | XCTAssertEqual(result.count, 2) 19 | XCTAssertEqual(result[0], ["foo", "bar", "baz"]) 20 | XCTAssertEqual(result[1], ["enough"]) 21 | } 22 | 23 | func testCanParseCommaSeparatedString() { 24 | let result = parser.parseCsvString("a,b,c", componentSeparator: ",") 25 | XCTAssertEqual(result.count, 1) 26 | XCTAssertEqual(result[0], ["a", "b", "c"]) 27 | } 28 | 29 | func testTrimsComponents() { 30 | let result = parser.parseCsvString(" a , b , c ", componentSeparator: ",") 31 | XCTAssertEqual(result.count, 1) 32 | XCTAssertEqual(result[0], ["a", "b", "c"]) 33 | } 34 | 35 | func testIncludesEmptyComponents() { 36 | let result = parser.parseCsvString(" a , , c ", componentSeparator: ",") 37 | XCTAssertEqual(result.count, 1) 38 | XCTAssertEqual(result[0], ["a", "", "c"]) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/View+Prefers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Prefers.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-08-01. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension View { 12 | 13 | /// Applies `.menuOrder(.fixed)`. 14 | /// 15 | /// This modifier is only applied in iOS 16.0 and later. 16 | @ViewBuilder 17 | func prefersMenuOrderFixed() -> some View { 18 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 19 | self.menuOrder(.fixed) 20 | } else { 21 | self 22 | } 23 | } 24 | 25 | /// Applies `.persistentSystemOverlays(.hidden)`. 26 | /// 27 | /// This modifier is only applied in iOS 16.0 and later. 28 | @ViewBuilder 29 | func prefersPersistentSystemOverlaysHidden() -> some View { 30 | if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { 31 | self.persistentSystemOverlays(.hidden) 32 | } else { 33 | self 34 | } 35 | } 36 | } 37 | 38 | #Preview { 39 | 40 | VStack { 41 | #if os(iOS) || os(macOS) 42 | Menu("Preview.Menu") { 43 | Button("Preview.Button.\(1)") {} 44 | Button("Preview.Button.\(2)") {} 45 | } 46 | .prefersMenuOrderFixed() 47 | #endif 48 | } 49 | .prefersPersistentSystemOverlaysHidden() 50 | } 51 | -------------------------------------------------------------------------------- /RELEASE_NOTES.md: -------------------------------------------------------------------------------- 1 | # Release notes 2 | 3 | [SwiftUIKit](https://github.com/danielsaidi/SwiftUIKit) honors semantic versioning, with the following strategy: 4 | 5 | * Deprecations can happen at any time. 6 | * Deprecations are removed in `major` updates. 7 | * Breaking changes should only occur in `major` updates. 8 | * Breaking changes *can* occur in `minor` and `patch` updates, if the alternative is worse. 9 | 10 | These release notes cover the current major version. See older versions for older release notes. 11 | 12 | 13 | 14 | ## 6.1 15 | 16 | This version bumps the package to Swift 6.1 and deprecates some list views. 17 | 18 | 19 | 20 | ## 6.0 21 | 22 | This version removes all deprecations, which means that many parts of the library are no longer available. 23 | 24 | Most components have been extracted into separate packages. See [this page](https://danielsaidi.com/opensource) for my various open-source libraries. If something you used is missing, you will most probably find it there. 25 | 26 | Many things have just been removed, when it has better, native alternatives. The last `5.9.4` version provides you with proper deprecation warnings to let you adjust. 27 | 28 | Some components that should have been removed still remain, since we need more time. They will be deprecated in future minor versions,using the same deprecation strategy as in version 5. 29 | 30 | The future of this library will be to extend native types. This will be fully realized in version 7.0. 31 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Files/URL+iCloud.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+iCloud.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-04-17. 6 | // Copyright 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension URL { 12 | 13 | /// The url to the iCloud ubiquity container. 14 | /// 15 | /// This is where documents and data will be saved and synced with iCloud. 16 | static func ubiquityContainerUrl( 17 | for manager: FileManager = .default, 18 | containerId: String? = nil 19 | ) -> URL? { 20 | manager.url(forUbiquityContainerIdentifier: nil) 21 | } 22 | 23 | /// The url to the iCloud ubiquity container Documents root. 24 | /// 25 | /// This is where documents and data will be saved and synced with iCloud. 26 | static func ubiquityContainerDocumentsUrl( 27 | for manager: FileManager = .default, 28 | containerId: String? = nil 29 | ) -> URL? { 30 | ubiquityContainerUrl(for: manager, containerId: containerId)? 31 | .appendingPathComponent("Documents") 32 | } 33 | 34 | /// The url to a local document fallback directory that can be used when the 35 | /// `ubiquityContainer` urls are nil. 36 | static func ubiquityContainerDocumentsLocalFallbackUrl( 37 | for manager: FileManager = .default 38 | ) -> URL? { 39 | manager.urls(for: .documentDirectory, in: .userDomainMask).first 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/docc.yml: -------------------------------------------------------------------------------- 1 | # This workflow builds and publishes DocC docs to GitHub Pages. 2 | 3 | # Source: https://maxxfrazer.medium.com/deploying-docc-with-github-actions-218c5ca6cad5 4 | # Sample: https://github.com/AgoraIO-Community/VideoUIKit-iOS/blob/main/.github/workflows/deploy_docs.yml 5 | 6 | name: DocC Runner 7 | 8 | on: 9 | push: 10 | branches: ["main"] 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow one concurrent deployment 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: true 22 | 23 | jobs: 24 | deploy: 25 | runs-on: macos-latest # macos-15 26 | 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | 31 | steps: 32 | - name: Checkout Code 33 | uses: actions/checkout@v5 34 | 35 | - name: Configure Pages 36 | uses: actions/configure-pages@v4 37 | 38 | - name: Setup Xcode 39 | uses: maxim-lobanov/setup-xcode@v1 40 | with: 41 | xcode-version: latest-stable # 16.4 42 | 43 | - name: Build DocC 44 | run: bash scripts/docc.sh 45 | 46 | - name: Upload DocC Artifact 47 | uses: actions/upload-pages-artifact@v3 48 | with: 49 | path: '.build/docs-iOS' 50 | 51 | - name: Deploy to GitHub Pages 52 | uses: actions/deploy-pages@v4 53 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Previews/SwiftUIPreviewInspector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIPreviewInspector.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-11-01. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This protocol can be used to check if the code is running in a SwiftUI preview. 12 | /// 13 | /// The protocol is implemented by `ProcessInfo`. 14 | public protocol SwiftUIPreviewInspector { 15 | 16 | /// Whether or not the code runs in a SwiftUI preview. 17 | var isSwiftUIPreview: Bool { get } 18 | } 19 | 20 | public extension SwiftUIPreviewInspector { 21 | 22 | /// Whether or not the code runs in a SwiftUI preview. 23 | var isSwiftUIPreview: Bool { 24 | ProcessInfo.isSwiftUIPreview 25 | } 26 | } 27 | 28 | extension ProcessInfo: SwiftUIPreviewInspector {} 29 | 30 | public extension ProcessInfo { 31 | 32 | /// Whether or not the code runs in a SwiftUI preview. 33 | var isSwiftUIPreview: Bool { 34 | environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" 35 | } 36 | 37 | /// Whether or not the code runs in a SwiftUI preview. 38 | static var isSwiftUIPreview: Bool { 39 | processInfo.isSwiftUIPreview 40 | } 41 | } 42 | 43 | #Preview { 44 | 45 | VStack { 46 | Text("Preview.IsSwiftUIPreview", bundle: .module) 47 | .font(.title) 48 | Text("\(ProcessInfo.processInfo.isSwiftUIPreview ? "Preview.Yes" : "Preview.No")", bundle: .module) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/String/String+Replace.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Replace.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2016-01-08. 6 | // Copyright © 2016-2025 Daniel Saidi. All rights reserved. 7 | // 8 | // Read more here: 9 | // https://danielsaidi.com/blog/2020/06/04/string-replace 10 | // 11 | 12 | import Foundation 13 | 14 | public extension String { 15 | 16 | @available(*, deprecated, message: "Use the new options-based version instead.") 17 | func replacing( 18 | _ string: String, 19 | with: String, 20 | caseSensitive: Bool 21 | ) -> String { 22 | caseSensitive 23 | ? replacingOccurrences(of: string, with: with) 24 | : replacingOccurrences(of: string, with: with, options: .caseInsensitive) 25 | } 26 | 27 | /// Replace a certain string with another one. 28 | func replacing( 29 | _ string: String, 30 | with other: String, 31 | _ options: NSString.CompareOptions? = nil 32 | ) -> String { 33 | if let options { 34 | replacingOccurrences(of: string, with: other, options: options) 35 | } else { 36 | replacingOccurrences(of: string, with: other) 37 | } 38 | } 39 | 40 | /// Replace a certain string with another one. 41 | mutating func replace( 42 | _ string: String, 43 | with other: String, 44 | _ options: NSString.CompareOptions? = nil 45 | ) { 46 | self = self.replacing(string, with: other, options) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/_Deprecated/ListButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListButtonStyle.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-10-03. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | @available(*, deprecated, message: "Use .tint(.primary) instead") 12 | public struct ListButtonStyle: ButtonStyle { 13 | 14 | /// Create a custom style. 15 | /// 16 | /// - Parameters: 17 | /// - pressedOpacity: The opacity to apply when the button is pressed, by default `0.5`. 18 | public init( 19 | pressedOpacity: Double = 0.5 20 | ) { 21 | self.pressedOpacity = pressedOpacity 22 | } 23 | 24 | /// The opacity to apply when the button is pressed. 25 | var pressedOpacity: Double 26 | 27 | public func makeBody(configuration: Configuration) -> some View { 28 | configuration.label 29 | .frame(maxWidth: .infinity, alignment: .leading) 30 | .contentShape(Rectangle()) 31 | .opacity(configuration.isPressed ? pressedOpacity : 1) 32 | } 33 | } 34 | 35 | @available(*, deprecated, message: "Use .tint(.primary) instead") 36 | public extension ButtonStyle where Self == ListButtonStyle { 37 | 38 | /// The standard list card button style. 39 | static var list: ListButtonStyle { .init() } 40 | 41 | /// A custom list card button style. 42 | static func list( 43 | pressedOpacity: Double 44 | ) -> Self { 45 | .init(pressedOpacity: pressedOpacity) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Images/ImageRepresentable+Data.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageRepresentable+Data.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-06-27. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension ImageRepresentable { 12 | 13 | /// Get resized and compressed JPG data from the image. 14 | /// 15 | /// - Parameters: 16 | /// - width: The width of the new image, by default `1000`. 17 | /// - quality: The compression quality, by default `0.8`. 18 | func jpegData( 19 | resizedToWidth width: CGFloat = 1000, 20 | withCompressionQuality quality: CGFloat = 0.8 21 | ) -> Data? { 22 | let resized = self.resized(toWidth: width) 23 | let image = resized ?? self 24 | return image.jpegData(compressionQuality: quality) 25 | } 26 | } 27 | 28 | #if os(macOS) 29 | import AppKit 30 | import Cocoa 31 | import CoreGraphics 32 | 33 | public extension ImageRepresentable { 34 | 35 | /// Get the image's core graphics image representation. 36 | var cgImage: CGImage? { 37 | cgImage(forProposedRect: nil, context: nil, hints: nil) 38 | } 39 | 40 | /// Get the image's JPEG data representation. 41 | func jpegData(compressionQuality: CGFloat) -> Data? { 42 | guard let image = cgImage else { return nil } 43 | let bitmap = NSBitmapImageRep(cgImage: image) 44 | return bitmap.representation(using: .jpeg, properties: [.compressionFactor: compressionQuality]) 45 | } 46 | } 47 | #endif 48 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Files/FileManager+UniqueFileName.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManager+UniqueFileName.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-01-18. 6 | // Copyright © 2022 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension FileManager { 12 | 13 | /// Get a unique url for any `url` to ensure that no pre-existing item exists. 14 | /// 15 | /// The function will increment a counter as long as a file or directory exists, 16 | /// and add it as a file suffix, using the `suffixSeparator` as separator. 17 | /// 18 | /// For instance, if you try to to save a file at a URL where another file exists, 19 | /// this will first add `-1` as file name suffix to check if another file with that 20 | /// name exists. It continues with `-2`, `-3` etc. until no file is found. 21 | func getUniqueDestinationUrl( 22 | forSuggested url: URL, 23 | separator: String = "-" 24 | ) -> URL { 25 | if !fileExists(atPath: url.path) { return url } 26 | let fileExtension = url.pathExtension 27 | let noExtension = url.deletingPathExtension() 28 | let fileName = noExtension.lastPathComponent 29 | var counter = 1 30 | repeat { 31 | let newUrl = noExtension 32 | .deletingLastPathComponent() 33 | .appendingPathComponent(fileName.appending("\(separator)\(counter)")) 34 | .appendingPathExtension(fileExtension) 35 | if !fileExists(atPath: newUrl.path) { return newUrl } 36 | counter += 1 37 | } while true 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Images/UIImage+Rotated.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UIImage+Rotated.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-08-17. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if canImport(UIKit) 10 | import UIKit 11 | 12 | public extension UIImage { 13 | 14 | /// Rotate an image a certain amount of radians. 15 | func rotated(withRadians radians: Float) -> UIImage? { 16 | let radians = CGFloat(radians) 17 | let transform = CGAffineTransform(rotationAngle: radians) 18 | var newSize = CGRect(origin: .zero, size: size) 19 | .applying(transform) 20 | .size 21 | 22 | // Trim off small float fractions to prevent rounding 23 | newSize.width = floor(newSize.width) 24 | newSize.height = floor(newSize.height) 25 | 26 | UIGraphicsBeginImageContextWithOptions(newSize, false, scale) 27 | let context = UIGraphicsGetCurrentContext()! 28 | 29 | // Move origin to middle 30 | context.translateBy(x: newSize.width/2, y: newSize.height/2) 31 | 32 | // Rotate around middle 33 | context.rotate(by: CGFloat(radians)) 34 | 35 | // Draw the image at its center 36 | let rect = CGRect( 37 | x: -size.width/2, 38 | y: -size.height/2, 39 | width: size.width, 40 | height: size.height) 41 | 42 | // Draw new image 43 | draw(in: rect) 44 | let newImage = UIGraphicsGetImageFromCurrentImageContext() 45 | UIGraphicsEndImageContext() 46 | return newImage 47 | } 48 | } 49 | #endif 50 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Date/Date+Compare.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+Compare.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2015-05-15. 6 | // Copyright © 2015-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This extension provides more readable date comparisons. 12 | public extension Date { 13 | 14 | /// Whether the date occurs after another date. 15 | func isAfter(_ date: Date) -> Bool { 16 | self > date 17 | } 18 | 19 | /// Whether the date occurs before another date. 20 | func isBefore(_ date: Date) -> Bool { 21 | self < date 22 | } 23 | 24 | /// Whether the date is the same as another date, for a certain granularity. 25 | func isCurrent( 26 | _ granularity: Calendar.Component, 27 | for calendar: Calendar = .current 28 | ) -> Bool { 29 | isSame(granularity, as: Date(), for: calendar) 30 | } 31 | 32 | @available(*, deprecated, renamed: "isCurrent") 33 | func isThis( 34 | _ granularity: Calendar.Component, 35 | for calendar: Calendar = .current 36 | ) -> Bool { 37 | isCurrent(granularity, for: calendar) 38 | } 39 | 40 | /// Whether the date is the same as another date. 41 | func isSame(as date: Date) -> Bool { 42 | self == date 43 | } 44 | 45 | /// Whether the date is the same as another date, for a certain granularity. 46 | func isSame( 47 | _ granularity: Calendar.Component, 48 | as date: Date, 49 | for calendar: Calendar = .current 50 | ) -> Bool { 51 | calendar.isDate(self, equalTo: date, toGranularity: granularity) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Collections/Collection+Async.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+Async.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-11-10. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Collection { 12 | 13 | /// Compact map a collection using an async transform. 14 | func asyncCompactMap( 15 | _ transform: (Element) async -> ResultType? 16 | ) async -> [ResultType] { 17 | await self 18 | .asyncMap(transform) 19 | .compactMap { $0 } 20 | } 21 | 22 | /// Compact map a collection using an async transform. 23 | func asyncCompactMap( 24 | _ transform: (Element) async throws -> ResultType? 25 | ) async throws -> [ResultType] { 26 | try await self 27 | .asyncMap(transform) 28 | .compactMap { $0 } 29 | } 30 | 31 | /// Map a collection using an async transform. 32 | func asyncMap( 33 | _ transform: (Element) async -> ResultType 34 | ) async -> [ResultType] { 35 | var result = [ResultType]() 36 | for item in self { 37 | await result.append(transform(item)) 38 | } 39 | return result 40 | } 41 | 42 | /// Map a collection using an async transform. 43 | func asyncMap( 44 | _ transform: (Element) async throws -> ResultType 45 | ) async throws -> [ResultType] { 46 | var result = [ResultType]() 47 | for item in self { 48 | try await result.append(transform(item)) 49 | } 50 | return result 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Collections/Sequence+BatchedTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Sequence+BatchTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2017-05-10. 6 | // Copyright © 2017-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class Sequence_BatchTests: XCTestCase { 13 | 14 | func testBatchingArrayCreatesSingleBatchIfBatchSizeExceedsArraySize() { 15 | let array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 16 | let batch = array.batched(withBatchSize: 20) 17 | 18 | XCTAssertEqual(batch.count, 1) 19 | XCTAssertEqual(batch.first!, array) 20 | } 21 | 22 | func testBatchingArrayCreatesManyBatchesIfArraySizeExceedsBatchSize() { 23 | let array = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] 24 | let batch = array.batched(withBatchSize: 3) 25 | 26 | XCTAssertEqual(batch.count, 4) 27 | XCTAssertEqual(batch[0], [1, 2, 3]) 28 | XCTAssertEqual(batch[1], [4, 5, 6]) 29 | XCTAssertEqual(batch[2], [7, 8, 9]) 30 | XCTAssertEqual(batch[3], [10]) 31 | } 32 | 33 | func testBatchingArrayPreservesIdentability() { 34 | let item1 = TestItem(name: "1") 35 | let item2 = TestItem(name: "2") 36 | let item3 = TestItem(name: "3") 37 | let item4 = TestItem(name: "4") 38 | 39 | let array = [item1, item2, item3, item4] 40 | let batch = array.batched(withBatchSize: 2) 41 | 42 | XCTAssertEqual(batch.count, 2) 43 | XCTAssertEqual(batch.last!, [item3, item4]) 44 | } 45 | } 46 | 47 | private struct TestItem: Equatable { 48 | 49 | let name: String 50 | } 51 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Grid/GridItem+Convenience.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GridItem.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-08-30. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension GridItem { 12 | 13 | /// Several items in the space of a single flexible item. 14 | static func adaptive(minimum: CGFloat, maximum: CGFloat) -> Self { 15 | .init(.adaptive(minimum: minimum, maximum: maximum)) 16 | } 17 | 18 | /// A single item with the specified fixed size. 19 | static func fixed(_ size: CGFloat) -> Self { 20 | .init(.fixed(size)) 21 | } 22 | 23 | /// A single flexible item. 24 | static func flexible(minimum: CGFloat, maximum: CGFloat) -> Self { 25 | .init(.flexible(minimum: minimum, maximum: maximum)) 26 | } 27 | } 28 | 29 | public extension Collection where Element == GridItem { 30 | 31 | /// Several items in the space of a single flexible item. 32 | static func adaptive(minimum: CGFloat, maximum: CGFloat) -> [Element] { 33 | [.adaptive(minimum: minimum, maximum: maximum)] 34 | } 35 | 36 | /// A single item with the specified fixed size. 37 | static func fixed(_ size: CGFloat) -> [Element] { 38 | [.fixed(size)] 39 | } 40 | 41 | /// Multiple items with the specified fixed sizes. 42 | static func fixed(_ sizes: [CGFloat]) -> [Element] { 43 | sizes.map { .fixed($0) } 44 | } 45 | 46 | /// A single flexible item. 47 | static func flexible(minimum: CGFloat, maximum: CGFloat) -> [Element] { 48 | [.flexible(minimum: minimum, maximum: maximum)] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /scripts/package-name.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script finds the main package name." 10 | 11 | echo 12 | echo "Usage: $0 [OPTIONS]" 13 | echo " -h, --help Show this help message" 14 | 15 | echo 16 | echo "Examples:" 17 | echo " $0" 18 | echo 19 | } 20 | 21 | # Function to display error message, show usage, and exit 22 | show_error_and_exit() { 23 | echo 24 | local error_message="$1" 25 | echo "Error: $error_message" 26 | show_usage 27 | exit 1 28 | } 29 | 30 | # Parse command line arguments 31 | while [[ $# -gt 0 ]]; do 32 | case $1 in 33 | -h|--help) 34 | show_usage; exit 0 ;; 35 | -*) 36 | show_error_and_exit "Unknown option $1" ;; 37 | *) 38 | show_error_and_exit "Unexpected argument '$1'" ;; 39 | esac 40 | shift 41 | done 42 | 43 | # Check that a Package.swift file exists 44 | if [ ! -f "Package.swift" ]; then 45 | show_error_and_exit "Package.swift not found in current directory" 46 | fi 47 | 48 | # Using grep and sed to extract the package name 49 | # 1. grep finds the line containing "name:" 50 | # 2. sed extracts the text between quotes 51 | if ! package_name=$(grep -m 1 'name:.*"' Package.swift | sed -n 's/.*name:[[:space:]]*"\([^"]*\)".*/\1/p'); then 52 | show_error_and_exit "Could not find package name in Package.swift" 53 | fi 54 | 55 | if [ -z "$package_name" ]; then 56 | show_error_and_exit "Could not find package name in Package.swift" 57 | fi 58 | 59 | # Output the package name 60 | echo "$package_name" 61 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Lists/ListDragHandle.swift: -------------------------------------------------------------------------------- 1 | #if os(iOS) 2 | // 3 | // ListDragHandle.swift 4 | // SwiftUIKit 5 | // 6 | // Created by Daniel Saidi on 2023-05-18. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This view can be used to display a drag handle for items in reorderable `List`. 12 | /// 13 | /// The handle is only displayed when a list is not edited, since enabling edit mode 14 | /// will render the native drag handle. 15 | public struct ListDragHandle: View { 16 | 17 | public init() {} 18 | 19 | @Environment(\.editMode) 20 | private var editMode 21 | 22 | public var body: some View { 23 | if shouldShowHandle { 24 | Image(systemName: "line.3.horizontal") 25 | .font(Font.title2.weight(.light)) 26 | .foregroundColor(.secondary) 27 | .opacity(0.5) 28 | } 29 | } 30 | } 31 | 32 | private extension ListDragHandle { 33 | 34 | var isEditing: Bool { 35 | editMode?.wrappedValue.isEditing ?? false 36 | } 37 | 38 | var shouldShowHandle: Bool { 39 | !isEditing 40 | } 41 | } 42 | 43 | #Preview { 44 | 45 | NavigationView { 46 | List { 47 | ForEach(1...10, id: \.self) { item in 48 | HStack { 49 | Label { 50 | Text("Preview.Item.\(item)", bundle: .module) 51 | } icon: { 52 | Color.red 53 | } 54 | Spacer() 55 | ListDragHandle() 56 | } 57 | } 58 | .onMove { _, _ in } 59 | .onDelete { _ in } 60 | } 61 | .toolbar { 62 | EditButton() 63 | } 64 | } 65 | } 66 | #endif 67 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Date/Date+AddRemove.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+Adding.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2015-05-15. 6 | // Copyright © 2015-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Date { 12 | 13 | /// Add a certain number days days to the date. 14 | func adding(days: Double) -> Date { 15 | let seconds = Double(days) * 60 * 60 * 24 16 | return addingTimeInterval(seconds) 17 | } 18 | 19 | /// Add a certain number hours days to the date. 20 | func adding(hours: Double) -> Date { 21 | let seconds = Double(hours) * 60 * 60 22 | return addingTimeInterval(seconds) 23 | } 24 | 25 | /// Add a certain number minutes days to the date. 26 | func adding(minutes: Double) -> Date { 27 | let seconds = Double(minutes) * 60 28 | return addingTimeInterval(seconds) 29 | } 30 | 31 | /// Add a certain number seconds days to the date. 32 | func adding(seconds: Double) -> Date { 33 | addingTimeInterval(Double(seconds)) 34 | } 35 | 36 | /// Remove a certain number of days to the date. 37 | func removing(days: Double) -> Date { 38 | adding(days: -days) 39 | } 40 | 41 | /// Remove a certain number of hours to the date. 42 | func removing(hours: Double) -> Date { 43 | adding(hours: -hours) 44 | } 45 | 46 | /// Remove a certain number of minutes to the date. 47 | func removing(minutes: Double) -> Date { 48 | adding(minutes: -minutes) 49 | } 50 | 51 | /// Remove a certain number of seconds to the date. 52 | func removing(seconds: Double) -> Date { 53 | adding(seconds: -seconds) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/String/String+Paragraph.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Paragraph.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-11-29. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension String { 12 | 13 | /// Find the index of the first new line paragraph before the provided location. 14 | /// 15 | /// A new paragraph is considered to start the character after a newline char, 16 | /// not the newline itself. 17 | func findIndexOfCurrentParagraph(from location: UInt) -> UInt { 18 | if isEmpty { return 0 } 19 | let count = UInt(count) 20 | var index = min(location, count-1) 21 | repeat { 22 | guard index > 0, index < count else { break } 23 | guard let char = character(at: index - 1) else { break } 24 | if char == .newLine || char == .carriageReturn { break } 25 | index -= 1 26 | } while true 27 | return max(index, 0) 28 | } 29 | 30 | /// Find the index of the first new line paragraph after the provided location. 31 | /// 32 | /// A new paragraph is considered to start the character after a newline char, 33 | /// not the newline itself. 34 | func findIndexOfNextParagraph(from location: UInt) -> UInt { 35 | var index = location 36 | repeat { 37 | guard let char = character(at: index) else { break } 38 | index += 1 39 | guard index < count else { break } 40 | if char == .newLine || char == .carriageReturn { break } 41 | } while true 42 | let found = index < count 43 | return found ? index : findIndexOfCurrentParagraph(from: location) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Files/BundleFileFinder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeychainWrapper.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2016-11-24. 6 | // Copyright © 2016-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This class can find files witin a certain bundle. 12 | open class BundleFileFinder { 13 | 14 | public init( 15 | bundle: Bundle = .main 16 | ) { 17 | self.bundle = bundle 18 | } 19 | 20 | public let bundle: Bundle 21 | 22 | /// Find files names that start with a certain prefix. 23 | open func findFilesWithFileNamePrefix(_ prefix: String) -> [String] { 24 | let format = "self BEGINSWITH %@" 25 | let predicate = NSPredicate(format: format, argumentArray: [prefix]) 26 | return findFilesWithPredicate(predicate) 27 | } 28 | 29 | /// Find files names that end with a certain suffix. 30 | open func findFilesWithFileNameSuffix(_ suffix: String) -> [String] { 31 | let format = "self ENDSWITH %@" 32 | let predicate = NSPredicate(format: format, argumentArray: [suffix]) 33 | return findFilesWithPredicate(predicate) 34 | } 35 | } 36 | 37 | private extension BundleFileFinder { 38 | 39 | func findFilesWithPredicate(_ predicate: NSPredicate) -> [String] { 40 | do { 41 | let path = bundle.bundlePath 42 | let fileManager = FileManager.default 43 | let files = try fileManager.contentsOfDirectory(atPath: path) 44 | let array = files as NSArray 45 | let filteredFiles = array.filtered(using: predicate) 46 | return filteredFiles as? [String] ?? [] 47 | } catch { 48 | return [String]() 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Device/DeviceIdentifierTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeviceIdentifierTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2016-11-24. 6 | // Copyright © 2016-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUIKit 11 | import XCTest 12 | 13 | final class KeychainBasedDeviceIdentifierTests: XCTestCase { 14 | 15 | var defaults: UserDefaults! 16 | var keychainService: KeychainService! 17 | var identifier: DeviceIdentifier! 18 | 19 | let key = "com.swiftuikit.deviceidentifier" 20 | 21 | override func setUp() { 22 | defaults = UserDefaults.standard 23 | keychainService = KeychainService.shared 24 | identifier = DeviceIdentifier( 25 | keychainService: keychainService, 26 | keychainAccessibility: .none, 27 | store: defaults 28 | ) 29 | identifier.resetDeviceIdentifier() 30 | } 31 | 32 | func testServiceGeneratesIdAndStoresInAllStores() { 33 | XCTAssertNil(defaults.string(forKey: key)) 34 | XCTAssertNil(keychainService.string(for: key, with: .none)) 35 | let id = identifier.getDeviceIdentifier() 36 | XCTAssertEqual(defaults.string(forKey: key), id) 37 | // XCTAssertEqual(keychainService.string(for: key, with: .none), id) 38 | } 39 | 40 | func testServiceCanWriteCustomKeyToAllStores() { 41 | XCTAssertNil(defaults.string(forKey: key)) 42 | XCTAssertNil(keychainService.string(for: key, with: .none)) 43 | let id = "abc123" 44 | identifier.setDeviceIdentifier(id) 45 | XCTAssertEqual(defaults.string(forKey: key), id) 46 | // XCTAssertEqual(keychainService.string(for: key, with: .none), id) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /scripts/tools/StringCatalogKeyBuilder/README.md: -------------------------------------------------------------------------------- 1 | # String Catalog Public Key Builder 2 | 3 | This command-line tool can generate public key wrappers for 4 | a string catalog's internal auto-generated keys. 5 | 6 | 7 | ## Why is this needed? 8 | 9 | When you manually add localized strings to a string catalog, 10 | Xcode will automatically generate keys that you can use to 11 | translate these keys. These keys are however internal, and 12 | can not be accessed from other targets. 13 | 14 | This script will auto-generate public key wrappers for all 15 | internal keys. These public keys can be used from any other 16 | target, and will use the `.module` bundle to ensure that a 17 | string is properly localised from anywhere. 18 | 19 | This script will also parse string namespaces into a nested 20 | string hierarchy, to allow you to group strings together. 21 | 22 | 23 | ## Usage 24 | 25 | Run `swift run l10n-gen --help` for help and usage examples. 26 | 27 | This command can parse a `from` catalog and write keys to a 28 | `to` target file path, or parse any `package` module string 29 | catalog at the package-relative `catalogPath` and write it 30 | to a package-relative `targetPath`. 31 | 32 | 33 | ## Terminal command 34 | 35 | The `/scripts/l10n-gen.script` can be used as a convenience. 36 | 37 | 38 | ## Namespaces 39 | 40 | You can add dots to a key to apply namespaces. For instance, 41 | `Experiments.DebugScreen.Title` would generate: 42 | 43 | ``` 44 | .l10n.experiments.debugScreen.title 45 | ``` 46 | 47 | You can customize the `l10n` root namespace name, to wrap a 48 | key collection in a specific root namespace. This is needed 49 | if you parse many different string catalogs, to avoid that 50 | the generated keys collide. 51 | 52 | Using namespaces also reduces the risk of merge conflicts. 53 | -------------------------------------------------------------------------------- /scripts/version-number.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script returns the latest project version." 10 | 11 | echo 12 | echo "Usage: $0 [OPTIONS]" 13 | echo " -h, --help Show this help message" 14 | 15 | echo 16 | echo "Examples:" 17 | echo " $0" 18 | echo 19 | } 20 | 21 | # Function to display error message, show usage, and exit 22 | show_error_and_exit() { 23 | echo 24 | local error_message="$1" 25 | echo "Error: $error_message" 26 | show_usage 27 | exit 1 28 | } 29 | 30 | # Parse command line arguments 31 | while [[ $# -gt 0 ]]; do 32 | case $1 in 33 | -h|--help) 34 | show_usage; exit 0 ;; 35 | -*) 36 | show_error_and_exit "Unknown option $1" ;; 37 | *) 38 | show_error_and_exit "Unexpected argument '$1'" ;; 39 | esac 40 | shift 41 | done 42 | 43 | # Check if the current directory is a Git repository 44 | if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then 45 | show_error_and_exit "Not a Git repository" 46 | fi 47 | 48 | # Fetch all tags 49 | if ! git fetch --tags > /dev/null 2>&1; then 50 | show_error_and_exit "Failed to fetch tags from remote" 51 | fi 52 | 53 | # Get the latest semver tag 54 | if ! latest_version=$(git tag -l --sort=-v:refname | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1); then 55 | show_error_and_exit "Failed to retrieve version tags" 56 | fi 57 | 58 | # Check if we found a version tag 59 | if [ -z "$latest_version" ]; then 60 | show_error_and_exit "No semver tags found in this repository" 61 | fi 62 | 63 | # Print the latest version 64 | echo "$latest_version" 65 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Lists/ListCardStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListCardStyle.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-04-26. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This style can be used to style a ``ListCard``. 12 | public struct ListCardStyle { 13 | 14 | /// Create a list card style 15 | /// 16 | /// - Parameters: 17 | /// - cornerRadius: The corner radius to apply, by default `8.0`. 18 | /// - shadowStyle: The shadowStyle to apply, by default ``ViewShadowStyle/listCard``. 19 | public init( 20 | cornerRadius: Double = 8.0, 21 | shadowStyle: ViewShadowStyle = .listCard 22 | ) { 23 | self.cornerRadius = cornerRadius 24 | self.shadowStyle = shadowStyle 25 | } 26 | 27 | /// The corner radius to apply. 28 | public var cornerRadius: Double 29 | 30 | /// The shadow style to apply. 31 | public var shadowStyle: ViewShadowStyle 32 | } 33 | 34 | public extension ListCardStyle { 35 | 36 | /// The standard list card style. 37 | static var standard: Self { .init() } 38 | } 39 | 40 | public extension View { 41 | 42 | /// Apply a ``ListCardStyle`` style to the view. 43 | func listCardStyle( 44 | _ style: ListCardStyle 45 | ) -> some View { 46 | self.environment(\.listCardStyle, style) 47 | } 48 | } 49 | 50 | private extension ListCardStyle { 51 | 52 | struct Key: EnvironmentKey { 53 | 54 | public static var defaultValue: ListCardStyle { 55 | .standard 56 | } 57 | } 58 | } 59 | 60 | public extension EnvironmentValues { 61 | 62 | var listCardStyle: ListCardStyle { 63 | get { self [ListCardStyle.Key.self] } 64 | set { self [ListCardStyle.Key.self] = newValue } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Files/DirectoryObservable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DirectoryObservable.swift 3 | // MetaNotes 4 | // 5 | // Created by Daniel Saidi on 2021-04-17. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | // Original implementation: 9 | // https://medium.com/over-engineering/monitoring-a-folder-for-changes-in-ios-dc3f8614f902 10 | // 11 | 12 | import Combine 13 | import SwiftUI 14 | 15 | /// This class can observe file system changes for a folder. 16 | /// 17 | /// The view uses an internal ``DirectoryMonitor`` to keep the ``files`` 18 | /// property in sync. 19 | @MainActor 20 | public class DirectoryObservable: ObservableObject { 21 | 22 | /// Create an instance that observes the provided `url`. 23 | /// 24 | /// - Parameters: 25 | /// - url: The directory URL to observe. 26 | /// - fileManager: The file manager to use, by default `.default`. 27 | public init( 28 | url: URL, 29 | fileManager: FileManager = .default 30 | ) { 31 | self.url = url 32 | self.fileManager = fileManager 33 | folderMonitor.startMonitoringChanges() 34 | self.handleChanges() 35 | } 36 | 37 | @Published public var files: [URL] = [] 38 | 39 | private let url: URL 40 | private let fileManager: FileManager 41 | 42 | private lazy var folderMonitor = DirectoryMonitor( 43 | url: url, 44 | onChange: { 45 | self.handleChanges() 46 | } 47 | ) 48 | 49 | private func handleChanges() { 50 | let files = try? fileManager.contentsOfDirectory( 51 | at: url, 52 | includingPropertiesForKeys: nil, 53 | options: .producesRelativePathURLs) 54 | 55 | DispatchQueue.main.async { 56 | self.files = files ?? [] 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Sharing/ShareSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShareSheet.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-01-29. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | /// This sheet can present a `UIActivityViewController` when sharing. 13 | public struct ShareSheet: UIViewControllerRepresentable { 14 | 15 | public init( 16 | activityItems: [Any], 17 | applicationActivities: [UIActivity]? = nil, 18 | excludedActivityTypes: [UIActivity.ActivityType]? = nil, 19 | callback: Callback? = nil 20 | ) { 21 | self.activityItems = activityItems 22 | self.applicationActivities = applicationActivities 23 | self.excludedActivityTypes = excludedActivityTypes 24 | self.callback = callback 25 | } 26 | 27 | public typealias Callback = ( 28 | _ activityType: UIActivity.ActivityType?, 29 | _ completed: Bool, 30 | _ returnedItems: [Any]?, 31 | _ error: Error?) -> Void 32 | 33 | private let activityItems: [Any] 34 | private let applicationActivities: [UIActivity]? 35 | private let excludedActivityTypes: [UIActivity.ActivityType]? 36 | private let callback: Callback? 37 | 38 | public func makeUIViewController(context: Context) -> UIActivityViewController { 39 | let controller = UIActivityViewController( 40 | activityItems: activityItems, 41 | applicationActivities: applicationActivities) 42 | controller.excludedActivityTypes = excludedActivityTypes 43 | controller.completionWithItemsHandler = callback 44 | return controller 45 | } 46 | 47 | public func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} 48 | } 49 | #endif 50 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Lists/ListCardButtonStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListCardButtonStyle.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-04-26. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This button style can be used to style a ``ListCard``. 12 | public struct ListCardButtonStyle: ButtonStyle { 13 | 14 | /// Create a list card button style 15 | /// 16 | /// - Parameters: 17 | /// - animation: The animation to apply when the button is pressed, by default `.linear`. 18 | /// - pressedScale: The scale to apply when the button is pressed, by default `0.98`. 19 | public init( 20 | animation: Animation? = nil, 21 | pressedScale: Double? = nil 22 | ) { 23 | self.animation = animation 24 | self.pressedScale = pressedScale ?? 0.98 25 | } 26 | 27 | /// The animation to apply when the button is pressed. 28 | public var animation: Animation? 29 | 30 | /// The scale to apply when the button is pressed. 31 | public var pressedScale: Double 32 | 33 | public func makeBody(configuration: Configuration) -> some View { 34 | configuration.label 35 | .scaleEffect(configuration.isPressed ? pressedScale : 1) 36 | .animation(animation, value: configuration.isPressed) 37 | } 38 | } 39 | 40 | public extension ButtonStyle where Self == ListCardButtonStyle { 41 | 42 | /// The standard list card button style. 43 | static var listCard: ListCardButtonStyle { .init() } 44 | 45 | /// A custom list card button style. 46 | static func listCard( 47 | animation: Animation? = nil, 48 | pressedScale: Double? = nil 49 | ) -> Self { 50 | .init( 51 | animation: animation, 52 | pressedScale: pressedScale 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Lists/SidebarListRowBackgroundModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SidebarListRowBackgroundModifier.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-08-16. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | public extension View { 13 | 14 | /// Apply a list row background color for a sidebar list. 15 | /// 16 | /// Unlike `.listRowBackground`, this modifier has no effect on regular 17 | /// sized user interfaces. 18 | func sidebarListRowBackground( 19 | _ color: Color 20 | ) -> some View { 21 | self.modifier(SidebarListRowBackgroundModifier(color)) 22 | } 23 | 24 | /// Apply a list row background view for a sidebar list. 25 | /// 26 | /// Unlike `.listRowBackground`, this modifier has no effect on regular 27 | /// sized user interfaces. 28 | func sidebarListRowBackground( 29 | _ style: Style 30 | ) -> some View { 31 | self.modifier(SidebarListRowBackgroundModifier(style)) 32 | } 33 | } 34 | 35 | /// This modifier can be used to apply a list row background to a list that appears 36 | /// in the sidebar. 37 | /// 38 | /// Unlike `listRowBackground` this has no effect on regular sized lists, since 39 | /// a sidebar then doesn't use row background. 40 | public struct SidebarListRowBackgroundModifier: ViewModifier { 41 | 42 | public init(_ style: Style) { 43 | self.style = style 44 | } 45 | 46 | private let style: Style 47 | 48 | @Environment(\.horizontalSizeClass) 49 | private var horizontalSizeClass 50 | 51 | public func body(content: Content) -> some View { 52 | if horizontalSizeClass == .regular { 53 | content 54 | } else { 55 | content.listRowBackground(style) 56 | } 57 | } 58 | } 59 | #endif 60 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Data/StorageCodableTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StorageCodableTests.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-05-30. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwiftUIKit 11 | import XCTest 12 | 13 | private struct User: Codable, Identifiable { 14 | 15 | var name: String 16 | var age: Int 17 | 18 | var id: String { name } 19 | } 20 | 21 | private class MyState: ObservableObject { 22 | 23 | @AppStorage("com.swiftuikit.appstorage.object", store: .standard) 24 | var object: StorageValue? 25 | 26 | @AppStorage("com.swiftuikit.appstorage.array", store: .standard) 27 | var array: [User]? 28 | 29 | @AppStorage("com.swiftuikit.appstorage.dict", store: .standard) 30 | var dictionary: [User.ID: User]? 31 | } 32 | 33 | final class AppStorageCodableTests: XCTestCase { 34 | 35 | private let value = User(name: "Daniel", age: 45) 36 | 37 | func testCanPersistObject() { 38 | let state1 = MyState() 39 | XCTAssertNil(state1.object) 40 | state1.object = .init(value) 41 | let state2 = MyState() 42 | XCTAssertEqual(state2.object?.value?.name, "Daniel") 43 | state2.object = nil 44 | } 45 | 46 | func testCanPersistArray() { 47 | let state1 = MyState() 48 | XCTAssertNil(state1.array) 49 | state1.array = [value] 50 | let state2 = MyState() 51 | XCTAssertEqual(state2.array?.first?.name, "Daniel") 52 | state2.array = nil 53 | } 54 | 55 | func testCanPersistDictionary() { 56 | let state1 = MyState() 57 | XCTAssertNil(state1.dictionary) 58 | state1.dictionary = ["foo": value] 59 | let state2 = MyState() 60 | XCTAssertEqual(state2.dictionary?["foo"]?.name, "Daniel") 61 | state2.dictionary = nil 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Date/Date+Difference.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+Difference.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-08-05. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Date { 12 | 13 | /// The number of years between this and another date. 14 | func years(from date: Date, calendar: Calendar = .current) -> Int { 15 | calendar.dateComponents([.year], from: date, to: self).year ?? 0 16 | } 17 | 18 | /// The number of months between this and another date. 19 | func months(from date: Date, calendar: Calendar = .current) -> Int { 20 | calendar.dateComponents([.month], from: date, to: self).month ?? 0 21 | } 22 | 23 | /// The number of weeks between this and another date. 24 | func weeks(from date: Date, calendar: Calendar = .current) -> Int { 25 | calendar.dateComponents([.weekOfYear], from: date, to: self).weekOfYear ?? 0 26 | } 27 | 28 | /// The number of days between this and another date. 29 | func days(from date: Date, calendar: Calendar = .current) -> Int { 30 | calendar.dateComponents([.day], from: date, to: self).day ?? 0 31 | } 32 | 33 | /// The number of hours between this and another date. 34 | func hours(from date: Date, calendar: Calendar = .current) -> Int { 35 | calendar.dateComponents([.hour], from: date, to: self).hour ?? 0 36 | } 37 | 38 | /// The number of minutes between this and another date. 39 | func minutes(from date: Date, calendar: Calendar = .current) -> Int { 40 | calendar.dateComponents([.minute], from: date, to: self).minute ?? 0 41 | } 42 | 43 | /// The number of seconds between this and another date. 44 | func seconds(from date: Date, calendar: Calendar = .current) -> Int { 45 | calendar.dateComponents([.second], from: date, to: self).second ?? 0 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Extensions/View+Conditionals.swift: -------------------------------------------------------------------------------- 1 | // 2 | // View+Conditionals.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-08-04. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | public extension View { 12 | 13 | /// Hide the view if the provided condition is `true`. 14 | @ViewBuilder 15 | func hidden(if condition: Bool) -> some View { 16 | if condition { 17 | self.hidden() 18 | } else { 19 | self 20 | } 21 | } 22 | 23 | /// Make the view searchable if the condition is `true`. 24 | @ViewBuilder 25 | func searchable( 26 | if condition: Bool, 27 | text: Binding, 28 | placement: SearchFieldPlacement = .automatic, 29 | prompt: String 30 | ) -> some View { 31 | if condition { 32 | self.searchable( 33 | text: text, 34 | placement: placement, 35 | prompt: prompt) 36 | } else { 37 | self 38 | } 39 | } 40 | 41 | #if !os(watchOS) && !os(tvOS) 42 | /// Make the view searchable if the condition is `true`. 43 | @available(iOS 17.0, macOS 14.0, tvOS 17.0, *) 44 | @ViewBuilder 45 | func searchable( 46 | if condition: Bool, 47 | text: Binding, 48 | isPresented: Binding, 49 | placement: SearchFieldPlacement = .automatic, 50 | prompt: Text? = nil 51 | ) -> some View { 52 | if condition { 53 | self.searchable( 54 | text: text, 55 | isPresented: isPresented, 56 | placement: placement, 57 | prompt: prompt 58 | ) 59 | } else { 60 | self 61 | } 62 | } 63 | #endif 64 | 65 | /// Show the view if the provided condition is `true`. 66 | func visible(if condition: Bool) -> some View { 67 | hidden(if: !condition) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Lists/ListSectionTitle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListSectionTitle.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-10-28. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This view mimics the `Section` title od a grouped `List`. 12 | /// 13 | /// This font uses `.headline` with a `.scaleEffect` in iOS 26 to allow for 14 | /// dynamic type support. 15 | /// 16 | /// This doesn't add insets by default, but you can set `withInsets` to `true` 17 | /// to apply a standard padding. 18 | public struct ListSectionTitle: View { 19 | 20 | public init( 21 | _ text: LocalizedStringKey, 22 | bundle: Bundle? = nil, 23 | withInsets: Bool = false 24 | ) { 25 | self.text = text 26 | self.bundle = bundle 27 | self.applyInsets = withInsets 28 | } 29 | 30 | private let text: LocalizedStringKey 31 | private let bundle: Bundle? 32 | private let applyInsets: Bool 33 | 34 | public var body: some View { 35 | if #available(iOS 26.0, *) { 36 | Text(text, bundle: bundle) 37 | .font(.headline) 38 | .foregroundColor(.secondary) 39 | .scaleEffect(0.98) 40 | } else { 41 | Text(text, bundle: bundle) 42 | .textCase(.uppercase) 43 | .foregroundColor(.secondary) 44 | .font(.footnote) 45 | .withGroupedListSectionHeaderInsets(if: applyInsets) 46 | } 47 | } 48 | } 49 | 50 | private extension View { 51 | 52 | @ViewBuilder 53 | func withGroupedListSectionHeaderInsets( 54 | if condition: Bool 55 | ) -> some View { 56 | if condition { 57 | self.padding(.leading) 58 | .padding(.top, -3) 59 | } else { 60 | self 61 | } 62 | } 63 | } 64 | 65 | #Preview { 66 | 67 | List { 68 | Section(header: Text("Preview.SectionTitle", bundle: .module)) { 69 | ListSectionTitle("Preview.SectionTitle", bundle: .module) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /.github/workflows/version-bump.yml: -------------------------------------------------------------------------------- 1 | # This workflow bumps the repository version. 2 | # You can bump it a major, minor, or patch step, or use a custom version. 3 | 4 | # For this to work, you must define these repository secrets: 5 | # - BUILD_CERTIFICATE_BASE64 6 | # - P12_PASSWORD 7 | # - KEYCHAIN_PASSWORD 8 | 9 | # OBS! You must also change FRAMEWORK_NAME below to use your 10 | # framework's name. 11 | 12 | # For more information see: 13 | # https://danielsaidi.com/blog/2025/11/09/building-closed-source-binaries-with-github-actions 14 | 15 | name: Bump Version 16 | 17 | on: 18 | workflow_dispatch: 19 | inputs: 20 | bump_type: 21 | description: 'Version bump' 22 | required: true 23 | type: choice 24 | options: 25 | - patch 26 | - minor 27 | - major 28 | - custom 29 | custom_version: 30 | description: 'Custom version (for "custom")' 31 | required: false 32 | type: string 33 | 34 | permissions: 35 | contents: write 36 | 37 | concurrency: 38 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 39 | cancel-in-progress: true 40 | 41 | jobs: 42 | bump: 43 | runs-on: ubuntu-latest 44 | 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v4 48 | with: 49 | fetch-depth: 0 # Fetch all history and tags 50 | 51 | - name: Configure Git 52 | run: | 53 | git config user.name "github-actions[bot]" 54 | git config user.email "github-actions[bot]@users.noreply.github.com" 55 | 56 | - name: Bump Version 57 | run: | 58 | if [ "${{ inputs.bump_type }}" = "custom" ]; then 59 | if [ -z "${{ inputs.custom_version }}" ]; then 60 | echo "Error: Custom version not provided" 61 | exit 1 62 | fi 63 | ./scripts/version-bump.sh --version "${{ inputs.custom_version }}" 64 | else 65 | ./scripts/version-bump.sh --type "${{ inputs.bump_type }}" 66 | fi 67 | -------------------------------------------------------------------------------- /scripts/chmod-all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script runs chmod +x on all .sh files in the current directory." 10 | 11 | echo 12 | echo "Usage: $0 [OPTIONS]" 13 | echo " -h, --help Show this help message" 14 | 15 | echo 16 | echo "Examples:" 17 | echo " $0" 18 | echo 19 | } 20 | 21 | # Function to display error message, show usage, and exit 22 | show_error_and_exit() { 23 | echo 24 | local error_message="$1" 25 | echo "Error: $error_message" 26 | show_usage 27 | exit 1 28 | } 29 | 30 | # Parse command line arguments 31 | while [[ $# -gt 0 ]]; do 32 | case $1 in 33 | -h|--help) 34 | show_usage; exit 0 ;; 35 | -*) 36 | show_error_and_exit "Unknown option $1" ;; 37 | *) 38 | show_error_and_exit "Unexpected argument '$1'" ;; 39 | esac 40 | done 41 | 42 | # Use the script folder to refer to other scripts 43 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 44 | 45 | # Function to make scripts executable 46 | make_executable() { 47 | local script="$1" 48 | local filename=$(basename "$script") 49 | 50 | echo "Making $filename executable..." 51 | if ! chmod +x "$script"; then 52 | echo "Failed to make $filename executable" ; return 1 53 | fi 54 | 55 | echo "Successfully made $filename executable" 56 | } 57 | 58 | # Start script 59 | echo 60 | echo "Making all .sh files in $(basename "$FOLDER") executable..." 61 | 62 | # Find all .sh files in the FOLDER and make them executable 63 | SCRIPT_COUNT=0 64 | while read -r script; do 65 | if ! make_executable "$script"; then 66 | exit 1 67 | fi 68 | ((SCRIPT_COUNT++)) 69 | done < <(find "$FOLDER" -name "*.sh" ! -name "chmod-all.sh" -type f) 70 | 71 | # Complete successfully 72 | if [ $SCRIPT_COUNT -eq 0 ]; then 73 | echo 74 | echo "No .sh files found to make executable" 75 | else 76 | echo 77 | echo "Successfully made $SCRIPT_COUNT script(s) executable!" 78 | fi 79 | 80 | echo 81 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Data/Collection+Codable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Collection+Codable.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-04-24. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | // Inspiration: https://nilcoalescing.com/blog/SaveCustomCodableTypesInAppStorageOrSceneStorage/ 9 | // 10 | 11 | import Foundation 12 | import SwiftUI 13 | 14 | /// This extension makes `Array` able to store `Codable` types. 15 | /// 16 | /// > Important: Values will be encoded and decoded with JSON. This may cause 17 | /// loss of important data. For instance, a JSON encoded `Color` will not include 18 | /// any dynamic color information, like dark mode and high contrast variants. 19 | extension Array: @retroactive RawRepresentable where Element: Codable { 20 | 21 | public init?(rawValue: String) { 22 | guard 23 | let data = rawValue.data(using: .utf8), 24 | let result = try? JSONDecoder().decode([Element].self, from: data) 25 | else { return nil } 26 | self = result 27 | } 28 | 29 | public var rawValue: String { 30 | guard 31 | let data = try? JSONEncoder().encode(self), 32 | let result = String(data: data, encoding: .utf8) 33 | else { return "" } 34 | return result 35 | } 36 | } 37 | 38 | /// This extension makes `Dictionary` able to store `Codable` types. 39 | /// 40 | /// > Important: Values will be encoded and decoded with JSON. This may cause 41 | /// loss of important data. For instance, a JSON encoded `Color` will not include 42 | /// any dynamic color information, like dark mode and high contrast variants. 43 | extension Dictionary: @retroactive RawRepresentable where Key: Codable, Value: Codable { 44 | 45 | public init?(rawValue: String) { 46 | guard 47 | let data = rawValue.data(using: .utf8), 48 | let result = try? JSONDecoder().decode([Key: Value].self, from: data) 49 | else { return nil } 50 | self = result 51 | } 52 | 53 | public var rawValue: String { 54 | guard 55 | let data = try? JSONEncoder().encode(self), 56 | let result = String(data: data, encoding: .utf8) 57 | else { return "{}" } 58 | return result 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/String/String+Subscript.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+Subscript.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-11-29. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This makes it possible to fetch characters from a string, as discussed here: 12 | /// https://stackoverflow.com/questions/24092884/get-nth-character-of-a-string-in-swift-programming-language 13 | public extension StringProtocol { 14 | 15 | func character( 16 | at index: Int 17 | ) -> String.Element? { 18 | guard count > index else { return nil } 19 | return self[index] 20 | } 21 | 22 | func character( 23 | at index: UInt 24 | ) -> String.Element? { 25 | character(at: Int(index)) 26 | } 27 | 28 | subscript( 29 | _ offset: Int 30 | ) -> Element { 31 | self[index(startIndex, offsetBy: offset)] 32 | } 33 | 34 | subscript( 35 | _ range: Range 36 | ) -> SubSequence { 37 | prefix(range.lowerBound+range.count).suffix(range.count) 38 | } 39 | 40 | subscript( 41 | _ range: ClosedRange 42 | ) -> SubSequence { 43 | prefix(range.lowerBound+range.count).suffix(range.count) 44 | } 45 | 46 | subscript( 47 | _ range: PartialRangeThrough 48 | ) -> SubSequence { 49 | prefix(range.upperBound.advanced(by: 1)) 50 | } 51 | 52 | subscript( 53 | _ range: PartialRangeUpTo 54 | ) -> SubSequence { 55 | prefix(range.upperBound) 56 | } 57 | 58 | subscript( 59 | _ range: PartialRangeFrom 60 | ) -> SubSequence { 61 | suffix(Swift.max(0, count-range.lowerBound)) 62 | } 63 | } 64 | 65 | private extension LosslessStringConvertible { 66 | 67 | var string: String { .init(self) } 68 | } 69 | 70 | private extension BidirectionalCollection { 71 | 72 | subscript(safe offset: Int) -> Element? { 73 | if isEmpty { return nil } 74 | guard let index = index( 75 | startIndex, 76 | offsetBy: offset, 77 | limitedBy: index(before: endIndex)) 78 | else { return nil } 79 | return self[index] 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Date/Date+Components.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+Components.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-11-03. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Date { 12 | 13 | /// Get the current day for the current calendar. 14 | var day: Int? { day() } 15 | 16 | /// Get the current hour for the current calendar. 17 | var hour: Int? { hour() } 18 | 19 | /// Get the current minute for the current calendar. 20 | var minute: Int? { minute() } 21 | 22 | /// Get the current month for the current calendar. 23 | var month: Int? { month() } 24 | 25 | /// Get the current second for the current calendar. 26 | var second: Int? { second() } 27 | 28 | /// Get the current year for the current calendar. 29 | var year: Int? { year() } 30 | 31 | 32 | /// Get the current day for the provided calendar. 33 | func day( 34 | for calendar: Calendar = .current 35 | ) -> Int? { 36 | calendar.dateComponents([.day], from: self).day 37 | } 38 | 39 | /// Get the current hour for the provided calendar. 40 | func hour( 41 | for calendar: Calendar = .current 42 | ) -> Int? { 43 | calendar.dateComponents([.hour], from: self).hour 44 | } 45 | 46 | /// Get the current minute for the provided calendar. 47 | func minute( 48 | for calendar: Calendar = .current 49 | ) -> Int? { 50 | calendar.dateComponents([.minute], from: self).minute 51 | } 52 | 53 | /// Get the current month for the provided calendar. 54 | func month( 55 | for calendar: Calendar = .current 56 | ) -> Int? { 57 | calendar.dateComponents([.month], from: self).month 58 | } 59 | 60 | /// Get the current second for the provided calendar. 61 | func second( 62 | for calendar: Calendar = .current 63 | ) -> Int? { 64 | calendar.dateComponents([.second], from: self).second 65 | } 66 | 67 | /// Get the current year for the provided calendar. 68 | func year( 69 | for calendar: Calendar = .current 70 | ) -> Int? { 71 | calendar.dateComponents([.year], from: self).year 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Commands/AboutPanelCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AboutPanelCommand.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-11-21. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if os(macOS) 10 | import SwiftUI 11 | 12 | /// This command can be used to open the macOS About Panel. 13 | public struct AboutPanelCommand: Commands { 14 | 15 | /// Create an About Panel command with panel properties. 16 | /// 17 | /// - Parameters: 18 | /// - title: The menu bar title. 19 | /// - applicationName: The name of the app, buy default the main bundle display name. 20 | /// - credits: Additional credits, by default `nil`. 21 | public init( 22 | title: String, 23 | applicationName: String = Bundle.main.displayName, 24 | credits: String? = nil 25 | ) { 26 | let options: [NSApplication.AboutPanelOptionKey: Any] 27 | if let credits { 28 | options = [ 29 | .applicationName: applicationName, 30 | .credits: NSAttributedString( 31 | string: credits, 32 | attributes: [ 33 | .foregroundColor: NSColor.secondaryLabelColor, 34 | .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) 35 | ] 36 | ) 37 | ] 38 | } else { 39 | options = [.applicationName: applicationName] 40 | } 41 | self.init(title: title, options: options) 42 | } 43 | 44 | /// Create an About Panel command with custom options. 45 | /// 46 | /// - Parameters: 47 | /// - title: The menu bar title. 48 | /// - options: Custom about panel options. 49 | public init( 50 | title: String, 51 | options: [NSApplication.AboutPanelOptionKey: Any] 52 | ) { 53 | self.title = title 54 | self.options = options 55 | } 56 | 57 | private let title: String 58 | private let options: [NSApplication.AboutPanelOptionKey: Any] 59 | 60 | public var body: some Commands { 61 | CommandGroup(replacing: .appInfo) { 62 | Button(title) { 63 | NSApplication.shared 64 | .orderFrontStandardAboutPanel(options: options) 65 | } 66 | } 67 | } 68 | } 69 | #endif 70 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/String/String+ParagraphTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+ParagraphTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2021-11-29. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | final class String_ParagraphTests: XCTestCase { 13 | 14 | let none = "foo bar baz" 15 | let single = "foo\nbar baz" 16 | let multi = "foo\nbar\rbaz" 17 | 18 | func currentResult(for string: String, from location: UInt) -> UInt { 19 | string.findIndexOfCurrentParagraph(from: location) 20 | } 21 | 22 | func testIndexOfCurrentParagraph() { 23 | XCTAssertEqual(currentResult(for: "", from: 0), 0) 24 | XCTAssertEqual(currentResult(for: "", from: 20), 0) 25 | 26 | XCTAssertEqual(currentResult(for: none, from: 0), 0) 27 | XCTAssertEqual(currentResult(for: none, from: 10), 0) 28 | XCTAssertEqual(currentResult(for: none, from: 20), 0) 29 | 30 | XCTAssertEqual(currentResult(for: single, from: 0), 0) 31 | XCTAssertEqual(currentResult(for: single, from: 5), 4) 32 | XCTAssertEqual(currentResult(for: single, from: 10), 4) 33 | 34 | XCTAssertEqual(currentResult(for: multi, from: 0), 0) 35 | XCTAssertEqual(currentResult(for: multi, from: 5), 4) 36 | XCTAssertEqual(currentResult(for: multi, from: 10), 8) 37 | } 38 | 39 | func nextResult(for string: String, from location: UInt) -> UInt { 40 | string.findIndexOfNextParagraph(from: location) 41 | } 42 | 43 | func testIndexOfNextParagraph() { 44 | XCTAssertEqual(nextResult(for: "", from: 0), 0) 45 | XCTAssertEqual(nextResult(for: "", from: 20), 0) 46 | 47 | XCTAssertEqual(nextResult(for: none, from: 0), 0) 48 | XCTAssertEqual(nextResult(for: none, from: 10), 0) 49 | XCTAssertEqual(nextResult(for: none, from: 20), 0) 50 | 51 | XCTAssertEqual(nextResult(for: single, from: 0), 4) 52 | XCTAssertEqual(nextResult(for: single, from: 5), 4) 53 | XCTAssertEqual(nextResult(for: single, from: 10), 4) 54 | 55 | XCTAssertEqual(nextResult(for: multi, from: 0), 4) 56 | XCTAssertEqual(nextResult(for: multi, from: 5), 8) 57 | XCTAssertEqual(nextResult(for: multi, from: 10), 8) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /scripts/sync-from.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script syncs Swift Package Scripts from a ." 10 | 11 | echo 12 | echo "Usage: $0 " 13 | echo " Required. The full path to a Swift Package Scripts root" 14 | 15 | echo 16 | echo "The script will replace the existing 'scripts' folder." 17 | 18 | echo 19 | echo "Examples:" 20 | echo " $0 ../SwiftPackageScripts" 21 | echo " $0 /path/to/SwiftPackageScripts" 22 | echo 23 | } 24 | 25 | # Function to display error message, show usage, and exit 26 | show_error_and_exit() { 27 | echo 28 | local error_message="$1" 29 | echo "Error: $error_message" 30 | show_usage 31 | exit 1 32 | } 33 | 34 | # Define argument variables 35 | SOURCE="" 36 | 37 | # Parse command line arguments 38 | while [[ $# -gt 0 ]]; do 39 | case $1 in 40 | -h|--help) 41 | show_usage; exit 0 ;; 42 | -*) 43 | show_error_and_exit "Unknown option $1" ;; 44 | *) 45 | if [ -z "$SOURCE" ]; then 46 | SOURCE="$1" 47 | else 48 | show_error_and_exit "Unexpected argument '$1'" 49 | fi 50 | shift 51 | ;; 52 | esac 53 | done 54 | 55 | # Verify SOURCE was provided 56 | if [ -z "$SOURCE" ]; then 57 | echo "" 58 | read -p "Please enter the source folder path: " SOURCE 59 | if [ -z "$SOURCE" ]; then 60 | show_error_and_exit "SOURCE_FOLDER is required" 61 | fi 62 | fi 63 | 64 | # Define variables 65 | FOLDER="scripts/" 66 | SOURCE_FOLDER="$SOURCE/$FOLDER" 67 | 68 | # Verify source folder exists 69 | if [ ! -d "$SOURCE_FOLDER" ]; then 70 | show_error_and_exit "Source folder '$SOURCE_FOLDER' does not exist" 71 | fi 72 | 73 | # Start script 74 | echo 75 | echo "Syncing scripts from $SOURCE_FOLDER..." 76 | 77 | # Remove existing folder 78 | echo "Removing existing scripts folder..." 79 | rm -rf $FOLDER 80 | 81 | # Copy folder 82 | echo "Copying scripts from source..." 83 | if ! cp -r "$SOURCE_FOLDER/" "$FOLDER/"; then 84 | echo "Failed to copy scripts from $SOURCE_FOLDER" 85 | exit 1 86 | fi 87 | 88 | # Complete successfully 89 | echo 90 | echo "Script syncing from $SOURCE_FOLDER completed successfully!" 91 | echo 92 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Text/TextEditorStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextEditorStyle.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-05-23. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if os(iOS) || os(macOS) 10 | import SwiftUI 11 | 12 | /// This enum defines various `TextEditor` styles. 13 | public enum TextEditorStyle { 14 | 15 | /// The standard, borderless style. 16 | case automatic 17 | 18 | /// The standard, borderless style. 19 | case plain 20 | 21 | /// A rounded border style. 22 | case roundedBorder 23 | 24 | /// A rounded border style with a custom color. 25 | case roundedColorBorder(Color, Double = 0.5) 26 | } 27 | 28 | public extension TextEditor { 29 | 30 | /// Apply a ``TextEditorStyle`` to a text editor. 31 | /// 32 | /// Due to how the modifier works, it must be applied to the `TextEditor`. 33 | @ViewBuilder 34 | func textEditorStyle(_ style: TextEditorStyle) -> some View { 35 | switch style { 36 | case .automatic, .plain: 37 | self 38 | case .roundedBorder: 39 | self.cornerRadius(cornerRadius) 40 | .overlay(stroke(Color.primary.opacity(0.17))) 41 | case .roundedColorBorder(let color, let width): 42 | self.cornerRadius(cornerRadius) 43 | .overlay(stroke(color, lineWidth: width)) 44 | } 45 | } 46 | } 47 | 48 | private extension TextEditor { 49 | 50 | var cornerRadius: Double { 5.0 } 51 | 52 | func stroke( 53 | _ color: Color, 54 | lineWidth: CGFloat = 0.5 55 | ) -> some View { 56 | RoundedRectangle(cornerRadius: cornerRadius) 57 | .stroke(color, lineWidth: lineWidth) 58 | } 59 | } 60 | 61 | #Preview { 62 | 63 | struct Preview: View { 64 | 65 | @State 66 | var text: String = "Hello, world" 67 | 68 | var body: some View { 69 | VStack { 70 | TextField("", text: $text) 71 | .textFieldStyle(.roundedBorder) 72 | TextEditor(text: $text) 73 | .textEditorStyle(.roundedBorder) 74 | TextEditor(text: $text) 75 | .textEditorStyle(.roundedColorBorder(.red, 5)) 76 | } 77 | .padding(10) 78 | .background(Color.primary.colorInvert()) 79 | // .environment(\.colorScheme, .dark) 80 | } 81 | } 82 | 83 | return Preview() 84 | } 85 | #endif 86 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Device/DeviceIdentifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DeviceIdentifier.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2016-11-24. 6 | // Copyright © 2016-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This class can generate a unique device identifier that is persisted in both user 12 | /// defaults and the user keychain. 13 | /// 14 | /// This means that the identifier will be persisted even when uninstalling the app. 15 | open class DeviceIdentifier { 16 | 17 | /// Create a device identifier. 18 | /// 19 | /// - Parameters: 20 | /// - keychainService: The service to use for keychain support, by default `.shared`. 21 | /// - keychainAccessibility: The keychain accessibility to use, by default `nil`. 22 | /// - store: The user defaults to persist ID in, by default `.standard`. 23 | public init( 24 | keychainService: KeychainService, 25 | keychainAccessibility: KeychainItemAccessibility? = nil, 26 | store: UserDefaults = .standard 27 | ) { 28 | self.keychainService = keychainService 29 | self.accessibility = keychainAccessibility 30 | self.store = store 31 | } 32 | 33 | private let keychainService: KeychainService 34 | private let accessibility: KeychainItemAccessibility? 35 | private let store: UserDefaults 36 | 37 | /// Get a unique device identifier from any store. 38 | /// 39 | /// If no device identifier exists, an identifier will be generated and persisted 40 | /// in both the keychain and in user defaults. 41 | open func getDeviceIdentifier() -> String { 42 | let keychainId = keychainService.string(for: key, with: accessibility) 43 | let storeId = store.string(forKey: key) 44 | let id = keychainId ?? storeId ?? UUID().uuidString 45 | if keychainId == nil || storeId == nil { setDeviceIdentifier(id) } 46 | return id 47 | } 48 | 49 | /// Remove the unique device identifier from all stores. 50 | open func resetDeviceIdentifier() { 51 | store.removeObject(forKey: key) 52 | keychainService.removeObject(for: key, with: accessibility) 53 | } 54 | 55 | /// Write a unique device identifier to all stores. 56 | open func setDeviceIdentifier(_ id: String) { 57 | store.set(id, forKey: key) 58 | keychainService.set(id, for: key, with: accessibility) 59 | } 60 | } 61 | 62 | extension DeviceIdentifier { 63 | 64 | var key: String { "com.swiftuikit.deviceidentifier" } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Text/TextFieldClearButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TextFieldClearButton.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-12-18. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | /// This view modifier adds a clear button to any `TextField`. 13 | /// 14 | /// You can apply it with `.modifier(TextFieldClearButton(...))`, or 15 | /// the custom `.withClearButton(for: $text)`. 16 | public struct TextFieldClearButton: ViewModifier { 17 | 18 | public init( 19 | text: Binding, 20 | animation: Animation? = nil 21 | ) { 22 | self._text = text 23 | self.animation = animation ?? .smooth 24 | } 25 | 26 | @Binding 27 | private var text: String 28 | 29 | private var animation: Animation 30 | 31 | public func body(content: Content) -> some View { 32 | HStack { 33 | content 34 | Spacer() 35 | Image(systemName: "multiply.circle.fill") 36 | .foregroundColor(.secondary) 37 | .opacity(text == "" ? 0 : 1) 38 | .animation(animation, value: text) 39 | .onTapGesture { self.text = "" } 40 | } 41 | } 42 | } 43 | 44 | @MainActor 45 | public extension TextField { 46 | 47 | /// Add a trailing ``TextFieldClearButton`` to this text field, that can 48 | /// be used to clear the text binding. 49 | func withClearButton( 50 | for text: Binding, 51 | _ animation: Animation? = nil 52 | ) -> some View { 53 | self.modifier( 54 | TextFieldClearButton( 55 | text: text, 56 | animation: animation 57 | ) 58 | ) 59 | } 60 | } 61 | 62 | #Preview { 63 | 64 | struct Preview: View { 65 | 66 | @State 67 | private var text = "" 68 | 69 | var placeholder: String { 70 | .init(localized: "Preview.Placeholder", bundle: .module) 71 | } 72 | 73 | var body: some View { 74 | VStack { 75 | TextField(placeholder, text: $text) 76 | .withClearButton(for: $text) 77 | TextField(placeholder, text: $text) 78 | .withClearButton( 79 | for: $text, 80 | .bouncy(duration: 1, extraBounce: 0.1) 81 | ) 82 | } 83 | .textFieldStyle(.roundedBorder) 84 | } 85 | } 86 | 87 | return Preview() 88 | } 89 | #endif 90 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Date/Date+AddRemoveTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+AddRemoveTests.swift 3 | // SwiftUIKitTests 4 | // 5 | // Created by Daniel Saidi on 2016-01-18. 6 | // Copyright © 2016-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUIKit 11 | import XCTest 12 | 13 | final class DateAddRemoveTests: XCTestCase { 14 | 15 | func testCanAddDays() { 16 | let date = Date(timeIntervalSince1970: 0) 17 | let result = date.adding(days: 3).timeIntervalSince1970 18 | XCTAssertEqual(result, 3 * 60 * 60 * 24) 19 | } 20 | 21 | func testCanAddHours() { 22 | let date = Date(timeIntervalSince1970: 0) 23 | let result = date.adding(hours: 3).timeIntervalSince1970 24 | XCTAssertEqual(result, 3 * 60 * 60) 25 | } 26 | 27 | func testCanAddMinutes() { 28 | let date = Date(timeIntervalSince1970: 0) 29 | let result = date.adding(minutes: 3).timeIntervalSince1970 30 | XCTAssertEqual(result, 3 * 60) 31 | } 32 | 33 | func testCanAddSeconds() { 34 | let date = Date(timeIntervalSince1970: 0) 35 | let result = date.adding(seconds: 3).timeIntervalSince1970 36 | XCTAssertEqual(result, 3) 37 | } 38 | 39 | func testCanRemoveDays() { 40 | let date = Date(timeIntervalSince1970: 3 * 60 * 60 * 24) 41 | let result = date.removing(days: 3).timeIntervalSince1970 42 | XCTAssertEqual(result, 0) 43 | } 44 | 45 | func testCanRemoveHours() { 46 | let date = Date(timeIntervalSince1970: 3 * 60 * 60) 47 | let result = date.removing(hours: 3).timeIntervalSince1970 48 | XCTAssertEqual(result, 0) 49 | } 50 | 51 | func testCanRemoveMinutes() { 52 | let date = Date(timeIntervalSince1970: 3 * 60) 53 | let result = date.removing(minutes: 3).timeIntervalSince1970 54 | XCTAssertEqual(result, 0) 55 | } 56 | 57 | func testCanRemoveSeconds() { 58 | let date = Date(timeIntervalSince1970: 3) 59 | let result = date.removing(seconds: 3).timeIntervalSince1970 60 | XCTAssertEqual(result, 0) 61 | } 62 | 63 | func testCanChainAddAndRemoval() { 64 | let date = Date(timeIntervalSince1970: 0) 65 | let result = date 66 | .adding(days: 3) 67 | .adding(hours: 2) 68 | .removing(seconds: 15) 69 | .timeIntervalSince1970 70 | let days: Double = 3 * 60 * 60 * 24 71 | let hours: Double = 2 * 60 * 60 72 | XCTAssertEqual(result, days + hours - 15) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Lists/ListShelfSectionStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListShelfSectionStyle.swift 3 | // KeyboardKit 4 | // 5 | // Created by Daniel Saidi on 2023-04-26. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This style can be used to style a ``ListShelfSection``. 12 | public struct ListShelfSectionStyle { 13 | 14 | /// Create a custom list shelf section style. 15 | /// 16 | /// - Parameters: 17 | /// - horizontalPadding: The horizontal edge padding to apply, by default `16`. 18 | /// - rowSpacing: The spacing to apply between each row, by default `16`. 19 | /// - rowTitleSpacing: The bottom padding to apply to each row title, by default `0`. 20 | /// - rowItemSpacing: The spacing to apply between each row item, by default `16`. 21 | public init( 22 | horizontalPadding: Double = 16, 23 | rowSpacing: Double = 16, 24 | rowTitleSpacing: Double = 10, 25 | rowItemSpacing: Double = 16 26 | ) { 27 | self.horizontalPadding = horizontalPadding 28 | self.rowSpacing = rowSpacing 29 | self.rowTitleSpacing = rowTitleSpacing 30 | self.rowItemSpacing = rowItemSpacing 31 | } 32 | 33 | /// The horizontal edge padding to apply. 34 | public var horizontalPadding: Double 35 | 36 | /// The spacing to apply between each row. 37 | public var rowSpacing: Double 38 | 39 | /// The bottom padding to apply to each row title. 40 | public var rowTitleSpacing: Double 41 | 42 | /// The spacing to apply between each row item. 43 | public var rowItemSpacing: Double 44 | } 45 | 46 | public extension ListShelfSectionStyle { 47 | 48 | /// The standard list sheld section style. 49 | static var standard: Self { .init() } 50 | } 51 | 52 | public extension View { 53 | 54 | /// Apply a ``ListShelfSectionStyle`` style to the view. 55 | func listShelfSectionStyle( 56 | _ style: ListShelfSectionStyle 57 | ) -> some View { 58 | self.environment(\.listShelfSectionStyle, style) 59 | } 60 | } 61 | 62 | private extension ListShelfSectionStyle { 63 | 64 | @MainActor struct Key: @preconcurrency EnvironmentKey { 65 | 66 | public static var defaultValue: ListShelfSectionStyle { 67 | .standard 68 | } 69 | } 70 | } 71 | 72 | public extension EnvironmentValues { 73 | 74 | var listShelfSectionStyle: ListShelfSectionStyle { 75 | get { self [ListShelfSectionStyle.Key.self] } 76 | set { self [ListShelfSectionStyle.Key.self] = newValue } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /scripts/sync-to.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script syncs Swift Package Scripts to a ." 10 | 11 | echo 12 | echo "Usage: $0 " 13 | echo " Required. The full path to a Swift Package root" 14 | 15 | echo 16 | echo "The script will replace any existing 'scripts' folder." 17 | 18 | echo 19 | echo "Examples:" 20 | echo " $0 ../MyPackage" 21 | echo " $0 /path/to/MyPackage" 22 | echo 23 | } 24 | 25 | # Function to display error message, show usage, and exit 26 | show_error_and_exit() { 27 | echo 28 | local error_message="$1" 29 | echo "Error: $error_message" 30 | show_usage 31 | exit 1 32 | } 33 | 34 | # Define argument variables 35 | TARGET="" 36 | 37 | # Parse command line arguments 38 | while [[ $# -gt 0 ]]; do 39 | case $1 in 40 | -h|--help) 41 | show_usage; exit 0 ;; 42 | -*) 43 | show_error_and_exit "Unknown option $1" ;; 44 | *) 45 | if [ -z "$TARGET" ]; then 46 | TARGET="$1" 47 | else 48 | show_error_and_exit "Unexpected argument '$1'" 49 | fi 50 | shift 51 | ;; 52 | esac 53 | done 54 | 55 | # Verify TARGET was provided 56 | if [ -z "$TARGET" ]; then 57 | echo "" 58 | read -p "Please enter the target folder path: " TARGET 59 | if [ -z "$TARGET" ]; then 60 | show_error_and_exit "TARGET_FOLDER is required" 61 | fi 62 | fi 63 | 64 | # Define variables 65 | FOLDER="scripts/" 66 | TARGET_FOLDER="$TARGET/$FOLDER" 67 | 68 | # Verify source folder exists 69 | if [ ! -d "$FOLDER" ]; then 70 | show_error_and_exit "Source folder '$FOLDER' does not exist" 71 | fi 72 | 73 | # Verify target directory exists 74 | if [ ! -d "$TARGET" ]; then 75 | show_error_and_exit "Target directory '$TARGET' does not exist" 76 | fi 77 | 78 | # Start script 79 | echo 80 | echo "Syncing scripts to $TARGET_FOLDER..." 81 | 82 | # Remove existing folder if it exists 83 | if [ -d "$TARGET_FOLDER" ]; then 84 | echo "Removing existing scripts folder in target..." 85 | rm -rf "$TARGET_FOLDER" 86 | fi 87 | 88 | # Copy folder 89 | echo "Copying scripts to target..." 90 | if ! cp -r "$FOLDER" "$TARGET_FOLDER"; then 91 | show_error_and_exit "Failed to copy scripts to $TARGET_FOLDER" 92 | fi 93 | 94 | # Complete successfully 95 | echo 96 | echo "Script syncing to $TARGET_FOLDER completed successfully!" 97 | echo 98 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Images/ImageRepresentable+Resized.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ImageRepresentable+Resized.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2022-06-29. 6 | // Copyright © 2022-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | #if canImport(UIKit) 12 | import UIKit 13 | 14 | public extension ImageRepresentable { 15 | 16 | /// Create a resized copy of the image. 17 | func resized(to size: CGSize) -> ImageRepresentable? { 18 | UIGraphicsBeginImageContextWithOptions(size, false, scale) 19 | draw(in: CGRect(origin: CGPoint.zero, size: size)) 20 | let result = UIGraphicsGetImageFromCurrentImageContext() 21 | UIGraphicsEndImageContext() 22 | return result 23 | } 24 | } 25 | #elseif canImport(AppKit) 26 | import AppKit 27 | 28 | public extension ImageRepresentable { 29 | 30 | /// Create a resized copy of the image. 31 | func resized(to newSize: CGSize) -> ImageRepresentable? { 32 | let newImage = ImageRepresentable(size: newSize) 33 | newImage.lockFocus() 34 | let sourceRect = NSRect(x: 0, y: 0, width: size.width, height: size.height) 35 | let destRect = NSRect(x: 0, y: 0, width: newSize.width, height: newSize.height) 36 | draw(in: destRect, from: sourceRect, operation: .sourceOver, fraction: CGFloat(1)) 37 | newImage.unlockFocus() 38 | return newImage 39 | } 40 | } 41 | #endif 42 | 43 | public extension ImageRepresentable { 44 | 45 | /// Create a resized copy of the image with a new height. 46 | func resized(toHeight points: CGFloat) -> ImageRepresentable? { 47 | let ratio = points / size.height 48 | let width = size.width * ratio 49 | let newSize = CGSize(width: width, height: points) 50 | return resized(to: newSize) 51 | } 52 | 53 | /// Create a resized copy of the image with a max height. 54 | func resized(toMaxHeight points: CGFloat) -> ImageRepresentable? { 55 | if size.height < points { return self } 56 | return resized(toHeight: points) 57 | } 58 | 59 | /// Create a resized copy of the image, with a new width. 60 | func resized(toWidth points: CGFloat) -> ImageRepresentable? { 61 | let ratio = points / size.width 62 | let height = size.height * ratio 63 | let newSize = CGSize(width: points, height: height) 64 | return resized(to: newSize) 65 | } 66 | 67 | /// Create a resized copy of the image, with a max width. 68 | func resized(toMaxWidth points: CGFloat) -> ImageRepresentable? { 69 | if size.width < points { return self } 70 | return resized(toWidth: points) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Text/MultilineSubmitViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MultilineSubmitViewModifier.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-09-15. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This view modifier can be applied to a multiline `TextField`, to auto-submit 12 | /// when the the primary key is pressed. 13 | public struct MultilineSubmitViewModifier: ViewModifier { 14 | 15 | /// Create a multiline submit view modifier. 16 | /// 17 | /// - Parameters: 18 | /// - text: The text binding used by the text field. 19 | /// - submitLabel: The submit label to use. 20 | /// - onSubmit: The function to call when return is pressed. 21 | public init( 22 | text: Binding, 23 | submitLabel: SubmitLabel, 24 | onSubmit: @escaping () -> Void 25 | ) { 26 | self._text = text 27 | self.submitLabel = submitLabel 28 | self.onSubmit = onSubmit 29 | } 30 | 31 | @Binding 32 | private var text: String 33 | 34 | private let submitLabel: SubmitLabel 35 | private let onSubmit: () -> Void 36 | 37 | @FocusState 38 | private var isFocused: Bool 39 | 40 | public func body(content: Content) -> some View { 41 | content 42 | .focused($isFocused) 43 | .submitLabel(submitLabel) 44 | #if os(visionOS) 45 | .onChange(of: text) { handle($1) } 46 | #else 47 | .onChange(of: text) { handle($0) } 48 | #endif 49 | } 50 | 51 | private func handle(_ newText: String) { 52 | guard isFocused else { return } 53 | guard newText.contains("\n") else { return } 54 | isFocused = false 55 | text = newText.replacingOccurrences(of: "\n", with: "") 56 | onSubmit() 57 | } 58 | } 59 | 60 | public extension View { 61 | 62 | /// Make a multiline textfield auto-submit when the primary button is pressed. 63 | /// 64 | /// - Parameters: 65 | /// - text: The text binding used by the text field. 66 | /// - submitLabel: The submit label to use, by default `.done`. 67 | /// - action: The function to call when return is pressed. 68 | func multilineSubmit( 69 | for text: Binding, 70 | submitLabel: SubmitLabel = .done, 71 | action: @escaping () -> Void = {} 72 | ) -> some View { 73 | self.modifier( 74 | MultilineSubmitViewModifier( 75 | text: text, 76 | submitLabel: submitLabel, 77 | onSubmit: action 78 | ) 79 | ) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Files/DirectoryMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DirectoryMonitor.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-04-17. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | // Original implementation: 9 | // https://medium.com/over-engineering/monitoring-a-folder-for-changes-in-ios-dc3f8614f902 10 | // 11 | 12 | import Foundation 13 | 14 | /// This class can monitor file system changes for a folder. 15 | public class DirectoryMonitor { 16 | 17 | /// Create an monitor instance. 18 | /// 19 | /// - Parameters: 20 | /// - url: The directory url to observe. 21 | /// - onChange: The function to call when the folder changes. 22 | public init( 23 | url: URL, 24 | onChange: @escaping () -> Void 25 | ) { 26 | self.url = url 27 | self.onChange = onChange 28 | } 29 | 30 | 31 | private let url: URL 32 | private let onChange: (() -> Void) 33 | 34 | private var fileDescriptor: CInt = -1 35 | private let monitorQueue = DispatchQueue(label: "FolderMonitorQueue", attributes: .concurrent) 36 | private var monitorSource: DispatchSourceFileSystemObject? 37 | 38 | 39 | /// Start monitoring changes to the directory. 40 | public func startMonitoringChanges() { 41 | guard 42 | monitorSource == nil, 43 | fileDescriptor == -1 44 | else { return } 45 | 46 | // Open the directory referenced by URL for monitoring only. 47 | fileDescriptor = open(url.path, O_EVTONLY) 48 | 49 | // Define a dispatch source monitoring the directory for additions, deletions, and renamings. 50 | monitorSource = DispatchSource.makeFileSystemObjectSource( 51 | fileDescriptor: fileDescriptor, 52 | eventMask: .write, 53 | queue: monitorQueue) 54 | 55 | // Define the block to call when a file change is detected. 56 | monitorSource?.setEventHandler { [weak self] in 57 | self?.onChange() 58 | } 59 | 60 | // Define a cancel handler to ensure the directory is closed when the source is cancelled. 61 | monitorSource?.setCancelHandler { [weak self] in 62 | guard let self = self else { return } 63 | close(self.fileDescriptor) 64 | self.fileDescriptor = -1 65 | self.monitorSource = nil 66 | } 67 | 68 | // Start monitoring the directory via the source. 69 | monitorSource?.resume() 70 | } 71 | 72 | /// Stop monitoring changes to the directory. 73 | public func stopMonitoringChanges() { 74 | monitorSource?.cancel() 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Colors/Color+Codable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Color+Codable.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-08-23. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | // This extension makes `Color` implement `Codable`. 10 | // 11 | // Original implementation: 12 | // https://brunowernimont.me/howtos/make-swiftui-color-codable 13 | 14 | import SwiftUI 15 | 16 | #if os(iOS) 17 | import UIKit 18 | #elseif os(watchOS) 19 | import WatchKit 20 | #elseif os(macOS) 21 | import AppKit 22 | #endif 23 | 24 | /// This extension makes `Color` implement `Codable`. 25 | extension Color: @retroactive Decodable {} 26 | extension Color: @retroactive Encodable {} 27 | 28 | public extension Color { 29 | 30 | enum CodingKeys: String, CodingKey { 31 | case red, green, blue, alpha 32 | } 33 | 34 | /// Initialize a color value from a decoder. 35 | init(from decoder: Decoder) throws { 36 | let container = try decoder.container(keyedBy: CodingKeys.self) 37 | let r = try container.decode(Double.self, forKey: .red) 38 | let g = try container.decode(Double.self, forKey: .green) 39 | let b = try container.decode(Double.self, forKey: .blue) 40 | let a = try container.decode(Double.self, forKey: .alpha) 41 | self.init(red: r, green: g, blue: b, opacity: a) 42 | } 43 | 44 | /// Encode the color, using an encoder. 45 | /// 46 | /// > Important: Encoding colors that support system features like dark mode, 47 | /// high contrast etc. will cause the encoded colors to be non-dynamic. 48 | func encode(to encoder: Encoder) throws { 49 | guard let colorComponents = self.colorComponents else { return } 50 | var container = encoder.container(keyedBy: CodingKeys.self) 51 | try container.encode(colorComponents.red, forKey: .red) 52 | try container.encode(colorComponents.green, forKey: .green) 53 | try container.encode(colorComponents.blue, forKey: .blue) 54 | try container.encode(colorComponents.alpha, forKey: .alpha) 55 | } 56 | } 57 | 58 | private extension Color { 59 | 60 | #if os(macOS) 61 | typealias SystemColor = NSColor 62 | #else 63 | typealias SystemColor = UIColor 64 | #endif 65 | 66 | var colorComponents: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat)? { 67 | var r: CGFloat = 0 68 | var g: CGFloat = 0 69 | var b: CGFloat = 0 70 | var a: CGFloat = 0 71 | 72 | #if os(macOS) 73 | SystemColor(self).getRed(&r, green: &g, blue: &b, alpha: &a) 74 | #else 75 | guard SystemColor(self).getRed(&r, green: &g, blue: &b, alpha: &a) else { return nil } 76 | #endif 77 | 78 | return (r, g, b, a) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Keychain/KeychainItemAccessibility.swift: -------------------------------------------------------------------------------- 1 | // 2 | // KeychainItemAccessibility.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2016-11-24. 6 | // Copyright © 2016-2025 Daniel Saidi. All rights reserved. 7 | // 8 | // Based on https://github.com/jrendel/SwiftKeychainWrapper 9 | // Created by James Blair on 4/24/16. 10 | // Copyright © 2016 Jason Rendel. All rights reserved. 11 | 12 | @preconcurrency import Foundation 13 | 14 | protocol KeychainAttrRepresentable { 15 | 16 | var keychainAttrValue: CFString { get } 17 | } 18 | 19 | /// This enum defines the various access scopes that a keychain item can use. 20 | /// 21 | /// The names follow certain conventions that are defined in the list below: 22 | /// 23 | /// * `afterFirstUnlock` 24 | /// Items can't be accessed after restart, until the device has been unlocked once 25 | /// by the user. After this, items remains accessible until the next restart. This is 26 | /// recommended for items that must be available to any background applications 27 | /// or processes. 28 | /// 29 | /// * `whenPasscodeSet` 30 | /// Items can only be accessed when the device has been unlocked by the user 31 | /// and a device passcode is set. No items can be stored on device if a passcode 32 | /// is not set. Disabling the passcode will delete all items. 33 | /// 34 | /// * `whenUnlocked` 35 | /// Items can only be accessed when the device has been unlocked by the user. 36 | /// This is recommended for items that we only mean to use when the application 37 | /// is active. 38 | /// 39 | /// * `*ThisDeviceOnly` 40 | /// Items will not be included in encrypted backup, and are thus not available after 41 | /// restoring apps from backups on a different device. 42 | public enum KeychainItemAccessibility { 43 | 44 | case afterFirstUnlock 45 | case afterFirstUnlockThisDeviceOnly 46 | case whenPasscodeSetThisDeviceOnly 47 | case whenUnlocked 48 | case whenUnlockedThisDeviceOnly 49 | 50 | static func accessibilityForAttributeValue(_ keychainAttrValue: CFString) -> KeychainItemAccessibility? { 51 | lookupTable.first { $0.value == keychainAttrValue }?.key 52 | } 53 | 54 | static var lookupTable: [KeychainItemAccessibility: CFString] { 55 | [ 56 | .afterFirstUnlock: kSecAttrAccessibleAfterFirstUnlock, 57 | .afterFirstUnlockThisDeviceOnly: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, 58 | .whenPasscodeSetThisDeviceOnly: kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, 59 | .whenUnlocked: kSecAttrAccessibleWhenUnlocked, 60 | .whenUnlockedThisDeviceOnly: kSecAttrAccessibleWhenUnlockedThisDeviceOnly 61 | ] 62 | } 63 | } 64 | 65 | extension KeychainItemAccessibility: KeychainAttrRepresentable { 66 | 67 | public var keychainAttrValue: CFString { 68 | Self.lookupTable[self]! 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Data/StorageValue.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StorageValue.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-04-24. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | // Inspiration: https://nilcoalescing.com/blog/SaveCustomCodableTypesInAppStorageOrSceneStorage/ 9 | // 10 | 11 | import Foundation 12 | import SwiftUI 13 | 14 | /// This type can be used to wrap any `Codable` type, to let us store that value 15 | /// as JSON in `AppStorage` and `SceneStorage`. 16 | /// 17 | /// > Important: Values will be encoded and decoded with JSON. This may cause 18 | /// loss of important data. For instance, a JSON encoded `Color` will not include 19 | /// any dynamic color information, like dark mode and high contrast variants. 20 | public struct StorageValue: RawRepresentable { 21 | 22 | /// Create a storage value. 23 | public init(_ value: Value? = nil) { 24 | self.value = value 25 | } 26 | 27 | /// Create a storage value with a JSON encoded string. 28 | public init?(rawValue: String) { 29 | guard 30 | let data = rawValue.data(using: .utf8), 31 | let result = try? JSONDecoder().decode(Value.self, from: data) 32 | else { return nil } 33 | self = .init(result) 34 | } 35 | 36 | /// The stored value. 37 | public var value: Value? 38 | } 39 | 40 | public extension StorageValue { 41 | 42 | /// Whether the storage value contains an actual value. 43 | var hasValue: Bool { 44 | value != nil 45 | } 46 | 47 | /// A JSON string representation of the storage value. 48 | var jsonString: String { 49 | guard 50 | let data = try? JSONEncoder().encode(value), 51 | let result = String(data: data, encoding: .utf8) 52 | else { return "" } 53 | return result 54 | } 55 | 56 | /// A JSON string representation of the storage value. 57 | var rawValue: String { 58 | jsonString 59 | } 60 | } 61 | 62 | /// This is a shorthand for ``StorageValue``. 63 | public typealias AppStorageValue = StorageValue 64 | 65 | /// This is a shorthand for ``StorageValue``. 66 | public typealias SceneStorageValue = StorageValue 67 | 68 | 69 | private struct User: Codable, Identifiable { 70 | 71 | var name: String 72 | var age: Int 73 | 74 | var id: String { name } 75 | } 76 | 77 | #Preview { 78 | 79 | struct Preview: View { 80 | 81 | @AppStorage("com.swiftuikit.appstorage.user") 82 | var userValue = StorageValue() 83 | 84 | var user: User? { userValue.value } 85 | 86 | var body: some View { 87 | Text(user?.name ?? "-") 88 | 89 | Button("Toggle user") { 90 | let hasValue = userValue.hasValue 91 | let daniel = User(name: "Daniel", age: 46) 92 | userValue.value = hasValue ? nil : daniel 93 | } 94 | } 95 | } 96 | 97 | return Preview() 98 | } 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Project Icon 3 |

4 | 5 |

6 | Version 7 | Swift 6.1 8 | Documentation 9 | MIT License 10 |

11 | 12 | 13 | 14 | # SwiftUIKit 15 | 16 | SwiftUIKit is a library that adds extra functionality to Swift and SwiftUI. It contains additional components, a lot of extensions to native types, and much more. 17 | 18 | SwiftUIKit can kickstart your development and solves many tricky problems. 19 | 20 | 21 | 22 | ## Installation 23 | 24 | SwiftUIKit can be installed with the Swift Package Manager: 25 | 26 | ``` 27 | https://github.com/danielsaidi/SwiftUIKit.git 28 | ``` 29 | 30 | 31 | 32 | ## Getting Started 33 | 34 | SwiftUIKit started small, but has grown big over the years. Due to its complexity, I decided to remove the demo. Instead, have a look at the various namespaces and use the live previews to explore the SDK. 35 | 36 | I will extract parts of this project into separate libraries, to provide better documentation & help. Have a look at [my open-source listing page][OpenSource] for some of these. 37 | 38 | 39 | 40 | ## Documentation 41 | 42 | The [online documentation][Documentation] has more information, code examples, etc. 43 | 44 | 45 | 46 | ## Support My Work 47 | 48 | You can [become a sponsor][Sponsors] to help me dedicate more time on my various [open-source tools][OpenSource]. Every contribution, no matter the size, makes a real difference in keeping these tools free and actively developed. 49 | 50 | 51 | 52 | ## Contact 53 | 54 | Feel free to reach out if you have questions, or want to contribute in any way: 55 | 56 | * Website: [danielsaidi.com][Website] 57 | * E-mail: [daniel.saidi@gmail.com][Email] 58 | * Bluesky: [@danielsaidi@bsky.social][Bluesky] 59 | * Mastodon: [@danielsaidi@mastodon.social][Mastodon] 60 | 61 | 62 | 63 | ## License 64 | 65 | SwiftUIKit is available under the MIT license. See the [LICENSE][License] file for more info. 66 | 67 | 68 | 69 | [Email]: mailto:daniel.saidi@gmail.com 70 | [Website]: https://danielsaidi.com 71 | [GitHub]: https://github.com/danielsaidi 72 | [OpenSource]: https://danielsaidi.com/opensource 73 | [Sponsors]: https://github.com/sponsors/danielsaidi 74 | 75 | [Bluesky]: https://bsky.app/profile/danielsaidi.bsky.social 76 | [Mastodon]: https://mastodon.social/@danielsaidi 77 | [Twitter]: https://twitter.com/danielsaidi 78 | 79 | [Documentation]: https://danielsaidi.github.io/SwiftUIKit 80 | [License]: https://github.com/danielsaidi/SwiftUIKit/blob/master/LICENSE 81 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Data/CsvParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CsvParser.swift 3 | // SwiftKit 4 | // 5 | // Created by Daniel Saidi on 2018-10-23. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This type can parse comma-separated value files and strings. 12 | /// 13 | /// When parsing a CSV file or string, each line will be split up into components by 14 | /// splitting the string with the provided `componentSeparator`. 15 | public class CsvParser { 16 | 17 | /// Create a CSV parser. 18 | public init( 19 | fileManager: FileManager = .default 20 | ) { 21 | self.fileManager = fileManager 22 | } 23 | 24 | private let fileManager: FileManager 25 | 26 | 27 | /// This error can be thrown while parsing a CSV file. 28 | public enum CsvError: Error { 29 | 30 | /// The requested file doesn't exist. 31 | case noFileWithName(_ fileName: String, andExtension: String, inBundle: Bundle) 32 | } 33 | 34 | 35 | /// Parse a CSV file in a certain bundle. 36 | /// 37 | /// - Parameters: 38 | /// - fileName: The name of the file to parse. 39 | /// - ext: The extension of the file to parse. 40 | /// - bundle: The bundle in which the file is located. 41 | /// - componentSeparator: The separator that separates components on each line. 42 | public func parseCsvFile( 43 | named fileName: String, 44 | withExtension ext: String, 45 | in bundle: Bundle, 46 | componentSeparator: Character 47 | ) throws -> [[String]] { 48 | guard let path = bundle.path(forResource: fileName, ofType: ext) else { 49 | throw CsvError.noFileWithName(fileName, andExtension: ext, inBundle: bundle) 50 | } 51 | let string = try String(contentsOfFile: path, encoding: .utf8) 52 | return parseCsvString(string, componentSeparator: componentSeparator) 53 | } 54 | 55 | /// Parse a CSV file at a certain url. 56 | /// 57 | /// - Parameters: 58 | /// - url: The url of the file to parse. 59 | /// - componentSeparator: The separator that separates components on each line. 60 | public func parseCsvFile( 61 | at url: URL, 62 | componentSeparator: Character 63 | ) throws -> [[String]] { 64 | let string = try String(contentsOf: url, encoding: .utf8) 65 | return parseCsvString(string, componentSeparator: componentSeparator) 66 | } 67 | 68 | /// Parse the provided CSV string. 69 | /// 70 | /// - Parameters: 71 | /// - string: The string to parse. 72 | /// - componentSeparator: The separator that separates components on each line. 73 | public func parseCsvString( 74 | _ string: String, 75 | componentSeparator: Character 76 | ) -> [[String]] { 77 | string 78 | .components(separatedBy: .newlines) 79 | .map { $0.trimmingCharacters(in: .whitespaces) } 80 | .filter { !$0.isEmpty } 81 | .map { $0.split(separator: componentSeparator) 82 | .map { String($0).trimmingCharacters(in: .whitespaces) } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Files/iCloudDocumenSync.swift: -------------------------------------------------------------------------------- 1 | // 2 | // iCloudDocumentSync.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-04-29. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// This class can sync iCloud document changes, in a shared uqibuity container. 12 | /// 13 | /// Note that you must have iCloud entitlements and must also have added iCloud 14 | /// information to `Info.plist`. All apps that should sync any documents must 15 | /// belong to the same ubiquity container and be identically configured. 16 | open class iCloudDocumentSync { 17 | 18 | public init( 19 | filePattern: String, 20 | fileManager: FileManager = .default, 21 | notificationCenter: NotificationCenter = .default 22 | ) { 23 | self.filePattern = filePattern 24 | self.fileManager = fileManager 25 | self.center = notificationCenter 26 | } 27 | 28 | private let filePattern: String 29 | private let fileManager: FileManager 30 | private let center: NotificationCenter 31 | 32 | private lazy var metadataQuery: NSMetadataQuery = { 33 | let query = NSMetadataQuery() 34 | query.notificationBatchingInterval = 1 35 | query.searchScopes = [NSMetadataQueryUbiquitousDataScope, NSMetadataQueryUbiquitousDocumentsScope] 36 | query.predicate = NSPredicate(format: "%K LIKE %@", NSMetadataItemFSNameKey, filePattern) 37 | query.sortDescriptors = [NSSortDescriptor(key: NSMetadataItemFSNameKey, ascending: true)] 38 | let selector = #selector(handleQueryNotification) 39 | center.addObserver(self, selector: selector, name: .NSMetadataQueryDidUpdate, object: query) 40 | center.addObserver(self, selector: selector, name: .NSMetadataQueryDidFinishGathering, object: query) 41 | return query 42 | }() 43 | 44 | /// Start syncing iCloud document changes. 45 | open func startSyncingChanges() { 46 | metadataQuery.start() 47 | } 48 | 49 | /// Stop syncing iCloud document changes. 50 | open func stopSyncingChanges() { 51 | center.removeObserver(self) 52 | } 53 | } 54 | 55 | @objc private extension iCloudDocumentSync { 56 | 57 | func handleQueryNotification(notification: Notification?) { 58 | guard let metadataQuery = notification?.object as? NSMetadataQuery else { return } 59 | metadataQuery.disableUpdates() 60 | metadataQuery.enumerateResults { item, _, _ in 61 | handleQueryItem(item) 62 | } 63 | metadataQuery.enableUpdates() 64 | } 65 | 66 | func handleQueryItem(_ item: Any) { 67 | guard 68 | let metadataItem = item as? NSMetadataItem, 69 | !isMetadataItemDownloaded(for: metadataItem), 70 | let url = metadataItem.value(forAttribute: NSMetadataItemURLKey) as? URL 71 | else { return } 72 | try? fileManager.startDownloadingUbiquitousItem(at: url) 73 | } 74 | 75 | func isMetadataItemDownloaded(for item: NSMetadataItem) -> Bool { 76 | let statusKey = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) 77 | return statusKey as? String == NSMetadataUbiquitousItemDownloadingStatusDownloaded 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Lists/ListButtonGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListButtonGroup.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2024-03-15. 6 | // Copyright © 2024-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | #if os(iOS) 10 | import SwiftUI 11 | 12 | /// This group applies zero insets and a clear background to render buttons in the 13 | /// content as a horizontal group. 14 | /// 15 | /// You can style the view with `.listButtonGroupStyle(...)`. 16 | public struct ListButtonGroup: View { 17 | 18 | /// Create a form button group section. 19 | /// 20 | /// - Parameters: 21 | /// - content: A custom content builder. 22 | public init( 23 | @ViewBuilder content: @escaping () -> Content 24 | ) { 25 | self.content = content 26 | } 27 | 28 | private let content: () -> Content 29 | 30 | @Environment(\.listButtonGroupStyle) 31 | private var style 32 | 33 | public var body: some View { 34 | Section { 35 | content() 36 | } 37 | .buttonStyle(style) 38 | .listRowInsets(.init(all: 0)) 39 | .listRowBackground(Color.clear) 40 | } 41 | } 42 | 43 | #Preview { 44 | 45 | struct PreviewList: View { 46 | 47 | var body: some View { 48 | List { 49 | "Add something".previewButton(.add) 50 | 51 | ListButtonGroup { 52 | HStack { 53 | "Bug".previewButton(.bug) 54 | "Camera".previewButton(.camera).disabled(true) 55 | "Photos".previewButton(.camera).opacity(0.5) 56 | "Feedback".previewButton(.feedback) 57 | } 58 | } 59 | 60 | Section { 61 | Text("Preview.Row") 62 | } 63 | } 64 | } 65 | } 66 | 67 | return VStack(spacing: 0) { 68 | PreviewList() 69 | Divider() 70 | PreviewList() 71 | .listButtonGroupStyle(.swedish) 72 | Divider() 73 | PreviewList() 74 | .environment(\.colorScheme, .dark) 75 | } 76 | .frame(maxHeight: .infinity) 77 | .background(Color.black.opacity(0.08).ignoresSafeArea()) 78 | } 79 | 80 | private extension ButtonStyle where Self == ListButtonGroupStyle { 81 | 82 | static var swedish: Self { 83 | .init( 84 | backgroundColor: .blue, 85 | labelStyle: .init(color: .yellow) 86 | ) 87 | } 88 | } 89 | 90 | @MainActor 91 | private extension String { 92 | 93 | func previewButton(_ icon: Image) -> some View { 94 | Button {} label: { Label(LocalizedStringKey(self), icon) } 95 | } 96 | } 97 | 98 | private extension Image { 99 | 100 | static let add = systemImage("plus") 101 | static let bug = systemImage("ladybug") 102 | static let camera = systemImage("camera") 103 | static let feedback = systemImage("envelope") 104 | static let photoLibrary = systemImage("photo.on.rectangle.angled") 105 | 106 | static func systemImage(_ name: String) -> Image { 107 | Image(systemName: name) 108 | } 109 | } 110 | #endif 111 | -------------------------------------------------------------------------------- /scripts/tools/StringCatalogKeyBuilder/Sources/StringCatalogKeyBuilder/StringCatalogParserCommand.swift: -------------------------------------------------------------------------------- 1 | import ArgumentParser 2 | import Foundation 3 | 4 | import SwiftPackageScripts 5 | 6 | /// This command can be used to parse a string catalog and generate 7 | /// Swift code that allows other targets to access its internal keys. 8 | struct StringCatalogParserCommand: ParsableCommand { 9 | static let configuration = CommandConfiguration( 10 | commandName: "l10n-gen", 11 | abstract: "Generate Swift code for a string catalog in any package.", 12 | usage: """ 13 | swift run l10n-gen --from /path/to/catalog.json --to /path/to/output.swift [--root ] 14 | swift run l10n-gen --package /path/to/package/ --catalog package/relative/catalog/path --target package/relative/file/path [--root ] 15 | """ 16 | ) 17 | 18 | @OptionGroup var packageOptions: PackageOptions 19 | @OptionGroup var pathOptions: PathOptions 20 | 21 | @Option var root: String = "l10n" 22 | 23 | func run() throws { 24 | print("\nGenerating code...\n") 25 | if try packageOptions.tryExecute(withRootNamespace: root) { return } 26 | if try pathOptions.tryExecute(withRootNamespace: root) { return } 27 | fatalError("No matching operation. Aborting.") 28 | } 29 | } 30 | 31 | /// These options are used when using the `--package` argument. 32 | struct PackageOptions: ParsableArguments { 33 | @Option(name: .long, help: "A command-relative path to a Swift Package.") 34 | var package: String? 35 | 36 | @Option(name: .long, help: "A package-relative path to the string catalog.") 37 | var catalog: String? 38 | 39 | @Option(name: .long, help: "A package-relative path to the target output file.") 40 | var target: String? 41 | 42 | func tryExecute( 43 | withRootNamespace root: String 44 | ) throws -> Bool { 45 | guard let package, let catalog, let target else { return false } 46 | let catalogPath = (package + catalog).cleanPath() 47 | let filePath = (package + target).cleanPath() 48 | try generateCode(from: catalogPath, to: filePath, withRootNamespace: root) 49 | return true 50 | } 51 | } 52 | 53 | /// These options are used when using the `--from` and `--to` arguments. 54 | struct PathOptions: ParsableArguments { 55 | @Option(name: .long, help: "A command-relative path to a source string catalog.") 56 | var from: String? 57 | 58 | @Option(name: .long, help: "A command-relative path to a target output file.") 59 | var to: String? 60 | 61 | func tryExecute( 62 | withRootNamespace root: String 63 | ) throws -> Bool { 64 | guard let from, let to else { return false } 65 | try generateCode(from: from, to: to, withRootNamespace: root) 66 | return true 67 | } 68 | } 69 | 70 | extension ParsableArguments { 71 | func generateCode( 72 | from catalogPath: String, 73 | to filePath: String, 74 | withRootNamespace root: String 75 | ) throws { 76 | print("Generating code from \"\(catalogPath)\" to \"\(filePath)\"...\n") 77 | let stringCatalog = try StringCatalog(path: catalogPath) 78 | let code = stringCatalog.generatePublicKeyWrappers(withRootNamespace: root) 79 | try code.write(toFile: filePath, atomically: true, encoding: .utf8) 80 | } 81 | } 82 | 83 | private extension String { 84 | func cleanPath() -> String { 85 | replacingOccurrences(of: "//", with: "/") 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Lists/ListCard.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListCard.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-04-26. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This view can be used as floating cards in lists and grids. 12 | /// 13 | /// The card can be styled with a corner radius and shadow, and can be provided 14 | /// with a list of context menu items. 15 | public struct ListCard: View { 16 | 17 | /// Create a list card. 18 | /// 19 | /// - Parameters: 20 | /// - content: The card content. 21 | public init( 22 | @ViewBuilder content: @escaping ContentBuilder 23 | ) where ContextMenuView == EmptyView { 24 | self.content = content 25 | self.contextMenu = { EmptyView() } 26 | } 27 | 28 | /// Create a list card with a context menu. 29 | /// 30 | /// - Parameters: 31 | /// - content: The card content. 32 | /// - contextMenu: The card context menu content. 33 | public init( 34 | @ViewBuilder content: @escaping ContentBuilder, 35 | @ViewBuilder contextMenu: @escaping ContextMenuBuilder 36 | ) { 37 | self.content = content 38 | self.contextMenu = contextMenu 39 | } 40 | 41 | public typealias ContentBuilder = () -> Content 42 | public typealias ContextMenuBuilder = () -> ContextMenuView 43 | 44 | @ViewBuilder 45 | private let content: ContentBuilder 46 | 47 | @ViewBuilder 48 | private let contextMenu: ContextMenuBuilder 49 | 50 | @Environment(\.listCardStyle) 51 | private var style 52 | 53 | public var body: some View { 54 | content() 55 | #if os(iOS) 56 | .contentShape(.contextMenuPreview, RoundedRectangle(cornerRadius: style.cornerRadius)) 57 | #endif 58 | .background(Color.primary.colorInvert()) 59 | .cornerRadius(style.cornerRadius) 60 | #if os(iOS) || os(macOS) || os(tvOS) 61 | .contextMenu(menuItems: contextMenu) 62 | #endif 63 | .shadow(style.shadowStyle) 64 | } 65 | } 66 | 67 | 68 | public extension ViewShadowStyle { 69 | 70 | /// The standard list card shadow style. 71 | static var listCard: Self { 72 | .init( 73 | color: .black.opacity(0.2), 74 | radius: 2, 75 | x: 0, 76 | y: 2 77 | ) 78 | } 79 | } 80 | 81 | #Preview { 82 | 83 | VStack { 84 | Group { 85 | Button {} label: { 86 | ListCard { 87 | Color.red.frame(width: 200, height: 200) 88 | } 89 | } 90 | .buttonStyle( 91 | .listCard( 92 | animation: .bouncy, 93 | pressedScale: 0.2 94 | ) 95 | ) 96 | Button {} label: { 97 | ListCard { 98 | Color.red.frame(width: 200, height: 200) 99 | } contextMenu: { 100 | Button("Preview.Button") {} 101 | } 102 | } 103 | } 104 | .buttonStyle(.listCard) 105 | .padding(50) 106 | .background(Color.gray) 107 | .cornerRadius(20) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Lists/PlainListContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlainListContent.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2023-06-01. 6 | // Copyright © 2023-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This view can be used to add a plain content view to any part of a `List`. 12 | private struct PlainListContent: View { 13 | 14 | public init( 15 | @ViewBuilder content: @escaping () -> Content 16 | ) { 17 | self.content = content 18 | } 19 | 20 | private let content: (() -> Content) 21 | 22 | public var body: some View { 23 | VStack { 24 | content() 25 | } 26 | .frame(maxWidth: .infinity) 27 | .listRowBackground(Color.clear) 28 | .prefersListRowSeparatorHidden() 29 | } 30 | } 31 | 32 | /// This view can be used to add a plain footer to a `List`. 33 | /// 34 | /// The view adds negative padding to account for spacing on certain platforms. 35 | public struct ListFooter: View { 36 | 37 | public init( 38 | @ViewBuilder content: @escaping () -> Content 39 | ) { 40 | #if os(iOS) 41 | self.padding = -10 42 | #else 43 | self.padding = 0 44 | #endif 45 | self.content = content 46 | } 47 | 48 | private let padding: Double 49 | private let content: (() -> Content) 50 | 51 | public var body: some View { 52 | PlainListContent(content: content) 53 | .padding(.top, padding) 54 | } 55 | } 56 | 57 | /// This view can be used to add a plain header to a `List`. 58 | /// 59 | /// The view adds negative padding to account for spacing on certain platforms. 60 | public struct ListHeader: View { 61 | 62 | /// Create a list header. 63 | /// 64 | /// - Parameters: 65 | /// - content: The header view. 66 | public init( 67 | @ViewBuilder content: @escaping () -> Content 68 | ) { 69 | #if os(iOS) 70 | self.padding = -10 71 | #else 72 | self.padding = 0 73 | #endif 74 | self.content = content 75 | } 76 | 77 | private let padding: Double 78 | private let content: (() -> Content) 79 | 80 | public var body: some View { 81 | PlainListContent(content: content) 82 | .padding(.bottom, padding) 83 | } 84 | } 85 | 86 | private extension View { 87 | 88 | @ViewBuilder 89 | func prefersListRowSeparatorHidden() -> some View { 90 | #if os(tvOS) || os(watchOS) 91 | self 92 | #else 93 | if #available(iOS 15.0, macOS 13.0, watchOS 9.0, *) { 94 | self.listRowSeparator(.hidden) 95 | } else { 96 | self 97 | } 98 | #endif 99 | } 100 | } 101 | 102 | #Preview { 103 | 104 | func item() -> some View { 105 | Text("Preview.Item", bundle: .module) 106 | } 107 | 108 | return VStack { 109 | List { 110 | ListHeader { 111 | Color.red.frame(square: 150) 112 | } 113 | Section { 114 | item() 115 | item() 116 | item() 117 | item() 118 | } 119 | ListFooter { 120 | Color.red.frame(square: 150) 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /scripts/release-validate-git.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Exit immediately if a command exits with a non-zero status 4 | set -e 5 | 6 | # Function to display usage information 7 | show_usage() { 8 | echo 9 | echo "This script validates the git repository for release." 10 | 11 | echo 12 | echo "Usage: $0 [-b|--branch ]" 13 | echo " -b, --branch Optional. The branch to validate (auto-detects default branch if not specified)" 14 | 15 | echo 16 | echo "This script will:" 17 | echo " * Validate that the script is run within a git repository" 18 | echo " * Validate that the current git branch matches the specified one" 19 | echo " * Validate that the git repository doesn't have any uncommitted changes" 20 | 21 | echo 22 | echo "Examples:" 23 | echo " $0" 24 | echo " $0 -b main" 25 | echo " $0 --branch develop" 26 | echo 27 | } 28 | 29 | # Function to display error message, show usage, and exit 30 | show_usage_error_and_exit() { 31 | echo 32 | local error_message="$1" 33 | echo "Error: $error_message" 34 | show_usage 35 | exit 1 36 | } 37 | 38 | # Function to display error message, and exit 39 | show_error_and_exit() { 40 | echo 41 | local error_message="$1" 42 | echo "Error: $error_message" 43 | echo 44 | exit 1 45 | } 46 | 47 | # Define argument variables 48 | BRANCH="" # Will be set to default after parsing 49 | 50 | # Parse command line arguments 51 | while [[ $# -gt 0 ]]; do 52 | case $1 in 53 | -b|--branch) 54 | shift 55 | if [[ $# -eq 0 || "$1" =~ ^- ]]; then 56 | show_usage_error_and_exit "--branch requires a branch name" 57 | fi 58 | BRANCH="$1" 59 | shift 60 | ;; 61 | -h|--help) 62 | show_usage; exit 0 ;; 63 | *) 64 | show_usage_error_and_exit "Unknown option or argument: $1" ;; 65 | esac 66 | done 67 | 68 | # If no BRANCH was provided, try to get the default branch name 69 | if [ -z "$BRANCH" ]; then 70 | FOLDER="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 71 | SCRIPT_DEFAULT_BRANCH="$FOLDER/git-default-branch.sh" 72 | 73 | if [ ! -f "$SCRIPT_DEFAULT_BRANCH" ]; then 74 | show_error_and_exit "Script not found: $SCRIPT_DEFAULT_BRANCH" 75 | fi 76 | 77 | if ! BRANCH=$("$SCRIPT_DEFAULT_BRANCH"); then 78 | show_error_and_exit "Failed to get default branch" 79 | fi 80 | fi 81 | 82 | # Start script 83 | echo 84 | echo "Validating git repository for branch '$BRANCH'..." 85 | 86 | # Check if the current directory is a Git repository 87 | if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then 88 | show_error_and_exit "Not a Git repository" 89 | fi 90 | 91 | # Verify that we're on the correct branch 92 | if ! current_branch=$(git rev-parse --abbrev-ref HEAD); then 93 | show_error_and_exit "Failed to get current branch name" 94 | fi 95 | 96 | if [ "$current_branch" != "$BRANCH" ]; then 97 | show_error_and_exit "Not on the specified branch. Current branch is '$current_branch', expected '$BRANCH'" 98 | fi 99 | 100 | # Check for uncommitted changes 101 | if [ -n "$(git status --porcelain)" ]; then 102 | show_error_and_exit "Git repository is dirty. There are uncommitted changes" 103 | fi 104 | 105 | # Complete successfully 106 | echo 107 | echo "Git repository validated successfully!" 108 | echo 109 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Views/FetchedDataView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FetchedDataView.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-10-29. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This view can present asynchronous data in a loading, fetched or failed state. 12 | /// 13 | /// By providing three views, this view will show the correct one depending on the 14 | /// state of the data: 15 | /// 16 | /// * `content` is shown when `data` has a value. 17 | /// * `loadingView` is shown when `data` is nil and loading. 18 | /// * `noDataView` is shown when `data` is nil and not loading. 19 | /// 20 | /// This lets you manage all three states with a single view. 21 | public struct FetchedDataView: View { 22 | 23 | @available(*, deprecated, message: "Use the view builder-based initializer instead.") 24 | public init( 25 | data: Model?, 26 | isLoading: Bool, 27 | loadingView: LoadingView, 28 | noDataView: NoDataView, 29 | @ViewBuilder content: @escaping (Model) -> Content 30 | ) { 31 | self.data = data 32 | self.isLoading = isLoading 33 | self.loadingView = { loadingView } 34 | self.noDataView = { noDataView } 35 | self.content = { content($0) } 36 | } 37 | 38 | public init( 39 | data: Model?, 40 | isLoading: Bool, 41 | @ViewBuilder loadingView: @escaping () -> LoadingView, 42 | @ViewBuilder noDataView: @escaping () -> NoDataView, 43 | @ViewBuilder content: @escaping (Model) -> Content 44 | ) { 45 | self.data = data 46 | self.isLoading = isLoading 47 | self.content = content 48 | self.loadingView = loadingView 49 | self.noDataView = noDataView 50 | } 51 | 52 | private let data: Model? 53 | private let isLoading: Bool 54 | private let loadingView: () -> LoadingView 55 | private let noDataView: () -> NoDataView 56 | private let content: (Model) -> Content 57 | 58 | public var body: some View { 59 | if let data = data { 60 | content(data) 61 | } else if isLoading { 62 | loadingView() 63 | } else { 64 | noDataView() 65 | } 66 | } 67 | } 68 | 69 | #Preview { 70 | 71 | struct Preview: View { 72 | 73 | let nilData: String? = nil 74 | let content: (String) -> Text = { .init($0) } 75 | let loadingView = Text("Preview.Loading") 76 | let noDataView = Text("Preview.NoData") 77 | 78 | var body: some View { 79 | if #available(iOS 17.0, tvOS 17.0, macOS 15.0, watchOS 11.0, visionOS 1.0, *) { 80 | FetchedDataView( 81 | data: nilData, 82 | isLoading: false, 83 | loadingView: { ProgressView() }, 84 | noDataView: { 85 | ContentUnavailableView( 86 | "No data", 87 | systemImage: "x.circle" 88 | ) 89 | .foregroundStyle(.red) 90 | }, 91 | content: { string in 92 | Text(string) 93 | } 94 | ) 95 | } 96 | } 97 | } 98 | 99 | return Preview() 100 | } 101 | -------------------------------------------------------------------------------- /Tests/SwiftUIKitTests/Data/MimeTypeTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MimeTypeTests.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2021-03-29. 6 | // Copyright © 2021-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUIKit 10 | import XCTest 11 | 12 | class MimeTypeTests: XCTestCase { 13 | 14 | func testAllTypesHaveValidPrefix() { 15 | MimeType.Application.allCases.forEach { 16 | let result = MimeType.application($0).id 17 | let hasValidPrefix = result.hasPrefix("application/") 18 | XCTAssertTrue(hasValidPrefix) 19 | } 20 | } 21 | 22 | func id(forAudio type: MimeType.Audio) -> String { 23 | MimeType.audio(type).id 24 | } 25 | 26 | func testIdIsValidForAllAudioTypes() { 27 | XCTAssertEqual(id(forAudio: .kar), "audio/midi") 28 | XCTAssertEqual(id(forAudio: .m4a), "audio/x-m4a") 29 | XCTAssertEqual(id(forAudio: .midi), "audio/midi") 30 | XCTAssertEqual(id(forAudio: .mp3), "audio/mpeg") 31 | XCTAssertEqual(id(forAudio: .ogg), "audio/ogg") 32 | XCTAssertEqual(id(forAudio: .ra), "audio/x-realaudio") 33 | } 34 | 35 | func id(forImage type: MimeType.Image) -> String { 36 | MimeType.image(type).id 37 | } 38 | 39 | func testIdIsValidForAllImageTypes() { 40 | XCTAssertEqual(id(forImage: .bmp), "image/x-ms-bmp") 41 | XCTAssertEqual(id(forImage: .gif), "image/gif") 42 | XCTAssertEqual(id(forImage: .ico), "image/x-icon") 43 | XCTAssertEqual(id(forImage: .jpeg), "image/jpeg") 44 | XCTAssertEqual(id(forImage: .jng), "image/x-jng") 45 | XCTAssertEqual(id(forImage: .png), "image/png") 46 | XCTAssertEqual(id(forImage: .svg), "image/svg+xml") 47 | XCTAssertEqual(id(forImage: .tiff), "image/tiff") 48 | XCTAssertEqual(id(forImage: .webp), "image/webp") 49 | XCTAssertEqual(id(forImage: .wbmp), "image/vnd.wap.wbmp") 50 | } 51 | 52 | func id(forText type: MimeType.Text) -> String { 53 | MimeType.text(type).id 54 | } 55 | 56 | func testIdIsValidForAllTextTypes() { 57 | XCTAssertEqual(id(forText: .plain), "text/plain") 58 | XCTAssertEqual(id(forText: .css), "text/css") 59 | XCTAssertEqual(id(forText: .html), "text/html") 60 | XCTAssertEqual(id(forText: .mathml), "text/mathml") 61 | XCTAssertEqual(id(forText: .xml), "text/xml") 62 | 63 | XCTAssertEqual(id(forText: .jad), "text/vnd.sun.j2me.app-descriptor") 64 | XCTAssertEqual(id(forText: .wml), "text/vnd.wap.wml") 65 | XCTAssertEqual(id(forText: .htc), "text/x-component") 66 | } 67 | 68 | func id(forVideo type: MimeType.Video) -> String { 69 | MimeType.video(type).id 70 | } 71 | 72 | func testIdIsValidForAllVideoTypes() { 73 | XCTAssertEqual(id(forVideo: .asf), "video/x-ms-asf") 74 | XCTAssertEqual(id(forVideo: .asx), "video/x-ms-asf") 75 | XCTAssertEqual(id(forVideo: .avi), "video/x-msvideo") 76 | XCTAssertEqual(id(forVideo: .flv), "video/x-flv") 77 | XCTAssertEqual(id(forVideo: .m4v), "video/x-m4v") 78 | XCTAssertEqual(id(forVideo: .mng), "video/x-mng") 79 | XCTAssertEqual(id(forVideo: .mp4), "video/mp4") 80 | XCTAssertEqual(id(forVideo: .mpeg), "video/mpeg") 81 | XCTAssertEqual(id(forVideo: .mov), "video/quicktime") 82 | XCTAssertEqual(id(forVideo: .ts), "video/mp2t") 83 | XCTAssertEqual(id(forVideo: .video3gpp), "video/3gpp") 84 | XCTAssertEqual(id(forVideo: .webm), "video/webm") 85 | XCTAssertEqual(id(forVideo: .wmv), "video/x-ms-wmv") 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/Styles/ViewShadowStyle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShadowStyle.swift 3 | // SwiftUIKit 4 | // 5 | // Created by Daniel Saidi on 2020-03-05. 6 | // Copyright © 2020-2025 Daniel Saidi. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// This styles defines shadow types, to allow strong typing. 12 | /// 13 | /// You can specify your own styles by creating new static properties, for instance: 14 | /// 15 | /// ```swift 16 | /// extension ShadowStyle { 17 | /// static let badge = Self( 18 | /// color: Color.black.opacity(0.1), 19 | /// radius: 3, 20 | /// x: 0, 21 | /// y: 2 22 | /// ) 23 | /// } 24 | /// ``` 25 | /// 26 | /// You can apply shadows with the `font(_ style:)` modifier. 27 | public struct ViewShadowStyle { 28 | 29 | public init( 30 | color: Color? = nil, 31 | radius: CGFloat = 0, 32 | x: CGFloat = 0, 33 | y: CGFloat = 0 34 | ) { 35 | self.color = color 36 | self.radius = radius 37 | self.x = x 38 | self.y = y 39 | } 40 | 41 | public let color: Color? 42 | public let radius: CGFloat 43 | public let x: CGFloat 44 | public let y: CGFloat 45 | } 46 | 47 | public extension ViewShadowStyle { 48 | 49 | /// This style applies no shadow. 50 | static var none: Self { 51 | .init(color: .clear) 52 | } 53 | 54 | /// The shadow of a badge that is attached to its parent, in a separate layer. 55 | static var badge: Self { 56 | .init(radius: 1, y: 1) 57 | } 58 | 59 | /// The shadow of a view that is elevated a bit above its parent view. 60 | static var elevated: Self { 61 | .init(radius: 3, x: 0, y: 2) 62 | } 63 | 64 | /// The shadow of a thin sticker that is attached to its parent view. 65 | static var sticker: Self { 66 | .init(radius: 0, y: 1) 67 | } 68 | } 69 | 70 | public extension View { 71 | 72 | /// Apply a ``ViewShadowStyle`` to the view. 73 | @ViewBuilder 74 | func shadow(_ style: ViewShadowStyle) -> some View { 75 | if let color = style.color { 76 | shadow( 77 | color: color, 78 | radius: style.radius, 79 | x: style.x, 80 | y: style.y 81 | ) 82 | } else { 83 | shadow( 84 | radius: style.radius, 85 | x: style.x, 86 | y: style.y 87 | ) 88 | } 89 | } 90 | } 91 | 92 | #Preview { 93 | 94 | struct Preview: View { 95 | 96 | @State 97 | private var isItemElevated = false 98 | 99 | var item: some View { 100 | RoundedRectangle(cornerRadius: 20) 101 | .foregroundColor(.white) 102 | .frame(width: 100, height: 100) 103 | } 104 | 105 | var body: some View { 106 | VStack(spacing: 20) { 107 | item.shadow(.none) 108 | item.shadow(.badge) 109 | item.shadow(.sticker) 110 | 111 | #if os(iOS) 112 | item.onTapGesture(perform: toggleElevated) 113 | .shadow(isItemElevated ? .elevated : .badge) 114 | #endif 115 | 116 | item.shadow(.elevated) 117 | } 118 | .padding() 119 | .background(Color.gray.opacity(0.4)) 120 | } 121 | 122 | func toggleElevated() { 123 | withAnimation { 124 | isItemElevated.toggle() 125 | } 126 | } 127 | } 128 | 129 | return Preview() 130 | } 131 | -------------------------------------------------------------------------------- /Sources/SwiftUIKit/SwiftUIKit.docc/SwiftUIKit.md: -------------------------------------------------------------------------------- 1 | # ``SwiftUIKit`` 2 | 3 | SwiftUIKit adds extra functionality to `SwiftUI`. 4 | 5 | 6 | 7 | ## Overview 8 | 9 | ![SwiftUIKit logo](Logo.png) 10 | 11 | SwiftUIKit is a library that adds extra functionality to Swift and SwiftUI. It contains additional components, a lot of extensions to native types, and much more. The library is divided into the namespaces found in the Topics section below. 12 | 13 | 14 | 15 | ## Installation 16 | 17 | SwiftUIKit can be installed with the Swift Package Manager: 18 | 19 | ``` 20 | https://github.com/danielsaidi/SwiftUIKit.git 21 | ``` 22 | 23 | 24 | 25 | ## Support My Work 26 | 27 | You can [become a sponsor][Sponsors] to help me dedicate more time on my various [open-source tools][OpenSource]. Every contribution, no matter the size, makes a real difference in keeping these tools free and actively developed. 28 | 29 | 30 | 31 | ## Getting Started 32 | 33 | SwiftUIKit started small, but has grown big over the years. Due to its complexity, I decided to remove the demo. Instead, have a look at the various namespaces and use the live previews to explore the SDK. 34 | 35 | I will extract parts of this SDK into separate libraries, to be able to provide better documentation & help. Have a look at [my open-source listing page][OpenSource] for some of these. 36 | 37 | 38 | 39 | ## Repository 40 | 41 | For more information, source code, etc., visit the [project repository][Repository]. 42 | 43 | 44 | 45 | ## License 46 | 47 | SwiftUIKit is available under the MIT license. 48 | 49 | 50 | 51 | ## Topics 52 | 53 | ### Colors 54 | 55 | - ``SwiftUICore/Color`` 56 | - ``ColorRepresentable`` 57 | 58 | ### Data 59 | 60 | - ``CsvParser`` 61 | - ``FetchedDataView`` 62 | - ``MimeType`` 63 | - ``StorageValue`` 64 | - ``AppStorageValue`` 65 | - ``SceneStorageValue`` 66 | 67 | ### Device 68 | 69 | - ``DeviceIdentifier`` 70 | 71 | ### Files 72 | 73 | - ``BundleFileFinder`` 74 | - ``DirectoryFileManager`` 75 | - ``DirectoryMonitor`` 76 | - ``DirectoryObservable`` 77 | - ``iCloudDocumentSync`` 78 | 79 | ### Fonts 80 | 81 | - ``SwiftUICore/Font`` 82 | - ``FontRepresentable`` 83 | 84 | ### Images 85 | 86 | - ``SwiftUICore/Image`` 87 | - ``ImageCache`` 88 | - ``ImageRepresentable`` 89 | 90 | ### Keychain 91 | 92 | - ``KeychainItemAccessibility`` 93 | - ``KeychainService`` 94 | - ``KeychainWrapper`` 95 | 96 | ### Lists 97 | 98 | - ``ListButtonGroup`` 99 | - ``ListButtonGroupStyle`` 100 | - ``ListCard`` 101 | - ``ListCardButtonStyle`` 102 | - ``ListCardStyle`` 103 | - ``ListDragHandle`` 104 | - ``ListFooter`` 105 | - ``ListHeader`` 106 | - ``ListSectionTitle`` 107 | - ``ListSelectItem`` 108 | - ``ListShelfSection`` 109 | - ``ListShelfSectionStyle`` 110 | - ``Reorderable`` 111 | - ``ReorderableForEach`` 112 | - ``SidebarListRowBackgroundModifier`` 113 | 114 | ### Previews 115 | 116 | - ``SwiftUIPreviewInspector`` 117 | 118 | ### Regex 119 | 120 | - ``ValidationRegex`` 121 | 122 | ### Sharing 123 | 124 | - ``ShareSheet`` 125 | 126 | ### Styles 127 | 128 | - ``ViewShadowStyle`` 129 | 130 | ### Text 131 | 132 | - ``SwiftUICore/Text`` 133 | - ``MultilineSubmitViewModifier`` 134 | - ``TextEditorStyle`` 135 | - ``TextFieldClearButton`` 136 | 137 | ### Views 138 | 139 | - ``SwiftUICore/View`` 140 | - ``EditableView`` 141 | 142 | 143 | 144 | [Repository]: https://github.com/danielsaidi/SwiftUIKit 145 | 146 | [Email]: mailto:daniel.saidi@gmail.com 147 | [Website]: https://danielsaidi.com 148 | [GitHub]: https://github.com/danielsaidi 149 | [OpenSource]: https://danielsaidi.com/opensource 150 | [Sponsors]: https://github.com/sponsors/danielsaidi 151 | --------------------------------------------------------------------------------