Skip to content

Commit 067e78e

Browse files
committed
AppKit: Implement NSViewRepresentable.
Signed-off-by: furby™ <devs@wabi.foundation>
1 parent 810cc92 commit 067e78e

File tree

3 files changed

+320
-1
lines changed

3 files changed

+320
-1
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ switch ProcessInfo.processInfo.environment["SCUI_LIBRARY_TYPE"] {
6161

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

0 commit comments

Comments
 (0)