Overview
I have a #StateObject that is being used to drive a NavigationSplitView.
I am using the #Published properties on the #StateObject to drive the selection of the view. #StateObject + #Published
When I get to the final selection of the NavigationSplitView I want to create a Selection.
This should publish a property only once.
import SwiftUI
import Combine
struct Consumption: Identifiable, Hashable {
var id: UUID = UUID()
var name: String
}
struct Frequency: Identifiable, Hashable {
var id: UUID = UUID()
var name: String
}
struct Selection: Identifiable {
var id: UUID = UUID()
var consumption: Consumption
var frequency: Frequency
}
class SomeModel: ObservableObject {
let consump: [Consumption] = ["Coffee","Tea","Beer"].map { str in Consumption(name: str)}
let freq: [Frequency] = ["daily","weekly","monthly"].map { str in Frequency(name: str)}
#Published var consumption: Consumption?
#Published var frequency: Frequency?
#Published var selection: Selection?
private var cancellable = Set<AnyCancellable>()
init(consumption: Consumption? = nil, frequency: Frequency? = nil, selection: Selection? = nil) {
self.consumption = consumption
self.frequency = frequency
self.selection = selection
$frequency
.print()
.sink { newValue in
if let newValue = newValue {
print("THIS SHOULD APPEAR ONCE")
print("---------------")
self.selection = .init(consumption: self.consumption!, frequency: newValue)
} else {
print("NOTHING")
print("---------------")
}
}.store(in: &cancellable)
}
}
Problem
The #StateObject on the view is publishing the property twice. (See code below)
struct ContentView: View {
#StateObject var model: SomeModel = .init()
var body: some View {
NavigationSplitView {
VStack {
List(model.consump, selection: $model.consumption) { item in
NavigationLink(value: item) {
Label(item.name, systemImage: "circle.fill")
}
}
}
} content: {
switch model.consumption {
case .none:
Text("nothing")
case .some(let consumption):
List(model.freq, id:\.id, selection: $model.frequency) { item in
NavigationLink(item.name, value: item)
}
.navigationTitle(consumption.name)
}
} detail: {
switch model.selection {
case .none:
Text("nothing selected")
case .some(let selection):
VStack {
Text(selection.consumption.name)
Text(selection.frequency.name)
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
My Solution
If I change the View by creating #State properties on the view and then use OnChange modifier to update the model I get the property to update once. Which is what I want. (see code below)
struct ContentView: View {
#StateObject var model: SomeModel = .init()
#State private var consumption: Consumption?
#State private var frequency: Frequency?
var body: some View {
NavigationSplitView {
VStack {
List(model.consump, selection: $consumption) { item in
NavigationLink(value: item) {
Label(item.name, systemImage: "circle.fill")
}
}
}.onChange(of: consumption) { newValue in
self.model.consumption = newValue
}
} content: {
switch model.consumption {
case .none:
Text("nothing")
case .some(let consumption):
List(model.freq, id:\.id, selection: $frequency) { item in
NavigationLink(item.name, value: item)
}
.navigationTitle(consumption.name)
.onChange(of: frequency) { newValue in
self.model.frequency = newValue
}
}
} detail: {
switch model.selection {
case .none:
Text("nothing selected")
case .some(let selection):
VStack {
Text(selection.consumption.name)
Text(selection.frequency.name)
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Question(s) / Help
Is the behaviour of #Binding different between #StateObject + #Published vs View + #State?
I don't understand why in my previous View the property was being published twice.
My solution is a bit more work but it works by using #State and onChange view modifiers.
Well I canĀ“t completely explain why this happens. It seems that List updates its selection twice.
Why? Unknown. ?May be a bug?.
Consider this debugging approach:
} content: {
switch model.consumption {
case .none:
Text("nothing")
case .some(let consumption):
// Create a custom binding and connect it to the viewmodel
let binding = Binding<Frequency?> {
model.frequency
} set: { frequency, transaction in
model.frequency = frequency
// this will print after the List selected a new Value
print("List set frequency")
}
// use the custom binding here
List(model.freq, id:\.id, selection: binding) { item in
NavigationLink(item.name, value: item)
}
.navigationTitle(consumption.name)
}
This will produce the following output:
receive subscription: (PublishedSubject)
request unlimited
receive value: (nil)
NOTHING
---------------
receive value: (Optional(__lldb_expr_7.Frequency(id: D9552E6A-71FE-407A-90F5-87C39FE24193, name: "daily")))
THIS SHOULD APPEAR ONCE
---------------
List set frequency
receive value: (Optional(__lldb_expr_7.Frequency(id: D9552E6A-71FE-407A-90F5-87C39FE24193, name: "daily")))
THIS SHOULD APPEAR ONCE
---------------
List set frequency
The reason you are not seeing this while using #State and .onChange is the .onChange modifier. It fires only if the value changes which it does not. On the other hand your Combine publisher fires every time no matter the value changed or not.
Solution:
You can keep your Combine approach and use the .removeDuplicates modifier.
$frequency
.removeDuplicates()
.print()
.sink { newValue in
This will ensure the sink will only get called when new values are sent.
Related
I have a list of fruits. the struct FruitRowView provides the layout for the view of each row. In this FruitRowView, there's a TextField which I want to display the name of each fruit. I am having trouble doing this. The reason why I want to use a TextField to display the name of each fruit rather than a Text is so that users can easily edit the name of the fruit right from that TextField. In this case, fruits are the Core Data entity and the fruit name is an attribute of this entity.
Here is my core data class:
class CoreDataViewModel: ObservableObject {
let container: NSPersistentContainer
#Published var savedEntities: [FruitEntity] = []
init() {
container = NSPersistentContainer(name: "FruitsContainer")
container.loadPersistentStores { (description, error) in
if let error = error {
print("Error with coreData. \(error)")
}
}
fetchFruits()
}
func fetchFruits() {
let request = NSFetchRequest<FruitEntity>(entityName: "FruitEntity")
do {
savedEntities = try container.viewContext.fetch(request)
} catch let error {
print("Error fetching. \(error)")
}
}
func addFruit(text: String) {
let newFruit = FruitEntity(context: container.viewContext)
newFruit.name = text
saveData()
}
func saveData() {
do {
try container.viewContext.save()
fetchFruits()
} catch let error {
print("Error saving. \(error)")
}
}
}
Here is my contentView:
struct ContentView: View {
//sheet variable
#State var showSheet: Bool = false
#StateObject var vm = CoreDataViewModel()
#State var refresh: Bool
var body: some View {
NavigationView {
VStack(spacing: 20) {
Button(action: {
showSheet.toggle()
}, label: {
Text("Add Fruit")
})
List {
ForEach(vm.savedEntities) { fruit in
FruitRowView(vm: vm, fruit: fruit)
}
}
}
.navigationTitle("Fruits")
.sheet(isPresented: $showSheet, content: {
SecondScreen(refresh: $refresh, vm: vm)
})
}
}
}
Here is my popup screen (used to create a new fruit)
struct SecondScreen: View {
#Binding var refresh: Bool
#Environment(\.presentationMode) var presentationMode
#ObservedObject var vm: CoreDataViewModel
#State var textFieldText: String = ""
var body: some View {
TextField("Add fruit here...", text: $textFieldText)
.font(.headline)
.padding(.horizontal)
Button(action: {
guard !textFieldText.isEmpty else { return }
vm.addFruit(text: textFieldText)
textFieldText = ""
presentationMode.wrappedValue.dismiss()
refresh.toggle()
}, label: {
Text("Save")
})
}
}
Here is my FruitRowView:
struct FruitRowView: View {
//instance of core data model
#ObservedObject var vm: CoreDataViewModel
var fruit: FruitEntity
#State var fruitName = fruit.name
var body: some View {
TextField("Enter fruit name", text: $fruitName)
}
}
So the error that I'm getting is: 'Cannot use instance member 'fruit' within property initializer; property initializers run before 'self' is available'. This error occurs in the FruitRowView when I try to assign fruitName to fruit.name. I assume that there's an easy workaround for this but I haven't been able to figure it out.
Since the fruitEntities in the view model is a published property, you don't need a state variable in the row. You need the binding for the row view, and you should pass it in the content view.
You don't need to pass the view model to the row view as well.
struct FruitRowView: View {
// No need to pass view model to child, only pass the data
// #ObservedObject var vm: CoreDataViewModel
#Binding var fruitName: String
// You don't need this.
// #State var fruitName = fruit.name
var body: some View {
TextField("Enter fruit name", text: $fruitName)
}
}
struct ContentView: View {
...
List {
ForEach(vm.savedEntities) { fruit in
FruitRowView(fruitName: $fruit.name)
}
}
...
}
kinda new to SwiftUI and CoreData.
My problem is that when CoreData already has data saved, the FetchedResults is not initialized when try to use data in sheet.
When I click on one of the buttons to open .viewApp sheet (which sets currentApplication to the application clicked on), it gives me the default values, implying that applications is not initialized.
After clicking another button, it works as expected.
My question is how can I initilize the FetchResults? Is it not initialized automatically?
Here is my code:
import SwiftUI
enum ActiveSheet: Identifiable {
case newApp, viewApp
var id: Int {
hashValue
}
}
struct ApplicationView : View {
let title: String
let company: String
let date: String
let notes: String
var body : some View {
Text(company)
Text(title)
Text(date)
VStack(alignment: .leading){
Text("Notes")
Text(notes).padding()
}
}
}
ContentView
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
#FetchRequest(sortDescriptors: []) var applications:FetchedResults<Application>
#State var showAddScreen = false
#State var showApplicationView = false
#State var currentApplication: Application? = nil
#State var activeSheet: ActiveSheet?
var body: some View {
NavigationView {
List {
ForEach(applications.indices, id:\.self) { i in
Button(action: {
currentApplication = applications[i]
activeSheet = .viewApp
}, label: {
HStack{
VStack{
Text(applications[i].company ?? "Error")
Text(applications[i].title ?? "Error")
}
Spacer()
Text(applications[i].date ?? "Error")
}
})
}.onDelete(perform: deleteApplications)
}
.navigationTitle("Applications")
.toolbar {
Button("Add") {
activeSheet = .newApp
}
}
}
.sheet(item: $activeSheet) { item in
switch item {
case .newApp:
AddScreenView(moc: _moc, activeSheet: $activeSheet)
case .viewApp:
ApplicationView(title: currentApplication?.title ?? "Job Title", company: currentApplication?.company ?? "Company Name", date: currentApplication?.date ?? "Date of Submission", notes: currentApplication?.notes ?? "Notes")
}
}
}
func deleteApplications(at offsets: IndexSet) {
for offset in offsets {
let application = applications[offset]
moc.delete(application)
}
try? moc.save()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
UPDATE:
I changed it to an if else in the ContentView and this has fixed the problem. However, it would be nice to know when the sheet is initialized prior to the fetchedreults.
I tried to create a list of editable objects in SwiftUI. Here is my idea.
First of all, the editable item is as follows:
struct Item: Identifiable {
var id: UUID
var ItemNum: Int
var notes: String = ""
}
final class ItemStore: ObservableObject {
#Published var items: [Item] = [
.init(id: .init(), ItemNum: 55),
.init(id: .init(), ItemNum: 57),
.init(id: .init(), ItemNum: 87)
]
}
After that I created a list that get data from the ItemStore:
struct ItemView: View {
#State private var editMode = EditMode.inactive
#ObservedObject var store: ItemStore
var body: some View {
NavigationView {
List {
ForEach(store.items.indexed(), id:\.1.id) {index, item in
NavigationLink(destination: ItemEditingView(item: self.$store.items[index])) {
VStack(alignment: .leading) {
Text("Item Num: \(item.itemNum)")
}
}
}
}
//.onAppear(perform: store.fetch) // want to fetch the data from the store whenever the list appear, however, no idea to perform the function?!
.navigationBarTitle("Items")
.navigationBarItems( trailing: addButton)
.environment(\.editMode, $editMode)
}
}
private var addButton: some View {
switch editMode {
case .inactive:
return AnyView(Button(action: onAdd) { Image(systemName: "plus") })
default:
return AnyView(EmptyView())
}
}
private func onAdd() {
store.items.append(Item(id: UUID(), itemNum: 10))
}
}
The editView:
struct ItemEditingView: View {
#Environment(\.presentationMode) var presentation
#Binding var item: Item
var body: some View {
Form {
Section(header: Text("Item")) {
Text(Text("Item Num: \(item.itemNum)"))
TextField("Type something...", text: $item.notes)
}
Section {
Button("Save") {
self.presentation.wrappedValue.dismiss()
}
}
}.navigationTitle(Text("Item Num: \(item.itemNum)"))
}
}
My question here:
I would like to fetch the data from 'store' onAppear. but it fails.
After I quit the app, all the previous data gone. How can I make them to keep inside my app, even the app is kill?
Your second question first: In terms of storing (persisting your data), you have many options. The easiest would be to store it in UserDefaults, which I'll show in my example. You could also choose to use CoreData, which would be more of a process to set up, but would give you a more robust solution later on. Many more options like Realm, Firebase, SQLite, etc. exist as well.
struct Item: Identifiable, Codable {
var id: UUID = UUID()
var itemNum: Int
var notes: String = ""
}
final class ItemStore: ObservableObject {
#Published var items: [Item] = [] {
didSet {
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(items) {
UserDefaults.standard.set(encoded, forKey: "savedItems")
}
}
}
let defaultValues : [Item] = [
.init(itemNum: 55),
.init(itemNum: 57),
.init(itemNum: 87)
]
func fetch() {
let decoder = JSONDecoder()
if let savedItems = UserDefaults.standard.object(forKey: "savedItems") as? Data,
let loadedItems = try? decoder.decode([Item].self, from: savedItems) {
items = loadedItems
} else {
items = defaultValues
}
}
}
struct ContentView : View {
#State private var editMode = EditMode.inactive
#ObservedObject var store: ItemStore = ItemStore()
var body: some View {
NavigationView {
List {
ForEach(Array(store.items.enumerated()), id:\.1.id) { (index,item) in
NavigationLink(destination: ItemEditingView(item: self.$store.items[index])) {
VStack(alignment: .leading) {
Text("Item Num: \(item.itemNum)")
}
}
}
}
.onAppear(perform: store.fetch)
.navigationBarTitle("Items")
.navigationBarItems( trailing: addButton)
.environment(\.editMode, $editMode)
}
}
private var addButton: some View {
switch editMode {
case .inactive:
return AnyView(Button(action: onAdd) { Image(systemName: "plus") })
default:
return AnyView(EmptyView())
}
}
private func onAdd() {
store.items.append(Item(id: UUID(), itemNum: 10))
}
}
struct ItemEditingView: View {
#Environment(\.presentationMode) var presentation
#Binding var item: Item
var body: some View {
Form {
Section(header: Text("Item")) {
Text("Item Num: \(item.itemNum)")
TextField("Type something...", text: $item.notes)
}
Section {
Button("Save") {
self.presentation.wrappedValue.dismiss()
}
}
}.navigationTitle(Text("Item Num: \(item.itemNum)"))
}
}
Regarding your first question, the reason that fetch failed is you had no fetch method. Plus, there was nothing to fetch, since the array of items just got populated upon creation of the ItemStore each time.
Notes:
Item now conforms to Codable -- this is what allows it to get transformed into a value that can be saved/loaded from UserDefaults
fetch is now called on onAppear.
Every time the data is changed, didSet is called, saving the new data to UserDefaults
There were a number of typos and things that just plain wouldn't compile in the original code, so make sure that the changes are reflected. Some of those include: enumerated instead of indexed in the ForEach, not calling Text(Text( with nested values, using the same capitalization of itemNum throughout, etc
Important: when testing this, make sure to give the simulator a few seconds after a change to save the data into UserDefaults before killing the app and opening it again.
I have some code like this:
class Data: ObservableObject {
#Published var data = dbContent
init(){
let db = Firestore.firestore()
db.collection("collection").document(userID).addSnapshotListener {
//getting data from DB and storing them as objects by appending them to data
}
}
}
struct 1View: View {
#ObservedObject var myData: Data = Data()
var body: some View {
2View(myData: self.myData)
3View(myData: self.myData)
}
}
struct 2View: View {
#State var myData: Data
var body: some View {
List(){
ForEach(data.count){ data in
Text(data)
}.onDelete(perform: deleteData) //Deletes the item
}
}
}
struct 3View: View {
#State var myData: Data
var body: some View {
List(){
ForEach(data.count){ data in
Text(data)
}.onDelete(perform: deleteData) //Deletes the item
}
}
}
Now the issue is, that I can delete the the item in the 2View. This is then also shown and I implemented the functionality that it deletes the Item in the DB as well.
So the DB data gets altered but this is not shown in the 3View until I refresh it by e.g. revisiting it.
I have no idea what the cause is. Maybe I got a wrong understanding of #Published and ObservedObject ?
#State means that the view owns the data and manages the state. Try using #ObservedObject in your child views as well. Here is an example:
Model
struct Book: Codable, Identifiable {
#DocumentID var id: String?
var title: String
var author: String
var numberOfPages: Int
enum CodingKeys: String, CodingKey {
case id
case title
case author
case numberOfPages = "pages"
}
}
ViewModel
class BooksViewModel: ObservableObject {
#Published var books = [Book]()
private var db = Firestore.firestore()
private var listenerRegistration: ListenerRegistration?
private var cancellables = Set<AnyCancellable>()
init() {
fetchData()
}
deinit {
unregister()
}
func unregister() {
if listenerRegistration != nil {
listenerRegistration?.remove()
}
}
func fetchData() {
unregister()
listenerRegistration = db.collection("books").addSnapshotListener { (querySnapshot, error) in
guard let documents = querySnapshot?.documents else {
print("No documents")
return
}
self.books = documents.compactMap { queryDocumentSnapshot -> Book? in
return try? queryDocumentSnapshot.data(as: Book.self)
}
}
}
func deleteBooks(at offsets: IndexSet) {
self.books.remove(atOffsets: offsets)
}
}
Views
import SwiftUI
struct SampleView: View {
#ObservedObject var viewModel = BooksViewModel()
var body: some View {
VStack {
InnerListView1(viewModel: viewModel)
InnerListView2(viewModel: viewModel)
}
}
}
struct InnerListView1: View {
#ObservedObject var viewModel: BooksViewModel
var body: some View {
List {
ForEach(viewModel.books) { book in
VStack(alignment: .leading) {
Text(book.title)
.font(.headline)
Text(book.author)
.font(.subheadline)
Text("\(book.numberOfPages) pages")
.font(.subheadline)
}
}
.onDelete { indexSet in
self.viewModel.deleteBooks(at: indexSet)
}
}
}
}
struct InnerListView2: View {
#ObservedObject var viewModel: BooksViewModel
var body: some View {
List(viewModel.books) { book in
VStack(alignment: .leading) {
Text(book.title)
.font(.headline)
Text(book.author)
.font(.subheadline)
Text("\(book.numberOfPages) pages")
.font(.subheadline)
}
}
}
}
One thing I noticed when trying to reproduce your issue: if you're using CodingKeys (which you only need to do if your the attribute names on the Firestore documents are different from the attribute names on your Swift structs), you need to make sure that the id is also included. Otherwise, id will be nil, which will result in the List view not being abel to tell the items apart.
I have a progress bar and a text field, both are updated depending on each other's input:
class ViewModel: ObservableObject {
#Published var progressBarValue: Double {
didSet {
textFieldValue = String(progressBarValue)
}
}
#Published var textFieldValue: String {
didSet {
progressBarValue = Double(progressBarValue)
}
}
}
Since updating one updates the other, I end up having an infinite recursion in my code.
Is there a way to workaround this with Combine or plain swift code?
Expanding on my comment, here is a minimal example of a slider and a textfield that both control (and be controlled by) a value via two-way bindings:
class ViewModel: ObservableObject {
#Published var progress: Double = 0
}
struct ContentView: View {
#EnvironmentObject var model: ViewModel
var body: some View {
VStack {
TextField("", value: self.$model.progress, formatter: NumberFormatter())
Slider(value: self.$model.progress, in: 0...100)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(ViewModel())
}
}
Note that I also had to inject a ViewModel instance to my environment on AppDelegate in order for this to work (both on preview & actual app)
Maybe additional checks to avoid loops will work?
#Published var progressBarValue: Double {
didSet {
let newText = String(progressBarValue)
if newText != textFieldValue {
textFieldValue = newText
}
}
}
#Published var textFieldValue: String {
didSet {
if let newProgress = Double(textFieldValue),
abs(newProgress - progressBarValue) > Double.ulpOfOne {
progressBarValue = newProgress
}
}
}