SwiftUI - How to create TextField that only accepts numbers

Although showing a number pad is a good first step, it does not actually prevent bad data from being entered:

  1. The user can paste non-numeric text into the textfield
  2. iPad users will still get a full keyboard
  3. Anyone with a Bluetooth keyboard attached can type anything

What you really want to do is sanitize the input, like this:

import SwiftUI
import Combine

struct StackOverflowTests: View {
    @State private var numOfPeople = "0"

    var body: some View {
        TextField("Total number of people", text: $numOfPeople)
            .keyboardType(.numberPad)
            .onReceive(Just(numOfPeople)) { newValue in
                let filtered = newValue.filter { "0123456789".contains($0) }
                if filtered != newValue {
                    self.numOfPeople = filtered
                }
        }
    }
}

Whenever numOfPeople changes, the non-numeric values are filtered out, and the filtered value is compared to see if numOfPeople should be updated a second time, overwriting the bad input with the filtered input.

Note that the Just publisher requires that you import Combine.

EDIT:

To explain the Just publisher, consider the following conceptual outline of what occurs when you change the value in the TextField:

  1. Because TextField takes a Binding to a String, when the contents of the field are changed, it also writes that change back to the @State variable.
  2. When a variable marked @State changes, SwiftUI recomputes the body property of the view.
  3. During the body computation, a Just publisher is created. Combine has a lot of different publishers to emit values over time, but the Just publisher takes "just" a single value (the new value of numberOfPeople) and emits it when asked.
  4. The onReceive method makes a View a subscriber to a publisher, in this case, the Just publisher we just created. Once subscribed, it immediately asks for any available values from the publisher, of which there is only one, the new value of numberOfPeople.
  5. When the onReceive subscriber receives a value, it executes the specified closure. Our closure can end one of two ways. If the text is already numeric only, then it does nothing. If the filtered text is different, it is written to the @State variable, which begins the loop again, but this time the closure will execute without modifying any properties.

Check out Using Combine for more info.


tl;dr

Checkout John M's solution for a much better way.


One way to do it is that you can set the type of keyboard on the TextField which will limit what people can type on.

TextField("Total number of people", text: $numOfPeople)
    .keyboardType(.numberPad)

Apple's documentation can be found here, and you can see a list of all supported keyboard types here.

However, this method is only a first step and is not ideal as the only solution:

  1. iPad doesn't have a numberPad so this method won't work on an iPad.
  2. If the user is using a hardware keyboard then this method won't work.
  3. It does not check what the user has entered. A user could copy/paste a non-numeric value into the TextField.

You should sanitise the data that is entered and make sure that it is purely numeric.

For a solution that does that checkout John M's solution below. He does a great job explaining how to sanitise the data and how it works.


Heavily inspired by John M.'s answer, I modified things slightly.

For me, on Xcode 12 and iOS 14, I noticed that typing letters did show in the TextField, despite me not wanting them to. I wanted letters to be ignored, and only numerals to be permitted.

Here's what I did:

@State private var goalValue = ""

var body: some View {
    TextField("12345", text: self.$goalValue)
        .keyboardType(.numberPad)
        .onReceive(Just(self.goalValue), perform: self.numericValidator)
}

func numericValidator(newValue: String) {
    if newValue.range(of: "^\\d+$", options: .regularExpression) != nil {
        self.goalValue = newValue
    } else if !self.goalValue.isEmpty {
        self.goalValue = String(newValue.prefix(self.goalValue.count - 1))
    }
}

The key here is the else if; this sets the value of the underlying variable to be everything-but-the-most-recent-character.

Also worth noting, if you'd like to permit decimal numbers and not limit to just integers, you could change the regex string to "^[\d]+\.?[\d]+$", which you'll have to escape to be "^[\\d]+\\.?[\\d]+$".