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