TM-SGNL-iOS/Signal/Registration/UserInterface/RegistrationVerificationCodeView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

287 lines
9.1 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalUI
import UIKit
// MARK: - RegistrationVerificationCodeTextField
private protocol RegistrationVerificationCodeTextFieldDelegate: AnyObject {
func textFieldDidDeletePrevious()
}
// Editing a code should feel seamless, as even though the UITextField only lets you edit a single
// digit at a time. For deletes to work properly, we need to detect delete events that would affect
// the _previous_ digit.
private class RegistrationVerificationCodeTextField: UITextField {
fileprivate weak var codeDelegate: RegistrationVerificationCodeTextFieldDelegate?
init() {
super.init(frame: .zero)
self.disableAiWritingTools()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func deleteBackward() {
var isDeletePrevious = false
if let selectedTextRange = selectedTextRange {
let cursorPosition = offset(from: beginningOfDocument, to: selectedTextRange.start)
if cursorPosition == 0 {
isDeletePrevious = true
}
}
super.deleteBackward()
if isDeletePrevious {
codeDelegate?.textFieldDidDeletePrevious()
}
}
}
// MARK: - RegistrationVerificationCodeViewDelegate
protocol RegistrationVerificationCodeViewDelegate: AnyObject {
func codeViewDidChange()
}
// MARK: - RegistrationVerificationCodeView
/// The ``RegistrationVerificationCodeView`` is a special "verification code" editor that should
/// feel like editing a single piece of text (ala UITextField) even though the individual digits of
/// the code are visually separated.
///
/// We use a separate ``UILabel`` for each digit, and move around a single ``UITextfield`` to let
/// the user edit the last/next digit.
class RegistrationVerificationCodeView: UIView {
weak var delegate: RegistrationVerificationCodeViewDelegate?
public init() {
super.init(frame: .zero)
createSubviews()
updateViewState()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private let digitCount = 6
private var digitLabels = [UILabel]()
private var digitStrokes = [UIView]()
private let cellFont: UIFont = UIFont.dynamicTypeLargeTitle1Clamped
private let interCellSpacing: CGFloat = 8
private let segmentSpacing: CGFloat = 24
private let strokeWidth: CGFloat = 3
private var cellSize: CGSize {
let vMargin: CGFloat = 4
let cellHeight: CGFloat = cellFont.lineHeight + vMargin * 2
let cellWidth: CGFloat = cellHeight * 2 / 3
return CGSize(width: cellWidth, height: cellHeight)
}
override var intrinsicContentSize: CGSize {
let totalWidth = (CGFloat(digitCount) * (cellSize.width + interCellSpacing)) + segmentSpacing
let totalHeight = strokeWidth + cellSize.height
return CGSize(width: totalWidth, height: totalHeight)
}
// We use a single text field to edit the "current" digit.
// The "current" digit is usually the "last"
fileprivate let textfield = RegistrationVerificationCodeTextField()
private var currentDigitIndex = 0
private var textfieldConstraints = [NSLayoutConstraint]()
// The current complete text - the "model" for this view.
private var digitText = ""
var isComplete: Bool {
return digitText.count == digitCount
}
var verificationCode: String {
return digitText
}
public func clear() {
guard isComplete else {
return
}
digitText = ""
updateViewState()
}
private func createSubviews() {
textfield.textAlignment = .left
textfield.delegate = self
textfield.codeDelegate = self
textfield.font = UIFont.dynamicTypeLargeTitle1Clamped
textfield.keyboardType = .numberPad
textfield.textContentType = .oneTimeCode
var digitViews = [UIView]()
(0..<digitCount).forEach { (_) in
let (digitView, digitLabel, digitStroke) = makeCellView(text: "")
digitLabels.append(digitLabel)
digitStrokes.append(digitStroke)
digitViews.append(digitView)
}
digitViews.insert(UIView.spacer(withWidth: segmentSpacing), at: 3)
let stackView = UIStackView(arrangedSubviews: digitViews)
stackView.axis = .horizontal
stackView.alignment = .center
stackView.spacing = interCellSpacing
addSubview(stackView)
stackView.autoPinHeightToSuperview()
stackView.autoHCenterInSuperview()
self.addSubview(textfield)
updateColors()
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapView)))
}
public func updateColors() {
textfield.textColor = Theme.primaryTextColor
digitLabels.forEach { $0.textColor = Theme.primaryTextColor }
let strokeColor = (hasError ? UIColor.ows_accentRed : Theme.secondaryTextAndIconColor)
for digitStroke in digitStrokes {
digitStroke.backgroundColor = strokeColor
}
}
private func makeCellView(text: String) -> (UIView, UILabel, UIView) {
let digitView = UIView()
let digitLabel = UILabel()
digitLabel.text = text
digitLabel.font = cellFont
digitLabel.textAlignment = .center
digitView.addSubview(digitLabel)
digitLabel.autoCenterInSuperview()
let strokeColor = Theme.secondaryTextAndIconColor
let strokeView = digitView.addBottomStroke(color: strokeColor, strokeWidth: strokeWidth)
strokeView.layer.cornerRadius = strokeWidth / 2
digitView.autoSetDimensions(to: cellSize)
return (digitView, digitLabel, strokeView)
}
private func digit(at index: Int) -> String {
return String(digitText.dropFirst(index).prefix(1))
}
// Ensure that all labels are displaying the correct
// digit (if any) and that the UITextField has replaced
// the "current" digit.
private func updateViewState() {
currentDigitIndex = min(digitCount - 1,
digitText.count)
(0..<digitCount).forEach { (index) in
let digitLabel = digitLabels[index]
digitLabel.text = digit(at: index)
digitLabel.isHidden = index == currentDigitIndex
}
NSLayoutConstraint.deactivate(textfieldConstraints)
textfieldConstraints.removeAll()
let digitLabelToReplace = digitLabels[currentDigitIndex]
textfield.text = digit(at: currentDigitIndex)
textfieldConstraints.append(textfield.autoAlignAxis(.horizontal, toSameAxisOf: digitLabelToReplace))
textfieldConstraints.append(textfield.autoAlignAxis(.vertical, toSameAxisOf: digitLabelToReplace))
// Move cursor to end of text.
let newPosition = textfield.endOfDocument
textfield.selectedTextRange = textfield.textRange(from: newPosition, to: newPosition)
}
@objc
private func didTapView() {
becomeFirstResponder()
}
@discardableResult
public override func becomeFirstResponder() -> Bool {
return textfield.becomeFirstResponder()
}
@discardableResult
public override func resignFirstResponder() -> Bool {
return textfield.resignFirstResponder()
}
private var hasError = false
func setHasError(_ hasError: Bool) {
self.hasError = hasError
updateColors()
}
func set(verificationCode: String) {
digitText = verificationCode
updateViewState()
self.delegate?.codeViewDidChange()
}
}
// MARK: -
extension RegistrationVerificationCodeView: UITextFieldDelegate {
public func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString newString: String) -> Bool {
var oldText = ""
if let textFieldText = textField.text {
oldText = textFieldText
}
let left = (oldText as NSString).substring(to: range.location)
let right = (oldText as NSString).substring(from: range.location + range.length)
let unfiltered = left + newString + right
let characterSet = CharacterSet(charactersIn: "0123456789")
let filtered = unfiltered.components(separatedBy: characterSet.inverted).joined()
let filteredAndTrimmed = String(filtered.prefix(1))
textField.text = filteredAndTrimmed
digitText = String(digitText.prefix(currentDigitIndex)) + filteredAndTrimmed
updateViewState()
self.delegate?.codeViewDidChange()
// Inform our caller that we took care of performing the change.
return false
}
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
self.delegate?.codeViewDidChange()
return false
}
}
// MARK: - RegistrationVerificationCodeTextFieldDelegate
extension RegistrationVerificationCodeView: RegistrationVerificationCodeTextFieldDelegate {
public func textFieldDidDeletePrevious() {
if digitText.isEmpty { return }
digitText = String(digitText.prefix(currentDigitIndex - 1))
updateViewState()
}
}