Skip to content

Repository files navigation

CBPinEntryView

A SwiftUI view for entering pins, one-time codes, or passwords — backspace, paste, and .oneTimeCode autofill all work correctly out of the box, with first-class accessibility (VoiceOver progress announcements, Dynamic Type, no colour-only states).

Requirements

  • iOS 16.0+
  • Swift 5.9+
  • No external dependencies

Installation

Swift Package Manager only:

dependencies: [
    .package(url: "https://github.com/Fawxy/CBPinEntryView.git", from: "2.0.0")
]

Upgrading from 1.x? CBPinEntryView 2.0 is a SwiftUI-only rewrite with no UIKit compatibility shim. See MIGRATION.md.

Usage

import CBPinEntryView

struct LoginView: View {
    @State private var pin = ""
    @State private var isError = false

    var body: some View {
        PinEntryView(pin: $pin, length: 6, isError: $isError) { pin in
            print("Completed: \(pin)")
        }
    }
}

pin is the single source of truth — read it directly, no getPinAsString() needed. Set pin = "" to clear.

Configuration

Behavioural configuration is applied via pin-prefixed modifiers, not the standard SwiftUI ones (see "Why pin-prefixed modifiers?" below):

PinEntryView(pin: $pin, length: 4, isError: $isError)
    .pinAllowedEntry(.numerical)
    .pinSecure(true, character: "")
    .pinKeyboardType(.numberPad)
    .pinTextContentType(.oneTimeCode)
    .pinTextInputAutocapitalization(.never)
    .pinHaptics(.default)
    .pinFocused($isFocused)

Custom cell rendering

The library ships one default cell (DefaultPinEntryCell) but imposes no particular look. Pass your own @ViewBuilder closure over PinEntryCellState:

PinEntryView(pin: $pin, length: 4, isError: $isError) { state in
    VStack {
        Text(state.character ?? "")
        Rectangle()
            .fill(state.isFocused ? Color.accentColor : Color.secondary)
            .frame(height: state.isFocused ? 3 : 1)
    }
}

PinEntryCellState is already masked when secure — a custom cell never sees the raw digit in secure mode.

Why pin-prefixed modifiers?

A wrapper view can't give a propagating standard modifier (.keyboardType, .textContentType, .textInputAutocapitalization) an overridable default: SwiftUI's environment resolves closest-to-the-field-wins, and the field inside PinEntryView is always closer than anything wrapped around it. Applying the standard modifier outside PinEntryView would silently do nothing. The pin-prefixed modifiers avoid that footgun — apply them directly on PinEntryView, before any type-erasing modifier like .frame().

Accessibility

The field carries a single accessibility element (label + a live "N of M entered" progress value); the cell overlay is hidden from VoiceOver. Secure entry reports count only, never characters. Error is surfaced non-visually as well as visually. This is guaranteed by the library regardless of the cell closure you provide.

UIKit interop

PinEntryView is a SwiftUI-only view. Host it from UIKit with UIHostingController:

let pinView = PinEntryView(pin: pinBinding, length: 4, isError: errorBinding) { pin in
    // handle completion
}
let hosting = UIHostingController(rootView: pinView)
addChild(hosting)
view.addSubview(hosting.view)
// ... Auto Layout constraints ...
hosting.didMove(toParent: self)

See Example/Example/HostingControllerInteropScreen.swift for a complete, running example.

Example app

Open Example/Example.xcodeproj in Xcode and run the Example scheme (iOS 17+ simulator). It exercises length, secure toggle, error toggle, allowed-character restriction, clear, programmatic focus, a custom underlined cell with a shake-on-error animation, an @Observable feature-model screen, and the UIHostingController interop screen.

Migrating from 1.x

See MIGRATION.md for the full API mapping.

License

CBPinEntryView is available under the MIT license. See the LICENSE file for more info.

About

A SwiftUI view for entering pins, one-time codes, or passwords — accessible and autofill-ready.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages