CocoaCompose

一系列 Cocoa 控件,外观恰到好处,提供现代化的 Swift API,并且能够很好地组合在一起。

Build and Test Mastodon

CocoaCompose 的创建是为了更容易地开发 Proxygen Mac 应用程序,这是一个用于测试应用和调试远程 API 端点的 HTTP 代理工具。

Proxygen app icon

Download on the App Store

用法

在 Xcode 中,在“项目”>“Package Dependencies”下添加 CocoaCompose。

然后如下所示导入它

import CocoaCompose

组件

CocoaCompose 包括以下组件

以下封装器有助于在首选项窗口中布局内容

所有组件都配置为开箱即用,在 Mac 应用程序中看起来很合适,并且具有易于使用的初始化程序,并接受用于值更改的闭包。默认情况下,所有组件都设置为动态类型 NSFont.TextStyle.body

Box (盒子)

Box 结合了一个标题标签和一个灰色着色的包装视图。

let box = Box(title: "Title", orientation: .vertical, views: [
    ...
])

Box

Button (按钮)

基本的 NSButton,其 bezelStyle 设置为 .rounded。可以使用标题和带有符号配置的可选图像进行配置。

let image = NSImage(systemSymbolName: "checkmark.seal.fill", accessibilityDescription: nil)
let configuration = NSImage.SymbolConfiguration(paletteColors: [.white, .systemGreen])

let button = Button(title: "Click Me", image: image, symbolConfiguration: configuration) {
    // do something here ...
}

Button

CalendarPicker (日历选择器)

CalendarPicker 是一个 NSDatePicker,其 datePickerStyle 设置为 .clockAndCalendar,并且 datePickerElements 配置为 .yearMonthDay

使用 dateminDatemaxDate 对其进行配置。

let picker = CalendarPicker(date: .now) { date in
    // do something here ...
}

CalendarPicker

Checkbox (复选框)

Checkbox 是一个 NSButton,其 buttonType 设置为 .switch。它接受标题和一个简单的布尔值来表示选中状态。

let checkbox = Checkbox(title: "Select something", isOn: true) { enabled in
    // do something here ...
}

使用 isOn 属性访问其选中状态。

let checked = checkbox.isOn

Checkbox

ClockPicker (时钟选择器)

ClockPicker 是一个 NSDatePicker,其 datePickerStyle 设置为 .clockAndCalendar,并且 datePickerElements 配置为 .hourMinuteSecond.hourMinute。 使用 dateminDatemaxDate 初始化它。

let picker = ClockPicker(date: .now) { date in
    // do something here ...
}

picker.showSeconds = true

ClockPicker

ColorWell (颜色井)

NSColorWellcolorWellStyle 设置为 .default.minimal.expanded。 使用 color 值配置它。 请注意,其他样式选项仅在 macOS 13.0 及更高版本中可用。

let colorWell = ColorWell(color: .blue) { color in
    // do something here ...
}

ColorWell

DatePicker (日期选择器)

DatePicker 是一个 NSDatePicker,其 datePickerStyle 设置为 .textFieldAndStepper.textField,并且 datePickerElements 配置为 .yearMonthDay.yearMonth。 使用 dateminDatemaxDate 初始化它。

let picker = DatePicker(date: .now) { date in
    // do something here ...
}

显示选择器的步进器。

picker.showStepper = true

显示选择器的天数。

picker.showDays = true

DatePicker

FontPicker (字体选择器)

FontPicker 是一个 NSButton,它使用 NSFontPanelNSFontManager 来显示字体选择面板。 使用 font 和可选标题初始化它。

如果未设置按钮标题,则将使用当前选定的字体显示当前字体显示名称。

let picker = FontPicker(font: myFont) { font in
    // do something here ...
}

更新所选字体。

picker.selectedFont = .preferredFont(forTextStyle: .body)

FontPicker

Image (图像)

Image 是一个 NSImageView,具有可选的 onClick 处理程序和 CGSize

let view = Image(image: myImage)
let view = Image(named: "App Icon")
let view = Image(systemSymbolName: "tortoise")

Label (标签)

Label 是一个禁用背景和边框绘制的 NSTextField。它还接受一个 NSAttributedString 作为值。

let label = Label(string: "Hello")
label.stringValue = "Hello world!"

Level (等级指示器)

Level 是一个 NSLevelIndicator,其 levelIndicatorStyle 设置为 .continuousCapacity。 使用 valueminValuemaxValue 初始化它。

let level = Level(value: 0.3, minValue: 0, maxValue: 1) { value in
    // do something here ...
}

Level

PopUp (弹出框)

PopUpNSPopUpButton 和可选的尾随文本标签组合到一个控件中。使用项目数组设置它,这些项目具有标题和可选的 NSImage,以及当前选择的索引。 对于没有选择,使用 selectedIndex 值 -1。

let popup = PopUp(items: [PopUp.Item(title: "Orange", image: image)] }, selectedIndex: 0, trailingText: "flag") { item in
    // do something here ...
}

为更改的选择设置回调。

popup.onChange = { item in
    // do something here ...
}

配置其项目和选定的项目。

popup.items = ["One", "Two", "Three"].map { .init(title: $0) }
popup.selectedIndex = 1

PopUp

Radio (单选按钮)

Radio 是一个垂直堆叠的 NSButton 控件,其 buttonType 设置为 .radio。 使用可选的 selectedIndex 参数初始化此组件,其中 -1 表示未选择。

您可以在单选按钮项目之后附加一个视图的水平堆栈,以将此选项与其他控件(例如 TextField)组合在一起。 这些尾随视图会自动为当前选定的项目启用,并为其他项目禁用。

let radio = Radio(items: [
    Radio.Item(title: "First"),
    Radio.Item(title: "Second", views: [
        TextField(value: "30", trailingText: "seconds") { text in
            // do something here ...
        },
    ])
    
], selectedIndex: 0) { index, previousIndex in
    // do something here ...
}

配置其选定的项目。

radio.selectedIndex = 1

Radio

ScrollView (滚动视图)

ScrollView 是一个 NSScrollView,它将 NSClipView 设置为其 contentView,并将一个视图堆栈设置为其 documentView。 堆栈自动使用适当的系统间距。

默认情况下,它仅垂直滚动。 设置 orientation: .horizontal 会将它的项目堆栈方向和滚动方向都切换到水平方向。

ScrollView(orientation: .vertical, views: [
    ...
])

Separator (分隔符)

Separator 是一个 NSBox,其 boxType 设置为 .separator

在首选项窗口中选项的各个部分之间使用分隔符。

let separator = Separator()

Slider (滑块)

Slider 是一个 NSSlider,其 sliderType 设置为 .linear。 使用 valueminValuemaxValue 初始化它。

let slider = Slider(value: 0.3, minValue: 0, maxValue: 1) { value in
    // do something here ...
}

Slider

Switch (开关)

Switch 是一个 NSSwitch。 使用 isOn 值设置它。

let switch = Switch(isOn: true) { isOn in
    // do something here ...
}

Switch

Tabs (标签页)

TabsNSSegmentedControlTabs.Item 列表组合在一起。 它会自动显示所选索引处的项目。

let tabs = Tabs(selectedIndex: 0, items: [
    .init(title: "URI", views: [
        ...
    ]),
    .init(title: "Headers", views: [
        ...
    ]),
    .init(title: "Body", views: [
        ...
    ])
]) { index in
    ...
}

使用以下属性访问其所选索引。

tabs.selectedIndex = 2

Tabs

TextField (文本字段)

TextField 是一个 NSTextField,带有可选的尾随 Label。 您应该使用适合您用例的 width 来配置它。

let textField = TextField(value: "30", trailingText: "seconds") { text in
    // do something here ...
}

配置其值或占位符字符串。

textField.stringValue = "50"
textField.placeholder = "Enter name"

TextField

TextView (文本视图)

TextView 是一个 NSScrollView,其中包含一个 NSTextView 作为文档视图。 它设置为禁用数据检测器和拼写校正。

let textView = TextView(text: "Example text") { text in
    // do something here ...
}

配置其文本和字体,并控制是否允许编辑。

textField.text = "Another text"
textField.font = .monospacedSystemFont(ofSize: 12, weight: .regular)
textField.isEditable = false

TextView

TimePicker (时间选择器)

TimePicker 是一个 NSDatePicker,其 datePickerStyle 设置为 .textFieldAndStepper.textField,并且 datePickerElements 配置为 .hourMinuteSecond.hourMinute。 使用 dateminDatemaxDate 初始化它。

let picker = TimePicker(date: .now) { date in
    // do something here ...
}

显示选择器的步进器。

picker.showStepper = true

显示选择器的秒数。

picker.showSeconds = true

TimePicker

将组件组合在一起

可以使用紧凑的代码将组件组合在一起,该代码与视觉最终结果的层次结构紧密匹配。

我们使用另外两个组件来初始化 Mac 首选项窗口的内容。

PreferenceList (首选项列表)

PreferenceList 接受一个区段列表,并负责它们之间适当的间距。

基本上,PreferenceList 中唯一的特殊之处在于它在其视图中查找前导标题标签,并将它们全部约束到相同的宽度。 这产生了 Mac 应用程序首选项窗口的熟悉的简洁外观(在 Ventura 中设置的恐怖之前)。

使用 PreferenceList.Style 设置它,以水平居中内容(首选项窗口内容通常居中)或将内容扩展到全宽度。

PreferenceList(views: [
    ...
])

PreferenceSection (首选项区段)

PreferenceSection 接受标题、组件列表,并在该部分中的所有组件下方显示一个可选的页脚文本。 部分标题显示在部分组件的左侧,右对齐。 标题文本应以冒号结尾。

部分中的视图可以使用 orientation: .horizontal 水平放置。

PreferenceSection(
    title: "Options:",
    footer: "This text appears below the section.",
    orientation: .vertical,
    views: [
        ...
    ]
)

PreferenceBlock (首选项块)

作为 PreferenceSection 的替代方法,如果您需要在组件上方的左对齐标题,请使用带有可选页脚文本的 PreferenceBlock。 同样,这里的标题文本应以冒号结尾。 此布局适合内容水平填充窗口的选项窗口。

部分中的视图可以使用 orientation: .horizontal 水平放置。

PreferenceBlock(
    title: "Options:",
    footer: "This text appears below the block.",
    orientation: .vertical,
    views: [
        ...
    ]
)

PreferenceGroup (首选项组)

PreferenceGroup 接受一个项目列表,每个项目都有一个标题和一个视图的水平堆栈。

它对于创建所有具有自己标题的选项列表(例如 PopUpTextField 组件)非常有用。

PreferenceGroup(items: [
    .init(title: "First:", views: [...]),
    .init(title: "Second:", views: [...]),
])

示例

以下示例使用包含多个 PreferenceSectionPreferenceList 初始化首选项窗口,每个部分都有自己的组件。

Preferences window

override func loadView() {
    view = NSView()
    view.wantsLayer = true
    
    title = "Test"
    
    let list = PreferenceList(style: .center, views: [
        PreferenceSection(title: "Enable:", views: [
            Switch(isOn: true) { isOn in
                
            },
        ]),
        PreferenceSection(title: "Choose any one:", views: [
            Radio(items: [
                .init(title: "One"),
                .init(title: "Two", views: [
                    PopUp(items: ["12", "13"].map { .init(title: $0) }, selectedIndex: 0, trailingText: "points") { index, title in
                        
                    }
                ]),
                .init(title: "Three", views: [
                    TextField(value: "15.0", trailingText: "milliseconds", width: 50) { text in
                        
                    }
                ])], selectedIndex: 0) { index, previousIndex in
                    
                },
        ]),
        Separator(),
        PreferenceGroup(items: [
            .init(title: "First:", views: [
                PopUp(items: ["One", "Two"].map { .init(title: $0) }, selectedIndex: 0) { index, title in
                    
                }
            ]),
            .init(title: "Second:", views: [
                PopUp(items: ["Foobar", "Plop"].map { .init(title: $0) }, selectedIndex: 0) { index, title in
                    
                }
            ]),
        ]),
        Separator(),
        PreferenceSection(title: "Test:", footer: "This here demonstrates some footer text that is shown below a section of items.", views: [
            Checkbox(title: "Click me", isOn: true) { enabled in
                
            },
            Checkbox(title: "Me too", isOn: true) { enabled in
                
            },
        ]),
        Separator(),
        PreferenceSection(title: "Start date:", orientation: .horizontal, alignment: .centerY, spacing: 20, views: [
            CalendarPicker() { date in
                
            },
            ClockPicker() { date in
                
            },
        ]),
        Separator(),
        PreferenceSection(title: "Maximum level:", views: [
            Box(views: [
                Level(value: 0.3) { value in
                    
                },
                Slider() { value in
                    print("value changed to \(value)")
                },
            ])
        ]),
        Separator(),
        PreferenceSection(title: "Body text:", views: [
            FontPicker() { font in
                
            },
            ColorWell(color: .blue, style: .default) { color in
                
            },
            Image(named: "AppIcon Mac", size: CGSize(width: 50, height: 50)) {
                
            },
        ]),
    ])
    
    view.addSubview(list)
    list.translatesAutoresizingMaskIntoConstraints = false
    view.addConstraints([
        list.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
        list.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 40),
        list.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -40),
        list.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor, constant: -20)
    ])
    
    preferredContentSize = CGSize(width: 500, height: view.fittingSize.height)
}