diff --git a/Examples/Package.resolved b/Examples/Package.resolved index 4986771fb..4a1fbaca4 100644 --- a/Examples/Package.resolved +++ b/Examples/Package.resolved @@ -27,6 +27,15 @@ "version" : "1.6.45" } }, + { + "identity" : "libwebp", + "kind" : "remoteSourceControl", + "location" : "https://github.com/the-swift-collective/libwebp", + "state" : { + "revision" : "5f745a17b9a5c2a4283f17c2cde4517610ab5f99", + "version" : "1.4.1" + } + }, { "identity" : "pathkit", "kind" : "remoteSourceControl", @@ -130,8 +139,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-image-formats", "state" : { - "revision" : "05a0169a2a5e9365a058e9aa13da5937be6e2586", - "version" : "0.3.1" + "revision" : "697bd8aa62bef7d74a1383454e3534a527769e41", + "version" : "0.3.2" } }, { @@ -143,15 +152,6 @@ "version" : "0.4.0" } }, - { - "identity" : "swift-libwebp", - "kind" : "remoteSourceControl", - "location" : "https://github.com/stackotter/swift-libwebp", - "state" : { - "revision" : "61dc3787c764022ad2f5ab4f9994a569afe86f9f", - "version" : "0.2.0" - } - }, { "identity" : "swift-log", "kind" : "remoteSourceControl", diff --git a/Package.resolved b/Package.resolved index 408b2c0a4..b6420789d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "9bdf10c7a21892e3f103174fa966d5a212af3dfca1608849b243319bfbb3d005", + "originHash" : "d7660de3524ddfd94fb5ed75c380bbcda1ec9d99cc0b74dee8d2f70756063c78", "pins" : [ { "identity" : "jpeg", @@ -19,6 +19,15 @@ "version" : "1.6.45" } }, + { + "identity" : "libwebp", + "kind" : "remoteSourceControl", + "location" : "https://github.com/the-swift-collective/libwebp", + "state" : { + "revision" : "5f745a17b9a5c2a4283f17c2cde4517610ab5f99", + "version" : "1.4.1" + } + }, { "identity" : "swift-cwinrt", "kind" : "remoteSourceControl", @@ -51,17 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/stackotter/swift-image-formats", "state" : { - "revision" : "05a0169a2a5e9365a058e9aa13da5937be6e2586", - "version" : "0.3.1" - } - }, - { - "identity" : "swift-libwebp", - "kind" : "remoteSourceControl", - "location" : "https://github.com/stackotter/swift-libwebp", - "state" : { - "revision" : "61dc3787c764022ad2f5ab4f9994a569afe86f9f", - "version" : "0.2.0" + "revision" : "697bd8aa62bef7d74a1383454e3534a527769e41", + "version" : "0.3.2" } }, { diff --git a/Package.swift b/Package.swift index 1ae7b31fe..4865160ee 100644 --- a/Package.swift +++ b/Package.swift @@ -95,7 +95,7 @@ let package = Package( ), .package( url: "https://github.com/stackotter/swift-image-formats", - .upToNextMinor(from: "0.3.1") + .upToNextMinor(from: "0.3.2") ), .package( url: "https://github.com/wabiverse/swift-windowsappsdk", diff --git a/Sources/AppKitBackend/BaseWidget.swift b/Sources/AppKitBackend/BaseWidget.swift new file mode 100644 index 000000000..487a96b02 --- /dev/null +++ b/Sources/AppKitBackend/BaseWidget.swift @@ -0,0 +1,123 @@ +import AppKit + +public class BaseWidget: NSView { + 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 } + if #available(macOS 11.0, *) { + leftConstraint = self.leftAnchor.constraint( + equalTo: superview.safeAreaLayoutGuide.leftAnchor, constant: CGFloat(x)) + } else { + leftConstraint = self.leftAnchor.constraint( + equalTo: superview.leftAnchor, constant: CGFloat(x)) + } + leftConstraint!.isActive = true + } + + private func updateTopConstraint() { + topConstraint?.isActive = false + guard let superview else { return } + if #available(macOS 11.0, *) { + topConstraint = self.topAnchor.constraint( + equalTo: superview.safeAreaLayoutGuide.topAnchor, constant: CGFloat(x)) + } else { + topConstraint = self.topAnchor.constraint( + equalTo: superview.topAnchor, constant: CGFloat(x)) + } + 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 viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + + updateLeftConstraint() + updateTopConstraint() + } +} + +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/AppKitBackend/NSViewRepresentable.swift b/Sources/AppKitBackend/NSViewRepresentable.swift new file mode 100644 index 000000000..52763e511 --- /dev/null +++ b/Sources/AppKitBackend/NSViewRepresentable.swift @@ -0,0 +1,206 @@ +import SwiftCrossUI +import AppKit + +public struct NSViewRepresentableContext { + public let coordinator: Coordinator + public internal(set) var environment: EnvironmentValues +} + +public protocol NSViewRepresentable: View where Content == Never { + associatedtype NSViewType: NSView + associatedtype Coordinator = Void + + /// Create the initial NSView instance. + @MainActor + func makeNSView(context: NSViewRepresentableContext) -> NSViewType + + /// Update the view with new values. + /// - Parameters: + /// - nsView: The view to update. + /// - context: The context, including the coordinator and potentially new environment + /// values. + /// - Note: This may be called even when `context` has not changed. + @MainActor + func updateNSView(_ nsView: NSViewType, context: NSViewRepresentableContext) + + /// Make the coordinator for this view. + /// + /// The coordinator is used when the view needs to communicate changes to the rest of + /// the view hierarchy (i.e. through bindings), and is often the view's delegate. + func makeCoordinator() -> Coordinator + + /// Compute the view's size. + /// - Parameters: + /// - proposal: The proposed frame for the view to render in. + /// - nsVIew: 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 + /// 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 `nsView.intrinsicContentSize` and `nsView.sizeThatFits(_:)` + /// to determine the return value. + func determineViewSize( + for proposal: SIMD2, nsView: NSViewType, + context: NSViewRepresentableContext + ) -> ViewSize + + /// Called to clean up the view when it's removed. + /// - Parameters: + /// - nsVIew: The view being dismantled. + /// - coordinator: The coordinator. + /// + /// This method is called after all AppKit lifecycle methods, such as + /// `nsView.didMoveToSuperview()`. + /// + /// The default implementation does nothing. + static func dismantleNSView(_ nsView: NSViewType, coordinator: Coordinator) +} + +extension NSViewRepresentable { + public static func dismantleNSView(_: NSViewType, coordinator _: Coordinator) { + // no-op + } + + public func determineViewSize( + for proposal: SIMD2, nsView: NSViewType, + context _: NSViewRepresentableContext + ) -> ViewSize { + let intrinsicSize = nsView.intrinsicContentSize + let sizeThatFits = nsView.fittingSize + + 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 + ) + } +} + +extension View where Self: NSViewRepresentable { + 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 = RepresentingWidget(representable: self) as? Backend.Widget { + return widget + } else { + fatalError("NSViewRepresentable 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! RepresentingWidget + representingWidget.update(with: environment) + + let size = + representingWidget.representable.determineViewSize( + for: proposedSize, + nsView: representingWidget.subview, + context: representingWidget.context! + ) + + if !dryRun { + representingWidget.width = size.size.x + representingWidget.height = size.size.y + } + + return ViewUpdateResult.leafView(size: size) + } +} + +extension NSViewRepresentable where Coordinator == Void { + public func makeCoordinator() { + return () + } +} + + +final class RepresentingWidget: BaseWidget { + var representable: Representable + var context: NSViewRepresentableContext? + + lazy var subview: Representable.NSViewType = { + let view = representable.makeNSView(context: context!) + + self.addSubview(view) + + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: self.topAnchor), + view.leadingAnchor.constraint(equalTo: self.leadingAnchor), + view.trailingAnchor.constraint(equalTo: self.trailingAnchor), + view.bottomAnchor.constraint(equalTo: self.bottomAnchor), + ]) + + return view + }() + + func update(with environment: EnvironmentValues) { + if context == nil { + context = .init(coordinator: representable.makeCoordinator(), environment: environment) + } else { + context!.environment = environment + representable.updateNSView(subview, context: context!) + } + } + + init(representable: Representable) { + self.representable = representable + super.init() + } + + deinit { + if let context { + Representable.dismantleNSView(subview, coordinator: context.coordinator) + } + } +} diff --git a/Sources/UIKitBackend/UIKitBackend+Passive.swift b/Sources/UIKitBackend/UIKitBackend+Passive.swift index 3a4aa6251..8bcbcfb42 100644 --- a/Sources/UIKitBackend/UIKitBackend+Passive.swift +++ b/Sources/UIKitBackend/UIKitBackend+Passive.swift @@ -15,7 +15,7 @@ extension UIKitBackend { case .leading: .natural case .trailing: - UIScreen.main.traitCollection.layoutDirection == .rightToLeft ? .left : .right + UITraitCollection.current.layoutDirection == .rightToLeft ? .left : .right } paragraphStyle.lineBreakMode = .byWordWrapping diff --git a/Sources/UIKitBackend/UIKitBackend.swift b/Sources/UIKitBackend/UIKitBackend.swift index 59a0a0d08..8cd47ba4f 100644 --- a/Sources/UIKitBackend/UIKitBackend.swift +++ b/Sources/UIKitBackend/UIKitBackend.swift @@ -71,7 +71,7 @@ public final class UIKitBackend: AppBackend { design: .default ) - switch UIScreen.main.traitCollection.userInterfaceStyle { + switch UITraitCollection.current.userInterfaceStyle { case .light: environment.colorScheme = .light case .dark: diff --git a/Sources/UIKitBackend/UIViewRepresentable.swift b/Sources/UIKitBackend/UIViewRepresentable.swift index 0c4f45093..2bad1c9a8 100644 --- a/Sources/UIKitBackend/UIViewRepresentable.swift +++ b/Sources/UIKitBackend/UIViewRepresentable.swift @@ -12,6 +12,7 @@ where Content == Never { associatedtype Coordinator = Void /// Create the initial UIView instance. + @MainActor func makeUIView(context: UIViewRepresentableContext) -> UIViewType /// Update the view with new values. @@ -20,12 +21,14 @@ where Content == Never { /// - context: The context, including the coordinator and potentially new environment /// values. /// - Note: This may be called even when `context` has not changed. + @MainActor func updateUIView(_ uiView: UIViewType, context: UIViewRepresentableContext) /// Make the coordinator for this view. /// /// The coordinator is used when the view needs to communicate changes to the rest of /// the view hierarchy (i.e. through bindings), and is often the view's delegate. + @MainActor func makeCoordinator() -> Coordinator /// Compute the view's size. @@ -43,6 +46,7 @@ where Content == Never { /// /// The default implementation uses `uiView.intrinsicContentSize` and `uiView.systemLayoutSizeFitting(_:)` /// to determine the return value. + @MainActor func determineViewSize( for proposal: SIMD2, uiView: UIViewType, context: UIViewRepresentableContext