From 086d546228738b6457787d854eb887d1f67501a7 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:56:58 +0800 Subject: [PATCH] [Feature][iOS] Add per-container orientation policy Summary of change: - Add an Objective-C-compatible system, portrait, and landscape policy to SPKContext and preserve it through context copy and merge. - Report and apply the configured policy with public UIKit scene geometry APIs. - Forward the top Sparkling container's orientation through its navigation controller so ordinary UINavigationController hosts rotate correctly. - Add focused policy, copy, merge, scheduling, and navigation forwarding tests plus a debug-only playground launch hook. TEST: scripts/coverage-ios.sh on iOS 26.5 Simulator; 331 tests in 20 suites passed TEST: iOS 26.2 Simulator EABC0BC7-12FE-4940-969C-FF3D6B9135F5; a landscape cold launch completed the portrait-to-landscapeRight scene transaction, and a portrait control launch remained portrait without a landscape transition TEST: git diff --check Co-authored-by: TRAE CLI --- docs/en/apis/sparkling-sdk-ios.md | 1 + docs/zh/apis/sparkling-sdk-ios.md | 1 + .../SparklingGo/DebugDevURLSupport.swift | 15 +++ .../Application/SPKRouterTests.swift | 124 ++++++++++++++++++ .../Application/SPKSchemeTests.swift | 25 ++++ .../Container/SPKViewController.swift | 81 ++++++++++++ .../Application/Scheme/SPKContext.swift | 19 +++ 7 files changed, 266 insertions(+) diff --git a/docs/en/apis/sparkling-sdk-ios.md b/docs/en/apis/sparkling-sdk-ios.md index 7cd58200..b6ecb6a0 100644 --- a/docs/en/apis/sparkling-sdk-ios.md +++ b/docs/en/apis/sparkling-sdk-ios.md @@ -87,6 +87,7 @@ Configuration object passed to both container types. | `naviBar` | Custom navigation bar (full-page containers only). | | `navigationBarBackHandler` | Called on the main thread for a navigation-bar back action before the SDK pops or dismisses. Return `true` when the host consumes the action, including an intentional rejection; return `false` only to keep the SDK default. Weakly capture coordinators that retain the container stack. | | `interactivePopGestureDelegate` | Weak, main-actor delegate for host-owned system interactive pop. It can reject the gesture before UIKit starts and receives exactly one completion or cancellation callback after every authorized gesture. Sparkling temporarily enables the system recognizer and restores its previous state when the container is no longer active. | +| `interfaceOrientationPolicy` | Per-container orientation policy for full-page containers: system/default, portrait, or landscape. Landscape supports both landscape orientations and prefers landscape-right for modal presentation. The system policy preserves the host application's UIKit behavior. | | `appTheme` | Theme configuration. | | `customUIElements` | Custom Lynx UI elements to register. | | `extra` | Dictionary of additional data passed to the container. | diff --git a/docs/zh/apis/sparkling-sdk-ios.md b/docs/zh/apis/sparkling-sdk-ios.md index 24f6fecb..2ea35362 100644 --- a/docs/zh/apis/sparkling-sdk-ios.md +++ b/docs/zh/apis/sparkling-sdk-ios.md @@ -85,6 +85,7 @@ SPKRouter.open(withURL: scheme.absoluteString, context: nil) | `naviBar` | 自定义导航栏(仅全页容器)。 | | `navigationBarBackHandler` | 在主线程收到导航栏返回动作,发生于 SDK 执行 pop 或 dismiss 之前。宿主消费该动作(包括明确拒绝导航)时返回 `true`;仅在需要保留 SDK 默认行为时返回 `false`。若 coordinator 持有容器栈,请使用弱引用捕获。 | | `interactivePopGestureDelegate` | 弱引用、主 actor 隔离的系统侧滑返回代理,适用于由宿主管理导航栈的场景。它可在 UIKit 开始手势前拒绝操作,并在每次已授权手势结束后恰好收到一次完成或取消回调。容器不再活跃时,Sparkling 会恢复系统手势识别器原有的启用状态。 | +| `interfaceOrientationPolicy` | 全页容器的单容器方向策略:跟随系统/默认、竖屏或横屏。横屏允许左右两个方向,模态展示时优先右横屏;跟随系统时保留宿主应用原有的 UIKit 行为。 | | `appTheme` | 主题配置。 | | `customUIElements` | 需要注册的自定义 Lynx UI 元素。 | | `extra` | 传递给容器的额外数据字典。 | diff --git a/packages/playground/ios/SparklingGo/SparklingGo/DebugDevURLSupport.swift b/packages/playground/ios/SparklingGo/SparklingGo/DebugDevURLSupport.swift index 8edabefe..d01dc1a6 100644 --- a/packages/playground/ios/SparklingGo/SparklingGo/DebugDevURLSupport.swift +++ b/packages/playground/ios/SparklingGo/SparklingGo/DebugDevURLSupport.swift @@ -57,9 +57,24 @@ enum DebugDevURLSupport { static func makeContext(delegate: SPKContainerLifecycleProtocol? = nil) -> SPKContext { let context = SPKContext() context.containerLifecycleDelegate = delegate + #if DEBUG + context.interfaceOrientationPolicy = interfaceOrientationPolicy( + from: ProcessInfo.processInfo.environment["SPARKLING_INTERFACE_ORIENTATION"]) + #endif return context } + static func interfaceOrientationPolicy(from value: String?) -> SPKInterfaceOrientationPolicy { + switch value?.lowercased() { + case "portrait": + return .portrait + case "landscape": + return .landscape + default: + return .system + } + } + static func storedDevURL(fallback: String) -> String { #if canImport(Sparkling_DebugTool) SparklingDebugTool.devURL(fallback: fallback) diff --git a/packages/playground/ios/SparklingGoTests/Application/SPKRouterTests.swift b/packages/playground/ios/SparklingGoTests/Application/SPKRouterTests.swift index 4a1b4e90..5777209e 100644 --- a/packages/playground/ios/SparklingGoTests/Application/SPKRouterTests.swift +++ b/packages/playground/ios/SparklingGoTests/Application/SPKRouterTests.swift @@ -43,6 +43,130 @@ struct SPKRouterTests { #expect(viewController != nil) } + @Test func systemOrientationPolicyUsesUIKitDefaults() { + let context = SPKContext() + let viewController = SPKViewController( + withURL: nil, + config: SPKSchemeParam(), + context: context, + frame: .zero) + let defaultViewController = UIViewController() + + #expect(viewController.supportedInterfaceOrientations == defaultViewController.supportedInterfaceOrientations) + #expect( + viewController.preferredInterfaceOrientationForPresentation + == defaultViewController.preferredInterfaceOrientationForPresentation) + } + + @Test func portraitOrientationPolicyRestrictsContainer() { + let context = SPKContext() + context.interfaceOrientationPolicy = .portrait + let viewController = SPKViewController( + withURL: nil, + config: SPKSchemeParam(), + context: context, + frame: .zero) + + #expect(viewController.supportedInterfaceOrientations == .portrait) + #expect(viewController.preferredInterfaceOrientationForPresentation == .portrait) + } + + @Test func landscapeOrientationPolicyRestrictsContainer() { + let context = SPKContext() + context.interfaceOrientationPolicy = .landscape + let viewController = SPKViewController( + withURL: nil, + config: SPKSchemeParam(), + context: context, + frame: .zero) + + #expect(viewController.supportedInterfaceOrientations == .landscape) + #expect(viewController.preferredInterfaceOrientationForPresentation == .landscapeRight) + } + + @Test func navigationControllerUsesTopSparklingOrientationPolicy() { + for (policy, expectedMask, expectedPreferred) in [ + ( + SPKInterfaceOrientationPolicy.portrait, + UIInterfaceOrientationMask.portrait, + UIInterfaceOrientation.portrait + ), + ( + SPKInterfaceOrientationPolicy.landscape, + UIInterfaceOrientationMask.landscape, + UIInterfaceOrientation.landscapeRight + ) + ] { + let context = SPKContext() + context.interfaceOrientationPolicy = policy + let viewController = SPKViewController( + withURL: nil, + config: SPKSchemeParam(), + context: context, + frame: .zero) + let navigationController = UINavigationController(rootViewController: viewController) + + #expect( + viewController.navigationControllerSupportedInterfaceOrientations( + navigationController) == expectedMask) + #expect( + viewController.navigationControllerPreferredInterfaceOrientationForPresentation( + navigationController) == expectedPreferred) + } + } + + @Test func viewAppearanceSchedulesPortraitGeometryUpdate() { + let context = SPKContext() + context.interfaceOrientationPolicy = .portrait + let viewController = SPKViewController( + withURL: nil, + config: SPKSchemeParam(), + context: context, + frame: .zero) + var requestedOrientations: [UIInterfaceOrientationMask] = [] + viewController.interfaceOrientationUpdateHandler = { + requestedOrientations.append($0) + } + + viewController.viewDidAppear(false) + + #expect(requestedOrientations == [.portrait]) + } + + @Test func viewAppearanceSchedulesLandscapeGeometryUpdate() { + let context = SPKContext() + context.interfaceOrientationPolicy = .landscape + let viewController = SPKViewController( + withURL: nil, + config: SPKSchemeParam(), + context: context, + frame: .zero) + var requestedOrientations: [UIInterfaceOrientationMask] = [] + viewController.interfaceOrientationUpdateHandler = { + requestedOrientations.append($0) + } + + viewController.viewDidAppear(false) + + #expect(requestedOrientations == [.landscape]) + } + + @Test func viewAppearanceDoesNotRequestSystemGeometryUpdate() { + let viewController = SPKViewController( + withURL: nil, + config: SPKSchemeParam(), + context: SPKContext(), + frame: .zero) + var requestedOrientations: [UIInterfaceOrientationMask] = [] + viewController.interfaceOrientationUpdateHandler = { + requestedOrientations.append($0) + } + + viewController.viewDidAppear(false) + + #expect(requestedOrientations.isEmpty) + } + @Test func createWithDifferentSchemes() { let hybridURL = "hybrid://example.com/path" let httpURL = "http://example.com/path" diff --git a/packages/playground/ios/SparklingGoTests/Application/SPKSchemeTests.swift b/packages/playground/ios/SparklingGoTests/Application/SPKSchemeTests.swift index 3c06e064..c312e793 100644 --- a/packages/playground/ios/SparklingGoTests/Application/SPKSchemeTests.swift +++ b/packages/playground/ios/SparklingGoTests/Application/SPKSchemeTests.swift @@ -266,6 +266,7 @@ struct SPKContextTests { #expect(context.loadingViewBuilder == nil) #expect(context.failedViewBuilder == nil) #expect(context.naviBar == nil) + #expect(context.interfaceOrientationPolicy == .system) } @Test @MainActor func initializationWithBuilders() { @@ -290,6 +291,7 @@ struct SPKContextTests { originalContext.navigationBarBackHandler = { _ in true } let interactivePopGestureDelegate = TestSchemeInteractivePopGestureDelegate() originalContext.interactivePopGestureDelegate = interactivePopGestureDelegate + originalContext.interfaceOrientationPolicy = .landscape let copiedContext = originalContext.copy() as? SPKContext @@ -298,9 +300,32 @@ struct SPKContextTests { #expect(copiedContext?.originURL == "hybrid://lynxview_page?bundle=.%2Fmain.lynx.bundle") #expect(copiedContext?.navigationBarBackHandler != nil) #expect(copiedContext?.interactivePopGestureDelegate === interactivePopGestureDelegate) + #expect(copiedContext?.interfaceOrientationPolicy == .landscape) #expect(copiedContext !== originalContext) } + @Test func mergePreservesExistingOrientationPolicyWithoutOverride() { + let target = SPKContext() + target.interfaceOrientationPolicy = .portrait + let source = SPKContext() + source.interfaceOrientationPolicy = .landscape + + target.merge(withContext: source, isOverride: false) + + #expect(target.interfaceOrientationPolicy == .portrait) + } + + @Test func mergeOverridesOrientationPolicyWhenRequested() { + let target = SPKContext() + target.interfaceOrientationPolicy = .portrait + let source = SPKContext() + source.interfaceOrientationPolicy = .landscape + + target.merge(withContext: source, isOverride: true) + + #expect(target.interfaceOrientationPolicy == .landscape) + } + @Test func copyPreservesLynxModuleAndCustomUIElements() { let originalContext = SPKContext() let customElement = NSObject() diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/SPKViewController.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/SPKViewController.swift index 0a3f65f8..1e968f2f 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/SPKViewController.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Container/SPKViewController.swift @@ -130,6 +130,34 @@ open class SPKViewController: UIViewController, SPKContainerProtocol { return true } + /// Returns the orientation mask configured for this container. + public override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + switch (self.context as? SPKContext)?.interfaceOrientationPolicy { + case .portrait: + return .portrait + case .landscape: + return .landscape + case .system, .none: + return super.supportedInterfaceOrientations + } + } + + /// Returns the preferred initial orientation for modal presentation. + public override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { + switch (self.context as? SPKContext)?.interfaceOrientationPolicy { + case .portrait: + return .portrait + case .landscape: + return .landscapeRight + case .system, .none: + return super.preferredInterfaceOrientationForPresentation + } + } + + /// Test seam for observing orientation update scheduling without mutating a scene. + @nonobjc + var interfaceOrientationUpdateHandler: ((UIInterfaceOrientationMask) -> Void)? + /// The original URL that was used to load the content. /// /// This property stores the initial URL for reference and potential reloading scenarios. @@ -364,6 +392,7 @@ open class SPKViewController: UIViewController, SPKContainerProtocol { /// - Parameter animated: Whether the appearance was animated public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + self.applyInterfaceOrientationPolicy() self.oldDelegate = self.navigationController?.interactivePopGestureRecognizer?.delegate self.originControllerPopGestureRecongnizerEnabled = self.navigationController?.interactivePopGestureRecognizer?.isEnabled ?? self.originControllerPopGestureRecongnizerEnabled @@ -381,6 +410,45 @@ open class SPKViewController: UIViewController, SPKContainerProtocol { self.containerLifecycleDelegate?.containerViewDidAppear?(self) } + /// Applies an explicit per-container orientation policy to the active window scene. + @nonobjc + func applyInterfaceOrientationPolicy() { + guard let interfaceOrientations = self.explicitInterfaceOrientationMask else { + return + } + + if #available(iOS 16.0, *) { + self.setNeedsUpdateOfSupportedInterfaceOrientations() + self.navigationController?.setNeedsUpdateOfSupportedInterfaceOrientations() + if let interfaceOrientationUpdateHandler = self.interfaceOrientationUpdateHandler { + interfaceOrientationUpdateHandler(interfaceOrientations) + return + } + guard let windowScene = self.viewIfLoaded?.window?.windowScene else { + return + } + windowScene.requestGeometryUpdate( + .iOS(interfaceOrientations: interfaceOrientations)) + } else { + if let interfaceOrientationUpdateHandler = self.interfaceOrientationUpdateHandler { + interfaceOrientationUpdateHandler(interfaceOrientations) + } else { + UIViewController.attemptRotationToDeviceOrientation() + } + } + } + + private var explicitInterfaceOrientationMask: UIInterfaceOrientationMask? { + switch (self.context as? SPKContext)?.interfaceOrientationPolicy { + case .portrait: + return .portrait + case .landscape: + return .landscape + case .system, .none: + return nil + } + } + /// Called when the view is about to disappear. /// /// This method handles the transition coordination for swipe gestures, @@ -1050,7 +1118,20 @@ extension SPKViewController: SPKContainerLifecycleProtocol { } extension SPKViewController: UINavigationControllerDelegate { + public func navigationControllerSupportedInterfaceOrientations( + _ navigationController: UINavigationController + ) -> UIInterfaceOrientationMask { + return (navigationController.topViewController as? SPKViewController)? + .supportedInterfaceOrientations ?? navigationController.supportedInterfaceOrientations + } + public func navigationControllerPreferredInterfaceOrientationForPresentation( + _ navigationController: UINavigationController + ) -> UIInterfaceOrientation { + return (navigationController.topViewController as? SPKViewController)? + .preferredInterfaceOrientationForPresentation + ?? navigationController.preferredInterfaceOrientationForPresentation + } } extension SPKViewController: UIGestureRecognizerDelegate { diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Scheme/SPKContext.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Scheme/SPKContext.swift index e91665d4..225eff68 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Scheme/SPKContext.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Scheme/SPKContext.swift @@ -60,6 +60,17 @@ public enum SPKAppTheme: Int { case SPKAppThemeDark } +/// Defines the interface orientation policy for a full-page Sparkling container. +@objc +public enum SPKInterfaceOrientationPolicy: Int { + /// Uses the host application's default orientation behavior. + case system + /// Restricts the container to portrait orientation. + case portrait + /// Restricts the container to landscape orientations. + case landscape +} + /// A context class that provides configuration and customization options for SPK containers. /// /// SPKContext extends SPKHybridContext to provide additional container-specific @@ -118,6 +129,11 @@ open class SPKContext: SPKHybridContext { /// and its content. Defaults to SPKAppThemeDefault. public var appTheme: SPKAppTheme = .SPKAppThemeDefault + /// The interface orientation policy for a full-page container. + /// + /// The system policy preserves the host application's default behavior. + public var interfaceOrientationPolicy: SPKInterfaceOrientationPolicy = .system + /// Indicates whether the container should use right-to-left layout. /// /// When true, the container adapts its layout for right-to-left languages. @@ -215,6 +231,9 @@ open class SPKContext: SPKHybridContext { self.appTheme = isOverride ? context.appTheme : self.appTheme + self.interfaceOrientationPolicy = + isOverride ? context.interfaceOrientationPolicy : self.interfaceOrientationPolicy + self.containerLifecycleDelegate = SPKHybridContext.merge( withProp: context.containerLifecycleDelegate,