'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)
}
}
}
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 |

