Skip to content

Commit 6fd98a7

Browse files
furby-tmstackotter
andcommitted
Add NSViewRepresentable for AppKit & support Swift 6 concurrency. (#105)
Co-authored-by: stackotter <stackotter@stackotter.dev>
1 parent 810cc92 commit 6fd98a7

File tree

7 files changed

+248
-26
lines changed

7 files changed

+248
-26
lines changed

Examples/Package.resolved

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@
2727
"version" : "1.6.45"
2828
}
2929
},
30+
{
31+
"identity" : "libwebp",
32+
"kind" : "remoteSourceControl",
33+
"location" : "https://github.com/the-swift-collective/libwebp",
34+
"state" : {
35+
"revision" : "5f745a17b9a5c2a4283f17c2cde4517610ab5f99",
36+
"version" : "1.4.1"
37+
}
38+
},
3039
{
3140
"identity" : "pathkit",
3241
"kind" : "remoteSourceControl",
@@ -130,8 +139,8 @@
130139
"kind" : "remoteSourceControl",
131140
"location" : "https://github.com/stackotter/swift-image-formats",
132141
"state" : {
133-
"revision" : "05a0169a2a5e9365a058e9aa13da5937be6e2586",
134-
"version" : "0.3.1"
142+
"revision" : "697bd8aa62bef7d74a1383454e3534a527769e41",
143+
"version" : "0.3.2"
135144
}
136145
},
137146
{
@@ -143,15 +152,6 @@
143152
"version" : "0.4.0"
144153
}
145154
},
146-
{
147-
"identity" : "swift-libwebp",
148-
"kind" : "remoteSourceControl",
149-
"location" : "https://github.com/stackotter/swift-libwebp",
150-
"state" : {
151-
"revision" : "61dc3787c764022ad2f5ab4f9994a569afe86f9f",
152-
"version" : "0.2.0"
153-
}
154-
},
155155
{
156156
"identity" : "swift-log",
157157
"kind" : "remoteSourceControl",

Package.resolved

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"originHash" : "9bdf10c7a21892e3f103174fa966d5a212af3dfca1608849b243319bfbb3d005",
2+
"originHash" : "d7660de3524ddfd94fb5ed75c380bbcda1ec9d99cc0b74dee8d2f70756063c78",
33
"pins" : [
44
{
55
"identity" : "jpeg",
@@ -19,6 +19,15 @@
1919
"version" : "1.6.45"
2020
}
2121
},
22+
{
23+
"identity" : "libwebp",
24+
"kind" : "remoteSourceControl",
25+
"location" : "https://github.com/the-swift-collective/libwebp",
26+
"state" : {
27+
"revision" : "5f745a17b9a5c2a4283f17c2cde4517610ab5f99",
28+
"version" : "1.4.1"
29+
}
30+
},
2231
{
2332
"identity" : "swift-cwinrt",
2433
"kind" : "remoteSourceControl",
@@ -51,17 +60,8 @@
5160
"kind" : "remoteSourceControl",
5261
"location" : "https://github.com/stackotter/swift-image-formats",
5362
"state" : {
54-
"revision" : "05a0169a2a5e9365a058e9aa13da5937be6e2586",
55-
"version" : "0.3.1"
56-
}
57-
},
58-
{
59-
"identity" : "swift-libwebp",
60-
"kind" : "remoteSourceControl",
61-
"location" : "https://github.com/stackotter/swift-libwebp",
62-
"state" : {
63-
"revision" : "61dc3787c764022ad2f5ab4f9994a569afe86f9f",
64-
"version" : "0.2.0"
63+
"revision" : "697bd8aa62bef7d74a1383454e3534a527769e41",
64+
"version" : "0.3.2"
6565
}
6666
},
6767
{

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ let package = Package(
9595
),
9696
.package(
9797
url: "https://github.com/stackotter/swift-image-formats",
98-
.upToNextMinor(from: "0.3.1")
98+
.upToNextMinor(from: "0.3.2")
9999
),
100100
.package(
101101
url: "https://github.com/wabiverse/swift-windowsappsdk",
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import AppKit
2+
import SwiftCrossUI
3+
4+
public struct NSViewRepresentableContext<Coordinator> {
5+
public let coordinator: Coordinator
6+
public internal(set) var environment: EnvironmentValues
7+
}
8+
9+
public protocol NSViewRepresentable: View where Content == Never {
10+
associatedtype NSViewType: NSView
11+
associatedtype Coordinator = Void
12+
13+
/// Create the initial NSView instance.
14+
@MainActor
15+
func makeNSView(context: NSViewRepresentableContext<Coordinator>) -> NSViewType
16+
17+
/// Update the view with new values.
18+
/// - Parameters:
19+
/// - nsView: The view to update.
20+
/// - context: The context, including the coordinator and potentially new environment
21+
/// values.
22+
/// - Note: This may be called even when `context` has not changed.
23+
@MainActor
24+
func updateNSView(_ nsView: NSViewType, context: NSViewRepresentableContext<Coordinator>)
25+
26+
/// Make the coordinator for this view.
27+
///
28+
/// The coordinator is used when the view needs to communicate changes to the rest of
29+
/// the view hierarchy (i.e. through bindings), and is often the view's delegate.
30+
@MainActor
31+
func makeCoordinator() -> Coordinator
32+
33+
/// Compute the view's size.
34+
/// - Parameters:
35+
/// - proposal: The proposed frame for the view to render in.
36+
/// - nsVIew: The view being queried for its preferred size.
37+
/// - context: The context, including the coordinator and environment values.
38+
/// - Returns: Information about the view's size. The ``SwiftCrossUI/ViewSize/size``
39+
/// property is what frame the view will actually be rendered with if the current layout
40+
/// pass is not a dry run, while the other properties are used to inform the layout engine
41+
/// how big or small the view can be. The ``SwiftCrossUI/ViewSize/idealSize`` property
42+
/// should not vary with the `proposal`, and should only depend on the view's contents.
43+
/// Pass `nil` for the maximum width/height if the view has no maximum size (and therefore
44+
/// may occupy the entire screen).
45+
///
46+
/// The default implementation uses `nsView.intrinsicContentSize` and `nsView.sizeThatFits(_:)`
47+
/// to determine the return value.
48+
func determineViewSize(
49+
for proposal: SIMD2<Int>, nsView: NSViewType,
50+
context: NSViewRepresentableContext<Coordinator>
51+
) -> ViewSize
52+
53+
/// Called to clean up the view when it's removed.
54+
/// - Parameters:
55+
/// - nsVIew: The view being dismantled.
56+
/// - coordinator: The coordinator.
57+
///
58+
/// This method is called after all AppKit lifecycle methods, such as
59+
/// `nsView.didMoveToSuperview()`.
60+
///
61+
/// The default implementation does nothing.
62+
static func dismantleNSView(_ nsView: NSViewType, coordinator: Coordinator)
63+
}
64+
65+
extension NSViewRepresentable {
66+
public static func dismantleNSView(_: NSViewType, coordinator _: Coordinator) {
67+
// no-op
68+
}
69+
70+
public func determineViewSize(
71+
for proposal: SIMD2<Int>, nsView: NSViewType,
72+
context _: NSViewRepresentableContext<Coordinator>
73+
) -> ViewSize {
74+
let intrinsicSize = nsView.intrinsicContentSize
75+
let sizeThatFits = nsView.fittingSize
76+
77+
let roundedSizeThatFits = SIMD2(
78+
Int(sizeThatFits.width.rounded(.up)),
79+
Int(sizeThatFits.height.rounded(.up)))
80+
let roundedIntrinsicSize = SIMD2(
81+
Int(intrinsicSize.width.rounded(.awayFromZero)),
82+
Int(intrinsicSize.height.rounded(.awayFromZero)))
83+
84+
return ViewSize(
85+
size: SIMD2(
86+
intrinsicSize.width < 0.0 ? proposal.x : roundedSizeThatFits.x,
87+
intrinsicSize.height < 0.0 ? proposal.y : roundedSizeThatFits.y
88+
),
89+
// The 10 here is a somewhat arbitrary constant value so that it's always the same.
90+
// See also `Color` and `Picker`, which use the same constant.
91+
idealSize: SIMD2(
92+
intrinsicSize.width < 0.0 ? 10 : roundedIntrinsicSize.x,
93+
intrinsicSize.height < 0.0 ? 10 : roundedIntrinsicSize.y
94+
),
95+
minimumWidth: max(0, roundedIntrinsicSize.x),
96+
minimumHeight: max(0, roundedIntrinsicSize.x),
97+
maximumWidth: nil,
98+
maximumHeight: nil
99+
)
100+
}
101+
}
102+
103+
extension View where Self: NSViewRepresentable {
104+
public var body: Never {
105+
preconditionFailure("This should never be called")
106+
}
107+
108+
public func children<Backend: AppBackend>(
109+
backend _: Backend,
110+
snapshots _: [ViewGraphSnapshotter.NodeSnapshot]?,
111+
environment _: EnvironmentValues
112+
) -> any ViewGraphNodeChildren {
113+
EmptyViewChildren()
114+
}
115+
116+
public func layoutableChildren<Backend: AppBackend>(
117+
backend _: Backend,
118+
children _: any ViewGraphNodeChildren
119+
) -> [LayoutSystem.LayoutableChild] {
120+
[]
121+
}
122+
123+
public func asWidget<Backend: AppBackend>(
124+
_: any ViewGraphNodeChildren,
125+
backend _: Backend
126+
) -> Backend.Widget {
127+
if let widget = RepresentingWidget(representable: self) as? Backend.Widget {
128+
return widget
129+
} else {
130+
fatalError("NSViewRepresentable requested by \(Backend.self)")
131+
}
132+
}
133+
134+
public func update<Backend: AppBackend>(
135+
_ widget: Backend.Widget,
136+
children: any ViewGraphNodeChildren,
137+
proposedSize: SIMD2<Int>,
138+
environment: EnvironmentValues,
139+
backend: Backend,
140+
dryRun: Bool
141+
) -> ViewUpdateResult {
142+
guard let backend = backend as? AppKitBackend else {
143+
fatalError("NSViewRepresentable updated by \(Backend.self)")
144+
}
145+
146+
let representingWidget = widget as! RepresentingWidget<Self>
147+
representingWidget.update(with: environment)
148+
149+
let size =
150+
representingWidget.representable.determineViewSize(
151+
for: proposedSize,
152+
nsView: representingWidget.subview,
153+
context: representingWidget.context!
154+
)
155+
156+
if !dryRun {
157+
backend.setSize(of: representingWidget, to: size.size)
158+
}
159+
160+
return ViewUpdateResult.leafView(size: size)
161+
}
162+
}
163+
164+
extension NSViewRepresentable where Coordinator == Void {
165+
public func makeCoordinator() {
166+
return ()
167+
}
168+
}
169+
170+
/// Exists to handle `deinit`, the rest of the stuff is just in here cause
171+
/// it's a convenient location.
172+
final class RepresentingWidget<Representable: NSViewRepresentable>: NSView {
173+
var representable: Representable
174+
var context: NSViewRepresentableContext<Representable.Coordinator>?
175+
176+
init(representable: Representable) {
177+
self.representable = representable
178+
super.init(frame: .zero)
179+
180+
self.translatesAutoresizingMaskIntoConstraints = false
181+
}
182+
183+
@available(*, unavailable)
184+
required init?(coder: NSCoder) {
185+
fatalError("init(coder:) is not used for this view")
186+
}
187+
188+
lazy var subview: Representable.NSViewType = {
189+
let view = representable.makeNSView(context: context!)
190+
191+
self.addSubview(view)
192+
193+
view.translatesAutoresizingMaskIntoConstraints = false
194+
NSLayoutConstraint.activate([
195+
view.topAnchor.constraint(equalTo: self.topAnchor),
196+
view.leadingAnchor.constraint(equalTo: self.leadingAnchor),
197+
view.trailingAnchor.constraint(equalTo: self.trailingAnchor),
198+
view.bottomAnchor.constraint(equalTo: self.bottomAnchor),
199+
])
200+
201+
return view
202+
}()
203+
204+
func update(with environment: EnvironmentValues) {
205+
if context == nil {
206+
context = .init(coordinator: representable.makeCoordinator(), environment: environment)
207+
} else {
208+
context!.environment = environment
209+
representable.updateNSView(subview, context: context!)
210+
}
211+
}
212+
213+
deinit {
214+
if let context {
215+
Representable.dismantleNSView(subview, coordinator: context.coordinator)
216+
}
217+
}
218+
}

Sources/UIKitBackend/UIKitBackend+Passive.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ extension UIKitBackend {
1515
case .leading:
1616
.natural
1717
case .trailing:
18-
UIScreen.main.traitCollection.layoutDirection == .rightToLeft ? .left : .right
18+
UITraitCollection.current.layoutDirection == .rightToLeft ? .left : .right
1919
}
2020
paragraphStyle.lineBreakMode = .byWordWrapping
2121

Sources/UIKitBackend/UIKitBackend.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public final class UIKitBackend: AppBackend {
7171
design: .default
7272
)
7373

74-
switch UIScreen.main.traitCollection.userInterfaceStyle {
74+
switch UITraitCollection.current.userInterfaceStyle {
7575
case .light:
7676
environment.colorScheme = .light
7777
case .dark:

Sources/UIKitBackend/UIViewRepresentable.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ where Content == Never {
1212
associatedtype Coordinator = Void
1313

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

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

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

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

0 commit comments

Comments
 (0)