I've made a small reproducing example app to demonstrate this issue. Just create an iOS App project and replace the App and ContentView files with the following code.
The app works as expected when running it in a simulator or on a device but the SwiftUI Preview will get stuck showing only the loading state, even though the print statement in the View's body has printed viewModel.state: showingContent(SwiftUITest.ViewModel.Content(text: "Hello world!", reloadButtonTitle: "Reload"))
to the console.
When stuck showing the loading state (an animated ProgressView
), If I change .padding(.all, 12)
to e.g. .padding(.all, 2)
or vice versa, then that will make the Preview render the expected content.
Also, if I tap the Reload-button, it will not show the ProgressView
for 2 seconds as expected (and as the app will do when run in a simulator or on a device), instead it will just show a white screen, and the same workaround (changing the padding amount) can be used to make the it render the expected content.
Can other people reproduce this behavior, is it a known bug, or am I doing something wrong?
TestApp.swift
import SwiftUI
@main
struct SwiftUITestApp: App {
var body: some Scene {
WindowGroup {
ContentView(viewModel: ViewModel(
contentLoader: {
try! await Task.sleep(nanoseconds: 2_000_000_000)
return .init(text: "Hello world!", reloadButtonTitle: "Reload")
}
))
}
}
}
ContentView.swift
import SwiftUI
struct ContentView: View {
@StateObject var viewModel: ViewModel
var body: some View {
let _ = print("viewModel.state:", viewModel.state)
Group {
switch viewModel.state {
case .notStarted, .loading:
ProgressView()
case .showingContent(let content):
VStack {
Text(content.text)
.padding(.all, 12)
Button(content.reloadButtonTitle) {
viewModel.handle(event: .reloadButtonWasTapped)
}
}
}
}
.onAppear {
viewModel.handle(event: .viewDidAppear)
}
}
}
// MARK: - ViewModel
@MainActor
final class ViewModel: ObservableObject {
@Published var state: State = .notStarted
let contentLoader: () async -> Content
init(contentLoader: @escaping () async -> Content) {
self.contentLoader = contentLoader
}
func handle(event: Event) {
switch state {
case .notStarted:
if event == .viewDidAppear { loadContent() }
case .loading:
break
case .showingContent:
if event == .reloadButtonWasTapped { loadContent() }
}
}
func loadContent() {
guard state != .loading else { return }
state = .loading
Task {
print("starts loading", Date.now)
let content = await contentLoader()
print("finished loading", Date.now)
state = .showingContent(content)
}
}
enum State: Equatable {
case notStarted
case loading
case showingContent(Content)
}
struct Content: Equatable {
let text: String
let reloadButtonTitle: String
}
enum Event: Equatable {
case viewDidAppear
case reloadButtonWasTapped
}
}
// MARK: - Preview
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(viewModel: ViewModel(
contentLoader: {
try! await Task.sleep(nanoseconds: 2_000_000_000)
return .init(text: "Hello world!", reloadButtonTitle: "Reload")
}
))
}
}
Here's the simple behavior of the app (recorded from simulator):
Each time the view appears, it loads it's content for two seconds while showing a ProgressView, then it shows the content, which includes a Reload button, and if you tap that button it will reload the content for 2 seconds again. I would expect the Preview to behave in the same way.