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..5375a24a 100644 --- a/Sources/UIKitBackend/UIKitBackend+Container.swift +++ b/Sources/UIKitBackend/UIKitBackend+Container.swift @@ -1,24 +1,26 @@ import SwiftCrossUI import UIKit -final class ScrollWidget: WrapperWidget { +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 updateViewConstraints() { + NSLayoutConstraint.activate(contentLayoutGuideConstraints) + super.updateViewConstraints() } func setScrollBars( @@ -29,8 +31,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 +44,8 @@ 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) + container.add(childWidget: child) } 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+SplitView.swift b/Sources/UIKitBackend/UIKitBackend+SplitView.swift new file mode 100644 index 00000000..7603abb5 --- /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: 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 diff --git a/Sources/UIKitBackend/UIKitBackend+Window.swift b/Sources/UIKitBackend/UIKitBackend+Window.swift index 51accf23..451a601c 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,21 @@ 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) + } + childWidget = child 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 +85,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/UIViewControllerRepresentable.swift b/Sources/UIKitBackend/UIViewControllerRepresentable.swift new file mode 100644 index 00000000..cfd73e84 --- /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. + /// - 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. + 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. + /// + /// 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). + 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. + 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.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 { + 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 5d2d6c93..0c4f4509 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.height.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 = @@ -165,7 +166,7 @@ where Coordinator == Void { } } -final class RepresentingWidget: BaseWidget { +final class ViewRepresentingWidget: 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..caf35647 --- /dev/null +++ b/Sources/UIKitBackend/Widget.swift @@ -0,0 +1,352 @@ +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 set } + + func add(childWidget: some WidgetProtocol) + func removeFromParentWidget() +} + +extension UIKitBackend { + public typealias Widget = any WidgetProtocol +} + +private 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() { + 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() { + 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() { + if let widthConstraint { + widthConstraint.constant = CGFloat(width) + } else { + let widthConstraint = view.widthAnchor.constraint(equalToConstant: CGFloat(width)) + self.widthConstraint = widthConstraint + widthConstraint.isActive = true + } + } + + func updateHeightConstraint() { + if let heightConstraint { + heightConstraint.constant = CGFloat(height) + } else { + let heightConstraint = view.heightAnchor.constraint(equalToConstant: CGFloat(height)) + self.heightConstraint = heightConstraint + 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(childWidget: some WidgetProtocol) { + if childWidget.parentWidget === self { return } + childWidget.removeFromParentWidget() + + 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() { + 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(childWidget: some WidgetProtocol) { + if childWidget.parentWidget === self { return } + childWidget.removeFromParentWidget() + + let childController = childWidget.controller + + view.addSubview(childWidget.view) + + if let childController { + addChild(childController) + childController.didMove(toParent: self) + } + + childWidgets.append(childWidget) + childWidget.parentWidget = 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 + } +} + +/// 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 + + init(child: some WidgetProtocol) { + self.child = child + super.init() + 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) + } +}