I have this navigationStack which has a number of NavigationLinks to game menu's where the user can pick the game to play. This menu consist of navigation links as well which lead to a certain type of the game. The game menu looks like this:
struct GameMenuView: View {
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
var body: some View {
VStack{
ButtonGame(game_name: "Title", view: AnyView(GameScreen(game: NewGame(count: 4, category: "All"))))
ButtonGame(game_name: "Title", view: AnyView(GameScreen(game: NewGame(count: 6, category: "2"))))
ButtonGame(game_name: "Title", view: AnyView(GameScreen(game: NewGame(count: 4, category: "None"))))
ButtonGame(game_name: "Title", view: AnyView(GameScreen(game: NewGame(count: 4, category: "Basic"))))
}
}
}
ButtonGame is just a struct which takes a view the NavigationLink should lead to. The view of the game screen takes the NewGame class which handles all the game logic and statistics. This all works fine, but I found that every time I navigate from the main menu to the game menu all these 4 NewGame classes get initialized and also when navigating from the GameMenu to the actual GameView all the NewGame classes get initialized again. Same thing happens when I navigate back from the game view to the game menu view.
This causes unnecessary loading of data and also causes the game to lose its state every time you leave the gameview. How can this be best handled? So that the specific game only gets initialized when the button for that game gets pressed, and that when navigating back to the game menu does not cause everything tot reload again?
In Swift (and SwiftUI) we use structs instead of classes for model data. The data is always prepared before hand, stored in #State and then body uses the data to create Views. So just change Game to be a struct like this:
struct Game: Identifiable {
let id = UUID()
let count: Int
let category: String
}
struct GameMenuView: View {
#Environment(\.presentationMode) var mode: Binding<PresentationMode>
#State var games = [Game(count: 4, category: "All"), Game(count: 6, category: "2"), Game(count: 4, category: "None"), Game(count: 4, category: "Basic")]
var body: some View {
List {
ForEach($games) { $game in
ButtonGame(gameName: "Title", game: $game)
}
}
}
}
struct ButtonGame: View {
#Binding var game: Game
Related
Here is a simple example of a more complex problem I'm having.
In this case, I have an array of Objects, and a struct that holds the state for them.
In the ContentView, I'm displaying a custom view, MyToggle, for each object, and passing the state via a Binding
struct Object: Identifiable {
let id = UUID()
let name: String
}
struct ObjectStates {
var states: [Object.ID: Bool] = [:]
subscript(objectId: Object.ID) -> Bool {
get { states[objectId, default: false] }
set { states[objectId] = newValue }
}
}
struct ContentView: View {
let objects = [Object(name: "Toggle 1"), Object(name: "Toggle 2"), Object(name: "Toggle 3"), Object(name: "Toggle 4"), Object(name: "Toggle 5")]
#State var objectStates = ObjectStates()
var body: some View {
List(objects) { object in
MyToggle(name: object.name, isOn: $objectStates[object.id])
}
}
}
struct MyToggle: View {
let name: String
#Binding var isOn: Bool
var body: some View {
let _ = print(name)
let _ = Self._printChanges()
Toggle(name, isOn: $isOn)
}
}
Each time a Toggle is changed, ObjectStates is updated, and all the subviews are redrawn. The Self._printChanges() is used to demonstrate this.
Is there a way to prevent all the subviews being redrawn when the state in the superview changes?
Interestingly, if I add another #State on the superview…
struct ContentView: View {
let objects = [Object(name: "Toggle 1"), Object(name: "Toggle 2"), Object(name: "Toggle 3"), Object(name: "Toggle 4"), Object(name: "Toggle 5")]
#State var objectStates = ObjectStates()
#State var isOn = false. // added this State
var body: some View {
List {
Toggle("Main", isOn: $isOn) // change it here
ForEach(objects) { object in
MyToggle(name: object.name, isOn: $objectStates[object.id])
}
}
}
}
The subviews are not redrawn when this state is updated.
First to the reason why this happens.
Both structs ObjectStates and Object are value types the same as the states dictionary. If you change a value type it gets destroyed and a new copy with the changed values is created. This new entity has a new id. The #State property wrapper detects changes by changes of the id of its wrapped value. That´s the reason we are using structs in SwiftUI. If you would use classes changes won´t reflect into the UI.
When an #State property is changed it sends its objectWillChange publisher. The SwiftUI View now tries to evaluate if it needs to change the presented view.
(Keep in mind the Views we are writing in SwiftUI are just a description of the view not the view itself.) To evaluate changes it calls the body var. Now when SubViews depend on any changed var it needs to evaluate these too. It can´t just guess if something changed or not. It has to run the body and compare it to the previous one.
That´s what you are seeing here. The subviews depend on objectStates so it has to call all MyToggle body vars to determine if they changed or not. It won´t call them in your second example because they do not depend on the changed #State var isOn.
Conclusion:
I don´t think there is something wrong with your approach. Calling the body var multiple times shouldn´t be of any concern. They should be lightweighted and easy to destroy / create anyway. List should only call the body var of the visible elements, so there should be no impact on performance if you have larger collections.
There is a work around for this. But it will have drawbacks as it uses a class to avoid the reevaluation of the subviews.
Change the struct to a class and implement ObservableObject
class ObjectStates: ObservableObject {
var states: [Object.ID: Bool] = [:]
subscript(objectId: Object.ID) -> Bool {
get { states[objectId, default: false] }
set { states[objectId] = newValue }
}
}
Declare it #StateObject inside your View. This is needed to bind the lifecycle of the class to the view.
#StateObject var objectStates = ObjectStates()
Now you can use the Toggles without recreating the MyToggle struct.
And of course the mandatory link to the video that´s more or less a must watch if working with SwiftUI -> Demystify SwiftUI
There are three views. Main view, a List view (displaying a list of locations) and a Content view (displaying a single location). Both the List view and the Content view are watching for changes from LocationManager(GPS position).
If the user NavigateLinks to Content view from the List view and then the observed LocationManager changes then ContentView is popped off and the List view is displayed.
I have NavigationViewStyle set to .stack (which some found to help) but it had no effect.
Does anyone know what I am doing wrong?
// Main view
#StateObject var locations = Locations()
var body: some Scene {
WindowGroup {
TabView(){
NavigationView {
ListView()
}.navigationViewStyle(.stack).tabItem {
Text("List")
}
}.environmentObject(locations)
}
}
// List view
#EnvironmentObject var locations: Locations
#ObservedObject private var locationManager = LocationManager()
var body: some View {
List(){
ForEach(locations, id: \.id) {
loc in
NavigationLink(destination: ContentView(location: loc, locationManager: locationManager)) {
Text(loc.name)
}.isDetailLink(false)
}
}.listStyle(PlainListStyle())
}.navigationTitle("List")
// Content/Detail view
let location: Location
let locationManager: LocationManager
var body: some View { Text("I am detail") }
What I ended up doing was having two observable objects, one for the List and one for Detail.
The observable objects are GPS coordinates so that when the Content view is displayed (you can keep a track of that by adding a state in onAppear for the View) you don't allow changes to the observable object for the List.
In other words the observable object that the List view had, didn't change if the Content view was visible. I did this in code by simply ignoring any location updates in that object if the current view was Content/Detail.
This worked perfectly.
So instead of this:
I did this
It pops because your objects are not unique. Think about this:
You are inside a view called Alen now you refresh that view and it's called Alenx, SwiftUI pops you back because the data is different.
The way to make it stay is to make the elements unique (Identifiable), for this you can assign some unique id to your models, here's 1 way of doing it:
struct Example: Identifiable {
let id = UUID()
var someValue: String
private enum CodingKeys: String, CodingKey { case someValue }
}
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.
I am currently working on a project, where I use SpriteView to display game content and normal Views to display menus and navigation in general. I am able to create and load SpriteViews when pressing buttons in the normal view but the communication does not work the other way. I want to be able to change State variables in the parent View-Element by using buttons/SKShapeNodes of the child SpriteView-Element.
I tried Binding variables between the two instances and using callback-functions. But I wasn't able to change any content in the View-Element.
Is there a simple and effective way to send requests from a child-SpriteView to the parent-View ?
You can use the ObservableObject - #Published pattern for this.
Make your GameScene conform to the ObservableObject protocol and publish the properties that you are interested to send the values of into the SwiftUI views, something like this:
class GameScene: SKScene, ObservableObject {
#Published var updates = 0
#Published var isPressed = false
// ...
}
Then, in your SwiftUI view, use a #StateObject property (or an #ObservedObject or an #EnvironmentObject) to store the GameScene instance. You can then use the GameScene's published properties in your SwiftUI view:
struct ContentView: View {
#StateObject private var scene: GameScene = {
let scene = GameScene()
scene.size = CGSize(width: 300, height: 400)
return scene
}()
var body: some View {
ZStack {
SpriteView(scene: scene).ignoresSafeArea()
VStack {
Text("Updates from SKScene: \(scene.updates)")
if scene.isPressed {
Text("isPressed is true inside GameScene")
}
}
}
}
}
When you "press buttons in the normal view", change the value of the published properties, and they will change the SwiftUI views that uses them.
I try to implement the MVVM way correctly in SwiftUI, so I came up with this (simplified) Model and ViewModel:
struct Model {
var property1: String
var property2: String
}
class ViewModel: ObservableObject {
#Published var model = Model(property1: "this is", property2: "a test")
}
Using this in a View works fine, but I experienced some bad performance issues, as I extended the ViewModel with some computed properties and some functions (as well the Model itself is more complicated). But let's stay with this example, because it demonstrates perfectly, what I think is a big problem in SwiftUI itself.
Imagine, you have those views to display the data:
struct ParentView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
print("redrawing ParentView")
return ChildView(viewModel: self.viewModel)
}
}
struct ChildView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
print("redrawing ChildView")
return VStack {
ViewForTextField(property: self.$viewModel.model.property1)
ViewForTextField(property: self.$viewModel.model.property2)
}
}
}
struct ViewForTextField: View {
#Binding var property: String
var body: some View {
print("redrawing textView of \(self.property)")
return TextField("...", text: self.$property)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
Now entering text into one of the TextField leads to a redraw of every View in my window! The print output is:
redrawing ParentView
redrawing ChildView
redrawing textView of this is
redrawing textView of a test
redrawing ParentView
redrawing ChildView
redrawing textView of this isa
redrawing textView of a test
redrawing ParentView
redrawing ChildView
redrawing textView of this isab
redrawing textView of a test
...
As I can see, SwiftUI redraws every view, because every view is listening to the ObservedObject.
How can I tell SwiftUI, that it only should redraw those views, where really happened any changes?
Actually MVVM means own model for every view, which is updated on model changes, not one model for all views.
So there is no needs to observe viewModel in ParentView as it does not depend on it
struct ParentView: View {
var viewModel: ViewModel // << just member to pass down in child
var body: some View {
print("redrawing ParentView")
return ChildView(viewModel: self.viewModel)
}
}
Alternate is to decompose view model so every view has own view sub-model, which would manage updates of own view.
If all your views observe the same thing, and that thing changes, then all your views will re-render. This is by definition. There's no option to configure to optionally update certain views.
That being said, you can work around it by manipulating what changes you'd like to publish.
E.g.;
class ViewModel: ObservableObject {
#Published var model = Model(property1: "this is", property2: "a test")
var mytext = "some text" // pass this as binding instead of model.propertyX
}
Now when textfield changes, mytext changes, but this change won't be published thus won't trigger further view updates. And you still can access it from view model.
I personally would recommend using #State and #EnvironmentObject instead of "view model". They are the built-in way to handle binding and view updates with tons of safe-guards and supports.
Also you should use value type instead of reference type as much as possible. MVVM from other languages didn't take this into consideration.