Created
March 7, 2021 12:58
-
-
Save alexdrone/def3036d4a7d82e58807b7df594b072e to your computer and use it in GitHub Desktop.
SwiftUI MacOS Quick Forms
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import AppKit | |
import Combine | |
extension View { | |
public func embedInLabel(label: String) -> some View { | |
FieldLabel(label: label) { | |
self | |
} | |
} | |
public func embedInFieldRow(label: String) -> some View { | |
FieldRow { | |
self.embedInLabel(label: label) | |
} | |
} | |
} | |
// MARK: - Inline TextFields | |
// MARK: EditableField | |
public struct EditableField<T: EditableFieldConvertible>: View { | |
@Binding public var value: T | |
@State public var textValue: String = "" | |
public var body: some View { | |
HStack { | |
TextField(FormLabels.placeholder, text: $textValue, onCommit: setValue).fieldRowStyle() | |
.onAppear(perform: setInitialTextValue) | |
DummyFieldTrailingButton() | |
} | |
} | |
private func setInitialTextValue() { | |
textValue = value.description | |
} | |
private func setValue() { | |
value = .init(textValue: textValue) | |
setInitialTextValue() | |
} | |
} | |
// MARK: ResettableEditableField | |
public struct ResettableEditableField<T: EditableFieldConvertible>: View { | |
@Binding public var value: T? | |
@State public var textValue: String = "" | |
public var body: some View { | |
HStack { | |
TextField(FormLabels.placeholder, text: $textValue, onCommit: setValue) | |
.fieldRowStyle() | |
.opacity(value != nil ? 1 : 0) | |
if let _ = value { | |
FieldTrailingButton(title: FormLabels.reset, action: resetValue) | |
} else { | |
FieldTrailingButton(title: FormLabels.set, action: setValue) | |
} | |
} | |
.onAppear(perform: setInitialTextValue) | |
} | |
private func setInitialTextValue() { | |
textValue = value?.description ?? "" | |
} | |
private func setValue() { | |
value = .init(textValue: textValue) | |
setInitialTextValue() | |
} | |
private func resetValue() { | |
value = nil | |
setInitialTextValue() | |
} | |
} | |
// MARK: - ON/OFF Toggles | |
// MARK: ToggleField | |
public struct ToggleField: View { | |
@Binding public var value: Bool | |
public var body: some View { | |
HStack { | |
Toggle(FormLabels.placeholder, isOn: $value).fieldRowStyle() | |
DummyFieldTrailingButton() | |
} | |
} | |
} | |
// MARK: ResettableToggleField | |
public struct ResettableToggleField: View { | |
@Binding public var value: Bool? | |
public var body: some View { | |
HStack { | |
Toggle(FormLabels.placeholder, isOn: $value ?? false) | |
.fieldRowStyle() | |
.opacity(value != nil ? 1 : 0) | |
if let _ = value { | |
FieldTrailingButton(title: FormLabels.reset) { value = nil } | |
} else { | |
FieldTrailingButton(title: FormLabels.set) { value = false } | |
} | |
} | |
} | |
} | |
// MARK: - DropDown Menus | |
// MARK: ResettableComboBoxField | |
public struct ResettableComboBoxField<T: ComboBoxBindableType>: View where T.RawValue == String { | |
@Binding public var value: T? | |
public let allValues: [T] | |
@State private var selection: Int = 0 | |
private var allComboBoxValues: [String] { allValues.map { $0.rawValue } } | |
public var body: some View { | |
HStack { | |
ComboBoxView(content: allComboBoxValues, selected: $selection) | |
.fieldRowStyle() | |
.opacity(value != nil ? 1 : 0) | |
fieldTrailingButton | |
} | |
.onChange(of: selection, perform: setValue) | |
.onAppear(perform: setInitialSelection) | |
} | |
@ViewBuilder | |
private var fieldTrailingButton: some View { | |
if let _ = value { | |
FieldTrailingButton(title: FormLabels.reset, action: resetValue) | |
} else { | |
FieldTrailingButton(title: FormLabels.set, action: setDefaultValue) | |
} | |
} | |
private func setDefaultValue() { | |
value = allValues.first | |
setInitialSelection() | |
} | |
private func resetValue() { | |
value = nil | |
} | |
private func setValue(from selection: Int) { | |
guard selection < allValues.count else { return } | |
value = allValues[selection] | |
} | |
private func setInitialSelection() { | |
selection = allComboBoxValues.firstIndex(of: value?.rawValue ?? "") ?? 0 | |
} | |
} | |
// MARK: ComboBoxField | |
public struct ComboBoxField<T: ComboBoxBindableType>: View where T.RawValue == String { | |
@Binding public var value: T | |
public let allValues: [T] | |
@State private var selection: Int = 0 | |
private var allComboBoxValues: [String] { allValues.map { $0.rawValue } } | |
public var body: some View { | |
HStack { | |
ComboBoxView(content: allComboBoxValues, selected: $selection).fieldRowStyle() | |
DummyFieldTrailingButton() | |
} | |
.onChange(of: selection, perform: setValue) | |
.onAppear(perform: setInitialSelection) | |
} | |
private func setValue(from selection: Int) { | |
guard selection < allValues.count else { return } | |
value = allValues[selection] | |
} | |
private func setInitialSelection() { | |
selection = allComboBoxValues.firstIndex(of: value.rawValue) ?? 0 | |
} | |
private func unimplemented() { } | |
} | |
// MARK: NSComboBox AppKit Wrapper | |
public struct ComboBoxView : NSViewRepresentable { | |
public private(set) var content: [String] | |
@Binding public var selected: Int | |
public final class Coordinator : NSObject, NSComboBoxDelegate { | |
private var selected: Binding<Int> | |
init(selected: Binding<Int>) { self.selected = selected } | |
public func comboBoxSelectionDidChange(_ notification: Notification) { | |
let index = selected.wrappedValue | |
if let combo = notification.object as? NSComboBox, index != combo.indexOfSelectedItem { | |
selected.wrappedValue = combo.indexOfSelectedItem | |
} | |
} | |
} | |
public func makeCoordinator() -> Self.Coordinator { Coordinator(selected: $selected) } | |
public func makeNSView(context: NSViewRepresentableContext<Self>) -> NSComboBox { | |
let nsView = NSComboBox() | |
nsView.hasVerticalScroller = true | |
nsView.usesDataSource = false | |
nsView.delegate = context.coordinator | |
for key in content { nsView.addItem(withObjectValue: key) } | |
return nsView | |
} | |
public func updateNSView(_ nsView: NSComboBox, context: NSViewRepresentableContext<Self>) { | |
guard selected != nsView.indexOfSelectedItem else { return } | |
DispatchQueue.main.async { nsView.selectItem(at: self.selected) } | |
} | |
} | |
// MARK: - Inline Vector Field | |
public struct InlineVectorField<T: EditableFieldConvertible>: View { | |
@Binding public var values: [T] | |
@State public var textValues: [String] | |
init(values: Binding<[T]>) { | |
self._values = values | |
self._textValues = State(initialValue: values.wrappedValue.map { $0.description }) | |
} | |
public var body: some View { | |
HStack { | |
ForEach(0..<values.count) { | |
TextField(FormLabels.placeholder, text: binding(at: $0), onCommit: setValue).fieldRowStyle() | |
} | |
DummyFieldTrailingButton() | |
} | |
.onAppear(perform: setInitialTextValue) | |
} | |
private func binding(at index: Int) -> Binding<String> { | |
guard index < textValues.count else{ fatalError() } | |
return Binding( | |
get: { textValues[index] }, | |
set: { textValues[index] = $0 }) | |
} | |
private func setInitialTextValue() { | |
textValues = values.map { $0.description } | |
} | |
private func setValue() { | |
values = textValues.map { .init(textValue: $0) } | |
setInitialTextValue() | |
} | |
} | |
// MARK: - Shared | |
// MARK: FieldLabel | |
public struct FieldLabel<C: View>: View { | |
public let label: String | |
public let content: C | |
public init(label: String, @ViewBuilder content: () -> C) { | |
self.label = label | |
self.content = content() | |
} | |
public var body: some View { | |
HStack { | |
Text(FormConstants.fieldPrefix + label).fieldRowStyle() | |
Divider().fieldRowStyle() | |
content | |
} | |
} | |
} | |
// MARK: FieldTrailingButton | |
public struct FieldTrailingButton: View { | |
public let title: String | |
public let action: () -> Void | |
public var body: some View { | |
HStack { | |
Divider().fieldRowStyle() | |
Button(title, action: action).fieldRowStyle() | |
} | |
} | |
} | |
public struct DummyFieldTrailingButton: View { | |
public var body: some View { | |
HStack { | |
Divider().fieldRowStyle() | |
Button(FormLabels.placeholder, action: unimplemented).fieldRowStyle().opacity(0) | |
} | |
} | |
private func unimplemented() {} | |
} | |
// MARK: FieldRow | |
public struct FieldRow<C: View>: View { | |
public let content: C | |
public init(@ViewBuilder content: () -> C) { | |
self.content = content() | |
} | |
public var body: some View { | |
HStack { | |
content | |
} | |
.frame(height: FormConstants.fieldHeight) | |
.padding(0) | |
.padding(.leading) | |
} | |
} | |
// MARK: ComboBoxBindableType Conformance | |
public typealias ComboBoxBindableType = RawRepresentable | |
extension String: RawRepresentable { | |
public typealias RawValue = Self | |
public init(rawValue: String) { self = rawValue } | |
public var rawValue: RawValue { self } | |
} | |
// MARK: - Binding Extensions | |
extension Binding { | |
public init<T>(keyPath: ReferenceWritableKeyPath<T, Value>, object: T) { | |
self.init( | |
get: { object[keyPath: keyPath] }, | |
set: { object[keyPath: keyPath] = $0} | |
) | |
} | |
} | |
public func ?? <T>(lhs: Binding<T?>, rhs: T) -> Binding<T> { | |
Binding( | |
get: { lhs.wrappedValue ?? rhs }, | |
set: { lhs.wrappedValue = $0 } | |
) | |
} | |
// MARK: - Collections | |
public protocol CollectionStoreItem { | |
init() | |
} | |
public final class CollectionStore<T: CollectionStoreItem>: ObservableObject { | |
fileprivate struct IdentifiableItem: Identifiable { | |
let index: Int | |
let value: T | |
var id: Int { index } | |
} | |
public private(set) var collection: [T] | |
public let objectWillChange = ObservableObjectPublisher() | |
private let onCommit: ([T]) -> Void | |
init(collection: [T], onCommit: @escaping ([T]) -> Void) { | |
self.collection = collection | |
self.onCommit = onCommit | |
} | |
init(collection: [T]?, onCommit: @escaping ([T]) -> Void) { | |
self.collection = collection ?? [] | |
self.onCommit = onCommit | |
} | |
fileprivate func items() -> [IdentifiableItem] { | |
collection.enumerated().map { IdentifiableItem(index: $0, value: $1) } | |
} | |
public func addNew() { | |
assert(Thread.isMainThread) | |
collection.append(T.init()) | |
onCommit(collection) | |
objectWillChange.send() | |
} | |
public func remove(at index: Int) { | |
assert(Thread.isMainThread) | |
guard index < collection.count else { return } | |
collection.remove(at: index) | |
onCommit(collection) | |
objectWillChange.send() | |
} | |
} | |
// MARK: FormCollectionView | |
public struct FormCollectionView<T: CollectionStoreItem, C: View>: View { | |
let title: String | |
@ObservedObject var store: CollectionStore<T> | |
let content: (T, Int) -> C | |
init(title: String, store: CollectionStore<T>, @ViewBuilder content: @escaping (T, Int) -> C) { | |
self.title = title | |
self.store = store | |
self.content = content | |
} | |
@ViewBuilder | |
private var header: some View { | |
HStack { | |
Text(title.uppercased()).fieldRowStyle() | |
Spacer() | |
FieldTrailingButton(title: FormLabels.add, action: store.addNew) | |
}.titleRowStyle() | |
} | |
public var body: some View { | |
VStack { | |
header | |
ForEach(store.items()) { item in | |
itemHeader(for: item) | |
} | |
} | |
} | |
@ViewBuilder | |
private func itemHeader(for item: CollectionStore<T>.IdentifiableItem) -> some View { | |
VStack(spacing: 0) { | |
HStack { | |
Spacer() | |
FieldTrailingButton(title: FormLabels.remove) { store.remove(at: item.index) } | |
}.embedInFieldRow(label: "[\(item.index)]") | |
content(item.value, item.index) | |
} | |
} | |
} | |
// MARK: - View Modifiers | |
extension TextField { | |
func fieldRowStyle() -> some View { | |
self | |
.frame(height: FormConstants.textFieldHeight) | |
.textFieldStyle(PlainTextFieldStyle()) | |
.font(.body) | |
.padding(.leading, 4) | |
.background(Color(NSColor.controlBackgroundColor)) | |
.cornerRadius(FormConstants.cornerRadius) | |
.shadow(color: Color.black.opacity(0.2), radius: 0, x: 0, y: 1) | |
.padding(0) | |
} | |
} | |
extension Text { | |
func fieldRowStyle() -> some View { | |
self | |
.font(.subheadline) | |
.bold() | |
.frame(width: FormConstants.labelWidth, height: FormConstants.fieldHeight, alignment: .leading) | |
.padding(0) | |
} | |
} | |
extension Button { | |
func fieldRowStyle() -> some View { | |
self | |
.buttonStyle(BorderlessButtonStyle()) | |
.font(.subheadline) | |
.frame(width: FormConstants.labelWidth / 2, alignment: .leading) | |
.padding(0) | |
} | |
} | |
extension Toggle { | |
func fieldRowStyle() -> some View { | |
HStack { | |
self.toggleStyle(CheckboxToggleStyle()).saturation(0).offset(y: FormConstants.centerOffset) | |
Spacer() | |
} | |
} | |
} | |
extension Divider { | |
func fieldRowStyle() -> some View { | |
self.frame(height: FormConstants.fieldHeight) | |
} | |
} | |
extension ComboBoxView { | |
func fieldRowStyle() -> some View { | |
self.saturation(0).offset(y: FormConstants.centerOffset) .padding(0) | |
} | |
} | |
extension View { | |
func titleRowStyle() -> some View { | |
self | |
.padding(.leading) | |
.frame(height: FormConstants.fieldHeight, alignment: .leading) | |
.background(FormConstants.titleBarColor) | |
.cornerRadius(FormConstants.cornerRadius) | |
.padding(.top) | |
} | |
} | |
// MARK: EditableFieldConvertible Conformance | |
public protocol EditableFieldConvertible: CustomStringConvertible { | |
init(textValue: String) | |
} | |
extension Int: EditableFieldConvertible { | |
public init(textValue: String) { self.init(Int(textValue) ?? 0) } | |
} | |
extension UInt: EditableFieldConvertible { | |
public init(textValue: String) { self.init(UInt(textValue) ?? 0) } | |
} | |
extension Float: EditableFieldConvertible { | |
public init(textValue: String) { self.init(Float(textValue) ?? 0) } | |
} | |
extension Double: EditableFieldConvertible { | |
public init(textValue: String) { self.init(Double(textValue) ?? 0) } | |
} | |
extension String: EditableFieldConvertible { | |
public init(textValue: String) { self.init(textValue) } | |
} | |
// MARK: - Constants | |
enum FormConstants { | |
static let fieldHeight: CGFloat = 26 | |
static let textFieldHeight: CGFloat = 21 | |
static let cornerRadius: CGFloat = 5 | |
static let centerOffset: CGFloat = -3 | |
static let labelWidth: CGFloat = 128 | |
static let fieldPrefix = "" | |
static let titleBarColor: Color = Color(NSColor.controlBackgroundColor) | |
} | |
enum FormLabels { | |
static let placeholder = "" | |
static let set = "Set" | |
static let reset = "Reset" | |
static let add = "Add" | |
static let remove = "Remove" | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment