├── .spi.yml ├── .gitignore ├── .swiftpm ├── xcode │ ├── package.xcworkspace │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ ├── QueryableTests.xcscheme │ │ └── Queryable.xcscheme └── QueryableTests.xctestplan ├── Package.swift ├── CHANGELOG.md ├── LICENSE ├── Tests └── QueryableTests │ ├── Error.swift │ ├── EdgeCaseTests.swift │ ├── BasicTests.swift │ └── ConflictResolutionTests.swift ├── MIGRATIONS.md ├── Sources └── Queryable │ ├── Handlers │ ├── Queryable+Conditional.swift │ ├── Queryable+Closure.swift │ ├── Queryable+Sheet.swift │ ├── Queryable+FullscreenCover.swift │ ├── Queryable+Overlay.swift │ ├── Queryable+ConfirmationDialog.swift │ └── Queryable+Alert.swift │ ├── QueryObservation.swift │ ├── QueryError.swift │ ├── QueryConflictPolicy.swift │ ├── Logger.swift │ ├── Helper │ └── StableItemContainerView.swift │ ├── QueryResolver.swift │ └── Queryable.swift ├── CODE_OF_CONDUCT.md └── README.md /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [Queryable] 5 | platform: ios 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/QueryableTests.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "5CCFDF89-BE72-45C1-9377-DF7D60125122", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "defaultTestExecutionTimeAllowance" : 60, 13 | "maximumTestExecutionTimeAllowance" : 60, 14 | "testTimeoutsEnabled" : true 15 | }, 16 | "testTargets" : [ 17 | { 18 | "parallelizable" : true, 19 | "target" : { 20 | "containerPath" : "container:", 21 | "identifier" : "QueryableTests", 22 | "name" : "QueryableTests" 23 | } 24 | } 25 | ], 26 | "version" : 1 27 | } 28 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Queryable", 7 | platforms: [ 8 | .iOS(.v15), 9 | .macOS(.v12), 10 | .watchOS(.v8), 11 | .tvOS(.v15) 12 | ], 13 | products: [ 14 | .library( 15 | name: "Queryable", 16 | targets: ["Queryable"]), 17 | ], 18 | dependencies: [ 19 | ], 20 | targets: [ 21 | .target( 22 | name: "Queryable", 23 | dependencies: [] 24 | ), 25 | .testTarget( 26 | name: "QueryableTests", 27 | dependencies: ["Queryable"] 28 | ) 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.0.0 4 | 5 | ### New 6 | 7 | - **Breaking:** The `@Queryable` property wrapper has been replaced by a `Queryable` class conforming to `ObservableObject`. This is an unfortunate breaking change, but it allows you to define Queryables literally *anywhere* in your app, i.e. in your views, view models or any other class your views have access to in some way. Please see the [Migration Guide](https://github.com/SwiftedMind/Queryable/blob/main/MIGRATIONS.md) for instructions on how to modify your existing code base. I'm sorry for the inconvenience of this, but I do believe it's going to be worth it. 8 | 9 | - Added Unit Tests to reduce the chance of introducing bugs or broken functionality when making future modifications. The test suite will be continuously expanded. 10 | 11 | ### Changed 12 | 13 | - **Breaking:** The logger configuration call has moved outside the generic class `Queryable` class to get rid off the awkward call `Queryable.configureLog()`. The logger can now be configured via `QueryableLogger.configure()`. 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dennis Müller 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/QueryableTests/Error.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import Foundation 24 | 25 | struct TestError: Error {} 26 | struct UnexpectedBehavior: Error {} 27 | -------------------------------------------------------------------------------- /MIGRATIONS.md: -------------------------------------------------------------------------------- 1 | # Migrations 2 | 3 | ## From 1.x.x to 2.x.x 4 | 5 | ### Replace All @Queryable Property Wrapper Instances 6 | 7 | The `@Queryable` property wrapper has been replaced by a `Queryable` class conforming to `ObservableObject` that you can define anywhere, *inside* as well as *outside* the SwiftUI environment (e.g. in a view model or some other class a view has a access to). 8 | 9 | In all your views, replace the following line: 10 | 11 | ```swift 12 | @Queryable var myQueryable 13 | ``` 14 | 15 | with this: 16 | 17 | ```swift 18 | @StateObject var myQueryable = Queryable 19 | ``` 20 | 21 | The `@StateObject` is only there to let SwiftUI handle the lifecycle of the object, it serves no other purpose. 22 | 23 | If you pass a reference of a Queryable down the view hierarchy, replace this: 24 | 25 | ```swift 26 | var myQueryable: Queryable.Trigger 27 | ``` 28 | 29 | with this: 30 | 31 | ```swift 32 | var myQueryable Queryable 33 | ``` 34 | 35 | All the calls to the `.queryable[...]` view modifiers will still work as before, no changes are needed. 36 | 37 | ### Replace Logger Configuration 38 | 39 | The logger configuration call has moved outside the generic class. Replace the following line: 40 | 41 | ```swift 42 | Queryable.configureLog() 43 | ``` 44 | 45 | with this: 46 | 47 | ```swift 48 | QueryableLogger.configure() 49 | ``` 50 | -------------------------------------------------------------------------------- /Sources/Queryable/Handlers/Queryable+Conditional.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IfQueryable.swift 3 | // 4 | // 5 | // Created by Kai Quan Tay on 12/1/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct WithQuery: View { 11 | @ObservedObject private var queryable: Queryable 12 | private var queryContent: (_ item: Item, _ query: QueryResolver) -> QueryContent 13 | 14 | public init( 15 | _ queryable: Queryable, 16 | @ViewBuilder queryContent: @escaping (_ item: Item, _ query: QueryResolver) -> QueryContent 17 | ) { 18 | self.queryable = queryable 19 | self.queryContent = queryContent 20 | } 21 | 22 | public init( 23 | _ queryable: Queryable, 24 | @ViewBuilder queryContent: @escaping (_ query: QueryResolver) -> QueryContent 25 | ) where Item == Void { 26 | self.queryable = queryable 27 | self.queryContent = { _, query in queryContent(query) } 28 | } 29 | 30 | public var body: some View { 31 | if let initialItemContainer = queryable.itemContainer { 32 | StableItemContainerView(itemContainer: initialItemContainer) { itemContainer in 33 | queryContent(itemContainer.item, initialItemContainer.resolver) 34 | .onDisappear { 35 | queryable.autoCancelContinuation(id: itemContainer.id, reason: .presentationEnded) 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Queryable/QueryObservation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | public struct QueryObservation: Sendable where Input: Sendable, Result: Sendable { 24 | /// The unique id of the current query. 25 | var queryId: String 26 | 27 | /// The input value of the query. 28 | public var input: Input 29 | 30 | /// The `QueryResolver` that you can use to resolve the query. 31 | public var resolver: QueryResolver 32 | } 33 | -------------------------------------------------------------------------------- /Sources/Queryable/QueryError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import Foundation 24 | 25 | /// An error indicating the cancellation of a query. 26 | public struct QueryCancellationError: Swift.Error, CustomStringConvertible { 27 | 28 | public var reason: String? 29 | public var description: String { 30 | reason ?? String(describing: self) 31 | } 32 | 33 | public init(reason: String? = nil) { 34 | self.reason = reason 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /Sources/Queryable/QueryConflictPolicy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import Foundation 24 | 25 | /// A query conflict resolving strategy for situations in which multiple queries are started at the same time. 26 | public enum QueryConflictPolicy { 27 | 28 | /// A query conflict resolving strategy that cancels the previous, ongoing query to allow the new query to continue. 29 | case cancelPreviousQuery 30 | 31 | /// A query conflict resolving strategy that cancels the new query to allow the previous, ongoing query to continue. 32 | case cancelNewQuery 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Queryable/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import Foundation 24 | import OSLog 25 | 26 | fileprivate(set) var logger: Logger = .init(OSLog.disabled) 27 | 28 | public struct QueryableLogger { 29 | /// Configures and enables a logger that prints out log messages for events inside the Queryable framework. 30 | /// 31 | /// This can be useful for debugging. 32 | /// - Parameter subsystem: The subsystem. If none is provided, the bundle's identifier will try to be used and if it is specifically set to `nil`, then `Queryable` will be used. 33 | public static func configure(inSubsystem subsystem: String? = Bundle.main.bundleIdentifier) { 34 | logger = .init(subsystem: subsystem ?? "Queryable", category: "Queryable") 35 | } 36 | 37 | private init() {} 38 | } 39 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/QueryableTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 18 | 19 | 20 | 21 | 24 | 30 | 31 | 32 | 33 | 34 | 44 | 45 | 51 | 52 | 54 | 55 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /Tests/QueryableTests/EdgeCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | 24 | import XCTest 25 | @testable import Queryable 26 | 27 | @MainActor 28 | final class EdgeCaseTests: XCTestCase { 29 | 30 | private let firstQueryId: String = "firstQueryId" 31 | private let secondQueryId: String = "secondQueryId" 32 | 33 | override func setUp() async throws { 34 | executionTimeAllowance = 5 35 | continueAfterFailure = false 36 | } 37 | 38 | func testDoubleAnswer() async throws { 39 | let queryable = Queryable() 40 | 41 | let task = Task { 42 | for await observation in queryable.queryObservation { 43 | observation.resolver.answer(with: true) 44 | observation.resolver.answer(with: false) // Queryable should ignore this and also don't crash 45 | return 46 | } 47 | } 48 | 49 | do { 50 | let trueResult = try await queryable.query() 51 | XCTAssertTrue(trueResult) 52 | } catch { 53 | XCTFail() 54 | } 55 | 56 | await task.value 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/Queryable/Helper/StableItemContainerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import SwiftUI 24 | 25 | /// A SwiftUI view that takes an item container provided at initialization and maintains its 26 | /// initial state for the lifetime of the view. This view can be used to ensure a particular 27 | /// state remains constant while working with SwiftUI views, which might rebuild or reevaluate 28 | /// their content at different times. 29 | struct StableItemContainerView: View where Input: Sendable, Result: Sendable { 30 | 31 | /// A private state property to store the initial item container. 32 | @State private var itemContainer: Queryable.ItemContainer 33 | 34 | /// A closure that defines the content of the view, accepting the item container as its argument. 35 | private let content: (_ itemContainer: Queryable.ItemContainer) -> Content 36 | 37 | /// Initializes a new `StableItemContainerView` with the given item container and a closure 38 | /// that defines the content of the view. 39 | /// 40 | /// - Parameters: 41 | /// - itemContainer: The item container that will be provided to the content of the view 42 | /// and maintained for the view's lifetime. 43 | /// - content: A closure that defines the content of the view, which accepts the item container 44 | /// as its argument. 45 | init( 46 | itemContainer: Queryable.ItemContainer, 47 | @ViewBuilder content: @escaping (_ itemContainer: Queryable.ItemContainer) -> Content 48 | ) { 49 | _itemContainer = .init(initialValue: itemContainer) 50 | self.content = content 51 | } 52 | 53 | var body: some View { 54 | content(itemContainer) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Queryable/QueryResolver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import Foundation 24 | 25 | /// A type that lets you answer a query made by a call to ``Queryable/Queryable/Trigger/query()``. 26 | @MainActor 27 | public struct QueryResolver: Sendable { 28 | 29 | private let answerHandler: (Result) -> Void 30 | private let cancelHandler: (Error) -> Void 31 | 32 | /// A type that lets you answer a query made by a call to ``Queryable/Queryable/Trigger/query()``. 33 | init( 34 | answerHandler: @escaping (Result) -> Void, 35 | errorHandler: @escaping (Error) -> Void 36 | ) { 37 | self.answerHandler = answerHandler 38 | self.cancelHandler = errorHandler 39 | } 40 | 41 | /// Answers the query with a result. 42 | /// - Parameter result: The result of the query. 43 | public func answer(with result: Result) { 44 | answerHandler(result) 45 | } 46 | 47 | /// Answers the query with an optional result. If it is `nil`, this will call ``Queryable/QueryResolver/cancelQuery()``. 48 | /// - Parameter result: The result of the query, as an optional. 49 | public func answer(withOptional optionalResult: Result?) { 50 | if let optionalResult { 51 | answerHandler(optionalResult) 52 | } else { 53 | cancelQuery() 54 | } 55 | } 56 | 57 | /// Answers the query. 58 | public func answer() where Result == Void { 59 | answerHandler(()) 60 | } 61 | 62 | /// Answers the query by throwing an error. 63 | /// - Parameter error: The error to throw. 64 | public func answer(throwing error: Error) { 65 | cancelHandler(error) 66 | } 67 | 68 | /// Cancels the query by throwing a ``Queryable/QueryCancellationError`` error. 69 | public func cancelQuery() { 70 | cancelHandler(QueryCancellationError()) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/Queryable/Handlers/Queryable+Closure.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import SwiftUI 24 | 25 | /// Helper type allowing for `nil` checks in SwiftUI without losing the checked item. 26 | /// 27 | /// This allows to support item types that are not `Equatable`. 28 | private struct NilEquatableWrapper: Equatable { 29 | let wrappedValue: WrappedValue? 30 | 31 | init(_ wrappedValue: WrappedValue?) { 32 | self.wrappedValue = wrappedValue 33 | } 34 | 35 | static func ==(lhs: NilEquatableWrapper, rhs: NilEquatableWrapper) -> Bool { 36 | if lhs.wrappedValue == nil { 37 | return rhs.wrappedValue == nil 38 | } else { 39 | return rhs.wrappedValue != nil 40 | } 41 | } 42 | } 43 | 44 | private struct CustomActionModifier: ViewModifier { 45 | @ObservedObject var queryable: Queryable 46 | var action: (_ item: Item, _ query: QueryResolver) -> Void 47 | 48 | func body(content: Content) -> some View { 49 | content 50 | .onChange(of: NilEquatableWrapper(queryable.itemContainer)) { wrapper in 51 | if let itemContainer = wrapper.wrappedValue { 52 | action(itemContainer.item, itemContainer.resolver) 53 | } 54 | } 55 | } 56 | } 57 | 58 | public extension View { 59 | 60 | @MainActor func queryableClosure( 61 | controlledBy queryable: Queryable, 62 | block: @escaping (_ item: Item, _ query: QueryResolver) -> Void 63 | ) -> some View { 64 | modifier(CustomActionModifier(queryable: queryable, action: block)) 65 | } 66 | 67 | @MainActor func queryableClosure( 68 | controlledBy queryable: Queryable, 69 | block: @escaping (_ query: QueryResolver) -> Void 70 | ) -> some View { 71 | modifier(CustomActionModifier(queryable: queryable) { _, query in 72 | block(query) 73 | }) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/Queryable/Handlers/Queryable+Sheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import SwiftUI 24 | 25 | private struct SheetModifier: ViewModifier { 26 | @ObservedObject private var queryable: Queryable 27 | private var onDismiss: (() -> Void)? 28 | private var queryContent: (_ item: Item, _ query: QueryResolver) -> QueryContent 29 | 30 | public init( 31 | controlledBy queryable: Queryable, 32 | onDismiss: (() -> Void)? = nil, 33 | @ViewBuilder queryContent: @escaping (_ item: Item, _ query: QueryResolver) -> QueryContent 34 | ) { 35 | self.queryable = queryable 36 | self.onDismiss = onDismiss 37 | self.queryContent = queryContent 38 | } 39 | 40 | func body(content: Content) -> some View { 41 | content 42 | .sheet(item: $queryable.itemContainer, onDismiss: onDismiss) { initialItemContainer in 43 | StableItemContainerView(itemContainer: initialItemContainer) { itemContainer in 44 | queryContent(itemContainer.item, itemContainer.resolver) 45 | .onDisappear { 46 | queryable.autoCancelContinuation(id: itemContainer.id, reason: .presentationEnded) 47 | } 48 | } 49 | } 50 | } 51 | } 52 | 53 | public extension View { 54 | 55 | @MainActor 56 | func queryableSheet( 57 | controlledBy queryable: Queryable, 58 | onDismiss: (() -> Void)? = nil, 59 | @ViewBuilder content: @escaping (_ item: Item, _ query: QueryResolver) -> Content 60 | ) -> some View { 61 | modifier(SheetModifier(controlledBy: queryable, onDismiss: onDismiss, queryContent: content)) 62 | } 63 | 64 | @MainActor 65 | func queryableSheet( 66 | controlledBy queryable: Queryable, 67 | onDismiss: (() -> Void)? = nil, 68 | @ViewBuilder content: @escaping (_ query: QueryResolver) -> Content 69 | ) -> some View { 70 | modifier(SheetModifier(controlledBy: queryable, onDismiss: onDismiss) { _, query in 71 | content(query) 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/Queryable/Handlers/Queryable+FullscreenCover.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import SwiftUI 24 | 25 | private struct FullScreenCoverModifier: ViewModifier { 26 | @ObservedObject private var queryable: Queryable 27 | private var onDismiss: (() -> Void)? 28 | private var queryContent: (_ item: Item, _ query: QueryResolver) -> QueryContent 29 | 30 | public init( 31 | controlledBy queryable: Queryable, 32 | onDismiss: (() -> Void)? = nil, 33 | @ViewBuilder queryContent: @escaping (_ item: Item, _ query: QueryResolver) -> QueryContent 34 | ) { 35 | self.queryable = queryable 36 | self.onDismiss = onDismiss 37 | self.queryContent = queryContent 38 | } 39 | 40 | func body(content: Content) -> some View { 41 | content 42 | .fullScreenCover(item: $queryable.itemContainer, onDismiss: onDismiss) { initialItemContainer in 43 | StableItemContainerView(itemContainer: initialItemContainer) { itemContainer in 44 | queryContent(itemContainer.item, itemContainer.resolver) 45 | .onDisappear { 46 | queryable.autoCancelContinuation(id: itemContainer.id, reason: .presentationEnded) 47 | } 48 | } 49 | } 50 | } 51 | } 52 | 53 | public extension View { 54 | 55 | @MainActor 56 | func queryableFullScreenCover( 57 | controlledBy queryable: Queryable, 58 | onDismiss: (() -> Void)? = nil, 59 | @ViewBuilder content: @escaping (_ item: Item, _ query: QueryResolver) -> Content 60 | ) -> some View { 61 | modifier(FullScreenCoverModifier(controlledBy: queryable, onDismiss: onDismiss, queryContent: content)) 62 | } 63 | 64 | @MainActor 65 | func queryableFullScreenCover( 66 | controlledBy queryable: Queryable, 67 | onDismiss: (() -> Void)? = nil, 68 | @ViewBuilder content: @escaping (_ query: QueryResolver) -> Content 69 | ) -> some View { 70 | modifier(FullScreenCoverModifier(controlledBy: queryable, onDismiss: onDismiss) { _, query in 71 | content(query) 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/Queryable.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 48 | 54 | 55 | 56 | 57 | 58 | 68 | 69 | 75 | 76 | 82 | 83 | 84 | 85 | 87 | 88 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /Sources/Queryable/Handlers/Queryable+Overlay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import SwiftUI 24 | 25 | private struct OverlayModifier: ViewModifier { 26 | @ObservedObject private var queryable: Queryable 27 | private var animation: Animation? = nil 28 | private var alignment: Alignment = .center 29 | private var queryContent: (_ item: Item, _ query: QueryResolver) -> QueryContent 30 | 31 | public init( 32 | controlledBy queryable: Queryable, 33 | animation: Animation? = nil, 34 | alignment: Alignment = .center, 35 | @ViewBuilder queryContent: @escaping (_ item: Item, _ query: QueryResolver) -> QueryContent 36 | ) { 37 | self.queryable = queryable 38 | self.animation = animation 39 | self.alignment = alignment 40 | self.queryContent = queryContent 41 | } 42 | 43 | func body(content: Content) -> some View { 44 | content 45 | .overlay(alignment: alignment) { 46 | ZStack { 47 | if let initialItemContainer = queryable.itemContainer { 48 | StableItemContainerView(itemContainer: initialItemContainer) { itemContainer in 49 | queryContent(itemContainer.item, initialItemContainer.resolver) 50 | .onDisappear { 51 | queryable.autoCancelContinuation(id: itemContainer.id, reason: .presentationEnded) 52 | } 53 | } 54 | } 55 | } 56 | .animation(animation, value: queryable.itemContainer == nil) 57 | } 58 | } 59 | } 60 | 61 | public extension View { 62 | 63 | @MainActor 64 | func queryableOverlay( 65 | controlledBy queryable: Queryable, 66 | animation: Animation? = nil, 67 | alignment: Alignment = .center, 68 | @ViewBuilder content: @escaping (_ item: Item, _ query: QueryResolver) -> Content 69 | ) -> some View { 70 | modifier(OverlayModifier(controlledBy: queryable, animation: animation, alignment: alignment, queryContent: content)) 71 | } 72 | 73 | @MainActor 74 | func queryableOverlay( 75 | controlledBy queryable: Queryable, 76 | animation: Animation? = nil, 77 | alignment: Alignment = .center, 78 | @ViewBuilder content: @escaping (_ query: QueryResolver) -> Content 79 | ) -> some View { 80 | modifier(OverlayModifier(controlledBy: queryable, animation: animation, alignment: alignment) { _, query in 81 | content(query) 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/Queryable/Handlers/Queryable+ConfirmationDialog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import SwiftUI 24 | 25 | private struct ConfirmationDialogModifier: ViewModifier { 26 | @State private var ids: [String] = [] 27 | 28 | @ObservedObject var queryable: Queryable 29 | var title: String 30 | @ViewBuilder var actions: (_ item: Item, _ query: QueryResolver) -> Actions 31 | @ViewBuilder var message: (_ item: Item) -> Message 32 | 33 | func body(content: Content) -> some View { 34 | content 35 | .background { 36 | if let initialItemContainer = queryable.itemContainer { 37 | ZStack { 38 | StableItemContainerView(itemContainer: initialItemContainer) { itemContainer in 39 | Color.clear 40 | .confirmationDialog( 41 | title, 42 | isPresented: .constant(true) 43 | ) { 44 | actions(itemContainer.item, itemContainer.resolver) 45 | } message: { 46 | message(itemContainer.item) 47 | .onDisappear { 48 | if let id = ids.first { 49 | queryable.autoCancelContinuation(id: id, reason: .presentationEnded) 50 | ids.removeFirst() 51 | } 52 | } 53 | } 54 | } 55 | .onAppear { ids.append(initialItemContainer.id) } 56 | } 57 | .id(initialItemContainer.id) 58 | } 59 | } 60 | } 61 | } 62 | 63 | public extension View { 64 | 65 | @MainActor 66 | func queryableConfirmationDialog( 67 | controlledBy queryable: Queryable, 68 | title: String, 69 | @ViewBuilder actions: @escaping (_ item: Item, _ query: QueryResolver) -> Actions, 70 | @ViewBuilder message: @escaping (_ item: Item) -> Message 71 | ) -> some View { 72 | modifier(ConfirmationDialogModifier(queryable: queryable, title: title, actions: actions, message: message)) 73 | } 74 | 75 | @MainActor 76 | func queryableConfirmationDialog( 77 | controlledBy queryable: Queryable, 78 | title: String, 79 | @ViewBuilder actions: @escaping (_ query: QueryResolver) -> Actions, 80 | @ViewBuilder message: @escaping () -> Message 81 | ) -> some View { 82 | modifier( 83 | ConfirmationDialogModifier(queryable: queryable, title: title) { _, query in 84 | actions(query) 85 | } message: { _ in 86 | message() 87 | } 88 | ) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/Queryable/Handlers/Queryable+Alert.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import SwiftUI 24 | 25 | private struct QueryableAlertModifier: ViewModifier { 26 | @State private var ids: [String] = [] 27 | 28 | @ObservedObject var queryable: Queryable 29 | var title: String 30 | @ViewBuilder var actions: (_ item: Item, _ query: QueryResolver) -> Actions 31 | @ViewBuilder var message: (_ item: Item) -> Message 32 | 33 | func body(content: Content) -> some View { 34 | content 35 | .background { 36 | if let initialItemContainer = queryable.itemContainer { 37 | ZStack { 38 | StableItemContainerView(itemContainer: initialItemContainer) { itemContainer in 39 | Color.clear 40 | .alert( 41 | title, 42 | isPresented: .constant(true) 43 | ) { 44 | actions(itemContainer.item, itemContainer.resolver) 45 | } message: { 46 | message(itemContainer.item) 47 | .onDisappear { 48 | if let id = ids.first { 49 | queryable.autoCancelContinuation(id: id, reason: .presentationEnded) 50 | ids.removeFirst() 51 | } 52 | } 53 | } 54 | } 55 | .onAppear { ids.append(initialItemContainer.id) } 56 | } 57 | .id(initialItemContainer.id) 58 | } 59 | } 60 | } 61 | } 62 | 63 | public extension View { 64 | 65 | /// Shows an alert controlled by a ``Queryable/Queryable``. 66 | @MainActor 67 | func queryableAlert( 68 | controlledBy queryable: Queryable, 69 | title: String, 70 | @ViewBuilder actions: @escaping (_ item: Item, _ query: QueryResolver) -> Actions, 71 | @ViewBuilder message: @escaping (_ item: Item) -> Message 72 | ) -> some View { 73 | modifier(QueryableAlertModifier(queryable: queryable, title: title, actions: actions, message: message)) 74 | } 75 | 76 | @MainActor 77 | func queryableAlert( 78 | controlledBy queryable: Queryable, 79 | title: String, 80 | @ViewBuilder actions: @escaping (_ query: QueryResolver) -> Actions, 81 | @ViewBuilder message: @escaping () -> Message 82 | ) -> some View { 83 | modifier( 84 | QueryableAlertModifier(queryable: queryable, title: title) { _, query in 85 | actions(query) 86 | } message: { _ in 87 | message() 88 | } 89 | ) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Tests/QueryableTests/BasicTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | 24 | import XCTest 25 | @testable import Queryable 26 | 27 | @MainActor 28 | final class BasicTests: XCTestCase { 29 | 30 | private let firstQueryId: String = "firstQueryId" 31 | private let secondQueryId: String = "secondQueryId" 32 | 33 | override func setUp() async throws { 34 | executionTimeAllowance = 5 35 | continueAfterFailure = false 36 | } 37 | 38 | func testBasic() async throws { 39 | let queryable = Queryable() 40 | 41 | let task = Task { 42 | for await observation in queryable.queryObservation { 43 | observation.resolver.answer(with: true) 44 | return 45 | } 46 | } 47 | 48 | do { 49 | let trueResult = try await queryable.query() 50 | XCTAssertTrue(trueResult) 51 | } catch { 52 | XCTFail() 53 | } 54 | 55 | await task.value 56 | } 57 | 58 | func testBasicThrowing() async throws { 59 | let queryable = Queryable() 60 | 61 | let task = Task { 62 | for await observation in queryable.queryObservation { 63 | observation.resolver.answer(throwing: TestError()) 64 | return 65 | } 66 | } 67 | 68 | do { 69 | _ = try await queryable.query() 70 | XCTFail() 71 | } catch { 72 | XCTAssert(error is TestError, "Unexpected error was thrown") 73 | } 74 | 75 | await task.value 76 | } 77 | 78 | func testInput() async throws { 79 | let queryable = Queryable() 80 | 81 | let task = Task { 82 | for await observation in queryable.queryObservation { 83 | observation.resolver.answer(with: !observation.input) 84 | } 85 | } 86 | 87 | do { 88 | let trueResult = try await queryable.query(with: true, id: firstQueryId) 89 | XCTAssertFalse(trueResult) 90 | 91 | let falseResult = try await queryable.query(with: false, id: secondQueryId) 92 | XCTAssertTrue(falseResult) 93 | } catch { 94 | XCTFail() 95 | } 96 | 97 | task.cancel() 98 | } 99 | 100 | func testCancellation() async throws { 101 | let queryable = Queryable() 102 | 103 | let task = Task { 104 | for await _ in queryable.queryObservation { 105 | queryable.cancel() 106 | return 107 | } 108 | } 109 | 110 | do { 111 | _ = try await queryable.query() 112 | XCTFail() 113 | } catch is QueryCancellationError { 114 | // Expected 115 | } catch { 116 | XCTFail() 117 | } 118 | 119 | await task.value 120 | } 121 | 122 | func testTaskCancellation() async throws { 123 | let queryable = Queryable() 124 | 125 | let task = Task { 126 | await withTaskGroup(of: Void.self) { group in 127 | 128 | group.addTask { 129 | do { 130 | _ = try await queryable.query() 131 | XCTFail() 132 | } catch is QueryCancellationError { 133 | // Expected 134 | } catch { 135 | XCTFail() 136 | } 137 | } 138 | 139 | group.cancelAll() 140 | } 141 | } 142 | 143 | await task.value 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Tests/QueryableTests/ConflictResolutionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import XCTest 24 | @testable import Queryable 25 | 26 | @MainActor 27 | final class ConflictResolutionTests: XCTestCase { 28 | 29 | private let firstQueryId: String = "firstQueryId" 30 | private let secondQueryId: String = "secondQueryId" 31 | 32 | override func setUp() async throws { 33 | executionTimeAllowance = 5 34 | continueAfterFailure = false 35 | } 36 | 37 | 38 | func testCancelNewQuery() async throws { 39 | let queryable = Queryable(queryConflictPolicy: .cancelNewQuery) 40 | 41 | let task = Task { 42 | await withTaskGroup(of: Void.self) { [firstQueryId, secondQueryId] group in 43 | 44 | // Observe first query 45 | group.addTask { 46 | for await observation in await queryable.queryObservation where observation.queryId == firstQueryId { 47 | do { 48 | _ = try await queryable.query(id: secondQueryId) 49 | XCTFail() 50 | await observation.resolver.answer(throwing: UnexpectedBehavior()) 51 | } catch is QueryCancellationError { 52 | // Expected 53 | await observation.resolver.answer() 54 | } catch { 55 | XCTFail() 56 | await observation.resolver.answer(throwing: UnexpectedBehavior()) 57 | } 58 | 59 | return 60 | } 61 | } 62 | 63 | // Observe second query 64 | group.addTask { 65 | for await observation in await queryable.queryObservation where observation.queryId == secondQueryId { 66 | await observation.resolver.answer() 67 | XCTFail() 68 | return 69 | } 70 | } 71 | 72 | await group.next() 73 | group.cancelAll() 74 | } 75 | } 76 | 77 | do { 78 | _ = try await queryable.query(id: firstQueryId) 79 | } catch is QueryCancellationError { 80 | XCTFail() 81 | } catch { 82 | XCTFail() 83 | } 84 | 85 | await task.value 86 | } 87 | 88 | func testPreviousQuery() async throws { 89 | let queryable = Queryable(queryConflictPolicy: .cancelPreviousQuery) 90 | 91 | let task = Task { 92 | await withTaskGroup(of: Void.self) { [firstQueryId, secondQueryId] group in 93 | 94 | // Observe first query 95 | group.addTask { 96 | for await observation in await queryable.queryObservation where observation.queryId == firstQueryId { 97 | do { 98 | _ = try await queryable.query(id: secondQueryId) 99 | } catch is QueryCancellationError { 100 | XCTFail() 101 | await observation.resolver.answer(throwing: UnexpectedBehavior()) 102 | } catch { 103 | XCTFail() 104 | await observation.resolver.answer(throwing: UnexpectedBehavior()) 105 | } 106 | 107 | return 108 | } 109 | } 110 | 111 | // Observe second query 112 | group.addTask { 113 | for await observation in await queryable.queryObservation where observation.queryId == secondQueryId { 114 | await observation.resolver.answer() 115 | return 116 | } 117 | } 118 | 119 | await group.next() 120 | group.cancelAll() 121 | } 122 | } 123 | 124 | do { 125 | _ = try await queryable.query(id: firstQueryId) 126 | XCTFail() 127 | } catch is QueryCancellationError { 128 | // Expected 129 | } catch { 130 | XCTFail() 131 | } 132 | 133 | await task.value 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | dennis@swiftedmind.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Sources/Queryable/Queryable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright © 2023 Dennis Müller and all collaborators 3 | // 4 | // Permission is hereby granted, free of charge, to any person obtaining a copy 5 | // of this software and associated documentation files (the "Software"), to deal 6 | // in the Software without restriction, including without limitation the rights 7 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | // copies of the Software, and to permit persons to whom the Software is 9 | // furnished to do so, subject to the following conditions: 10 | // 11 | // The above copyright notice and this permission notice shall be included in all 12 | // copies or substantial portions of the Software. 13 | // 14 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | // SOFTWARE. 21 | // 22 | 23 | import Foundation 24 | import Combine 25 | 26 | /// A type that can trigger a view presentation from within an `async` function and `await` its completion and potential result value. 27 | /// 28 | /// An example use case would be a boolean coming from a confirmation dialog view. First, create a property of the desired data type: 29 | /// 30 | /// ```swift 31 | /// @StateObject var deletionConfirmation = Queryable() 32 | /// ``` 33 | /// 34 | /// Alternatively, you can put the queryable instance in any class that your view has access to: 35 | /// 36 | /// ```swift 37 | /// class SomeObservableObject: ObservableObject { 38 | /// let deletionConfirmation = Queryable() 39 | /// } 40 | /// 41 | /// struct MyView: View { 42 | /// @StateObject private var someObservableObject = SomeObservableObject() 43 | /// } 44 | /// ``` 45 | /// 46 | /// Then, use one of the `queryable` prefixed presentation modifiers to show the deletion confirmation. For instance, here we use an alert: 47 | /// 48 | /// ```swift 49 | /// someView 50 | /// .queryableAlert( 51 | /// controlledBy: deletionConfirmation, 52 | /// title: "Do you want to delete this?") { itemName, query in 53 | /// Button("Cancel", role: .cancel) { 54 | /// query.answer(with: false) 55 | /// } 56 | /// Button("OK") { 57 | /// query.answer(with: true) 58 | /// } 59 | /// } message: { itemName in 60 | /// Text("This cannot be reversed!") 61 | /// } 62 | /// ``` 63 | /// 64 | /// To actually present the alert and await the boolean result, call ``Queryable/Queryable/query(with:)`` on the ``Queryable/Queryable`` property. 65 | /// This will activate the alert presentation which can then resolve the query in its completion handler. 66 | /// 67 | /// ```swift 68 | /// do { 69 | /// let item = // ... 70 | /// let shouldDelete = try await deletionConfirmation.query(with: item.name) 71 | /// } catch {} 72 | /// ``` 73 | /// 74 | /// When the Task that calls ``Queryable/Queryable/query(with:)`` is cancelled, the suspended query will also cancel and deactivate (i.e. close) the wrapped navigation presentation. 75 | /// In that case, a ``Queryable/QueryCancellationError`` error is thrown. 76 | @MainActor public final class Queryable: ObservableObject where Input: Sendable, Result: Sendable { 77 | let queryConflictPolicy: QueryConflictPolicy 78 | var storedContinuationState: ContinuationState? 79 | 80 | /// Optional item storing the input value for a query and is used to indicate if the query has started, which usually coincides with a presentation being shown. 81 | @Published var itemContainer: ItemContainer? 82 | 83 | public init(queryConflictPolicy: QueryConflictPolicy = .cancelNewQuery) { 84 | self.queryConflictPolicy = queryConflictPolicy 85 | } 86 | 87 | // MARK: - Public Interface 88 | 89 | /// Requests the collection of data by starting a query on the `Result` type, providing an input value. 90 | /// 91 | /// This method will suspend for as long as the query is unanswered and not cancelled. When the parent Task is cancelled, this method will immediately cancel the query and throw a ``Queryable/QueryCancellationError`` error. 92 | /// 93 | /// Creating multiple queries at the same time will cause a query conflict which is resolved using the ``Queryable/QueryConflictPolicy`` defined in the initializer of ``Queryable/Queryable``. The default policy is ``Queryable/QueryConflictPolicy/cancelPreviousQuery``. 94 | /// - Returns: The result of the query. 95 | public func query(with item: Input) async throws -> Result { 96 | try await query(with: item, id: UUID().uuidString) 97 | } 98 | 99 | /// Requests the collection of data by starting a query on the `Result` type, providing an input value. 100 | /// 101 | /// This method will suspend for as long as the query is unanswered and not cancelled. When the parent Task is cancelled, this method will immediately cancel the query and throw a ``Queryable/QueryCancellationError`` error. 102 | /// 103 | /// Creating multiple queries at the same time will cause a query conflict which is resolved using the ``Queryable/QueryConflictPolicy`` defined in the initializer of ``Queryable/Queryable``. The default policy is ``Queryable/QueryConflictPolicy/cancelPreviousQuery``. 104 | /// - Returns: The result of the query. 105 | public func query() async throws -> Result where Input == Void { 106 | try await query(with: ()) 107 | } 108 | 109 | /// Cancels any ongoing queries. 110 | public func cancel() { 111 | objectWillChange.send() 112 | itemContainer?.resolver.answer(throwing: QueryCancellationError()) 113 | } 114 | 115 | /// A flag indicating if a query is active. 116 | public var isQuerying: Bool { 117 | itemContainer != nil 118 | } 119 | 120 | /// An `AsyncStream` observing incoming queries and emitting their inputs and resolver to handle manually. 121 | /// 122 | /// Only use this, if you need more fine-grained control over the Queryable, i.e. setting view states yourself or adding tests. 123 | /// In most cases, you should prefer to use one of the `.queryable[...]` view modifiers instead. 124 | /// - Warning: With this, there will be no way of knowing when a query has been cancelled external 125 | /// (a sheet that was closed through a gesture, or a system dialog that has overridden any app dialogs) 126 | /// 127 | /// - Warning: Do not implement both a manual query observation as well as a `.queryable[...]` view modifier for the same Queryable instance. 128 | /// This will result in unexpected behavior. 129 | var queryObservation: AsyncStream> { 130 | AsyncStream(bufferingPolicy: .unbounded) { continuation in 131 | let task = Task { 132 | for await container in $itemContainer.values { 133 | if Task.isCancelled { return } 134 | if let container { 135 | continuation.yield(.init(queryId: container.id, input: container.item, resolver: container.resolver)) 136 | } 137 | } 138 | } 139 | 140 | continuation.onTermination = { _ in 141 | task.cancel() 142 | } 143 | } 144 | } 145 | 146 | // MARK: - Internal Interface 147 | 148 | func query(with item: Input, id: String) async throws -> Result { 149 | return try await withTaskCancellationHandler { 150 | try await withCheckedThrowingContinuation { continuation in 151 | storeContinuation(continuation, withId: id, item: item) 152 | } 153 | } onCancel: { 154 | Task { 155 | await autoCancelContinuation(id: id, reason: .taskCancelled) 156 | } 157 | } 158 | } 159 | 160 | func query(id: String) async throws -> Result where Input == Void { 161 | try await query(with: Void(), id: id) 162 | } 163 | 164 | func storeContinuation( 165 | _ newContinuation: CheckedContinuation, 166 | withId id: String, 167 | item: Input 168 | ) { 169 | if let storedContinuationState { 170 | switch queryConflictPolicy { 171 | case .cancelPreviousQuery: 172 | logger.warning("Cancelling previous query of »\(Result.self, privacy: .public)« to allow new query.") 173 | storedContinuationState.continuation.resume(throwing: QueryCancellationError()) 174 | self.storedContinuationState = nil 175 | objectWillChange.send() 176 | self.itemContainer = nil 177 | case .cancelNewQuery: 178 | logger.warning("Cancelling new query of »\(Result.self, privacy: .public)« because another query is ongoing.") 179 | newContinuation.resume(throwing: QueryCancellationError()) 180 | return 181 | } 182 | } 183 | 184 | let resolver = QueryResolver { result in 185 | self.resumeContinuation(returning: result, queryId: id) 186 | } errorHandler: { error in 187 | self.resumeContinuation(throwing: error, queryId: id) 188 | } 189 | 190 | storedContinuationState = .init(queryId: id, continuation: newContinuation) 191 | objectWillChange.send() 192 | itemContainer = .init(queryId: id, item: item, resolver: resolver) 193 | } 194 | 195 | func autoCancelContinuation(id: String, reason: AutoCancelReason) { 196 | // If the user cancels a query programmatically and immediately starts the next one, we need to prevent the `QueryInternalError.queryAutoCancel` from the `onDisappear` handler of the canceled query to cancel the new query. That's why the presentations store an id 197 | if storedContinuationState?.queryId == id { 198 | switch reason { 199 | case .presentationEnded: 200 | logger.notice("Cancelling query of »\(Result.self, privacy: .public)« because presentation has terminated.") 201 | case .taskCancelled: 202 | logger.notice("Cancelling query of »\(Result.self, privacy: .public)« because the task was cancelled.") 203 | } 204 | 205 | storedContinuationState?.continuation.resume(throwing: QueryCancellationError()) 206 | storedContinuationState = nil 207 | objectWillChange.send() 208 | itemContainer = nil 209 | } 210 | } 211 | 212 | // MARK: - Private Interface 213 | 214 | private func resumeContinuation(returning result: Result, queryId: String) { 215 | guard itemContainer?.id == queryId else { return } 216 | storedContinuationState?.continuation.resume(returning: result) 217 | storedContinuationState = nil 218 | objectWillChange.send() 219 | itemContainer = nil 220 | } 221 | 222 | private func resumeContinuation(throwing error: Error, queryId: String) { 223 | guard itemContainer?.id == queryId else { return } 224 | storedContinuationState?.continuation.resume(throwing: error) 225 | storedContinuationState = nil 226 | objectWillChange.send() 227 | itemContainer = nil 228 | } 229 | } 230 | 231 | // MARK: - Auxiliary Types 232 | 233 | extension Queryable { 234 | struct ItemContainer: Identifiable { 235 | var id: String { queryId } 236 | let queryId: String 237 | var item: Input 238 | var resolver: QueryResolver 239 | } 240 | 241 | struct ContinuationState { 242 | let queryId: String 243 | var continuation: CheckedContinuation 244 | } 245 | 246 | enum AutoCancelReason { 247 | case presentationEnded 248 | case taskCancelled 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Queryable 4 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FSwiftedMind%2FQueryable%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/SwiftedMind/Queryable) 5 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2FSwiftedMind%2FQueryable%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/SwiftedMind/Queryable) 6 | ![GitHub release (latest by date)](https://img.shields.io/github/v/release/SwiftedMind/Queryable?label=Latest%20Release) 7 | ![GitHub](https://img.shields.io/github/license/SwiftedMind/Queryable) 8 | 9 | `Queryable` is a type that lets you trigger a view presentation and `await` its completion from a single `async` function call, fully hiding the necessary state handling of the presented view. 10 | 11 | ```swift 12 | import SwiftUI 13 | import Queryable 14 | 15 | struct ContentView: View { 16 | @StateObject var buttonConfirmation = Queryable() 17 | 18 | var body: some View { 19 | Button("Commit", action: confirm) 20 | .queryableAlert(controlledBy: buttonConfirmation, title: "Really?") { item, query in 21 | Button("Yes") { query.answer(with: true) } 22 | Button("No") { query.answer(with: false) } 23 | } message: {_ in} 24 | } 25 | 26 | @MainActor 27 | private func confirm() { 28 | Task { 29 | do { 30 | let isConfirmed = try await buttonConfirmation.query() 31 | // Do something with the result 32 | } catch {} 33 | } 34 | } 35 | } 36 | ``` 37 | 38 | Not only does this free the presented view from any kind of context (it simply provides an answer to the query), but you can also pass `buttonConfirmation` down the view hierarchy so that any child view can conveniently trigger the confirmation without needing to deal with the actually displayed UI. It works with `alerts`, `confirmationDialogs`, `sheets`, `fullScreenCover` and fully custom `overlays`. 39 | 40 | You can also initialize and store a `Queryable` inside a view model or any other class that the consuming views have access to. 41 | 42 | - [Installation](#installation) 43 | - **[Get Started](#get-started)** 44 | - [Supported Queryable Modifiers](#supported-queryable-modifiers) 45 | - [Updating to Queryable 2.0.0](#updating-to-queryable-200) 46 | - [License](#license) 47 | 48 | ## Installation 49 | 50 | Queryable supports iOS 15+, macOS 12+, watchOS 8+ and tvOS 15+. 51 | 52 | ### In Swift Package 53 | 54 | Add the following line to the dependencies in your `Package.swift` file: 55 | 56 | ```swift 57 | .package(url: "https://github.com/SwiftedMind/Queryable", from: "2.0.0") 58 | ``` 59 | 60 | ### In Xcode project 61 | 62 | Go to `File` > `Add Packages...` and enter the URL "https://github.com/SwiftedMind/Queryable" into the search field at the top right. Queryable should appear in the list. Select it and click "Add Package" in the bottom right. 63 | 64 | ### Usage 65 | 66 | To use, simply import the `Queryable` target in your code. 67 | 68 | ```swift 69 | import SwiftUI 70 | import Queryable 71 | 72 | struct ContentView: View { 73 | @StateObject var buttonConfirmation = Queryable() 74 | /* ... */ 75 | } 76 | ``` 77 | 78 | You can also define the Queryable inside a class, like this: 79 | 80 | ```swift 81 | @MainActor class MyViewModel: ObservableObject { 82 | let buttonConfirmation = Queryable() 83 | } 84 | ``` 85 | 86 | ## Get Started 87 | 88 | To best explain what `Queryable` does, let's look at an example. Say we have a button whose action needs a confirmation by the user. The confirmation should be presented as an alert with two buttons. 89 | 90 | Usually, you would implement this in a way similar to the following: 91 | 92 | ```swift 93 | import SwiftUI 94 | 95 | struct ContentView: View { 96 | @State private var isShowingConfirmationAlert = false 97 | 98 | var body: some View { 99 | Button("Do it!") { 100 | isShowingConfirmationAlert = true 101 | } 102 | .alert( 103 | "Do you really want to do this?", 104 | isPresented: $isShowingConfirmationAlert 105 | ) { 106 | Button("Yes") { confirmAction(true) } 107 | Button("No") { confirmAction(false) } 108 | } message: {} 109 | } 110 | 111 | @MainActor private func confirmAction(_ confirmed: Bool) { 112 | print(confirmed) 113 | } 114 | } 115 | ``` 116 | 117 | The code is fairly simple. We toggle the alert presentation whenever the button is pressed and then call `confirmAction(_:)` with the answer the user has given. There's nothing wrong with this approach, it works perfectly fine. 118 | 119 | However, I believe there is a much more convenient way of doing it. If you think about it, triggering the presentation of an alert and waiting for some kind of result – the user's confirmation in this case – is basically just an asynchronous operation. In Swift, there's a mechanism for that: *Swift Concurrency*. 120 | 121 | Wouldn't it be awesome if we could simply `await` the confirmation and get the result as the return value of a single `async` function call? Something like this: 122 | 123 | ```swift 124 | import SwiftUI 125 | 126 | struct ContentView: View { 127 | // Some property that takes care of the view presentation 128 | var buttonConfirmation: /* ?? */ 129 | 130 | var body: some View { 131 | Button("Do it!") { 132 | confirm() 133 | } 134 | .alert( 135 | "Do you really want to do this?", 136 | isPresented: /* ?? */ 137 | ) { 138 | Button("Yes") { /* ?? */ } 139 | Button("No") { /* ?? */ } 140 | } message: {} 141 | } 142 | 143 | @MainActor private func confirm() { 144 | Task { 145 | do { 146 | // Suspend, show the alert and resume with the user's answer 147 | let isConfirmed = try await buttonConfirmation.query() 148 | } catch {} 149 | } 150 | } 151 | } 152 | ``` 153 | 154 | The idea is that this `query()` method would suspend the current task, somehow toggle the presentation of the alert and then resume with the result, all without us ever leaving the scope. The entire user interaction with the UI is contained in this single line. 155 | 156 | And that is exactly what `Queryable` does. It's a type you can use to control view presentations from asynchronous contexts. Here's what it looks like: 157 | 158 | ```swift 159 | import SwiftUI 160 | import Queryable 161 | 162 | struct ContentView: View { 163 | // Since we don't need to provide data with the confirmation, we pass `Void` as the Input. 164 | // The Result type should be a Bool. 165 | @StateObject var buttonConfirmation = Queryable() 166 | 167 | var body: some View { 168 | Button("Commit") { 169 | confirm() 170 | } 171 | .queryableAlert( // Special alert modifier whose presentation is controlled by a Queryable 172 | controlledBy: buttonConfirmation, 173 | title: "Do you really want to do this?" 174 | ) { item, query in 175 | // The provided query type lets us return a result 176 | Button("Yes") { query.answer(with: true) } 177 | Button("No") { query.answer(with: false) } 178 | } message: {_ in} 179 | } 180 | 181 | @MainActor 182 | private func confirm() { 183 | Task { 184 | do { 185 | let isConfirmed = try await buttonConfirmation.query() 186 | // Do something with the result 187 | } catch {} 188 | } 189 | } 190 | } 191 | ``` 192 | 193 | In my opinion, this looks and feels much cleaner and a lot more convenient. As a bonus, we can now reuse the alert for all kinds of things, since it doesn't know anything about its context. 194 | 195 | > **Note** 196 | > 197 | > It is your responsibility to make sure that every query is answered at some point (unless cancelled, see [below](#cancelling-queries)). Failing to do so will cause undefined behavior and possibly crashes. This is because `Queryable` uses `Continuations` under the hood. 198 | 199 | ### Passing Down The View Hierarchy 200 | 201 | Another interesting thing you can do with `Queryable` is pass it down the view hierarchy. In the following example, `MyChildView` has no idea about the alert from `ContentView`, but it still can query a confirmation and receive a result. If you later swap out the `alert` for a `confirmationDialog` in `ContentView`, nothing changes for `MyChildView`. 202 | 203 | ```swift 204 | import SwiftUI 205 | import Queryable 206 | 207 | struct MyChildView: View { 208 | // Passed from a parent view 209 | var buttonConfirmation: Queryable 210 | 211 | var body: some View { 212 | Button("Confirm Here Instead") { 213 | confirm() 214 | } 215 | } 216 | 217 | @MainActor 218 | private func confirm() { 219 | Task { 220 | do { 221 | // This view has no idea how the confirmation is obtained. It doesn't need to! 222 | let isConfirmed = try await buttonConfirmation.query() 223 | // Do something with the result 224 | } catch {} 225 | } 226 | } 227 | } 228 | ``` 229 | 230 | ### Providing an Input Value 231 | 232 | In the examples above, we've used `Void` as the generic `Input` type for `Queryable`, since the confirmation alert didn't need it. But we can pass any value type we want. 233 | 234 | For example, let's say we want to present a sheet on which the user can create a new `PlayerItem` that we then save in a database (or send to a backend). By querying with an input of type `PlayerItem`, we can provide the `PlayerEditor` view with data to pre-fill some of the inputs in the form. 235 | 236 | ```swift 237 | struct PlayerItem { 238 | var name: String 239 | /* ... */ 240 | 241 | static var draft: PlayerItem {/* ... */} 242 | } 243 | 244 | struct PlayerListView: View { 245 | @StateObject var playerCreation = Queryable() 246 | 247 | var body: some View { 248 | /* ... */ 249 | .queryableSheet(controlledBy: playerCreation) { playerDraft, query in 250 | PlayerEditor(draft: playerDraft, onCompletion: { player in 251 | query.answer(with: player) 252 | }) 253 | } 254 | } 255 | 256 | @MainActor 257 | private func createPlayer() { 258 | Task { 259 | do { 260 | let createdPlayer = try await buttonConfirmation.query(with: PlayerItem.draft) 261 | // Store player in database, for example 262 | } catch {} 263 | } 264 | } 265 | } 266 | ``` 267 | 268 | This can be incredibly handy. 269 | 270 | ### Cancelling Queries 271 | 272 | There are a few ways an ongoing query is cancelled. 273 | 274 | - You call the `cancel()` method on the `Queryable` property, for instance `buttonConfiguration.cancel()`. 275 | - The `Task` that calls the `query()` method is cancelled. When this happens, the query will automatically be cancelled and end the view presentation. 276 | - The view is dismissed by the system or the user (by swiping down a sheet, for example). The `Queryable` will detect this and cancel any ongoing queries. 277 | - A new query is started while another one is ongoing. This will either cancel the new one or the ongoing one, depending on the specified [conflict policy](#handling-conflicts). 278 | 279 | In all of the above cases, a `QueryCancellationError` will be thrown. 280 | 281 | ### Handling Conflicts 282 | 283 | If you try to start a query while another one is already ongoing, there will be a conflict. The default behavior in that situation is for the new query to be cancelled. You can alter that by specifying a `QueryConflictPolicy` during initialization, like so: 284 | 285 | ```swift 286 | Queryable(queryConflictPolicy: .cancelNewQuery) 287 | Queryable(queryConflictPolicy: .cancelPreviousQuery) 288 | ``` 289 | 290 | 291 | ## Supported Queryable Modifiers 292 | 293 | Currently, these are the view modifiers that support being controlled by a `Queryable`: 294 | 295 | - `queryableAlert(controlledBy:title:actions:message)` 296 | - `queryableConfirmationDialog(controlledBy:title:actions:message)` 297 | - `queryableFullScreenCover(controlledBy:onDismiss:content:)` 298 | - `queryableSheet(controlledBy:onDismiss:content:)` 299 | - `queryableOverlay(controlledBy:animation:alignment:content:)` 300 | - `queryableClosure(controlledBy:block:)` 301 | 302 | ## Updating to Queryable 2.0.0 303 | 304 | Please see the [Migration Guide](https://github.com/SwiftedMind/Queryable/blob/main/MIGRATIONS.md). 305 | 306 | ## License 307 | 308 | MIT License 309 | 310 | Copyright (c) 2023 Dennis Müller and all collaborators 311 | 312 | Permission is hereby granted, free of charge, to any person obtaining a copy 313 | of this software and associated documentation files (the "Software"), to deal 314 | in the Software without restriction, including without limitation the rights 315 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 316 | copies of the Software, and to permit persons to whom the Software is 317 | furnished to do so, subject to the following conditions: 318 | 319 | The above copyright notice and this permission notice shall be included in all 320 | copies or substantial portions of the Software. 321 | 322 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 323 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 324 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 325 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 326 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 327 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 328 | SOFTWARE. 329 | --------------------------------------------------------------------------------