I have a SwiftUI + CoreData simple Todo app, and everything works properly, but my updateTodo function which is supposed to handle the click on a todo and turn in from done to undone and vice versa, isn't working properly.
When I click on a todo nothing happens in the UI, but when I go a screen back and come back to the todos screen, I can see the UI change, also it does persist so when I close and open the app the change is being reflected in the app.
So my problem is that the 'isDone' property is not being toggled in the UI in real-time, and only when the view reappears it actually shows the change that has been made.
ViewModel (CoreData) :
class TodoViewModel:ObservableObject {
let container: NSPersistentContainer
#Published var categories = [CategoryTodo]()
#Published var todos = [Todo]()
init() {
container = NSPersistentContainer(name: "UniversityProject")
container.loadPersistentStores { (description, error) in
if let error = error {
fatalError("Error: \(error.localizedDescription)")
}
}
}
//MARK: - Todos
func getTodos() {
let request = NSFetchRequest<Todo>(entityName: "Todo")
let sort = NSSortDescriptor(key: #keyPath(Todo.dateCreated), ascending: false)
request.sortDescriptors = [sort]
do {
try todos = container.viewContext.fetch(request)
} catch {
print("Error getting data. \(error)")
}
}
func addTodo(todo text: String, category categoryName:String) {
let newTodo = Todo(context: container.viewContext)
newTodo.todo = text
newTodo.category = categoryName
newTodo.id = UUID().uuidString
newTodo.isDone = false
newTodo.dateCreated = Date()
saveTodo()
}
func saveTodo() {
do {
try container.viewContext.save()
getTodos()
} catch let error {
print("Error: \(error)")
}
}
func deleteTodo(indexSet: IndexSet) {
let todoIndex = indexSet[indexSet.startIndex]
let object = todos[todoIndex]
container.viewContext.delete(object)
saveTodo()
}
func updateTodo(item: Todo) {
item.setValue(!item.isDone, forKey: "isDone")
saveTodo()
}
}
TodosView:
struct TodosView: View {
#EnvironmentObject var viewModel: TodoViewModel
let categoryName:String
var body: some View {
List {
ForEach(viewModel.todos.filter{$0.category == categoryName}) { item in
TodoItem(item: item)
.onTapGesture {
withAnimation(.linear) {
viewModel.updateTodo(item: item)
}
}
}
.onDelete(perform: viewModel.deleteTodo)
}.onAppear { viewModel.getTodos() }
.navigationBarTitle(categoryName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
EditButton()
NavigationLink(destination: AddTodoView(category: categoryName)) {
Image(systemName: "plus.circle")
.resizable()
.foregroundColor(.blue)
.frame(width: 25, height: 25)
}
}
}
}
}
}
without all relevent code, I can only guess and suggest you try something like these:
func updateTodo(item: Todo) {
if let ndx = todos.firstIndex(where: {$0.id == item.id}) {
todos[ndx].isDone = !item.isDone
saveTodo()
}
}
or
func updateTodo(item: Todo) {
objectWillChange.send()
item.setValue(!item.isDone, forKey: "isDone")
saveTodo()
}
Related
I am using Relam to store the data locally and working fine but when I try to add the new record with navigation link it returns the duplicate record as well . Another problem is when I click the record , I am expecting change the navigation but since it got duplicate record , the first record does not work but the second one it work .
Here is the Model .
import SwiftUI
import RealmSwift
struct Task: Identifiable {
var id: String
var title: String
var completed: Bool = false
var completedAt: Date = Date()
init(taskObject: TaskObject) {
self.id = taskObject.id.stringValue
self.title = taskObject.title
self.completed = taskObject.completed
self.completedAt = taskObject.completedAt
}
}
Here is the Persisted Model...
import Foundation
import RealmSwift
class TaskObject: Object {
#Persisted(primaryKey: true) var id: ObjectId
#Persisted var title: String
#Persisted var completed: Bool = false
#Persisted var completedAt: Date = Date()
}
Here is the View Model ..
/
/ 2
final class TaskViewModel: ObservableObject {
// 3
#Published var tasks: [Task] = []
// 4
private var token: NotificationToken?
init() {
setupObserver()
}
deinit {
token?.invalidate()
}
// 5
private func setupObserver() {
do {
let realm = try Realm()
let results = realm.objects(TaskObject.self)
token = results.observe({ [weak self] changes in
// 6
self?.tasks = results.map(Task.init)
.sorted(by: { $0.completedAt > $1.completedAt })
.sorted(by: { !$0.completed && $1.completed })
})
} catch let error {
print(error.localizedDescription)
}
}
// 7
func addTask(title: String) {
let taskObject = TaskObject(value: [
"title": title,
"completed": false
])
do {
let realm = try Realm()
try realm.write {
realm.add(taskObject)
}
} catch let error {
print(error.localizedDescription)
}
}
// 8
func markComplete(id: String, completed: Bool) {
do {
let realm = try Realm()
let objectId = try ObjectId(string: id)
let task = realm.object(ofType: TaskObject.self, forPrimaryKey: objectId)
try realm.write {
task?.completed = completed
task?.completedAt = Date()
}
} catch let error {
print(error.localizedDescription)
}
}
func remove(id: String) {
do {
let realm = try Realm()
let objectId = try ObjectId(string: id)
if let task = realm.object(ofType: TaskObject.self, forPrimaryKey: objectId) {
try realm.write {
realm.delete(task)
}
}
} catch let error {
print(error.localizedDescription)
}
}
func updateTitle(id: String, newTitle: String) {
do {
let realm = try Realm()
let objectId = try ObjectId(string: id)
let task = realm.object(ofType: TaskObject.self, forPrimaryKey: objectId)
try realm.write {
task?.title = newTitle
}
} catch let error {
print(error.localizedDescription)
}
}
}
Here is the code for Content view ...
struct ContentView: View {
var body: some View {
NavigationView {
VStack {
AddTaskView()
TaskListView()
}
.navigationTitle("Todo")
.navigationBarTitleDisplayMode(.automatic)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Here is the code for Add task view ..
import SwiftUI
struct AddTaskView: View {
#State private var taskTitle: String = ""
#EnvironmentObject private var viewModel: TaskViewModel
var body: some View {
HStack(spacing: 12) {
TextField("Enter New Task..", text: $taskTitle)
Button(action: handleSubmit) {
Image(systemName: "plus")
}
}
.padding(20)
}
private func handleSubmit() {
viewModel.addTask(title: taskTitle)
taskTitle = ""
}
}
Here is the Task list View ..
struct TaskListView: View {
#EnvironmentObject private var viewModel: TaskViewModel
var body: some View {
ScrollView {
LazyVStack (alignment: .leading) {
ForEach(viewModel.tasks, id: \.id) { task in
TaskRowView(task: task)
Divider().padding(.leading, 20)
NavigationLink (destination: TaskView(task: task)) {
TaskRowView(task: task)
}.animation(.default)
}
}
}
}
}
Here is the code for Row View ..
struct TaskRowView: View {
let task: Task
// 1
#EnvironmentObject private var viewModel: TaskViewModel
var body: some View {
HStack(spacing: 12) {
Button(action: {
// 2
viewModel.markComplete(id: task.id, completed: !task.completed)
}) {
Image(systemName: task.completed ? "checkmark.circle.fill" : "circle")
.resizable()
.frame(width: 20, height: 20)
.foregroundColor(task.completed ? Color.green : Color.gray)
}
VStack(alignment: .leading, spacing: 8) {
Text(task.title)
.foregroundColor(.black)
if !task.completedAt.formatted().isEmpty {
Text(task.completedAt.formatted())
.foregroundColor(.gray)
.font(.caption)
}
}
Spacer()
}
.padding(EdgeInsets(top: 16, leading: 20, bottom: 16, trailing: 20))
}
}
Here is the screenshot ..
Let's troubleshoot the discrepancies one by one.
According to your code, each row in the list represents a Task. But, there are two models Task and TaskObject (persistable model) for that.
struct Task: Identifiable {
var id: String
var title: String
var completed: Bool = false
var completedAt: Date = Date()
init(taskObject: TaskObject) {
self.id = taskObject.id.stringValue
self.title = taskObject.title
self.completed = taskObject.completed
self.completedAt = taskObject.completedAt
}
}
class TaskObject: Object {
#Persisted(primaryKey: true) var id: ObjectId
#Persisted var title: String
#Persisted var completed: Bool = false
#Persisted var completedAt: Date = Date()
}
Instead of using two models, convert them into one.
class TaskObject: Object, Identifiable {
#Persisted(primaryKey: true) var id: ObjectId
#Persisted var title: String
#Persisted var completed: Bool = false
#Persisted var completedAt: Date = Date()
var idStr: String {
id.stringValue
}
}
Therefore, there's no need for mapping to another object after retrieving it from the database. The updated setupObserver function should be...
private func setupObserver() {
do {
let realm = try Realm()
let results = realm.objects(TaskObject.self)
token = results.observe({ [weak self] changes in
// 6
self?.tasks = results
.sorted(by: { $0.completedAt > $1.completedAt })
.sorted(by: { !$0.completed && $1.completed })
})
} catch let error {
print(error.localizedDescription)
}
}
Let's address your questions now.
When I try to add the new record with navigation link it returns the duplicate record as well
It does not produce duplicate data. Instead, the same data is displayed twice in the view. To correct this, remove one of the two instances of TaskRowView(task: task).
struct TaskListView: View {
#EnvironmentObject private var viewModel: TaskViewModel
var body: some View {
ScrollView {
LazyVStack (alignment: .leading) {
ForEach(viewModel.tasks, id: \.id) { task in
TaskRowView(task: task) // first row 📌
Divider().padding(.leading, 20)
NavigationLink (destination: TaskView(task: task)) {
TaskRowView(task: task) // second row 📌
}.animation(.default)
}
}
}
}
}
Next question,
I am expecting change the navigation but since it got duplicate record , the first record does not work but the second one it work.
Again, the second one changes navigation, and the first one does not, because this is exactly what is written in the code.
TaskRowView(task: task) // Why would it change navigation?
Divider().padding(.leading, 20)
NavigationLink (destination: TaskView(task: task)) {
TaskRowView(task: task) // changing navigation
}
I have a reminder app that I am trying to implement persistent data in but whenever I close the app no data is saved. I know how to make it work with a normal MVC but I would like to get it working with the view model that I have.
I think I know what needs to change to fix the problem but I am not sure how to get to the solution. I am pretty sure that in the ReminderApp file under the NavigationView where it says HomeViewModel(reminds: store.reminds) I think that the store.reminds part needs to be binded to with a $ at the beginning but when I try doing that it doesn't work and instead says that HomeViewModel reminds property expects Reminder instead of Binding.
ReminderStore loads and saves the reminders to a file with the reminders and HomeViewModel contains the reminders array and appends a reminder to the array when a user adds a new reminder.
If anyone knows how to get this working that would be great since I have been stuck on this. My minimal reproducable example code is below.
RemindersApp
import SwiftUI
#main
struct RemindersApp: App {
#StateObject private var store = ReminderStore()
var body: some Scene {
WindowGroup {
NavigationView {
HomeView(homeVM: HomeViewModel(reminds: store.reminds)) {
ReminderStore.save(reminds: store.reminds) { result in
if case .failure(let error) = result {
fatalError(error.localizedDescription)
}
}
}
.navigationBarHidden(true)
}
.onAppear {
ReminderStore.load { result in
switch result {
case .failure(let error):
fatalError(error.localizedDescription)
case .success(let reminds):
store.reminds = reminds
}
}
}
}
}
}
HomeView
import SwiftUI
struct HomeView: View {
#StateObject var homeVM: HomeViewModel
#Environment(\.scenePhase) private var scenePhase
#State var addView = false
let saveAction: ()->Void
var body: some View {
VStack {
List {
ForEach($homeVM.reminds) { $remind in
Text(remind.title)
}
}
}
.safeAreaInset(edge: .top) {
HStack {
Text("Reminders")
.font(.title)
.padding()
Spacer()
Button(action: {
addView.toggle()
}) {
Image(systemName: "plus")
.padding()
.font(.title2)
}
.sheet(isPresented: $addView) {
NavigationView {
VStack {
Form {
TextField("Title", text: $homeVM.newRemindData.title)
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Dismiss") {
homeVM.newRemindData = Reminder.Data()
addView.toggle()
}
}
ToolbarItem(placement: .principal) {
Text("New Reminder")
.font(.title3)
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") {
homeVM.addRemindData(remindData: homeVM.newRemindData)
addView.toggle()
}
}
}
}
}
.onChange(of: scenePhase) { phase in
if phase == .inactive { saveAction() }
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
HomeView(homeVM: HomeViewModel(reminds: Reminder.sampleReminders), saveAction: {})
}
}
ReminderStore
import Foundation
import SwiftUI
class ReminderStore: ObservableObject {
#Published var reminds: [Reminder] = []
private static func fileURL() throws -> URL {
try FileManager.default.url(for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false)
.appendingPathComponent("reminds.data")
}
static func load(completion: #escaping (Result<[Reminder], Error>) -> Void) {
DispatchQueue.global(qos: .background).async {
do {
let fileURL = try fileURL()
guard let file = try? FileHandle(forReadingFrom: fileURL) else {
DispatchQueue.main.async {
completion(.success([]))
}
return
}
let reminds = try JSONDecoder().decode([Reminder].self, from: file.availableData)
DispatchQueue.main.async {
completion(.success(reminds))
}
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
}
static func save(reminds: [Reminder], completion: #escaping (Result<Int, Error>) -> Void) {
do {
let data = try JSONEncoder().encode(reminds)
let outfile = try fileURL()
try data.write(to: outfile)
DispatchQueue.main.async {
completion(.success(reminds.count))
}
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
}
HomeViewModel
import Foundation
class HomeViewModel: ObservableObject {
#Published var reminds: [Reminder]
#Published var newRemindData = Reminder.Data()
init(reminds: [Reminder]) {
self.reminds = reminds
}
func addRemindData(remindData: Reminder.Data) {
let newRemind = Reminder(data: remindData)
reminds.append(newRemind)
newRemindData = Reminder.Data()
}
}
Reminder
import Foundation
struct Reminder: Identifiable, Codable {
var title: String
let id: UUID
init(title: String, id: UUID = UUID()) {
self.title = title
self.id = id
}
}
extension Reminder {
struct Data {
var title: String = ""
var id: UUID = UUID()
}
var data: Data {
Data(title: title)
}
mutating func update(from data: Data) {
title = data.title
}
init(data: Data) {
title = data.title
id = UUID()
}
}
extension Reminder {
static var sampleReminders = [
Reminder(title: "Reminder1"),
Reminder(title: "Reminder2"),
Reminder(title: "Reminder3")
]
}
The reason you are struggeling here is because you try to have multiple Source of truth.
documentation on dataflow in SwiftUI
You should move the code from HomeViewModel to your ReminderStore and change the static functions to instance functions. This would keep your logic in one place.
You can pass your ReminderStore to your HomeView as an #EnvironmentObject
This would simplify your code to:
class ReminderStore: ObservableObject {
#Published var reminds: [Reminder] = []
#Published var newRemindData = Reminder.Data()
private func fileURL() throws -> URL {
try FileManager.default.url(for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: false)
.appendingPathComponent("reminds.data")
}
func load() {
DispatchQueue.global(qos: .background).async {
do {
let fileURL = try self.fileURL()
guard let file = try? FileHandle(forReadingFrom: fileURL) else {
return
}
let reminds = try JSONDecoder().decode([Reminder].self, from: file.availableData)
DispatchQueue.main.async {
self.reminds = reminds
}
} catch {
DispatchQueue.main.async {
fatalError(error.localizedDescription)
}
}
}
}
func save() {
do {
let data = try JSONEncoder().encode(reminds)
let outfile = try fileURL()
try data.write(to: outfile)
} catch {
fatalError(error.localizedDescription)
}
}
func addRemindData() {
let newRemind = Reminder(data: newRemindData)
reminds.append(newRemind)
newRemindData = Reminder.Data()
}
}
struct RemindersApp: App {
#StateObject private var store = ReminderStore()
var body: some Scene {
WindowGroup {
NavigationView {
HomeView() {
store.save()
}
.navigationBarHidden(true)
.environmentObject(store)
}
.onAppear {
store.load()
}
}
}
}
struct HomeView: View {
#Environment(\.scenePhase) private var scenePhase
#EnvironmentObject private var store: ReminderStore
#State var addView = false
let saveAction: ()->Void
var body: some View {
VStack {
List {
ForEach(store.reminds) { remind in
Text(remind.title)
}
}
}
.safeAreaInset(edge: .top) {
HStack {
Text("Reminders")
.font(.title)
.padding()
Spacer()
Button(action: {
addView.toggle()
}) {
Image(systemName: "plus")
.padding()
.font(.title2)
}
.sheet(isPresented: $addView) {
NavigationView {
VStack {
Form {
TextField("Title", text: $store.newRemindData.title)
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Dismiss") {
store.newRemindData = Reminder.Data()
addView.toggle()
}
}
ToolbarItem(placement: .principal) {
Text("New Reminder")
.font(.title3)
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") {
store.addRemindData()
addView.toggle()
}
}
}
}
}
.onChange(of: scenePhase) { phase in
if phase == .inactive { saveAction() }
}
}
}
}
}
An issue I would recommend solving:
Naming a type after something that´s allready taken by Swift is a bad idea. You should rename your Data struct to something different.
ReminderStore.save isn't invoking in time.
By the time it invokes it doesn't have/get the reminder data.
That's the first thing I would make sure gets done. You may end up running into other issues afterward, but I would personally focus on that first.
I have a swiftui list with a .searchable modifier, contained within a navigation view child view, within a sheet presentation. When the Cancel button is used, not only is the search field dismissed (unfocused, keyboard dismissed etc), but the navigation view dismisses (ie moves back a page) and then the containing sheet view also dismisses.
This list acts as a custom picker view that allows the selection of multiple HKWorkoutActivityTypes. I use it in multiple locations and the bug only sometimes presents itself, despite being implemented in almost identical views. If I swap the .sheet for a .fullScreenCover, then the presentation of the bug swaps between those previously unaffected and those that were.
The parent view (implemented as a form item, in .sheet):
struct MultipleWorkoutActivityTypePickerFormItem: View, Equatable {
static func == (lhs: MultipleWorkoutActivityTypePickerFormItem, rhs: MultipleWorkoutActivityTypePickerFormItem) -> Bool {
return lhs.workoutActivityTypes == rhs.workoutActivityTypes
}
#Binding var workoutActivityTypes: [HKWorkoutActivityType]
var workoutActivityTypeSelectionDescription: String {
var string = ""
if workoutActivityTypes.count == 1 {
string = workoutActivityTypes.first?.commonName ?? ""
} else if workoutActivityTypes.count == 2 {
string = "\(workoutActivityTypes[0].commonName) & \(workoutActivityTypes[1].commonName)"
} else if workoutActivityTypes.count > 2 {
string = "\(workoutActivityTypes.first?.commonName ?? "") & \(workoutActivityTypes.count - 1) others"
} else {
string = "Any Workout"
}
return string
}
var body: some View {
NavigationLink {
MultipleWorkoutActivityTypePickerView(selectedWorkoutActivityTypes: $workoutActivityTypes)
.equatable()
} label: {
HStack {
Text("Workout Types:")
Spacer()
Text(workoutActivityTypeSelectionDescription)
.foregroundColor(.secondary)
}
}
}
}
And the child view:
struct MultipleWorkoutActivityTypePickerView: View, Equatable {
static func == (lhs: MultipleWorkoutActivityTypePickerView, rhs: MultipleWorkoutActivityTypePickerView) -> Bool {
return (lhs.favouriteWorkoutActivityTypes == rhs.favouriteWorkoutActivityTypes && lhs.selectedWorkoutActivityTypes == rhs.selectedWorkoutActivityTypes)
}
#State var searchString: String = ""
#Environment(\.dismissSearch) var dismissSearch
#Environment(\.isSearching) var isSearching
//MARK: - FUNCTIONS
#Binding var selectedWorkoutActivityTypes: [HKWorkoutActivityType]
#State var favouriteWorkoutActivityTypes: [FavouriteWorkoutActivityType] = []
#State var searchResults: [HKWorkoutActivityType] = HKWorkoutActivityType.allCases
let viewContext = PersistenceController.shared.container.viewContext
func loadFavouriteWorkoutActivityTypes() {
let request: NSFetchRequest<FavouriteWorkoutActivityType> = FavouriteWorkoutActivityType.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \FavouriteWorkoutActivityType.index, ascending: true)]
do {
try favouriteWorkoutActivityTypes = viewContext.fetch(request)
} catch {
print(error.localizedDescription)
}
print("FavouriteWorkoutActivityType - Load")
}
func updateSearchResults() {
if searchString.isEmpty {
searchResults = HKWorkoutActivityType.allCases
} else {
searchResults = HKWorkoutActivityType.allCases.filter { $0.commonName.contains(searchString) }
}
}
func createFavouriteWorkoutActivityType(workoutActivityType: HKWorkoutActivityType) {
let newFavouriteWorkoutActivityType = FavouriteWorkoutActivityType(context: viewContext)
newFavouriteWorkoutActivityType.workoutActivityTypeRawValue = Int16(workoutActivityType.rawValue)
newFavouriteWorkoutActivityType.index = Int16(favouriteWorkoutActivityTypes.count)
print(newFavouriteWorkoutActivityType)
do {
try viewContext.save()
} catch {
print(error.localizedDescription)
}
loadFavouriteWorkoutActivityTypes()
}
func updateFavouriteWorkoutActivityTypeIndex(favouriteWorkoutActivityType: FavouriteWorkoutActivityType) {
//reoder
}
func deleteFavouriteWorkoutActivityType(favouriteWorkoutActivityType: FavouriteWorkoutActivityType) {
viewContext.delete(favouriteWorkoutActivityType)
do {
try viewContext.save()
} catch {
print(error.localizedDescription)
}
loadFavouriteWorkoutActivityTypes()
}
func deleteFavouriteWorkoutActivityTypeFor(workoutActivityType: HKWorkoutActivityType) {
for favouriteWorkoutActivityType in favouriteWorkoutActivityTypes {
if favouriteWorkoutActivityType.workoutActivityTypeRawValue == workoutActivityType.rawValue {
viewContext.delete(favouriteWorkoutActivityType)
}
}
do {
try viewContext.save()
} catch {
print(error.localizedDescription)
}
loadFavouriteWorkoutActivityTypes()
}
func deleteItems(offsets: IndexSet) {
offsets.map { favouriteWorkoutActivityTypes[$0] }.forEach(viewContext.delete)
renumberItems()
do {
try viewContext.save()
} catch {
print(error.localizedDescription)
}
loadFavouriteWorkoutActivityTypes()
}
func renumberItems() {
if favouriteWorkoutActivityTypes.count > 1 {
for item in 1...favouriteWorkoutActivityTypes.count {
favouriteWorkoutActivityTypes[item - 1].index = Int16(item)
}
} else if favouriteWorkoutActivityTypes.count == 1 {
favouriteWorkoutActivityTypes[0].index = Int16(1)
}
// print("ChartsViewModel - Renumber")
do {
try viewContext.save()
} catch {
print(error.localizedDescription)
}
loadFavouriteWorkoutActivityTypes()
}
func moveItems(from source: IndexSet, to destination: Int) {
// Make an array of items from fetched results
var revisedItems: [FavouriteWorkoutActivityType] = favouriteWorkoutActivityTypes.map{ $0 }
// change the order of the items in the array
revisedItems.move(fromOffsets: source, toOffset: destination)
// update the userOrder attribute in revisedItems to
// persist the new order. This is done in reverse order
// to minimize changes to the indices.
for reverseIndex in stride( from: revisedItems.count - 1,
through: 0,
by: -1 )
{
revisedItems[reverseIndex].index =
Int16(reverseIndex)
}
// print("ChartsViewModel - Move")
do {
try viewContext.save()
} catch {
print(error.localizedDescription)
}
loadFavouriteWorkoutActivityTypes()
}
var body: some View {
List {
if searchString == "" {
Section {
ForEach(favouriteWorkoutActivityTypes) { favouriteWorkoutActivityType in
if let workoutActivityType = HKWorkoutActivityType(rawValue: UInt(favouriteWorkoutActivityType.workoutActivityTypeRawValue)) {
HStack {
HStack {
Label {
Text(workoutActivityType.commonName)
} icon: {
Image(systemName: (selectedWorkoutActivityTypes.contains(workoutActivityType) ? "checkmark.circle" : "circle"))
}
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
if !selectedWorkoutActivityTypes.contains(where: {$0 == workoutActivityType}) {
selectedWorkoutActivityTypes.append(workoutActivityType)
print("does not contain, adding:", workoutActivityType.commonName)
} else {
selectedWorkoutActivityTypes = selectedWorkoutActivityTypes.filter({$0 != workoutActivityType})
print("does contain, removing:", workoutActivityType.commonName)
}
}
Button {
withAnimation {
deleteFavouriteWorkoutActivityType(favouriteWorkoutActivityType: favouriteWorkoutActivityType)
}
} label: {
Image(systemName: "heart.fill")
}
.buttonStyle(BorderlessButtonStyle())
}
}
}
.onDelete(perform: deleteItems(offsets:))
.onMove(perform: moveItems(from:to:))
if favouriteWorkoutActivityTypes.count < 1 {
Text("Save your favourite workout types by hitting the hearts below.")
.foregroundColor(.secondary)
}
} header: {
Text("Favourites:")
}
}
Section {
ForEach(searchResults, id: \.self) { workoutActivityType in
HStack {
HStack {
Label {
Text(workoutActivityType.commonName)
} icon: {
Image(systemName: (selectedWorkoutActivityTypes.contains(workoutActivityType) ? "checkmark.circle" : "circle"))
}
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
if !selectedWorkoutActivityTypes.contains(where: {$0 == workoutActivityType}) {
selectedWorkoutActivityTypes.append(workoutActivityType)
print("does not contain, adding:", workoutActivityType.commonName)
} else {
selectedWorkoutActivityTypes = selectedWorkoutActivityTypes.filter({$0 != workoutActivityType})
print("does contain, removing:", workoutActivityType.commonName)
}
}
if favouriteWorkoutActivityTypes.contains(where: { FavouriteWorkoutActivityType in
workoutActivityType.rawValue == UInt(FavouriteWorkoutActivityType.workoutActivityTypeRawValue)
}) {
Button {
withAnimation {
deleteFavouriteWorkoutActivityTypeFor(workoutActivityType: workoutActivityType)
}
} label: {
Image(systemName: "heart.fill")
}
.buttonStyle(BorderlessButtonStyle())
} else {
Button {
withAnimation {
createFavouriteWorkoutActivityType(workoutActivityType: workoutActivityType)
}
} label: {
Image(systemName: "heart")
}
.buttonStyle(BorderlessButtonStyle())
}
}
}
} header: {
if searchString == "" {
Text("All:")
} else {
Text("Results:")
}
}
}
.searchable(text: $searchString.animation(), prompt: "Search")
.onChange(of: searchString, perform: { _ in
withAnimation {
updateSearchResults()
}
})
.onAppear {
loadFavouriteWorkoutActivityTypes()
}
.navigationBarTitle(Text("Type"), displayMode: .inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
}
}
}
I have been struggling to get an app to work with CloudKit and record sharing. I have
created several apps that sync Core Data records among devices for one user. I have not
been able to get the UICloudSharingController to work for sharing records. I can display
the Sharing View Controller, but tapping on Mail or Message displays a keyboard but no
address field and no way to dismiss the view. I have been so frustrated by it that I
decided to try the Apple "Sharing" sample app to start from the basics. However, the
sample app does not work for me either.
Here's the link to the sample app:
https://github.com/apple/cloudkit-sample-sharing/tree/swift-concurrency
The code below is pretty much straight from the sample app.
This is the ContentView file:
import SwiftUI
import CloudKit
struct ContentView: View {
#EnvironmentObject private var vm: ViewModel
#State private var isAddingContact = false
#State private var isSharing = false
#State private var isProcessingShare = false
#State private var showShareView = false
#State private var showIntermediateView = false
#State private var activeShare: CKShare?
#State private var activeContainer: CKContainer?
var body: some View {
NavigationView {
contentView
.navigationTitle("Contacts")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { Task.init { try await vm.refresh() } } label: { Image(systemName: "arrow.clockwise") }
}
ToolbarItem(placement: .navigationBarLeading) {
progressView
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { isAddingContact = true }) { Image(systemName: "plus") }
}
}
}
.onAppear {
Task.init {
try await vm.initialize()
try await vm.refresh()
}
}
.sheet(isPresented: $isAddingContact, content: {
AddContactView(onAdd: addContact, onCancel: { isAddingContact = false })
})
}
/// This progress view will display when either the ViewModel is loading, or a share is processing.
var progressView: some View {
let showProgress: Bool = {
if case .loading = vm.state {
return true
} else if isProcessingShare {
return true
}
return false
}()
return Group {
if showProgress {
ProgressView()
}
}
}
/// Dynamic view built from ViewModel state.
private var contentView: some View {
Group {
switch vm.state {
case let .loaded(privateContacts, sharedContacts):
List {
Section(header: Text("Private")) {
ForEach(privateContacts) { contactRowView(for: $0) }
}
Section(header: Text("Shared")) {
ForEach(sharedContacts) { contactRowView(for: $0, shareable: false) }
}
}.listStyle(GroupedListStyle())
case .error(let error):
VStack {
Text("An error occurred: \(error.localizedDescription)").padding()
Spacer()
}
case .loading:
VStack { EmptyView() }
}
}
}
/// Builds a `CloudSharingView` with state after processing a share.
private func shareView() -> CloudSharingView? {
guard let share = activeShare, let container = activeContainer else {
return nil
}
return CloudSharingView(container: container, share: share)
}
/// Builds a Contact row view for display contact information in a List.
private func contactRowView(for contact: Contact, shareable: Bool = true) -> some View {
HStack {
VStack(alignment: .leading) {
Text(contact.name)
Text(contact.phoneNumber)
.textContentType(.telephoneNumber)
.font(.footnote)
}
if shareable {
Spacer()
Button(action: { Task.init { try? await shareContact(contact) }
}, label: { Image(systemName: "square.and.arrow.up") }).buttonStyle(BorderlessButtonStyle())
.sheet(isPresented: $isSharing, content: { shareView() })
}//if sharable
}//h
}//contact row view
// MARK: - Actions
private func addContact(name: String, phoneNumber: String) async throws {
try await vm.addContact(name: name, phoneNumber: phoneNumber)
try await vm.refresh()
isAddingContact = false
}
private func shareContact(_ contact: Contact) async throws {
isProcessingShare = true
do {
let (share, container) = try await vm.createShare(contact: contact)
isProcessingShare = false
activeShare = share
activeContainer = container
isSharing = true
} catch {
debugPrint("Error sharing contact record: \(error)")
}
}
}
And the UIViewControllerRepresentable file for the sharing view:
import Foundation
import SwiftUI
import UIKit
import CloudKit
/// This struct wraps a `UIImagePickerController` for use in SwiftUI.
struct CloudSharingView: UIViewControllerRepresentable {
#Environment(\.presentationMode) var presentationMode
let container: CKContainer
let share: CKShare
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
func makeUIViewController(context: Context) -> some UIViewController {
let sharingController = UICloudSharingController(share: share, container: container)
sharingController.availablePermissions = [.allowReadWrite, .allowPrivate]
sharingController.delegate = context.coordinator
return sharingController
}
func makeCoordinator() -> CloudSharingView.Coordinator {
Coordinator()
}
final class Coordinator: NSObject, UICloudSharingControllerDelegate {
func cloudSharingController(_ csc: UICloudSharingController, failedToSaveShareWithError error: Error) {
debugPrint("Error saving share: \(error)")
}
func itemTitle(for csc: UICloudSharingController) -> String? {
"Sharing Example"
}
}
}
This is the presented screen when tapping to share a record. This all looks as expected:
This is the screen after tapping Messages (same result for Mail). I don't see any way to influence the second screen presentation - it seems that the view controller representable is not working with this version of Xcode:
Any guidance would be appreciated. Xcode 13.1, iOS 15 and I am on macOS Monterrey.
I had the same issue and fixed it by changing makeUIViewController() in CloudSharingView.swift:
func makeUIViewController(context: Context) -> some UIViewController {
share[CKShare.SystemFieldKey.title] = "Boom"
let sharingController = UICloudSharingController(share: share, container: container)
sharingController.availablePermissions = [.allowReadWrite, .allowPrivate]
sharingController.delegate = context.coordinator
-->>> sharingController.modalPresentationStyle = .none
return sharingController
}
It seems like some value work, some don't. Not sure why.
I am trying to create a simple application that leverages CloudKit to sync between multiple iOS devices (via iCloud) and possibly macOS devices (still via iCloud).
Problem:
I have a core data entity which seems to work great locally in my app. When I switch to using Cloudkit I am unable to see changes on another device without closing/reopening the app.
I am using the Cloudkit template in Xcode with the SwiftUI lifecycle. ie, PersistenceController and managed object context.
I think this is a view not refreshing issue, but am not 100% sure. Once the app on a different device is closed and reopened then it loads the new data successfully. This applies to additions and deletes.
Testing:
I have tested this using Testflight as well as running two simulators locally.
Code:
PersistenceController.swift
import CoreData
struct PersistenceController {
static var shared = PersistenceController()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for i in 0..<10 {
let newItem = Card(context: viewContext)
newItem.cardValue = String(i)
newItem.cardOrder = Int32(i)
}
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
//Is this needed?
lazy var updateContext: NSManagedObjectContext = {
let _updateContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
_updateContext.parent = PersistenceController.shared.updateContext
return _updateContext
}()
let container: NSPersistentCloudKitContainer
init(inMemory: Bool = false) {
container = NSPersistentCloudKitContainer(name: "Cards")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
}
}
ContentView.swift
import SwiftUI
import CoreData
struct ContentView: View {
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(entity: Card.entity(),
sortDescriptors: [NSSortDescriptor(key: "cardOrder", ascending: true)])
var cards: FetchedResults<Card>
var body: some View {
NavigationView {
VStack {
Text("Scrum +")
.font(.largeTitle)
.fontWeight(.regular)
Spacer()
List(cards, id: \.id) {card in
NavigationLink(destination: PointRow(card: card)) {
PointRow(card: card)
}
}
Spacer()
NavigationLink(destination: SettingsView().environment(\.managedObjectContext, self.managedObjectContext)
) {
Text("Card Settings")
}
.padding(.bottom)
}
}
}
}
SettingsView.swift (where saving/deleting/reordering happens)
import SwiftUI
struct SettingsView: View {
#State private var editMode = EditMode.inactive
#State private var showModal = false
#Environment(\.managedObjectContext) var managedObjectContext
#FetchRequest(sortDescriptors: [NSSortDescriptor(key: "cardOrder", ascending: true)])
var cards: FetchedResults<Card>
var body: some View {
VStack {
Text("Card Values")
.font(.largeTitle)
HStack {
EditButton()
.padding(.leading)
Spacer()
addButton
.padding(.trailing)
}
.padding(.vertical)
List {
ForEach(cards) {
item in
Text(item.cardValue!)
}
.onDelete(perform: onDelete)
.onMove(perform: onMove)
.environment(\.editMode, $editMode)
}
.sheet(isPresented: $showModal) {
//Show the view to add a new card.
SettingsModal(showModal: self.$showModal, numberOfCards: cards.count).environment(\.managedObjectContext, self.managedObjectContext)
}
}
}
private var addButton: some View {
switch editMode {
case .inactive:
return AnyView(Button(action: onAdd) { Image(systemName: "plus") })
default:
return AnyView(EmptyView())
}
}
func onAdd() {
showModal = true
}
func onDelete(indexSet: IndexSet) {
// print("Deleting card at index -> " + indexSet.first)
let cardToDelete = self.cards[indexSet.first!]
self.managedObjectContext.delete(cardToDelete)
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
reorder()
}
private func onMove(source: IndexSet, destination: Int) {
// pointCards.points.move(fromOffsets: source, toOffset: destination)
// source.
let firstCard = self.cards[source.first!]
//If there is a card located in the destination.
if (destination < self.cards.count) {
let secondCard = self.cards[destination]
let tmp = Int(secondCard.cardOrder)
//Increment all place holders from the destination on.
for i in tmp..<self.cards.count {
self.cards[i].cardOrder += 1
}
}
firstCard.cardOrder = Int32(destination)
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
self.managedObjectContext.refreshAllObjects()
//Reorder just in case moved to the end.
reorder()
}
private func reorder() {
for i in 0..<cards.count {
cards[i].cardOrder = Int32(i)
}
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
}
}
Screenshots of my Core Data model using Cloudkit and of the Entitlement selection.
Capabilities
CoreData Properties
UPDATE:
I have found the solution.
The Persistent Controller should have the following three lines added to the init function:
//Setup auto merge of Cloudkit data
container.viewContext.automaticallyMergesChangesFromParent = true
container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
//Set the Query generation to .current. for dynamically updating views from Cloudkit
try? container.viewContext.setQueryGenerationFrom(.current)
Then this must be ran on real devices. For whatever reason I am unable to get this working in the simulator, but on real devices it syncs in about 30 seconds without closing the app.
Thanks everyone for your help!