Created
May 30, 2022 18:55
-
-
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
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 | |
/// 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