Listening to `scenePhase` prevents destination update in a `task` closure
Zeta611 opened this issue · 2 comments
Describe the bug
Strangely, listening to scenePhase
via @Environment(\.scenePhase)
in the app struct prevents destination update in a task
closure (in an asynchronous context).
To Reproduce
TestCover.zip
The view / model:
@MainActor
final class ContentViewModel: ObservableObject {
@Published var destination: Destination?
init() { destination = .loading }
func task() async {
try? await Task.sleep(for: .seconds(1))
destination = nil
}
enum Destination { case loading }
}
struct ContentView: View {
@ObservedObject private(set) var viewModel: ContentViewModel
var body: some View {
Text("Hello, world!")
.fullScreenCover(
unwrapping: $viewModel.destination,
case: /ContentViewModel.Destination.loading
) { _ in ProgressView() }
.task { await viewModel.task() }
}
}
The app:
@main
struct TestCoverApp: App {
// Commenting out the following line makes it work again!
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup { ContentView(viewModel: ContentViewModel()) }
}
}
Expected behavior
The fullScreenCover
should dismiss after destination
gets nil-ed in task
.
Environment
- swiftui-navigation version 0.6.1
- Xcode 14.2
- Swift 5.7.2
- OS: iOS 16.2
Hey @Zeta611! When the scenePhase
changes (and it happens a few times when the app launches), TestCoverApp
body is re-evaluated, and a new ContentViewModel()
instance is passed to the View
. As a result, the early instance that received the .task
signal is discarded, and no update effectively happens, as it is not observed anymore when the task
function resumes.
The solution is to keep a reference to your first ContentViewModel
in some way. Because you made this type @MainActor
, it is not always convenient to create and store this value at the top level, but the easiest is probably to simply replace ContentView
's @ObservedObject
by @StateObject
, as this latter will only keep the first instance it sees.
As an alternative if you can't use @StateObject
, you can try this MainActorIsolated
wrapper:
@MainActor
public final class MainActorIsolated<Value>: Sendable {
public lazy var value: Value = initialValue()
private let initialValue: @MainActor () -> Value
nonisolated public init(initialValue: @MainActor @escaping () -> Value) {
self.initialValue = initialValue
}
}
and use it as:
@main
struct TestCoverApp: App {
@Environment(\.scenePhase) private var scenePhase
let viewModel = MainActorIsolated { ContentViewModel() }
var body: some Scene {
WindowGroup {
ContentView(viewModel: viewModel.value)
}
}
}
(only body
is re-evaluated. The TestCoverApp
is not recreated1, but you can store the value out of TestCoverApp
, at the top level, if you're more comfortable with that).
Footnotes
-
Apparently at least. This is still an open question for me, and I never managed to get a proper response everywhere I asked for a counter-example where
TestCoverApp
would be recreated. ↩
Thank you for providing a thorough and an insightful analysis of the lifecycle and how to workaround the issue!
Indeed, I observed that the object was recreated multiple times and was quite puzzled by it.
Thank you again, and I will close this issue as this doesn't seem to be the issue with the library.