Currency mask support

commit_hash:c3ec8930a82a0af840bcf9bf76dcf2033fa1c1df
This commit is contained in:
denlvovich
2026-01-16 11:46:48 +03:00
parent 8d9a16f3c4
commit 55299d08e4
17 changed files with 337 additions and 8 deletions
+10
View File
@@ -16192,6 +16192,7 @@
"client/ios/LayoutKit/LayoutKit/Base/AttributedStringExtensions.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Base/AttributedStringExtensions.swift",
"client/ios/LayoutKit/LayoutKit/Base/CMTimeExtensions.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Base/CMTimeExtensions.swift",
"client/ios/LayoutKit/LayoutKit/Base/MaskedInputViewModel.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Base/MaskedInputViewModel.swift",
"client/ios/LayoutKit/LayoutKit/Base/Masks/CurrencyMaskFormatter.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Base/Masks/CurrencyMaskFormatter.swift",
"client/ios/LayoutKit/LayoutKit/Base/Masks/FixedLengthMaskFormatter.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Base/Masks/FixedLengthMaskFormatter.swift",
"client/ios/LayoutKit/LayoutKit/Base/Masks/MaskValidator.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Base/Masks/MaskValidator.swift",
"client/ios/LayoutKit/LayoutKit/Base/Masks/PhoneMaskFormatter.swift":"divkit/public/client/ios/LayoutKit/LayoutKit/Base/Masks/PhoneMaskFormatter.swift",
@@ -16510,6 +16511,7 @@
"client/ios/LayoutKit/LayoutKitTests/UI/Views/TextSelectionTests.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/UI/Views/TextSelectionTests.swift",
"client/ios/LayoutKit/LayoutKitTests/UIElementPathTests.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/UIElementPathTests.swift",
"client/ios/LayoutKit/LayoutKitTests/Utils.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/Utils.swift",
"client/ios/LayoutKit/LayoutKitTests/ViewModels/CurrencyMaskFormatterTests.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/ViewModels/CurrencyMaskFormatterTests.swift",
"client/ios/LayoutKit/LayoutKitTests/ViewModels/FixedLengthMaskFormatterTests.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/ViewModels/FixedLengthMaskFormatterTests.swift",
"client/ios/LayoutKit/LayoutKitTests/ViewModels/GalleryViewLayoutTests.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/ViewModels/GalleryViewLayoutTests.swift",
"client/ios/LayoutKit/LayoutKitTests/ViewModels/GalleryViewMetricsTests.swift":"divkit/public/client/ios/LayoutKit/LayoutKitTests/ViewModels/GalleryViewMetricsTests.swift",
@@ -17689,6 +17691,14 @@
"client/ios/Tests/reference_snapshots/div-input/base-properties_414@3x_step5.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/base-properties_414@3x_step5.png",
"client/ios/Tests/reference_snapshots/div-input/base-properties_414@3x_step6.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/base-properties_414@3x_step6.png",
"client/ios/Tests/reference_snapshots/div-input/base-properties_414@3x_step7.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/base-properties_414@3x_step7.png",
"client/ios/Tests/reference_snapshots/div-input/currency_input_mask_375@2x_step0.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/currency_input_mask_375@2x_step0.png",
"client/ios/Tests/reference_snapshots/div-input/currency_input_mask_375@2x_step1.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/currency_input_mask_375@2x_step1.png",
"client/ios/Tests/reference_snapshots/div-input/currency_input_mask_375@2x_step2.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/currency_input_mask_375@2x_step2.png",
"client/ios/Tests/reference_snapshots/div-input/currency_input_mask_375@2x_step3.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/currency_input_mask_375@2x_step3.png",
"client/ios/Tests/reference_snapshots/div-input/currency_input_mask_414@3x_step0.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/currency_input_mask_414@3x_step0.png",
"client/ios/Tests/reference_snapshots/div-input/currency_input_mask_414@3x_step1.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/currency_input_mask_414@3x_step1.png",
"client/ios/Tests/reference_snapshots/div-input/currency_input_mask_414@3x_step2.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/currency_input_mask_414@3x_step2.png",
"client/ios/Tests/reference_snapshots/div-input/currency_input_mask_414@3x_step3.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/currency_input_mask_414@3x_step3.png",
"client/ios/Tests/reference_snapshots/div-input/fixed_length_input_mask_375@2x_step0.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/fixed_length_input_mask_375@2x_step0.png",
"client/ios/Tests/reference_snapshots/div-input/fixed_length_input_mask_375@2x_step1.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/fixed_length_input_mask_375@2x_step1.png",
"client/ios/Tests/reference_snapshots/div-input/fixed_length_input_mask_375@2x_step10.png":"divkit/public/client/ios/Tests/reference_snapshots/div-input/fixed_length_input_mask_375@2x_step10.png",
@@ -283,6 +283,7 @@
8CF32B7E2875E12F003F799A /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CF32B7D2875E12F003F799A /* Utils.swift */; };
8CF91B042D0AE4ED0082FDC1 /* MockDivActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CF91B032D0AE4ED0082FDC1 /* MockDivActionHandler.swift */; };
8CFD4C9E2B2C5B780092814C /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFD4C9D2B2C5B780092814C /* Theme.swift */; };
9807E2E32F17D24B00633FB7 /* CurrencyMaskFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9807E2E22F17D23200633FB7 /* CurrencyMaskFormatterTests.swift */; };
982A7F142C9C08F30081DE74 /* PagerViewLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982A7F132C9C08F30081DE74 /* PagerViewLayoutTests.swift */; };
983406132E85C3A4004B1AA3 /* DivTemplatesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 983406122E85C39C004B1AA3 /* DivTemplatesTests.swift */; };
9860E7E72D9C326E00F93B48 /* TextInputBlockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9860E7E62D9C325B00F93B48 /* TextInputBlockTests.swift */; };
@@ -675,6 +676,7 @@
8CF8BBE92B32682700569777 /* DivBuilders.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DivBuilders.swift; sourceTree = "<group>"; };
8CF91B032D0AE4ED0082FDC1 /* MockDivActionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDivActionHandler.swift; sourceTree = "<group>"; };
8CFD4C9D2B2C5B780092814C /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
9807E2E22F17D23200633FB7 /* CurrencyMaskFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyMaskFormatterTests.swift; sourceTree = "<group>"; };
982A7F132C9C08F30081DE74 /* PagerViewLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagerViewLayoutTests.swift; sourceTree = "<group>"; };
983406122E85C39C004B1AA3 /* DivTemplatesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DivTemplatesTests.swift; sourceTree = "<group>"; };
9860E7E62D9C325B00F93B48 /* TextInputBlockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextInputBlockTests.swift; sourceTree = "<group>"; };
@@ -1410,6 +1412,7 @@
437B19D329C8A73300A4C467 /* MaskValidatorTests.swift */,
65509D262A98EA4F00F75699 /* FixedLengthMaskFormatterTests.swift */,
65509D282A98EA9200F75699 /* PhoneMaskFormatterTests.swift */,
9807E2E22F17D23200633FB7 /* CurrencyMaskFormatterTests.swift */,
);
name = ViewModels;
path = LayoutKit/LayoutKitTests/ViewModels;
@@ -2236,6 +2239,7 @@
65509D272A98EA4F00F75699 /* FixedLengthMaskFormatterTests.swift in Sources */,
8C97EA3C2A5C169E00EC0C3E /* UIViewRenderable+AccessibilityTests.swift in Sources */,
431DC9C22961EF84007FA268 /* ContainerBlockLayoutTests.swift in Sources */,
9807E2E32F17D24B00633FB7 /* CurrencyMaskFormatterTests.swift in Sources */,
8CF329572875D5C8003F799A /* BlockTests+Layout.swift in Sources */,
8CF3294B2875D5C8003F799A /* GenericCollectionLayoutTests.swift in Sources */,
8CF3294F2875D5C8003F799A /* VisibilityActionPerformersTests.swift in Sources */,
@@ -293,8 +293,12 @@ extension DivInputMask {
patternElements: divFixedLengthInputMask.patternElements
.map { $0.makePatternElement(resolver) }
))
case .divCurrencyInputMask:
nil
case let .divCurrencyInputMask(divCurrencyInputMask):
MaskValidator(
formatter: CurrencyMaskFormatter(
locale: divCurrencyInputMask.resolveLocale(resolver)
)
)
case .divPhoneInputMask:
MaskValidator(formatter: PhoneMaskFormatter(
masksByCountryCode: PhoneMasks().value.typedJSON(),
@@ -310,8 +314,11 @@ extension DivInputMask {
variableName: divFixedLengthInputMask.rawTextVariable,
defaultValue: ""
)
case .divCurrencyInputMask:
nil
case let .divCurrencyInputMask(divCurrencyInputMask):
context.makeBinding(
variableName: divCurrencyInputMask.rawTextVariable,
defaultValue: ""
)
case let .divPhoneInputMask(divPhoneInputMask):
context.makeBinding(
variableName: divPhoneInputMask.rawTextVariable,
@@ -83,6 +83,12 @@ public class MaskedInputViewModel {
string: string
)
}
let newInputData = self.maskValidator.formatted(rawText: newString)
if newInputData.rawText == self.rawText {
return // Changes rejected
}
self.rawText = self.maskValidator.formatted(rawText: newString).rawText
self.rawCursorPosition = newCursorPosition
}.dispose(in: disposePool)
@@ -0,0 +1,141 @@
import Foundation
public final class CurrencyMaskFormatter: MaskFormatter {
private let locale: Locale
private var lastCorrectInput = InputData(
text: "", cursorPosition: nil, rawData: []
)
private lazy var formatter: NumberFormatter = {
let formatter = NumberFormatter()
formatter.locale = locale
formatter.numberStyle = .decimal
formatter.maximumFractionDigits = 2
return formatter
}()
public init(locale: String?) {
self.locale = locale.map { Locale(identifier: $0) } ?? Locale.current
}
public func formatted(
rawText: String,
rawCursorPosition: CursorData?
) -> InputData {
guard !rawText.isEmpty else {
return InputData(text: "", cursorPosition: nil, rawData: [])
}
guard let formattedText: String = {
let pattern = "^(?:0|[1-9]\\d*)(?:[.,]\\d{0,2})?$"
if rawText.range(of: pattern, options: .regularExpression) != nil,
let val = formattedValue(text: rawText) {
return val
}
return nil
}() else {
return lastCorrectInput
}
var newCursorPosition: CursorPosition? = if rawText.endIndex == rawCursorPosition?
.cursorPosition.rawValue {
CursorPosition(rawValue: formattedText.endIndex)
} else {
nil
}
var rawData: [InputData.RawCharacter] = []
var formattedTextPointer = 0
for rawTextPointer in 0..<rawText.count {
while formattedTextPointer < formattedText.count {
guard let formattedTextChar = formattedText[formattedTextPointer],
let rawTextChar = rawText[rawTextPointer] else {
break
}
formattedTextPointer += 1
if newCursorPosition == nil,
formattedTextChar.char == rawTextChar.char,
rawTextChar.index == rawCursorPosition?.cursorPosition.rawValue {
newCursorPosition = CursorPosition(
rawValue: formattedTextChar.index
)
}
if formattedTextChar.char == rawTextChar.char {
rawData.append(
InputData.RawCharacter(
char: rawTextChar.char,
index: formattedTextChar.index
)
)
break
}
}
}
let resultInputData = InputData(
text: formattedText,
cursorPosition: newCursorPosition,
rawData: rawData
)
lastCorrectInput = resultInputData
return resultInputData
}
public func equals(_ other: any MaskFormatter) -> Bool {
guard let other = other as? CurrencyMaskFormatter else {
return false
}
return self.locale == other.locale
}
private func formattedValue(text: String) -> String? {
let decimalSeparator = locale.decimalSeparator ?? "."
formatter.minimumFractionDigits = 0
if let value = Int(text),
let formatted = formatter.string(from: NSNumber(value: value)) {
return formatted
}
if let value = Int(text.trimTrailingDecimalSeparator(decimalSeparator)),
let formatted = formatter.string(from: NSNumber(value: value)) {
return formatted + decimalSeparator
}
formatter.minimumFractionDigits = text.split(separator: decimalSeparator.first ?? ".").last?
.count ?? 0
if let value = Decimal(string: text, locale: locale) {
return formatter.string(from: value as NSNumber)
}
return nil
}
}
extension String {
fileprivate subscript(_ i: Int) -> InputData.RawCharacter? {
guard i >= 0, i < count else { return nil }
let index = self.index(startIndex, offsetBy: i)
return InputData.RawCharacter(
char: self[index],
index: index
)
}
fileprivate func trimTrailingDecimalSeparator(_ separator: String) -> String {
if hasSuffix(separator) {
return String(dropLast())
}
return self
}
}
@@ -19,7 +19,7 @@ public final class MaskValidator: Equatable {
}
public func formatted(rawText: String, rawCursorPosition: CursorData? = nil) -> InputData {
formatter.formatted(rawText: rawText, rawCursorPosition: rawCursorPosition)
self.formatter.formatted(rawText: rawText, rawCursorPosition: rawCursorPosition)
}
public func removeSymbols(at index: String.Index, data: InputData) -> (String, CursorData?) {
@@ -88,7 +88,7 @@ public final class MaskValidator: Equatable {
public enum CursorPositionTag {}
public typealias CursorPosition = Tagged<CursorPositionTag, String.Index>
public struct CursorData: Equatable {
public struct CursorData: Equatable, Hashable {
let cursorPosition: CursorPosition
let afterNonDecodingSymbols: Bool
}
@@ -0,0 +1,159 @@
@testable import LayoutKit
import Testing
import VGSL
import XCTest
@Suite
struct CurrencyMaskFormatterTests {
@Test(
arguments: [
("", "", ""),
("1", "1", "1"),
("12", "12", "12"),
("1234", "1,234", "1234"),
("1234.0", "1,234.0", "1234.0"),
("1234.06", "1,234.06", "1234.06"),
("1234.", "1,234.", "1234."),
("0.33", "0.33", "0.33"),
]
)
func rawTextFormattingUsLocal(
rawText: String, expectedText: String, expectedRawText: String
) {
let formatter = makeFormatter()
let result = formatter.formatted(rawText: rawText, rawCursorPosition: nil)
#expect(result.text == expectedText)
#expect(result.rawText == expectedRawText)
}
@Test(
arguments: [
"e4",
"053",
".95",
"345.0543",
"345.05.43"
]
)
func rawTextWrongFormattingUsLocal(
rawText: String
) {
let formatter = makeFormatter()
let result = formatter.formatted(rawText: rawText, rawCursorPosition: nil)
#expect(result.rawText == "")
}
@Test(
arguments: [
("", "", ""),
("1", "1", "1"),
("12", "12", "12"),
("1234", "1 234", "1234"),
("1234,0", "1 234,0", "1234,0"),
("1234,06", "1 234,06", "1234,06"),
("1234,", "1 234,", "1234,"),
("0,33", "0,33", "0,33")
]
)
func rawTextFormattingRusLocal(
rawText: String, expectedText: String, expectedRawText: String
) {
let formatter = makeFormatter(localIdentifier: "ru_RU")
let result = formatter.formatted(rawText: rawText, rawCursorPosition: nil)
#expect(result.text == expectedText)
#expect(result.rawText == expectedRawText)
}
@Test
func invalidInputReturnsLastCorrectValue() {
let formatter = makeFormatter()
let first = formatter.formatted(rawText: "1234", rawCursorPosition: nil)
#expect(first.text == "1,234")
let second = formatter.formatted(rawText: "12aa34", rawCursorPosition: nil)
#expect(second.text == "1,234")
#expect(second.rawText == "1234")
}
@Test
func rawData() {
let formatter = makeFormatter()
let rawText = "1234.5"
let text = "1 234.5"
let data = formatter.formatted(rawText: rawText, rawCursorPosition: nil).rawData
let expected: [InputData.RawCharacter] = [
.init(char: "1", index: text.index(text.startIndex, offsetBy: 0)),
.init(char: "2", index: text.index(text.startIndex, offsetBy: 2)),
.init(char: "3", index: text.index(text.startIndex, offsetBy: 3)),
.init(char: "4", index: text.index(text.startIndex, offsetBy: 4)),
.init(char: ".", index: text.index(text.startIndex, offsetBy: 5)),
.init(char: "5", index: text.index(text.startIndex, offsetBy: 6)),
]
#expect(data == expected)
}
@Test(
arguments: [
(0, 0),
(1, 2),
(3, 4),
(5, 7)
]
)
func cursorPositionWithoutFormattingSymbols(
rawCursor: Int, expectedCursor: Int
) {
let formatter = makeFormatter()
let rawText = "1234567"
let result = formatter.formatted(
rawText: rawText,
rawCursorPosition: .init(rawCursor, false)
)
let expected: CursorPosition = .init(integerLiteral: expectedCursor)
#expect(result.cursorPosition == expected)
}
@Test
func cursorPositionAtEnd() {
let formatter = makeFormatter()
let rawText = "1234.56"
let result = formatter.formatted(
rawText: rawText,
rawCursorPosition: .init(rawText.count, false)
)
#expect(result.cursorPosition?.rawValue == result.text.endIndex)
}
@Test
func cursorPositionAfterDecimalSeparator() {
let formatter = makeFormatter()
let rawText = "1234.5"
let result = formatter.formatted(
rawText: rawText,
rawCursorPosition: .init(5, false)
)
#expect(result.cursorPosition == 6)
}
}
private func makeFormatter(
localIdentifier: String = "en_US"
) -> CurrencyMaskFormatter {
CurrencyMaskFormatter(locale: localIdentifier)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -2,7 +2,8 @@
"description": "Currency input mask",
"platforms": [
"android",
"web"
"web",
"ios"
],
"div_data": {
"card": {
+2 -1
View File
@@ -2731,7 +2731,8 @@
"case_id": 106,
"platforms": [
"android",
"web"
"web",
"ios"
],
"tags": [
"DivInput"