Search code examples
swiftswiftuiuiprogressviewswiftui-viewmutating-function

Controlling @State value from inside a function


Within a View, I'm calling a function that references some external objects and updates a chart. For the time when function is processing I would like to hide the chart and replace it with a ProgressView. Question: How to control value of the @State from inside function.

I've tried different versions, the code below returns error on line: numbers = updateNumbers()

Cannot use mutating member on immutable value: 'self' is immutable

Simplified example

Swift playground.

import SwiftUI
import Charts
import PlaygroundSupport

struct ProgressExample: View {

    @State private var isUpdating: Bool = false
    @State private var numbers: [Int] = []

    var body: some View {
        VStack {
            Button("Update data") {
                isUpdating = true // Triggered refresh, show ProgressView
                numbers = updateNumbers()
            }
            .padding()
            Spacer()
            if (isUpdating) {
                ProgressView("Updating")
            } else {
                List {
                    ForEach(numbers, id: \.self) { number in
                        Text("Number: \(number)")
                    }
                }
            }
        }
    }

    // Long running function
    mutating func updateNumbers() -> [Int] {
        var randomIntegers: [Int] = []
        for _ in 0...10 {
            randomIntegers.append(Int.random(in: 1...100))
            Thread.sleep(forTimeInterval: 0.1)
        }
        self.isUpdating = false // The method completed, hide ProgressView
        return randomIntegers
    }
}

// Present the view in the Live View window
PlaygroundPage.current.setLiveView(ProgressExample())

Solution

  • The function doesn't need to be mutating. State.wrappedValue has a nonmutating setter, because it actually mutates some reference type value in State, not mutating the struct value.

    So just remove mutating

    func updateNumbers() -> [Int] {
        var randomIntegers: [Int] = []
        for _ in 0...10 {
            randomIntegers.append(Int.random(in: 1...100))
            Thread.sleep(forTimeInterval: 0.1)
        }
        self.isUpdating = false
        return randomIntegers
    }
    

    I'd also give the view a frame when setting it as the playground live view, since the playground apparently can't figure out an appropriate size to display its live view.

    PlaygroundPage.current.setLiveView(
        ProgressExample()
            .frame(width: 700, height: 700)
    )
    

    Though it doesn't work in a playground, I recommend using a Preview to play with your SwiftUI views.

    #Preview {
        ProgressExample()
    }
    

    Now notice that the progress view doesn't actually show up, because Thread.sleep blocks the UI thread and stops UI updates. I hope in your real code the work is not running on the main thread.

    In any case though, I strongly recommend making updateNumbers an async method and calling it in a .task modifier. For your simple example here, you can rewrite the view like this:

    struct ProgressExample: View {
    
        @State private var isUpdating: Bool = false
        @State private var numbers: [Int] = []
    
        var body: some View {
            VStack {
                Button("Update data") {
                    isUpdating = true
                }
                .padding()
                Spacer()
                if isUpdating {
                    ProgressView("Updating")
                } else {
                    List {
                        ForEach(numbers, id: \.self) { number in
                            Text("Number: \(number)")
                        }
                    }
                }
            }
            .task(id: isUpdating) {
                if isUpdating {
                    numbers = await updateNumbers()
                }
            }
        }
    
        func updateNumbers() async -> [Int] {
            var randomIntegers: [Int] = []
            for _ in 0...10 {
                // I increased the range of this so it is less likely to get duplicate numbers.
                // Since these are used in a ForEach with id being the number itself,
                // having duplicated numbers is undefined behaviour
                randomIntegers.append(Int.random(in: 1...10000))
                do {
                    try await Task.sleep(for: .milliseconds(100))
                } catch {
                    break // task has been cancelled
                }
            }
            self.isUpdating = false
            return randomIntegers
        }
    }