diff --git a/Stripe/PublicHeaders/STPPaymentCardTextField.h b/Stripe/PublicHeaders/STPPaymentCardTextField.h index 976cd04b5b3..03ded33ce4c 100644 --- a/Stripe/PublicHeaders/STPPaymentCardTextField.h +++ b/Stripe/PublicHeaders/STPPaymentCardTextField.h @@ -332,6 +332,20 @@ The curent brand image displayed in the receiver. */ - (void)paymentCardTextFieldDidBeginEditing:(nonnull STPPaymentCardTextField *)textField; +/** + Notification that the user pressed the `return` key after completely filling + out the STPPaymentCardTextField with data that passes validation. + + The Stripe SDK is going to `resignFirstResponder` on the `STPPaymentCardTextField` + to dismiss the keyboard after this delegate method returns, however if your app wants + to do something more (ex: move first responder to another field), this is a good + opportunity to do that. + + This is delivered *before* the corresponding `paymentCardTextFieldDidEndEditing:` + + @param textField The STPPaymentCardTextField that was being edited when the user pressed return + */ +- (void)paymentCardTextFieldWillEndEditingForReturn:(nonnull STPPaymentCardTextField *)textField; /** Called when editing ends in the text field as a whole. diff --git a/Stripe/STPAddCardViewController.m b/Stripe/STPAddCardViewController.m index 0851968d9eb..697b8b5e329 100644 --- a/Stripe/STPAddCardViewController.m +++ b/Stripe/STPAddCardViewController.m @@ -378,6 +378,10 @@ - (void)paymentFieldNextTapped { [[self.addressViewModel.addressCells stp_boundSafeObjectAtIndex:0] becomeFirstResponder]; } +- (void)paymentCardTextFieldWillEndEditingForReturn:(__unused STPPaymentCardTextField *)textField { + [self paymentFieldNextTapped]; +} + - (void)paymentCardTextFieldDidBeginEditingCVC:(STPPaymentCardTextField *)textField { BOOL isAmex = [STPCardValidator brandForNumber:textField.cardNumber] == STPCardBrandAmex; UIImage *newImage; diff --git a/Stripe/STPPaymentCardTextField.m b/Stripe/STPPaymentCardTextField.m index 01a4fedc917..0e1f2a92859 100644 --- a/Stripe/STPPaymentCardTextField.m +++ b/Stripe/STPPaymentCardTextField.m @@ -471,13 +471,20 @@ - (BOOL)becomeFirstResponder { return [firstResponder becomeFirstResponder]; } +/** + Returns the next text field to be edited, in priority order: + + 1. If we're currently in a text field, returns the next one (ignoring postalCodeField if postalCodeEntryEnabled == NO) + 2. Otherwise, returns the first invalid field (either cycling back from the end or as it gains 1st responder) + 3. As a final fallback, just returns the last field + */ - (nonnull STPFormTextField *)nextFirstResponderField { STPFormTextField *currentFirstResponder = [self currentFirstResponderField]; if (currentFirstResponder) { NSUInteger index = [self.allFields indexOfObject:currentFirstResponder]; if (index != NSNotFound) { STPFormTextField *nextField = [self.allFields stp_boundSafeObjectAtIndex:index + 1]; - if (self.postalCodeEntryEnabled || nextField != self.postalCodeField) { + if (nextField != nil && (self.postalCodeEntryEnabled || nextField != self.postalCodeField)) { return nextField; } } @@ -1322,6 +1329,14 @@ - (void)formTextFieldTextDidChange:(STPFormTextField *)formTextField { if (sanitizedCvc.length < [STPCardValidator maxCVCLengthForCardBrand:self.viewModel.brand]) { break; } + } else if (fieldType == STPCardFieldTypePostalCode) { + /* + Similar to the UX problems on CVC, since our Postal Code validation + is pretty light, we want to block auto-advance here. In the US, this + allows users to enter 9 digit zips if they want, and as many as they + need in non-US countries (where >0 characters is "valid") + */ + break; } // This is a no-op if this is the last field & they're all valid @@ -1478,6 +1493,21 @@ - (void)textFieldDidEndEditing:(UITextField *)textField { } } +- (BOOL)textFieldShouldReturn:(UITextField *)textField { + if (textField == [self lastSubField] && [self firstInvalidSubField] == nil) { + // User pressed return in the last field, and all fields are valid + if ([self.delegate respondsToSelector:@selector(paymentCardTextFieldWillEndEditingForReturn:)]) { + [self.delegate paymentCardTextFieldWillEndEditingForReturn:self]; + } + [self resignFirstResponder]; + } else { + // otherwise, move to the next field + [[self nextFirstResponderField] becomeFirstResponder]; + } + + return NO; +} + - (UIImage *)brandImage { STPCardFieldType fieldType = STPCardFieldTypeNumber; if (self.currentFirstResponderField) { diff --git a/Tests/Tests/STPPaymentCardTextFieldTest.m b/Tests/Tests/STPPaymentCardTextFieldTest.m index 2a4369cc28c..1688ae9ede4 100644 --- a/Tests/Tests/STPPaymentCardTextFieldTest.m +++ b/Tests/Tests/STPPaymentCardTextFieldTest.m @@ -33,11 +33,25 @@ + (UIImage *)brandImageForCardBrand:(STPCardBrand)cardBrand; */ @interface PaymentCardTextFieldBlockDelegate: NSObject @property (nonatomic, strong, nullable) void (^didChange)(STPPaymentCardTextField *); +@property (nonatomic, strong, nullable) void (^willEndEditingForReturn)(STPPaymentCardTextField *); +@property (nonatomic, strong, nullable) void (^didEndEditing)(STPPaymentCardTextField *); // add more properties for other delegate methods as this test needs them @end @implementation PaymentCardTextFieldBlockDelegate - (void)paymentCardTextFieldDidChange:(STPPaymentCardTextField *)textField { - self.didChange(textField); + if (self.didChange) { + self.didChange(textField); + } +} +- (void)paymentCardTextFieldWillEndEditingForReturn:(STPPaymentCardTextField *)textField { + if (self.willEndEditingForReturn) { + self.willEndEditingForReturn(textField); + } +} +- (void)paymentCardTextFieldDidEndEditing:(STPPaymentCardTextField *)textField { + if (self.didEndEditing) { + self.didEndEditing(textField); + } } @end @@ -600,4 +614,54 @@ - (void)testBecomeFirstResponder { @"When all fields are valid, the last one should be the preferred firstResponder"); } +- (void)testShouldReturnCyclesThroughFields { + PaymentCardTextFieldBlockDelegate *delegate = [PaymentCardTextFieldBlockDelegate new]; + delegate.willEndEditingForReturn = ^(__unused STPPaymentCardTextField *textField) { + XCTFail(@"Did not expect editing to end in this test"); + }; + self.sut.delegate = delegate; + + [self.sut becomeFirstResponder]; + XCTAssertTrue(self.sut.numberField.isFirstResponder); + + XCTAssertFalse([self.sut.numberField.delegate textFieldShouldReturn:self.sut.numberField], @"shouldReturn = NO"); + XCTAssertTrue(self.sut.expirationField.isFirstResponder, @"with side effect to move 1st responder to next field"); + + XCTAssertFalse([self.sut.expirationField.delegate textFieldShouldReturn:self.sut.expirationField], @"shouldReturn = NO"); + XCTAssertTrue(self.sut.cvcField.isFirstResponder, @"with side effect to move 1st responder to next field"); + + XCTAssertFalse([self.sut.cvcField.delegate textFieldShouldReturn:self.sut.cvcField], @"shouldReturn = NO"); + XCTAssertTrue(self.sut.numberField.isFirstResponder, @"with side effect to move 1st responder from last field to first invalid field"); +} + +- (void)testShouldReturnDismissesWhenValid { + __block BOOL hasReturned = NO; + __block BOOL didEnd = NO; + + [self.sut setCardParams:[STPFixtures cardParams]]; + + PaymentCardTextFieldBlockDelegate *delegate = [PaymentCardTextFieldBlockDelegate new]; + delegate.willEndEditingForReturn = ^(__unused STPPaymentCardTextField *textField) { + XCTAssertFalse(didEnd, @"willEnd is called before didEnd"); + XCTAssertFalse(hasReturned, @"willEnd is only called once"); + hasReturned = YES; + }; + delegate.didEndEditing = ^(__unused STPPaymentCardTextField *textField) { + XCTAssertTrue(hasReturned, @"didEndEditing should be called after willEnd"); + XCTAssertFalse(didEnd, @"didEnd is only called once"); + didEnd = YES; + }; + + self.sut.delegate = delegate; + [self.sut becomeFirstResponder]; + XCTAssertTrue(self.sut.cvcField.isFirstResponder, @"when textfield is filled out, default first responder is the last field"); + + XCTAssertFalse(hasReturned, @"willEndEditingForReturn delegate method should not have been called yet"); + XCTAssertFalse([self.sut.cvcField.delegate textFieldShouldReturn:self.sut.cvcField], @"shouldReturn = NO"); + + XCTAssertNil(self.sut.currentFirstResponderField, @"Should have resigned first responder"); + XCTAssertTrue(hasReturned, @"delegate method has been invoked"); + XCTAssertTrue(didEnd, @"delegate method has been invoked"); +} + @end