Skip to content

Commit 01c9930

Browse files
committed
Added source
1 parent 7ddb085 commit 01c9930

6 files changed

Lines changed: 178 additions & 41 deletions

File tree

.gitignore

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -44,47 +44,7 @@ playground.xcworkspace
4444
#
4545
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
4646
# hence it is not needed unless you have added a package configuration file to your project
47-
# .swiftpm
47+
.swiftpm
4848

4949
.build/
5050

51-
# CocoaPods
52-
#
53-
# We recommend against adding the Pods directory to your .gitignore. However
54-
# you should judge for yourself, the pros and cons are mentioned at:
55-
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56-
#
57-
# Pods/
58-
#
59-
# Add this line if you want to avoid checking in source code from the Xcode workspace
60-
# *.xcworkspace
61-
62-
# Carthage
63-
#
64-
# Add this line if you want to avoid checking in source code from Carthage dependencies.
65-
# Carthage/Checkouts
66-
67-
Carthage/Build/
68-
69-
# Accio dependency management
70-
Dependencies/
71-
.accio/
72-
73-
# fastlane
74-
#
75-
# It is recommended to not store the screenshots in the git repo.
76-
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
77-
# For more information about the recommended setup visit:
78-
# https://docs.fastlane.tools/best-practices/source-control/#source-control
79-
80-
fastlane/report.xml
81-
fastlane/Preview.html
82-
fastlane/screenshots/**/*.png
83-
fastlane/test_output
84-
85-
# Code Injection
86-
#
87-
# After new code Injection tools there's a generated folder /iOSInjectionProject
88-
# https://github.com/johnno1962/injectionforxcode
89-
90-
iOSInjectionProject/

Package.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// swift-tools-version:5.2
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
import PackageDescription
5+
6+
let package = Package(
7+
name: "NumericText",
8+
platforms: [.iOS(.v13), .macOS(.v10_15), .watchOS(.v6), .tvOS(.v13)],
9+
products: [
10+
.library(
11+
name: "NumericText",
12+
targets: ["NumericText"]),
13+
],
14+
dependencies: [],
15+
targets: [
16+
.target(
17+
name: "NumericText",
18+
dependencies: []),
19+
.testTarget(
20+
name: "NumericTextTests",
21+
dependencies: ["NumericText"]),
22+
]
23+
)

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# NumericText
2+
3+
A simple SwiftUI `TextField` that limits user input to numbers.
4+
5+
The most common response in https://stackoverflow.com/questions/58733003/swiftui-how-to-create-textfield-that-only-accepts-numbers (the top search result), advocates just setting a numeric keyboard. But that totally misses cases when used with hardware keyboards. Many of the suggested
6+
7+
This `NumericTextField` prevents any non-numeric text input, no matter the source (paste, external keyboard). You can choose to allow integers or floating point.
8+
Standard `TextFields` have a `Formatter` that you can pass in, that will be used to format/validate input. However this only occurs when the user finishes editing, not for every keystroke. So a user can type `123abc4` and see that in the text field, then when they hit return it will change to `1234`. That's really not ideal. With `NumericTextField` when they type a non-numeric character it is ignored and never shows up in the text field.
9+
10+
11+
## Usage:
12+
13+
It works just like `TextField` but you are binding it to `NSNumber?` instead of a `String`.
14+
15+
```
16+
// Inside your view
17+
@State private static var int: NSNumber?
18+
@State private static var double: NSNumber?
19+
20+
var body: Some View {
21+
VStack {
22+
NumericTextField("Int", number: $int, isDecimalAllowed: false)
23+
NumericTextField("Double", number: $double, isDecimalAllowed: true)
24+
}
25+
}
26+
27+
```
28+
29+
## Installation
30+
31+
Use Swift Package Manager or just drag and drop the two source files into your project. It supports any of Apple's platforms that support SwiftUI's initial release. So iOS 13, macOS 10.15, tvOS 13, watchOS 6.
32+
33+
## Improvement ideas
34+
* Ditch `NSNumber?` as the bound value, and allow binding either `Int?` or `Double?`. I took a quick shot at this, but my generics skills weren't sufficient to figure it out.
35+
* Support the other initializers that `TextField` supports.
36+
37+
Pull requests are welcome to help with these or any other things you may find or think of.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import SwiftUI
2+
3+
/// A `TextField` replacement that limits user input to numbers.
4+
public struct NumericTextField: View {
5+
6+
/// This is what consumers of the text field will access
7+
@Binding private var number: NSNumber?
8+
@State private var string: String
9+
private let isDecimalAllowed: Bool
10+
private let formatter: NumberFormatter = NumberFormatter()
11+
12+
private let title: LocalizedStringKey
13+
private let onEditingChanged: (Bool) -> Void
14+
private let onCommit: () -> Void
15+
16+
/// Creates a text field with a text label generated from a localized title string.
17+
///
18+
/// - Parameters:
19+
/// - titleKey: The key for the localized title of the text field,
20+
/// describing its purpose.
21+
/// - number: The number to be displayed and edited.
22+
/// - isDecimalAllowed: Should the user be allowed to enter a decimal number, or an integer
23+
/// - onEditingChanged: An action thats called when the user begins editing `text` and after the user finishes editing `text`.
24+
/// The closure receives a Boolean indicating whether the text field is currently being edited.
25+
/// - onCommit: An action to perform when the user performs an action (for example, when the user hits the return key) while the text field has focus.
26+
public init(_ titleKey: LocalizedStringKey, number: Binding<NSNumber?>, isDecimalAllowed: Bool, onEditingChanged: @escaping (Bool) -> Void = { _ in }, onCommit: @escaping () -> Void = {}) {
27+
formatter.numberStyle = .decimal
28+
_number = number
29+
if let number = number.wrappedValue, let string = formatter.string(from: number) {
30+
_string = State(initialValue: string)
31+
} else {
32+
_string = State(initialValue: "")
33+
}
34+
self.isDecimalAllowed = isDecimalAllowed
35+
title = titleKey
36+
self.onEditingChanged = onEditingChanged
37+
self.onCommit = onCommit
38+
}
39+
40+
public var body: some View {
41+
TextField(title, text: $string, onEditingChanged: onEditingChanged, onCommit: onCommit)
42+
.keyboardType(isDecimalAllowed ? .decimalPad : .numberPad)
43+
.onChange(of: string, perform: numberChanged(newValue:))
44+
}
45+
46+
private func numberChanged(newValue: String) {
47+
let numeric = newValue.numericValue(allowDecimalSeparator: isDecimalAllowed)
48+
if newValue != numeric {
49+
string = numeric
50+
}
51+
number = formatter.number(from: string)
52+
}
53+
}
54+
55+
struct NumericTextField_Previews: PreviewProvider {
56+
@State private static var int: NSNumber?
57+
@State private static var double: NSNumber?
58+
59+
static var previews: some View {
60+
VStack {
61+
NumericTextField("Int", number: $int, isDecimalAllowed: false)
62+
.textFieldStyle(RoundedBorderTextFieldStyle())
63+
.padding()
64+
NumericTextField("Double", number: $double, isDecimalAllowed: true)
65+
.textFieldStyle(RoundedBorderTextFieldStyle())
66+
.padding()
67+
}
68+
}
69+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import Foundation
2+
3+
public extension String {
4+
/// Get the numeric only value from the string
5+
/// - Parameter allowDecimalSeparator: If `true` then a single decimal separator will be allowed in the string.
6+
/// - Returns: Only numeric characters and optionally a single decimal character. If non-numeric values were interspersed `1a2b` then the result will be `12`.
7+
/// The numeric characters returned may be outside the normal 0-9 ASCII that you would expect. So you should avoid trying any `Int(someString)` with the results.
8+
/// Stick to using `NumberFormatter` like Apple intended.
9+
func numericValue(allowDecimalSeparator: Bool) -> String {
10+
var hasFoundDecimal = false
11+
return self.filter {
12+
if $0.isWholeNumber {
13+
return true
14+
} else if allowDecimalSeparator && String($0) == (Locale.current.decimalSeparator ?? ".") {
15+
defer { hasFoundDecimal = true }
16+
return !hasFoundDecimal
17+
}
18+
return false
19+
}
20+
}
21+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import NumericText
2+
import XCTest
3+
4+
final class StringNumericTests: XCTestCase {
5+
func testDoubleDecimal() {
6+
XCTAssertEqual("12.3.4".numericValue(allowDecimalSeparator: true), "12.34")
7+
XCTAssertEqual("12..34".numericValue(allowDecimalSeparator: true), "12.34")
8+
XCTAssertEqual(".1234.".numericValue(allowDecimalSeparator: true), ".1234")
9+
}
10+
11+
func testObscureNumericCharacters() throws {
12+
let formatter = NumberFormatter()
13+
formatter.numberStyle = .decimal
14+
15+
// DEVANAGARI 5
16+
let fiveString = ""
17+
XCTAssertEqual(fiveString.numericValue(allowDecimalSeparator: false), fiveString)
18+
let five = try XCTUnwrap(formatter.number(from: fiveString))
19+
XCTAssertEqual(five, 5)
20+
}
21+
22+
func testAlphaNumeric() {
23+
XCTAssertEqual("12a.3b4".numericValue(allowDecimalSeparator: true), "12.34")
24+
XCTAssertEqual("12abc34".numericValue(allowDecimalSeparator: true), "1234")
25+
XCTAssertEqual("a.1234.".numericValue(allowDecimalSeparator: true), ".1234")
26+
}
27+
}

0 commit comments

Comments
 (0)