From 191d8aa63e506888ac3f26dce5c75a91e52bd63a Mon Sep 17 00:00:00 2001 From: Roman Kovalchuk Date: Wed, 16 Sep 2026 02:24:38 +0300 Subject: [PATCH 1/7] test: add Swift Testing target covering pin view logic layer Add NerdzPinViewTests with 47 tests across text position/range/selection math and per-state appearance config resolution for the bordered, underline, and one-time item views. Logic layer is near-fully covered; the UIKit view/rendering layer is intentionally left for snapshot tests. --- Package.swift | 4 + .../Tests/BorderedItemViewConfigTests.swift | 177 +++++++++++++++ .../Tests/OneTimeItemViewConfigTests.swift | 177 +++++++++++++++ .../Tests/PinTextPositionTests.swift | 42 ++++ .../Tests/PinTextRangeTests.swift | 210 ++++++++++++++++++ .../Tests/PinTextSelectionRectTests.swift | 126 +++++++++++ .../Tests/UnderlineItemViewConfigTests.swift | 175 +++++++++++++++ 7 files changed, 911 insertions(+) create mode 100644 Tests/NerdzPinViewTests/Tests/BorderedItemViewConfigTests.swift create mode 100644 Tests/NerdzPinViewTests/Tests/OneTimeItemViewConfigTests.swift create mode 100644 Tests/NerdzPinViewTests/Tests/PinTextPositionTests.swift create mode 100644 Tests/NerdzPinViewTests/Tests/PinTextRangeTests.swift create mode 100644 Tests/NerdzPinViewTests/Tests/PinTextSelectionRectTests.swift create mode 100644 Tests/NerdzPinViewTests/Tests/UnderlineItemViewConfigTests.swift diff --git a/Package.swift b/Package.swift index 9360666..689ae75 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,10 @@ let package = Package( .target( name: "NerdzPinView", dependencies: [] + ), + .testTarget( + name: "NerdzPinViewTests", + dependencies: ["NerdzPinView"] ) ] ) diff --git a/Tests/NerdzPinViewTests/Tests/BorderedItemViewConfigTests.swift b/Tests/NerdzPinViewTests/Tests/BorderedItemViewConfigTests.swift new file mode 100644 index 0000000..cc6678e --- /dev/null +++ b/Tests/NerdzPinViewTests/Tests/BorderedItemViewConfigTests.swift @@ -0,0 +1,177 @@ +// +// BorderedItemViewConfigTests.swift +// NerdzPinView +// +// Created by Roman Kovalchuk on 16.09.2026. +// + +import UIKit +import Testing +@testable import NerdzPinView + +@MainActor +@Suite("Bordered Item View Config") +struct BorderedItemViewConfigTests { + + @MainActor + @Suite("Appearance state resolution with overrides") + struct AppearanceWithOverridesTests { + + @Test + func testGetBackgroundColorWhenOverridesSetShouldResolvePerState() { + // Arrange + let config = TestData.configWithOverrides() + + // Act & Assert + #expect(config.getBackgroundColor(for: .disabled) == TestData.defaultColor) + #expect(config.getBackgroundColor(for: .normal) == TestData.defaultColor) + #expect(config.getBackgroundColor(for: .active) == TestData.activeColor) + #expect(config.getBackgroundColor(for: .error) == TestData.errorColor) + } + + @Test + func testGetBorderColorWhenOverridesSetShouldResolvePerState() { + // Arrange + let config = TestData.configWithOverrides() + + // Act & Assert + #expect(config.getBorderColor(for: .disabled) == TestData.defaultColor) + #expect(config.getBorderColor(for: .normal) == TestData.defaultColor) + #expect(config.getBorderColor(for: .active) == TestData.activeColor) + #expect(config.getBorderColor(for: .error) == TestData.errorColor) + } + + @Test + func testGetTextColorWhenOverridesSetShouldResolvePerState() { + // Arrange + let config = TestData.configWithOverrides() + + // Act & Assert + #expect(config.getTextColor(for: .disabled) == TestData.defaultColor) + #expect(config.getTextColor(for: .normal) == TestData.defaultColor) + #expect(config.getTextColor(for: .active) == TestData.activeColor) + #expect(config.getTextColor(for: .error) == TestData.errorColor) + } + + @Test + func testGetBorderWidthWhenOverridesSetShouldResolvePerState() { + // Arrange + let config = TestData.configWithOverrides() + + // Act & Assert + #expect(config.getBorderWidth(for: .disabled) == TestData.defaultWidth) + #expect(config.getBorderWidth(for: .normal) == TestData.defaultWidth) + #expect(config.getBorderWidth(for: .active) == TestData.activeWidth) + #expect(config.getBorderWidth(for: .error) == TestData.errorWidth) + } + } + + @MainActor + @Suite("Appearance state resolution without overrides") + struct AppearanceWithoutOverridesTests { + + @Test + func testGetBackgroundColorWhenOverridesNilShouldFallBackToDefault() { + // Arrange + let config = TestData.configWithoutOverrides() + + // Act & Assert + #expect(config.getBackgroundColor(for: .active) == TestData.defaultColor) + #expect(config.getBackgroundColor(for: .error) == TestData.defaultColor) + } + + @Test + func testGetBorderColorWhenOverridesNilShouldFallBackToDefault() { + // Arrange + let config = TestData.configWithoutOverrides() + + // Act & Assert + #expect(config.getBorderColor(for: .active) == TestData.defaultColor) + #expect(config.getBorderColor(for: .error) == TestData.defaultColor) + } + + @Test + func testGetTextColorWhenOverridesNilShouldFallBackToDefault() { + // Arrange + let config = TestData.configWithoutOverrides() + + // Act & Assert + #expect(config.getTextColor(for: .active) == TestData.defaultColor) + #expect(config.getTextColor(for: .error) == TestData.defaultColor) + } + + @Test + func testGetBorderWidthWhenOverridesNilShouldFallBackToDefault() { + // Arrange + let config = TestData.configWithoutOverrides() + + // Act & Assert + #expect(config.getBorderWidth(for: .active) == TestData.defaultWidth) + #expect(config.getBorderWidth(for: .error) == TestData.defaultWidth) + } + } + + @MainActor + @Suite("Defaults") + struct DefaultsTests { + + @Test + func testLayoutDefaultValueShouldMatchDocumentedDefaults() { + // Act + let layout = BorderedItemView.LayoutConfig.defaultValue + + // Assert + #expect(layout.cornerRadius == 8) + #expect(layout.cursorWidth == 1) + } + } +} + +@MainActor +private enum TestData { + static let defaultColor = UIColor(white: 0.1, alpha: 1) + static let activeColor = UIColor(white: 0.2, alpha: 1) + static let errorColor = UIColor(white: 0.3, alpha: 1) + + static let defaultWidth: CGFloat = 1 + static let activeWidth: CGFloat = 2 + static let errorWidth: CGFloat = 3 + + static func configWithOverrides() -> BorderedItemView.AppearanceConfig { + BorderedItemView.AppearanceConfig( + defaultBackgroundColor: defaultColor, + activeBackgroundColor: activeColor, + errorBackgroundColor: errorColor, + defaultValueColor: defaultColor, + activeValueColor: activeColor, + errorValueColor: errorColor, + placeholderColor: defaultColor, + defaultBorderColor: defaultColor, + activeBorderColor: activeColor, + errorBorderColor: errorColor, + defaultBorderWidth: defaultWidth, + activeBorderWidth: activeWidth, + errorBorderWidth: errorWidth, + cursorColor: defaultColor + ) + } + + static func configWithoutOverrides() -> BorderedItemView.AppearanceConfig { + BorderedItemView.AppearanceConfig( + defaultBackgroundColor: defaultColor, + activeBackgroundColor: nil, + errorBackgroundColor: nil, + defaultValueColor: defaultColor, + activeValueColor: nil, + errorValueColor: nil, + placeholderColor: defaultColor, + defaultBorderColor: defaultColor, + activeBorderColor: nil, + errorBorderColor: nil, + defaultBorderWidth: defaultWidth, + activeBorderWidth: nil, + errorBorderWidth: nil, + cursorColor: defaultColor + ) + } +} diff --git a/Tests/NerdzPinViewTests/Tests/OneTimeItemViewConfigTests.swift b/Tests/NerdzPinViewTests/Tests/OneTimeItemViewConfigTests.swift new file mode 100644 index 0000000..3f40aea --- /dev/null +++ b/Tests/NerdzPinViewTests/Tests/OneTimeItemViewConfigTests.swift @@ -0,0 +1,177 @@ +// +// OneTimeItemViewConfigTests.swift +// NerdzPinView +// +// Created by Roman Kovalchuk on 16.09.2026. +// + +import UIKit +import Testing +@testable import NerdzPinView + +@MainActor +@Suite("One Time Item View Config") +struct OneTimeItemViewConfigTests { + + @MainActor + @Suite("Appearance state resolution with overrides") + struct AppearanceWithOverridesTests { + + @Test + func testGetBackgroundColorWhenOverridesSetShouldResolvePerState() { + // Arrange + let config = TestData.configWithOverrides() + + // Act & Assert + #expect(config.getBackgroundColor(for: .disabled) == TestData.defaultColor) + #expect(config.getBackgroundColor(for: .normal) == TestData.defaultColor) + #expect(config.getBackgroundColor(for: .active) == TestData.activeColor) + #expect(config.getBackgroundColor(for: .error) == TestData.errorColor) + } + + @Test + func testGetBorderColorWhenOverridesSetShouldResolvePerState() { + // Arrange + let config = TestData.configWithOverrides() + + // Act & Assert + #expect(config.getBorderColor(for: .disabled) == TestData.defaultColor) + #expect(config.getBorderColor(for: .normal) == TestData.defaultColor) + #expect(config.getBorderColor(for: .active) == TestData.activeColor) + #expect(config.getBorderColor(for: .error) == TestData.errorColor) + } + + @Test + func testGetTextColorWhenOverridesSetShouldResolvePerState() { + // Arrange + let config = TestData.configWithOverrides() + + // Act & Assert + #expect(config.getTextColor(for: .disabled) == TestData.defaultColor) + #expect(config.getTextColor(for: .normal) == TestData.defaultColor) + #expect(config.getTextColor(for: .active) == TestData.activeColor) + #expect(config.getTextColor(for: .error) == TestData.errorColor) + } + + @Test + func testGetBorderWidthWhenOverridesSetShouldResolvePerState() { + // Arrange + let config = TestData.configWithOverrides() + + // Act & Assert + #expect(config.getBorderWidth(for: .disabled) == TestData.defaultWidth) + #expect(config.getBorderWidth(for: .normal) == TestData.defaultWidth) + #expect(config.getBorderWidth(for: .active) == TestData.activeWidth) + #expect(config.getBorderWidth(for: .error) == TestData.errorWidth) + } + } + + @MainActor + @Suite("Appearance state resolution without overrides") + struct AppearanceWithoutOverridesTests { + + @Test + func testGetBackgroundColorWhenOverridesNilShouldFallBackToDefault() { + // Arrange + let config = TestData.configWithoutOverrides() + + // Act & Assert + #expect(config.getBackgroundColor(for: .active) == TestData.defaultColor) + #expect(config.getBackgroundColor(for: .error) == TestData.defaultColor) + } + + @Test + func testGetBorderColorWhenOverridesNilShouldFallBackToDefault() { + // Arrange + let config = TestData.configWithoutOverrides() + + // Act & Assert + #expect(config.getBorderColor(for: .active) == TestData.defaultColor) + #expect(config.getBorderColor(for: .error) == TestData.defaultColor) + } + + @Test + func testGetTextColorWhenOverridesNilShouldFallBackToDefault() { + // Arrange + let config = TestData.configWithoutOverrides() + + // Act & Assert + #expect(config.getTextColor(for: .active) == TestData.defaultColor) + #expect(config.getTextColor(for: .error) == TestData.defaultColor) + } + + @Test + func testGetBorderWidthWhenOverridesNilShouldFallBackToDefault() { + // Arrange + let config = TestData.configWithoutOverrides() + + // Act & Assert + #expect(config.getBorderWidth(for: .active) == TestData.defaultWidth) + #expect(config.getBorderWidth(for: .error) == TestData.defaultWidth) + } + } + + @MainActor + @Suite("Defaults") + struct DefaultsTests { + + @Test + func testLayoutDefaultValueShouldMatchDocumentedDefaults() { + // Act + let layout = OneTimeItemView.LayoutConfig.defaultValue + + // Assert + #expect(layout.itemHeight == 50) + #expect(layout.cornerRadius == 8) + } + } +} + +@MainActor +private enum TestData { + static let defaultColor = UIColor(white: 0.1, alpha: 1) + static let activeColor = UIColor(white: 0.2, alpha: 1) + static let errorColor = UIColor(white: 0.3, alpha: 1) + + static let defaultWidth: CGFloat = 1 + static let activeWidth: CGFloat = 2 + static let errorWidth: CGFloat = 3 + + static func configWithOverrides() -> OneTimeItemView.AppearanceConfig { + OneTimeItemView.AppearanceConfig( + defaultBackgroundColor: defaultColor, + activeBackgroundColor: activeColor, + errorBackgroundColor: errorColor, + defaultValueColor: defaultColor, + activeValueColor: activeColor, + errorValueColor: errorColor, + placeholderColor: defaultColor, + defaultBorderColor: defaultColor, + activeBorderColor: activeColor, + errorBorderColor: errorColor, + defaultBorderWidth: defaultWidth, + activeBorderWidth: activeWidth, + errorBorderWidth: errorWidth, + cursorColor: defaultColor + ) + } + + static func configWithoutOverrides() -> OneTimeItemView.AppearanceConfig { + OneTimeItemView.AppearanceConfig( + defaultBackgroundColor: defaultColor, + activeBackgroundColor: nil, + errorBackgroundColor: nil, + defaultValueColor: defaultColor, + activeValueColor: nil, + errorValueColor: nil, + placeholderColor: defaultColor, + defaultBorderColor: defaultColor, + activeBorderColor: nil, + errorBorderColor: nil, + defaultBorderWidth: defaultWidth, + activeBorderWidth: nil, + errorBorderWidth: nil, + cursorColor: defaultColor + ) + } +} diff --git a/Tests/NerdzPinViewTests/Tests/PinTextPositionTests.swift b/Tests/NerdzPinViewTests/Tests/PinTextPositionTests.swift new file mode 100644 index 0000000..b688271 --- /dev/null +++ b/Tests/NerdzPinViewTests/Tests/PinTextPositionTests.swift @@ -0,0 +1,42 @@ +// +// PinTextPositionTests.swift +// NerdzPinView +// +// Created by Roman Kovalchuk on 16.09.2026. +// + +import Testing +@testable import NerdzPinView + +@MainActor +@Suite("Pin Text Position") +struct PinTextPositionTests { + + @Test + func testInitWhenGivenOffsetShouldStoreIt() { + // Arrange + let offset = TestData.offset + + // Act + let position = PinTextPosition(offset: offset) + + // Assert + #expect(position.offset == offset) + } + + @Test + func testDescriptionWhenGivenOffsetShouldMatchOffsetString() { + // Arrange + let offset = TestData.offset + + // Act + let position = PinTextPosition(offset: offset) + + // Assert + #expect(position.description == String(offset)) + } +} + +private enum TestData { + static let offset = 3 +} diff --git a/Tests/NerdzPinViewTests/Tests/PinTextRangeTests.swift b/Tests/NerdzPinViewTests/Tests/PinTextRangeTests.swift new file mode 100644 index 0000000..d6978bd --- /dev/null +++ b/Tests/NerdzPinViewTests/Tests/PinTextRangeTests.swift @@ -0,0 +1,210 @@ +// +// PinTextRangeTests.swift +// NerdzPinView +// +// Created by Roman Kovalchuk on 16.09.2026. +// + +import Testing +@testable import NerdzPinView + +@Suite("Pin Text Range") +struct PinTextRangeTests { + + @MainActor + @Suite("Init from/to") + struct InitFromToTests { + + @Test + func testInitWhenFromLessThanToShouldStorePositions() throws { + // Arrange + let from = PinTextPosition(offset: TestData.lowerOffset) + let to = PinTextPosition(offset: TestData.upperOffset) + + // Act + let range = try #require(PinTextRange(from: from, to: to)) + + // Assert + #expect(range.startPosition.offset == TestData.lowerOffset) + #expect(range.endPosition.offset == TestData.upperOffset) + } + + @Test + func testInitWhenFromEqualsToShouldReturnNil() { + // Arrange + let position = PinTextPosition(offset: TestData.lowerOffset) + + // Act + let range = PinTextRange(from: position, to: PinTextPosition(offset: TestData.lowerOffset)) + + // Assert + #expect(range == nil) + } + + @Test + func testInitWhenFromGreaterThanToShouldReturnNil() { + // Arrange + let from = PinTextPosition(offset: TestData.upperOffset) + let to = PinTextPosition(offset: TestData.lowerOffset) + + // Act + let range = PinTextRange(from: from, to: to) + + // Assert + #expect(range == nil) + } + } + + @MainActor + @Suite("Init from/maxOffset") + struct InitMaxOffsetTests { + + @Test + func testInitWhenPositiveOffsetWithinBoundsShouldExtendForward() { + // Arrange + let from = PinTextPosition(offset: TestData.lowerOffset) + let maxOffset = TestData.smallMaxOffset + + // Act + let range = PinTextRange(from: from, maxOffset: maxOffset, in: TestData.baseString) + + // Assert + #expect(range.startPosition.offset == TestData.lowerOffset) + #expect(range.endPosition.offset == TestData.lowerOffset + maxOffset) + } + + @Test + func testInitWhenPositiveOffsetExceedsLengthShouldClampToStringCount() { + // Arrange + let from = PinTextPosition(offset: TestData.lowerOffset) + + // Act + let range = PinTextRange(from: from, maxOffset: TestData.hugeMaxOffset, in: TestData.baseString) + + // Assert + #expect(range.endPosition.offset == TestData.baseString.count) + } + + @Test + func testInitWhenNegativeOffsetWithinBoundsShouldExtendBackward() { + // Arrange + let from = PinTextPosition(offset: TestData.upperOffset) + let maxOffset = TestData.negativeMaxOffset + + // Act + let range = PinTextRange(from: from, maxOffset: maxOffset, in: TestData.baseString) + + // Assert + #expect(range.startPosition.offset == TestData.upperOffset + maxOffset) + #expect(range.endPosition.offset == TestData.upperOffset) + } + + @Test + func testInitWhenNegativeOffsetBelowZeroShouldClampToZero() { + // Arrange + let from = PinTextPosition(offset: TestData.lowerOffset) + + // Act + let range = PinTextRange(from: from, maxOffset: TestData.hugeNegativeMaxOffset, in: TestData.baseString) + + // Assert + #expect(range.startPosition.offset == 0) + #expect(range.endPosition.offset == TestData.lowerOffset) + } + } + + @MainActor + @Suite("Derived properties") + struct DerivedPropertyTests { + + @Test + func testLengthWhenRangeSpansPositionsShouldReturnDifference() throws { + // Arrange + let from = PinTextPosition(offset: TestData.lowerOffset) + let to = PinTextPosition(offset: TestData.upperOffset) + + // Act + let range = try #require(PinTextRange(from: from, to: to)) + + // Assert + #expect(range.length == TestData.upperOffset - TestData.lowerOffset) + } + + @Test + func testIsEmptyWhenMaxOffsetIsZeroShouldReturnTrue() { + // Arrange + let from = PinTextPosition(offset: TestData.lowerOffset) + + // Act + let range = PinTextRange(from: from, maxOffset: TestData.zeroMaxOffset, in: TestData.baseString) + + // Assert + #expect(range.isEmpty) + } + + @Test + func testIsEmptyWhenRangeSpansPositionsShouldReturnFalse() throws { + // Arrange + let range = try #require( + PinTextRange( + from: PinTextPosition(offset: TestData.lowerOffset), + to: PinTextPosition(offset: TestData.upperOffset) + ) + ) + + // Act + let isEmpty = range.isEmpty + + // Assert + #expect(isEmpty == false) + } + + @Test + func testDescriptionWhenRangeSpansPositionsShouldMatchFormat() throws { + // Arrange + let range = try #require( + PinTextRange( + from: PinTextPosition(offset: TestData.lowerOffset), + to: PinTextPosition(offset: TestData.upperOffset) + ) + ) + + // Act + let description = range.description + + // Assert + #expect(description == "[\(TestData.lowerOffset) ..< \(TestData.upperOffset)]") + } + + @Test + func testFullRangeWhenRangeSpansPositionsShouldMapToSubstring() throws { + // Arrange + let base = TestData.baseString + let range = try #require( + PinTextRange( + from: PinTextPosition(offset: TestData.lowerOffset), + to: PinTextPosition(offset: TestData.upperOffset) + ) + ) + + // Act + let stringRange = range.fullRange(in: base) + + // Assert + let expectedStart = base.index(base.startIndex, offsetBy: TestData.lowerOffset) + let expectedEnd = base.index(base.startIndex, offsetBy: TestData.upperOffset) + #expect(stringRange == expectedStart.. UnderlineItemView.AppearanceConfig { + UnderlineItemView.AppearanceConfig( + defaultBackgroundColor: defaultColor, + activeBackgroundColor: activeColor, + errorBackgroundColor: errorColor, + defaultValueColor: defaultColor, + activeValueColor: activeColor, + errorValueColor: errorColor, + defaultUnderlineColor: defaultColor, + activeUnderlineColor: activeColor, + errorUnderlineColor: errorColor, + defaultUnderlineHeight: defaultHeight, + activeUnderlineHeight: activeHeight, + errorUnderlineHeight: errorHeight, + placeholderColor: defaultColor, + cursorColor: defaultColor + ) + } + + static func configWithoutOverrides() -> UnderlineItemView.AppearanceConfig { + UnderlineItemView.AppearanceConfig( + defaultBackgroundColor: defaultColor, + activeBackgroundColor: nil, + errorBackgroundColor: nil, + defaultValueColor: defaultColor, + activeValueColor: nil, + errorValueColor: nil, + defaultUnderlineColor: defaultColor, + activeUnderlineColor: nil, + errorUnderlineColor: nil, + defaultUnderlineHeight: defaultHeight, + activeUnderlineHeight: nil, + errorUnderlineHeight: nil, + placeholderColor: defaultColor, + cursorColor: defaultColor + ) + } +} From 7823b22203edd860ab7cfaf3e10928656dcc9b9a Mon Sep 17 00:00:00 2001 From: Roman Kovalchuk Date: Wed, 16 Sep 2026 02:26:38 +0300 Subject: [PATCH 2/7] ci: add GitHub Actions workflow for build and test Run build and test on a macOS runner via xcodebuild against an iOS Simulator (public repo, so macOS minutes are free). The library depends on UIKit and cannot build on Linux. The test job derives an available iPhone simulator UDID at runtime so it survives runner image changes. --- .github/workflows/ci.yml | 60 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a6ee52 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 From d923b87e97d383646cad1d6047a77d9567be129c Mon Sep 17 00:00:00 2001 From: Roman Kovalchuk Date: Wed, 16 Sep 2026 02:28:55 +0300 Subject: [PATCH 3/7] docs: fix Swift version badge and add CHANGELOG Correct the README Swift badge (it claimed 5.9 and rendered 5.1 while the package requires Swift 6.0), add a Requirements section stating iOS 16 and Xcode 16, remove a duplicate Requirements section, and add a Keep a Changelog CHANGELOG.md starting at 3.2.0. --- CHANGELOG.md | 25 +++++++++++++++++++++++++ README.md | 12 ++++++------ 2 files changed, 31 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..72d56af --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# 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. diff --git a/README.md b/README.md index ab69555..46273e8 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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. From 38f74b29f426543344d0107a960555a9d35bd8aa Mon Sep 17 00:00:00 2001 From: Roman Kovalchuk Date: Wed, 16 Sep 2026 02:29:29 +0300 Subject: [PATCH 4/7] chore: fix stale file header project name Replace the leftover PinViewDemo project name in file headers with NerdzPinView across nine source files. --- Sources/NerdzPinView/General/Aliases.swift | 2 +- Sources/NerdzPinView/General/PinCodeItemViewState.swift | 2 +- .../NerdzPinView/ItemViews/Abstract/DefaultableConfigType.swift | 2 +- .../ItemViews/Abstract/ItemViewAppearanceConfigurable.swift | 2 +- .../ItemViews/Abstract/ItemViewLayoutConfigurable.swift | 2 +- .../NerdzPinView/ItemViews/Abstract/PinCodeItemViewType.swift | 2 +- .../ItemViews/PredefinedViews/BorderedItemView.swift | 2 +- Sources/NerdzPinView/Views/PinInputView/PinCodeInputView.swift | 2 +- Sources/NerdzPinView/Views/PinInputView/PinTapableView.swift | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/NerdzPinView/General/Aliases.swift b/Sources/NerdzPinView/General/Aliases.swift index e202baf..ef1c701 100644 --- a/Sources/NerdzPinView/General/Aliases.swift +++ b/Sources/NerdzPinView/General/Aliases.swift @@ -1,6 +1,6 @@ // // Aliases.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 19.11.2024. // diff --git a/Sources/NerdzPinView/General/PinCodeItemViewState.swift b/Sources/NerdzPinView/General/PinCodeItemViewState.swift index 1b4e98e..b2c54fa 100644 --- a/Sources/NerdzPinView/General/PinCodeItemViewState.swift +++ b/Sources/NerdzPinView/General/PinCodeItemViewState.swift @@ -1,6 +1,6 @@ // // PinCodeItemViewState.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 19.11.2024. // diff --git a/Sources/NerdzPinView/ItemViews/Abstract/DefaultableConfigType.swift b/Sources/NerdzPinView/ItemViews/Abstract/DefaultableConfigType.swift index 01c3ad9..0bd3a6d 100644 --- a/Sources/NerdzPinView/ItemViews/Abstract/DefaultableConfigType.swift +++ b/Sources/NerdzPinView/ItemViews/Abstract/DefaultableConfigType.swift @@ -1,6 +1,6 @@ // // DefaultableConfigType.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 20.11.2024. // diff --git a/Sources/NerdzPinView/ItemViews/Abstract/ItemViewAppearanceConfigurable.swift b/Sources/NerdzPinView/ItemViews/Abstract/ItemViewAppearanceConfigurable.swift index 7160264..1b980a5 100644 --- a/Sources/NerdzPinView/ItemViews/Abstract/ItemViewAppearanceConfigurable.swift +++ b/Sources/NerdzPinView/ItemViews/Abstract/ItemViewAppearanceConfigurable.swift @@ -1,6 +1,6 @@ // // ItemViewAppearanceConfigurable.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 20.11.2024. // diff --git a/Sources/NerdzPinView/ItemViews/Abstract/ItemViewLayoutConfigurable.swift b/Sources/NerdzPinView/ItemViews/Abstract/ItemViewLayoutConfigurable.swift index 2f3a9b5..8439b41 100644 --- a/Sources/NerdzPinView/ItemViews/Abstract/ItemViewLayoutConfigurable.swift +++ b/Sources/NerdzPinView/ItemViews/Abstract/ItemViewLayoutConfigurable.swift @@ -1,6 +1,6 @@ // // ItemViewLayoutConfigurable.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 20.11.2024. // diff --git a/Sources/NerdzPinView/ItemViews/Abstract/PinCodeItemViewType.swift b/Sources/NerdzPinView/ItemViews/Abstract/PinCodeItemViewType.swift index 077b461..04abd5d 100644 --- a/Sources/NerdzPinView/ItemViews/Abstract/PinCodeItemViewType.swift +++ b/Sources/NerdzPinView/ItemViews/Abstract/PinCodeItemViewType.swift @@ -1,6 +1,6 @@ // // PinCodeItemViewType.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 19.11.2024. // diff --git a/Sources/NerdzPinView/ItemViews/PredefinedViews/BorderedItemView.swift b/Sources/NerdzPinView/ItemViews/PredefinedViews/BorderedItemView.swift index 064650a..7318bd7 100644 --- a/Sources/NerdzPinView/ItemViews/PredefinedViews/BorderedItemView.swift +++ b/Sources/NerdzPinView/ItemViews/PredefinedViews/BorderedItemView.swift @@ -1,6 +1,6 @@ // // BorderedItemView.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 19.11.2024. // diff --git a/Sources/NerdzPinView/Views/PinInputView/PinCodeInputView.swift b/Sources/NerdzPinView/Views/PinInputView/PinCodeInputView.swift index 23b13f8..0d9295f 100644 --- a/Sources/NerdzPinView/Views/PinInputView/PinCodeInputView.swift +++ b/Sources/NerdzPinView/Views/PinInputView/PinCodeInputView.swift @@ -1,6 +1,6 @@ // // PinCodeInputView.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 19.11.2024. // diff --git a/Sources/NerdzPinView/Views/PinInputView/PinTapableView.swift b/Sources/NerdzPinView/Views/PinInputView/PinTapableView.swift index 3d9758d..631d649 100644 --- a/Sources/NerdzPinView/Views/PinInputView/PinTapableView.swift +++ b/Sources/NerdzPinView/Views/PinInputView/PinTapableView.swift @@ -1,6 +1,6 @@ // // TapableView.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 20.11.2024. // From 27dcb0b3dcb097a70ede063f74a5dcd10fcaa081 Mon Sep 17 00:00:00 2001 From: Roman Kovalchuk Date: Wed, 16 Sep 2026 02:46:58 +0300 Subject: [PATCH 5/7] docs: add DocC catalog and public API doc comments Add a Documentation.docc catalog (landing page with Topics, a Getting Started article, and a UIKit usage article) and /// doc comments to every public symbol across the module. docbuild is warning free. No code or signature changes. --- .../Documentation.docc/GettingStarted.md | 90 +++++ .../Documentation.docc/NerdzPinView.md | 63 ++++ .../Documentation.docc/UIKitUsage.md | 88 +++++ Sources/NerdzPinView/General/Aliases.swift | 9 + .../General/PinCodeItemViewState.swift | 12 + .../General/UIView+FillView.swift | 5 + .../Abstract/DefaultableConfigType.swift | 6 + .../ItemViewAppearanceConfigurable.swift | 8 + .../Abstract/ItemViewLayoutConfigurable.swift | 8 + .../Abstract/PinCodeItemViewType.swift | 29 +- .../PredefinedViews/BorderedItemView.swift | 128 +++++-- .../PredefinedViews/OneTimeItemView.swift | 125 +++++-- .../PredefinedViews/UnderlineItemView.swift | 148 ++++++-- .../SwiftUI/NerdzBorderedPinView.swift | 77 ++++- .../SwiftUI/NerdzUnderlinePinView.swift | 79 ++++- .../OneTimeCodeInputView.swift | 327 ++++++++++++++---- .../OneTimeCodeItemViewType.swift | 15 +- .../DesignableBorderedPinInputView.swift | 189 ++++++---- .../DesignableUnderlinedPinInputView.swift | 189 ++++++---- .../Views/PinInputView/PinCodeInputView.swift | 181 ++++++++-- .../Views/PinInputView/PinTapableView.swift | 12 +- .../NerdzPinView/Views/PinTextPosition.swift | 13 +- Sources/NerdzPinView/Views/PinTextRange.swift | 53 ++- .../Views/PinTextSelectionRect.swift | 20 +- 24 files changed, 1551 insertions(+), 323 deletions(-) create mode 100644 Sources/NerdzPinView/Documentation.docc/GettingStarted.md create mode 100644 Sources/NerdzPinView/Documentation.docc/NerdzPinView.md create mode 100644 Sources/NerdzPinView/Documentation.docc/UIKitUsage.md diff --git a/Sources/NerdzPinView/Documentation.docc/GettingStarted.md b/Sources/NerdzPinView/Documentation.docc/GettingStarted.md new file mode 100644 index 0000000..1bf2544 --- /dev/null +++ b/Sources/NerdzPinView/Documentation.docc/GettingStarted.md @@ -0,0 +1,90 @@ +# Getting Started + +Add NerdzPinView to your project and present a pin input in SwiftUI. + +## 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. + +## A Minimal SwiftUI Example + +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 + +- +- ``NerdzUnderlinePinView`` diff --git a/Sources/NerdzPinView/Documentation.docc/NerdzPinView.md b/Sources/NerdzPinView/Documentation.docc/NerdzPinView.md new file mode 100644 index 0000000..f4e4827 --- /dev/null +++ b/Sources/NerdzPinView/Documentation.docc/NerdzPinView.md @@ -0,0 +1,63 @@ +# ``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 for SwiftUI or for UIKit. + +## Topics + +### Essentials + +- +- + +### 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`` diff --git a/Sources/NerdzPinView/Documentation.docc/UIKitUsage.md b/Sources/NerdzPinView/Documentation.docc/UIKitUsage.md new file mode 100644 index 0000000..bd1ec73 --- /dev/null +++ b/Sources/NerdzPinView/Documentation.docc/UIKitUsage.md @@ -0,0 +1,88 @@ +# Using the UIKit Input Views + +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() + + override func viewDidLoad() { + super.viewDidLoad() + + pinView.config = PinCodeInputView.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() + +codeView.config = OneTimeCodeInputView.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 + +- +- ``PinCodeInputView`` +- ``OneTimeCodeInputView`` diff --git a/Sources/NerdzPinView/General/Aliases.swift b/Sources/NerdzPinView/General/Aliases.swift index ef1c701..0fe84c9 100644 --- a/Sources/NerdzPinView/General/Aliases.swift +++ b/Sources/NerdzPinView/General/Aliases.swift @@ -5,5 +5,14 @@ // Created by Roman Kovalchuk on 19.11.2024. // +/// A closure that reports an event carrying no associated value. +/// +/// Used across the module for callbacks such as first responder changes, +/// where only the fact that the event happened matters. public typealias PinCodeEmptyAction = () -> Void + +/// A closure that reports an event carrying the current code as a string. +/// +/// Used for callbacks such as value changes and completion, where the +/// latest entered value is delivered to the caller. public typealias PinCodeTextAction = (String) -> Void diff --git a/Sources/NerdzPinView/General/PinCodeItemViewState.swift b/Sources/NerdzPinView/General/PinCodeItemViewState.swift index b2c54fa..5604830 100644 --- a/Sources/NerdzPinView/General/PinCodeItemViewState.swift +++ b/Sources/NerdzPinView/General/PinCodeItemViewState.swift @@ -5,9 +5,21 @@ // Created by Roman Kovalchuk on 19.11.2024. // +/// The visual state of a single item view inside a pin or one-time code input. +/// +/// Item views use this state to pick the matching colors, border, and cursor +/// visibility. It is distinct from the container view state, which describes +/// the whole input rather than an individual cell. public enum PinCodeItemViewState { + /// The item belongs to an input that cannot receive text. case disabled + + /// The item is the current insertion point and shows the blinking cursor. case active + + /// The item is idle and neither focused nor in an error state. case normal + + /// The item belongs to an input that is presenting an error. case error } diff --git a/Sources/NerdzPinView/General/UIView+FillView.swift b/Sources/NerdzPinView/General/UIView+FillView.swift index c79a090..be59e4e 100644 --- a/Sources/NerdzPinView/General/UIView+FillView.swift +++ b/Sources/NerdzPinView/General/UIView+FillView.swift @@ -8,6 +8,11 @@ import UIKit extension UIView { + /// Adds a subview and pins it to the receiver's layout margins guide on every edge. + /// + /// - Parameters: + /// - view: The subview to add and constrain. + /// - directionalLayoutMargins: The margins applied to the receiver before pinning the subview. func addAndFillSubview(_ view: UIView, directionalLayoutMargins: NSDirectionalEdgeInsets) { self.directionalLayoutMargins = directionalLayoutMargins view.translatesAutoresizingMaskIntoConstraints = false diff --git a/Sources/NerdzPinView/ItemViews/Abstract/DefaultableConfigType.swift b/Sources/NerdzPinView/ItemViews/Abstract/DefaultableConfigType.swift index 0bd3a6d..03f4a22 100644 --- a/Sources/NerdzPinView/ItemViews/Abstract/DefaultableConfigType.swift +++ b/Sources/NerdzPinView/ItemViews/Abstract/DefaultableConfigType.swift @@ -5,7 +5,13 @@ // Created by Roman Kovalchuk on 20.11.2024. // +/// A configuration type that provides a ready to use default value. +/// +/// Item view layout and appearance configurations conform to this protocol so +/// that container views can seed themselves without requiring the caller to +/// supply a fully specified configuration. @MainActor public protocol DefaultableConfigType { + /// The default configuration applied when no custom value is provided. static var defaultValue: Self { get } } diff --git a/Sources/NerdzPinView/ItemViews/Abstract/ItemViewAppearanceConfigurable.swift b/Sources/NerdzPinView/ItemViews/Abstract/ItemViewAppearanceConfigurable.swift index 1b980a5..02ca665 100644 --- a/Sources/NerdzPinView/ItemViews/Abstract/ItemViewAppearanceConfigurable.swift +++ b/Sources/NerdzPinView/ItemViews/Abstract/ItemViewAppearanceConfigurable.swift @@ -5,8 +5,16 @@ // Created by Roman Kovalchuk on 20.11.2024. // +/// An item view whose colors, fonts, and other visual traits are driven by a configuration value. +/// +/// Container views read and assign ``appearanceConfig`` to keep every item +/// styled consistently. The associated configuration conforms to +/// ``DefaultableConfigType`` so a default styling is always available. @MainActor public protocol ItemViewAppearanceConfigurable: AnyObject { + /// The concrete appearance configuration used by the conforming item view. associatedtype AppearanceConfig: DefaultableConfigType + + /// The appearance configuration currently applied to the item view. var appearanceConfig: AppearanceConfig { get set } } diff --git a/Sources/NerdzPinView/ItemViews/Abstract/ItemViewLayoutConfigurable.swift b/Sources/NerdzPinView/ItemViews/Abstract/ItemViewLayoutConfigurable.swift index 8439b41..9c2c6ca 100644 --- a/Sources/NerdzPinView/ItemViews/Abstract/ItemViewLayoutConfigurable.swift +++ b/Sources/NerdzPinView/ItemViews/Abstract/ItemViewLayoutConfigurable.swift @@ -5,8 +5,16 @@ // Created by Roman Kovalchuk on 20.11.2024. // +/// An item view whose sizing and geometry are driven by a configuration value. +/// +/// Container views read and assign ``layoutConfig`` to keep every item laid out +/// consistently. The associated configuration conforms to +/// ``DefaultableConfigType`` so a default layout is always available. @MainActor public protocol ItemViewLayoutConfigurable: AnyObject { + /// The concrete layout configuration used by the conforming item view. associatedtype LayoutConfig: DefaultableConfigType + + /// The layout configuration currently applied to the item view. var layoutConfig: LayoutConfig { get set } } diff --git a/Sources/NerdzPinView/ItemViews/Abstract/PinCodeItemViewType.swift b/Sources/NerdzPinView/ItemViews/Abstract/PinCodeItemViewType.swift index 04abd5d..9869567 100644 --- a/Sources/NerdzPinView/ItemViews/Abstract/PinCodeItemViewType.swift +++ b/Sources/NerdzPinView/ItemViews/Abstract/PinCodeItemViewType.swift @@ -7,18 +7,39 @@ import Foundation +/// A single cell inside a ``PinCodeInputView`` that renders one character of the code. +/// +/// Conforming views display the current character, a placeholder, and a cursor, +/// and report taps back to the container so it can move the active position. +/// The predefined conformers are ``BorderedItemView`` and ``UnderlineItemView``. @MainActor public protocol PinCodeItemViewType: AnyObject { - + + /// A closure invoked when the user taps the item view. var onViewTapped: PinCodeEmptyAction? { get set } - + + /// The current visual state that drives colors, border, and cursor visibility. var viewState: PinCodeItemViewState { get set } + + /// The character currently shown in the item, or `nil` when the item is empty. var valueCharacter: Character? { get } + + /// The placeholder character shown while the item has no value. var placeholderCharacter: Character? { get set } + + /// The character substituted for the real value when secure entry is on. var secureTextCharacter: Character? { get set } + + /// A Boolean value indicating whether the real value is masked by the secure character. var shouldSecureText: Bool { get set } + + /// The delay before the visible character is replaced by the secure character. var secureTextDelay: TimeInterval { get set } - - // Animated + + /// Sets the displayed character, optionally animating the transition to the secure character. + /// + /// - Parameters: + /// - character: The character to display, or `nil` to clear the item. + /// - animated: Whether to briefly show the real character before masking it. Only relevant when secure entry is on. func setCharacter(_ character: Character?, animated: Bool) } diff --git a/Sources/NerdzPinView/ItemViews/PredefinedViews/BorderedItemView.swift b/Sources/NerdzPinView/ItemViews/PredefinedViews/BorderedItemView.swift index 7318bd7..1c66d71 100644 --- a/Sources/NerdzPinView/ItemViews/PredefinedViews/BorderedItemView.swift +++ b/Sources/NerdzPinView/ItemViews/PredefinedViews/BorderedItemView.swift @@ -7,21 +7,43 @@ import UIKit +/// A pin item view that draws each character inside a rounded rectangle with a border. +/// +/// Use it as the item type of a ``PinCodeInputView``. Its appearance and layout +/// are driven by ``AppearanceConfig`` and ``LayoutConfig``, and it supports a +/// blinking cursor, a placeholder, and secure text masking. public final class BorderedItemView: PinTapableView, PinCodeItemViewType, ItemViewLayoutConfigurable, ItemViewAppearanceConfigurable { - + // MARK: - Internal types - + + /// Layout values that control the size and geometry of a ``BorderedItemView``. public struct LayoutConfig: DefaultableConfigType { + /// The default layout applied when no custom value is provided. public static let defaultValue: LayoutConfig = LayoutConfig() - + + /// The corner radius of the blinking cursor. public var cursorCornerRadius: CGFloat + + /// The cursor height as a fraction of the item height. public var cursorHeightMultiplier: CGFloat + + /// The width of the blinking cursor. public var cursorWidth: CGFloat - + + /// The corner radius of the item's rounded rectangle. public var cornerRadius: CGFloat - + + /// The insets applied around the character label. public var contentLabelEdgeInsets: UIEdgeInsets - + + /// Creates a layout configuration. + /// + /// - Parameters: + /// - cursorCornerRadius: The corner radius of the blinking cursor. + /// - cursorHeightMultiplier: The cursor height as a fraction of the item height. + /// - cursorWidth: The width of the blinking cursor. + /// - cornerRadius: The corner radius of the item's rounded rectangle. + /// - contentLabelEdgeInsets: The insets applied around the character label. public init( cursorCornerRadius: CGFloat = 0.5, cursorHeightMultiplier: CGFloat = 0.7, @@ -37,34 +59,80 @@ public final class BorderedItemView: PinTapableView, PinCodeItemViewType, ItemVi } } - // Implementation has a major flaw with duplicated properties + /// Colors, border widths, and fonts that control the look of a ``BorderedItemView``. + /// + /// State specific values are optional. When a value for the active or error + /// state is `nil`, the corresponding default value is used instead. public struct AppearanceConfig: DefaultableConfigType { - + + /// The default appearance applied when no custom value is provided. public static let defaultValue: AppearanceConfig = AppearanceConfig() - + + /// The background color used in the normal and disabled states. public var defaultBackgroundColor: UIColor - // If state value valiables are nil - + + /// The background color used in the active state, or `nil` to reuse the default. public var activeBackgroundColor: UIColor? + + /// The background color used in the error state, or `nil` to reuse the default. public var errorBackgroundColor: UIColor? - + + /// The character color used in the normal and disabled states. public var defaultValueColor: UIColor + + /// The character color used in the active state, or `nil` to reuse the default. public var activeValueColor: UIColor? + + /// The character color used in the error state, or `nil` to reuse the default. public var errorValueColor: UIColor? - + + /// The border color used in the normal and disabled states. public var defaultBorderColor: UIColor + + /// The border color used in the active state, or `nil` to reuse the default. public var activeBorderColor: UIColor? + + /// The border color used in the error state, or `nil` to reuse the default. public var errorBorderColor: UIColor? - + + /// The border width used in the normal and disabled states. public var defaultBorderWidth: CGFloat + + /// The border width used in the active state, or `nil` to reuse the default. public var activeBorderWidth: CGFloat? + + /// The border width used in the error state, or `nil` to reuse the default. public var errorBorderWidth: CGFloat? - + + /// The color of the placeholder character. public var placeholderColor: UIColor + + /// The color of the blinking cursor. public var cursorColor: UIColor + + /// The font used for the character and placeholder labels. public var font: UIFont - + // MARK: - Life cycle - + + /// Creates an appearance configuration. + /// + /// - Parameters: + /// - defaultBackgroundColor: The background color for the normal and disabled states. + /// - activeBackgroundColor: The background color for the active state, or `nil` to reuse the default. + /// - errorBackgroundColor: The background color for the error state, or `nil` to reuse the default. + /// - defaultValueColor: The character color for the normal and disabled states. + /// - activeValueColor: The character color for the active state, or `nil` to reuse the default. + /// - errorValueColor: The character color for the error state, or `nil` to reuse the default. + /// - placeholderColor: The color of the placeholder character. + /// - defaultBorderColor: The border color for the normal and disabled states. + /// - activeBorderColor: The border color for the active state, or `nil` to reuse the default. + /// - errorBorderColor: The border color for the error state, or `nil` to reuse the default. + /// - defaultBorderWidth: The border width for the normal and disabled states. + /// - activeBorderWidth: The border width for the active state, or `nil` to reuse the default. + /// - errorBorderWidth: The border width for the error state, or `nil` to reuse the default. + /// - cursorColor: The color of the blinking cursor. + /// - font: The font used for the character and placeholder labels. public init( defaultBackgroundColor: UIColor = .white, activeBackgroundColor: UIColor? = nil, @@ -167,37 +235,47 @@ public final class BorderedItemView: PinTapableView, PinCodeItemViewType, ItemVi } // MARK: - Properties(public) - + + /// The current visual state that drives colors, border, and cursor visibility. public var viewState: PinCodeItemViewState = .normal { didSet { updateCursorPlaceholderVisibility() updateViewStateDependentAppearance() } } - + + /// The character currently shown in the item, or `nil` when the item is empty. public var valueCharacter: Character? { didSet { updateCursorPlaceholderVisibility() } } - + + /// The placeholder character shown while the item has no value. public var placeholderCharacter: Character? { didSet { placeholderLabel.text = placeholderCharacter.flatMap({ $0 }).map({ String($0) }) } } - + + /// The character substituted for the real value when secure entry is on. public var secureTextCharacter: Character? + + /// A Boolean value indicating whether the real value is masked by the secure character. public var shouldSecureText: Bool = false + + /// The delay before the visible character is replaced by the secure character. public var secureTextDelay: TimeInterval = .zero - + + /// The layout configuration currently applied to the item view. public var layoutConfig: LayoutConfig = LayoutConfig.defaultValue { didSet { resetConstants() configureView() } } - + + /// The appearance configuration currently applied to the item view. public var appearanceConfig: AppearanceConfig = AppearanceConfig.defaultValue { didSet { updateConfigDependentAppearance() @@ -248,7 +326,11 @@ public final class BorderedItemView: PinTapableView, PinCodeItemViewType, ItemVi // MARK: - Methods(public) - // Animated - is only for secure value animation + /// Sets the displayed character, optionally animating the transition to the secure character. + /// + /// - Parameters: + /// - character: The character to display, or `nil` to clear the item. + /// - animated: Whether to briefly show the real character before masking it. Only relevant when secure entry is on. public func setCharacter(_ character: Character?, animated: Bool) { self.valueCharacter = character self.updateCursorPlaceholderVisibility() diff --git a/Sources/NerdzPinView/ItemViews/PredefinedViews/OneTimeItemView.swift b/Sources/NerdzPinView/ItemViews/PredefinedViews/OneTimeItemView.swift index aea5125..ef52f52 100644 --- a/Sources/NerdzPinView/ItemViews/PredefinedViews/OneTimeItemView.swift +++ b/Sources/NerdzPinView/ItemViews/PredefinedViews/OneTimeItemView.swift @@ -7,21 +7,47 @@ import UIKit +/// A one-time code item view that draws each character inside a rounded rectangle with a border. +/// +/// Use it as the item type of a ``OneTimeCodeInputView``. It exposes an +/// intrinsic height through its ``LayoutConfig`` and a caret rectangle so the +/// container can position the system text cursor over the active cell. public final class OneTimeItemView: OneTimeCodeItemView { - + // MARK: - Internal types - + + /// Layout values that control the size and geometry of a ``OneTimeItemView``. public struct LayoutConfig: DefaultableConfigType { + /// The default layout applied when no custom value is provided. public static let defaultValue = LayoutConfig() - + + /// The intrinsic height of the item view. public var itemHeight: CGFloat + + /// The corner radius of the blinking cursor. public var cursorCornerRadius: CGFloat + + /// The cursor height as a fraction of the item height. public var cursorHeightMultiplier: CGFloat + + /// The width of the blinking cursor. public var cursorWidth: CGFloat + + /// The corner radius of the item's rounded rectangle. public var cornerRadius: CGFloat - + + /// The insets applied around the character label. public var contentLabelEdgeInsets: UIEdgeInsets - + + /// Creates a layout configuration. + /// + /// - Parameters: + /// - itemHeight: The intrinsic height of the item view. + /// - cursorCornerRadius: The corner radius of the blinking cursor. + /// - cursorHeightMultiplier: The cursor height as a fraction of the item height. + /// - cursorWidth: The width of the blinking cursor. + /// - cornerRadius: The corner radius of the item's rounded rectangle. + /// - contentLabelEdgeInsets: The insets applied around the character label. public init( itemHeight: CGFloat = 50, cursorCornerRadius: CGFloat = 0.5, @@ -39,34 +65,80 @@ public final class OneTimeItemView: OneTimeCodeItemView { } } - // Implementation has a major flaw with duplicated properties + /// Colors, border widths, and fonts that control the look of a ``OneTimeItemView``. + /// + /// State specific values are optional. When a value for the active or error + /// state is `nil`, the corresponding default value is used instead. public struct AppearanceConfig: DefaultableConfigType { - + + /// The default appearance applied when no custom value is provided. public static let defaultValue: AppearanceConfig = AppearanceConfig() - + + /// The background color used in the normal and disabled states. public var defaultBackgroundColor: UIColor - // If state value valiables are nil - + + /// The background color used in the active state, or `nil` to reuse the default. public var activeBackgroundColor: UIColor? + + /// The background color used in the error state, or `nil` to reuse the default. public var errorBackgroundColor: UIColor? - + + /// The character color used in the normal and disabled states. public var defaultValueColor: UIColor + + /// The character color used in the active state, or `nil` to reuse the default. public var activeValueColor: UIColor? + + /// The character color used in the error state, or `nil` to reuse the default. public var errorValueColor: UIColor? - + + /// The border color used in the normal and disabled states. public var defaultBorderColor: UIColor + + /// The border color used in the active state, or `nil` to reuse the default. public var activeBorderColor: UIColor? + + /// The border color used in the error state, or `nil` to reuse the default. public var errorBorderColor: UIColor? - + + /// The border width used in the normal and disabled states. public var defaultBorderWidth: CGFloat + + /// The border width used in the active state, or `nil` to reuse the default. public var activeBorderWidth: CGFloat? + + /// The border width used in the error state, or `nil` to reuse the default. public var errorBorderWidth: CGFloat? - + + /// The color of the placeholder character. public var placeholderColor: UIColor + + /// The color of the blinking cursor. public var cursorColor: UIColor + + /// The font used for the character and placeholder labels. public var font: UIFont - + // MARK: - Life cycle - + + /// Creates an appearance configuration. + /// + /// - Parameters: + /// - defaultBackgroundColor: The background color for the normal and disabled states. + /// - activeBackgroundColor: The background color for the active state, or `nil` to reuse the default. + /// - errorBackgroundColor: The background color for the error state, or `nil` to reuse the default. + /// - defaultValueColor: The character color for the normal and disabled states. + /// - activeValueColor: The character color for the active state, or `nil` to reuse the default. + /// - errorValueColor: The character color for the error state, or `nil` to reuse the default. + /// - placeholderColor: The color of the placeholder character. + /// - defaultBorderColor: The border color for the normal and disabled states. + /// - activeBorderColor: The border color for the active state, or `nil` to reuse the default. + /// - errorBorderColor: The border color for the error state, or `nil` to reuse the default. + /// - defaultBorderWidth: The border width for the normal and disabled states. + /// - activeBorderWidth: The border width for the active state, or `nil` to reuse the default. + /// - errorBorderWidth: The border width for the error state, or `nil` to reuse the default. + /// - cursorColor: The color of the blinking cursor. + /// - font: The font used for the character and placeholder labels. public init( defaultBackgroundColor: UIColor = .white, activeBackgroundColor: UIColor? = nil, @@ -169,46 +241,53 @@ public final class OneTimeItemView: OneTimeCodeItemView { } // MARK: - Properties(public) - + + /// The rectangle, in the item view's coordinate space, where the caret is drawn. public var caretRect: CGRect { cursorView.bounds } - + + /// The current visual state that drives colors, border, and cursor visibility. public var viewState: PinCodeItemViewState = .normal { didSet { updateCursorPlaceholderVisibility() updateViewStateDependentAppearance() } } - + + /// The character currently shown in the item, or `nil` when the item is empty. public var valueCharacter: Character? { didSet { contentLabel.text = valueCharacter.map({ String($0 )}) - + updateCursorPlaceholderVisibility() } } - + + /// The placeholder character shown while the item has no value. public var placeholderCharacter: Character? { didSet { placeholderLabel.text = placeholderCharacter.flatMap({ $0 }).map({ String($0) }) } } - + + /// The layout configuration currently applied to the item view. public var layoutConfig: LayoutConfig = LayoutConfig.defaultValue { didSet { resetConstants() configureView() } } - + + /// The appearance configuration currently applied to the item view. public var appearanceConfig: AppearanceConfig = AppearanceConfig.defaultValue { didSet { updateConfigDependentAppearance() updateViewStateDependentAppearance() } } - + + /// The intrinsic content size, whose height is taken from ``LayoutConfig/itemHeight``. public override var intrinsicContentSize: CGSize { CGSize(width: UIView.noIntrinsicMetric, height: layoutConfig.itemHeight) } diff --git a/Sources/NerdzPinView/ItemViews/PredefinedViews/UnderlineItemView.swift b/Sources/NerdzPinView/ItemViews/PredefinedViews/UnderlineItemView.swift index 39f73ca..edb8e9e 100644 --- a/Sources/NerdzPinView/ItemViews/PredefinedViews/UnderlineItemView.swift +++ b/Sources/NerdzPinView/ItemViews/PredefinedViews/UnderlineItemView.swift @@ -7,20 +7,48 @@ import UIKit +/// A pin item view that draws each character above a colored underline. +/// +/// Use it as the item type of a ``PinCodeInputView``. Its appearance and layout +/// are driven by ``AppearanceConfig`` and ``LayoutConfig``, and it supports a +/// blinking cursor, a placeholder, and secure text masking. public final class UnderlineItemView: PinTapableView, PinCodeItemViewType, ItemViewLayoutConfigurable, ItemViewAppearanceConfigurable { - + // MARK: - Internal types - + + /// Layout values that control the size and geometry of an ``UnderlineItemView``. public struct LayoutConfig: DefaultableConfigType { + /// The default layout applied when no custom value is provided. public static var defaultValue: LayoutConfig = LayoutConfig() - + + /// The corner radius of the blinking cursor. public var cursorCornerRadius: CGFloat + + /// The cursor height as a fraction of the item height. public var cursorHeightMultiplier: CGFloat + + /// The width of the blinking cursor. public var cursorWidth: CGFloat - + + /// The corner radius applied to the item's bounds. public var cornerRadius: CGFloat + + /// The insets applied around the character label. public var contentLabelEdgeInsets: UIEdgeInsets - + + /// Creates a layout configuration. + /// + /// The underline height is driven by the appearance configuration rather + /// than by layout, so `underlineHeight` here is accepted for call site + /// convenience and is not stored. + /// + /// - Parameters: + /// - cursorCornerRadius: The corner radius of the blinking cursor. + /// - cursorHeightMultiplier: The cursor height as a fraction of the item height. + /// - cursorWidth: The width of the blinking cursor. + /// - cornerRadius: The corner radius applied to the item's bounds. + /// - underlineHeight: A convenience parameter that is not stored. See ``UnderlineItemView/AppearanceConfig`` for underline height. + /// - contentLabelEdgeInsets: The insets applied around the character label. public init( cursorCornerRadius: CGFloat = 0.5, cursorHeightMultiplier: CGFloat = 0.7, @@ -37,34 +65,80 @@ public final class UnderlineItemView: PinTapableView, PinCodeItemViewType, ItemV } } - // Implementation has a major flaw with duplicated properties + /// Colors, underline metrics, and fonts that control the look of an ``UnderlineItemView``. + /// + /// State specific values are optional. When a value for the active or error + /// state is `nil`, the corresponding default value is used instead. public struct AppearanceConfig: DefaultableConfigType { - + + /// The default appearance applied when no custom value is provided. public static let defaultValue: AppearanceConfig = AppearanceConfig() - + + /// The background color used in the normal and disabled states. public var defaultBackgroundColor: UIColor - // If state value valiables are nil - + + /// The background color used in the active state, or `nil` to reuse the default. public var activeBackgroundColor: UIColor? + + /// The background color used in the error state, or `nil` to reuse the default. public var errorBackgroundColor: UIColor? - + + /// The character color used in the normal and disabled states. public var defaultValueColor: UIColor + + /// The character color used in the active state, or `nil` to reuse the default. public var activeValueColor: UIColor? + + /// The character color used in the error state, or `nil` to reuse the default. public var errorValueColor: UIColor? - + + /// The underline color used in the normal and disabled states. public var defaultUnderlineColor: UIColor + + /// The underline color used in the active state, or `nil` to reuse the default. public var activeUnderlineColor: UIColor? + + /// The underline color used in the error state, or `nil` to reuse the default. public var errorUnderlineColor: UIColor? - + + /// The underline height used in the normal and disabled states. public var defaultUnderlineHeight: CGFloat + + /// The underline height used in the active state, or `nil` to reuse the default. public var activeUnderlineHeight: CGFloat? + + /// The underline height used in the error state, or `nil` to reuse the default. public var errorUnderlineHeight: CGFloat? - + + /// The color of the placeholder character. public var placeholderColor: UIColor + + /// The color of the blinking cursor. public var cursorColor: UIColor + + /// The font used for the character and placeholder labels. public var font: UIFont - + // MARK: - Life cycle - + + /// Creates an appearance configuration. + /// + /// - Parameters: + /// - defaultBackgroundColor: The background color for the normal and disabled states. + /// - activeBackgroundColor: The background color for the active state, or `nil` to reuse the default. + /// - errorBackgroundColor: The background color for the error state, or `nil` to reuse the default. + /// - defaultValueColor: The character color for the normal and disabled states. + /// - activeValueColor: The character color for the active state, or `nil` to reuse the default. + /// - errorValueColor: The character color for the error state, or `nil` to reuse the default. + /// - defaultUnderlineColor: The underline color for the normal and disabled states. + /// - activeUnderlineColor: The underline color for the active state, or `nil` to reuse the default. + /// - errorUnderlineColor: The underline color for the error state, or `nil` to reuse the default. + /// - defaultUnderlineHeight: The underline height for the normal and disabled states. + /// - activeUnderlineHeight: The underline height for the active state, or `nil` to reuse the default. + /// - errorUnderlineHeight: The underline height for the error state, or `nil` to reuse the default. + /// - placeholderColor: The color of the placeholder character. + /// - cursorColor: The color of the blinking cursor. + /// - font: The font used for the character and placeholder labels. public init( defaultBackgroundColor: UIColor = .clear, activeBackgroundColor: UIColor? = nil, @@ -167,37 +241,47 @@ public final class UnderlineItemView: PinTapableView, PinCodeItemViewType, ItemV } // MARK: - Properties(public) - + + /// The current visual state that drives colors, underline, and cursor visibility. public var viewState: PinCodeItemViewState = .normal { didSet { updateCursorPlaceholderVisibility() updateViewStateDependentAppearance() } } - + + /// The character currently shown in the item, or `nil` when the item is empty. public var valueCharacter: Character? { didSet { updateCursorPlaceholderVisibility() } } - + + /// The placeholder character shown while the item has no value. public var placeholderCharacter: Character? { didSet { placeholderLabel.text = placeholderCharacter.flatMap({ $0 }).map({ String($0) }) } } - + + /// The character substituted for the real value when secure entry is on. public var secureTextCharacter: Character? + + /// A Boolean value indicating whether the real value is masked by the secure character. public var shouldSecureText: Bool = false + + /// The delay before the visible character is replaced by the secure character. public var secureTextDelay: TimeInterval = 2 - + + /// The layout configuration currently applied to the item view. public var layoutConfig: LayoutConfig = LayoutConfig.defaultValue { didSet { resetConstants() configureView() } } - + + /// The appearance configuration currently applied to the item view. public var appearanceConfig: AppearanceConfig = AppearanceConfig.defaultValue { didSet { updateConfigDependentAppearance() @@ -233,29 +317,39 @@ public final class UnderlineItemView: PinTapableView, PinCodeItemViewType, ItemV // MARK: - Life cycle + /// Creates the item view programmatically with the given frame. + /// + /// - Parameter frame: The initial frame rectangle for the view. public override init(frame: CGRect) { super.init(frame: frame) - + configureView() setupCursorAnimation() updateCursorPlaceholderVisibility() updateConfigDependentAppearance() updateViewStateDependentAppearance() } - + + /// Creates the item view from data in the given unarchiver. + /// + /// - Parameter coder: The unarchiver providing the encoded view data. public required init?(coder: NSCoder) { super.init(coder: coder) - + configureView() setupCursorAnimation() updateCursorPlaceholderVisibility() updateConfigDependentAppearance() updateViewStateDependentAppearance() } - + // MARK: - Methods(public) - - // Animated - is only for secure value animation + + /// Sets the displayed character, optionally animating the transition to the secure character. + /// + /// - Parameters: + /// - character: The character to display, or `nil` to clear the item. + /// - animated: Whether to briefly show the real character before masking it. Only relevant when secure entry is on. public func setCharacter(_ character: Character?, animated: Bool) { self.valueCharacter = character self.updateCursorPlaceholderVisibility() diff --git a/Sources/NerdzPinView/SwiftUI/NerdzBorderedPinView.swift b/Sources/NerdzPinView/SwiftUI/NerdzBorderedPinView.swift index 383a837..d7869b8 100644 --- a/Sources/NerdzPinView/SwiftUI/NerdzBorderedPinView.swift +++ b/Sources/NerdzPinView/SwiftUI/NerdzBorderedPinView.swift @@ -8,33 +8,93 @@ import UIKit import SwiftUI +/// A SwiftUI wrapper around ``DesignableBorderedPinInputView`` for bordered pin entry. +/// +/// It bridges the UIKit input into SwiftUI through `UIViewRepresentable`, binding +/// the entered ``text``, the ``viewState``, and the keyboard focus. Styling is +/// supplied through the item and view configuration parameters of ``init(text:viewState:isFocused:onPinViewEnteredFully:autocapitalizationType:autocorrectionType:spellCheckingType:smartQuotesType:smartDashesType:smartInsertDeleteType:keyboardType:keyboardAppearance:returnKeyType:enablesReturnKeyAutomatically:isSecureTextEntry:textContentType:config:itemsLayoutConfig:itemsAppearanceConfig:)``. public struct NerdzBorderedPinView: UIViewRepresentable { - + + /// The overall state of the underlying pin input. public typealias ViewState = DesignableBorderedPinInputView.PinViewType.ViewState + + /// The behavior and layout configuration of the underlying pin input. public typealias ViewConfig = DesignableBorderedPinInputView.PinViewType.PinViewConfig - + + /// The currently entered value. @Binding public var text: String + + /// The overall state of the input. @Binding public var viewState: DesignableBorderedPinInputView.PinViewType.ViewState + + /// The keyboard focus state that drives first responder status. @FocusState.Binding public var isFocused: Bool - + + /// A closure invoked once every item has been filled. public var onPinViewEnteredFully: PinCodeTextAction? + + /// The autocapitalization style for the keyboard. public var autocapitalizationType: UITextAutocapitalizationType + + /// The autocorrection behavior for the keyboard. public var autocorrectionType: UITextAutocorrectionType + + /// The spell checking behavior for the keyboard. public var spellCheckingType: UITextSpellCheckingType + + /// The smart quotes behavior for the keyboard. public var smartQuotesType: UITextSmartQuotesType + + /// The smart dashes behavior for the keyboard. public var smartDashesType: UITextSmartDashesType + + /// The smart insert and delete behavior for the keyboard. public var smartInsertDeleteType: UITextSmartInsertDeleteType + + /// The keyboard type presented for input. public var keyboardType: UIKeyboardType + + /// The appearance of the keyboard. public var keyboardAppearance: UIKeyboardAppearance + + /// The title of the keyboard return key. public var returnKeyType: UIReturnKeyType + + /// A Boolean value indicating whether the return key is enabled only when there is text. public var enablesReturnKeyAutomatically: Bool + + /// A Boolean value indicating whether entered characters are masked. public var isSecureTextEntry: Bool + + /// The semantic meaning of the text, used for autofill. public var textContentType: UITextContentType! private let config: ViewConfig private let itemsLayoutConfig: BorderedItemView.LayoutConfig private let itemsAppearanceConfig: BorderedItemView.AppearanceConfig - + + /// Creates a bordered pin view. + /// + /// - Parameters: + /// - text: A binding to the entered value. + /// - viewState: A binding to the overall input state. + /// - isFocused: A focus binding that drives first responder status. + /// - onPinViewEnteredFully: A closure invoked once every item has been filled. + /// - autocapitalizationType: The autocapitalization style for the keyboard. + /// - autocorrectionType: The autocorrection behavior for the keyboard. + /// - spellCheckingType: The spell checking behavior for the keyboard. + /// - smartQuotesType: The smart quotes behavior for the keyboard. + /// - smartDashesType: The smart dashes behavior for the keyboard. + /// - smartInsertDeleteType: The smart insert and delete behavior for the keyboard. + /// - keyboardType: The keyboard type presented for input. + /// - keyboardAppearance: The appearance of the keyboard. + /// - returnKeyType: The title of the keyboard return key. + /// - enablesReturnKeyAutomatically: Whether the return key is enabled only when there is text. + /// - isSecureTextEntry: Whether entered characters are masked. + /// - textContentType: The semantic meaning of the text, used for autofill. + /// - config: The behavior and layout configuration of the pin input. + /// - itemsLayoutConfig: The layout configuration applied to every item view. + /// - itemsAppearanceConfig: The appearance configuration applied to every item view. public init( text: Binding, viewState: Binding, @@ -77,6 +137,10 @@ public struct NerdzBorderedPinView: UIViewRepresentable { self.itemsAppearanceConfig = itemsAppearanceConfig } + /// Creates and configures the backing UIKit view. + /// + /// - Parameter context: The representable context provided by SwiftUI. + /// - Returns: A configured ``DesignableBorderedPinInputView``. public func makeUIView(context: Context) -> DesignableBorderedPinInputView { let view = DesignableBorderedPinInputView() @@ -93,6 +157,11 @@ public struct NerdzBorderedPinView: UIViewRepresentable { return view } + /// Applies the current bindings and configuration to the backing UIKit view. + /// + /// - Parameters: + /// - uiView: The backing view to update. + /// - context: The representable context provided by SwiftUI. public func updateUIView(_ uiView: DesignableBorderedPinInputView, context: Context) { updateKitView(view: uiView) diff --git a/Sources/NerdzPinView/SwiftUI/NerdzUnderlinePinView.swift b/Sources/NerdzPinView/SwiftUI/NerdzUnderlinePinView.swift index f132233..7bf6681 100644 --- a/Sources/NerdzPinView/SwiftUI/NerdzUnderlinePinView.swift +++ b/Sources/NerdzPinView/SwiftUI/NerdzUnderlinePinView.swift @@ -8,33 +8,93 @@ import UIKit import SwiftUI +/// A SwiftUI wrapper around ``DesignableUnderlinedPinInputView`` for underlined pin entry. +/// +/// It bridges the UIKit input into SwiftUI through `UIViewRepresentable`, binding +/// the entered ``text``, the ``viewState``, and the keyboard focus. Styling is +/// supplied through the item and view configuration parameters of ``init(text:viewState:isFocused:onPinViewEnteredFully:autocapitalizationType:autocorrectionType:spellCheckingType:smartQuotesType:smartDashesType:smartInsertDeleteType:keyboardType:keyboardAppearance:returnKeyType:enablesReturnKeyAutomatically:isSecureTextEntry:textContentType:config:itemsLayoutConfig:itemsAppearanceConfig:)``. public struct NerdzUnderlinePinView: UIViewRepresentable { - + + /// The overall state of the underlying pin input. public typealias ViewState = DesignableUnderlinedPinInputView.PinViewType.ViewState + + /// The behavior and layout configuration of the underlying pin input. public typealias ViewConfig = DesignableUnderlinedPinInputView.PinViewType.PinViewConfig - + + /// The currently entered value. @Binding public var text: String + + /// The overall state of the input. @Binding public var viewState: ViewState + + /// The keyboard focus state that drives first responder status. @FocusState.Binding public var isFocused: Bool - + + /// A closure invoked once every item has been filled. public var onPinViewEnteredFully: PinCodeTextAction? + + /// The autocapitalization style for the keyboard. public var autocapitalizationType: UITextAutocapitalizationType + + /// The autocorrection behavior for the keyboard. public var autocorrectionType: UITextAutocorrectionType + + /// The spell checking behavior for the keyboard. public var spellCheckingType: UITextSpellCheckingType + + /// The smart quotes behavior for the keyboard. public var smartQuotesType: UITextSmartQuotesType + + /// The smart dashes behavior for the keyboard. public var smartDashesType: UITextSmartDashesType + + /// The smart insert and delete behavior for the keyboard. public var smartInsertDeleteType: UITextSmartInsertDeleteType + + /// The keyboard type presented for input. public var keyboardType: UIKeyboardType + + /// The appearance of the keyboard. public var keyboardAppearance: UIKeyboardAppearance + + /// The title of the keyboard return key. public var returnKeyType: UIReturnKeyType + + /// A Boolean value indicating whether the return key is enabled only when there is text. public var enablesReturnKeyAutomatically: Bool + + /// A Boolean value indicating whether entered characters are masked. public var isSecureTextEntry: Bool + + /// The semantic meaning of the text, used for autofill. public var textContentType: UITextContentType! - + private let config: DesignableUnderlinedPinInputView.PinViewType.PinViewConfig private let itemsLayoutConfig: UnderlineItemView.LayoutConfig private let itemsAppearanceConfig: UnderlineItemView.AppearanceConfig - + + /// Creates an underlined pin view. + /// + /// - Parameters: + /// - text: A binding to the entered value. + /// - viewState: A binding to the overall input state. + /// - isFocused: A focus binding that drives first responder status. + /// - onPinViewEnteredFully: A closure invoked once every item has been filled. + /// - autocapitalizationType: The autocapitalization style for the keyboard. + /// - autocorrectionType: The autocorrection behavior for the keyboard. + /// - spellCheckingType: The spell checking behavior for the keyboard. + /// - smartQuotesType: The smart quotes behavior for the keyboard. + /// - smartDashesType: The smart dashes behavior for the keyboard. + /// - smartInsertDeleteType: The smart insert and delete behavior for the keyboard. + /// - keyboardType: The keyboard type presented for input. + /// - keyboardAppearance: The appearance of the keyboard. + /// - returnKeyType: The title of the keyboard return key. + /// - enablesReturnKeyAutomatically: Whether the return key is enabled only when there is text. + /// - isSecureTextEntry: Whether entered characters are masked. + /// - textContentType: The semantic meaning of the text, used for autofill. + /// - config: The behavior and layout configuration of the pin input. + /// - itemsLayoutConfig: The layout configuration applied to every item view. + /// - itemsAppearanceConfig: The appearance configuration applied to every item view. public init( text: Binding, viewState: Binding, @@ -77,6 +137,10 @@ public struct NerdzUnderlinePinView: UIViewRepresentable { self.itemsAppearanceConfig = itemsAppearanceConfig } + /// Creates and configures the backing UIKit view. + /// + /// - Parameter context: The representable context provided by SwiftUI. + /// - Returns: A configured ``DesignableUnderlinedPinInputView``. public func makeUIView(context: Context) -> DesignableUnderlinedPinInputView { let view = DesignableUnderlinedPinInputView() @@ -93,6 +157,11 @@ public struct NerdzUnderlinePinView: UIViewRepresentable { return view } + /// Applies the current bindings and configuration to the backing UIKit view. + /// + /// - Parameters: + /// - uiView: The backing view to update. + /// - context: The representable context provided by SwiftUI. public func updateUIView(_ uiView: DesignableUnderlinedPinInputView, context: Context) { updateKitView(view: uiView) diff --git a/Sources/NerdzPinView/Views/OneTimeCodeInputView/OneTimeCodeInputView.swift b/Sources/NerdzPinView/Views/OneTimeCodeInputView/OneTimeCodeInputView.swift index 80c194b..9f52c68 100644 --- a/Sources/NerdzPinView/Views/OneTimeCodeInputView/OneTimeCodeInputView.swift +++ b/Sources/NerdzPinView/Views/OneTimeCodeInputView/OneTimeCodeInputView.swift @@ -7,27 +7,66 @@ import UIKit +/// A view that can act as one item cell of a ``OneTimeCodeInputView``. +/// +/// Any conforming type is a `UIView` that also renders a one-time code character +/// (``OneTimeCodeItemViewType``) and is both layout and appearance configurable. public typealias OneTimeCodeItemView = UIView & OneTimeCodeItemViewType & ItemViewLayoutConfigurable & ItemViewAppearanceConfigurable +/// A generic one-time code input backed by a full `UITextInput` implementation. +/// +/// Unlike ``PinCodeInputView``, this view integrates with the system text input +/// machinery, so it supports the caret, text selection ranges, and one-time code +/// autofill. It manages one item view of type `T` per character and can group +/// the items into two halves. It reports edits through ``onPinValueChanged`` and +/// completion through ``onPinViewEnteredFully``. Use +/// ``DesignableOneTimeCodeInputView`` for a ready to use configuration. @MainActor public class OneTimeCodeInputView: UIView, UITextInput, @preconcurrency UIEditMenuInteractionDelegate { - + // MARK: - Internal types - + + /// The overall state of the one-time code input. public enum ViewState { + /// The input cannot receive text. case disabled + + /// The input is idle and accepts text. case normal + + /// The input is presenting an error and highlights every item accordingly. case error } - + + /// Behavior and layout options for a ``OneTimeCodeInputView``. public struct Config { + /// The number of characters the input accepts. public var pinLength: Int + + /// The placeholder character shown in empty items, or `nil` for none. public var placeholderCharacter: Character? + + /// The title of the paste action shown in the edit menu. public var pasteActionTitle: String + + /// A Boolean value indicating whether the items are split into two visually separated groups. public var shouldGroupNumbers: Bool + + /// The spacing between adjacent items. public var itemSpacing: CGFloat + + /// The spacing between the two groups when grouping is enabled. public var groupSpacing: CGFloat - + + /// Creates a one-time code input configuration. + /// + /// - Parameters: + /// - pinLength: The number of characters the input accepts. + /// - placeholderCharacter: The placeholder character shown in empty items, or `nil` for none. + /// - pasteActionTitle: The title of the paste action shown in the edit menu. + /// - shouldGroupNumbers: Whether the items are split into two visually separated groups. + /// - itemSpacing: The spacing between adjacent items. + /// - groupSpacing: The spacing between the two groups when grouping is enabled. public init( pinLength: Int = 6, placeholderCharacter: Character? = nil, @@ -232,12 +271,19 @@ public class OneTimeCodeInputView: UIView, UITextInput, } // MARK: - Properties(public) - + + /// A closure invoked whenever the entered value changes. public var onPinValueChanged: PinCodeTextAction? + + /// A closure invoked once every item has been filled. public var onPinViewEnteredFully: PinCodeTextAction? + + /// A closure invoked when the input becomes first responder. public var onBecomeFirstResponder: PinCodeEmptyAction? + + /// A closure invoked when the input resigns first responder. public var onResignFirstResponder: PinCodeEmptyAction? - + /// The one-time code value without formatting. public var value: String { get { @@ -250,43 +296,56 @@ public class OneTimeCodeInputView: UIView, UITextInput, } } + /// The behavior and layout configuration. Assigning a new value rebuilds the item views. public var config: Config = Config() { didSet { configureView() } } - + + /// The overall state of the input, propagated to every item view. public var viewState: ViewState = .normal { didSet { update() } } - + + /// The layout configuration applied to every item view. public var layoutConfig: T.LayoutConfig = T.LayoutConfig.defaultValue { didSet { itemViews.forEach({ $0.layoutConfig = layoutConfig }) } } - + + /// The appearance configuration applied to every item view. public var appearanceConfig: T.AppearanceConfig = T.AppearanceConfig.defaultValue { didSet { itemViews.forEach({ $0.appearanceConfig = appearanceConfig }) } } - + + /// A Boolean value indicating whether the input can become first responder. `false` while disabled. public override var canBecomeFirstResponder: Bool { viewState != .disabled } - + // MARK: - UIKeyInput - + + /// A Boolean value indicating whether the input contains any characters. public var hasText: Bool { !value.isEmpty } - + + /// The autocorrection behavior for the keyboard. public var autocorrectionType: UITextAutocorrectionType = .no + + /// The keyboard type presented for input. public var keyboardType: UIKeyboardType = .numberPad + + /// The title of the keyboard return key. public var returnKeyType: UIReturnKeyType = .done + + /// The semantic meaning of the text, used for autofill. Defaults to one-time code. public var textContentType: UITextContentType! = .oneTimeCode // MARK: - Properties(private) @@ -297,20 +356,32 @@ public class OneTimeCodeInputView: UIView, UITextInput, // MARK: - Life cycle + /// Creates the input programmatically with the given frame. + /// + /// - Parameter frame: The initial frame rectangle for the view. public override init(frame: CGRect) { super.init(frame: frame) - + configureView() } - + + /// Creates the input from data in the given unarchiver. + /// + /// - Parameter coder: The unarchiver providing the encoded view data. public required init?(coder: NSCoder) { super.init(coder: coder) - + configureView() } - + // MARK: - Methods(public) - + + /// Reports whether the input can perform a given action, enabling paste only when the pasteboard has text. + /// + /// - Parameters: + /// - action: The selector describing the action to evaluate. + /// - sender: The object requesting the action. + /// - Returns: `true` if the action is supported in the current context. open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if action == #selector(paste(_:)) { return UIPasteboard.general.hasStrings @@ -319,17 +390,23 @@ public class OneTimeCodeInputView: UIView, UITextInput, return super.canPerformAction(action, withSender: sender) } } - + + /// Inserts the pasteboard string at the current caret position. + /// + /// - Parameter sender: The object requesting the paste. open override func paste(_ sender: Any?) { guard let string = UIPasteboard.general.string else { return } - + insertText(string) } - + // MARK: - UIKeyInput - + + /// Inserts text at the current selection, clamping to the configured length. + /// + /// - Parameter text: The text to insert. Characters beyond the remaining capacity are dropped. open func insertText(_ text: String) { guard let range = selectedTextRange as? TextRange else { return @@ -343,6 +420,7 @@ public class OneTimeCodeInputView: UIView, UITextInput, update() } + /// Deletes the character before the caret, or the current selection. open func deleteBackward() { guard let range = selectedTextRange as? TextRange else { return @@ -367,6 +445,9 @@ public class OneTimeCodeInputView: UIView, UITextInput, // MARK: - UIResponder + /// Makes the input active, placing the caret at the end of the current value. + /// + /// - Returns: `true` if the input became first responder. @discardableResult open override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() @@ -384,19 +465,27 @@ public class OneTimeCodeInputView: UIView, UITextInput, return result } + /// Deactivates the input and refreshes the item views. + /// + /// - Returns: `true` if the input resigned first responder. @discardableResult open override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() - + if result { update() - + onResignFirstResponder?() } - + return result } - + + /// Handles taps, showing the edit menu when already active or becoming first responder otherwise. + /// + /// - Parameters: + /// - touches: The touches that ended. + /// - event: The event the touches belong to. public override func touchesEnded(_ touches: Set, with event: UIEvent?) { super.touchesEnded(touches, with: event) @@ -414,6 +503,13 @@ public class OneTimeCodeInputView: UIView, UITextInput, // MARK: - UIEditMenuInteractionDelegate + /// Provides the edit menu, offering a paste action when the pasteboard has text. + /// + /// - Parameters: + /// - interaction: The edit menu interaction requesting the menu. + /// - configuration: The configuration for the menu being presented. + /// - suggestedActions: The system suggested menu elements. + /// - Returns: A menu containing the paste action, or `nil` when there is nothing to paste. open func editMenuInteraction( _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, @@ -525,12 +621,16 @@ public class OneTimeCodeInputView: UIView, UITextInput, // MARK: - UITextInput // MARK: - Handling text input - - // Not used in this view + + /// The delegate notified of text and selection changes. Not used by this view. public var inputDelegate: (any UITextInputDelegate)? - + // MARK: - Replacing and returning text - + + /// Returns the substring covered by a text range. + /// + /// - Parameter range: The range to read. + /// - Returns: The text in the range, or `nil` when the range is empty or invalid. public func text(in range: UITextRange) -> String? { guard let range = range as? TextRange else { return nil @@ -538,18 +638,30 @@ public class OneTimeCodeInputView: UIView, UITextInput, return textStorage.text(in: range) } - + + /// Replaces the text in a range. This view ignores direct replacements. + /// + /// - Parameters: + /// - range: The range to replace. + /// - text: The replacement text. public func replace(_ range: UITextRange, withText text: String) { // Do nothing } - + + /// Reports whether a proposed text change is allowed, always `true` for this view. + /// + /// - Parameters: + /// - range: The range that would change. + /// - text: The replacement text. + /// - Returns: Always `true`. public func shouldChangeText(in range: UITextRange, replacementText text: String) -> Bool { // Assume that it should change characters always return true } - + // MARK: - Working with marked and selected text - + + /// The current selection, expressed as a range. A zero-length range represents the caret. public var selectedTextRange: UITextRange? = nil { willSet { inputDelegate?.selectionWillChange(self) @@ -559,13 +671,13 @@ public class OneTimeCodeInputView: UIView, UITextInput, update() } } - - // Otp or pin codes not inlude mark text range - + + /// The range of marked text. Marked text is unsupported, so this is always `nil`. public var markedTextRange: UITextRange? { return nil } - + + /// The style for marked text. Marked text is unsupported, so this is always `nil`. public var markedTextStyle: [NSAttributedString.Key : Any]? { get { return nil @@ -574,25 +686,39 @@ public class OneTimeCodeInputView: UIView, UITextInput, // We don't support marked text } } - + + /// Sets marked text. Marked text is unsupported, so this does nothing. + /// + /// - Parameters: + /// - markedText: The text to mark. + /// - selectedRange: The selection within the marked text. public func setMarkedText(_ markedText: String?, selectedRange: NSRange) { // We don't support marked text } - + + /// Removes any marked text. Marked text is unsupported, so this does nothing. public func unmarkText() { // We don't support marked text } - + // MARK: - Computing text ranges and text positions - + + /// The position at the start of the value. public var beginningOfDocument: UITextPosition { textStorage.start } - + + /// The position at the end of the value. public var endOfDocument: UITextPosition { textStorage.end } - + + /// Creates a range between two positions. + /// + /// - Parameters: + /// - fromPosition: The start position. + /// - toPosition: The end position. + /// - Returns: The range, or `nil` when either position is invalid. public func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { guard let fromPosition = fromPosition as? TextPosition, let toPosition = toPosition as? TextPosition else { return nil @@ -600,7 +726,13 @@ public class OneTimeCodeInputView: UIView, UITextInput, return textStorage.makeRange(from: fromPosition, to: toPosition) } - + + /// Returns the position a given offset away from another position. + /// + /// - Parameters: + /// - position: The starting position. + /// - offset: The signed number of characters to move. + /// - Returns: The resulting position, or `nil` when it falls out of bounds. public func position(from position: UITextPosition, offset: Int) -> UITextPosition? { guard let position = position as? TextPosition else { return nil @@ -615,7 +747,14 @@ public class OneTimeCodeInputView: UIView, UITextInput, return TextPosition(newIndex) } - + + /// Returns the position a given offset away from another position in a layout direction. + /// + /// - Parameters: + /// - position: The starting position. + /// - direction: The layout direction to move in. + /// - offset: The number of characters to move. + /// - Returns: The resulting position, or `nil` when it falls out of bounds. public func position( from position: UITextPosition, in direction: UITextLayoutDirection, @@ -640,7 +779,13 @@ public class OneTimeCodeInputView: UIView, UITextInput, } // MARK: - Evaluating text positions - + + /// Compares two positions. + /// + /// - Parameters: + /// - position: The first position. + /// - other: The second position. + /// - Returns: The ordering of the two positions. public func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { guard let position = position as? TextPosition, let other = other as? TextPosition else { return .orderedSame @@ -648,7 +793,13 @@ public class OneTimeCodeInputView: UIView, UITextInput, return position.compare(other) } - + + /// Returns the character distance between two positions. + /// + /// - Parameters: + /// - from: The starting position. + /// - toPosition: The ending position. + /// - Returns: The signed number of characters between the positions. public func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { guard let from = from as? TextPosition, let toPosition = toPosition as? TextPosition else { return 0 @@ -656,9 +807,15 @@ public class OneTimeCodeInputView: UIView, UITextInput, return toPosition.index - from.index } - + // MARK: - Deterninging layout and writing direction - + + /// Returns the position farthest in a direction within a range. + /// + /// - Parameters: + /// - range: The range to search within. + /// - direction: The layout direction. + /// - Returns: The farthest position, or `nil` when the range is invalid. public func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { guard let range = range as? TextRange else { return nil @@ -676,33 +833,54 @@ public class OneTimeCodeInputView: UIView, UITextInput, } } + /// Returns the range obtained by extending a position toward a direction. + /// + /// - Parameters: + /// - position: The anchor position. + /// - direction: The direction to extend toward. + /// - Returns: The extended range, or `nil` for vertical directions. public func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? { switch direction { case .right: return self.textRange(from: position, to: endOfDocument) - + case .left: return self.textRange(from: beginningOfDocument, to: position) - + case .up, .down: return nil - + @unknown default: return nil } } - + + /// Returns the base writing direction, always left to right for code input. + /// + /// - Parameters: + /// - position: The position to query. + /// - direction: The storage direction to consider. + /// - Returns: Always `.leftToRight`. public func baseWritingDirection(for position: UITextPosition, in direction: UITextStorageDirection) -> NSWritingDirection { // OTP input should be left-to-right always. .leftToRight } - + + /// Sets the base writing direction for a range. The direction is fixed, so this does nothing. + /// + /// - Parameters: + /// - writingDirection: The requested writing direction. + /// - range: The range to apply it to. public func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) { // Do nothing } - + // MARK: - Geometry and hit-testing - + + /// Returns the rectangle enclosing the item views covered by a range. + /// + /// - Parameter range: The range to measure. + /// - Returns: The bounding rectangle, or `.zero` when the range is empty or invalid. public func firstRect(for range: UITextRange) -> CGRect { guard let range = range as? TextRange, !range.isEmpty else { return .zero @@ -727,6 +905,10 @@ public class OneTimeCodeInputView: UIView, UITextInput, return firstRect.union(secondRect) } + /// Returns the caret rectangle for a position, in the input's coordinate space. + /// + /// - Parameter position: The position to locate the caret at. + /// - Returns: The caret rectangle, or `.zero` when the position is invalid. public func caretRect(for position: UITextPosition) -> CGRect { guard let position = position as? TextPosition else { return .zero @@ -735,16 +917,30 @@ public class OneTimeCodeInputView: UIView, UITextInput, let digitView = itemViews[clampIndex(position.index)] return digitView.convert(digitView.caretRect, to: self) } - + + /// Returns the selection rectangles for a range. Text selection is unsupported, so this is empty. + /// + /// - Parameter range: The range to measure. + /// - Returns: Always an empty array. public func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { // No text-selection return [] } - + + /// Returns the position closest to a point anywhere in the input. + /// + /// - Parameter point: The point, in the input's coordinate space. + /// - Returns: The closest position, or `nil` when no item is hit. public func closestPosition(to point: CGPoint) -> UITextPosition? { return closestPosition(to: point, within: textStorage.extent) } - + + /// Returns the position closest to a point within a range. + /// + /// - Parameters: + /// - point: The point, in the input's coordinate space. + /// - range: The range to restrict the result to. + /// - Returns: The closest position inside the range, or `nil` when no item is hit. public func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { guard let range = range as? TextRange, let digitView = hitTest(point, with: nil) as? T, let index = itemViews.firstIndex(of: digitView) else { return nil @@ -752,7 +948,11 @@ public class OneTimeCodeInputView: UIView, UITextInput, return range.contains(index) ? TextPosition(index) : nil } - + + /// Returns the single character range at a point. + /// + /// - Parameter point: The point, in the input's coordinate space. + /// - Returns: A one character range at the point, or `nil` when no item is hit. public func characterRange(at point: CGPoint) -> UITextRange? { guard let startPosition = closestPosition(to: point) as? TextPosition, let endPosition = position(from: startPosition, offset: 1) else { return nil @@ -760,8 +960,9 @@ public class OneTimeCodeInputView: UIView, UITextInput, return self.textRange(from: startPosition, to: endPosition) } - + // MARK: - Tokenizing input text - + + /// The tokenizer used to segment the input text into words and other units. public lazy var tokenizer: any UITextInputTokenizer = UITextInputStringTokenizer(textInput: self) } diff --git a/Sources/NerdzPinView/Views/OneTimeCodeInputView/OneTimeCodeItemViewType.swift b/Sources/NerdzPinView/Views/OneTimeCodeInputView/OneTimeCodeItemViewType.swift index 429d3bd..beaebed 100644 --- a/Sources/NerdzPinView/Views/OneTimeCodeInputView/OneTimeCodeItemViewType.swift +++ b/Sources/NerdzPinView/Views/OneTimeCodeInputView/OneTimeCodeItemViewType.swift @@ -7,11 +7,22 @@ import Foundation +/// A single cell inside a ``OneTimeCodeInputView`` that renders one character of the code. +/// +/// Conforming views expose the rectangle used to draw the system caret so the +/// container can position the text input cursor over the active cell. The +/// predefined conformer is ``OneTimeItemView``. @MainActor -public protocol OneTimeCodeItemViewType: AnyObject { +public protocol OneTimeCodeItemViewType: AnyObject { + /// The rectangle, in the item view's coordinate space, where the caret is drawn. var caretRect: CGRect { get } + + /// The current visual state that drives colors, border, and cursor visibility. var viewState: PinCodeItemViewState { get set } - + + /// The character currently shown in the item, or `nil` when the item is empty. var valueCharacter: Character? { get set } + + /// The placeholder character shown while the item has no value. var placeholderCharacter: Character? { get set } } diff --git a/Sources/NerdzPinView/Views/PinInputView/DesignableBorderedPinInputView.swift b/Sources/NerdzPinView/Views/PinInputView/DesignableBorderedPinInputView.swift index f38dac6..3768cd6 100644 --- a/Sources/NerdzPinView/Views/PinInputView/DesignableBorderedPinInputView.swift +++ b/Sources/NerdzPinView/Views/PinInputView/DesignableBorderedPinInputView.swift @@ -7,13 +7,20 @@ import UIKit +/// A ready to use bordered pin input that wraps and pre-styles a ``PinCodeInputView``. +/// +/// It exposes the underlying ``pinView`` along with forwarding properties for +/// text, configuration, state, and keyboard traits, and applies a default +/// bordered appearance. Subclass it to customize the preset styling, or use +/// ``NerdzBorderedPinView`` to embed it in SwiftUI. @MainActor open class DesignableBorderedPinInputView: UIView { - + // MARK: - Aliases - + + /// The concrete ``PinCodeInputView`` specialization backing this view. public typealias PinViewType = PinCodeInputView - + // MARK: - Internal types private enum Constants { @@ -32,7 +39,8 @@ open class DesignableBorderedPinInputView: UIView { } // MARK: - Properties(public) - + + /// The backing pin input, pre-configured with the default bordered styling. open var pinView: PinViewType = { let view = PinViewType() view.config = PinViewType.PinViewConfig(pinLength: 6, isContentCentered: false) @@ -50,50 +58,56 @@ open class DesignableBorderedPinInputView: UIView { return view }() + /// A closure invoked once every item has been filled. public var onPinViewEnteredFully: PinCodeTextAction? { get { pinView.onPinViewEnteredFully } - + set { pinView.onPinViewEnteredFully = newValue } } - + + /// A closure invoked whenever the entered value changes. public var onPinValueChanged: PinCodeTextAction? { get { pinView.onPinValueChanged } - + set { pinView.onPinValueChanged = newValue } } - + + /// A closure invoked when the input becomes first responder. public var onBecomeFirstResponder: PinCodeEmptyAction? { get { pinView.onBecomeFirstResponder } - + set { pinView.onBecomeFirstResponder = newValue } } - + + /// A closure invoked when the input resigns first responder. public var onResignFirstResponder: PinCodeEmptyAction? { get { pinView.onResignFirstResponder } - + set { pinView.onResignFirstResponder = newValue } } - + + /// The currently entered value. public var text: String { pinView.text } - + + /// The behavior and layout configuration of the underlying pin input. open var config: PinViewType.PinViewConfig { get { pinView.config @@ -104,46 +118,52 @@ open class DesignableBorderedPinInputView: UIView { } } + /// The overall state of the underlying pin input. open var viewState: PinViewType.ViewState { get { pinView.viewState } - + set { pinView.viewState = newValue } } - + + /// The layout configuration applied to every item view. open var layoutConfig: BorderedItemView.LayoutConfig { get { pinView.layoutConfig } - + set { pinView.layoutConfig = newValue } } - + + /// The appearance configuration applied to every item view. open var appearanceConfig: BorderedItemView.AppearanceConfig { get { pinView.appearanceConfig } - + set { pinView.appearanceConfig = newValue } } - + + /// A Boolean value indicating whether this wrapper can become first responder. Always `false`, since the ``pinView`` holds first responder. open override var canBecomeFirstResponder: Bool { false } - + // MARK: - UIKeyInput - + + /// A Boolean value indicating whether the input contains any characters. open var hasText: Bool { pinView.hasText } - + + /// The autocapitalization style for the keyboard. open var autocapitalizationType: UITextAutocapitalizationType { get { pinView.autocapitalizationType @@ -154,164 +174,207 @@ open class DesignableBorderedPinInputView: UIView { } } + /// The autocorrection behavior for the keyboard. open var autocorrectionType: UITextAutocorrectionType { get { pinView.autocorrectionType } - + set { pinView.autocorrectionType = newValue } } - + + /// The spell checking behavior for the keyboard. open var spellCheckingType: UITextSpellCheckingType { get { pinView.spellCheckingType } - + set { pinView.spellCheckingType = newValue } } - + + /// The smart quotes behavior for the keyboard. open var smartQuotesType: UITextSmartQuotesType { get { pinView.smartQuotesType } - + set { pinView.smartQuotesType = newValue } } - + + /// The smart dashes behavior for the keyboard. open var smartDashesType: UITextSmartDashesType { get { pinView.smartDashesType } - + set { pinView.smartDashesType = newValue } } - + + /// The smart insert and delete behavior for the keyboard. open var smartInsertDeleteType: UITextSmartInsertDeleteType { get { pinView.smartInsertDeleteType } - + set { pinView.smartInsertDeleteType = newValue } } - + + /// The keyboard type presented for input. open var keyboardType: UIKeyboardType { get { pinView.keyboardType } - + set { pinView.keyboardType = newValue } } - + + /// The appearance of the keyboard. open var keyboardAppearance: UIKeyboardAppearance { get { pinView.keyboardAppearance } - + set { pinView.keyboardAppearance = newValue } } - + + /// The title of the keyboard return key. open var returnKeyType: UIReturnKeyType { get { pinView.returnKeyType } - + set { pinView.returnKeyType = newValue } } - + + /// A Boolean value indicating whether the return key is enabled only when there is text. open var enablesReturnKeyAutomatically: Bool { get { pinView.enablesReturnKeyAutomatically } - + set { pinView.enablesReturnKeyAutomatically = newValue } } - + + /// A Boolean value indicating whether entered characters are masked. open var isSecureTextEntry: Bool { get { pinView.isSecureTextEntry } - + set { pinView.isSecureTextEntry = newValue } } - + + /// The semantic meaning of the text, used for autofill. open var textContentType: UITextContentType! { get { pinView.textContentType } - + set { pinView.textContentType = newValue } } - + // MARK: - Life cycle - + + /// Creates the view programmatically with the given frame. + /// + /// - Parameter frame: The initial frame rectangle for the view. public override init(frame: CGRect) { super.init(frame: frame) - + initialConfiguration() } - + + /// Creates the view from data in the given unarchiver. + /// + /// - Parameter coder: The unarchiver providing the encoded view data. public required init?(coder: NSCoder) { super.init(coder: coder) - + initialConfiguration() } - + // MARK: - Methods(public) - + + /// Reports whether the wrapped input can perform a given action. + /// + /// - Parameters: + /// - action: The selector describing the action to evaluate. + /// - sender: The object requesting the action. + /// - Returns: `true` if the action is supported in the current context. open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { pinView.canPerformAction(action, withSender: sender) } - + + /// Pastes the pasteboard string into the wrapped input. + /// + /// - Parameter sender: The object requesting the paste. open override func paste(_ sender: Any?) { pinView.paste(sender) } - + // MARK: - UIKeyInput - + + /// Inserts text into the wrapped input. + /// + /// - Parameter text: The text to insert. open func insertText(_ text: String) { pinView.insertText(text) } - + + /// Deletes the character before the active position in the wrapped input. open func deleteBackward() { pinView.deleteBackward() } - + // MARK: - UIResponder - + + /// Makes the wrapped input active. + /// + /// - Returns: `true` if the input became first responder. @discardableResult open override func becomeFirstResponder() -> Bool { pinView.becomeFirstResponder() } - + + /// Deactivates the wrapped input. + /// + /// - Returns: `true` if the input resigned first responder. @discardableResult open override func resignFirstResponder() -> Bool { pinView.resignFirstResponder() } - + // MARK: - UIEditMenuInteractionDelegate - + + /// Forwards the edit menu request to the wrapped input. + /// + /// - Parameters: + /// - interaction: The edit menu interaction requesting the menu. + /// - configuration: The configuration for the menu being presented. + /// - suggestedActions: The system suggested menu elements. + /// - Returns: The menu provided by the wrapped input, or `nil` when there is nothing to paste. open func editMenuInteraction( _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, @@ -319,11 +382,15 @@ open class DesignableBorderedPinInputView: UIView { ) -> UIMenu? { pinView.editMenuInteraction(interaction, menuFor: configuration, suggestedActions: suggestedActions) } - + + /// Replaces the entire entered value of the wrapped input. + /// + /// - Parameter text: The new value, or `nil` to clear the input. open func setText(_ text: String?) { pinView.setText(text) } - + + /// Adds the wrapped ``pinView`` as a subview and pins it to the bounds. open func initialConfiguration() { addAndFillSubview(pinView, directionalLayoutMargins: .zero) } diff --git a/Sources/NerdzPinView/Views/PinInputView/DesignableUnderlinedPinInputView.swift b/Sources/NerdzPinView/Views/PinInputView/DesignableUnderlinedPinInputView.swift index b9bdd2f..703ed0c 100644 --- a/Sources/NerdzPinView/Views/PinInputView/DesignableUnderlinedPinInputView.swift +++ b/Sources/NerdzPinView/Views/PinInputView/DesignableUnderlinedPinInputView.swift @@ -7,13 +7,20 @@ import UIKit +/// A ready to use underlined pin input that wraps and pre-styles a ``PinCodeInputView``. +/// +/// It exposes the underlying ``pinView`` along with forwarding properties for +/// text, configuration, state, and keyboard traits, and applies a default +/// underlined appearance. Subclass it to customize the preset styling, or use +/// ``NerdzUnderlinePinView`` to embed it in SwiftUI. @MainActor open class DesignableUnderlinedPinInputView: UIView, UIKeyInput, @preconcurrency UIEditMenuInteractionDelegate { - + // MARK: - Aliases - + + /// The concrete ``PinCodeInputView`` specialization backing this view. public typealias PinViewType = PinCodeInputView - + // MARK: - Internal types private enum Constants { @@ -32,7 +39,8 @@ open class DesignableUnderlinedPinInputView: UIView, UIKeyInput, @preconcurrency } // MARK: - Properties(public) - + + /// The backing pin input, pre-configured with the default underlined styling. open var pinView: PinViewType = { let view = PinViewType() view.config = PinViewType.PinViewConfig(pinLength: 6, isContentCentered: false) @@ -50,50 +58,56 @@ open class DesignableUnderlinedPinInputView: UIView, UIKeyInput, @preconcurrency return view }() + /// A closure invoked once every item has been filled. public var onPinViewEnteredFully: PinCodeTextAction? { get { pinView.onPinViewEnteredFully } - + set { pinView.onPinViewEnteredFully = newValue } } - + + /// A closure invoked whenever the entered value changes. public var onPinValueChanged: PinCodeTextAction? { get { pinView.onPinValueChanged } - + set { pinView.onPinValueChanged = newValue } } - + + /// A closure invoked when the input becomes first responder. public var onBecomeFirstResponder: PinCodeEmptyAction? { get { pinView.onBecomeFirstResponder } - + set { pinView.onBecomeFirstResponder = newValue } } - + + /// A closure invoked when the input resigns first responder. public var onResignFirstResponder: PinCodeEmptyAction? { get { pinView.onResignFirstResponder } - + set { pinView.onResignFirstResponder = newValue } } - + + /// The currently entered value. public var text: String { pinView.text } - + + /// The behavior and layout configuration of the underlying pin input. open var config: PinViewType.PinViewConfig { get { pinView.config @@ -104,46 +118,52 @@ open class DesignableUnderlinedPinInputView: UIView, UIKeyInput, @preconcurrency } } + /// The overall state of the underlying pin input. open var viewState: PinViewType.ViewState { get { pinView.viewState } - + set { pinView.viewState = newValue } } - + + /// The layout configuration applied to every item view. open var layoutConfig: UnderlineItemView.LayoutConfig { get { pinView.layoutConfig } - + set { pinView.layoutConfig = newValue } } - + + /// The appearance configuration applied to every item view. open var appearanceConfig: UnderlineItemView.AppearanceConfig { get { pinView.appearanceConfig } - + set { pinView.appearanceConfig = newValue } } - + + /// A Boolean value indicating whether this wrapper can become first responder. Always `false`, since the ``pinView`` holds first responder. open override var canBecomeFirstResponder: Bool { false } - + // MARK: - UIKeyInput - + + /// A Boolean value indicating whether the input contains any characters. open var hasText: Bool { pinView.hasText } - + + /// The autocapitalization style for the keyboard. open var autocapitalizationType: UITextAutocapitalizationType { get { pinView.autocapitalizationType @@ -154,164 +174,207 @@ open class DesignableUnderlinedPinInputView: UIView, UIKeyInput, @preconcurrency } } + /// The autocorrection behavior for the keyboard. open var autocorrectionType: UITextAutocorrectionType { get { pinView.autocorrectionType } - + set { pinView.autocorrectionType = newValue } } - + + /// The spell checking behavior for the keyboard. open var spellCheckingType: UITextSpellCheckingType { get { pinView.spellCheckingType } - + set { pinView.spellCheckingType = newValue } } - + + /// The smart quotes behavior for the keyboard. open var smartQuotesType: UITextSmartQuotesType { get { pinView.smartQuotesType } - + set { pinView.smartQuotesType = newValue } } - + + /// The smart dashes behavior for the keyboard. open var smartDashesType: UITextSmartDashesType { get { pinView.smartDashesType } - + set { pinView.smartDashesType = newValue } } - + + /// The smart insert and delete behavior for the keyboard. open var smartInsertDeleteType: UITextSmartInsertDeleteType { get { pinView.smartInsertDeleteType } - + set { pinView.smartInsertDeleteType = newValue } } - + + /// The keyboard type presented for input. open var keyboardType: UIKeyboardType { get { pinView.keyboardType } - + set { pinView.keyboardType = newValue } } - + + /// The appearance of the keyboard. open var keyboardAppearance: UIKeyboardAppearance { get { pinView.keyboardAppearance } - + set { pinView.keyboardAppearance = newValue } } - + + /// The title of the keyboard return key. open var returnKeyType: UIReturnKeyType { get { pinView.returnKeyType } - + set { pinView.returnKeyType = newValue } } - + + /// A Boolean value indicating whether the return key is enabled only when there is text. open var enablesReturnKeyAutomatically: Bool { get { pinView.enablesReturnKeyAutomatically } - + set { pinView.enablesReturnKeyAutomatically = newValue } } - + + /// A Boolean value indicating whether entered characters are masked. open var isSecureTextEntry: Bool { get { pinView.isSecureTextEntry } - + set { pinView.isSecureTextEntry = newValue } } - + + /// The semantic meaning of the text, used for autofill. open var textContentType: UITextContentType! { get { pinView.textContentType } - + set { pinView.textContentType = newValue } } - + // MARK: - Life cycle - + + /// Creates the view programmatically with the given frame. + /// + /// - Parameter frame: The initial frame rectangle for the view. public override init(frame: CGRect) { super.init(frame: frame) - + initialConfiguration() } - + + /// Creates the view from data in the given unarchiver. + /// + /// - Parameter coder: The unarchiver providing the encoded view data. public required init?(coder: NSCoder) { super.init(coder: coder) - + initialConfiguration() } - + // MARK: - Methods(public) - + + /// Reports whether the wrapped input can perform a given action. + /// + /// - Parameters: + /// - action: The selector describing the action to evaluate. + /// - sender: The object requesting the action. + /// - Returns: `true` if the action is supported in the current context. open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { pinView.canPerformAction(action, withSender: sender) } - + + /// Pastes the pasteboard string into the wrapped input. + /// + /// - Parameter sender: The object requesting the paste. open override func paste(_ sender: Any?) { pinView.paste(sender) } - + // MARK: - UIKeyInput - + + /// Inserts text into the wrapped input. + /// + /// - Parameter text: The text to insert. open func insertText(_ text: String) { pinView.insertText(text) } - + + /// Deletes the character before the active position in the wrapped input. open func deleteBackward() { pinView.deleteBackward() } - + // MARK: - UIResponder - + + /// Makes the wrapped input active. + /// + /// - Returns: `true` if the input became first responder. @discardableResult open override func becomeFirstResponder() -> Bool { pinView.becomeFirstResponder() } - + + /// Deactivates the wrapped input. + /// + /// - Returns: `true` if the input resigned first responder. @discardableResult open override func resignFirstResponder() -> Bool { pinView.resignFirstResponder() } - + // MARK: - UIEditMenuInteractionDelegate - + + /// Forwards the edit menu request to the wrapped input. + /// + /// - Parameters: + /// - interaction: The edit menu interaction requesting the menu. + /// - configuration: The configuration for the menu being presented. + /// - suggestedActions: The system suggested menu elements. + /// - Returns: The menu provided by the wrapped input, or `nil` when there is nothing to paste. open func editMenuInteraction( _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, @@ -319,11 +382,15 @@ open class DesignableUnderlinedPinInputView: UIView, UIKeyInput, @preconcurrency ) -> UIMenu? { pinView.editMenuInteraction(interaction, menuFor: configuration, suggestedActions: suggestedActions) } - + + /// Replaces the entire entered value of the wrapped input. + /// + /// - Parameter text: The new value, or `nil` to clear the input. open func setText(_ text: String?) { pinView.setText(text) } - + + /// Adds the wrapped ``pinView`` as a subview and pins it to the bounds. open func initialConfiguration() { addAndFillSubview(pinView, directionalLayoutMargins: .zero) } diff --git a/Sources/NerdzPinView/Views/PinInputView/PinCodeInputView.swift b/Sources/NerdzPinView/Views/PinInputView/PinCodeInputView.swift index 0d9295f..ebf6af4 100644 --- a/Sources/NerdzPinView/Views/PinInputView/PinCodeInputView.swift +++ b/Sources/NerdzPinView/Views/PinInputView/PinCodeInputView.swift @@ -7,37 +7,85 @@ import UIKit +/// A view that can act as one item cell of a ``PinCodeInputView``. +/// +/// Any conforming type is a `UIView` that also renders a pin character +/// (``PinCodeItemViewType``) and is both layout and appearance configurable. public typealias PinCodeItemView = UIView & PinCodeItemViewType & ItemViewLayoutConfigurable & ItemViewAppearanceConfigurable +/// A generic pin code input made of individually tappable item views. +/// +/// The container manages one item view of type `T` per character, tracks the +/// active cell, and forwards keyboard input, pasting, and first responder +/// changes. It reports edits through ``onPinValueChanged`` and completion +/// through ``onPinViewEnteredFully``. Use ``DesignableBorderedPinInputView`` or +/// ``DesignableUnderlinedPinInputView`` for ready to use configurations. @MainActor public class PinCodeInputView: UIView, UIKeyInput, @preconcurrency UIEditMenuInteractionDelegate { - + // MARK: - Internal types - + + /// The overall state of the pin input. public enum ViewState { + /// The input cannot receive text. case disabled + + /// The input is idle and accepts text. case normal + + /// The input is presenting an error and highlights every item accordingly. case error } - + + /// Behavior and layout options for a ``PinCodeInputView``. public struct PinViewConfig: Equatable { + /// The number of characters the input accepts. public var pinLength: Int + + /// The placeholder character shown in empty items, or `nil` for none. public var placeholderCharacter: Character? - + + /// The delay before an entered character is masked when secure entry is on. public var secureTextDelay: TimeInterval + + /// The character used to mask entered values when secure entry is on. public var secureTextCharacter: Character - + + /// The title of the paste action shown in the edit menu. public var pasteActionTitle: String + + /// The minimum press duration that triggers the paste gesture. public var pasteGestureMinDuration: TimeInterval - - // If content is centered - stack view would take located in center of the view / otherwise would be stretched + + /// A Boolean value indicating whether the items are centered rather than stretched to fill the width. public var isContentCentered: Bool + + /// The spacing between item views. public var containerSpacing: CGFloat - + + /// A Boolean value indicating whether deleting moves the active item back to the previous cell. public var shouldMoveToPreviousOnDelete: Bool + + /// A Boolean value indicating whether the input resigns first responder once fully entered. public var shouldResignFirstResponderOnEnd: Bool + + /// A Boolean value indicating whether the input resigns first responder when the return key is pressed. public var shouldResignFirstResponderOnReturn: Bool - + + /// Creates a pin input configuration. + /// + /// - Parameters: + /// - pinLength: The number of characters the input accepts. + /// - placeholderCharacter: The placeholder character shown in empty items, or `nil` for none. + /// - secureTextCharacter: The character used to mask entered values when secure entry is on. + /// - secureTextDelay: The delay before an entered character is masked when secure entry is on. + /// - pasteActionTitle: The title of the paste action shown in the edit menu. + /// - pasteGestureMinDuration: The minimum press duration that triggers the paste gesture. + /// - isContentCentered: Whether the items are centered rather than stretched to fill the width. + /// - containerSpacing: The spacing between item views. + /// - shouldMoveToPreviousOnDelete: Whether deleting moves the active item back to the previous cell. + /// - shouldResignFirstResponderOnEnd: Whether the input resigns first responder once fully entered. + /// - shouldResignFirstResponderOnReturn: Whether the input resigns first responder when the return key is pressed. public init( pinLength: Int = 5, placeholderCharacter: Character? = nil, @@ -66,65 +114,102 @@ public class PinCodeInputView: UIView, UIKeyInput, @preconcu } // MARK: - Properties(public) - + + /// A closure invoked whenever the entered value changes. public var onPinValueChanged: PinCodeTextAction? + + /// A closure invoked once every item has been filled. public var onPinViewEnteredFully: PinCodeTextAction? + + /// A closure invoked when the input becomes first responder. public var onBecomeFirstResponder: PinCodeEmptyAction? + + /// A closure invoked when the input resigns first responder. public var onResignFirstResponder: PinCodeEmptyAction? - + + /// The currently entered value, concatenated from every filled item. public var text: String { charactersArray .compactMap({ $0 }) .map({ String($0) }) .joined() } - + + /// The behavior and layout configuration. Assigning a new value rebuilds the item views. public var config: PinViewConfig = PinViewConfig() { didSet { guard oldValue != config else { return } configureView() } } - + + /// The overall state of the input, propagated to every item view. public var viewState: ViewState = .normal { didSet { updateSubviewStates() } } - + + /// The layout configuration applied to every item view. public var layoutConfig: T.LayoutConfig = T.LayoutConfig.defaultValue { didSet { itemViews.forEach({ $0.layoutConfig = layoutConfig }) } } - + + /// The appearance configuration applied to every item view. public var appearanceConfig: T.AppearanceConfig = T.AppearanceConfig.defaultValue { didSet { itemViews.forEach({ $0.appearanceConfig = appearanceConfig }) } } - + + /// A Boolean value indicating whether the input can become first responder. `false` while disabled. public override var canBecomeFirstResponder: Bool { viewState != .disabled } - + // MARK: - UIKeyInput - + + /// A Boolean value indicating whether the input contains any characters. public var hasText: Bool { !text.isEmpty } - + + /// The autocapitalization style for the keyboard. public var autocapitalizationType: UITextAutocapitalizationType = .none + + /// The autocorrection behavior for the keyboard. public var autocorrectionType: UITextAutocorrectionType = .no + + /// The spell checking behavior for the keyboard. public var spellCheckingType: UITextSpellCheckingType = .no + + /// The smart quotes behavior for the keyboard. public var smartQuotesType: UITextSmartQuotesType = .no + + /// The smart dashes behavior for the keyboard. public var smartDashesType: UITextSmartDashesType = .no + + /// The smart insert and delete behavior for the keyboard. public var smartInsertDeleteType: UITextSmartInsertDeleteType = .no + + /// The keyboard type presented for input. public var keyboardType: UIKeyboardType = .numberPad + + /// The appearance of the keyboard. public var keyboardAppearance: UIKeyboardAppearance = .default + + /// The title of the keyboard return key. public var returnKeyType: UIReturnKeyType = .done + + /// A Boolean value indicating whether the return key is enabled only when there is text. public var enablesReturnKeyAutomatically: Bool = true + + /// A Boolean value indicating whether entered characters are masked. public var isSecureTextEntry: Bool = false + + /// The semantic meaning of the text, used for autofill. Defaults to one-time code. public var textContentType: UITextContentType! = .oneTimeCode // MARK: - Properties(private) @@ -165,20 +250,32 @@ public class PinCodeInputView: UIView, UIKeyInput, @preconcu // MARK: - Life cycle + /// Creates the input programmatically with the given frame. + /// + /// - Parameter frame: The initial frame rectangle for the view. public override init(frame: CGRect) { super.init(frame: frame) - + configureView() } - + + /// Creates the input from data in the given unarchiver. + /// + /// - Parameter coder: The unarchiver providing the encoded view data. public required init?(coder: NSCoder) { super.init(coder: coder) - + configureView() } - + // MARK: - Methods(public) - + + /// Reports whether the input can perform a given action, enabling paste only when the pasteboard has text. + /// + /// - Parameters: + /// - action: The selector describing the action to evaluate. + /// - sender: The object requesting the action. + /// - Returns: `true` if the action is supported in the current context. open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if action == #selector(paste(_:)) { return UIPasteboard.general.hasStrings @@ -188,6 +285,9 @@ public class PinCodeInputView: UIView, UIKeyInput, @preconcu } } + /// Pastes the pasteboard string into the items, starting at the active cell. + /// + /// - Parameter sender: The object requesting the paste. open override func paste(_ sender: Any?) { if let string = UIPasteboard.general.string { let pin: [Character] = Array(string) @@ -222,7 +322,13 @@ public class PinCodeInputView: UIView, UIKeyInput, @preconcu } // MARK: - UIKeyInput - + + /// Inserts text at the active item and advances the active position. + /// + /// A newline is treated as a return key press and may resign first responder + /// depending on ``PinViewConfig/shouldResignFirstResponderOnReturn``. + /// + /// - Parameter text: The text to insert. Only the first character is used per item. open func insertText(_ text: String) { if text == "\n" { // Return key pressed @@ -262,6 +368,7 @@ public class PinCodeInputView: UIView, UIKeyInput, @preconcu updateSubviewStates() } + /// Clears the active item and, when configured, moves the active position to the previous cell. open func deleteBackward() { guard let activeItemIndex else { return @@ -286,6 +393,9 @@ public class PinCodeInputView: UIView, UIKeyInput, @preconcu // MARK: - UIResponder + /// Makes the input active, selecting the first item when no specific cell was tapped. + /// + /// - Returns: `true` if the input became first responder. @discardableResult open override func becomeFirstResponder() -> Bool { // If become first responder was called without taping on specific item - select first one @@ -305,18 +415,28 @@ public class PinCodeInputView: UIView, UIKeyInput, @preconcu return super.becomeFirstResponder() } + /// Deactivates the input and clears the active item selection. + /// + /// - Returns: `true` if the input resigned first responder. @discardableResult open override func resignFirstResponder() -> Bool { activeItemIndex = nil updateSubviewStates() - + onResignFirstResponder?() - + return super.resignFirstResponder() } - + // MARK: - UIEditMenuInteractionDelegate - + + /// Provides the edit menu, offering a paste action when the pasteboard has text. + /// + /// - Parameters: + /// - interaction: The edit menu interaction requesting the menu. + /// - configuration: The configuration for the menu being presented. + /// - suggestedActions: The system suggested menu elements. + /// - Returns: A menu containing the paste action, or `nil` when there is nothing to paste. open func editMenuInteraction( _ interaction: UIEditMenuInteraction, menuFor configuration: UIEditMenuConfiguration, @@ -333,6 +453,9 @@ public class PinCodeInputView: UIView, UIKeyInput, @preconcu return UIMenu(title: "", children: [pasteAction]) } + /// Replaces the entire entered value without triggering input callbacks. + /// + /// - Parameter text: The new value. Characters beyond the pin length are ignored, and a shorter or `nil` value clears the remaining items. open func setText(_ text: String?) { let pin: [Character] = Array(text ?? "") diff --git a/Sources/NerdzPinView/Views/PinInputView/PinTapableView.swift b/Sources/NerdzPinView/Views/PinInputView/PinTapableView.swift index 631d649..c9a40df 100644 --- a/Sources/NerdzPinView/Views/PinInputView/PinTapableView.swift +++ b/Sources/NerdzPinView/Views/PinInputView/PinTapableView.swift @@ -7,8 +7,18 @@ import UIKit +/// A base `UIView` that reports taps through a closure. +/// +/// It installs a tap gesture recognizer only while ``onViewTapped`` is set, and +/// removes it when the closure is cleared. Predefined item views such as +/// ``BorderedItemView`` and ``UnderlineItemView`` subclass it to forward taps to +/// their container. public class PinTapableView: UIView { - + + /// A closure invoked whenever the view is tapped. + /// + /// Assigning a non `nil` value installs the tap gesture recognizer, and + /// setting it back to `nil` removes it. public var onViewTapped: PinCodeEmptyAction? { didSet { if onViewTapped == nil { diff --git a/Sources/NerdzPinView/Views/PinTextPosition.swift b/Sources/NerdzPinView/Views/PinTextPosition.swift index 350f98b..7374859 100644 --- a/Sources/NerdzPinView/Views/PinTextPosition.swift +++ b/Sources/NerdzPinView/Views/PinTextPosition.swift @@ -7,13 +7,22 @@ import UIKit +/// A position inside a pin code string, expressed as a character offset. +/// +/// This is the module's concrete `UITextPosition` used by the `UITextInput` +/// conformances to describe caret locations and range endpoints. public class PinTextPosition: UITextPosition { + /// The zero based character offset represented by this position. public let offset: Int - + + /// A textual representation of the position, used for debugging. public override var description: String { "\(offset)" } - + + /// Creates a position at the given character offset. + /// + /// - Parameter offset: The zero based character offset. public init(offset: Int) { self.offset = offset } diff --git a/Sources/NerdzPinView/Views/PinTextRange.swift b/Sources/NerdzPinView/Views/PinTextRange.swift index d726d85..4d7e0fb 100644 --- a/Sources/NerdzPinView/Views/PinTextRange.swift +++ b/Sources/NerdzPinView/Views/PinTextRange.swift @@ -7,44 +7,67 @@ import UIKit +/// A range of characters inside a pin code string, bounded by two ``PinTextPosition`` values. +/// +/// This is the module's concrete `UITextRange` used by the `UITextInput` +/// conformances to describe selections and insertion ranges. open class PinTextRange: UITextRange { - + + /// The position at the start of the range. public let startPosition: PinTextPosition + + /// The position at the end of the range. public let endPosition: PinTextPosition - + + /// The number of characters spanned by the range. public var length: Int { endPosition.offset - startPosition.offset } - + + /// A textual representation of the range, used for debugging. public override var description: String { "[\(startPosition.offset) ..< \(endPosition.offset)]" } - + + /// The start of the range, as a `UITextPosition`. public override var start: UITextPosition { startPosition } - + + /// The end of the range, as a `UITextPosition`. public override var end: UITextPosition { endPosition } - + + /// A Boolean value indicating whether the range spans no characters. public override var isEmpty: Bool { startPosition.offset >= endPosition.offset } - - // from may be larger than to - // from and to must each contain a valid indices + + /// Creates a range between two positions, or `nil` when the bounds are not strictly increasing. + /// + /// - Parameters: + /// - from: The start position. + /// - to: The end position, which must be strictly greater than `from`. public init?(from: PinTextPosition, to: PinTextPosition) { guard from.offset < to.offset else { return nil } - + self.startPosition = from self.endPosition = to } - - // maxLength may be negative - // from must contain a valid index + + /// Creates a range that extends from a position by a signed character count, clamped to the base string. + /// + /// A positive `maxOffset` extends forward from `from`, while a negative + /// value extends backward. The resulting bounds are clamped so they stay + /// within `baseString`. + /// + /// - Parameters: + /// - from: The anchor position the range extends from. + /// - maxOffset: The signed number of characters to extend by. May be negative. + /// - baseString: The string the offsets are clamped against. public init(from: PinTextPosition, maxOffset: Int, in baseString: String) { if maxOffset >= 0 { self.startPosition = from @@ -58,6 +81,10 @@ open class PinTextRange: UITextRange { } } + /// Converts the range into a `String.Index` range within the given string. + /// + /// - Parameter baseString: The string the offsets are resolved against. + /// - Returns: The equivalent `String.Index` range inside `baseString`. public func fullRange(in baseString: String) -> Range { let beginIndex = baseString.index(baseString.startIndex, offsetBy: startPosition.offset) let endIndex = baseString.index(beginIndex, offsetBy: endPosition.offset - startPosition.offset) diff --git a/Sources/NerdzPinView/Views/PinTextSelectionRect.swift b/Sources/NerdzPinView/Views/PinTextSelectionRect.swift index 084a705..7ecb199 100644 --- a/Sources/NerdzPinView/Views/PinTextSelectionRect.swift +++ b/Sources/NerdzPinView/Views/PinTextSelectionRect.swift @@ -7,27 +7,37 @@ import UIKit +/// A selection rectangle describing part of a pin code selection. +/// +/// This is the module's concrete `UITextSelectionRect`. Pin inputs are always +/// laid out left to right and horizontally, so the writing direction and +/// orientation are fixed. public class PinTextSelectionRect: UITextSelectionRect { private let _rect: CGRect private let _containsStart: Bool private let _containsEnd: Bool - + + /// The writing direction of the selection, always left to right. public override var writingDirection: NSWritingDirection { .leftToRight } - + + /// A Boolean value indicating whether the selection is vertical, always `false`. public override var isVertical: Bool { false } - + + /// The rectangle, in the input view's coordinate space, covered by the selection. public override var rect: CGRect { _rect } - + + /// A Boolean value indicating whether the rectangle contains the start of the selection. public override var containsStart: Bool { _containsStart } - + + /// A Boolean value indicating whether the rectangle contains the end of the selection. public override var containsEnd: Bool { _containsEnd } From e79ccb74c16a442635b3e3731bd14b43fd8c4a1b Mon Sep 17 00:00:00 2001 From: Roman Kovalchuk Date: Wed, 16 Sep 2026 02:51:12 +0300 Subject: [PATCH 6/7] fix: remove unused underlineHeight parameter from UnderlineItemView.LayoutConfig The LayoutConfig initializer accepted an underlineHeight argument that was never stored, so setting it had no effect. Underline height is controlled by AppearanceConfig via getUnderlineHeight(for:). Remove the dead parameter and its doc, and add a LayoutConfig round-trip test guarding that every initializer argument lands in a stored property. Runtime behavior unchanged. --- CHANGELOG.md | 4 +++ .../PredefinedViews/UnderlineItemView.swift | 7 ++-- .../Tests/UnderlineItemViewConfigTests.swift | 35 +++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72d56af..612c8d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,3 +23,7 @@ Nothing yet. * 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. diff --git a/Sources/NerdzPinView/ItemViews/PredefinedViews/UnderlineItemView.swift b/Sources/NerdzPinView/ItemViews/PredefinedViews/UnderlineItemView.swift index edb8e9e..d2434b3 100644 --- a/Sources/NerdzPinView/ItemViews/PredefinedViews/UnderlineItemView.swift +++ b/Sources/NerdzPinView/ItemViews/PredefinedViews/UnderlineItemView.swift @@ -38,23 +38,20 @@ public final class UnderlineItemView: PinTapableView, PinCodeItemViewType, ItemV /// Creates a layout configuration. /// - /// The underline height is driven by the appearance configuration rather - /// than by layout, so `underlineHeight` here is accepted for call site - /// convenience and is not stored. + /// The underline height is driven by the appearance configuration + /// (see ``UnderlineItemView/AppearanceConfig``), not by layout. /// /// - Parameters: /// - cursorCornerRadius: The corner radius of the blinking cursor. /// - cursorHeightMultiplier: The cursor height as a fraction of the item height. /// - cursorWidth: The width of the blinking cursor. /// - cornerRadius: The corner radius applied to the item's bounds. - /// - underlineHeight: A convenience parameter that is not stored. See ``UnderlineItemView/AppearanceConfig`` for underline height. /// - contentLabelEdgeInsets: The insets applied around the character label. public init( cursorCornerRadius: CGFloat = 0.5, cursorHeightMultiplier: CGFloat = 0.7, cursorWidth: CGFloat = 1, cornerRadius: CGFloat = 0, - underlineHeight: CGFloat = 2, contentLabelEdgeInsets: UIEdgeInsets = UIEdgeInsets(top: 2, left: 2, bottom: 2, right: 2) ) { self.cursorCornerRadius = cursorCornerRadius diff --git a/Tests/NerdzPinViewTests/Tests/UnderlineItemViewConfigTests.swift b/Tests/NerdzPinViewTests/Tests/UnderlineItemViewConfigTests.swift index dd0b57b..dfb32f7 100644 --- a/Tests/NerdzPinViewTests/Tests/UnderlineItemViewConfigTests.swift +++ b/Tests/NerdzPinViewTests/Tests/UnderlineItemViewConfigTests.swift @@ -123,6 +123,35 @@ struct UnderlineItemViewConfigTests { #expect(layout.cornerRadius == 0) } } + + @MainActor + @Suite("Layout config round trip") + struct LayoutConfigRoundTripTests { + + // Guards the real stored surface of LayoutConfig. The underline height is + // intentionally NOT part of LayoutConfig (it lives in AppearanceConfig via + // getUnderlineHeight(for:)), so every initializer argument here must land in + // a stored property. This would surface a regression if a non-stored + // (dropped) parameter were ever reintroduced. + @Test + func testInitWhenCustomValuesProvidedShouldStoreEveryProperty() { + // Act + let layout = UnderlineItemView.LayoutConfig( + cursorCornerRadius: TestData.cursorCornerRadius, + cursorHeightMultiplier: TestData.cursorHeightMultiplier, + cursorWidth: TestData.cursorWidth, + cornerRadius: TestData.cornerRadius, + contentLabelEdgeInsets: TestData.contentLabelEdgeInsets + ) + + // Assert + #expect(layout.cursorCornerRadius == TestData.cursorCornerRadius) + #expect(layout.cursorHeightMultiplier == TestData.cursorHeightMultiplier) + #expect(layout.cursorWidth == TestData.cursorWidth) + #expect(layout.cornerRadius == TestData.cornerRadius) + #expect(layout.contentLabelEdgeInsets == TestData.contentLabelEdgeInsets) + } + } } @MainActor @@ -135,6 +164,12 @@ private enum TestData { static let activeHeight: CGFloat = 4 static let errorHeight: CGFloat = 6 + static let cursorCornerRadius: CGFloat = 1.5 + static let cursorHeightMultiplier: CGFloat = 0.9 + static let cursorWidth: CGFloat = 3 + static let cornerRadius: CGFloat = 5 + static let contentLabelEdgeInsets = UIEdgeInsets(top: 4, left: 5, bottom: 6, right: 7) + static func configWithOverrides() -> UnderlineItemView.AppearanceConfig { UnderlineItemView.AppearanceConfig( defaultBackgroundColor: defaultColor, From 754f1d8cac7bcca46db0c2eec5f7fb9bbce37166 Mon Sep 17 00:00:00 2001 From: Roman Kovalchuk Date: Wed, 16 Sep 2026 11:51:10 +0300 Subject: [PATCH 7/7] docs: address PR review on DocC articles and aliases Add a dedicated SwiftUIUsage article symmetric to UIKitUsage, slim GettingStarted to installation plus next-step pointers, and wire the new article into the landing page Topics. Remove low-value doc comments from the PinCode action typealiases. --- .../Documentation.docc/GettingStarted.md | 63 ++---------------- .../Documentation.docc/NerdzPinView.md | 3 +- .../Documentation.docc/SwiftUIUsage.md | 64 +++++++++++++++++++ Sources/NerdzPinView/General/Aliases.swift | 8 --- 4 files changed, 71 insertions(+), 67 deletions(-) create mode 100644 Sources/NerdzPinView/Documentation.docc/SwiftUIUsage.md diff --git a/Sources/NerdzPinView/Documentation.docc/GettingStarted.md b/Sources/NerdzPinView/Documentation.docc/GettingStarted.md index 1bf2544..49af63a 100644 --- a/Sources/NerdzPinView/Documentation.docc/GettingStarted.md +++ b/Sources/NerdzPinView/Documentation.docc/GettingStarted.md @@ -1,6 +1,6 @@ # Getting Started -Add NerdzPinView to your project and present a pin input in SwiftUI. +Add NerdzPinView to your project. ## Installation @@ -29,62 +29,9 @@ let package = Package( The package targets iOS 16 and later. -## A Minimal SwiftUI Example +## Next Steps -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. +Pick the integration that matches your app. -```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 - -- -- ``NerdzUnderlinePinView`` +- for the SwiftUI wrappers. +- for direct use of the UIKit input views. diff --git a/Sources/NerdzPinView/Documentation.docc/NerdzPinView.md b/Sources/NerdzPinView/Documentation.docc/NerdzPinView.md index f4e4827..6292810 100644 --- a/Sources/NerdzPinView/Documentation.docc/NerdzPinView.md +++ b/Sources/NerdzPinView/Documentation.docc/NerdzPinView.md @@ -8,13 +8,14 @@ NerdzPinView provides styled, multi-cell code entry components for iOS. At its c 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 for SwiftUI or for UIKit. +To get started, read , then for SwiftUI or for UIKit. ## Topics ### Essentials - +- - ### SwiftUI Views diff --git a/Sources/NerdzPinView/Documentation.docc/SwiftUIUsage.md b/Sources/NerdzPinView/Documentation.docc/SwiftUIUsage.md new file mode 100644 index 0000000..d80d8a6 --- /dev/null +++ b/Sources/NerdzPinView/Documentation.docc/SwiftUIUsage.md @@ -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 + +- +- +- ``NerdzUnderlinePinView`` diff --git a/Sources/NerdzPinView/General/Aliases.swift b/Sources/NerdzPinView/General/Aliases.swift index 0fe84c9..0323127 100644 --- a/Sources/NerdzPinView/General/Aliases.swift +++ b/Sources/NerdzPinView/General/Aliases.swift @@ -5,14 +5,6 @@ // Created by Roman Kovalchuk on 19.11.2024. // -/// A closure that reports an event carrying no associated value. -/// -/// Used across the module for callbacks such as first responder changes, -/// where only the fact that the event happened matters. public typealias PinCodeEmptyAction = () -> Void -/// A closure that reports an event carrying the current code as a string. -/// -/// Used for callbacks such as value changes and completion, where the -/// latest entered value is delivered to the caller. public typealias PinCodeTextAction = (String) -> Void