├── .gitignore ├── .swiftlint.yml ├── LICENSE ├── Package.swift ├── README.md ├── Scripts ├── build-debug.sh ├── build-docs.sh ├── build-release.sh └── run-linter.sh ├── Sources └── SettingsKit │ ├── Keychain │ └── KeychainItem.swift │ ├── Setting Views │ ├── ButtonSetting.swift │ ├── DestructiveButtonSetting.swift │ ├── DisclosureButtonSetting.swift │ ├── EnumSetting.swift │ ├── LabelSetting.swift │ ├── LinkSetting.swift │ ├── PickerSetting.swift │ ├── PushSetting.swift │ ├── SegmentedEnumSetting.swift │ ├── SegmentedSetting.swift │ ├── SwitchSetting.swift │ └── TextFieldSetting.swift │ ├── Support Types │ └── Pickable.swift │ ├── Support Views │ ├── AppHeader.swift │ ├── SettingChevron.swift │ └── SettingIcon.swift │ ├── User Defaults │ ├── ObservableUserDefaults.swift │ └── UserDefault.swift │ └── Utilities │ └── OpenAppSettings.swift └── icon.png /.gitignore: -------------------------------------------------------------------------------- 1 | # Swift Package Manager related 2 | 3 | .DS_Store 4 | /.build 5 | /Packages 6 | xcuserdata/ 7 | /build 8 | .swiftpm 9 | 10 | # This should only be used in Swift packages 11 | /*.xcodeproj 12 | 13 | # Documentation build product 14 | 15 | /Docs/Reference 16 | 17 | # Xcode related 18 | 19 | */build/* 20 | *.pbxuser 21 | !default.pbxuser 22 | *.mode1v3 23 | !default.mode1v3 24 | *.mode2v3 25 | !default.mode2v3 26 | *.perspectivev3 27 | !default.perspectivev3 28 | xcuserdata 29 | profile 30 | *.moved-aside 31 | DerivedData 32 | .idea/ 33 | *.hmap 34 | *.xcuserstate 35 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | whitelist_rules: # only these rules will be applied 2 | - colon 3 | - comma 4 | - conditional_returns_on_newline 5 | - control_statement 6 | - empty_enum_arguments 7 | - empty_parameters 8 | - empty_parentheses_with_trailing_closure 9 | - file_length 10 | - legacy_cggeometry_functions 11 | - legacy_constant 12 | - legacy_constructor 13 | - legacy_hashing 14 | - legacy_nsgeometry_functions 15 | - line_length 16 | - class_delegate_protocol 17 | - weak_delegate 18 | - anyobject_protocol 19 | - closing_brace 20 | - closure_end_indentation 21 | - closure_parameter_position 22 | - closure_spacing 23 | - explicit_init 24 | - generic_type_name 25 | - identical_operands 26 | - implicit_getter 27 | - is_disjoint 28 | - leading_whitespace 29 | - literal_expression_end_indentation 30 | - mark 31 | - modifier_order 32 | - multiline_arguments 33 | - multiline_function_chains 34 | - multiple_closures_with_trailing_closure 35 | - operator_usage_whitespace 36 | - operator_whitespace 37 | - overridden_super_call 38 | - override_in_extension 39 | - prohibited_super_call 40 | - protocol_property_accessors_order 41 | - redundant_discardable_let 42 | - redundant_nil_coalescing 43 | - redundant_objc_attribute 44 | - redundant_optional_initialization 45 | - redundant_void_return 46 | - return_arrow_whitespace 47 | - shorthand_operator 48 | - statement_position 49 | - switch_case_alignment 50 | - syntactic_sugar 51 | - trailing_comma 52 | - trailing_semicolon 53 | - type_name 54 | - unneeded_break_in_switch 55 | - unused_enumerated 56 | - unused_optional_binding 57 | - valid_ibinspectable 58 | - vertical_parameter_alignment 59 | - vertical_parameter_alignment_on_call 60 | - vertical_whitespace 61 | - void_return 62 | - yoda_condition 63 | - array_init 64 | - attributes 65 | - compiler_protocol_init 66 | - contains_over_first_not_nil 67 | - discouraged_direct_init 68 | - discouraged_object_literal 69 | - discouraged_optional_boolean 70 | - dynamic_inline 71 | - force_try 72 | - force_unwrapping 73 | - legacy_random 74 | 75 | # The values below are not final and may need further tweaking. 76 | 77 | file_length: 78 | - 2000 #warning 79 | - 2500 #error 80 | 81 | function_body_length: 82 | - 200 #warning 83 | - 400 #error 84 | 85 | line_length: 86 | - 200 #warning 87 | - 400 #error 88 | 89 | type_body_length: 90 | - 1000 #warning 91 | - 2000 #error 92 | 93 | identifier_name: 94 | min_length: 1 #warning 95 | max_length: #warning or error 96 | warning: 100 97 | error: 150 98 | 99 | type_name: 100 | min_length: 1 101 | max_length: 80 #warning 102 | 103 | excluded: # paths to ignore during linting. Takes precedence over `included`. 104 | - External 105 | 106 | reporter: "xcode" # reporter type (xcode, json, csv, checkstyle, junit, html, emoji, sonarqube, markdown) 107 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | MIT License 3 | 4 | Copyright (c) 2019 Apparata AB 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SettingsKit", 7 | platforms: [ 8 | // Relevant platforms. 9 | .iOS(.v17), .macOS(.v14), .tvOS(.v17), .visionOS(.v1) 10 | ], 11 | products: [ 12 | .library(name: "SettingsKit", targets: ["SettingsKit"]) 13 | ], 14 | dependencies: [ 15 | // It's a good thing to keep things relatively 16 | // independent, but add any dependencies here. 17 | ], 18 | targets: [ 19 | .target( 20 | name: "SettingsKit", 21 | dependencies: [], 22 | swiftSettings: [ 23 | .define("DEBUG", .when(configuration: .debug)), 24 | .define("RELEASE", .when(configuration: .release)), 25 | .define("SWIFT_PACKAGE") 26 | ]) 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # SettingsKit 3 | 4 | ## License 5 | 6 | See the LICENSE file for licensing information. (It's the MIT license.) 7 | 8 | ## User Defaults 9 | 10 | ### `ObservableUserDefaults` 11 | 12 | Base class for observable settings objects. **NOTE:** There must not be dots in the key, or the kvo observation does not work, for some reason. 13 | 14 | **Example:** 15 | 16 | ```Swift 17 | public class Settings: ObservableUserDefaults { 18 | 19 | @UserDefault("isOnboardingEnabledSetting", default: true) 20 | public var isOnboardingEnabled: Bool 21 | 22 | @UserDefault("apiEnvironmentSetting", default: .development) 23 | public var apiEnvironment: APIEnvironment 24 | 25 | @UserDefault("isDebugLogEnabledSetting", default: false) 26 | public var isDebugLogEnabled: Bool 27 | 28 | @UserDefault("DebugLogFilename", default: "log.txt") 29 | public var logFilename: String 30 | } 31 | ``` 32 | 33 | Subscribing to UserDefaults changes: 34 | 35 | ```Swift 36 | private func subscribeToAPIEnvironmentChanges() { 37 | settings.$apiEnvironment.sink { [weak self] environment in 38 | self?.didRequestAPIEnvironmentChange(to: environment) 39 | }.store(in: &cancellables) 40 | } 41 | ``` 42 | 43 | ### `@UserDefault` 44 | 45 | Example of using the `@UserDefault` property wrapper. If you have `@AppStorage` properties in your SwiftUI views, the same keys can be used with `@UserDefault` to access the same values. 46 | 47 | **Example:** 48 | 49 | ```Swift 50 | class SomeClass { 51 | @UserDefault("THE_KEY", default: 8) 52 | var whatever: Int 53 | ``` 54 | 55 | ## Form Row Views 56 | 57 | ### `ButtonSetting` 58 | 59 | **Example:** 60 | 61 | ```Swift 62 | ButtonSetting("OK") { 63 | print("OK!") 64 | } 65 | ``` 66 | 67 | ### `DestructiveButtonSetting` 68 | 69 | Red button for destructive actions. 70 | 71 | **Example:** 72 | 73 | ```Swift 74 | DestructiveButtonSetting("Delete") { 75 | print("Destroy!") 76 | } 77 | ``` 78 | 79 | ### `LinkSetting` 80 | 81 | **Example:** 82 | 83 | ```Swift 84 | LinkSetting("Github", url: URL(string: "https://github.com")!) { 85 | print("OK!") 86 | } 87 | ``` 88 | 89 | ### `EnumSetting` 90 | 91 | Setting view for picking an enum value. 92 | 93 | The enum should be `CaseIterable` and `Pickable`, which is a typealias for the combination of `Codable`, `Identifiable`, and `CustomStringConvertible`. 94 | 95 | **Example:** 96 | 97 | ```Swift 98 | public enum APIEnvironment: String, CaseIterable, Codable, Identifiable { 99 | case development = "Development" 100 | case staging = "Staging" 101 | case production = "Production" 102 | } 103 | 104 | extension APIEnvironment: CustomStringConvertible { 105 | public var description: String { 106 | rawValue 107 | } 108 | 109 | public var id: APIEnvironment { self } 110 | } 111 | 112 | @State private var apiEnvironment: APIEnvironment 113 | 114 | EnumSetting("API Environment", selection: $apiEnvironment) 115 | ``` 116 | 117 | ### `SegmentedEnumSetting` 118 | 119 | The enum should be `CaseIterable` and `Pickable`, which is a typealias for the combination of `Codable`, `Identifiable`, and `CustomStringConvertible`. 120 | 121 | **Example:** 122 | 123 | ```Swift 124 | public enum APIEnvironment: String, CaseIterable, Codable, Identifiable { 125 | case development = "Development" 126 | case staging = "Staging" 127 | case production = "Production" 128 | } 129 | 130 | extension APIEnvironment: CustomStringConvertible { 131 | public var description: String { 132 | rawValue 133 | } 134 | 135 | public var id: APIEnvironment { self } 136 | } 137 | 138 | @State private var apiEnvironment: APIEnvironment 139 | 140 | SegmentedEnumSetting(selection: $apiEnvironment) 141 | ``` 142 | 143 | ### `SegmentedSetting` 144 | 145 | A segmented setting view for picking a value from a collection. 146 | 147 | **Example:** 148 | 149 | ```Swift 150 | SegmentedSetting("API Environment", 151 | values: APIEnvironment.allCases, 152 | selection: $settings.apiEnvironment) 153 | ``` 154 | 155 | ### `SwitchSetting` 156 | 157 | **Example:** 158 | 159 | ```Swift 160 | @State private var isLogEnabled: Bool 161 | 162 | SwitchSetting("Enable log", isOn: $isLogEnabled) 163 | ``` 164 | 165 | ### `TextFieldTextSetting` 166 | 167 | Title and text field setting. 168 | 169 | **Example:** 170 | 171 | ```Swift 172 | TextFieldSetting("Log Filename", 173 | value: $settings.logFilename) 174 | ``` 175 | 176 | ### `LabelSetting` 177 | 178 | **Example:** 179 | 180 | ```Swift 181 | LabelSetting("Name") 182 | ``` 183 | 184 | ```Swift 185 | LabelSetting("Name", value: "Steve") 186 | ``` 187 | 188 | ### `PickerSetting` 189 | 190 | A setting view for picking a value from a collection. 191 | 192 | **Example:** 193 | 194 | ```Swift 195 | PickerSetting("API Environment", 196 | values: APIEnvironment.allCases, 197 | selection: $settings.apiEnvironment) 198 | ``` 199 | -------------------------------------------------------------------------------- /Scripts/build-debug.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Change directory to where this script is located and go up one step 4 | cd "$(dirname ${BASH_SOURCE[0]})/.." 5 | 6 | swift package clean 7 | swift build --configuration debug 8 | -------------------------------------------------------------------------------- /Scripts/build-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Build reference documentation 5 | # 6 | 7 | MODULE=SettingsKit 8 | AUTHOR="Apparata AB" 9 | AUTHOR_URL=https://apparata.se 10 | GITHUB_URL=https://github.com/apparata/SettingsKit 11 | 12 | # Change directory to where this script is located and go up one step 13 | cd "$(dirname ${BASH_SOURCE[0]})/.." 14 | 15 | # The output will go in Docs/Reference/${MODULE} 16 | rm -rf "Docs/Reference/${MODULE}" 17 | mkdir -p "Docs/Reference/${MODULE}" 18 | 19 | jazzy \ 20 | --clean \ 21 | --module $MODULE \ 22 | --author $AUTHOR \ 23 | --author_url $AUTHOR_URL \ 24 | --output "Docs/Reference/${MODULE}" \ 25 | --readme "README.md" \ 26 | --theme fullwidth \ 27 | --source-directory . \ 28 | --swift-build-tool spm \ 29 | --build-tool-arguments -Xswiftc,-swift-version,-Xswiftc,5 30 | # --github_url $GITHUB_URL 31 | 32 | # To open the index page: 33 | # open "Docs/Reference/${MODULE}/index.html" 34 | -------------------------------------------------------------------------------- /Scripts/build-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Change directory to where this script is located and go up one step 4 | cd "$(dirname ${BASH_SOURCE[0]})/.." 5 | 6 | swift package clean 7 | swift build --configuration release 8 | -------------------------------------------------------------------------------- /Scripts/run-linter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Change directory to where this script is located and go up one step 4 | cd "$(dirname ${BASH_SOURCE[0]})/.." 5 | 6 | if which swiftlint >/dev/null; then 7 | swiftlint 8 | else 9 | echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint" 10 | fi 11 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Keychain/KeychainItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Apparata AB. All rights reserved. 3 | // 4 | 5 | #if canImport(Security) 6 | 7 | import Foundation 8 | import Security 9 | 10 | /// Example: 11 | /// 12 | /// ``` 13 | /// @KeychainItem("THE_TOKEN") 14 | /// var someImportantToken: String 15 | /// 16 | /// @KeychainItem("THE_OTHER_TOKEN", service: "com.whatever.tokens") 17 | /// var someOtherToken: String 18 | /// ``` 19 | @propertyWrapper 20 | public struct KeychainItem { 21 | 22 | private let account: String 23 | private let service: String? 24 | 25 | public var wrappedValue: Value? { 26 | get { 27 | copyItem(account: account) 28 | } 29 | set { 30 | if let newValue = newValue { 31 | if isExistingItem { 32 | updateItem(account: account, value: newValue) 33 | } else { 34 | addItem(account: account, value: newValue) 35 | } 36 | } else { 37 | deleteItem(account: account) 38 | } 39 | } 40 | } 41 | 42 | private var isExistingItem: Bool { 43 | return copyItem(account: account) != nil 44 | } 45 | 46 | public init(_ account: String, service: String? = nil) { 47 | self.account = account 48 | self.service = service 49 | } 50 | 51 | private func copyItem(account: String) -> Value? { 52 | var query: [String: AnyObject] = [ 53 | kSecClass as String: kSecClassGenericPassword, 54 | kSecAttrAccount as String: account as AnyObject, 55 | kSecReturnData as String: true as AnyObject, 56 | kSecMatchLimit as String: kSecMatchLimitOne 57 | ] 58 | if let service = service { 59 | query[kSecAttrService as String] = service as AnyObject 60 | } 61 | var result: AnyObject? = nil 62 | let status = SecItemCopyMatching(query as CFDictionary, &result) 63 | if status == errSecItemNotFound { 64 | return nil 65 | } 66 | guard status == 0 else { 67 | print("[KeychainItem] Copy Error: \(status)") 68 | return nil 69 | } 70 | guard let data = result as? Data else { 71 | print("[KeychainItem] Copy Error: Invalid Data") 72 | return nil 73 | } 74 | do { 75 | let value = try JSONDecoder().decode(Value.self, from: data) 76 | return value 77 | } catch { 78 | print("[KeychainItem] Copy Error: \(error.localizedDescription)") 79 | return nil 80 | } 81 | } 82 | 83 | private func updateItem(account: String, value: Value) { 84 | guard let data = try? JSONEncoder().encode(value) else { 85 | print("[KeychainItem] Add Error: Could not encode value.") 86 | return 87 | } 88 | var query: [String: AnyObject] = [ 89 | kSecClass as String: kSecClassGenericPassword, 90 | kSecAttrAccount as String: account as AnyObject 91 | ] 92 | if let service = service { 93 | query[kSecAttrService as String] = service as AnyObject 94 | } 95 | let attributes: [String: AnyObject] = [ 96 | kSecValueData as String: data as AnyObject 97 | ] 98 | let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) 99 | guard status == 0 else { 100 | print("[KeychainItem] Add Error: \(status)") 101 | return 102 | } 103 | } 104 | 105 | private func addItem(account: String, value: Value) { 106 | guard let data = try? JSONEncoder().encode(value) else { 107 | print("[KeychainItem] Add Error: Could not encode value.") 108 | return 109 | } 110 | var query: [String: AnyObject] = [ 111 | kSecClass as String: kSecClassGenericPassword, 112 | kSecAttrAccount as String: account as AnyObject, 113 | kSecValueData as String: data as AnyObject 114 | ] 115 | if let service = service { 116 | query[kSecAttrService as String] = service as AnyObject 117 | } 118 | let status = SecItemAdd(query as CFDictionary, nil) 119 | guard status == 0 else { 120 | print("[KeychainItem] Add Error: \(status)") 121 | return 122 | } 123 | } 124 | 125 | private func deleteItem(account: String) { 126 | var query: [String: AnyObject] = [ 127 | kSecClass as String: kSecClassGenericPassword, 128 | kSecAttrAccount as String: account as AnyObject 129 | ] 130 | if let service = service { 131 | query[kSecAttrService as String] = service as AnyObject 132 | } 133 | let status = SecItemDelete(query as CFDictionary) 134 | if status == errSecItemNotFound { 135 | return 136 | } 137 | guard status == 0 else { 138 | print("[KeychainItem] Delete Error: \(status)") 139 | return 140 | } 141 | } 142 | 143 | } 144 | 145 | #endif 146 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Setting Views/ButtonSetting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // MARK: - ButtonSetting 8 | 9 | public struct ButtonSetting: View { 10 | 11 | let icon: SettingIcon? 12 | 13 | let label: Label 14 | 15 | let color: Color? 16 | 17 | let action: () -> Void 18 | 19 | public var body: some View { 20 | Button(action: action) { 21 | if let icon = icon { 22 | HStack { 23 | icon 24 | label 25 | .foregroundColor(color ?? .primary) 26 | } 27 | } else { 28 | label 29 | .foregroundColor(color ?? .accentColor) 30 | .frame(maxWidth: .infinity) 31 | } 32 | } 33 | } 34 | 35 | public init(label: Label, color: Color? = nil, icon: SettingIcon? = nil, action: @escaping () -> Void) { 36 | self.label = label 37 | self.color = color 38 | self.icon = icon 39 | self.action = action 40 | } 41 | } 42 | 43 | 44 | extension ButtonSetting where Label == Text { 45 | 46 | public init(_ titleKey: LocalizedStringKey, color: Color? = nil, icon: SettingIcon? = nil, action: @escaping () -> Void) { 47 | self.label = Text(titleKey) 48 | .fontWeight(icon == nil ? .semibold : .regular) 49 | self.color = color 50 | self.icon = icon 51 | self.action = action 52 | } 53 | 54 | public init(_ title: S, color: Color? = nil, icon: SettingIcon? = nil, action: @escaping () -> Void) { 55 | self.label = Text(title) 56 | .fontWeight(icon == nil ? .semibold : .regular) 57 | self.color = color 58 | self.icon = icon 59 | self.action = action 60 | } 61 | } 62 | 63 | // MARK: - Preview 64 | 65 | struct ButtonSetting_Previews: PreviewProvider { 66 | static var previews: some View { 67 | NavigationView { 68 | Form { 69 | Section { 70 | ButtonSetting("Title") { 71 | print("Hello") 72 | } 73 | ButtonSetting("Title", icon: SettingIcon("shippingbox.fill")) { 74 | print("Hello") 75 | } 76 | ButtonSetting(label: Text("Title").fontWeight(.semibold)) { 77 | print("Hello") 78 | } 79 | } 80 | }.navigationTitle("Settings") 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Setting Views/DestructiveButtonSetting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // MARK: - DestructiveButtonSetting 8 | 9 | public struct DestructiveButtonSetting: View { 10 | 11 | let label: Label 12 | 13 | let color: Color? 14 | 15 | let action: () -> Void 16 | 17 | public var body: some View { 18 | Button(action: action) { 19 | label 20 | .frame(maxWidth: .infinity) 21 | .foregroundColor(color ?? .red) 22 | } 23 | } 24 | 25 | public init(label: Label, color: Color? = nil, action: @escaping () -> Void) { 26 | self.label = label 27 | self.color = color 28 | self.action = action 29 | } 30 | } 31 | 32 | extension DestructiveButtonSetting where Label == Text { 33 | 34 | public init(_ titleKey: LocalizedStringKey, color: Color? = nil, action: @escaping () -> Void) { 35 | self.label = Text(titleKey) 36 | .fontWeight(.semibold) 37 | self.color = color 38 | self.action = action 39 | } 40 | 41 | public init(_ title: S, color: Color? = nil, action: @escaping () -> Void) { 42 | self.label = Text(title) 43 | .fontWeight(.semibold) 44 | self.color = color 45 | self.action = action 46 | } 47 | } 48 | 49 | // MARK: - Preview 50 | 51 | struct DestructiveButtonSetting_Previews: PreviewProvider { 52 | static var previews: some View { 53 | NavigationView { 54 | Form { 55 | Section { 56 | DestructiveButtonSetting("Title") { 57 | print("Hello") 58 | } 59 | } 60 | }.navigationTitle("Settings") 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Setting Views/DisclosureButtonSetting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // MARK: - DisclosureButtonSetting 8 | 9 | public struct DisclosureButtonSetting: View { 10 | 11 | let icon: SettingIcon 12 | 13 | let label: Label 14 | 15 | let color: Color? 16 | 17 | let action: () -> Void 18 | 19 | public var body: some View { 20 | Button(action: action) { 21 | HStack { 22 | icon 23 | label 24 | .foregroundColor(color ?? .primary) 25 | Spacer() 26 | SettingChevron() 27 | } 28 | } 29 | } 30 | 31 | public init(label: Label, color: Color? = nil, icon: SettingIcon, action: @escaping () -> Void) { 32 | self.label = label 33 | self.color = color 34 | self.icon = icon 35 | self.action = action 36 | } 37 | } 38 | 39 | 40 | extension DisclosureButtonSetting where Label == Text { 41 | 42 | public init(_ titleKey: LocalizedStringKey, color: Color? = nil, icon: SettingIcon, action: @escaping () -> Void) { 43 | self.label = Text(titleKey) 44 | .fontWeight(.regular) 45 | self.color = color 46 | self.icon = icon 47 | self.action = action 48 | } 49 | 50 | public init(_ title: S, color: Color? = nil, icon: SettingIcon, action: @escaping () -> Void) { 51 | self.label = Text(title) 52 | .fontWeight(.regular) 53 | self.color = color 54 | self.icon = icon 55 | self.action = action 56 | } 57 | } 58 | 59 | // MARK: - Preview 60 | 61 | struct DisclosureButtonSetting_Previews: PreviewProvider { 62 | static var previews: some View { 63 | NavigationView { 64 | Form { 65 | Section { 66 | DisclosureButtonSetting("Title", icon: SettingIcon("shippingbox.fill")) { 67 | print("Hello") 68 | } 69 | DisclosureButtonSetting(label: Text("Title").fontWeight(.semibold), icon: SettingIcon("shippingbox.fill")) { 70 | print("Hello") 71 | } 72 | } 73 | }.navigationTitle("Settings") 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Setting Views/EnumSetting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // MARK: - EnumSetting 8 | 9 | /// Setting view for picking an enum value. 10 | /// 11 | /// The enum should be `Pickable` and `CaseIterable`. 12 | /// 13 | /// *Example:* 14 | /// 15 | /// ``` 16 | /// public enum APIEnvironment: String, CaseIterable { 17 | /// case development = "Development" 18 | /// case staging = "Staging" 19 | /// case production = "Production" 20 | /// } 21 | /// 22 | /// extension APIEnvironment: Pickable { 23 | /// public var id: APIEnvironment { self } 24 | /// public var description: String { 25 | /// rawValue 26 | /// } 27 | /// } 28 | /// ``` 29 | public struct EnumSetting: View { 30 | 31 | let icon: SettingIcon? 32 | 33 | let label: Label 34 | 35 | @Binding var selection: T 36 | 37 | public var body: some View { 38 | Picker(selection: $selection, label: HStack { 39 | icon 40 | label 41 | }) { 42 | ForEach(Array(T.allCases)) { value in 43 | Text(value.description).tag(value) 44 | } 45 | } 46 | } 47 | 48 | public init(label: Label, selection: Binding, icon: SettingIcon? = nil) { 49 | self.label = label 50 | self._selection = selection 51 | self.icon = icon 52 | } 53 | } 54 | 55 | extension EnumSetting where Label == Text { 56 | 57 | public init(_ titleKey: LocalizedStringKey, selection: Binding, icon: SettingIcon? = nil) { 58 | self.label = Text(titleKey) 59 | self._selection = selection 60 | self.icon = icon 61 | } 62 | 63 | public init(_ title: S, selection: Binding, icon: SettingIcon? = nil) { 64 | self.label = Text(title) 65 | self._selection = selection 66 | self.icon = icon 67 | } 68 | } 69 | 70 | // MARK: - Preview 71 | 72 | struct EnumSetting_Previews: PreviewProvider { 73 | 74 | enum APIEnvironment: String, Pickable, CaseIterable { 75 | case development = "Development" 76 | case staging = "Staging" 77 | case production = "Production" 78 | 79 | var id: APIEnvironment { self } 80 | var description: String { 81 | rawValue 82 | } 83 | } 84 | 85 | static var previews: some View { 86 | NavigationView { 87 | Form { 88 | Section { 89 | EnumSetting( 90 | "Title", 91 | selection: .constant(APIEnvironment.development) 92 | ) 93 | EnumSetting( 94 | "Title", 95 | selection: .constant(APIEnvironment.development), 96 | icon: SettingIcon("doc.text.fill.viewfinder") 97 | ) 98 | EnumSetting( 99 | "Title", 100 | selection: .constant(APIEnvironment.development), 101 | icon: SettingIcon("person.fill.checkmark") 102 | ) 103 | } 104 | Section { 105 | EnumSetting( 106 | label: Text("Title"), 107 | selection: .constant(APIEnvironment.development) 108 | ) 109 | EnumSetting( 110 | label: Text("Title"), 111 | selection: .constant(APIEnvironment.development), 112 | icon: SettingIcon("doc.text.fill.viewfinder") 113 | ) 114 | EnumSetting( 115 | label: Text("Title"), 116 | selection: .constant(APIEnvironment.development), 117 | icon: SettingIcon("person.fill.checkmark") 118 | ) 119 | } 120 | }.navigationTitle("Settings") 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Setting Views/LabelSetting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // MARK: - LabelSetting 8 | 9 | public struct LabelSetting: View { 10 | 11 | private let icon: SettingIcon? 12 | 13 | private let label: Label 14 | 15 | private let value: Value? 16 | 17 | public var body: some View { 18 | HStack { 19 | icon 20 | label 21 | if let value = value { 22 | Spacer() 23 | value 24 | } 25 | } 26 | } 27 | 28 | public init(label: Label, value: Value?, icon: SettingIcon? = nil) { 29 | self.label = label 30 | self.value = value 31 | self.icon = icon 32 | } 33 | } 34 | 35 | extension LabelSetting where Value == EmptyView { 36 | public init(label: Label, icon: SettingIcon? = nil) { 37 | self.label = label 38 | self.value = nil 39 | self.icon = icon 40 | } 41 | } 42 | 43 | extension LabelSetting where Label == Text, Value == Text { 44 | public init(_ title: String, value: String? = nil, icon: SettingIcon? = nil) { 45 | self.label = Text(title) 46 | self.value = value.map(Text.init) 47 | self.icon = icon 48 | } 49 | } 50 | 51 | extension LabelSetting where Label == Text, Value == Image { 52 | public init(_ title: String, value: Image, icon: SettingIcon? = nil) { 53 | self.label = Text(title) 54 | self.value = value 55 | self.icon = icon 56 | } 57 | } 58 | 59 | // MARK: - Preview 60 | 61 | struct LabelValueSetting_Previews: PreviewProvider { 62 | static var previews: some View { 63 | NavigationView { 64 | Form { 65 | Section { 66 | LabelSetting("Title") 67 | LabelSetting( 68 | "Title", 69 | icon: SettingIcon("shippingbox.fill") 70 | ) 71 | LabelSetting( 72 | "Title", 73 | icon: SettingIcon("crown.fill") 74 | ) 75 | NavigationLink(destination: Text("")) { 76 | LabelSetting("Title") 77 | } 78 | NavigationLink(destination: Text("")) { 79 | LabelSetting( 80 | "Title", 81 | icon: SettingIcon("mosaic.fill") 82 | ) 83 | } 84 | } 85 | Section { 86 | NavigationLink(destination: Text("")) { 87 | LabelSetting("Title", value: "Value") 88 | } 89 | NavigationLink(destination: Text("")) { 90 | LabelSetting("Title", 91 | value: "Value", 92 | icon: SettingIcon("bus.fill")) 93 | } 94 | } 95 | Section { 96 | LabelSetting(label: Text("Title")) 97 | LabelSetting(label: Text("Title"), 98 | icon: SettingIcon("shippingbox.fill")) 99 | LabelSetting(label: Text("Title"), 100 | value: Text("Value")) 101 | LabelSetting(label: Text("Title"), 102 | value: Text("Value"), 103 | icon: SettingIcon("shippingbox.fill")) 104 | } 105 | }.navigationTitle("Settings") 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Setting Views/LinkSetting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // MARK: - LinkSetting 8 | 9 | public struct LinkSetting: View { 10 | 11 | let icon: SettingIcon? 12 | 13 | let label: Label 14 | 15 | let color: Color? 16 | 17 | let url: URL 18 | 19 | public var body: some View { 20 | Link(destination: url) { 21 | if let icon = icon { 22 | HStack { 23 | icon 24 | label 25 | .foregroundColor(color ?? .primary) 26 | } 27 | } else { 28 | label 29 | .foregroundColor(color ?? .accentColor) 30 | .frame(maxWidth: .infinity) 31 | } 32 | } 33 | } 34 | 35 | public init(label: Label, url: URL, color: Color? = nil, icon: SettingIcon? = nil) { 36 | self.label = label 37 | self.icon = icon 38 | self.color = color 39 | self.url = url 40 | } 41 | } 42 | 43 | extension LinkSetting where Label == Text { 44 | 45 | public init(_ titleKey: LocalizedStringKey, url: URL, color: Color? = nil, icon: SettingIcon? = nil) { 46 | self.label = Text(titleKey) 47 | .fontWeight(icon == nil ? .semibold : .regular) 48 | self.color = color 49 | self.icon = icon 50 | self.url = url 51 | } 52 | 53 | public init(_ title: S, url: URL, color: Color? = nil, icon: SettingIcon? = nil) { 54 | self.label = Text(title) 55 | .fontWeight(icon == nil ? .semibold : .regular) 56 | self.color = color 57 | self.icon = icon 58 | self.url = url 59 | } 60 | } 61 | 62 | // MARK: - Preview 63 | 64 | struct LinkSetting_Previews: PreviewProvider { 65 | static var previews: some View { 66 | NavigationView { 67 | Form { 68 | Section { 69 | LinkSetting("Github", 70 | url: URL(string: "https://github.com")!) 71 | LinkSetting("Github", 72 | url: URL(string: "https://github.com")!, 73 | icon: SettingIcon("shippingbox.fill")) 74 | } 75 | Section { 76 | LinkSetting(label: Text("Github").fontWeight(.semibold), 77 | url: URL(string: "https://github.com")!) 78 | LinkSetting(label: Text("Github"), 79 | url: URL(string: "https://github.com")!, 80 | icon: SettingIcon("shippingbox.fill")) 81 | } 82 | }.navigationTitle("Settings") 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Setting Views/PickerSetting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // MARK: - PickerSetting 8 | 9 | /// A setting view for picking a value from a collection. 10 | /// 11 | /// Values must conform to `Pickable`. 12 | /// 13 | /// *Example:* 14 | /// 15 | /// ``` 16 | /// PickerSetting("API Environment", 17 | /// values: APIEnvironment.allCases, 18 | /// selection: $settings.apiEnvironment) 19 | /// ``` 20 | public struct PickerSetting: View { 21 | 22 | let icon: SettingIcon? 23 | 24 | let label: Label 25 | 26 | let values: [T] 27 | 28 | @Binding var selection: T 29 | 30 | public var body: some View { 31 | Picker(selection: $selection, label: HStack { 32 | icon 33 | label 34 | Spacer() 35 | }) { 36 | ForEach(values) { value in 37 | Text(value.description).tag(value) 38 | } 39 | } 40 | } 41 | 42 | public init(label: Label, values: [T], selection: Binding, icon: SettingIcon? = nil) { 43 | self.label = label 44 | self.values = values 45 | self._selection = selection 46 | self.icon = icon 47 | } 48 | } 49 | 50 | extension PickerSetting where Label == Text { 51 | 52 | public init(_ titleKey: LocalizedStringKey, values: [T], selection: Binding, icon: SettingIcon? = nil) { 53 | self.label = Text(titleKey) 54 | self.values = values 55 | self._selection = selection 56 | self.icon = icon 57 | } 58 | 59 | public init(_ title: S, values: [T], selection: Binding, icon: SettingIcon? = nil) { 60 | self.label = Text(title) 61 | self.values = values 62 | self._selection = selection 63 | self.icon = icon 64 | } 65 | } 66 | 67 | // MARK: - Preview 68 | 69 | struct PickerSetting_Previews: PreviewProvider { 70 | 71 | enum APIEnvironment: String, Pickable, CaseIterable { 72 | case development = "Development" 73 | case staging = "Staging" 74 | case production = "Production" 75 | 76 | var id: APIEnvironment { self } 77 | var description: String { 78 | rawValue 79 | } 80 | } 81 | 82 | static var previews: some View { 83 | NavigationView { 84 | Form { 85 | Section { 86 | PickerSetting( 87 | "Title", 88 | values: APIEnvironment.allCases, 89 | selection: .constant(.development) 90 | ) 91 | PickerSetting( 92 | "Title", 93 | values: APIEnvironment.allCases, 94 | selection: .constant(.development), 95 | icon: SettingIcon("4k.tv.fill") 96 | ) 97 | PickerSetting( 98 | "Title", 99 | values: APIEnvironment.allCases, 100 | selection: .constant(.development), 101 | icon: SettingIcon("4k.tv") 102 | ) 103 | } 104 | }.navigationTitle("Settings") 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Setting Views/PushSetting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2025 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // MARK: - PushSetting 8 | 9 | public struct PushSetting: View { 10 | 11 | private let icon: SettingIcon? 12 | 13 | private let label: Label 14 | 15 | private let color: Color? 16 | 17 | private let destination: () -> Destination 18 | 19 | public var body: some View { 20 | NavigationLink(destination: destination()) { 21 | if let icon = icon { 22 | HStack { 23 | icon 24 | label 25 | .foregroundColor(color ?? .primary) 26 | } 27 | } else { 28 | label 29 | .foregroundColor(color ?? .accentColor) 30 | .frame(maxWidth: .infinity) 31 | } 32 | } 33 | .buttonStyle(.plain) 34 | } 35 | 36 | public init( 37 | label: Label, 38 | color: Color? = nil, 39 | icon: SettingIcon? = nil, 40 | @ViewBuilder destination: @escaping () -> Destination 41 | ) { 42 | self.label = label 43 | self.color = color 44 | self.icon = icon 45 | self.destination = destination 46 | } 47 | } 48 | 49 | extension PushSetting where Label == Text { 50 | 51 | public init( 52 | _ titleKey: LocalizedStringKey, 53 | color: Color? = nil, 54 | icon: SettingIcon? = nil, 55 | @ViewBuilder destination: @escaping () -> Destination 56 | ) { 57 | self.label = Text(titleKey) 58 | .fontWeight(icon == nil ? .semibold : .regular) 59 | self.color = color 60 | self.icon = icon 61 | self.destination = destination 62 | } 63 | 64 | public init( 65 | _ title: S, 66 | color: Color? = nil, 67 | icon: SettingIcon? = nil, 68 | @ViewBuilder destination: @escaping () -> Destination 69 | ) { 70 | self.label = Text(title) 71 | .fontWeight(icon == nil ? .semibold : .regular) 72 | self.color = color 73 | self.icon = icon 74 | self.destination = destination 75 | } 76 | } 77 | 78 | // MARK: - Preview 79 | 80 | #Preview { 81 | NavigationView { 82 | Form { 83 | Section { 84 | PushSetting("Title") { 85 | Text("Destination") 86 | } 87 | PushSetting("Title", icon: SettingIcon("shippingbox.fill")) { 88 | Text("Destination") 89 | } 90 | PushSetting(label: Text("Title").fontWeight(.semibold)) { 91 | Text("Destination") 92 | } 93 | } 94 | }.navigationTitle("Settings") 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Setting Views/SegmentedEnumSetting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // MARK: - SegmentedEnumSetting 8 | 9 | /// Segmented setting view for picking an enum value. 10 | /// 11 | /// The enum should be `Pickable`. 12 | /// 13 | /// *Example:* 14 | /// 15 | /// ``` 16 | /// public enum APIEnvironment: String, CaseIterable { 17 | /// case development = "Development" 18 | /// case staging = "Staging" 19 | /// case production = "Production" 20 | /// } 21 | /// 22 | /// extension APIEnvironment: Pickable { 23 | /// public var id: APIEnvironment { self } 24 | /// public var description: String { 25 | /// rawValue 26 | /// } 27 | /// } 28 | /// 29 | /// @State var apiEnvironment: APIEnvironment = .development 30 | /// 31 | /// SegmentedEnumSetting("API Environment" 32 | /// selection: $apiEnvironment) 33 | /// 34 | /// ``` 35 | public struct SegmentedEnumSetting: View { 36 | 37 | let label: Label 38 | 39 | @Binding var selection: T 40 | 41 | public var body: some View { 42 | Picker(selection: $selection, label: label) { 43 | ForEach(Array(T.allCases)) { value in 44 | Text(value.description).tag(value) 45 | } 46 | } 47 | .pickerStyle(SegmentedPickerStyle()) 48 | } 49 | 50 | public init(label: Label, selection: Binding) { 51 | self.label = label 52 | self._selection = selection 53 | } 54 | } 55 | 56 | extension SegmentedEnumSetting where Label == Text { 57 | 58 | public init(_ titleKey: LocalizedStringKey, selection: Binding) { 59 | self.label = Text(titleKey) 60 | self._selection = selection 61 | } 62 | 63 | public init(_ title: S, selection: Binding) { 64 | self.label = Text(title) 65 | self._selection = selection 66 | } 67 | } 68 | 69 | // MARK: - Preview 70 | 71 | struct SegmentedEnumSetting_Previews: PreviewProvider { 72 | 73 | enum APIEnvironment: String, Pickable, CaseIterable { 74 | case development = "Development" 75 | case staging = "Staging" 76 | case production = "Production" 77 | 78 | var id: APIEnvironment { self } 79 | var description: String { 80 | rawValue 81 | } 82 | } 83 | 84 | static var previews: some View { 85 | NavigationView { 86 | Form { 87 | Section { 88 | SegmentedEnumSetting( 89 | "Title", 90 | selection: .constant(APIEnvironment.development)) 91 | SegmentedEnumSetting( 92 | "Title", 93 | selection: .constant(APIEnvironment.staging)) 94 | } 95 | }.navigationTitle("Settings") 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Setting Views/SegmentedSetting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // MARK: - SegmentedSetting 8 | 9 | /// A segmented setting view for picking a value from a collection. 10 | /// 11 | /// *Example:* 12 | /// 13 | /// ``` 14 | /// SegmentedSetting("API Environment", 15 | /// values: APIEnvironment.allCases, 16 | /// selection: $apiEnvironment) 17 | /// ``` 18 | public struct SegmentedSetting: View { 19 | 20 | let label: Label 21 | 22 | let values: [T] 23 | 24 | @Binding var selection: T 25 | 26 | public var body: some View { 27 | Picker(selection: $selection, label: label) { 28 | ForEach(values) { value in 29 | Text(value.description).tag(value) 30 | } 31 | } 32 | .pickerStyle(SegmentedPickerStyle()) 33 | } 34 | 35 | public init(label: Label, values: [T], selection: Binding) { 36 | self.label = label 37 | self.values = values 38 | self._selection = selection 39 | } 40 | } 41 | 42 | extension SegmentedSetting where Label == Text { 43 | 44 | public init(_ titleKey: LocalizedStringKey, values: [T], selection: Binding) { 45 | self.label = Text(titleKey) 46 | self.values = values 47 | self._selection = selection 48 | } 49 | 50 | public init(_ title: S, values: [T], selection: Binding) { 51 | self.label = Text(title) 52 | self.values = values 53 | self._selection = selection 54 | } 55 | } 56 | 57 | // MARK: - Preview 58 | 59 | struct SegmentedSetting_Previews: PreviewProvider { 60 | 61 | enum APIEnvironment: String, Pickable, CaseIterable { 62 | case development = "Development" 63 | case staging = "Staging" 64 | case production = "Production" 65 | 66 | var id: APIEnvironment { self } 67 | var description: String { 68 | rawValue 69 | } 70 | } 71 | 72 | static var previews: some View { 73 | NavigationView { 74 | Form { 75 | Section { 76 | SegmentedSetting("Title", 77 | values: APIEnvironment.allCases, 78 | selection: .constant(.development)) 79 | SegmentedSetting("Title", 80 | values: APIEnvironment.allCases, 81 | selection: .constant(.staging)) 82 | } 83 | }.navigationTitle("Settings") 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Setting Views/SwitchSetting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // MARK: - SwitchSetting 8 | 9 | public struct SwitchSetting: View { 10 | 11 | let icon: SettingIcon? 12 | 13 | let label: Label 14 | 15 | @Binding var isOn: Bool 16 | 17 | public var body: some View { 18 | Toggle(isOn: $isOn) { 19 | HStack(spacing: 8) { 20 | icon 21 | label 22 | } 23 | } 24 | } 25 | 26 | public init(label: Label, isOn: Binding, icon: SettingIcon? = nil) { 27 | self.label = label 28 | self._isOn = isOn 29 | self.icon = icon 30 | } 31 | } 32 | 33 | extension SwitchSetting where Label == Text { 34 | 35 | public init(_ titleKey: LocalizedStringKey, isOn: Binding, icon: SettingIcon? = nil) { 36 | self.label = Text(titleKey) 37 | self._isOn = isOn 38 | self.icon = icon 39 | } 40 | 41 | public init(_ title: S, isOn: Binding, icon: SettingIcon? = nil) { 42 | self.label = Text(title) 43 | self._isOn = isOn 44 | self.icon = icon 45 | } 46 | } 47 | 48 | // MARK: - Preview 49 | 50 | struct SwitchSetting_Previews: PreviewProvider { 51 | static var previews: some View { 52 | NavigationView { 53 | Form { 54 | Section { 55 | SwitchSetting("Title", isOn: .constant(false)) 56 | SwitchSetting("Title", isOn: .constant(true)) 57 | SwitchSetting( 58 | "Title", 59 | isOn: .constant(true), 60 | icon: SettingIcon("pc") 61 | ) 62 | SwitchSetting( 63 | "Title", 64 | isOn: .constant(false), 65 | icon: SettingIcon("cpu") 66 | ) 67 | } 68 | }.navigationTitle("Settings") 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Setting Views/TextFieldSetting.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | // MARK: - TextFieldSetting 8 | 9 | /// Title and text field setting. 10 | /// 11 | /// *Example:* 12 | /// 13 | /// ``` 14 | /// TextFieldSetting("Log Filename", 15 | /// value: $settings.logFilename) 16 | /// ``` 17 | public struct TextFieldSetting: View { 18 | 19 | let icon: SettingIcon? 20 | 21 | let label: Label 22 | 23 | @Binding var value: String 24 | 25 | let placeholder: String 26 | 27 | let formatter: Formatter? 28 | 29 | let onEditingChanged: (Bool) -> Void 30 | 31 | let onCommit: () -> Void 32 | 33 | public var body: some View { 34 | HStack { 35 | icon 36 | label 37 | Spacer() 38 | if let formatter = formatter { 39 | TextField(placeholder, value: $value, formatter: formatter, onEditingChanged: onEditingChanged, onCommit: onCommit) 40 | .multilineTextAlignment(.trailing) 41 | .foregroundColor(.secondary) 42 | .frame(maxWidth: 200) 43 | } else { 44 | TextField(placeholder, text: $value, onEditingChanged: onEditingChanged, onCommit: onCommit) 45 | .multilineTextAlignment(.trailing) 46 | .foregroundColor(.secondary) 47 | .frame(maxWidth: 200) 48 | } 49 | } 50 | } 51 | 52 | public init(label: Label, value: Binding, placeholder: String = "", icon: SettingIcon? = nil, formatter: Formatter? = nil, onEditingChanged: @escaping (Bool) -> Void = { _ in }, onCommit: @escaping () -> Void = { }) { 53 | self.label = label 54 | self._value = value 55 | self.placeholder = placeholder 56 | self.icon = icon 57 | self.formatter = formatter 58 | self.onEditingChanged = onEditingChanged 59 | self.onCommit = onCommit 60 | } 61 | } 62 | 63 | extension TextFieldSetting where Label == Text { 64 | 65 | public init(_ titleKey: LocalizedStringKey, value: Binding, formatter: Formatter? = nil, placeholder: String = "", icon: SettingIcon? = nil, onEditingChanged: @escaping (Bool) -> Void = { _ in }, onCommit: @escaping () -> Void = { }) { 66 | self.label = Text(titleKey) 67 | self._value = value 68 | self.formatter = formatter 69 | self.placeholder = placeholder 70 | self.icon = icon 71 | self.onEditingChanged = onEditingChanged 72 | self.onCommit = onCommit 73 | } 74 | 75 | public init(_ title: S, value: Binding, formatter: Formatter? = nil, placeholder: String = "", icon: SettingIcon? = nil, onEditingChanged: @escaping (Bool) -> Void = { _ in }, onCommit: @escaping () -> Void = { }) { 76 | self.label = Text(title) 77 | self._value = value 78 | self.formatter = formatter 79 | self.placeholder = placeholder 80 | self.icon = icon 81 | self.onEditingChanged = onEditingChanged 82 | self.onCommit = onCommit 83 | } 84 | } 85 | 86 | // MARK: - Preview 87 | 88 | struct TitleEditableTextSetting_Previews: PreviewProvider { 89 | 90 | static var previews: some View { 91 | NavigationView { 92 | Form { 93 | Section { 94 | TextFieldSetting("Title", 95 | value: .constant("Value")) 96 | TextFieldSetting("Title", 97 | value: .constant("Value"), 98 | icon: SettingIcon("command.circle.fill")) 99 | } 100 | }.navigationTitle("Settings") 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Support Types/Pickable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2021 Apparata AB. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | 7 | public typealias Pickable = Identifiable 8 | & Hashable 9 | & CustomStringConvertible 10 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Support Views/AppHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2020 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | public struct AppHeader: View { 8 | 9 | let name: String 10 | let version: String 11 | let icon: String? 12 | let iconSize: CGFloat 13 | let title: Text 14 | 15 | public var body: some View { 16 | VStack(spacing: 0) { 17 | VStack(spacing: 0) { 18 | imageOrPlaceholder 19 | .frame(width: iconSize, height: iconSize) 20 | .clipShape(RoundedRectangle(cornerRadius: iconSize / 5.7, style: .continuous)) 21 | .padding(.bottom) 22 | 23 | HStack(spacing: 6) { 24 | Text(name) 25 | .fontWeight(.semibold) 26 | .textCase(.none) 27 | .font(.title2) 28 | .foregroundColor(.primary) 29 | Text(version) 30 | .fontWeight(.regular) 31 | .textCase(.none) 32 | .font(.title2) 33 | .foregroundColor(.secondary) 34 | } 35 | } 36 | .padding() 37 | .padding([.horizontal, .bottom]) 38 | .padding(.top, 4) 39 | HStack { 40 | title 41 | Spacer() 42 | } 43 | } 44 | .frame(maxWidth: .infinity) 45 | } 46 | 47 | @ViewBuilder private var imageOrPlaceholder: some View { 48 | if let icon = icon { 49 | Image(icon) 50 | .resizable() 51 | } else { 52 | Rectangle() 53 | .foregroundColor(.blue) 54 | .overlay(Text("\(Int(iconSize)) x \(Int(iconSize))").foregroundColor(.white)) 55 | } 56 | } 57 | 58 | public init(name: String, version: String, icon: String?, iconSize: CGFloat = 120, titleKey: LocalizedStringKey) { 59 | self.name = name 60 | self.version = version 61 | self.icon = icon 62 | self.iconSize = iconSize 63 | self.title = Text(titleKey) 64 | } 65 | 66 | public init(name: String, version: String, icon: String?, iconSize: CGFloat = 120, title: S) { 67 | self.name = name 68 | self.version = version 69 | self.icon = icon 70 | self.iconSize = iconSize 71 | self.title = Text(title) 72 | } 73 | } 74 | 75 | struct AppHeader_Previews: PreviewProvider { 76 | static var previews: some View { 77 | NavigationView { 78 | Form { 79 | Section(header: AppHeader(name: "Placeholder", version: "1.0.0", icon: nil, title: "General")) { 80 | LabelSetting("Title") 81 | LabelSetting("Title", icon: SettingIcon("shippingbox.fill")) 82 | LabelSetting("Title", icon: SettingIcon("crown.fill")) 83 | NavigationLink(destination: Text("")) { 84 | LabelSetting("Title") 85 | } 86 | NavigationLink(destination: Text("")) { 87 | LabelSetting("Title", icon: SettingIcon("mosaic.fill")) 88 | } 89 | } 90 | }.navigationTitle("Settings") 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Support Views/SettingChevron.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | public struct SettingChevron: View { 8 | 9 | public init() { 10 | // 11 | } 12 | 13 | public var body: some View { 14 | Image(systemName: "chevron.right") 15 | .font(.system(size: 14.0, weight: .semibold)) 16 | .foregroundColor(.secondary) 17 | .opacity(0.5) 18 | } 19 | } 20 | 21 | #Preview { 22 | Form { 23 | HStack { 24 | Spacer() 25 | SettingChevron() 26 | } 27 | } 28 | #if os(macOS) 29 | .formStyle(.grouped) 30 | #endif 31 | } 32 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Support Views/SettingIcon.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2020 Apparata AB. All rights reserved. 3 | // 4 | 5 | import SwiftUI 6 | 7 | public struct SettingIcon: View { 8 | 9 | let icon: Image 10 | 11 | @Environment(\.settingIconStyle) private var style 12 | 13 | public var body: some View { 14 | ZStack { 15 | style.color 16 | .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) 17 | icon 18 | .imageScale(.small) 19 | .foregroundColor(style.titleColor) 20 | } 21 | .frame(width: 24, height: 24) 22 | } 23 | 24 | public init(_ systemIcon: String) { 25 | self.icon = Image(systemName: systemIcon) 26 | } 27 | 28 | public init(name: String) { 29 | self.icon = Image(name) 30 | } 31 | } 32 | 33 | // MARK: Style Modifier 34 | 35 | extension View { 36 | 37 | public func settingIconStyle(color: Color = .accentColor, titleColor: Color = .white) -> some View { 38 | let style = SettingIconStyle(color: color, titleColor: titleColor) 39 | return self.environment(\.settingIconStyle, style) 40 | } 41 | 42 | public func settingIconStyle(_ style: SettingIconStyle) -> some View { 43 | return self.environment(\.settingIconStyle, style) 44 | } 45 | } 46 | 47 | // MARK: - SettingIconStyle 48 | 49 | public struct SettingIconStyle: Hashable { 50 | public let color: Color 51 | public let titleColor: Color 52 | 53 | public init(color: Color, titleColor: Color) { 54 | self.color = color 55 | self.titleColor = titleColor 56 | } 57 | } 58 | 59 | struct SettingIconStyleKey: EnvironmentKey { 60 | static let defaultValue = SettingIconStyle( 61 | color: .accentColor, 62 | titleColor: .white 63 | ) 64 | } 65 | 66 | extension EnvironmentValues { 67 | var settingIconStyle: SettingIconStyle { 68 | get { 69 | return self[SettingIconStyleKey.self] 70 | } 71 | set { 72 | self[SettingIconStyleKey.self] = newValue 73 | } 74 | } 75 | } 76 | 77 | // MARK: - Preview 78 | 79 | struct SettingsRowIcon_Previews: PreviewProvider { 80 | static var previews: some View { 81 | SettingIcon("square.fill.text.grid.1x2") 82 | .settingIconStyle(color: .green) 83 | .previewLayout(PreviewLayout.sizeThatFits) 84 | .padding() 85 | .previewDisplayName("Example") 86 | SettingIcon("circle") 87 | .settingIconStyle(color: .blue) 88 | .previewLayout(PreviewLayout.sizeThatFits) 89 | .padding() 90 | .previewDisplayName("Example") 91 | SettingIcon("ant") 92 | .settingIconStyle(color: .orange) 93 | .previewLayout(PreviewLayout.sizeThatFits) 94 | .padding() 95 | .previewDisplayName("Example") 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Sources/SettingsKit/User Defaults/ObservableUserDefaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Apparata AB. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Combine 7 | 8 | /** 9 | Base class for observable settings objects. 10 | 11 | *Example:* 12 | 13 | ``` 14 | public class Settings: ObservableUserDefaults { 15 | 16 | @UserDefault("isOnboardingEnabledSetting", default: true) 17 | public var isOnboardingEnabled: Bool 18 | 19 | @UserDefault("apiEnvironmentSetting", default: .development) 20 | public var apiEnvironment: APIEnvironment 21 | 22 | @UserDefault("isDebugLogEnabledSetting", default: false) 23 | public var isDebugLogEnabled: Bool 24 | 25 | @UserDefault("EngineeringModeLogFilename", default: "log.txt") 26 | public var logFilename: String 27 | } 28 | ``` 29 | 30 | *Example:* 31 | 32 | ``` 33 | private func subscribeToAPIEnvironmentChanges() { 34 | settings.$apiEnvironment.sink { [weak self] environment in 35 | self?.didRequestAPIEnvironmentChange(to: environment) 36 | }.store(in: &cancellables) 37 | } 38 | ``` 39 | */ 40 | open class ObservableUserDefaults: ObservableObject { 41 | 42 | public let objectWillChange = PassthroughSubject() 43 | 44 | private var didChangeCancellable: AnyCancellable? 45 | 46 | public init() { 47 | didChangeCancellable = NotificationCenter.default 48 | .publisher(for: UserDefaults.didChangeNotification) 49 | .map { _ in () } 50 | .receive(on: DispatchQueue.main) 51 | .subscribe(objectWillChange) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/SettingsKit/User Defaults/UserDefault.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2019 Apparata AB. All rights reserved. 3 | // 4 | 5 | import Foundation 6 | import Combine 7 | 8 | /// Example: 9 | /// 10 | /// ``` 11 | /// @UserDefault("THE_KEY", default: 8) 12 | /// var whatever: Int 13 | /// ``` 14 | @propertyWrapper 15 | public class UserDefault { 16 | 17 | public let key: String 18 | 19 | public let defaultValue: T 20 | 21 | public var wrappedValue: T { 22 | get { 23 | if isBasicType { 24 | return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue 25 | } else { 26 | guard let data = UserDefaults.standard.data(forKey: key) else { 27 | return defaultValue 28 | } 29 | guard let value = try? JSONDecoder().decode(T.self, from: data) else { 30 | return defaultValue 31 | } 32 | return value 33 | } 34 | } 35 | set { 36 | if isBasicType { 37 | UserDefaults.standard.set(newValue, forKey: key) 38 | } else { 39 | guard let data = try? JSONEncoder().encode(newValue) else { 40 | print("Error: Failed to store user default value for key '\(key)'") 41 | UserDefaults.standard.removeObject(forKey: key) 42 | return 43 | } 44 | UserDefaults.standard.set(data, forKey: key) 45 | } 46 | } 47 | } 48 | 49 | public var projectedValue: AnyPublisher { 50 | userDefaultObserver.publisher 51 | .map { self.wrappedValue } 52 | .eraseToAnyPublisher() 53 | } 54 | 55 | private var isBasicType: Bool { 56 | T.self == Bool.self 57 | || T.self == Float.self 58 | || T.self == Double.self 59 | || T.self == Int.self 60 | || T.self == Int32.self 61 | || T.self == Int16.self 62 | || T.self == String.self 63 | || T.self == URL.self 64 | || T.self == Date.self 65 | || T.self == Data.self 66 | } 67 | 68 | private let userDefaultObserver: UserDefaultObserver 69 | 70 | public init(_ key: String, `default` defaultValue: T) { 71 | self.key = key 72 | self.defaultValue = defaultValue 73 | userDefaultObserver = UserDefaultObserver(key: key) 74 | } 75 | 76 | } 77 | 78 | fileprivate class UserDefaultObserver: NSObject { 79 | 80 | private var kvoContext = 0 // Arbitrary value, does not matter 81 | 82 | private let key: String 83 | 84 | private let subject = PassthroughSubject<(), Never>() 85 | 86 | var publisher: AnyPublisher<(), Never> { 87 | subject.eraseToAnyPublisher() 88 | } 89 | 90 | init(key: String) { 91 | self.key = key 92 | super.init() 93 | startObserving() 94 | } 95 | 96 | deinit { 97 | stopObserving() 98 | } 99 | 100 | private func startObserving() { 101 | UserDefaults.standard.addObserver(self, forKeyPath: key, options: [.new], context: &kvoContext) 102 | } 103 | 104 | private func stopObserving() { 105 | UserDefaults.standard.removeObserver(self, forKeyPath: key, context: &kvoContext) 106 | } 107 | 108 | override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { 109 | if context == &kvoContext { 110 | subject.send(()) 111 | } else { 112 | super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /Sources/SettingsKit/Utilities/OpenAppSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2016 Apparata AB. All rights reserved. 3 | // 4 | 5 | #if canImport(UIKit) 6 | 7 | import UIKit 8 | 9 | @available(iOSApplicationExtension, unavailable) 10 | public func openAppSettings(completionHandler: ((_ success: Bool) -> Void)? = nil) { 11 | guard let appSettingsURL = URL(string: UIApplication.openSettingsURLString) else { 12 | completionHandler?(false) 13 | return 14 | } 15 | guard UIApplication.shared.canOpenURL(appSettingsURL) else { 16 | completionHandler?(false) 17 | return 18 | } 19 | UIApplication.shared.open(appSettingsURL, options: [:], completionHandler: completionHandler) 20 | } 21 | 22 | #endif 23 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apparata/SettingsKit/a3de3eb89074d0a9f480b782eff81d96b1eb86e0/icon.png --------------------------------------------------------------------------------