Skip to content

Commit 9471292

Browse files
authored
Atom Effects (#131)
* Implement AtomEffect * Update tests * Documentation * Update README * Update documentation
1 parent 5d14db2 commit 9471292

30 files changed

+899
-397
lines changed

Examples/Packages/iOS/Sources/ExampleVoiceMemo/Atoms/VoiceMemoListAtoms.swift

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -120,32 +120,8 @@ struct RecordingDataAtom: StateAtom, Hashable {
120120
return nil
121121
}
122122

123-
func updated(newValue: RecordingData?, oldValue: RecordingData?, context: UpdatedContext) {
124-
let audioRecorder = context.read(AudioRecorderAtom())
125-
let audioSession = context.read(AudioSessionAtom())
126-
127-
if let data = oldValue {
128-
let voiceMemo = VoiceMemo(
129-
url: data.url,
130-
date: data.date,
131-
duration: audioRecorder.currentTime
132-
)
133-
134-
context[VoiceMemosAtom()].insert(voiceMemo, at: 0)
135-
audioRecorder.stop()
136-
try? audioSession.setActive(false, options: [])
137-
}
138-
139-
if let data = newValue {
140-
do {
141-
try audioSession.setCategory(.playAndRecord, mode: .default, options: .defaultToSpeaker)
142-
try audioSession.setActive(true, options: [])
143-
try audioRecorder.record(url: data.url)
144-
}
145-
catch {
146-
context[IsRecordingFailedAtom()] = true
147-
}
148-
}
123+
func effect(context: CurrentContext) -> some AtomEffect {
124+
RecordingEffect()
149125
}
150126
}
151127

Examples/Packages/iOS/Sources/ExampleVoiceMemo/Atoms/VoiceMemoRowAtoms.swift

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,21 @@ struct IsPlayingAtom: StateAtom {
1717
return false
1818
}
1919

20-
func updated(newValue: Bool, oldValue: Bool, context: UpdatedContext) {
21-
let audioPlayer = context.read(AudioPlayerAtom(voiceMemo: voiceMemo))
20+
func effect(context: CurrentContext) -> some AtomEffect {
21+
UpdateEffect {
22+
let audioPlayer = context.read(AudioPlayerAtom(voiceMemo: voiceMemo))
23+
let isPlaying = context.read(self)
2224

23-
guard newValue else {
24-
return audioPlayer.stop()
25-
}
25+
guard isPlaying else {
26+
return audioPlayer.stop()
27+
}
2628

27-
do {
28-
try audioPlayer.play(url: voiceMemo.url)
29-
}
30-
catch {
31-
context[IsPlaybackFailedAtom()] = true
29+
do {
30+
try audioPlayer.play(url: voiceMemo.url)
31+
}
32+
catch {
33+
context[IsPlaybackFailedAtom()] = true
34+
}
3235
}
3336
}
3437
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import AVFoundation
2+
import Atoms
3+
4+
final class RecordingEffect: AtomEffect {
5+
private var currentData: RecordingData?
6+
7+
func updated(context: Context) {
8+
let audioSession = context.read(AudioSessionAtom())
9+
let audioRecorder = context.read(AudioRecorderAtom())
10+
let data = context.read(RecordingDataAtom())
11+
12+
if let currentData {
13+
let voiceMemo = VoiceMemo(
14+
url: currentData.url,
15+
date: currentData.date,
16+
duration: audioRecorder.currentTime
17+
)
18+
19+
context[VoiceMemosAtom()].insert(voiceMemo, at: 0)
20+
audioRecorder.stop()
21+
try? audioSession.setActive(false, options: [])
22+
}
23+
24+
currentData = data
25+
26+
if let data {
27+
do {
28+
try audioSession.setCategory(.playAndRecord, mode: .default, options: .defaultToSpeaker)
29+
try audioSession.setActive(true, options: [])
30+
try audioRecorder.record(url: data.url)
31+
}
32+
catch {
33+
context[IsRecordingFailedAtom()] = true
34+
}
35+
}
36+
}
37+
}

README.md

Lines changed: 68 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -811,9 +811,11 @@ struct FetchMoviesPhaseAtom: ValueAtom, Refreshable, Hashable {
811811
await context.refresh(FetchMoviesTaskAtom().phase)
812812
}
813813

814-
func updated(newValue: AsyncPhase<[Movies], Error>, oldValue: AsyncPhase<[Movies], Error>, context: UpdatedContext) {
815-
if case .failure = newValue {
816-
print("Failed to fetch movies.")
814+
func effect(context: CurrentContext) -> some AtomEffect {
815+
UpdateEffect {
816+
if case .failure = context.read(self) {
817+
print("Failed to fetch movies.")
818+
}
817819
}
818820
}
819821
}
@@ -1305,6 +1307,69 @@ AtomScope(id: TextScopeID()) {
13051307
This is also useful when multiple identical screens are stacked and each screen needs isolated states such as user inputs.
13061308
Note that other atoms that depend on scoped atoms will be in a shared state and must be given `Scoped` attribute as well in order to scope them as well.
13071309

1310+
#### Atom Effects
1311+
1312+
Atom effects are an API for managing side effects that are synchronized with the atom's lifecycle. They are widely applicable for variety of usage such as state synchronization, state persistence, logging, and etc, by observing and reacting to state changes.
1313+
1314+
You can create custom effects that conform to the [`AtomEffect`](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atomeffect) protocol, but there are several predefined effects.
1315+
1316+
|API|Use|
1317+
|:--|:--|
1318+
|[InitializeEffect](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/initializeeffect)|Performs an arbitrary action when the atom is initialized.|
1319+
|[UpdateEffect](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/updateeffect)|Performs an arbitrary action when the atom is updated.|
1320+
|[ReleaseEffect](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/releaseeffect)|Performs an arbitrary action when the atom is released.|
1321+
|[MergedEffect](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/mergedeffect)|Merges multiple atom effects into one.|
1322+
1323+
Atom effects are attached to atoms via the [`Atom.effect(context:)`](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atom/effect(context:)-2kcbd) function.
1324+
1325+
```swift
1326+
struct CounterAtom: StateAtom, Hashable {
1327+
func defaultValue(context: Context) -> Int {
1328+
UserDefaults.standard.integer(forKey: "persistence_key")
1329+
}
1330+
1331+
func effect(context: CurrentContext) -> some AtomEffect {
1332+
UpdateEffect {
1333+
UserDefaults.standard.set(context.read(self), forKey: "persistence_key")
1334+
}
1335+
}
1336+
}
1337+
```
1338+
1339+
Each atom initializes its effect when the atom is initialized, and the effect is retained until the atom is no longer used from anywhere and is released, thus it allows to declare stateful side effects.
1340+
1341+
```swift
1342+
struct CounterAtom: StateAtom, Hashable {
1343+
func defaultValue(context: Context) -> Int {
1344+
0
1345+
}
1346+
1347+
func effect(context: CurrentContext) -> some AtomEffect {
1348+
CountTimerEffect()
1349+
}
1350+
}
1351+
1352+
1353+
final class CountTimerEffect: AtomEffect {
1354+
private var timer: Timer?
1355+
1356+
func initialized(context: Context) {
1357+
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
1358+
context[CounterAtom()] += 1
1359+
}
1360+
}
1361+
1362+
func updated(context: Context) {
1363+
print("Count: \(context.read(CounterAtom()))")
1364+
}
1365+
1366+
func released(context: Context) {
1367+
timer?.invalidate()
1368+
timer = nil
1369+
}
1370+
}
1371+
```
1372+
13081373
#### Override Atoms
13091374

13101375
You can override atoms in [AtomRoot](#atomroot) or [AtomScope](#atomscope) to overwirete the atom states for dependency injection or faking state in particular view, which is useful especially for testing.
@@ -1658,29 +1723,6 @@ class MessageLoader: ObservableObject {
16581723

16591724
</details>
16601725

1661-
#### Side effects
1662-
1663-
All atom types can optionally implement [`updated(newValue:oldValue:context:)`](https://ra1028.github.io/swiftui-atom-properties/documentation/atoms/atom/updated(newvalue:oldvalue:context:)-98n6k) method to manage arbitrary side-effects of value updates, such as state persistence, state synchronization, logging, and etc.
1664-
In the above example, the initial state of the atom is retrieved from UserDefaults, and when the user updates the state, the value is reflected into UserDefaults as a side effect.
1665-
1666-
<details><summary><code>📖 Example</code></summary>
1667-
1668-
```swift
1669-
struct PersistentCounterAtom: StateAtom, Hashable {
1670-
func defaultValue(context: Context) -> Int {
1671-
UserDefaults.standard.integer(forKey: "persistence_key")
1672-
}
1673-
1674-
func updated(newValue: Int, oldValue: Int, context: UpdatedContext) {
1675-
if newValue != oldValue {
1676-
UserDefaults.standard.set(newValue, forKey: "persistence_key")
1677-
}
1678-
}
1679-
}
1680-
```
1681-
1682-
</details>
1683-
16841726
---
16851727

16861728
### Dealing with Known SwiftUI Bugs

Sources/Atoms/Atoms.docc/Atoms.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ Building state by compositing atoms automatically optimizes rendering based on i
3333
- ``TaskAtom/phase``
3434
- ``ThrowingTaskAtom/phase``
3535

36+
### Effects
37+
38+
- ``AtomEffect``
39+
- ``InitializeEffect``
40+
- ``UpdateEffect``
41+
- ``ReleaseEffect``
42+
- ``MergedEffect``
43+
3644
### Attributes
3745

3846
- ``Scoped``
@@ -67,6 +75,7 @@ Building state by compositing atoms automatically optimizes rendering based on i
6775
- ``AtomViewContext``
6876
- ``AtomTestContext``
6977
- ``AtomCurrentContext``
78+
- ``AtomEffectContext``
7079

7180
### Misc
7281

@@ -80,5 +89,6 @@ Building state by compositing atoms automatically optimizes rendering based on i
8089
- ``ChangesOfModifier``
8190
- ``TaskPhaseModifier``
8291
- ``AnimationModifier``
92+
- ``EmptyEffect``
8393
- ``AtomProducer``
8494
- ``AtomRefreshProducer``

0 commit comments

Comments
 (0)