Search code examples
swiftuicombine

Infinite loop when using onAppear in SwiftUI


I have a strange infinite loop when using onAppear and I cannot identify the root of the problem. It only happens when the view is the detail view of a navigation view, but it works fine when it's the root view. Another interesting thing is that if I wrap the detail view in a NavigationView (so, now we have a navigation view inside a navigation view), then the issue does not appear anymore. Is this a bug in SwiftUI? Is conceptually my design OK? I mean, using onAppear like viewDidLoad to trigger the initial sequence. Thanks for suggestions.

Here is the source code. ContentView.swift:

import SwiftUI

struct ContentView: View {

    @StateObject var viewModel = ContentViewModel()

    var body: some View {
        NavigationView {
            VStack {
                Group {
                    switch viewModel.state {
                    case .loading:
                        Text("Loading...")
                    case .loaded:
                        HStack {
                            Text("Loaded")
                            Button("Retry") {
                                viewModel.fetchData()
                            }
                        }
                    }
                }
                .padding(.bottom, 20)
                NavigationLink("Go to detail screen", destination: DetailView())
            }
        }
        .onAppear() {
            viewModel.fetchData()
        }
    }
}

class ContentViewModel: ObservableObject  {

    enum State {
        case loading
        case loaded
    }

    @Published var state: State = .loading

    func fetchData() {
        state = .loading
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            self.state = .loaded
        }
    }
}

And here the code of the detail view:

import SwiftUI

struct DetailView: View {

    @StateObject var viewModel = DetailViewModel()

    var body: some View {
        Group {
            switch viewModel.state {
            case .loading:
                Text("Loading...")
            case .loaded:
                HStack {
                    Text("Loaded")
                    Button("Retry") {
                        viewModel.fetchData()
                    }
                }
            }
        }
        .onAppear() {
            print("infinite loop here")
            viewModel.fetchData()
        }
    }
}

class DetailViewModel: ObservableObject  {

    enum State {
        case loading
        case loaded
    }

    @Published var state: State = .loading

    func fetchData() {
        state = .loading
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            self.state = .loaded
        }
    }
}

Here I attach the project: https://www.dropbox.com/s/5alokj3q81jbpj7/TestBug.zip?dl=0

I'm using Xcode Version 12.5.1 (12E507) and iOS 14.5

Thanks a lot.


Solution

  • This issue is a bug in iOS 14 with Group, and will still happen even when building with Xcode 13.

    What's happening is the SwiftUI runtime is messing up its view diff, so it believes that it "appeared" when really it only reloaded, and thus did not disappear (which is a requirement for onAppear to be called, hence a bug).

    To compensate for this issue, you'll need to use initialisers rather than relying on onAppear.

    I've amended your code, this amended version does not infinitely loop.

    struct DetailView: View {
    
        @StateObject var viewModel = DetailViewModel()
    
        var body: some View {
            Group {
                switch viewModel.state {
                case .loading:
                    Text("Loading...")
                case .loaded:
                    HStack {
                        Text("Loaded")
                        Button("Retry") {
                            viewModel.fetchData()
                        }
                    }
                }
            }
        }
    }
    
    class DetailViewModel: ObservableObject  {
    
        enum State {
            case loading
            case loaded
        }
    
        @Published var state: State = .loading
    
        init() {
            self.fetchData()
        }
    
        func fetchData() {
            state = .loading
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                self.state = .loaded
            }
        }
    }
    

    Hope the project goes well!