From 183aecd8f31386387772433075bee951e6593855 Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Fri, 17 Jan 2025 19:26:52 -0500 Subject: [PATCH 1/7] Initial work supporting controllers --- Sources/UIKitBackend/BaseWidget.swift | 117 -------- .../UIKitBackend/UIKitBackend+Container.swift | 72 ++--- .../UIKitBackend/UIKitBackend+Control.swift | 6 +- .../UIKitBackend/UIKitBackend+Picker.swift | 2 +- .../UIKitBackend/UIKitBackend+Window.swift | 25 +- .../UIKitBackend/UIViewRepresentable.swift | 6 +- Sources/UIKitBackend/Widget.swift | 271 ++++++++++++++++++ 7 files changed, 331 insertions(+), 168 deletions(-) delete mode 100644 Sources/UIKitBackend/BaseWidget.swift create mode 100644 Sources/UIKitBackend/Widget.swift diff --git a/Sources/UIKitBackend/BaseWidget.swift b/Sources/UIKitBackend/BaseWidget.swift deleted file mode 100644 index 0977c957..00000000 --- a/Sources/UIKitBackend/BaseWidget.swift +++ /dev/null @@ -1,117 +0,0 @@ -import UIKit - -public class BaseWidget: UIView { - private var leftConstraint: NSLayoutConstraint? - private var topConstraint: NSLayoutConstraint? - private var widthConstraint: NSLayoutConstraint? - private var heightConstraint: NSLayoutConstraint? - - var x = 0 { - didSet { - if x != oldValue { - updateLeftConstraint() - } - } - } - - var y = 0 { - didSet { - if y != oldValue { - updateTopConstraint() - } - } - } - - var width = 0 { - didSet { - if width != oldValue { - updateWidthConstraint() - } - } - } - - var height = 0 { - didSet { - if height != oldValue { - updateHeightConstraint() - } - } - } - - init() { - super.init(frame: .zero) - - self.translatesAutoresizingMaskIntoConstraints = false - } - - @available(*, unavailable) - public required init?(coder: NSCoder) { - fatalError("init(coder:) is not used for this view") - } - - private func updateLeftConstraint() { - leftConstraint?.isActive = false - guard let superview else { return } - leftConstraint = self.leftAnchor.constraint( - equalTo: superview.safeAreaLayoutGuide.leftAnchor, constant: CGFloat(x)) - leftConstraint!.isActive = true - } - - private func updateTopConstraint() { - topConstraint?.isActive = false - guard let superview else { return } - topConstraint = self.topAnchor.constraint( - equalTo: superview.safeAreaLayoutGuide.topAnchor, constant: CGFloat(y)) - topConstraint!.isActive = true - } - - private func updateWidthConstraint() { - widthConstraint?.isActive = false - widthConstraint = self.widthAnchor.constraint(equalToConstant: CGFloat(width)) - widthConstraint!.isActive = true - } - - private func updateHeightConstraint() { - heightConstraint?.isActive = false - heightConstraint = self.heightAnchor.constraint(equalToConstant: CGFloat(height)) - heightConstraint!.isActive = true - } - - public override func didMoveToSuperview() { - super.didMoveToSuperview() - - updateLeftConstraint() - updateTopConstraint() - } -} - -extension UIKitBackend { - public typealias Widget = BaseWidget -} - -class WrapperWidget: BaseWidget { - init(child: View) { - super.init() - - self.addSubview(child) - child.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - child.topAnchor.constraint(equalTo: self.topAnchor), - child.leadingAnchor.constraint(equalTo: self.leadingAnchor), - child.bottomAnchor.constraint(equalTo: self.bottomAnchor), - child.trailingAnchor.constraint(equalTo: self.trailingAnchor), - ]) - } - - override convenience init() { - self.init(child: View(frame: .zero)) - } - - var child: View { - subviews[0] as! View - } - - override var intrinsicContentSize: CGSize { - child.intrinsicContentSize - } -} diff --git a/Sources/UIKitBackend/UIKitBackend+Container.swift b/Sources/UIKitBackend/UIKitBackend+Container.swift index 00f89a34..5e2ee4e9 100644 --- a/Sources/UIKitBackend/UIKitBackend+Container.swift +++ b/Sources/UIKitBackend/UIKitBackend+Container.swift @@ -1,26 +1,29 @@ import SwiftCrossUI import UIKit -final class ScrollWidget: WrapperWidget { +final class ScrollWidget: ContainerWidget, UIScrollViewDelegate { + private var scrollView = UIScrollView() private var childWidthConstraint: NSLayoutConstraint? private var childHeightConstraint: NSLayoutConstraint? - - private let innerChild: BaseWidget - - init(child innerChild: BaseWidget) { - self.innerChild = innerChild - super.init(child: UIScrollView()) - - child.addSubview(innerChild) - - 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), - ]) + + 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) + ] + + override func loadView() { + view = scrollView + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.delegate = self } - + + override func viewWillLayoutSubviews() { + NSLayoutConstraint.activate(contentLayoutGuideConstraints) + super.viewWillLayoutSubviews() + } + func setScrollBars( hasVerticalScrollBar: Bool, hasHorizontalScrollBar: Bool @@ -29,8 +32,8 @@ final class ScrollWidget: WrapperWidget { 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 @@ -42,7 +45,7 @@ final class ScrollWidget: WrapperWidget { 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 @@ -50,22 +53,22 @@ final class ScrollWidget: WrapperWidget { 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) + child.add(toWidget: container) } public func setPosition( @@ -73,37 +76,36 @@ extension UIKitBackend { in container: Widget, to position: SIMD2 ) { - 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 { - let size = widget.intrinsicContentSize + let size = widget.view.intrinsicContentSize return SIMD2( Int(size.width.rounded(.awayFromZero)), Int(size.height.rounded(.awayFromZero)) diff --git a/Sources/UIKitBackend/UIKitBackend+Control.swift b/Sources/UIKitBackend/UIKitBackend+Control.swift index f9a65afc..861168b6 100644 --- a/Sources/UIKitBackend/UIKitBackend+Control.swift +++ b/Sources/UIKitBackend/UIKitBackend+Control.swift @@ -103,16 +103,16 @@ final class TextFieldWidget: WrapperWidget, UITextFieldDelegate { } #endif -final class ClickableWidget: WrapperWidget { +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 diff --git a/Sources/UIKitBackend/UIKitBackend+Picker.swift b/Sources/UIKitBackend/UIKitBackend+Picker.swift index 0511bb18..db3f1d24 100644 --- a/Sources/UIKitBackend/UIKitBackend+Picker.swift +++ b/Sources/UIKitBackend/UIKitBackend+Picker.swift @@ -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?) diff --git a/Sources/UIKitBackend/UIKitBackend+Window.swift b/Sources/UIKitBackend/UIKitBackend+Window.swift index 51accf23..abc60f67 100644 --- a/Sources/UIKitBackend/UIKitBackend+Window.swift +++ b/Sources/UIKitBackend/UIKitBackend+Window.swift @@ -3,6 +3,7 @@ import UIKit final class RootViewController: UIViewController { unowned var backend: UIKitBackend var resizeHandler: ((CGSize) -> Void)? + private var childWidget: (any WidgetProtocol)? init(backend: UIKitBackend) { self.backend = backend @@ -33,13 +34,20 @@ final class RootViewController: UIViewController { backend.onTraitCollectionChange?() } - func setChild(to child: UIView) { - view.subviews.forEach { $0.removeFromSuperview() } - view.addSubview(child) - + func setChild(to child: some WidgetProtocol) { + childWidget?.removeFromParentWidget() + child.removeFromParentWidget() + + let childController = child.controller + view.addSubview(child.view) + if let childController { + addChild(childController) + childController.didMove(toParent: self) + } + NSLayoutConstraint.activate([ - child.widthAnchor.constraint(equalTo: view.safeAreaLayoutGuide.widthAnchor), - child.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor), + child.view.widthAnchor.constraint(equalTo: view.safeAreaLayoutGuide.widthAnchor), + child.view.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor) ]) } } @@ -76,9 +84,8 @@ extension UIKitBackend { // of the screen could be obscured (e.g. covered by the notch). In the future we // might want to let users decide what to do, but for now, lie and say that the safe // area insets aren't even part of the window. - // If/when this is updated, ``RootViewController/setChild(to:)``, - // ``BaseWidget/updateLeftConstraint()``, and ``BaseWidget/updateTopConstraint()`` - // will also need to be updated. + // If/when this is updated, ``RootViewController`` and ``WidgetProtocolHelpers`` will + // also need to be updated. let size = window.safeAreaLayoutGuide.layoutFrame.size return SIMD2(Int(size.width), Int(size.height)) } diff --git a/Sources/UIKitBackend/UIViewRepresentable.swift b/Sources/UIKitBackend/UIViewRepresentable.swift index 5d2d6c93..31dbcd94 100644 --- a/Sources/UIKitBackend/UIViewRepresentable.swift +++ b/Sources/UIKitBackend/UIViewRepresentable.swift @@ -150,8 +150,8 @@ where Self: UIViewRepresentable { ) if !dryRun { - representingWidget.width = size.size.x - representingWidget.height = size.size.y + representingWidget.frame.size.width = CGFloat(size.size.x) + representingWidget.frame.size.height = CGFloat(size.size.y) } return ViewUpdateResult.leafView(size: size) @@ -165,7 +165,7 @@ where Coordinator == Void { } } -final class RepresentingWidget: BaseWidget { +final class RepresentingWidget: BaseViewWidget { var representable: Representable var context: UIViewRepresentableContext? diff --git a/Sources/UIKitBackend/Widget.swift b/Sources/UIKitBackend/Widget.swift new file mode 100644 index 00000000..0a26f73f --- /dev/null +++ b/Sources/UIKitBackend/Widget.swift @@ -0,0 +1,271 @@ +import UIKit + +public protocol WidgetProtocol: UIResponder { + var x: Int { get set } + var y: Int { get set } + var width: Int { get set } + var height: Int { get set } + + var view: UIView! { get } + var controller: UIViewController? { get } + + var childWidgets: [any WidgetProtocol] { get set } + var parentWidget: (any WidgetProtocol)? { get } + + func add(toWidget other: some WidgetProtocol) + func removeFromParentWidget() +} + +extension UIKitBackend { + public typealias Widget = any WidgetProtocol +} + +fileprivate protocol WidgetProtocolHelpers: WidgetProtocol { + var leftConstraint: NSLayoutConstraint? { get set } + var topConstraint: NSLayoutConstraint? { get set } + var widthConstraint: NSLayoutConstraint? { get set } + var heightConstraint: NSLayoutConstraint? { get set } +} + +extension WidgetProtocolHelpers { + func updateLeftConstraint() { + leftConstraint?.isActive = false + guard let superview = view.superview else { return } + leftConstraint = view.leftAnchor.constraint( + equalTo: superview.safeAreaLayoutGuide.leftAnchor, constant: CGFloat(x)) + // Set the constraint priority for leftConstraint (and topConstraint) to just under + // "required" so that we don't get warnings about unsatisfiable constraints from + // scroll views. + leftConstraint!.priority = .init(UILayoutPriority.required.rawValue - 1.0) + leftConstraint!.isActive = true + } + + func updateTopConstraint() { + topConstraint?.isActive = false + guard let superview = view.superview else { return } + topConstraint = view.topAnchor.constraint( + equalTo: superview.safeAreaLayoutGuide.topAnchor, constant: CGFloat(y)) + topConstraint!.priority = .init(UILayoutPriority.required.rawValue - 1.0) + topConstraint!.isActive = true + } + + func updateWidthConstraint() { + widthConstraint?.isActive = false + widthConstraint = view.widthAnchor.constraint(equalToConstant: CGFloat(width)) + widthConstraint!.isActive = true + } + + func updateHeightConstraint() { + heightConstraint?.isActive = false + heightConstraint = view.heightAnchor.constraint(equalToConstant: CGFloat(height)) + heightConstraint!.isActive = true + } +} + +class BaseViewWidget: UIView, WidgetProtocolHelpers { + fileprivate var leftConstraint: NSLayoutConstraint? + fileprivate var topConstraint: NSLayoutConstraint? + fileprivate var widthConstraint: NSLayoutConstraint? + fileprivate var heightConstraint: NSLayoutConstraint? + + var x = 0 { + didSet { + if x != oldValue { + updateLeftConstraint() + } + } + } + + var y = 0 { + didSet { + if y != oldValue { + updateTopConstraint() + } + } + } + + var width = 0 { + didSet { + if width != oldValue { + updateWidthConstraint() + } + } + } + + var height = 0 { + didSet { + if height != oldValue { + updateHeightConstraint() + } + } + } + + var childWidgets: [any WidgetProtocol] = [] + weak var parentWidget: (any WidgetProtocol)? + + var view: UIView! { self } + + var controller: UIViewController? { + var responder: UIResponder = self + while let next = responder.next { + if let controller = next as? UIViewController { + return controller + } + responder = next + } + return nil + } + + init() { + super.init(frame: .zero) + + self.translatesAutoresizingMaskIntoConstraints = false + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) is not used for this view") + } + + public override func didMoveToSuperview() { + super.didMoveToSuperview() + + updateLeftConstraint() + updateTopConstraint() + } + + func add(toWidget other: some WidgetProtocol) { + if parentWidget === other { return } + removeFromParentWidget() + + other.view.addSubview(self) + parentWidget = other + other.childWidgets.append(self) + } + + func removeFromParentWidget() { + if let parentWidget { + parentWidget.childWidgets.remove(at: parentWidget.childWidgets.firstIndex { $0 === self }!) + self.parentWidget = nil + } + removeFromSuperview() + } +} + +class BaseControllerWidget: UIViewController, WidgetProtocolHelpers { + fileprivate var leftConstraint: NSLayoutConstraint? + fileprivate var topConstraint: NSLayoutConstraint? + fileprivate var widthConstraint: NSLayoutConstraint? + fileprivate var heightConstraint: NSLayoutConstraint? + + var x = 0 { + didSet { + if x != oldValue { + updateLeftConstraint() + } + } + } + + var y = 0 { + didSet { + if y != oldValue { + updateTopConstraint() + } + } + } + + var width = 0 { + didSet { + if width != oldValue { + updateWidthConstraint() + } + } + } + + var height = 0 { + didSet { + if height != oldValue { + updateHeightConstraint() + } + } + } + + var childWidgets: [any WidgetProtocol] + weak var parentWidget: (any WidgetProtocol)? + + var controller: UIViewController? { self } + + init() { + childWidgets = [] + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not used for this view") + } + + func add(toWidget other: some WidgetProtocol) { + if parentWidget === other { return } + removeFromParentWidget() + + other.view.addSubview(view) + + if let otherController = other.controller { + otherController.addChild(self) + didMove(toParent: otherController) + } + + parentWidget = other + other.childWidgets.append(self) + } + + func removeFromParentWidget() { + if let parentWidget { + parentWidget.childWidgets.remove(at: parentWidget.childWidgets.firstIndex { $0 === self }!) + self.parentWidget = nil + } + if let parent { + willMove(toParent: nil) + removeFromParent() + } + view.removeFromSuperview() + } +} + +class WrapperWidget: BaseViewWidget { + init(child: View) { + super.init() + + self.addSubview(child) + child.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + child.topAnchor.constraint(equalTo: self.topAnchor), + child.leadingAnchor.constraint(equalTo: self.leadingAnchor), + child.bottomAnchor.constraint(equalTo: self.bottomAnchor), + child.trailingAnchor.constraint(equalTo: self.trailingAnchor), + ]) + } + + override convenience init() { + self.init(child: View(frame: .zero)) + } + + var child: View { + subviews[0] as! View + } + + override var intrinsicContentSize: CGSize { + child.intrinsicContentSize + } +} + +class ContainerWidget: BaseControllerWidget { + let child: any WidgetProtocol + + init(child: some WidgetProtocol) { + self.child = child + super.init() + child.add(toWidget: self) + } +} From 8243bc459bb678211c37790cf63889b8c4bfb58c Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Fri, 17 Jan 2025 20:31:22 -0500 Subject: [PATCH 2/7] Implement UIViewControllerRepresentable --- .../UIKitBackend/UIKitBackend+Container.swift | 16 +- .../UIKitBackend/UIKitBackend+Window.swift | 6 +- .../UIViewControllerRepresentable.swift | 189 ++++++++++++++++++ .../UIKitBackend/UIViewRepresentable.swift | 73 +++---- Sources/UIKitBackend/Widget.swift | 50 ++--- 5 files changed, 264 insertions(+), 70 deletions(-) create mode 100644 Sources/UIKitBackend/UIViewControllerRepresentable.swift diff --git a/Sources/UIKitBackend/UIKitBackend+Container.swift b/Sources/UIKitBackend/UIKitBackend+Container.swift index 5e2ee4e9..e38546a2 100644 --- a/Sources/UIKitBackend/UIKitBackend+Container.swift +++ b/Sources/UIKitBackend/UIKitBackend+Container.swift @@ -1,29 +1,28 @@ import SwiftCrossUI import UIKit -final class ScrollWidget: ContainerWidget, UIScrollViewDelegate { +final class ScrollWidget: ContainerWidget { private var scrollView = UIScrollView() private var childWidthConstraint: NSLayoutConstraint? private var childHeightConstraint: NSLayoutConstraint? - + 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) + scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: child.view.bottomAnchor), ] - + override func loadView() { view = scrollView scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.delegate = self } - + override func viewWillLayoutSubviews() { NSLayoutConstraint.activate(contentLayoutGuideConstraints) super.viewWillLayoutSubviews() } - + func setScrollBars( hasVerticalScrollBar: Bool, hasHorizontalScrollBar: Bool @@ -45,7 +44,8 @@ final class ScrollWidget: ContainerWidget, UIScrollViewDelegate { case (true, true): childWidthConstraint!.isActive = false case (false, nil): - childWidthConstraint = child.view.widthAnchor.constraint(equalTo: scrollView.widthAnchor) + childWidthConstraint = child.view.widthAnchor.constraint( + equalTo: scrollView.widthAnchor) fallthrough case (false, false): childWidthConstraint!.isActive = true diff --git a/Sources/UIKitBackend/UIKitBackend+Window.swift b/Sources/UIKitBackend/UIKitBackend+Window.swift index abc60f67..363b7c6e 100644 --- a/Sources/UIKitBackend/UIKitBackend+Window.swift +++ b/Sources/UIKitBackend/UIKitBackend+Window.swift @@ -37,17 +37,17 @@ final class RootViewController: UIViewController { func setChild(to child: some WidgetProtocol) { childWidget?.removeFromParentWidget() child.removeFromParentWidget() - + let childController = child.controller view.addSubview(child.view) if let childController { addChild(childController) childController.didMove(toParent: self) } - + NSLayoutConstraint.activate([ child.view.widthAnchor.constraint(equalTo: view.safeAreaLayoutGuide.widthAnchor), - child.view.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor) + child.view.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor), ]) } } diff --git a/Sources/UIKitBackend/UIViewControllerRepresentable.swift b/Sources/UIKitBackend/UIViewControllerRepresentable.swift new file mode 100644 index 00000000..615af34f --- /dev/null +++ b/Sources/UIKitBackend/UIViewControllerRepresentable.swift @@ -0,0 +1,189 @@ +import SwiftCrossUI +import UIKit + +public struct UIViewControllerRepresentableContext { + public let coordinator: Coordinator + public internal(set) var environment: EnvironmentValues +} + +public protocol UIViewControllerRepresentable: View +where Content == Never { + associatedtype UIViewControllerType: UIViewController + associatedtype Coordinator = Void + + /// Create the initial UIViewController instance. + func makeUIViewController(context: UIViewControllerRepresentableContext) + -> UIViewControllerType + + /// Update the view with new values. + /// - Parameters: + /// - uiViewController: The controller to update. + /// - context: The context, including the coordinator and potentially new environment + /// values. + /// - Note: This may be called even when `context` has not changed. + func updateUIViewController( + _ uiViewController: UIViewControllerType, + context: UIViewControllerRepresentableContext) + + /// Make the coordinator for this controller. + /// + /// The coordinator is used when the controller needs to communicate changes to the rest of + /// the view hierarchy (i.e. through bindings). + func makeCoordinator() -> Coordinator + + /// Compute the view's size. + /// - Parameters: + /// - proposal: The proposed frame for the view to render in. + /// - uiViewController: The controller being queried for its view's preferred size. + /// - context: The context, including the coordinator and environment values. + /// - Returns: Information about the view's size. The ``SwiftCrossUI/ViewSize/size`` + /// property is what frame the view will actually be rendered with if the current layout + /// pass is not a dry run, while the other properties are used to inform the layout engine + /// how big or small the view can be. The ``SwiftCrossUI/ViewSize/idealSize`` property + /// should not vary with the `proposal`, and should only depend on the view's contents. + /// Pass `nil` for the maximum width/height if the view has no maximum size (and therefore + /// may occupy the entire screen). + /// + /// The default implementation uses `uiViewController.view.intrinsicContentSize` and + /// `uiViewController.view.systemLayoutSizeFitting(_:)` to determine the return value. + func determineViewSize( + for proposal: SIMD2, uiViewController: UIViewControllerType, + context: UIViewControllerRepresentableContext + ) -> ViewSize + + /// Called to clean up the view when it's removed. + /// - Parameters: + /// - uiViewController: The controller being dismantled. + /// - coordinator: The coordinator. + /// + /// The default implementation does nothing. + static func dismantleUIViewController( + _ uiViewController: UIViewControllerType, coordinator: Coordinator) +} + +extension UIViewControllerRepresentable { + public static func dismantleUIViewController( + _: UIViewControllerType, coordinator _: Coordinator + ) { + // no-op + } + + public func determineViewSize( + for proposal: SIMD2, uiViewController: UIViewControllerType, + context: UIViewControllerRepresentableContext + ) -> ViewSize { + defaultViewSize(proposal: proposal, view: uiViewController.view) + } +} + +extension View +where Self: UIViewControllerRepresentable { + public var body: Never { + preconditionFailure("This should never be called") + } + + public func children( + backend _: Backend, + snapshots _: [ViewGraphSnapshotter.NodeSnapshot]?, + environment _: EnvironmentValues + ) -> any ViewGraphNodeChildren { + EmptyViewChildren() + } + + public func layoutableChildren( + backend _: Backend, + children _: any ViewGraphNodeChildren + ) -> [LayoutSystem.LayoutableChild] { + [] + } + + public func asWidget( + _: any ViewGraphNodeChildren, + backend _: Backend + ) -> Backend.Widget { + if let widget = ControllerRepresentingWidget(representable: self) as? Backend.Widget { + return widget + } else { + fatalError("UIViewControllerRepresentable requested by \(Backend.self)") + } + } + + public func update( + _ widget: Backend.Widget, + children _: any ViewGraphNodeChildren, + proposedSize: SIMD2, + environment: EnvironmentValues, + backend _: Backend, + dryRun: Bool + ) -> ViewUpdateResult { + let representingWidget = widget as! ControllerRepresentingWidget + representingWidget.update(with: environment) + + let size = + representingWidget.representable.determineViewSize( + for: proposedSize, + uiViewController: representingWidget.subcontroller, + context: representingWidget.context! + ) + + if !dryRun { + representingWidget.width = size.size.x + representingWidget.height = size.size.y + } + + return ViewUpdateResult.leafView(size: size) + } +} + +extension UIViewControllerRepresentable +where Coordinator == Void { + public func makeCoordinator() { + return () + } +} + +final class ControllerRepresentingWidget: + BaseControllerWidget +{ + var representable: Representable + var context: UIViewControllerRepresentableContext? + + lazy var subcontroller: Representable.UIViewControllerType = + { + let subcontroller = representable.makeUIViewController(context: context!) + + view.addSubview(subcontroller.view) + addChild(subcontroller) + subcontroller.didMove(toParent: self) + + subcontroller.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + subcontroller.view.topAnchor.constraint(equalTo: view.topAnchor), + subcontroller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + subcontroller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + subcontroller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + return subcontroller + }() + + func update(with environment: EnvironmentValues) { + if context == nil { + context = .init(coordinator: representable.makeCoordinator(), environment: environment) + } else { + context!.environment = environment + representable.updateUIViewController(subcontroller, context: context!) + } + } + + init(representable: Representable) { + self.representable = representable + super.init() + } + + deinit { + if let context { + Representable.dismantleUIViewController(subcontroller, coordinator: context.coordinator) + } + } +} diff --git a/Sources/UIKitBackend/UIViewRepresentable.swift b/Sources/UIKitBackend/UIViewRepresentable.swift index 31dbcd94..8a59cb76 100644 --- a/Sources/UIKitBackend/UIViewRepresentable.swift +++ b/Sources/UIKitBackend/UIViewRepresentable.swift @@ -31,7 +31,7 @@ where Content == Never { /// Compute the view's size. /// - Parameters: /// - proposal: The proposed frame for the view to render in. - /// - uiVIew: The view being queried for its preferred size. + /// - uiView: The view being queried for its preferred size. /// - context: The context, including the coordinator and environment values. /// - Returns: Information about the view's size. The ``SwiftCrossUI/ViewSize/size`` /// property is what frame the view will actually be rendered with if the current layout @@ -41,7 +41,7 @@ where Content == Never { /// Pass `nil` for the maximum width/height if the view has no maximum size (and therefore /// may occupy the entire screen). /// - /// The default implementation uses `uiView.intrinsicContentSize` and `uiView.sizeThatFits(_:)` + /// The default implementation uses `uiView.intrinsicContentSize` and `uiView.systemLayoutSizeFitting(_:)` /// to determine the return value. func determineViewSize( for proposal: SIMD2, uiView: UIViewType, @@ -50,16 +50,43 @@ where Content == Never { /// Called to clean up the view when it's removed. /// - Parameters: - /// - uiVIew: The view being dismantled. + /// - uiView: The view being dismantled. /// - coordinator: The coordinator. /// /// This method is called after all UIKit lifecycle methods, such as - /// `uiView.didMoveToSuperview()`. + /// `uiView.didMoveToWindow()`. /// /// The default implementation does nothing. static func dismantleUIView(_ uiView: UIViewType, coordinator: Coordinator) } +// Used both here and by UIViewControllerRepresentable +func defaultViewSize(proposal: SIMD2, view: UIView) -> ViewSize { + let intrinsicSize = view.intrinsicContentSize + + let sizeThatFits = view.systemLayoutSizeFitting( + CGSize(width: CGFloat(proposal.x), height: CGFloat(proposal.y))) + + let minimumSize = view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + let maximumSize = view.systemLayoutSizeFitting(UIView.layoutFittingExpandedSize) + + return ViewSize( + size: SIMD2( + Int(sizeThatFits.width.rounded(.up)), + Int(sizeThatFits.height.rounded(.up))), + // The 10 here is a somewhat arbitrary constant value so that it's always the same. + // See also `Color` and `Picker`, which use the same constant. + idealSize: SIMD2( + intrinsicSize.width < 0.0 ? 10 : Int(intrinsicSize.width.rounded(.awayFromZero)), + intrinsicSize.height < 0.0 ? 10 : Int(intrinsicSize.height.rounded(.awayFromZero)) + ), + minimumWidth: Int(minimumSize.width.rounded(.towardZero)), + minimumHeight: Int(minimumSize.width.rounded(.towardZero)), + maximumWidth: maximumSize.width, + maximumHeight: maximumSize.height + ) +} + extension UIViewRepresentable { public static func dismantleUIView(_: UIViewType, coordinator _: Coordinator) { // no-op @@ -69,33 +96,7 @@ extension UIViewRepresentable { for proposal: SIMD2, uiView: UIViewType, context _: UIViewRepresentableContext ) -> ViewSize { - let intrinsicSize = uiView.intrinsicContentSize - let sizeThatFits = uiView.sizeThatFits( - CGSize(width: CGFloat(proposal.x), height: CGFloat(proposal.y))) - - let roundedSizeThatFits = SIMD2( - Int(sizeThatFits.width.rounded(.up)), - Int(sizeThatFits.height.rounded(.up))) - let roundedIntrinsicSize = SIMD2( - Int(intrinsicSize.width.rounded(.awayFromZero)), - Int(intrinsicSize.height.rounded(.awayFromZero))) - - return ViewSize( - size: SIMD2( - intrinsicSize.width < 0.0 ? proposal.x : roundedSizeThatFits.x, - intrinsicSize.height < 0.0 ? proposal.y : roundedSizeThatFits.y - ), - // The 10 here is a somewhat arbitrary constant value so that it's always the same. - // See also `Color` and `Picker`, which use the same constant. - idealSize: SIMD2( - intrinsicSize.width < 0.0 ? 10 : roundedIntrinsicSize.x, - intrinsicSize.height < 0.0 ? 10 : roundedIntrinsicSize.y - ), - minimumWidth: max(0, roundedIntrinsicSize.x), - minimumHeight: max(0, roundedIntrinsicSize.x), - maximumWidth: nil, - maximumHeight: nil - ) + defaultViewSize(proposal: proposal, view: uiView) } } @@ -124,7 +125,7 @@ where Self: UIViewRepresentable { _: any ViewGraphNodeChildren, backend _: Backend ) -> Backend.Widget { - if let widget = RepresentingWidget(representable: self) as? Backend.Widget { + if let widget = ViewRepresentingWidget(representable: self) as? Backend.Widget { return widget } else { fatalError("UIViewRepresentable requested by \(Backend.self)") @@ -139,7 +140,7 @@ where Self: UIViewRepresentable { backend _: Backend, dryRun: Bool ) -> ViewUpdateResult { - let representingWidget = widget as! RepresentingWidget + let representingWidget = widget as! ViewRepresentingWidget representingWidget.update(with: environment) let size = @@ -150,8 +151,8 @@ where Self: UIViewRepresentable { ) if !dryRun { - representingWidget.frame.size.width = CGFloat(size.size.x) - representingWidget.frame.size.height = CGFloat(size.size.y) + representingWidget.width = size.size.x + representingWidget.height = size.size.y } return ViewUpdateResult.leafView(size: size) @@ -165,7 +166,7 @@ where Coordinator == Void { } } -final class RepresentingWidget: BaseViewWidget { +final class ViewRepresentingWidget: BaseViewWidget { var representable: Representable var context: UIViewRepresentableContext? diff --git a/Sources/UIKitBackend/Widget.swift b/Sources/UIKitBackend/Widget.swift index 0a26f73f..34e11a30 100644 --- a/Sources/UIKitBackend/Widget.swift +++ b/Sources/UIKitBackend/Widget.swift @@ -5,13 +5,13 @@ public protocol WidgetProtocol: UIResponder { var y: Int { get set } var width: Int { get set } var height: Int { get set } - + var view: UIView! { get } var controller: UIViewController? { get } - + var childWidgets: [any WidgetProtocol] { get set } var parentWidget: (any WidgetProtocol)? { get } - + func add(toWidget other: some WidgetProtocol) func removeFromParentWidget() } @@ -20,7 +20,7 @@ extension UIKitBackend { public typealias Widget = any WidgetProtocol } -fileprivate protocol WidgetProtocolHelpers: WidgetProtocol { +private protocol WidgetProtocolHelpers: WidgetProtocol { var leftConstraint: NSLayoutConstraint? { get set } var topConstraint: NSLayoutConstraint? { get set } var widthConstraint: NSLayoutConstraint? { get set } @@ -35,7 +35,9 @@ extension WidgetProtocolHelpers { equalTo: superview.safeAreaLayoutGuide.leftAnchor, constant: CGFloat(x)) // Set the constraint priority for leftConstraint (and topConstraint) to just under // "required" so that we don't get warnings about unsatisfiable constraints from - // scroll views. + // scroll views, which position relative to their contentLayoutGuide instead. + // This *should* be high enough that it won't cause any problems unless there was + // a constraint conflict anyways. leftConstraint!.priority = .init(UILayoutPriority.required.rawValue - 1.0) leftConstraint!.isActive = true } @@ -99,12 +101,12 @@ class BaseViewWidget: UIView, WidgetProtocolHelpers { } } } - + var childWidgets: [any WidgetProtocol] = [] weak var parentWidget: (any WidgetProtocol)? - + var view: UIView! { self } - + var controller: UIViewController? { var responder: UIResponder = self while let next = responder.next { @@ -133,19 +135,20 @@ class BaseViewWidget: UIView, WidgetProtocolHelpers { updateLeftConstraint() updateTopConstraint() } - + func add(toWidget other: some WidgetProtocol) { if parentWidget === other { return } removeFromParentWidget() - + other.view.addSubview(self) parentWidget = other other.childWidgets.append(self) } - + func removeFromParentWidget() { if let parentWidget { - parentWidget.childWidgets.remove(at: parentWidget.childWidgets.firstIndex { $0 === self }!) + parentWidget.childWidgets.remove( + at: parentWidget.childWidgets.firstIndex { $0 === self }!) self.parentWidget = nil } removeFromSuperview() @@ -189,40 +192,41 @@ class BaseControllerWidget: UIViewController, WidgetProtocolHelpers { } } } - + var childWidgets: [any WidgetProtocol] weak var parentWidget: (any WidgetProtocol)? - + var controller: UIViewController? { self } - + init() { childWidgets = [] super.init(nibName: nil, bundle: nil) } - + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not used for this view") } - + func add(toWidget other: some WidgetProtocol) { if parentWidget === other { return } removeFromParentWidget() - + other.view.addSubview(view) - + if let otherController = other.controller { otherController.addChild(self) didMove(toParent: otherController) } - + parentWidget = other other.childWidgets.append(self) } - + func removeFromParentWidget() { if let parentWidget { - parentWidget.childWidgets.remove(at: parentWidget.childWidgets.firstIndex { $0 === self }!) + parentWidget.childWidgets.remove( + at: parentWidget.childWidgets.firstIndex { $0 === self }!) self.parentWidget = nil } if let parent { @@ -262,7 +266,7 @@ class WrapperWidget: BaseViewWidget { class ContainerWidget: BaseControllerWidget { let child: any WidgetProtocol - + init(child: some WidgetProtocol) { self.child = child super.init() From 3e4d3a70cdb806bc352afbc4b519a0ddb1ffa2e8 Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Fri, 17 Jan 2025 22:26:11 -0500 Subject: [PATCH 3/7] iPad-only split views --- .../UIKitBackend/UIKitBackend+Container.swift | 2 +- .../UIKitBackend/UIKitBackend+SplitView.swift | 90 +++++++++++++++++++ Sources/UIKitBackend/Widget.swift | 74 +++++++++++---- 3 files changed, 147 insertions(+), 19 deletions(-) create mode 100644 Sources/UIKitBackend/UIKitBackend+SplitView.swift diff --git a/Sources/UIKitBackend/UIKitBackend+Container.swift b/Sources/UIKitBackend/UIKitBackend+Container.swift index e38546a2..03c5b649 100644 --- a/Sources/UIKitBackend/UIKitBackend+Container.swift +++ b/Sources/UIKitBackend/UIKitBackend+Container.swift @@ -68,7 +68,7 @@ extension UIKitBackend { } public func addChild(_ child: Widget, to container: Widget) { - child.add(toWidget: container) + container.add(childWidget: child) } public func setPosition( diff --git a/Sources/UIKitBackend/UIKitBackend+SplitView.swift b/Sources/UIKitBackend/UIKitBackend+SplitView.swift new file mode 100644 index 00000000..f2710a9c --- /dev/null +++ b/Sources/UIKitBackend/UIKitBackend+SplitView.swift @@ -0,0 +1,90 @@ +import UIKit + +#if os(iOS) + final class SplitWidget: WrapperControllerWidget, + UISplitViewControllerDelegate + { + var resizeHandler: (() -> Void)? + private let sidebarContainer: ContainerWidget + private let mainContainer: ContainerWidget + + init(sidebarWidget: any WidgetProtocol, mainWidget: any 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 diff --git a/Sources/UIKitBackend/Widget.swift b/Sources/UIKitBackend/Widget.swift index 34e11a30..ab287d2d 100644 --- a/Sources/UIKitBackend/Widget.swift +++ b/Sources/UIKitBackend/Widget.swift @@ -10,9 +10,9 @@ public protocol WidgetProtocol: UIResponder { var controller: UIViewController? { get } var childWidgets: [any WidgetProtocol] { get set } - var parentWidget: (any WidgetProtocol)? { get } + var parentWidget: (any WidgetProtocol)? { get set } - func add(toWidget other: some WidgetProtocol) + func add(childWidget: some WidgetProtocol) func removeFromParentWidget() } @@ -136,13 +136,23 @@ class BaseViewWidget: UIView, WidgetProtocolHelpers { updateTopConstraint() } - func add(toWidget other: some WidgetProtocol) { - if parentWidget === other { return } - removeFromParentWidget() + func add(childWidget: some WidgetProtocol) { + if childWidget.parentWidget === self { return } + childWidget.removeFromParentWidget() - other.view.addSubview(self) - parentWidget = other - other.childWidgets.append(self) + let childController = childWidget.controller + + addSubview(childWidget.view) + + if let controller, + let childController + { + controller.addChild(childController) + childController.didMove(toParent: controller) + } + + childWidgets.append(childWidget) + childWidget.parentWidget = self } func removeFromParentWidget() { @@ -208,19 +218,21 @@ class BaseControllerWidget: UIViewController, WidgetProtocolHelpers { fatalError("init(coder:) is not used for this view") } - func add(toWidget other: some WidgetProtocol) { - if parentWidget === other { return } - removeFromParentWidget() + func add(childWidget: some WidgetProtocol) { + if childWidget.parentWidget === self { return } + childWidget.removeFromParentWidget() + + let childController = childWidget.controller - other.view.addSubview(view) + view.addSubview(childWidget.view) - if let otherController = other.controller { - otherController.addChild(self) - didMove(toParent: otherController) + if let childController { + addChild(childController) + childController.didMove(toParent: self) } - parentWidget = other - other.childWidgets.append(self) + childWidgets.append(childWidget) + childWidget.parentWidget = self } func removeFromParentWidget() { @@ -270,6 +282,32 @@ class ContainerWidget: BaseControllerWidget { init(child: some WidgetProtocol) { self.child = child super.init() - child.add(toWidget: self) + add(childWidget: child) + } +} + +class WrapperControllerWidget: BaseControllerWidget { + let child: Controller + + init(child: Controller) { + self.child = child + super.init() + } + + override func loadView() { + super.loadView() + + view.addSubview(child.view) + addChild(child) + + child.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + child.view.topAnchor.constraint(equalTo: view.topAnchor), + child.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + child.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + child.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + child.didMove(toParent: self) } } From 9594a0b3633cbd3d950ee0cd3b801cfa4284358a Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Fri, 17 Jan 2025 22:48:13 -0500 Subject: [PATCH 4/7] final fixes --- .../UIKitBackend/UIKitBackend+SplitView.swift | 2 +- .../UIViewControllerRepresentable.swift | 36 +++++++++---------- .../UIKitBackend/UIViewRepresentable.swift | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Sources/UIKitBackend/UIKitBackend+SplitView.swift b/Sources/UIKitBackend/UIKitBackend+SplitView.swift index f2710a9c..7603abb5 100644 --- a/Sources/UIKitBackend/UIKitBackend+SplitView.swift +++ b/Sources/UIKitBackend/UIKitBackend+SplitView.swift @@ -8,7 +8,7 @@ import UIKit private let sidebarContainer: ContainerWidget private let mainContainer: ContainerWidget - init(sidebarWidget: any WidgetProtocol, mainWidget: any WidgetProtocol) { + init(sidebarWidget: some WidgetProtocol, mainWidget: some WidgetProtocol) { // UISplitViewController requires its children to be controllers, not views sidebarContainer = ContainerWidget(child: sidebarWidget) mainContainer = ContainerWidget(child: mainWidget) diff --git a/Sources/UIKitBackend/UIViewControllerRepresentable.swift b/Sources/UIKitBackend/UIViewControllerRepresentable.swift index 615af34f..7667a9e5 100644 --- a/Sources/UIKitBackend/UIViewControllerRepresentable.swift +++ b/Sources/UIKitBackend/UIViewControllerRepresentable.swift @@ -148,24 +148,24 @@ final class ControllerRepresentingWidget? - lazy var subcontroller: Representable.UIViewControllerType = - { - let subcontroller = representable.makeUIViewController(context: context!) - - view.addSubview(subcontroller.view) - addChild(subcontroller) - subcontroller.didMove(toParent: self) - - subcontroller.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - subcontroller.view.topAnchor.constraint(equalTo: view.topAnchor), - subcontroller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - subcontroller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - subcontroller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - - return subcontroller - }() + lazy var subcontroller: Representable.UIViewControllerType = { + let subcontroller = representable.makeUIViewController(context: context!) + + view.addSubview(subcontroller.view) + addChild(subcontroller) + + subcontroller.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + subcontroller.view.topAnchor.constraint(equalTo: view.topAnchor), + subcontroller.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + subcontroller.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + subcontroller.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + subcontroller.didMove(toParent: self) + + return subcontroller + }() func update(with environment: EnvironmentValues) { if context == nil { diff --git a/Sources/UIKitBackend/UIViewRepresentable.swift b/Sources/UIKitBackend/UIViewRepresentable.swift index 8a59cb76..0c4f4509 100644 --- a/Sources/UIKitBackend/UIViewRepresentable.swift +++ b/Sources/UIKitBackend/UIViewRepresentable.swift @@ -81,7 +81,7 @@ func defaultViewSize(proposal: SIMD2, view: UIView) -> ViewSize { intrinsicSize.height < 0.0 ? 10 : Int(intrinsicSize.height.rounded(.awayFromZero)) ), minimumWidth: Int(minimumSize.width.rounded(.towardZero)), - minimumHeight: Int(minimumSize.width.rounded(.towardZero)), + minimumHeight: Int(minimumSize.height.rounded(.towardZero)), maximumWidth: maximumSize.width, maximumHeight: maximumSize.height ) From 05ae690e0dbda32b522da083755a276a6b217611 Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Fri, 17 Jan 2025 23:10:17 -0500 Subject: [PATCH 5/7] Explain ContainerWidget --- Sources/UIKitBackend/Widget.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/UIKitBackend/Widget.swift b/Sources/UIKitBackend/Widget.swift index ab287d2d..da334d9a 100644 --- a/Sources/UIKitBackend/Widget.swift +++ b/Sources/UIKitBackend/Widget.swift @@ -276,6 +276,13 @@ class WrapperWidget: BaseViewWidget { } } +/// The root class for widgets who are passed their children on initialization. +/// +/// If a widget is passed an arbitrary child widget on initialization (as opposed to e.g. ``WrapperWidget``, +/// which has a specific non-widget subview), it must be a view controller. If the widget is +/// a view but the child is a controller, that child will not be connected to the parent view +/// controller (as a view can't know what its controller will be during initialization). This +/// widget handles setting up the responder chain during initialization. class ContainerWidget: BaseControllerWidget { let child: any WidgetProtocol From e67b05405e3c6b075cf57e4d7cbf1e4b9ff1b3bf Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Sat, 18 Jan 2025 17:44:41 -0500 Subject: [PATCH 6/7] oops, forgot that --- Sources/UIKitBackend/UIKitBackend+Window.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/UIKitBackend/UIKitBackend+Window.swift b/Sources/UIKitBackend/UIKitBackend+Window.swift index 363b7c6e..451a601c 100644 --- a/Sources/UIKitBackend/UIKitBackend+Window.swift +++ b/Sources/UIKitBackend/UIKitBackend+Window.swift @@ -44,6 +44,7 @@ final class RootViewController: UIViewController { addChild(childController) childController.didMove(toParent: self) } + childWidget = child NSLayoutConstraint.activate([ child.view.widthAnchor.constraint(equalTo: view.safeAreaLayoutGuide.widthAnchor), From 246c69da2723c76da9eab83e81e9ba4ce74cf91a Mon Sep 17 00:00:00 2001 From: Riley Baker Date: Sun, 2 Feb 2025 23:53:40 -0500 Subject: [PATCH 7/7] Address initial PR comments --- .../UIKitBackend/UIKitBackend+Container.swift | 4 +- .../UIViewControllerRepresentable.swift | 24 +++--- Sources/UIKitBackend/Widget.swift | 78 +++++++++++++------ 3 files changed, 69 insertions(+), 37 deletions(-) diff --git a/Sources/UIKitBackend/UIKitBackend+Container.swift b/Sources/UIKitBackend/UIKitBackend+Container.swift index 03c5b649..5375a24a 100644 --- a/Sources/UIKitBackend/UIKitBackend+Container.swift +++ b/Sources/UIKitBackend/UIKitBackend+Container.swift @@ -18,9 +18,9 @@ final class ScrollWidget: ContainerWidget { scrollView.translatesAutoresizingMaskIntoConstraints = false } - override func viewWillLayoutSubviews() { + override func updateViewConstraints() { NSLayoutConstraint.activate(contentLayoutGuideConstraints) - super.viewWillLayoutSubviews() + super.updateViewConstraints() } func setScrollBars( diff --git a/Sources/UIKitBackend/UIViewControllerRepresentable.swift b/Sources/UIKitBackend/UIViewControllerRepresentable.swift index 7667a9e5..cfd73e84 100644 --- a/Sources/UIKitBackend/UIViewControllerRepresentable.swift +++ b/Sources/UIKitBackend/UIViewControllerRepresentable.swift @@ -16,11 +16,11 @@ where Content == Never { -> UIViewControllerType /// Update the view with new values. + /// - Note: This may be called even when `context` has not changed. /// - Parameters: /// - uiViewController: The controller to update. /// - context: The context, including the coordinator and potentially new environment /// values. - /// - Note: This may be called even when `context` has not changed. func updateUIViewController( _ uiViewController: UIViewControllerType, context: UIViewControllerRepresentableContext) @@ -32,31 +32,31 @@ where Content == Never { func makeCoordinator() -> Coordinator /// Compute the view's size. + /// + /// The default implementation uses `uiViewController.view.intrinsicContentSize` and + /// `uiViewController.view.systemLayoutSizeFitting(_:)` to determine the return value. /// - Parameters: /// - proposal: The proposed frame for the view to render in. /// - uiViewController: The controller being queried for its view's preferred size. /// - context: The context, including the coordinator and environment values. /// - Returns: Information about the view's size. The ``SwiftCrossUI/ViewSize/size`` - /// property is what frame the view will actually be rendered with if the current layout - /// pass is not a dry run, while the other properties are used to inform the layout engine - /// how big or small the view can be. The ``SwiftCrossUI/ViewSize/idealSize`` property - /// should not vary with the `proposal`, and should only depend on the view's contents. - /// Pass `nil` for the maximum width/height if the view has no maximum size (and therefore - /// may occupy the entire screen). - /// - /// The default implementation uses `uiViewController.view.intrinsicContentSize` and - /// `uiViewController.view.systemLayoutSizeFitting(_:)` to determine the return value. + /// property is what frame the view will actually be rendered with if the current layout + /// pass is not a dry run, while the other properties are used to inform the layout + /// engine how big or small the view can be. The ``SwiftCrossUI/ViewSize/idealSize`` + /// property should not vary with the `proposal`, and should only depend on the view's + /// contents. Pass `nil` for the maximum width/height if the view has no maximum size + /// (and therefore may occupy the entire screen). func determineViewSize( for proposal: SIMD2, uiViewController: UIViewControllerType, context: UIViewControllerRepresentableContext ) -> ViewSize /// Called to clean up the view when it's removed. + /// + /// The default implementation does nothing. /// - Parameters: /// - uiViewController: The controller being dismantled. /// - coordinator: The coordinator. - /// - /// The default implementation does nothing. static func dismantleUIViewController( _ uiViewController: UIViewControllerType, coordinator: Coordinator) } diff --git a/Sources/UIKitBackend/Widget.swift b/Sources/UIKitBackend/Widget.swift index da334d9a..caf35647 100644 --- a/Sources/UIKitBackend/Widget.swift +++ b/Sources/UIKitBackend/Widget.swift @@ -29,38 +29,70 @@ private protocol WidgetProtocolHelpers: WidgetProtocol { extension WidgetProtocolHelpers { func updateLeftConstraint() { - leftConstraint?.isActive = false - guard let superview = view.superview else { return } - leftConstraint = view.leftAnchor.constraint( - equalTo: superview.safeAreaLayoutGuide.leftAnchor, constant: CGFloat(x)) - // Set the constraint priority for leftConstraint (and topConstraint) to just under - // "required" so that we don't get warnings about unsatisfiable constraints from - // scroll views, which position relative to their contentLayoutGuide instead. - // This *should* be high enough that it won't cause any problems unless there was - // a constraint conflict anyways. - leftConstraint!.priority = .init(UILayoutPriority.required.rawValue - 1.0) - leftConstraint!.isActive = true + guard let superview = view.superview else { + leftConstraint?.isActive = false + return + } + + if let leftConstraint, + leftConstraint.secondAnchor === superview.safeAreaLayoutGuide.leftAnchor + { + leftConstraint.constant = CGFloat(x) + leftConstraint.isActive = true + } else { + self.leftConstraint?.isActive = false + let leftConstraint = view.leftAnchor.constraint( + equalTo: superview.safeAreaLayoutGuide.leftAnchor, constant: CGFloat(x)) + self.leftConstraint = leftConstraint + // Set the constraint priority for leftConstraint (and topConstraint) to just + // under "required" so that we don't get warnings about unsatisfiable constraints + // from scroll views, which position relative to their contentLayoutGuide instead. + // This *should* be high enough that it won't cause any problems unless there was + // a constraint conflict anyways. + leftConstraint.priority = .init(UILayoutPriority.required.rawValue - 1.0) + leftConstraint.isActive = true + } } func updateTopConstraint() { - topConstraint?.isActive = false - guard let superview = view.superview else { return } - topConstraint = view.topAnchor.constraint( - equalTo: superview.safeAreaLayoutGuide.topAnchor, constant: CGFloat(y)) - topConstraint!.priority = .init(UILayoutPriority.required.rawValue - 1.0) - topConstraint!.isActive = true + guard let superview = view.superview else { + topConstraint?.isActive = false + return + } + + if let topConstraint, + topConstraint.secondAnchor === superview.safeAreaLayoutGuide.topAnchor + { + topConstraint.constant = CGFloat(y) + topConstraint.isActive = true + } else { + self.topConstraint?.isActive = false + let topConstraint = view.topAnchor.constraint( + equalTo: superview.safeAreaLayoutGuide.topAnchor, constant: CGFloat(y)) + self.topConstraint = topConstraint + topConstraint.priority = .init(UILayoutPriority.required.rawValue - 1.0) + topConstraint.isActive = true + } } func updateWidthConstraint() { - widthConstraint?.isActive = false - widthConstraint = view.widthAnchor.constraint(equalToConstant: CGFloat(width)) - widthConstraint!.isActive = true + if let widthConstraint { + widthConstraint.constant = CGFloat(width) + } else { + let widthConstraint = view.widthAnchor.constraint(equalToConstant: CGFloat(width)) + self.widthConstraint = widthConstraint + widthConstraint.isActive = true + } } func updateHeightConstraint() { - heightConstraint?.isActive = false - heightConstraint = view.heightAnchor.constraint(equalToConstant: CGFloat(height)) - heightConstraint!.isActive = true + if let heightConstraint { + heightConstraint.constant = CGFloat(height) + } else { + let heightConstraint = view.heightAnchor.constraint(equalToConstant: CGFloat(height)) + self.heightConstraint = heightConstraint + heightConstraint.isActive = true + } } }