I'm a beginner working on an app using the MVVM model. The basic idea is that I have Events that each contain several sessions. The events need to be able to be modified including adding and deleting sessions from the events. The main problem is that I can not modify a passed object as described below.
My model:
import SwiftUI
struct Event: Identifiable {
let id = UUID()
let trackName: String
let date: Date
var sessions: [Session]
}
struct Session: Identifiable {
let id = UUID()
let sessionNumber: Int
let time: Date
}
I then have a View Model file that publishes the array of Events :
import Foundation
class EventListViewModel: ObservableObject {
// exposed variables
#Published var events: [Event] = []
var session1 = Session(sessionNumber: 1, time: Date.now)
var session2 = Session(sessionNumber: 2, time: Date.now)
init() {
getEvents()
}
func getEvents() {
let newEvents = [
Event(trackName: "Awesome Event", date: Date.now, sessions: [session1, session2]),
Event(trackName: "Second Event", date: Date.now, sessions: [session1, session2]),
Event(trackName: "Latest Event", date: Date.now, sessions: [session1, session2]),
]
events.append(contentsOf: newEvents)
}
I then have an EventsListView that works as expected. Because "EventListViewModel" is observable, I can set it as an environment object in my View. And because "events" is published, I can access it in the View. So far so good.
import SwiftUI
struct EventsListView: View {
#EnvironmentObject var eventListViewModel: EventListViewModel
var body: some View {
NavigationView {
List{
ForEach(eventListViewModel.events) { event in
NavigationLink(destination: EventDetailView(event: event), label: {
EventCell(event: event)
})
}
.onDelete(perform: eventListViewModel.deleteEvent)
.onMove(perform: eventListViewModel.moveEvent)
}
.navigationTitle("Events")
.navigationBarItems(
leading: EditButton(),
trailing: NavigationLink("New Event", destination: AddEventView())
)
}
}
}
When you click on the event in this List, it opens another view where you can see the details of the event. That works fine. The problem is that I can read the "session" variable, but I cannot append to it. I cannot modify it. "Cannot use mutating member on immutable value: 'self' is immutable"
mport SwiftUI
struct EventDetailView: View {
//#EnvironmentObject var eventListViewModel: EventListViewModel
var event: Event
var body: some View {
//NavigationView {
VStack {
Text(event.trackName)
.font(.title)
.padding()
Text("Number of Sessions: \(event.sessions.count)")
HStack{
Text("Sessions")
.font(.headline)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading )
Text("Add Session")
.font(.headline)
.multilineTextAlignment(.trailing)
.onTapGesture {
//eventListViewModel.addSession(event: event)
var newSession = Session(sessionNumber: event.sessions.count+1, time: Date.now)
//event.sessions.append(newSession) // <-THIS DOES NOT WORK
}
}
ForEach(event.sessions) { session in
SessionCell(session: session) // <-THIS WORKS
}
In the Event model, I set the sessions variable to a var.. I understand there is some concept where when you pass an object, it is read only. How should I set this up so that I can modify the sessions? I haven't actually gotten to this point yet, but I also need to be able to modify the Events. What's the proper way to organize this?
Thanks a lot
UPDATE------
I've implemented your suggestion, except I can't get the EventView preview to work. I've done this and it errors saying "Cannot convert value of type 'Event' to expected argument type 'Binding'". I've tried all sorts of things and nothing seems to work. I tried including as an .environmentObject modifier, combos if local variables with Binding wrappers, etc. I can't crack it.
struct EventView_Previews: PreviewProvider {
static var previews: some View {
Group {
//EventView(eventIndex: 0)
EventView( Event(name: "Event", track: "Motorsport Park", date: Date.now, notes: "", sessions: [Session(time: Date.now, bestLap: "1:32.5", setupSel: 0, tiresSel: 0, weather: "", notes: "")]) )
.environmentObject(EventListViewModel())
}
}
}
Try using a binding for your event, such as:
In EventsListView use this (note $):
ForEach($eventListViewModel.events) { $event in
NavigationLink(destination: EventDetailView(event: $event), label: {
...
}
and in EventDetailView, use this:
struct EventDetailView: View {
#EnvironmentObject var eventListViewModel: EventListViewModel
#Binding var event: Event // <-- here
EDIT-1: try something like this for your EventView_Previews problem
struct EventView_Previews: PreviewProvider {
#State static var event = Event(name: "Event", track: "Motorsport Park", date: Date.now, notes: "", sessions: [Session(time: Date.now)])
static var previews: some View {
Group {
EventView(event: $event)
.environmentObject(EventListViewModel())
}
}
}
This is assuming your EventView is something like this:
struct EventView: View {
#EnvironmentObject var eventListViewModel: EventListViewModel
#Binding var event: Event
// ....
}
Related
When I update a binding property from an array in a pushed view 2+ layers down, the navigation pops back instantly after a change to the property.
Xcode 13.3 beta, iOS 15.
I created a simple demo and code is below.
Shopping Lists
List Edit
List section Edit
Updating the list title (one view deep) is fine, navigation stack stays same, and changes are published if I return. But when adjusting a section title (two deep) the navigation pops back as soon as I make a single change to the property.
I have a feeling I'm missing basic fundamentals here, and I have a feeling it must be related to the lists id? but I'm struggling to figure it out or work around it.
GIF
Code:
Models:
struct ShoppingList {
let id: String = UUID().uuidString
var title: String
var sections: [ShoppingListSection]
}
struct ShoppingListSection {
let id: String = UUID().uuidString
var title: String
}
View Model:
final class ShoppingListsViewModel: ObservableObject {
#Published var shoppingLists: [ShoppingList] = [
.init(
title: "Shopping List 01",
sections: [
.init(title: "Fresh food")
]
)
]
}
Content View:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}
}
}
ShoppingListsView
struct ShoppingListsView: View {
#StateObject private var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
ShoppingListEditView
struct ShoppingListEditView: View {
#Binding var shoppingList: ShoppingList
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("Title", text: $shoppingList.title)
}
Section(header: Text("Sections")) {
List($shoppingList.sections, id: \.id) { $section in
NavigationLink(destination: ShoppingListSectionEditView(section: $section)) {
Text(section.title)
}
}
}
}
.navigationBarTitle("Edit list")
}
}
ShoppingListSectionEditView
struct ShoppingListSectionEditView: View {
#Binding var section: ShoppingListSection
var body: some View {
Form {
Section(header: Text("Title")) {
TextField("title", text: $section.title)
}
}
.navigationBarTitle("Edit section")
}
}
try this, works for me:
struct ContentView: View {
var body: some View {
NavigationView {
ShoppingListsView()
}.navigationViewStyle(.stack) // <--- here
}
}
Try to make you object confirm to Identifiable and return value which unique and stable, for your case is ShoppingList.
Detail view seems will pop when object id changed.
The reason your stack is popping back to the root ShoppingListsView is that the change in the list is published and the root ShoppingListsView is registered to listen for updates to the #StateObject.
Therefore, any change to the list is listened to by ShoppingListsView, causing that view to be re-rendered and for all new views on the stack to be popped in order to render the root ShoppingListsView, which is listening for updates on the #StateObject.
The solution to this is to change the #StateObject to #EnvironmentObject
Please refactor your code to change ShoppingListsViewModel to use an #EnvironmentObject wrapper instead of a #StateObject wrapper
You may pass the environment object in to all your child views and also add a boolean #Published flag to track any updates to the data.
Then your ShoppingListView would look as below
struct ShoppingListsView: View {
#EnvironmentObject var viewModel = ShoppingListsViewModel()
var body: some View {
List($viewModel.shoppingLists, id: \.id) { $shoppingList in
NavigationLink(destination: ShoppingListEditView(shoppingList: $shoppingList)) {
Text(shoppingList.title)
}
}
.navigationBarTitle("Shopping Lists")
}
}
Don't forget to pass the viewModel in to all your child views.
That should fix your problem.
I'm trying to make a UI with SwiftUI that lists a collection of things that can be of different kinds and can each be updated. I'd like to make the type settable in the UI with a Picker, and I also want the view to update when the item is modified some other way (say, from another part of the UI, or over the network.)
I like a "redux"-style setup, and I don't want to jettison that.
Here's a simple example that shows two items, each with a "randomize" button that changes the item at random and a Picker that lets you choose the new item type. The latter works as expected: the Picker changes the #State var, the store gets updated, etc. The 'randomize' button updates the store and the let property label, but the #State and the Picker don't update.
I would love some advice on good ways to get this to work the way I want.
import SwiftUI
import Combine
#main
struct PuffedWastApp: App {
var store = Store()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(store)
}
}
}
enum ItemState:String, CaseIterable {
case apple
case bannana
case coconut
}
enum Action {
case set(Int,ItemState)
}
class Store: ObservableObject {
#Published var state:[ItemState] = [.apple, .apple]
func reduce(_ action:Action) {
print("do an action")
switch (action) {
case let .set(index,new_state):
print("set \(index) to \(new_state)")
state[index] = new_state
self.objectWillChange.send()
}
}
}
struct ContentView: View {
#EnvironmentObject var store: Store
var body: some View {
VStack {
ForEach(store.state.indices) { index in
ItemContainer(index: index)
//Text("\(index)")
}
}
.padding()
}
}
struct ItemContainer: View {
#EnvironmentObject var store: Store
let index: Int
var body: some View {
ItemView(
index: index,
label: store.state[index], // let property: updates on change in the Store
localLabel: store.state[index], //#State variable; doesn't update on change in the Store
dispatch: store.reduce
)
.padding()
}
}
struct ItemView: View {
let index: Int
let label: ItemState
#State var localLabel: ItemState
let dispatch: (Action)->()
var body: some View {
HStack{
//Text("\(index)")
Text(label.rawValue)
Text(localLabel.rawValue)
Button("Randomize") { dispatch( .set(index, ItemState.allCases.randomElement() ?? .apple ) ) }
Picker("Item type", selection: $localLabel ) {
ForEach( ItemState.allCases , id: \.self ) {
Text($0.rawValue).tag($0)
}
}.onChange(of: localLabel) { dispatch(.set(index, $0)) }
}
.padding()
}
}
Try changing this line:
#State var localLabel: ItemState
to
#Binding var localLabel: ItemState
and pass it in your ItemView init as:
ItemView(
index: index,
label: store.state[index],
localLabel: $store.state[index],
dispatch: store.reduce
)
I have an issue with a OSX app developed with SwiftUI.
Here is my model.
class Chapter: Identifiable, Hashable, ObservableObject {
var content: [ChapterContent] = []
var id: Int
// Other Equatable/Hashable related code...
}
// A chapter content contains either a message or an event,
// both can't be nil at the same time
struct ChapterContent: Identifiable, Hashable {
var event: Event?
var message: Message?
var id: Int {
return self.message?.id ?? self.event?.id ?? .min
}
init(_ event: Event) {
self.event = event
self.message = nil
}
init(_ message: Message) {
self.event = nil
self.message = message
}
var representingView: AnyView {
// A view that represents the event or the message
}
// Other Equatable/Hashable related code...
}
The app starts with a ContentView() containing an array of chapters, represented in a list.
The view also contains a button to add a new chapter (with a hardcoded id so far just to test this).
struct ContentView: View {
var chapters: [Chapter]
var body: some View {
NavigationView {
VStack {
List {
ForEach(chapters, id: \.self) { chapter in
Text("Chapter C\(chapter.id)")
}
}
Spacer()
NavigationLink(
destination: ChapterView(chapter: Chapter(id: 1)),
label: {
Text("ADD CHAPTER")
})
}
}
}
}
In this ChapterView, I display all the content of the chapter and also two buttons to add either a new event or a new message into this chapter. To simplify it I'll just show the ADD EVENT button.
struct ChapterView: View {
#State var chapter: Chapter
var body: some View {
NavigationView {
VStack(spacing: 0) {
VStack(spacing: 0) {
ForEach(chapter.content, id: \.self) { content in
content.representingView
}
.padding(0)
}
HStack(spacing: 0) {
NavigationLink(
destination: EventFormView(with: $chapter),
label: {
Text("ADD EVENT")
})
}
}
}
}
}
And in the EventFormView:
struct EventFormView: View {
#Binding var chapter: Chapter
init(with chapter: Binding<Chapter>) {
self._chapter = chapter
}
}
Somewhere in EventFormView I add the newly created event with:
chapter.content.append(newEventChapterContent)
After checking while debugging, I ensured that the value is actually added into the content array of the chapter. However, this doesn't trigger any UI update in the list (which is contained in ChapterView).
I guess it is related to the way I use the State/Binding protocols. I should even maybe use ObservedObject but I don't know how to do so and correctly transmit data from view to view.
Thank you for your help
At first make content published so it can be observed by views
class Chapter: Identifiable, Hashable, ObservableObject {
#Published var content: [ChapterContent] = []
now as it is observable, wrap it in observed in views
struct ChapterView: View {
#ObservedObject var chapter: Chapter
struct EventFormView: View {
#ObservedObject var chapter: Chapter
// previous init not needed
and create it as usual, because observable object is a reference, ie
EventFormView(chapter: chapter)
I have a view that displays a few photos that are loaded from an API in a scroll view. I want to defer fetching the images until the view is displayed. My view, simplified looks something like this:
struct DetailView : View {
#ObservedObject var viewModel: DetailViewModel
init(viewModel: DetailViewModel) {
self.viewModel = viewModel
}
var body: some View {
GeometryReader { geometry in
ZStack {
Color("peachLight").edgesIgnoringSafeArea(.all)
if self.viewModel.errorMessage != nil {
ErrorView(error: self.viewModel.errorMessage!)
} else if self.viewModel.imageUrls.count == 0 {
VStack {
Text("Loading").foregroundColor(Color("blueDark"))
Text("\(self.viewModel.imageUrls.count)").foregroundColor(Color("blueDark"))
}
} else {
VStack {
UIScrollViewWrapper {
HStack {
ForEach(self.viewModel.imageUrls, id: \.self) { imageUrl in
LoadableImage(url: imageUrl)
.scaledToFill()
}.frame(width: geometry.size.width, height: self.scrollViewHeight)
}.edgesIgnoringSafeArea(.all)
}.frame(width: geometry.size.width, height: self.scrollViewHeight)
Spacer()
}
}
}
}.onAppear(perform: { self.viewModel.fetchDetails() })
.onReceive(viewModel.objectWillChange, perform: {
print("Received new value from view model")
print("\(self.viewModel.imageUrls)")
})
}
}
my view model looks like this:
import Foundation
import Combine
class DetailViewModel : ObservableObject {
#Published var imageUrls: [String] = []
#Published var errorMessage : String?
private var fetcher: Fetchable
private var resourceId : String
init(fetcher: Fetchable, resource: Resource) {
self.resourceId = resource.id
// self.fetchDetails() <-- uncommenting this line results in onReceive being called + a view update
}
// this is a stubbed version of my data fetch that performs the same way as my actual
// data call in regards to ObservableObject updates
// MARK - Data Fetching Stub
func fetchDetails() {
if let path = Bundle.main.path(forResource: "detail", ofType: "json") {
do {
let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe)
let parsedData = try JSONDecoder().decode(DetailResponse.self, from: data)
self.imageUrls = parsedData.photos // <-- this doesn't trigger a change, and even manually calling self.objectWillChange.send() here doesn't trigger onReceive/view update
print("setting image urls to \(parsedData.photos)")
} catch {
print("error decoding")
}
}
}
}
If I fetch my data within the init method of my view model, the onReceive block on my view IS called when the #Published imageUrls property is set. However, when I remove the fetch from the init method and call from the view using:
.onAppear(perform: { self.viewModel.fetchDetails() })
the onReceive for viewModel.objectWillChange is NOT called, even though the data is updated. I don't know why this is the case and would really appreciate any help here.
Use instead
.onReceive(viewModel.$imageUrls, perform: { newUrls in
print("Received new value from view model")
print("\(newUrls)")
})
I tested this as I found the same issue, and it seems like only value types can be used with onReceive
use enums, strings, etc.
it doesn't work with reference types because I guess technically a reference type doesn't change reference location and simply points elsewhere when changed? idk haha but ya
as a solution, you can set a viewModel #published property which is like a state enum, make changes to that when you have new data, and then on receive can access that...hope that makes sense, let me know if not
I want to pop a NavigationLink from within code. I followed this article and it works for one link (https://swiftui-lab.com/bug-navigationlink-isactive/). However, when using a list of links, one has to use a separate boolean for each NavigationLink.
So I thought the smart way to do this is with an EnvironmentObject that holds a dictionary with a boolean for each ChildView:
class Navigation: ObservableObject{
#Published var show:[UUID:Bool] = [:]
}
Let's say we want to have a variable number child views (of type MyObject).
What needs to be changed in order to make it work?
struct MyObject:Identifiable{
var id = UUID()
var name:String
}
struct ContentView: View {
#EnvironmentObject var navigation:Navigation
var objects = [MyObject(name: "view1"), MyObject(name: "view2"), MyObject(name: "view3")]
init() {
for object in objects{
self.navigation.show[object.id] = false
}
}
var body: some View {
NavigationView{
VStack{
ForEach(objects, id:\.self.id){ object in
NavigationLink(destination: Child(object:object), isActive: self.$navigation.show[object.id], label: {
Text(object.name)
})
}
}
}
}
}
struct Child: View {
#EnvironmentObject var navi:Navigation
var object:MyObject
var body: some View {
Button(action:{self.navi.show[self.object.id] = false}, label: {
Text("back")
})
}
}
The view that the NavigationLink navigates to has an environment variable set called presentationMode. This variable lets you programatically pop your child view back to the parent view.
So instead of having to keep track of all the display states, we can simplify your Child struct to something like this:
struct Child: View {
#Environment(\.presentationMode) private var presentation
var object:MyObject
var body: some View {
Button(action:{ self.presentation.wrappedValue.dismiss() }, label: {
Text("back")
})
}
}