'SwiftUI One time loadingView when Observable object empty and / or initial view

I'm trying to achieve something like the "Medium" app that on first load shows a shimmering redacted view and then data is shown. On subsequent loads (like a pull down refresh for example) the redacted view is no longer seen, the data is simply updated.

If I try and add any view just a text "loading" for example using Ztack / or overlays, the text will be shown (even if only very briefly). This is what I want to avoid.

What mechanism can I use to achieve this please, and to avoid the initial blank page that the code below shows?

Thanks

struct SimpleView: View {
    
    @StateObject var stationsFetcher: StationsFetcher
    @ObservedObject var lm = LocationProvider()
    
    init() {
        
        _stationsFetcher = StateObject(wrappedValue: StationsFetcher())
        
    }
    
    var body: some View {
        
        List {
            
            ForEach (stationsFetcher.stations ) { station in
                
                StationCellView(station: station)
                
            }.listRowInsets(EdgeInsets())
                .listRowBackground(Color.clear)
                .listRowSeparator(.hidden)
        }
        .overlay(loadingOverlay)
        .onReceive(lm.$location) {
            if $0 != nil {
                Task {
                    
                    await stationsFetcher.fetchClosestStationsAndPrices(noOfStations:30)
                    
                }
            }
        }
    }
    
    @ViewBuilder private var loadingOverlay: some View {
        
        if stationsFetcher.isLoading {
            ZStack {
                ProgressView()
                    .opacity(0.7)
                    .padding()
                    .overlay(
                        RoundedRectangle(cornerRadius: 16)
                        
                            .stroke(.blue, lineWidth: 1)
                    )
                
                    .tint(.white)
                    .accentColor(Color.white)
                    .offset(x: 145, y: 300)
            }
        }
    }
}

My MRE attempt

import SwiftUI

struct ContentView: View {
    
    @State var selection = 0
    
    var body: some View {
        
        TabView {
           Text("goto tab 2")
                .tabItem {
                    Image(systemName: "1.square.fill")
                    Text("First")
                }
            SimpleView()
                .tabItem {
                    Image(systemName: "2.square.fill")
                    Text("Second")
                }
            
        }
        .font(.headline)
    }
    
}

struct SimpleView: View {
    
    @StateObject var stationsFetcher: StationsFetcher
    
    init() {
        
        _stationsFetcher = StateObject(wrappedValue: StationsFetcher())
        
    }
    
    var body: some View {
        
        List {
            
            ForEach (stationsFetcher.stations ) { station in
                
                HStack {
                    Text("\(station.id) : ")
                    Text(station.name)
                    
                }
            }
        }
        .onAppear{
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                
                Task {
                    
                    await stationsFetcher.fetchStations()
                    
                }
                
            }
            
        }.overlay(loadingOverlay)
        
    }
    
    @ViewBuilder private var loadingOverlay: some View {
           
           if stationsFetcher.isLoading {
               ZStack {
                   ProgressView()
                       .padding()
                       .overlay(
                           RoundedRectangle(cornerRadius: 16)
                           
                               .stroke(.blue, lineWidth: 1)
                       )
                   
                       //.tint(.white)
                       //.accentColor(Color.white)
                       .offset(x: 145, y: 300)
               }
           }
       }
    
}

struct Station: Identifiable{
    
    var id : Int
    var name : String
    
}

@MainActor
class StationsFetcher: ObservableObject {
    
    @Published var stations = [Station]()
    @Published var isLoading = false
    
    func fetchStations() async {
        
        isLoading = true
        
        var randomStations = [Station]()
        
        (1...Int.random(in: 1..<100)).forEach {id in
            
            let newStation = Station(id: id, name: String(Int.random(in: 1..<100)))
                
                randomStations.append(newStation)
            
        }
        
        stations = randomStations
        isLoading = false
    }
    
}

Edit : a Similar way to the proposal by yrb

Adding a published Bool to the fetcher ...

.overlay(stationsFetcher.shouldShowShimmer
                     ? AnyView(PlaceholderView()) : AnyView(loadingOverlay))
            
            
            .onReceive(lm.$location) {
                if $0 != nil {
                    Task {
                        
                        await stationsFetcher.fetchClosestStationsAndPrices(noOfStations:30)
                        
                    }
                }
                
          }

App with one time shimmer



Solution 1:[1]

With your MRE attempt, combined with what I had previously done, I think I figured out your issue. First, my MRE (If it is wrong in a fundamental way, please tell me how):

struct ContentView: View {
    
    @State var selection = 0
    
    var body: some View {
        TabView {
           Text("goto tab 2")
                .tabItem {
                    Image(systemName: "1.square.fill")
                    Text("First")
                }
            SimpleView()
                .tabItem {
                    Image(systemName: "2.square.fill")
                    Text("Second")
                }
        }
        .font(.headline)
    }
}

struct SimpleView: View {
    
    @StateObject var stationsFetcher: StationsFetcher
    // The initialization of the ObservableObject should always be @StateObject
    @StateObject var lm: LocationProvider

    init() {
        _stationsFetcher = StateObject(wrappedValue: StationsFetcher())
        _lm = StateObject(wrappedValue: LocationProvider())
    }

    var body: some View {
        List {
            ForEach (stationsFetcher.stations ) { station in
                Text(station.name)
            }
        }
        .overlay(loadingOverlay)
        .onReceive(lm.$location) {
            if $0 != nil {
                Task {
                    await stationsFetcher.fetchStations()
                }
            }
        }
    }
    
    @ViewBuilder private var loadingOverlay: some View {
        if stationsFetcher.isLoading {
            ZStack {
                Text("Loading...")
                    .opacity(0.7)
                    .padding()
                    .overlay(
                        RoundedRectangle(cornerRadius: 16)
                            .fill(
                                Color.blue
                                    .opacity(0.2)
                            )
                    )
                    .tint(.white)
                    .accentColor(Color.white)
            }
        }
    }
}

@MainActor
class StationsFetcher: ObservableObject {
    @Published var isLoading = false
    @Published var stations: [Station]
    
    init() {
        self.stations = Array(1...2).map( { Station(name: "Station \($0)") })
    }
    
    func fetchStations() async {
        isLoading = true
        // Put the delay here
        DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
            self.stations = Array(1...30).map( { Station(name: "Station \($0)") })
            self.isLoading = false
        }
    }
}

@MainActor
class LocationProvider: ObservableObject {
    @Published var location: Bool?
    
    init() {
        // Put the delay here
        DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
            self.location = true
        }
    }
}

struct Station: Identifiable {
    // Use a UUID and not a random Int for the ID. IDs must be unique or
    // you can have issues
    let id = UUID()
    let name: String
}

If the above is correct, the problem is clear. You are setting the overlay to show when stationsFetcher.fetchStations runs. The problem is, you have made an async call before that: obtaining your location. With the simulator, I think you are getting the location so quickly, that you are getting a brief flash before your overlay kicks in. When I mocked this up WITH LocationProvider taking a few seconds, it becomes obvious what is going on. The solution is simple: the Bool for the .overlay should live in the view, and once it turns false, never turns true again. Therefore, as an example:

struct SimpleView: View {
    
    @StateObject var stationsFetcher: StationsFetcher
    @StateObject var lm: LocationProvider
    
    @State private var isFirstLoad: Bool = true

    init() {
        _stationsFetcher = StateObject(wrappedValue: StationsFetcher())
        _lm = StateObject(wrappedValue: LocationProvider())
    }

    var body: some View {
        List {
            ForEach (stationsFetcher.stations ) { station in
                Text(station.name)
            }
        }
        .overlay(loadingOverlay)
        .onReceive(lm.$location) {
            if $0 != nil {
                Task {
                    await stationsFetcher.fetchStations()
                }
            }
        }
        .onReceive(stationsFetcher.$isLoading) {
            // stationsFetcher.isLoading is now optional, thus the guard
            // stationsFetcher.isLoading doesn't receive a value until 
            // stationsFetcher.fetchStations() is called.
            guard let isLoading = $0 else { return }
            // The first time stationsFetcher.isLoading gets a value, it is
            // true, so this test fails. Once the fetch is complete,
            // stationsFetcher.isLoading is set to false, and 
            // isFirstLoad is set to false for the rest of the app life.
            if !isLoading {
                isFirstLoad = false
            }
        }
    }
    
    @ViewBuilder private var loadingOverlay: some View {
        if isFirstLoad {
                Text("Loading...")
                    .opacity(0.7)
                    // This gives a full cover overlay
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .overlay(
                        RoundedRectangle(cornerRadius: 16)
                            .fill(
                                Color.blue
                                    .opacity(0.2)
                            )
                    )
                    .tint(.white)
                    .accentColor(Color.white)
            }
    }
}

@MainActor
class StationsFetcher: ObservableObject {
    // Making isLoading optional gives us three states, so nil can be first load.
    @Published var isLoading: Bool?
    @Published var stations: [Station]
    
    init() {
        self.stations = Array(1...2).map( { Station(name: "Station \($0)") })
    }
    
    func fetchStations() async {
        isLoading = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
            self.stations = Array(1...30).map( { Station(name: "Station \($0)") })
            self.isLoading = false
        }
    }
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Yrb