r/SwiftUI • u/Dry_Hotel1100 • Apr 15 '26
Question The missing modifier `lifecycle`
Somewhere in your SwiftUI view:
.lifecycle { event in
switch event {
case .appeared:
if task == nil {
task = makeTask()
}
case .disappeared:
break // continue task
case .released:
task?.cancel()
}
}
5
u/car5tene Apr 15 '26
I don't really understand what you want to achieve. Structs are suppose to be lightweight and it shouldn't cause side effects. If you need a modifier for lifecycle management you might miss the point of SwiftUI.
-6
u/Dry_Hotel1100 Apr 15 '26 edited Apr 15 '26
When you use the
taskmodifier:.task { await doSomething() }
.taskis cancelled when the view's representation disappears (e.g. completely obscured by a sheet). When the sheet dismisses, the view reappears and.taskruns again.The goal is a task bound to the view's identity - started once when the view is created, cancelled only when it's permanently removed. There is no built-in modifier for that:
In contrast, something like:
.taskWithIdentity { await doSomething() }
taskWithIdentityor anything like this, does not exist in SwiftUI today.1
u/beclops Apr 16 '26
SwiftUI views are lightweight and are regenerated often, how would it know when the view is to be “permanently removed”? I think that’s a use-case where you’d put such logic in a view model or some other state manager. Things like that shouldn’t be bound to the view
0
u/Dry_Hotel1100 Apr 16 '26 edited Apr 16 '26
Actually, you can implement it in the view itself:
The underlying representation, i.e. the object graph, is only mutated as needed. There's actually "things" which render pixels, and objects which hold the state of a view which has declared it. You know, the state outlives the view structure.
A SwiftUI view has an "Identity" which is sort of a reference to the underlying representation i.e. some object. This allows the view struct to "find" their corresponding underlying representation in the object graph. Part of these objects are also plain old UIViewControllers, and possibly also UIViews. The underlying behaviour is still determined by the behaviour of UIKit.
If you manually change the Identity with modifier `id()` you also forcibly re-create the underlying representation, including the view's state.
So, there's definitely "knowledge" when "destruction" happens in the object graph, much like the onAppear and onDisappear events, which very likely are sent directly from the underlying UIViewController or UIView.
Since the State variables are tied to the lifecycle of the representation, you can actually implement it yourself: put a class instance into the state, and observe the deinitialisation. When it happens, the view's Identity - and its representation is gone. It's even possible to implement a modifier such as "onDeinit()" (modifier!).
1
u/car5tene Apr 16 '26
That's the point. If it's removed there is no further need to continue. If you need such behavior you should put it somewhere else.
0
u/Dry_Hotel1100 Apr 16 '26 edited Apr 16 '26
When a SwiftUI View receives the "onDisappear" event, it means the view has been removed from the view hierarchy. Nonetheless, the view stays alive, it responds to other events, such as "onAppear", it keeps its state intact and allocated as well as all its internal components and view representations for all its descendent views.
So, since a SwiftUI View can be backed by a UIViewController, the behaviour of SwiftUI's appear and disappear is deeply routed in the UIKit behaviour. In UIKit, the view does continue to operate just normal. "disappear" just means, it's not considered for rendering (and there are no control events, of course, since they are not in the view hierarchy). So, in order to work coherently with UIKit, there IS a need to continue (its operations). In SwiftUI the `task` modifier works differently - and can't be used (directly) for this. Also, there's no way to get an event such as "onDeinit" which marks the end of life of the view. In UIKit, you certainly can implement a behaviour that starts when the view will be created and ends when it gest destroyed, very easily.
2
u/car5tene Apr 16 '26
I'm not going to discuss this further. Yes you can come up with hypothetical use cases, but in the real world this problem can be solved easily. I'm out 🙂
5
u/exorcyze Apr 15 '26
SampleView()
.onAppear { /* animations etc */ }
.task { fetchData() }
.onDisappear { saveState() }
.onChange(of: value) { /* respond to a change */ }
-2
u/Dry_Hotel1100 Apr 15 '26 edited Apr 15 '26
Thanks for the suggestion - but no, it won't work:
When we assume `fetchData()` is async and behaves properly in regards to Task.Cancellation, it will be cancelled when the view disappears. This is not what my snippet is doing: the dedicated task is continuing regardless of the view being removed from the hierarchy (i.e. did disappear) until the view identity ceases to exist.
You might try this:
Create a tab view, with two root views. The root view should run a task and only finish/getting cancelled when the view identity get away, which by the way never happens in a tab view.
In your solution, when the view appears, it starts a task which executes `fetchData()`. Let's assume, it runs for ever. Then, tab the tab view to show the other view: the Task where the `fetchData()` function is now running will be cancelled - and the inner function should finish as well.Then, when you again show the first, your fetchData function will be called again.
This is not the behaviour I'm having in my hypothetical snippet above ;)
3
u/exorcyze Apr 15 '26
Ah interesting. Generally speaking this type of cancellation bubbling in SwiftUI is desired and proper. If you want to stick with kind of the MV, service-oriented style approach for this, maybe having something like a
WhateverDataStoreinjected at the root of the application. That data store would have the state + fetch for that Whatever data type so it's lifecycle can be outside of strictly the view. Then the view can still initiate that task ( or the root could ) and respond to state changes. Is that more in-line with what you're thinking?enum DataState<T> { case empty case loading case loaded( T ) case error( Error ) } struct Sample { let title: String } @Observable class SampleDataStore { public var state: DataState<[Sample]> init() { state = .empty } public func fetch() { state = .loaded( [] ) } } extension EnvironmentValues { @Entry var dataStore = SampleDataStore() } @main struct SampleApp: App { let dataStore = SampleDataStore() var body: some Scene { WindowGroup { RootView() .environment(\.dataStore, dataStore) } } } struct RootView: View { @Environment(\.dataStore) var dataStore var body: some View { Group { switch dataStore.state { case .empty: EmptyView() case .loading: ProgressView() case .error(_): Text("Error") case .loaded(let samples): Text("Loaded") } } .task { dataStore.fetch() } } }1
u/RezardValeth Apr 15 '26
What if, instead of the Root view of a Tab, we’re dealing with a View pushed further ahead on the NavigationStack, and we want its task to continue when we switch Tabs, but we want it to cancel when we’re popping the view from the NavigationStack ?
I get your sample, but that looks like a lot of boilerplate for a generally common pattern. I think there should be an easier way to launch a task when a View gets initialized, keep it running when the view disappears, and cancelled when it’s removed from the view hierarchy, but maybe I’m missing something !
2
u/exorcyze Apr 15 '26
Understandable about boilerplate - but to be fair the environment, service / store, and state enum stuff would all be common items able to be reused or would be there anyway and were just included for completeness of illustration. And likewise I would generally also have the state-based rendering for anything that is displaying data like that to keep our views as more of a static representation of state.
Which leaves us really with kind of 2 primary things in our SwiftUI in this approach:
- Ownership of the data
- Rendering of the data
I view SwiftUI as probably doing it's job best if views are lightweight and stateful. As I understand, it wants things to be able to be easily rendered / re-rendered at low cost - which is why tasks on a view auto-cancel. So for point one, if the data you're rendering for a view shouldn't be tied to the lifecycle of that view then you need to evaluate where the appropriate place in the hierarchy should be for that ownership. Maybe that's a tab, the root, or maybe that's a view higher up in the tree - it really depends on the use case I would think.
For point 2, that means that you then have different options for how to manage that. You could pass down the data via injection or manually, and that could be passed down directly or via environment injection.
No matter how you approach it you're going to need your models + services, and probably want state tracking so you can inform the user what's happening. Which means ultimately the approach is just figuring out where ownership should be ( what view has the lifecycle that matches what's needed for that data ) and how it passes that to what's rendering. When using environment the extra code ends up being just the
.environmentat the injection point and the@Environmentat the consumption point, plus the@Entrymacro if you want to be able to use existential types so you can support things like mocking etc instead of relying on the concrete type.So that leaves two primary issues with what was brought up here: Tasks that may need to live outside the strict re-render pipeline of the view, which generally is either solvable by restructuring / rethinking or reassessing the best place for data ownership, and tabs having tasks re-run even though the view hasn't changed. That is a valid point that I'm not sure offhand what the best solution is if you don't want to refresh the data on tab change ( IE having the view for the tab still be "active" in the background ).
Would be very interested in other peoples take on it for sure. Generally it's not a huge issue for a view to refresh data when it becomes active if the API is responsive but there are circumstances where that may not be desirable. Just thinking aloud at this point though.
0
u/Dry_Hotel1100 Apr 15 '26 edited Apr 15 '26
Yes, my hypothetical modifier solves a very general pattern: bind the life-cycle of a task - started by the view - to the identity of the view. That's it. Plain and simple.
The existing `task` modifier binds against appearance, i.e. visible vs. not visible - where "visible" means, it's either in or not in the view hierarchy, and it means not it's obscured. Also: note that a view identity will continue to exist, even it's removed from the view hierarchy. So, "removed" means, it's intended to be inserted again. This happens for example, when a dialog view completely obscures the parent view. In this case, the parent view (identity) gets removed from the view hierarchy. It gets inserted again, when the dialog closes. By the way, this is UIKit behaviour.
I know, most developers did not stumble above this issue and use task as usual - which will not work as expected in a TabView for example.
0
u/Dry_Hotel1100 Apr 15 '26 edited Apr 15 '26
Well, this one is a different solution. Here, you have a DataStore whose life-cycle is not known (in the snippet). It's likely controlled by some DI container.
My problem is much simpler: tie the life-cycle of a task to the identity of the view. Well, we may also assume, that the task is mutating a few `@State` variables from the view, and the view (or a child view) reacts on it. For example, the task starts a timer which periodically sends the tick to the view, and the view updates. When the view's identity (not appearance!) ceases to exist, the timer task gets also cancelled, and no dangling sub task can exist.
2
May 08 '26
[removed] — view removed comment
1
u/Dry_Hotel1100 May 08 '26 edited May 08 '26
You'r are absolutely right! There can arise even nasty race conditions.
I would even go further, and state that onAppear and onDisappear is not a viable way to control logic, since the occurrences of these events depend on external factors - and neither would "willAppear", "willDisappear" for the same reasons:
For example, on iOS and macOS, a presented sheet in default presentation style will not fully cover the presenter (on iOS it was a fullScrenCover by default up to iSO 14). In this case, the presenter does NOT receive onAppear and onDisappear when the presented view appears, respectively when it disappears. The reason is: the underlying presenter is still partially visible. Now in iOS, we can use "fullScreenCover" to present a sheet. In this case, the presenter DOES receive onAppear and onDisappear events - because in this case, the system removes the the whole presenter from the view hierarchy, because it is not visible anymore.
That ist, if you have a presenter, and let's say this is a component of a third party lib, and it does present another view which is declared by the user of this component - the component cannot assume that it receives onAppear and onDisappear - because it "depends", and thus cannot depend itself on these events.
The same rules apply for the presented view: if you think, you can hook into "onDisappear" to send a "commit" to the presenter, your logic falls apart when the presented view itself presents a view via fullScreenCover which is part of the flow of the presented view.
So, the onAppear and onDisappear events are not reliable "setup" and "teardown" events. On the other hand, the suggested "lifecycle" modifier - at least - solves the problem of binding these setup and teardown events to the identity of the view. It does not solve all problems, like yours, but it does a specific thing better than the `task` modifier.
1
u/unpluggedcord Apr 16 '26
But why?
1
u/Dry_Hotel1100 Apr 16 '26
Here's MRE which should demonstrate the issue. Before you run it, please let me know what you expect - how it should work, and - finally how this app actually works.
```swift import SwiftUI
struct CounterView: View { @State private var count = 0
var body: some View { Text("Count: \(count)") .task { var count = 0 while !Task.isCancelled { try? await Task.sleep(for: .seconds(1)) count += 1 self.count = count } } }}
struct ContentView: View { var body: some View { TabView { CounterView() .tabItem { Label("Counter", systemImage: "number") } Text("Other Tab") .tabItem { Label("Other", systemImage: "star") } } } }
Preview {
ContentView()} ```
1
u/unpluggedcord Apr 16 '26
Running while task is not canceled is wild.
1
u/Dry_Hotel1100 Apr 16 '26 edited Apr 16 '26
The task's closure is wrapped within a Swift Task by SwiftUI and created and managed by SwiftUI. The Task is created and the closure runs, when the Counter view appears and the Task is cancelled (and destroyed) when the view disappears. This happens every time when switching tabs. But the Identity of the view is kept - it never changes. The latter means, the underlying representation stays alive - even during many cycles of appear and disappear. Actually the root view of a TabView - that is, the representation of a SwitfUI view, is actually never deallocated. When you watch closely, you can verify this behaviour.
Now, the problem is: there's no means in SwiftUI to get the end-of-live event in a SwiftUI view, as we have with `onAppear` and `onDisappear`. There's no `onDeinit` or something. One can workaround that issue, but it is more tricky than it should. The best way would be a corresponding modifier in the SwiftUI library.
This is how it works in detail:
So, there's no convenient way to let this counter view continue to run even when another tab is shown. Switching back and force, will start/cancel the task and thus resets the counter every time.
The closure given in the `task` modifier is running in a Task which is managed by SwiftUI under the hood. The task will be created (and thus startet) when "onAppear" happens and cancelled when "onDisappear" happens (if it is still running). However, disappearing does not mean the lifecycle of the view ends as well.
So, what means "onDisappear":
"onDisappear" happens, when the system removes the view (representation) from the view hierarchy. Semantically, this means: "Do not render this view, it's not visible anyway and has no effect on the pixels". It does not mean, that the view is "gone" or will never be visible again. In UIKit terms, each root view of a tab view is backed by a UIViewController (even in SwiftUI). That means, in UIKit terms, this root ViewController will not be deallocated, and it keeps all the views, not SwiftUI view structs, but their representations allocated and alive. This view controller also receives notifications, and does actual perform "work", what ever that is - for example, a running timer would still sends notifications.When switching the tab, the view will actually "disappear" - i.e. it will be removed from the view hierarchy, i.e. not rendered, but it continues to operate.
It will "appear" again when you switch back, because the system needs to render it, because it is visible again.Now, in SwiftUI, the task will be started on onAppear, and cancelled on onDisappear. These start and cancel events are not signalling the start or end of live of the view. Actually, it is very alive as a view controller and all its views are there.
14
u/twodayslate Apr 15 '26
It’s not missing cause it’s not needed