Xcode Previews bug? View is not re-rendered as expected

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.

  • This is fixed if you use the new #Preview macro instead of the PreviewProvider. Alternatively replace the top-level Group in the ContentView's body with a ZStack

Add a Comment

Replies

Thanks Developer Tools Engineer, I cannot use Xcode 15 / #Preview macro in this project, but replacing the Group with a ZStack works!

  • Great news! Though why aren't you able to use the new Preview macro?

  • The build servers for the project I'm working in are currently using Xcode 14.3.1 (but will of course be updated to Xcode 15 at some point in time).

Add a Comment