BTUIKExpiryFormField.m 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. #import "BTUIKCardExpiryFormat.h"
  2. #import "BTUIKCardExpirationValidator.h"
  3. #import "BTUIKExpiryFormField.h"
  4. #import "BTUIKInputAccessoryToolbar.h"
  5. #import "BTUIKLocalizedString.h"
  6. #import "BTUIKTextField.h"
  7. #import "BTUIKUtil.h"
  8. #define BTUIKCardExpiryFieldYYYYPrefix @"20"
  9. #define BTUIKCardExpiryFieldComponentSeparator @"/"
  10. #define BTUIKCardExpiryPlaceholderFourDigitYear BTUIKLocalizedString(EXPIRY_PLACEHOLDER_FOUR_DIGIT_YEAR)
  11. #define BTUIKCardExpiryPlaceholderTwoDigitYear BTUIKLocalizedString(EXPIRY_PLACEHOLDER_TWO_DIGIT_YEAR)
  12. @interface BTUIKExpiryFormField ()
  13. @property (nonatomic, strong) BTUIKExpiryInputView *expiryInputView;
  14. @end
  15. @implementation BTUIKExpiryFormField
  16. - (instancetype)initWithFrame:(CGRect)frame {
  17. self = [super initWithFrame:frame];
  18. if (self) {
  19. self.textField.accessibilityLabel = BTUIKLocalizedString(EXPIRATION_DATE_LABEL);
  20. self.formLabel.text = BTUIKLocalizedString(EXPIRATION_DATE_LABEL);
  21. [self updatePlaceholder];
  22. self.expiryInputView = [BTUIKExpiryInputView new];
  23. self.expiryInputView.delegate = self;
  24. // Use custom date picker, but fall back to number pad keyboard if inputView is set to nil
  25. self.textField.keyboardType = UIKeyboardTypeNumberPad;
  26. self.textField.inputView = self.expiryInputView;
  27. }
  28. return self;
  29. }
  30. #pragma mark - Custom accessors
  31. - (void)setExpirationDate:(NSString *)expirationDate {
  32. [self setText:expirationDate];
  33. }
  34. - (NSString *)expirationDate {
  35. if (!self.expirationMonth || !self.expirationYear) return nil;
  36. return [NSString stringWithFormat:@"%@%@", self.expirationMonth, self.expirationYear];
  37. }
  38. - (BOOL)valid {
  39. if (!self.expirationYear || !self.expirationMonth) {
  40. return NO;
  41. }
  42. return [BTUIKCardExpirationValidator month:self.expirationMonth.intValue year:self.expirationYear.intValue validForDate:[NSDate date]];
  43. }
  44. #pragma mark - Private methods
  45. - (void)updatePlaceholder {
  46. NSString *placeholder = BTUIKLocalizedString(EXPIRY_PLACEHOLDER_FOUR_DIGIT_YEAR);
  47. [self setThemedPlaceholder:placeholder];
  48. }
  49. - (void)kernExpiration:(NSMutableAttributedString *)input {
  50. CGFloat kerningValue = 4;
  51. [input removeAttribute:NSKernAttributeName range:NSMakeRange(0, input.length)];
  52. [input beginEditing];
  53. if (input.length > 2) {
  54. [input addAttribute:NSKernAttributeName value:@(kerningValue) range:NSMakeRange(1, 1)];
  55. if (input.length > 3) {
  56. [input addAttribute:NSKernAttributeName value:@(kerningValue) range:NSMakeRange(2, 1)];
  57. }
  58. }
  59. [input endEditing];
  60. }
  61. - (void)setThemedPlaceholder:(NSString *)placeholder {
  62. NSMutableAttributedString *attributedPlaceholder = [[NSMutableAttributedString alloc] initWithString:placeholder ?: @""
  63. attributes:@{}];
  64. [self kernExpiration:attributedPlaceholder];
  65. self.textField.placeholder = placeholder;
  66. }
  67. #pragma mark - Helpers
  68. - (BOOL)dateCouldEndWithFourDigitYear:(NSString *)expirationDate {
  69. NSArray *expirationComponents = [expirationDate componentsSeparatedByString:BTUIKCardExpiryFieldComponentSeparator];
  70. NSString *yearComponent = [expirationComponents count] >= 2 ? expirationComponents[1] : nil;
  71. return (yearComponent && yearComponent.length >= 2 && [[yearComponent substringToIndex:2] isEqualToString:BTUIKCardExpiryFieldYYYYPrefix]);
  72. }
  73. // Returns YES if date is either a valid date or can have digits appended to make one. It does not contain any expiration
  74. // date validation.
  75. - (BOOL)dateIsValid:(NSString *)date {
  76. NSArray *dateComponents = [date componentsSeparatedByString:BTUIKCardExpiryFieldComponentSeparator];
  77. NSString *yearComponent;
  78. if (dateComponents.count >= 2) {
  79. yearComponent = dateComponents[1];
  80. } else {
  81. yearComponent = date.length >= 4 ? [date substringWithRange:NSMakeRange(2, date.length - 2)] : nil;
  82. }
  83. BOOL couldEndWithFourDigitYear = yearComponent && yearComponent.length >= 2 && [[yearComponent substringToIndex:2] isEqualToString:BTUIKCardExpiryFieldYYYYPrefix];
  84. if (couldEndWithFourDigitYear ? date.length > 7 : date.length > 5) {
  85. return NO;
  86. }
  87. NSString *updatedNumberText = [BTUIKUtil stripNonDigits:date];
  88. NSString *monthStr = [updatedNumberText substringToIndex:MIN((NSUInteger)2, updatedNumberText.length)];
  89. if (monthStr.length > 0) {
  90. NSInteger month = [monthStr integerValue];
  91. if(month < 0 || 12 < month) {
  92. return NO;
  93. }
  94. if(monthStr.length >= 2 && month == 0) {
  95. return NO;
  96. }
  97. }
  98. return YES;
  99. }
  100. #pragma mark - Protocol conformance
  101. #pragma mark UITextFieldDelegate
  102. - (void)fieldContentDidChange {
  103. _expirationMonth = nil;
  104. _expirationYear = nil;
  105. NSString *formattedValue;
  106. NSUInteger formattedCursorLocation;
  107. BTUIKCardExpiryFormat *format = [[BTUIKCardExpiryFormat alloc] init];
  108. format.value = self.textField.text;
  109. format.cursorLocation = [self.textField offsetFromPosition:self.textField.beginningOfDocument toPosition:self.textField.selectedTextRange.start];
  110. format.backspace = self.backspace;
  111. [format formattedValue:&formattedValue cursorLocation:&formattedCursorLocation];
  112. // Important: Reset the state of self.backspace.
  113. // Otherwise, the user won't be able to do the following:
  114. // Enter "11/16", then backspace to
  115. // "1", and then type e.g. "2". Instead of showing:
  116. // "12/" (as it should), the form would instead remain stuck at
  117. // "1".
  118. self.backspace = NO;
  119. // This is because UIControlEventEditingChanged is *not* sent after the "/" is removed.
  120. // We can't trigger UIControlEventEditingChanged here (after removing a "/") because that would cause an infinite loop.
  121. NSMutableAttributedString *result = [[NSMutableAttributedString alloc] initWithString:formattedValue];
  122. [self kernExpiration:result];
  123. self.textField.attributedText = result;
  124. UITextPosition *newPosition = [self.textField positionFromPosition:self.textField.beginningOfDocument offset:formattedCursorLocation];
  125. UITextRange *newRange = [self.textField textRangeFromPosition:newPosition toPosition:newPosition];
  126. self.textField.selectedTextRange = newRange;
  127. NSArray *expirationComponents = [self.textField.text componentsSeparatedByString:BTUIKCardExpiryFieldComponentSeparator];
  128. if(expirationComponents.count == 2 && (self.textField.text.length == 3 || self.textField.text.length == 5 || self.textField.text.length == 7)) {
  129. _expirationMonth = expirationComponents[0];
  130. _expirationYear = expirationComponents[1];
  131. }
  132. [self updatePlaceholder];
  133. self.displayAsValid = ((self.textField.text.length != 5 && self.textField.text.length != 7) || self.valid);
  134. [self.delegate formFieldDidChange:self];
  135. }
  136. - (void)textFieldDidBeginEditing:(UITextField *)textField {
  137. self.expiryInputView.selectedYear = self.expirationYear.intValue;
  138. self.expiryInputView.selectedMonth = self.expirationMonth.intValue;
  139. [super textFieldDidBeginEditing:textField];
  140. self.displayAsValid = YES;
  141. }
  142. - (void)textFieldDidEndEditing:(UITextField *)textField {
  143. [super textFieldDidEndEditing:textField];
  144. self.displayAsValid = self.textField.text.length == 0 || self.valid;
  145. }
  146. - (BOOL)textField:(UITextField *)textField shouldChangeCharactersInRange:(NSRange)range replacementString:(NSString *)newText {
  147. NSString *numericNewText = [BTUIKUtil stripNonDigits:newText];
  148. if (![numericNewText isEqualToString:newText]) {
  149. return NO;
  150. }
  151. NSString *updatedText = [textField.text stringByReplacingCharactersInRange:range withString:numericNewText];
  152. return [self dateIsValid:updatedText];
  153. }
  154. - (BOOL)entryComplete {
  155. return [super entryComplete] && ![self.expirationYear isEqualToString:BTUIKCardExpiryFieldYYYYPrefix];
  156. }
  157. #pragma mark BTUIKExpiryInputViewDelegate
  158. - (void)expiryInputViewDidChange:(BTUIKExpiryInputView *)expiryInputView {
  159. if (expiryInputView.selectedYear > 0) {
  160. self.expirationDate = [NSString stringWithFormat:@"%02li%04li", (long)expiryInputView.selectedMonth, (long)expiryInputView.selectedYear];
  161. } else {
  162. self.expirationDate = [NSString stringWithFormat:@"%02li", (long)expiryInputView.selectedMonth];
  163. }
  164. }
  165. @end