Home > Mobile >  SwiftUI unwrapping optionals inside View (pyramid of doom)
SwiftUI unwrapping optionals inside View (pyramid of doom)

Time:08-19

I have a sample program that does three things

  1. Generate a random integer from -10...10 (regular function)
  2. Generate 10 million random numbers from -10...10 (asynchronous function)
  3. Calculate the average of #2 (throwing asynchronous function)

Below is the full working code. It works without errors, but the view has a horrible readability with three nested if/let loops. What's the best way/convention to get rid of the pyramid of doom in this scenario?

Result screenshot (how it should work)

Working code (methods)

class NumberManager: ObservableObject {
    @Published var integer: Int?
    @Published var numbers: [Double]?
    @Published var average: Double?


    func generateInt() {
        self.integer = Int.random(in: -10...10)
    }
    
    func generateNumbers() async  {
        self.numbers = (1...10_000_000).map { _ in Double.random(in: -10...10) }
        // takes about 5 seconds to run...
    }

    func calculateAverageNumber(for numbers: [Double]) async throws {
        guard !numbers.isEmpty else {
            print("numbers not generated")
            return
        }
        let total = numbers.reduce(0,  )
        let average = total / Double(numbers.count)
        self.average = average
    }
}

Working code (view)

struct ContentView: View {
    
    @StateObject var numberManager = NumberManager()
    
    var body: some View {
        
        if let integer = numberManager.integer {
            if let numbers = numberManager.numbers {
                if let average = numberManager.average {
                    Text("Integer is \(integer)")
                    Text("First number is: \(numbers[0])")
                    Text("Average is: \(average)")
                } else {
                    LoadingView(loadingType: "Calculating average")
                        .task {
                            do {
                                try await numberManager.calculateAverageNumber(for: numbers)
                            } catch {
                                print("empty numbers array")
                            }
                        }
                }
            } else {
                LoadingView(loadingType: "Generating numbers")
                    .task {
                        await numberManager.generateNumbers()
                    }
            }
        } else {
            LoadingView(loadingType: "Generating int")
                .task {
                    numberManager.generateInt()
                }
        }
    }
}

What I tried so far... I tried building helper functions to build views as below, and called those functions that returns views inside my ContentView. When I run it, the integer and the number array gets generated and shows, but the last task that calculates the average does not get called again at all.

Result screenshot(with issues)

Code (Runs without errors. But the last task that calculates average doesn't get executed)

struct ContentView: View {
    
    @StateObject var numberManager = NumberManager()
    
    var body: some View {
        
        intergerView()
            .task {
                print("Generating Int")
                numberManager.generateInt()
            }
        numbersView()
            .task {
                print("Generating Numbers")
                await numberManager.generateNumbers()
            }
        averageView()
            .task {
                do {
                    print("Calculating Average")
                    try await numberManager.calculateAverageNumber(for: numberManager.numbers ?? [])

                } catch {
                    print("error")
                }
            }
    }
}

private func intergerView() -> some View {
    guard let integer = numberManager.integer else {
        return AnyView(LoadingView(loadingType: "Generating int"))
    }
    return AnyView(Text("Integer is \(integer)"))
}

private func numbersView() -> some View {
    guard let numbers = numberManager.numbers else {
        return AnyView(LoadingView(loadingType: "Generating numbers"))
    }
    return AnyView(Text("First number is: \(numbers[0])"))
}

private func averageView() -> some View {
    guard let average = numberManager.average else {
        return AnyView(LoadingView(loadingType: "Calculating average"))
    }
    return AnyView(Text("Average is: \(average)"))
}

EDIT: In my app, I have a view that does all different functions in one view (it's like a dashboard). Some require others to run first (like calculating the average), whereas some can run on its own (Like generating one random integer). I want to display whatever that's loaded first, while displaying a loadingview placeholder for parts that aren't loaded yet.

CodePudding user response:

You are almost done. Use @ViewBuilder , remove AnyView wrapper and dont use guard

@ViewBuilder
var intergerView: some View {
    if let integer = numberManager.integer {
        LoadingView(loadingType: "Generating int")
    } else {
        Text("Integer is \(integer)")
    }
}

CodePudding user response:

Several issues here:

  • generateNumbers and calculateAverageNumber depend on each other. So they need to await each other.

  • your "working code" does not match the description of your code. You say you want to show what ever finishes first but your if/else statements introduce dependencies between all 3 functions/views

  • you don´t need 3 different views. One that can be customized should be enough.


class NumberManager: ObservableObject {
    @Published var integer: Int?
    @Published var numbers: [Double]?
    @Published var average: Double?
    
    
    func generateInt() {
        self.integer = Int.random(in: -10...10)
    }
    
    func generateNumbers() async  {
        self.numbers = (1...10_000_000).map { _ in Double.random(in: -10...10) }
        // takes about 5 seconds to run...
    }
    
    // No need for arguments here
    func calculateAverageNumber() async throws {
        guard let numbers = numbers, !numbers.isEmpty else {
            print("numbers not generated")
            return
        }
        let total = numbers.reduce(0,  )
        let average = total / Double(numbers.count)
        self.average = average
    }
    
    //This function will handle the dependenies of generating the values and calculating the avarage
    func calculateNumbersAndAvarage() async throws{
        await generateNumbers()
        try await calculateAverageNumber()
    }
}

The View:

struct ContentView: View{
    
    @StateObject private var numberManager = NumberManager()
    
    var body: some View{
        //Show the different detail views.
        VStack{
            Spacer()
            Spacer()
            
            DetailView(text: numberManager.integer != nil ? "Integer is \(numberManager.integer!)" : nil)
                .onAppear {
                    numberManager.generateInt()
                }
            Spacer()
            Group{
                DetailView(text: isNumbersValid ? "First number is: \(numberManager.numbers![0])" : nil)
                
                Spacer()
                
                DetailView(text: numberManager.average != nil ? "Average is: \(numberManager.average!)" : nil)
            }.onAppear {
                Task{
                    do{
                       try await numberManager.calculateNumbersAndAvarage()
                    }
                    catch{
                        print("error")
                    }
                }
            }
            
            Spacer()
            Spacer()
        }
    }
    
    //Just to make it more readable
    var isNumbersValid: Bool{
        numberManager.numbers != nil && numberManager.numbers?.count != 0
    }
}

and the DetailView:

struct DetailView: View{
    
    let text: String?
    
    var body: some View{
        // If no text to show, show `ProgressView`, or `LoadingView` in your case. You can inject the view directly or use a property for the String argument.
        if let text = text {
            Text(text)
                .font(.headline)
                .padding()
        } else{
            ProgressView()
        }
    }
}

The code should speak for itself. If you have any further question regarding this code please feel free to do so, but please read and try to understand how this works first.

  • Related