Reputation: 45
I am trying to create a login page using in MVVM
using RxSwift + AppCoordinator
.
What I am trying to achieve is:
success
-> Success Alert, If error
-> ErrorAlertHowever, using the AppCoordinator
and MVVM
together, the subscriber
and observer
seems to be NOT working, because:
success
response from the API, Success Alert is not displayed.error
response from the API, Error Alert is not displayed.I have tried debugging, but since I am quite new to RxSwift
, I could not figure this out, and I do not know where I am going wrong, however, to me, the flow and logic in my code seems to be correct.
can anyone help/guide me with a better approach or help spot any errors in my code?
thanks in advance.
here is what i have:
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
private var appCoordinator = AppCoordinator()
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
appCoordinator.start()
return true
}
}
import Foundation
import RxCocoa
import RxSwift
import Swinject
class AppCoordinator: BaseCoordinator {
let sessionService = SessionService()
var window = UIWindow(frame: UIScreen.main.bounds)
override func start() {
navigationController.navigationBar.isHidden = true
window.rootViewController = navigationController
window.makeKeyAndVisible()
// TODO: here you could check if user is signed in and show appropriate screen
let coordinator = LogInCoordinator()
coordinator.navigationController = navigationController
start(coordinator: coordinator)
}
}
protocol LogInListener {
func didLogIn()
}
extension AppCoordinator: LogInListener {
func didLogIn() {
print("Logged In")
// TODO: Navigate to Dashboard or any other flow
// However, this lines of code is NOT being called at all, and I do not see
// the print statement either. I dont know why.?
}
}
import Foundation
import UIKit
protocol Coordinator: AnyObject {
var navigationController: UINavigationController { get set }
var parentCoordinator: Coordinator? { get set }
func start()
func start(coordinator: Coordinator)
func didFinish(coordinator: Coordinator)
}
class BaseCoordinator: Coordinator {
var childCoordinators: [Coordinator] = []
var parentCoordinator: Coordinator?
var navigationController = UINavigationController()
func start() {
fatalError("Start method must be implemented")
}
func start(coordinator: Coordinator) {
childCoordinators.append(coordinator)
coordinator.parentCoordinator = self
coordinator.start()
}
func didFinish(coordinator: Coordinator) {
if let index = childCoordinators.firstIndex(where: { $0 === coordinator }) {
childCoordinators.remove(at: index)
}
}
}
import Foundation
import RxSwift
import RxCocoa
import SwiftyJSON
import Alamofire
protocol Authentication {
func login(username: String, password: String) -> Single<AuthResponse>
}
// MARK: - SessionService
class SessionService: Authentication {
func login(username: String, password: String) -> Single<AuthResponse> {
let formHeader: HTTPHeaders? = [
"Content-Type": "application/x-www-form-urlencoded"
]
let parameters: Parameters = [
"username": username,
"password": password
]
let decoder = JSONDecoder()
return Single<AuthResponse>.create { single in
AF.request(API.auth, method: .get, parameters: parameters, headers: formHeader).responseDecodable(of: AuthResponse.self, decoder: decoder, completionHandler: { _ in
// it returns either error or success, I got Success.
single(.success(AuthResponse()))
})
return Disposables.create()
}
}
}
And, the Below is the LogIn Part
import Foundation
import RxSwift
import RxCocoa
class LogInCoordinator: BaseCoordinator {
private let disposeBag = DisposeBag()
override func start() {
let vc = LoginViewController.instantiate()
// Coordinator initializes and injects viewModel
let logInViewModel = LogInViewModel(authentication: SessionService())
vc.viewModel = logInViewModel
// Coordinator subscribes to events and notifies parentCoordinator
logInViewModel.didLogIn
.subscribe(onNext: { [weak self] in
guard let self = self else { return }
self.navigationController.viewControllers = []
self.parentCoordinator?.didFinish(coordinator: self)
(self.parentCoordinator as? LogInListener)?.didLogIn()
})
.disposed(by: disposeBag)
navigationController.viewControllers = [vc]
}
}
import Foundation
import RxSwift
import RxCocoa
class LogInViewModel {
private let disposeBag = DisposeBag()
private let authentication: Authentication
var username: BehaviorRelay<String> = BehaviorRelay(value: "")
var password: BehaviorRelay<String> = BehaviorRelay(value: "")
let isLogInActive: Observable<Bool>
// events
let didLogIn = PublishSubject<Void>()
let logInDidFail = PublishSubject<Error>()
init(authentication: Authentication) {
self.authentication = authentication
self.isLogInActive = Observable.combineLatest(username, password).map { $0.0 != "" && $0.1 != "" }
}
func onLoginClicked() {
authentication.login(username: username.value, password: password.value).map { _ in }
.observe(on: MainScheduler.instance)
.subscribe(onSuccess: { [weak self] _ in // not being called
self?.didLogIn.onNext(())
}, onFailure: { [weak self] error in // not being called
self?.logInDidFail.onNext(error)
})
.disposed(by: disposeBag)
}
}
import UIKit
import RxSwift
import RxCocoa
import CocoaLumberjack
class LoginViewController: UIViewController, Storyboarded {
@IBOutlet weak var emailTextField: UITextField!
@IBOutlet weak var passwordTextField: UITextField!
@IBOutlet weak var loginButton: UIButton!
private let disposeBag = DisposeBag()
var viewModel: LogInViewModel!
override func viewDidLoad() {
super.viewDidLoad()
self.emailTextField.placeholder = "Email or Username"
self.passwordTextField.placeholder = "Password"
self.passwordTextField.isSecureTextEntry = true
viewModel = LogInViewModel(authentication: SessionService())
self.setUpBindings()
}
private func setUpBindings() {
guard let viewModel = viewModel else { return }
emailTextField.rx.text.orEmpty
.bind(to: viewModel.username)
.disposed(by: disposeBag)
passwordTextField.rx.text.orEmpty
.bind(to: viewModel.password)
.disposed(by: disposeBag)
loginButton.rx.tap
.bind { viewModel.onLoginClicked() }
.disposed(by: disposeBag)
viewModel.isLogInActive
.bind(to: loginButton.rx.isEnabled)
.disposed(by: disposeBag)
viewModel.logInDidFail
.subscribe(onNext: { error in
print("Failed: \(error)") // not printing the line
})
.disposed(by: disposeBag)
}
}
Upvotes: 1
Views: 266
Reputation: 33979
Short Answer
You are making two different LoginViewModels. You are subscribing to the didLogIn
of one of them, but sending the next event into the didLogIn
of the other.
Longer Answer
This is the result of having all that unnecessary boilerplate surrounding what should be a very simple process.
Your setUpBindings() method should look more like this:
private func setUpBindings() {
let didFail = PublishSubject<Error>()
let didLogin = didLogIn(
trigger: loginButton.rx.tap.asObservable(),
username: emailTextField.rx.text.asObservable(),
password: passwordTextField.rx.text.asObservable(),
login: login(username:password:),
didFail: didFail.asObserver()
)
buttonEnabled(fields: [
emailTextField.rx.text.asObservable(),
passwordTextField.rx.text.asObservable()
])
.bind(to: loginButton.rx.isEnabled)
.disposed(by: disposeBag)
didLogin
.subscribe(onNext: { _ in
print("Logged In")
// TODO: Navigate to Dashboard or any other flow
})
.disposed(by: disposeBag)
didFail
.subscribe(onNext: { error in
print("Failed: \(error)") // not printing the line
})
.disposed(by: disposeBag)
}
Notice that there are two view models here with separate concerns. Both of them are simple functions:
func buttonEnabled(fields: [Observable<String?>]) -> Observable<Bool> {
Observable.combineLatest(fields.map { $0.compactMap { $0 } }).map { $0.allSatisfy { !$0.isEmpty }}
}
func didLogIn(trigger: Observable<Void>, username: Observable<String?>, password: Observable<String?>, login: @escaping (String, String) -> Single<AuthResponse>, didFail: AnyObserver<Error>) -> Observable<AuthResponse> {
let credentials = Observable.combineLatest(username.compactMap { $0 }, password.compactMap { $0 }) { (username: $0, password: $1) }
return trigger
.withLatestFrom(credentials)
.flatMapLatest {
login($0.username, $0.password)
.asObservable()
.catch { didFail.onNext($0); return Observable.empty() }
}
}
Note that both of these view models are easily testable and because they are small, they are also nicely reusable.
Upvotes: 0