TM-SGNL-iOS/SignalUI/Views/TextFieldFormatting.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

213 lines
10 KiB
Swift

//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
public class TextFieldFormatting {
private init() {}
// Performs cursory validation and change handling for phone number text field edits
// Allows UIKit to apply the majority of edits (unlike +phoneNumberTextField:changeCharacters...")
// which applies the edit manually.
// Useful when +phoneNumberTextField:changeCharactersInRange:... can't be used
// because it applies changes manually and requires failing any change request from UIKit.
public static func phoneNumberTextField(
_ textField: UITextField,
shouldChangeCharactersIn range: NSRange,
replacementString insertionText: String,
plusPrefixedCallingCode: String
) -> Bool {
let isDeletion = insertionText.isEmpty
guard !isDeletion else { return true }
// If we're deleting text, we're going to want to ignore
// parens and spaces when finding a character to delete.
// Let's tell UIKit to not apply the edit and just apply it ourselves.
phoneNumberTextField(textField, changeCharactersIn: range, replacementString: insertionText, plusPrefixedCallingCode: plusPrefixedCallingCode)
return false
}
// Reformats the text in a UITextField to apply phone number formatting
public static func reformatPhoneNumberTextField(_ textField: UITextField, plusPrefixedCallingCode: String) {
let originalCursorOffset: Int
if let selectedTextRange = textField.selectedTextRange {
originalCursorOffset = textField.offset(from: textField.beginningOfDocument, to: selectedTextRange.start)
} else {
originalCursorOffset = 0
}
let originalText = textField.text ?? ""
let trimmedText = originalText.digitsOnly().phoneNumberTrimmedToMaxLength
let updatedText = PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(trimmedText, plusPrefixedCallingCode: plusPrefixedCallingCode)
let updatedCursorOffset = PhoneNumberUtil.translateCursorPosition(
UInt(originalCursorOffset),
from: originalText,
to: updatedText,
stickingRightward: false
)
textField.text = updatedText
if let position = textField.position(from: textField.beginningOfDocument, offset: Int(updatedCursorOffset)) {
textField.selectedTextRange = textField.textRange(from: position, to: position)
}
}
// This convenience function can be used to reformat the contents of
// a phone number text field as the user modifies its text by typing,
// pasting, etc. Applies the incoming edit directly. The text field delegate
// should return NO from -textField:shouldChangeCharactersInRange:...
//
// "callingCode" should be of the form: "+1".
public static func phoneNumberTextField(
_ textField: UITextField,
changeCharactersIn range: NSRange,
replacementString insertionText: String,
plusPrefixedCallingCode: String
) {
// Phone numbers takes many forms.
//
// * We only want to let the user enter decimal digits.
// * The user shouldn't have to enter hyphen, parentheses or whitespace;
// the phone number should be formatted automatically.
// * The user should be able to copy and paste freely.
// * Invalid input should be simply ignored.
//
// We accomplish this by being permissive and trying to "take as much of the user
// input as possible".
//
// * Always accept deletes.
// * Ignore invalid input.
// * Take partial input if possible.
let oldText = textField.text ?? ""
// Construct the new contents of the text field by:
// 1. Determining the "left" substring: the contents of the old text _before_ the deletion range.
// Filtering will remove non-decimal digit characters like hyphen "-".
var left = (oldText as NSString).substring(to: range.location).digitsOnly()
// 2. Determining the "right" substring: the contents of the old text _after_ the deletion range.
let right = (oldText as NSString).substring(from: range.location + range.length).digitsOnly()
// 3. Determining the "center" substring: the contents of the new insertion text.
let center = insertionText.digitsOnly()
// 3a. If user hits backspace, they should always delete a _digit_ to the
// left of the cursor, even if the text _immediately_ to the left of
// cursor is "formatting text" (e.g. whitespace, a hyphen or a
// parentheses).
let isJustDeletion = insertionText.isEmpty
if isJustDeletion {
let deletedText = (oldText as NSString).substring(with: range)
let didDeleteFormatting = deletedText.count == 1 && deletedText.digitsOnly().isEmpty
if didDeleteFormatting && !left.isEmpty {
left = String(left.dropLast())
}
}
// 4. Construct the "raw" new text by concatenating left, center and right.
// Ensure we don't exceed the maximum length for a e164 phone number
let textAfterChange = left.appending(center).appending(right).phoneNumberTrimmedToMaxLength
// 5. Construct the "formatted" new text by inserting a hyphen if necessary.
// reformat the phone number, trying to keep the cursor beside the inserted or deleted digit
let cursorPositionAfterChange = min(left.utf16.count + center.utf16.count, textAfterChange.utf16.count)
let formattedText = PhoneNumber.bestEffortFormatPartialUserSpecifiedTextToLookLikeAPhoneNumber(textAfterChange, plusPrefixedCallingCode: plusPrefixedCallingCode)
let cursorPositionAfterReformat = PhoneNumberUtil.translateCursorPosition(
UInt(cursorPositionAfterChange),
from: textAfterChange,
to: formattedText,
stickingRightward: isJustDeletion
)
textField.text = formattedText
if let position = textField.position(from: textField.beginningOfDocument, offset: Int(cursorPositionAfterReformat)) {
textField.selectedTextRange = textField.textRange(from: position, to: position)
}
}
public static func ows2FAPINTextField(
_ textField: UITextField,
changeCharactersIn range: NSRange,
replacementString insertionText: String
) {
// * We only want to let the user enter decimal digits.
// * The user should be able to copy and paste freely.
// * Invalid input should be simply ignored.
//
// We accomplish this by being permissive and trying to "take as much of the user
// input as possible".
//
// * Always accept deletes.
// * Ignore invalid input.
// * Take partial input if possible.
let oldText = textField.text ?? ""
// Construct the new contents of the text field by:
// 1. Determining the "left" substring: the contents of the old text _before_ the deletion range.
// Filtering will remove non-decimal digit characters.
let left = (oldText as NSString).substring(to: range.location).digitsOnly()
// 2. Determining the "right" substring: the contents of the old text _after_ the deletion range.
let right = (oldText as NSString).substring(from: range.location + range.length).digitsOnly()
// 3. Determining the "center" substring: the contents of the new insertion text.
let center = insertionText.digitsOnly()
// 4. Construct the "raw" new text by concatenating left, center and right.
let textAfterChange = left.appending(center).appending(right)
// 5. Ensure we don't exceed the maximum length for a PIN.
// We explicitly no longer do this here. We don't want to truncate passwords.
// Instead, we rely on the view to notify when the user's pin is too long.
// 6. Construct the final text.
textField.text = textAfterChange
let cursorPositionAfterChange = min(left.utf16.count + center.utf16.count, textAfterChange.utf16.count)
if let position = textField.position(from: textField.beginningOfDocument, offset: cursorPositionAfterChange) {
textField.selectedTextRange = textField.textRange(from: position, to: position)
}
}
// The purpose of the example phone number is to indicate to the user that they should enter
// their phone number _without_ a country calling code (e.g. +1 or +44) but _with_ area code, etc.
public static func exampleNationalNumber(forCountryCode countryCode: String, includeExampleLabel: Bool) -> String? {
owsAssertDebug(!countryCode.isEmpty)
let phoneNumberUtil = SSKEnvironment.shared.phoneNumberUtilRef
let countryCodeForParsing = phoneNumberUtil.countryCodeForParsing(fromCountryCode: countryCode)
guard let nationalNumber = phoneNumberUtil.exampleNationalNumber(forCountryCode: countryCodeForParsing) else {
owsFailDebug("examplePhoneNumber == nil")
return nil
}
guard includeExampleLabel else {
return nationalNumber
}
let formatString = OWSLocalizedString(
"PHONE_NUMBER_EXAMPLE_FORMAT",
comment: "A format for a label showing an example phone number. Embeds {{the example phone number}}."
)
return String(format: formatString, nationalNumber)
}
}
private extension String {
private static let kMaxPhoneNumberLength: Int = 18
var phoneNumberTrimmedToMaxLength: String {
// Ensure we don't exceed the maximum length for a e164 phone number,
// 15 digits, per: https://en.wikipedia.org/wiki/E.164
//
// NOTE: The actual limit is 18, not 15, because of certain invalid phone numbers in Germany.
// https://github.com/googlei18n/libphonenumber/blob/master/FALSEHOODS.md
if self.count > Self.kMaxPhoneNumberLength {
return String(self.prefix(Self.kMaxPhoneNumberLength))
}
return self
}
}