TOPasscodeInputField.m 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423
  1. //
  2. // TOPasscodeInputField.m
  3. //
  4. // Copyright 2017 Timothy Oliver. All rights reserved.
  5. //
  6. // Permission is hereby granted, free of charge, to any person obtaining a copy
  7. // of this software and associated documentation files (the "Software"), to
  8. // deal in the Software without restriction, including without limitation the
  9. // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
  10. // sell copies of the Software, and to permit persons to whom the Software is
  11. // furnished to do so, subject to the following conditions:
  12. //
  13. // The above copyright notice and this permission notice shall be included in
  14. // all copies or substantial portions of the Software.
  15. //
  16. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
  17. // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  18. // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  19. // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
  20. // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
  21. // IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  22. #import "TOPasscodeInputField.h"
  23. #import "TOPasscodeVariableInputView.h"
  24. #import "TOPasscodeFixedInputView.h"
  25. #import <AudioToolbox/AudioToolbox.h>
  26. @interface TOPasscodeInputField ()
  27. // Convenience getters
  28. @property (nonatomic, readonly) UIView *inputField; // Returns whichever input field is currently visible
  29. @property (nonatomic, readonly) NSInteger maximumPasscodeLength; // The mamximum number of characters allowed (0 if uncapped)
  30. @property (nonatomic, strong, readwrite) TOPasscodeFixedInputView *fixedInputView;
  31. @property (nonatomic, strong, readwrite) TOPasscodeVariableInputView *variableInputView;
  32. @property (nonatomic, strong, readwrite) UIButton *submitButton;
  33. @property (nonatomic, strong, readwrite) UIVisualEffectView *visualEffectView;
  34. @end
  35. @implementation TOPasscodeInputField
  36. #pragma mark - View Set-up -
  37. - (instancetype)initWithFrame:(CGRect)frame
  38. {
  39. if (self = [super initWithFrame:frame]) {
  40. [self setUp];
  41. [self setUpForStyle:TOPasscodeInputFieldStyleFixed];
  42. }
  43. return self;
  44. }
  45. - (instancetype)initWithStyle:(TOPasscodeInputFieldStyle)style
  46. {
  47. if (self = [self initWithFrame:CGRectZero]) {
  48. _style = style;
  49. [self setUp];
  50. [self setUpForStyle:style];
  51. }
  52. return self;
  53. }
  54. - (void)setUp
  55. {
  56. self.backgroundColor = [UIColor clearColor];
  57. _submitButtonSpacing = 4.0f;
  58. _submitButtonVerticalSpacing = 5.0f;
  59. _visualEffectView = [[UIVisualEffectView alloc] initWithEffect:nil];
  60. [self addSubview:_visualEffectView];
  61. }
  62. - (void)setUpForStyle:(TOPasscodeInputFieldStyle)style
  63. {
  64. if (self.inputField) {
  65. [self.inputField removeFromSuperview];
  66. self.variableInputView = nil;
  67. self.fixedInputView = nil;
  68. }
  69. if (style == TOPasscodeInputFieldStyleVariable) {
  70. self.variableInputView = [[TOPasscodeVariableInputView alloc] init];
  71. [self.visualEffectView.contentView addSubview:self.variableInputView];
  72. }
  73. else {
  74. self.fixedInputView = [[TOPasscodeFixedInputView alloc] init];
  75. [self.visualEffectView.contentView addSubview:self.fixedInputView];
  76. }
  77. // Set the frame for the currently visible input view
  78. [self.inputField sizeToFit];
  79. // Size this view to match
  80. [self sizeToFit];
  81. }
  82. #pragma mark - View Layout -
  83. - (void)sizeToFit
  84. {
  85. // Resize the view to encompass the current input view
  86. CGRect frame = self.frame;
  87. [self.inputField sizeToFit];
  88. frame.size = self.inputField.frame.size;
  89. if (self.horizontalLayout) {
  90. frame.size.height += self.submitButtonVerticalSpacing + CGRectGetHeight(self.submitButton.frame);
  91. }
  92. self.frame = CGRectIntegral(frame);
  93. }
  94. - (void)layoutSubviews
  95. {
  96. [super layoutSubviews];
  97. self.visualEffectView.frame = self.inputField.bounds;
  98. if (!self.submitButton) { return; }
  99. [self.submitButton sizeToFit];
  100. [self bringSubviewToFront:self.submitButton];
  101. CGRect frame = self.submitButton.frame;
  102. if (!self.horizontalLayout) {
  103. frame.origin.x = CGRectGetMaxX(self.bounds) + self.submitButtonSpacing;
  104. frame.origin.y = (CGRectGetHeight(self.bounds) - CGRectGetHeight(frame)) * 0.5f;
  105. }
  106. else {
  107. frame.origin.x = (CGRectGetWidth(self.frame) - frame.size.width) * 0.5f;
  108. frame.origin.y = CGRectGetMaxY(self.inputField.frame) + self.submitButtonVerticalSpacing;
  109. }
  110. self.submitButton.frame = CGRectIntegral(frame);
  111. }
  112. #pragma mark - Interaction -
  113. - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
  114. {
  115. [super touchesBegan:touches withEvent:event];
  116. if (!self.enabled) { return; }
  117. self.contentAlpha = 0.5f;
  118. }
  119. - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
  120. {
  121. [super touchesCancelled:touches withEvent:event];
  122. if (!self.enabled) { return; }
  123. [UIView animateWithDuration:0.3f animations:^{
  124. self.contentAlpha = 1.0f;
  125. }];
  126. [self becomeFirstResponder];
  127. }
  128. - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event
  129. {
  130. CGRect frame = self.bounds;
  131. frame.size.width += self.submitButton.frame.size.width + (self.submitButtonSpacing * 2.0f);
  132. frame.size.height += self.submitButtonVerticalSpacing;
  133. if (CGRectContainsPoint(frame, point)) {
  134. return YES;
  135. }
  136. return NO;
  137. }
  138. - (id)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
  139. if ([[super hitTest:point withEvent:event] isEqual:self.submitButton]) {
  140. if (CGRectContainsPoint(self.submitButton.frame, point)) {
  141. return self.submitButton;
  142. } else {
  143. return self;
  144. }
  145. }
  146. return [super hitTest:point withEvent:event];
  147. }
  148. #pragma mark - Text Input Protocol -
  149. - (BOOL)canBecomeFirstResponder { return self.enabled; }
  150. - (BOOL)hasText { return self.passcode.length > 0; }
  151. - (void)insertText:(NSString *)text
  152. {
  153. if ([text isEqualToString:@"\n"]) {
  154. if (self.passcodeCompletedHandler) { self.passcodeCompletedHandler(self.passcode); }
  155. return;
  156. }
  157. [self appendPasscodeCharacters:text animated:NO];
  158. }
  159. - (void)deleteBackward
  160. {
  161. [self deletePasscodeCharactersOfCount:1 animated:YES];
  162. }
  163. - (UIKeyboardType)keyboardType { return UIKeyboardTypeASCIICapable; }
  164. - (UITextAutocorrectionType)autocorrectionType { return UITextAutocorrectionTypeNo; }
  165. - (UIReturnKeyType)returnKeyType { return UIReturnKeyGo; }
  166. - (BOOL)enablesReturnKeyAutomatically { return YES; }
  167. #pragma mark - Text Input -
  168. - (void)setPasscode:(NSString *)passcode animated:(BOOL)animated
  169. {
  170. if (passcode == self.passcode) { return; }
  171. _passcode = passcode;
  172. BOOL passcodeIsComplete = NO;
  173. if (self.fixedInputView) {
  174. [self.fixedInputView setHighlightedLength:_passcode.length animated:animated];
  175. passcodeIsComplete = _passcode.length >= self.maximumPasscodeLength;
  176. }
  177. else {
  178. [self.variableInputView setLength:_passcode.length animated:animated];
  179. }
  180. if (self.submitButton) {
  181. self.submitButton.hidden = (_passcode.length == 0);
  182. [self bringSubviewToFront:self.submitButton];
  183. }
  184. if (passcodeIsComplete && self.passcodeCompletedHandler) {
  185. self.passcodeCompletedHandler(_passcode);
  186. }
  187. [self reloadInputViews];
  188. }
  189. - (void)appendPasscodeCharacters:(NSString *)characters animated:(BOOL)animated
  190. {
  191. if (characters == nil) { return; }
  192. if (self.maximumPasscodeLength > 0 && self.passcode.length >= self.maximumPasscodeLength) { return; }
  193. if (_passcode == nil) { _passcode = @""; }
  194. [self setPasscode:[_passcode stringByAppendingString:characters] animated:animated];
  195. }
  196. - (void)deletePasscodeCharactersOfCount:(NSInteger)deleteCount animated:(BOOL)animated
  197. {
  198. if (deleteCount <= 0 || self.passcode.length <= 0) { return; }
  199. [self setPasscode:[self.passcode substringToIndex:(self.passcode.length - 1)] animated:animated];
  200. }
  201. - (void)resetPasscodeAnimated:(BOOL)animated playImpact:(BOOL)impact
  202. {
  203. [self setPasscode:nil animated:animated];
  204. // Play a negative impact effect
  205. if (@available(iOS 9.0, *)) {
  206. // https://stackoverflow.com/questions/41444274/how-to-check-if-haptic-engine-uifeedbackgenerator-is-supported
  207. if (impact) { AudioServicesPlaySystemSoundWithCompletion(1521, nil); }
  208. }
  209. if (!animated) { return; }
  210. CGPoint center = self.center;
  211. CGPoint offset = center;
  212. offset.x -= self.frame.size.width * 0.3f;
  213. // Play the view sliding out and then springing back in
  214. id completionBlock = ^(BOOL finished) {
  215. [UIView animateWithDuration:1.0f
  216. delay:0.0f
  217. usingSpringWithDamping:0.15f
  218. initialSpringVelocity:10.0f
  219. options:0 animations:^{
  220. self.center = center;
  221. }completion:nil];
  222. };
  223. [UIView animateWithDuration:0.05f animations:^{
  224. self.center = offset;
  225. }completion:completionBlock];
  226. if (!self.submitButton) { return; }
  227. [UIView animateWithDuration:0.7f animations:^{
  228. self.submitButton.alpha = 0.0f;
  229. } completion:^(BOOL complete) {
  230. self.submitButton.alpha = 1.0f;
  231. self.submitButton.hidden = YES;
  232. }];
  233. }
  234. #pragma mark - Button Callbacks -
  235. - (void)submitButtonTapped:(id)sender
  236. {
  237. if (self.passcodeCompletedHandler) {
  238. self.passcodeCompletedHandler(self.passcode);
  239. }
  240. }
  241. #pragma mark - Private Accessors -
  242. - (UIView *)inputField
  243. {
  244. if (self.fixedInputView) {
  245. return (UIView *)self.fixedInputView;
  246. }
  247. return (UIView *)self.variableInputView;
  248. }
  249. - (NSInteger)maximumPasscodeLength
  250. {
  251. if (self.style == TOPasscodeInputFieldStyleFixed) {
  252. return self.fixedInputView.length;
  253. }
  254. return 0;
  255. }
  256. #pragma mark - Public Accessors -
  257. - (void)setShowSubmitButton:(BOOL)showSubmitButton
  258. {
  259. if (_showSubmitButton == showSubmitButton) {
  260. return;
  261. }
  262. _showSubmitButton = showSubmitButton;
  263. if (!_showSubmitButton) {
  264. [self.submitButton removeFromSuperview];
  265. self.submitButton = nil;
  266. return;
  267. }
  268. self.submitButton = [UIButton buttonWithType:UIButtonTypeSystem];
  269. [self.submitButton setTitle:@"OK" forState:UIControlStateNormal];
  270. [self.submitButton addTarget:self action:@selector(submitButtonTapped:) forControlEvents:UIControlEventTouchUpInside];
  271. [self.submitButton.titleLabel setFont:[UIFont systemFontOfSize:18.0f]];
  272. self.submitButton.hidden = YES;
  273. [self addSubview:self.submitButton];
  274. [self setNeedsLayout];
  275. }
  276. - (void)setSubmitButtonSpacing:(CGFloat)submitButtonSpacing
  277. {
  278. if (submitButtonSpacing == _submitButtonSpacing) { return; }
  279. _submitButtonSpacing = submitButtonSpacing;
  280. [self setNeedsLayout];
  281. }
  282. - (void)setSubmitButtonFontSize:(CGFloat)submitButtonFontSize
  283. {
  284. if (submitButtonFontSize == _submitButtonFontSize) { return; }
  285. _submitButtonFontSize = submitButtonFontSize;
  286. self.submitButton.titleLabel.font = [UIFont systemFontOfSize:_submitButtonFontSize];
  287. [self.submitButton sizeToFit];
  288. [self setNeedsLayout];
  289. }
  290. - (void)setStyle:(TOPasscodeInputFieldStyle)style
  291. {
  292. if (style == _style) { return; }
  293. _style = style;
  294. [self setUpForStyle:_style];
  295. }
  296. - (void)setPasscode:(NSString *)passcode
  297. {
  298. [self setPasscode:passcode animated:NO];
  299. }
  300. - (void)setContentAlpha:(CGFloat)contentAlpha
  301. {
  302. _contentAlpha = contentAlpha;
  303. self.inputField.alpha = contentAlpha;
  304. self.submitButton.alpha = contentAlpha;
  305. }
  306. - (void)setHorizontalLayout:(BOOL)horizontalLayout
  307. {
  308. [self setHorizontalLayout:horizontalLayout animated:NO duration:0.0f];
  309. }
  310. - (void)setHorizontalLayout:(BOOL)horizontalLayout animated:(BOOL)animated duration:(CGFloat)duration
  311. {
  312. if (_horizontalLayout == horizontalLayout) {
  313. return;
  314. }
  315. UIView *snapshotView = nil;
  316. if (self.submitButton && self.submitButton.hidden == NO && animated) {
  317. snapshotView = [self.submitButton snapshotViewAfterScreenUpdates:NO];
  318. snapshotView.frame = self.submitButton.frame;
  319. [self addSubview:snapshotView];
  320. }
  321. _horizontalLayout = horizontalLayout;
  322. if (!animated || !self.submitButton) {
  323. [self sizeToFit];
  324. [self setNeedsLayout];
  325. return;
  326. }
  327. self.submitButton.alpha = 0.0f;
  328. [self setNeedsLayout];
  329. [self layoutIfNeeded];
  330. id animationBlock = ^{
  331. self.submitButton.alpha = 1.0f;
  332. snapshotView.alpha = 0.0f;
  333. };
  334. id completionBlock = ^(BOOL complete) {
  335. [snapshotView removeFromSuperview];
  336. [self bringSubviewToFront:self.submitButton];
  337. };
  338. [UIView animateWithDuration:duration animations:animationBlock completion:completionBlock];
  339. }
  340. @end