If a property of my model object is an array of a custom object, how do I let users append objects to it? - ios

I'm making an app where the user can create groups, and fill these groups with people. Groups are showed in a list and link to GroupViews, where there is a list of people part of that particular group.
The list of and creation of groups work as expected; the user can create groups and clicking any group takes the user to that specific groups own view.
The list of and creation of people don't work; when the user attempts to append a person to the people list it falls between the AddPersonView and GroupView and does not show up in the list.
Below is my current attempt at a solution:
Models.swift
import Foundation
struct Group: Identifiable {
var id: UUID
var name: String
var people: [Person]
init(name: String) {
self.id = UUID()
self.name = name
self.people = [Person]()
}
}
struct Person: Identifiable {
var id: UUID
var firstName: String
var lastName: String
}
ModelView.swift
import Foundation
class GroupList: ObservableObject {
#Published var groups = [Group]()
}
ContentView.swift
import SwiftUI
struct ContentView: View {
#ObservedObject var groupList: GroupList
#State private var showingAddGroupView = false
var body: some View {
NavigationView {
List(groupList.groups) { group in
NavigationLink(destination: GroupView(group: group.people)) {
Text(group.name)
}
}
.navigationBarItems(trailing:
Button(action: {
self.showingAddGroupView.toggle()
}) {
Text("Add group")
})
}
.sheet(isPresented: $showingAddGroupView) {
AddGroupView(groupList: self.groupList)
}
}
}
AddGroupView.swift
import SwiftUI
struct AddGroupView: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var groupList: GroupList
#State private var name = ""
var body: some View {
VStack {
TextField("Name", text: self.$name)
Button(action: {
self.groupList.groups.append(Group(name: self.name))
self.presentationMode.wrappedValue.dismiss()
}) {
Text("OK")
}
}
}
}
GroupView.swift
import SwiftUI
struct GroupView: View {
var group: [Person]
#State private var showingAddPersonView = false
var body: some View {
NavigationView {
VStack {
List(group) { person in
NavigationLink(destination: Text(person.firstName)) {
Text("\(person.firstName) \(person.lastName)")
}
}
.sheet(isPresented: $showingAddPersonView) {
AddPersonView(group: self.group)
}
}
.navigationBarItems(trailing:
Button(action: {
self.showingAddPersonView.toggle()
}) {
Text("Add person")
})
}
}
}
AddPersonView.swift
import SwiftUI
struct AddPersonView: View {
#Environment(\.presentationMode) var presentationMode
#State var group: [Person]
#State private var firstName = ""
#State private var lastName = ""
var body: some View {
VStack {
TextField("First name", text: self.$firstName)
TextField("Last name", text: self.$lastName)
Button(action: {
self.group.append(Person(id: UUID(), firstName: self.firstName, lastName: self.lastName))
self.presentationMode.wrappedValue.dismiss()
}) {
Text("OK")
}
}
}
}

check this out:
the problem is exactly there what Lou said - structs will be copied. you must change and work on your observable object - not on copies.
import SwiftUI
import Foundation
struct Group: Identifiable {
var id: UUID
var name: String
var people: [Person]
init(name: String) {
self.id = UUID()
self.name = name
self.people = [Person]()
}
}
struct Person: Identifiable {
var id: UUID
var firstName: String
var lastName: String
}
class GroupList: ObservableObject {
#Published var groups = [Group]()
func getGroupBy(id: UUID) -> Group? {
let result = groups.filter { $0.id == id }
if result.count == 1 {
return result[0]
}
return nil
}
func getGroupIndex(id: UUID) -> Int? {
return groups.firstIndex { $0.id == id }
}
}
struct ContentView: View {
#EnvironmentObject var groupList: GroupList
#State private var showingAddGroupView = false
var body: some View {
NavigationView {
List(self.groupList.groups) { group in
NavigationLink(destination: GroupView(group: group).environmentObject(self.groupList)) {
Text(group.name)
}
}
.navigationBarItems(trailing:
Button(action: {
self.showingAddGroupView.toggle()
}) {
Text("Add group")
})
}
.sheet(isPresented: $showingAddGroupView) {
AddGroupView().environmentObject(self.groupList)
}
}
}
struct AddGroupView: View {
#Environment(\.presentationMode) var presentationMode
#EnvironmentObject var groupList: GroupList
#State private var name = ""
var body: some View {
VStack {
TextField("Name", text: self.$name)
Button(action: {
self.groupList.groups.append(Group(name: self.name))
self.presentationMode.wrappedValue.dismiss()
}) {
Text("OK")
}
}
}
}
struct GroupView: View {
#EnvironmentObject var groupList: GroupList
var group: Group
#State private var showingAddPersonView = false
var body: some View {
NavigationView {
VStack {
List(self.group.people) { person in
NavigationLink(destination: Text(person.firstName)) {
Text("\(person.firstName) \(person.lastName)")
}
}
.sheet(isPresented: $showingAddPersonView) {
AddPersonView(group: self.group).environmentObject(self.groupList)
}
}
.navigationBarItems(trailing:
Button(action: {
self.showingAddPersonView.toggle()
}) {
Text("Add person")
})
}
}
}
struct AddPersonView: View {
#Environment(\.presentationMode) var presentationMode
#EnvironmentObject var groupList : GroupList
#State var group: Group
#State private var firstName = ""
#State private var lastName = ""
var body: some View {
VStack {
TextField("First name", text: self.$firstName)
TextField("Last name", text: self.$lastName)
Button(action: {
if let index = self.groupList.getGroupIndex(id: self.group.id) {
self.groupList.groups[index].people.append(Person(id: UUID(), firstName: self.firstName, lastName: self.lastName))
self.group = self.groupList.groups[index]
}
self.presentationMode.wrappedValue.dismiss()
}) {
Text("OK")
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView().environmentObject(GroupList())
}
}

The issue is in GroupView, with this line
var group: [Person]
When you call AddPersonView, you are sending a copy of the array and the view appends a person to that copy and then it is lost when the view dismisses.
You must pass something that is shared. Probably this should all be in an ObservableObject and not local view variables.
Pass down the GroupList object or bindings to its internals

Related

ForEach not working with Identifiable & id = UUID()

import SwiftUI
struct TestStudentView: View {
#StateObject var students = Students()
#State private var name = ""
#State private var numberOfSubjects = ""
#State private var subjects = [Subjects](repeating: Subjects(name: "", grade: ""), count: 10)
var body: some View {
NavigationView {
Group {
Form {
Section(header: Text("Student details")) {
TextField("Name", text: $name)
TextField("Number of subjects", text: $numberOfSubjects)
}
let count = Int(numberOfSubjects) ?? 0
Text("Count: \(count)")
Section(header: Text("Subject grades")) {
if count>0 && count<10 {
ForEach(0 ..< count, id: \.self) { number in
TextField("Subjects", text: $subjects[number].name)
TextField("Grade", text: $subjects[number].grade)
}
}
}
}
VStack {
ForEach(students.details) { student in
Text(student.name)
ForEach(student.subjects) { subject in //Does not work as expected
//ForEach(student.subjects, id:\.id) { subject in //Does not work as expected
//ForEach(student.subjects, id:\.self) { subject in //works fine with this
HStack {
Text("Subject: \(subject.name)")
Text("Grade: \(subject.grade)")
}
}
}
}
}
.navigationTitle("Student grades")
.navigationBarItems(trailing:
Button(action: {
let details = Details(name: name, subjects: subjects)
students.details.append(details)
}, label: {
Text("Save")
})
)
}
}
}
struct TestStudentView_Previews: PreviewProvider {
static var previews: some View {
TestStudentView()
}
}
class Students: ObservableObject {
#Published var details = [Details]()
}
struct Details: Identifiable {
let id = UUID()
var name: String
var subjects: [Subjects]
}
struct Subjects: Identifiable, Hashable {
let id = UUID()
var name: String
var grade: String
}
When I use - "ForEach(student.subjects, id:.id) { subject in" under normal circumstances it is supposed to work as id = UUID and the incorrect output is as follows:
then as the class conforms to Identifiable I tried - "ForEach(student.subjects) { subject in" it still does not work correctly. However, when I do - "ForEach(student.subjects, id:.self) { subject in" except I had to have the class conform to hashable and gives me the correct expected output. The correct output which is shown:
You need to use a map instead of repeating.
By using Array.init(repeating:) will invoke the Subjects to initialize only one time, and then insert that object into the array multiple times.
So all, in this case, all id is same.
You can check by just print all id in by this .onAppear() { print(subjects.map({ (sub) in print(sub.id) }))
struct TestStudentView: View {
#StateObject var students = Students()
#State private var name = ""
#State private var numberOfSubjects = ""
#State private var subjects: [Subjects] = (0...10).map { _ in
Subjects(name: "", grade: "")
} //<-- Here

SwiftUI #Binding property not updating nested views - Xcode 12

I have problems updating my nested SwiftUI views with #Binding property.
I declared a DataModel with ObservableObject protocol:
class DataModel: ObservableObject {
#Published var subjects: [Subject] = []
...
}
I added it to my main app:
#main
struct LessonToTextApp: App {
#ObservedObject private var data = DataModel()
var body: some Scene {
WindowGroup {
NavigationView {
SubjectsView(subjects: $data.subjects) {
data.save()
}
}
.onAppear {
data.load()
}
}
}
}
I passed the Subjects array to the first view
struct SubjectsView: View {
#Binding var subjects: [Subject]
var body: some View {
List {
if subjects.isEmpty {
Text("subjects.empty")
} else {
ForEach(subjects) { subject in
NavigationLink(destination: DetailView(subject: binding(for: subject), saveAction: saveAction)) {
CardView(subject: subject)
}
.listRowBackground(subject.color)
.cornerRadius(10)
}
}
}
private func binding(for subject: Subject) -> Binding<Subject> {
guard let subIndex = subjects.firstIndex(where: { $0.id == subject.id }) else {
fatalError("Can't find subject in array")
}
return $subjects[subIndex]
}
And then i passed the single subject to the Second view using the function binding declared above:
struct DetailView: View {
#Binding var subject: Subject
var body: some View {
ForEach(subject.lessons) { lesson in
NavigationLink(destination: LessonView(lesson: lesson)) {
Text(lesson.date, style: .date)
}
}
.onDelete { indexSet in
self.subject.lessons.remove(atOffsets: indexSet)
}
}
In the DetailView, when i delete an item in ForEach the item still appear, the view doesn't update.
I'm using SwiftUI 2.0 on Xcode 12.3 (12C33)
EDIT
This is the Model:
struct Subject: Identifiable, Codable {
let id: UUID
var name: String
var teacher: String
var color: Color
var lessons: [Lesson]
}
struct Lesson: Identifiable, Codable {
let id: UUID
let date: Date
var lenghtInMinutes: Int
var transcript: String
}
Your SubjectsView should take in the entire DataModel as an #EnvironmentObject
struct LessonToTextApp: App {
#StateObject private var data = DataModel()
var body: some Scene {
WindowGroup {
NavigationView {
SubjectsView().environmentObject(data) {
data.save()
}
...
struct SubjectsView: View {
#EnvironmentObject var data = DataModel
var body: some View {
List {
if data.subjects.isEmpty {
Text("subjects.empty")
...
Also, struct are immutable
class Subject: Identifiable, Codable, ObservableObject {
let id: UUID
#Published var name: String
#Published var teacher: String
#Published var color: Color
...
}
struct DetailView: View {
#ObservedObject var subject: Subject
var body: some View {
...
That way you can get to the DetailView with
DetailView(subject: subject)

SwiftUI: Sheet cannot show correct values in first time

I found strange behavior in SwiftUI.
The sheet shows empty text when I tap a list column first time.
It seems correct after second time.
Would you help me?
import SwiftUI
let fruits: [String] = [
"Apple",
"Banana",
"Orange",
]
struct ContentView: View {
#State var isShowintSheet = false
#State var selected: String = ""
var body: some View {
NavigationView {
List(fruits, id: \.self) { fruit in
Button(action: {
selected = fruit
isShowintSheet = true
}) {
Text(fruit)
}
}
}
.sheet(isPresented: $isShowintSheet, content: {
Text(selected)
})
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
list
first tap
after second tap
Use .sheet(item:) instead. Here is fixed code.
Verified with Xcode 12.1 / iOS 14.1
struct ContentView: View {
#State var selected: String?
var body: some View {
NavigationView {
List(fruits, id: \.self) { fruit in
Button(action: {
selected = fruit
}) {
Text(fruit)
}
}
}
.sheet(item: $selected, content: { item in
Text(item)
})
}
}
extension String: Identifiable {
public var id: String { self }
}
Thank you, Omid.
I changed my code from Asperi's code using #State like this.
import SwiftUI
struct Fruit: Identifiable, Hashable {
var name: String
var id = UUID()
}
let fruits: [Fruit] = [
Fruit(name: "Apple"),
Fruit(name: "Banana"),
Fruit(name: "Orange"),
]
struct ContentView: View {
#State var selected: Fruit?
var body: some View {
NavigationView {
List(fruits, id: \.self) { fruit in
Button(action: {
selected = fruit
}) {
Text(fruit.name)
}
}
}
.sheet(item: $selected, content: { item in
Text(item.name)
})
}
}

SwiftUI manipulate items from a struct from a view

I'd like the ability to edit and put into a new view the 'expenses' the user adds. I've been having problems accessing the data after a new expense has been added. I am able to delete the items and add them up but I'd like to click on the 'expenses' and see and edit the content in them Image of the view
//Content View
import SwiftUI
struct ExpenseItem: Identifiable, Codable {
let id = UUID()
let name: String
let type: String
let amount: Int
}
class Expenses: ObservableObject {
#Published var items = [ExpenseItem]() {
didSet {
let encoder = JSONEncoder()
if let encoded = try?
encoder.encode(items) {
UserDefaults.standard.set(encoded, forKey: "Items")
}
}
}
init() {
if let items = UserDefaults.standard.data(forKey: "Items") {
let decoder = JSONDecoder()
if let decoded = try?
decoder.decode([ExpenseItem].self, from: items) {
self.items = decoded
return
}
}
}
// Computed property that calculates the total amount
var total: Int {
self.items.reduce(0) { result, item -> Int in
result + item.amount
}
}
}
struct ContentView: View {
#ObservedObject var expenses = Expenses()
#State private var showingAddExpense = false
var body: some View {
NavigationView {
List {
ForEach(expenses.items) { item in
HStack {
VStack {
Text(item.name)
.font(.headline)
Text(item.type)
}
Spacer()
Text("$\(item.amount)")
}
}
.onDelete(perform: removeItems)
// View that shows the total amount of the expenses
HStack {
Text("Total")
Spacer()
Text("\(expenses.total)")
}
}
.navigationBarTitle("iExpense")
.navigationBarItems(trailing: Button(action: {
self.showingAddExpense = true
}) {
Image(systemName: "plus")
}
)
.sheet(isPresented: $showingAddExpense) {
AddView(expenses: self.expenses)
}
}
}
func removeItems(at offsets: IndexSet) {
expenses.items.remove(atOffsets: offsets)
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
//AddExpense
import SwiftUI
struct AddView: View {
#Environment(\.presentationMode) var presentationMode
#ObservedObject var expenses: Expenses
#State private var name = ""
#State private var type = "Personal"
#State private var amount = ""
static let types = ["Business", "Personal"]
var body: some View {
NavigationView {
Form {
TextField("Name", text: $name)
Picker("Type", selection: $type) {
ForEach(Self.types, id: \.self) {
Text($0)
}
}
TextField("Amount", text: $amount)
.keyboardType(.numberPad)
}
.navigationBarTitle("Add new expense")
.navigationBarItems(trailing: Button("Save") {
if let actualAmount = Int(self.amount) {
let item = ExpenseItem(name: self.name, type: self.type, amount: actualAmount)
self.expenses.items.append(item)
self.presentationMode
.wrappedValue.dismiss()
}
})
}
}
}
struct AddView_Previews: PreviewProvider {
static var previews: some View {
AddView(expenses: Expenses())
}
}
Remove #observedObject in AddView.
A view cannot change an ObservableObject. ObservableObject is used for being notified when a value is changed.
When you pass the expenses class to AddView, you are giving it a reference. Therefore, AddView can change the expenses, and consequently update ContentView.

SwiftUI: How to update passing array item in the other view

I'm trying to update arrays item with typed new value into Textfield, but List is not updated with edited value.
My Code is:
Model:
struct WalletItem: Identifiable{
let id = UUID()
var name:String
var cardNumber:String
var type:String
var cvc:String
let pin:String
var dateOfExpiry:String
}
ModelView:
class Wallet: ObservableObject{
#Published var wallets = [
WalletItem(name: "BSB", cardNumber: "123456789", type: "master card", cvc: "1234", pin: "1234", dateOfExpiry: "2016-06-29"),
WalletItem(name: "Alpha bank", cardNumber: "123456789", type: "master card", cvc: "1234", pin: "1234", dateOfExpiry: "2017-03-12"),
WalletItem(name: "MTŠ‘", cardNumber: "123456789", type: "master card", cvc: "1234", pin: "1234", dateOfExpiry: "2020-11-12"),
]
}
First View:
struct WalletListView: View {
// Properties
// ==========
#ObservedObject var wallet = Wallet()
#State var isNewItemSheetIsVisible = false
var body: some View {
NavigationView {
List(wallet.wallets) { walletItem in
NavigationLink(destination: EditWalletItem(walletItem: walletItem)){
Text(walletItem.name)
}
}
.navigationBarTitle("Cards", displayMode: .inline)
.navigationBarItems(
leading: Button(action: { self.isNewItemSheetIsVisible = true
}) {
HStack {
Image(systemName: "plus.circle.fill")
Text("Add item")
}
}
)
}
.sheet(isPresented: $isNewItemSheetIsVisible) {
NewWalletItem(wallet: self.wallet)
}
}
}
and Secondary View:
struct EditWalletItem: View {
#State var walletItem: WalletItem
#Environment(\.presentationMode) var presentationMode
var body: some View {
Form{
Section(header: Text("Card Name")){
TextField("", text: $walletItem.name)
}
}
.navigationBarItems(leading:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
})
{
Text("Back")
}, trailing:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
})
{
Text("Save")
})
}
}
P.S: If I use #Binding instead of the #State I've got an error in the first view: Initializer init(_:) requires that Binding<String> conform to StringProtocol
Here are modified parts (tested & works with Xcode 11.2 / iOS 13.2):
Sure over binding
struct EditWalletItem: View {
#Binding var walletItem: WalletItem
Place to pass it
List(Array(wallet.wallets.enumerated()), id: .element.id) { (i, walletItem) in
NavigationLink(destination: EditWalletItem(walletItem: self.$wallet.wallets[i])){
Text(walletItem.name)
}
}
ForEach(Array(list.enumerated())) will only work correctly if the list is an Array but not for an ArraySlice, and it has the downside of copying the list.
A better approach is using a .indexed() helper:
struct IndexedCollection<Base: RandomAccessCollection>: RandomAccessCollection {
typealias Index = Base.Index
typealias Element = (index: Index, element: Base.Element)
let base: Base
var startIndex: Index { self.base.startIndex }
var endIndex: Index { self.base.endIndex }
func index(after i: Index) -> Index {
self.base.index(after: i)
}
func index(before i: Index) -> Index {
self.base.index(before: i)
}
func index(_ i: Index, offsetBy distance: Int) -> Index {
self.base.index(i, offsetBy: distance)
}
subscript(position: Index) -> Element {
(index: position, element: self.base[position])
}
}
extension RandomAccessCollection {
func indexed() -> IndexedCollection<Self> {
IndexedCollection(base: self)
}
}
Example:
// SwiftUIPlayground
// https://github.com/ralfebert/SwiftUIPlayground/
import Foundation
import SwiftUI
struct Position {
var id = UUID()
var count: Int
var name: String
}
class BookingModel: ObservableObject {
#Published var positions: [Position]
init(positions: [Position] = []) {
self.positions = positions
}
}
struct EditableListExample: View {
#ObservedObject var bookingModel = BookingModel(
positions: [
Position(count: 1, name: "Candy"),
Position(count: 0, name: "Bread"),
]
)
var body: some View {
// >>> Passing a binding into an Array via index:
List(bookingModel.positions.indexed(), id: \.element.id) { i, _ in
PositionRowView(position: self.$bookingModel.positions[i])
}
}
}
struct PositionRowView: View {
#Binding var position: Position
var body: some View {
Stepper(
value: $position.count,
label: {
Text("\(position.count)x \(position.name)")
}
)
}
}
struct EditableListExample_Previews: PreviewProvider {
static var previews: some View {
EditableListExample()
}
}
See also:
How does the Apple-suggested .indexed() property work in a ForEach?

Resources