Why can't I write to an #ObservedObject? - swiftui

I have a struct called Activity which has an id (UUID), name (String), description (String) and timesCompleted (Int).
I also have a class called Activities that contains an array of Activity structs called activityList. Activities is marked with ObservableObject.
I have activities declared as a #StateObject in my ContentView and I pass it to my ActivityDetailView where it is declared as an #ObservedObject.
However I can only partially write to activities.activityList in the child view. I can append, but I can't overwrite, update or remove an element from the array. No error is thrown but the view immediately crashes and the app returns to the main ContentView.
How do you update/write to an #ObservedObject? As you can see from the comments in my updateTimesCompleted() function I've tried all kinds of things to update/overwrite an existing element. All crash silently and return to ContentView. Append does not fail, but isn't the behavior I want, I want to update/overwrite an array element, not append a new copy.
Activity Struct:
struct Activity : Codable, Identifiable, Equatable {
var id = UUID()
var name: String
var description: String
var timesCompleted: Int
}
Activities Class:
class Activities: ObservableObject {
#Published var activityList = [Activity]() {
didSet {
if let encoded = try? JSONEncoder().encode(activityList) {
UserDefaults.standard.set(encoded, forKey: "activityList")
}
}
}
init() {
if let savedList = UserDefaults.standard.data(forKey: "activityList") {
if let decodedList = try? JSONDecoder().decode([Activity].self, from: savedList) {
activityList = decodedList
return
}
}
activityList = []
}
init(activityList: [Activity]) {
self.activityList = activityList
}
subscript(index: Int) -> Activity {
get {
assert(index < activityList.count, "Index out of range")
return activityList[index]
}
set {
assert(index < activityList.count, "Index out of range")
activityList[index] = newValue
}
}
}
ContentView:
struct ContentView: View {
#StateObject var activities = Activities()
#State private var showingAddActivity = false
var body: some View {
NavigationView {
List {
ForEach(activities.activityList) { activity in
NavigationLink {
ActivityDetailView(activity: activity, activities: activities)
} label: {
Text(activity.name)
}
}
}
.navigationTitle("Habits")
.toolbar {
Button {
showingAddActivity = true
let _ = print("add activity")
}
label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddActivity) {
AddActivityView(activities: activities)
}
}
}
ActivityDetailView:
struct ActivityDetailView: View {
#State private var timesCompleted = 0
let activity: Activity
#ObservedObject var activities: Activities
var body: some View {
NavigationView {
Form {
Text("Activity: \(activity.name)")
Text("Description: \(activity.description)")
Stepper {
Text("Times Completed: \(timesCompleted)")
} onIncrement: {
timesCompleted += 1
updateTimesCompleted()
} onDecrement: {
if timesCompleted > 0 {
timesCompleted -= 1
updateTimesCompleted()
}
}
}
.navigationTitle("Activity Details")
}
}
func updateTimesCompleted() {
let newActivity = Activity(name: activity.name, description: activity.description, timesCompleted: timesCompleted)
let _ = print("count: \(activities.activityList.count)")
let index = activities.activityList.firstIndex(of: activity)
let _ = print(index ?? -666)
if let index = index {
activities.activityList[index] = Activity(name: activity.name, description: activity.description, timesCompleted: timesCompleted)
//activities.activityList.swapAt(index, activities.activityList.count - 1)
//activities.activityList[index].incrementTimesCompleted()
//activities.activityList.append(newActivity)
//activities.activityList.remove(at: index)
//activities.activityList.removeAll()
//activities.activityList.append(newActivity)
}
}
}

You could try this approach, where the activity is passed to the ActivityDetailView
as a binding.
In addition, #ObservedObject var activities: Activities is used directly in AddActivityView to add an Activity to the list.
struct Activity : Codable, Identifiable, Equatable {
let id = UUID() // <-- here
var name: String
var description: String
var timesCompleted: Int
enum CodingKeys: String, CodingKey { // <-- here
case name,description,timesCompleted
}
}
class Activities: ObservableObject {
#Published var activityList = [Activity]() {
didSet {
if let encoded = try? JSONEncoder().encode(activityList) {
UserDefaults.standard.set(encoded, forKey: "activityList")
}
}
}
init() {
if let savedList = UserDefaults.standard.data(forKey: "activityList") {
if let decodedList = try? JSONDecoder().decode([Activity].self, from: savedList) {
activityList = decodedList
return
}
}
activityList = []
}
init(activityList: [Activity]) {
self.activityList = activityList
}
subscript(index: Int) -> Activity {
get {
assert(index < activityList.count, "Index out of range")
return activityList[index]
}
set {
assert(index < activityList.count, "Index out of range")
activityList[index] = newValue
}
}
}
struct ContentView: View {
#StateObject var activities = Activities()
#State private var showingAddActivity = false
var body: some View {
NavigationView {
List {
ForEach($activities.activityList) { $activity in // <-- here
NavigationLink {
ActivityDetailView(activity: $activity) // <-- here
} label: {
Text(activity.name)
}
}
}
.navigationTitle("Habits")
.toolbar {
Button {
showingAddActivity = true
}
label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddActivity) {
AddActivityView(activities: activities)
}
.onAppear {
// for testing
if activities.activityList.isEmpty {
activities.activityList.append(Activity(name: "activity-1", description: "activity-1", timesCompleted: 1))
activities.activityList.append(Activity(name: "activity-2", description: "activity-2", timesCompleted: 2))
activities.activityList.append(Activity(name: "activity-3", description: "activity-3", timesCompleted: 3))
}
}
}
}
// -- here for testing
struct AddActivityView: View {
#ObservedObject var activities: Activities
var body: some View {
Text("AddActivityView")
Button("add activity") {
activities.activityList.append(Activity(name: "workingDog", description: "workingDog", timesCompleted: 5))
}
}
}
struct ActivityDetailView: View {
#Binding var activity: Activity // <-- here
var body: some View {
Form {
Text("Activity: \(activity.name)")
Text("Description: \(activity.description)")
Stepper {
Text("Times Completed: \(activity.timesCompleted)")
} onIncrement: {
activity.timesCompleted += 1 // <-- here
} onDecrement: {
if activity.timesCompleted > 0 {
activity.timesCompleted -= 1 // <-- here
}
}
}
}
}

Related

How to make a binding computed property update on an ObservableObject class?

I have this class:
class MyModel:ObservableObject {
var selectedObjectIndex:Int {
set {}
get {
let selectedResult = objects.filter({ $0 == selectedObject })
if selectedResult == 0 { return 0 }
return objects.firstIndex(of: selectedResult.first!)!
}
}
}
This selectedObjectIndex would normally be a #Published var selectedObjectIndex:Int but because it is a computed property, I cannot use #Published there.
So I was forced to add this to the model
func getselectedObjectIndexBinding() -> Binding<Int> {
let binding = Binding<Int>(
get: { () -> Int in
return self.selectedObjectIndex
}) { (newValue) in
self.selectedObjectIndex = newValue
}
return binding
}
In the view I have a Picker, using this like
Picker(selection: myModel.getselectedObjectIndexBinding(),
label: Text("select")) {
The picker appears correctly but If I set a new value on it, the model selectedObjectIndex is not updated.
You can try something like below, whenever a binding is updated, your selectedObjectIndex can trigger ObjectWillChange notification after doing some relevant work. I have created selectedObjectIndex as propertyObserver.
struct CreditCardFront: View {
#ObservedObject var myModel:MyModel
var body: some View {
Picker(selection: myModel.getselectedObjectIndexBinding(),
label: Text("select")) {
ForEach(1..<10) { value in
Text("\(value)")
}
}
Text("\(myModel.selectedObjectIndex ?? 0)")
}
}
class MyModel:ObservableObject {
var selectedObjectIndex:Int?{
didSet{
// Your work here
update() // call notification on specific change
}
}
func getselectedObjectIndexBinding() -> Binding<Int> {
let binding = Binding<Int>(
get: { () -> Int in
return self.selectedObjectIndex ?? 0
}) { (newValue) in
self.selectedObjectIndex = newValue
}
return binding
}
func update(){
self.objectWillChange.send()
}
}
#main-:
#main
struct WaveViewApp: App {
#StateObject var model:MyModel
init() {
let model = MyModel()
_model = StateObject(wrappedValue: model)
}
var body: some Scene {
WindowGroup {
CreditCardFront(myModel: model)
}
}
}

SwiftUI NavigationLink breaks after removing then inserting new ForEach element

For some reason, my NavigationLink is breaking in a specific circumstance:
Given the code below, here's the steps to reproduce:
Tap Sign In, which inserts an account into the list
Hit back to pop the stack
Swipe left and Delete, which removes the first element of the list
Tap Sign In again (should push onto the stack but does not)
Tap the first row (should push onto the stack but does not)
Here's the code:
import SwiftUI
class Account: ObservableObject, Identifiable, Equatable, Hashable {
let id: String
init(id: String) {
self.id = id
}
static func == (lhs: Account, rhs: Account) -> Bool {
return lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
class AccountManager: ObservableObject {
#Published private (set) var isLoading: Bool = false
#Published private (set) var accounts: [Account] = []
init() {
load()
}
func load() {
isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
self.accounts = [ Account(id: UUID().uuidString) ]
self.isLoading = false
}
}
func add(account: Account) {
accounts.insert(account, at: 0)
}
func delete(at offsets: IndexSet) {
accounts.remove(atOffsets: offsets)
}
}
struct AccountManagerEnvironmentKey: EnvironmentKey {
static var defaultValue: AccountManager = AccountManager()
}
extension EnvironmentValues {
var accountManager: AccountManager {
get { return self[AccountManagerEnvironmentKey.self] }
set { self[AccountManagerEnvironmentKey.self] = newValue }
}
}
struct ContentView: View {
#Environment(\.accountManager) var accountManager
#State var isLoading: Bool = false
#State var accounts: [Account] = []
#State var selectedAccount: Account? = nil
var body: some View {
NavigationView() {
ZStack {
List {
ForEach(accounts) { account in
NavigationLink(
destination: Text(account.id),
tag: account,
selection: $selectedAccount
) {
Text(account.id)
}
}
.onDelete(perform: { offsets in
accountManager.delete(at: offsets)
})
}
if isLoading {
ProgressView("Loading...")
}
}
.navigationBarTitle("Accounts", displayMode: .inline)
.toolbar(content: {
ToolbarItem(placement: .primaryAction) {
Button("Sign In") {
let newAccount = Account(id: UUID().uuidString)
accountManager.add(account: newAccount)
selectedAccount = newAccount
}
}
})
.onReceive(accountManager.$isLoading) { value in
isLoading = value
}
.onReceive(accountManager.$accounts) { value in
accounts = value
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
If I change the button action to do this, it works:
accountManager.add(account: newAccount)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) {
selectedAccount = newAccount
}
But that seems like a massive hack.

How to edit a list of subclass objects

I have a list of items of different classes derived from the same class.
The goal: editing any object using a different view
The model:
class Paper: Hashable, Equatable {
var name: String
var length: Int
init() {
name = ""
length = 0
}
init(name: String, length: Int) {
self.name = name
self.length = length
}
static func == (lhs: Paper, rhs: Paper) -> Bool {
return lhs.length == rhs.length
}
func hash(into hasher: inout Hasher) {
hasher.combine(length)
}
}
class ScientificPaper: Paper {
var biology: Bool
override init(name: String, length: Int) {
biology = false
super.init(name: name, length: length)
}
}
class TechnicalPaper: Paper {
var electronics: Bool
override init(name: String, length: Int) {
electronics = false
super.init(name: name, length: length)
}
}
The main view containing the list.
struct TestView: View {
#Binding var papers: [Paper]
#State private var edit = false
#State private var selectedPaper = Paper()
var body: some View {
let scientificBinding = Binding<ScientificPaper>(
get: {selectedPaper as! ScientificPaper},
set: { selectedPaper = $0 }
)
VStack {
List {
ForEach(papers, id: \.self) { paper in
HStack {
Text(paper.name)
Text("\(paper.length)")
Spacer()
Button("Edit") {
selectedPaper = paper
edit = true
}
}
}
}
}
.sheet(isPresented: $edit) {
VStack {
if selectedPaper is ScientificPaper {
ScientificForm(paper: scientificBinding)
}
if selectedPaper is TechnicalPaper {
TechnicalForm(paper: technicalBinding)
}
}
}
}
}
The custom view for each class.
struct ScientificForm: View {
#Binding var paper: ScientificPaper
var body: some View {
Form {
Text("Scientific")
TextField("Name: ", text: $paper.name)
TextField("Length: ", value: $paper.length, formatter: NumberFormatter())
TextField("Biology: ", value: $paper.biology, formatter: NumberFormatter())
}
}
}
struct TechnicalForm: View {
#Binding var paper: TechnicalPaper
var body: some View {
Form {
Text("Technical")
TextField("Name: ", text: $paper.name)
TextField("Length: ", value: $paper.length, formatter: NumberFormatter())
TextField("Electronics: ", value: $paper.electronics, formatter: NumberFormatter())
}
}
}
Problem is that at run time I get the following:
Could not cast value of type 'Paper' to 'ScientificPaper'.
maybe because the selectedPaper is already initialized as Paper.
What is the right strategy to edit list items belonging to different classes?
The error is due to creating binding in body, which calculates on every refresh, so binding is invalid.
The solution is to make binding as computable property, so it is requested only after validation in correct flow.
Tested with Xcode 12.1 / iOS 14.1 (demo is for scientificBinding only for simplicity)
struct TestView: View {
#Binding var papers: [Paper]
#State private var edit = false
#State private var selectedPaper = Paper()
var scientificBinding: Binding<ScientificPaper> { // << here !!
return Binding<ScientificPaper>(
get: {selectedPaper as! ScientificPaper},
set: { selectedPaper = $0 }
)
}
var body: some View {
VStack {
List {
ForEach(papers, id: \.self) { paper in
HStack {
Text(paper.name)
Text("\(paper.length)")
Spacer()
Button("Edit") {
selectedPaper = paper
edit = true
}
}
}
}
}
.sheet(isPresented: $edit) {
VStack {
if selectedPaper is ScientificPaper {
ScientificForm(paper: scientificBinding)
}
// if selectedPaper is TechnicalPaper {
// TechnicalForm(paper: technicalBinding)
// }
}
}
}
}

Why .append doesn't reload swiftUI view

I have the following view hierarchy
Nurse List View > Nurse Card > Favorite button
Nurse List View
struct NurseListView: View {
#State var data: [Nurse] = []
var body: some View {
List {
ForEach(data.indices, id: \.self) { index in
NurseCard(data: self.$data[index])
}
}
}
}
Nurse Card
struct NurseCard: View {
#Binding var data: Nurse
var body: some View {
FavoriteActionView(data:
Binding(
get: { self.data },
set: { self.data = $0 as! Nurse }
)
)
}
}
Favorite Action View
struct FavoriteActionView: View {
#Binding var data: FavoritableData
var body: some View {
Button(action: {
self.toggleFavIcon()
}) {
VStack {
Image(data.isFavorite ? "fav-icon" : "not-fav-icon")
Text(String(data.likes.count))
}
}
}
private func toggleFavIcon() {
if data.isFavorite {
if let index = data.likes.firstIndex(of: AppState.currentUser.uid) {
data.likes.remove(at: index)
}
} else {
data.likes.append(AppState.currentUser.uid)
}
}
}
When toggleFavIcon execute, it append/remove the user id from the likes property in data object but I can't see the change unless I go back to previous page and reopen the page. What I am missing here?
As Asperi wrote, using an ObservableObject would work well here. Something like this:
class FavoritableData: ObservableObject {
#Published var likes: [String] = []
#Published var isFavorite = false
}
struct FavoriteActionView: View {
#ObservedObject var data: FavoritableData
var body: some View {
Button(action: {
self.toggleFavIcon()
}) {
VStack {
Image(data.isFavorite ? "fav-icon" : "not-fav-icon")
Text(String(data.likes.count))
}
}
}
private func toggleFavIcon() {
if data.isFavorite {
if let index = data.likes.firstIndex(of: AppState.currentUser.uid) {
data.likes.remove(at: index)
}
} else {
data.likes.append(AppState.currentUser.uid)
}
}
}

Refreshing NavigationView Environment Object

Im trying to create an environment object that is editable and putting it in a list.
The Variables are only refreshing when I switch the tab for example (so whenever I leave the NavigationView) and then come back.
The same worked with a ModalView before. Is it a bug maybe? Or am I doing something wrong?
import SwiftUI
import Combine
struct TestView: View {
#State var showSheet: Bool = false
#EnvironmentObject var feed: TestObject
func addObjects() {
var strings = ["one","two","three","four","five","six"]
for s in strings {
var testItem = TestItem(text: s)
self.feed.items.append(testItem)
}
}
var body: some View {
TabView {
NavigationView {
List(feed.items.indices, id:\.self) { i in
NavigationLink(destination: detailView(feed: self._feed, i: i)) {
HStack {
Text(self.feed.items[i].text)
Text("(\(self.feed.items[i].read.description))")
}
}
}
}
.tabItem({ Text("Test") })
.tag(0)
Text("Blank")
.tabItem({ Text("Test") })
.tag(0)
}.onAppear {
self.addObjects()
}
}
}
struct detailView: View {
#EnvironmentObject var feed: TestObject
var i: Int
var body: some View {
VStack {
Text(feed.items[i].text)
Text(feed.items[i].read.description)
Button(action: { self.feed.items[self.i].isRead.toggle() }) {
Text("Toggle read")
}
}
}
}
final class TestItem: ObservableObject {
init(text: String) {
self.text = text
self.isRead = false
}
static func == (lhs: TestItem, rhs: TestItem) -> Bool {
lhs.text < rhs.text
}
var text: String
var isRead: Bool
let willChange = PassthroughSubject<TestItem, Never>()
var read: Bool {
set {
self.isRead = newValue
}
get {
self.isRead
}
}
}
class TestObject: ObservableObject {
var willChange = PassthroughSubject<TestObject, Never>()
#Published var items: [TestItem] = [] {
didSet {
willChange.send(self)
}
}
}
try passing .environmentObject on your destination:
NavigationLink(destination: detailView(feed: self._feed, i: i).environmentObject(x))
You have to use willSet instead of didSet.
TestItem should be a value type: struct or enum. SwiftUI's observation system properly works only with value types.