From 0cd6da661c9604424171e70ee262ab3043a3e6a3 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 13 Jan 2026 14:02:37 -0800 Subject: [PATCH 1/3] Working Demo app --- .claude/settings.local.json | 9 ++ Demo/Demo.xcodeproj/project.pbxproj | 145 ++++++++++++++++++++ Demo/SwiftUIDemo/AppNavigatorDelegate.swift | 16 +++ Demo/SwiftUIDemo/SwiftUIDemoApp.swift | 36 +++++ Demo/SwiftUIDemo/Tabs.swift | 30 ++++ Demo/SwiftUIDemo/path-configuration.json | 26 ++++ Source/SwiftUI/HotwireNavigationView.swift | 124 +++++++++++++++++ Source/SwiftUI/HotwireRootView.swift | 115 ++++++++++++++++ 8 files changed, 501 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 Demo/SwiftUIDemo/AppNavigatorDelegate.swift create mode 100644 Demo/SwiftUIDemo/SwiftUIDemoApp.swift create mode 100644 Demo/SwiftUIDemo/Tabs.swift create mode 100644 Demo/SwiftUIDemo/path-configuration.json create mode 100644 Source/SwiftUI/HotwireNavigationView.swift create mode 100644 Source/SwiftUI/HotwireRootView.swift diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..5f563b7d --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:native.hotwired.dev)", + "Bash(swift build:*)", + "Bash(xcodebuild:*)" + ] + } +} diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index d4a347ad..2d4599d1 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -19,6 +19,13 @@ 844BA7B92B9A488A008F48DB /* path-configuration.json in Resources */ = {isa = PBXBuildFile; fileRef = 844BA7AE2B9A488A008F48DB /* path-configuration.json */; }; 848E12212BD813CA009BB0A4 /* HotwireNative in Frameworks */ = {isa = PBXBuildFile; productRef = 848E12202BD813CA009BB0A4 /* HotwireNative */; }; 84A617FF2D5AA88B00694AEE /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A617FE2D5AA88B00694AEE /* Tabs.swift */; }; + /* SwiftUI Demo Build Files */ + 84SWUI0012D5BB00100000001 /* SwiftUIDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000005 /* SwiftUIDemoApp.swift */; }; + 84SWUI0012D5BB00100000002 /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000006 /* Tabs.swift */; }; + 84SWUI0012D5BB00100000003 /* AppNavigatorDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000007 /* AppNavigatorDelegate.swift */; }; + 84SWUI0012D5BB00100000004 /* path-configuration.json in Resources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000008 /* path-configuration.json */; }; + 84SWUI0012D5BB0010000000A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 844BA7932B9A4844008F48DB /* Assets.xcassets */; }; + 84SWUI0012D5BB0010000000B /* HotwireNative in Frameworks */ = {isa = PBXBuildFile; productRef = 84SWUI0012D5BB0010000000C /* HotwireNative */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -36,6 +43,12 @@ 844BA7AE2B9A488A008F48DB /* path-configuration.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "path-configuration.json"; sourceTree = ""; }; 8458F3EF2BD8139400867B66 /* hotwire-native-ios */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "hotwire-native-ios"; path = ..; sourceTree = ""; }; 84A617FE2D5AA88B00694AEE /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = ""; }; + /* SwiftUI Demo File References */ + 84SWUI0012D5BB00100000005 /* SwiftUIDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIDemoApp.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000006 /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000007 /* AppNavigatorDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigatorDelegate.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000008 /* path-configuration.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "path-configuration.json"; sourceTree = ""; }; + 84SWUI0012D5BB00100000009 /* SwiftUIDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUIDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -47,6 +60,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 84SWUI0012D5BB00100000020 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 84SWUI0012D5BB0010000000B /* HotwireNative in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -54,6 +75,7 @@ isa = PBXGroup; children = ( 844BA7892B9A4843008F48DB /* Demo */, + 84SWUI0012D5BB00100000010 /* SwiftUIDemo */, 844BA7882B9A4843008F48DB /* Products */, 8458F3EC2BD8135600867B66 /* Frameworks */, ); @@ -63,6 +85,7 @@ isa = PBXGroup; children = ( 844BA7872B9A4843008F48DB /* Demo.app */, + 84SWUI0012D5BB00100000009 /* SwiftUIDemo.app */, ); name = Products; sourceTree = ""; @@ -102,6 +125,17 @@ name = Frameworks; sourceTree = ""; }; + 84SWUI0012D5BB00100000010 /* SwiftUIDemo */ = { + isa = PBXGroup; + children = ( + 84SWUI0012D5BB00100000005 /* SwiftUIDemoApp.swift */, + 84SWUI0012D5BB00100000006 /* Tabs.swift */, + 84SWUI0012D5BB00100000007 /* AppNavigatorDelegate.swift */, + 84SWUI0012D5BB00100000008 /* path-configuration.json */, + ); + path = SwiftUIDemo; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -125,6 +159,26 @@ productReference = 844BA7872B9A4843008F48DB /* Demo.app */; productType = "com.apple.product-type.application"; }; + 84SWUI0012D5BB00100000030 /* SwiftUIDemo */ = { + isa = PBXNativeTarget; + buildConfigurationList = 84SWUI0012D5BB00100000040 /* Build configuration list for PBXNativeTarget "SwiftUIDemo" */; + buildPhases = ( + 84SWUI0012D5BB00100000021 /* Sources */, + 84SWUI0012D5BB00100000020 /* Frameworks */, + 84SWUI0012D5BB00100000022 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SwiftUIDemo; + packageProductDependencies = ( + 84SWUI0012D5BB0010000000C /* HotwireNative */, + ); + productName = SwiftUIDemo; + productReference = 84SWUI0012D5BB00100000009 /* SwiftUIDemo.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -138,6 +192,9 @@ 844BA7862B9A4843008F48DB = { CreatedOnToolsVersion = 15.3; }; + 84SWUI0012D5BB00100000030 = { + CreatedOnToolsVersion = 15.3; + }; }; }; buildConfigurationList = 844BA7822B9A4843008F48DB /* Build configuration list for PBXProject "Demo" */; @@ -156,6 +213,7 @@ projectRoot = ""; targets = ( 844BA7862B9A4843008F48DB /* Demo */, + 84SWUI0012D5BB00100000030 /* SwiftUIDemo */, ); }; /* End PBXProject section */ @@ -171,6 +229,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 84SWUI0012D5BB00100000022 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 84SWUI0012D5BB0010000000A /* Assets.xcassets in Resources */, + 84SWUI0012D5BB00100000004 /* path-configuration.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -189,6 +256,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 84SWUI0012D5BB00100000021 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 84SWUI0012D5BB00100000001 /* SwiftUIDemoApp.swift in Sources */, + 84SWUI0012D5BB00100000002 /* Tabs.swift in Sources */, + 84SWUI0012D5BB00100000003 /* AppNavigatorDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ @@ -376,6 +453,61 @@ }; name = Release; }; + /* SwiftUI Demo Build Configurations */ + 84SWUI0012D5BB00100000050 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.SwiftUIDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 84SWUI0012D5BB00100000051 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.SwiftUIDemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -397,6 +529,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 84SWUI0012D5BB00100000040 /* Build configuration list for PBXNativeTarget "SwiftUIDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 84SWUI0012D5BB00100000050 /* Debug */, + 84SWUI0012D5BB00100000051 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ @@ -404,6 +545,10 @@ isa = XCSwiftPackageProductDependency; productName = HotwireNative; }; + 84SWUI0012D5BB0010000000C /* HotwireNative */ = { + isa = XCSwiftPackageProductDependency; + productName = HotwireNative; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 844BA77F2B9A4843008F48DB /* Project object */; diff --git a/Demo/SwiftUIDemo/AppNavigatorDelegate.swift b/Demo/SwiftUIDemo/AppNavigatorDelegate.swift new file mode 100644 index 00000000..13454b5f --- /dev/null +++ b/Demo/SwiftUIDemo/AppNavigatorDelegate.swift @@ -0,0 +1,16 @@ +import HotwireNative +import UIKit + +final class AppNavigatorDelegate: NavigatorDelegate { + func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult { + // Accept all proposals with the default view controller. + // Customize this to return .acceptCustom(viewController) for native screens. + .accept + } + + func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) { + if let errorPresenter = visitable as? ErrorPresenter { + errorPresenter.presentError(error, retryHandler: retryHandler) + } + } +} diff --git a/Demo/SwiftUIDemo/SwiftUIDemoApp.swift b/Demo/SwiftUIDemo/SwiftUIDemoApp.swift new file mode 100644 index 00000000..fbc9498c --- /dev/null +++ b/Demo/SwiftUIDemo/SwiftUIDemoApp.swift @@ -0,0 +1,36 @@ +import HotwireNative +import SwiftUI + +@main +struct SwiftUIDemoApp: App { + @State private var navigator: Navigator? + + init() { + configureHotwire() + } + + var body: some Scene { + WindowGroup { + HotwireRootView(tabs: HotwireTab.all) + .navigatorDelegate(AppNavigatorDelegate()) + .ignoresSafeArea() + } + } + + private func configureHotwire() { + // Load path configuration from local file and remote server. + Hotwire.loadPathConfiguration(from: [ + .file(Bundle.main.url(forResource: "path-configuration", withExtension: "json")!) + ]) + + // Set an optional custom user agent prefix. + Hotwire.config.applicationUserAgentPrefix = "Hotwire SwiftUI Demo;" + + // Configure UI options. + Hotwire.config.backButtonDisplayMode = .minimal + Hotwire.config.showDoneButtonOnModals = true + #if DEBUG + Hotwire.config.debugLoggingEnabled = true + #endif + } +} diff --git a/Demo/SwiftUIDemo/Tabs.swift b/Demo/SwiftUIDemo/Tabs.swift new file mode 100644 index 00000000..c1b7b31d --- /dev/null +++ b/Demo/SwiftUIDemo/Tabs.swift @@ -0,0 +1,30 @@ +import HotwireNative +import UIKit + +extension HotwireTab { + static let all: [HotwireTab] = [ + .home, + .components, + .resources + ] + + private static let baseURL = URL(string: "https://hotwire-native-demo.dev")! + + static let home = HotwireTab( + title: "Home", + image: UIImage(systemName: "house")!, + url: baseURL + ) + + static let components = HotwireTab( + title: "Components", + image: UIImage(systemName: "square.grid.2x2")!, + url: baseURL.appendingPathComponent("components") + ) + + static let resources = HotwireTab( + title: "Resources", + image: UIImage(systemName: "book.closed")!, + url: baseURL.appendingPathComponent("resources") + ) +} diff --git a/Demo/SwiftUIDemo/path-configuration.json b/Demo/SwiftUIDemo/path-configuration.json new file mode 100644 index 00000000..17b8c45c --- /dev/null +++ b/Demo/SwiftUIDemo/path-configuration.json @@ -0,0 +1,26 @@ +{ + "settings": {}, + "rules": [ + { + "patterns": [ + "/new$", + "/edit$", + "/modal" + ], + "properties": { + "context": "modal", + "pull_to_refresh_enabled": false + }, + "comment": "Present forms and custom modal path as modals." + }, + { + "patterns": [ + "^/$" + ], + "properties": { + "presentation": "clear_all" + }, + "comment": "Reset navigation stacks when visiting root page." + } + ] +} diff --git a/Source/SwiftUI/HotwireNavigationView.swift b/Source/SwiftUI/HotwireNavigationView.swift new file mode 100644 index 00000000..92307a7f --- /dev/null +++ b/Source/SwiftUI/HotwireNavigationView.swift @@ -0,0 +1,124 @@ +import SwiftUI +import UIKit + +/// A SwiftUI view that wraps a single `Navigator` for stack-based navigation. +/// +/// Use this as your root view in a SwiftUI app when you don't need tabs. +/// +/// ```swift +/// @main +/// struct MyApp: App { +/// init() { +/// Hotwire.loadPathConfiguration(from: ...) +/// } +/// +/// var body: some Scene { +/// WindowGroup { +/// HotwireNavigationView( +/// name: "main", +/// startURL: URL(string: "https://example.com")! +/// ) +/// } +/// } +/// } +/// ``` +public struct HotwireNavigationView: UIViewControllerRepresentable { + private let name: String + private let startURL: URL + private var navigatorDelegate: NavigatorDelegate? + private var onNavigatorReady: ((Navigator) -> Void)? + + /// Creates a new Hotwire navigation view with a single navigator. + /// - Parameters: + /// - name: A name for the navigator configuration (used internally). + /// - startURL: The initial URL to load. + public init(name: String = "main", startURL: URL) { + self.name = name + self.startURL = startURL + } + + public func makeUIViewController(context: Context) -> UINavigationController { + let configuration = Navigator.Configuration(name: name, startLocation: startURL) + let navigator = Navigator(configuration: configuration, delegate: context.coordinator) + context.coordinator.navigator = navigator + navigator.start() + + // Notify that navigator is ready + onNavigatorReady?(navigator) + + return navigator.rootViewController + } + + public func updateUIViewController(_ controller: UINavigationController, context: Context) { + // Update delegate reference if it changed + context.coordinator.customDelegate = navigatorDelegate + context.coordinator.onNavigatorReady = onNavigatorReady + } + + public func makeCoordinator() -> Coordinator { + Coordinator(customDelegate: navigatorDelegate, onNavigatorReady: onNavigatorReady) + } + + // MARK: - Modifiers + + /// Sets a custom navigator delegate for handling visit proposals and errors. + /// - Parameter delegate: The delegate to handle navigation events. + /// - Returns: A modified view with the delegate set. + public func navigatorDelegate(_ delegate: NavigatorDelegate) -> HotwireNavigationView { + var copy = self + copy.navigatorDelegate = delegate + return copy + } + + /// Provides access to the navigator when it becomes ready. + /// - Parameter action: A closure called with the navigator. + /// - Returns: A modified view that reports when the navigator is ready. + public func onNavigatorReady(_ action: @escaping (Navigator) -> Void) -> HotwireNavigationView { + var copy = self + copy.onNavigatorReady = action + return copy + } + + // MARK: - Coordinator + + public class Coordinator: NSObject, NavigatorDelegate { + var customDelegate: NavigatorDelegate? + var onNavigatorReady: ((Navigator) -> Void)? + var navigator: Navigator? + + init(customDelegate: NavigatorDelegate?, onNavigatorReady: ((Navigator) -> Void)?) { + self.customDelegate = customDelegate + self.onNavigatorReady = onNavigatorReady + } + + // MARK: - NavigatorDelegate + + public func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult { + customDelegate?.handle(proposal: proposal, from: navigator) ?? .accept + } + + public func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) { + customDelegate?.visitableDidFailRequest(visitable, error: error, retryHandler: retryHandler) + } + + public func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + if let customDelegate { + customDelegate.didReceiveAuthenticationChallenge(challenge, completionHandler: completionHandler) + } else { + completionHandler(.performDefaultHandling, nil) + } + } + + public func formSubmissionDidStart(to url: URL) { + customDelegate?.formSubmissionDidStart(to: url) + } + + public func formSubmissionDidFinish(at url: URL) { + customDelegate?.formSubmissionDidFinish(at: url) + } + + public func requestDidFinish(at url: URL) { + customDelegate?.requestDidFinish(at: url) + } + } +} diff --git a/Source/SwiftUI/HotwireRootView.swift b/Source/SwiftUI/HotwireRootView.swift new file mode 100644 index 00000000..f6966cf1 --- /dev/null +++ b/Source/SwiftUI/HotwireRootView.swift @@ -0,0 +1,115 @@ +import SwiftUI +import UIKit + +/// A SwiftUI view that wraps `HotwireTabBarController` for tab-based navigation. +/// +/// Use this as your root view in a SwiftUI app to get full Hotwire Native functionality +/// with multiple tabs. +/// +/// ```swift +/// @main +/// struct MyApp: App { +/// init() { +/// Hotwire.loadPathConfiguration(from: ...) +/// } +/// +/// var body: some Scene { +/// WindowGroup { +/// HotwireRootView(tabs: [ +/// HotwireTab(title: "Home", url: URL(string: "https://example.com")!) +/// ]) +/// } +/// } +/// } +/// ``` +public struct HotwireRootView: UIViewControllerRepresentable { + private let tabs: [HotwireTab] + private var navigatorDelegate: NavigatorDelegate? + private var onActiveNavigatorChange: ((Navigator) -> Void)? + + /// Creates a new Hotwire root view with the specified tabs. + /// - Parameter tabs: The tabs to display in the tab bar. + public init(tabs: [HotwireTab]) { + self.tabs = tabs + } + + public func makeUIViewController(context: Context) -> HotwireTabBarController { + let controller = HotwireTabBarController(navigatorDelegate: context.coordinator) + context.coordinator.tabBarController = controller + controller.load(tabs) + return controller + } + + public func updateUIViewController(_ controller: HotwireTabBarController, context: Context) { + // Update delegate reference if it changed + context.coordinator.customDelegate = navigatorDelegate + context.coordinator.onActiveNavigatorChange = onActiveNavigatorChange + } + + public func makeCoordinator() -> Coordinator { + Coordinator(customDelegate: navigatorDelegate, onActiveNavigatorChange: onActiveNavigatorChange) + } + + // MARK: - Modifiers + + /// Sets a custom navigator delegate for handling visit proposals and errors. + /// - Parameter delegate: The delegate to handle navigation events. + /// - Returns: A modified view with the delegate set. + public func navigatorDelegate(_ delegate: NavigatorDelegate) -> HotwireRootView { + var copy = self + copy.navigatorDelegate = delegate + return copy + } + + /// Provides access to the active navigator when it changes. + /// - Parameter action: A closure called with the active navigator. + /// - Returns: A modified view that reports navigator changes. + public func onActiveNavigatorChange(_ action: @escaping (Navigator) -> Void) -> HotwireRootView { + var copy = self + copy.onActiveNavigatorChange = action + return copy + } + + // MARK: - Coordinator + + public class Coordinator: NSObject, NavigatorDelegate { + var customDelegate: NavigatorDelegate? + var onActiveNavigatorChange: ((Navigator) -> Void)? + weak var tabBarController: HotwireTabBarController? + + init(customDelegate: NavigatorDelegate?, onActiveNavigatorChange: ((Navigator) -> Void)?) { + self.customDelegate = customDelegate + self.onActiveNavigatorChange = onActiveNavigatorChange + } + + // MARK: - NavigatorDelegate + + public func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult { + customDelegate?.handle(proposal: proposal, from: navigator) ?? .accept + } + + public func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) { + customDelegate?.visitableDidFailRequest(visitable, error: error, retryHandler: retryHandler) + } + + public func didReceiveAuthenticationChallenge(_ challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + if let customDelegate { + customDelegate.didReceiveAuthenticationChallenge(challenge, completionHandler: completionHandler) + } else { + completionHandler(.performDefaultHandling, nil) + } + } + + public func formSubmissionDidStart(to url: URL) { + customDelegate?.formSubmissionDidStart(to: url) + } + + public func formSubmissionDidFinish(at url: URL) { + customDelegate?.formSubmissionDidFinish(at: url) + } + + public func requestDidFinish(at url: URL) { + customDelegate?.requestDidFinish(at: url) + } + } +} From a73e3007942a46077f8647a2e81c10146cc0cb38 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 13 Jan 2026 14:40:20 -0800 Subject: [PATCH 2/3] Migrate rest of demo app functionality --- Demo/Demo.xcodeproj/project.pbxproj | 28 ++++++ Demo/SwiftUIDemo/AppNavigatorDelegate.swift | 47 ++++++++- Demo/SwiftUIDemo/Bridge/FormComponent.swift | 74 +++++++++++++++ Demo/SwiftUIDemo/Bridge/MenuComponent.swift | 95 +++++++++++++++++++ .../Bridge/OverflowMenuComponent.swift | 65 +++++++++++++ Demo/SwiftUIDemo/Demo.swift | 11 +++ Demo/SwiftUIDemo/NumbersViewController.swift | 52 ++++++++++ Demo/SwiftUIDemo/SwiftUIDemoApp.swift | 15 ++- Demo/SwiftUIDemo/Tabs.swift | 56 +++++++---- Demo/SwiftUIDemo/path-configuration.json | 13 ++- Source/SwiftUI/HotwireRootView.swift | 19 ++++ 11 files changed, 450 insertions(+), 25 deletions(-) create mode 100644 Demo/SwiftUIDemo/Bridge/FormComponent.swift create mode 100644 Demo/SwiftUIDemo/Bridge/MenuComponent.swift create mode 100644 Demo/SwiftUIDemo/Bridge/OverflowMenuComponent.swift create mode 100644 Demo/SwiftUIDemo/Demo.swift create mode 100644 Demo/SwiftUIDemo/NumbersViewController.swift diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 2d4599d1..a6c2c970 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -26,6 +26,11 @@ 84SWUI0012D5BB00100000004 /* path-configuration.json in Resources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000008 /* path-configuration.json */; }; 84SWUI0012D5BB0010000000A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 844BA7932B9A4844008F48DB /* Assets.xcassets */; }; 84SWUI0012D5BB0010000000B /* HotwireNative in Frameworks */ = {isa = PBXBuildFile; productRef = 84SWUI0012D5BB0010000000C /* HotwireNative */; }; + 84SWUI0012D5BB0010000000D /* Demo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB0010000000E /* Demo.swift */; }; + 84SWUI0012D5BB0010000000F /* NumbersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000011 /* NumbersViewController.swift */; }; + 84SWUI0012D5BB00100000012 /* FormComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000013 /* FormComponent.swift */; }; + 84SWUI0012D5BB00100000014 /* MenuComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000015 /* MenuComponent.swift */; }; + 84SWUI0012D5BB00100000016 /* OverflowMenuComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000017 /* OverflowMenuComponent.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -49,6 +54,11 @@ 84SWUI0012D5BB00100000007 /* AppNavigatorDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigatorDelegate.swift; sourceTree = ""; }; 84SWUI0012D5BB00100000008 /* path-configuration.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "path-configuration.json"; sourceTree = ""; }; 84SWUI0012D5BB00100000009 /* SwiftUIDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUIDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 84SWUI0012D5BB0010000000E /* Demo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Demo.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000011 /* NumbersViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumbersViewController.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000013 /* FormComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormComponent.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000015 /* MenuComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuComponent.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000017 /* OverflowMenuComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverflowMenuComponent.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -128,14 +138,27 @@ 84SWUI0012D5BB00100000010 /* SwiftUIDemo */ = { isa = PBXGroup; children = ( + 84SWUI0012D5BB00100000018 /* Bridge */, 84SWUI0012D5BB00100000005 /* SwiftUIDemoApp.swift */, + 84SWUI0012D5BB0010000000E /* Demo.swift */, 84SWUI0012D5BB00100000006 /* Tabs.swift */, 84SWUI0012D5BB00100000007 /* AppNavigatorDelegate.swift */, + 84SWUI0012D5BB00100000011 /* NumbersViewController.swift */, 84SWUI0012D5BB00100000008 /* path-configuration.json */, ); path = SwiftUIDemo; sourceTree = ""; }; + 84SWUI0012D5BB00100000018 /* Bridge */ = { + isa = PBXGroup; + children = ( + 84SWUI0012D5BB00100000013 /* FormComponent.swift */, + 84SWUI0012D5BB00100000015 /* MenuComponent.swift */, + 84SWUI0012D5BB00100000017 /* OverflowMenuComponent.swift */, + ); + path = Bridge; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -261,8 +284,13 @@ buildActionMask = 2147483647; files = ( 84SWUI0012D5BB00100000001 /* SwiftUIDemoApp.swift in Sources */, + 84SWUI0012D5BB0010000000D /* Demo.swift in Sources */, 84SWUI0012D5BB00100000002 /* Tabs.swift in Sources */, 84SWUI0012D5BB00100000003 /* AppNavigatorDelegate.swift in Sources */, + 84SWUI0012D5BB0010000000F /* NumbersViewController.swift in Sources */, + 84SWUI0012D5BB00100000012 /* FormComponent.swift in Sources */, + 84SWUI0012D5BB00100000014 /* MenuComponent.swift in Sources */, + 84SWUI0012D5BB00100000016 /* OverflowMenuComponent.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Demo/SwiftUIDemo/AppNavigatorDelegate.swift b/Demo/SwiftUIDemo/AppNavigatorDelegate.swift index 13454b5f..fc7b535d 100644 --- a/Demo/SwiftUIDemo/AppNavigatorDelegate.swift +++ b/Demo/SwiftUIDemo/AppNavigatorDelegate.swift @@ -1,16 +1,53 @@ import HotwireNative import UIKit -final class AppNavigatorDelegate: NavigatorDelegate { +final class AppNavigatorDelegate: NavigatorDelegate, HotwireTabBarControllerDelegate { + weak var tabBarController: HotwireTabBarController? + func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult { - // Accept all proposals with the default view controller. - // Customize this to return .acceptCustom(viewController) for native screens. - .accept + switch proposal.viewController { + case NumbersViewController.pathConfigurationIdentifier: + return .acceptCustom(NumbersViewController( + url: proposal.url, + navigator: navigator + )) + + default: + return .accept + } } func visitableDidFailRequest(_ visitable: Visitable, error: Error, retryHandler: RetryBlock?) { - if let errorPresenter = visitable as? ErrorPresenter { + if let turboError = error as? TurboError, case let .http(statusCode) = turboError, statusCode == 401 { + promptForAuthentication() + } else if let errorPresenter = visitable as? ErrorPresenter { errorPresenter.presentError(error, retryHandler: retryHandler) + } else { + showErrorAlert(error: error) } } + + // MARK: - Private + + private func promptForAuthentication() { + guard let tabBarController else { return } + + // Clean up empty screen from 401 response. + tabBarController.activeNavigator.pop(animated: false) + + let authURL = Demo.current.appendingPathComponent("/session/new") + tabBarController.activeNavigator.route(authURL) + } + + private func showErrorAlert(error: Error) { + guard let tabBarController else { return } + + let alert = UIAlertController( + title: "Visit failed!", + message: error.localizedDescription, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + tabBarController.activeNavigator.rootViewController.present(alert, animated: true) + } } diff --git a/Demo/SwiftUIDemo/Bridge/FormComponent.swift b/Demo/SwiftUIDemo/Bridge/FormComponent.swift new file mode 100644 index 00000000..5a800f48 --- /dev/null +++ b/Demo/SwiftUIDemo/Bridge/FormComponent.swift @@ -0,0 +1,74 @@ +import Foundation +import HotwireNative +import UIKit + +/// Bridge component to display a submit button in the native toolbar, +/// which will submit the form on the page when tapped. +final class FormComponent: BridgeComponent { + override class var name: String { "form" } + + override func onReceive(message: Message) { + guard let event = Event(rawValue: message.event) else { + return + } + + switch event { + case .connect: + handleConnectEvent(message: message) + case .submitEnabled: + handleSubmitEnabled() + case .submitDisabled: + handleSubmitDisabled() + } + } + + // MARK: Private + + private weak var submitBarButtonItem: UIBarButtonItem? + private var viewController: UIViewController? { + delegate?.destination as? UIViewController + } + + private func handleConnectEvent(message: Message) { + guard let data: MessageData = message.data() else { return } + configureBarButton(with: data.submitTitle) + } + + private func handleSubmitEnabled() { + submitBarButtonItem?.isEnabled = true + } + + private func handleSubmitDisabled() { + submitBarButtonItem?.isEnabled = false + } + + private func configureBarButton(with title: String) { + guard let viewController else { return } + + let action = UIAction { [unowned self] _ in + reply(to: Event.connect.rawValue) + } + + let item = UIBarButtonItem(title: title, primaryAction: action) + viewController.navigationItem.rightBarButtonItem = item + submitBarButtonItem = item + } +} + +// MARK: Events + +private extension FormComponent { + enum Event: String { + case connect + case submitEnabled + case submitDisabled + } +} + +// MARK: Message data + +private extension FormComponent { + struct MessageData: Decodable { + let submitTitle: String + } +} diff --git a/Demo/SwiftUIDemo/Bridge/MenuComponent.swift b/Demo/SwiftUIDemo/Bridge/MenuComponent.swift new file mode 100644 index 00000000..e7a1e1c2 --- /dev/null +++ b/Demo/SwiftUIDemo/Bridge/MenuComponent.swift @@ -0,0 +1,95 @@ +import Foundation +import HotwireNative +import UIKit + +/// Bridge component to display a native bottom sheet menu, +/// which will send the selected index of the tapped menu item back to the web. +final class MenuComponent: BridgeComponent { + override class var name: String { "menu" } + + override func onReceive(message: Message) { + guard let event = Event(rawValue: message.event) else { + return + } + + switch event { + case .display: + handleDisplayEvent(message: message) + } + } + + // MARK: Private + + private var viewController: UIViewController? { + delegate?.destination as? UIViewController + } + + private func handleDisplayEvent(message: Message) { + guard let data: MessageData = message.data() else { return } + showAlertSheet(with: data.title, items: data.items) + } + + private func showAlertSheet(with title: String, items: [Item]) { + let alertController = UIAlertController( + title: title, + message: nil, + preferredStyle: .actionSheet + ) + + for item in items { + let action = UIAlertAction(title: item.title, style: .default) { [unowned self] _ in + onItemSelected(item: item) + } + alertController.addAction(action) + } + + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) + alertController.addAction(cancelAction) + + // Set popoverController for iPads + if let popoverController = alertController.popoverPresentationController { + if let barButtonItem = viewController?.navigationItem.rightBarButtonItem { + popoverController.barButtonItem = barButtonItem + } else { + popoverController.sourceView = viewController?.view + popoverController.sourceRect = viewController?.view.bounds ?? .zero + popoverController.permittedArrowDirections = [] + } + } + + viewController?.present(alertController, animated: true) + } + + private func onItemSelected(item: Item) { + reply( + to: Event.display.rawValue, + with: SelectionMessageData(selectedIndex: item.index) + ) + } +} + +// MARK: Events + +private extension MenuComponent { + enum Event: String { + case display + } +} + +// MARK: Message data + +private extension MenuComponent { + struct MessageData: Decodable { + let title: String + let items: [Item] + } + + struct Item: Decodable { + let title: String + let index: Int + } + + struct SelectionMessageData: Encodable { + let selectedIndex: Int + } +} diff --git a/Demo/SwiftUIDemo/Bridge/OverflowMenuComponent.swift b/Demo/SwiftUIDemo/Bridge/OverflowMenuComponent.swift new file mode 100644 index 00000000..f82dfda2 --- /dev/null +++ b/Demo/SwiftUIDemo/Bridge/OverflowMenuComponent.swift @@ -0,0 +1,65 @@ +import Foundation +import HotwireNative +import UIKit + +/// Bridge component to display a native 3-dot menu in the toolbar, +/// which will notify the web when it has been tapped. +final class OverflowMenuComponent: BridgeComponent { + override class var name: String { "overflow-menu" } + + override func onReceive(message: Message) { + guard let event = Event(rawValue: message.event) else { + return + } + + switch event { + case .connect: + handleConnectEvent(message: message) + } + } + + // MARK: Private + + private var viewController: UIViewController? { + delegate?.destination as? UIViewController + } + + private func handleConnectEvent(message: Message) { + guard let data: MessageData = message.data() else { return } + showOverflowMenuItem(data) + } + + private func showOverflowMenuItem(_ data: MessageData) { + guard let viewController else { return } + + let action = UIAction { [unowned self] _ in + overflowAction() + } + + viewController.navigationItem.rightBarButtonItem = UIBarButtonItem( + title: data.label, + image: .init(systemName: "ellipsis.circle"), + primaryAction: action + ) + } + + private func overflowAction() { + reply(to: Event.connect.rawValue) + } +} + +// MARK: Events + +private extension OverflowMenuComponent { + enum Event: String { + case connect + } +} + +// MARK: Message data + +private extension OverflowMenuComponent { + struct MessageData: Decodable { + let label: String + } +} diff --git a/Demo/SwiftUIDemo/Demo.swift b/Demo/SwiftUIDemo/Demo.swift new file mode 100644 index 00000000..1a1f8358 --- /dev/null +++ b/Demo/SwiftUIDemo/Demo.swift @@ -0,0 +1,11 @@ +import Foundation + +struct Demo { + static let remote = URL(string: "https://hotwire-native-demo.dev")! + static let local = URL(string: "http://localhost:3000")! + + /// Update this to choose which demo is run + static var current: URL { + remote + } +} diff --git a/Demo/SwiftUIDemo/NumbersViewController.swift b/Demo/SwiftUIDemo/NumbersViewController.swift new file mode 100644 index 00000000..0bd8bb74 --- /dev/null +++ b/Demo/SwiftUIDemo/NumbersViewController.swift @@ -0,0 +1,52 @@ +import HotwireNative +import UIKit + +/// A simple native table view controller to demonstrate loading non-Turbo screens +/// for a visit proposal. +final class NumbersViewController: UITableViewController, PathConfigurationIdentifiable { + static var pathConfigurationIdentifier: String { "numbers" } + + init(url: URL, navigator: NavigationHandler) { + self.url = url + self.navigator = navigator + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private var url: URL + private weak var navigator: NavigationHandler? + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Numbers" + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + } + + override func numberOfSections(in tableView: UITableView) -> Int { + 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + 100 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + + let number = indexPath.row + 1 + cell.textLabel?.text = "Row \(number)" + cell.accessoryType = .disclosureIndicator + + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let detailURL = url.appendingPathComponent("\(indexPath.row + 1)") + navigator?.route(detailURL) + tableView.deselectRow(at: indexPath, animated: true) + } +} diff --git a/Demo/SwiftUIDemo/SwiftUIDemoApp.swift b/Demo/SwiftUIDemo/SwiftUIDemoApp.swift index fbc9498c..ff477061 100644 --- a/Demo/SwiftUIDemo/SwiftUIDemoApp.swift +++ b/Demo/SwiftUIDemo/SwiftUIDemoApp.swift @@ -3,7 +3,7 @@ import SwiftUI @main struct SwiftUIDemoApp: App { - @State private var navigator: Navigator? + private let navigatorDelegate = AppNavigatorDelegate() init() { configureHotwire() @@ -12,7 +12,7 @@ struct SwiftUIDemoApp: App { var body: some Scene { WindowGroup { HotwireRootView(tabs: HotwireTab.all) - .navigatorDelegate(AppNavigatorDelegate()) + .navigatorDelegate(navigatorDelegate) .ignoresSafeArea() } } @@ -20,15 +20,24 @@ struct SwiftUIDemoApp: App { private func configureHotwire() { // Load path configuration from local file and remote server. Hotwire.loadPathConfiguration(from: [ - .file(Bundle.main.url(forResource: "path-configuration", withExtension: "json")!) + .file(Bundle.main.url(forResource: "path-configuration", withExtension: "json")!), + .server(Demo.current.appendingPathComponent("configurations/ios_v1.json")) ]) // Set an optional custom user agent prefix. Hotwire.config.applicationUserAgentPrefix = "Hotwire SwiftUI Demo;" + // Register bridge components. + Hotwire.registerBridgeComponents([ + FormComponent.self, + MenuComponent.self, + OverflowMenuComponent.self, + ]) + // Configure UI options. Hotwire.config.backButtonDisplayMode = .minimal Hotwire.config.showDoneButtonOnModals = true + Hotwire.config.animateReplaceActions = true #if DEBUG Hotwire.config.debugLoggingEnabled = true #endif diff --git a/Demo/SwiftUIDemo/Tabs.swift b/Demo/SwiftUIDemo/Tabs.swift index c1b7b31d..03bb3fb9 100644 --- a/Demo/SwiftUIDemo/Tabs.swift +++ b/Demo/SwiftUIDemo/Tabs.swift @@ -2,29 +2,53 @@ import HotwireNative import UIKit extension HotwireTab { - static let all: [HotwireTab] = [ - .home, - .components, - .resources - ] + static let all: [HotwireTab] = { + var tabs: [HotwireTab] = [ + .navigation, + .bridgeComponents, + .resources + ] - private static let baseURL = URL(string: "https://hotwire-native-demo.dev")! + if Demo.current == Demo.local { + tabs.append(.bugsAndFixes) + } - static let home = HotwireTab( - title: "Home", - image: UIImage(systemName: "house")!, - url: baseURL + return tabs + }() + + static let navigation = HotwireTab( + title: "Navigation", + image: UIImage(systemName: "arrow.left.arrow.right")!, + url: Demo.current ) - static let components = HotwireTab( - title: "Components", - image: UIImage(systemName: "square.grid.2x2")!, - url: baseURL.appendingPathComponent("components") + static let bridgeComponents = HotwireTab( + title: "Bridge Components", + image: { + if #available(iOS 17.4, *) { + return UIImage(systemName: "widget.small")! + } else { + return UIImage(systemName: "square.grid.2x2")! + } + }(), + url: Demo.current.appendingPathComponent("components") ) static let resources = HotwireTab( title: "Resources", - image: UIImage(systemName: "book.closed")!, - url: baseURL.appendingPathComponent("resources") + image: { + if #available(iOS 17.4, *) { + return UIImage(systemName: "questionmark.text.page")! + } else { + return UIImage(systemName: "book.closed")! + } + }(), + url: Demo.current.appendingPathComponent("resources") + ) + + static let bugsAndFixes = HotwireTab( + title: "Bugs & Fixes", + image: UIImage(systemName: "ladybug")!, + url: Demo.current.appendingPathComponent("bugs") ) } diff --git a/Demo/SwiftUIDemo/path-configuration.json b/Demo/SwiftUIDemo/path-configuration.json index 17b8c45c..8be5fefb 100644 --- a/Demo/SwiftUIDemo/path-configuration.json +++ b/Demo/SwiftUIDemo/path-configuration.json @@ -1,5 +1,7 @@ { - "settings": {}, + "settings": { + "enable_feature_x": true + }, "rules": [ { "patterns": [ @@ -13,6 +15,15 @@ }, "comment": "Present forms and custom modal path as modals." }, + { + "patterns": [ + "/numbers$" + ], + "properties": { + "view_controller": "numbers" + }, + "comment": "Intercept with a native view." + }, { "patterns": [ "^/$" diff --git a/Source/SwiftUI/HotwireRootView.swift b/Source/SwiftUI/HotwireRootView.swift index f6966cf1..75cda2b0 100644 --- a/Source/SwiftUI/HotwireRootView.swift +++ b/Source/SwiftUI/HotwireRootView.swift @@ -1,6 +1,14 @@ import SwiftUI import UIKit +/// A protocol for navigator delegates that need access to the tab bar controller. +/// +/// Implement this protocol in your `NavigatorDelegate` if you need to access +/// the `HotwireTabBarController` for operations like authentication redirects. +public protocol HotwireTabBarControllerDelegate: AnyObject { + var tabBarController: HotwireTabBarController? { get set } +} + /// A SwiftUI view that wraps `HotwireTabBarController` for tab-based navigation. /// /// Use this as your root view in a SwiftUI app to get full Hotwire Native functionality @@ -36,6 +44,12 @@ public struct HotwireRootView: UIViewControllerRepresentable { public func makeUIViewController(context: Context) -> HotwireTabBarController { let controller = HotwireTabBarController(navigatorDelegate: context.coordinator) context.coordinator.tabBarController = controller + + // Provide tabBarController to delegates that need it + if let tabBarDelegate = navigatorDelegate as? HotwireTabBarControllerDelegate { + tabBarDelegate.tabBarController = controller + } + controller.load(tabs) return controller } @@ -44,6 +58,11 @@ public struct HotwireRootView: UIViewControllerRepresentable { // Update delegate reference if it changed context.coordinator.customDelegate = navigatorDelegate context.coordinator.onActiveNavigatorChange = onActiveNavigatorChange + + // Ensure tabBarController reference is up to date + if let tabBarDelegate = navigatorDelegate as? HotwireTabBarControllerDelegate { + tabBarDelegate.tabBarController = controller + } } public func makeCoordinator() -> Coordinator { From 545258a55fd123dc9e66f3e1da451418d8d0a666 Mon Sep 17 00:00:00 2001 From: Joe Masilotti Date: Tue, 27 Jan 2026 14:12:58 -0800 Subject: [PATCH 3/3] NumbersViewController -> NumbersView --- .claude/settings.local.json | 3 +- Demo/Demo.xcodeproj/project.pbxproj | 11 ++-- Demo/SwiftUIDemo/AppNavigatorDelegate.swift | 9 ++-- Demo/SwiftUIDemo/NumbersView.swift | 35 +++++++++++++ Demo/SwiftUIDemo/NumbersViewController.swift | 52 ------------------- .../PathConfigurationIdentifiable.swift | 10 ++-- .../Navigator/Helpers/ProposalResult.swift | 9 +++- 7 files changed, 57 insertions(+), 72 deletions(-) create mode 100644 Demo/SwiftUIDemo/NumbersView.swift delete mode 100644 Demo/SwiftUIDemo/NumbersViewController.swift diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5f563b7d..cbdd8551 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -3,7 +3,8 @@ "allow": [ "WebFetch(domain:native.hotwired.dev)", "Bash(swift build:*)", - "Bash(xcodebuild:*)" + "Bash(xcodebuild:*)", + "WebSearch" ] } } diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index a6c2c970..1d91d037 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -19,7 +19,6 @@ 844BA7B92B9A488A008F48DB /* path-configuration.json in Resources */ = {isa = PBXBuildFile; fileRef = 844BA7AE2B9A488A008F48DB /* path-configuration.json */; }; 848E12212BD813CA009BB0A4 /* HotwireNative in Frameworks */ = {isa = PBXBuildFile; productRef = 848E12202BD813CA009BB0A4 /* HotwireNative */; }; 84A617FF2D5AA88B00694AEE /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84A617FE2D5AA88B00694AEE /* Tabs.swift */; }; - /* SwiftUI Demo Build Files */ 84SWUI0012D5BB00100000001 /* SwiftUIDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000005 /* SwiftUIDemoApp.swift */; }; 84SWUI0012D5BB00100000002 /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000006 /* Tabs.swift */; }; 84SWUI0012D5BB00100000003 /* AppNavigatorDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000007 /* AppNavigatorDelegate.swift */; }; @@ -27,7 +26,7 @@ 84SWUI0012D5BB0010000000A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 844BA7932B9A4844008F48DB /* Assets.xcassets */; }; 84SWUI0012D5BB0010000000B /* HotwireNative in Frameworks */ = {isa = PBXBuildFile; productRef = 84SWUI0012D5BB0010000000C /* HotwireNative */; }; 84SWUI0012D5BB0010000000D /* Demo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB0010000000E /* Demo.swift */; }; - 84SWUI0012D5BB0010000000F /* NumbersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000011 /* NumbersViewController.swift */; }; + 84SWUI0012D5BB0010000000F /* NumbersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000011 /* NumbersView.swift */; }; 84SWUI0012D5BB00100000012 /* FormComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000013 /* FormComponent.swift */; }; 84SWUI0012D5BB00100000014 /* MenuComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000015 /* MenuComponent.swift */; }; 84SWUI0012D5BB00100000016 /* OverflowMenuComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84SWUI0012D5BB00100000017 /* OverflowMenuComponent.swift */; }; @@ -48,14 +47,13 @@ 844BA7AE2B9A488A008F48DB /* path-configuration.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "path-configuration.json"; sourceTree = ""; }; 8458F3EF2BD8139400867B66 /* hotwire-native-ios */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "hotwire-native-ios"; path = ..; sourceTree = ""; }; 84A617FE2D5AA88B00694AEE /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = ""; }; - /* SwiftUI Demo File References */ 84SWUI0012D5BB00100000005 /* SwiftUIDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIDemoApp.swift; sourceTree = ""; }; 84SWUI0012D5BB00100000006 /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = ""; }; 84SWUI0012D5BB00100000007 /* AppNavigatorDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigatorDelegate.swift; sourceTree = ""; }; 84SWUI0012D5BB00100000008 /* path-configuration.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "path-configuration.json"; sourceTree = ""; }; 84SWUI0012D5BB00100000009 /* SwiftUIDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftUIDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 84SWUI0012D5BB0010000000E /* Demo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Demo.swift; sourceTree = ""; }; - 84SWUI0012D5BB00100000011 /* NumbersViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumbersViewController.swift; sourceTree = ""; }; + 84SWUI0012D5BB00100000011 /* NumbersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumbersView.swift; sourceTree = ""; }; 84SWUI0012D5BB00100000013 /* FormComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormComponent.swift; sourceTree = ""; }; 84SWUI0012D5BB00100000015 /* MenuComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuComponent.swift; sourceTree = ""; }; 84SWUI0012D5BB00100000017 /* OverflowMenuComponent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverflowMenuComponent.swift; sourceTree = ""; }; @@ -143,7 +141,7 @@ 84SWUI0012D5BB0010000000E /* Demo.swift */, 84SWUI0012D5BB00100000006 /* Tabs.swift */, 84SWUI0012D5BB00100000007 /* AppNavigatorDelegate.swift */, - 84SWUI0012D5BB00100000011 /* NumbersViewController.swift */, + 84SWUI0012D5BB00100000011 /* NumbersView.swift */, 84SWUI0012D5BB00100000008 /* path-configuration.json */, ); path = SwiftUIDemo; @@ -287,7 +285,7 @@ 84SWUI0012D5BB0010000000D /* Demo.swift in Sources */, 84SWUI0012D5BB00100000002 /* Tabs.swift in Sources */, 84SWUI0012D5BB00100000003 /* AppNavigatorDelegate.swift in Sources */, - 84SWUI0012D5BB0010000000F /* NumbersViewController.swift in Sources */, + 84SWUI0012D5BB0010000000F /* NumbersView.swift in Sources */, 84SWUI0012D5BB00100000012 /* FormComponent.swift in Sources */, 84SWUI0012D5BB00100000014 /* MenuComponent.swift in Sources */, 84SWUI0012D5BB00100000016 /* OverflowMenuComponent.swift in Sources */, @@ -481,7 +479,6 @@ }; name = Release; }; - /* SwiftUI Demo Build Configurations */ 84SWUI0012D5BB00100000050 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { diff --git a/Demo/SwiftUIDemo/AppNavigatorDelegate.swift b/Demo/SwiftUIDemo/AppNavigatorDelegate.swift index fc7b535d..7112dffb 100644 --- a/Demo/SwiftUIDemo/AppNavigatorDelegate.swift +++ b/Demo/SwiftUIDemo/AppNavigatorDelegate.swift @@ -1,16 +1,13 @@ import HotwireNative -import UIKit +import SwiftUI final class AppNavigatorDelegate: NavigatorDelegate, HotwireTabBarControllerDelegate { weak var tabBarController: HotwireTabBarController? func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult { switch proposal.viewController { - case NumbersViewController.pathConfigurationIdentifier: - return .acceptCustom(NumbersViewController( - url: proposal.url, - navigator: navigator - )) + case NumbersView.pathConfigurationIdentifier: + return .accept(NumbersView(url: proposal.url, navigator: navigator)) default: return .accept diff --git a/Demo/SwiftUIDemo/NumbersView.swift b/Demo/SwiftUIDemo/NumbersView.swift new file mode 100644 index 00000000..cbff944d --- /dev/null +++ b/Demo/SwiftUIDemo/NumbersView.swift @@ -0,0 +1,35 @@ +import HotwireNative +import SwiftUI + +/// A simple native view to demonstrate loading non-Turbo screens +/// for a visit proposal. +struct NumbersView: View, PathConfigurationIdentifiable { + static var pathConfigurationIdentifier: String { "numbers" } + + let url: URL + var navigator: NavigationHandler? + + var body: some View { + List(1...100, id: \.self) { number in + Button { + let detailURL = url.appendingPathComponent("\(number)") + navigator?.route(detailURL) + } label: { + HStack { + Text("Row \(number)") + .foregroundStyle(.primary) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + } + .listStyle(.plain) + .navigationTitle("Numbers") + } +} + +#Preview { + NumbersView(url: URL(string: "https://example.com")!) +} diff --git a/Demo/SwiftUIDemo/NumbersViewController.swift b/Demo/SwiftUIDemo/NumbersViewController.swift deleted file mode 100644 index 0bd8bb74..00000000 --- a/Demo/SwiftUIDemo/NumbersViewController.swift +++ /dev/null @@ -1,52 +0,0 @@ -import HotwireNative -import UIKit - -/// A simple native table view controller to demonstrate loading non-Turbo screens -/// for a visit proposal. -final class NumbersViewController: UITableViewController, PathConfigurationIdentifiable { - static var pathConfigurationIdentifier: String { "numbers" } - - init(url: URL, navigator: NavigationHandler) { - self.url = url - self.navigator = navigator - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private var url: URL - private weak var navigator: NavigationHandler? - - override func viewDidLoad() { - super.viewDidLoad() - - title = "Numbers" - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") - } - - override func numberOfSections(in tableView: UITableView) -> Int { - 1 - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - 100 - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) - - let number = indexPath.row + 1 - cell.textLabel?.text = "Row \(number)" - cell.accessoryType = .disclosureIndicator - - return cell - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let detailURL = url.appendingPathComponent("\(indexPath.row + 1)") - navigator?.route(detailURL) - tableView.deselectRow(at: indexPath, animated: true) - } -} diff --git a/Source/Turbo/Navigator/Helpers/PathConfigurationIdentifiable.swift b/Source/Turbo/Navigator/Helpers/PathConfigurationIdentifiable.swift index fc1208c7..3daf58fa 100644 --- a/Source/Turbo/Navigator/Helpers/PathConfigurationIdentifiable.swift +++ b/Source/Turbo/Navigator/Helpers/PathConfigurationIdentifiable.swift @@ -1,20 +1,22 @@ -import UIKit +import Foundation -/// As a convenience, a view controller may conform to `PathConfigurationIdentifiable`. +/// As a convenience, a view controller or SwiftUI view may conform to `PathConfigurationIdentifiable`. /// -/// Use a view controller's `pathConfigurationIdentifier` property instead of `proposal.url` when deciding how to handle a proposal. +/// Use a type's `pathConfigurationIdentifier` property instead of `proposal.url` when deciding how to handle a proposal. /// /// ```swift /// func handle(proposal: VisitProposal, from navigator: Navigator) -> ProposalResult { /// switch proposal.viewController { /// case RecipeViewController.pathConfigurationIdentifier: /// return .acceptCustom(RecipeViewController()) +/// case NumbersView.pathConfigurationIdentifier: +/// return .accept(NumbersView()) /// default: /// return .accept /// } /// } /// ``` /// - Note: See `VisitProposal.viewController` on how to use this in your configuration file. -public protocol PathConfigurationIdentifiable: UIViewController { +public protocol PathConfigurationIdentifiable { static var pathConfigurationIdentifier: String { get } } diff --git a/Source/Turbo/Navigator/Helpers/ProposalResult.swift b/Source/Turbo/Navigator/Helpers/ProposalResult.swift index 2c52c7ed..9b2a650b 100644 --- a/Source/Turbo/Navigator/Helpers/ProposalResult.swift +++ b/Source/Turbo/Navigator/Helpers/ProposalResult.swift @@ -1,13 +1,18 @@ -import UIKit +import SwiftUI /// Return from `NavigatorDelegate.handle(proposal:from:)` to route a custom controller. public enum ProposalResult: Equatable { /// Route a `VisitableViewController`. case accept - /// Route a custom `UIViewController` or subclass + /// Route a custom `UIViewController` or subclass. case acceptCustom(UIViewController) /// Do not route. Navigation is not modified. case reject + + /// Route a SwiftUI view wrapped in a `UIHostingController`. + public static func accept(_ view: V) -> ProposalResult { + .acceptCustom(UIHostingController(rootView: view)) + } }