Skip to content

Implement NSViewRepresentable (cleaned up version of #105) #109

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 1 commit into from
Mar 2, 2025
Merged
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
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
218 changes: 218 additions & 0 deletions Sources/AppKitBackend/NSViewRepresentable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import AppKit
import SwiftCrossUI

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.
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] {
[]
}

public func asWidget<Backend: AppBackend>(
_: any ViewGraphNodeChildren,
backend _: Backend
) -> Backend.Widget {
if let widget = RepresentingWidget(representable: self) as? Backend.Widget {
return widget
} else {
fatalError("NSViewRepresentable requested by \(Backend.self)")
}
}

public func update<Backend: AppBackend>(
_ widget: Backend.Widget,
children: any ViewGraphNodeChildren,
proposedSize: SIMD2<Int>,
environment: EnvironmentValues,
backend: Backend,
dryRun: Bool
) -> ViewUpdateResult {
guard let backend = backend as? AppKitBackend else {
fatalError("NSViewRepresentable updated by \(Backend.self)")
}

let representingWidget = widget as! RepresentingWidget<Self>
representingWidget.update(with: environment)

let size =
representingWidget.representable.determineViewSize(
for: proposedSize,
nsView: representingWidget.subview,
context: representingWidget.context!
)

if !dryRun {
backend.setSize(of: representingWidget, to: size.size)
}

return ViewUpdateResult.leafView(size: size)
}
}

extension NSViewRepresentable where Coordinator == Void {
public func makeCoordinator() {
return ()
}
}

/// Exists to handle `deinit`, the rest of the stuff is just in here cause
/// it's a convenient location.
final class RepresentingWidget<Representable: NSViewRepresentable>: NSView {
var representable: Representable
var context: NSViewRepresentableContext<Representable.Coordinator>?

init(representable: Representable) {
self.representable = representable
super.init(frame: .zero)

self.translatesAutoresizingMaskIntoConstraints = false
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not used for this view")
}

lazy var subview: Representable.NSViewType = {
let view = representable.makeNSView(context: context!)

self.addSubview(view)

view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
view.topAnchor.constraint(equalTo: self.topAnchor),
view.leadingAnchor.constraint(equalTo: self.leadingAnchor),
view.trailingAnchor.constraint(equalTo: self.trailingAnchor),
view.bottomAnchor.constraint(equalTo: self.bottomAnchor),
])

return view
}()

func update(with environment: EnvironmentValues) {
if context == nil {
context = .init(coordinator: representable.makeCoordinator(), environment: environment)
} else {
context!.environment = environment
representable.updateNSView(subview, context: context!)
}
}

deinit {
if let context {
Representable.dismantleNSView(subview, coordinator: context.coordinator)
}
}
}
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