SwiftUI: How do I avoid modifying state during view update? - swift

I want to update a text label after it is being pressed, but I am getting this error while my app runs (there is no compile error): [SwiftUI] Modifying state during view update, this will cause undefined behaviour.
This is my code:
import SwiftUI
var randomNum = Int.random(in: 0 ..< 230)
struct Flashcard : View {
#State var cardText = String()
var body: some View {
randomNum = Int.random(in: 0 ..< 230)
cardText = myArray[randomNum].kana
let stack = VStack {
Text(cardText)
.color(.red)
.bold()
.font(.title)
.tapAction {
self.flipCard()
}
}
return stack
}
func flipCard() {
cardText = myArray[randomNum].romaji
}
}

If you're running into this issue inside a function that isn't returning a View (and therefore can't use onAppear or gestures), another workaround is to wrap the update in an async update:
func updateUIView(_ uiView: ARView, context: Context) {
if fooVariable { do a thing }
DispatchQueue.main.async { fooVariable = nil }
}
I can't speak to whether this is best practices, however.
Edit: I work at Apple now; this is an acceptable method. An alternative is using a view model that conforms to ObservableObject.

struct ContentView: View {
#State var cardText: String = "Hello"
var body: some View {
self.cardText = "Goodbye" // <<< This mutation is no good.
return Text(cardText)
.onTapGesture {
self.cardText = "World"
}
}
}
Here I'm modifying a #State variable within the body of the body view. The problem is that mutations to #State variables cause the view to update, which in turn call the body method on the view. So, already in the middle of a call to body, another call to body initiated. This could go on and on.
On the other hand, a #State variable can be mutated in the onTapGesture block, because it's asynchronous and won't get called until after the update is finished.
For example, let's say I want to change the text every time a user taps the text view. I could have a #State variable isFlipped and when the text view is tapped, the code in the gesture's block toggles the isFlipped variable. Since it's a special #State variable, that change will drive the view to update. Since we're no longer in the middle of a view update, we won't get the "Modifying state during view update" warning.
struct ContentView: View {
#State var isFlipped = false
var body: some View {
return Text(isFlipped ? "World" : "Hello")
.onTapGesture {
self.isFlipped.toggle() // <<< This mutation is ok.
}
}
}
For your FlashcardView, you might want to define the card outside of the view itself and pass it into the view as a parameter to initialization.
struct Card {
let kana: String
let romaji: String
}
let myCard = Card(
kana: "Hello",
romaji: "World"
)
struct FlashcardView: View {
let card: Card
#State var isFlipped = false
var body: some View {
return Text(isFlipped ? card.romaji : card.kana)
.onTapGesture {
self.isFlipped.toggle()
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
return FlashcardView(card: myCard)
}
}
#endif
However, if you want the view to change when card itself changes (not that you necessarily should do that as a logical next step), then this code is insufficient. You'll need to import Combine and reconfigure the card variable and the Card type itself, in addition to figuring out how and where the mutation going to happen. And that's a different question.
Long story short: modify #State variables within gesture blocks. If you want to modify them outside of the view itself, then you need something else besides a #State annotation. #State is for local/private use only.
(I'm using Xcode 11 beta 5)

On every redraw (in case a state variable changes) var body: some View gets reevaluated. Doing so in your case changes another state variable, which would without mitigation end in a loop since on every reevaluation another state variable change gets made.
How SwiftUI handles this is neither guaranteed to be stable, nor safe. That is why SwiftUI warns you that next time it may crash due to this.
Be it due to an implementation change, suddenly triggering an edge condition, or bad luck when something async changes text while it is being read from the same variable, giving you a garbage string/crash.
In most cases you will probably be fine, but that is less so guaranteed than usual.

Related

Call view modifying function on child view

I have a view which has a function fadeInOut() that fades a square in and out.
struct FadingSquare: View {
#State var fading = false
var body: some View {
Rectangle()
.frame(width: 20, height: 20)
.foregroundColor(.primary)
.opacity(fading ? 1 : 0)
.onAppear {
fadeInOut()
}
}
func fadeInOut() {
withAnimation(.linear(duration: 1)) {
self.fading = true
}
withAnimation(.linear(duration: 1).delay(1)) {
self.fading = false
}
}
}
That works fine, the square fades in, then fades out.
But I want to place FadingSquare in other places, and call fadingSquare.fadeInOut() from a parent view. This is what I have attempted:
struct ContentView: View {
var fadingSquare = FadingSquare()
var body: some View {
VStack {
fadingSquare
Button {
fadingSquare.fadeInOut()
} label: {
Text("Fade")
}
}
}
}
This does not work, and making fadingSquare a #State doesn't work either. I'd rather not use Bindings, I want the FadingSquare to be responsible for its own values.
So in SwiftUI, parents can modify their children in really just two ways.
Within the parent view, (ContentView) add a #State variable to track the fade. Within the child view, (FadingSquare) change the #State variable to #Binding and remove the default value. Now, within ContentView, you can have the square update when the parent's fade variable changes by writing it in the body as FadingSquare(fading: $ourFadingState) where ourFadingState is that #State variable we created.
The parent and the child each hold a reference to the same ObservableObject. This is the most performant way to do it when dealing with complex UIs. Within the parent's button action, you'd modify the object's fading property. Within the FadingSquare, you'd have an #ObservedObject variable which would cause the square to update itself upon any changes, regardless of if they were made within the view itself, a parent, or on the moon.
By the way, for SwiftUI to work properly, you don't keep a fadingView property, you instantiate a brand new one within the body code. This is because when it's a property, it can't update until the parent gets trashed, which will never happen because ContentView is typically the root view of a scene and will never get destroyed. Look at the system SwiftUI views and notice you don't need to hold them within variables either. We do it the same way.
So instead of:
var fadingSquare = FadingSquare()
var body: some View {
fadingSquare
}
It should be:
var body: some View {
FadingSquare()
}
Remember that unlike UIKit, a SwiftUI view struct is not the view itself. It is a collection of data describing to the system what the view should contain during a given refresh, like HTML. So initializing a new FadingSquare() within body code is correct and it will be the same view, even if it is "a new" struct in code.

Updating property wrapper like #StateObject, affects other view rendering that does not use that property

When using different property wrappers associated with view updates, changes in one place affect rendering of views that do not use that property.
struct ContentView: View {
#StateObject var viewModel = MyViewModel()
#State var thirdTitle = "thirdTitle"
var body: some View {
VStack {
Text(viewModel.firstTitle)
.background(.random)
Text(viewModel.secondTitle)
.background(.random)
Text(thirdTitle)
.background(.random)
Button("Change First Title") {
viewModel.chageFirstTitle()
}
}
}
}
class MyViewModel: ObservableObject {
#Published var firstTitle = "firstTitle"
#Published var secondTitle = "secondTitle"
func chageFirstTitle() {
firstTitle = "hello world"
}
}
I understand that the reason why the Text exposing the viewModel.secondTitle is re-rendered is because the #StateObject varviewModel = MyViewModel() dependency changed when the `viewModel.firstTitle changed.
However, I don't know why Text using #State var thirdTitle = "thirdTitle" is re-rendered too. In WWDC21 session Demystify SwiftUI, I saw that the view is re-rendered only when the related dependency is updated according to the dependency graph. But, even though the thirdTitle is irrelevant to the change of the viewModel, third Text using that dependency is re-rendered and the background color is changed.
What's even more confusing is that if I seperate the third Text into a separate view ( ThirdView ) and receive the thirdTitle using #Binding, the background color does not change because it is not re-rendering at that time.
struct ContentView: View {
#StateObject var viewModel = MyViewModel()
#State var thirdTitle = "thirdTitle"
var body: some View {
VStack {
Text(viewModel.firstTitle)
.background(.random)
Text(viewModel.secondTitle)
.background(.random)
ThirdView(text: $thirdTitle)
.background(.random)
Button("Change First Title") {
viewModel.chageFirstTitle()
}
}
}
}
struct ThirdView: View {
#Binding var text: String
var body: some View {
Text(text)
.background(.random)
}
}
Regarding the situation I explained, could you help me to understand the rendering conditions of the view?
To render SwiftUI calls body property of a view (it is computable, i.e. executes completely on call). This call is performed whenever any view dependency, i.e. dynamic property, is changed.
So, viewModel.chageFirstTitle() changes dependency for ContentView and ContentView.body is called and every primitive in it is rendered. ThirdView also created but as far as its dependency is not changed, its body is not called, so content is not re-rendered.
A few things wrong here. We don't use view model objects in SwiftUI for view data, it's quite inefficient/buggy to do so. Instead, use a struct with mutating funcs with an #State. Pass in params to sub-Views as lets for read access, #Binding is only when you need write access. In terms of rendering, first of all body is only called if the let property is different from the last time the sub-View is init, then it diffs the body from the last time it was called, if there are any differences then SwiftUI adds/removes/updates actual UIKit UIViews on your behalf, then actual rendering of those UIViews, e.g. drawRect, is done by CoreGraphics.
struct ContentViewConfig {
var firstTitle = "firstTitle"
var secondTitle = "secondTitle"
mutating func changeFirstTitle() {
firstTitle = "hello world"
}
}
struct ContentView: View {
#State var config = Config()
...
struct ThirdView: View {
let text: String
...
Combine's ObservableObject is usually only used when needing to use Combine, e.g. using combineLatest with multiple publishers or for a Store object to hold the model struct arrays in #Published properties that are not tied to a View's lifetime like #State. Your use case doesn't look like a valid use of ObservableObject.

Swift: Struct instances not updating view

The following code is simplified and isolated. It is intended to have a Text() view, which shows the number of times the button has been clicked, and a Button() to increment the text view.
The issue: Clicking the button does not actually change the Text() view, and it continues to display "1"
struct Temp: View {
#State var h = struct1()
var body: some View {
VStack {
Button(action: {
h.num += 1
}, label: {
Text("CLICK ME")
})
Text(String(h.num))
}
}
}
struct struct1 {
#State var num = 1
}
What's funny is that the same code works in swift playgrounds (obviously with the Text() changed to print()). So, I'm wondering if this is an XCode specific bug? If not, why is this happening?
Remove #State from the variable in struct1
SwiftUI wrappers are only for SwiftUI Views with the exception of #Published inside an ObservableObject.
I have not found this in any documentation explicitly but the wrappers conform to DynamicProperty and of you look at the documentation for that it says that
The view gives values to these properties prior to recomputing the view’s body.
So it is implied that if the wrapped variable is not in a struct that is also a SwiftUI View it will not get an updated value because it does not have a body.
https://developer.apple.com/documentation/swiftui/dynamicproperty
The bug is that it works in Playgrounds but Playground seems to have a few of these things. Likely because of the way it complies and runs.

#Published in an ObservableObject vs #State on a View leads to unpredictable update behavior in SwiftUI

This question is coming on the heels of this question that I asked (and had answered by #Asperi) yesterday, but it introduces a new unexpected element.
The basic setup is a 3 column macOS SwiftUI app. If you run the code below and scroll the list to an item further down the list (say item 80) and click, the List will re-render and occasionally "jump" to a place (like item 40), leaving the actual selected item out of frame. This issue was solved in the previous question by encapsulating SidebarRowView into its own view.
However, that solution works if the active binding (activeItem) is stored as a #State variable on the SidebarList view (see where I've marked //#1). If the active item is stored on an ObservableObject view model (see //#2), the scrolling behavior is affected.
I assume this is because the diffing algorithm somehow works differently with the #Published value and the #State value. I'd like to figure out a way to use the #Published value since the active item needs to be manipulated by the state of the app and used in the NavigationLink via isActive: (say if a push notification comes in that affects it).
Is there a way to use the #Published value and not have it re-render the whole List and thus not affect the scrolled position?
Reproducible code follows -- see the commented line for what to change to see the behavior with #Published vs #State
struct Item : Identifiable, Hashable {
let id = UUID()
var name : String
}
class SidebarListViewModel : ObservableObject {
#Published var items = Array(0...300).map { Item(name: "Item \($0)") }
#Published var activeItem : Item? //#2
}
struct SidebarList : View {
#StateObject private var viewModel = SidebarListViewModel()
#State private var activeItem : Item? //#1
var body: some View {
List(viewModel.items) {
SidebarRowView(item: $0, activeItem: $viewModel.activeItem) //change this to $activeItem and the scrolling works as expected
}.listStyle(SidebarListStyle())
}
}
struct SidebarRowView: View {
let item: Item
#Binding var activeItem: Item?
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue {
activeItem = item
}
}
}
var body: some View {
NavigationLink(destination: Text(item.name),
isActive: navigationBindingForItem(item: item)) {
Text(item.name)
}
}
}
struct ContentView : View {
var body: some View {
NavigationView {
SidebarList()
Text("No selection")
Text("No selection")
.frame(minWidth: 300)
}
}
}
(Built and tested with Xcode 13.0 on macOS 11.3)
Update. I still think that the original answer identified the problem, however seems that there's an even easier workaround to this: push the view model one level upstream, to the root ContentView, and inject the items array to the SidebarList view.
Thus, the following changes should fix the "jumping" issue:
struct SidebarList : View {
let items: [Item]
#Binding var activeItemId: UUID?
// ...
}
// ...
struct ContentView : View {
#StateObject private var viewModel = SidebarListViewModel()
var body: some View {
NavigationView {
SidebarList(items: viewModel.items,
activeItemId: $viewModel.activeItemId)
// ...
}
For some reason, this works, I don't have an explanation why. However, there's one problem left, that's caused by SwiftUI: programatically changing the selection won't make the list scroll to the new selection. Scroll SwiftUI List to new selection might help fixing this too.
Also, warmly recommending to move the NavigationLink from the body of SidebarRowView to the List part of SidebarList, this will help you limit the amount of details that get leaked to the row view.
Another recommendation I would make, would be to use the tag:selection: alternative to isActive. This works better when you have a pool of possible navigation links from which only one can be active at a certain time. This involves of course changing the view model from var activeItem: Item? to var activeItemId: UUID?, this will avoid the need of the hacky navigationBindingForItem function:
class SidebarListViewModel : ObservableObject {
#Published var items = // ...
#Published var activeItemId : UUID?
}
// ...
NavigationLink(destination: ...,
tag: item.id,
selection: $activeItemId) {
Original Answer
This is most likely what's causing the problematic behaviour:
func navigationBindingForItem(item: Item) -> Binding<Bool> {
.init {
activeItem == item
} set: { newValue in
if newValue {
activeItem = item
}
}
}
If you put a breakpoint on the binding setter, you'll see that the setter gets called every time you select something, and if you also print the item name, you'll see that when the problematic scrolling happens, it always scroll to the previous selected item.
Seems this "manual" binding interferes with the SwiftUI update cycle, causing the framework to malfunction.
The solution here is simple: remove the #Binding declaration from the activeItem property, and keep it as a "regular" one. You also can safely remove the isActive argument passed to the navigation link.
Bindings are needed only when you need to update values in parent components, most of the time simple values are enough. This also makes your views simpler, and more in line with the Swift/SwiftUI principles of using immutable values as much as possible.

How to refresh number of ForEach's displaying elements after array's size changes (SwiftUI, Xcode 11 Beta 5)

Im trying to implement a view that can change the amount of displaying items (created by a ForEach loop) if the content array's size changes, just like how a shopping app might change its number of items available after the user pull to refresh
Here are some code I have tried so far. If I remember correctly, these worked with Xcode beta 4, but with beta 5:
If the array's size increases, the loop will still display the originial number of elements
Array's size decrease will cause an index out of range error
Code:
import SwiftUI
struct test : View {
#State var array:[String] = []
#State var label = "not pressed"
var body: some View {
VStack{
Text(label).onTapGesture {
self.array.append("ForEach refreshed")
self.label = "pressed"
}
ForEach(0..<array.count){number in
Text(self.array[number])
}
}
}
}
#if DEBUG
struct test_Previews: PreviewProvider {
static var previews: some View {
test()
}
}
#endif
I'm new to SwiftUI and GUI programming in general, and just feels like every content is defined at launch time and really hard to make changes afterwards (For example: reset a view after the user navigate away then return to it). Solutions to the loop problem or any tips for making views more dynamic would be greatly appreciated!
Beta 5 Release Notes say:
The retroactive conformance of Int to the Identifiable protocol is
removed. Change any code that relies on this conformance to pass
.self to the id parameter of the relevant initializer. Constant
ranges of Int continue to be accepted:
List(0..<5) {
Text("Rooms")
}
However, you shouldn’t pass a range that changes at runtime. If you
use a variable that changes at runtime to define the range, the list
displays views according to the initial range and ignores any
subsequent updates to the range.
You should change your ForEach to receive an array, instead of range. Ideally an Identifiable array, to avoid using \.self. But depending on your goal, this can still work:
import SwiftUI
struct ContentView : View {
#State var array:[String] = []
#State var label = "not pressed"
var body: some View {
VStack{
Text(label).onTapGesture {
self.array.append("ForEach refreshed")
self.label = "pressed"
}
ForEach(array, id: \.self) { item in
Text(item)
}
}
}
}
Or as rob mayoff suggested, if you need the index:
struct ContentView : View {
#State var array:[String] = []
#State var label = "not pressed"
var body: some View {
VStack{
Text(label).onTapGesture {
self.array.append("ForEach refreshed")
self.label = "pressed"
}
ForEach(array.indices, id: \.self) { index in
Text(self.array[index])
}
}
}
}```