Skip to content

Instantly share code, notes, and snippets.

@alexdrone
Created March 7, 2021 12:58
Show Gist options
  • Save alexdrone/def3036d4a7d82e58807b7df594b072e to your computer and use it in GitHub Desktop.
Save alexdrone/def3036d4a7d82e58807b7df594b072e to your computer and use it in GitHub Desktop.
SwiftUI MacOS Quick Forms
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