RxSwift, binding Action to UIButton stops producing events after view transitions - ios

I'm trying to implement a MVVM login screen using RxSwift and encountered some difficulties.
My login screen (user, password, login button) transitions to a camera view screen, where the app checks for camera permissions, and if those are not approved, logs the user out and returns to the login screen.
I have a loginAction in my LoginViewModel that returns an Action<Void, LoginResult>, where LoginResult is a Result<Bool, Error> and my loginProvider service returns an Observable<LoginResult>:
struct LoginViewModel {
let sceneCoordinator: SceneCoordinatorType
let loginProvider: LoginProviderType
var usernameText = Variable<String>("")
var passwordText = Variable<String>("")
var isValid: Observable<Bool> {
return Observable.combineLatest(usernameText.asObservable(), passwordText.asObservable()) { username, password in
username.count > 0 && password.count > 0
}
}
init(loginProvider: LoginProvider, coordinator: SceneCoordinatorType) {
self.loginProvider = loginProvider
self.sceneCoordinator = coordinator
}
lazy var loginAction: Action<Void, LoginResult> = { (coordinator: SceneCoordinatorType, service: LoginProviderType, username: String, password: String) in
return Action<Void, LoginResult>(enabledIf: self.isValid) { _ in
return service.login(username: username, password: password)
.observeOn(MainScheduler.instance)
.do(onNext: { result in
guard let loggedIn = result.value else { return }
if loggedIn {
let cameraViewModel = CameraViewModel(coordinator: coordinator)
coordinator.transition(to: Scene.camera(cameraViewModel), type: .modal)
}
}(self.sceneCoordinator, self.loginProvider, self.companyText.value, self.usernameText.value, self.passwordText.value)
}
Everything works fine, valid input logs in successfully (loginProvider sends the request the my server, get the response and handles all additional steps accordingly).
In case that the user doesn't grant camera permissions, I have a Observable for that in my CameraViewModel which I bind to my CameraViewController, subscribe to and in case needed, log the user out and pop the view back to the login screen using CocoaAction that pops the current view (using a scene coordinator class).
Problem is, when I try to log in again after transitioning back to the Login screen, the subscription for the elements emitted by the loginAction doesn't receive any elements.
Here's the code for the LoginViewController:
class LoginViewController: UIViewController, BindableType {
var viewModel: LoginViewModel!
private let disposeBag = DisposeBag()
private var loginAction: Action<Void, LoginResult>!
#IBOutlet weak var usernameTextField: UITextField!
#IBOutlet weak var passwordTextField: UITextField!
#IBOutlet weak var loginButton: UIButton!
#IBOutlet weak var loadingIndicator: UIActivityIndicatorView!
private var usernameObservable: Observable<String> {
return usernameTextField.rx.text
.throttle(0.5, scheduler: MainScheduler.instance)
.map() { text in
return text ?? ""
}
}
private var passwordObservable: Observable<String> {
return passwordTextField.rx.text
.throttle(0.5, scheduler: MainScheduler.instance)
.map() { text in
return text ?? ""
}
}
override func viewDidLoad() {
super.viewDidLoad()
}
func bindViewModel() {
usernameObservable
.bind(to: viewModel.usernameText)
.disposed(by: disposeBag)
passwordObservable
.debug()
.bind(to: viewModel.passwordText)
.disposed(by: disposeBag)
loginAction = viewModel.loginAction
loginAction.elements
.subscribe(onNext: { [weak self] result in
self?.loadingIndicator.stopAnimating()
var message = ""
switch result {
case .failure(.unknownError):
message = "unknown error"
case .failure(.wrongCredentials):
message = "wrong credentials"
case .failure(.serviceUnavailable):
message = "service unavailable"
case let .success(loggedIn):
return
}
self?.errorMessage(message: message)
}).disposed(by: disposeBag)
loginButton.rx.tap
.subscribe(onNext: { [unowned self] in
self.loadingIndicator.startAnimating()
self.loginAction.execute(Void())
})
.disposed(by: disposeBag)
viewModel.isValid
.bind(to: loginButton.rx.isEnabled)
.disposed(by: disposeBag)
}
I can see that tapping the login button produces the tap event, however the Action itself stops being called. Any idea what I'm missing?

I am not sure how ScreenCoordinator works, but I would start with moving creation of usernameObservable/passwordObservable to viewDidLoad() because the issue seems to be lifecycle-related. Also, you can add a few more .debug() calls (with parameter to be able to distinguish between them in the logs).

Found the issue: Apparently the action didn't reach a complete state because one on the underlying services returned an observer that was subscribed in the login view controller and not disposed when returning to it from the camera view controller.
I've added a clearing method in that service which creates a new DisposeBag when returning to the login view, which allows the action to complete and released for reuse.

Related

transfer data with protocol

I try to decode weather api
this is my struct class weatherModal :
import Foundation
struct WeatherModel:Decodable{
var main:Main?
}
struct Main:Decodable {
var temp : Double?
var feels_like : Double?
var temp_min:Double?
var temp_max:Double?
var pressure , humidity: Int?
}
I am trying to learn protocols. So this is where a make api call manager class :
protocol WeatherManagerProtocol:AnyObject {
func weatherData(weatherData:WeatherModel)
}
class WeatherManager{
var weather : WeatherModel?
weak var delegate :WeatherManagerProtocol?
public func callWeather(city:String) {
let url = "http://api.openweathermap.org/data/2.5/weather?q=\(city)&appid=1234"
URLSession.shared.dataTask(with:URL(string: url)!) { (data, response, err) in
if err != nil {
print(err!.localizedDescription)
} else {
do {
self.weather = try JSONDecoder().decode(WeatherModel.self, from: data!)
self.delegate?.weatherData(weatherData: self.weather!)
} catch {
print(error.localizedDescription)
}
}
}.resume()
}
}
In my ViewController what I want to do is user write city name on textfield and If user clicked the process button print the information about weather.
class ViewController: UIViewController {
#IBOutlet weak var textField: UITextField!
var weatherManager = WeatherManager()
var data : WeatherModel?
override func viewDidLoad() {
super.viewDidLoad()
weatherManager.delegate = self
// Do any additional setup after loading the view.
}
#IBAction func processButtonClicked(_ sender: Any) {
if textField.text != "" {
weatherManager.callWeather(city: textField.text ?? "nil")
print(data?.main?.humidity) // it print nil
} else{
print("empty")
}
}
extension ViewController: WeatherManagerProtocol{
func weatherData(weatherData: WeatherModel) {
self.data = weatherData
print(self.data.main)
// in here I can show my data
}
}
When I clicked process button it always print nil. Why ? What am I doing wrong?
You seem not to understand how your own code is supposed to work. The whole idea of the protocol-and-delegate pattern you've set up is that the "signal" round-trips thru the weather manager on a path like this:
You (the ViewController) say weatherManager.callWeather
The weather manager does some networking.
The weather manager calls its own delegate's weatherData.
You (the ViewController) are that delegate, so your weatherData is called and that is where you can print.
So that is the signal path:
#IBAction func processButtonClicked(_ sender: Any) {
weatherManager.callWeather(city: textField.text ?? "nil") // < TO wm
}
func weatherData(weatherData: WeatherModel) { // < FROM wm
// can print `weatherData` here
}
You cannot short circuit this path by trying to print the weather data anywhere else. Stay on the path. You cannot turn this into a "linear" simple path; it is asynchronous.
If you do want it to look more like a "linear" simple path, use a completion handler instead of a delegate callback. That's what I do in my version of this same experiment, so my view controller code looks like this:
self.jsonTalker.fetchJSON(zip:self.currentZip) { result in
DispatchQueue.main.async {
// and now `result` contains the weather data, or an error
Even better, use the Combine framework (or wait until Swift 6 implements async/await).
You call to weatherManager.callWeather that start URLSession.shared.dataTask. the task is executed asynchronously, but callWeather return immediately. So, when you print data?.main?.humidity, the task did not finish yet, and data is still nil. After the task finish, you call weatherData and assign the response to data.

How do I rerender my view in swiftUI after a user logged in with Google on Firebase?

I'm fairly new to iOS Programming and swiftUI in particular.
I have the issue that I try to integrate firebase auth in my App in order to manage users.
Now the login, and log out basically works, the issue is, that after logging in, my view (which conditionally renders eighter the Google sign-in button or a list of content does not rerender so I still see the sign-in button even though I'm signed in).
I have set an observable Object to hold my auth status but unfortunately, it does not reload the current user automatically. So I set up a function to reload it manually which I would like to trigger when logging in. This works for the logout button but the logging in finishes in the AppDelegate, where for some reason I can't access the reloadUser() function.
I'm sure there is a better way to do this and would appreciate any help!
The Environment:
final class UserData: ObservableObject {
#Published var showFavoritesOnly = false
#Published var qrTags = qrTagData
#Published var user: User? = Auth.auth().currentUser
func reloadUser() -> Void {
self.user = Auth.auth().currentUser
}
}
The View I'd like to render:
struct MyQuaggsList: View {
#EnvironmentObject private var userData: UserData
var body: some View {
Group {
if getLogInState() != nil {
VStack {
NavigationView {
List {
Toggle(isOn: $userData.showFavoritesOnly) {
Text("Show Favorites Only")
}
ForEach(userData.qrTags) { qrTag in
if !self.userData.showFavoritesOnly || qrTag.isFavorite {
NavigationLink(
destination: QuagDetail(qrTag: qrTag)
.environmentObject(self.userData)
) {
QuaggRow(qrTag: qrTag)
}
}
}
}
.navigationBarTitle(Text("My Quaggs"))
}
SignOutButton()
}
} else {
SignInView()
}
}.onAppear(perform: {self.userData.reloadUser()})
}
func getLogInState() -> User? {
return Auth.auth().currentUser
}
}
Also, note there is the .onAppear() function which unfortunately only triggers on the initial appear not on the reappearance of the view after the user logged in.
Thanks so much in advance! It has been really frustrating.
The firebase and swiftUI combination is kinda tricky at first, but you will figure out that the same pattern is used in every single project, no worries.
Just follow my steps and customise on your project, here is our strategy.
- This might be a long answer, but i want to leave it as a refrence to all Firebase-SwiftUI user Managing in Stack OverFlow. -
Creating a SessionStore class which provides the BindableObject, and listen to your users Authentification and Handle the Auth and CRUD methods.
Creating a Model to our project ( you already did it)
Adding Auth methods in SessionStore Class.
Listening for changes and putting things together.
Let s start by SessionStore Class:
import SwiftUI
import Firebase
import Combine
class SessionStore : BindableObject {
var didChange = PassthroughSubject<SessionStore, Never>()
var session: User? { didSet { self.didChange.send(self) }}
var handle: AuthStateDidChangeListenerHandle?
func listen () {
// monitor authentication changes using firebase
handle = Auth.auth().addStateDidChangeListener { (auth, user) in
if let user = user {
// if we have a user, create a new user model
print("Got user: \(user)")
self.session = User(
uid: user.uid,
displayName: user.displayName
)
} else {
// if we don't have a user, set our session to nil
self.session = nil
}
}
}
// additional methods (sign up, sign in) will go here
}
Notice that we’ve declared that our session property is an optional User type, which we haven’t yet defined. Let’s quickly make one:
class User {
var uid: String
var email: String?
var displayName: String?
init(uid: String, displayName: String?, email: String?) {
self.uid = uid
self.email = email
self.displayName = displayName
}
}
Now, adding signUp, signIn and signOut methods
class SessionStore : BindableObject {
// prev code...
func signUp(
email: String,
password: String,
handler: #escaping AuthDataResultCallback
) {
Auth.auth().createUser(withEmail: email, password: password, completion: handler)
}
func signIn(
email: String,
password: String,
handler: #escaping AuthDataResultCallback
) {
Auth.auth().signIn(withEmail: email, password: password, completion: handler)
}
func signOut () -> Bool {
do {
try Auth.auth().signOut()
self.session = nil
return true
} catch {
return false
}
}
}
Finally, we need a way to stop listening to our authentication change handler.
class SessionStore : BindableObject {
// prev code...
func unbind () {
if let handle = handle {
Auth.auth().removeStateDidChangeListener(handle)
}
}
}
Finally, Making our content view:
import SwiftUI
struct ContentView : View {
#EnvironmentObject var session: SessionStore
var body: some View {
Group {
if (session.session != nil) {
Text("Hello user!")
} else {
Text("Our authentication screen goes here...")
}
}
}
}
#Ghazi Tozri thanks again for your answer, while it wasn't what I wanted to do exactly it pushed me in the right direction.
I just want to put here what I finally did so if anyone wants to not use Email / Password sign-in but Google sign-in they can benefit from it too.
I used the Combine Framework + the #Publisher Syntax to make it a bit more readable and I also don't need the Signing in and out Methods because Google Provides them.
The SwiftUI Button for Google sign-in would look something like this:
struct GoogleSignIn : UIViewRepresentable {
#EnvironmentObject private var userData: SessionStore
func makeUIView(context: UIViewRepresentableContext<GoogleSignIn>) -> GIDSignInButton {
let button = GIDSignInButton()
button.colorScheme = .dark
GIDSignIn.sharedInstance()?.presentingViewController = UIApplication.shared.windows.last?.rootViewController
//If you want to restore a session
//GIDSignIn.sharedInstance()?.restorePreviousSignIn()
return button
}
func updateUIView(_ uiView: GIDSignInButton, context: UIViewRepresentableContext<GoogleSignIn>) {
}
}
And the Used SessionStore like this:
import SwiftUI
import Combine
import Firebase
import GoogleSignIn
final class SessionStore: ObservableObject {
#Published var showFavoritesOnly = false
#Published var qrTags = qrTagData
#Published var session: User?
var handle: AuthStateDidChangeListenerHandle?
func listen() {
handle = Auth.auth().addStateDidChangeListener { (auth, user) in
if let user = user {
self.session = user
} else {
self.session = nil
}
}
}
}
In a view that checks for the authentication state I use the .onAppear() function like this:
struct UserProfile: View {
#EnvironmentObject private var session: SessionStore
var body: some View {
VStack {
if session.session != nil {
SignOutButton()
} else {
SignInView()
}
}.onAppear(perform: {self.session.listen()})
}
}
To sign out this function will do (from the Firebase Docs):
func signOut() -> Void {
let firebaseAuth = Auth.auth()
do {
try firebaseAuth.signOut()
}
catch let signOutError as NSError {
print ("Error signing out: %#", signOutError)
}
}
Hope this can help somebody else too.

Prevent disposal of PublishSubject (RxSwift)

I'm struggling with specific use-case incorporating RxSwift's PublishSubject.
For sake of simplicity unimportant details were omitted.
There is a MVVM setup. In VC I have a UIButton, on tap of which a network call should dispatch. In ViewModel I have a buttonDidTapSubject: PublishSubject<Void>.
class ViewModel {
let disposeBag = DisposeBag()
let buttonDidTapSubject = PublishSubject<Void>()
let service: Service
typealias Credentials = (String, String)
var credentials: Observable<Credentials> {
return Observable.just(("testEmail", "testPassword"))
}
init(_ service: Service) {
self.service = service
buttonDidTapSubject
.withLatestFrom(credentials)
.flatMap(service.login) // login method has signature func login(_ creds: Credentials) -> Observable<User>
.subscribe(onNext: { user in print("Logged in \(user)") },
onError: { error in print("Received error") })
.disposed(by: disposeBag)
}
}
class ViewController: UIViewController {
let viewModel: ViewModel
let button = UIButton()
init(_ viewModel: ViewModel) {
self.viewModel = viewModel
}
}
In controller's viewDidLoad I make a binding:
override func viewDidLoad() {
button.rx.tap.asObservable()
.subscribe(viewModel.buttonDidTapSubject)
.disposed(by: disposeBag)
}
The problem is, since network request can fail and Observable that is returned from login(_:) method will produce an error, the whole subscription to buttonDidTapSubject in ViewModel will be disposed. And all other taps on a button will not trigger sequence to login in ViewModel.
Is there any way to avoid this kind of behavior?
You can use retry to prevent finishing the subcription. If you only want to retry in specific cases or errors you can also use retryWhen operator
In the view model:
lazy var retrySubject: Observable<Void> = {
return viewModel.buttonDidTapSubject
.retryWhen { error in
if (error == .networkError){ //check here your error
return .just(Void())
} else {
return .never() // Do not retry
}
}
}()
In the view controller I would have done it in another way:
override func viewDidLoad() {
super.viewDidLoad()
button.rx.tap.asObservable()
.flatMap { [weak self] _ in
return self?.viewModel.retrySubject
}
.subscribe(onNext: {
//do whatever
})
.disposed(by: disposeBag)
}
Not sure if still relevant - Use PublishRelay ( although it is RxCocoa )

RxSwift: Is it safe to always use [unowned self] when a class has a disposeBag property?

I recently found an article that says using [unowned self] is always safe as long as you are adding the subscription to a DisposeBag and it is inside the view controller.
Assuming I have a ViewController where deinit is not being called due to a strong reference:
class ViewController: UIViewController {
#IBOutlet weak var searchBar: UISearchBar!
#IBOutlet weak var tableView: UITableView!
private let disposeBag = DisposeBag()
private var results = Variable<[Item]>([])
private var searchText = Variable("")
var selectedCompletion: ((Item) -> Void)!
override func viewDidLoad() {
super.viewDidLoad()
results.asObservable()
.bind(to: tableView.rx.items(cellIdentifier: "CustomCell", cellType: CustomCell.self)) { row, item, cell in
cell.configure(with: item)
}
.disposed(by: disposeBag)
tableView.rx.itemSelected
.subscribe(onNext: { ip in
self.selectedCompletion(self.results.value[ip.row])
self.navigationController?.popViewController(animated: true)
})
.disposed(by:disposeBag)
searchBar.rx.text
.debounce(0.6, scheduler: MainScheduler.instance)
.subscribe(onNext: { searchText in
if searchText == nil || searchText!.isEmpty { return }
self.search(query: searchText!)
})
.disposed(by: disposeBag)
}
private func search(query: String) {
// Search asynchronously
search(for: query) { response in
// Some logic here...
self.results.value = searchResult.results
}
}
}
I should simply be able to declare [unowned self] in my subscription closures and not have to worry about my app crashing from self being nil.
Where I'm confused is, because search is asynchronous, doesn't that mean self can be nil if the ViewController has been popped off the navigation stack before the query completes?
Or would the disposeBag be deallocated first and the closure wouldn't complete?
Any clarification about how to know whether or not a class owns a closure would be great too.
In my experience it's a safe approach to use unowned with a dispose bag, except one block - onDisposed. There have been the cases when an app crashed because of unowed keyword -> weak is useful here.
as #kzaher says on github
you should never use unowned.
sources:
https://github.com/RxSwiftCommunity/RxDataSources/issues/169
https://github.com/ReactiveX/RxSwift/issues/1593

RxSwift: Prevent multiple network requests

I am currently having an issue with multiple network requests executing when using RxSwift Observables. I understand that if one creates a cold observable and it has multiple observers, the observable will execute its block each time it is subscribed to.
I have tried to create a shared subscription observable that executes the network request once, and multiple subscribers will be notified of the result. Below is the what I have tried.
Sequence of events
Create the view model with the tap event of a uibutton
Create the serviceStatus Observable as a public property on the view model. This Observable is mapped from the buttonTapped Observable. It then filters out the "Loading" status. The returned Observable has a shareReplay(1) executed on it to return a shared subscription.
Create the serviceExecuting Observable as a public property on the view model. This observable is mapped from the serviceStatus Observable. It will return true if the status is "Loading"
Bind the uilabel to the serviceStatus Observable
Bind the activity indicator to the serviceExecuting Observable.
When the button is tapped, the service request is executed three time where I would be expecting it to be executed only once. Does anything stand out as incorrect?
Code
class ViewController {
let disposeBag = DisposeBag()
var button: UIButton!
var resultLabel: UILabel!
var activityIndicator: UIActivityIndicator!
lazy var viewModel = { // 1
return ViewModel(buttonTapped: self.button.rx.tap.asObservable())
}
override func viewDidLoad() {
super.viewDidLoad()
self.viewModel.serviceStatus.bindTo(self.resultLabel.rx_text).addDispsoableTo(disposeBag) // 4
self.viewModel.serviceExecuting.bindTo(self.activityIndicator.rx_animating).addDispsoableTo(disposeBag) // 5
}
}
class ViewModel {
public var serviceStatus: Observable<String> { // 2
let serviceStatusObseravble = self.getServiceStatusObservable()
let filtered = serviceStatusObseravble.filter { status in
return status != "Loading"
}
return filtered
}
public var serviceExecuting: Observable<Bool> { // 3
return self.serviceStatus.map { status in
return status == "Loading"
}
.startWith(false)
}
private let buttonTapped: Observable<Void>
init(buttonTapped: Observable<Void>) {
self.buttonTapped = buttonTapped
}
private func getServiceStatusObservable() -> Observable<String> {
return self.buttonTapped.flatMap { _ -> Observable<String> in
return self.createServiceStatusObservable()
}
}
private func createServiceStatusObservable() -> Observable<String> {
return Observable.create({ (observer) -> Disposable in
someAsyncServiceRequest() { result }
observer.onNext(result)
})
return NopDisposable.instance
})
.startWith("Loading")
.shareReplay(1)
}
EDIT:
Based on the conversation below, the following is what I was looking for...
I needed to apply a share() function on the Observable returned from the getServiceStatusObservable() method and not the Observable returned from the createServiceStatusObservable() method. There were multiple observers being added to this observable to inspect the current state. This meant that the observable executing the network request was getting executed N times (N being the number of observers). Now every time the button is tapped, the network request is executed once which is what I needed.
private func getServiceStatusObservable() -> Observable<String> {
return self.buttonTapped.flatMap { _ -> Observable<String> in
return self.createServiceStatusObservable()
}.share()
}
.shareReplay(1) will apply to only one instance of the observable. When creating it in createServiceStatusObservable() the sharing behavior will only affect the one value returned by this function.
class ViewModel {
let serviceStatusObservable: Observable<String>
init(buttonTapped: Observable<Void>) {
self.buttonTapped = buttonTapped
self.serviceStatusObservable = Observable.create({ (observer) -> Disposable in
someAsyncServiceRequest() { result in
observer.onNext(result)
}
return NopDisposable.instance
})
.startWith("Loading")
.shareReplay(1)
}
private func getServiceStatusObservable() -> Observable<String> {
return self.buttonTapped.flatMap { [weak self] _ -> Observable<String> in
return self.serviceStatusObservable
}
}
}
With this version, serviceStatusObservable is only created once, hence it's side effect will be shared everytime it is used, as it is the same instance.

Resources