SwiftUI ObservedObject causes undesirable visible view updates - swift

I am working on an app that applies a filter to an image. The filter has a number of parameters that the user can modify. I have created an ObservableObject that contain said parameters. Whenever one of the parameters changes, there is a visible update for views, even if the view displays the same value as before. This does not happen when I model the parameters as individual #State variables.
If this is to be expected (after all the observed object does change, so each view depending on it will update), is an ObservedObject the right tool for the job? On the other hand it seems to be very inconvenient to model the parameters as individual #State/#Binding variables, especially if a large number of parameters (e.g. 10+) need to be passed to multiple subviews!
Hence my question:
Am I using ObservedObject correctly here? Are the visible updates unintended, but acceptable, or is there a better solution to handle this in swiftUI?
Example using #ObservedObject:
import SwiftUI
class Parameters: ObservableObject {
#Published var pill: String = "red"
#Published var hand: String = "left"
}
struct ContentView: View {
#ObservedObject var parameters = Parameters()
var body: some View {
VStack {
// Using the other Picker causes a visual effect here...
Picker(selection: self.$parameters.pill, label: Text("Which pill?")) {
Text("red").tag("red")
Text("blue").tag("blue")
}.pickerStyle(SegmentedPickerStyle())
// Using the other Picker causes a visual effect here...
Picker(selection: self.$parameters.hand, label: Text("Which hand?")) {
Text("left").tag("left")
Text("right").tag("right")
}.pickerStyle(SegmentedPickerStyle())
}
}
}
Example using #State variables:
import SwiftUI
struct ContentView: View {
#State var pill: String = "red"
#State var hand: String = "left"
var body: some View {
VStack {
Picker(selection: self.$pill, label: Text("Which pill?")) {
Text("red").tag("red")
Text("blue").tag("blue")
}.pickerStyle(SegmentedPickerStyle())
Picker(selection: self.$hand, label: Text("Which hand?")) {
Text("left").tag("left")
Text("right").tag("right")
}.pickerStyle(SegmentedPickerStyle())
}
}
}

Warning: This answer is less than ideal. If the properties of parameters will be updated in another view (e.g. an extra picker), the picker view will not be updated.
The ContentView should not 'observe' parameters; a change in parameters will cause it to update its content (which is visible in case of the Pickers). To prevent the need for the observed property wrapper, we can provide explicit bindings for parameter's properties instead. It is OK for a subview of ContentView to use #Observed on parameters.
import SwiftUI
class Parameters: ObservableObject {
#Published var pill: String = "red"
#Published var hand: String = "left"
}
struct ContentView: View {
var parameters = Parameters()
var handBinding: Binding<String> {
Binding<String>(
get: { self.parameters.hand },
set: { self.parameters.hand = $0 }
)
}
var pillBinding: Binding<String> {
Binding<String>(
get: { self.parameters.pill },
set: { self.parameters.pill = $0 }
)
}
var body: some View {
VStack {
InfoDisplay(parameters: parameters)
Picker(selection: self.pillBinding, label: Text("Which pill?")) {
Text("red").tag("red")
Text("blue").tag("blue")
}.pickerStyle(SegmentedPickerStyle())
Picker(selection: self.handBinding, label: Text("Which hand?")) {
Text("left" ).tag("left")
Text("right").tag("right")
}.pickerStyle(SegmentedPickerStyle())
}
}
}
struct InfoDisplay: View {
#ObservedObject var parameters: Parameters
var body: some View {
Text("I took the \(parameters.pill) pill from your \(parameters.hand) hand!")
}
}

Second attempt
ContentView should not observe parameters (this causes the undesired visible update). The properties of parameters should be ObservableObjects as well to make sure views can update when a specific property changes.
Since Strings are structs they cannot conform to ObservableObject; a small wrapper 'ObservableValue' is necessary.
MyPicker is a small wrapper around Picker to make the view update on changes. The default Picker accepts a binding and thus relies on a view up the hierarchy to perform updates.
This approach feels scalable:
There is a single source of truth (parameters in ContentView)
Views only update when necessary (no undesired visual effects)
Disadvantages:
Seems a lot of boilerplate code for something that feels so trivial it should be provided by the platform (I feel I am missing something)
If you add a second MyPicker for the same property, the updates are not instantaneous.
import SwiftUI
import Combine
class ObservableValue<Value: Hashable>: ObservableObject {
#Published var value: Value
init(initialValue: Value) {
value = initialValue
}
}
struct MyPicker<Value: Hashable, Label: View, Content : View>: View {
#ObservedObject var object: ObservableValue<Value>
let content: () -> Content
let label: Label
init(object: ObservableValue<Value>,
label: Label,
#ViewBuilder _ content: #escaping () -> Content) {
self.object = object
self.label = label
self.content = content
}
var body: some View {
Picker(selection: $object.value, label: label, content: content)
.pickerStyle(SegmentedPickerStyle())
}
}
class Parameters: ObservableObject {
var pill = ObservableValue(initialValue: "red" )
var hand = ObservableValue(initialValue: "left")
private var subscriber: Any?
init() {
subscriber = pill.$value
.combineLatest(hand.$value)
.sink { _ in
self.objectWillChange.send()
}
}
}
struct ContentView: View {
var parameters = Parameters()
var body: some View {
VStack {
InfoDisplay(parameters: parameters)
MyPicker(object: parameters.pill, label: Text("Which pill?")) {
Text("red").tag("red")
Text("blue").tag("blue")
}
MyPicker(object: parameters.hand, label: Text("Which hand?")) {
Text("left").tag("left")
Text("right").tag("right")
}
}
}
}
struct InfoDisplay: View {
#ObservedObject var parameters: Parameters
var body: some View {
Text("I took the \(parameters.pill.value) pill from your \(parameters.hand.value) hand!")
}
}

Related

SwiftUI pass overlay content to parent views

MainView and AnotherMainView contain names arrays. They iterate over these arrays and both use the DetailView to show the name with some additional content. MyViewModel provides some functionalities such as adding a name etc. These should be triggered from a DetailOverlayView which uses the selected name for the actions (please see code comments.
My problem is, starting an overlay within the DetailView results in a very small overlay within the DetailView "cell" which is not what I want. This is why I would like to show it on the parent view. Unfortunately, I don't know how to pass the DetailOverlayView to the MainView and AnotherMainView. I tried with bindings or environment but this did not work. I would like to avoid redundancy such as defining overlays in both parent views etc.
Alternatively, maybe there is a solution to call it from the DetailView, but bypassing the detail view frame without giving some hardcoded height and width values.
struct MainView: View {
#StateObject var viewModel: MyViewModel
var names = ["aaa", "ccc", "ddd", "gght"]
var body: some View {
VStack {
ForEach(names, id: \.self, content: { name in
DetailView(name: name).environmentObject(viewModel)
})
}///.overlay(... -> need DetailOverlayView here
}
}
struct AnotherMainView: View {
#StateObject var viewModel: MyViewModel
var names = ["bbb", "hhh"]
var body: some View {
VStack {
ForEach(names, id: \.self, content: { name in
DetailView(name: name).environmentObject(viewModel)
})
}///.overlay(... -> need DetailOverlayView here
}
}
struct DetailView: View {
#EnvironmentObject var viewModel: MyViewModel
#State var name: String
var body: some View {
VStack {
Text("Name of the user: ")
Text(name)
}///.overlay(... -> this would show an DetailOverlayView as a part of this small DetailView and in its bounds which is not what I want
}
struct DetailOverlayView: View {
#Binding var name: String ///this is the same name that the DetailView has, not sure if binding makes sense here
#EnvironmentObject var viewModel: MyViewModel
var body: some View {
VStack {
Button(action: {
viewModel.addName(name: name)
}, label: {
Text("Add name")
})
Button(action: {
viewModel.doSomething1(name: name)
}, label: {
Text("doSomething1")
})
Button(action: {
viewModel.doSomething2(name: name)
}, label: {
Text("doSomething2")
})
}
}
}
}
class MyViewModel: ObservableObject {
func addName(name: String) {
///
}
func doSomething1(name: String) {
///
}
func doSomething2(name: String) {
///
}
}

SwiftUI List selection always nil

In my macOS app project, I have a SwiftUI List view of NavigationLinks build with a foreach loop from an array of items:
struct MenuView: View {
#EnvironmentObject var settings: UserSettings
var body: some View {
List(selection: $settings.selectedWeek) {
ForEach(settings.weeks) { week in
NavigationLink(
destination: WeekView(week: week)
.environmentObject(settings)
tag: week,
selection: $settings.selectedWeek)
{
Image(systemName: "circle")
Text("\(week.name)")
}
}
.onDelete { set in
settings.weeks.remove(atOffsets: set)
}
.onMove { set, i in
settings.weeks.move(fromOffsets: set, toOffset: i)
}
}
.navigationTitle("Weekplans")
.listStyle(SidebarListStyle())
}
}
This view creates the sidebar menu for a overall NavigationView.
In this List view, I would like to use the selection mechanic together with tag from NavigationLink. Week is a custom model class:
struct Week: Identifiable, Hashable, Equatable {
var id = UUID()
var days: [Day] = []
var name: String
}
And UserSettings looks like this:
class UserSettings: ObservableObject {
#Published var weeks: [Week] = [
Week(name: "test week 1"),
Week(name: "foobar"),
Week(name: "hello world")
]
#Published var selectedWeek: Week? = UserDefaults.standard.object(forKey: "week.selected") as? Week {
didSet {
var a = oldValue
var b = selectedWeek
UserDefaults.standard.set(selectedWeek, forKey: "week.selected")
}
}
}
My goal is to directly store the value from List selection in UserDefaults. The didSet property gets executed, but the variable is always nil. For some reason the selected List value can't be stored in the published / bindable variable.
Why is $settings.selectedWeek always nil?
A couple of suggestions:
SwiftUI (specifically on macOS) is unreliable/unpredictable with certain List behaviors. One of them is selection -- there are a number of things that either completely don't work or at best are slightly broken that work fine with the equivalent iOS code. The good news is that NavigationLink and isActive works like a selection in a list -- I'll use that in my example.
#Published didSet may work in certain situations, but that's another thing that you shouldn't rely on. The property wrapper aspect makes it behave differently than one might except (search SO for "#Published didSet" to see a reasonable number of issues dealing with it). The good news is that you can use Combine to recreate the behavior and do it in a safer/more-reliable way.
A logic error in the code:
You are storing a Week in your user defaults with a certain UUID. However, you regenerate the array of weeks dynamically on every launch, guaranteeing that their UUIDs will be different. You need to store your week's along with your selection if you want to maintain them from launch to launch.
Here's a working example which I'll point out a few things about below:
import SwiftUI
import Combine
struct ContentView : View {
var body: some View {
NavigationView {
MenuView().environmentObject(UserSettings())
}
}
}
class UserSettings: ObservableObject {
#Published var weeks: [Week] = []
#Published var selectedWeek: UUID? = nil
private var cancellable : AnyCancellable?
private var initialItems = [
Week(name: "test week 1"),
Week(name: "foobar"),
Week(name: "hello world")
]
init() {
let decoder = PropertyListDecoder()
if let data = UserDefaults.standard.data(forKey: "weeks") {
weeks = (try? decoder.decode([Week].self, from: data)) ?? initialItems
} else {
weeks = initialItems
}
if let prevValue = UserDefaults.standard.string(forKey: "week.selected.id") {
selectedWeek = UUID(uuidString: prevValue)
print("Set selection to: \(prevValue)")
}
cancellable = $selectedWeek.sink {
if let id = $0?.uuidString {
UserDefaults.standard.set(id, forKey: "week.selected.id")
let encoder = PropertyListEncoder()
if let encoded = try? encoder.encode(self.weeks) {
UserDefaults.standard.set(encoded, forKey: "weeks")
}
}
}
}
func selectionBindingForId(id: UUID) -> Binding<Bool> {
Binding<Bool> { () -> Bool in
self.selectedWeek == id
} set: { (newValue) in
if newValue {
self.selectedWeek = id
}
}
}
}
//Unknown what you have in here
struct Day : Equatable, Hashable, Codable {
}
struct Week: Identifiable, Hashable, Equatable, Codable {
var id = UUID()
var days: [Day] = []
var name: String
}
struct WeekView : View {
var week : Week
var body: some View {
Text("Week: \(week.name)")
}
}
struct MenuView: View {
#EnvironmentObject var settings: UserSettings
var body: some View {
List {
ForEach(settings.weeks) { week in
NavigationLink(
destination: WeekView(week: week)
.environmentObject(settings),
isActive: settings.selectionBindingForId(id: week.id)
)
{
Image(systemName: "circle")
Text("\(week.name)")
}
}
.onDelete { set in
settings.weeks.remove(atOffsets: set)
}
.onMove { set, i in
settings.weeks.move(fromOffsets: set, toOffset: i)
}
}
.navigationTitle("Weekplans")
.listStyle(SidebarListStyle())
}
}
In UserSettings.init the weeks are loaded if they've been saved before (guaranteeing the same IDs)
Use Combine on $selectedWeek instead of didSet. I only store the ID, since it seems a little pointless to store the whole Week struct, but you could alter that
I create a dynamic binding for the NavigationLinks isActive property -- the link is active if the stored selectedWeek is the same as the NavigationLink's week ID.
Beyond those things, it's mostly the same as your code. I don't use selection on List, just isActive on the NavigationLink
I didn't implement storing the Week again if you did the onMove or onDelete, so you would have to implement that.
Bumped into a situation like this where multiple item selection didn't work on macOS. Here's what I think is happening and how to workaround it and get it working
Background
So on macOS NavigationLinks embedded in a List render their Destination in a detail view (by default anyway). e.g.
struct ContentView: View {
let beatles = ["John", "Paul", "Ringo", "George", "Pete"]
#State var listSelection = Set<String>()
var body: some View {
NavigationView {
List(beatles, id: \.self, selection: $listSelection) { name in
NavigationLink(name) {
Text("Some details about \(name)")
}
}
}
}
}
Renders like so
Problem
When NavigationLinks are used it is impossible to select multiple items in the sidebar (at least as of Xcode 13 beta4).
... but it works fine if just Text elements are used without any NavigationLink embedding.
What's happening
The detail view can only show one NavigationLink View at a time and somewhere in the code (possibly NavigationView) there is piece of code that is enforcing that compliance by stomping on multiple selection and setting it to nil, e.g.
let selectionBinding = Binding {
backingVal
} set: { newVal in
guard newVal <= 1 else {
backingVal = nil
return
}
backingVal = newVal
}
What happens in these case is to the best of my knowledge not defined. With some Views such as TextField it goes out of sync with it's original Source of Truth (for more), while with others, as here it respects it.
Workaround/Fix
Previously I suggested using a ZStack to get around the problem, which works, but is over complicated.
Instead the idiomatic option for macOS, as spotted on the Lost Moa blog post is to not use NaviationLink at all.
It turns out that just placing sidebar and detail Views adjacent to each other and using binding is enough for NavigationView to understand how to render and stops it stomping on multiple item selections. Example shown below:
struct ContentView: View {
let beatles = ["John", "Paul", "Ringo", "George", "Pete"]
#State var listSelection: Set<String> = []
var body: some View {
NavigationView {
SideBar(items: beatles, selection: $listSelection)
Detail(ids: listSelection)
}
}
struct SideBar: View {
let items: Array<String>
#Binding var selection: Set<String>
var body: some View {
List(items, id: \.self, selection: $selection) { name in
Text(name)
}
}
}
struct Detail: View {
let ids: Set<String>
var detailsMsg: String {
ids.count == 1 ? "Would show details for \(ids.first)"
: ids.count > 1 ? "Too many items selected"
: "Nothing selected"
}
var body: some View {
Text(detailsMsg)
}
}
}
Have fun.

Reload aync call on view state update

I have the following view:
struct SpriteView: View {
#Binding var name: String
#State var sprite: Image = Image(systemName: "exclamationmark")
var body: some View {
VStack{
sprite
}
.onAppear(perform: loadSprite)
}
func loadSprite() {
// async function
getSpriteFromNetwork(self.name){ result in
switch result {
// async callback
case .success(newSprite):
self.sprite = newSprite
}
}
}
What I want to happen is pretty simple: a user modifies name in text field (from parent view), which reloads SpriteView with the new sprite. But the above view doesn't work since when the view is reloaded with the new name, loadSprite isn't called again (onAppear only fires when the view is first loaded). I also can't put loadSprite in the view itself (and have it return an image) since it'll lead to an infinite loop.
There is a beta function onChange that is exactly what I'm looking for, but it's only in the beta version of Xcode. Since Combine is all about async callbacks and SwiftUI and Combine are supposed to play well together, I thought this sort of behavior would be trivial to implement but I've been having a lot of trouble with it.
I don't particular like this solution since it requires creating a new ObservableObject but this how I ended up doing it:
class SpriteLoader: ObservableObject {
#Published var sprite: Image = Image(systemName: "exclamationmark")
func loadSprite(name: String) {
// async function
self.sprite = Image(systemName: "arrow.right")
}
}
struct ParentView: View {
#State var name: String
#State var spriteLoader = SpriteLoader()
var body: some View {
SpriteView(spriteLoader: spriteLoader)
TextField(name, text: $name, onCommit: {
spriteLoader.loadSprite(name: name)
})
}
}
struct SpriteView: View {
#ObservedObject var spriteLoader: SpriteLoader
var body: some View {
VStack{
spriteLoader.sprite
}
}
}
Old answer:
I think the best way to do this is as follows:
Parent view:
struct ParentView: View {
#State var name: String
#State spriteView = SpriteView()
var body: some View {
spriteView
TextField(value: $name, onCommit: {
spriteView.loadSprite(name)
})
}
And then the sprite view won't even need the #Binding name member.

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).

SwiftUI: How to iterate over an array of bindable objects?

I'm trying to learn SwiftUI, and how bindings work.
I have this code that works, that shows a list of projects. When one project is tapped, a binding to that project is passed to the child view:
struct ProjectsView: View {
#ObjectBinding var state: AppState
#State var projectName: String = ""
var body: some View {
NavigationView {
List {
ForEach(0..<state.projects.count) { index in
NavigationLink(destination: ProjectView(project: self.$state.projects[index])) {
Text(self.state.projects[index].title)
}
}
}
.navigationBarTitle("Projects")
}
}
}
The child view, where I'm mutating the project using a binding:
struct ProjectView: View {
#Binding var project: Project
#State var projectName: String = ""
var body: some View {
VStack {
Text(project.title)
TextField(
$projectName,
placeholder: Text("Change project name"),
onCommit: {
self.project.title = self.projectName
self.projectName = ""
})
.padding()
}
}
}
However, I would rather iterate over the projects array without using indexes (beacuse I want to learn, and its easier to read), but not sure how I then can pass the binding to a single project. I tried it like this, but then I can't get access to project.title, since it's a binding, and not a String.
ForEach($state.projects) { project in
NavigationLink(destination: ProjectView(project: project)) {
Text(project.title)
}
}
How can I achieve this?
Note: I use Xcode 11.2, #ObjectBinding is obsoleted in it (so you need to update to verify below code).
I asked about model, because it might matter for approach. For similar functionality I preferred Combine's ObservableObject, so model is reference not value types.
Here is the approach, which I tune for your scenario. It is not exactly as you requested, because ForEach requires some sequence, but you try to feed it with unsupported type.
Anyway you may consider below just as alternate (and it is w/o indexes). It is complete module so you can paste it in Xcode 11.2 and test in preview. Hope it would be helpful somehow.
Preview:
Solution:
import SwiftUI
import Combine
class Project: ObservableObject, Identifiable {
var id: String = UUID().uuidString
#Published var title: String = ""
init (title: String) {
self.title = title
}
}
class AppState: ObservableObject {
#Published var projects: [Project] = []
init(_ projects: [Project]) {
self.projects = projects
}
}
struct ProjectView: View {
#ObservedObject var project: Project
#State var projectName: String = ""
var body: some View {
VStack {
Text(project.title)
TextField("Change project name",
text: $projectName,
onCommit: {
self.project.title = self.projectName
self.projectName = ""
})
.padding()
}
}
}
struct ContentView: View {
#ObservedObject var state: AppState = AppState([Project(title: "1"), Project(title: "2")])
#State private var refreshed = false
var body: some View {
NavigationView {
List {
ForEach(state.projects) { project in
NavigationLink(destination: ProjectView(project: project)) {
// !!! existance of .refreshed state property somewhere in ViewBuilder
// is important to inavidate view, so below is just a demo
Text("Named: \(self.refreshed ? project.title : project.title)")
}
.onReceive(project.$title) { _ in
self.refreshed.toggle()
}
}
}
.navigationBarTitle("Projects")
.navigationBarItems(trailing: Button(action: {
self.state.projects.append(Project(title: "Unknown"))
}) {
Text("New")
})
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
I'm sort of stuck on the same issue you are, and I found a partial solution. But first I should point out that iterating over the index with ForEach(0..<state.projects.count) { index in ... } is not a good idea because index is an Int, which does not conform to Identifiable. Because of that, the UI will not update when your array changes, and you'll see a warning in the console.
My solution directly accesses the state.projects array when creating ProjectView and using firstIndex(of:) to get a bindable form of the project element. It's kind of icky but it's as far as I could get to making it more SwiftUI-y.
ForEach(state.projects) { project in
NavigationLink(destination: ProjectView(project: self.$state.projects[self.state.projects.firstIndex(of: project)!]))) {
Text(project.title)
}
}
I've found this works:
In your AppState, when you add a project, observe its changes:
import Combine
class AppState: ObservableObject {
#Published var projects: [Project]
var futures = Set<AnyCancellable>()
func addProject(project: Project) {
project.objectWillChange
.sink {_ in
self.objectWillChange.send()
}
.store(in: &futures)
}
...
}
If you ever need to create a binding for a project var in your outer view, do it like this:
func titleBinding(forProject project: Project) -> Binding<String> {
Binding {
project.title
} set: { newValue in
project.title = newValue
}
}
You shouldn't need it if you are passing the project into another view, though.
Unfortunately this doesn't seem to be possible at this time. The only way to achieve this is like the following:
ForEach(state.projects.indices) { index in
NavigationLink(destination: ProjectView(project: state.projects[index])) {
Text(state.projects[index].title)
}
}
NOTE: I didn't compile this code. This is just to give you the gesture for how to go about it. i.e. use and index of type Index and not Int.