Skip to content

Commit 60c2c3c

Browse files
committed
Impl onChange+task modifiers, fix onAppear+onDisappear modifiers
The onAppear and onDisappear modifiers were trying to avoid introducing more layers of nesting, but that caused state management to get messed up. It would've been fixable without reintroducing the extra view graph nodes, but it would've gotten pretty ugly when introducing the planned @State modifier.
1 parent d94e00a commit 60c2c3c

8 files changed

+183
-60
lines changed

Sources/SwiftCrossUI/Modifiers/EnvironmentModifier.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
struct EnvironmentModifier<Child: View>: LightweightView {
1+
struct EnvironmentModifier<Child: View>: View {
22
var body: TupleView1<Child>
33
var modification: (EnvironmentValues) -> EnvironmentValues
44

Sources/SwiftCrossUI/Modifiers/OnAppearModifier.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,19 @@ extension View {
77
/// if these docs have been kept up to date, the action gets called just
88
/// before creating the view's widget.
99
public func onAppear(perform action: @escaping () -> Void) -> some View {
10-
OnAppearModifier(body: self, action: action)
10+
OnAppearModifier(body: TupleView1(self), action: action)
1111
}
1212
}
1313

14-
struct OnAppearModifier<Content: View>: LightweightView {
15-
var body: Content
14+
struct OnAppearModifier<Content: View>: View {
15+
var body: TupleView1<Content>
1616
var action: () -> Void
1717

1818
func asWidget<Backend: AppBackend>(
1919
_ children: any ViewGraphNodeChildren,
2020
backend: Backend
2121
) -> Backend.Widget {
2222
action()
23-
return body.asWidget(children, backend: backend)
23+
return defaultAsWidget(children, backend: backend)
2424
}
2525
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
extension View {
2+
public func onChange<Value: Equatable>(
3+
of value: Value,
4+
initial: Bool = false,
5+
perform action: @escaping () -> Void
6+
) -> some View {
7+
OnChangeModifier(
8+
body: TupleView1(self),
9+
value: value,
10+
action: action,
11+
initial: initial
12+
)
13+
}
14+
}
15+
16+
class OnChangeModifierState<Value: Equatable>: Observable {
17+
var previousValue: Value?
18+
}
19+
20+
struct OnChangeModifier<Value: Equatable, Content: View>: View {
21+
var state = OnChangeModifierState<Value>()
22+
23+
var body: TupleView1<Content>
24+
25+
var value: Value
26+
var action: () -> Void
27+
var initial: Bool
28+
29+
func update<Backend: AppBackend>(
30+
_ widget: Backend.Widget,
31+
children: any ViewGraphNodeChildren,
32+
proposedSize: SIMD2<Int>,
33+
environment: EnvironmentValues,
34+
backend: Backend,
35+
dryRun: Bool
36+
) -> ViewSize {
37+
if let previousValue = state.previousValue, value != previousValue {
38+
action()
39+
} else if initial && state.previousValue == nil {
40+
action()
41+
}
42+
state.previousValue = value
43+
44+
return defaultUpdate(
45+
widget,
46+
children: children,
47+
proposedSize: proposedSize,
48+
environment: environment,
49+
backend: backend,
50+
dryRun: dryRun
51+
)
52+
}
53+
}

Sources/SwiftCrossUI/Modifiers/OnDisappearModifier.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ extension View {
55
/// down to the leaf views due to essentially relying on the `deinit` of the
66
/// modifier view's ``ViewGraphNode``.
77
public func onDisappear(perform action: @escaping () -> Void) -> some View {
8-
OnDisappearModifier(body: self, action: action)
8+
OnDisappearModifier(body: TupleView1(self), action: action)
99
}
1010
}
1111

12-
struct OnDisappearModifier<Content: View>: TypeSafeView {
13-
var body: Content
12+
struct OnDisappearModifier<Content: View>: View {
13+
var body: TupleView1<Content>
1414
var action: () -> Void
1515

1616
func children<Backend: AppBackend>(
@@ -19,7 +19,7 @@ struct OnDisappearModifier<Content: View>: TypeSafeView {
1919
environment: EnvironmentValues
2020
) -> OnDisappearModifierChildren {
2121
OnDisappearModifierChildren(
22-
wrappedChildren: body.children(
22+
wrappedChildren: defaultChildren(
2323
backend: backend,
2424
snapshots: snapshots,
2525
environment: environment
@@ -32,7 +32,7 @@ struct OnDisappearModifier<Content: View>: TypeSafeView {
3232
backend: Backend,
3333
children: OnDisappearModifierChildren
3434
) -> [LayoutSystem.LayoutableChild] {
35-
body.layoutableChildren(
35+
defaultLayoutableChildren(
3636
backend: backend,
3737
children: children.wrappedChildren
3838
)
@@ -42,7 +42,7 @@ struct OnDisappearModifier<Content: View>: TypeSafeView {
4242
_ children: OnDisappearModifierChildren,
4343
backend: Backend
4444
) -> Backend.Widget {
45-
body.asWidget(children.wrappedChildren, backend: backend)
45+
defaultAsWidget(children.wrappedChildren, backend: backend)
4646
}
4747

4848
func update<Backend: AppBackend>(
@@ -53,7 +53,7 @@ struct OnDisappearModifier<Content: View>: TypeSafeView {
5353
backend: Backend,
5454
dryRun: Bool
5555
) -> ViewSize {
56-
body.update(
56+
defaultUpdate(
5757
widget,
5858
children: children.wrappedChildren,
5959
proposedSize: proposedSize,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
extension View {
2+
/// Starts a task before a view appears (but after ``View/body`` has been
3+
/// accessed), and cancels the task when the view disappears. Additionally,
4+
/// if `id` changes the current task is cancelled and a new one is started.
5+
///
6+
/// This variant of `task` can be useful when the lifetime of the task
7+
/// must be linked to a value with a potentially shorter lifetime than the
8+
/// view.
9+
public nonisolated func task<Id: Equatable>(
10+
id: Id,
11+
priority: TaskPriority = .userInitiated,
12+
_ action: @escaping () async -> Void
13+
) -> some View {
14+
TaskModifier(
15+
id: id,
16+
content: TupleView1(self),
17+
priority: priority,
18+
action: action
19+
)
20+
}
21+
22+
/// Starts a task before a view appears (but after ``View/body`` has been
23+
/// accessed), and cancels the task when the view disappears.
24+
public nonisolated func task(
25+
priority: TaskPriority = .userInitiated,
26+
_ action: @escaping () async -> Void
27+
) -> some View {
28+
TaskModifier(
29+
id: 0,
30+
content: TupleView1(self),
31+
priority: priority,
32+
action: action
33+
)
34+
}
35+
}
36+
37+
class TaskModifierState<Id: Equatable>: Observable {
38+
var task: Task<(), any Error>?
39+
}
40+
41+
struct TaskModifier<Id: Equatable, Content: View>: View {
42+
var state = TaskModifierState<Id>()
43+
44+
var id: Id
45+
var content: Content
46+
var priority: TaskPriority
47+
var action: () async -> Void
48+
49+
var body: some View {
50+
// Explicitly return to disable result builder (we don't want an extra
51+
// layer of views).
52+
return
53+
content
54+
.onChange(of: id, initial: true) {
55+
state.task?.cancel()
56+
state.task = Task(priority: priority) {
57+
await action()
58+
}
59+
}
60+
.onDisappear {
61+
state.task?.cancel()
62+
}
63+
}
64+
}

Sources/SwiftCrossUI/ViewGraph/ViewGraphNode.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,14 +228,14 @@ public class ViewGraphNode<NodeView: View, Backend: AppBackend> {
228228
backend: backend,
229229
dryRun: dryRun
230230
)
231-
backend.show(widget: widget)
232231

233232
// We assume that the view's sizing behaviour won't change between consecutive dry run updates
234233
// and the following real update because groups of updates following that pattern are assumed to
235234
// be occurring within a single overarching view update. It may seem weird that we set it
236235
// to false after real updates, but that's because it may get invalidated between a real
237236
// update and the next dry-run update.
238237
if !dryRun {
238+
backend.show(widget: widget)
239239
sizeCache = [:]
240240
} else {
241241
sizeCache[proposedSize] = size

Sources/SwiftCrossUI/Views/LightweightView.swift

Lines changed: 0 additions & 47 deletions
This file was deleted.

Sources/SwiftCrossUI/Views/View.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,20 +68,53 @@ extension View {
6868
backend: Backend,
6969
snapshots: [ViewGraphSnapshotter.NodeSnapshot]?,
7070
environment: EnvironmentValues
71+
) -> any ViewGraphNodeChildren {
72+
defaultChildren(
73+
backend: backend,
74+
snapshots: snapshots,
75+
environment: environment
76+
)
77+
}
78+
79+
/// The default `View.children` implementation. Haters may see this as a
80+
/// composition lover re-implementing inheritance; I see it as innovation.
81+
func defaultChildren<Backend: AppBackend>(
82+
backend: Backend,
83+
snapshots: [ViewGraphSnapshotter.NodeSnapshot]?,
84+
environment: EnvironmentValues
7185
) -> any ViewGraphNodeChildren {
7286
body.children(backend: backend, snapshots: snapshots, environment: environment)
7387
}
7488

7589
public func layoutableChildren<Backend: AppBackend>(
7690
backend: Backend,
7791
children: any ViewGraphNodeChildren
92+
) -> [LayoutSystem.LayoutableChild] {
93+
defaultLayoutableChildren(backend: backend, children: children)
94+
}
95+
96+
/// The default `View.layoutableChildren` implementation. Haters may see
97+
/// this as a composition lover re-implementing inheritance; I see it as
98+
/// innovation.
99+
func defaultLayoutableChildren<Backend: AppBackend>(
100+
backend: Backend,
101+
children: any ViewGraphNodeChildren
78102
) -> [LayoutSystem.LayoutableChild] {
79103
body.layoutableChildren(backend: backend, children: children)
80104
}
81105

82106
public func asWidget<Backend: AppBackend>(
83107
_ children: any ViewGraphNodeChildren,
84108
backend: Backend
109+
) -> Backend.Widget {
110+
defaultAsWidget(children, backend: backend)
111+
}
112+
113+
/// The default `View.asWidget` implementation. Haters may see this as a
114+
/// composition lover re-implementing inheritance; I see it as innovation.
115+
func defaultAsWidget<Backend: AppBackend>(
116+
_ children: any ViewGraphNodeChildren,
117+
backend: Backend
85118
) -> Backend.Widget {
86119
let vStack = VStack(content: body)
87120
return vStack.asWidget(children, backend: backend)
@@ -94,6 +127,26 @@ extension View {
94127
environment: EnvironmentValues,
95128
backend: Backend,
96129
dryRun: Bool
130+
) -> ViewSize {
131+
defaultUpdate(
132+
widget,
133+
children: children,
134+
proposedSize: proposedSize,
135+
environment: environment,
136+
backend: backend,
137+
dryRun: dryRun
138+
)
139+
}
140+
141+
/// The default `View.update` implementation. Haters may see this as a
142+
/// composition lover re-implementing inheritance; I see it as innovation.
143+
func defaultUpdate<Backend: AppBackend>(
144+
_ widget: Backend.Widget,
145+
children: any ViewGraphNodeChildren,
146+
proposedSize: SIMD2<Int>,
147+
environment: EnvironmentValues,
148+
backend: Backend,
149+
dryRun: Bool
97150
) -> ViewSize {
98151
let vStack = VStack(content: body)
99152
return vStack.update(

0 commit comments

Comments
 (0)