SwiftUI navigate if ObservedObject property changes - swift

I can't for the life of me figure out how to only navigate if a property from my ObservedObject changes to meet a condition. ie when my state changes to some condition, navigate to the next screen.
I've used the tag and selection initializer on the NavigationLink but it selection requires a Binding, and I can't derive a Binding from the properties on my ObservedObject without using the .constant() initializer on Binding which is only an immutable value.
#ObservedObject var store: Store<AppState, AppValue>
NavigationLink(
destination: SecondView(),
tag: true,
selection: store.shouldNavigate // Can't do this because I need a binding
)
How else are people implementing buttons that only navigate if a condition in their state is met? I'm trying to avoid using the #State because I want the navigation to depend on my app state not on a local state that I'm toggling based on some business logic
public final class Store<Value, Action>: ObservableObject {
#Published public private(set) var value: Value
}
UPDATE:
So it looks like I should be able to create a binding but since store.value gives me Binding<Value> I get an error: Generic parameter Subject cannot be inferred.

just remove private(set) in your model, NavigationLink will set shouldNavigate to false after navigation is completed, so it should not be private(set)
public final class Store<Value, Action>: ObservableObject {
#Published public var value: Value
}

I show you an important intermediate solution before generics. I think the key here is that selection binding requires optional binding. That's most hassles coming from.
enum AppState: String{
case none = "none"
case red = "red"
case blue = "blue"
case green = "green"
case purple = "purple"
}
enum AppValue: String{
case none
}
public final class Store<V, A>: ObservableObject {
#Published var value: AppState? = AppState.none
public var link :Color = Color.white
init(value: AppState = .none, link : Color = Color.white){
self.link = link
self.value = value
}
}
struct TestView: View {
#ObservedObject var store: Store<AppState?, AppValue>
var viewStates: [Store<AppState?,AppValue>] =
[Store(value: .red, link: Color.red),
Store(value: .blue, link: Color.blue) ,
Store(value: .green, link: Color.green),
Store(value: .purple, link: Color.purple) ]
var body: some View {
NavigationView{
VStack{
ForEach(viewStates, id: \.value){ s in
Group{
NavigationLink(destination: s.link, tag: s.value!, selection: self.$store.value ){EmptyView()}
Button( s.value!.rawValue, action: {
self.store.value = s.value!})
}}
}
}
}
}

Related

ObservableObject with many #Published properties redraws everything unnecessarily

Consider the following code:
class Model: ObservableObject {
#Published var property1: Int = 0
#Published var property2: Int = 0
}
struct ObjectBindingTest: View {
#StateObject private var model = Model()
var body: some View {
print("——— top")
return VStack(spacing: 30) {
SomeSimpleComponent(property: $model.property1)
SomeSimpleComponent2(property: $model.property2)
}
.padding(50)
}
}
struct SomeSimpleComponent: View {
#Binding var property: Int
var body: some View {
print("component 1")
return HStack {
Text("\(property)")
Button("Increment", action: { property += 1 })
}
}
}
struct SomeSimpleComponent2: View {
#Binding var property: Int
var body: some View {
print("component 2")
return HStack {
Text("\(property)")
Button("Increment", action: { property += 1 })
}
}
}
Whenever you press on one of the buttons, you will see in console:
——— top
component 1
component 2
Meaning that all body blocks get evaluated.
I would expect that only the corresponding row gets updated: if I press the first button and therefore update property1, the second row shouldn't have to re-evaluate its body because it's only dependent on property2.
This is causing big performance issues in my app. I have a page to edit an object with many properties. I use an ObservableObject with many #Published properties. Every time a property changes (for instance while typing in a field), all the controls in the page get updated, which causes lags and freezes. The performance issues mostly happen in iOS 14; I'm not sure whether they're not happening in iOS 15 or if it's just that the device has more computing power.
How to prevent unnecessary updates coming from ObservableObject, and only update the views that actually watch the updated property?
The behavior you are seeing is expected
By default an ObservableObject synthesizes an objectWillChange publisher that emits the changed value before any of its #Published properties changes.
In other words all the wrappers trigger a single publisher so SwiftUI does not know which was updated.
https://developer.apple.com/documentation/combine/observableobject
You can get a partial performance upgrade by changing from a class to a struct and using #State
struct Model {
var property1: Int = 0
var property2: Int = 0
}
#State private var model = Model()
In certain cases such a ForEach you will get improvements by adding a few protocols.
struct Model: Equatable, Hashable, Identifiable {
let id: UUID = .init()
//More code
Check out Demystify SwiftUI from #wwdc21
https://developer.apple.com/wwdc21/10022 it will provide a greater insight into the why.

SwiftUI 4.0 - Passing a Binding via .navigationDestination(for: , destination: )

How do I pass a Binding via the new .navigationDestination(for: , destination: )?
import SwiftUI
enum TestEnum: String, Hashable, CaseIterable {
case first, second, third
}
struct ContentView: View {
#State private var test: TestEnum = .first
var body: some View {
NavigationStack {
VStack {
NavigationLink(value: test, label: {
Text(test.rawValue)
})
}
// This does not work, as it won't allow me to use $caze
.navigationDestination(for: TestEnum.self, destination: { caze in
SecondView(test: $caze)
})
}
}
}
struct SecondView: View {
#Environment(\.presentationMode) var presentationMode
#Binding var test: TestEnum
var body: some View {
ForEach(TestEnum.allCases, id: \.self) { caze in
Button(action: {
test = caze
presentationMode.wrappedValue.dismiss()
}, label: {
Text(caze.rawValue)
})
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
In SwiftUI 3.0 I'd simply use:
NavigationLink(destination: SecondView(test: $test), label: {
Text(test.rawValue)
})
Is this still the correct approach, as we cannot pass a Binding yet?
Not really interested in complex workarounds like using an EnvironmentObject and passing an index, as the SwiftUI 3.0 approach works fine.
However, if there is a proper way of passing a Binding via .navigationDestination(for: , destination: ) I'll happily use it.
According to this doc:
https://developer.apple.com/documentation/charts/chart/navigationdestination(for:destination:)/
[WRONG: Where destination get passed from NavigationLink, but in the doc they use the NavigationLink("Mint", value: Color.mint) version of the NavigationLink. I don't know if this make any difference.
You are using the NavigationLink(value:label:)]
EDIT: I insist, after further investigation, that you are using the ViewModifier wrong.
Read the doc that I pointed above. The viewModifier signature is:
func navigationDestination<D, C>(
for data: D.Type,
destination: #escaping (D) -> C
) -> some View where D : Hashable, C : View
Note D is Hashable, not State
So, when you pass $caze you are not passing the #State var above, but the NavigationLink(value: test... that is not a #State wrapper. Is the generic D ... a single value, so $caze is not a Binding to the State.
You can pass normally $test, from that is a Binding to the State test. But the parameter inside the closure .navigationDestination( value in ... is not.

Trying to dynamically update swiftUI view

struct MakeVideo: View {
#EnvironmentObject var modelData: ModelData
#State private var chosenFriends: [FriendModel] = []
mutating func addFriend(_friend: FriendModel) -> Void {
chosenFriends.append(_friend)
}
var body: some View {
VStack {
ForEach(modelData.friends) { friend in
HStack {
ProfilePic(picture: friend.profilepic!)
Text("#"+friend.username!)
//TODO: This is updating the val, it just isn't being shown here
Button("Add", action: friend.toggleChosen)
if friend.isChosen {
Image(systemName: "star.fill")
.foregroundColor(.yellow)
} else {
Image(systemName: "star")
}
}
}
}
}
}
struct MakeVideo_Previews: PreviewProvider {
static var previews: some View {
MakeVideo()
.environmentObject(ModelData())
}
}
I am trying to dynamically update this so that when ai click the Button, it'll make the star be filled instead of empty. In the debugger I see the class value being changed however the change does not appear in the view. I also made the var in the class #Published, which I thought would allow the view to change with the value
Here is my code for the classes and ModelData
class FriendModel: Identifiable, ObservableObject {
init(id: Int, username: String, profilepic: String) {
self.id = id
self.username = username
self.profilepic = profilepic
}
var id: Int?
var username: String?
var profilepic: String?
#Published var isChosen = false
//var profilepic: UIImage
func toggleChosen() {
print(self.isChosen)
self.isChosen = !self.isChosen
print(self.isChosen)
}
}
var allvideos: [VideoModel] = [VideoModel(id: 1, name: "Beach Trip", length: 25, url: "mona"), VideoModel(id: 2, name: "Dogs", length: 10, url:"dog"), VideoModel(id: 3, name: "Concerts", length: 42, url: "hogrider")]
var allfriends: [FriendModel] = [FriendModel(id: 1, username: "bpaul18", profilepic: "profilepic"), FriendModel(id: 2, username: "kmill", profilepic: "profilepic"), FriendModel(id: 3, username: "dudeitsdom", profilepic: "profilepic")]
final class ModelData: ObservableObject {
#Published var videos: [VideoModel] = allvideos
#Published var friends: [FriendModel] = allfriends
}
You don't say that you get a compiler error on the following line:
Button("Add", action: friend.toggleChosen)
Therefore I deduce that FriendModel is a class, not a struct. If FriendModel were a struct and toggleChosen were a mutating method, then you would get an error: “Cannot use mutating member on immutable value”.
Even if FriendModel conforms to ObservableObject, the problem is that ObservableObjects do not automatically compose. A change to an #Published property of a FriendModel will not be noticed by a containing ModelData, which means (in this case) that SwiftUI will not notice that friend.isChosen was modified.
I suggest making FriendModel into a struct.
I also recommend using Point-Free's Composable Architecture package (or something similar) as your app architecture, because it provides a comprehensive solution to problems like this.
try using this:
ForEach($modelData.friends) { $friend in // <-- here $
Body is only called for states that are used. Since chosenFriends is not used in body, it is not called when it is changed in a button action.
To fix, write a func isFriend and lookup the friend ID in the chosenFriends array and use the result of that in body to show the star. Since chosenFriends is now used, body will be called. Also change the button to call the addFriend func which would be better named as chooseFriend by the way.

Change a #Published var via Picker

I would like to change a #Published var through the Picker but I found that it fails to compile.
I have a Language struct:
enum Language: Int, CaseIterable, Identifiable, Hashable {
case en
case zh_hant
var description: String {
switch self {
case.en:
return "English"
case.zh_hant:
return "繁體中文"
}
}
var id: Int {
self.rawValue
}
}
And inside my DataModel, I have published the variable selectedLanguage
final class ModelData: ObservableObject {
#Published var currentLanguage: Language = Language.zh_hant
}
I tried to use a picker for the user to change the language:
#EnvironmentObject var modelData: ModelData
In the body of the view:
Picker("Selected Language", selection: modelData.currentLanguage) {
ForEach(Language.allCases, id: \.rawValue) { language in
Text(language.description).tag(language)
}
}.pickerStyle(SegmentedPickerStyle()).padding(.horizontal)
It says "Cannot convert value of type 'Language' to expected argument type 'Binding'"
How can I modify the currentLanguage in the modelData directly with the picker?
You were very close. You need to use the $ when making a binding to a #Published property like that:
Picker("Selected Language", selection: $modelData.currentLanguage) {
ForEach(Language.allCases, id: \.id) { language in
Text(language.description).tag(language)
}
}.pickerStyle(SegmentedPickerStyle()).padding(.horizontal)
I also changed the id: to \.id rather than \.rawValue which is somewhat meaningless since they resolve to the same thing, but makes a little more sense semantically.

Why does picker binding not update when using SwiftUI?

I have just begun learning Swift (and even newer at Swift UI!) so apologies if this is a newbie error.
I am trying to write a very simple programme where a user chooses someone's name from a picker and then sees text below that displays a greeting for that person.
But, the bound var chosenPerson does not update when a new value is picked using the picker. This means that instead of showing a greeting like "Hello Harry", "Hello no-one" is shown even when I've picked a person.
struct ContentView: View {
var people = ["Harry", "Hermione", "Ron"]
#State var chosenPerson: String? = nil
var body: some View {
NavigationView {
Form {
Section {
Picker("Choose your favourite", selection: $chosenPerson) {
ForEach ((0..<people.count), id: \.self) { person in
Text(self.people[person])
}
}
}
Section{
Text("Hello \(chosenPerson ?? "no-one")")
}
}
}
}
}
(I have included one or two pieces of the original formatting in case this is making a difference)
I've had a look at this question, it seemed like it might be a similar problem but adding .tag(person) to Text(self.people[person])did not solve my issue.
How can I get the greeting to show the picked person's name?
Bind to the index, not to the string. Using the picker, you are not doing anything that would ever change the string! What changes when a picker changes is the selected index.
struct ContentView: View {
var people = ["Harry", "Hermione", "Ron"]
#State var chosenPerson = 0
var body: some View {
NavigationView {
Form {
Section {
Picker("Choose your favourite", selection: $chosenPerson) {
ForEach(0..<people.count) { person in
Text(self.people[person])
}
}
}
Section {
Text("Hello \(people[chosenPerson])")
}
}
}
}
}
The accepted answer is right if you are using simple arrays, but It was not working for me because I was using an array of custom model structs with and id defined as string, and in this situation the selection must be of the same type as this id.
Example:
struct CustomModel: Codable, Identifiable, Hashable{
var id: String // <- ID of type string
var name: String
var imageUrl: String
And then, when you are going to use the picker:
struct UsingView: View {
#State private var chosenCustomModel: String = "" //<- String as ID
#State private var models: [CustomModel] = []
var body: some View {
VStack{
Picker("Picker", selection: $chosenCustomModel){
ForEach(models){ model in
Text(model.name)
.foregroundColor(.blue)
}
}
}
Hope it helps somebody.