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

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.

Related

SwiftUI: How do I share an ObservedObject between child views without affecting the parent view

TL;DR: If I have a view containing a NavigationSplitView(sidebar:detail:), with a property (such as a State or StateObject) tracking user selection, how should I make it so that the sidebar and detail views observe the user selection, but the parent view does not?
Using SwiftUI's new NavigationSplitView (or the deprecated NavigationView), a common paradigm is to have a list of selectable items in the sidebar view, with details of the selected item in the detail view. The selection, of course, needs to be observed, usually from within an ObservedObject.
struct ExampleView: View {
#StateObject private var viewModel = ExampleViewModel()
var body: some View {
NavigationSplitView {
SidebarView(selection: $viewModel.selection)
} detail: {
DetailView(item: viewModel.selection)
}
}
}
struct SidebarView: View {
let selectableItems: [Item] = []
#Binding var selection: Item?
var body: some View {
List(selectableItems, selection: $viewModel.selected) { item in
NavigationLink(value: item) { Text(item.name) }
}
}
}
struct DetailView: View {
let item: Item?
var body: some View {
// Details of the selected item
}
}
#MainActor
final class ExampleViewModel: ObservableObject {
#Published var selection: Item? = nil
}
This, however, poses a problem: the ExampleView owns the ExampleViewModel used for tracking the selection, which means that it gets recalculated whenever the selection changes. This, in turn, causes its children SidebarView and DetailView to be redrawn.
Since we want those children to be recalculated, one might be forgiven for thinking that everything is as intended. However, the ExampleView itself should not be recalculated in my opinion, because doing so will not only update the child views (intended), but also everything in the parent view (not intended). This is especially true if its body is composed of other views, modifiers, or setup work. Case in point: in this example, the NavigationSplitView itself will also be recalculated, which I don't think is what we want.
Almost all tutorials, guides and examples I see online use a version of the above example - sometimes the viewModel is passed as an ObservedObject, or as an EnvironmentObject, but they all share the same trait in that the parent view containing the NavigationSplitView is observing the property that should only be observed by the children of NavigationSplitView.
My current solution is to initiate the viewmodel in the parent view, but not observe it:
struct ExampleView: View {
let viewModel = ExampleViewModel()
...
}
#MainActor
final class ExampleViewModel: ObservableObject {
#Published var selection: Item? = nil
nonisolated init() { }
}
This way, the parent view will remain intact (at least in regards to user selection); however, this will cause the ExampleViewModel to be recreated if anything else would cause the ExampleView to be redrawn - effectively resetting our user selection. Additionally, we are unable to pass any of the viewModel's properties as bindings. So while it works for my current use-case, I don't consider this an effective solution.

SwiftUI - #Binding to a computed property which accesses value inside ObservableObject property duplicates the variable?

In the code below (a stripped-down version of some code in a project) I'm using a MVVM pattern with two views:
ViewA - displays a value stored in an ObservableObject ViewModel;
ViewB - displays the same value and has a Slider that changes that value, which is passed to the view using Binding.
Inside of ViewModelA I have a computed property which serves both to avoid the View from accessing the Model directly and to perform some other operations when the value inside the model (the one being displayed) is changed.
I'm also passing that computed value to a ViewModelB, using Binding, which acts as a StateObject for ViewB. However, when dragging the Slider to change that value, the value changes on ViewA but doesn't change on ViewB and the slider itself doesn't slide. As expected, when debugging, the wrappedValue inside the Binding is not changing. But how is the change propagated upwards (through the Binding's setters, I imagine) but not downwards back to ViewB?? I imagine this can only happen if the variable is being duplicated somewhere and changed only in one place, but I can't seem to understand where or if that's what's actually happening.
Thanks in advance!
Views:
import SwiftUI
struct ContentView: View {
#StateObject var viewModelA = ViewModelA()
var body: some View {
VStack{
ViewA(value: viewModelA.value)
ViewB(value: $viewModelA.value)
}
}
}
struct ViewA: View {
let value: Double
var body: some View {
Text("\(value)").padding()
}
}
struct ViewB: View {
#StateObject var viewModelB: ViewModelB
init(value: Binding<Double>){
_viewModelB = StateObject(wrappedValue: ViewModelB(value: value))
}
var body: some View {
VStack{
Text("\(viewModelB.value)")
Slider(value: $viewModelB.value, in: 0...1)
}
}
}
ViewModels:
class ViewModelA: ObservableObject {
#Published var model = Model()
var value: Double {
get {
model.value
}
set {
model.value = newValue
// perform other checks and operations
}
}
}
class ViewModelB: ObservableObject {
#Binding var value: Double
init(value: Binding<Double>){
self._value = value
}
}
Model:
struct Model {
var value: Double = 0
}
If you only look where you can't go, you might just miss the riches below
Breaking single source of truth, and breaching local (private) property of #StateObjectby sharing it via Binding are two places where you can't go.
#EnvironmentObject or more generally the concept of "shared object" between views are the riches below.
This is an example of doing it without MVVM nonsense:
import SwiftUI
final class EnvState: ObservableObject {#Published var value: Double = 0 }
struct ContentView: View {
#EnvironmentObject var eos: EnvState
var body: some View {
VStack{
ViewA()
ViewB()
}
}
}
struct ViewA: View {
#EnvironmentObject var eos: EnvState
var body: some View {
Text("\(eos.value)").padding()
}
}
struct ViewB: View {
#EnvironmentObject var eos: EnvState
var body: some View {
VStack{
Text("\(eos.value)")
Slider(value: $eos.value, in: 0...1)
}
}
}
Isn't this easier to read, cleaner, less error-prone, with fewer overheads, and without serious violation of fundamental coding principles?
MVVM does not take value type into consideration. And the reason Swift introduces value type is so that you don't pass shared mutable references and create all kinds of bugs.
Yet the first thing MVVM devs do is to introduce shared mutable references for every view and pass references around via binding...
Now to your question:
the only options I see are either using only one ViewModel per Model, or having to pass the Model (or it's properties) between ViewModels through Binding
Another option is to drop MVVM, get rid of all view models, and use #EnvironmentObject instead.
Or if you don't want to drop MVVM, pass #ObservedObject (your view model being a reference type) instead of #Binding.
E.g.;
struct ContentView: View {
#ObservedObject var viewModelA = ViewModelA()
var body: some View {
VStack{
ViewA(value: viewModelA)
ViewB(value: viewModelA)
}
}
}
On a side note, what's the point of "don't access model directly from view"?
It makes zero sense when your model is value type.
Especially when you pass view model reference around like cookies in a party so everyone can have it.
Really it looks like broken single-source or truth concept. Instead the following just works (ViewModelB might probably be needed for something, but not for this case)
Tested with Xcode 12 / iOS 14
Only modified parts:
struct ContentView: View {
#StateObject var viewModelA = ViewModelA()
var body: some View {
VStack{
ViewA(value: viewModelA.value)
ViewB(value: $viewModelA.model.value)
}
}
}
struct ViewB: View {
#Binding var value: Double
var body: some View {
VStack{
Text("\(value)")
Slider(value: $value, in: 0...1)
}
}
}

SwiftUI: ObservableObject does not persist its State over being redrawn

Problem
In Order to achieve a clean look and feel of the App's code, I create ViewModels for every View that contains logic.
A normal ViewModel looks a bit like this:
class SomeViewModel: ObservableObject {
#Published var state = 1
// Logic and calls of Business Logic goes here
}
and is used like so:
struct SomeView: View {
#ObservedObject var viewModel = SomeViewModel()
var body: some View {
// Code to read and write the State goes here
}
}
This workes fine when the Views Parent is not being updated. If the parent's state changes, this View gets redrawn (pretty normal in a declarative Framework). But also the ViewModel gets recreated and does not hold the State afterward. This is unusual when you compare to other Frameworks (eg: Flutter).
In my opinion, the ViewModel should stay, or the State should persist.
If I replace the ViewModel with a #State Property and use the int (in this example) directly it stays persisted and does not get recreated:
struct SomeView: View {
#State var state = 1
var body: some View {
// Code to read and write the State goes here
}
}
This does obviously not work for more complex States. And if I set a class for #State (like the ViewModel) more and more Things are not working as expected.
Question
Is there a way of not recreating the ViewModel every time?
Is there a way of replicating the #State Propertywrapper for #ObservedObject?
Why is #State keeping the State over the redraw?
I know that usually, it is bad practice to create a ViewModel in an inner View but this behavior can be replicated by using a NavigationLink or Sheet.
Sometimes it is then just not useful to keep the State in the ParentsViewModel and work with bindings when you think of a very complex TableView, where the Cells themself contain a lot of logic.
There is always a workaround for individual cases, but I think it would be way easier if the ViewModel would not be recreated.
Duplicate Question
I know there are a lot of questions out there talking about this issue, all talking about very specific use-cases. Here I want to talk about the general problem, without going too deep into custom solutions.
Edit (adding more detailed Example)
When having a State-changing ParentView, like a list coming from a Database, API, or cache (think about something simple). Via a NavigationLink you might reach a Detail-Page where you can modify the Data. By changing the data the reactive/declarative Pattern would tell us to also update the ListView, which would then "redraw" the NavigationLink, which would then lead to a recreation of the ViewModel.
I know I could store the ViewModel in the ParentView / ParentView's ViewModel, but this is the wrong way of doing it IMO. And since subscriptions are destroyed and/or recreated - there might be some side effects.
Finally, there is a Solution provided by Apple: #StateObject.
By replacing #ObservedObject with #StateObject everything mentioned in my initial post is working.
Unfortunately, this is only available in ios 14+.
This is my Code from Xcode 12 Beta (Published June 23, 2020)
struct ContentView: View {
#State var title = 0
var body: some View {
NavigationView {
VStack {
Button("Test") {
self.title = Int.random(in: 0...1000)
}
TestView1()
TestView2()
}
.navigationTitle("\(self.title)")
}
}
}
struct TestView1: View {
#ObservedObject var model = ViewModel()
var body: some View {
VStack {
Button("Test1: \(self.model.title)") {
self.model.title += 1
}
}
}
}
class ViewModel: ObservableObject {
#Published var title = 0
}
struct TestView2: View {
#StateObject var model = ViewModel()
var body: some View {
VStack {
Button("StateObject: \(self.model.title)") {
self.model.title += 1
}
}
}
}
As you can see, the StateObject Keeps it value upon the redraw of the Parent View, while the ObservedObject is being reset.
I agree with you, I think this is one of many major problems with SwiftUI. Here's what I find myself doing, as gross as it is.
struct MyView: View {
#State var viewModel = MyViewModel()
var body : some View {
MyViewImpl(viewModel: viewModel)
}
}
fileprivate MyViewImpl : View {
#ObservedObject var viewModel : MyViewModel
var body : some View {
...
}
}
You can either construct the view model in place or pass it in, and it gets you a view that will maintain your ObservableObject across reconstruction.
Is there a way of not recreating the ViewModel every time?
Yes, keep ViewModel instance outside of SomeView and inject via constructor
struct SomeView: View {
#ObservedObject var viewModel: SomeViewModel // << only declaration
Is there a way of replicating the #State Propertywrapper for #ObservedObject?
No needs. #ObservedObject is-a already DynamicProperty similarly to #State
Why is #State keeping the State over the redraw?
Because it keeps its storage, ie. wrapped value, outside of view. (so, see first above again)
You need to provide custom PassThroughSubject in your ObservableObject class. Look at this code:
//
// Created by Франчук Андрей on 08.05.2020.
// Copyright © 2020 Франчук Андрей. All rights reserved.
//
import SwiftUI
import Combine
struct TextChanger{
var textChanged = PassthroughSubject<String,Never>()
public func changeText(newValue: String){
textChanged.send(newValue)
}
}
class ComplexState: ObservableObject{
var objectWillChange = ObservableObjectPublisher()
let textChangeListener = TextChanger()
var text: String = ""
{
willSet{
objectWillChange.send()
self.textChangeListener.changeText(newValue: newValue)
}
}
}
struct CustomState: View {
#State private var text: String = ""
let textChangeListener: TextChanger
init(textChangeListener: TextChanger){
self.textChangeListener = textChangeListener
print("did init")
}
var body: some View {
Text(text)
.onReceive(textChangeListener.textChanged){newValue in
self.text = newValue
}
}
}
struct CustomStateContainer: View {
//#ObservedObject var state = ComplexState()
var state = ComplexState()
var body: some View {
VStack{
HStack{
Text("custom state View: ")
CustomState(textChangeListener: state.textChangeListener)
}
HStack{
Text("ordinary Text View: ")
Text(state.text)
}
HStack{
Text("text input: ")
TextInput().environmentObject(state)
}
}
}
}
struct TextInput: View {
#EnvironmentObject var state: ComplexState
var body: some View {
TextField("input", text: $state.text)
}
}
struct CustomState_Previews: PreviewProvider {
static var previews: some View {
return CustomStateContainer()
}
}
First, I using TextChanger to pass new value of .text to .onReceive(...) in CustomState View. Note, that onReceive in this case gets PassthroughSubject, not the ObservableObjectPublisher. In last case you will have only Publisher.Output in perform: closure, not the NewValue. state.text in that case would have old value.
Second, look at the ComplexState class. I made an objectWillChange property to make text changes send notification to subscribers manually. Its almost the same like #Published wrapper do. But, when the text changing it will send both, and objectWillChange.send() and textChanged.send(newValue). This makes you be able to choose in exact View, how to react on state changing. If you want ordinary behavior, just put the state into #ObservedObject wrapper in CustomStateContainer View. Then, you will have all the views recreated and this section will get updated values too:
HStack{
Text("ordinary Text View: ")
Text(state.text)
}
If you don't want all of them to be recreated, just remove #ObservedObject. Ordinary text View will stop updating, but CustomState will. With no recreating.
update:
If you want more control, you can decide while changing the value, who do you want to inform about that change.
Check more complex code:
//
//
// Created by Франчук Андрей on 08.05.2020.
// Copyright © 2020 Франчук Андрей. All rights reserved.
//
import SwiftUI
import Combine
struct TextChanger{
// var objectWillChange: ObservableObjectPublisher
// #Published
var textChanged = PassthroughSubject<String,Never>()
public func changeText(newValue: String){
textChanged.send(newValue)
}
}
class ComplexState: ObservableObject{
var onlyPassthroughSend = false
var objectWillChange = ObservableObjectPublisher()
let textChangeListener = TextChanger()
var text: String = ""
{
willSet{
if !onlyPassthroughSend{
objectWillChange.send()
}
self.textChangeListener.changeText(newValue: newValue)
}
}
}
struct CustomState: View {
#State private var text: String = ""
let textChangeListener: TextChanger
init(textChangeListener: TextChanger){
self.textChangeListener = textChangeListener
print("did init")
}
var body: some View {
Text(text)
.onReceive(textChangeListener.textChanged){newValue in
self.text = newValue
}
}
}
struct CustomStateContainer: View {
//var state = ComplexState()
#ObservedObject var state = ComplexState()
var body: some View {
VStack{
HStack{
Text("custom state View: ")
CustomState(textChangeListener: state.textChangeListener)
}
HStack{
Text("ordinary Text View: ")
Text(state.text)
}
HStack{
Text("text input with full state update: ")
TextInput().environmentObject(state)
}
HStack{
Text("text input with no full state update: ")
TextInputNoUpdate().environmentObject(state)
}
}
}
}
struct TextInputNoUpdate: View {
#EnvironmentObject var state: ComplexState
var body: some View {
TextField("input", text: Binding( get: {self.state.text},
set: {newValue in
self.state.onlyPassthroughSend.toggle()
self.state.text = newValue
self.state.onlyPassthroughSend.toggle()
}
))
}
}
struct TextInput: View {
#State private var text: String = ""
#EnvironmentObject var state: ComplexState
var body: some View {
TextField("input", text: Binding(
get: {self.text},
set: {newValue in
self.state.text = newValue
// self.text = newValue
}
))
.onAppear(){
self.text = self.state.text
}.onReceive(state.textChangeListener.textChanged){newValue in
self.text = newValue
}
}
}
struct CustomState_Previews: PreviewProvider {
static var previews: some View {
return CustomStateContainer()
}
}
I made a manual Binding to stop broadcasting objectWillChange. But you still need to gets new value in all the places you changing this value to stay synchronized. Thats why I modified TextInput too.
Is that what you needed?
My solution is use EnvironmentObject and don't use ObservedObject at view it's viewModel will be reset, you pass through hierarchy by
.environmentObject(viewModel)
Just init viewModel somewhere it will not be reset(example root view).

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])
}
}
}
}```

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

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.