4

Xcode 11.3, Swift 5.1.3

I am trying currently to create a custom property wrapper that allows me to link variables to a Firebase database. When doing this, to make it update the view, I at first tried to use the @ObservedObject @Bar var foo = []. But I get an error that multiple property wrappers are not supported. Next thing I tried to do, which would honestly be ideal, was try to make my custom property wrapper update the view itself upon being changed, just like @State and @ObservedObject. This both avoids needing to go down two layers to access the underlying values and avoid the use of nesting property wrappers. To do this, I checked the SwiftUI documentation and found out that they both implement the DynamicProperty protocol. I tried to use this too but failed because I need to be able to update the view (call update()) from within my Firebase database observers, which I cannot do since .update() is mutating.

Here is my current attempt at this:

import SwiftUI
import Firebase
import CodableFirebase
import Combine 

@propertyWrapper
final class DatabaseBackedArray<Element>: ObservableObject where Element: Codable & Identifiable {
    typealias ObserverHandle = UInt
    typealias Action = RealtimeDatabase.Action
    typealias Event = RealtimeDatabase.Event

    private(set) var reference: DatabaseReference

    private var currentValue: [Element]

    private var childAddedObserverHandle: ObserverHandle?
    private var childChangedObserverHandle: ObserverHandle?
    private var childRemovedObserverHandle: ObserverHandle?

    private var childAddedActions: [Action<[Element]>] = []
    private var childChangedActions: [Action<[Element]>] = []
    private var childRemovedActions: [Action<[Element]>] = []

    init(wrappedValue: [Element], _ path: KeyPath<RealtimeDatabase, RealtimeDatabase>, events: Event = .all,
         actions: [Action<[Element]>] = []) {
        currentValue = wrappedValue
        reference = RealtimeDatabase()[keyPath: path].reference

        for action in actions {
            if action.event.contains(.childAdded) {
                childAddedActions.append(action)
            }
            if action.event.contains(.childChanged) {
                childChangedActions.append(action)
            }
            if action.event.contains(.childRemoved) {
                childRemovedActions.append(action)
            }
        }

        if events.contains(.childAdded) {
            childAddedObserverHandle = reference.observe(.childAdded) { snapshot in
                guard let value = snapshot.value, let decodedValue = try? FirebaseDecoder().decode(Element.self, from: value) else {
                    fatalError("Could not decode value from Firebase.")
                }
                self.objectWillChange.send()
                self.currentValue.append(decodedValue)
                self.childAddedActions.forEach { $0.action(&self.currentValue) }
            }
        }
        if events.contains(.childChanged) {
            childChangedObserverHandle = reference.observe(.childChanged) { snapshot in
                guard let value = snapshot.value, let decodedValue = try? FirebaseDecoder().decode(Element.self, from: value) else {
                    fatalError("Could not decode value from Firebase.")
                }
                guard let changeIndex = self.currentValue.firstIndex(where: { $0.id == decodedValue.id }) else {
                    return
                }
                self.objectWillChange.send()
                self.currentValue[changeIndex] = decodedValue
                self.childChangedActions.forEach { $0.action(&self.currentValue) }
            }
        }
        if events.contains(.childRemoved) {
            childRemovedObserverHandle = reference.observe(.childRemoved) { snapshot in
                guard let value = snapshot.value, let decodedValue = try? FirebaseDecoder().decode(Element.self, from: value) else {
                    fatalError("Could not decode value from Firebase.")
                }
                self.objectWillChange.send()
                self.currentValue.removeAll { $0.id == decodedValue.id }
                self.childRemovedActions.forEach { $0.action(&self.currentValue) }
            }
        }
    }

    private func setValue(to value: [Element]) {
        guard let encodedValue = try? FirebaseEncoder().encode(currentValue) else {
            fatalError("Could not encode value to Firebase.")
        }
        reference.setValue(encodedValue)
    }

    var wrappedValue: [Element] {
        get {
            return currentValue
        }
        set {
            self.objectWillChange.send()
            setValue(to: newValue)
        }
    }

    var projectedValue: Binding<[Element]> {
        return Binding(get: {
            return self.wrappedValue
        }) { newValue in
            self.wrappedValue = newValue
        }
    }

    var hasActiveObserver: Bool {
        return childAddedObserverHandle != nil || childChangedObserverHandle != nil || childRemovedObserverHandle != nil
    }
    var hasChildAddedObserver: Bool {
        return childAddedObserverHandle != nil
    }
    var hasChildChangedObserver: Bool {
        return childChangedObserverHandle != nil
    }
    var hasChildRemovedObserver: Bool {
        return childRemovedObserverHandle != nil
    }

    func connectObservers(for event: Event) {
        if event.contains(.childAdded) && childAddedObserverHandle == nil {
            childAddedObserverHandle = reference.observe(.childAdded) { snapshot in
                guard let value = snapshot.value, let decodedValue = try? FirebaseDecoder().decode(Element.self, from: value) else {
                    fatalError("Could not decode value from Firebase.")
                }
                self.objectWillChange.send()
                self.currentValue.append(decodedValue)
                self.childAddedActions.forEach { $0.action(&self.currentValue) }
            }
        }
        if event.contains(.childChanged) && childChangedObserverHandle == nil {
            childChangedObserverHandle = reference.observe(.childChanged) { snapshot in
                guard let value = snapshot.value, let decodedValue = try? FirebaseDecoder().decode(Element.self, from: value) else {
                    fatalError("Could not decode value from Firebase.")
                }
                guard let changeIndex = self.currentValue.firstIndex(where: { $0.id == decodedValue.id }) else {
                    return
                }
                self.objectWillChange.send()
                self.currentValue[changeIndex] = decodedValue
                self.childChangedActions.forEach { $0.action(&self.currentValue) }
            }
        }
        if event.contains(.childRemoved) && childRemovedObserverHandle == nil {
            childRemovedObserverHandle = reference.observe(.childRemoved) { snapshot in
                guard let value = snapshot.value, let decodedValue = try? FirebaseDecoder().decode(Element.self, from: value) else {
                    fatalError("Could not decode value from Firebase.")
                }
                self.objectWillChange.send()
                self.currentValue.removeAll { $0.id == decodedValue.id }
                self.childRemovedActions.forEach { $0.action(&self.currentValue) }                
            }
        }
    }

    func removeObserver(for event: Event) {
        if event.contains(.childAdded), let handle = childAddedObserverHandle {
            reference.removeObserver(withHandle: handle)
            self.childAddedObserverHandle = nil
        }
        if event.contains(.childChanged), let handle = childChangedObserverHandle {
            reference.removeObserver(withHandle: handle)
            self.childChangedObserverHandle = nil
        }
        if event.contains(.childRemoved), let handle = childRemovedObserverHandle {
            reference.removeObserver(withHandle: handle)
            self.childRemovedObserverHandle = nil
        }
    }
    func removeAction(_ action: Action<[Element]>) {
        if action.event.contains(.childAdded) {
            childAddedActions.removeAll { $0.id == action.id }
        }
        if action.event.contains(.childChanged) {
            childChangedActions.removeAll { $0.id == action.id }
        }
        if action.event.contains(.childRemoved) {
            childRemovedActions.removeAll { $0.id == action.id }
        }
    }

    func removeAllActions(for event: Event) {
        if event.contains(.childAdded) {
            childAddedActions = []
        }
        if event.contains(.childChanged) {
            childChangedActions = []
        }
        if event.contains(.childRemoved) {
            childRemovedActions = []
        }
    }
}

struct School: Codable, Identifiable {
    /// The unique id of the school.
    var id: String

    /// The name of the school.
    var name: String

    /// The city of the school.
    var city: String

    /// The province of the school.
    var province: String

    /// Email domains for student emails from the school.
    var domains: [String]
}

@dynamicMemberLookup
struct RealtimeDatabase {
    private var path: [String]

    var reference: DatabaseReference {
        var ref = Database.database().reference()
        for component in path {
            ref = ref.child(component)
        }
        return ref
    }

    init(previous: Self? = nil, child: String? = nil) {
        if let previous = previous {
            path = previous.path
        } else {
            path = []
        }
        if let child = child {
            path.append(child)
        }
    }

    static subscript(dynamicMember member: String) -> Self {
        return Self(child: member)
    }

    subscript(dynamicMember member: String) -> Self {
        return Self(child: member)
    }

    static subscript(dynamicMember keyPath: KeyPath<Self, Self>) -> Self {
        return Self()[keyPath: keyPath]
    }

    static let reference = Database.database().reference()

    struct Event: OptionSet, Hashable {
        let rawValue: UInt
        static let childAdded = Event(rawValue: 1 << 0)
        static let childChanged = Event(rawValue: 1 << 1)
        static let childRemoved = Event(rawValue: 1 << 2)

        static let all: Event = [.childAdded, .childChanged, .childRemoved]
        static let constructive: Event = [.childAdded, .childChanged]
        static let destructive: Event = .childRemoved
    }

    struct Action<Value>: Identifiable {

        let id = UUID()
        let event: Event
        let action: (inout Value) -> Void

        private init(on event: Event, perform action: @escaping (inout Value) -> Void) {
            self.event = event
            self.action = action
        }

        static func on<Value>(_ event: RealtimeDatabase.Event, perform action: @escaping (inout Value) -> Void) -> Action<Value> {
            return Action<Value>(on: event, perform: action)
        }
    }
}

Usage example:

struct ContentView: View {

    @DatabaseBackedArray(\.schools, events: .all, actions: [.on(.constructive) { $0.sort { $0.name < $1.name } }])
    var schools: [School] = []

    var body: some View {
        Text("School: ").bold() +
            Text(schools.isEmpty ? "Loading..." : schools.first!.name)
    }
}

When I try to use this though, the view never updates with the value from Firebase even though I am positive that the .childAdded observer is being called.


One of my attempts at fixing this was to store all of these variables in a singleton that itself conforms to ObservableObject. This solution is also ideal as it allows the variables being observed to be shared throughout my application, preventing multiples instances of the same date and allowing for a single source of truth. Unfortunately, this too did not update the view with the fetched value of currentValue.

class Session: ObservableObject {

    @DatabaseBackedArray(\.schools, events: .all, actions: [.on(.constructive) { $0.sort { $0.name < $1.name } }])
    var schools: [School] = []

    private init() {
        //Send `objectWillChange` when `schools` property changes
        _schools.objectWillChange.sink {
            self.objectWillChange.send()
        }
    }

    static let current = Session()

}


struct ContentView: View {

    @ObservedObject
    var session = Session.current

    var body: some View {
        Text("School: ").bold() +
            Text(session.schools.isEmpty ? "Loading..." : session.schools.first!.name)
    }
}

Is there any way to make a custom property wrapper that also updates a view in SwiftUI?

Noah Wilder
  • 1,656
  • 20
  • 38
  • Create a model called `DataService` that conforms to `ObservableObject`. Now this model should contain a `@Published` property called `DBArray` which will use your custom property wrapper. In the `SwiftUI` `View` use `DataService` as an `ObservedObject`. `@Published` changes automatically will invoke `objectWillChange.send()` but for other undetected changes in your `DataService` you invoke `objectWillChange.send()` manually – user1046037 Dec 18 '19 at 03:55
  • When you are posting an example, don't post your entire problem, create an example with the bare minimum code that others can test to see where the problem is. – user1046037 Dec 18 '19 at 03:57
  • @user1046037, "contain a `@Published` property called `DBArray` which will use your custom property wrapper". I cannot do this because then that would be using multiple property wrappers which is not supported. – Noah Wilder Dec 18 '19 at 03:59
  • Fair point, why can't your custom property wrapper have a property which uses `@Published`. That way you could use `dataService.dbArray.somePublishedProperty`. Any changes to that value your model could invoke `objectWillChange.send()` – user1046037 Dec 18 '19 at 04:05
  • Surprisingly @user1046037, that was the first thing I thought of after encountering this when trying to find a solution to this problem. I added that attempt at the bottom of my post. TLDR: that didn't work either for some reason (maybe because you would also need an `@ObservedObject` wrapper before the `schools` variable in `Session`?... I don't know). – Noah Wilder Dec 18 '19 at 04:26
  • Based on my understanding `_schools.objectWillChange.sink { ... }` closure will not get invoked because you need to retain it. Asssign it to a property `canceller = _schools.objectWillChange.sink { ... }`. In this example `canceller` would be a property in `Session`. If you don't retain it the subscription would be cancelled – user1046037 Dec 18 '19 at 04:32
  • You are correct, I'll post the solution. Thank you @user1046037! – Noah Wilder Dec 18 '19 at 04:34
  • Just a small suggestion, it would be easier for others if you post simplified examples to isolate the problem faster – user1046037 Dec 18 '19 at 04:35

2 Answers2

2

Making use of the DynamicProperty protocol we can easily trigger view updates by making use of SwiftUI's existing property wrappers. (DynamicProperty tells SwiftUI to look for these within our type)

@propertyWrapper
struct OurPropertyWrapper: DynamicProperty {
    
    // A state object that we notify of updates
    @StateObject private var updater = Updater()
    
    var wrappedValue: T {
        get {
            // Your getter code here
        }
        nonmutating set {
            // Tell SwiftUI we're going to change something
            updater.notifyUpdate()
            // Your setter code here
        }
    }
    
    class Updater: ObservableObject {
        func notifyUpdate() {
            objectWillChange.send()
        }
    }
}
Apptek Studios
  • 512
  • 6
  • 9
-1

The solution to this is to make a minor tweak to the solution of the singleton. Credits to @user1046037 for pointing this out to me. The problem with the singleton fix mentioned in the original post, is that it does not retain the canceller for the sink in the initializer. Here is the correct code:

class Session: ObservableObject {

    @DatabaseBackedArray(\.schools, events: .all, actions: [.on(.constructive) { $0.sort { $0.name < $1.name } }])
    var schools: [School] = []

    private var cancellers = [AnyCancellable]()

    private init() {
        _schools.objectWillChange.sink {
            self.objectWillChange.send()
        }.assign(to: &cancellers)
    }

    static let current = Session()

}
Noah Wilder
  • 1,656
  • 20
  • 38