[PM-26060] Consolidate StackNavigator to BitwardenKit (#2095)

This commit is contained in:
Katherine Bertelsen 2025-11-04 08:23:55 -06:00 committed by GitHub
parent 2e4b325edb
commit cd937bc2a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 499 additions and 529 deletions

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - AuthModule
/// An object that builds coordinators for the auth flow.

View File

@ -1,3 +1,4 @@
import BitwardenKit
import Foundation
// MARK: - DebugMenuModule

View File

@ -1,3 +1,4 @@
import BitwardenKitMocks
import XCTest
@testable import AuthenticatorShared

View File

@ -1,294 +0,0 @@
import BitwardenKit
import SwiftUI
// MARK: - StackNavigator
/// An object used to navigate between views in a stack interface.
///
@MainActor
public protocol StackNavigator: Navigator {
/// Whether the stack of views in the navigator is empty.
var isEmpty: Bool { get }
/// Dismisses the view that was presented modally by the navigator.
///
/// - Parameters animated: Whether the transition should be animated.
///
func dismiss(animated: Bool)
/// Dismisses the view that was presented modally by the navigator
/// and executes a block of code when dismissing completes.
///
/// - Parameters:
/// - animated: Whether the transition should be animated.
/// - completion: The block that is executed when dismissing completes.
///
func dismiss(animated: Bool, completion: (() -> Void)?)
/// Pushes a view onto the navigator's stack.
///
/// - Parameters:
/// - view: The view to push onto the stack.
/// - animated: Whether the transition should be animated.
/// - hidesBottomBar: Whether the bottom bar should be hidden when the view is pushed.
///
func push<Content: View>(_ view: Content, animated: Bool, hidesBottomBar: Bool)
/// Pushes a view controller onto the navigator's stack.
///
/// - Parameters:
/// - viewController: The view controller to push onto the stack.
/// - animated: Whether the transition should be animated.
///
func push(_ viewController: UIViewController, animated: Bool)
/// Pops a view off the navigator's stack.
///
/// - Parameter animated: Whether the transition should be animated.
/// - Returns: The `UIViewController` that was popped off the navigator's stack.
///
@discardableResult
func pop(animated: Bool) -> UIViewController?
/// Pops all the view controllers on the stack except the root view controller.
///
/// - Parameter animated: Whether the transition should be animated.
/// - Returns: An array of `UIViewController`s that were popped of the navigator's stack.
///
@discardableResult
func popToRoot(animated: Bool) -> [UIViewController]
/// Presents a view modally.
///
/// - Parameters:
/// - view: The view to present.
/// - animated: Whether the transition should be animated.
/// - overFullscreen: Whether or not the presented modal should cover the full screen.
/// - onCompletion: A closure to call on completion.
///
func present<Content: View>(
_ view: Content,
animated: Bool,
overFullscreen: Bool,
onCompletion: (() -> Void)?,
)
/// Presents a view controller modally. Supports presenting on top of presented modals if necessary.
///
/// - Parameters:
/// - viewController: The view controller to present.
/// - animated: Whether the transition should be animated.
/// - overFullscreen: Whether or not the presented modal should cover the full screen.
/// - onCompletion: A closure to call on completion.
///
func present(
_ viewController: UIViewController,
animated: Bool,
overFullscreen: Bool,
onCompletion: (() -> Void)?,
)
/// Replaces the stack with the specified view.
///
/// - Parameters:
/// - view: The view that will replace the stack.
/// - animated: Whether the transition should be animated.
///
func replace<Content: View>(_ view: Content, animated: Bool)
}
extension StackNavigator {
/// Dismisses the view that was presented modally by the navigator. Animation is controlled by
/// `UI.animated`.
///
func dismiss() {
dismiss(animated: UI.animated)
}
/// Dismisses the view that was presented modally by the navigator. Animation is controlled by
/// `UI.animated`. Executes a block of code when dismissing completes.
///
func dismiss(completion: (() -> Void)?) {
dismiss(animated: UI.animated, completion: completion)
}
/// Pushes a view onto the navigator's stack.
///
/// - Parameters:
/// - view: The view to push onto the stack.
/// - animated: Whether the transition should be animated. Defaults to `UI.animated`.
///
func push<Content: View>(_ view: Content, animated: Bool = UI.animated) {
push(view, animated: animated, hidesBottomBar: false)
}
/// Pushes a view controller onto the navigator's stack.
///
/// - Parameters:
/// - viewController: The view controller to push onto the stack.
/// - animated: Whether the transition should be animated. Defaults to `UI.animated`.
/// - navigationTitle: The navigation title to pre-populate the navigation bar so that it doesn't flash.
/// - searchController: If non-nil, pre-populate the navigation bar with a search bar backed by the
/// supplied UISearchController.
/// Normal SwiftUI search contorls will not work if this value is supplied. Tracking the searchController
/// behavior must be done through a UISearchControllerDelegate or a UISearchResultsUpdating object.
///
func push(
_ viewController: UIViewController,
animated: Bool = UI.animated,
navigationTitle: String? = nil,
searchController: UISearchController? = nil,
) {
if let navigationTitle {
// Preset some navigation item values so that the navigation bar does not flash oddly once
// the view's push animation has completed. This happens because `UIHostingController` does
// not resolve its `navigationItem` properties until the view has been displayed on screen.
// In this case, that doesn't happen until the push animation has completed, which results
// in both the title and the search bar flashing into view after the push animation
// completes. This occurs on all iOS versions (tested on iOS 17).
//
// The values set here are temporary, and are overwritten once the hosting controller has
// resolved its root view's navigation bar modifiers.
viewController.navigationItem.largeTitleDisplayMode = .never
viewController.navigationItem.title = navigationTitle
if let searchController {
if #available(iOS 16.0, *) {
viewController.navigationItem.preferredSearchBarPlacement = .stacked
}
viewController.navigationItem.searchController = searchController
viewController.navigationItem.hidesSearchBarWhenScrolling = false
}
}
push(viewController, animated: animated)
}
/// Pops a view off the navigator's stack. Animation is controlled by `UI.animated`.
///
/// - Returns: The `UIViewController` that was popped off the navigator's stack.
///
@discardableResult
func pop() -> UIViewController? {
pop(animated: UI.animated)
}
/// Pops all the view controllers on the stack except the root view controller. Animation is controlled by
/// `UI.animated`.
///
/// - Returns: An array of `UIViewController`s that were popped of the navigator's stack.
///
@discardableResult
func popToRoot() -> [UIViewController] {
popToRoot(animated: UI.animated)
}
/// Presents a view modally.
///
/// - Parameters:
/// - view: The view to present.
/// - animated: Whether the transition should be animated. Defaults to `UI.animated`.
/// - overFullscreen: Whether or not the presented modal should cover the full screen.
/// - onCompletion: The closure to call after presenting.
///
func present<Content: View>(
_ view: Content,
animated: Bool = UI.animated,
overFullscreen: Bool = false,
onCompletion _: (() -> Void)? = nil,
) {
present(
view,
animated: animated,
overFullscreen: overFullscreen,
onCompletion: nil,
)
}
/// Presents a view controller modally. Supports presenting on top of presented modals if necessary. Animation is
/// controlled by `UI.animated`.
///
/// - Parameters:
/// - viewController: The view controller to present.
/// - overFullscreen: Whether or not the presented modal should cover the full screen.
/// - onCompletion: The closure to call after presenting.
///
func present(
_ viewController: UIViewController,
overFullscreen: Bool = false,
onCompletion: (() -> Void)? = nil,
) {
present(
viewController,
animated: UI.animated,
overFullscreen: overFullscreen,
onCompletion: onCompletion,
)
}
/// Replaces the stack with the specified view. Animation is controlled by `UI.animated`.
///
/// - Parameter view: The view that will replace the stack.
///
func replace<Content: View>(_ view: Content) {
replace(view, animated: UI.animated)
}
}
// MARK: - UINavigationController
extension UINavigationController: StackNavigator {
public var isEmpty: Bool {
viewControllers.isEmpty
}
public var rootViewController: UIViewController? {
self
}
public func dismiss(animated: Bool) {
dismiss(animated: animated, completion: nil)
}
@discardableResult
public func pop(animated: Bool) -> UIViewController? {
popViewController(animated: animated)
}
@discardableResult
public func popToRoot(animated: Bool) -> [UIViewController] {
popToRootViewController(animated: animated) ?? []
}
public func push<Content: View>(_ view: Content, animated: Bool, hidesBottomBar: Bool) {
let viewController = UIHostingController(rootView: view)
viewController.hidesBottomBarWhenPushed = hidesBottomBar
let animated = self.view.window != nil ? animated : false
push(viewController, animated: animated)
}
public func push(_ viewController: UIViewController, animated: Bool) {
let animated = view.window != nil ? animated : false
pushViewController(viewController, animated: animated)
}
public func present<Content: View>(
_ view: Content,
animated: Bool,
overFullscreen: Bool,
onCompletion: (() -> Void)? = nil,
) {
let controller = UIHostingController(rootView: view)
controller.isModalInPresentation = true
if overFullscreen {
controller.modalPresentationStyle = .overFullScreen
controller.view.backgroundColor = .clear
}
let animated = self.view.window != nil ? animated : false
present(controller, animated: animated, onCompletion: onCompletion)
}
public func replace<Content: View>(_ view: Content, animated: Bool) {
let animated = self.view.window != nil ? animated : false
setViewControllers([UIHostingController(rootView: view)], animated: animated)
}
}

View File

@ -1,63 +0,0 @@
import SwiftUI
import XCTest
@testable import AuthenticatorShared
@MainActor
class StackNavigatorTests: BitwardenTestCase {
var subject: UINavigationController!
override func setUp() {
super.setUp()
subject = UINavigationController()
setKeyWindowRoot(viewController: subject)
}
/// `present(_:animated:)` presents the hosted view.
func testPresent() {
subject.present(EmptyView(), animated: false)
XCTAssertTrue(subject.presentedViewController is UIHostingController<EmptyView>)
}
/// `present(_:animated:)` presents the hosted view on existing presented views.
func testPresentOnPresentedView() {
subject.present(EmptyView(), animated: false)
subject.present(ScrollView<EmptyView> {}, animated: false)
XCTAssertTrue(subject.presentedViewController is UIHostingController<EmptyView>)
waitFor(subject.presentedViewController?.presentedViewController != nil)
XCTAssertTrue(
subject.presentedViewController?.presentedViewController
is UIHostingController<ScrollView<EmptyView>>,
)
}
/// `dismiss(animated:)` dismisses the hosted view.
func testDismiss() {
subject.present(EmptyView(), animated: false)
subject.dismiss(animated: false)
waitFor(subject.presentedViewController == nil)
}
/// `push(_:animated:)` pushes the hosted view.
func testPush() {
subject.push(EmptyView(), animated: false)
XCTAssertTrue(subject.topViewController is UIHostingController<EmptyView>)
}
/// `pop(animated:)` pops the hosted view.
func testPop() {
subject.push(EmptyView(), animated: false)
subject.push(EmptyView(), animated: false)
subject.pop(animated: false)
XCTAssertEqual(subject.viewControllers.count, 1)
XCTAssertTrue(subject.topViewController is UIHostingController<EmptyView>)
}
/// `replace(_:animated:)` replaces the hosted view.
func testReplace() {
subject.push(EmptyView(), animated: false)
subject.replace(Text("replaced"), animated: false)
XCTAssertEqual(subject.viewControllers.count, 1)
XCTAssertTrue(subject.topViewController is UIHostingController<Text>)
}
}

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - FileSelectionModule
/// An object that builds coordinators for the file selection flow.

View File

@ -1,4 +1,5 @@
import BitwardenKit
import BitwardenKitMocks
import BitwardenResources
import SwiftUI
import XCTest

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - SettingsModule
/// An object that builds coordinators for the settings tab.

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - TutorialModule
/// An object that builds tutorial coordinators

View File

@ -1,3 +1,4 @@
import BitwardenKit
import Foundation
// MARK: - AuthenticatorItemModule

View File

@ -1,3 +1,4 @@
import BitwardenKit
import Foundation
// MARK: - ItemListModule

View File

@ -1,19 +1,18 @@
import BitwardenKit
import BitwardenShared
import SwiftUI
final class MockStackNavigator: StackNavigator {
struct NavigationAction {
var type: NavigationType
var view: Any?
var animated: Bool
var embedInNavigationController: Bool?
var hidesBottomBar: Bool?
var isModalInPresentation: Bool?
var overFullscreen: Bool?
public final class MockStackNavigator: StackNavigator {
public struct NavigationAction {
public var type: NavigationType
public var view: Any?
public var animated: Bool
public var embedInNavigationController: Bool?
public var hidesBottomBar: Bool?
public var isModalInPresentation: Bool?
public var overFullscreen: Bool?
}
enum NavigationType {
public enum NavigationType {
case dismissed
case dismissedWithCompletionHandler
case pushed
@ -24,26 +23,28 @@ final class MockStackNavigator: StackNavigator {
case replaced
}
var actions: [NavigationAction] = []
var alertOnDismissed: (() -> Void)?
var alerts: [BitwardenKit.Alert] = []
var isEmpty = true
var isNavigationBarHidden = false
var isPresenting = false
var rootViewController: UIViewController?
public var actions: [NavigationAction] = []
public var alertOnDismissed: (() -> Void)?
public var alerts: [BitwardenKit.Alert] = []
public var isEmpty = true
public var isNavigationBarHidden = false
public var isPresenting = false
public var rootViewController: UIViewController?
var viewControllersToPop: [UIViewController] = []
public var viewControllersToPop: [UIViewController] = []
func dismiss(animated: Bool) {
public init() {}
public func dismiss(animated: Bool) {
actions.append(NavigationAction(type: .dismissed, animated: animated))
}
func dismiss(animated: Bool, completion: (() -> Void)?) {
public func dismiss(animated: Bool, completion: (() -> Void)?) {
completion?()
actions.append(NavigationAction(type: .dismissedWithCompletionHandler, animated: animated))
}
func push<Content: View>(_ view: Content, animated: Bool, hidesBottomBar: Bool) {
public func push<Content: View>(_ view: Content, animated: Bool, hidesBottomBar: Bool) {
actions.append(NavigationAction(
type: .pushed,
view: view,
@ -52,7 +53,7 @@ final class MockStackNavigator: StackNavigator {
))
}
func push(_ viewController: UIViewController, animated: Bool) {
public func push(_ viewController: UIViewController, animated: Bool) {
actions.append(NavigationAction(
type: .pushed,
view: viewController,
@ -61,27 +62,27 @@ final class MockStackNavigator: StackNavigator {
}
@discardableResult
func pop(animated: Bool) -> UIViewController? {
public func pop(animated: Bool) -> UIViewController? {
actions.append(NavigationAction(type: .popped, animated: animated))
return viewControllersToPop.last
}
@discardableResult
func popToRoot(animated: Bool) -> [UIViewController] {
public func popToRoot(animated: Bool) -> [UIViewController] {
actions.append(NavigationAction(type: .poppedToRoot, animated: animated))
return viewControllersToPop
}
func present(_ alert: BitwardenKit.Alert) {
public func present(_ alert: BitwardenKit.Alert) {
alerts.append(alert)
}
func present(_ alert: BitwardenKit.Alert, onDismissed: (() -> Void)?) {
public func present(_ alert: BitwardenKit.Alert, onDismissed: (() -> Void)?) {
alerts.append(alert)
alertOnDismissed = onDismissed
}
func present<Content: View>( // swiftlint:disable:this function_parameter_count
public func present<Content: View>( // swiftlint:disable:this function_parameter_count
_ view: Content,
animated: Bool,
embedInNavigationController: Bool,
@ -102,7 +103,7 @@ final class MockStackNavigator: StackNavigator {
)
}
func present(
public func present(
_ viewController: UIViewController,
animated: Bool,
overFullscreen: Bool,
@ -119,11 +120,11 @@ final class MockStackNavigator: StackNavigator {
)
}
func setNavigationBarHidden(_ hidden: Bool, animated: Bool) {
public func setNavigationBarHidden(_ hidden: Bool, animated: Bool) {
isNavigationBarHidden = hidden
}
func replace<Content: View>(_ view: Content, animated: Bool) {
public func replace<Content: View>(_ view: Content, animated: Bool) {
actions.append(NavigationAction(type: .replaced, view: view, animated: animated))
}
}

View File

@ -1,4 +1,3 @@
import BitwardenKit
import SwiftUI
// MARK: - StackNavigator
@ -223,28 +222,74 @@ public extension StackNavigator {
// MARK: - UINavigationController
extension UINavigationController: StackNavigator {
/// Returns whether the navigation controller's stack is empty.
///
/// - Returns: `true` if there are no view controllers in the stack, `false` otherwise.
public var isEmpty: Bool {
viewControllers.isEmpty
}
/// Returns the root view controller of the navigation stack.
///
/// For UINavigationController, this returns the navigation controller itself
/// as it serves as the root container for the navigation stack.
///
/// - Returns: The navigation controller instance.
public var rootViewController: UIViewController? {
self
}
/// Dismisses the modally presented view controller without a completion handler.
///
/// This is a convenience method that calls the system's dismiss method
/// with a nil completion handler.
///
/// - Parameters:
/// - animated: Whether the dismissal should be animated.
public func dismiss(animated: Bool) {
dismiss(animated: animated, completion: nil)
}
/// Pops the top view controller from the navigation stack.
///
/// Removes and returns the top view controller from the navigation stack.
/// If the stack only contains the root view controller, this method does nothing
/// and returns nil.
///
/// - Parameters:
/// - animated: Whether the pop transition should be animated.
/// - Returns: The view controller that was popped, or nil if no controller was popped.
@discardableResult
public func pop(animated: Bool) -> UIViewController? {
popViewController(animated: animated)
}
/// Pops all view controllers except the root view controller.
///
/// Removes all view controllers from the stack except the root view controller
/// and returns an array of the popped controllers.
///
/// - Parameters:
/// - animated: Whether the pop transition should be animated.
/// - Returns: An array of view controllers that were popped from the stack.
/// Returns an empty array if no controllers were popped.
@discardableResult
public func popToRoot(animated: Bool) -> [UIViewController] {
popToRootViewController(animated: animated) ?? []
}
/// Pushes a SwiftUI view onto the navigation stack.
///
/// Wraps the provided SwiftUI view in a UIHostingController and pushes it
/// onto the navigation stack. Automatically disables animation if the
/// navigation controller is not currently in a window to prevent animation
/// issues during initial setup.
///
/// - Parameters:
/// - view: The SwiftUI view to push onto the stack.
/// - animated: Whether the push transition should be animated.
/// - hidesBottomBar: Whether the bottom bar (tab bar) should be hidden
/// when this view controller is displayed.
public func push<Content: View>(_ view: Content, animated: Bool, hidesBottomBar: Bool) {
let viewController = UIHostingController(rootView: view)
viewController.hidesBottomBarWhenPushed = hidesBottomBar
@ -252,11 +297,38 @@ extension UINavigationController: StackNavigator {
push(viewController, animated: animated)
}
/// Pushes a view controller onto the navigation stack.
///
/// Adds the specified view controller to the top of the navigation stack.
/// Automatically disables animation if the navigation controller is not
/// currently in a window to prevent animation issues during initial setup.
///
/// - Parameters:
/// - viewController: The view controller to push onto the stack.
/// - animated: Whether the push transition should be animated.
public func push(_ viewController: UIViewController, animated: Bool) {
let animated = view.window != nil ? animated : false
pushViewController(viewController, animated: animated)
}
/// Presents a SwiftUI view modally.
///
/// Wraps the provided SwiftUI view in a UIHostingController and presents it modally.
/// Optionally embeds the view in a new navigation controller and configures
/// various presentation options. Automatically disables animation if the
/// navigation controller is not currently in a window to prevent animation issues
/// during initial setup.
///
/// - Parameters:
/// - view: The SwiftUI view to present modally.
/// - animated: Whether the presentation should be animated.
/// - embedInNavigationController: Whether to wrap the view in a new
/// navigation controller.
/// - isModalInPresentation: Whether the modal enforces modal behavior,
/// preventing interactive dismissal.
/// - overFullscreen: Whether the modal should use full-screen presentation
/// with a clear background.
/// - onCompletion: Optional closure called after presentation completes.
public func present<Content: View>(
_ view: Content,
animated: Bool,
@ -284,6 +356,16 @@ extension UINavigationController: StackNavigator {
present(controller, animated: animated, onCompletion: onCompletion)
}
/// Replaces the entire navigation stack with a single SwiftUI view.
///
/// Removes all existing view controllers from the navigation stack and
/// replaces them with a single new view controller containing the specified
/// SwiftUI view. Automatically disables animation if the navigation controller
/// is not currently in a window to prevent animation issues during initial setup.
///
/// - Parameters:
/// - view: The SwiftUI view that will become the new root of the stack.
/// - animated: Whether the replacement should be animated.
public func replace<Content: View>(_ view: Content, animated: Bool) {
let animated = self.view.window != nil ? animated : false
setViewControllers([UIHostingController(rootView: view)], animated: animated)

View File

@ -1,8 +1,8 @@
import BitwardenKit
import BitwardenKitMocks
import SwiftUI
import XCTest
@testable import BitwardenShared
// MARK: - StackNavigatorTests
class StackNavigatorTests: BitwardenTestCase {
@ -14,7 +14,7 @@ class StackNavigatorTests: BitwardenTestCase {
override func setUp() {
super.setUp()
subject = UINavigationController()
subject = MockUINavigationController()
setKeyWindowRoot(viewController: subject)
}
@ -63,14 +63,7 @@ class StackNavigatorTests: BitwardenTestCase {
/// `present(_:animated:)` presents the hosted view on existing presented views.
@MainActor
func test_present_onPresentedView() {
subject.present(EmptyView(), animated: false, embedInNavigationController: false)
subject.present(ScrollView<EmptyView> {}, animated: false, embedInNavigationController: false)
XCTAssertTrue(subject.presentedViewController is UIHostingController<EmptyView>)
waitFor(subject.presentedViewController?.presentedViewController != nil)
XCTAssertTrue(
subject.presentedViewController?.presentedViewController
is UIHostingController<ScrollView<EmptyView>>,
)
// This test in in `BitwardenShared.StackNavigatorHostedTests` because it requires a host app.
}
/// `present(_:animated:)` presents the hosted view without embedding it in a navigation controller.

View File

@ -0,0 +1,283 @@
import BitwardenKit
import UIKit
import XCTest
// MARK: - MockUINavigationController
public class MockUINavigationController: UINavigationController {
// MARK: Static properties
/// A size for the `mockWindow` and `mockView` objects to have.
/// This happens to be the size of the iPhone 5, 5C, 5S, and SE.
private static var mockWindowSize = CGRect(x: 0, y: 0, width: 320, height: 568)
// MARK: Presentation Tracking
/// Indicates whether the `present` method has been called.
public var presentCalled = false
/// The view controller that was presented, if any.
public var presentedView: UIViewController?
/// Indicates whether the presentation was animated.
public var presentAnimated = false
/// The completion handler passed to the `present` method.
public var presentCompletion: (() -> Void)?
/// Returns the currently presented view controller.
override public var presentedViewController: UIViewController? {
presentedView
}
// MARK: Dismissal Tracking
/// Indicates whether the `dismiss` method has been called.
public var dismissCalled = false
/// Indicates whether the dismissal was animated.
public var dismissAnimated = false
/// The completion handler passed to the `dismiss` method.
public var dismissCompletion: (() -> Void)?
// MARK: Push/Pop Tracking
/// Indicates whether the `pushViewController` method has been called.
public var pushViewControllerCalled = false
/// The view controller that was pushed, if any.
public var pushedViewController: UIViewController?
/// Indicates whether the push operation was animated.
public var pushAnimated = false
/// Indicates whether the `popViewController` method has been called.
public var popViewControllerCalled = false
/// The view controller that was popped, if any.
var poppedViewController: UIViewController?
/// Indicates whether the pop operation was animated.
var popAnimated = false
// MARK: Navigation Controller Support
/// Internal storage for a navigation controller.
private var _navigationController: UINavigationController?
/// Returns the internally stored navigation controller, bypassing the superclass one.
override public var navigationController: UINavigationController? {
get { _navigationController }
set { _navigationController = newValue }
}
// MARK: Mock Window and View Hierarchy
/// The mock window used for testing view hierarchy.
private var mockWindow: UIWindow?
/// The mock view used as the main view.
private var mockView: UIView?
/// Returns the mock view or the default view if no mock view is set.
override public var view: UIView! {
get {
mockView ?? super.view
}
set {
mockView = newValue
super.view = newValue
}
}
/// Returns whether the mock view or default view is loaded.
override public var isViewLoaded: Bool {
mockView != nil || super.isViewLoaded
}
// MARK: Initialization
/// Initializes the mock navigation controller with the specified nib name and bundle.
///
/// - Parameters:
/// - nibNameOrNil: The name of the nib file to load, or nil if no nib should be loaded.
/// - nibBundleOrNil: The bundle containing the nib file, or nil for the main bundle.
override public init(
nibName nibNameOrNil: String?,
bundle nibBundleOrNil: Bundle?,
) {
super.init(
nibName: nibNameOrNil,
bundle: nibBundleOrNil,
)
setUpMockHierarchy()
}
/// Initializes the mock navigation controller with a nil nib name and bundle.
public init() {
super.init(nibName: nil, bundle: nil)
}
/// Initializes the mock navigation controller with a root view controller.
///
/// - Parameters:
/// - rootViewController: The view controller to use as the root of the navigation stack.
override public init(rootViewController: UIViewController) {
super.init(nibName: nil, bundle: nil)
// Set viewControllers array directly to avoid hierarchy issues
viewControllers = [rootViewController]
}
/// Initializes the mock view controller from a coder.
///
/// - Parameters:
/// - coder: The coder to initialize from.
required init?(coder: NSCoder) {
super.init(coder: coder)
setUpMockHierarchy()
}
// MARK: View Life Cycle Methods
/// Called after the view controller's view is loaded into memory.
/// Ensures that a mock view exists even if `loadView` wasn't called.
override public func viewDidLoad() {
super.viewDidLoad()
// Ensure we have a view even if loadView wasn't called
if view == nil {
view = UIView(frame: MockUINavigationController.mockWindowSize)
}
}
/// Creates the view controller's view programmatically.
/// Sets up a mock view with the predefined mock window size.
override public func loadView() {
if mockView == nil {
mockView = UIView(frame: MockUINavigationController.mockWindowSize)
}
view = mockView
}
// MARK: UIViewController Overrides
/// Presents a view controller modally and tracks the presentation details for testing.
///
/// - Parameters:
/// - viewControllerToPresent: The view controller to present.
/// - animated: Whether to animate the presentation.
/// - completion: A completion handler to call after the presentation finishes.
override public func present(
_ viewControllerToPresent: UIViewController,
animated: Bool,
completion: (() -> Void)? = nil,
) {
presentCalled = true
presentedView = viewControllerToPresent
presentAnimated = animated
presentCompletion = completion
// Set up the presented view controller's hierarchy
viewControllerToPresent.beginAppearanceTransition(true, animated: animated)
viewControllerToPresent.endAppearanceTransition()
// Call completion if provided
completion?()
}
/// Dismisses the currently presented view controller and tracks the dismissal details for testing.
///
/// - Parameters:
/// - animated: Whether to animate the dismissal.
/// - completion: A completion handler to call after the dismissal finishes.
override public func dismiss(animated: Bool, completion: (() -> Void)? = nil) {
dismissCalled = true
dismissAnimated = animated
dismissCompletion = completion
if let presentedView {
presentedView.beginAppearanceTransition(false, animated: animated)
presentedView.endAppearanceTransition()
}
// Clear the presented view controller
presentedView = nil
completion?()
}
override public func pushViewController(_ viewController: UIViewController, animated: Bool) {
pushViewControllerCalled = true
pushedViewController = viewController
pushAnimated = animated
// Add to view controllers array
var controllers = viewControllers
controllers.append(viewController)
viewControllers = controllers
// Simulate appearance transitions safely
DispatchQueue.main.async {
viewController.beginAppearanceTransition(true, animated: animated)
viewController.endAppearanceTransition()
}
}
@discardableResult
override public func popViewController(animated: Bool) -> UIViewController? {
popViewControllerCalled = true
popAnimated = animated
guard viewControllers.count > 1 else { return nil }
var controllers = viewControllers
let poppedVC = controllers.removeLast()
poppedViewController = poppedVC
viewControllers = controllers
// Simulate appearance transitions safely
DispatchQueue.main.async {
poppedVC.beginAppearanceTransition(false, animated: animated)
poppedVC.endAppearanceTransition()
}
return poppedVC
}
// MARK: Helper Methods
/// Resets and clears all local variables, to prepare the mock for reuse.
public func reset() {
presentCalled = false
presentedView = nil
presentAnimated = false
presentCompletion = nil
dismissCalled = false
dismissAnimated = false
dismissCompletion = nil
pushViewControllerCalled = false
pushedViewController = nil
pushAnimated = false
popViewControllerCalled = false
poppedViewController = nil
popAnimated = false
_navigationController = nil
}
// MARK: Mock Hierarchy
/// Sets up a `UIWindow` and `UIView` to use as mocks in the view hierarchy.
private func setUpMockHierarchy() {
// Create a mock window to avoid issues with view hierarchy
mockWindow = UIWindow(frame: MockUINavigationController.mockWindowSize)
mockWindow?.rootViewController = self
// Create a mock view
mockView = UIView(frame: mockWindow?.frame ?? .zero)
view = mockView
}
}

View File

@ -43,17 +43,6 @@ public class MockUIViewController: UIViewController {
/// The completion handler passed to the `dismiss` method.
public var dismissCompletion: (() -> Void)?
// MARK: Navigation Tracking
/// Indicates whether the `pushViewController` method has been called.
public var pushViewControllerCalled = false
/// The view controller that was pushed, if any.
public var pushedViewController: UIViewController?
/// Indicates whether the `popViewController` method has been called.
public var popViewControllerCalled = false
// MARK: Navigation Controller Support
/// Internal storage for a navigation controller.
@ -107,6 +96,11 @@ public class MockUIViewController: UIViewController {
setUpMockHierarchy()
}
/// Initializes the mock navigation controller with a nil nib name and bundle.
public init() {
super.init(nibName: nil, bundle: nil)
}
/// Initializes the mock view controller from a coder.
///
/// - Parameters:
@ -164,7 +158,7 @@ public class MockUIViewController: UIViewController {
}
/// Dismisses the currently presented view controller and tracks the dismissal details for testing.
///
///
/// - Parameters:
/// - animated: Whether to animate the dismissal.
/// - completion: A completion handler to call after the dismissal finishes.
@ -197,9 +191,7 @@ public class MockUIViewController: UIViewController {
dismissAnimated = false
dismissCompletion = nil
pushViewControllerCalled = false
pushedViewController = nil
popViewControllerCalled = false
_navigationController = nil
}
// MARK: Mock Hierarchy

View File

@ -1,3 +1,4 @@
import BitwardenKit
import UIKit
// MARK: - AuthModule

View File

@ -1,3 +1,4 @@
import BitwardenKit
import Foundation
// MARK: - ProfileSwitcherModule

View File

@ -1,3 +1,4 @@
import BitwardenKitMocks
import XCTest
@testable import BitwardenShared

View File

@ -0,0 +1,38 @@
import BitwardenKit
import BitwardenKitMocks
import SwiftUI
import XCTest
// MARK: - StackNavigatorTests
class StackNavigatorHostedTests: BitwardenTestCase {
// MARK: Properties
var subject: UINavigationController!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
subject = UINavigationController()
setKeyWindowRoot(viewController: subject)
}
// MARK: Tests
/// `present(_:animated:)` presents the hosted view on existing presented views.
/// This is in `BitwardenSharedTests` instead of `BitwardenKitTests` because it requires a host app,
/// due to the fact that the implementation of `StackNavigator` creates a `UIHostingController`,
/// so we cannot mock it without significantly more rigamarole, which seems excessive for one test.
@MainActor
func test_present_onPresentedView() {
subject.present(EmptyView(), animated: false, embedInNavigationController: false)
subject.present(ScrollView<EmptyView> {}, animated: false, embedInNavigationController: false)
XCTAssertTrue(subject.presentedViewController is UIHostingController<EmptyView>)
waitFor(subject.presentedViewController?.presentedViewController != nil)
XCTAssertTrue(
subject.presentedViewController?.presentedViewController
is UIHostingController<ScrollView<EmptyView>>,
)
}
}

View File

@ -1,3 +1,4 @@
import BitwardenKit
import Foundation
// MARK: - DebugMenuModule

View File

@ -1,3 +1,4 @@
import BitwardenKitMocks
import SwiftUI
import XCTest

View File

@ -1,3 +1,4 @@
import BitwardenKit
import UIKit
// MARK: - ExtensionSetupModule

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - FileSelectionModule
/// An object that builds coordinators for the file selection flow.

View File

@ -1,3 +1,4 @@
import BitwardenKitMocks
import SwiftUI
import XCTest

View File

@ -1,3 +1,4 @@
import BitwardenKit
import Foundation
// MARK: - LoginRequestModule

View File

@ -1,3 +1,4 @@
import BitwardenKitMocks
import SwiftUI
import XCTest

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - PasswordAutoFillModule
/// An object that builds coordinators for the password autofill flow.

View File

@ -1,3 +1,4 @@
import BitwardenKitMocks
import BitwardenSdk
import SwiftUI
import XCTest

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - AddEditFolderModule
/// An object that builds coordinators for the add and edit folder view.

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - SettingsModule
/// An object that builds coordinators for the settings tab.

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - ExportCXFModule
/// An object that builds coordinators for the Credential Exchange export flow.

View File

@ -1,4 +1,5 @@
import BitwardenKit
import BitwardenKitMocks
import SwiftUI
import XCTest

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - Generator Module
/// An object that builds coordinators for the generator tab.

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - ImportCXFModule
/// An object that builds coordinators for the Credential Exchange import flow.

View File

@ -1,3 +1,4 @@
import BitwardenKitMocks
import SwiftUI
import XCTest

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - PasswordHistoryModule
/// An object that builds coordinators for the password history view.

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - SendModule
/// An object that builds coordinators for the send tab.

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - SendItemModule
/// An object that builds coordinators for the send item flow.

View File

@ -1,3 +1,4 @@
import BitwardenKitMocks
import SwiftUI
import XCTest

View File

@ -1,3 +1,5 @@
import BitwardenKit
// MARK: - ImportLoginsModule
/// An object that builds coordinators for the import logins views.

View File

@ -1,3 +1,4 @@
import BitwardenKit
import Foundation
// MARK: - VaultModule

View File

@ -1,3 +1,4 @@
import BitwardenKit
import Foundation
// MARK: - VaultModule

View File

@ -55,7 +55,7 @@ class MockAppModule:
}
func makeItemListCoordinator(
stackNavigator _: AuthenticatorShared.StackNavigator,
stackNavigator _: StackNavigator,
) -> AnyCoordinator<ItemListRoute, ItemListEvent> {
itemListCoordinator.asAnyCoordinator()
}

View File

@ -1,112 +0,0 @@
import AuthenticatorShared
import BitwardenKit
import SwiftUI
final class MockStackNavigator: StackNavigator {
struct NavigationAction {
var type: NavigationType
var view: Any?
var animated: Bool
var hidesBottomBar: Bool?
var overFullscreen: Bool?
}
enum NavigationType {
case dismissed
case dismissedWithCompletionHandler
case pushed
case popped
case poppedToRoot
case presented
case presentedInSheet
case replaced
}
var actions: [NavigationAction] = []
var alerts: [BitwardenKit.Alert] = []
var isEmpty = true
var isPresenting: Bool { actions.last?.type == .presented }
var rootViewController: UIViewController?
var viewControllersToPop: [UIViewController] = []
func dismiss(animated: Bool) {
actions.append(NavigationAction(type: .dismissed, animated: animated))
}
func dismiss(animated: Bool, completion: (() -> Void)?) {
completion?()
actions.append(NavigationAction(type: .dismissedWithCompletionHandler, animated: animated))
}
func push<Content: View>(_ view: Content, animated: Bool, hidesBottomBar: Bool) {
actions.append(NavigationAction(
type: .pushed,
view: view,
animated: animated,
hidesBottomBar: hidesBottomBar,
))
}
func push(_ viewController: UIViewController, animated: Bool) {
actions.append(NavigationAction(
type: .pushed,
view: viewController,
animated: animated,
))
}
@discardableResult
func pop(animated: Bool) -> UIViewController? {
actions.append(NavigationAction(type: .popped, animated: animated))
return viewControllersToPop.last
}
@discardableResult
func popToRoot(animated: Bool) -> [UIViewController] {
actions.append(NavigationAction(type: .poppedToRoot, animated: animated))
return viewControllersToPop
}
func present(_ alert: BitwardenKit.Alert) {
alerts.append(alert)
}
func present<Content: View>(
_ view: Content,
animated: Bool,
overFullscreen: Bool,
onCompletion: (() -> Void)?,
) {
onCompletion?()
actions.append(
NavigationAction(
type: .presented,
view: view,
animated: animated,
overFullscreen: overFullscreen,
),
)
}
func present(
_ viewController: UIViewController,
animated: Bool,
overFullscreen: Bool,
onCompletion: (() -> Void)?,
) {
onCompletion?()
actions.append(
NavigationAction(
type: .presented,
view: viewController,
animated: animated,
overFullscreen: overFullscreen,
),
)
}
func replace<Content: View>(_ view: Content, animated: Bool) {
actions.append(NavigationAction(type: .replaced, view: view, animated: animated))
}
}

View File

@ -194,7 +194,7 @@ class MockAppModule:
func makeVaultCoordinator(
delegate _: BitwardenShared.VaultCoordinatorDelegate,
stackNavigator _: BitwardenShared.StackNavigator,
stackNavigator _: StackNavigator,
) -> BitwardenShared.AnyCoordinator<BitwardenShared.VaultRoute, AuthAction> {
vaultCoordinator.asAnyCoordinator()
}