Sitemap
Level Up Coding

Coding tutorials and news. The developer homepage gitconnected.com && skilled.dev && levelup.dev

SwiftUI: Custom Numeric TextField

8 min readJul 20, 2024

--

I think the title is pretty self-explanatory!

We are make a numeric TextField in this article, only allowing our users to input numbers.

We will first take a look at the numeric formatter built in to SwiftUI and its limitations as our motivation and move onto create our SUPER OWN Custom Numeric TextField!

The exact same idea applies to any custom TextField formatting and validation. You can use it to create an emoji-only TextField, only allowing specific characters, the possibility becomes infinite! You can create one, you can make the other!

As a bonus, I will also be sharing with you some of the styling tips such as adding a clear button, hiding the cursor, disabling copy/select actions, and etc.

SwiftUI Number Formatter

First of all, let’s take a look at the default SwiftUI implementation of the number formatter just in case it meets your need so that you don’t have to waste your time implement a custom TextField.

We can attach a number formatter to TextField by using init(value:format:prompt:label:) to restrict the kind of data it can contain to numbers.


import SwiftUI

struct NumericTextfieldDemo: View {
@State private var number: Float = 0

var body: some View {
VStack {
Text("Number: \(number)")

TextField("Enter a number", value: $number, format: .number)
.textFieldStyle(PlainTextFieldStyle())
.keyboardType(.numbersAndPunctuation)
.padding(.vertical, 8)
.padding(.horizontal, 16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.yellow.opacity(0.2))
)
.padding()

}
.padding()
}
}

As you can see, quiet number of limitations here!

Only validate and update on Return

That is the user is free to enter any kind of text, that can be alphabetic, punctuations, anything! And that will show up on the textfield because the validation is not happened until the return key pressed!

Another draw back of only able to validate on return is that if the value is invalid on validation, it will jump back to where they start off before entering.

How is that different from validating while inputting?

Let’s say we are inputting 44ddaa44.

With validation on return, we will have 44 when the return key pressed whereas if we are checking while inputting, we should have 4444 instead.

You might say why cannot we just simply constraint the input keyboard to numbers by attaching the .keyboardType(.numberPad) modifier to the TextField?

No decimal point nor return key!

Common Number Types Only

Another thing that the .number format really bothers me is that the input type, ie: the type of our State variable, can only be one of common? number types. That is

  • Int
  • Double
  • Float

However, I need CGFloat! Yes, I guess you can always do the conversion onChange of the state variable but obviously too much work for me!

Let’s create our own!

Custom Numeric Text Field

If you have ever used UITextField and UITextFieldDelegate, the solution is pretty obvious! Validate on textFieldDidChangeSelection(_:) and that’s it!

The idea is simple. However, when I actually try to implement it, it acutally requires some twisting around to make it work out the way I wanted. Let me share it with you here!

Basic

Let’s start with the most basic one and make improvments as we move on!


import SwiftUI

struct NumericTextfieldDemo: View {
@State private var number: CGFloat = 1.55555555

var body: some View {
VStack {
Text("Number: \(number)")

NumericTextfield(input: $number)
.padding(.vertical, 8)
.padding(.horizontal, 16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.yellow.opacity(0.2))
)
.padding()


}
.padding()
}
}

struct NumericTextfield: UIViewRepresentable {

@Binding var input: CGFloat

func makeUIView(context: Context) -> UITextField {
let textField = UITextField()
textField.text = "\(input)"

textField.font = .systemFont(ofSize: 12)
textField.delegate = context.coordinator

return textField
}

func updateUIView(_ uiView: UITextField, context: Context) {}
}


extension NumericTextfield {

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

class Coordinator: NSObject, UITextFieldDelegate {
var parent: NumericTextfield

init(_ control: NumericTextfield) {
self.parent = control
super.init()
}

func textFieldDidChangeSelection(_ textField: UITextField) {
guard let text = textField.text else { return }
if let float = Float(text) {
parent.input = CGFloat(float)
return
}

if text.count > 0 {
textField.text = String(text.prefix(text.count - 1))
} else {
textField.text = ""
parent.input = 0
}
}
}
}

The main part that process our input, as I mentioned above, is our textFieldDidChangeSelection(_:).

  • If we can convert it to a number, we will update our Binding input and have the textField.text takes on its new value.
  • If not, we will roll back to what we have before entering the last character. If that means an empty textField, we will set the input to 0.

In addition to the validation, we have also assign the initial value of our textField.text to be the state variable value on makeUIView.

Let’s give it a run.

The validation part looks great! Just what I was expecting.

However, other parts, not so much.

Get Itsuki’s stories in your inbox

Join Medium for free to get updates from this writer.

Specifically

  • Keyboard not dismissing on Return button press
  • TextField is filling up the ENTIRE width and height without me telling it to do so!

Let’s solve it!

Hide Keyboard on Return

To hide on return button press, all we have to do is to implement the textFieldShouldReturn(_:) delegate method in our coordinator.

class Coordinator: NSObject, UITextFieldDelegate {
//...

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}

Hide Keyboard on Tap outside

I always want a little extra (because I am greedy)!

Let’s also hide the keyboard on Tap outside of the TextField!

W can achieve this by controlling the focus of the our NumericTextfield with FocusState just like we do with regular SwiftUI TextField. Since we don’t want to dismiss the keyboard if the tap is within the textfield, let’s also add a GeometryReader to get its position and use it to check against the tap location.


struct NumericTextfieldDemo: View {
@State private var number: CGFloat = 1.55
@FocusState private var numberFocused

@State private var textFieldRect: CGRect = .zero

var body: some View {
VStack {
Text("Number: \(number)")

NumericTextfield(input: $number)
.focused($numberFocused)
//...
.overlay(content: {
GeometryReader { geometry in
DispatchQueue.main.async {
self.textFieldRect = geometry.frame(in: .global)
}
return Color.clear
}
})
.padding()


}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.gray.opacity(0.2))
.contentShape(Rectangle())
.onTapGesture(coordinateSpace: .global) { location in
if !textFieldRect.contains(location) {
numberFocused = false
}
}

}
}

I am using frame and contentShape on our VStack to control the tappable area. In this case the contentShape is actually not needed since our background does have a non-transparent color, but I am putting it here just in case you have Color.clear instead.

Also, it is super important to make sure that both the location and the frame are in the same coordinate space, .global in this case.

TextField Size

Yes you can always just set the frame but that won’t be dynamic based on the font.

Unfortunately simply add scaleToFit() modifier like the following does not work out quite right!

NumericTextfield(input: $number)
.focused($numberFocused)
.scaledToFit()
// ...

To solve this we will need to setContentHuggingPriority on updateUIView. If you are used to UIKit, you should already know what they are used for. It sets the priority with which a view resists being made larger than its intrinsic size.

struct NumericTextfield: UIViewRepresentable {   
@Binding var input: CGFloat

func updateUIView(_ uiView: UITextField, context: Context) {
uiView.setContentHuggingPriority(.defaultHigh, for: .vertical)
uiView.setContentHuggingPriority(.defaultLow, for: .horizontal)
}
}

We are keeping the horizontal priority while changing the vertical one here.

A lot better!

Extra BONUS!!!

Bonus time!

Since we are already using UITextField, let’s see some customizations that we CANNOT achieve using a SwiftUI TextField.

First of all, to add a clear button, simply set clearButtonMode.

func makeUIView(context: Context) -> UITextField {
// ...
textField.clearButtonMode = .whileEditing
// ...
}

You can also always just overlay your TextField with your own button so even without able to control it directly, not a big deal!

Time for some interesting ones that can ONLY (at least of what I know) be achieved through UITextField.

  • Hide input cursor
  • Remove selection cursor
  • remove action menu: that is copy, paste, and etc.

We will need to subclass UITextField to change those.


class CustomTextfield: UITextField {

// remove input cursor
override func caretRect(for position: UITextPosition) -> CGRect {
return .zero
}

// remove selection cursor
override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {
return []
}

// remove menu: copy, paste, and etc.
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
return false
}

}

And use it in our UIViewRepresentable.

func makeUIView(context: Context) -> UITextField {
let textField = CustomTextfield()
// ...
}

Wrap up!

Just putting everything together, here is what we have!


import SwiftUI

struct NumericTextfieldDemo: View {
@State private var number: CGFloat = 1.55
@FocusState private var numberFocused

var body: some View {
VStack {
Text("Number: \(number)")

NumericTextfield(input: $number)
.focused($numberFocused)
.padding(.vertical, 8)
.padding(.horizontal, 16)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(.yellow.opacity(0.2))
)
.padding()


}
.padding()
.onTapGesture {
numberFocused = false
}
}
}

class CustomTextfield: UITextField {

// remove input cursor
override func caretRect(for position: UITextPosition) -> CGRect {
return .zero
}

// remove selection cursor
override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] {
return []
}

// remove menu: copy, paste, and etc.
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
return false
}

}


struct NumericTextfield: UIViewRepresentable {

@Binding var input: CGFloat

func makeUIView(context: Context) -> UITextField {
let textField = CustomTextfield()
textField.text = "\(input)"

textField.font = .systemFont(ofSize: 12)
textField.delegate = context.coordinator

textField.contentVerticalAlignment = .top

return textField
}

func updateUIView(_ uiView: UITextField, context: Context) {
uiView.setContentHuggingPriority(.defaultHigh, for: .vertical)
uiView.setContentHuggingPriority(.defaultLow, for: .horizontal)

}
}



extension NumericTextfield {

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

class Coordinator: NSObject, UITextFieldDelegate {
var parent: NumericTextfield

init(_ control: NumericTextfield) {
self.parent = control
super.init()
}

func textFieldDidChangeSelection(_ textField: UITextField) {
guard let text = textField.text else { return }
if let float = Float(text) {
parent.input = CGFloat(float)
return
}

if text.count > 0 {
textField.text = String(text.prefix(text.count - 1))
} else {
textField.text = ""
parent.input = 0
}
}

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}
}

As I have mentioned, the exact same idea applies to any custom TextField formatting and validation. Give it a try!

Thank you for reading!

That’s all I have for today!

Happy texting!

--

--