├── .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://swiftpackageindex.com/SwiftedMind/Queryable)
5 | [](https://swiftpackageindex.com/SwiftedMind/Queryable)
6 | 
7 | 
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 |
--------------------------------------------------------------------------------