Photo by Aaron Burden on Unsplash
SwiftUI - Extract navigation logic from SwiftUI views for testability
Problem: How to test your navigation logic in SwiftUI ?
Solution: Extract navigation logic in a separate Service that handles it using UIKit
SwiftUI is very powerful tool, for UI creation and animation
But I really don't like the navigation logic
That is couples with SwiftUI views with NavigationLink
Make it difficult to test
The idea here is to extract the navigation logic in a separate service
So SwiftUI views only have to use it
And doesn't know anything about navigation logic
I draw a diagram of the navigation logic
To better understand what we are trying to achieve
Here we handle the navigation logic with UIKit
So the navigation bar will be a UINavigationBarController
Everything related with navigation bar:
Title, back button, left button item
Will be created using UIKit
api (UIAlertController
, UIBarButtonItem
, ...)
Only navigation bar visibility will be handle by the SwiftUI view
Using the modifier .navigationBarHidden(true)
Because when try to handle visibiity from UIKit
I encountered crashes
From this diagram
And the UIKit
requirement
I have a better idea of what I'm trying to achieve
I'm able to write those specs:
========= LOGIN [ ] On init user not login set login view as root with no animation [ ] User first login push to the onboarding view [ ] Validate onboarding set the infos view as root with no animation [ ] On init user login set infos view as root with no animation ========= INFOS [ ] Infos can push to info details view [ ] Infos nav bar have a title [ ] Infos nav bar have a right button for logout [ ] Infos nav bar right button action present an alert sheet [ ] Alert sheet have a title (no message) [ ] Alert sheet have a cancel action (user can dismiss) [ ] Alert sheet have a logout action [ ] Confirm logout set login as root with no animation [ ] Login user after a logout set infos view as root (no onboarding) ========= INFOS DETAILS [ ] Infos details can push to new info view [ ] Infos details nav bar have a title [ ] Infos details nav bar have a back button (user can pop back) [ ] Infos details nav bar have a right button for create new info ========= NEW INFOS [ ] New info nav bar have a title [ ] New info nav bar have a back button (user can pop back) [ ] Create new info pop to the root view (infos view)
Let's start with the first requirement for login
========= LOGIN [ ] On init user not login set login view as root with no animation
Start writing the test
func test_init_userIsNotLogin_setLoginViewAsRootWithNoAnimation() {
let userIsNotLogin = false
let navigationSpy = UINavigationControllerSpy() // 1
_ = NavigationService(navigationController: navigationSpy, // 2
isLogin: userIsNotLogin)
XCTAssertTrue(navigationSpy.controllers.first is UIHostingController<LoginView>, "Expect login view as root view controller")
XCTAssertFalse(navigationSpy.isAnimate, "Expect no animation when displaying the login view on init")
}
The compiler is guiding us
- He cannot find
UINavigationControllerSpy
in scope we need to create it - He cannot find
NavigationService
in scope we need to create it
Create the UINavigationControllerSpy
first
This is a spy because it just capture values
private class UINavigationControllerSpy: UINavigationController {
var controllers = [UIViewController]()
var isAnimate = false
override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
self.controllers = viewControllers
self.isAnimate = animated
}
}
Now create the NavigationService
class NavigationService: ObservableObject {
let isLogin: Bool
let navigationController: UINavigationController
init(navigationController: UINavigationController, isLogin: Bool) {
self.navigationController = navigationController
self.isLogin = isLogin
if !isLogin {
setLoginViewAsRoot()
}
// MARK: Helpers
private func setLoginViewAsRoot() {
// Do nothing want to see failed test first
}
}
Run the test and It fails
XCTAssertTrue failed - Expect login view as root view controller
Let's make it pass
Setting the Loginview
as root view controller
private func setLoginViewAsRoot() {
let loginViewController = UIHostingController(rootView: LoginView()) // 1
navigationController.setViewControllers([loginViewController], animated: false)
}
- View controller is created from SwiftUI view using
UIHostingController
Run the test it pass
Can checkmark this requirement as done
========= LOGIN [✅] On init user not login set login view as root with no animation
Continue with the second requirement for login
========= LOGIN [ ] User first login push to the onboarding view
As usual writing the test first
func test_login_userIsFirstLogin_pushToOnboardingViewWithAnimation() {
let userIsFirstLogin = true
let userIsNotLogin = false
let navigationSpy = UINavigationControllerSpy()
let sut = NavigationService(navigationController: navigationSpy,
isLogin: userIsNotLogin,
isFirstLogin: userIsFirstLogin) // 1
sut.login() // 2
XCTAssertTrue(navigationSpy.controllers[1] is UIHostingController<OnboardingView>, "Expect onboarding view visible")
XCTAssertTrue(navigationSpy.isAnimate, "Expect animation when display onboarding view")
}
- The compiler is guiding us with this error:
Extra argumentisFirstLogin
in call We need to add theisFirstLogin
parameter. - The
login()
function not exist we also need to create it
class NavigationService: ObservableObject {
...
var isFirstLogin: Bool
init(navigationController: UINavigationController, isLogin: Bool, isFirstLogin: Bool) {
...
self.isFirstLogin = isFirstLogin
...
}
func login() {
if isFirstLogin {
displayOnboardingView()
}
}
...
// MARK: Helpers
...
private func displayOnboardingView() {
// Do nothing want to see a test that failed first
}
}
Xcode can now compile
Run the test
It crash
Fatal error: Index out of range
That is normal we need to update the UINavigationControllerSpy
private class UINavigationControllerSpy: UINavigationController {
...
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
self.controllers.append(viewController)
self.isAnimate = animated
}
...
}
And also the navigation that push the view
private func displayOnboardingView() {
let onboardingViewController = UIHostingController(rootView: OnboardingView())
navigationController.pushViewController(onboardingViewController, animated: true)
}
Run the test it pass
Can mark this spec as done
========= LOGIN [✅] User first login push to the onboarding view
To simply the test setup
Extract the sut creation to an helper method
class SwiftUINavigationTests: XCTestCase {
...
// MARK: Helpers
private func makeSUT(isLogin: Bool = true, isFirstLogin: Bool = false) -> (NavigationService, UINavigationControllerSpy) {
let navigationSpy = UINavigationControllerSpy()
let navigationService = NavigationService(
navigationController: navigationSpy,
isLogin: isLogin,
isFirstLogin: isFirstLogin
)
return (navigationService, navigationSpy)
}
}
You can use it like this for example
func test_login_userIsFirstLogin_pushToOnboardingViewWithAnimation() {
let userIsFirstLogin = true
let (sut, navigationSpy) = makeSUT(isLogin: false, isFirstLogin: userIsFirstLogin)
sut.login()
XCTAssertTrue(navigationSpy.controllers[1] is UIHostingController<OnboardingView>, "Expect onboarding view visible")
XCTAssertTrue(navigationSpy.isAnimate, "Expect animation when display onboarding view")
}
Continue with this requirement for onboarding validation
========= LOGIN [ ] Validate onboarding set the infos view as root with no animation
As usual write the test first
func test_validateOnboarding_setInfosViewAsRootWithWithAnimation() {
let userIsFirstLogin = true
let (sut, navigationSpy) = makeSUT(isLogin: false, isFirstLogin: userIsFirstLogin)
sut.validateOnboarding() // 1
XCTAssertTrue(navigationSpy.controllers.first is UIHostingController<InfosView>, "Expect infos view visible")
XCTAssertTrue(navigationSpy.isAnimate, "Expect animation when display infos view")
}
- The
validateOnboarding()
function not exist we need to create it
Create the validateOnboarding()
function
class NavigationService: ObservableObject {
...
func validateOnboarding() {
setInfosViewAsRoot(animated: true)
}
func setInfosViewAsRoot(animated: Bool = false) {
// Do nothing want to see a failed test first
}
...
}
Run the test and it fails
Let’s make it pass
func setInfosViewAsRoot(animated: Bool = false) {
let infosViewController = UIHostingController(rootView: InfosView())
navigationController.setViewControllers([infosViewController], animated: animated)
}
Run tests
They are passing
Can mark this requirement as done.
========= LOGIN [✅] Validate onboarding set the infos view as root with no animation
Continue with this requirement for already login user
========= LOGIN [] On init user login set infos view as root with no animation
As usual write the test first
func test_init_userIsLogin_setInfosViewAsRootWithNoAnimation() {
let userIsLogin = true
let (_, navigationSpy) = makeSUT(isLogin: userIsLogin, isFirstLogin: true)
XCTAssertTrue(navigationSpy.controllers.first is UIHostingController<InfosView>, "Expect infos view as root view controller")
XCTAssertFalse(navigationSpy.isAnimate, "Expect no animation when displaying the infos view on init")
}
Run the test
He fails with error
XCTAssertTrue failed - Expect infos view as root view controller
Let’s make it pass
class NavigationService: ObservableObject {
...
init(navigationController: UINavigationController, isLogin: Bool, isFirstLogin: Bool) {
...
if isLogin {
setInfosViewAsRoot()
} else {
setLoginViewAsRoot()
}
}
...
Run tests
They are passing
Can mark this requirement as done.
========= LOGIN [✅] On init user login set infos view as root with no animation
Continue with this requirement for push to view details
========= INFOS [] Infos can push to info details view
As usual write the test first
func test_displayInfoDetails_pushToInfoDetaisViewWithAnimation() {
let info = Info(title: "A title")
let (sut, navigationSpy) = makeSUT()
sut.displayInfoDetails(for: info) // 1
XCTAssertTrue(navigationSpy.controllers[1] is UIHostingController<InfoDetailsView>, "Expect infos details view visible")
XCTAssertTrue(navigationSpy.isAnimate, "Expect animation when display info details view")
}
- The
displayInfoDetails(for:)
function not exist we need to create it
Create the displayInfoDetails(for:)
for Xcode to compile
func displayInfoDetails(for info: Info) {
// Do nothing want to see a failed test first
}
Run the test and it fails with error
Fatal error: Index out of range
Let’s make it pass
func displayInfoDetails(for info: Info) {
let infoDetailsViewController = UIHostingController(rootView: InfoDetailsView(info: info))
navigationController.pushViewController(infoDetailsViewController, animated: true)
}
Run tests
They are passing
Can mark this requirement as done.
========= INFOS [✅] Infos can push to info details view
Continue with this requirement for infos nav bar title
========= INFOS [] Infos nav bar have a title
As usual write the test first
func test_displayInfo_navBarWithTitle() throws {
let (sut, navigationSpy) = makeSUT()
sut.setInfosViewAsRoot()
let controller = try XCTUnwrap(navigationSpy.controllers.first)
XCTAssertEqual(controller.title, "Infos")
}
Run the test and it fails with error
XCTAssertEqual failed: ("nil") is not equal to ("Optional("Infos")")
Let’s make it pass
func setInfosViewAsRoot(animated: Bool = false) {
let infosViewController = UIHostingController(rootView: InfosView())
infosViewController.title = "Infos" 👈
navigationController.setViewControllers([infosViewController], animated: animated)
}
Run tests
They are passing
Can mark this requirement as done.
========= INFOS [✅] Infos nav bar have a title
Continue with this requirement for infos logout button
========= INFOS [ ] Infos nav bar have a right button for logout
As usual write the test first
func test_displayInfo_navBarWithRightButtonLogout() throws {
let rightBarItemImage = UIImage(systemName: "arrowshape.turn.up.left.circle")
let (sut, navigationSpy) = makeSUT()
sut.setInfosViewAsRoot()
let controller = try XCTUnwrap(navigationSpy.controllers.first, "Expect info view controller")
XCTAssertNotNil(controller.navigationItem.rightBarButtonItem, "Expect a right bar button item")
XCTAssertEqual(controller.navigationItem.rightBarButtonItem?.image, rightBarItemImage, "Expect identical image")
XCTAssertEqual(controller.navigationItem.rightBarButtonItem?.style, .plain, "Expect identical style")
XCTAssertEqual(controller.navigationItem.rightBarButtonItem?.tintColor, .red, "Expect identical tint color")
}
Run the test and all assertions failed
Let’s make it pass
func setInfosViewAsRoot(animated: Bool = false) {
let infosViewController = UIHostingController(rootView: InfosView())
infosViewController.title = "Infos"
let logoutItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left.circle"), style: .plain, target: self, action: nil) 👈
logoutItem.tintColor = .red 👈
infosViewController.navigationItem.rightBarButtonItem = logoutItem 👈
navigationController.setViewControllers([infosViewController], animated: animated)
}
Run tests
They are passing
Can mark this requirement as done.
========= INFOS [✅] Infos nav bar have a right button for logout
Continue with this requirement for logout button that present an alert sheet
========= INFOS [ ] Infos nav bar right button action present an alert sheet
As usual write the test first
func test_presentAlertSheetLogout_presentAnAlert() throws {
let (sut, navigationSpy) = makeSUT()
let controller = try XCTUnwrap(navigationSpy.controllers.first)
XCTAssertEqual(controller.navigationItem.rightBarButtonItem?.action?.description, "presentAlertSheetLogout", "Expect an action to present the logout view")
sut.presentAlertSheetLogout() // 1
let alertController = try XCTUnwrap(navigationSpy.viewControllerToPresent as? UIAlertController, "Expect the view to present is of type UIAlertController") // 2
XCTAssertEqual(navigationSpy.isAnimate, true, "Expect the alert presentation to be animated")
XCTAssertEqual(alertController.preferredStyle, .actionSheet, "Expect the prefered style to be an .actionSheet")
}
- The
presentAlertSheetLogout()
function not exist we need to create it - The
viewControllerToPresent
property not exist we also need to create it
Fix the error for Xcode to compile
Create the presentAlertSheetLogout()
function
class NavigationService: ObservableObject {
...
private(set) var alertSheet: UIAlertController?
...
@objc func presentAlertSheetLogout() {
// Do nothing want to see a failed test first
}
}
Create the viewControllerToPresent
property
private class UINavigationControllerSpy: UINavigationController {
...
var viewControllerToPresent = UIViewController()
...
override func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil) {
self.viewControllerToPresent = viewControllerToPresent
self.isAnimate = flag
}
...
Run the test and it fails
XCTAssertEqual failed: ("nil") is not equal to ("Optional("presentAlertSheetLogout")") - Expect an action to present the logout view
XCTUnwrap failed: expected non-nil value of type "UIAlertController" - Expect the view to present is of type UIAlertController
Let’s make it pass
First add a Selector
for item button action
func setInfosViewAsRoot(animated: Bool = false) {
let infosViewController = UIHostingController(rootView: InfosView())
infosViewController.title = "Infos"
👇
let logoutItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left.circle"),
style: .plain,
target: self,
action: #selector(presentAlertSheetLogout))
logoutItem.tintColor = .red
infosViewController.navigationItem.rightBarButtonItem = logoutItem
navigationController.setViewControllers([infosViewController], animated: animated)
updateUserAsNotFirstLogin()
Second create and present the UIAlertController
@objc func presentAlertSheetLogout() {
alertSheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
if let alertSheet = alertSheet {
navigationController.present(alertSheet, animated: true)
}
}
Run tests
They are passing
Can mark this requirement as done.
========= INFOS [✅] Infos nav bar right button action present an alert sheet
Continue with this requirement for infos nav bar title
========= INFOS [ ] Alert sheet have a title (no message)
As usual write the test first
func test_presentAlertSheetLogout_alertSheetWithTitleAndNoMessage() throws {
let (sut, navigationSpy) = makeSUT()
sut.presentAlertSheetLogout()
let alertController = try XCTUnwrap(navigationSpy.viewControllerToPresent as? UIAlertController, "Expect the view to present is of type UIAlertController")
XCTAssertEqual(alertController.title, "Want to logout?", "Expect a title")
XCTAssertEqual(alertController.message, nil, "Expect no message")
}
Run the test and it fails
XCTAssertEqual failed: ("nil") is not equal to ("Optional("Want to logout?")") - Expect a title
Let’s make it pass
func presentAlertSheetLogout() {
let title = "Want to logout?" 👈
alertSheet = UIAlertController(title: title 👈, message: nil, preferredStyle: .actionSheet)
if let alertSheet = alertSheet {
navigationController.present(alertSheet, animated: true)
}
}
Run tests
They are passing
Can mark this requirement as done.
========= INFOS [✅] Alert sheet have a title (no message)
Continue with this requirement for alert and cancel action
========= INFOS [ ] Alert sheet have a cancel action (user can dismiss)
As usual write the test first
func test_presentAlertSheetLogout_alertSheetWithCancelAction() throws {
let (sut, navigationSpy) = makeSUT()
sut.presentAlertSheetLogout()
let alertController = try XCTUnwrap(navigationSpy.viewControllerToPresent as? UIAlertController, "Expect the view to present is of type UIAlertController")
XCTAssertEqual(alertController.actions[0].style, .cancel, "Expect to have a cancel action")
}
Run the test and it fails
XCTAssertEqual failed: throwing "** -[__NSArray0 objectAtIndex:]: index 0 beyond bounds for empty NSArray" - Expect to have a cancel action*
Let’s make it pass
func presentAlertSheetLogout() {
let title = "Want to logout?"
alertSheet = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil) 👈
self.logout() })
alertSheet?.addAction(cancelAction) 👈
if let alertSheet = alertSheet {
navigationController.present(alertSheet, animated: true)
}
}
Run tests
They are passing
Can mark this requirement as done.
========= INFOS [✅] Alert sheet have a cancel action (user can dismiss)
Continue with this requirement for
========= INFOS [ ] Alert sheet have a logout action
As usual write the test first
func test_presentAlertSheetLogout_alertSheetWithLogoutAction() throws {
let (sut, navigationSpy) = makeSUT()
sut.presentAlertSheetLogout()
let alertController = try XCTUnwrap(navigationSpy.viewControllerToPresent as? UIAlertController, "Expect the view to present is of type UIAlertController")
XCTAssertEqual(alertController.actions[1].style, .destructive, "Expect to have a destructive action for the logout")
XCTAssertEqual(alertController.actions[1].title, "Logout", "Expect to have a title")
}
Run the test and it fails
index 1 beyond bounds [0 .. 0]" - Expect to have a destructive action for the logout
index 1 beyond bounds [0 .. 0]" - Expect to have a title
Let’s make it pass
func presentAlertSheetLogout() {
let title = "Want to logout?"
alertSheet = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
let logoutAction = UIAlertAction(title: "Logout", style: .destructive, handler: { _ in }) 👈
alertSheet?.addAction(cancelAction)
alertSheet?.addAction(logoutAction) 👈
if let alertSheet = alertSheet {
navigationController.present(alertSheet, animated: true)
}
}
Run tests
They are passing
Can mark this requirement as done.
========= LOGIN [✅] Alert sheet have a logout action
Continue with this requirement for logout confirmation
========= INFOS [ ] Confirm logout set login as root with no animation
As usual write the test first
func test_presentAlertSheetLogout_confirmLogoutSetLoginAsRootWithNoAnimation() throws {
let (sut, navigationSpy) = makeSUT()
sut.presentAlertSheetLogout()
let alertController = try XCTUnwrap(navigationSpy.viewControllerToPresent as? UIAlertController, "Expect the view to present is of type UIAlertController")
alertController.actions[1].simulateTriggerAction() // 1
XCTAssertTrue(navigationSpy.controllers.first is UIHostingController<LoginView>, "Expect login view as root view controller")
XCTAssertFalse(navigationSpy.isAnimate, "Expect no animation when displaying the login view")
}
- The
UIAlertAction
has no membersimulateTriggerAction()
we need to create it
Fix the error for Xcode to compile
extension UIAlertAction {
typealias AlertHandler = @convention(block) (UIAlertAction) -> Void
func simulateTriggerAction() {
guard let block = value(forKey: "handler") else {
XCTFail("Expect value for key handler")
return
}
let handler = unsafeBitCast(block as AnyObject, to: AlertHandler.self)
handler(self)
}
}
Run the test and it fails
XCTAssertTrue failed - Expect login view as root view controller
XCTAssertFalse failed - Expect no animation when displaying the login view
Let’s make it pass
class NavigationService: ObservableObject {
...
👇
func logout() {
setLoginViewAsRoot()
}
...
func presentAlertSheetLogout() {
let title = "Want to logout?"
alertSheet = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
let logoutAction = UIAlertAction(title: "Logout", style: .destructive, handler: { _ in self.logout() 👈 })
alertSheet?.addAction(cancelAction)
alertSheet?.addAction(logoutAction)
if let alertSheet = alertSheet {
navigationController.present(alertSheet, animated: true)
}
}
Run tests
They are passing
Can mark this requirement as done.
========= INFOS [✅] Confirm logout set login as root with no animation
Continue with this requirement for logout user that login again display the infos view
========= LOGIN [ ] Login user after a logout set infos view as root (no onboarding)
As usual write the test first
func test_login_onLogoutLoginAgainSetInfosAsRootWithAnimation() throws {
let userIsNotLogin = false
let userIsFirstLogin = true
let (sut, navigationSpy) = makeSUT(isLogin: userIsNotLogin,
isFirstLogin: userIsFirstLogin)
sut.login() // Display onboarding view
sut.validateOnboarding() // Display infos view
sut.presentAlertSheetLogout() // Display logout action
let alertController = try XCTUnwrap(navigationSpy.viewControllerToPresent as? UIAlertController, "Expect the view to present is of type UIAlertController")
alertController.actions[1].simulateTriggerAction() // Logout display login view
sut.login() // Display infos view (not onboarding)
XCTAssertTrue(navigationSpy.controllers.first is UIHostingController<InfosView>, "Expect infos view visible")
XCTAssertTrue(navigationSpy.isAnimate, "Expect animation when display infos view")
}
Run the test and it fails
XCTAssertTrue failed - Expect infos view visible
XCTAssertTrue failed - Expect animation when display infos view
Let’s make it pass
class NavigationService: ObservableObject {
...
func login() {
if isFirstLogin {
displayOnboardingView()
} else {
setInfosViewAsRoot(animated: true) 👈
}
}
...
func setInfosViewAsRoot(animated: Bool = false) {
...
updateUserAsNotFirstLogin()
}
// MARK: Helpers
private func updateUserAsNotFirstLogin() {
isFirstLogin = false
}
...
}
Run tests
They are passing
Can mark this requirement as done.
========= INFOS [✅] Login user after a logout set infos view as root (no onboarding)
I let you those requirements as an exercise
They have been already made for other view
// ========= INFOS DETAILS // [] Infos details can push to new info view // [] Infos details nav bar have a title // [] Infos details nav bar have a right button for create new info
Continue with this requirement for
========= INFOS DETAILS [ ] Infos details nav bar have a back button (user can pop back)
As usual write the test first
func test_displayInfoDetails_navBarWithBackButton() throws {
let selectedInfo = Info(title: "A title info") // 1
let (sut, navigationSpy) = makeSUT(isLogin: true, isFirstLogin: false)
sut.displayInfoDetails(for: selectedInfo)
let controller = try XCTUnwrap(navigationSpy.controllers[1])
XCTAssertTrue(controller.navigationItem.hidesBackButton == false, "Expect that nav bar display a back button")
}
- The
Info
type not exist we need to create it
Fix the error for Xcode to compile
struct Info: Equatable {
let title: String
}
Run the test and it succeed
Ok cool we didn't change anything
But the test is passing
Why? 🤔
Because a nav bar displays the back button by default
But can we trust a test that we didn't see failing?
Of course no!
To be sure I testing the right behaviour
I always need to see a failing test
Ok let's make this test fails
Hide the back button
func displayInfoDetails(for info: Info) {
let infoDetailsViewController = UIHostingController(rootView: InfoDetailsView(info: info))
infoDetailsViewController.title = "Info Details"
let createItem = UIBarButtonItem(image: UIImage(systemName: "plus"), style: .plain, target: self, action: #selector(displayNewInfo))
createItem.tintColor = .blue
infoDetailsViewController.navigationItem.rightBarButtonItem = createItem
infoDetailsViewController.navigationItem.setHidesBackButton(true 👈, animated: false)
navigationController.pushViewController(infoDetailsViewController, animated: true)
}
Run tests
If now failed
XCTAssertTrue failed - Expect that nav bar display a back button
Perfect that's what we want
I testing the right behaviour
I revert this change and make tests succeed again
Can mark this requirement as done.
========= INFOS DETAILS [✅] Infos details nav bar have a back button (user can pop back)
I let you those requirements as an exercise
They have been already made for others views
========= NEW INFOS [ ] New info nav bar have a title [ ] New info nav bar have a back button (user can pop back)
Continue with this requirement that go back to first view
========= NEW INFOS [ ] Create new info pop to the root view (infos view)
As usual write the test first
func test_popToRoot_popBackToRootViewWithAnimation() throws {
let (sut, navigationSpy) = makeSUT()
sut.popToRoot() // 1
XCTAssertEqual(navigationSpy.popToRootViewControllerCallCount, 1, "Expect that pop to root function of the navigation controller was called") // 2
XCTAssertTrue(navigationSpy.isAnimate, "Expect animation when go back")
}
- The
popToRoot()
function not exist we need to create it - The
popToRootViewControllerCallCount
property of thenavigationSpy
not exist we need to create it
Fix the error for Xcode to compile
Start with popToRoot()
function
class NavigationService: ObservableObject {
...
func popToRoot() {
// Do nothing want to see a failed test first
}
...
}
Now the popToRootViewControllerCallCount
property
private class UINavigationControllerSpy: UINavigationController {
...
var popToRootViewControllerCallCount = 0
...
override func popToRootViewController(animated: Bool) -> [UIViewController]? {
self.popToRootViewControllerCallCount += 1
self.isAnimate = animated
return nil
}
}
Run the test and it fails
XCTAssertEqual failed: ("0") is not equal to ("1") - Expect that pop to root function of the navigation controller was called
XCTAssertTrue failed - Expect animation when go back
Let’s make it pass
func popToRoot() {
navigationController.popToRootViewController(animated: true) 👈
}
Run tests
They are passing
Can mark this requirement as done.
========= NEW INFOS [✅] Create new info pop to the root view (infos view)
All behaviour are tested
Cool 😎 !!
========= LOGIN [✅] On init user not login set login view as root with no animation [✅] User first login push to the onboarding view [✅] Validate onboarding set the infos view as root with no animation [✅] On init user login set infos view as root with no animation ========= INFOS [✅] Infos can push to info details view [✅] Infos nav bar have a title [✅] Infos nav bar have a right button for logout [✅] Infos nav bar right button action present an alert sheet [✅] Alert sheet have a title (no message) [✅] Alert sheet have a cancel action (user can dismiss) [✅] Alert sheet have a logout action [✅] Confirm logout set login as root with no animation [✅] Login user after a logout set infos view as root (no onboarding) ========= INFOS DETAILS [✅] Infos details can push to new info view [✅] Infos details nav bar have a title [✅] Infos details nav bar have a back button (user can pop back) [✅] Infos details nav bar have a right button for create new info ========= NEW INFOS [✅] New info nav bar have a title [✅] New info nav bar have a back button (user can pop back) [✅] Create new info pop to the root view (infos view)
Now the integration part
How SwiftUI view will use this UINavigationController
from UIKit
Let's see the SwiftUINavigationApp
that is the root (entry point of the app)
struct SwiftUINavigationApp: App {
var body: some Scene {
WindowGroup {
LoginView()
}
}
}
Only the login view will display if run the app
Without any nav bar
We need a way to inject the UIKit
nav bar to a SwiftUI view
This a job for the UIViewControllerRepresentable
That can convert any UIViewController
to a SwiftUI view
As UINavigationController
is a subclass of UIViewController
We can inject the UINavigationController
this way
First create the NavigationControllerSwiftUI
struct NavigationControllerSwiftUI: UIViewControllerRepresentable {
typealias UIViewControllerType = UINavigationController
let navigationService: NavigationService
init(navigationService: NavigationService) {
self.navigationService = navigationService
}
func makeUIViewController(context: Context) -> UIViewControllerType {
return navigationService.navigationController
}
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
}
Next use it in the SwiftUINavigationApp
Of course the logic to init the NavigationService
must be tested
And in a real app must call a service to init his parameters
But we are focusing here on the navigation logic
(And this article is already long enough right? 😅)
@main
struct SwiftUINavigationApp: App {
let navigationService = NavigationService(navigationController: UINavigationController(), isLogin: false, isFirstLogin: true)
var body: some Scene {
WindowGroup {
NavigationControllerSwiftUI(navigationService: navigationService)
.edgesIgnoringSafeArea(.top)
.environmentObject(navigationService) // 1
}
}
}
Inject the navigationService
as environmentObject
So the NavigationService
can be accessible from any subview
Here is an example for the LoginView
struct LoginView: View {
@EnvironmentObject var navigationService: NavigationService // 1
var body: some View {
CustomButton(title: "Login") {
navigationService.login() // 2
}
.navigationBarHidden(true) // 3
}
}
- The
NavigationService
can be access via the@EnvironmentObject
variable - Now the
navigationService
is used on login button action - Hide the nav bar from the SwiftUI view, get crash when try to handle it using
UIKit
I make this for all SwiftUI views
Let's see if hour logic cover by test is working
Running the app
Everything working fine
This is the end of this article
You see how convenient it is to have the navigation logic in a central place
Easy to scale, to test, to maintain, ...
Hope you enjoy it
Of course you also use the UINavigationBarController
only for navigation purposes
And get a custom nav bar made using SwiftUI view
That it !!