diff --git a/docs/en/apis/sparkling-sdk-ios.md b/docs/en/apis/sparkling-sdk-ios.md index b6ecb6a0..65e3f8a7 100644 --- a/docs/en/apis/sparkling-sdk-ios.md +++ b/docs/en/apis/sparkling-sdk-ios.md @@ -88,6 +88,7 @@ Configuration object passed to both container types. | `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. | +| `lynxViewport` | Optional fixed logical-pixel viewport for Lynx screen metrics and exact layout bounds. The outer UIKit container keeps its host-assigned frame. | | `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 2ea35362..b6708863 100644 --- a/docs/zh/apis/sparkling-sdk-ios.md +++ b/docs/zh/apis/sparkling-sdk-ios.md @@ -86,6 +86,7 @@ SPKRouter.open(withURL: scheme.absoluteString, context: nil) | `navigationBarBackHandler` | 在主线程收到导航栏返回动作,发生于 SDK 执行 pop 或 dismiss 之前。宿主消费该动作(包括明确拒绝导航)时返回 `true`;仅在需要保留 SDK 默认行为时返回 `false`。若 coordinator 持有容器栈,请使用弱引用捕获。 | | `interactivePopGestureDelegate` | 弱引用、主 actor 隔离的系统侧滑返回代理,适用于由宿主管理导航栈的场景。它可在 UIKit 开始手势前拒绝操作,并在每次已授权手势结束后恰好收到一次完成或取消回调。容器不再活跃时,Sparkling 会恢复系统手势识别器原有的启用状态。 | | `interfaceOrientationPolicy` | 全页容器的单容器方向策略:跟随系统/默认、竖屏或横屏。横屏允许左右两个方向,模态展示时优先右横屏;跟随系统时保留宿主应用原有的 UIKit 行为。 | +| `lynxViewport` | 可选的 Lynx 固定逻辑像素 viewport,用于设置 screen metrics 和 exact layout 边界;外层 UIKit 容器仍保持宿主分配的 frame。 | | `appTheme` | 主题配置。 | | `customUIElements` | 需要注册的自定义 Lynx UI 元素。 | | `extra` | 传递给容器的额外数据字典。 | diff --git a/packages/playground/ios/SparklingGoTests/Application/SPKSchemeTests.swift b/packages/playground/ios/SparklingGoTests/Application/SPKSchemeTests.swift index c312e793..c3adc554 100644 --- a/packages/playground/ios/SparklingGoTests/Application/SPKSchemeTests.swift +++ b/packages/playground/ios/SparklingGoTests/Application/SPKSchemeTests.swift @@ -267,6 +267,7 @@ struct SPKContextTests { #expect(context.failedViewBuilder == nil) #expect(context.naviBar == nil) #expect(context.interfaceOrientationPolicy == .system) + #expect(context.lynxViewport == nil) } @Test @MainActor func initializationWithBuilders() { @@ -284,6 +285,13 @@ struct SPKContextTests { #expect(context.naviBar === naviBar) } + @Test func lynxViewportRejectsInvalidDimensions() { + #expect(SPKLynxViewport(width: 0, height: 100) == nil) + #expect(SPKLynxViewport(width: 100, height: -1) == nil) + #expect(SPKLynxViewport(width: .infinity, height: 100) == nil) + #expect(SPKLynxViewport(width: 100, height: .nan) == nil) + } + @Test @MainActor func copyMethod() { let originalContext = SPKContext() // SPKContext doesn't have title property @@ -292,6 +300,7 @@ struct SPKContextTests { let interactivePopGestureDelegate = TestSchemeInteractivePopGestureDelegate() originalContext.interactivePopGestureDelegate = interactivePopGestureDelegate originalContext.interfaceOrientationPolicy = .landscape + originalContext.lynxViewport = SPKLynxViewport(width: 200, height: 300) let copiedContext = originalContext.copy() as? SPKContext @@ -301,6 +310,8 @@ struct SPKContextTests { #expect(copiedContext?.navigationBarBackHandler != nil) #expect(copiedContext?.interactivePopGestureDelegate === interactivePopGestureDelegate) #expect(copiedContext?.interfaceOrientationPolicy == .landscape) + #expect(copiedContext?.lynxViewport?.width == 200) + #expect(copiedContext?.lynxViewport?.height == 300) #expect(copiedContext !== originalContext) } @@ -326,6 +337,30 @@ struct SPKContextTests { #expect(target.interfaceOrientationPolicy == .landscape) } + @Test func mergePreservesExistingLynxViewportWithoutOverride() { + let target = SPKContext() + target.lynxViewport = SPKLynxViewport(width: 100, height: 200) + let source = SPKContext() + source.lynxViewport = SPKLynxViewport(width: 300, height: 400) + + target.merge(withContext: source, isOverride: false) + + #expect(target.lynxViewport?.width == 100) + #expect(target.lynxViewport?.height == 200) + } + + @Test func mergeOverridesLynxViewportWhenRequested() { + let target = SPKContext() + target.lynxViewport = SPKLynxViewport(width: 100, height: 200) + let source = SPKContext() + source.lynxViewport = SPKLynxViewport(width: 300, height: 400) + + target.merge(withContext: source, isOverride: true) + + #expect(target.lynxViewport?.width == 300) + #expect(target.lynxViewport?.height == 400) + } + @Test func copyPreservesLynxModuleAndCustomUIElements() { let originalContext = SPKContext() let customElement = NSObject() diff --git a/packages/playground/ios/SparklingGoTests/Application/SPKViewTests.swift b/packages/playground/ios/SparklingGoTests/Application/SPKViewTests.swift index b32c779e..63f90489 100644 --- a/packages/playground/ios/SparklingGoTests/Application/SPKViewTests.swift +++ b/packages/playground/ios/SparklingGoTests/Application/SPKViewTests.swift @@ -165,6 +165,28 @@ struct SPKViewTests { #expect(view.frame.height == 100) } + @Test func lynxViewportSurvivesOuterFrameChanges() { + let params = SPKLynxKitParams() + params.context = SPKContext() + params.widthMode = .exact + params.heightMode = .exact + params.viewport = SPKLynxViewport(width: 200, height: 300) + + let view = SPKWrapperLynxView( + withFrame: CGRect(x: 0, y: 0, width: 390, height: 844), + params: params) + + #expect(view.frame.size == CGSize(width: 390, height: 844)) + #expect(view.preferredLayoutWidth == 200) + #expect(view.preferredLayoutHeight == 300) + + view.frame = CGRect(x: 0, y: 0, width: 844, height: 390) + view.layoutSubviews() + + #expect(view.preferredLayoutWidth == 200) + #expect(view.preferredLayoutHeight == 300) + } + @Test func nilURLHandling() { let view = SPKContainerView() diff --git a/packages/playground/ios/SparklingGoTests/Service/SPKHybridContextTests.swift b/packages/playground/ios/SparklingGoTests/Service/SPKHybridContextTests.swift index f301bb50..45c8bdec 100644 --- a/packages/playground/ios/SparklingGoTests/Service/SPKHybridContextTests.swift +++ b/packages/playground/ios/SparklingGoTests/Service/SPKHybridContextTests.swift @@ -3,9 +3,10 @@ // LICENSE file in the root directory of this source tree. import Lynx -import Sparkling import Testing +@testable import Sparkling + struct SPKHybridContextTests { // MARK: - Helper Methods @@ -242,6 +243,18 @@ struct SPKHybridContextTests { #expect((context.initialData as? [String: String])?["lynx"] == "data") } + @Test func testLynxViewportMapsIntoKitParams() { + let context = SPKContext() + context.lynxViewport = SPKLynxViewport(width: 200, height: 300) + + let params = SPKLynxKitUtils.lynxKitParams(withContext: context) + + #expect(params.viewport?.width == 200) + #expect(params.viewport?.height == 300) + #expect(params.widthMode == .exact) + #expect(params.heightMode == .exact) + } + // MARK: - Edge Cases @Test func testComplexDataTypes() { 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 225eff68..58372016 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Scheme/SPKContext.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Application/Scheme/SPKContext.swift @@ -71,6 +71,26 @@ public enum SPKInterfaceOrientationPolicy: Int { case landscape } +/// A fixed logical-pixel viewport for the Lynx content inside a Sparkling container. +/// +/// The outer container remains owned by its host layout. This value controls +/// Lynx screen metrics and exact layout bounds without exposing LynxViewBuilder. +@objcMembers +public final class SPKLynxViewport: NSObject { + public let width: CGFloat + public let height: CGFloat + + @objc(initWithWidth:height:) + public init?(width: CGFloat, height: CGFloat) { + guard width.isFinite, height.isFinite, width > 0, height > 0 else { + return nil + } + self.width = width + self.height = height + super.init() + } +} + /// A context class that provides configuration and customization options for SPK containers. /// /// SPKContext extends SPKHybridContext to provide additional container-specific @@ -134,6 +154,9 @@ open class SPKContext: SPKHybridContext { /// The system policy preserves the host application's default behavior. public var interfaceOrientationPolicy: SPKInterfaceOrientationPolicy = .system + /// An optional fixed logical-pixel viewport for Lynx screen metrics and layout. + public var lynxViewport: SPKLynxViewport? + /// Indicates whether the container should use right-to-left layout. /// /// When true, the container adapts its layout for right-to-left languages. @@ -234,6 +257,12 @@ open class SPKContext: SPKHybridContext { self.interfaceOrientationPolicy = isOverride ? context.interfaceOrientationPolicy : self.interfaceOrientationPolicy + self.lynxViewport = + SPKHybridContext.merge( + withProp: context.lynxViewport, + to: self.lynxViewport, + isOverride: isOverride) as? SPKLynxViewport + self.containerLifecycleDelegate = SPKHybridContext.merge( withProp: context.containerLifecycleDelegate, diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKLynxKitUtils.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKLynxKitUtils.swift index 5e98a616..649744fd 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKLynxKitUtils.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKLynxKitUtils.swift @@ -33,6 +33,7 @@ open class SPKLynxKitUtils: SPKKitUtils { lynxKitParams.context = context lynxKitParams.widthMode = context?.widthMode != nil ? context?.widthMode?.intValue as? LynxViewSizeMode : LynxViewSizeMode.exact lynxKitParams.heightMode = context?.heightMode != nil ? context?.heightMode?.intValue as? LynxViewSizeMode : LynxViewSizeMode.exact + lynxKitParams.viewport = (context as? SPKContext)?.lynxViewport lynxKitParams.imageFetcher = context?.imageFetcher lynxKitParams.loadMeta = context?.loadData lynxKitParams.templateProvider = context?.templateProvider diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKWrapperLynxView.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKWrapperLynxView.swift index ac80ee8c..002a77d6 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKWrapperLynxView.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKWrapperLynxView.swift @@ -95,6 +95,15 @@ open class SPKWrapperLynxView: LynxView, SPKWrapperLynxViewProtocol { /// global properties that are passed to the Lynx template. private var globalProps: LynxTemplateData? + /// Keeps exact Lynx layout metrics independent from outer UIKit constraints. + private var fixedViewportSize: CGSize? + + open override var frame: CGRect { + didSet { + self.applyFixedViewportIfNeeded() + } + } + /// Read-only snapshot of the current global properties as a plain Swift /// dictionary. Debug tools (for example the GlobalProps inspector panel) /// use this to display the props passed to the underlying Lynx template @@ -152,6 +161,10 @@ open class SPKWrapperLynxView: LynxView, SPKWrapperLynxViewProtocol { var lynxConfig: LynxConfig? = nil let containerID = UUID().uuidString let namescope = params?.context?.pipeNameSpace ?? "host" + let viewportSize = params?.viewport.map { + CGSize(width: $0.width, height: $0.height) + } + let lynxFrame = viewportSize.map { CGRect(origin: .zero, size: $0) } ?? frame super.init { builder in lynxConfig = LynxConfig(provider: params?.context?.templateProvider ?? Self.globalResourceProvider) builder.config = lynxConfig @@ -180,6 +193,10 @@ open class SPKWrapperLynxView: LynxView, SPKWrapperLynxViewProtocol { builder.addLynxResourceProvider(LYNX_PROVIDER_TYPE_EXTERNAL_JS, provider: Self.globalResourceProvider) builder.setThreadStrategyForRender(.allOnUI) + if viewportSize != nil { + builder.frame = lynxFrame + builder.screenSize = lynxFrame.size + } var URL = params?.sourceUrl ?? "" builder.lynxModuleExtraData = ["URL": URL] @@ -188,11 +205,12 @@ open class SPKWrapperLynxView: LynxView, SPKWrapperLynxViewProtocol { self.containerID = containerID self.lynxConfig = lynxConfig self.params = params - self.frame = frame self.layoutWidthMode = params?.widthMode as? LynxViewSizeMode ?? .undefined self.layoutHeightMode = params?.heightMode as? LynxViewSizeMode ?? .undefined - self.preferredLayoutWidth = frame.size.width - self.preferredLayoutHeight = frame.size.height + self.fixedViewportSize = viewportSize + self.preferredLayoutWidth = lynxFrame.size.width + self.preferredLayoutHeight = lynxFrame.size.height + self.frame = frame self.setupGlobalProps() self.globalProps?.update(self.containerID, forKey: "containerID") @@ -448,29 +466,43 @@ extension SPKWrapperLynxView: SPKLynxResourceProviderDelegate { protocol SPKUIKit {} extension SPKWrapperLynxView: SPKUIKit { + private func applyFixedViewportIfNeeded() { + guard let fixedViewportSize else { + return + } + self.updateScreenMetrics( + withWidth: fixedViewportSize.width, + height: fixedViewportSize.height) + self.updateViewport( + withPreferredLayoutWidth: fixedViewportSize.width, + preferredLayoutHeight: fixedViewportSize.height) + } + open override func triggerLayout() { + let layoutSize = self.fixedViewportSize ?? self.frame.size switch self.layoutWidthMode { case .undefined, .max: - self.preferredMaxLayoutWidth = self.frame.size.width + self.preferredMaxLayoutWidth = layoutSize.width case .exact: - self.preferredLayoutWidth = self.frame.size.width + self.preferredLayoutWidth = layoutSize.width default: - self.preferredMaxLayoutWidth = self.frame.size.width + self.preferredMaxLayoutWidth = layoutSize.width } switch self.layoutHeightMode { case .undefined, .max: - self.preferredMaxLayoutHeight = self.frame.size.height + self.preferredMaxLayoutHeight = layoutSize.height case .exact: - self.preferredLayoutHeight = self.frame.size.height + self.preferredLayoutHeight = layoutSize.height default: - self.preferredMaxLayoutHeight = self.frame.size.height + self.preferredMaxLayoutHeight = layoutSize.height } super.triggerLayout() } open override func layoutSubviews() { super.layoutSubviews() + self.applyFixedViewportIfNeeded() self.triggerLayout() } } diff --git a/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKWrapperLynxViewProtocol.swift b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKWrapperLynxViewProtocol.swift index 846c6c33..b1aba721 100644 --- a/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKWrapperLynxViewProtocol.swift +++ b/packages/sparkling-sdk/ios/Sparkling/Sources/Service/LynxService/SPKWrapperLynxViewProtocol.swift @@ -35,6 +35,9 @@ public protocol SPKLynxElement { /// The height sizing mode for the Lynx view layout. public var heightMode: LynxViewSizeMode? + /// Optional fixed logical-pixel viewport for Lynx screen metrics and layout. + public var viewport: SPKLynxViewport? + /// Query parameters to be passed along with requests. public var queryItems: [String: Any]?