Numeric TextField in SwiftUI


Greetings, traveler!

When working with numeric text fields in SwiftUI, setting .keyboardType(.numberPad) or .decimalPad is often a first step toward guiding user input. However, these configurations alone do not guarantee clean numeric input. Users can still paste non-numeric values, use external keyboards, or encounter full keyboards on iPads. To ensure consistent and valid input, the text must be programmatically sanitized.

This article explores a practical approach using a custom ViewModifier, combined with Combine’s Just publisher, to filter out unwanted characters and optionally support decimal input.

Why Programmatic Filtering is Necessary

Using a numeric keyboard does not prevent users from inputting invalid characters. Consider the following scenarios:

  • Users can paste arbitrary strings into the field.
  • iPad users may still be shown a full keyboard.
  • External keyboard users are not restricted by software keyboard types.

These gaps highlight the need for additional filtering logic that goes beyond keyboard configuration.

Implementation Overview

The solution involves defining a view modifier that intercepts user input and enforces character-level restrictions:

import SwiftUI
import Combine

public extension View {
    func numericInput(
        _ text: Binding<String>,
        isInteger: Bool
    ) -> some View {
        modifier(
            NumericTextModifier(
                text: text,
                hasDecimalPart: !isInteger
            )
        )
    }
}

private struct NumericTextModifier: ViewModifier {
    
    private enum Constants {
        static let allNumbers = "0123456789"
    }
    
    @Binding var text: String
    var hasDecimalPart: Bool
    
    private var decimalSeparator: String {
        Locale.current.decimalSeparator ?? "."
    }
    
    func body(content: Content) -> some View {
        content
            .onReceive(Just(text)) { newText in
                var numbers = Constants.allNumbers
                
                if hasDecimalPart {
                    numbers += decimalSeparator
                }
                
                if hasSeparatorWithoutDecimalPart(newText) {
                    text = String(newText.dropLast())
                } else {
                    let value = newText.filter {
                        numbers.contains($0)
                    }
                    
                    guard value != newText else { return }
                    text = value
                }
            }
            .keyboardType(hasDecimalPart ? .decimalPad : .numberPad)
    }
    
    private func hasSeparatorWithoutDecimalPart(_ text: String) -> Bool {
        text.components(separatedBy: decimalSeparator).count - 1 > 1
    }
    
}

Understanding the Role of Just in This Context

To understand the mechanics behind Just, it’s useful to consider the flow of data in SwiftUI when a TextField is bound to a @State or @Binding variable.

When a user modifies the text in a TextField, the change is immediately propagated to the bound variable. Since this variable is marked with @State, SwiftUI responds by recomputing the view’s body. During this recomputation, a Just publisher is instantiated with the most recent value of the text field.

Just is a publisher from the Combine framework that emits a single value and then finishes. In this case, the value is the latest string input from the user. When .onReceive(Just(text)) is used, the view subscribes to this single-value publisher. As soon as the subscription is established, the publisher emits its value—there is no delay or stream involved, just a one-time transmission of data.

This triggers the closure supplied to .onReceive, which inspects the new text. If the value already satisfies the numeric criteria, no further action is taken. Otherwise, the string is filtered to remove any disallowed characters, and the binding is updated with the sanitized result. If this updated value is different from the original input, the entire cycle may repeat, but it will quickly stabilize as soon as the input becomes valid.

This mechanism ensures that invalid input is corrected immediately after entry, creating a robust and user-friendly experience without compromising the declarative structure of SwiftUI.

Conclusion

Programmatic input sanitization is essential for robust numeric entry in SwiftUI. This approach is adaptable and locale-aware, making it a reliable foundation for numeric input across different user environments.