Best way to implement expensive derived properties in SwiftUI? - swift

I have a somewhat complicated situation. I'm implementing a simple scrolling chat view in SwiftUI. There's a ChatView with a list of ChatMessageCells. Each Message has a User, and that has a ProfileImage which is a struct made up of a couple of fields (bucket and key, used to construct the URL to the image on the server). This can periodically update on the User class, and can also be nil.
Update: This is not a particularly expensive operation, but let’s say for the sake of argument that it is expensive, and I only want to recompute it once. There basically two ways: recompute when (one of the) source properties changes, or recompute when needed and store the result. I’d like to know the best way to do both.
The view needs to construct the URL, because it needs to specify the desired image size for the image. In this way, the URL is a derived property of the message.user.profileImage property.
I tried using .onChange(of: self.message.user.profileImage) in my ChatMessageCell view hierarchy to then compute the URL and set self.profileImageURL, but you can’t set simple properties. So I adorned self.profileImageURL with #State, which allowed the code to compile, and I assign it in init(). But if it’s #State, that assignment doesn't seem to have any effect.
So, I'm pretty unsure how to do this.
ChatView and ChatMessageCell look like this:
struct ChatView : View
{
#ObservedObject public var stream : ChatStream
var body: some View {
ScrollViewReader { scrollView in
ScrollView {
LazyVStack {
ForEach(self.stream.messages) { inMsg in
ChatMessageCell(message: inMsg)
}
}
}
}
}
struct
ChatMessageCell: View
{
public let message : ChatStream.Message
#State var profileImageURL : URL?
init(message inMessage: ChatStream.Message)
{
self.message = inMessage
let image = inMessage.user.profileImage
let url = image?.sharpImageURL(forSize: kProfileImageSize)
self.profileImageURL = url // <-- doesn't assign if #State
}
var body: some View
{
VStack(alignment: .leading)
{
Text("Key: \(self.message.user.profileImage?.key ?? "nil")")
Text("URL: \(self.profileImageURL?.absoluteString ?? "nil")")
// This is just for debugging. Really there's a `KFImage` here that’s
// supposed to async load the image.
}
.onChange(of: self.message.user.profileImage)
{ inVal in
let url = inVal?.sharpImageURL(forSize: kProfileImageSize)
self.profileImageURL = url // <-- can't modify if not #State
}
}
}
Other classes:
class ChatStream
{
public struct Message
{
var id : Int
var date : Date
var message : String
var user : User
init(fromIncoming inMsg: IncomingMessage, user inUser: User)
{
self.id = inMsg.id
self.date = inMsg.date
self.message = inMsg.message
self.user = inUser
}
}
public class User
{
typealias ID = String
let id : ID
var username : String
var onlineAt : Date?
#Published var profileImage : ProfileImage?
init(fromIncoming inUser: IncomingUser)
{
self.id = inUser.id
self.username = inUser.username
self.onlineAt = inUser.onlineAt
self.profileImage = inUser.profileImage
}
func update(fromIncoming inUser: IncomingUser)
{
self.username = inUser.username
self.onlineAt = inUser.onlineAt
self.profileImage = inUser.profileImage
}
}
#Published var messages = OrderedSet<Message>()
#Published var users = [User.ID : User]()
}
extension ChatStream : ObservableObject {}
extension ChatStream.ProfileImage : Equatable {}

It would be more straightforward to have message be an observable object, which triggers changes when the user property updates, and have the URL be a computed property of message. You haven't shown the implementation of Message or User so it's hard to be specific. If Message is a struct then you could just have a reference to the user as an observable object.

Related

Using RealmSwift and SwiftUI, how can I look at a record in the view and change it simultaneously, without view poping out?

I'm getting an object from Realm using #ObservedResults and then I need to modify the record in the same screen. Like adding items and deleting items, but I need this to happen without returning to previous NavigationView. And I need to update the view displaying the data as its updated.
It seems that #StateRealmObject should be the solution, but how can I designate a stored Realm object as a StateRealmObject? What would be a better approach?
Model:
class Order: Object, ObjectKeyIdentifiable {
#objc dynamic var orderID : String = NSUUID().uuidString
#objc dynamic var name : String = "No Name"
let orderLineId = List<OrderLine>()
.
.
.
override static func primaryKey() -> String? {
return "orderID"
}
}
View: (simplified)
struct OrderView: View {
var currentOrder : Order?
#ObservedResults (Order.self) var allOrders
var realm = try! Realm()
init(orderId: String) {
currentOrder = allOrders.filter("orderID ==\"\(orderId)\"")[0].thaw()
}
func deleteOrderLine(id: String, sku: String) {
try! realm.write {
let query = allOrderLines.filter("id == \"\(id)\" AND productSku == \"\(sku)\"")[0]
realm.delete(query)
}
}
var body: some View {
//Here is where all order data is displayed. When deleting a line. The view pops out. I //need to change data and keep view, and save changes as they are made.
//If delete line func is called, it works, but it pops the view.
deleteOrderLine(id: String, sku: String)
}
}
As per request, this is the parent navigation view, which I believe is the core of the problem.
struct OrderListView2: View {
#ObservedResults (Order.self) var allOrders
var body: some View {
VStack{
List{
ForEach(allOrders.sorted(byKeyPath: "dateCreated",ascending: false), id: \.self) { order in
NavigationLink(destination: OrderView(orderId: order.orderID)){
Text(order.info...)
}
}
The problem is that your allOrderLines collection is not frozen.
This result set is live so when you delete an entry your SwiftUI view is displaying a result set that is older than your live collection.
Hence the RLMException for the index out of bounds. To solve this problem you must freeze that collection for the view then delete the desired object and then reload your frozen collection. To achieve this i would suggest you use a viewmodel.
class OrderViewModel: ObservableObject {
#Published var orders: Results<Order>
init() {
let realm = try! Realm()
self.orders = realm.objects(Order.self).freeze()
}
func deleteOrderLine(id: String, sku: String) {
let realm = try! Realm()
try! realm.write {
let query = realm.objects(Order.self).filter("id == \"\(id)\" AND productSku == \"\(sku)\"")[0]
realm.delete(query)
}
self.orders = realm.objects(Order.self).freeze()
}
}
You can then use the #Published orders for your view. Initialize the viewmodel with #StateObject var viewModel = OrderViewModel()

Userdefaults with published enum

try to save user setting, but UserDefaults is not working, Xcode 12.3, swiftui 2.0, when I am reload my app, my setting not updating for new value)
class PrayerTimeViewModel: ObservableObject {
#Published var lm = LocationManager()
#Published var method: CalculationMethod = .dubai {
didSet {
UserDefaults.standard.set(method.params, forKey: "method")
self.getPrayerTime()
}
}
func getPrayerTime() {
let cal = Calendar(identifier: Calendar.Identifier.gregorian)
let date = cal.dateComponents([.year, .month, .day], from: Date())
let coordinates = Coordinates(latitude: lm.location?.latitude ?? 0.0, longitude: lm.location?.longitude ?? 0.0)
var par = method.params
par.madhab = mashab
self.times = PrayerTimes(coordinates: coordinates, date: date, calculationParameters: par)
}
and view.. update with AppStorage
struct MethodView: View {
#ObservedObject var model: PrayerTimeViewModel
#Environment(\.presentationMode) var presentationMode
#AppStorage("method", store: UserDefaults(suiteName: "method")) var method: CalculationMethod = .dubai
var body: some View {
List(CalculationMethod.allCases, id: \.self) { item in
Button(action: {
self.model.objectWillChange.send()
self.presentationMode.wrappedValue.dismiss()
self.model.method = item
method = item
}) {
HStack {
Text("\(item.rawValue)")
if model.method == item {
Image(systemName: "checkmark")
.foregroundColor(.black)
}
}
}
}
}
}
You have two issues.
First, as I mentioned in my comment above that you are using two different suites for UserDefaults. This means that you are storing and retrieving from two different locations. Either use UserDefaults.standard or use the one with your chosen suite UserDefaults(suitName: "method") - you don't have to use a suite unless you plan on sharing your defaults with other extensions then it would be prudent to do so.
Secondly you are storing the wrong item in UserDefaults. You are storing a computed property params rather than the actual enum value. When you try to retrieve the value it fails as it is not getting what it expects and uses the default value that you have set.
Here is a simple example that shows what you could do. There is a simple enum that has a raw value (String) and conforms to Codable, it also has a computed property. This matches your enum.
I have added an initialiser to my ObservableObject. This serves the purpose to populate my published Place from UserDefaults when the Race object is constructed.
Then in my ContentView I update the place depending on a button press. This updates the UI and it updates the value in UserDefaults.
This should be enough for you to understand how it works.
enum Place: String, Codable {
case first
case second
case third
case notPlaced
var someComputedProperty: String {
"Value stored: \(self.rawValue)"
}
}
class Race: ObservableObject {
#Published var place: Place = .notPlaced {
didSet {
// Store the rawValue of the enum into UserDefaults
// We can store the actual enum but that requires more code
UserDefaults.standard.setValue(place.rawValue, forKey: "method")
// Using a custom suite
// UserDefaults(suiteName: "method").setValue(place.rawValue, forKey: "method")
}
}
init() {
// Load the value from UserDefaults if it exists
if let rawValue = UserDefaults.standard.string(forKey: "method") {
// We need to nil-coalesce here as this is a failable initializer
self.place = Place(rawValue: rawValue) ?? .notPlaced
}
// Using a custom suite
// if let rawValue = UserDefaults(suiteName: "method")?.string(forKey: "method") {
// self.place = Place(rawValue: rawValue) ?? .notPlaced
// }
}
}
struct ContentView: View {
#StateObject var race: Race = Race()
var body: some View {
VStack(spacing: 20) {
Text(race.place.someComputedProperty)
.padding(.bottom, 20)
Button("Set First") {
race.place = .first
}
Button("Set Second") {
race.place = .second
}
Button("Set Third") {
race.place = .third
}
}
}
}
Addendum:
Because the enum conforms to Codable it would be possible to use AppStorage to read and write the property. However, that won't update the value in your ObservableObject so they could easily get out of sync. It is best to have one place where you control a value. In this case your ObservableObject should be the source of truth, and all updates (reading and writing to UserDefaults) should take place through there.
You write in one UserDefaults domain but read from the different. Assuming your intention is to use suite only UserDefaults, you should change one in model, like
#Published var method: CalculationMethod = .dubai {
didSet {
UserDefaults(suiteName: "method").set(method.params, forKey: "method")
self.getPrayerTime()
}
}
or if you want to use standard then just use AppStorage with default constructor, like
// use UserDefaults.standard by default
#AppStorage("method") var method: CalculationMethod = .dubai

How can I use the #Binding Wrapper in the following mvvm architecture?

I've set up a mvvm architecture. I've got a model, a bunch of views and for each view one single store. To illustrate my problem, consider the following:
In my model, there exists a user object user and two Views (A and B) with two Stores (Store A, Store B) which both use the user object. View A and View B are not dependent on each other (both have different stores which do not share the user object) but are both able to edit the state of the user object. Obviously, you need to propagate somehow the changes from one store to the other. In order to do so, I've built a hierarchy of stores with one root store who maintains the entire "app state" (all states of shared objects like user). Now, Store A and B only maintain references on root stores objects instead of maintaining objects themselves. I'd expected now, that if I change the object in View A, that Store A would propagate the changes to the root store which would propagate the changes once again to Store B. And when I switch to View B, I should be able now to see my changes. I used Bindings in Store A and B to refer to the root stores objects. But this doesn't work properly and I just don't understand the behavior of Swift's Binding. Here is my concrete set up as a minimalistic version:
public class RootStore: ObservableObject {
#Published var storeA: StoreA?
#Published var storeB: StoreB?
#Published var user: User
init(user: User) {
self.user = user
}
}
extension ObservableObject {
func binding<T>(for keyPath: ReferenceWritableKeyPath<Self, T>) -> Binding<T> {
Binding(get: { [unowned self] in self[keyPath: keyPath] },
set: { [unowned self] in self[keyPath: keyPath] = $0 })
}
}
public class StoreA: ObservableObject {
#Binding var user: User
init(user: Binding<User>) {
_user = user
}
}
public class StoreB: ObservableObject {
#Binding var user: User
init(user: Binding<User>) {
_user = user
}
}
In my SceneDelegate.swift, I've got the following snippet:
user = User()
let rootStore = RootStore(user: user)
let storeA = StoreA(user: rootStore.binding(for: \.user))
let storeB = StoreB(user: rootStore.binding(for: \.user))
rootStore.storeA = storeA
rootStore.storeB = storeB
let contentView = ContentView()
.environmentObject(appState) // this is used for a tabView. You can safely ignore this for this question
.environmentObject(rootStore)
then, the contentView is passed as a rootView to the UIHostingController. Now my ContentView:
struct ContentView: View {
#EnvironmentObject var appState: AppState
#EnvironmentObject var rootStore: RootStore
var body: some View {
TabView(selection: $appState.selectedTab) {
ViewA().environmentObject(rootStore.storeA!).tabItem {
Image(systemName: "location.circle.fill")
Text("ViewA")
}.tag(Tab.viewA)
ViewB().environmentObject(rootStore.storeB!).tabItem {
Image(systemName: "waveform.path.ecg")
Text("ViewB")
}.tag(Tab.viewB)
}
}
}
And now, both Views:
struct ViewA: View {
// The profileStore manages user related data
#EnvironmentObject var storeA: StoreA
var body: some View {
Section(header: HStack {
Text("Personal Information")
Spacer()
Image(systemName: "info.circle")
}) {
TextField("First name", text: $storeA.user.firstname)
}
}
}
struct ViewB: View {
#EnvironmentObject var storeB: StoreB
var body: some View {
Text("\(storeB.user.firstname)")
}
}
Finally, my issue is, that changes are just not reflected as they are supposed to be. When I change something in ViewA and switch to ViewB, I don't see the updated first name of the user. When I change back to ViewA my change is also lost. I used didSet inside the stores and similar for debugging purposes and the Binding actually seems to work. The change is propagated but somehow the View just doesn't update. I also forced with some artificial state changing (adding a state bool variable and just toggling it in an onAppear()) that the view rerenders but still, it doesn't take the updated value and I just don't know what to do.
EDIT: Here is a minimal version of my User object
public struct User {
public var id: UUID?
public var firstname: String
public var birthday: Date
public init(id: UUID? = nil,
firstname: String,
birthday: Date? = nil) {
self.id = id
self.firstname = firstname
self.birthday = birthday ?? Date()
}
}
For simplicity, I didn't pass the attributes in the SceneDelegate.swift snippet above.
In your scenario it is more appropriate to have User as-a ObservableObject and pass it by reference between stores, as well as use in corresponding views explicitly as ObservedObject.
Here is simplified demo combined from your code snapshot and applied the idea.
Tested with Xcode 11.4 / iOS 13.4
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let user = User(id: UUID(), firstname: "John")
let rootStore = RootStore(user: user)
let storeA = StoreA(user: user)
let storeB = StoreB(user: user)
rootStore.storeA = storeA
rootStore.storeB = storeB
return ContentView().environmentObject(rootStore)
}
}
public class User: ObservableObject {
public var id: UUID?
#Published public var firstname: String
#Published public var birthday: Date
public init(id: UUID? = nil,
firstname: String,
birthday: Date? = nil) {
self.id = id
self.firstname = firstname
self.birthday = birthday ?? Date()
}
}
public class RootStore: ObservableObject {
#Published var storeA: StoreA?
#Published var storeB: StoreB?
#Published var user: User
init(user: User) {
self.user = user
}
}
public class StoreA: ObservableObject {
#Published var user: User
init(user: User) {
self.user = user
}
}
public class StoreB: ObservableObject {
#Published var user: User
init(user: User) {
self.user = user
}
}
struct ContentView: View {
#EnvironmentObject var rootStore: RootStore
var body: some View {
TabView {
ViewA(user: rootStore.user).environmentObject(rootStore.storeA!).tabItem {
Image(systemName: "location.circle.fill")
Text("ViewA")
}.tag(1)
ViewB(user: rootStore.user).environmentObject(rootStore.storeB!).tabItem {
Image(systemName: "waveform.path.ecg")
Text("ViewB")
}.tag(2)
}
}
}
struct ViewA: View {
#EnvironmentObject var storeA: StoreA // keep only if it is needed in real view
#ObservedObject var user: User
init(user: User) {
self.user = user
}
var body: some View {
VStack {
HStack {
Text("Personal Information")
Image(systemName: "info.circle")
}
TextField("First name", text: $user.firstname)
}
}
}
struct ViewB: View {
#EnvironmentObject var storeB: StoreB
#ObservedObject var user: User
init(user: User) {
self.user = user
}
var body: some View {
Text("\(user.firstname)")
}
}
Providing an alternative answer here with some changes to your design as a comparison.
The shared state here is the user object. Put it in #EnvironmentObject, which is by definition the external state object shared by views in the hierarchy. This way you don't need to notify StoreA which notifies RootStore which then notifies StoreB.
Then StoreA, StoreB can be local #State, and RootStore is not required. Store A, B can be value types since there's nothing to observe.
Since #EnvironmentObject is by definition an ObservableObject, we don't need User to
conform to ObservableObject, and can thus make User a value type.
final class EOState: ObservableObject {
#Published var user = User()
}
struct ViewA: View {
#EnvironmentObject eos: EOState
#State storeA = StoreA()
// ... TextField("First name", text: $eos.user.firstname)
}
struct ViewB: View {
#EnvironmentObject eos: EOState
#State storeB = StoreB()
// ... Text("\(eos.user.firstname)")
}
The rest should be straight-forward.
What is the take-away in this comparison?
Should avoid objects observing each other, or a long publish chain. It's confusing, hard to track, and not scalable.
MVVM tells you nothing about managing state. SwiftUI is most powerful when you've learnt how to allocate and manage your states. MVVM however heavily relies upon #ObservedObject for binding, because iOS had no binding. For beginners this is dangerous, because it needs to be reference type. The result might be, as in this case, overuse of reference types which defeats the whole purpose of a SDK built around value types.
It also removes most of the boilerplate init codes, and one can focus on 1 shared state object instead of 4.
If you think SwiftUI creators are idiots, SwiftUI is not scalable and requires MVVM on top of it, IMO you are sadly mistaken.

Using Firebase Realtime Database with SwiftUI List - Clearing a list

I'm wondering what's the most effective way to clear a list's data that's presented in a View via an observable object? At the moment, everytime I reload the view the data gets duplicated (as we are checking the query for updates and parsing the responses). This is not the expected behaviour. The expected behaviour would be to only update the #Published property "if" the database indicates that a new notification has been received.
I know the culprit is the code within the .onAppear block - I'm just not sure architecturally how I might solve this. How could I use this listener while only parsing new data, not data that was previously parsed?
I attempted to clear the list .onAppear, but resulted in a crash indicated that I tried to delete a section while there were already 0 sections so that didn't work.
I've thought of possibly providing the Message object with a Static Unique ID to upload with the Message object when sending to Firebase (Or using the firebase key itself). That way I could use a set of dictionary objects using the unique ID to identify the object in the dictionary. This may help me avoid duplicate entries.
struct Updates: View {
#ObservedObject var dataController = DataController()
var body: some View {
ZStack {
VStack {
List {
ForEach(self.dataController.messages, id: \.id) { message in
Text(message.message)
}
}
}.onAppear {
self.dataController.query.observe(.childAdded) { snapshot in
let data = JSON(snapshot.value as Any)
if let message = Message.parseFirebaseQuery(dataJSON: data){
self.dataController.messages.append(message)
}
}
}
}
}
}
class DataController: ObservableObject {
#Published var query = ChatPathsAndReferences.refs.databaseMessages.queryLimited(toLast:100).queryOrdered(byChild: "date")
#Published var messages = [Message]()
}
I resolved this by adding an init block to my ObservableObject
class DataController: ObservableObject {
#Published var query = ChatPathsAndReferences.refs.databaseMessages.queryLimited(toLast:100).queryOrdered(byChild: "date")
#Published var messages = [Message]()
init() {
self.query.observe(.childAdded) { snapshot in
let data = JSON(snapshot.value as Any)
if let chatMessage = ChatMessage.parseFirebaseQuery(dataJSON: data){
self.messages.append(chatMessage)
}
}
}
}
So that now, when I create the view, the object initialises the observer in the data controller rather than the view.
struct Updates: View {
// As you can see, we initialise the data controller which inits the observer.
// Since the messages in the data controller are #Published,
// we don't need to worry about checking for updates again in the view
#ObservedObject var chatDataController: DataController = DataController()
var body: some View {
GeometryReader { geometry in
ZStack {
Color("Background")
VStack {
// messages
List {
ForEach(self.chatDataController.messages, id: \.id) { message in
Text(message.message)
}
}
}
}
}
}

String contents not being changed

This is my main view where I create an object of getDepthData() that holds a string variable that I want to update when the user click the button below. But it never gets changed after clicking the button
import SwiftUI
struct InDepthView: View {
#State var showList = false
#State var pickerSelectedItem = 1
#ObservedObject var data = getDepthData()
var body: some View {
VStack(alignment: .leading) {
Button(action: {
self.data.whichCountry = "usa"
print(" indepthview "+self.data.whichCountry)
}) {
Text("change value")
}
}
}
}
Here is my class where I hold a string variable to keep track of the country they are viewing. But when every I try to modify the whichCountry variable it doesn't get changed
class getDepthData: ObservableObject {
#Published var data : Specific!
#Published var countries : HistoricalSpecific!
#State var whichCountry: String = "italy"
init() {
updateData()
}
func updateData() {
let url = "https://corona.lmao.ninja/v2/countries/"
let session = URLSession(configuration: .default)
session.dataTask(with: URL(string: url+"\(self.whichCountry)")!) { (data, _, err) in
if err != nil {
print((err?.localizedDescription)!)
return
}
let json = try! JSONDecoder().decode(Specific.self, from: data!)
DispatchQueue.main.async {
self.data = json
}
}.resume()
}
}
Any help would be greatly appreciated!
You need to define the whichCountry variable as #Published to apply changes on it
#Published var whichCountry: String = "italy"
You need to mark whichCountry as a #Published variable so SwiftUI publishes a event when this property have been changed. This causes the body property to reload
#Published var whichCountry: String = "italy"
By the way it is a convention to write the first letter of your class capitalized:
class GetDepthData: ObservableObject { }
As the others have mentioned, you need to define the whichCountry variable as #Published to apply changes to it. In addition you probably want to update your data because whichCountry has changed. So try this:
#Published var whichCountry: String = "italy" {
didSet {
self.updateData()
}
}