ForEach in List stops update propagation in DestinationView - swift

I think is very clear from this dummy example: if you remove the ForEach code row, magically, the propagation will flow and clock will tick, otherwise it will freeze once the detail view is presented.
class ModelView: ObservableObject {
#Published var clock = Date()
init() {
Timer.publish(every: 1, on: .main, in: .default)
.autoconnect()
.print()
.assign(to: &$clock)
}
}
struct ContentView: View {
#StateObject var viewModel = ModelView()
var body: some View {
NavigationView {
List {
NavigationLink("Clock", destination: Text(viewModel.clock, formatter: formatter))
ForEach(0..<1) { _ in } // <- Remove this row and the clock will work
}
}
}
var formatter: DateFormatter {
let formatter = DateFormatter()
formatter.timeStyle = .medium
return formatter
}
}

I'd say it is rather some kind of coincidence that it works in first case, because destination is (or can be) copied on navigation (that's probably happens in second case).
The correct approach would be to have separated standalone view for details with own observer. Parent view should not update child view property if there is no binding.
Tested and worked with Xcode 12.4 / iOS 14.4
struct ContentView: View {
#StateObject var viewModel = ModelView()
var body: some View {
NavigationView {
List {
NavigationLink("Clock", destination: DetailsView(viewModel: viewModel))
ForEach(0..<1) { _ in } // <- Remove this row and the clock will work
}
}
}
}
struct DetailsView: View {
#ObservedObject var viewModel: ModelView
var body: some View {
Text(viewModel.clock, formatter: formatter)
}
var formatter: DateFormatter {
let formatter = DateFormatter()
formatter.timeStyle = .medium
return formatter
}
}

Related

How do I access and change the #environment variable in another struct? [duplicate]

This question already has an answer here:
SwiftUI : Dismiss modal from child view
(1 answer)
Closed last year.
In view A I use .fullScreenCover() to open a full screen view B. And in this full screen view B there is a view named C, which has AVPlayer and some progress bar controls. I will also put some other views in view B.
So the question is, can I put a button in view C that closes the view B?
...
func listDir( a1:UTType = UTType.movie) -> Array<URL> {
var directoryContents = [URL]()
var directoryContentsW = [URL]()
let documentsUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
directoryContentsW = try! FileManager.default.contentsOfDirectory(at: documentsUrl, includingPropertiesForKeys: nil)
for i in directoryContentsW{
let ext_path = i.pathExtension.lowercased()
if ext_path == "mp4" || ext_path == "mov" {
directoryContents.append(i)
}
print("\(i.lastPathComponent): \(i.pathExtension)")
}
return directoryContents
}
struct Item: Identifiable{
let posi: URL
let id = UUID()
}
struct view_A: View {
#State var item: Item? = nil
#State var list0 = listDir()
var body: some View {
List{
ForEach(list0, id: \.self){ i in
Button( (i.lastPathComponent as NSString).deletingPathExtension ){
item = Item(posi: i)
}
}
}.fullScreenCover(item: $item ,content: { view_B(item: $0) })
}
}
struct view_B: View{
let item: Item? = nil
#Environment(\.presentationMode) var presentationMode
var body: some View{
Color.primary.edgesIgnoringSafeArea(.all)
ZStack{
view_C_videoPlay(filePosi: item!.posi)
}
}
}
struct view_C_videoPlay: View {
#State var player: AVPlayer
...
init(filePosi: URL){
...
}
var body: some View {
Button("abc"){
presentationMode.wrappedValue.dismiss()
}
...
}
}
The compiler prompt:
Cannot find 'presentationMode' in scope
Don’t use presentationMode just make a block var and in the implementation set item to nil. Call the block from a button handler. Repeat the pattern in each View to chain them up.
.fullScreenCover(item: $item) { i in
view_B(item: i) { result in
// could do something with result
item = nil
}
Note if you don’t need the result there is a built in dismiss environment action you could try.
struct SheetView: View {
#Environment(\.dismiss) var dismiss
var body: some View {
NavigationView {
SheetContents()
.toolbar {
Button("Done") {
dismiss()
}
}
}
}
}

Use the same view for adding and editing CoreData objects

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())
}
}

View parameter only passed to view when application first loads - SwiftUI

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")
}

How to use date and dateFormatter in a list?

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))

DatePicker using Time-Interval in SwiftUI

I want to use a DatePicker in SwiftUI, it is working fine and as expected. I want to add an Time-interval, like explained: UIDatePicker 15 Minute Increments Swift
DatePicker("Please enter a time", selection: $wakeUp, displayedComponents: .hourAndMinute)
Is there a Modifier for that in SwifUI?
I don't believe there is a modifier for this. However it's possible to "do it yourself" by using UIViewRepresentable to wrap a UIDatePicker:
The basic structure for this code is based on the Interfacing with UIKit tutorial.
struct MyDatePicker: UIViewRepresentable {
#Binding var selection: Date
let minuteInterval: Int
let displayedComponents: DatePickerComponents
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
func makeUIView(context: UIViewRepresentableContext<MyDatePicker>) -> UIDatePicker {
let picker = UIDatePicker()
// listen to changes coming from the date picker, and use them to update the state variable
picker.addTarget(context.coordinator, action: #selector(Coordinator.dateChanged), for: .valueChanged)
return picker
}
func updateUIView(_ picker: UIDatePicker, context: UIViewRepresentableContext<MyDatePicker>) {
picker.minuteInterval = minuteInterval
picker.date = selection
switch displayedComponents {
case .hourAndMinute:
picker.datePickerMode = .time
case .date:
picker.datePickerMode = .date
case [.hourAndMinute, .date]:
picker.datePickerMode = .dateAndTime
default:
break
}
}
class Coordinator {
let datePicker: MyDatePicker
init(_ datePicker: MyDatePicker) {
self.datePicker = datePicker
}
#objc func dateChanged(_ sender: UIDatePicker) {
datePicker.selection = sender.date
}
}
}
struct DatePickerDemo: View {
#State var wakeUp: Date = Date()
#State var minterval: Int = 1
var body: some View {
VStack {
Stepper(value: $minterval) {
Text("Minute interval: \(minterval)")
}
MyDatePicker(selection: $wakeUp, minuteInterval: minterval, displayedComponents: .hourAndMinute)
Text("\(wakeUp)")
}
}
}
struct DatePickerDemo_Previews: PreviewProvider {
static var previews: some View {
DatePickerDemo()
}
}
Not fully a SwiftUI solution, but much simpler than the existing answer. Attach this to your main body View that contains the picker
.onAppear {
UIDatePicker.appearance().minuteInterval = 5
}
The one downside is this will apply to all pickers in the view and can affect pickers in other views in the app, but you can always do the same thing in those views (set minute interval back to 1).
I think, the below will help..
struct DatePickerView: View {
#Binding var dateSelected: Date
var type: DatePickerComponents? = .date
var body: some View {
DatePicker("", selection: $dateSelected, displayedComponents: .hourAndMinute)
.datePickerStyle(.wheel)
.onAppear {
if type == .hourAndMinute {
UIDatePicker.appearance().minuteInterval = 15
} else {
UIDatePicker.appearance().minimumDate = dateSelected
}
}
}
}
It is also possible with Introspect lib
DatePicker(...)
.introspectDatePicker {
$0.minuteInterval = 30
$0.roundsToMinuteInterval = true
}