Skip to content

Commit 71bc864

Browse files
committed
Merge branch 'main' of github.com:SwiftedMind/Queryable
2 parents 0d0b719 + b1ec2b6 commit 71bc864

1 file changed

Lines changed: 264 additions & 11 deletions

File tree

README.md

Lines changed: 264 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,47 @@
1+
12
<p align="center">
2-
<img width="200" height="200" src="https://user-images.githubusercontent.com/7083109/231771342-16b43178-4c4e-40e2-aa96-5dbc7fa3c130.png">
3+
<img width="200" height="200" src="https://user-images.githubusercontent.com/7083109/231827191-7472e663-a8f2-42c6-a7aa-77bb38ae484a.png">
34
</p>
45

5-
# Queryable
6+
# Queryable - Asynchronous View Presentations in SwiftUI
67
![GitHub release (latest by date)](https://img.shields.io/github/v/release/SwiftedMind/Queryable?label=Latest%20Release)
78
![GitHub](https://img.shields.io/github/license/SwiftedMind/Queryable)
89

9-
[Work in Progress]
10+
`Queryable` is a property wrapper that can trigger a view presentation and `await` its completion from a single `async` function call, while fully hiding the state handling of the presented view.
1011

11-
- **[Features](#features)**
12-
- [Installation](#installation)
13-
- [Documentation](#documentation)
14-
- [License](#license)
12+
```swift
13+
import SwiftUI
14+
import Queryable
15+
16+
struct ContentView: View {
17+
@Queryable<Void, Bool> var buttonConfirmation
1518

16-
## Features
19+
var body: some View {
20+
Button("Commit", action: confirm)
21+
.queryableAlert(controlledBy: buttonConfirmation, title: "Really?") { item, query in
22+
Button("Yes") { query.answer(with: true) }
23+
Button("No") { query.answer(with: false) }
24+
} message: {_ in}
25+
}
1726

18-
[Work in Progress]
27+
@MainActor
28+
private func confirm() {
29+
Task {
30+
do {
31+
let isConfirmed = try await buttonConfirmation.query()
32+
// Do something with the result
33+
} catch {}
34+
}
35+
}
36+
}
37+
```
38+
39+
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`.
40+
41+
- [Installation](#installation)
42+
- **[Get Started](#get-started)**
43+
- [Supported Queryable Modifiers](#supported-queryable-modifiers)
44+
- [License](#license)
1945

2046
## Installation
2147

@@ -33,9 +59,236 @@ Add the following line to the dependencies in your `Package.swift` file:
3359

3460
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.
3561

36-
## Documentation
62+
### Usage
63+
64+
To use, simply import the `Queryable` target in your code.
65+
66+
```swift
67+
import SwiftUI
68+
import Queryable
69+
70+
struct ContentView: View {
71+
@Queryable<Void, Bool> var buttonConfirmation
72+
/* ... */
73+
}
74+
```
75+
76+
## Get Started
77+
78+
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.
79+
80+
Usually, you would implement this in a way similar to the following:
81+
82+
```swift
83+
import SwiftUI
84+
85+
struct ContentView: View {
86+
@State private var isShowingConfirmationAlert = false
87+
88+
var body: some View {
89+
Button("Do it!") {
90+
isShowingConfirmationAlert = true
91+
}
92+
.alert(
93+
"Do you really want to do this?",
94+
isPresented: $isShowingConfirmationAlert
95+
) {
96+
Button("Yes") { confirmAction(true) }
97+
Button("No") { confirmAction(false) }
98+
} message: {}
99+
}
100+
101+
@MainActor private func confirmAction(_ confirmed: Bool) {
102+
print(confirmed)
103+
}
104+
}
105+
```
106+
107+
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.
108+
109+
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*.
110+
111+
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:
112+
113+
```swift
114+
import SwiftUI
115+
116+
struct ContentView: View {
117+
// Some property that takes care of the view presentation
118+
var buttonConfirmation: /* ?? */
119+
120+
var body: some View {
121+
Button("Do it!") {
122+
confirm()
123+
}
124+
.alert(
125+
"Do you really want to do this?",
126+
isPresented: /* ?? */
127+
) {
128+
Button("Yes") { /* ?? */ }
129+
Button("No") { /* ?? */ }
130+
} message: {}
131+
}
132+
133+
@MainActor private func confirm() {
134+
Task {
135+
do {
136+
// Suspend, show the alert and resume with the user's answer
137+
let isConfirmed = try await buttonConfirmation.query()
138+
} catch {}
139+
}
140+
}
141+
}
142+
```
143+
144+
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.
145+
146+
And that is exactly what `Queryable` does. It's a property wrapper that you can add within any SwiftUI `View` to control view presentations from asynchronous contexts. Here's what it looks like:
147+
148+
```swift
149+
import SwiftUI
150+
import Queryable
151+
152+
struct ContentView: View {
153+
// Since we don't need to provide data with the confirmation, we pass `Void` as the Input.
154+
// The Result type should be a Bool.
155+
@Queryable<Void, Bool> var buttonConfirmation
156+
157+
var body: some View {
158+
Button("Commit") {
159+
confirm()
160+
}
161+
.queryableAlert( // Special alert modifier whose presentation is controller by a Queryable
162+
controlledBy: buttonConfirmation,
163+
title: "Do you really want to do this?"
164+
) { item, query in
165+
// The provided query type lets us return a result
166+
Button("Yes") { query.answer(with: true) }
167+
Button("No") { query.answer(with: false) }
168+
} message: {_ in}
169+
}
170+
171+
@MainActor
172+
private func confirm() {
173+
Task {
174+
do {
175+
let isConfirmed = try await buttonConfirmation.query()
176+
// Do something with the result
177+
} catch {}
178+
}
179+
}
180+
}
181+
```
182+
183+
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.
184+
185+
> **Note**
186+
>
187+
> 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.
188+
189+
### Passing Down The View Hierarchy
190+
191+
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`.
192+
193+
```swift
194+
import SwiftUI
195+
import Queryable
196+
197+
struct MyChildView: View {
198+
// Passed from a parent view
199+
var buttonConfirmation: Queryable<Void, Bool>.Trigger
200+
201+
var body: some View {
202+
Button("Confirm Here Instead") {
203+
confirm()
204+
}
205+
}
206+
207+
@MainActor
208+
private func confirm() {
209+
Task {
210+
do {
211+
// This view has no idea how the confirmation is obtained. It doesn't need to!
212+
let isConfirmed = try await buttonConfirmation.query()
213+
// Do something with the result
214+
} catch {}
215+
}
216+
}
217+
}
218+
```
219+
220+
### Providing an Input Value
221+
222+
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.
223+
224+
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.
225+
226+
```swift
227+
struct PlayerItem {
228+
var name: String
229+
/* ... */
230+
231+
static var draft: PlayerItem {/* ... */}
232+
}
233+
234+
struct PlayerListView: View {
235+
@Queryable<PlayerItem, PlayerItem> var playerCreation
236+
237+
var body: some View {
238+
/* ... */
239+
.queryableSheet(controlledBy: playerCreation) { playerDraft, query in
240+
PlayerEditor(draft: playerDraft, onCompletion: { player in
241+
query.answer(with: player)
242+
})
243+
}
244+
}
245+
246+
@MainActor
247+
private func createPlayer() {
248+
Task {
249+
do {
250+
let createdPlayer = try await buttonConfirmation.query(with: PlayerItem.draft)
251+
// Store player in database, for example
252+
} catch {}
253+
}
254+
}
255+
}
256+
```
257+
258+
This can be incredibly handy.
259+
260+
### Cancelling Queries
261+
262+
There are a few ways an ongoing query is cancelled.
263+
264+
- You call the `cancel()` method on the `Queryable` property, for instance `buttonConfiguration.cancel()`.
265+
- The `Task` that calls the `query()` method is cancelled. When this happens, the query will automatically be cancelled and end the view presentation.
266+
- 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.
267+
- 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).
268+
269+
In all of the above cases, a `QueryCancellationError` will be thrown.
270+
271+
### Handling Conflicts
272+
273+
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 previous query to be cancelled. You can alter that by specifying a `QueryConflictPolicy` for you `Queryable`, like so:
274+
275+
```swift
276+
@Queryable<Void, Bool>(queryConflictPolicy: .cancelNewQuery) var buttonConfirmation
277+
@Queryable<Void, Bool>(queryConflictPolicy: .cancelPreviousQuery) var otherButtonConfirmation
278+
```
279+
280+
281+
## Supported Queryable Modifiers
282+
283+
Currently, these are the view modifiers that support being controlled by a `Queryable`:
284+
285+
- `queryableAlert(controlledBy:title:actions:message)`
286+
- `queryableConfirmationDialog(controlledBy:title:actions:message)`
287+
- `queryableFullScreenCover(controlledBy:onDismiss:content:)`
288+
- `queryableSheet(controlledBy:onDismiss:content:)`
289+
- `queryableOverlay(controlledBy:animation:alignment:content:)`
290+
- `queryableClosure(controlledBy:block:)`
37291

38-
[Work in Progress]
39292

40293
## License
41294

0 commit comments

Comments
 (0)