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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..612c8d4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +This changelog starts its history at version 3.2.0. Earlier history is available through the git tags up to 3.1.0. + +## [Unreleased] + +Nothing yet. + +## [3.2.0] 2026-09-16 + +### Added + +* Swift Testing unit test target covering the pin view logic layer (text position, range, and selection math, plus per state appearance config resolution). +* GitHub Actions CI workflow that builds and tests on a macOS runner via xcodebuild against an iOS Simulator. +* DocC documentation catalog and doc comments for the public API. + +### Changed + +* Corrected the README Swift version badge (it showed Swift 5.1 or 5.9, but the package requires Swift 6.0) and documented the Xcode 16 requirement. +* Raised the package to Swift tools 6.0, which sets the minimum Xcode to 16 for consumers. + +### Removed + +* Removed the unused `underlineHeight` parameter from `UnderlineItemView.LayoutConfig.init`. The parameter was never stored and had no effect (underline height is controlled by `UnderlineItemView.AppearanceConfig` via `getUnderlineHeight(for:)`). Runtime behavior is unchanged. Call sites that passed `underlineHeight:` to the layout initializer must remove that argument and set the height on the appearance config instead. 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/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. diff --git a/Sources/NerdzPinView/Documentation.docc/GettingStarted.md b/Sources/NerdzPinView/Documentation.docc/GettingStarted.md new file mode 100644 index 0000000..49af63a --- /dev/null +++ b/Sources/NerdzPinView/Documentation.docc/GettingStarted.md @@ -0,0 +1,37 @@ +# Getting Started + +Add NerdzPinView to your project. + +## Installation + +NerdzPinView is distributed as a Swift package. Add it to your project through Xcode with File, Add Package Dependencies, then enter the repository URL and pick the ``NerdzPinView`` library product. + +You can also declare the dependency directly in a `Package.swift` manifest. + +```swift +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "MyApp", + platforms: [.iOS(.v16)], + dependencies: [ + .package(url: "https://github.com/RomanKovalchukDev/NerdzPinView.git", from: "1.0.0") + ], + targets: [ + .target( + name: "MyApp", + dependencies: ["NerdzPinView"] + ) + ] +) +``` + +The package targets iOS 16 and later. + +## Next Steps + +Pick the integration that matches your app. + +- 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 new file mode 100644 index 0000000..6292810 --- /dev/null +++ b/Sources/NerdzPinView/Documentation.docc/NerdzPinView.md @@ -0,0 +1,64 @@ +# ``NerdzPinView`` + +Customizable pin code and one-time code input views for UIKit, with ready to use SwiftUI wrappers. + +## Overview + +NerdzPinView provides styled, multi-cell code entry components for iOS. At its core are two generic UIKit containers. ``PinCodeInputView`` drives a row of tappable item cells through `UIKeyInput`, while ``OneTimeCodeInputView`` implements the full `UITextInput` protocol so it supports the system caret and one-time code autofill. Each container is parameterized by an item view type, and the module ships bordered, underlined, and grouped item views out of the box. + +For most apps the pre-styled wrappers are enough. ``DesignableBorderedPinInputView``, ``DesignableUnderlinedPinInputView``, and ``DesignableOneTimeCodeInputView`` bundle sensible defaults, and ``NerdzBorderedPinView`` and ``NerdzUnderlinePinView`` expose that behavior to SwiftUI through bindings for the text, the state, and the keyboard focus. + +To get started, read , then 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/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/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 e202baf..0323127 100644 --- a/Sources/NerdzPinView/General/Aliases.swift +++ b/Sources/NerdzPinView/General/Aliases.swift @@ -1,9 +1,10 @@ // // Aliases.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 19.11.2024. // public typealias PinCodeEmptyAction = () -> Void + public typealias PinCodeTextAction = (String) -> Void diff --git a/Sources/NerdzPinView/General/PinCodeItemViewState.swift b/Sources/NerdzPinView/General/PinCodeItemViewState.swift index 1b4e98e..5604830 100644 --- a/Sources/NerdzPinView/General/PinCodeItemViewState.swift +++ b/Sources/NerdzPinView/General/PinCodeItemViewState.swift @@ -1,13 +1,25 @@ // // PinCodeItemViewState.swift -// PinViewDemo +// NerdzPinView // // 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 01c3ad9..03f4a22 100644 --- a/Sources/NerdzPinView/ItemViews/Abstract/DefaultableConfigType.swift +++ b/Sources/NerdzPinView/ItemViews/Abstract/DefaultableConfigType.swift @@ -1,11 +1,17 @@ // // DefaultableConfigType.swift -// PinViewDemo +// NerdzPinView // // 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 7160264..02ca665 100644 --- a/Sources/NerdzPinView/ItemViews/Abstract/ItemViewAppearanceConfigurable.swift +++ b/Sources/NerdzPinView/ItemViews/Abstract/ItemViewAppearanceConfigurable.swift @@ -1,12 +1,20 @@ // // ItemViewAppearanceConfigurable.swift -// PinViewDemo +// NerdzPinView // // 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 2f3a9b5..9c2c6ca 100644 --- a/Sources/NerdzPinView/ItemViews/Abstract/ItemViewLayoutConfigurable.swift +++ b/Sources/NerdzPinView/ItemViews/Abstract/ItemViewLayoutConfigurable.swift @@ -1,12 +1,20 @@ // // ItemViewLayoutConfigurable.swift -// PinViewDemo +// NerdzPinView // // 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 077b461..9869567 100644 --- a/Sources/NerdzPinView/ItemViews/Abstract/PinCodeItemViewType.swift +++ b/Sources/NerdzPinView/ItemViews/Abstract/PinCodeItemViewType.swift @@ -1,24 +1,45 @@ // // PinCodeItemViewType.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 19.11.2024. // 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 064650a..1c66d71 100644 --- a/Sources/NerdzPinView/ItemViews/PredefinedViews/BorderedItemView.swift +++ b/Sources/NerdzPinView/ItemViews/PredefinedViews/BorderedItemView.swift @@ -1,27 +1,49 @@ // // BorderedItemView.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 19.11.2024. // 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..d2434b3 100644 --- a/Sources/NerdzPinView/ItemViews/PredefinedViews/UnderlineItemView.swift +++ b/Sources/NerdzPinView/ItemViews/PredefinedViews/UnderlineItemView.swift @@ -7,26 +7,51 @@ 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 + /// (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. + /// - 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 @@ -37,34 +62,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 +238,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 +314,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 23b13f8..ebf6af4 100644 --- a/Sources/NerdzPinView/Views/PinInputView/PinCodeInputView.swift +++ b/Sources/NerdzPinView/Views/PinInputView/PinCodeInputView.swift @@ -1,43 +1,91 @@ // // PinCodeInputView.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 19.11.2024. // 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 3d9758d..c9a40df 100644 --- a/Sources/NerdzPinView/Views/PinInputView/PinTapableView.swift +++ b/Sources/NerdzPinView/Views/PinInputView/PinTapableView.swift @@ -1,14 +1,24 @@ // // TapableView.swift -// PinViewDemo +// NerdzPinView // // Created by Roman Kovalchuk on 20.11.2024. // 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 } 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 + ) + } +}