Skip to content

Commit 0ac9214

Browse files
committed
Change to SPM
1 parent 8c529e3 commit 0ac9214

File tree

6 files changed

+369
-6
lines changed

6 files changed

+369
-6
lines changed
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Scheme
3+
LastUpgradeVersion = "1320"
4+
version = "1.3">
5+
<BuildAction
6+
parallelizeBuildables = "YES"
7+
buildImplicitDependencies = "YES">
8+
<BuildActionEntries>
9+
<BuildActionEntry
10+
buildForTesting = "YES"
11+
buildForRunning = "YES"
12+
buildForProfiling = "YES"
13+
buildForArchiving = "YES"
14+
buildForAnalyzing = "YES">
15+
<BuildableReference
16+
BuildableIdentifier = "primary"
17+
BlueprintIdentifier = "UnitNumberField"
18+
BuildableName = "UnitNumberField"
19+
BlueprintName = "UnitNumberField"
20+
ReferencedContainer = "container:">
21+
</BuildableReference>
22+
</BuildActionEntry>
23+
<BuildActionEntry
24+
buildForTesting = "YES"
25+
buildForRunning = "YES"
26+
buildForProfiling = "NO"
27+
buildForArchiving = "NO"
28+
buildForAnalyzing = "YES">
29+
<BuildableReference
30+
BuildableIdentifier = "primary"
31+
BlueprintIdentifier = "UnitNumberFieldTests"
32+
BuildableName = "UnitNumberFieldTests"
33+
BlueprintName = "UnitNumberFieldTests"
34+
ReferencedContainer = "container:">
35+
</BuildableReference>
36+
</BuildActionEntry>
37+
</BuildActionEntries>
38+
</BuildAction>
39+
<TestAction
40+
buildConfiguration = "Debug"
41+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
42+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
43+
shouldUseLaunchSchemeArgsEnv = "YES">
44+
<Testables>
45+
<TestableReference
46+
skipped = "NO">
47+
<BuildableReference
48+
BuildableIdentifier = "primary"
49+
BlueprintIdentifier = "UnitNumberFieldTests"
50+
BuildableName = "UnitNumberFieldTests"
51+
BlueprintName = "UnitNumberFieldTests"
52+
ReferencedContainer = "container:">
53+
</BuildableReference>
54+
</TestableReference>
55+
</Testables>
56+
</TestAction>
57+
<LaunchAction
58+
buildConfiguration = "Debug"
59+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
60+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
61+
launchStyle = "0"
62+
useCustomWorkingDirectory = "NO"
63+
ignoresPersistentStateOnLaunch = "NO"
64+
debugDocumentVersioning = "YES"
65+
debugServiceExtension = "internal"
66+
allowLocationSimulation = "YES">
67+
</LaunchAction>
68+
<ProfileAction
69+
buildConfiguration = "Release"
70+
shouldUseLaunchSchemeArgsEnv = "YES"
71+
savedToolIdentifier = ""
72+
useCustomWorkingDirectory = "NO"
73+
debugDocumentVersioning = "YES">
74+
<MacroExpansion>
75+
<BuildableReference
76+
BuildableIdentifier = "primary"
77+
BlueprintIdentifier = "UnitNumberField"
78+
BuildableName = "UnitNumberField"
79+
BlueprintName = "UnitNumberField"
80+
ReferencedContainer = "container:">
81+
</BuildableReference>
82+
</MacroExpansion>
83+
</ProfileAction>
84+
<AnalyzeAction
85+
buildConfiguration = "Debug">
86+
</AnalyzeAction>
87+
<ArchiveAction
88+
buildConfiguration = "Release"
89+
revealArchiveInOrganizer = "YES">
90+
</ArchiveAction>
91+
</Scheme>

Package.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import PackageDescription
55

66
let package = Package(
77
name: "UnitNumberField",
8+
platforms: [
9+
.macOS(.v10_15),
10+
],
811
products: [
912
// Products define the executables and libraries a package produces, and make them visible to other packages.
1013
.library(

README.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,55 @@
11
# UnitNumberField
2+
A customized text field for unit-based number for SwiftUI and AppKit (macOS). Allows number only and validates the user input.
3+
Similar to the input field in Sketch.
24

3-
A description of this package.
5+
![Screenshot](Sources/doc/screen1.png)
6+
7+
## Features
8+
9+
- Compatibility to SwiftUI and AppKit (macOS)
10+
- Available for Double values only at the moment
11+
- Inline label for unit
12+
- Feedback closure if input is not valid
13+
- Range control (clamp the given input to a certain range)
14+
- Formatter control (validate the input towards a given formatter)
15+
- Bezel style support
16+
- Control size support
17+
18+
## Installation
19+
20+
Add https://github.com/Maschina/UnitNumberField in the [“Swift Package Manager” tab in Xcode](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app).
21+
22+
## Requirements
23+
24+
macOS 10.15+
25+
26+
## Usage
27+
28+
Example for usage in SwiftUI **with** units:
29+
30+
```swift
31+
UnitNumberField(
32+
value: proxy,
33+
unitText: "°",
34+
range: 0...360,
35+
formatter: NumberFormatter.doubleFormatter(digits: 0),
36+
bezelStyle: .roundedBezel,
37+
controlSize: .small
38+
)
39+
.frame(width: 55)
40+
```
41+
42+
43+
Example for usage in SwiftUI **without** units:
44+
45+
```swift
46+
UnitNumberField(
47+
value: proxy,
48+
unitText: nil,
49+
range: 0...360,
50+
formatter: NumberFormatter.doubleFormatter(digits: 0),
51+
bezelStyle: .roundedBezel,
52+
controlSize: .small
53+
)
54+
.frame(width: 55)
55+
```
Lines changed: 222 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,224 @@
1-
public struct UnitNumberField {
2-
public private(set) var text = "Hello, World!"
1+
import SwiftUI
2+
import AVFoundation // system sounds
33

4-
public init() {
5-
}
4+
/// A customized text field that allows numbers only and validates the user input
5+
public struct UnitNumberField: NSViewRepresentable {
6+
public typealias NSViewType = CustomTextField
7+
8+
@Binding var value: Double
9+
10+
let unitText: String
11+
let range: ClosedRange<Double>
12+
let formatter: NumberFormatter
13+
let bezelStyle: NSTextField.BezelStyle
14+
let controlSize: NSControl.ControlSize
15+
let isValid: ((Bool) -> ())?
16+
17+
/// Initializes the custom control
18+
/// - Parameters:
19+
/// - value: Binding to the value
20+
/// - unitText: Static text representation of the unit
21+
/// - range: Valid range for the user input
22+
/// - formatter: Valid format for the user input
23+
/// - isValid: Optional: Closure to determine if currently typed user input is already valid
24+
/// - bezelStyle: Optional: Bezel style
25+
/// - controlSize: Optional: Size of the control
26+
init(value: Binding<Double>, unitText: String, range: ClosedRange<Double>, formatter: NumberFormatter, isValid: ((Bool) -> ())? = nil, bezelStyle: NSTextField.BezelStyle = .squareBezel, controlSize: NSControl.ControlSize = .regular) {
27+
self._value = value
28+
self.unitText = unitText
29+
self.range = range
30+
self.formatter = formatter
31+
self.isValid = isValid
32+
self.bezelStyle = bezelStyle
33+
self.controlSize = controlSize
34+
}
35+
36+
public func makeNSView(context: Context) -> CustomTextField {
37+
let view = CustomTextField(frame: .zero, unitText: unitText, controlSize: controlSize)
38+
39+
view.delegate = context.coordinator
40+
// Bezel style
41+
view.bezelStyle = bezelStyle
42+
// Font size
43+
view.font = .systemFont(ofSize: NSFont.systemFontSize(for: controlSize))
44+
45+
return view
46+
}
47+
48+
public func updateNSView(_ nsView: CustomTextField, context: Context) {
49+
nsView.stringValue = formatter.string(for: value) ?? ""
50+
}
51+
52+
public func makeCoordinator() -> Coordinator {
53+
Coordinator(parent: self, initValue: value)
54+
}
55+
56+
// MARK: User input Coordinator
57+
58+
final public class Coordinator : NSObject, NSTextFieldDelegate {
59+
let parent: UnitNumberField
60+
var lastValidInput: Double?
61+
62+
init(parent: UnitNumberField, initValue: Double) {
63+
self.parent = parent
64+
self.lastValidInput = initValue
65+
}
66+
67+
/// Validate text input using `formatter` and `range`
68+
/// - Parameter stringValue: String input
69+
/// - Returns: Returns output if valid, or `nil`
70+
private func validation(_ stringValue: String) -> Double? {
71+
// Formatter compliant?
72+
guard let value = parent.formatter.number(from: stringValue) else { return nil }
73+
let doubleValue = value.doubleValue
74+
// in range?
75+
if parent.range.contains(doubleValue) {
76+
return doubleValue
77+
} else {
78+
if parent.range.upperBound < doubleValue { return parent.range.upperBound }
79+
else { return parent.range.lowerBound }
80+
}
81+
}
82+
83+
public func controlTextDidChange(_ obj: Notification) {
84+
guard let textField = obj.object as? NSTextField else { return }
85+
let stringValue = textField.stringValue
86+
// feedback if current user input is valid
87+
parent.isValid?(validation(stringValue) != nil)
88+
}
89+
90+
/// Validate when text input finished
91+
public func controlTextDidEndEditing(_ obj: Notification) {
92+
guard let textField = obj.object as? NSTextField else { return }
93+
let stringValue = textField.stringValue
94+
95+
guard let doubleValue = validation(stringValue) else {
96+
// input was not valid
97+
NSSound.beep()
98+
textField.stringValue = parent.formatter.string(for: lastValidInput) ?? ""
99+
return
100+
}
101+
// input valid
102+
lastValidInput = doubleValue
103+
textField.stringValue = parent.formatter.string(for: doubleValue) ?? ""
104+
parent.value = doubleValue
105+
}
106+
107+
/// Listen for certain keyboard keys
108+
public func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
109+
switch commandSelector {
110+
case #selector(NSStandardKeyBindingResponding.insertNewline(_:)): // RETURN
111+
textView.window?.makeFirstResponder(nil) // Blur cursor
112+
return true
113+
114+
case #selector(NSStandardKeyBindingResponding.cancelOperation(_:)): // ESC
115+
guard let textField = control as? NSTextField else { return false }
116+
NSSound.beep()
117+
textField.stringValue = parent.formatter.string(for: lastValidInput) ?? ""
118+
return true
119+
120+
default:
121+
return false
122+
}
123+
}
124+
}
125+
126+
// MARK: Custom NSTextField
127+
128+
/// Custom text field which includes a suffix label for the unit text
129+
public class CustomTextField: NSTextField {
130+
private let suffixLabel: NSTextField
131+
132+
convenience init(frame frameRect: NSRect, unitText: String, controlSize: NSControl.ControlSize = .regular) {
133+
self.init(frame: frameRect)
134+
135+
self.controlSize = controlSize
136+
self.suffixLabel.stringValue = unitText
137+
self.suffixLabel.font = NSFont.boldSystemFont(ofSize: controlSize == .regular ? NSFont.systemFontSize : NSFont.smallSystemFontSize)
138+
}
139+
140+
override init(frame frameRect: NSRect) {
141+
// Suffix label
142+
self.suffixLabel = NSTextField(labelWithString: "")
143+
self.suffixLabel.translatesAutoresizingMaskIntoConstraints = false
144+
self.suffixLabel.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
145+
self.suffixLabel.drawsBackground = false
146+
147+
// Super init
148+
super.init(frame: frameRect)
149+
150+
// Text field modifications
151+
self.cell = CustomTextFieldCell(suffixLabelWidth: suffixLabel.intrinsicContentSize.width)
152+
self.usesSingleLineMode = true
153+
154+
// Adding suffix label to view layers
155+
self.addSubview(self.suffixLabel)
156+
NSLayoutConstraint.activate([
157+
suffixLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -5),
158+
suffixLabel.firstBaselineAnchor.constraint(equalTo: self.firstBaselineAnchor)
159+
])
160+
}
161+
162+
required init?(coder: NSCoder) {
163+
fatalError("init(coder:) has not been implemented")
164+
}
165+
}
166+
167+
// MARK: Custom NSTextFieldCell
168+
169+
/// Constraint field cell to give sufficient room to the unit label
170+
private class CustomTextFieldCell: NSTextFieldCell {
171+
var suffixLabelWidth: CGFloat?
172+
173+
convenience init(suffixLabelWidth: CGFloat? = nil) {
174+
self.init(textCell: "")
175+
self.suffixLabelWidth = suffixLabelWidth
176+
}
177+
178+
override init(textCell string: String) {
179+
super.init(textCell: string)
180+
181+
self.isEditable = true
182+
self.isBordered = true
183+
self.drawsBackground = true
184+
self.isBezeled = true
185+
self.isSelectable = true
186+
}
187+
188+
required init(coder: NSCoder) {
189+
fatalError("init(coder:) has not been implemented")
190+
}
191+
192+
override func drawingRect(forBounds rect: NSRect) -> NSRect {
193+
let rectInset = NSRect(x: rect.origin.x, y: rect.origin.y, width: rect.size.width - (suffixLabelWidth ?? 10.0), height: rect.size.height)
194+
return super.drawingRect(forBounds: rectInset)
195+
}
196+
}
197+
}
198+
199+
200+
// MARK: - Previews
201+
202+
struct TextFieldPlusView: View {
203+
@State var value: Double = 0.0
204+
205+
func doubleFormatter(digits: Int = 1) -> NumberFormatter {
206+
let formatter = NumberFormatter()
207+
formatter.maximumFractionDigits = digits
208+
return formatter
209+
}
210+
211+
var body: some View {
212+
VStack {
213+
UnitNumberField(value: $value, unitText: "%", range: 0...100, formatter: doubleFormatter(digits: 0), bezelStyle: .roundedBezel, controlSize: .small)
214+
}
215+
}
216+
}
217+
218+
struct TextFieldPlusView_Previews: PreviewProvider {
219+
static var previews: some View {
220+
TextFieldPlusView(value: 100)
221+
.frame(width: 55)
222+
.padding()
223+
}
6224
}

Sources/doc/screen1.png

8.5 KB
Loading

Tests/UnitNumberFieldTests/UnitNumberFieldTests.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,5 @@ final class UnitNumberFieldTests: XCTestCase {
66
// This is an example of a functional test case.
77
// Use XCTAssert and related functions to verify your tests produce the correct
88
// results.
9-
XCTAssertEqual(UnitNumberField().text, "Hello, World!")
109
}
1110
}

0 commit comments

Comments
 (0)