Skip to content

Commit 08422e5

Browse files
committed
Implement UIViewRepresentable
1 parent 1e6044f commit 08422e5

File tree

1 file changed

+195
-0
lines changed

1 file changed

+195
-0
lines changed
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import SwiftCrossUI
2+
import UIKit
3+
4+
public struct UIViewRepresentableContext<Coordinator> {
5+
public let coordinator: Coordinator
6+
public internal(set) var environment: EnvironmentValues
7+
}
8+
9+
public protocol UIViewRepresentable: View
10+
where Content == Never {
11+
associatedtype UIViewType: UIView
12+
associatedtype Coordinator = Void
13+
14+
/// Create the initial UIView instance.
15+
func makeUIView(context: UIViewRepresentableContext<Coordinator>) -> UIViewType
16+
17+
/// Update the view with new values.
18+
/// - Parameters:
19+
/// - uiView: 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 updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext<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 preferred size.
32+
/// - Parameters:
33+
/// - proposal: The proposed frame for the view to render in.
34+
/// - uiVIew: The view being queried for its preferred size.
35+
/// - context: The context, including the coordinator and environment values.
36+
/// - Returns: The preferred size for the view, ideally one that fits within `proposal`,
37+
/// or `nil` to use a default size for this view.
38+
func sizeThatFits(
39+
_ proposal: CGSize, uiView: UIViewType, context: UIViewRepresentableContext<Coordinator>
40+
) -> CGSize?
41+
42+
/// Called to clean up the view when it's removed.
43+
/// - Parameters:
44+
/// - uiVIew: The view being queried for its preferred size.
45+
/// - coordinator: The coordinator.
46+
///
47+
/// The default implementation does nothing.
48+
static func dismantleUIView(_ uiView: UIViewType, coordinator: Coordinator)
49+
}
50+
51+
extension UIViewRepresentable {
52+
public static func dismantleUIView(_: UIViewType, coordinator _: Coordinator) {
53+
// no-op
54+
}
55+
56+
public func sizeThatFits(
57+
_ proposal: CGSize, uiView: UIViewType, context _: UIViewRepresentableContext<Coordinator>
58+
) -> CGSize? {
59+
// For many growable views, such as WKWebView, sizeThatFits just returns the current
60+
// size -- which is 0 x 0 on first render. So, check if the view can grow to fill
61+
// the available space first.
62+
let intrinsicContentSize = uiView.intrinsicContentSize
63+
if intrinsicContentSize.width < 0.0 || intrinsicContentSize.height < 0.0 {
64+
return nil
65+
}
66+
return uiView.sizeThatFits(proposal)
67+
}
68+
}
69+
70+
extension View
71+
where Self: UIViewRepresentable {
72+
public var body: Never {
73+
preconditionFailure("This should never be called")
74+
}
75+
76+
public func children<Backend: AppBackend>(
77+
backend _: Backend,
78+
snapshots _: [ViewGraphSnapshotter.NodeSnapshot]?,
79+
environment _: EnvironmentValues
80+
) -> any ViewGraphNodeChildren {
81+
EmptyViewChildren()
82+
}
83+
84+
public func layoutableChildren<Backend: AppBackend>(
85+
backend _: Backend,
86+
children _: any ViewGraphNodeChildren
87+
) -> [LayoutSystem.LayoutableChild] {
88+
[]
89+
}
90+
91+
public func asWidget<Backend: AppBackend>(
92+
_: any ViewGraphNodeChildren,
93+
backend _: Backend
94+
) -> Backend.Widget {
95+
if let widget = RepresentingWidget(representable: self) as? Backend.Widget {
96+
return widget
97+
} else {
98+
fatalError("UIViewRepresentable requested by \(Backend.self)")
99+
}
100+
}
101+
102+
public func update<Backend: AppBackend>(
103+
_ widget: Backend.Widget,
104+
children _: any ViewGraphNodeChildren,
105+
proposedSize: SIMD2<Int>,
106+
environment: EnvironmentValues,
107+
backend _: Backend,
108+
dryRun: Bool
109+
) -> ViewUpdateResult {
110+
let representingWidget = widget as! RepresentingWidget<Self>
111+
representingWidget.updateContext(environment: environment)
112+
113+
let preferredSize =
114+
representingWidget.representable.sizeThatFits(
115+
CGSize(width: proposedSize.x, height: proposedSize.y),
116+
uiView: representingWidget.subview,
117+
context: representingWidget.context!
118+
) ?? representingWidget.subview.intrinsicContentSize
119+
120+
let roundedSize = SIMD2(
121+
Int(preferredSize.width.rounded(.awayFromZero)),
122+
Int(preferredSize.height.rounded(.awayFromZero)))
123+
124+
// Not only does -1 x -1 mean "grow to fill", UIKit allows you to return -1 for only one axis!
125+
let size = ViewSize(
126+
size: SIMD2(
127+
roundedSize.x < 0 ? proposedSize.x : min(proposedSize.x, roundedSize.x),
128+
roundedSize.y < 0 ? proposedSize.y : min(proposedSize.y, roundedSize.y)
129+
),
130+
idealSize: SIMD2(
131+
roundedSize.x < 0 ? proposedSize.x : roundedSize.x,
132+
roundedSize.y < 0 ? proposedSize.y : roundedSize.y
133+
),
134+
minimumWidth: roundedSize.x > proposedSize.x ? roundedSize.x : 0,
135+
minimumHeight: roundedSize.y > proposedSize.y ? roundedSize.y : 0,
136+
maximumWidth: nil,
137+
maximumHeight: nil
138+
)
139+
140+
if !dryRun {
141+
representingWidget.width = size.size.x
142+
representingWidget.height = size.size.y
143+
}
144+
145+
return ViewUpdateResult.leafView(size: size)
146+
}
147+
}
148+
149+
extension UIViewRepresentable
150+
where Coordinator == Void {
151+
public func makeCoordinator() {
152+
return ()
153+
}
154+
}
155+
156+
final class RepresentingWidget<Representable: UIViewRepresentable>: BaseWidget {
157+
var representable: Representable
158+
var context: UIViewRepresentableContext<Representable.Coordinator>?
159+
160+
lazy var subview: Representable.UIViewType = {
161+
let view = representable.makeUIView(context: context!)
162+
163+
self.addSubview(view)
164+
165+
view.translatesAutoresizingMaskIntoConstraints = false
166+
NSLayoutConstraint.activate([
167+
view.topAnchor.constraint(equalTo: self.topAnchor),
168+
view.leadingAnchor.constraint(equalTo: self.leadingAnchor),
169+
view.trailingAnchor.constraint(equalTo: self.trailingAnchor),
170+
view.bottomAnchor.constraint(equalTo: self.bottomAnchor),
171+
])
172+
173+
return view
174+
}()
175+
176+
func updateContext(environment: EnvironmentValues) {
177+
if context == nil {
178+
context = .init(coordinator: representable.makeCoordinator(), environment: environment)
179+
} else {
180+
context!.environment = environment
181+
representable.updateUIView(subview, context: context!)
182+
}
183+
}
184+
185+
init(representable: Representable) {
186+
self.representable = representable
187+
super.init()
188+
}
189+
190+
deinit {
191+
if let context {
192+
Representable.dismantleUIView(subview, coordinator: context.coordinator)
193+
}
194+
}
195+
}

0 commit comments

Comments
 (0)