├── .github ├── FUNDING.yml └── workflows │ └── swift.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── ObjectUI │ ├── Data │ └── Object.swift │ └── View │ ├── ObjectView.swift │ └── StateObjectView.swift └── Tests └── ObjectUITests └── ObjectUITests.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [0xLeif] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-Fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: macOS-latest 9 | strategy: 10 | matrix: 11 | destination: ['platform=iOS Simulator,OS=13.1,name=iPhone 11'] 12 | xcode: ['/Applications/Xcode_12.app/Contents/Developer'] 13 | steps: 14 | - uses: actions/checkout@v1 15 | # Github Actions' machines do in fact have recent versions of Xcode, 16 | # but you may have to explicitly switch to them. We explicitly want 17 | # to use Xcode 12, so we use xcode-select to switch to it. 18 | - name: Switch to Xcode 12 19 | run: sudo xcode-select --switch /Applications/Xcode_12.app 20 | # Since we want to be running our tests from Xcode, we need to 21 | # generate an .xcodeproj file. Luckly, Swift Package Manager has 22 | # build in functionality to do so. 23 | - name: Generate xcodeproj 24 | run: swift package generate-xcodeproj 25 | # Finally, we invoke xcodebuild to run the tests on an iPhone 11 26 | # simulator. 27 | - name: Run tests 28 | run: xcodebuild test -destination 'name=iPhone 11' -scheme 'ObjectUI-Package' 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | /Package.resolved -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Zach Eriksen 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 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "ObjectUI", 8 | platforms: [ 9 | .iOS(.v14), 10 | .macOS(.v11), 11 | .watchOS(.v7), 12 | .tvOS(.v14) 13 | ], 14 | products: [ 15 | // Products define the executables and libraries a package produces, and make them visible to other packages. 16 | .library( 17 | name: "ObjectUI", 18 | targets: ["ObjectUI"]), 19 | ], 20 | dependencies: [ 21 | // Dependencies declare other packages that this package depends on. 22 | // .package(url: /* package url */, from: "1.0.0"), 23 | .package(url: "https://github.com/0xLet/WTV", from: "0.1.1"), 24 | .package(url: "https://github.com/0xLet/SURL", from: "0.1.0"), 25 | .package(url: "https://github.com/0xLet/SwiftFu", from: "1.0.1"), 26 | .package(url: "https://github.com/0xLeif/Chronicle", from: "0.2.3"), 27 | .package(url: "https://github.com/0xLeif/Task", from: "1.0.0"), 28 | .package(url: "https://github.com/0xLeif/Yarn", from: "1.0.0") 29 | ], 30 | targets: [ 31 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 32 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 33 | .target( 34 | name: "ObjectUI", 35 | dependencies: [ 36 | "WTV", 37 | "SURL", 38 | "SwiftFu", 39 | "Chronicle", 40 | "Task", 41 | "Yarn" 42 | ]), 43 | .testTarget( 44 | name: "ObjectUITests", 45 | dependencies: ["ObjectUI"]), 46 | ] 47 | ) 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## [Prefer CacheStore](https://github.com/0xOpenBytes/CacheStore) 2 | 3 | # ObjectUI 4 | 5 | *Create SwiftUI Views with any data* 6 | 7 | ## Usage 8 | ```swift 9 | import ObjectUI 10 | ``` 11 | 12 | ### Basic Examples 13 | 14 | #### ObjectView 15 | ```swift 16 | struct ContentView: View { 17 | var body: some View { 18 | ObjectView(data: "Hello, World 👋") { object in 19 | if let text = object.value(as: String.self) { 20 | Text(text) 21 | } else { 22 | ProgressView() 23 | } 24 | } 25 | } 26 | } 27 | ``` 28 | 29 | #### StateObjectView 30 | ```swift 31 | struct ContentView: View { 32 | var body: some View { 33 | StateObjectView { object in 34 | if let text = object.value(as: String.self) { 35 | Text(text) 36 | } else { 37 | ProgressView() 38 | .onAppear { 39 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 40 | object.set(value: "👋") 41 | } 42 | } 43 | } 44 | } 45 | } 46 | } 47 | ``` 48 | 49 | ### JSON Example 50 | ```swift 51 | struct ContentView: View { 52 | let json = """ 53 | { 54 | "userId": 1, 55 | "id": 2, 56 | "title": "quis ut nam facilis et officia qui", 57 | "completed": false 58 | } 59 | """ 60 | .data(using: .utf8) 61 | 62 | var body: some View { 63 | ObjectView(data: json) { object in 64 | VStack { 65 | object.userId.value(as: Int.self).map { userId in 66 | Text("\(userId)") 67 | } 68 | 69 | object.id.value(as: Int.self).map { id in 70 | Text("\(id)") 71 | } 72 | 73 | object.title.value(as: String.self).map { title in 74 | Text(title) 75 | } 76 | 77 | object.completed.value(as: Bool.self).map { completed in 78 | Text(completed.description) 79 | } 80 | } 81 | } 82 | } 83 | } 84 | ``` 85 | -------------------------------------------------------------------------------- /Sources/ObjectUI/Data/Object.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Object.swift 3 | // ObjectUI 4 | // 5 | // Created by Leif on 5/24/21. 6 | // 7 | 8 | import SwiftFu 9 | import SwiftUI 10 | 11 | @dynamicMemberLookup 12 | public class Object: FuableClass, ObservableObject { 13 | public enum ObjectVariable: String, Hashable { 14 | case value 15 | case child 16 | case array 17 | case json 18 | } 19 | 20 | /// Variables of the object 21 | @Published public var variables: [AnyHashable: Any] = [:] 22 | 23 | /// @dynamicMemberLookup 24 | public subscript(dynamicMember member: String) -> Object { 25 | variable(named: member) 26 | } 27 | 28 | // MARK: public init 29 | 30 | public init() { } 31 | public convenience init(_ closure: (Object) -> Void) { 32 | self.init() 33 | 34 | closure(self) 35 | } 36 | public init(_ value: Any? = nil, _ closure: ((Object) -> Void)? = nil) { 37 | defer { 38 | if let closure = closure { 39 | configure(closure) 40 | } 41 | } 42 | 43 | guard let value = value else { 44 | return 45 | } 46 | let unwrappedValue = unwrap(value) 47 | if let _ = unwrappedValue as? NSNull { 48 | return 49 | } 50 | if let object = unwrappedValue as? Object { 51 | consume(object) 52 | } else if let array = unwrappedValue as? [Any] { 53 | consume(Object(array: array)) 54 | } else if let dictionary = unwrappedValue as? [AnyHashable: Any] { 55 | consume(Object(dictionary: dictionary)) 56 | } else if let data = unwrappedValue as? Data { 57 | consume(Object(data: data)) 58 | } else { 59 | consume(Object().set(value: unwrappedValue)) 60 | } 61 | } 62 | 63 | // MARK: private init 64 | 65 | private init(array: [Any]) { 66 | set( 67 | variable: ObjectVariable.array.rawValue, 68 | value: array.map { Object($0) } 69 | ) 70 | } 71 | private init(dictionary: [AnyHashable: Any]) { 72 | variables = dictionary 73 | } 74 | private init(data: Data) { 75 | defer { 76 | set(variable: ObjectVariable.json.rawValue, value: String(data: data, encoding: .utf8)) 77 | set(value: data) 78 | } 79 | if let json = try? JSONSerialization.jsonObject(with: data, 80 | options: .allowFragments) as? [Any] { 81 | set(variable: ObjectVariable.array.rawValue, value: json) 82 | return 83 | } 84 | guard let json = try? JSONSerialization.jsonObject(with: data, 85 | options: .allowFragments) as? [AnyHashable: Any] else { 86 | return 87 | } 88 | consume(Object(json)) 89 | } 90 | } 91 | 92 | // MARK: public variables 93 | 94 | 95 | public extension Object { 96 | var array: [Object] { 97 | if let array = variables[ObjectVariable.array.rawValue] as? [Data] { 98 | return array.map { Object(data: $0) } 99 | } else if let array = variables[ObjectVariable.array.rawValue] as? [Any] { 100 | return array.map { value in 101 | guard let json = value as? [AnyHashable: Any] else { 102 | return Object(value) 103 | } 104 | return Object(dictionary: json) 105 | } 106 | } 107 | return [] 108 | } 109 | 110 | var child: Object { 111 | (variables[ObjectVariable.child.rawValue] as? Object) ?? Object() 112 | } 113 | 114 | var json: Object { 115 | (variables[ObjectVariable.json.rawValue] as? Object) ?? Object() 116 | } 117 | 118 | var value: Any { 119 | variables[ObjectVariable.value.rawValue] ?? Object() 120 | } 121 | } 122 | 123 | // MARK: public functions 124 | 125 | 126 | public extension Object { 127 | /// Retrieve a Value from the current object 128 | @discardableResult 129 | func variable(named: AnyHashable) -> Object { 130 | guard let value = variables[named] else { 131 | return Object() 132 | } 133 | if let array = value as? [Any] { 134 | return Object(array: array) 135 | } 136 | guard let object = value as? Object else { 137 | return Object(unwrap(value)) 138 | } 139 | return object 140 | } 141 | /// Set a named Value to the current object 142 | @discardableResult 143 | func set(variable named: AnyHashable = ObjectVariable.value.rawValue, value: Any?) -> Self { 144 | guard let value = value, 145 | (unwrap(value) as? NSNull) == nil else { 146 | return self 147 | } 148 | 149 | variables[named] = value 150 | 151 | return self 152 | } 153 | /// Modify a Value with a name to the current object 154 | @discardableResult 155 | func modify(variable named: AnyHashable = ObjectVariable.value.rawValue, modifier: (T?) -> T?) -> Self { 156 | guard let variable = variables[named], 157 | let value = variable as? T else { 158 | variables[named] = modifier(nil) 159 | 160 | return self 161 | } 162 | variables[named] = modifier(value) 163 | 164 | return self 165 | } 166 | /// Update a Value with a name to the current object 167 | @discardableResult 168 | func update(variable named: AnyHashable = ObjectVariable.value.rawValue, modifier: (T) -> T) -> Self { 169 | guard let variable = variables[named], 170 | let value = variable as? T else { 171 | return self 172 | } 173 | variables[named] = modifier(value) 174 | 175 | return self 176 | } 177 | /// Set the ChildObject with a name of `_object` to the current object 178 | @discardableResult 179 | func set(childObject object: Object) -> Self { 180 | variables[ObjectVariable.child.rawValue] = object 181 | 182 | return self 183 | } 184 | /// Set the Array with a name of `_array` to the current object 185 | @discardableResult 186 | func set(array: [Any]) -> Self { 187 | variables[ObjectVariable.array.rawValue] = array 188 | 189 | return self 190 | } 191 | 192 | @discardableResult 193 | func configure(_ closure: (Object) -> Void) -> Object { 194 | closure(self) 195 | 196 | return self 197 | } 198 | 199 | @discardableResult 200 | func consume(_ object: Object) -> Object { 201 | object.variables.forEach { (key, value) in 202 | self.set(variable: key, value: value) 203 | } 204 | 205 | return self 206 | } 207 | 208 | func value(as type: T.Type? = nil) -> T? { 209 | value as? T 210 | } 211 | 212 | func value(decodedAs type: T.Type) -> T? where T: Decodable { 213 | guard let data = value(as: Data.self) else { 214 | return nil 215 | } 216 | 217 | return try? JSONDecoder().decode(T.self, from: data) 218 | } 219 | } 220 | 221 | private extension Object { 222 | /// Unwraps the Any type 223 | func unwrap(_ value: Any) -> Any { 224 | let mValue = Mirror(reflecting: value) 225 | let isValueOptional = mValue.displayStyle != .optional 226 | let isValueEmpty = mValue.children.isEmpty 227 | if isValueOptional { return value } 228 | if isValueEmpty { return NSNull() } 229 | guard let (_, unwrappedValue) = mValue.children.first else { return NSNull() } 230 | return unwrappedValue 231 | } 232 | } 233 | 234 | extension Object: CustomStringConvertible { 235 | public var description: String { 236 | """ 237 | Object { 238 | \( 239 | variables 240 | .map { (key, value) in 241 | guard let object = value as? Object else { 242 | return "|\t* \(key): \(value) (\(type(of: value)))" 243 | } 244 | 245 | let values = object.description.split(separator: "\n") 246 | .dropFirst() 247 | 248 | if values.dropLast().isEmpty { 249 | return "|\t* \(key): Object { }" 250 | } 251 | 252 | return "|\t* \(key): Object {\n\(values.map { "|\t \($0)" }.joined(separator: "\n"))" 253 | } 254 | .joined(separator: "\n") 255 | ) 256 | } 257 | """ 258 | } 259 | } 260 | 261 | extension Object: Hashable { 262 | public static func == (lhs: Object, rhs: Object) -> Bool { 263 | lhs.description == rhs.description 264 | } 265 | 266 | public func hash(into hasher: inout Hasher) { 267 | hasher.combine(description) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /Sources/ObjectUI/View/ObjectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjectView.swift 3 | // ObjectUI 4 | // 5 | // Created by Leif on 5/24/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct ObjectView: View where Content: View { 11 | @ObservedObject private var object: Object 12 | 13 | private let content: (Object) -> Content 14 | 15 | public init( 16 | data: Any? = nil, 17 | @ViewBuilder content: @escaping (Object) -> Content 18 | ) { 19 | self.object = Object(data) 20 | self.content = content 21 | } 22 | 23 | public var body: some View { 24 | content(object) 25 | } 26 | } 27 | 28 | struct ObjectView_Previews: PreviewProvider { 29 | static var previews: some View { 30 | ObjectView(data: "Hello, World 👋") { object in 31 | if let text = object.value(as: String.self) { 32 | Text(text) 33 | } else { 34 | ProgressView() 35 | 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/ObjectUI/View/StateObjectView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ObjectView.swift 3 | // ObjectUI 4 | // 5 | // Created by Leif on 5/24/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct StateObjectView: View where Content: View { 11 | @StateObject private var object: Object = Object() 12 | 13 | private let content: (Object) -> Content 14 | 15 | public init( 16 | @ViewBuilder content: @escaping (Object) -> Content 17 | ) { 18 | self.content = content 19 | } 20 | 21 | public var body: some View { 22 | content(object) 23 | } 24 | } 25 | 26 | struct StateObjectView_Previews: PreviewProvider { 27 | static var previews: some View { 28 | StateObjectView { object in 29 | if let text = object.value(as: String.self) { 30 | Text(text) 31 | } else { 32 | ProgressView() 33 | .onAppear { 34 | DispatchQueue.main.asyncAfter(deadline: .now() + 3) { 35 | object.set(value: "👋") 36 | } 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/ObjectUITests/ObjectUITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import ObjectUI 3 | 4 | final class ObjectUITests: XCTestCase { 5 | func testExample() { 6 | enum Value: String { 7 | case text 8 | } 9 | let object = Object() 10 | object.set(variable: Value.text, value: "Hello, World!") 11 | 12 | XCTAssertEqual(object.variable(named: Value.text).value(), "Hello, World!") 13 | } 14 | 15 | func testTextVariable() { 16 | let object = Object() 17 | object.set(variable: "text", value: "Hello, World!") 18 | 19 | XCTAssertEqual(object.text.value(), "Hello, World!") 20 | } 21 | 22 | func testArray() { 23 | let json = """ 24 | [ 25 | { 26 | "userId": 19, 27 | "id": 2, 28 | "title": "quis ut nam facilis et officia qui", 29 | "completed": false 30 | }, 31 | { 32 | "userId": 1, 33 | "id": 8, 34 | "title": "quo adipisci enim quam ut ab", 35 | "completed": true 36 | } 37 | ] 38 | """ 39 | .data(using: .utf8) 40 | 41 | let object = Object(json) 42 | 43 | XCTAssertEqual(object.array.count, 2) 44 | 45 | let post = object.array[0] 46 | 47 | XCTAssertEqual(post.userId.value(), 19) 48 | XCTAssertEqual(post.id.value(), 2) 49 | XCTAssertEqual(post.title.value(), "quis ut nam facilis et officia qui") 50 | XCTAssertEqual(post.completed.value(), false) 51 | } 52 | } 53 | --------------------------------------------------------------------------------