Unable to implement Apple Pay in SwiftUI - ios

I'm trying to implement Apple Pay in my SwiftUI app and I'm stuck at showing the button.
I have done that by using UIViewRepresentable
import SwiftUI
import UIKit
import PassKit
import Foundation
struct ApplePayButton: UIViewRepresentable {
func makeCoordinator() -> Coordinator {
Coordinator()
}
func updateUIView(_ uiView: PKPaymentButton, context: Context) {
}
func makeUIView(context: Context) -> PKPaymentButton {
let paymentButton = PKPaymentButton(paymentButtonType: .plain, paymentButtonStyle: .black)
return paymentButton
}
class Coordinator: NSObject, PKPaymentAuthorizationViewControllerDelegate {
func paymentAuthorizationViewControllerDidFinish(_ controller: PKPaymentAuthorizationViewController) {
//
}
func paymentAuthorizationViewController(_ controller: PKPaymentAuthorizationViewController, didAuthorizePayment payment: PKPayment, handler completion: #escaping (PKPaymentAuthorizationResult) -> Void) {
print("did authorize payment")
}
func paymentAuthorizationViewControllerWillAuthorizePayment(_ controller: PKPaymentAuthorizationViewController) {
print("Will authorize payment")
}
}
}
class ApplePayManager: NSObject {
let currencyCode: String
let countryCode: String
let merchantID: String
let paymentNetworks: [PKPaymentNetwork]
let items: [PKPaymentSummaryItem]
init(items: [PKPaymentSummaryItem],
currencyCode: String = "EUR",
countryCode: String = "AT",
merchantID: String = "c.c.c",
paymentNetworks: [PKPaymentNetwork] = [PKPaymentNetwork.masterCard, PKPaymentNetwork.visa]) {
self.items = items
self.currencyCode = currencyCode
self.countryCode = countryCode
self.merchantID = merchantID
self.paymentNetworks = paymentNetworks
}
func paymentViewController() -> PKPaymentAuthorizationViewController? {
if PKPaymentAuthorizationViewController.canMakePayments(usingNetworks: paymentNetworks) {
let request = PKPaymentRequest()
request.currencyCode = self.currencyCode
request.countryCode = self.countryCode
request.supportedNetworks = paymentNetworks
request.merchantIdentifier = self.merchantID
request.paymentSummaryItems = items
request.merchantCapabilities = [.capabilityCredit, .capabilityDebit]
return PKPaymentAuthorizationViewController(paymentRequest: request)
}
return nil
}
}
I do not want to use PKPaymentAuthorizationController because I want to use the native button.
When I click at the button I get this error:
[General] Payment request is invalid: Error Domain=PKPassKitErrorDomain Code=1 "Invalid in-app payment request" UserInfo={NSLocalizedDescription=Invalid in-app payment request, NSUnderlyingError=0x600003aeebb0 {Error Domain=PKPassKitErrorDomain Code=1 "PKPaymentRequest must contain an NSArray property 'paymentSummaryItems' of at least 1 valid objects of class PKPaymentSummaryItem" UserInfo={NSLocalizedDescription=PKPaymentRequest must contain an NSArray property 'paymentSummaryItems' of at least 1 valid objects of class PKPaymentSummaryItem}}}
View:
struct PaymentView: View {
#Environment(\.presentationMode) private var presentationMode
#ObservedObject var requestViewModel: RequestViewModel
var applePayManager = ApplePayManager(items: [
PKPaymentSummaryItem(label: "Some Product", amount: 9.99)
])
var body: some View {
NavigationView {
VStack {
Text("By paying you agree to give the package to transporter.")
// requestViewModel.respondToRequest(status: button.status)
ApplePayButton()
.frame(width: 228, height: 40, alignment: .center)
.onTapGesture {
applePayManager.paymentViewController()
}
}
.navigationBarTitle("Payment")
.navigationBarItems(trailing: Button(action: {
presentationMode.wrappedValue.dismiss()
}) {
Text("Done")
})
}
}
}
What am I doing wrong here?

Just in case someone else is struggling like me: Here is the full code.
import Foundation
import PassKit
class PaymentHandler: NSObject, ObservableObject {
func startPayment(paymentSummaryItems: [PKPaymentSummaryItem]) {
// Create our payment request
let paymentRequest = PKPaymentRequest()
paymentRequest.paymentSummaryItems = paymentSummaryItems
paymentRequest.merchantIdentifier = "merchant.de.xxx"
paymentRequest.merchantCapabilities = .capability3DS
paymentRequest.countryCode = "AT"
paymentRequest.currencyCode = "EUR"
paymentRequest.requiredShippingContactFields = [.phoneNumber, .emailAddress]
paymentRequest.supportedNetworks = [.masterCard, .visa]
// Display our payment request
let paymentController = PKPaymentAuthorizationController(paymentRequest: paymentRequest)
paymentController.delegate = self
paymentController.present(completion: { (presented: Bool) in })
}
}
/**
PKPaymentAuthorizationControllerDelegate conformance.
*/
extension PaymentHandler: PKPaymentAuthorizationControllerDelegate {
func paymentAuthorizationController(_ controller: PKPaymentAuthorizationController, didAuthorizePayment payment: PKPayment, completion: #escaping (PKPaymentAuthorizationStatus) -> Void) {
completion(.success)
print("paymentAuthorizationController completion(.success)")
}
func paymentAuthorizationControllerDidFinish(_ controller: PKPaymentAuthorizationController) {
print("DidFinish")
}
func paymentAuthorizationControllerWillAuthorizePayment(_ controller: PKPaymentAuthorizationController) {
print("WillAuthorizePayment")
}
}
struct PaymentButton: UIViewRepresentable {
func updateUIView(_ uiView: PKPaymentButton, context: Context) { }
func makeUIView(context: Context) -> PKPaymentButton {
return PKPaymentButton(paymentButtonType: .plain, paymentButtonStyle: .automatic)
}
}
Use it in the view:
PaymentButton()
.frame(width: 228, height: 40, alignment: .center)
.onTapGesture {
paymentHandler.startPayment(paymentSummaryItems: paymentSummaryItems)
}

Related

swiftUI ScrollView UIRefreshControl overlay on navigation bar

Until iOS 15.2 , the SwiftUI ScrollView doesn't support refreshable{}, so I using UIRefreshControl to solve this issues. Everything went smoothly and success was just one step away ;-(
full code ContentView.swift :
import SwiftUI
extension UIView {
func viewsInHierarchy<ViewType: UIView>() -> [ViewType]? {
var views: [ViewType] = []
viewsInHierarchy(views: &views)
return views.count > 0 ? views : nil
}
fileprivate func viewsInHierarchy<ViewType: UIView>(views: inout [ViewType]) {
subviews.forEach { eachSubView in
if let matchingView = eachSubView as? ViewType {
views.append(matchingView)
}
eachSubView.viewsInHierarchy(views: &views)
}
}
func searchViewAnchestorsFor<ViewType: UIView>(
_ onViewFound: (ViewType) -> Void
) {
if let matchingView = self.superview as? ViewType {
onViewFound(matchingView)
} else {
superview?.searchViewAnchestorsFor(onViewFound)
}
}
func searchViewAnchestorsFor<ViewType: UIView>() -> ViewType? {
if let matchingView = self.superview as? ViewType {
return matchingView
} else {
return superview?.searchViewAnchestorsFor()
}
}
func printViewHierarchyInformation(_ level: Int = 0) {
printViewInformation(level)
self.subviews.forEach { $0.printViewHierarchyInformation(level + 1) }
}
func printViewInformation(_ level: Int) {
let leadingWhitespace = String(repeating: " ", count: level)
let className = "\(Self.self)"
let superclassName = "\(self.superclass!)"
let frame = "\(self.frame)"
let id = (self.accessibilityIdentifier == nil) ? "" : " `\(self.accessibilityIdentifier!)`"
print("\(leadingWhitespace)\(className): \(superclassName)\(id) \(frame)")
}
}
final class ViewControllerResolver: UIViewControllerRepresentable {
let onResolve: (UIViewController) -> Void
init(onResolve: #escaping (UIViewController) -> Void) {
self.onResolve = onResolve
}
func makeUIViewController(context: Context) -> ParentResolverViewController {
ParentResolverViewController(onResolve: onResolve)
}
func updateUIViewController(_ uiViewController: ParentResolverViewController, context: Context) { }
}
class ParentResolverViewController: UIViewController {
let onResolve: (UIViewController) -> Void
init(onResolve: #escaping (UIViewController) -> Void) {
self.onResolve = onResolve
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("Use init(onResolve:) to instantiate ParentResolverViewController.")
}
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
if let parent = parent {
//parent.navigationController?.navigationBar.prefersLargeTitles = true
//parent.navigationItem.largeTitleDisplayMode = .never
self.onResolve(parent)
// print("didMove(toParent: \(parent)")
}
}
}
struct ContentView: View {
var body: some View {
NavigationView {
ScrollView {
VStack {
HStack {
Spacer()
Text("Content")
Spacer()
}
Spacer()
}
.background(Color.red)
}
.navigationTitle("title")
.navigationBarTitleDisplayMode(.inline)
.overlay(
ViewControllerResolver { parent in
var scrollView: UIScrollView?
if let scrollViewsInHierarchy: [UIScrollView] = parent.view.viewsInHierarchy() {
if scrollViewsInHierarchy.count == 1, let firstScrollViewInHierarchy = scrollViewsInHierarchy.first {
scrollView = firstScrollViewInHierarchy
}
}
if let scrollView = scrollView {
guard scrollView.refreshControl == nil else { return }
let refreshControl = UIRefreshControl()
refreshControl.transform = CGAffineTransform(scaleX: 0.75, y: 0.75)
refreshControl.layoutIfNeeded()
let uiAction = UIAction(handler: { uiAction in
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
refreshControl.endRefreshing()
}
})
refreshControl.addAction(uiAction, for: .primaryActionTriggered)
scrollView.refreshControl = refreshControl
}
}
.frame(width: 0, height: 0)
)
}
}
}

IOS SwiftUI - #main -> Thread 1: EXC_BAD_ACCESS (code=2, address=0x7ffee1a57ff0)

In the code below you can see the error and the code used in my xxApp.swift folder.
I was creating Sign In and a Sign Up function, and everything worked very well, but then I got this error when clicking on the register button.
If you need more code just let me know, thanks in advance!
Here is the code :
import SwiftUI
import Firebase
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
FirebaseApp.configure()
return true
}
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
FirebaseApp.configure()
return true
}`final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
FirebaseApp.configure()
return true
}
}
#main -> "Thread 1: EXC_BAD_ACCESS (code=2, address=0x7ffee1a57ff0)"
struct PSMAApp: App {
#UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
#StateObject var sessionService = SessionServiceImpl()
var body: some Scene {
WindowGroup {
NavigationView{
switch sessionService.state {
case .loggedIn:
HomeView()
.environmentObject(sessionService)
case .loggedOut:
LoginView()
}
}
}
}
}
Here is the register Button - ViewModel :
import Foundation
import Combine
enum RegistrationState {
case successfull
case failed(error: Error)
case na
}
protocol RegistrationViewModel {
func register()
var hasError: Bool { get }
var service: RegistrationService { get }
var state: RegistrationState { get }
var userDetails: RegistrationDetails { get }
init(service: RegistrationService)
}
final class RegistrationViewModelImpl: ObservableObject, RegistrationViewModel {
#Published var hasError: Bool = false
#Published var state: RegistrationState = .na
let service: RegistrationService
var userDetails: RegistrationDetails = RegistrationDetails.new
private var subscriptions = Set<AnyCancellable>()
init(service: RegistrationService) {
self.service = service
setupErrorSubscriptions()
}
func register() {
service
.register(with: userDetails)
.sink { [weak self] res in
switch res {
case .failure(let error):
self?.state = .failed(error: error)
default: break
}
} receiveValue: { [weak self] in
self?.state = .successfull
}
.store(in: &subscriptions)
}
}
private extension RegistrationViewModelImpl {
func setupErrorSubscriptions() {
$state
.map { state -> Bool in
switch state {
case .successfull,
.na:
return false
case .failed:
return true
}
}
.assign(to: &$hasError)
}
}
Here is Register - RegistrationService :
import Combine
import Foundation
import Firebase
import FirebaseDatabase
enum RegistrationKeys: String {
case username
}
protocol RegistrationService {
func register(with details: RegistrationDetails) -> AnyPublisher<Void, Error>
}
final class RegistrationServiceImpl: RegistrationService {
func register(with details: RegistrationDetails) -> AnyPublisher<Void, Error> {
Deferred {
Future { promise in
Auth.auth()
.createUser(withEmail: details.email,
password: details.password) {res, error in
if let err = error {
promise(.failure(err))
} else {
if let uid = res?.user.uid {
let values = [
RegistrationKeys.username.rawValue: details.username] as [String : Any]
Database.database()
.reference()
.child("users")
.child(uid)
.updateChildValues(values) {
error, ref in
if let err = error {
promise(.failure(err))
} else {
promise(.success(()))
}
}
} else {
promise(.failure(NSError(domain: "Invalid user ID", code: 0, userInfo: nil)))
}
}
}
}
}
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}
}
Here is Registraion - SessionService :
import Combine
import Foundation
import FirebaseAuth
enum SessionState {
case loggedIn
case loggedOut
}
protocol SessionService {
var state: SessionState { get }
var userDetails: SessionUserDetails? { get }
func logout()
}
final class SessionServiceImpl: ObservableObject, SessionService {
#Published var state: SessionState = .loggedOut
#Published var userDetails: SessionUserDetails?
private var handler: AuthStateDidChangeListenerHandle?
init() {
setupFirebaseAuthHandler()
}
func logout() {
try? Auth.auth().signOut()
}
}
private extension SessionServiceImpl {
func setupFirebaseAuthHandler() {
handler = Auth
.auth()
.addStateDidChangeListener { [weak self] res, user in
guard let self = self else { return }
self.state = user == nil ? .loggedOut : .loggedIn
if let uid = user?.uid {
self.handleRefresh(with: uid)
}
}
}
func handleRefresh(with uid: String) {
Database
.database()
.reference()
.child("users")
.child(uid)
.observe(.value) { [weak self] snapshot in
guard let self = self,
let value = snapshot.value as? NSDictionary,
let username = value[RegistrationKeys.username.rawValue] as? String else{
return
}
DispatchQueue.main.async {
self.userDetails = SessionUserDetails(username: username)
}
}
}
}
Here is the Register - Model :
struct RegistrationDetails {
var email: String
var username: String
var password: String
}
extension RegistrationDetails {
static var new: RegistrationDetails {
RegistrationDetails(email: "",
username: "",
password: "")
}
}
Here is the Registration - View :
import SwiftUI
struct RegisterView: View {
#StateObject private var vm = RegistrationViewModelImpl(
service: RegistrationServiceImpl()
)
var body: some View {
NavigationView {
VStack(spacing: 32) {
VStack(spacing: 16) {
InputTextFieldView(text: $vm.userDetails.email,
placeholder: "Email",
keyboardType: .emailAddress,
sfSymbol: "envelope")
InputPasswordView(password: $vm.userDetails.password,
placeholder: "Password",
sfSymbol: "lock")
Divider()
InputTextFieldView(text: $vm.userDetails.username,
placeholder: "Username",
keyboardType: .namePhonePad,
sfSymbol: nil)
}
ButtonComponentView(title: "Sign up") {
vm.register()
}
}
.padding(.horizontal, 15)
.navigationTitle("Register")
.applyClose()
.alert(isPresented: $vm.hasError,
content: {
if case .failed(let error) = vm.state {
return Alert(
title: Text("Error"),
message: Text(error.localizedDescription))
} else {
return Alert(
title: Text("Error"),
message: Text("Something went wrong"))
}
})
}
}
}
struct RegisterView_Previews: PreviewProvider {
static var previews: some View {
RegisterView()
.preferredColorScheme(.dark)
}
}

Admob sdk v8.0+ rewarded ad in SwiftUI

I am trying to make rewarded ad from Admob in my SwiftUI app but faced some problems. I am using official documentation that is written in obj-c and trying to make swift class from it.
Here what i have
final class Rewarded: NSObject, GADFullScreenContentDelegate {
let token = Bundle.main.object(forInfoDictionaryKey: "GADApplicationIdentifier") as? String
var rewardedAd = GADRewardedAd()
var rewardFunction: (() -> Void)? = nil
override init() {
super.init()
LoadRewarded()
}
func LoadRewarded() {
let req = GADRequest()
GADRewardedAd.load(withAdUnitID: token!, request: req, completionHandler: { gad, error in
print(error)
})
}
func showAd(rewardFunction: #escaping () -> Void){
let root = UIApplication.shared.windows.first?.rootViewController
do {
try self.rewardedAd.canPresent(fromRootViewController: root!)
self.rewardFunction = rewardFunction
self.rewardedAd.present(fromRootViewController: root!, userDidEarnRewardHandler: rewardFunction)
} catch let error {
print(error)
}
}
func rewardedAd(_ rewardedAd: GADRewardedAd, userDidEarn reward: GADAdReward) {
if let rf = rewardFunction {
rf()
}
}
func rewardedAdDidDismiss(_ rewardedAd: GADRewardedAd) {
self.rewardedAd = GADRewardedAd()
LoadRewarded()
}
}
In method showAd i have an exception nilerror from invocation of try self.rewardedAd.canPresent(fromRootViewController: root!) and have no idea what to do with that. I didn't find any tutorial that shows how to set up it with version of sdk 8+, can you please help me to figure out what is the problem.
Found a solution, Google has paper on migration to v8+ sdk. So, from https://medium.com/#michaelbarneyjr/how-to-integrate-admob-ads-in-swiftui-fbfd3d774c50 and https://developers.google.com/admob/ios/migration#swift_7 I made a working rewarded ad with version of sdk 8.5 in SwiftUI
final class Rewarded: NSObject, GADFullScreenContentDelegate {
var rewardedAd: GADRewardedAd?
var rewardFunction: (() -> Void)? = nil
override init() {
super.init()
LoadRewarded()
}
func LoadRewarded(){
let request = GADRequest()
GADRewardedAd.load(withAdUnitID: Bundle.main.object(forInfoDictionaryKey: "GADApplicationIdentifier") as! String,
request: request, completionHandler: { (ad, error) in
if let error = error {
print("Rewarded ad failed to load with error: \(error.localizedDescription)")
return
}
self.rewardedAd = ad
self.rewardedAd?.fullScreenContentDelegate = self
}
)
}
func showAd(rewardFunction: #escaping () -> Void){
let root = UIApplication.shared.windows.first?.rootViewController
if let ad = rewardedAd {
ad.present(fromRootViewController: root!,
userDidEarnRewardHandler: {
let reward = ad.adReward
rewardFunction()
}
)
} else {
print("Ad wasn't ready")
}
}
func rewardedAd(_ rewardedAd: GADRewardedAd, userDidEarn reward: GADAdReward) {
if let rf = rewardFunction {
rf()
}
}
}
I found this code. I think it can help you:
import SwiftUI
import GoogleMobileAds
import UIKit
final class Rewarded: NSObject, GADRewardedAdDelegate{
var rewardedAd:GADRewardedAd = GADRewardedAd(adUnitID: rewardID)
var rewardFunction: (() -> Void)? = nil
override init() {
super.init()
LoadRewarded()
}
func LoadRewarded(){
let req = GADRequest()
self.rewardedAd.load(req)
}
func showAd(rewardFunction: #escaping () -> Void){
if self.rewardedAd.isReady{
self.rewardFunction = rewardFunction
let root = UIApplication.shared.windows.first?.rootViewController
self.rewardedAd.present(fromRootViewController: root!, delegate: self)
}
else{
print("Not Ready")
}
}
func rewardedAd(_ rewardedAd: GADRewardedAd, userDidEarn reward: GADAdReward) {
if let rf = rewardFunction {
rf()
}
}
func rewardedAdDidDismiss(_ rewardedAd: GADRewardedAd) {
self.rewardedAd = GADRewardedAd(adUnitID: rewardID)
LoadRewarded()
}
}
struct ContentView:View{
var rewardAd:Rewarded
init(){
self.rewardAd = Rewarded()
}
var body : some View{
Button(action: {
self.rewardAd.showAd(rewardFunction: {
print("Give Reward")
}
}){
Text("My Button")
}
}
}
Here is the link where I find the snippet: https://medium.com/#michaelbarneyjr/how-to-integrate-admob-ads-in-swiftui-fbfd3d774c50

Setting Apple SSO button dimensions in SwiftUI

I can't figure out how to make this apple sign-in button wider and taller. no matter where I try to add .frame(width: .... it just seems to move the button around the screen. But, does not alter the dimensions of the button itself.
This is in ContentView.swift:
struct ContentView : View {
#State var credentials: CredentialsOrError?
var body: some View {
VStack {
if $credentials.wrappedValue == nil {
SignInWithAppleButton(credentials: $credentials)
}
else if $credentials.wrappedValue!.isSuccess
{
Text("User: \($credentials.wrappedValue!.values!.user)")
Text("Given name: \($credentials.wrappedValue!.values?.givenName ?? "")")
Text("Family name: \($credentials.wrappedValue!.values?.familyName ?? "")")
Text("Email: \($credentials.wrappedValue!.values?.email ?? "")")
}
else {
Text($credentials.wrappedValue!.error!.localizedDescription).foregroundColor(.red)
}
}.fullScreenCover(isPresented: .constant($credentials.wrappedValue != nil && $credentials.wrappedValue!.isSuccess) , content: {
HomeView()
})
}
}
This is from SignInWithAppleButton.swift:
struct SignInWithAppleButton: View {
#Binding var credentials: CredentialsOrError?
var body: some View {
let button = ButtonController(credentials: $credentials)
return button
.frame(width: button.button.frame.width, height: button.button.frame.height, alignment: .center)
}
struct ButtonController: UIViewControllerRepresentable {
let button: ASAuthorizationAppleIDButton = ASAuthorizationAppleIDButton()
let vc: UIViewController = UIViewController()
#Binding var credentials: CredentialsOrError?
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
func makeUIViewController(context: Context) -> UIViewController {
vc.view.addSubview(button)
return vc
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) { }
class Coordinator: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
let parent: ButtonController
init(_ parent: ButtonController) {
self.parent = parent
super.init()
parent.button.addTarget(self, action: #selector(didTapButton), for: .touchUpInside)
}
#objc func didTapButton() {
let appleIDProvider = ASAuthorizationAppleIDProvider()
let request = appleIDProvider.createRequest()
request.requestedScopes = [.fullName, .email]
let authorizationController = ASAuthorizationController(authorizationRequests: [request])
authorizationController.presentationContextProvider = self
authorizationController.delegate = self
authorizationController.performRequests()
}
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
return parent.vc.view.window!
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
guard let credentials = authorization.credential as? ASAuthorizationAppleIDCredential else {
parent.credentials = .error("Credentials are not of type ASAuthorizationAppleIDCredential")
return
}
parent.credentials = .credentials(user: credentials.user, givenName: credentials.fullName?.givenName, familyName: credentials.fullName?.familyName, email: credentials.email)
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
parent.credentials = .error(error)
}
}
}
}
Attached is a picture of what it looks like currently on my dark mode iPhone and I've also attached a picture of the light mode emulator.
Related question: How can I hard code the background of the screen to be white?
In the emulator:
On my dark mode iPhone:

How to unit test RxCocoa BehaviorRelay

I am starting out with unit testing RxSwift Driver. And I am having issues testing a Driver.
This is the code structure of my ViewModel:
import Foundation
import RxSwift
import RxCocoa
class LoginViewViewModel {
private let loginService: LoginService
private let _loading = BehaviorRelay<Bool>(value: false)
private let _loginResponse = BehaviorRelay<LoginResponse?>(value: nil)
private let _phoneMessage = BehaviorRelay<String>(value: "")
private let _pinMessage = BehaviorRelay<String>(value: "")
private let _enableButton = BehaviorRelay<Bool>(value: false)
var loginResponse: Driver<LoginResponse?> { return _loginResponse.asDriver() }
var loading: Driver<Bool> { return _loading.asDriver() }
var phoneMessage: Driver<String> { return _phoneMessage.asDriver() }
var pinMessage: Driver<String> { return _pinMessage.asDriver() }
var enableButton: Driver<Bool> { return _enableButton.asDriver() }
private let phone = BehaviorRelay<String>(value: "")
private let pin = BehaviorRelay<String>(value: "")
private let disposeBag = DisposeBag()
init(phone: Driver<String>, pin: Driver<String>, buttonTapped: Driver<Void>, loginService: LoginService) {
self.loginService = loginService
phone
.throttle(0.5)
.distinctUntilChanged()
.drive(onNext: { [weak self] (phone) in
self?.phone.accept(phone)
self?.validateFields()
}).disposed(by: disposeBag)
pin
.throttle(0.5)
.distinctUntilChanged()
.drive(onNext: { [weak self] (pin) in
self?.pin.accept(pin)
self?.validateFields()
}).disposed(by: disposeBag)
buttonTapped
.drive(onNext: { [weak self] () in
self?.loginUser(phone: self!.phone.value, pin: self!.pin.value)
}).disposed(by: disposeBag)
}
private func validateFields() {
guard phone.value.count > 0 else {
return
}
_enableButton.accept(false)
guard pin.value.count > 0 else {
return
}
_enableButton.accept(true)
_phoneMessage.accept("")
_pinMessage.accept("")
}
private func loginUser(phone: String, pin: String) {
_loading.accept(true)
_phoneMessage.accept("")
_pinMessage.accept("")
loginService.loginUser(phone: phone, pin: pin) { [weak self] (response, error) in
self?._loading.accept(false)
if let error = error {
if error.message! == "Invalid credentials" {
self?._phoneMessage.accept("Invalid Phone Number")
self?._pinMessage.accept("Invalid Pin Provided")
}
} else {
response?.saveUserInfo()
self?._loginResponse.accept(response)
}
}
}
}
And my UnitTest looks like this:
class LoginViewViewModelTest: XCTestCase {
private class MockLoginService: LoginService {
func loginUser(phone: String, pin: String, completion: #escaping LoginService.LoginDataCompletion) {
guard phone == "+17045674568", pin == "1234" else {
let loginresponse = LoginResponse(message: "Login Successfully", status: true, status_code: 200, data: LoginData(access_token: "adadksdewffjfwe", token_type: "bearer", expires_in: 3600, expiry_time: "today", user: User(id: "1dsldsdsjkj", name: "RandomGuy", phone: "12345", pin_set: true, custom_email: false, email: "somerandom#email.com")))
completion(loginresponse, nil)
return
}
let akuError = AKUError(status: false, message: "Invalid Credential.", status_code: "404")
completion(nil, akuError)
}
}
var viewModel: LoginViewViewModel!
var scheduler: SchedulerType!
var phone: BehaviorRelay<String>!
var pin: BehaviorRelay<String>!
var buttonClicked: BehaviorRelay<Void>!
override func setUp() {
super.setUp()
phone = BehaviorRelay<String>(value: "")
pin = BehaviorRelay<String>(value: "")
buttonClicked = BehaviorRelay<Void>(value: ())
let loginService = MockLoginService()
viewModel = LoginViewViewModel(phone: phone.asDriver(), pin: pin.asDriver(), buttonTapped: buttonClicked.asDriver(), loginService: loginService)
scheduler = ConcurrentDispatchQueueScheduler(qos: .default)
}
override func tearDown() {
super.tearDown()
}
func testLoginButtonClicked_Loading() {
let loadingObservable = viewModel.loading.asObservable().subscribeOn(scheduler)
phone.accept("12345")
pin.accept("12345")
buttonClicked.accept(())
let loadingState = try! loadingObservable.skip(0).toBlocking().first()!
XCTAssertNotNil(loadingState)
XCTAssertEqual(loadingState, true)
}
}
My question:
I am trying to track the state of the loading driver variable. But, it's always false. Even after writing a debugger for checking the states, it only prints out one value and, it's always false.
I decided to add a break point to the code, and I noticed
let loadingState = try! loadingObservable.skip(0).toBlocking().first()! only gets called once the function is done executing.
Is there a way to test for the loading state?
Is it necessary to test for the loading state?
Thanks.
I believe the problem is that RxBlocking only deals with the first event that is emitted. You need to look at a series of events. Look into using RxTest instead. Here is a unit test using RxTest that passes with the view model you created:
class LoginLoadingTests: XCTestCase {
var scheduler: TestScheduler!
var result: TestableObserver<Bool>!
var bag: DisposeBag!
override func setUp() {
super.setUp()
scheduler = TestScheduler(initialClock: 0)
result = scheduler.createObserver(Bool.self)
bag = DisposeBag()
}
func testLoading() {
let loginService = MockLoginService { phone, pin, response in
self.scheduler.scheduleAt(20, action: { response(nil, RxError.unknown) })
}
let tap = scheduler.createHotObservable([.next(10, ())])
let viewModel = LoginViewViewModel(phone: Driver.just("9876543210"), pin: Driver.just("1234"), buttonTapped: tap.asDriver(onErrorJustReturn: ()), loginService: loginService)
viewModel.loading
.drive(result)
.disposed(by: bag)
scheduler.start()
XCTAssertEqual(result.events, [
.next(0, false),
.next(10, true),
.next(20, false)
])
}
}
struct MockLoginService: LoginService {
init(loginUser: #escaping (_ phone: String, _ pin: String, _ response: #escaping (LoginResponse?, Error?) -> Void) -> Void) {
_loginUser = loginUser
}
func loginUser(phone: String, pin: String, response: #escaping (LoginResponse?, Error?) -> ()) {
_loginUser(phone, pin, response)
}
let _loginUser: (_ phone: String, _ pin: String, _ response: #escaping (LoginResponse?, Error?) -> Void) -> Void
}

Resources