How to use SwiftUI Picker to update a Realm to-one relationship? - ios

I have the following Realm schema where a Race is done on a Track:
final class Race: Object, ObjectKeyIdentifiable {
#Persisted(primaryKey: true) var _id: ObjectId
#Persisted var track: Track?
#Persisted var duration: Int = 45
}
final class Track: Object, ObjectKeyIdentifiable {
#Persisted(primaryKey: true) var _id: ObjectId
#Persisted var name: String = "Imola"
#Persisted var country: String = "🇮🇹"
#Persisted(originProperty: "tracks") var group: LinkingObjects<TrackGroup>
}
final class TrackGroup: Object, ObjectKeyIdentifiable {
#Persisted(primaryKey: true) var _id: ObjectId
#Persisted var tracks = RealmSwift.List<Track>()
}
In my ContentView I have an Add Button that opens a sheet (AddRaceView). The new Race is already created when the sheet appears. Now, I want to use a Picker for the Track selection for our newly created Race.
The following code does not update the Track for the editable Race, and I do not understand why:
struct AddRaceView: View {
#ObservedRealmObject var race: Race
#ObservedRealmObject var trackGroup: TrackGroup
var body: some View {
Form {
chooseTrackSection
raceDurationSection
}
}
#State private var trackPickerVisible = false
var chooseTrackSection: some View {
Section(header: Text("Track")) {
Button {
withAnimation(.easeIn) {
self.trackPickerVisible.toggle()
}
} label: {
HStack {
Text(race.track?.name ?? "")
Spacer()
Image(systemName: "arrow.turn.right.down")
}
}
if trackPickerVisible {
// HERE: Selection is not processed.
Picker(selection: $race.track, label: Text("Track")) {
ForEach(trackGroup.tracks) {
Text($0.name)
}
}
.pickerStyle(.wheel)
}
}
}
Updating other values in Race (like duration) does work! When Track is a String for example, I can use the Picker to make a selection. The problem must be connected to the fact that I'm trying to change a Realm object/relationship.

There are three things that need to be taken into account:
Picker needs to know where to find the values. The value can be specified manually by adding .tag(value) to the elements. While ForEach provides implicit tags for objects that conform to Identifiable, the type doesn't match in your case (needs to be Optional<Track> instead of Track).
The Picker compares all tag values to the selection to find out which item is currently selected. The comparison fails if the objects are not from the same Realm instance. Unfortunately, there isn't currently any way to specify a Realm for ObservedResults or an ObservedRealmObject.
Referencing objects from a frozen Realm doesn't work, so they (or their Realm) have to be thawed first.
Code:
// Fetch the Tracks from the Race's Realm by finding the TrackGroup by primaryKey
if let tracks = race.realm?.thaw().object(ofType: TrackGroup.self, forPrimaryKey: trackGroup._id)?.tracks {
Picker("Track", selection: $race.track) {
ForEach(tracks) { track in
Text(track.name)
.tag(Optional(track)) // Specify the value, making it optional to exactly match the expected type
}
}
}

Related

#Published Realm object not triggering view redrawing in SwiftUI

In our app we use a UserService that is a ObservableObject and passed as environment. A synced realm is opened and the app user (a RealmObject) is obtained using flexible sync.
When updating the users properties, such as his username, the view does not get redrawn. This is against my expectations since UserService contains a #Published property where the user (that is being edited) is stored. On the database it clearly shows the property being edited, however the view does not get redrawn, only when restarting the app the new properties are shown.
What would be the best way to have a UserService objects taking care of all user related logic (storing a user object (reference to it), containing functions to update, ...) and use this to display the active data of this user throughout the views?
Here is a MRE (the login logic is left out to reduce complexity):
import SwiftUI
import RealmSwift
class UserService2: ObservableObject {
var realm: Realm
#Published var ownUser: User
var userNotificationToken: NotificationToken?
init(realm: Realm, ownUser: User) {
self.realm = realm
self.ownUser = ownUser
userNotificationToken = ownUser.observe { change in
print(change) // just to see that the user object is actually live and being updated...
}
}
func changeName(newName: String) {
do {
try self.realm.write {
self.ownUser.userName = newName
}
} catch {
print("error")
}
}
}
struct TestView: View {
#EnvironmentObject var userService: UserService2
var body: some View {
VStack {
Text(userService.ownUser.userName ?? "no name")
Button {
userService.changeName(newName: Date().description)
} label: {
Text("change name")
}
}
}
}
struct ContentView: View {
var realm: Realm? = nil
init() {
let flexSyncConfig = app.currentUser!.flexibleSyncConfiguration(initialSubscriptions: { subs in
subs.append(
QuerySubscription<User>(name: "ownUserQuery") {
$0._id == "123"
})
})
do {
let realm = try Realm(configuration: flexSyncConfig)
self.realm = realm
} catch {
print("sth went wrong")
}
}
var body: some View {
if let realm = realm, let ownUser = realm.objects(User.self).where( { $0._id == "123" } ).first {
TestView()
.environmentObject(UserService2(realm: realm, ownUser: ownUser))
} else {
ProgressView()
}
}
}
The User Object looks like this
import Foundation
import RealmSwift
class User: Object, ObjectKeyIdentifiable {
#Persisted(primaryKey: true) var _id = UUID().uuidString
#Persisted var userName: String?
convenience init(_id: String? = nil, userName: String? = nil) {
self.init()
if let _id = _id {
self._id = _id
}
self.userName = userName
}
}
P.S. I assume I could observe changes on the object using realm and somehow force a view refresh, but I would find it much more clean using the already existing way to watch for changes and redraw views when needed using #Published...
P.P.S. This user object is created on the server using a trigger when someone authenticates. However, I assume this is not really relevant to this problem.
The issue here is the usage of a reference type as "Source of truth".
ObservableObject and SwiftUI Views use Combine Publishers to know when to refresh.
The #Published value sends the .objectWillChange publisher of the ObservableObject only when its wrapped value "changes". "changes" in this context means it gets replaced. So value types are preferred here, because if you change one of the properties the whole object will be replaced. This does not happen for reference types.
Multiple possible solutions here:
change the User class to a struct (Probably not wanted here, because this object implements Realm)
use the .objectWillChange.send() method yourself before altering the user
instead of altering the ownUservar replace it with a new one that contains the new information.
func changeName(newName: String) {
do {
self.objectWillChange.send() //add this
try self.realm.write {
self.ownUser.userName = newName
}
} catch {
print("error")
}
}

SwiftUI Published array not updating list on sort

Basically, I have this list view that can be sorted by PPD or CPD and based on the toggle, I want it to show one or the other. The problem is, when I click the toggle, the array is sorted but the list does not update properly. It's almost as if its late or something, When I initially click the toggle to sort list by PPD - no change occurs, then when i switch it back to sort by CPD, it is now sorted by PPD. The actual array is sorted in the correct order but the list itself is displaying the previous sort. Code below:
import SwiftUI
struct RestaurantMenu: View {
#ObservedObject var restaurant: Restaurant
//when Restaurant's #Published variable (sortedMenu) changes, we want list to update.
//it fucking should since restaurant is observedobject and menu is published, but it is either
//updating late, or only sometimes, out of sync, i got no clue
#State private var sortByPPD: Bool = false
var body: some View {
VStack{
Toggle(isOn: self.$sortByPPD) {
Text("Sort by PPD")
}.onChange(of: sortByPPD) { _ in
sortMenu(restaurant: restaurant, sortByCPD: !sortByPPD)
}
if(sortByPPD) {
List {
ForEach(restaurant.sortedMenu) { anItem in
PPDMenuItem(item: anItem)
}
}
.navigationBarTitle(Text("Menu"), displayMode: .inline)
}
else {
List {
ForEach(restaurant.sortedMenu) { anItem in
CPDMenuItem(item: anItem)
}
}
.navigationBarTitle(Text("Menu"), displayMode: .inline)
}
}
}
}
This is the class with the list that wont sort correctly ^
import Foundation
import CoreData
import SwiftUI
public class Restaurant: NSManagedObject, Identifiable {
#NSManaged public var name: String?
#NSManaged public var menu: NSSet?
#Published public var sortedMenu = [FoodItem]()
}
extension Restaurant {
static func allRestaurantsFetchRequest() -> NSFetchRequest<Restaurant> {
let fetchRequest = NSFetchRequest<Restaurant>(entityName: "Restaurant")
fetchRequest.sortDescriptors =
[NSSortDescriptor(key: "name", ascending: true)]
return fetchRequest
}
}
This is the Restaurant class (Note that Restaurant is a core data entity but the sortedMenu is not stored, this is intentional and the array is created on app launch and functioning fine.
//given a restaurant entity, return an array of fooditems sorted by cpd
public func sortMenu(restaurant: Restaurant, sortByCPD: Bool) {
if(sortByCPD) {
restaurant.sortedMenu.sort(by: { $0.cpd?.doubleValue ?? 0.0 > $1.cpd?.doubleValue ?? 0.0})
} else {
restaurant.sortedMenu.sort(by: { $0.ppd?.doubleValue ?? 0.0 > $1.ppd?.doubleValue ?? 0.0})
}
}
This is the sortMenu function that gets called when the toggle is switched.

how do I get a binding of computed property in SwiftUI?

I have a search bar in a list that filters an array of students and stores them in a computed property called searchResults of type [Student], I want to pass a binding of each filtered student in the list to another view that accepts a binding.
If I tried to pass a binding of searchResults in the list, it says: Cannot find '$searchResults' in scope, I think because searchResults is not a state, and I cannot make it a state since it is a computed property.
struct StudentsSection: View {
#Binding var course: Course
#State var searchText = ""
var searchResults: [Student] { course.students.filter { $0.fullName.contains(searchText) } }
var body: some View {
List($searchResults) { $student in // error: Cannot find '$searchResults' in scope
StudentRow(student: $student, course: $course)
}
.searchable(text: $searchText)
}
}
I want to achieve the same result as the code below:
struct StudentsSection: View {
#Binding var course: Course
var body: some View {
List($course.students) { $student in // no errors
StudentRow(student: $student, course: $course)
}
}
}

SwiftUI List only using first item of given array

I have a simple view below to display all of the contacts for the user:
struct AllContactsView: View {
static let withState: some View = AllContactsView().environmentObject(AllContactsProvider())
#EnvironmentObject var contactsProvider: AllContactsProvider
let title: UserListType = .selectInvitees
var body: some View {
List(self.contactsProvider.invitees) { invite in
self.row(for: invite)
}
.navigationBarTitle(Text(self.title.rawValue))
.onAppear(perform: self.contactsProvider.fetch)
}
func row(for invite: Invitee) -> some View {
// everything below is only printed out once!
print(self.contactsProvider.invitees.prettyPrinted) // shows correct array of contacts
print(type(of: self.contactsProvider.invitees)) // this is indeed an array
print(invite) // prints out the first item in the array (which is expected on first pass)
return UserRow(invitee: invite)
}
}
I am manipulating the array of CNContacts I get like this to an array of Invitees, which is what I am attempting to display in my list:
self?.invitees = contacts.asSimpleContacts.map({ $0.asUser.asInvitee })
Using the supporting functions and extensions below:
// Contact Value simplified so we can pass it around as a value type.
public struct SimpleContact: Hashable, Codable {
let firstName: String
let lastName: String
let emails: [String]
let phoneNumbers: [PhoneNumber]
var fullName: String { "\(self.firstName) \(self.lastName)" }
var asUser: User {
User(
id: Constants.unsavedID,
username: self.fullName,
picURL: "al",
email: self.emails.first ?? "",
phone: self.phoneNumbers.first ?? "",
created: Date().timeIntervalSince1970
)
}
}
extension CNContact {
/// Returns the `SimpleContact` representation of `self`
var asSimpleContact: SimpleContact {
SimpleContact(
firstName: self.givenName,
lastName: self.familyName,
emails: self.emailAddresses.map({ String($0.value) }),
phoneNumbers: self.phoneNumbers.map({ Authentication.sanitize(phoneNo: $0.value.stringValue) })
)
}
}
extension Array where Element == CNContact {
/// Returns the `SimpleContact` mapping of `self`
var asSimpleContacts: [SimpleContact] { self.map({ $0.asSimpleContact }) }
}
public struct User: Hashable, Codable, Identifiable {
public let id: String
let username: String
let picURL: String
let email: String
let phone: String
let created: Double
var asInvitee: Invitee { Invitee(user: self, isGoing: false) }
}
The contacts are populated into self.contactsProvider.invitees as expected following self.contactsProvider.fetch(). However, SwiftUI is displaying self.contactsProvider.invitees.count instances of self.contactsProvider.invitees.first, rather than each contact. I have compared my approach below to other examples online and can't seem to find where I went wrong. I have determined that the issue lies somewhere with the contacts manipulation - when I supply a mocked array of invitees, everything works as expected, despite things compiling and running as expected without the mocks, and printing and debugging not revealing anything.
Any help would be appreciated.
I just ran into this issue, to answer a bit more clearly:
Each row in a SwiftUI list should be generated with a unique ID.
If you are using the List() function to create the view, make sure you are specifying an id
struct MenuView: View {
var body: some View {
VStack {
Section(header: MenuSectionHeader()) {
//item.sku is the unique identifier for the row
List(Menu.allItems, id:\.sku) { item in
MenuItemRow(item)
}
}
}
}
For anyone who runs across something similar, the problem was that the ID I was instantiating the objects with was not unique. Curious that this would be the expected behavior if that error is made, but thats what it was.

SwiftUI: "Dynamic List with groups" not refreshed when an `#Published` property is changed

I am trying to create a dynamic grouped List in SwiftUI and I am facing an issue where if I change the collection that is mark as #Published of the inner ForEach that change is not visible in the UI unless I go to a different screen/sheet. I do not understand if what I am doing is correct or incorrect bug there are very limited resources on the topic of "dynamic grouped List in SwiftUI" so I am hoping you to point me in the right direction.
Here is my setup:
Model:
class Product: Identifiable, ObservableObject {
let id = UUID()
var name: String
init(name: String) {
self.name = name
}
}
class Category: Identifiable, ObservableObject {
let id = UUID()
#Published var items = [Product]()
var categoryName = ""
}
class Categories: ObservableObject {
#Published var items = [Category]()
}
and the View
struct ProductListView: View {
#ObservedObject var categories: Categories = Categories()
var body: some View {
List {
ForEach(categories.items) { category in
Section(header: Text(category.categoryName)) {
ForEach(category.items) { item in
Text(item.name)
}
}
}
}
.listStyle(GroupedListStyle())
}
func appendProduct() {
let product = Product(name: self.$name.wrappedValue, quantity: 1, complated: false)
let basicCategory = "Generic"
let existingCategory = self.categories.items.filter({$0.categoryName == basicCategory}).first
if (existingCategory?.items != nil) {
// Changes here do not refresh the UI
existingCategory?.items.append(product)
} else {
let category = Category()
category.categoryName = basicCategory
category.items.append(product)
self.categories.items.append(category)
}
}
}
When I append to the items of the Category (existingCategory?.items.append(product)), the UI is not updated unless I got to a different View with navigation or using a .sheet()
Anyone has an idea what is wrong here? I am quite new to Swift and SwfitUI.
Your View is only observing categories, therefore only direct changes to categories will result in a redraw of your view.
This is why self.categories.items.append(Category()) would always result in a view redraw but existingCategory?.items.append(product) is not.
existingCategory?.items.append(product) is only adding an Element to one of categories Category Elements, but the Category Element is still the same so no changed where made directly to the observed categories.
You could try this:
self.$categories.items[0].items.wrappedValue.append(product)
This will also always result in an redraw of your view because your operating directly on the categories binding.
In Swift, Category and Product should be a struct.
That gives you value semantics which is what SwiftUI uses to detect changes. I.e. a change to a Product, is detected as a change to Category, which is detected as a change to the #Published items, which SwiftUI is observing.

Resources