Bindable boolean in array that's togglable SwiftUI - swift

I have an array in SwiftUI where it's an array of a struct that contains a boolean value which is bounded by a Toggle.
struct Blah {
#State var enabled = true
}
struct ContentView: View {
#State public var blahs: [Blah] = [
Blah(false)
]
var body : some View {
List(blahs) { blah in
Toggle(isOn: blah.$enabled)
}
}
}
the blahs arrays will have a button that will append more Blah objects. Xcode is telling me this though:
Accessing State's value outside of being installed on a View. This will result in a constant Binding of the initial value and will not update.
How should I change this? I don't think I'm applying the concept right.

#State should only be used on a View — it shouldn’t be used inside your model.
Once you’ve removed that, you can use the element binding syntax to get bindings to individual items on the List:
struct Blah : Identifiable {
var id = UUID()
var enabled = true
}
struct ContentView: View {
#State public var blahs: [Blah] = [
Blah(false)
]
var body : some View {
List($blahs) { $blah in
Toggle(isOn: $blah.enabled)
}
}
}

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.

How to modify a user input inside a SwiftUI form loop

I'm developing a simple SwiftUI app in Xcode 11. I want to have a form that loops through multiple user input strings and displays a form with a button. When the user presses the button it modifies the input value - specifically increment or decrement it.
However when passing an array of references like UserInput().foo where UserInput is a published observable object I cannot modify the value inside a ForEach because the ForEach is passed a copy as oppose to the original reference (at least that's my basic understanding). How do I then try to achieve it? I read about inout and everybody says to avoid it but surely this must be a relatively common issue.
I've made an simple example of what I'm trying to do but I can't quite work it out:
import SwiftUI
class UserInput: ObservableObject {
#Published var foo: String = ""
#Published var bar: String = ""
}
struct ContentView: View {
#ObservedObject var input = UserInput()
var body: some View {
LoopInputs()
}
func LoopInputs() -> AnyView?{
var userinputs = [
[UserInput().foo, "Foo"],
[UserInput().bar, "Bar"]
]
var inputs: some View{
VStack(){
ForEach(userinputs, id: \.self){userinput in
Text("\(userinput[1]): \(String(userinput[0]))")
Button(action: {
increment(input: String(userinput[0]))
}){
Text("Increase")
}
}
}
}
return AnyView(inputs)
}
func increment(input: String){
var lead = Int(input) ?? 0
lead += 1
// input = String(lead)
}
}
As I understood, when adding a value to userinputs, the ForEach values doesn't change.
Well, if that's the case, first of all, you could try creating a struct and in it, you declare foo and bar, then just declare a variable of type the struct. It'll look like this:
struct Input: Identifiable {
var id = UUID()
var foo: String
var bar: String
}
class UserInput: ObservableObject {
#Published var inputs: [Input] = [Input]()
}
//ContentView
struct ContentView: View {
#ObservedObject var input = UserInput()
var body: some View {
LoopInputs()
}
func LoopInputs() -> AnyView? {
var inputs: some View {
VStack {
ForEach(input.inputs) { userinput in
Text("\(userinput.bar): \(String(userinput.foo))")
Button(action: {
increment(input: String(userinput.foo))
}) {
Text("Increase")
}
}
}
}
return AnyView(inputs)
}
func increment(input: String) {
var lead = Int(input) ?? 0
lead += 1
// input = String(lead)
}
}
Wouldn't this be easier and more elegant?

Published computed properties in SwiftUI model objects

Suppose I have a data model in my SwiftUI app that looks like the following:
class Tallies: Identifiable, ObservableObject {
let id = UUID()
#Published var count = 0
}
class GroupOfTallies: Identifiable, ObservableObject {
let id = UUID()
#Published var elements: [Tallies] = []
}
I want to add a computed property to GroupOfTallies that resembles the following:
// Returns the sum of counts of all elements in the group
var cumulativeCount: Int {
return elements.reduce(0) { $0 + $1.count }
}
However, I want SwiftUI to update views when the cumulativeCount changes. This would occur either when elements changes (the array gains or loses elements) or when the count field of any contained Tallies object changes.
I have looked into representing this as an AnyPublisher, but I don't think I have a good enough grasp on Combine to make it work properly. This was mentioned in this answer, but the AnyPublisher created from it is based on a published Double rather than a published Array. If I try to use the same approach without modification, cumulativeCount only updates when the elements array changes, but not when the count property of one of the elements changes.
There are multiple issues here to address.
First, it's important to understand that SwiftUI updates the view's body when it detects a change, either in a #State property, or from an ObservableObject (via #ObservedObject and #EnvironmentObject property wrappers).
In the latter case, this is done either via a #Published property, or manually with objectWillChange.send(). objectWillChange is an ObservableObjectPublisher publisher available on any ObservableObject.
This is a long way of saying that IF the change in a computed property is caused together with a change of any #Published property - for example, when another element is added from somewhere:
elements.append(Talies())
then there's no need to do anything else - SwiftUI will recompute the view that observes it, and will read the new value of the computed property cumulativeCount.
Of course, if the .count property of one of the Tallies objects changes, this would NOT cause a change in elements, because Tallies is a reference-type.
The best approach given your simplified example is actually to make it a value-type - a struct:
struct Tallies: Identifiable {
let id = UUID()
var count = 0
}
Now, a change in any of the Tallies objects would cause a change in elements, which will cause the view that "observes" it to get the now-new value of the computed property. Again, no extra work needed.
If you insist, however, that Tallies cannot be a value-type for whatever reason, then you'd need to listen to any changes in Tallies by subscribing to their .objectWillChange publishers:
class GroupOfTallies: Identifiable, ObservableObject {
let id = UUID()
#Published var elements: [Tallies] = [] {
didSet {
cancellables = [] // cancel the previous subscription
elements.publisher
.flatMap { $0.objectWillChange }
.sink(receiveValue: self.objectWillChange.send)
.store(in: &cancellables)
}
}
private var cancellables = Set<AnyCancellable>
var cumulativeCount: Int {
return elements.reduce(0) { $0 + $1.count } // no changes here
}
}
The above will subscribe a change in the elements array (to account for additions and removals) by:
converting the array into a Sequence publisher of each array element
then flatMap again each array element, which is a Tallies object, into its objectWillChange publisher
then for any output, call objectWillChange.send(), to notify of the view that observes it of its own changes.
This is similar to the last option of #New Devs answer, but a little shorter, essentially just passing the objectWillChange notification to the parent object:
import Combine
class Tallies: Identifiable, ObservableObject {
let id = UUID()
#Published var count = 0
func increase() {
count += 1
}
}
class GroupOfTallies: Identifiable, ObservableObject {
let id = UUID()
var sinks: [AnyCancellable] = []
#Published var elements: [Tallies] = [] {
didSet {
sinks = elements.map {
$0.objectWillChange.sink( receiveValue: objectWillChange.send)
}
}
}
var cumulativeCount: Int {
return elements.reduce(0) { $0 + $1.count }
}
}
SwiftUI Demo:
struct ContentView: View {
#ObservedObject
var group: GroupOfTallies
init() {
let group = GroupOfTallies()
group.elements.append(contentsOf: [Tallies(), Tallies()])
self.group = group
}
var body: some View {
VStack(spacing: 50) {
Text( "\(group.cumulativeCount)")
Button( action: group.elements.first!.increase) {
Text( "Increase first")
}
Button( action: group.elements.last!.increase) {
Text( "Increase last")
}
}
}
}
The simplest & fastest is to use value-type model.
Here is a simple demo. Tested & worked with Xcode 12 / iOS 14
struct TestTallies: View {
#StateObject private var group = GroupOfTallies() // SwiftUI 2.0
// #ObservedObject private var group = GroupOfTallies() // SwiftUI 1.0
var body: some View {
VStack {
Text("Cumulative: \(group.cumulativeCount)")
Divider()
Button("Add") { group.elements.append(Tallies(count: 1)) }
Button("Update") { group.elements[0].count = 5 }
}
}
}
struct Tallies: Identifiable { // << make struct !!
let id = UUID()
var count = 0
}
class GroupOfTallies: Identifiable, ObservableObject {
let id = UUID()
#Published var elements: [Tallies] = []
var cumulativeCount: Int {
return elements.reduce(0) { $0 + $1.count }
}
}

SwiftUI: How to Update NavigationView Detail Screens?

I've got an app that gets a list of vehicles from a REST backend server. It then uses that list to build a list of vehicles that can be tapped to show the details about one of them:
#State private var selectedVehicle: Vehicle?
#Binding var vehicles: [Vehicle]
List {
NavigationView {
ForEach( vehicles ) { vehicle in
NavigationLink( destination: VehicleDetailScreen( vehicle: vehicle ),
tag: vehicle,
selection: self.$selectedVehicle ) {
Text( vehicle.name )
}
}
}
}
struct VehicleDetailScreen: View {
var vehicle: Vehicle
var body: some View {
// Lots of rendering code omitted
}
}
So far, so good. This works nicely. The problem arises when we fetch updated information from the server. Updating the bound vehicles property works great for updating the list. But the detail screen is still showing data that's no longer relevant.
My first thought was just to pop the detail view off of the NavigationView. Unfortunately, SwiftUI doesn't provide any reliable way that I can find to do this in a two-column view on the iPad.
My next thought was that we needed to pass the vehicle in to VehicleDetailScreen as a #Binding too so that we can update it. But this is tough to do as well because we would need a reference to that binding so that we can cram updated values into it. The only way I can think of to do that would be to rework our network and model object code entirely so that it works like CoreData, keeping objects in memory and updating them with new values from the server, rather than generating new objects. This would be a good deal of effort, and obviously isn't something I'm keen to do if there's another option.
So I'm kind of stuck on this. Any thoughts/ideas/suggestions are very welcome!
Perhaps the concept of #Binding is somewhat confusing. From a #State var (parent view), to #Binding var (child view).
A struct Hashable to facilitate and reorder the elements of the array [Vehicle].
Something like this:
struct Vehicle: Hashable {
var name:String
//var otherItem: Any
}
struct ContentView: View {
#State var vehicle: Vehicle //the struct of your REST
#State var vehicles: [Vehicle] // the array of your REST
var body: some View {
List {
NavigationView {
ForEach(vehicles, id:\.self) { item in // loop the array to get every single item conform to the struct
NavigationLink( destination: VehicleDetailScreen(vehicle: self.$vehicle)) { // here to pass the binding
Text("\(self.vehicle.name)")
}
}
}
}
}
}
//detail view
struct VehicleDetailScreen: View {
#Binding var vehicle: Vehicle // here the binding
var body: some View {
Text("\(vehicle.name)")
}
}
If you want your detail views to update when data changes, you will have to make use of bindings.
As far as architecture goes, I would suggest to create so called Stores that hold data which can be used in multiple views. This, in combination with some static provider for Stores, makes it that you can easily access and modify data anywhere, and let your views update automatically.
When using UIKit, you would manually refresh data by calling reloadTable for instance. In SwiftUI this is not done. You could hypothetically manually trigger the view to update, but I would advice against this, as it is not the way SwiftUI was intended.
I've modified your code to show an example of this:
class StoreProvider {
static let carStore = CarStore()
}
class CarStore: ObservableObject {
#Published var vehicles: [Vehicle] = [Vehicle(id: "car01", name: "Porsche", year: 2016), Vehicle(id: "car02", name: "Lamborghini", year: 2002)]
}
struct Vehicle: Identifiable, Hashable {
let id: String
var name: String
var year: Int
}
struct CarOverview: View {
#ObservedObject var store = StoreProvider.carStore
#State var selectedVehicle: Vehicle?
var body: some View {
NavigationView {
List {
ForEach(store.vehicles.indices) { vehicleIndex in
NavigationLink(destination: VehicleDetailScreen(vehicle: self.$store.vehicles[vehicleIndex])) {
Text(self.store.vehicles[vehicleIndex].name)
}.onTapGesture {
self.selectedVehicle = self.store.vehicles[vehicleIndex]
}
}
}
}
}
}
struct VehicleDetailScreen: View {
#Binding var vehicle: Vehicle
func updateValues() {
vehicle.year = Int.random(in: 1990..<2020)
}
var body: some View {
VStack {
Text(vehicle.name)
Text("Year: ") + Text(vehicle.year.description)
}.onTapGesture(perform: updateValues)
}
}

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.