SwiftUI Class won't DeInit - swift

I've got a class class MapSearch that I instantiate when I need to auto-complete address results. It works perfectly but it never deinitializes and I can't figure out why.
Easily test by creating the files below. Use the back button after navigating to the test page and watch the console messages. You will see that the view model initializes and deinitializes as it should, but you'll only see MapSearch initialize.
HomeView.swift
import SwiftUI
struct HomeView: View {
var body: some View {
NavigationView {
NavigationLink(destination: TestView(viewModel: TestViewModel()) {
Text("TestView")
}
}
}
}
TestView.swift
import SwiftUI
struct TestView: View {
#StateObject var viewModel: ViewModel
var body: some View {
Text("Hello World")
}
}
TestViewModel.swift
import Foundation
extension TestView {
#MainActor
class ViewModel: ObservableObject {
#Published var mapSearch: MapSearch()
init() {
print("Test View Model Initialized")
}
deinit {
print("Test View Model Deinitialized")
}
}
}
MapSearch.swift
import Combine
import CoreLocation
import Foundation
import MapKit
/// Uses MapKit and CoreLocation to auto-complete an address
class MapSearch: NSObject, ObservableObject {
#Published var countryName: String = "United States"
#Published var locationResults: [MKLocalSearchCompletion] = []
#Published var searchTerm = ""
private var cancellables: Set<AnyCancellable> = []
private var searchCompleter = MKLocalSearchCompleter()
private var currentPromise: ((Result<[MKLocalSearchCompletion], Error>) -> Void)?
override init() {
super.init()
searchCompleter.delegate = self
searchCompleter.resultTypes = MKLocalSearchCompleter.ResultType([.address])
$searchTerm
.debounce(for: .seconds(0.2), scheduler: RunLoop.main)
.removeDuplicates()
.flatMap({ (currentSearchTerm) in
self.searchTermToResults(searchTerm: currentSearchTerm, countryName: self.countryName)
})
.sink(receiveCompletion: { (_) in
}, receiveValue: { (results) in
// Show country specific results
self.locationResults = results.filter { $0.subtitle.contains(self.countryName) }
})
.store(in: &cancellables)
print("MapSearch Initialized")
}
deinit {
print("MapSearch Deinitialized")
}
func searchTermToResults(searchTerm: String, countryName: String) -> Future<[MKLocalSearchCompletion], Error> {
Future { promise in
self.searchCompleter.queryFragment = searchTerm
self.currentPromise = promise
}
}
}
extension MapSearch: MKLocalSearchCompleterDelegate {
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
currentPromise?(.success(completer.results))
}
func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
// deal with the error here, it will finish the Combine publisher stream
currentPromise?(.failure(error))
}
}

The MapSearch class needed to be adjusted to add [weak self] in the combine calls. Now it deinits properly.
Here's the code for reference:
import Combine
import CoreLocation
import Foundation
import MapKit
/// Uses MapKit and CoreLocation to auto-complete an address
class MapSearch: NSObject, ObservableObject {
#Published var countryName: String = "United States"
#Published var locationResults: [MKLocalSearchCompletion] = []
#Published var searchTerm = ""
private var cancellables: Set<AnyCancellable> = []
private var searchCompleter = MKLocalSearchCompleter()
private var currentPromise: ((Result<[MKLocalSearchCompletion], Error>) -> Void)?
override init() {
super.init()
searchCompleter.delegate = self
searchCompleter.resultTypes = MKLocalSearchCompleter.ResultType([.address])
$searchTerm
.debounce(for: .seconds(0.2), scheduler: RunLoop.main)
.removeDuplicates()
.flatMap({ [weak self] (currentSearchTerm) in
(self?.searchTermToResults(searchTerm: currentSearchTerm, countryName: self?.countryName ?? "")) ??
Future { [weak self] promise in
self?.searchCompleter.queryFragment = self?.searchTerm ?? ""
self?.currentPromise = promise
}
})
.sink(receiveCompletion: { (_) in
}, receiveValue: { [weak self] (results) in
// Show country specific results
self?.locationResults = results.filter { $0.subtitle.contains(self?.countryName ?? "") }
})
.store(in: &cancellables)
print("MapSearch Initialized")
}
deinit {
print("MapSearch Deinitialized")
}
func searchTermToResults(searchTerm: String, countryName: String) -> Future<[MKLocalSearchCompletion], Error> {
Future { [weak self] promise in
self?.searchCompleter.queryFragment = searchTerm
self?.currentPromise = promise
}
}
}
extension MapSearch: MKLocalSearchCompleterDelegate {
func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
currentPromise?(.success(completer.results))
}
func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) {
// deal with the error here, it will finish the Combine publisher stream
currentPromise?(.failure(error))
}
}

Related

GlobalActor directive doesn't guarantee a function will be called on that actor

Assuming I have defined a global actor:
#globalActor actor MyActor {
static let shared = MyActor()
}
And I have a class, in which a couple of methods need to act under this:
class MyClass {
#MyActor func doSomething(undoManager: UndoManager) {
// Do something here
undoManager?.registerUndo(withTarget: self) {
$0.reverseSomething(undoManager: UndoManager)
}
}
#MyActor func reverseSomething(undoManager: UndoManager) {
// Do the reverse of something here
print(\(Thread.isMainThread) /// Prints true when called from undo stack
undoManager?.registerUndo(withTarget: self) {
$0.doSomething(undoManager: UndoManager)
}
}
}
Assume the code gets called from a SwiftUI view:
struct MyView: View {
#Environment(\.undoManager) private var undoManager: UndoManager?
let myObject: MyClass
var body: some View {
Button("Do something") { myObject.doSomething(undoManager: undoManager) }
}
}
Note that when the action is undone the 'reversing' func it is called on the MainThread. Is the correct way to prevent this to wrap the undo action in a task? As in:
#MyActor func reverseSomething(undoManager: UndoManager) {
// Do the reverse of something here
print(\(Thread.isMainThread) /// Prints true
undoManager?.registerUndo(withTarget: self) {
Task { $0.doSomething(undoManager: UndoManager) }
}
}
I am surprised that the compiler does not generate a warning about calling a global actor 'MyActor'-isolated instance method in a synchronous nonisolated context (i.e. the closure). It would appear that the compiler is confused by the closure syntax within an actor isolated method.
Anyway, you can wrap it in a Task and it should run that on the appropriate actor:
#MyActor func doSomething(undoManager: UndoManager) {
// Do something here
undoManager.registerUndo(withTarget: self) { target in
Task { #MyActor in
target.reverseSomething(undoManager: undoManager)
}
}
}
That having been said, I have found erratic UndoManager behavior when using it from a background thread (i.e., not on the main actor).
So, especially because undo/redo is behavior generally initiated from the UI (on the main thread), I would keep it on the main thread, and only run the desired work on another actor. E.g.:
struct ContentView: View {
#StateObject var viewModel = ViewModel()
#State var input: String = ""
var body: some View {
VStack {
TextField(text: $input) {
Text("enter value")
}
Button("Add record") {
viewModel.addAndPrepareUndo(for: input)
input = ""
}.disabled(input.isEmpty)
Button("Undo") {
viewModel.undo()
}.disabled(!viewModel.canUndo)
Button("Redo") {
viewModel.redo()
}.disabled(!viewModel.canRedo)
}
.padding()
}
}
#globalActor actor MyGlobalActor {
static let shared = MyGlobalActor()
}
#MainActor
class ViewModel: ObservableObject {
#MyGlobalActor
var values: [String] = []
#Published var canUndo = false
#Published var canRedo = false
private var undoManager = UndoManager()
func undo() {
undoManager.undo()
updateUndoStatus()
}
func redo() {
undoManager.redo()
updateUndoStatus()
}
func updateUndoStatus() {
canUndo = undoManager.canUndo
canRedo = undoManager.canRedo
}
func addAndPrepareUndo(for newValue: String) {
undoManager.registerUndo(withTarget: self) { [weak self] target in
guard let self else { return }
self.removeAndPrepareRedo(for: newValue)
}
updateUndoStatus()
Task { #MyGlobalActor in
values.append(newValue)
print(#function, values)
}
}
func removeAndPrepareRedo(for revertValue: String) {
undoManager.registerUndo(withTarget: self) { [weak self] target in
guard let self else { return }
self.addAndPrepareUndo(for: revertValue)
}
updateUndoStatus()
Task { #MyGlobalActor in
values.removeLast()
print(#function, values)
}
}
}
Now, this is a somewhat contrived example (for something this simple, we wouldn't have a simply array on a global actor), but hopefully it illustrates the idea.
Or, you can use a non-global actor:
struct ContentView: View {
#StateObject var viewModel = ViewModel()
#State var input: String = ""
var body: some View {
VStack {
TextField(text: $input) {
Text("enter value")
}
Button("Add record") {
viewModel.addAndPrepareUndo(for: input)
input = ""
}.disabled(input.isEmpty)
Button("Undo") {
viewModel.undo()
}.disabled(!viewModel.canUndo)
Button("Redo") {
viewModel.redo()
}.disabled(!viewModel.canRedo)
}
.padding()
}
}
#MainActor
class ViewModel: ObservableObject {
var model = Model()
#Published var canUndo = false
#Published var canRedo = false
private var undoManager = UndoManager()
func undo() {
undoManager.undo()
updateUndoStatus()
}
func redo() {
undoManager.redo()
updateUndoStatus()
}
func updateUndoStatus() {
canUndo = undoManager.canUndo
canRedo = undoManager.canRedo
}
func addAndPrepareUndo(for newValue: String) {
undoManager.registerUndo(withTarget: self) { [weak self] target in
guard let self else { return }
self.removeAndPrepareRedo(for: newValue)
}
updateUndoStatus()
Task {
await model.append(newValue)
await print(#function, model.values())
}
}
func removeAndPrepareRedo(for revertValue: String) {
undoManager.registerUndo(withTarget: self) { [weak self] target in
guard let self else { return }
self.addAndPrepareUndo(for: revertValue)
}
updateUndoStatus()
Task {
await model.removeLast()
await print(#function, model.values())
}
}
}
actor Model {
private var strings: [String] = []
func append(_ string: String) {
strings.append(string)
}
func removeLast() {
strings.removeLast()
}
func values() -> [String] {
strings
}
}

Update data using MVVM architecture in swift

I am using MVVM Architecture for learning to develop a small weather application for learning purposes.
View
class ViewController: UIViewController {
private var cityDataViewModel = CityDataViewModel()
private var data = [ConsolidatedWeather]()
#IBOutlet weak var label1: UILabel!
#IBOutlet weak var label2: UILabel!
override func viewDidLoad() {
super.viewDidLoad()
loadCityData()
}
func loadCityData(){
print("loadCityData")
cityDataViewModel.getCityData {
}
}
}
ViewModel
class CityDataViewModel{
private var networkManager = CityNetworkManager()
private var weatherNetworkManager = WeatherNetworkManager()
var weatherModel = [ConsolidatedWeather]()
var myStruct :[WeatherModel] = []
var weatherState: String?
var minTemp: Double?
var maxTemp: Double?
var currentTemperature: Double?
var summary: String?
var dateString: String = ""
//MARK: - Get cityInformation
func getCityData(completion: #escaping () -> ()) {
networkManager.getCityDataNetworkCall { [weak self](result) in
switch result{
case .success(let information):
information.forEach { (data) in
print("\(data.title) || \(data.locationType) || \(data.woeid) || \(data.lattLong)")
print("loadCityData 3")
self?.getCityWeatherInformation(with: data.woeid)
print("loadCityData 4")
}
completion()
case .failure(let error):
print(error)
}
}
}
//MARK: - Get Weather data
func getCityWeatherInformation(with woeid: Int){
//[weak self]
weatherNetworkManager.getWeatherDataNetworkCall(cityId: woeid) {[weak self] (result) in
print("loadCityData 5")
switch result{
case .success(let listOfData):
self?.weatherModel = listOfData.consolidatedWeather
}
case .failure(let error):
print(error)
}
}
}
var ttile: String{
return weatherState ?? ""
}
}
From the view, I am sending a call to ViewModel to get cityId by using func getCityDat()
After get the cityId I called func getCityWeatherInformation(with woeid: Int) for get details weather data. I am successfully getting those data from server.
How can I send that information to view for updating my viewController?
Setting up a protocol/closure system as mentioned in the comments is certainly a popular option.
As of iOS 13, you also have the option of using Combine to publish the changes on your ViewModel, which can trigger the ViewController to update.
A simplified example:
import Combine
import UIKit
class MyVC : UIViewController {
private var label = UILabel()
private var label2 = UILabel()
private var viewModel = ViewModel()
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
addLabels()
linkPublishers()
viewModel.getData()
}
func linkPublishers() {
//OPTION 1
viewModel.objectWillChange.sink { (_) in
DispatchQueue.main.async {
self.label.text = self.viewModel.text1
self.label2.text = self.viewModel.text2
}
}
.store(in: &cancellables)
// **** OR ****
//OPTION 2
viewModel
.$text1
.receive(on: RunLoop.main)
.sink { (newLabelText) in
self.label.text = newLabelText
}.store(in: &cancellables)
viewModel
.$text2
.receive(on: RunLoop.main)
.sink { (newLabelText) in
self.label2.text = newLabelText
}.store(in: &cancellables)
}
func addLabels() {
label.frame = CGRect(x: 0, y: 0, width: 200, height: 40)
self.view.addSubview(label)
label2.frame = CGRect(x: 0, y: 40, width: 200, height: 40)
self.view.addSubview(label2)
}
}
class ViewModel : ObservableObject {
#Published var text1 = ""
#Published var text2 = ""
func getData() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.text1 = "Hello, world"
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.text2 = "Hello, world 2"
}
}
}
The ViewModel here does a fake task mocking an async network call. Then, it sets one of its #Published properties to the result of that data.
Back in the ViewController, linkPublishers has two different ways of hooking up those published properties:
Observing objectWillChange, which gets triggered before any of the published properties update
Observing each #Published property independently.

Weird Swift TextField glitch that might not have a solution?

so I have a fairly large file that defines the view of a search bar. I just spent the last two hours removing all of the excess/unnecessary code from the file. The error I'm having is that when I type pretty fast into the search bar, not every key that is pressed is registered, so it ends up coming out as some garbled mess. It seems like the more ObservedObjects, State variables, Binding variables, and just normal variables and code I remove, the quicker the better the text field works.
The glitch I'm having can be seen in this link: https://youtu.be/42sjhDxSKBw
For reference, what I typed in was "Hello stack overflow this is a test for typing fast"...if I type it in slower, it all appears.
In the example below, I removed all the variables so it runs pretty smoothly. Does anyone have any experience with SwiftUI TextFields demonstrating this odd behavior of not registering every key when there is a lot going on? The view for the text field (in it's simplest most broken down form, without all the different variables and stuff, is the following):
import SwiftUI
import Mapbox
import MapboxGeocoder
struct SearchBar: View {
var VModel : ViewModel
#Binding var searchedText: String
var body: some View {
let binding = Binding<String>(get: {
self.searchedText
}, set: {
self.searchText = $0
self.searchedText = self.searchText
self.VModel.findResults(address: self.searchedText)
if self.VModel.searchResults.count >= 0 {
self.showResults = true
self.showMoreDetails = false
} else {
self.showResults = false
}
}
)
return VStack {
HStack {
TextField("Search", text: binding, onEditingChanged: { isEditing in
print("we are not editing the text field")
}, onCommit: {
print("pressed enter")
if self.VModel.searchResults.first != nil {
self.annotation.addNextAnnotation(address: self.rowText(result: self.VModel.searchResults.first!).label)
self.searchedText = "\(self.rowText(result: self.VModel.searchResults.first!).label)"
}
})
}
.foregroundColor(Color(.white))
.background(Color.gray)
}
}
}
The ViewModel class looks like:
import SwiftUI
import CoreLocation
import Mapbox
import MapboxGeocoder
class ViewModel: ObservableObject {
#ObservedObject var locationManager = LocationManager()
#Published var lat: Double?
#Published var lon: Double?
#Published var location: CLLocationCoordinate2D?
#Published var name: CLPlacemark?
#Published var searchResults: [GeocodedPlacemark] = []
func findResults(address: String) {
let geocoder = Geocoder(accessToken: "pk.eyJ1Ijoibmlja2JyaW5zbWFkZSIsImEiOiJjazh4Y2dzcW4wbnJyM2ZtY2V1d20yOW4wIn0.LY1H3cf7Uz4BhAUz6JmMww")
let foptions = ForwardGeocodeOptions(query: address)
foptions.maximumResultCount = 10
geocoder.geocode(foptions) { (placemarks, attribution ,error) in
guard let placemarks = placemarks else {
return
}
self.searchResults = []
for placemark in placemarks {
self.searchResults.append(placemark)
}
}
}
}
In a function used to display the search results, I have the following code block that uses searchResults:
ForEach(self.VModel.searchResults, id: \.self) { result in
Button(action: {
self.annotation.addNextAnnotation(address: self.rowText(result: result).label)
self.showResults = false
self.searchedText = self.rowText(result: result).label
}, label: {
self.rowText(result: result).view.font(.system(size: 13))
}).listRowBackground(Color.gray)
}
Try like the following (not tested as env cannot be replicated)
import Combine
class ViewModel: ObservableObject {
#ObservedObject var locationManager = LocationManager()
#Published var lat: Double?
#Published var lon: Double?
#Published var location: CLLocationCoordinate2D?
#Published var name: CLPlacemark?
#Published var searchResults: [GeocodedPlacemark] = []
private let searchValue = CurrentValueSubject<String, Never>("")
private var cancellable: AnyCancellable?
func findResults(address: String) {
if nil == cancellable {
cancellable = self.searchValue
.debounce(for: 0.5, scheduler: DispatchQueue.main)
.flatMap { newValue in
Future<[GeocodedPlacemark], Never> { promise in
let geocoder = Geocoder(accessToken: "pk.eyJ1Ijoibmlja2JyaW5zbWFkZSIsImEiOiJjazh4Y2dzcW4wbnJyM2ZtY2V1d20yOW4wIn0.LY1H3cf7Uz4BhAUz6JmMww")
let foptions = ForwardGeocodeOptions(query: address)
foptions.maximumResultCount = 10
geocoder.geocode(foptions) { (placemarks, attribution ,error) in
guard let placemarks = placemarks else {
return
}
promise(.success(placemarks))
}
}
}
.receive(on: DispatchQueue.main)
.sink(receiveValue: { placemarks in
self.searchResults = placemarks
})
}
self.searchValue.send(address)
}
}

Testing view models that rely on an asynchronous data source

I'm building a SwiftUI app that is using the MVVM pattern. The data source for the view models is provided by a custom Publisher for a Realm database. As I'm trying to be good and do a bit of test-driven development, I wrote a test to ensure that the view model responds appropriately to inputs from the SwiftUI front end (specifically in this instance, only querying the Realm once the UI was displayed). The code functions as expected but the test doesn't...
This is almost certainly because I'm not accounting for background processing / thread issues. My normal approach would to set up an expectation but this doesn't help as I need to use the property I'm interested in to create a Publisher but this completes immediately after emitting the initial state and I don't know how to keep it "alive" until the expectation expires. Can anyone point me in the right direction?
View model:
final class PatientListViewModel: ObservableObject, UnidirectionalDataFlowType {
typealias InputType = Input
enum Input {
case onAppear
}
private var cancellables = Set<AnyCancellable>()
private let onAppearSubject = PassthroughSubject<Void, Never>()
// MARK: Output
#Published private(set) var patients: [Patient] = []
// MARK: Private properties
private let realmSubject = PassthroughSubject<Array<Patient>, Never>()
private let realmService: RealmServiceType
// MARK: Initialiser
init(realmService: RealmServiceType) {
self.realmService = realmService
bindInputs()
bindOutputs()
}
// MARK: ViewModel protocol conformance (functional)
func apply(_ input: Input) {
switch input {
case .onAppear:
onAppearSubject.send()
}
}
// MARK: Private methods
private func bindInputs() {
let _ = onAppearSubject
.flatMap { [realmService] _ in realmService.all(Patient.self) }
.share()
.eraseToAnyPublisher()
.receive(on: RunLoop.main)
.subscribe(realmSubject)
.store(in: &cancellables)
}
private func bindOutputs() {
let _ = realmSubject
.assign(to: \.patients, on: self)
.store(in: &cancellables)
}
}
Test class: (very bulky due to my debugging code!)
import XCTest
import RealmSwift
import Combine
#testable import AthenaVS
class AthenaVSTests: XCTestCase {
private var cancellables = Set<AnyCancellable>()
private var service: RealmServiceType?
override func setUp() {
service = TestRealmService()
}
override func tearDown() {
// Put teardown code here. This method is called after the invocation of each test method in the class.
service = nil
cancellables.removeAll()
}
func testPatientListViewModel() {
let viewModel = PatientListViewModel(realmService: service!)
let expectation = self.expectation(description: #function)
var outcome = Array<Patient>()
let _ = viewModel.patients.publisher.collect()
.handleEvents(receiveSubscription: { (subscription) in
print("Receive subscription")
}, receiveOutput: { output in
print("Received output: \(output)")
outcome = output
}, receiveCompletion: { _ in
print("Receive completion")
expectation.fulfill()
}, receiveCancel: {
print("Receive cancel")
expectation.fulfill()
}, receiveRequest: { demand in
print("Receive request: \(demand)")})
.sink { _ in }
.store(in: &cancellables)
viewModel.apply(.onAppear)
waitForExpectations(timeout: 2, handler: nil)
XCTAssertEqual(outcome.count, 4, "ViewModel state should change once triggered")
}
}
EDITED:
My apologies for the lack of clarity. The rest of the code base is as follows:
SwiftUI View
struct ContentView: View {
#ObservedObject var viewModel: PatientListViewModel
var body: some View {
NavigationView {
List {
ForEach(viewModel.patients) { patient in
Text(patient.name)
}
.onDelete(perform: delete )
}
.navigationBarTitle("Patients")
.navigationBarItems(trailing:
Button(action: { self.viewModel.apply(.onAdd) })
{ Image(systemName: "plus.circle")
.font(.title)
}
)
}
.onAppear(perform: { self.viewModel.apply(.onAppear) })
}
func delete(at offset: IndexSet) {
viewModel.apply(.onDelete(offset))
}
}
Realm Service
protocol RealmServiceType {
func all<Element>(_ type: Element.Type, within realm: Realm) -> AnyPublisher<Array<Element>, Never> where Element: Object
#discardableResult
func addPatient(_ name: String, to realm: Realm) throws -> AnyPublisher<Patient, Never>
func deletePatient(_ patient: Patient, from realm: Realm)
}
extension RealmServiceType {
func all<Element>(_ type: Element.Type) -> AnyPublisher<Array<Element>, Never> where Element: Object {
all(type, within: try! Realm())
}
func deletePatient(_ patient: Patient) {
deletePatient(patient, from: try! Realm())
}
}
final class TestRealmService: RealmServiceType {
private let patients = [
Patient(name: "Tiddles"), Patient(name: "Fang"), Patient(name: "Phoebe"), Patient(name: "Snowy")
]
init() {
let realm = try! Realm()
guard realm.isEmpty else { return }
try! realm.write {
for p in patients {
realm.add(p)
}
}
}
func all<Element>(_ type: Element.Type, within realm: Realm) -> AnyPublisher<Array<Element>, Never> where Element: Object {
return Publishers.realm(collection: realm.objects(type).sorted(byKeyPath: "name")).eraseToAnyPublisher()
}
func addPatient(_ name: String, to realm: Realm) throws -> AnyPublisher<Patient, Never> {
let patient = Patient(name: name)
try! realm.write {
realm.add(patient)
}
return Just(patient).eraseToAnyPublisher()
}
func deletePatient(_ patient: Patient, from realm: Realm) {
try! realm.write {
realm.delete(patient)
}
}
}
Custom Publisher (using Realm as a backend)
/ 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
let _ = subscriber.receive(Array(collection.elements))
// case .update(_, let deletions, let insertions, let modifications):
case .update(_, _, _, _):
let _ = 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() {
subscriber = nil
notificationToken = nil
}
}
The issue I'm experiencing is a failure of the test case. I am anticipating the the view model will map an input (.onAppear) from the SwiftUI front end into an array of 'Patients' and assign this array to its patients property. The code works as expected but XCTAssertEqual fails, reporting that the 'patients' property is an empty array after calling 'viewmodel.assign(.onAppear)'. If I put a property observer on 'patients' it does update as expected but the test is not "seeing" the this.

How would I write a test to make sure the UIbutton "Show all Providers" turns up when there's more than 12 or more items in the table view?

So I'm completely new to testing and I just needed some help figuring out for example how I would write a test for each of the three cases in the enum of the View Model (none, dontSeeProvider, showAllProviders).
enum ProvidersButtonType {
case none, dontSeeProvider, showAllProviders
}
I haven't been able to figure out how to write a test for cases "showAllProviders" and "dontSeeProviders".
This is the View Model:
import RxSwift
import RxCocoa
struct TopProvidersPickerItem {
let provider: MVPD
let logoImage: Observable<UIImage>
init(provider: MVPD, imageLoader: DecodableProviding) {
self.init(provider: provider, logoImage: imageLoader.image(fromURL: provider.logoUrl))
}
init(provider: MVPD, logoImage: Observable<UIImage>) {
self.provider = provider
self.logoImage = logoImage.catchErrorJustReturn(UIImage())
}
}
enum ProvidersButtonType {
case none, dontSeeProvider, showAllProviders
}
struct TopProvidersPickerViewModel {
var caption: String {
return "Get access to more full episodes by signing in with your TV Provider"
}
let buttonType = Variable<ProvidersButtonType>(.none)
let items: Observable<[TopProvidersPickerItem]>
let selectedItem: PublishSubject<TopProvidersPickerItem> = PublishSubject()
let showAllProvidersTrigger: PublishSubject<Void> = PublishSubject()
let mvpdPicked: Observable<MVPD>
init(topProviders: Observable<[MVPD]>, imageLoader: DecodableProviding) {
let items = topProviders.map({ mvpds in
return mvpds.map { mvpd in
TopProvidersPickerItem(provider: mvpd, imageLoader: imageLoader)
}
})
self.init(items: items)
}
init(items: Observable<[TopProvidersPickerItem]>) {
self.items = items
mvpdPicked = selectedItem.map { $0.provider }
let buttonType = items.map { (array) -> ProvidersButtonType in
if array.count > 12 {
return .showAllProviders
} else {
return .dontSeeProvider
}
}
buttonType.bind(to: self.buttonType)
}
}
This is the View Controller:
import UIKit
import RxCocoa
import RxSwift
public class ProviderCollectionViewCell: UICollectionViewCell {
#IBOutlet public private(set) weak var imageView: UIImageView!
}
public class TopProvidersPickerViewController: UIViewController,
ViewModelHolder {
var viewModel: TopProvidersPickerViewModel! = nil
private let bag = DisposeBag()
#IBOutlet public private(set) weak var collectionView: UICollectionView!
#IBOutlet public private(set) weak var captionLabel: UILabel!
#IBOutlet weak var viewAllProvidersButton: UIButton!
override public func viewDidLoad() {
super.viewDidLoad()
captionLabel.text = viewModel.caption
setupRx()
}
private func setupRx() {
viewModel.buttonType.asObservable().subscribe(onNext: { [button = self.viewAllProvidersButton] type in
button?.isHidden = false
switch type {
case .none:
button?.isHidden = true
case .dontSeeProvider:
button?.setTitle("Don't see provider", for: .normal)
case .showAllProviders:
button?.setTitle("Show all providers", for: .normal)
}
})
.disposed(by: bag)
viewModel.items
.bind(to: collectionView
.rx
.items(cellIdentifier: "ProviderCell", cellType: ProviderCollectionViewCell.self)) { [ unowned self ] _, item, cell in
item.logoImage.bind(to: cell.imageView.rx.image).addDisposableTo(self.bag)
}
.addDisposableTo(bag)
collectionView
.rx
.modelSelected(TopProvidersPickerItem.self)
.bind(to: self.viewModel.selectedItem)
.addDisposableTo(bag)
viewAllProvidersButton
.rx
.tap
.bind(to: self.viewModel.showAllProvidersTrigger)
.addDisposableTo(bag)
}
}
I wrote a test for the "none" case, but haven't been able to figure out the other two cases:
import FBSnapshotTestCase
import OHHTTPStubs
import RxSwift
#testable import AuthSuite
class TopProvidersPickerViewControllerTests: FBSnapshotTestCase,
ProvidersViewControllerTests {
override func setUp() {
super.setUp()
recordMode = true
}
func testDoesNotShowButtonWhenLoadingProviders() {
let viewModel = TopProvidersPickerViewModel(items: .never())
let controller = TopProvidersPickerViewController.instantiateViewController(with: viewModel)
presentViewController(controller)
FBSnapshotVerifyView(controller.view)
}
I've never used FB Snapshot Tester. I'm going to have to look into that.
Here's how I would do it:
I wouldn't expose the enum to the ViewController. setupRx() would contain this instead:
private func setupRx() {
viewModel.buttonTitle
.bind(to: viewAllProvidersButton.rx.title(for: .normal))
.disposed(by: bag)
viewModel.buttonHidden
.bind(to: viewAllProvidersButton.rx.isHidden)
.disposed(by: bag)
// everything else
}
Then to test the title of the button, for example, I would use these tests:
import XCTest
import RxSwift
#testable import RxPlayground
class TopProvidersPickerViewModelTests: XCTestCase {
func testButtonTitleEmptyItems() {
let topProviders = Observable<[MVPD]>.just([])
let decodableProviding = MockDecodableProviding()
let viewModel = TopProvidersPickerViewModel(topProviders: topProviders, imageLoader: decodableProviding)
var title: String = ""
_ = viewModel.buttonTitle.subscribe(onNext: { title = $0 })
XCTAssertEqual(title, "Don't see provider")
}
func testButtonTitle12Items() {
let topProviders = Observable<[MVPD]>.just(Array(repeating: MVPD(), count: 12))
let decodableProviding = MockDecodableProviding()
let viewModel = TopProvidersPickerViewModel(topProviders: topProviders, imageLoader: decodableProviding)
var title: String = ""
_ = viewModel.buttonTitle.subscribe(onNext: { title = $0 })
XCTAssertEqual(title, "Don't see provider")
}
func testButtonTitle13Items() {
let topProviders = Observable<[MVPD]>.just(Array(repeating: MVPD(), count: 13))
let decodableProviding = MockDecodableProviding()
let viewModel = TopProvidersPickerViewModel(topProviders: topProviders, imageLoader: decodableProviding)
var title: String = ""
_ = viewModel.buttonTitle.subscribe(onNext: { title = $0 })
XCTAssertEqual(title, "Show all providers")
}
}
class MockDecodableProviding: DecodableProviding {
// nothing needed for these tests.
}