Skip to content

Commit 810cc92

Browse files
authored
[UIKitBackend] UIViewControllerRepresentable and iPad-only split views (#104)
* Initial work supporting controllers * Implement UIViewControllerRepresentable * iPad-only split views * final fixes * Explain ContainerWidget * oops, forgot that * Address initial PR comments
1 parent fc6be77 commit 810cc92

9 files changed

+719
-194
lines changed

Sources/UIKitBackend/BaseWidget.swift

Lines changed: 0 additions & 117 deletions
This file was deleted.

Sources/UIKitBackend/UIKitBackend+Container.swift

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,26 @@
11
import SwiftCrossUI
22
import UIKit
33

4-
final class ScrollWidget: WrapperWidget<UIScrollView> {
4+
final class ScrollWidget: ContainerWidget {
5+
private var scrollView = UIScrollView()
56
private var childWidthConstraint: NSLayoutConstraint?
67
private var childHeightConstraint: NSLayoutConstraint?
78

8-
private let innerChild: BaseWidget
9+
private lazy var contentLayoutGuideConstraints: [NSLayoutConstraint] = [
10+
scrollView.contentLayoutGuide.leadingAnchor.constraint(equalTo: child.view.leadingAnchor),
11+
scrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: child.view.trailingAnchor),
12+
scrollView.contentLayoutGuide.topAnchor.constraint(equalTo: child.view.topAnchor),
13+
scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: child.view.bottomAnchor),
14+
]
915

10-
init(child innerChild: BaseWidget) {
11-
self.innerChild = innerChild
12-
super.init(child: UIScrollView())
13-
14-
child.addSubview(innerChild)
16+
override func loadView() {
17+
view = scrollView
18+
scrollView.translatesAutoresizingMaskIntoConstraints = false
19+
}
1520

16-
NSLayoutConstraint.activate([
17-
innerChild.topAnchor.constraint(equalTo: child.contentLayoutGuide.topAnchor),
18-
innerChild.bottomAnchor.constraint(equalTo: child.contentLayoutGuide.bottomAnchor),
19-
innerChild.leftAnchor.constraint(equalTo: child.contentLayoutGuide.leftAnchor),
20-
innerChild.rightAnchor.constraint(equalTo: child.contentLayoutGuide.rightAnchor),
21-
])
21+
override func updateViewConstraints() {
22+
NSLayoutConstraint.activate(contentLayoutGuideConstraints)
23+
super.updateViewConstraints()
2224
}
2325

2426
func setScrollBars(
@@ -29,8 +31,8 @@ final class ScrollWidget: WrapperWidget<UIScrollView> {
2931
case (true, true):
3032
childHeightConstraint!.isActive = false
3133
case (false, nil):
32-
childHeightConstraint = innerChild.heightAnchor.constraint(
33-
equalTo: child.heightAnchor)
34+
childHeightConstraint = child.view.heightAnchor.constraint(
35+
equalTo: scrollView.heightAnchor)
3436
fallthrough
3537
case (false, false):
3638
childHeightConstraint!.isActive = true
@@ -42,68 +44,68 @@ final class ScrollWidget: WrapperWidget<UIScrollView> {
4244
case (true, true):
4345
childWidthConstraint!.isActive = false
4446
case (false, nil):
45-
childWidthConstraint = innerChild.widthAnchor.constraint(equalTo: child.widthAnchor)
47+
childWidthConstraint = child.view.widthAnchor.constraint(
48+
equalTo: scrollView.widthAnchor)
4649
fallthrough
4750
case (false, false):
4851
childWidthConstraint!.isActive = true
4952
default:
5053
break
5154
}
5255

53-
child.showsVerticalScrollIndicator = hasVerticalScrollBar
54-
child.showsHorizontalScrollIndicator = hasHorizontalScrollBar
56+
scrollView.showsVerticalScrollIndicator = hasVerticalScrollBar
57+
scrollView.showsHorizontalScrollIndicator = hasHorizontalScrollBar
5558
}
5659
}
5760

5861
extension UIKitBackend {
5962
public func createContainer() -> Widget {
60-
BaseWidget()
63+
BaseViewWidget()
6164
}
6265

6366
public func removeAllChildren(of container: Widget) {
64-
container.subviews.forEach { $0.removeFromSuperview() }
67+
container.childWidgets.forEach { $0.removeFromParentWidget() }
6568
}
6669

6770
public func addChild(_ child: Widget, to container: Widget) {
68-
container.addSubview(child)
71+
container.add(childWidget: child)
6972
}
7073

7174
public func setPosition(
7275
ofChildAt index: Int,
7376
in container: Widget,
7477
to position: SIMD2<Int>
7578
) {
76-
guard index < container.subviews.count else {
79+
guard index < container.childWidgets.count else {
7780
assertionFailure("Attempting to set position of nonexistent subview")
7881
return
7982
}
8083

81-
let child = container.subviews[index] as! BaseWidget
84+
let child = container.childWidgets[index]
8285
child.x = position.x
8386
child.y = position.y
8487
}
8588

8689
public func removeChild(_ child: Widget, from container: Widget) {
87-
assert(child.isDescendant(of: container))
88-
child.removeFromSuperview()
90+
assert(child.view.isDescendant(of: container.view))
91+
child.removeFromParentWidget()
8992
}
9093

9194
public func createColorableRectangle() -> Widget {
92-
BaseWidget()
95+
BaseViewWidget()
9396
}
9497

9598
public func setColor(ofColorableRectangle widget: Widget, to color: Color) {
96-
widget.backgroundColor = color.uiColor
99+
widget.view.backgroundColor = color.uiColor
97100
}
98101

99102
public func setCornerRadius(of widget: Widget, to radius: Int) {
100-
widget.layer.cornerRadius = CGFloat(radius)
101-
widget.layer.masksToBounds = true
102-
widget.setNeedsLayout()
103+
widget.view.layer.cornerRadius = CGFloat(radius)
104+
widget.view.layer.masksToBounds = true
103105
}
104106

105107
public func naturalSize(of widget: Widget) -> SIMD2<Int> {
106-
let size = widget.intrinsicContentSize
108+
let size = widget.view.intrinsicContentSize
107109
return SIMD2(
108110
Int(size.width.rounded(.awayFromZero)),
109111
Int(size.height.rounded(.awayFromZero))

Sources/UIKitBackend/UIKitBackend+Control.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,16 +103,16 @@ final class TextFieldWidget: WrapperWidget<UITextField>, UITextFieldDelegate {
103103
}
104104
#endif
105105

106-
final class ClickableWidget: WrapperWidget<BaseWidget> {
106+
final class ClickableWidget: ContainerWidget {
107107
private var gestureRecognizer: UITapGestureRecognizer!
108108
var onClick: (() -> Void)?
109109

110-
override init(child: BaseWidget) {
110+
override init(child: some WidgetProtocol) {
111111
super.init(child: child)
112112

113113
gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(viewTouched))
114114
gestureRecognizer.cancelsTouchesInView = true
115-
child.addGestureRecognizer(gestureRecognizer)
115+
child.view.addGestureRecognizer(gestureRecognizer)
116116
}
117117

118118
@objc

Sources/UIKitBackend/UIKitBackend+Picker.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import SwiftCrossUI
22
import UIKit
33

4-
protocol Picker: BaseWidget {
4+
protocol Picker: WidgetProtocol {
55
func setOptions(to options: [String])
66
func setChangeHandler(to onChange: @escaping (Int?) -> Void)
77
func setSelectedOption(to index: Int?)
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import UIKit
2+
3+
#if os(iOS)
4+
final class SplitWidget: WrapperControllerWidget<UISplitViewController>,
5+
UISplitViewControllerDelegate
6+
{
7+
var resizeHandler: (() -> Void)?
8+
private let sidebarContainer: ContainerWidget
9+
private let mainContainer: ContainerWidget
10+
11+
init(sidebarWidget: some WidgetProtocol, mainWidget: some WidgetProtocol) {
12+
// UISplitViewController requires its children to be controllers, not views
13+
sidebarContainer = ContainerWidget(child: sidebarWidget)
14+
mainContainer = ContainerWidget(child: mainWidget)
15+
16+
super.init(child: UISplitViewController())
17+
18+
child.delegate = self
19+
20+
child.preferredDisplayMode = .oneBesideSecondary
21+
child.preferredPrimaryColumnWidthFraction = 0.3
22+
23+
child.viewControllers = [sidebarContainer, mainContainer]
24+
}
25+
26+
override func viewDidLoad() {
27+
NSLayoutConstraint.activate([
28+
sidebarContainer.view.leadingAnchor.constraint(
29+
equalTo: sidebarContainer.child.view.leadingAnchor),
30+
sidebarContainer.view.trailingAnchor.constraint(
31+
equalTo: sidebarContainer.child.view.trailingAnchor),
32+
sidebarContainer.view.topAnchor.constraint(
33+
equalTo: sidebarContainer.child.view.topAnchor),
34+
sidebarContainer.view.bottomAnchor.constraint(
35+
equalTo: sidebarContainer.child.view.bottomAnchor),
36+
mainContainer.view.leadingAnchor.constraint(
37+
equalTo: mainContainer.child.view.leadingAnchor),
38+
mainContainer.view.trailingAnchor.constraint(
39+
equalTo: mainContainer.child.view.trailingAnchor),
40+
mainContainer.view.topAnchor.constraint(
41+
equalTo: mainContainer.child.view.topAnchor),
42+
mainContainer.view.bottomAnchor.constraint(
43+
equalTo: mainContainer.child.view.bottomAnchor),
44+
])
45+
46+
super.viewDidLoad()
47+
}
48+
49+
override func viewDidLayoutSubviews() {
50+
super.viewDidLayoutSubviews()
51+
resizeHandler?()
52+
}
53+
}
54+
55+
extension UIKitBackend {
56+
public func createSplitView(
57+
leadingChild: any WidgetProtocol,
58+
trailingChild: any WidgetProtocol
59+
) -> any WidgetProtocol {
60+
precondition(
61+
UIDevice.current.userInterfaceIdiom != .phone,
62+
"NavigationSplitView is currently unsupported on iPhone and iPod touch.")
63+
64+
return SplitWidget(sidebarWidget: leadingChild, mainWidget: trailingChild)
65+
}
66+
67+
public func setResizeHandler(
68+
ofSplitView splitView: Widget,
69+
to action: @escaping () -> Void
70+
) {
71+
let splitWidget = splitView as! SplitWidget
72+
splitWidget.resizeHandler = action
73+
}
74+
75+
public func sidebarWidth(ofSplitView splitView: Widget) -> Int {
76+
let splitWidget = splitView as! SplitWidget
77+
return Int(splitWidget.child.primaryColumnWidth.rounded(.toNearestOrEven))
78+
}
79+
80+
public func setSidebarWidthBounds(
81+
ofSplitView splitView: Widget,
82+
minimum minimumWidth: Int,
83+
maximum maximumWidth: Int
84+
) {
85+
let splitWidget = splitView as! SplitWidget
86+
splitWidget.child.minimumPrimaryColumnWidth = CGFloat(minimumWidth)
87+
splitWidget.child.maximumPrimaryColumnWidth = CGFloat(maximumWidth)
88+
}
89+
}
90+
#endif

0 commit comments

Comments
 (0)