How to remove duplicate set elements in SwiftUI - ios

I have an entity of places called Place which has a relationship (Set<Activity>) and I am passing the places (fetch request) to the PlacesView and each place has a list of Activities. What I try to accomplish is, that instead of showing all activities related to a place directly, I want to pre-group them in date elements in a ActivityDatesView. Now I want to select the date and see all activities related to that place and date. Date is an attribute of the Activity entity.
How can I modify my code, to not display activities, that have the same activity.date (without time), where activity.date is Date() value coming from a CoreData entity and Activity is a NSManagedObject Subclass.
List(Array(place.activities as Set), id: \.self) { activity in
Text(dateFormatter.string(from: activity.date))
}
For Views, I had:
struct PlacesView {
#FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Place.name, ascending: true)]) var places: FetchedResults<Foo>
var body: some View {
//...
}
}
struct ActivityDatesView {
#ObservedObject var place: Place
var body: some View {
List(Array(place.activities as Set), id: \.self) { activity in
Text(dateFormatter.string(from: activity.date)) // This displays duplicate entries
}
}
}
struct ActivityView {
#ObservedObject var place: Place
var selectedDate: Date
var body: some View {
//...
}
}

Because to get the features of Set() your struct must be Hashable and Equatable if you customize this protocols and override the default provided functions you get the desired functionality with much less code. The best is all your dates save their time and you dont lose any information.
struct ContentView: View {
let activities = [Activity(date: Date()), Activity(date: Date().addingTimeInterval(3600)), Activity(date: Date().addingTimeInterval(24*3600))]
var body: some View {
NavigationView {
VStack {
List(Array(Set(activities))) { activity in
//prints 2 dates
Text(activity.date.description)
}
}
}
}
}
struct Activity: Equatable, Identifiable, Hashable {
var id = UUID()
var name = "Activity"
var date: Date
//Add this function to your struct
static func == (lhs: Activity, rhs: Activity) -> Bool {
Calendar.current.dateComponents([.year, .month, .day], from: lhs.date) == Calendar.current.dateComponents([.year, .month, .day], from: rhs.date)
}
//Add this function also to your struct
func hash(into hasher: inout Hasher) {
hasher.combine(Calendar.current.dateComponents([.year, .month, .day], from: date))
}
}

Related

UserDefaults state not refresh

I use "UserDefaults" to store the date time of the user to reload the page. However, I found that it does not refresh even if the time passed
final class ModelData: ObservableObject {
#Published var lastLaunchDate: Date = UserDefaults.standard.value(forKey: "lastLaunch") as? Date ?? Date()
func getTimeStamp() -> Date {
let now_components = Calendar.current.dateComponents([.month, .day, .hour, .minute], from: Date())
let lastLaunch_components = Calendar.current.dateComponents([.month, .day, .hour, .minute], from: lastLaunchDate)
print(now_components.minute, lastLaunch_components.minute)
if now_components.minute != lastLaunch_components.minute {
lastLaunchDate = Date()
UserDefaults.standard.setValue(lastLaunchDate, forKey: "lastLaunch")
}
print(now_components.minute, lastLaunch_components.minute)
}
return lastLaunchDate
}
}
Since I am testing, I use 'minute' to ease my test. I expect that when there is change of minute, the above piece of code will update my userDefaults.
struct HomeView: View {
#EnvironmentObject var modelData: ModelData
var body: some View {
Text("\(modelData.getTimeStamp())")
}
}
I found that I browse into another page, go back to HomeView. The timestamp did not get update at all. I printed the lastLaunchDate, it also did not update as well.
I found that I browse into another page, go back to HomeView. The
timestamp did not get update at all.
If you navigate back to a parent View, SwiftUI does not re-render it unless something changes.
Instead... you will need to use the .onAppear view modifier to call your function.
Also, Text("\(modelData.getTimeStamp())") is not very SwiftUI especially in your case.
Since you have #Published var lastLaunchDate, use it as it's meant to be used:
Text("\(modelData.lastLaunchDate)")
Now when lastLaunchDate is changed, the SwiftUI engine will automatically update the View.
Solution:
final class ModelData: ObservableObject {
#Published var lastLaunchDate = UserDefaults.standard.value(forKey: "lastLaunch") as? Date ?? Date()
func refresh() {
let compareDate = Date()
let refreshInterval = TimeInterval(3) //3 second interval for test purpose
if compareDate.timeIntervalSince(lastLaunchDate) > refreshInterval {
lastLaunchDate = compareDate
UserDefaults.standard.setValue(lastLaunchDate, forKey: "lastLaunch")
}
}
}
struct ContentView: View {
#EnvironmentObject var modelData: ModelData
var body: some View {
NavigationView {
VStack {
Text("\(modelData.lastLaunchDate)")
NavigationLink(destination: Text("Destination"),
label: { Text("Navigate") })
}
.onAppear {
modelData.refresh()
}
}
}
}
NOTE:
if now_components.minute != lastLaunch_components.minute is error prone.
Example: compare last-update-time of 00:01 with current-time of 01:01.
Check will fail.

How to add test data to PreviewProvider

I am new to iOS/Swift and to the MVVM architecture and was wondering how to send in test data to the SwiftUI Preview. (Actual data is received from the ViewModel from an API call)
Test Data that I want to add in:
test data = [(name: "Test1", price: "18.00-21.00"), (name: "Test2", price: "8.00-11.00"), (name: "Test3", price: "10.00")]
My View:
struct TodayView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
NavigationView {
List(viewModel.results, id: \.self) { item in
Text(item.name)
.font(.subheadline)
Text(item.price ?? "NIL")
.font(.headline)
}
.listStyle(InsetGroupedListStyle())
.navigationBarTitle(Text("\(viewModel.titleDate)"))
}
}
}
struct TodayView_Previews: PreviewProvider {
static var previews: some View {
// Add Test Data here? How to pass it into TodayView?
TodayView(viewModel: ViewModel())
}
}
My View Model( I am sending both the Date & Actual Data to the View):
class ViewModel: ObservableObject {
// Title Date
#Published var titleDate: String = ""
let dateLong = Date()
func createDate() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "E, MMM d"
return formatter.string(from: Date())
}
init(){
self.results = [Model.Calendar]()
titleDate = self.createDate()
loadData()
}
func loadData() {
// API Call
}
// Actual Data received from API call
#Published var results: [Model.Calendar]
}
My Model:
struct Model {
struct Calendar: Codable, Hashable {
var name: String
var price: String
}
}
You can try the following:
struct TodayView_Previews: PreviewProvider {
static var previews: some View {
let viewModel = ViewModel()
viewModel.results = <your_test_data>
return TodayView(viewModel: viewModel)
}
}
Also, you may need to remove loadData() from init (in ViewModel), so your test data won't be overridden.
You can create public protocol called 'ViewModelProtocol', which will have basic functions and properties that your ViewModel needs, like for instance
var results: [Model.Calendar]
And inside of View, you can put type of ViewModel to be of that protocol.
Then, you can inject any object that conforms to that particular 'ViewModelProtocol', which allows you to create two different view models ViewModel and ViewModelMock accordingly, where both of them should be conformed to ViewModelProtocol.
In ViewModelMock you will add dummy data for your testing, while regular ViewModel will have to fetch the data from the Network.
public protocol ViewModelProtocol {
var results: [Model.Calendar]
}
struct ViewModel: ViewModelProtocol {...}
struct ViewModelMock: ViewModelProtocol {...}

#ObservedObject does not get updated

I'm trying to update a view with a simple Observable pattern, but it doesn't happen for some reason. The Publisher gets updated, but the subscriber doesn't. I've simplified to the code below. When you click the Add button, the view doesn't get updated and also the variable.
I'm using this function (NetworkManager.shared.saveNew()), because I update after a CloudKit notification. If you know a workaround, I'd be pleased to know!
import SwiftUI
import Combine
public class NetworkManager: ObservableObject {
static let shared = NetworkManager()
#Published var list = [DataSource]()
init() {
self.list = [DataSource(id: "Hello", action: [1], link: "http://hello.com", year: 2020)]
}
func saveNew(item: DataSource) {
self.list.append(item)
}
}
struct DataSource: Identifiable, Codable {
var id: String
var action: [Int]
var link: String
var year: Int
init(id: String, action: [Int], link: String, year: Int) {
self.id = id
self.action = action
self.link = link
self.year = year
}
}
struct ContentView: View {
#ObservedObject var list = NetworkManager()
var body: some View {
VStack {
Button("Add") {
NetworkManager.shared.saveNew(item: DataSource(id: "GoodBye", action: [1], link: "http://goodbye", year: 2030))
}
List(list.list, id:\.id) { item in
Text(item.id)
}
}
}
}
It should be used same instance of NetworkManager, so here is fixed variant (and better to name manager as manager but not as list)
struct ContentView: View {
#ObservedObject var manager = NetworkManager.shared // << fix !!
var body: some View {
VStack {
Button("Add") {
self.manager.saveNew(item: DataSource(id: "GoodBye", action: [1], link: "http://goodbye", year: 2030))
}
List(manager.list, id:\.id) { item in
Text(item.id)
}
}
}
}

How to group core data items by date in SwiftUI?

What I have in my iOS app is:
TO DO ITEMS
To do item 3
24/03/2020
------------
To do item 2
24/03/2020
------------
To do item 1
23/03/2020
------------
What I would like to have is:
TO DO ITEMS
24/03
To do item 3
24/03/2020
------------
To do item 2
24/03/2020
------------
23/03
To do item 1
23/03/2020
------------
===============
What I have so far:
I am using Core Data and have 1 Entity: Todo. Module: Current Product Module. Codegen: Class Definition.
This entity has 2 attributes: title (String), date (Date).
ContentView.swift
Displays the list.
import SwiftUI
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
#State private var date = Date()
#FetchRequest(
entity: Todo.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \Todo.date, ascending: true)
]
) var todos: FetchedResults<Todo>
#State private var show_modal: Bool = false
var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .short
return formatter
}
// func to group items per date. Seemed to work at first, but crashes the app if I try to add new items using .sheet
func update(_ result : FetchedResults<Todo>)-> [[Todo]]{
return Dictionary(grouping: result){ (element : Todo) in
dateFormatter.string(from: element.date!)
}.values.map{$0}
}
var body: some View {
NavigationView {
VStack {
List {
ForEach(update(todos), id: \.self) { (section: [Todo]) in
Section(header: Text( self.dateFormatter.string(from: section[0].date!))) {
ForEach(section, id: \.self) { todo in
HStack {
Text(todo.title ?? "")
Text("\(todo.date ?? Date(), formatter: self.dateFormatter)")
}
}
}
}.id(todos.count)
// With this loop there is no crash, but it doesn't group items
//ForEach(Array(todos.enumerated()), id: \.element) {(i, todo) in
// HStack {
// Text(todo.title ?? "")
// Text("\(todo.date ?? Date(), formatter: self.dateFormatter)")
// }
//}
}
}
.navigationBarTitle(Text("To do items"))
.navigationBarItems(
trailing:
Button(action: {
self.show_modal = true
}) {
Text("Add")
}.sheet(isPresented: self.$show_modal) {
TodoAddView().environment(\.managedObjectContext, self.moc)
}
)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
return ContentView().environment(\.managedObjectContext, context)
}
}
TodoAddView.swift
In this view I add new item.
import SwiftUI
struct TodoAddView: View {
#Environment(\.presentationMode) var presentationMode
#Environment(\.managedObjectContext) var moc
static let dateFormat: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter
}()
#State private var showDatePicker = false
#State private var title = ""
#State private var date : Date = Date()
var body: some View {
NavigationView {
VStack {
HStack {
Button(action: {
self.showDatePicker.toggle()
}) {
Text("\(date, formatter: Self.dateFormat)")
}
Spacer()
}
if self.showDatePicker {
DatePicker(
selection: $date,
displayedComponents: .date,
label: { Text("Date") }
)
.labelsHidden()
}
TextField("title", text: $title)
Spacer()
}
.padding()
.navigationBarTitle(Text("Add to do item"))
.navigationBarItems(
leading:
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Cancel")
},
trailing:
Button(action: {
let todo = Todo(context: self.moc)
todo.date = self.date
todo.title = self.title
do {
try self.moc.save()
}catch{
print(error)
}
self.presentationMode.wrappedValue.dismiss()
}) {
Text("Done")
}
)
}
}
}
struct TodoAddView_Previews: PreviewProvider {
static var previews: some View {
TodoAddView()
}
}
I have tried this:
I have searched for some examples. One looked good: How to properly group a list fetched from CoreData by date? and I have used the update function and ForEach from there, but it doesn't work with .sheet in SwiftUI. When I open the .sheet (after tapping Add) the app crashes with an error:
Thread 1: Exception: "Attempt to create two animations for cell"
How to fix it? Or is there another way of grouping core data by date? I have been told that I should add grouping to my data model. And just show it later in UI. I don't know where to start.
Another guess is that I maybe could edit my #FetchRequest code to add grouping there. But I am searching for a solution few days without luck.
I know there is a setPropertiesToGroupBy in Core Data, but I don't know if and how it works with #FetchRequest and SwiftUI.
Another guess: Is it possible to use Dictionary(grouping: attributeName) to group CoreData Entity instances in SwiftUI based on their attributes?
Grouping arrays looks so easy: https://www.hackingwithswift.com/example-code/language/how-to-group-arrays-using-dictionaries , but I don't know if and how it works with Core Data and #FetchRequest.
I'm just getting into SwiftUI myself, so this might be a misunderstanding, but I think the issue is that the update function is unstable, in the sense that it does not guarantee to return the groups in the same order each time. SwiftUI consequently gets confused when new items are added. I found that the errors were avoided by specifically sorting the array:
func update(_ result : FetchedResults<Todo>)-> [[Todo]]{
return Dictionary(grouping: result){ (element : Todo) in
dateFormatter.string(from: element.date!)
}.values.sorted() { $0[0].date! < $1[0].date! }
}
I'm also quite new to programming and the following solution might be a little less than elegant but ngl I'm quite proud to figure it out myself!
I added a bool to my object named lastOfDay that triggers a textview of the date on that object:
ForEach(allResults) { result in
VStack(spacing: 0) {
if currentMethod == .byDate {
if result.lastOfDay {
Text("\(result.date, formatter: dateFormatter)")
}
}
ListView(result: result)
}
}
Then I have an onAppear function that copies my fetched results to a separate, non-CoreData array, organizing them by date and checking whether the next result's day is different from the current object's day - and flipping the necessary bools. I hoped to achieve this through some version of .map but figured that it was necessary to account for situations when the list was empty or only had a single item.
if allResults.count != 0 {
if allResults.count == 1 {
allResults[0].lastOfDay = true
}
for i in 0..<(allResults.count-1) {
if allResults.count > 1 {
allResults[0].lastOfDay = true
if allResults[i].date.hasSame(.day, as: allResults[i+1].date) {
allResults[i+1].lastOfDay = false
} else {
allResults[i+1].lastOfDay = true
}
}
}
}
The hasSame date extension method I picked up on in this answer. I don't know how well this approach will work if you desire to let the user delete batches but it works perfectly for me because I only want to implement either singular deletes or delete all objects (however since I trigger the filtering process every time such a change happens - usage might get expensive w bigger data sets).
Embedding grouping into a managed object model is the way to go because it would be more robust and will work well with large data sets. I have provided an answer with a sample project on how to implement it.
When we are using init(grouping:by:) on Dictionary, we are likely recompiling the whole list, backed by a dictionary that does the grouping, every time we perform any list manipulation such as insertion or deletion, which is not performant and, in my case, causes jaggy animations. It doesn’t make sense performance-wise to fetch entities from the store sorted one way and then do the sorting locally again to divide them into sections.
Grouping with fetch request is not possible, as far as I know, because propertiesToGroupBy is the Core Data interface for using SQL GROUP BY query and is meant to be used with aggregate functions (e.g. min, max, sum) and not to divide data sets into sections.
Looks like #SectionedFetchRequest property wrapper exists just for such task. Below is an example made from boilerplate CoreData project.
The key is that you have to mark a var you're sectioning by as #objc.
extension Todo {
#objc
var sect: String { date?.formatted(date: .abbreviated, time: .omitted) ?? "Undefined" }
}
struct ContentView: View {
#Environment(\.managedObjectContext) private var viewContext
#SectionedFetchRequest(
sectionIdentifier: \Todo.sect,
sortDescriptors: [NSSortDescriptor(keyPath: \Todo.date, ascending: true)],
animation: .default)
private var items
var body: some View {
NavigationView {
List {
ForEach(items) { section in
Section(section.id) {
ForEach(section) { item in
NavigationLink {
Text("Item at \(item.date!, format: .dateTime.year().month().hour().minute().second())")
} label: {
Text(item.date!, formatter: itemFormatter)
}
}
}
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newItem = Todo(context: viewContext)
newItem.date = Date()
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
}
extension Todo {
static var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .short
return formatter
}
}

How to properly group a list fetched from CoreData by date?

For the sake of simplicity lets assume I want to create a simple todo app. I have an entity Todo in my xcdatamodeld with the properties id, title and date, and the following swiftui view (example pictured):
import SwiftUI
struct ContentView: View {
#Environment(\.managedObjectContext) var moc
#State private var date = Date()
#FetchRequest(
entity: Todo.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \Todo.date, ascending: true)
]
) var todos: FetchedResults<Todo>
var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .short
return formatter
}
var body: some View {
VStack {
List {
ForEach(todos, id: \.self) { todo in
HStack {
Text(todo.title ?? "")
Text("\(todo.date ?? Date(), formatter: self.dateFormatter)")
}
}
}
Form {
DatePicker(selection: $date, in: ...Date(), displayedComponents: .date) {
Text("Datum")
}
}
Button(action: {
let newTodo = Todo(context: self.moc)
newTodo.title = String(Int.random(in: 0 ..< 100))
newTodo.date = self.date
newTodo.id = UUID()
try? self.moc.save()
}, label: {
Text("Add new todo")
})
}
}
}
The todos are sorted by date upon fetching, and are displayed in a list like this:
I want to group the list based on each todos respective date as such (mockup):
From my understanding this could work with Dictionaries in the init() function, however I couldn't come up with anything remotely useful. Is there an efficient way to group data?
Update for iOS 15
SwiftUI now has built-in support for Sectioned Fetch Requests in a List via the #SectionedFetchRequest property wrapper. This wrapper reduces the amount of boilerplate required to group Core Data lists.
Example code
#Environment(\.managedObjectContext) var moc
#State private var date = Date()
#SectionedFetchRequest( // Here we use SectionedFetchRequest
entity: Todo.entity(),
sectionIdentifier: \.dateString // Add this line
sortDescriptors: [
SortDescriptor(\.date, order: .forward)
]
) var todos: SectionedFetchResults<Todo>
var body: some View {
VStack {
List {
ForEach(todos) { (section: [Todo]) in
Section(section[0].dateString!))) {
ForEach(section) { todo in
HStack {
Text(todo.title ?? "")
Text("\(todo.date ?? Date(), formatted: todo.dateFormatter)")
}
}
}
}.id(todos.count)
}
Form {
DatePicker(selection: $date, in: ...Date(), displayedComponents: .date) {
Text("Datum")
}
}
Button(action: {
let newTodo = Todo(context: self.moc)
newTodo.title = String(Int.random(in: 0 ..< 100))
newTodo.date = self.date
newTodo.id = UUID()
try? self.moc.save()
}, label: {
Text("Add new todo")
})
}
The Todo class can also be refactored to contain the logic for getting the date string. As a bonus, we can also use the .formatted beta method on Date to produce the relevant String.
struct Todo {
...
var dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .short
return formatter
}()
var dateString: String? {
formatter.string(from: date)
}
}
You may try the following, It should work in your situation.
#Environment(\.managedObjectContext) var moc
#State private var date = Date()
#FetchRequest(
entity: Todo.entity(),
sortDescriptors: [
NSSortDescriptor(keyPath: \Todo.date, ascending: true)
]
) var todos: FetchedResults<Todo>
var dateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateStyle = .short
return formatter
}
func update(_ result : FetchedResults<Todo>)-> [[Todo]]{
return Dictionary(grouping: result){ (element : Todo) in
dateFormatter.string(from: element.date!)
}.values.map{$0}
}
var body: some View {
VStack {
List {
ForEach(update(todos), id: \.self) { (section: [Todo]) in
Section(header: Text( self.dateFormatter.string(from: section[0].date!))) {
ForEach(section, id: \.self) { todo in
HStack {
Text(todo.title ?? "")
Text("\(todo.date ?? Date(), formatter: self.dateFormatter)")
}
}
}
}.id(todos.count)
}
Form {
DatePicker(selection: $date, in: ...Date(), displayedComponents: .date) {
Text("Datum")
}
}
Button(action: {
let newTodo = Todo(context: self.moc)
newTodo.title = String(Int.random(in: 0 ..< 100))
newTodo.date = self.date
newTodo.id = UUID()
try? self.moc.save()
}, label: {
Text("Add new todo")
})
}
}
To divide SwiftUI List backed by Core Data into sections, you can change your data model to support grouping. In this particular case, this can be achieved by introducing TodoSection entity to your managed object model. The entity would have a date attribute for sorting sections and a unique name string attribute that would serve as a section id, as well as a section header name. The unique quality can be enforced by using Core Data unique constraints on your managed object. Todos in each section can be modeled as a to many relationship to your Todo entity.
When saving a new Todo object, you would have to use Find or Create pattern to find out whether you already have a section in store or you would have to create a new one.
let sectionName = dateFormatter.string(from: date)
let sectionFetch: NSFetchRequest<TodoSection> = TodoSection.fetchRequest()
sectionFetch.predicate = NSPredicate(format: "%K == %#", #keyPath(TodoSection.name), sectionName)
let results = try! moc.fetch(sectionFetch)
if results.isEmpty {
// Section not found, create new section.
let newSection = TodoSection(context: moc)
newSection.name = sectionName
newSection.date = date
newSection.addToTodos(newTodo)
} else {
// Section found, use it.
let existingSection = results.first!
existingSection.addToTodos(newTodo)
}
To display your sections and accompanying todos nested ForEach views can be used with Section in between. Core Data uses NSSet? for to many relationships so you would have to use an array proxy and conform Todo to Comparable for everything to work with SwiftUI.
extension TodoSection {
var todosArrayProxy: [Todo] {
(todos as? Set<Todo> ?? []).sorted()
}
}
extension Todo: Comparable {
public static func < (lhs: Todo, rhs: Todo) -> Bool {
lhs.title! < rhs.title!
}
}
If you need to delete a certain todo, bear in mind that the last removed todo in section should also delete the entire section object.
I tried using init(grouping:by:) on Dictionary, as it has been suggested here, and, in my case, it causes jaggy animations, which are probably the sign that we are going in the wrong direction. I’m guessing the whole list of items has to be recompiled when we delete a single item. Furthermore, embedding grouping into a data model would be more performant and future-proof as our data set grows.
I have provided a sample project if you need any further reference.

Resources