Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
build-and-test:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Select Xcode
# Pin to the latest stable Xcode provided by the runner image so the
# Swift 6 toolchain is deterministic across runs.
run: sudo xcode-select -switch /Applications/Xcode.app

- name: Show environment
run: |
xcodebuild -version
swift --version

- name: Build
# Build against a generic simulator destination. We do not pin a device
# name here (like "iPhone 16") because the exact simulators installed on
# GitHub macos-latest runners change over time, and a generic Simulator
# destination always resolves. A successful build in Swift 6 language
# mode (tools-version 6.0) is also the strict concurrency guard.
run: |
xcodebuild build \
-scheme NerdzPinView \
-destination 'generic/platform=iOS Simulator'

- name: Select an available iPhone simulator
# Tests need a concrete device, so we derive one at runtime instead of
# hardcoding a name. We list available devices as JSON and pick the first
# available iPhone, exposing its UDID for the test step. This survives
# runner image changes (any iPhone the image ships with will be used).
id: sim
run: |
UDID=$(xcrun simctl list devices available --json | python3 -c "import json,sys; d=json.load(sys.stdin)['devices']; ids=[x['udid'] for r in d for x in d[r] if x.get('isAvailable') and x.get('name','').startswith('iPhone')]; print(ids[0] if ids else '')")
if [ -z "$UDID" ]; then
echo "No available iPhone simulator found." >&2
exit 1
fi
echo "Using simulator UDID: $UDID"
echo "udid=$UDID" >> "$GITHUB_OUTPUT"

- name: Test
run: |
xcodebuild test \
-scheme NerdzPinView \
-destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \
-enableCodeCoverage YES
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

This changelog starts its history at version 3.2.0. Earlier history is available through the git tags up to 3.1.0.

## [Unreleased]

Nothing yet.

## [3.2.0] 2026-09-16

### Added

* Swift Testing unit test target covering the pin view logic layer (text position, range, and selection math, plus per state appearance config resolution).
* GitHub Actions CI workflow that builds and tests on a macOS runner via xcodebuild against an iOS Simulator.
* DocC documentation catalog and doc comments for the public API.

### Changed

* Corrected the README Swift version badge (it showed Swift 5.1 or 5.9, but the package requires Swift 6.0) and documented the Xcode 16 requirement.
* Raised the package to Swift tools 6.0, which sets the minimum Xcode to 16 for consumers.

### Removed

* Removed the unused `underlineHeight` parameter from `UnderlineItemView.LayoutConfig.init`. The parameter was never stored and had no effect (underline height is controlled by `UnderlineItemView.AppearanceConfig` via `getUnderlineHeight(for:)`). Runtime behavior is unchanged. Call sites that passed `underlineHeight:` to the layout initializer must remove that argument and set the height on the appearance config instead.
4 changes: 4 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ let package = Package(
.target(
name: "NerdzPinView",
dependencies: []
),
.testTarget(
name: "NerdzPinViewTests",
dependencies: ["NerdzPinView"]
)
]
)
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

NerdzPinView is a highly customisable library used for entering pin and one time codes.

[![Swift 5.9](https://img.shields.io/badge/Swift-5.1-orange.svg?style=flat)](https://developer.apple.com/swift/)
[![Swift 6.0](https://img.shields.io/badge/Swift-6.0-orange.svg?style=flat)](https://developer.apple.com/swift/)
[![SPM Compatible](https://img.shields.io/badge/Swift%20Package%20Manager-8A2BE2)](https://www.swift.org/documentation/package-manager/)
[![Platforms iOS](https://img.shields.io/badge/Platforms-iOS-lightgray.svg?style=flat)](http://www.apple.com/ios/)
[![License MIT](https://img.shields.io/badge/License-MIT-lightgrey.svg?style=flat)](https://opensource.org/licenses/MIT)
Expand All @@ -11,6 +11,11 @@ NerdzPinView is a highly customisable library used for entering pin and one time

NerdzPinView library supports both UIKIt and SwiftUI frameworks.

## Requirements

* iOS 16 or later
* Swift 6.0 (Xcode 16 or later)

## Installation

### Swift Package Manager
Expand Down Expand Up @@ -114,11 +119,6 @@ For full control (for example to plug in a custom item view), use the generic `P

You can also browse the [UIKit demo project](https://github.com/RomanKovalchukDev/NerdzPinView/tree/main/Samples/NerdzPinUIKitSample).

## Requirements

- iOS 16.0 +
- Xcode 16.0 +

## License

NerdzPinView is available under the MIT license. See LICENSE for details.
37 changes: 37 additions & 0 deletions Sources/NerdzPinView/Documentation.docc/GettingStarted.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Getting Started

Add NerdzPinView to your project.

## Installation

NerdzPinView is distributed as a Swift package. Add it to your project through Xcode with File, Add Package Dependencies, then enter the repository URL and pick the ``NerdzPinView`` library product.

You can also declare the dependency directly in a `Package.swift` manifest.

```swift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
name: "MyApp",
platforms: [.iOS(.v16)],
dependencies: [
.package(url: "https://github.com/RomanKovalchukDev/NerdzPinView.git", from: "1.0.0")
],
targets: [
.target(
name: "MyApp",
dependencies: ["NerdzPinView"]
)
]
)
```

The package targets iOS 16 and later.

## Next Steps

Pick the integration that matches your app.

- <doc:SwiftUIUsage> for the SwiftUI wrappers.
- <doc:UIKitUsage> for direct use of the UIKit input views.
64 changes: 64 additions & 0 deletions Sources/NerdzPinView/Documentation.docc/NerdzPinView.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# ``NerdzPinView``

Customizable pin code and one-time code input views for UIKit, with ready to use SwiftUI wrappers.

## Overview

NerdzPinView provides styled, multi-cell code entry components for iOS. At its core are two generic UIKit containers. ``PinCodeInputView`` drives a row of tappable item cells through `UIKeyInput`, while ``OneTimeCodeInputView`` implements the full `UITextInput` protocol so it supports the system caret and one-time code autofill. Each container is parameterized by an item view type, and the module ships bordered, underlined, and grouped item views out of the box.

For most apps the pre-styled wrappers are enough. ``DesignableBorderedPinInputView``, ``DesignableUnderlinedPinInputView``, and ``DesignableOneTimeCodeInputView`` bundle sensible defaults, and ``NerdzBorderedPinView`` and ``NerdzUnderlinePinView`` expose that behavior to SwiftUI through bindings for the text, the state, and the keyboard focus.

To get started, read <doc:GettingStarted>, then <doc:SwiftUIUsage> for SwiftUI or <doc:UIKitUsage> for UIKit.

## Topics

### Essentials

- <doc:GettingStarted>
- <doc:SwiftUIUsage>
- <doc:UIKitUsage>

### SwiftUI Views

- ``NerdzBorderedPinView``
- ``NerdzUnderlinePinView``

### UIKit Input Views

- ``PinCodeInputView``
- ``OneTimeCodeInputView``
- ``DesignableBorderedPinInputView``
- ``DesignableUnderlinedPinInputView``
- ``DesignableOneTimeCodeInputView``
- ``PinTapableView``

### Item Views

- ``BorderedItemView``
- ``UnderlineItemView``
- ``OneTimeItemView``

### Item View Protocols

- ``PinCodeItemViewType``
- ``OneTimeCodeItemViewType``
- ``PinCodeItemView``
- ``OneTimeCodeItemView``

### Configuration

- ``DefaultableConfigType``
- ``ItemViewAppearanceConfigurable``
- ``ItemViewLayoutConfigurable``
- ``PinCodeItemViewState``

### Text Input Primitives

- ``PinTextPosition``
- ``PinTextRange``
- ``PinTextSelectionRect``

### Callbacks

- ``PinCodeEmptyAction``
- ``PinCodeTextAction``
64 changes: 64 additions & 0 deletions Sources/NerdzPinView/Documentation.docc/SwiftUIUsage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# SwiftUI Usage

Present a pin input in SwiftUI with the ready to use wrappers.

## Overview

Use ``NerdzBorderedPinView`` (or ``NerdzUnderlinePinView`` for the underlined style). It needs three bindings. One for the entered ``NerdzBorderedPinView/text``, one for the ``NerdzBorderedPinView/viewState``, and a `FocusState` binding for the keyboard focus.

```swift
import SwiftUI
import NerdzPinView

struct VerificationView: View {
@State private var code: String = ""
@State private var pinState: NerdzBorderedPinView.ViewState = .normal
@FocusState private var isFocused: Bool

var body: some View {
NerdzBorderedPinView(
text: $code,
viewState: $pinState,
isFocused: $isFocused,
onPinViewEnteredFully: { enteredCode in
verify(enteredCode)
}
)
.frame(height: 56)
.onAppear {
isFocused = true
}
}

private func verify(_ enteredCode: String) {
// Validate the code, then reflect the result in the state.
pinState = enteredCode == "123456" ? .normal : .error
}
}
```

The ``NerdzBorderedPinView/onPinViewEnteredFully`` closure fires once every cell is filled. Drive the ``NerdzBorderedPinView/viewState`` binding to `.error` to highlight an invalid code, or to `.disabled` to block further input.

## Customizing the Appearance

Pass a ``BorderedItemView/AppearanceConfig`` and a ``BorderedItemView/LayoutConfig`` (or the underline equivalents) to the initializer to change colors, fonts, and metrics. Pass a ``NerdzBorderedPinView/ViewConfig`` to adjust behavior such as the number of characters.

```swift
NerdzBorderedPinView(
text: $code,
viewState: $pinState,
isFocused: $isFocused,
config: .init(pinLength: 4),
itemsAppearanceConfig: BorderedItemView.AppearanceConfig(
defaultBorderColor: .systemGray,
activeBorderColor: .systemBlue,
font: .systemFont(ofSize: 20, weight: .semibold)
)
)
```

## See Also

- <doc:GettingStarted>
- <doc:UIKitUsage>
- ``NerdzUnderlinePinView``
88 changes: 88 additions & 0 deletions Sources/NerdzPinView/Documentation.docc/UIKitUsage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Using the UIKit Input Views
Comment thread
RomanKovalchukDev marked this conversation as resolved.

Embed a pin or one-time code input directly in a view controller.

## Overview

The SwiftUI wrappers are built on top of two generic UIKit containers, and you can use those containers directly. ``PinCodeInputView`` is a `UIKeyInput` based row of tappable cells, and ``OneTimeCodeInputView`` is a `UITextInput` based field that supports the system caret and one-time code autofill. Both are generic over an item view type, so you specialize them with one of the predefined item views such as ``BorderedItemView``, ``UnderlineItemView``, or ``OneTimeItemView``.

## PinCodeInputView

Specialize ``PinCodeInputView`` with an item view, configure it, then add it to your hierarchy. Call `becomeFirstResponder()` to raise the keyboard.

```swift
import UIKit
import NerdzPinView

final class PinViewController: UIViewController {

private let pinView = PinCodeInputView<BorderedItemView>()

override func viewDidLoad() {
super.viewDidLoad()

pinView.config = PinCodeInputView<BorderedItemView>.PinViewConfig(pinLength: 6)
pinView.appearanceConfig = BorderedItemView.AppearanceConfig(
font: .systemFont(ofSize: 18, weight: .medium)
)

pinView.onPinValueChanged = { value in
print("Current value: \(value)")
}

pinView.onPinViewEnteredFully = { value in
print("Completed: \(value)")
}

pinView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(pinView)

NSLayoutConstraint.activate([
pinView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
pinView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
pinView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
pinView.heightAnchor.constraint(equalToConstant: 56)
])
}

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)

pinView.becomeFirstResponder()
}
}
```

Set ``PinCodeInputView/viewState`` to `.error` to highlight an invalid entry, and use ``PinCodeInputView/setText(_:)`` to prefill or clear the value without triggering the change callbacks.

## OneTimeCodeInputView

``OneTimeCodeInputView`` works the same way, specialized with ``OneTimeItemView``. Because it conforms to `UITextInput`, it participates in one-time code autofill and can group the cells into two halves.

```swift
let codeView = OneTimeCodeInputView<OneTimeItemView>()

codeView.config = OneTimeCodeInputView<OneTimeItemView>.Config(
pinLength: 6,
shouldGroupNumbers: true
)
codeView.appearanceConfig = OneTimeItemView.AppearanceConfig(
font: .systemFont(ofSize: 18, weight: .medium)
)

codeView.onPinViewEnteredFully = { value in
print("Completed: \(value)")
}
```

Read the entered characters at any time through ``OneTimeCodeInputView/value``.

## Using the Pre-Styled Wrappers

If you do not need to pick the item type yourself, the designable wrappers apply a default styling and expose the same callbacks and configuration. Use ``DesignableBorderedPinInputView``, ``DesignableUnderlinedPinInputView``, or ``DesignableOneTimeCodeInputView``. These are also the views the SwiftUI wrappers bridge to.

## See Also

- <doc:GettingStarted>
- ``PinCodeInputView``
- ``OneTimeCodeInputView``
Loading
Loading