Я создал собственный Publisher для использования с базой данных Realm, который, кажется, работает должным образом, но не хочет хорошо играть с SwiftUI.
Я изолировал проблему с интерфейсом между моделью представления и SwiftUI. Модель представления, кажется, ведет себя как ожидалось, основываясь на результатах различных наблюдателей свойств и операторов .print (), которые я добавила для поиска ошибок, но выходит за рамки модели представления, хранилища данных модели представления (представленного свойство 'state') отображается как пустое, следовательно, пустой интерфейс пользователя.
Интересно, что если я заменю свой код Combine прямым преобразованием массива запроса результатов области, пользовательский интерфейс отобразится, как и ожидалось (хотя я не реализовал токены уведомлений для динамического обновления c при добавлении элементов / удалено, et c).
Я подозреваю, что я не вижу древесину для всех деревьев, поэтому очень ценю внешнюю перспективу и рекомендации: -)
Кодовая база ниже - я пропустил сгенерированный Apple шаблон по большей части.
SceneDelegate:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Create the SwiftUI view that provides the window contents.
let patientService = MockPatientService()
let viewModel = AnyViewModel(PatientListViewModel(patientService: patientService))
print("#(function) viewModel contains \(viewModel.state.patients.count) patients")
let contentView = PatientListView()
.environmentObject(viewModel)
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
Patient.swift
import Foundation
import RealmSwift
@objcMembers final class Patient: Object, Identifiable {
dynamic let id: String = UUID().uuidString
dynamic var name: String = ""
required init() {
super.init()
}
init(name: String) {
self.name = name
}
}
PatientService
import Foundation
import RealmSwift
@objcMembers final class Patient: Object, Identifiable {
dynamic let id: String = UUID().uuidString
dynamic var name: String = ""
required init() {
super.init()
}
init(name: String) {
self.name = name
}
}
ViewModel
import Foundation
import Combine
protocol ViewModel: ObservableObject where ObjectWillChangePublisher.Output == Void {
associatedtype State // the type of the state of a given scene
associatedtype Input // inputs to the view model that are transformed by the trigger method
var state: State { get }
func trigger(_ input: Input)
}
final class AnyViewModel<State, Input>: ObservableObject { // wrapper enables "effective" (not true) type erasure of the view model
private let wrappedObjectWillChange: () -> AnyPublisher<Void, Never>
private let wrappedState: () -> State
private let wrappedTrigger: (Input) -> Void
var objectWillChange: some Publisher {
wrappedObjectWillChange()
}
var state: State {
wrappedState()
}
func trigger(_ input: Input) {
wrappedTrigger(input)
}
init<V: ViewModel>(_ viewModel: V) where V.State == State, V.Input == Input {
self.wrappedObjectWillChange = { viewModel.objectWillChange.eraseToAnyPublisher() }
self.wrappedState = { viewModel.state }
self.wrappedTrigger = viewModel.trigger
}
}
extension AnyViewModel: Identifiable where State: Identifiable {
var id: State.ID {
state.id
}
}
RealmCollectionPublisher
import Foundation
import Combine
import RealmSwift
// MARK: Custom publisher - produces a stream of Object arrays in response to change notifcations on a given Realm collection
extension Publishers {
struct Realm<Collection: RealmCollection>: Publisher {
typealias Output = Array<Collection.Element>
typealias Failure = Never // TODO: Not true but deal with this later
let collection: Collection
init(collection: Collection) {
self.collection = collection
}
func receive<S>(subscriber: S) where S : Subscriber, Failure == S.Failure, Output == S.Input {
let subscription = RealmSubscription(subscriber: subscriber, collection: collection)
subscriber.receive(subscription: subscription)
}
}
}
// MARK: Convenience accessor function to the custom publisher
extension Publishers {
static func realm<Collection: RealmCollection>(collection: Collection) -> Publishers.Realm<Collection> {
return Publishers.Realm(collection: collection)
}
}
// MARK: Custom subscription
private final class RealmSubscription<S: Subscriber, Collection: RealmCollection>: Subscription where S.Input == Array<Collection.Element> {
private var subscriber: S?
private let collection: Collection
private var notificationToken: NotificationToken?
init(subscriber: S, collection: Collection) {
self.subscriber = subscriber
self.collection = collection
self.notificationToken = collection.observe { (changes: RealmCollectionChange) in
switch changes {
case .initial:
// Results are now populated and can be accessed without blocking the UI
print("Initial")
subscriber.receive(Array(collection.elements))
case .update(_, let deletions, let insertions, let modifications):
print("Updated")
subscriber.receive(Array(collection.elements))
case .error(let error):
fatalError("\(error)")
#warning("Impl error handling - do we want to fail or log and recover?")
}
}
}
func request(_ demand: Subscribers.Demand) {
// no impl as RealmSubscriber is effectively just a sink
}
func cancel() {
print("Cancel called on RealnSubscription")
subscriber = nil
notificationToken = nil
}
deinit {
print("RealmSubscription de-initialised")
}
}
PatientListViewModel
class PatientListViewModel: ViewModel {
@Published var state: PatientListState = PatientListState(patients: [AnyViewModel<PatientDetailState, Never>]()) {
willSet {
print("Current PatientListState : \(newValue)")
}
}
private let patientService: PatientService
private var cancellables = Set<AnyCancellable>()
init(patientService: PatientService) {
self.patientService = patientService
// Scenario 1 - This code sets state which is correctly shown in UI (although not dynamically updated)
let viewModels = patientService.allPatientsAsArray()
.map { AnyViewModel(PatientDetailViewModel(patient: $0, patientService: patientService)) }
self.state = PatientListState(patients: viewModels)
// Scenario 2 (BUGGED) - This publisher's downstream emissions update dynamically, downstream outputs are correct and the willSet observer suggests .assign is working
// but the UI does not reflect the changes (if the above declarative code is removed, the number of patients is always zero)
let publishedState = Publishers.realm(collection: patientService.allPatientsAsResults())
.print()
.map { results in
results.map { AnyViewModel(PatientDetailViewModel(patient: $0, patientService: patientService)) } }
.map { PatientListState(patients: $0) }
.eraseToAnyPublisher()
.assign(to: \.state, on: self)
.store(in: &cancellables)
}
func trigger(_ input: PatientListInput) {
switch(input) {
case .delete(let indexSet):
let patient = state.patients[indexSet.first!].state.patient
patientService.deletePatient(patient)
print("Deleting item at index \(indexSet.first!) - patient is \(patient)")
#warning("Know which patient to remove but need to ensure the state is updated")
}
}
deinit {
print("Viewmodel being deinitialised")
}
}
PatientListView
struct PatientListState {
var patients: [AnyViewModel<PatientDetailState, Never>]
}
enum PatientListInput {
case delete(IndexSet)
}
struct PatientListView: View {
@EnvironmentObject var viewModel: AnyViewModel<PatientListState, PatientListInput>
var body: some View {
NavigationView {
VStack {
Text("Patients: \(viewModel.state.patients.count)")
List {
ForEach(viewModel.state.patients) { viewModel in
PatientCell(patient: viewModel.state.patient)
}
.onDelete(perform: deletePatient)
}
.navigationBarTitle("Patients")
}
}
}
private func deletePatient(at offset: IndexSet) {
viewModel.trigger(.delete(offset))
}
}
PatientDetailViewModel
class PatientDetailViewModel: ViewModel {
@Published private(set) var state: PatientDetailState
private let patientService: PatientService
private let patient: Patient
init(patient: Patient, patientService: PatientService) {
self.patient = patient
self.patientService = patientService
self.state = PatientDetailState(patient: patient)
}
func trigger(_ input: Never) {
// TODO: Implementation
}
}
PatientDetailView
struct PatientDetailState {
let patient: Patient
var name: String {
patient.name
}
}
extension PatientDetailState: Identifiable {
var id: Patient.ID {
patient.id
}
}
struct PatientDetailView: View {
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
}
}
struct PatientDetailView_Previews: PreviewProvider {
static var previews: some View {
PatientDetailView()
}
}