diff --git a/.changeset/quiet-clerk-host-back.md b/.changeset/quiet-clerk-host-back.md new file mode 100644 index 00000000000..5d2e9571a55 --- /dev/null +++ b/.changeset/quiet-clerk-host-back.md @@ -0,0 +1,5 @@ +--- +"@clerk/expo-native-components": patch +--- + +Allow the iOS host back button in embedded auth and profile views to dismiss its enclosing native navigation screen immediately. `onHostBack` now runs only when there is no screen to pop, such as when the view is the stack's root screen or is presented in a modal. diff --git a/packages/expo-native-components/ios/ClerkAuthNativeView.swift b/packages/expo-native-components/ios/ClerkAuthNativeView.swift index 5f8d2ba66d5..b170304cbf2 100644 --- a/packages/expo-native-components/ios/ClerkAuthNativeView.swift +++ b/packages/expo-native-components/ios/ClerkAuthNativeView.swift @@ -111,7 +111,12 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { override func makeHostedController() -> UIViewController? { let hostBackAction: (() -> Void)? = currentHostBackButton - ? { [weak self] in self?.onHostBack([:]) } + ? { [weak self] in + guard let self else { return } + if !self.popEnclosingNavigationRoute() { + self.onHostBack([:]) + } + } : nil return ClerkNativeBridge.shared.makeAuthViewController( diff --git a/packages/expo-native-components/ios/ClerkNativeViewHost.swift b/packages/expo-native-components/ios/ClerkNativeViewHost.swift index 4e6540aceea..7429f0ceabc 100644 --- a/packages/expo-native-components/ios/ClerkNativeViewHost.swift +++ b/packages/expo-native-components/ios/ClerkNativeViewHost.swift @@ -5,6 +5,7 @@ public class ClerkNativeViewHost: ExpoView { private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) private var hasInitialized: Bool = false private var configuredObserver: NSObjectProtocol? + private var isPoppingHostRoute = false public required init(appContext: AppContext? = nil) { super.init(appContext: appContext) @@ -23,6 +24,7 @@ public class ClerkNativeViewHost: ExpoView { super.didMoveToWindow() guard window != nil else { + isPoppingHostRoute = false if hasInitialized { hostedViewDidDetachFromWindow() } @@ -58,6 +60,39 @@ public class ClerkNativeViewHost: ExpoView { func hostedViewDidDetachFromWindow() {} + func popEnclosingNavigationRoute() -> Bool { + guard !isPoppingHostRoute else { return true } + + // Follow controller containment, not the responder chain: past a presented controller the + // responder chain continues to its presenter, so a host inside a modal would pop the screen + // behind the modal instead of letting `onHostBack` dismiss it. + var route = enclosingViewController(of: self) + + while let candidate = route { + if let navigationController = candidate.parent as? UINavigationController, + navigationController.viewControllers.count > 1 { + guard navigationController.topViewController === candidate else { return false } + + isPoppingHostRoute = true + guard navigationController.popViewController(animated: true) != nil else { + isPoppingHostRoute = false + return false + } + if let coordinator = navigationController.transitionCoordinator { + coordinator.animate(alongsideTransition: nil) { [weak self] _ in + self?.isPoppingHostRoute = false + } + } else { + isPoppingHostRoute = false + } + return true + } + route = candidate.parent + } + + return false + } + private func addConfiguredObserver() { guard configuredObserver == nil else { return } @@ -162,7 +197,7 @@ private final class ClerkNativeHostingCoordinator { controller.view.frame = containerView.bounds controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] - if let parentVC = findViewController(from: containerView) { + if let parentVC = enclosingViewController(of: containerView) { parentVC.addChild(controller) containerView.addSubview(controller.view) controller.didMove(toParent: parentVC) @@ -190,15 +225,15 @@ private final class ClerkNativeHostingCoordinator { guard let containerView else { return } hostingController?.view.frame = containerView.bounds } +} - private func findViewController(from view: UIView) -> UIViewController? { - var responder: UIResponder? = view - while let nextResponder = responder?.next { - if let vc = nextResponder as? UIViewController { - return vc - } - responder = nextResponder +private func enclosingViewController(of view: UIView) -> UIViewController? { + var responder: UIResponder? = view + while let nextResponder = responder?.next { + if let viewController = nextResponder as? UIViewController { + return viewController } - return nil + responder = nextResponder } + return nil } diff --git a/packages/expo-native-components/ios/ClerkUserProfileNativeView.swift b/packages/expo-native-components/ios/ClerkUserProfileNativeView.swift index f2ca604d41f..46f406d5361 100644 --- a/packages/expo-native-components/ios/ClerkUserProfileNativeView.swift +++ b/packages/expo-native-components/ios/ClerkUserProfileNativeView.swift @@ -47,7 +47,12 @@ public class ClerkUserProfileNativeView: ClerkUserProfileCustomPageHost { override func makeHostedController() -> UIViewController? { let hostBackAction: (() -> Void)? = currentHostBackButton - ? { [weak self] in self?.onHostBack([:]) } + ? { [weak self] in + guard let self else { return } + if !self.popEnclosingNavigationRoute() { + self.onHostBack([:]) + } + } : nil return ClerkNativeBridge.shared.makeUserProfileViewController( diff --git a/packages/expo-native-components/ios/Tests/ClerkNativeViewHostTests.swift b/packages/expo-native-components/ios/Tests/ClerkNativeViewHostTests.swift index acbfed504e2..030acd8a7d0 100644 --- a/packages/expo-native-components/ios/Tests/ClerkNativeViewHostTests.swift +++ b/packages/expo-native-components/ios/Tests/ClerkNativeViewHostTests.swift @@ -37,6 +37,126 @@ final class ClerkNativeViewHostTests: XCTestCase { unmount(hostView, from: window) } + @MainActor + func testHostBackFallsBackWhenThereIsNoNavigationController() { + let hostView = TestClerkNativeViewHost(appContext: nil) + let container = UIViewController() + container.view.addSubview(hostView) + + XCTAssertFalse(hostView.popEnclosingNavigationRoute()) + } + + @MainActor + func testHostBackDoesNotPopTheRootScreen() { + let root = UIViewController() + let navigation = UINavigationController(rootViewController: root) + let hostView = TestClerkNativeViewHost(appContext: nil) + root.view.addSubview(hostView) + + XCTAssertFalse(hostView.popEnclosingNavigationRoute()) + XCTAssertTrue(navigation.topViewController === root) + } + + @MainActor + func testHostBackPopsTheEnclosingScreen() { + let root = UIViewController() + let screen = UIViewController() + let navigation = UINavigationController(rootViewController: root) + navigation.setViewControllers([root, screen], animated: false) + let hostView = TestClerkNativeViewHost(appContext: nil) + screen.view.addSubview(hostView) + + XCTAssertTrue(hostView.popEnclosingNavigationRoute()) + XCTAssertTrue(navigation.topViewController === root) + XCTAssertFalse(hostView.popEnclosingNavigationRoute()) + } + + @MainActor + func testHostBackPopsTheScreenContainingAChildController() { + let root = UIViewController() + let screen = UIViewController() + let child = UIViewController() + screen.addChild(child) + screen.view.addSubview(child.view) + child.didMove(toParent: screen) + let navigation = UINavigationController(rootViewController: root) + navigation.setViewControllers([root, screen], animated: false) + let hostView = TestClerkNativeViewHost(appContext: nil) + child.view.addSubview(hostView) + + XCTAssertTrue(hostView.popEnclosingNavigationRoute()) + XCTAssertTrue(navigation.topViewController === root) + } + + @MainActor + func testHostBackDoesNotPopAScreenBelowTheTopOfTheStack() { + let root = UIViewController() + let screen = UIViewController() + let top = UIViewController() + let navigation = UINavigationController(rootViewController: root) + navigation.setViewControllers([root, screen, top], animated: false) + let hostView = TestClerkNativeViewHost(appContext: nil) + screen.view.addSubview(hostView) + + XCTAssertFalse(hostView.popEnclosingNavigationRoute()) + XCTAssertEqual(navigation.viewControllers, [root, screen, top]) + } + + @MainActor + func testHostBackInAModalDoesNotPopTheScreenBehindIt() { + let root = UIViewController() + let screen = UIViewController() + screen.definesPresentationContext = true + let navigation = UINavigationController(rootViewController: root) + navigation.setViewControllers([root, screen], animated: false) + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = navigation + window.makeKeyAndVisible() + window.layoutIfNeeded() + let modal = UIViewController() + modal.modalPresentationStyle = .currentContext + screen.present(modal, animated: false) + let hostView = TestClerkNativeViewHost(appContext: nil) + modal.view.addSubview(hostView) + // The responder chain continues from the modal to the pushed screen behind it. + XCTAssertTrue(modal.next === screen) + + XCTAssertFalse(hostView.popEnclosingNavigationRoute()) + XCTAssertEqual(navigation.viewControllers, [root, screen]) + + modal.dismiss(animated: false) + window.isHidden = true + } + + @MainActor + func testHostBackInAModalFromANestedStackDoesNotPopTheOuterScreen() { + let outerRoot = UIViewController() + let outerScreen = UIViewController() + let outer = UINavigationController(rootViewController: outerRoot) + outer.setViewControllers([outerRoot, outerScreen], animated: false) + let inner = UINavigationController(rootViewController: UIViewController()) + outerScreen.addChild(inner) + outerScreen.view.addSubview(inner.view) + inner.didMove(toParent: outerScreen) + inner.definesPresentationContext = true + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = outer + window.makeKeyAndVisible() + window.layoutIfNeeded() + let modal = UIViewController() + modal.modalPresentationStyle = .currentContext + inner.present(modal, animated: false) + let hostView = TestClerkNativeViewHost(appContext: nil) + modal.view.addSubview(hostView) + XCTAssertTrue(modal.next === inner) + + XCTAssertFalse(hostView.popEnclosingNavigationRoute()) + XCTAssertEqual(outer.viewControllers, [outerRoot, outerScreen]) + + modal.dismiss(animated: false) + window.isHidden = true + } + @MainActor private func mountInWindow(_ hostView: UIView) -> UIWindow { let window = UIWindow(frame: UIScreen.main.bounds) diff --git a/packages/expo-native-components/src/EmbeddedNavigation.types.ts b/packages/expo-native-components/src/EmbeddedNavigation.types.ts index 4d12688f776..44a8f02b411 100644 --- a/packages/expo-native-components/src/EmbeddedNavigation.types.ts +++ b/packages/expo-native-components/src/EmbeddedNavigation.types.ts @@ -4,8 +4,11 @@ */ export interface EmbeddedNavigationProps { /** - * Shows a back button on the component's root screen and calls this when it - * is tapped. Pop your own route in response. + * Shows a back button on the component's root screen. On iOS, tapping it pops + * the native navigation screen that contains the component, and this callback + * runs only when there is no such screen to pop, such as when the component is + * the stack's root screen or is presented in a modal. On Android, tapping it + * always calls this. Pop your own route or dismiss your modal in response. * * The component keeps its own navigation chrome, so screen titles, back * buttons, swipe-back, and transitions inside the component stay native.