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 @@ -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. |
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 @@ -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` | 传递给容器的额外数据字典。 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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)
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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
Comment on lines 208 to 209
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")

Expand Down Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]?

Expand Down
Loading