Skip to content

Commit

Permalink
[macOS] Multiline Pill Picker (microsoft#2121)
Browse files Browse the repository at this point in the history
* First pass on MultilinePillPicker

* Add disabling to AppKit wrapper and demo controller

* Fix deprecated API

* Use line width constant for padding

* Remove @mainactor from WIP code

* Comment updates
  • Loading branch information
mischreiber authored Jan 28, 2025
1 parent 1dfcb5f commit fa970f4
Show file tree
Hide file tree
Showing 8 changed files with 226 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
//

import AppKit
import FluentUI
import SwiftUI

class TestMultilinePillPickerViewController: NSViewController {
override func loadView() {
let containerView = NSStackView(frame: .zero)
containerView.orientation = .vertical

let pillPickerView = MultilinePillPickerView(labels: labels) { [weak self] index in
self?.pillButtonPressed(index)
}
containerView.addView(pillPickerView, in: .center)
self.pillPickerView = pillPickerView

let checkbox = NSButton(checkboxWithTitle: "Enabled", target: self, action: #selector(toggleEnabled(_:)))
checkbox.state = .on
containerView.addView(checkbox, in: .center)

view = containerView
}

@objc func toggleEnabled(_ sender: NSButton?) {
pillPickerView?.isEnabled = sender?.state == .on
}

func pillButtonPressed(_ index: Int) {
guard let window = NSApplication.shared.mainWindow else {
print("No window -- selected index \(index)")
return
}
let alert = NSAlert()
alert.messageText = "Suggestion clicked"
alert.informativeText = "\(labels[index])"
alert.addButton(withTitle: "OK")
alert.beginSheetModal(for: window)
}

private let labels: [String] = [
"One",
"Two",
"Three",
"Four",
"Five",
"Six",
"Seven",
"Eight",
"Nine",
]

private var pillPickerView: MultilinePillPickerView?
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public let testViewControllers = [TestViewController(title: "Avatar View",
type: TestFilledTemplateImageViewController.self),
TestViewController(title: "Link",
type: TestLinkViewController.self),
TestViewController(title: "Multiline Pill Picker",
type: TestMultilinePillPickerViewController.self),
TestViewController(title: "Notification Bar View",
type: TestNotificationBarViewController.self),
TestViewController(title: "Separator",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
9252C6222C62A8B3009C9272 /* FluentUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9252C6212C62A8B3009C9272 /* FluentUI */; };
92AD71232D4062050089499E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8F5368062295F4C10098AC8F /* Assets.xcassets */; };
92AD71252D4062080089499E /* FluentUI in Frameworks */ = {isa = PBXBuildFile; productRef = 92AD71242D4062080089499E /* FluentUI */; };
92AD71272D4064B50089499E /* TestMultilinePillPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92AD71262D4064B00089499E /* TestMultilinePillPickerViewController.swift */; };
9B4AEBAB2705206300B68020 /* TestFilledTemplateImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B4AEBAA2705206300B68020 /* TestFilledTemplateImageViewController.swift */; };
9B8661772A4F5DAE00FA4F78 /* TestColorProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B8661752A4F5C4800FA4F78 /* TestColorProvider.swift */; };
A257F81E2512DE45002CAA6E /* TestColorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A257F81C2512DDF7002CAA6E /* TestColorViewController.swift */; };
Expand Down Expand Up @@ -141,6 +142,7 @@
8F931A6C22BD593300311764 /* FluentUI_unittest.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = FluentUI_unittest.xcconfig; sourceTree = "<group>"; };
9252C61E2C62A881009C9272 /* fluentui-apple */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "fluentui-apple"; path = ../../..; sourceTree = "<group>"; };
92AD711E2D4061340089499E /* FluentUIUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluentUIUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
92AD71262D4064B00089499E /* TestMultilinePillPickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMultilinePillPickerViewController.swift; sourceTree = "<group>"; };
9B4AEBAA2705206300B68020 /* TestFilledTemplateImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestFilledTemplateImageViewController.swift; sourceTree = "<group>"; };
9B8661752A4F5C4800FA4F78 /* TestColorProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestColorProvider.swift; sourceTree = "<group>"; };
A257F81C2512DDF7002CAA6E /* TestColorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestColorViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -302,6 +304,7 @@
8061BF8222EF957200F2D245 /* TestDatePickerController.swift */,
9B4AEBAA2705206300B68020 /* TestFilledTemplateImageViewController.swift */,
E61C96D622987B3C0006561F /* TestLinkViewController.swift */,
92AD71262D4064B00089499E /* TestMultilinePillPickerViewController.swift */,
3A8CB0E12996CD6400B68FCF /* TestNotificationBarViewController.swift */,
AC97EFE8247FAB1D00DADC99 /* TestSeparatorViewController.swift */,
E6A92D4E24BEAEEA00562BCA /* TestViewControllers.swift */,
Expand Down Expand Up @@ -636,6 +639,7 @@
E6A92D2E24BEA8AC00562BCA /* TestAvatarViewController.swift in Sources */,
E6A92D4F24BEAEEA00562BCA /* TestViewControllers.swift in Sources */,
9B8661772A4F5DAE00FA4F78 /* TestColorProvider.swift in Sources */,
92AD71272D4064B50089499E /* TestMultilinePillPickerViewController.swift in Sources */,
E6A92D3124BEA8AC00562BCA /* TestDatePickerController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
6 changes: 6 additions & 0 deletions MicrosoftFluentUI.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,12 @@ fi', :execution_position => :before_compile }
link_mac.source_files = ["#{macos_root}/#{components_dir}/Link/**/*.{swift,h}"]
end

s.subspec 'MultilinePillPicker_mac' do |multilinepillpicker_mac|
multilinepillpicker_mac.platform = :osx
multilinepillpicker_mac.dependency "#{s.name}/Core_mac"
multilinepillpicker_mac.source_files = ["#{macos_root}/#{components_dir}/MultilinePillPicker/**/*.{swift,h}"]
end

s.subspec 'Separator_mac' do |separator_mac|
separator_mac.platform = :osx
separator_mac.dependency "#{s.name}/Core_mac"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
//

import SwiftUI

/// This is a simple control for hosting multiple rows of pill buttons. At present, this control only
/// supports a hard-coded two rows of elements, but more flexibility may be added in the future.
public struct MultilinePillPicker: View {
/// Creates a multiline pill picker.
/// - Parameters:
/// - labels: An array of labels to show in the picker.
/// - action: An action to invoke when a pill is selected in the picker. Includes the index of the item being selected.
public init(labels: [String], action: ((Int) -> Void)? = nil) {
self.labels = labels
self.action = action
}

public var body: some View {
VStack(alignment: .leading, spacing: spacing) {
// Bias towards the first row
let midIndex = Int(ceil(Double(labels.count) / 2.0))
row(0..<midIndex)
row(midIndex..<labels.count)
}
.frame(alignment: .center)
.padding(lineWidth)
}

@ViewBuilder
private func button(_ index: Int) -> some View {
SwiftUI.Button(action: {
action?(index)
}, label: {
Text(labels[index])
.padding(.vertical, paddingVertical)
.padding(.horizontal, paddingHorizontal)
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.stroke(Color(nsColor: Colors.primaryTint10), lineWidth: lineWidth)
)
})
.buttonStyle(.plain)
}

@ViewBuilder
private func row(_ range: Range<Int>) -> some View {
HStack(spacing: spacing) {
ForEach(range, id: \.self) { index in
button(index)
}
}
}

private let labels: [String]
private let action: ((Int) -> Void)?

// Constants
private let cornerRadius: CGFloat = 6.0
private let lineWidth: CGFloat = 1.0
private let paddingHorizontal: CGFloat = 8.0
private let paddingVertical: CGFloat = 4.0
private let spacing: CGFloat = 4.0
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
//

import AppKit
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 {
/// Creates a multiline pill picker.
/// - Parameters:
/// - labels: An array of labels to show in the picker.
/// - action: An action to invoke when a pill is selected in the picker. Includes the index of the item being selected.
@objc(initWithLabels:action:)
@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))
}

@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

@MainActor required init(rootView: AnyView) {
fatalError("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()
}
}

private func updatePicker() {
let picker = MultilinePillPicker(labels: labels, action: action)
.disabled(!isEnabled)
rootView = AnyView(picker)
}
}
37 changes: 37 additions & 0 deletions Sources/FluentUI_macOS/Core/ControlHostingView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
//

import AppKit
import SwiftUI

/// Common wrapper for hosting and exposing SwiftUI components to AppKit-based clients.
open class ControlHostingView: NSHostingView<AnyView> {
/// 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
/// the control view in an `AnyView.`
///
/// - Parameter controlView: An `AnyView`-wrapped component to host.
/// - 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)
if #available(macOS 13.3, *) {
self.sizingOptions = [.intrinsicContentSize]
self.safeAreaRegions = safeAreaRegions
}

layer?.backgroundColor = .clear
translatesAutoresizingMaskIntoConstraints = false
}

@MainActor @preconcurrency 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")
}
}
2 changes: 1 addition & 1 deletion scripts/removeUnusedResourcesFromAssets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func findUsedResources(in rootPath: String) -> Set<String> {
#endif

do {
let resourceFileListContents = try String(contentsOf: fileURL)
let resourceFileListContents = try String(contentsOf: fileURL, encoding: .utf8)

for entry in resourceFileListContents.split(separator: "\n") {
let resourceFileEntry = entry.trimmingCharacters(in: .whitespacesAndNewlines)
Expand Down

0 comments on commit fa970f4

Please sign in to comment.