Handling derived state in SwiftUI - swift

say I am creating an "Date Editor" view. The goal is:
- Take a default, seed date.
- It lets the user alter the input.
- If the user then chooses, they can press "Save", in which case the owner of the view can decide to do something with the data.
Here's one way to implement it:
struct AlarmEditor : View {
var seedDate : Date
var handleSave : (Date) -> Void
#State var editingDate : Date?
var body : some View {
let dateBinding : Binding<Date> = Binding(
get: {
return self.editingDate ?? seedDate
},
set: { date in
self.editingDate = date
}
)
return VStack {
DatePicker(
selection: dateBinding,
displayedComponents: .hourAndMinute,
label: { Text("Date") }
)
Spacer()
Button(action: {
self.handleSave(dateBinding.wrappedValue)
}) {
Text("Save").font(.headline).bold()
}
}
}
}
The Problem
What if the owner changes the value of seedDate?
Say in that case, what I wanted to do was to reset the value of editingDate to the new seedDate.
What would be an idiomatic way of doing this?

I'm not sure that I have understand the purpose of the seedDate here. But I think you are relying on events (kind of UIKit way) a bit too much instead of the single source of truth principle (the SwiftUI way).
Update: Added a way to cancel the date edition.
In that case, the editor view should mutate the Binding only when saving. To do so, it uses a private State that will be used for the date picker. This way, the source of truth is preserved as the private state used will never leave the context of the editing view.
struct ContentView: View {
#State var dateEditorVisible = false
#State var date: Date = Date() // source of truth
var body: some View {
NavigationView {
VStack {
Text("\(date.format("HH:mm:ss"))")
Button(action: self.showDateEditor) {
Text("Edit")
}
.sheet(isPresented: $dateEditorVisible) {
// Here we provide a two way binding to the `date` state
// and a way to dismiss the editor view.
DateEditorView(date: self.$date, dismiss: self.hideDateEditor)
}
}
}
}
func showDateEditor() {
dateEditorVisible = true
}
func hideDateEditor() {
dateEditorVisible = false
}
}
struct DateEditorView: View {
// Only a binding.
// Updating this value will update the `#State date` of the parent view
#Binding var date: Date
#State private var editingDate: Date = Date()
private var dismiss: () -> Void
init(date: Binding<Date>, dismiss: #escaping () -> Void) {
self._date = date
self.dismiss = dismiss
// assign the wrapped value as default value for edition
self.editingDate = date.wrappedValue
}
var body: some View {
VStack {
DatePicker(selection: $editingDate, displayedComponents: .hourAndMinute) {
Text("Date")
}
HStack {
Button(action: self.save) {
Text("Save")
}
Button(action: self.dismiss) {
Text("Cancel")
}
}
}
}
func save() {
date = editingDate
dismiss()
}
}
With this way, you don't need to define a save action to update the parent view or keep in sync the current value with some default value. You only have a single source of truth that drives all of your UI.
Edit:
The Date extension to make it build.
extension Date {
private static let formater = DateFormatter()
func format(_ format: String) -> String {
Self.formater.dateFormat = format
return Self.formater.string(from: self)
}
}

I would prefer to do this via explicitly used ViewModel for such editor, and it requires minimal modifications in your code. Here is possible approach (tested & worked with Xcode 11.2.1):
Testing parent
struct TestAlarmEditor: View {
private var editorModel = AlarmEditorViewModel()
var body: some View {
VStack {
AlarmEditor(viewModel: self.editorModel, handleSave: {_ in }, editingDate: nil)
Button("Reset") {
self.editorModel.seedDate = Date(timeIntervalSinceNow: 60 * 60)
}
}
}
}
Simple view model for editor
class AlarmEditorViewModel: ObservableObject {
#Published var seedDate = Date() // << can be any or set via init
}
Updated editor
struct AlarmEditor : View {
#ObservedObject var viewModel : AlarmEditorViewModel
var handleSave : (Date) -> Void
#State var editingDate : Date?
var body : some View {
let dateBinding : Binding<Date> = Binding(
get: {
return self.editingDate ?? self.viewModel.seedDate
},
set: { date in
self.editingDate = date
}
)
return VStack {
DatePicker(
selection: dateBinding,
displayedComponents: .hourAndMinute,
label: { Text("Date") }
)
.onReceive(self.viewModel.$seedDate, perform: {
self.editingDate = $0 }) // << reset here
Spacer()
Button(action: {
self.handleSave(dateBinding.wrappedValue)
}) {
Text("Save").font(.headline).bold()
}
}
}
}

Comment and warning
Basically, this question amounts to looking for a replacement for didSet on the OP's var seedDate.
I used one of my support requests with Apple on this same question a few months ago. The latest response from them was that they have received several questions like this, but they don't have a "good" solution yet. I shared the solution below and they answered "Since it's working, use it."
What follows below is quite "smelly" but it does work. Hopefully we'll see improvements in iOS 14 that remove the necessity for something like this.
Concept
We can take advantage of the fact that body is the only entrance point for view rendering. Therefore, we can track changes to our view's inputs over time and change internal state based on that. We just have to be careful about how we update things so that SwiftUI's idea of State is not modified incorrectly.
We can do this by using a struct that contains two reference values:
The value we want to track
The value we want to modify when #1 changes
If we want SwiftUI to update we replace the reference value. If we want to update based on changes to #1 inside the body, we update the value held by the reference value.
Implementation
Gist here
First, we want to wrap any value in a reference type. This allows us to save a value without triggering SwiftUI's update mechanisms.
// A class that lets us wrap any value in a reference type
class ValueHolder<Value> {
init(_ value: Value) { self.value = value }
var value: Value
}
Now, if we declare #State var valueHolder = ValueHolder(0) we can do:
Button("Tap me") {
self.valueHolder.value = 0 // **Doesn't** trigger SwiftUI update
self.valueHolder = ValueHolder(0) // **Does** trigger SwiftUI update
}
Second, create a property wrapper that holds two of these, one for our external input value, and one for our internal state.
See this answer for an explanation of why I use State in the property wrapper.
// A property wrapper that holds a tracked value, and a value we'd like to update when that value changes.
#propertyWrapper
struct TrackedValue<Tracked, Value>: DynamicProperty {
var trackedHolder: State<ValueHolder<Tracked>>
var valueHolder: State<ValueHolder<Value>>
init(wrappedValue value: Value, tracked: Tracked) {
self.trackedHolder = State(initialValue: ValueHolder(tracked))
self.valueHolder = State(initialValue: ValueHolder(value))
}
var wrappedValue: Value {
get { self.valueHolder.wrappedValue.value }
nonmutating set { self.valueHolder.wrappedValue = ValueHolder(newValue) }
}
var projectedValue: Self { return self }
}
And finally add a convenience method to let us efficiently update when we need to. Since this returns a View you can use it inside of any ViewBuilder.
extension TrackedValue {
#discardableResult
public func update(tracked: Tracked, with block:(Tracked, Value) -> Value) -> some View {
self.valueHolder.wrappedValue.value = block(self.trackedHolder.wrappedValue.value, self.valueHolder.wrappedValue.value)
self.trackedHolder.wrappedValue.value = tracked
return EmptyView()
}
}
Usage
If you run the below code, childCount will reset to 0 every time masterCount changes.
struct ContentView: View {
#State var count: Int = 0
var body: some View {
VStack {
Button("Master Count: \(self.count)") {
self.count += 1
}
ChildView(masterCount: self.count)
}
}
}
struct ChildView: View {
var masterCount: Int
#TrackedValue(tracked: 0) var childCount: Int = 0
var body: some View {
self.$childCount.update(tracked: self.masterCount) { (old, myCount) -> Int in
if self.masterCount != old {
return 0
}
return myCount
}
return Button("Child Count: \(self.childCount)") {
self.childCount += 1
}
}
}

following your code, I would do something like this.
struct AlarmEditor: View {
var handleSave : (Date) -> Void
#State var editingDate : Date
init(seedDate: Date, handleSave: #escaping (Date) -> Void) {
self._editingDate = State(initialValue: seedDate)
self.handleSave = handleSave
}
var body: some View {
Form {
DatePicker(
selection: $editingDate,
displayedComponents: .hourAndMinute,
label: { Text("Date") }
)
Spacer()
Button(action: {
self.handleSave(self.editingDate)
}) {
Text("Save").font(.headline).bold()
}
}
}//body
}//AlarmEditor
struct AlarmEditor_Previews: PreviewProvider {
static var previews: some View {
AlarmEditor(seedDate: Date()) { editingDate in
print(editingDate.description)
}
}
}
And, use it like this elsewhere.
AlarmEditor(seedDate: Date()) { editingDate in
//do anything you want with editingDate
print(editingDate.description)
}
this is my sample output:
2020-02-07 23:39:42 +0000
2020-02-07 22:39:42 +0000
2020-02-07 23:39:42 +0000
2020-02-07 21:39:42 +0000
what do you think? 50 points

Related

SwiftUI: How to update element in ForEach without necessity to update all elements?

Imagine that you have some parent view that generate some number of child views:
struct CustomParent: View {
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index)
}
}
}
}
struct CustomChild: View {
#State var index: Int
#State private var text: String = ""
var body: some View {
Button(action: {
// Here should be some update of background/text/opacity or whatever.
// So how can I update background/text/opacity or whatever for button with index for example 3 from button with index for example 1?
}) {
Text(text)
}
.onAppear {
text = String(index)
}
}
}
Question is included in the code as comment.
Thanks!
UPDATE:
First of all really thanks for all of your answers, but now imagine that you use mentioned advanced approach.
struct CustomParent: View {
#StateObject var customViewModel = CustomViewModel()
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, customViewModel: customViewModel)
}
}
}
}
If I use let _ = Self._printChanges() method in CustomChildView, to catch UI updates/changes, it'll print that every element in ForEach was updated/changed on button action.
struct CustomChild: View {
let index: Int
#ObservedObject var customViewModel: CustomViewModel
var body: some View {
let _ = Self._printChanges() // This have been added to code
Button(action: {
customViewModel.buttonPushed(at: index)
}) {
Text(customViewModel.childTexts[index])
}
}
}
class CustomViewModel: ObservableObject {
#Published var childTexts = [String](repeating: "", count: 10)
init() {
for i in 0..<childTexts.count {
childTexts[i] = String(i)
}
}
func buttonPushed(at index: Int) {
//button behaviors goes here
//for example:
childTexts[index + 1] = "A"
}
}
And now imagine that you have for example 1000 custom elements which have some background, opacity, shadow, texts, fonts and so on. Now I change text in any of the elements.
Based on log from let _ = Self._printChanges() method, it goes through all elements, and all elements are updated/changed what can cause delay.
Q1: Why did update/change all elements, if I change text in only one element?
Q2: How can I prevent update/change all elements, if I change only one?
Q3: How to update element in ForEach without necessity to update all elements?
Simpler Approach:
Although child views cannot access things that the host views have, it's possible to declare the child states in the host view and pass that state as a binding variable to the child view. In the code below, I have passed the childTexts variable to the child view, and (for your convenience) initialized the text so that it binds to the original element in the array (so that your onAppear works properly). Every change performed on the text and childTexts variable inside the child view reflects on the host view.
I strongly suggest not to do this though, as more elegant approaches exist.
struct CustomParent: View {
#State var childTexts = [String](repeating: "", count: 10)
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, childTexts: $childTexts)
}
}
}
}
struct CustomChild: View {
let index: Int
#Binding private var text: String
#Binding private var childTexts: [String]
init(index: Int, childTexts: Binding<[String]>) {
self.index = index
self._childTexts = childTexts
self._text = childTexts[index]
}
var body: some View {
Button(action: {
//button behaviors goes here
//for example
childTexts[index + 1] = "A"
}) {
Text(text)
}
.onAppear {
text = String(index)
}
}
}
Advanced Approach:
By using the Combine framework, all your logics can be moved into an ObservableObject view model. This is much better as the button logic is no longer inside the view. In simplest terms, the #Published variable in the ObservableObject will publish a change when it senses its own mutation, while the #StateObjectand the #ObservedObject will listen and recalculate the view for you.
struct CustomParent: View {
#StateObject var customViewModel = CustomViewModel()
var body: some View {
HStack {
ForEach(0..<10, id: \.self) { index in
CustomChild(index: index, customViewModel: customViewModel)
}
}
}
}
struct CustomChild: View {
let index: Int
#ObservedObject var customViewModel: CustomViewModel
var body: some View {
Button(action: {
customViewModel.buttonPushed(at: index)
}) {
Text(customViewModel.childTexts[index])
}
}
}
class CustomViewModel: ObservableObject {
#Published var childTexts = [String](repeating: "", count: 10)
init() {
for i in 0..<childTexts.count {
childTexts[i] = String(i)
}
}
func buttonPushed(at index: Int) {
//button behaviors goes here
//for example:
childTexts[index + 1] = "A"
}
}

SwiftUI: How to make #StateObject ViewModel update when host View's state variable changes

I have a View that displays a grid of images. The View has a #StateObject variable for its ViewModel, and a #State variable that stores the images. (My image index is in Core Data, and the #Query property wrapper comes from this post. TLDR: it stores the fetch results from Core Data, and when they change, it updates the View like a #State var.)
When the fetch results update, I need the ViewModel to know that this happened, so it can rebuild its index for use in my ForEach. But I haven't found a way to hook those events together. I have verified that indexAssets is updating by manually refreshing the ViewModel on tap. What I need is for that refresh to happen automatically when indexAssets changes.
PhotoGridView.swift
struct PhotoGridView: View {
#Query(.all) var indexAssets: QueryResults<IndexAsset>
#StateObject var vm = PhotoGridViewModel()
func updateVm() {
vm.createIndex(indexAssets)
}
var body: some View {
return GeometryReader { geo in
VStack {
HStack {
Text("\(indexAssets.count) assets")
Spacer()
TrashView()
}.padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
ScrollView {
ForEach(vm.sortedKeys, id: \.self) { key in
let indexAssets = vm.index[key]
let date = indexAssets?.first?.creationDate
GridSectionView(titleDate:date, indexAssets:$vm.index[key], geoSize: geo.size)
}
}
}.onAppear {
updateVm()
}.onTapGesture {
updateVm()
}
.navigationDestination(for: IndexAsset.self) { indexAsset in
AssetDetailView(indexAsset: indexAsset)
}
}
}
}
PhotoGridViewModel.swift
class PhotoGridViewModel: ObservableObject {
#Published var index: [String:[IndexAsset]] = [:]
#Published var indexAssets: QueryResults<IndexAsset>?
#Published var sortedKeys : [String] = []
func createIndex() {
guard let assets = self.indexAssets else {return}
self.createIndex(assets)
}
func createIndex(_ queryResults: QueryResults<IndexAsset>) -> [String:[IndexAsset]]? {
indexAssets = queryResults
if queryResults.count > 0 {
var lastDate = Date.distantFuture
var newIndex = [String:[IndexAsset]]()
for i in 0..<queryResults.count {
let item = queryResults[i]
let isSameDay = isSameDay(firstDate: lastDate, secondDate: item.creationDate!)
if isSameDay {
newIndex[item.creationDateKey!]?.append(item)
} else {
newIndex[item.creationDateKey!] = [item]
}
lastDate = item.creationDate!
}
self.index = newIndex
self.sortedKeys = newIndex.keys.sorted().reversed()
return newIndex
}
return nil
}
private func isSameDay(firstDate:Date, secondDate:Date) -> Bool {
return Calendar.current.isDate(
firstDate,
equalTo: secondDate,
toGranularity: .day
)
}
}
(I didn't include Query because it's long, and it's in the post linked above. But also: the TLDR is that it stores the latest fetch results from Core Data, and when they change, it updates the View like a #State variable.)
Thanks y'all
You need to remove the view model object (we don't use those in SwiftUI) and use #FetchRequest for Core data which makes the View struct behave like an object.
You also need to remove the id: \.self because that crashes the ForEach View, you need to supply a unique identifier key on the data not the data itself.
Move the indexing into your Core Data model. And update it when you make a change.

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.

How to use SwiftUI and Combine to propagate state changes of child View to parent View?

How can I, using SwiftUI and Combine, have a state of the uppermost View depend on a state of its contained SubView, determined by criteria among others dependent on its contained SubSubView?
The scenario
I have the following View hierarchy: V1 contains V2, which contains V3.
V1 is a general, mostly decorative, 'wrapper' of a specific settings view V2 and holds a "Save" button. The button's disabled state of type Bool should depend on the save-ability state of V2.
V2 is a specific settings view. Which type of V2, the specific settings shown, may differ depending on the rest of my program. It is guaranteed to be able to determine its save-ability. It contains a Toggle and V3, a MusicPicker. V2's save-ability is dependent on criteria processing V3's selection-state and its Toggle-state.
V3 is a general 'MusicPicker' view with a selection-state of type Int?. It could be used with any parent, communicating bidirectionally its selection-state.
A Binding should normally be used to communicate back and forth between 2 views. As such, there could be a binding between V1 and V2 and V2 and V3. However, V2 cannot/should not react to a binding's value change of V3 and communicate this (along with other criteria) back to V1, as far as I know/understand. I may use ObservableObjects to share a save-ability with V1 and V2 and to share a selection-state with V2 and V3, but it is unclear to me how to integrate V3's ObservableObject changes with other criteria to set V1's ObservableObject.
The examples
Using #State and #Binding
/* V1 */
struct SettingsView: View {
#State var saveable = false
var body: some View {
VStack {
Button(action: saveAction){
Text("Save")
}.disabled(!saveable)
getSpecificV2(saveable: $saveable)
}
}
func getSpecificV2(saveable: Binding<Bool>) -> AnyView {
// [Determining logic...]
return AnyView(SpecificSettingsView(saveable: saveable))
}
func saveAction(){
// More code...
}
}
/* V2 */
struct SpecificSettingsView: View {
#Binding var saveable: Bool
#State var toggled = false
#State var selectedValue: Int?
var body: some View {
Form {
Toggle("Toggle me", isOn: $toggled)
CustomPicker(selected: $selectedValue)
}
}
func someCriteriaProcess() -> Bool {
if let selected = selectedValue {
return (selected == 5)
} else {
return toggled
}
}
}
/* V3 */
struct CustomPicker: View {
#Binding var selected: Int?
var body: some View {
List {
Text("None")
.onTapGesture {
self.selected = nil
}.foregroundColor(selected == nil ? .blue : .primary)
Text("One")
.onTapGesture {
self.selected = 1
}.foregroundColor(selected == 1 ? .blue : .primary)
Text("Two")
.onTapGesture {
self.selected = 2
}.foregroundColor(selected == 2 ? .blue : .primary)
}
}
}
In this example code, I would need to essentially have saveable be dependent on someCriteriaProcess().
Using ObservableObject
In response to Tobias' answer, a possible alternative would be to use ObservableObjects.
/* V1 */
class SettingsStore: ObservableObject {
#Published var saveable = false
}
struct SettingsView: View {
#ObservedObject var store = SettingsStore()
var body: some View {
VStack {
Button(action: saveAction){
Text("Save")
}.disabled(!store.saveable)
getSpecificV2()
}.environmentObject(store)
}
func getSpecificV2() -> AnyView {
// [Determining logic...]
return AnyView(SpecificSettingsView())
}
func saveAction(){
// More code...
}
}
/* V2 */
struct SpecificSettingsView: View {
#EnvironmentObject var settingsStore: SettingsStore
#ObservedObject var pickerStore = PickerStore()
#State var toggled = false
#State var selectedValue: Int?
var body: some View {
Form {
Toggle("Toggle me", isOn: $toggled)
CustomPicker(store: pickerStore)
}.onReceive(pickerStore.objectWillChange){ selected in
print("Called for selected: \(selected ?? -1)")
self.settingsStore.saveable = self.someCriteriaProcess()
}
}
func someCriteriaProcess() -> Bool {
if let selected = selectedValue {
return (selected == 5)
} else {
return toggled
}
}
}
/* V3 */
class PickerStore: ObservableObject {
public let objectWillChange = PassthroughSubject<Int?, Never>()
var selected: Int? {
willSet {
objectWillChange.send(newValue)
}
}
}
struct CustomPicker: View {
#ObservedObject var store: PickerStore
var body: some View {
List {
Text("None")
.onTapGesture {
self.store.selected = nil
}.foregroundColor(store.selected == nil ? .blue : .primary)
Text("One")
.onTapGesture {
self.store.selected = 1
}.foregroundColor(store.selected == 1 ? .blue : .primary)
Text("Two")
.onTapGesture {
self.store.selected = 2
}.foregroundColor(store.selected == 2 ? .blue : .primary)
}
}
}
Using the onReceive() attachment, I try to react to any changes of the PickerStore. Although the action fires and the debug prints correctly, no UI change is shown.
The question
What is (in this scenario) the most appropriate approach to react to a change in V3, process this with other states in V2, and correspondingly change a state of V1, using SwiftUI and Combine?
Posting this answer on the premise of the approach with ObservableObject that is added on your question itself.
Look carefully. As soon as the code:
.onReceive(pickerStore.objectWillChange){ selected in
print("Called for selected: \(selected ?? -1)")
self.settingsStore.saveable = self.someCriteriaProcess()
}
runs in the SpecificSettingsView the settingsStore is about to change which triggers the parent SettingsView to refresh its associated view components. That means the func getSpecificV2() -> AnyView will return SpecificSettingsView object that in turns will instantiate the PickerStore again. Because,
SwiftUI views, being value type (as they are struct), will not retain your objects within their view scope if the view is recreated by a parent view, for example. So it’s best to pass those observable objects by reference and have a sort of container view, or holder class, which will instantiate and reference those objects. If the view is the only owner of this object, and that view is recreated because its parent view is updated by SwiftUI, you’ll lose the current state of your ObservedObject.
(Read More on the above)
If you just push the instantiation of the PickerStore higher in the view hierarchy (probably the ultimate parent) you will get the expected behavior.
struct SettingsView: View {
#ObservedObject var store = SettingsStore()
#ObservedObject var pickerStore = PickerStore()
. . .
func getSpecificV2() -> AnyView {
// [Determining logic...]
return AnyView(SpecificSettingsView(pickerStore: pickerStore))
}
. . .
}
struct SpecificSettingsView: View {
#EnvironmentObject var settingsStore: SettingsStore
#ObservedObject var pickerStore: PickerStore
. . .
}
Note: I uploaded the project at remote repository here
Because SwiftUI doesn't support refreshing Views on changes inside a nested ObservableObject, you need to do this manually. I posted a solution here on how to do this:
https://stackoverflow.com/a/58996712/12378791 (e.g. with ObservedObject)
https://stackoverflow.com/a/58878219/12378791 (e.g. with EnvironmentObject)
I have figured out a working approach with the same end result, that may be useful to others. It does not, however, pass data in the way I requested in my question, but SwiftUI does not seem suitable to do so in any case.
As V2, the 'middle' view, can properly access both important states, that of the selection and save-ability, I realised I could make V2 the parent view and have V1, initially the 'parent' view, be a child view accepting #ViewBuilder content instead. This example would not be applicable to all cases, but it would to mine.
A working example is as follows.
/* V2 */
struct SpecificSettingsView: View {
#State var toggled = false
#State var selected: Int?
var saveable: Bool {
return someCriteriaProcess()
}
var body: some View {
SettingsView(isSaveable: self.saveable, onSave: saveAction){
Form {
Toggle("Toggle me", isOn: self.$toggled)
CustomPicker(selected: self.$selected)
}
}
}
func someCriteriaProcess() -> Bool {
if let selected = selected {
return (selected == 2)
} else {
return toggled
}
}
func saveAction(){
guard saveable else { return }
// More code...
}
}
/* V1 */
struct SettingsView<Content>: View where Content: View {
var content: () -> Content
var saveAction: () -> Void
var saveable: Bool
init(isSaveable saveable: Bool, onSave saveAction: #escaping () -> Void, #ViewBuilder content: #escaping () -> Content){
self.saveable = saveable
self.saveAction = saveAction
self.content = content
}
var body: some View {
VStack {
// More decoration
Button(action: saveAction){
Text("Save")
}.disabled(!saveable)
content()
}
}
}
/* V3 */
struct CustomPicker: View {
#Binding var selected: Int?
var body: some View {
List {
Text("None")
.onTapGesture {
self.selected = nil
}.foregroundColor(selected == nil ? .blue : .primary)
Text("One")
.onTapGesture {
self.selected = 1
}.foregroundColor(selected == 1 ? .blue : .primary)
Text("Two")
.onTapGesture {
self.selected = 2
}.foregroundColor(selected == 2 ? .blue : .primary)
}
}
}
I hope this proves useful to others.

'Fatal error: index out of range' when deleting bound object in view

I am having some trouble avoiding index out of range errors when modifying an array that a child view depends on a bound object from.
I have a parent view called WorkoutList. WorkoutList has an EnvironmentObject of ActiveWorkoutStore. ActiveWorkoutStore is an ObservableObject that has an array of Workout objects. I have a list of active workouts being retrieved from ActiveWorkoutStore. I'm using a ForEach loop to work with the indices of these active workouts and pass an object binding to a child view called EditWorkout as a destination for a NavigationLink. EditWorkout has a button to finish a workout, which removes it from ActiveWorkoutStore's array of workouts and adds it to WorkoutHistoryStore. I'm running into trouble when I remove this object from ActiveWorkoutStore's activeWorkouts array, immediately causing an index out of range error. I'm suspecting this is because the active view relies on a bound object that I've just deleted. I've tried a couple permutations of this, including passing a workout to EditWorkout, then using its id to reference a workout in ActiveWorkoutStore to perform my operations, but run into similar troubles. I've seen a lot of examples online that follow this pattern of leveraging ForEach to iterate over indices and I've mirrored it as best I can tell, but I suspect I may be missing a nuance to the approach.
I've attached code samples below. Let me know if you have any questions or if there's anything else I should include! Thanks in advance for your help!
WorkoutList (Parent View)
import SwiftUI
struct WorkoutList: View {
#EnvironmentObject var activeWorkoutsStore: ActiveWorkoutStore
#State private var addExercise = false
#State private var workoutInProgress = false
var newWorkoutButton: some View {
Button(action: {
self.activeWorkoutsStore.newActiveWorkout()
}) {
Text("New Workout")
Image(systemName: "plus.circle")
}
}
var body: some View {
NavigationView {
Group {
if activeWorkoutsStore.activeWorkouts.isEmpty {
Text("No active workouts")
} else {
List {
ForEach(activeWorkoutsStore.activeWorkouts.indices.reversed(), id: \.self) { activeWorkoutIndex in
NavigationLink(destination: EditWorkout(activeWorkout: self.$activeWorkoutsStore.activeWorkouts[activeWorkoutIndex])) {
Text(self.activeWorkoutsStore.activeWorkouts[activeWorkoutIndex].id.uuidString)
}
}
}
}
}
.navigationBarTitle(Text("Active Workouts"))
.navigationBarItems(trailing: newWorkoutButton)
}
}
}
EditWorkout (Child View)
//
// EditWorkout.swift
// workout-planner
//
// Created by Dominic Minischetti III on 11/2/19.
// Copyright © 2019 Dominic Minischetti. All rights reserved.
//
import SwiftUI
struct EditWorkout: View {
#EnvironmentObject var workoutHistoryStore: WorkoutHistoryStore
#EnvironmentObject var activeWorkoutStore: ActiveWorkoutStore
#EnvironmentObject var exerciseStore: ExerciseStore
#Environment(\.presentationMode) var presentationMode
#State private var addExercise = false
#Binding var activeWorkout: Workout
var currentDayOfWeek: String {
let weekdayIndex = Calendar.current.component(.weekday, from: Date())
return Calendar.current.weekdaySymbols[weekdayIndex - 1]
}
var chooseExercisesButton: some View {
Button (action: {
self.addExercise = true
}) {
HStack {
Image(systemName: "plus.square")
Text("Choose Exercises")
}
}
.sheet(isPresented: self.$addExercise) {
AddWorkoutExercise(exercises: self.$activeWorkout.exercises)
.environmentObject(self.exerciseStore)
}
}
var saveButton: some View {
Button(action: {
self.workoutHistoryStore.addWorkout(workout: self.$activeWorkout.wrappedValue)
self.activeWorkoutStore.removeActiveWorkout(workout: self.$activeWorkout.wrappedValue)
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Finish Workout")
}
.disabled(self.$activeWorkout.wrappedValue.exercises.isEmpty)
}
var body: some View {
Form {
Section(footer: Text("Choose which exercises are part of this workout")) {
chooseExercisesButton
}
Section(header: Text("Exercises")) {
if $activeWorkout.wrappedValue.exercises.isEmpty {
Text("No exercises")
} else {
ForEach(activeWorkout.exercises.indices, id: \.self) { exerciseIndex in
NavigationLink(destination: EditWorkoutExercise(exercise: self.$activeWorkout.exercises[exerciseIndex])) {
VStack(alignment: .leading) {
Text(self.activeWorkout.exercises[exerciseIndex].name)
Text("\(self.activeWorkout.exercises[exerciseIndex].sets.count) Set\(self.activeWorkout.exercises[exerciseIndex].sets.count == 1 ? "" : "s")")
.font(.footnote)
.opacity(0.5)
}
}
}
saveButton
}
}
}
.navigationBarTitle(Text("Edit Workout"), displayMode: .inline )
}
}
ActiveWorkoutStore
import Foundation
import Combine
class ActiveWorkoutStore: ObservableObject {
#Published var activeWorkouts: [Workout] = []
func newActiveWorkout() {
activeWorkouts.append(Workout())
}
func saveActiveWorkout(workout: Workout) {
let workoutIndex = activeWorkouts.firstIndex(where: { $0.id == workout.id })!
activeWorkouts[workoutIndex] = workout
}
func removeActiveWorkout(workout: Workout) {
if let workoutIndex = activeWorkouts.firstIndex(where: { $0.id == workout.id }) {
activeWorkouts.remove(at: workoutIndex)
}
}
}
Workout
import SwiftUI
struct Workout: Hashable, Codable, Identifiable {
var id = UUID()
var date = Date()
var exercises: [WorkoutExercise] = []
}
ForEach<Range> is constant range container (pay attention on below description of constructor), it is not allowed to modify it after construction.
extension ForEach where Data == Range<Int>, ID == Int, Content : View {
/// Creates an instance that computes views on demand over a *constant*
/// range.
///
/// This instance only reads the initial value of `data` and so it does not
/// need to identify views across updates.
///
/// To compute views on demand over a dynamic range use
/// `ForEach(_:id:content:)`.
public init(_ data: Range<Int>, #ViewBuilder content: #escaping (Int) -> Content)
}
If you want to modify container, you have to use ForEach(activeWorkout.exercises)