Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[UIKitBackend] UIViewControllerRepresentable and iPad-only split views #104

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 0 additions & 117 deletions Sources/UIKitBackend/BaseWidget.swift

This file was deleted.

64 changes: 33 additions & 31 deletions Sources/UIKitBackend/UIKitBackend+Container.swift
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import SwiftCrossUI
import UIKit

final class ScrollWidget: WrapperWidget<UIScrollView> {
final class ScrollWidget: ContainerWidget {
private var scrollView = UIScrollView()
private var childWidthConstraint: NSLayoutConstraint?
private var childHeightConstraint: NSLayoutConstraint?

private let innerChild: BaseWidget
private lazy var contentLayoutGuideConstraints: [NSLayoutConstraint] = [
scrollView.contentLayoutGuide.leadingAnchor.constraint(equalTo: child.view.leadingAnchor),
scrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: child.view.trailingAnchor),
scrollView.contentLayoutGuide.topAnchor.constraint(equalTo: child.view.topAnchor),
scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: child.view.bottomAnchor),
]

init(child innerChild: BaseWidget) {
self.innerChild = innerChild
super.init(child: UIScrollView())

child.addSubview(innerChild)
override func loadView() {
view = scrollView
scrollView.translatesAutoresizingMaskIntoConstraints = false
}

NSLayoutConstraint.activate([
innerChild.topAnchor.constraint(equalTo: child.contentLayoutGuide.topAnchor),
innerChild.bottomAnchor.constraint(equalTo: child.contentLayoutGuide.bottomAnchor),
innerChild.leftAnchor.constraint(equalTo: child.contentLayoutGuide.leftAnchor),
innerChild.rightAnchor.constraint(equalTo: child.contentLayoutGuide.rightAnchor),
])
override func viewWillLayoutSubviews() {
NSLayoutConstraint.activate(contentLayoutGuideConstraints)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

activate probably only has to be called once at initialisation, and I'm not entirely certain on this but viewWillLayoutSubviews probably runs more than once. Could maybe move this to loadView?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I remember, I tried putting it in loadView, and it crashed because the view hierarchy was insufficiently assembled at that point. I can double-check if it works in viewDidLoad, but ultimately activating the same instance of NSLayoutConstraint multiple times is harmless.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. Moving this to viewDidLoad causes the app to crash, with

Thread 1: "Unable to activate constraint with anchors <NSLayoutXAxisAnchor:0x60000178c7c0 \"_UIScrollViewLayoutGuide:0x600003b38e00'UIScrollView-contentLayoutGuide'.leading\"> and <NSLayoutXAxisAnchor:0x60000178c800 \"UIKitBackend.BaseViewWidget:0x10f912fd0.leading\"> because they have no common ancestor. Does the constraint or its anchors reference items in different view hierarchies? That's illegal."

However, updateViewConstraints also works, is called less frequently, and seems more appropriate, so I'll move it to there.

super.viewWillLayoutSubviews()
}

func setScrollBars(
Expand All @@ -29,8 +31,8 @@ final class ScrollWidget: WrapperWidget<UIScrollView> {
case (true, true):
childHeightConstraint!.isActive = false
case (false, nil):
childHeightConstraint = innerChild.heightAnchor.constraint(
equalTo: child.heightAnchor)
childHeightConstraint = child.view.heightAnchor.constraint(
equalTo: scrollView.heightAnchor)
fallthrough
case (false, false):
childHeightConstraint!.isActive = true
Expand All @@ -42,68 +44,68 @@ final class ScrollWidget: WrapperWidget<UIScrollView> {
case (true, true):
childWidthConstraint!.isActive = false
case (false, nil):
childWidthConstraint = innerChild.widthAnchor.constraint(equalTo: child.widthAnchor)
childWidthConstraint = child.view.widthAnchor.constraint(
equalTo: scrollView.widthAnchor)
fallthrough
case (false, false):
childWidthConstraint!.isActive = true
default:
break
}

child.showsVerticalScrollIndicator = hasVerticalScrollBar
child.showsHorizontalScrollIndicator = hasHorizontalScrollBar
scrollView.showsVerticalScrollIndicator = hasVerticalScrollBar
scrollView.showsHorizontalScrollIndicator = hasHorizontalScrollBar
}
}

extension UIKitBackend {
public func createContainer() -> Widget {
BaseWidget()
BaseViewWidget()
}

public func removeAllChildren(of container: Widget) {
container.subviews.forEach { $0.removeFromSuperview() }
container.childWidgets.forEach { $0.removeFromParentWidget() }
}

public func addChild(_ child: Widget, to container: Widget) {
container.addSubview(child)
container.add(childWidget: child)
}

public func setPosition(
ofChildAt index: Int,
in container: Widget,
to position: SIMD2<Int>
) {
guard index < container.subviews.count else {
guard index < container.childWidgets.count else {
assertionFailure("Attempting to set position of nonexistent subview")
return
}

let child = container.subviews[index] as! BaseWidget
let child = container.childWidgets[index]
child.x = position.x
child.y = position.y
}

public func removeChild(_ child: Widget, from container: Widget) {
assert(child.isDescendant(of: container))
child.removeFromSuperview()
assert(child.view.isDescendant(of: container.view))
child.removeFromParentWidget()
}

public func createColorableRectangle() -> Widget {
BaseWidget()
BaseViewWidget()
}

public func setColor(ofColorableRectangle widget: Widget, to color: Color) {
widget.backgroundColor = color.uiColor
widget.view.backgroundColor = color.uiColor
}

public func setCornerRadius(of widget: Widget, to radius: Int) {
widget.layer.cornerRadius = CGFloat(radius)
widget.layer.masksToBounds = true
widget.setNeedsLayout()
widget.view.layer.cornerRadius = CGFloat(radius)
widget.view.layer.masksToBounds = true
}

public func naturalSize(of widget: Widget) -> SIMD2<Int> {
let size = widget.intrinsicContentSize
let size = widget.view.intrinsicContentSize
return SIMD2(
Int(size.width.rounded(.awayFromZero)),
Int(size.height.rounded(.awayFromZero))
Expand Down
6 changes: 3 additions & 3 deletions Sources/UIKitBackend/UIKitBackend+Control.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,16 @@ final class TextFieldWidget: WrapperWidget<UITextField>, UITextFieldDelegate {
}
#endif

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

override init(child: BaseWidget) {
override init(child: some WidgetProtocol) {
super.init(child: child)

gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(viewTouched))
gestureRecognizer.cancelsTouchesInView = true
child.addGestureRecognizer(gestureRecognizer)
child.view.addGestureRecognizer(gestureRecognizer)
}

@objc
Expand Down
2 changes: 1 addition & 1 deletion Sources/UIKitBackend/UIKitBackend+Picker.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import SwiftCrossUI
import UIKit

protocol Picker: BaseWidget {
protocol Picker: WidgetProtocol {
func setOptions(to options: [String])
func setChangeHandler(to onChange: @escaping (Int?) -> Void)
func setSelectedOption(to index: Int?)
Expand Down
90 changes: 90 additions & 0 deletions Sources/UIKitBackend/UIKitBackend+SplitView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import UIKit

#if os(iOS)
final class SplitWidget: WrapperControllerWidget<UISplitViewController>,
UISplitViewControllerDelegate
{
var resizeHandler: (() -> Void)?
private let sidebarContainer: ContainerWidget
private let mainContainer: ContainerWidget

init(sidebarWidget: some WidgetProtocol, mainWidget: some WidgetProtocol) {
// UISplitViewController requires its children to be controllers, not views
sidebarContainer = ContainerWidget(child: sidebarWidget)
mainContainer = ContainerWidget(child: mainWidget)

super.init(child: UISplitViewController())

child.delegate = self

child.preferredDisplayMode = .oneBesideSecondary
child.preferredPrimaryColumnWidthFraction = 0.3

child.viewControllers = [sidebarContainer, mainContainer]
}

override func viewDidLoad() {
NSLayoutConstraint.activate([
sidebarContainer.view.leadingAnchor.constraint(
equalTo: sidebarContainer.child.view.leadingAnchor),
sidebarContainer.view.trailingAnchor.constraint(
equalTo: sidebarContainer.child.view.trailingAnchor),
sidebarContainer.view.topAnchor.constraint(
equalTo: sidebarContainer.child.view.topAnchor),
sidebarContainer.view.bottomAnchor.constraint(
equalTo: sidebarContainer.child.view.bottomAnchor),
mainContainer.view.leadingAnchor.constraint(
equalTo: mainContainer.child.view.leadingAnchor),
mainContainer.view.trailingAnchor.constraint(
equalTo: mainContainer.child.view.trailingAnchor),
mainContainer.view.topAnchor.constraint(
equalTo: mainContainer.child.view.topAnchor),
mainContainer.view.bottomAnchor.constraint(
equalTo: mainContainer.child.view.bottomAnchor),
])

super.viewDidLoad()
}

override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
resizeHandler?()
}
}

extension UIKitBackend {
public func createSplitView(
leadingChild: any WidgetProtocol,
trailingChild: any WidgetProtocol
) -> any WidgetProtocol {
precondition(
UIDevice.current.userInterfaceIdiom != .phone,
"NavigationSplitView is currently unsupported on iPhone and iPod touch.")

return SplitWidget(sidebarWidget: leadingChild, mainWidget: trailingChild)
}

public func setResizeHandler(
ofSplitView splitView: Widget,
to action: @escaping () -> Void
) {
let splitWidget = splitView as! SplitWidget
splitWidget.resizeHandler = action
}

public func sidebarWidth(ofSplitView splitView: Widget) -> Int {
let splitWidget = splitView as! SplitWidget
return Int(splitWidget.child.primaryColumnWidth.rounded(.toNearestOrEven))
}

public func setSidebarWidthBounds(
ofSplitView splitView: Widget,
minimum minimumWidth: Int,
maximum maximumWidth: Int
) {
let splitWidget = splitView as! SplitWidget
splitWidget.child.minimumPrimaryColumnWidth = CGFloat(minimumWidth)
splitWidget.child.maximumPrimaryColumnWidth = CGFloat(maximumWidth)
}
}
#endif
Loading
Loading