Search code examples
swiftfirebasefirebase-realtime-databaseswiftuiproperty-wrapper

Custom Property Wrapper that Updates View Swift


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?


Solution

  • 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()
    
    }