From e93876ba342e4bfdce8f92870d1c2ef4065946ee Mon Sep 17 00:00:00 2001 From: Mike Schreiber Date: Wed, 29 Jan 2025 11:11:57 -0800 Subject: [PATCH] Fix ObjC export of MultilinePillPickerView (#2122) --- .../MultilinePillPickerView.swift | 60 +++++++++++-------- .../Core/ControlHostingView.swift | 59 ++++++++++++++---- 2 files changed, 84 insertions(+), 35 deletions(-) diff --git a/Sources/FluentUI_macOS/Components/MultilinePillPicker/MultilinePillPickerView.swift b/Sources/FluentUI_macOS/Components/MultilinePillPicker/MultilinePillPickerView.swift index 004ef12f2..db88a985f 100644 --- a/Sources/FluentUI_macOS/Components/MultilinePillPicker/MultilinePillPickerView.swift +++ b/Sources/FluentUI_macOS/Components/MultilinePillPicker/MultilinePillPickerView.swift @@ -4,12 +4,13 @@ // import AppKit +import Combine import SwiftUI /// This is a work-in-progress control for hosting multiple rows of pill buttons. At present, this control /// only supports a hard-coded two rows of elements. @objc(MSFMultilinePillPickerView) -public final class MultilinePillPickerView: ControlHostingView { +public final class MultilinePillPickerView: ControlHostingView, ObservableObject { /// Creates a multiline pill picker. /// - Parameters: /// - labels: An array of labels to show in the picker. @@ -18,37 +19,48 @@ public final class MultilinePillPickerView: ControlHostingView { @MainActor public init(labels: [String], action: (@MainActor (Int) -> Void)? = nil) { self.labels = labels self.action = action - let picker = MultilinePillPicker(labels: labels, action: action) - super.init(AnyView(picker)) + super.init(AnyView(EmptyView())) + + let wrapper = MultilinePillPickerWrapper(viewModel: viewModel) + self.hostingView.rootView = AnyView(wrapper) + + // Set up observation to keep the view model in sync. + bindProperty(from: self.$isEnabled, to: \.isEnabled, on: viewModel) + bindProperty(from: self.$labels, to: \.labels, on: viewModel) + bindProperty(from: self.$action, to: \.action, on: viewModel) } @MainActor required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") + preconditionFailure("init(coder:) has not been implemented") } @MainActor required init(rootView: AnyView) { - fatalError("init(rootView:) has not been implemented") + preconditionFailure("init(rootView:) has not been implemented") } - @MainActor public var isEnabled: Bool = true { - didSet { - updatePicker() - } - } - @MainActor public var labels: [String] { - didSet { - updatePicker() - } - } - @MainActor public var action: (@MainActor (Int) -> Void)? { - didSet { - updatePicker() - } - } + @MainActor @Published public var isEnabled: Bool = true + @MainActor @Published public var labels: [String] + @MainActor @Published public var action: (@MainActor (Int) -> Void)? + + @MainActor private let viewModel: MultilinePillPickerViewModel = .init() +} + +/// Maps properties from `MultilinePillPickerView` to `MultilinePillPickerViewWrapper`. +fileprivate class MultilinePillPickerViewModel: ObservableObject { + @Published var isEnabled: Bool = true + @Published var labels: [String] = [] + @Published var action: (@MainActor (Int) -> Void)? +} + +/// Private wrapper `View` to map from view model to `MultilinePillPicker`. +fileprivate struct MultilinePillPickerWrapper: View { + @ObservedObject var viewModel: MultilinePillPickerViewModel - private func updatePicker() { - let picker = MultilinePillPicker(labels: labels, action: action) - .disabled(!isEnabled) - rootView = AnyView(picker) + var body: some View { + MultilinePillPicker( + labels: viewModel.labels, + action: viewModel.action + ) + .disabled(!viewModel.isEnabled) } } diff --git a/Sources/FluentUI_macOS/Core/ControlHostingView.swift b/Sources/FluentUI_macOS/Core/ControlHostingView.swift index 34ac8b0bc..fd6d2ee35 100644 --- a/Sources/FluentUI_macOS/Core/ControlHostingView.swift +++ b/Sources/FluentUI_macOS/Core/ControlHostingView.swift @@ -4,10 +4,18 @@ // import AppKit +import Combine import SwiftUI -/// Common wrapper for hosting and exposing SwiftUI components to AppKit-based clients. -open class ControlHostingView: NSHostingView { +/// Common wrapper for hosting and exposing SwiftUI components to UIKit-based clients. +open class ControlHostingView: NSView { + + /// The intrinsic content size of the wrapped SwiftUI view. + @objc public override var intrinsicContentSize: CGSize { + // Our desired size should always be the same as our hosted view. + return hostingView.intrinsicContentSize + } + /// Initializes and returns an instance of `ControlHostingContainer` that wraps `controlView`. /// /// Unfortunately this class can't use Swift generics, which are incompatible with Objective-C interop. Instead we have to wrap @@ -17,21 +25,50 @@ open class ControlHostingView: NSHostingView { /// - Parameter safeAreaRegions: Passthrough to the respective property on NSHostingView. /// Indicates which safe area regions the underlying hosting controller should add to its view. public init(_ controlView: AnyView, safeAreaRegions: SafeAreaRegions = .all) { - super.init(rootView: controlView) + hostingView = NSHostingView.init(rootView: controlView) if #available(macOS 13.3, *) { - self.sizingOptions = [.intrinsicContentSize] - self.safeAreaRegions = safeAreaRegions + hostingView.sizingOptions = [.intrinsicContentSize] + hostingView.safeAreaRegions = safeAreaRegions } - layer?.backgroundColor = .clear - translatesAutoresizingMaskIntoConstraints = false + super.init(frame: .zero) + + self.configureHostedView() } - @MainActor @preconcurrency required public init?(coder: NSCoder) { + required public init?(coder: NSCoder) { preconditionFailure("init(coder:) has not been implemented") } - - @MainActor @preconcurrency required public init(rootView: AnyView) { - preconditionFailure("init(rootView:) has not been implemented") + + let hostingView: NSHostingView + var cancellables: Set = [] + + // Helper function to facilitate binding ourselves to a ViewModel. + func bindProperty( + from source: Published.Publisher, + to viewModelKeyPath: ReferenceWritableKeyPath, + on viewModel: Root + ) { + source + .sink { [weak viewModel] newValue in + viewModel?[keyPath: viewModelKeyPath] = newValue + } + .store(in: &cancellables) + } + + /// Adds `hostingController.view` to ourselves as a subview, and enables necessary constraints. + private func configureHostedView() { + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + + addSubview(hostingView) + hostingView.translatesAutoresizingMaskIntoConstraints = false + + let requiredConstraints = [ + hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), + hostingView.bottomAnchor.constraint(equalTo: bottomAnchor), + hostingView.topAnchor.constraint(equalTo: topAnchor) + ] + self.addConstraints(requiredConstraints) } }