Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/en/apis/sparkling-sdk-ios.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions docs/zh/apis/sparkling-sdk-ios.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 传递给容器的额外数据字典。 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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

Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Loading