Skip to content

Commit 8243bc4

Browse files
committed
Implement UIViewControllerRepresentable
1 parent 183aecd commit 8243bc4

File tree

5 files changed

+264
-70
lines changed

5 files changed

+264
-70
lines changed

Sources/UIKitBackend/UIKitBackend+Container.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,28 @@
11
import SwiftCrossUI
22
import UIKit
33

4-
final class ScrollWidget: ContainerWidget, UIScrollViewDelegate {
4+
final class ScrollWidget: ContainerWidget {
55
private var scrollView = UIScrollView()
66
private var childWidthConstraint: NSLayoutConstraint?
77
private var childHeightConstraint: NSLayoutConstraint?
8-
8+
99
private lazy var contentLayoutGuideConstraints: [NSLayoutConstraint] = [
1010
scrollView.contentLayoutGuide.leadingAnchor.constraint(equalTo: child.view.leadingAnchor),
1111
scrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: child.view.trailingAnchor),
1212
scrollView.contentLayoutGuide.topAnchor.constraint(equalTo: child.view.topAnchor),
13-
scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: child.view.bottomAnchor)
13+
scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: child.view.bottomAnchor),
1414
]
15-
15+
1616
override func loadView() {
1717
view = scrollView
1818
scrollView.translatesAutoresizingMaskIntoConstraints = false
19-
scrollView.delegate = self
2019
}
21-
20+
2221
override func viewWillLayoutSubviews() {
2322
NSLayoutConstraint.activate(contentLayoutGuideConstraints)
2423
super.viewWillLayoutSubviews()
2524
}
26-
25+
2726
func setScrollBars(
2827
hasVerticalScrollBar: Bool,
2928
hasHorizontalScrollBar: Bool
@@ -45,7 +44,8 @@ final class ScrollWidget: ContainerWidget, UIScrollViewDelegate {
4544
case (true, true):
4645
childWidthConstraint!.isActive = false
4746
case (false, nil):
48-
childWidthConstraint = child.view.widthAnchor.constraint(equalTo: scrollView.widthAnchor)
47+
childWidthConstraint = child.view.widthAnchor.constraint(
48+
equalTo: scrollView.widthAnchor)
4949
fallthrough
5050
case (false, false):
5151
childWidthConstraint!.isActive = true

Sources/UIKitBackend/UIKitBackend+Window.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,17 +37,17 @@ final class RootViewController: UIViewController {
3737
func setChild(to child: some WidgetProtocol) {
3838
childWidget?.removeFromParentWidget()
3939
child.removeFromParentWidget()
40-
40+
4141
let childController = child.controller
4242
view.addSubview(child.view)
4343
if let childController {
4444
addChild(childController)
4545
childController.didMove(toParent: self)
4646
}
47-
47+
4848
NSLayoutConstraint.activate([
4949
child.view.widthAnchor.constraint(equalTo: view.safeAreaLayoutGuide.widthAnchor),
50-
child.view.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor)
50+
child.view.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor),
5151
])
5252
}
5353
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import SwiftCrossUI
2+
import UIKit
3+
4+
public struct UIViewControllerRepresentableContext<Coordinator> {
5+
public let coordinator: Coordinator
6+
public internal(set) var environment: EnvironmentValues
7+
}
8+
9+
public protocol UIViewControllerRepresentable: View
10+
where Content == Never {
11+
associatedtype UIViewControllerType: UIViewController
12+
associatedtype Coordinator = Void
13+
14+
/// Create the initial UIViewController instance.
15+
func makeUIViewController(context: UIViewControllerRepresentableContext<Coordinator>)
16+
-> UIViewControllerType
17+
18+
/// Update the view with new values.
19+
/// - Parameters:
20+
/// - uiViewController: The controller to update.
21+
/// - context: The context, including the coordinator and potentially new environment
22+
/// values.
23+
/// - Note: This may be called even when `context` has not changed.
24+
func updateUIViewController(
25+
_ uiViewController: UIViewControllerType,
26+
context: UIViewControllerRepresentableContext<Coordinator>)
27+
28+
/// Make the coordinator for this controller.
29+
///
30+
/// The coordinator is used when the controller needs to communicate changes to the rest of
31+
/// the view hierarchy (i.e. through bindings).
32+
func makeCoordinator() -> Coordinator
33+
34+
/// Compute the view's size.
35+
/// - Parameters:
36+
/// - proposal: The proposed frame for the view to render in.
37+
/// - uiViewController: The controller being queried for its view's preferred size.
38+
/// - context: The context, including the coordinator and environment values.
39+
/// - Returns: Information about the view's size. The ``SwiftCrossUI/ViewSize/size``
40+
/// property is what frame the view will actually be rendered with if the current layout
41+
/// pass is not a dry run, while the other properties are used to inform the layout engine
42+
/// how big or small the view can be. The ``SwiftCrossUI/ViewSize/idealSize`` property
43+
/// should not vary with the `proposal`, and should only depend on the view's contents.
44+
/// Pass `nil` for the maximum width/height if the view has no maximum size (and therefore
45+
/// may occupy the entire screen).
46+
///
47+
/// The default implementation uses `uiViewController.view.intrinsicContentSize` and
48+
/// `uiViewController.view.systemLayoutSizeFitting(_:)` to determine the return value.
49+
func determineViewSize(
50+
for proposal: SIMD2<Int>, uiViewController: UIViewControllerType,
51+
context: UIViewControllerRepresentableContext<Coordinator>
52+
) -> ViewSize
53+
54+
/// Called to clean up the view when it's removed.
55+
/// - Parameters:
56+
/// - uiViewController: The controller being dismantled.
57+
/// - coordinator: The coordinator.
58+
///
59+
/// The default implementation does nothing.
60+
static func dismantleUIViewController(
61+
_ uiViewController: UIViewControllerType, coordinator: Coordinator)
62+
}
63+
64+
extension UIViewControllerRepresentable {
65+
public static func dismantleUIViewController(
66+
_: UIViewControllerType, coordinator _: Coordinator
67+
) {
68+
// no-op
69+
}
70+
71+
public func determineViewSize(
72+
for proposal: SIMD2<Int>, uiViewController: UIViewControllerType,
73+
context: UIViewControllerRepresentableContext<Coordinator>
74+
) -> ViewSize {
75+
defaultViewSize(proposal: proposal, view: uiViewController.view)
76+
}
77+
}
78+
79+
extension View
80+
where Self: UIViewControllerRepresentable {
81+
public var body: Never {
82+
preconditionFailure("This should never be called")
83+
}
84+
85+
public func children<Backend: AppBackend>(
86+
backend _: Backend,
87+
snapshots _: [ViewGraphSnapshotter.NodeSnapshot]?,
88+
environment _: EnvironmentValues
89+
) -> any ViewGraphNodeChildren {
90+
EmptyViewChildren()
91+
}
92+
93+
public func layoutableChildren<Backend: AppBackend>(
94+
backend _: Backend,
95+
children _: any ViewGraphNodeChildren
96+
) -> [LayoutSystem.LayoutableChild] {
97+
[]
98+
}
99+
100+
public func asWidget<Backend: AppBackend>(
101+
_: any ViewGraphNodeChildren,
102+
backend _: Backend
103+
) -> Backend.Widget {
104+
if let widget = ControllerRepresentingWidget(representable: self) as? Backend.Widget {
105+
return widget
106+
} else {
107+
fatalError("UIViewControllerRepresentable requested by \(Backend.self)")
108+
}
109+
}
110+
111+
public func update<Backend: AppBackend>(
112+
_ widget: Backend.Widget,
113+
children _: any ViewGraphNodeChildren,
114+
proposedSize: SIMD2<Int>,
115+
environment: EnvironmentValues,
116+
backend _: Backend,
117+
dryRun: Bool
118+
) -> ViewUpdateResult {
119+
let representingWidget = widget as! ControllerRepresentingWidget<Self>
120+
representingWidget.update(with: environment)
121+
122+
let size =
123+
representingWidget.representable.determineViewSize(
124+
for: proposedSize,
125+
uiViewController: representingWidget.subcontroller,
126+
context: representingWidget.context!
127+
)
128+
129+
if !dryRun {
130+
representingWidget.width = size.size.x
131+
representingWidget.height = size.size.y
132+
}
133+
134+
return ViewUpdateResult.leafView(size: size)
135+
}
136+
}
137+
138+
extension UIViewControllerRepresentable
139+
where Coordinator == Void {
140+
public func makeCoordinator() {
141+
return ()
142+
}
143+
}
144+
145+
final class ControllerRepresentingWidget<Representable: UIViewControllerRepresentable>:
146+
BaseControllerWidget
147+
{
148+
var representable: Representable
149+
var context: UIViewControllerRepresentableContext<Representable.Coordinator>?
150+
151+
lazy var subcontroller: Representable.UIViewControllerType =
152+
{
153+
let subcontroller = representable.makeUIViewController(context: context!)
154+
155+
view.addSubview(subcontroller.view)
156+
addChild(subcontroller)
157+
subcontroller.didMove(toParent: self)
158+
159+
subcontroller.view.translatesAutoresizingMaskIntoConstraints = false
160+
NSLayoutConstraint.activate([
161+
subcontroller.view.topAnchor.constraint(equalTo: view.topAnchor),
162+
subcontroller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
163+
subcontroller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
164+
subcontroller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
165+
])
166+
167+
return subcontroller
168+
}()
169+
170+
func update(with environment: EnvironmentValues) {
171+
if context == nil {
172+
context = .init(coordinator: representable.makeCoordinator(), environment: environment)
173+
} else {
174+
context!.environment = environment
175+
representable.updateUIViewController(subcontroller, context: context!)
176+
}
177+
}
178+
179+
init(representable: Representable) {
180+
self.representable = representable
181+
super.init()
182+
}
183+
184+
deinit {
185+
if let context {
186+
Representable.dismantleUIViewController(subcontroller, coordinator: context.coordinator)
187+
}
188+
}
189+
}

Sources/UIKitBackend/UIViewRepresentable.swift

Lines changed: 37 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ where Content == Never {
3131
/// Compute the view's size.
3232
/// - Parameters:
3333
/// - proposal: The proposed frame for the view to render in.
34-
/// - uiVIew: The view being queried for its preferred size.
34+
/// - uiView: The view being queried for its preferred size.
3535
/// - context: The context, including the coordinator and environment values.
3636
/// - Returns: Information about the view's size. The ``SwiftCrossUI/ViewSize/size``
3737
/// property is what frame the view will actually be rendered with if the current layout
@@ -41,7 +41,7 @@ where Content == Never {
4141
/// Pass `nil` for the maximum width/height if the view has no maximum size (and therefore
4242
/// may occupy the entire screen).
4343
///
44-
/// The default implementation uses `uiView.intrinsicContentSize` and `uiView.sizeThatFits(_:)`
44+
/// The default implementation uses `uiView.intrinsicContentSize` and `uiView.systemLayoutSizeFitting(_:)`
4545
/// to determine the return value.
4646
func determineViewSize(
4747
for proposal: SIMD2<Int>, uiView: UIViewType,
@@ -50,16 +50,43 @@ where Content == Never {
5050

5151
/// Called to clean up the view when it's removed.
5252
/// - Parameters:
53-
/// - uiVIew: The view being dismantled.
53+
/// - uiView: The view being dismantled.
5454
/// - coordinator: The coordinator.
5555
///
5656
/// This method is called after all UIKit lifecycle methods, such as
57-
/// `uiView.didMoveToSuperview()`.
57+
/// `uiView.didMoveToWindow()`.
5858
///
5959
/// The default implementation does nothing.
6060
static func dismantleUIView(_ uiView: UIViewType, coordinator: Coordinator)
6161
}
6262

63+
// Used both here and by UIViewControllerRepresentable
64+
func defaultViewSize(proposal: SIMD2<Int>, view: UIView) -> ViewSize {
65+
let intrinsicSize = view.intrinsicContentSize
66+
67+
let sizeThatFits = view.systemLayoutSizeFitting(
68+
CGSize(width: CGFloat(proposal.x), height: CGFloat(proposal.y)))
69+
70+
let minimumSize = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
71+
let maximumSize = view.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize)
72+
73+
return ViewSize(
74+
size: SIMD2(
75+
Int(sizeThatFits.width.rounded(.up)),
76+
Int(sizeThatFits.height.rounded(.up))),
77+
// The 10 here is a somewhat arbitrary constant value so that it's always the same.
78+
// See also `Color` and `Picker`, which use the same constant.
79+
idealSize: SIMD2(
80+
intrinsicSize.width < 0.0 ? 10 : Int(intrinsicSize.width.rounded(.awayFromZero)),
81+
intrinsicSize.height < 0.0 ? 10 : Int(intrinsicSize.height.rounded(.awayFromZero))
82+
),
83+
minimumWidth: Int(minimumSize.width.rounded(.towardZero)),
84+
minimumHeight: Int(minimumSize.width.rounded(.towardZero)),
85+
maximumWidth: maximumSize.width,
86+
maximumHeight: maximumSize.height
87+
)
88+
}
89+
6390
extension UIViewRepresentable {
6491
public static func dismantleUIView(_: UIViewType, coordinator _: Coordinator) {
6592
// no-op
@@ -69,33 +96,7 @@ extension UIViewRepresentable {
6996
for proposal: SIMD2<Int>, uiView: UIViewType,
7097
context _: UIViewRepresentableContext<Coordinator>
7198
) -> ViewSize {
72-
let intrinsicSize = uiView.intrinsicContentSize
73-
let sizeThatFits = uiView.sizeThatFits(
74-
CGSize(width: CGFloat(proposal.x), height: CGFloat(proposal.y)))
75-
76-
let roundedSizeThatFits = SIMD2(
77-
Int(sizeThatFits.width.rounded(.up)),
78-
Int(sizeThatFits.height.rounded(.up)))
79-
let roundedIntrinsicSize = SIMD2(
80-
Int(intrinsicSize.width.rounded(.awayFromZero)),
81-
Int(intrinsicSize.height.rounded(.awayFromZero)))
82-
83-
return ViewSize(
84-
size: SIMD2(
85-
intrinsicSize.width < 0.0 ? proposal.x : roundedSizeThatFits.x,
86-
intrinsicSize.height < 0.0 ? proposal.y : roundedSizeThatFits.y
87-
),
88-
// The 10 here is a somewhat arbitrary constant value so that it's always the same.
89-
// See also `Color` and `Picker`, which use the same constant.
90-
idealSize: SIMD2(
91-
intrinsicSize.width < 0.0 ? 10 : roundedIntrinsicSize.x,
92-
intrinsicSize.height < 0.0 ? 10 : roundedIntrinsicSize.y
93-
),
94-
minimumWidth: max(0, roundedIntrinsicSize.x),
95-
minimumHeight: max(0, roundedIntrinsicSize.x),
96-
maximumWidth: nil,
97-
maximumHeight: nil
98-
)
99+
defaultViewSize(proposal: proposal, view: uiView)
99100
}
100101
}
101102

@@ -124,7 +125,7 @@ where Self: UIViewRepresentable {
124125
_: any ViewGraphNodeChildren,
125126
backend _: Backend
126127
) -> Backend.Widget {
127-
if let widget = RepresentingWidget(representable: self) as? Backend.Widget {
128+
if let widget = ViewRepresentingWidget(representable: self) as? Backend.Widget {
128129
return widget
129130
} else {
130131
fatalError("UIViewRepresentable requested by \(Backend.self)")
@@ -139,7 +140,7 @@ where Self: UIViewRepresentable {
139140
backend _: Backend,
140141
dryRun: Bool
141142
) -> ViewUpdateResult {
142-
let representingWidget = widget as! RepresentingWidget<Self>
143+
let representingWidget = widget as! ViewRepresentingWidget<Self>
143144
representingWidget.update(with: environment)
144145

145146
let size =
@@ -150,8 +151,8 @@ where Self: UIViewRepresentable {
150151
)
151152

152153
if !dryRun {
153-
representingWidget.frame.size.width = CGFloat(size.size.x)
154-
representingWidget.frame.size.height = CGFloat(size.size.y)
154+
representingWidget.width = size.size.x
155+
representingWidget.height = size.size.y
155156
}
156157

157158
return ViewUpdateResult.leafView(size: size)
@@ -165,7 +166,7 @@ where Coordinator == Void {
165166
}
166167
}
167168

168-
final class RepresentingWidget<Representable: UIViewRepresentable>: BaseViewWidget {
169+
final class ViewRepresentingWidget<Representable: UIViewRepresentable>: BaseViewWidget {
169170
var representable: Representable
170171
var context: UIViewRepresentableContext<Representable.Coordinator>?
171172

0 commit comments

Comments
 (0)