Skip to content

Add NSViewRepresentable for AppKit & support Swift 6 concurrency. #105

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Mar 2, 2025
22 changes: 11 additions & 11 deletions Examples/Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@
"version" : "1.6.45"
}
},
{
"identity" : "libwebp",
"kind" : "remoteSourceControl",
"location" : "https://github.com/the-swift-collective/libwebp",
"state" : {
"revision" : "5f745a17b9a5c2a4283f17c2cde4517610ab5f99",
"version" : "1.4.1"
}
},
{
"identity" : "pathkit",
"kind" : "remoteSourceControl",
Expand Down Expand Up @@ -130,8 +139,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/stackotter/swift-image-formats",
"state" : {
"revision" : "05a0169a2a5e9365a058e9aa13da5937be6e2586",
"version" : "0.3.1"
"revision" : "697bd8aa62bef7d74a1383454e3534a527769e41",
"version" : "0.3.2"
}
},
{
Expand All @@ -143,15 +152,6 @@
"version" : "0.4.0"
}
},
{
"identity" : "swift-libwebp",
"kind" : "remoteSourceControl",
"location" : "https://github.com/stackotter/swift-libwebp",
"state" : {
"revision" : "61dc3787c764022ad2f5ab4f9994a569afe86f9f",
"version" : "0.2.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
Expand Down
24 changes: 12 additions & 12 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"originHash" : "9bdf10c7a21892e3f103174fa966d5a212af3dfca1608849b243319bfbb3d005",
"originHash" : "d7660de3524ddfd94fb5ed75c380bbcda1ec9d99cc0b74dee8d2f70756063c78",
"pins" : [
{
"identity" : "jpeg",
Expand All @@ -19,6 +19,15 @@
"version" : "1.6.45"
}
},
{
"identity" : "libwebp",
"kind" : "remoteSourceControl",
"location" : "https://github.com/the-swift-collective/libwebp",
"state" : {
"revision" : "5f745a17b9a5c2a4283f17c2cde4517610ab5f99",
"version" : "1.4.1"
}
},
{
"identity" : "swift-cwinrt",
"kind" : "remoteSourceControl",
Expand Down Expand Up @@ -51,17 +60,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/stackotter/swift-image-formats",
"state" : {
"revision" : "05a0169a2a5e9365a058e9aa13da5937be6e2586",
"version" : "0.3.1"
}
},
{
"identity" : "swift-libwebp",
"kind" : "remoteSourceControl",
"location" : "https://github.com/stackotter/swift-libwebp",
"state" : {
"revision" : "61dc3787c764022ad2f5ab4f9994a569afe86f9f",
"version" : "0.2.0"
"revision" : "697bd8aa62bef7d74a1383454e3534a527769e41",
"version" : "0.3.2"
}
},
{
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ let package = Package(
),
.package(
url: "https://github.com/stackotter/swift-image-formats",
.upToNextMinor(from: "0.3.1")
.upToNextMinor(from: "0.3.2")
),
.package(
url: "https://github.com/wabiverse/swift-windowsappsdk",
Expand Down
129 changes: 129 additions & 0 deletions Sources/AppKitBackend/NSViewRepresentable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import SwiftCrossUI
import AppKit

public struct NSViewRepresentableContext<Coordinator> {
public let coordinator: Coordinator
public internal(set) var environment: EnvironmentValues
}

public protocol NSViewRepresentable: View where Content == Never {
associatedtype NSViewType: NSView
associatedtype Coordinator = Void

/// Create the initial NSView instance.
@MainActor
func makeNSView(context: NSViewRepresentableContext<Coordinator>) -> NSViewType

/// Update the view with new values.
/// - Parameters:
/// - nsView: The view to update.
/// - context: The context, including the coordinator and potentially new environment
/// values.
/// - Note: This may be called even when `context` has not changed.
@MainActor
func updateNSView(_ nsView: NSViewType, context: NSViewRepresentableContext<Coordinator>)

/// Make the coordinator for this view.
///
/// The coordinator is used when the view needs to communicate changes to the rest of
/// the view hierarchy (i.e. through bindings), and is often the view's delegate.
@MainActor
func makeCoordinator() -> Coordinator

/// Compute the view's size.
/// - Parameters:
/// - proposal: The proposed frame for the view to render in.
/// - nsVIew: The view being queried for its preferred size.
/// - context: The context, including the coordinator and environment values.
/// - Returns: Information about the view's size. The ``SwiftCrossUI/ViewSize/size``
/// property is what frame the view will actually be rendered with if the current layout
/// pass is not a dry run, while the other properties are used to inform the layout engine
/// how big or small the view can be. The ``SwiftCrossUI/ViewSize/idealSize`` property
/// should not vary with the `proposal`, and should only depend on the view's contents.
/// Pass `nil` for the maximum width/height if the view has no maximum size (and therefore
/// may occupy the entire screen).
///
/// The default implementation uses `nsView.intrinsicContentSize` and `nsView.sizeThatFits(_:)`
/// to determine the return value.
@MainActor
func determineViewSize(
for proposal: SIMD2<Int>, nsView: NSViewType,
context: NSViewRepresentableContext<Coordinator>
) -> ViewSize

/// Called to clean up the view when it's removed.
/// - Parameters:
/// - nsVIew: The view being dismantled.
/// - coordinator: The coordinator.
///
/// This method is called after all AppKit lifecycle methods, such as
/// `nsView.didMoveToSuperview()`.
///
/// The default implementation does nothing.
static func dismantleNSView(_ nsView: NSViewType, coordinator: Coordinator)
}

extension NSViewRepresentable {
public static func dismantleNSView(_: NSViewType, coordinator _: Coordinator) {
// no-op
}

public func determineViewSize(
for proposal: SIMD2<Int>, nsView: NSViewType,
context _: NSViewRepresentableContext<Coordinator>
) -> ViewSize {
let intrinsicSize = nsView.intrinsicContentSize
let sizeThatFits = nsView.fittingSize

let roundedSizeThatFits = SIMD2(
Int(sizeThatFits.width.rounded(.up)),
Int(sizeThatFits.height.rounded(.up)))
let roundedIntrinsicSize = SIMD2(
Int(intrinsicSize.width.rounded(.awayFromZero)),
Int(intrinsicSize.height.rounded(.awayFromZero)))

return ViewSize(
size: SIMD2(
intrinsicSize.width < 0.0 ? proposal.x : roundedSizeThatFits.x,
intrinsicSize.height < 0.0 ? proposal.y : roundedSizeThatFits.y
),
// The 10 here is a somewhat arbitrary constant value so that it's always the same.
// See also `Color` and `Picker`, which use the same constant.
idealSize: SIMD2(
intrinsicSize.width < 0.0 ? 10 : roundedIntrinsicSize.x,
intrinsicSize.height < 0.0 ? 10 : roundedIntrinsicSize.y
),
minimumWidth: max(0, roundedIntrinsicSize.x),
minimumHeight: max(0, roundedIntrinsicSize.x),
maximumWidth: nil,
maximumHeight: nil
)
}
}

extension View where Self: NSViewRepresentable {
public var body: Never {
preconditionFailure("This should never be called")
}

public func children<Backend: AppBackend>(
backend _: Backend,
snapshots _: [ViewGraphSnapshotter.NodeSnapshot]?,
environment _: EnvironmentValues
) -> any ViewGraphNodeChildren {
EmptyViewChildren()
}

public func layoutableChildren<Backend: AppBackend>(
backend _: Backend,
children _: any ViewGraphNodeChildren
) -> [LayoutSystem.LayoutableChild] {
[]
}
}

extension NSViewRepresentable where Coordinator == Void {
public func makeCoordinator() {
return ()
}
}
2 changes: 1 addition & 1 deletion Sources/UIKitBackend/UIKitBackend+Passive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ extension UIKitBackend {
case .leading:
.natural
case .trailing:
UIScreen.main.traitCollection.layoutDirection == .rightToLeft ? .left : .right
UITraitCollection.current.layoutDirection == .rightToLeft ? .left : .right
}
paragraphStyle.lineBreakMode = .byWordWrapping

Expand Down
2 changes: 1 addition & 1 deletion Sources/UIKitBackend/UIKitBackend.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public final class UIKitBackend: AppBackend {
design: .default
)

switch UIScreen.main.traitCollection.userInterfaceStyle {
switch UITraitCollection.current.userInterfaceStyle {
case .light:
environment.colorScheme = .light
case .dark:
Expand Down
4 changes: 4 additions & 0 deletions Sources/UIKitBackend/UIViewRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ where Content == Never {
associatedtype Coordinator = Void

/// Create the initial UIView instance.
@MainActor
func makeUIView(context: UIViewRepresentableContext<Coordinator>) -> UIViewType

/// Update the view with new values.
Expand All @@ -20,12 +21,14 @@ where Content == Never {
/// - context: The context, including the coordinator and potentially new environment
/// values.
/// - Note: This may be called even when `context` has not changed.
@MainActor
func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext<Coordinator>)

/// Make the coordinator for this view.
///
/// The coordinator is used when the view needs to communicate changes to the rest of
/// the view hierarchy (i.e. through bindings), and is often the view's delegate.
@MainActor
func makeCoordinator() -> Coordinator

/// Compute the view's size.
Expand All @@ -43,6 +46,7 @@ where Content == Never {
///
/// The default implementation uses `uiView.intrinsicContentSize` and `uiView.systemLayoutSizeFitting(_:)`
/// to determine the return value.
@MainActor
func determineViewSize(
for proposal: SIMD2<Int>, uiView: UIViewType,
context: UIViewRepresentableContext<Coordinator>
Expand Down
Loading