When the NavigationLink is pressed I want to create an object (with the time when it was pressed), add it to the savedObjects and pass then new object to the destination view.
How can I do this without changing the state while the view is updating?
struct ContentView: View {
#State private var savedObjects = [
MyObject(
id: 0,
date: Date()
),
MyObject(
id: 1,
date: Date()
),
MyObject(
id: 2,
date: Date()
),
MyObject(
id: 3,
date: Date()
)
]
var body: some View {
NavigationView {
List {
NavigationLink("Save new object and navigate to it", destination: DestinationView(object: MyObject(id: Int.random(in: 10...1000), date: Date())))
ForEach(savedObjects) { object in
NavigationLink("Navigate to object \(object.id)", destination: DestinationView(object: object))
}
}
}
}
}
class MyObject: ObservableObject, Identifiable {
var id: Int
var date: Date
init(id: Int, date: Date) {
self.id = id
self.date = date
}
}
struct DestinationView: View {
#ObservedObject var object: MyObject
var body: some View {
VStack {
Text("object \(object.id)")
Text("date: \(object.date.description)")
}
}
}
Here is a solution that refreshes state with a new Date when the link appears.
struct LinkWithPayloadView: View {
#State private var date = Date()
var body: some View {
NavigationView {
navigationLink(payload: "Cookies and Milk", date: date)
.onAppear(perform: {
date = Date()
})
}
}
func navigationLink(payload: String, date: Date) -> some View {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
let payload = Payload(timestamp: date, inners: payload)
let vstack = VStack {
Text("\(payload.inners)")
Text("\(formatter.string(from: payload.timestamp))")
}
return NavigationLink(payload.inners, destination: vstack)
}
struct Payload {
let timestamp : Date
let inners : String
}
}
Credit #mallow for the idea
My question is why can't you make a separate destination view to save the object? Then you don't need to worry about saving it in the starting view.
struct DestinationView: View {
#Environment(\.managedObjectContext) var managedObjectContext
#ObservedObject var object: MyObject
init(id : Int, date : Date) {
let myObject = MyObject(context: managedObjectContext)
myObject.date = date
myObject.id = id
try! managedObjectContext.save()
self.object = myObject
}
var body: some View {
VStack {
Text("object \(object.id)")
Text("date: \(object.date.description)")
}
}
}
Then you can just reload the objects from coredata when the navigation closes.
I think programmatic navigation is the best solution in my case. So clicking a different button that creates and saves the object which in turn creates a new NavigationLink and then initiating the navigation immediately as explained here:
https://www.hackingwithswift.com/quick-start/swiftui/how-to-use-programmatic-navigation-in-swiftui
Related
I have a simple SwiftUI view with a Picker containing a list of objects from a data array. The Picker lists the objects just fine, but the selected value is not being saved to the binding variable $selectedCar. It returns empty string. This is the view in question:
struct GarageSpace: View {
var currentUserID: String
#Environment(\.presentationMode) var presentationMode
#Binding var selectedPlaceID: String
#Binding var selectedPlaceName: String
#Binding var selectedPlaceDate: Date
#Binding var selectedSpaceID: String
#State var selectedCar: String
#Binding var cars: CarArrayObject
var body: some View {
VStack{
Group{
Picker("Car", selection: $selectedCar) {
if let cars = cars{
ForEach(cars.dataArray, id: \.self) {car in
let year = car.year! as String
let make = car.make as String
let model = car.model! as String
let string = year + " " + make + " " + model
Text(string) //displays correctly in Picker
}
}
}
Spacer()
if let cars = cars {
Button {
print("yes")
print(selectedCar) //returns empty string
} label: {
Text("Confirm")
}
}
}
}
}
}
The above view is displayed via a NavigationLink on the previous screen:
NavigationLink(destination: GarageSpace(currentUserID: currentUserID, selectedPlaceID: $selectedPlaceID, selectedPlaceName: $selectedPlaceName, selectedPlaceDate: $selectedPlaceDate, selectedSpaceID: $selectedSpaceID, selectedCar: "", cars: $cars)) {
}
This NavigationLink might be the culprit because I'm sending an empty string for selectedCar. However, it forces me to initialize a value with the NavigationLink.
Any ideas? Thanks!
EDIT:
Added a tag of type String, still same outcome:
Text(string).tag(car.carID)
EDIT: FOUND THE ISSUE! However, I'm still stumped. The selection variable is empty because I wasn't pressing on the Picker since I only had one item in the array. How can I get the Picker to "select" an item if it's the only one in the array by default?
With tag, all works well in my simple tests. Here is my test code:
import SwiftUI
#main
struct TestApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
struct ContentView: View {
var body: some View {
GarageSpace()
}
}
struct GarageSpace: View {
#State var selectedCar: String = ""
#State var cars: CarArrayObject? = CarArrayObject(car: CarModel(make: "Ford"))
var body: some View {
VStack {
Group {
Picker("Car", selection: $selectedCar) {
if let cars = cars {
ForEach(cars.dataArray, id: \.self) { car in
Text(car.make).tag(car.carID)
}
}
}
Spacer()
if let cars = cars {
Button {
print("----> selectedCar carID: \(selectedCar)")
} label: {
Text("Show selected carID")
}
}
}
}
// optional, to select the first car
.onAppear {
if let cars = cars {
selectedCar = (cars.dataArray.first != nil) ? cars.dataArray.first!.carID : ""
}
}
}
}
struct CarModel: Hashable {
var make = ""
var carID = UUID().uuidString
}
class CarArrayObject: ObservableObject{
// for testing
#Published var dataArray = [CarModel(make: "Toyota"), CarModel(make: "Suzuki"), CarModel(make: "VW")]
/// USED FOR SINGLE CAR SELECTION
init(car: CarModel) {
self.dataArray.append(car)
}
/// USED FOR GETTING CARS FOR USER PROFILE
init(userID: String) {
// print("GET CARS FOR USER ID \(userID)")
// DataService.instance.downloadCarForUser(userID: userID) { (returnedCars) in
//
// let sortedCars = returnedCars.sorted { (car1, car2) -> Bool in
// return car1.dateCreated > car2.dateCreated
// }
// self.dataArray.append(contentsOf: sortedCars)
// }
}
}
I'm working on an iOS app that track people's medication and I got an add view and an edit view, both look almost the same with the exception that on my edit view I use the .onAppear to load all the medication data into the fields with an existing medication using let medication: Medication
My Form looks something like this:
Form {
Group {
TextField("Medication name", text: $name).disableAutocorrection(true)
TextField("Remaining quantity", text: $remainingQuantity).keyboardType(.numberPad)
TextField("Box quantity", text: $boxQuantity).keyboardType(.numberPad)
DatePicker("Date", selection: $date, in: Date()...).datePickerStyle(GraphicalDatePickerStyle())
Picker(selection: $repeatPeriod, label: Text("Repeating")) {
ForEach(RepeatPeriod.periods, id: \.self) { periods in
Text(periods).tag(periods)
}
.onAppear {
if pickerView {
self.name = self.medication.name != nil ? "\(self.medication.name!)" : ""
self.remainingQuantity = (self.medication.remainingQuantity != 0) ? "\(self.medication.remainingQuantity)" : ""
self.boxQuantity = (self.medication.boxQuantity != 0) ? "\(self.medication.boxQuantity)" : ""
self.date = self.medication.date ?? Date()
self.repeatPeriod = self.medication.repeatPeriod ?? "Nunca"
self.notes = self.medication.notes != nil ? "\(self.medication.notes!)" : ""
}
}
}
I thought of using a binding variable like isEditMode and it works fine but I had some issue related to the moc object when calling the add view that doesn't provide an object.
Here's how my editView preview looks like
struct EditMedicationSwiftUIView_Previews: PreviewProvider {
static let moc = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
static var previews: some View {
let medication = Medication(context: moc)
return NavigationView {
EditMedicationSwiftUIView(medication: medication)
}
}
}
Any suggestions?
Here is a simplified version of what I think you are trying to do. It uses code from a SwiftUI sample project. Just create an Xcode SwiftUI project with CoreData.
import SwiftUI
import CoreData
//Standard List Screen where you can select an item to see/edit and you find a button to add
struct ReusableParentView: View {
#Environment(\.managedObjectContext) private var viewContext
#FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
animation: .default)
private var items: FetchedResults<Item>
//Keeps work out of the Views so it can be reused
#StateObject var vm: ReusableParentViewModel = ReusableParentViewModel()
var body: some View {
NavigationView{
List{
ForEach(items) { item in
NavigationLink {
//This is the same view as the sheet but witht he item passed fromt he list
ReusableItemView(item: item)
} label: {
VStack{
Text(item.timestamp.bound, formatter: itemFormatter)
Text(item.hasChanges.description)
}
}
}.onDelete(perform: { indexSet in
for idx in indexSet{
vm.deleteItem(item: items[idx], moc: viewContext)
}
})
}
//Show sheet to add new item
.sheet(item: $vm.newItem, onDismiss: {
vm.saveContext(moc: viewContext)
//You can also cancel/get rid of the new item/changes if the user doesn't save
//vm.cancelAddItem(moc: viewContext)
}, content: { newItem in
NavigationView{
ReusableItemView(item: newItem)
}
//Inject the VM the children Views have access to the functions
.environmentObject(vm)
})
.toolbar(content: {
ToolbarItem(placement: .automatic, content: {
//Trigger new item sheet
Button(action: {
vm.addItem(moc: viewContext)
}, label: {
Image(systemName: "plus")
})
})
})
}
//Inject the VM the children Views have access to the functions
.environmentObject(vm)
}
private let itemFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
formatter.timeStyle = .medium
return formatter
}()
}
//The Item's View
struct ReusableItemView: View {
//All CoreData objects are ObservableObjects to see changes you have to wrap them in this
#ObservedObject var item: Item
#Environment(\.editMode) var editMode
var body: some View {
VStack{
if editMode?.wrappedValue == .active{
EditItemView(item: item)
}else{
ShowItemView(item: item)
}
}
.toolbar(content: {
ToolbarItem(placement: .automatic, content: {
//If you want to edit this info just press this button
Button(editMode?.wrappedValue == .active ? "done": "edit"){
if editMode?.wrappedValue == .active{
editMode?.wrappedValue = .inactive
}else{
editMode?.wrappedValue = .active
}
}
})
})
}
}
//The View to just show the items info
struct ShowItemView: View {
//All CoreData objects are ObservableObjects to see changes you have to wrap them in this
#ObservedObject var item: Item
var body: some View {
if item.timestamp != nil{
Text("Item at \(item.timestamp!)")
}else{
Text("nothing to show")
}
}
}
//The View to edit the item's info
struct EditItemView: View {
#Environment(\.managedObjectContext) private var viewContext
#EnvironmentObject var vm: ReusableParentViewModel
#Environment(\.editMode) var editMode
//All CoreData objects are ObservableObjects to see changes you have to wrap them in this
#ObservedObject var item: Item
var body: some View {
DatePicker("timestamp", selection: $item.timestamp.bound).datePickerStyle(GraphicalDatePickerStyle())
}
}
struct ReusableParentView_Previews: PreviewProvider {
static var previews: some View {
ReusableParentView().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
class ReusableParentViewModel: ObservableObject{
//Can be used to show a sheet when a new item is created
#Published var newItem: Item? = nil
//If you dont want to create a CoreData item immediatly just present a sheet with the AddItemView in it
#Published var presentAddSheet: Bool = false
func addItem(moc: NSManagedObjectContext) -> Item{
//You should never create an ObservableObject inside a SwiftUI View unless it is using #StateObject which doesn't apply to a CoreData object
let temp = Item(context: moc)
temp.timestamp = Date()
//Sets the newItem variable
newItem = temp
//And returns the new item for other uses
return temp
}
func cancelAddItem(moc: NSManagedObjectContext){
rollbackChagnes(moc: moc)
newItem = nil
}
func rollbackChagnes(moc: NSManagedObjectContext){
moc.rollback()
}
func deleteItem(item: Item, moc: NSManagedObjectContext){
moc.delete(item)
saveContext(moc: moc)
}
func saveContext(moc: NSManagedObjectContext){
do{
try moc.save()
}catch{
print(error)
}
}
}
And if for some reason you don't want to create a CoreData object ahead of time which seems to be what you are doing you can always Create the temp variables and make a sharable editable view that takes in #Binding for each variable you want to edit.
//The View to Add the item's info, you can show this anywhere.
struct AddItemView: View {
#Environment(\.managedObjectContext) private var viewContext
#EnvironmentObject var vm: ReusableParentViewModel
//These can be temporary variables
#State var tempTimestamp: Date = Date()
var body: some View {
EditableItemView(timestamp: $tempTimestamp)
.toolbar(content: {
ToolbarItem(placement: .navigationBarLeading, content: {
//Create and save the item
Button("save"){
let new = vm.addItem(moc: viewContext)
new.timestamp = tempTimestamp
vm.saveContext(moc: viewContext)
}
})
})
}
}
//The View to edit the item's info
struct EditItemView: View {
#EnvironmentObject var vm: ReusableParentViewModel
#Environment(\.managedObjectContext) private var viewContext
#ObservedObject var item: Item
var body: some View {
VStack{
EditableItemView(timestamp: $item.timestamp.bound)
.onDisappear(perform: {
vm.rollbackChagnes(moc: viewContext)
})
//Just save the item
Button("save"){
vm.saveContext(moc: viewContext)
}
}
}
}
//The View to edit the item's info
struct EditableItemView: View {
#Environment(\.managedObjectContext) private var viewContext
#EnvironmentObject var vm: ReusableParentViewModel
//All CoreData objects are ObservableObjects to see changes you have to wrap them in this
#Binding var timestamp: Date
var body: some View {
DatePicker("timestamp", selection: $timestamp).datePickerStyle(GraphicalDatePickerStyle())
}
}
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 a navigation view (SettingsView) as shown below. When I go to PayScheduleForm the first time, the date from UserDefaults is passed properly. If I change the payDate value in PayScheduleForm, I can see that it updates the UserDefaults key properly. However, if I go back to SettingsView, and then go to PayScheduleForm again, the original value is still shown in the picker.
It's kind of an odd scenario so maybe it's better explained step by step:
Start App
Go to Settings -> Pay Schedule
Last UserDefaults payDate value is in DatePicker (10/08/2020)
Change value to 10/14/2020 - console shows that string of UserDefaults.standard.object(forKey: "payDate") = 10/14/2020
Go back to settings (using back button)
Go back to Pay Schedule and see that DatePicker has its original value (10/08/2020)
Of course if I restart the app again, I see 10/14/2020 in the DatePicker
struct SettingsView: View {
var body: some View {
NavigationView{
if #available(iOS 14.0, *) {
List{
NavigationLink(destination: AccountsList()) {
Text("Accounts")
}
NavigationLink(destination: CategoriesList()) {
Text("Categories")
}
NavigationLink(destination: PayScheduleForm(date: getPayDate()).onAppear(){
getPayDate()
}) {
Text("Pay Schedule")
}
}.navigationTitle("Settings")
} else {
// Fallback on earlier versions
}
}.onAppear(perform: {
getUserDefaults()
})
}
func getPayDate() -> Date{
var date = Date()
if UserDefaults.standard.object(forKey: "payDate") != nil {
date = UserDefaults.standard.object(forKey: "payDate") as! Date
}
let df = DateFormatter()
df.dateFormat = "MM/dd/yyyy"
print(df.string(from: date))
return date
}
struct PayScheduleForm: View {
var frequencies = ["Bi-Weekly"]
#Environment(\.managedObjectContext) var context
#State var payFrequency: String?
#State var date: Date
var nextPayDay: String{
let nextPaydate = (Calendar.current.date(byAdding: .day, value: 14, to: date ))
return Utils.dateFormatterMed.string(from: nextPaydate ?? Date() )
}
var body: some View {
Form{
Picker(selection: $payFrequency, label: Text("Pay Frequency")) {
if #available(iOS 14.0, *) {
ForEach(0 ..< frequencies.count) {
Text(self.frequencies[$0]).tag(payFrequency)
}.onChange(of: payFrequency, perform: { value in
UserDefaults.standard.set(payFrequency, forKey:"payFrequency")
print(UserDefaults.standard.string(forKey:"payFrequency")!)
})
} else {
// Fallback on earlier versions
}
}
if #available(iOS 14.0, *) {
DatePicker(selection: $date, displayedComponents: .date) {
Text("Last Payday")
}
.onChange(of: date, perform: { value in
UserDefaults.standard.set(date, forKey: "payDate")
let date = UserDefaults.standard.object(forKey: "payDate") as! Date
let df = DateFormatter()
df.dateFormat = "MM/dd/yyyy"
print(df.string(from: date))
})
} else {
// Fallback on earlier versions
}
}
```
Fixed this. Posting so others with the same issue can find the answer.
iOS 14 allows the #AppStorage property wrapper which easily allows access to UserDefaults, BUT the Date type is not allowed with #AppStorage as of now.
Instead you can use an #Observable object
First create a swift file called UserSettings.swift and create a class in there:
class UserSettings: ObservableObject {
#Published var date: Date {
didSet {
UserDefaults.standard.set(date, forKey: "payDate")
}
}
init() {
self.date = UserDefaults.standard.object(forKey: "payDate") as? Date ?? Date()
}
}
Then add an #ObservableObject to your view
#ObservedObject var userSettings = UserSettings()
...
DatePicker(selection: $userSettings.date, displayedComponents: .date) {
Text("Last Payday")
}
Im trying to make a simple app that when the "Add" button is pressed, it adds a new cell to the list with the title and the date it was created. Im creating this practice app using mvvm but I cant figure out how to use date and dateFormatter properly. The following code is what I have:
The model:
struct Model: Identifiable {
var id = UUID()
var title: String
var createdAt = Date()
}
The ViewModel:
class ViewModel: ObservableObject {
#Published var items = [Model]()
}
And the view:
struct ContentView: View {
static var DateFormatter: DateFormatter {
let formatter = self.DateFormatter
formatter.dateStyle = .long
return formatter
}
#EnvironmentObject var viewModel: ViewModel
var body: some View {
NavigationView {
List {
ForEach(0 ..< viewModel.items.count, id: \.self) { index in
VStack {
Text(self.viewModel.items[index].title)
Text(self.viewModel.items[index].createdAt)
}
}
}
.navigationBarTitle("Practice")
.navigationBarItems(trailing: Button(action: makeNew) {
Text("Add")
})
}
}
func makeNew() {
withAnimation {
viewModel.items.append(Model(title: "New Item \(viewModel.items.count + 1)", createdAt: Date()))
}
}
}
First of all it is Swift naming convention to start your properties with a lowercase letter.
Second you need to fix your date formatter declaration:
static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .long
return formatter
}()
Then you call your ContentView static property ContentView.dateFormatter.string(from: yourdate). In your case:
Text(ContentView.dateFormatter.string(from: self.viewModel.items[index].createdAt))