0

My App uses SwiftUI, targets iOS 15+ and is governed by an ObservableObject, AppState, which serves as the source of truth throughout the app. I use @Published vars to manage the state of overlays (e.g. while loading data) in various places without problems.

Simplified AppState:

class AppState:ObservableObject {
  @Published var showLoadingOverlay = false

  func loadData() {
    self.showLoadingOverlay = true
    // fech data
    self.showLoadingOverlay = false
  }
}

Within views I inject the AppState via @EnvironmentObject and use the published variables to control fullScreenCover.

Simplified example:

struct MyDataView:View {
  @EnvironmentObject var state:AppState

  var body: some View {
    ScrollView {
      VStack {
        ForEach(Array(zip(state.dataSets.indices, state.dataSets)), id: \.0) { (dataSetIndex, dataSet) in
          MirrorChartView(dataSet: dataSet)
        }
      }
    }
    .onAppear {
      state.loadData()
    }
    .fullScreenCover(isPresented: $state.showLoadingOverlay) {
      LoadingOverlay()
    }
  }
}

The problem I'm encountering is that one overlay remains shown while the binding value changes to false. This "getting stuck" only occurs in cases where loadData() completes very fast and the overlay would virtually get dismissed before being fully shown.

Adding a debug monitor to the view confirms that the binding to AppState is properly propagated:

struct MyDataView:View {
  @EnvironmentObject var state:AppState

  var body: some View {
    VStack {
      // draw stuff
    }
    .onAppear {
      state.loadData()
    }
    .onChange(of: state.showLoadingOverlay) { showOverlay in
      print("show overlay: \(showOverlay)")
    }
    .fullScreenCover(isPresented: $state.showLoadingOverlay) {
      LoadingOverlay()
    }
  }
}

I.e. I can see the log show overlay: false while the overlay is sliding up, but it doesn't get dismissed.

Even adding an indirection via a @State local binding does not reliably fix the issue:

struct MyDataView:View {
  @EnvironmentObject var state:AppState
  @State var showLoadingOverlay = false

  var body: some View {
    VStack {
      // draw stuff
    }
    .onAppear {
      state.loadData()
    }
    .onChange(of: state.showLoadingOverlay) { showOverlay in
      self.showLoadingOverlay = showOverlay
    }
    .fullScreenCover(isPresented: self.$showLoadingOverlay) {
      LoadingOverlay()
    }
  }
}

Update: more testing has shown that even an in-place binding being false does not dismiss the fullScreenCover:

Replacing the state binding with an in-place custom binding shows my state to be properly propagated and causing a view update. The sheet, however, still stays presented.

struct MyDataView:View {
  @EnvironmentObject var state:AppState

  var body: some View {
    VStack {
      // draw stuff
    }
    .onAppear {
      state.loadData()
    }
    .fullScreenCover(isPresented: .init(
      get: {
        print("recomputing binding: \(state.showOverlay)")
        return state.showOverlay
      }, set: { newValue in
        state.showOverlay = newValue
      }
    )) {
      LoadingOverlay()
    }
  }
}

This results in the following log message chronology:

recomputing binding: false
recomputing binding: true
recomputing binding: false

Update: further findings

I'm not sure what to make of it, but adding a log message to the LoadingOverlay's .onAppear() shows, that it appears after the binding has changed to false:

struct MyDataView:View {
  @EnvironmentObject var state:AppState

  var body: some View {
    ScrollView {
      VStack {
        ForEach(Array(zip(state.dataSets.indices, state.dataSets)), id: \.0) { (dataSetIndex, dataSet) in
          MirrorChartView(dataSet: dataSet)
        }
      }
    }
    .onAppear {
      state.loadData()
    }
    .onChange(of: state.showLoadingOverlay) { show in
      print("show loading overlay \(show)")
    }
    .fullScreenCover(isPresented: $state.showLoadingOverlay) {
      LoadingOverlay()
        .onAppear {
          print("loading overlay appears")
        }
    }
  }
}

This results in the following log message chronology:

show loading overlay: true
show loading overlay: false
loading overlay appears

What ways are there to resolve this issue?

Haensl
  • 343
  • 3
  • 16
  • 2
    I couldn't reproduce it with [code like this](https://gist.github.com/svenoaks/4a37d348ee83ba77325c16938a997c6d). Could you make a reproducible example? – Steve M Nov 08 '21 at 03:35
  • can you share what `fullscreenCover` is? – Avba Nov 08 '21 at 10:25
  • @SteveM your example is pretty much what's going on and why I'm so baffled by this behaviour. `loadData()` in my case uses `combineLatest` to aggregate data from two sources and updates another `@Published var` in `state`. Since I use this very pattern in many places throughout the app, I have trouble creating a reproducible example, i.e. it works everywhere else. However, other places actually invoke an API and are comparatively slow, whereas in this instance it's just some filtering and aggregating using a publisher pipeline which happens basically instantly if that data is already loaded. – Haensl Nov 08 '21 at 11:53
  • @AvnerBarr The code within `.fullScreenCover()` just shows a _dump_ loading overlay, i.e just a View with some text and a `ProgressView`. The loading overlay itself has neither `@State` nor does it access the `AppState`. Purely rendering. – Haensl Nov 08 '21 at 11:55
  • @SteveM I just realized that there is a difference in your example from what I'm doing: loading is triggered `onAppear()` instead of by a button click. That should not make a difference though. – Haensl Nov 08 '21 at 12:01
  • @haensi yes I tried it with `onAppear()` too, among some other things. Wondering if you have any `UIViewRepresentable` anywhere in the views, I find they can sometimes cause strange inexplicable behaviors such as this. – Steve M Nov 08 '21 at 13:35
  • @SteveM (Un)Fortunately not. I, too, suspect that the problem relates to view re-rendering. What I observe is: the view is being (re)drawn with data, while the overlay is still animating in. But the view itself is rather simple, it contains a `ScrollView` that draws a chart `ForEach` data set compiled in the pipeline. I'll add the view code to the question above. – Haensl Nov 08 '21 at 14:57
  • @SteveM I've added to findings up top: The overlay's `onAppear()` fires _after_ the binding has changed to `false`. – Haensl Nov 08 '21 at 18:27

1 Answers1

1

Try loading the data in the onAppear() of LoadingOverlay. That way, you delay the loading and may prevent whatever condition is causing the issue:

import SwiftUI

class AppState: ObservableObject {
    @Published var showLoadingOverlay = false
    
    func loadData() {
        //load data
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
            self.showLoadingOverlay = false
        }
    }
}

struct LoadingOverlay: View {
    @EnvironmentObject var state: AppState
    var body: some View {
        VStack {
            Text("Loading")
        }
        .onAppear {
            state.loadData()
        }
    }
}

struct ContentView: View {
    @StateObject var state = AppState()
    var body: some View {
        VStack {
            Text("Data View")
        }
        .onAppear {
            state.showLoadingOverlay = true
        }
        
        .fullScreenCover(isPresented: $state.showLoadingOverlay) {
            LoadingOverlay()
        }
        .environmentObject(state)
    }
}
Steve M
  • 9,296
  • 11
  • 49
  • 98
  • Hm...that's probably worth a shot but unfortunately would require major re-wiring of my state logic. The way it's implemented at the moment is: state dictates UI, i.e. the `loadData()` call causes the published `showLoadingOverlay` variable to become truthy/falsy as a result of the logic behind the scenes in `AppState`. This controls whether or not the overlay is shown. My current app logic can not easily be changed so that the user can navigate directly to the overlay which then causes data to load. – Haensl Nov 08 '21 at 20:03
  • I've tried a million things by now - the only way to reliably get the overlay to dismiss for me is a variant of what you suggest: I create a `@State var showOverlay` that starts out `true` in the view. I moved the `loadData()` call to `onAppear` of the overlay as you suggest and add a subscription to `state.showLoadingOverlay` that connects it explicitly to the view's `showOverlay` state variable within `onAppear()`, i.e. `$state.showLoadingOverlay.sink { loadingState in self.showOverlay = loadingState }.store(...)`. I am still baffled by this. – Haensl Nov 10 '21 at 19:08
  • @haensl I’ve seen a few things similar to this in SwiftUI with rapidly emitting publishers. I wouldn’t worry about it, find the workaround and move on. If you can send feedback to Apple. – Steve M Nov 12 '21 at 02:57