Skip to content

Instantly share code, notes, and snippets.

@Maschina
Created May 30, 2022 18:55
Show Gist options
  • Save Maschina/f391b3ed8e5d45389e72fbe95d290f00 to your computer and use it in GitHub Desktop.
Save Maschina/f391b3ed8e5d45389e72fbe95d290f00 to your computer and use it in GitHub Desktop.
A customized text field that allows numbers only and validates the user input
import SwiftUI
/// A customized text field that allows numbers only and validates the user input
struct MeasurementField: NSViewRepresentable {
typealias NSViewType = NSTextField
@Binding var measurement: Measurement<Unit>
let range: ClosedRange<Double>
let measurementFormatter: MeasurementFormatter
let numberFormatter: NumberFormatter
var configuration = { (view: NSViewType) in }
@Binding var isFirstResponder: Bool
let isValid: ((Bool) -> Void)?
let onSubmit: (() -> Void)?
let onExit: (() -> Void)?
private(set) var unit: Unit
/// Initializes the custom control
/// - Parameters:
/// - measurement: Binding to the value
/// - range: Valid range for the user input
/// - formatter: Valid format for the user input and for the measurement
/// - isValid: Optional: Handler to determine if currently typed user input is already valid
/// - isFirstResponder: Optional: Closure to control the first responder
/// - configuration: Optional: Adjust the AppKit NSTextField
/// - onSubmit: Optional: Handler to determine if user submitted the input (e.g. ENTER key)
/// - onExit: Optional: Handler to determine when user cancelled the input (e.g. ESC key)
init(
measurement: Binding<Measurement<Unit>>,
range: ClosedRange<Double>,
formatter: MeasurementFormatter? = nil,
isFirstResponder: Binding<Bool>? = nil,
configuration: @escaping (NSTextField) -> Void = { _ in },
isValid: ((Bool) -> Void)? = nil,
onSubmit: (() -> Void)? = nil,
onExit: (() -> Void)? = nil
) {
self._measurement = measurement
self.unit = measurement.wrappedValue.unit
self.range = range
if let measurementFormatter = formatter {
self.measurementFormatter = measurementFormatter
self.numberFormatter = measurementFormatter.numberFormatter
}
else {
let measurementFormatter = Self.defaultMeasurementFormatter
self.measurementFormatter = measurementFormatter
self.numberFormatter = measurementFormatter.numberFormatter
}
self._isFirstResponder = isFirstResponder ?? .constant(false)
self.configuration = configuration
self.isValid = isValid
self.onSubmit = onSubmit
self.onExit = onExit
}
func makeNSView(context: Context) -> NSTextField {
let view = { () -> NSTextField in
if self.unit.symbol.isEmpty {
let view = CustomTextField(frame: .zero)
configuration(view)
return view
}
else {
let view = CustomTextFieldWithSuffix(
frame: .zero,
measurementFormatter: measurementFormatter,
unit: unit
)
configuration(view)
return view
}
}()
// Layout
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
// Delegate
view.delegate = context.coordinator
return view
}
func updateNSView(_ nsView: NSTextField, context: Context) {
nsView.stringValue = numberFormatter.string(for: measurement.value) ?? ""
configuration(nsView)
switch isFirstResponder {
case true: nsView.becomeFirstResponder()
case false: nsView.resignFirstResponder()
}
}
func makeCoordinator() -> Coordinator {
Coordinator(
parent: self,
value: $measurement,
initValue: measurement,
isFirstResponder: $isFirstResponder
)
}
// MARK: User input Coordinator
final class Coordinator: NSObject, NSTextFieldDelegate {
let parent: MeasurementField
var value: Binding<Measurement<Unit>>
var lastValidInput: Measurement<Unit>?
var isFirstResponder: Binding<Bool>
init(
parent: MeasurementField,
value: Binding<Measurement<Unit>>,
initValue: Measurement<Unit>,
isFirstResponder: Binding<Bool>
) {
self.parent = parent
self.value = value
self.lastValidInput = initValue
self.isFirstResponder = isFirstResponder
}
/// Validate text input using `formatter` and `range`
/// - Parameter stringValue: String input
/// - Returns: Returns output if valid, or `nil`
private func validation(_ stringValue: String) -> Double? {
// Formatter compliant?
guard let value = parent.numberFormatter.number(from: stringValue) else { return nil }
let doubleValue = value.doubleValue
// in range?
if parent.range.contains(doubleValue) {
return doubleValue
}
else {
NSSound.beep()
if parent.range.upperBound < doubleValue {
return parent.range.upperBound
}
else {
return parent.range.lowerBound
}
}
}
func controlTextDidChange(_ obj: Notification) {
guard let textField = obj.object as? NSTextField else { return }
let stringValue = textField.stringValue
// feedback if current user input is valid
parent.isValid?(validation(stringValue) != nil)
}
/// Validate when text input finished
func controlTextDidEndEditing(_ obj: Notification) {
guard let textField = obj.object as? NSTextField else { return }
let stringValue = textField.stringValue
guard let doubleValue = validation(stringValue) else {
// input was not valid
NSSound.beep()
textField.stringValue =
parent.numberFormatter.string(for: lastValidInput?.value) ?? ""
return
}
// input valid
lastValidInput = Measurement(value: doubleValue, unit: parent.unit)
value.wrappedValue = Measurement(value: doubleValue, unit: parent.unit)
}
/// Listen for certain keyboard keys
func control(
_ control: NSControl,
textView: NSTextView,
doCommandBy commandSelector: Selector
) -> Bool {
switch commandSelector {
case #selector(NSStandardKeyBindingResponding.insertNewline(_:)): // RETURN
textView.window?.makeFirstResponder(nil) // Blur cursor
parent.onSubmit?()
return true
case #selector(NSStandardKeyBindingResponding.cancelOperation(_:)): // ESC
guard let textField = control as? NSTextField else { return false }
NSSound.beep()
textField.stringValue = parent.numberFormatter.string(for: lastValidInput) ?? ""
parent.onExit?()
return true
default:
return false
}
}
func textFieldDidBeginEditing(_ textField: NSTextField) {
self.isFirstResponder.wrappedValue = true
}
func textFieldDidEndEditing(_ textField: NSTextField) {
self.isFirstResponder.wrappedValue = false
}
}
// MARK: Custom NSTextField with suffix
/// Custom text field which includes a suffix label for the unit text
private class CustomTextFieldWithSuffix: NSTextField, NSTextFieldDelegate {
private let suffixLabel: NSTextField
override var controlSize: NSControl.ControlSize {
didSet {
self.suffixLabel.font = NSFont.boldSystemFont(
ofSize: controlSize == .regular
? NSFont.systemFontSize : NSFont.smallSystemFontSize
)
self.font = .systemFont(ofSize: NSFont.systemFontSize(for: controlSize))
}
}
convenience init(
frame frameRect: NSRect,
measurementFormatter: MeasurementFormatter,
unit: Unit
) {
self.init(frame: frameRect)
self.suffixLabel.stringValue = measurementFormatter.string(from: unit)
}
override init(
frame frameRect: NSRect
) {
// Suffix label
self.suffixLabel = NSTextField(labelWithString: "")
self.suffixLabel.translatesAutoresizingMaskIntoConstraints = false
self.suffixLabel.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
self.suffixLabel.drawsBackground = false
// Super init
super.init(frame: frameRect)
// Text field modifications
self.cell = CustomTextFieldCell(
suffixLabelWidth: suffixLabel.intrinsicContentSize.width
)
self.usesSingleLineMode = true
// Adding suffix label to view layers
self.addSubview(self.suffixLabel)
NSLayoutConstraint.activate([
suffixLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -5),
suffixLabel.firstBaselineAnchor.constraint(equalTo: self.firstBaselineAnchor),
])
}
required init?(
coder: NSCoder
) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: Custom NSTextField
/// Custom text field which includes a suffix label for the unit text
private class CustomTextField: NSTextField {
override var controlSize: NSControl.ControlSize {
didSet {
self.font = .systemFont(ofSize: NSFont.systemFontSize(for: controlSize))
}
}
convenience init(
frame frameRect: NSRect,
controlSize: NSControl.ControlSize = .regular
) {
self.init(frame: frameRect)
self.controlSize = controlSize
}
override init(
frame frameRect: NSRect
) {
// Super init
super.init(frame: frameRect)
// Text field modifications
self.usesSingleLineMode = true
}
required init?(
coder: NSCoder
) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: Custom NSTextFieldCell
/// Constraint field cell to give sufficient room to the unit label
private class CustomTextFieldCell: NSTextFieldCell {
var suffixLabelWidth: CGFloat?
convenience init(
suffixLabelWidth: CGFloat? = nil
) {
self.init(textCell: "")
self.suffixLabelWidth = suffixLabelWidth
}
override init(
textCell string: String
) {
super.init(textCell: string)
self.isEditable = true
self.isBordered = true
self.drawsBackground = true
self.isBezeled = true
self.isSelectable = true
}
required init(
coder: NSCoder
) {
fatalError("init(coder:) has not been implemented")
}
override func drawingRect(forBounds rect: NSRect) -> NSRect {
let rectInset = NSRect(
x: rect.origin.x,
y: rect.origin.y,
width: rect.size.width - (suffixLabelWidth ?? 10.0),
height: rect.size.height
)
return super.drawingRect(forBounds: rectInset)
}
}
}
// MARK: - Extensions
extension MeasurementField {
static var defaultMeasurementFormatter: MeasurementFormatter {
let measurementFormatter = MeasurementFormatter()
measurementFormatter.unitOptions = .providedUnit
measurementFormatter.unitStyle = .short
measurementFormatter.numberFormatter.groupingSeparator = ""
return measurementFormatter
}
}
// MARK: - Previews
struct MeasurementFieldView: View {
@State var value: Double = 0.0
func doubleFormatter(digits: Int = 1) -> NumberFormatter {
let formatter = NumberFormatter()
formatter.maximumFractionDigits = digits
return formatter
}
func measurementFormatter(digits: Int = 1) -> MeasurementFormatter {
let formatter = MeasurementFormatter()
formatter.unitOptions = .providedUnit
formatter.unitStyle = .medium
formatter.numberFormatter.groupingSeparator = ""
formatter.numberFormatter.maximumFractionDigits = digits
return formatter
}
var body: some View {
VStack(alignment: .leading) {
MeasurementField(
measurement: .constant(Measurement(value: value, unit: UnitTemperature.kelvin)),
range: 0...500,
formatter: measurementFormatter(digits: 0),
configuration: {
$0.bezelStyle = .roundedBezel
$0.controlSize = .small
}
)
.frame(width: 55)
MeasurementField(
measurement: .constant(Measurement(value: value, unit: Unit(symbol: ""))),
range: 0...500,
formatter: measurementFormatter(digits: 0),
configuration: {
$0.bezelStyle = .roundedBezel
$0.controlSize = .small
}
)
.frame(width: 41)
}
}
}
struct MeasurementFieldView_Previews: PreviewProvider {
static var previews: some View {
MeasurementFieldView(value: 300)
.preferredColorScheme(.light)
.padding()
MeasurementFieldView(value: 30)
.preferredColorScheme(.dark)
.padding()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment