// // KBBackspaceLongPressHandler.m // CustomKeyboard // #import "KBBackspaceLongPressHandler.h" #import "KBResponderUtils.h" #import "KBSkinManager.h" #import "KBBackspaceUndoManager.h" static const NSTimeInterval kKBBackspaceLongPressMinDuration = 0.35; static const NSTimeInterval kKBBackspaceRepeatInterval = 0.06; static const NSTimeInterval kKBBackspaceChunkStartDelay = 1.0; static const NSTimeInterval kKBBackspaceChunkRepeatInterval = 0.1; static const NSTimeInterval kKBBackspaceChunkFastDelay = 1.4; static const NSInteger kKBBackspaceChunkSize = 6; static const NSInteger kKBBackspaceChunkSizeFast = 12; static const CGFloat kKBBackspaceClearLabelCornerRadius = 8.0; static const CGFloat kKBBackspaceClearLabelHeight = 26.0; static const CGFloat kKBBackspaceClearLabelPaddingX = 10.0; static const CGFloat kKBBackspaceClearLabelTopGap = 6.0; static const CGFloat kKBBackspaceClearLabelHorizontalInset = 6.0; static const NSInteger kKBBackspaceClearBatchSize = 24; static const NSTimeInterval kKBBackspaceClearBatchInterval = 0.005; static const NSInteger kKBBackspaceClearMaxDeletes = 10000; static const NSInteger kKBBackspaceClearEmptyContextMaxRounds = 40; static const NSInteger kKBBackspaceClearMaxStep = 80; typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) { KBBackspaceChunkClassUnknown = 0, KBBackspaceChunkClassWhitespace, KBBackspaceChunkClassASCIIWord, KBBackspaceChunkClassPunctuation, KBBackspaceChunkClassOther }; @interface KBBackspaceLongPressHandler () @property (nonatomic, weak) UIView *containerView; @property (nonatomic, weak) UIView *backspaceButton; @property (nonatomic, strong) UILongPressGestureRecognizer *longPress; @property (nonatomic, assign) BOOL showClearLabelEnabled; @property (nonatomic, assign) BOOL backspaceHoldActive; @property (nonatomic, assign) NSTimeInterval backspaceHoldStartTime; @property (nonatomic, assign) BOOL backspaceChunkModeActive; @property (nonatomic, assign) BOOL backspaceClearHighlighted; @property (nonatomic, assign) NSUInteger backspaceHoldToken; @property (nonatomic, assign) BOOL backspaceHasLastTouchPoint; @property (nonatomic, assign) CGPoint backspaceLastTouchPointInSelf; @property (nonatomic, assign) NSUInteger backspaceClearToken; @property (nonatomic, strong) UILabel *backspaceClearLabel; @end @implementation KBBackspaceLongPressHandler - (instancetype)initWithContainerView:(UIView *)containerView { if (self = [super init]) { _containerView = containerView; } return self; } - (void)bindDeleteButton:(UIView *)button showClearLabel:(BOOL)showClearLabel { if (self.backspaceButton == button) { return; } if (self.longPress && self.backspaceButton) { [self.backspaceButton removeGestureRecognizer:self.longPress]; } self.backspaceButton = button; self.showClearLabelEnabled = showClearLabel; self.backspaceHoldActive = NO; self.backspaceChunkModeActive = NO; self.backspaceClearHighlighted = NO; self.backspaceHasLastTouchPoint = NO; self.backspaceHoldToken += 1; [self kb_hideBackspaceClearLabel]; if (!button) { return; } self.longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(onBackspaceLongPress:)]; self.longPress.minimumPressDuration = kKBBackspaceLongPressMinDuration; self.longPress.allowableMovement = CGFLOAT_MAX; self.longPress.cancelsTouchesInView = YES; [button addGestureRecognizer:self.longPress]; } - (void)performClearAction { [self kb_clearAllInput]; } #pragma mark - Long Press - (void)onBackspaceLongPress:(UILongPressGestureRecognizer *)gr { UIView *hostView = [self kb_hostView]; if (!hostView) { return; } if (gr) { self.backspaceLastTouchPointInSelf = [gr locationInView:hostView]; self.backspaceHasLastTouchPoint = YES; } switch (gr.state) { case UIGestureRecognizerStateBegan: { [[KBBackspaceUndoManager shared] registerNonClearAction]; self.backspaceHoldToken += 1; NSUInteger token = self.backspaceHoldToken; self.backspaceHoldActive = YES; self.backspaceHoldStartTime = [NSDate date].timeIntervalSinceReferenceDate; self.backspaceChunkModeActive = NO; [self kb_setBackspaceClearHighlighted:NO]; [self kb_hideBackspaceClearLabel]; if (self.showClearLabelEnabled) { [self kb_showBackspaceClearLabelIfNeeded]; } [self kb_backspaceStepForToken:token]; } break; case UIGestureRecognizerStateChanged: { [self kb_handleBackspaceLongPressChanged:gr]; } break; case UIGestureRecognizerStateEnded: case UIGestureRecognizerStateCancelled: case UIGestureRecognizerStateFailed: { [self kb_handleBackspaceLongPressEnded:gr]; } break; default: break; } } #pragma mark - Delete Steps - (void)kb_backspaceStepForToken:(NSUInteger)token { if (!self.backspaceHoldActive) { return; } if (token != self.backspaceHoldToken) { return; } UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton); UIInputViewController *ivc = KBFindInputViewController(start); if (!ivc) { self.backspaceHoldActive = NO; return; } id proxy = ivc.textDocumentProxy; NSString *before = proxy.documentContextBeforeInput ?: @""; NSTimeInterval elapsed = [NSDate date].timeIntervalSinceReferenceDate - self.backspaceHoldStartTime; NSInteger deleteCount = 1; if (before.length > 0) { deleteCount = [self kb_backspaceDeleteCountForContext:before elapsed:elapsed]; } if (!self.backspaceChunkModeActive && elapsed >= kKBBackspaceChunkStartDelay) { self.backspaceChunkModeActive = YES; if (self.showClearLabelEnabled) { [self kb_showBackspaceClearLabelIfNeeded]; } } for (NSInteger i = 0; i < deleteCount; i++) { [proxy deleteBackward]; } NSTimeInterval interval = [self kb_backspaceRepeatIntervalForElapsed:elapsed]; __weak typeof(self) weakSelf = self; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(interval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ __strong typeof(weakSelf) selfStrong = weakSelf; [selfStrong kb_backspaceStepForToken:token]; }); } - (NSTimeInterval)kb_backspaceRepeatIntervalForElapsed:(NSTimeInterval)elapsed { if (elapsed >= kKBBackspaceChunkStartDelay) { return kKBBackspaceChunkRepeatInterval; } return kKBBackspaceRepeatInterval; } - (NSInteger)kb_backspaceDeleteCountForContext:(NSString *)context elapsed:(NSTimeInterval)elapsed { if (elapsed < kKBBackspaceChunkStartDelay) { return 1; } NSInteger maxCount = (elapsed >= kKBBackspaceChunkFastDelay) ? kKBBackspaceChunkSizeFast : kKBBackspaceChunkSize; return [self kb_backspaceChunkDeleteCountForContext:context maxCount:maxCount]; } - (NSInteger)kb_backspaceChunkDeleteCountForContext:(NSString *)context maxCount:(NSInteger)maxCount { if (context.length == 0) { return 1; } static NSCharacterSet *whitespaceSet = nil; static NSCharacterSet *asciiWordSet = nil; static NSCharacterSet *punctuationSet = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet]; asciiWordSet = [NSCharacterSet characterSetWithCharactersInString: @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"]; punctuationSet = [NSCharacterSet punctuationCharacterSet]; }); __block NSInteger deleteCount = 0; __block KBBackspaceChunkClass chunkClass = KBBackspaceChunkClassUnknown; [context enumerateSubstringsInRange:NSMakeRange(0, context.length) options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) { if (substring.length == 0) { return; } KBBackspaceChunkClass currentClass = KBBackspaceChunkClassOther; if ([substring rangeOfCharacterFromSet:whitespaceSet].location != NSNotFound) { currentClass = KBBackspaceChunkClassWhitespace; } else if ([substring rangeOfCharacterFromSet:asciiWordSet].location != NSNotFound) { currentClass = KBBackspaceChunkClassASCIIWord; } else if ([substring rangeOfCharacterFromSet:punctuationSet].location != NSNotFound) { currentClass = KBBackspaceChunkClassPunctuation; } if (chunkClass == KBBackspaceChunkClassUnknown) { chunkClass = currentClass; } else if (chunkClass != currentClass) { *stop = YES; return; } deleteCount += 1; if (deleteCount >= maxCount) { *stop = YES; } }]; return MAX(deleteCount, 1); } - (NSInteger)kb_clearDeleteCountForContext:(NSString *)context hitBoundary:(BOOL *)hitBoundary { if (context.length == 0) { return kKBBackspaceClearBatchSize; } static NSCharacterSet *sentenceBoundarySet = nil; static NSCharacterSet *whitespaceSet = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"]; whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet]; }); NSInteger length = context.length; NSInteger end = length; while (end > 0) { unichar ch = [context characterAtIndex:end - 1]; if ([whitespaceSet characterIsMember:ch]) { end -= 1; } else { break; } } NSInteger searchEnd = end; while (searchEnd > 0) { unichar ch = [context characterAtIndex:searchEnd - 1]; if ([sentenceBoundarySet characterIsMember:ch]) { searchEnd -= 1; } else { break; } } NSInteger boundaryIndex = NSNotFound; for (NSInteger i = searchEnd - 1; i >= 0; i--) { unichar ch = [context characterAtIndex:i]; if ([sentenceBoundarySet characterIsMember:ch]) { boundaryIndex = i; break; } } BOOL boundaryFound = (boundaryIndex != NSNotFound); NSInteger deleteCount = length; if (boundaryIndex != NSNotFound) { deleteCount = length - (boundaryIndex + 1); } deleteCount = MAX(deleteCount, 1); if (hitBoundary) { *hitBoundary = boundaryFound; } return MIN(deleteCount, kKBBackspaceClearMaxStep); } #pragma mark - Long Press State - (void)kb_handleBackspaceLongPressChanged:(UILongPressGestureRecognizer *)gr { if (!self.backspaceHoldActive) { return; } if (!self.showClearLabelEnabled) { return; } [self kb_showBackspaceClearLabelIfNeeded]; UIView *hostView = [self kb_hostView]; if (!hostView) { return; } CGPoint point = [gr locationInView:hostView]; self.backspaceLastTouchPointInSelf = point; self.backspaceHasLastTouchPoint = YES; BOOL inside = [self kb_isPointInsideBackspaceClearLabel:point]; [self kb_setBackspaceClearHighlighted:inside]; } - (void)kb_handleBackspaceLongPressEnded:(UILongPressGestureRecognizer *)gr { BOOL shouldClear = NO; if (self.showClearLabelEnabled) { shouldClear = self.backspaceClearHighlighted; if (!shouldClear) { UIView *hostView = [self kb_hostView]; CGPoint point = CGPointZero; if (gr && hostView) { point = [gr locationInView:hostView]; } else if (self.backspaceHasLastTouchPoint) { point = self.backspaceLastTouchPointInSelf; } shouldClear = [self kb_isPointInsideBackspaceClearLabel:point]; } } self.backspaceHoldActive = NO; self.backspaceChunkModeActive = NO; self.backspaceHoldToken += 1; self.backspaceHasLastTouchPoint = NO; [self kb_hideBackspaceClearLabel]; if (shouldClear) { [self kb_clearAllInput]; } } #pragma mark - Clear Label - (void)kb_showBackspaceClearLabelIfNeeded { UIView *hostView = [self kb_hostView]; if (!hostView || !self.backspaceButton) { return; } UILabel *label = self.backspaceClearLabel; [self kb_refreshBackspaceClearLabelColors]; if (!label.superview) { [hostView addSubview:label]; } [self kb_updateBackspaceClearLabelFrame]; [hostView bringSubviewToFront:label]; if (label.hidden) { label.alpha = 0.0; label.hidden = NO; [self kb_playLightHaptic]; [UIView animateWithDuration:0.12 animations:^{ label.alpha = 1.0; }]; } } - (void)kb_hideBackspaceClearLabel { if (!_backspaceClearLabel || _backspaceClearLabel.hidden) { return; } _backspaceClearLabel.hidden = YES; _backspaceClearLabel.alpha = 1.0; [self kb_setBackspaceClearHighlighted:NO]; } - (void)kb_updateBackspaceClearLabelFrame { UIView *hostView = [self kb_hostView]; if (!hostView || !self.backspaceButton || !self.backspaceClearLabel) { return; } CGRect btnFrame = [self.backspaceButton convertRect:self.backspaceButton.bounds toView:hostView]; UILabel *label = self.backspaceClearLabel; CGSize textSize = [label sizeThatFits:CGSizeMake(CGFLOAT_MAX, kKBBackspaceClearLabelHeight)]; CGFloat width = MAX(textSize.width + kKBBackspaceClearLabelPaddingX * 2.0, 60.0); CGFloat height = kKBBackspaceClearLabelHeight; CGFloat x = CGRectGetMidX(btnFrame) - width * 0.5; CGFloat y = CGRectGetMinY(btnFrame) - height - kKBBackspaceClearLabelTopGap; if (x < kKBBackspaceClearLabelHorizontalInset) { x = kKBBackspaceClearLabelHorizontalInset; } CGFloat maxX = CGRectGetWidth(hostView.bounds) - kKBBackspaceClearLabelHorizontalInset - width; if (x > maxX) { x = maxX; } if (y < 0) { y = 0; } label.frame = CGRectIntegral(CGRectMake(x, y, width, height)); } - (BOOL)kb_isPointInsideBackspaceClearLabel:(CGPoint)point { if (!self.backspaceClearLabel || self.backspaceClearLabel.hidden) { return NO; } [self kb_updateBackspaceClearLabelFrame]; CGRect hitFrame = CGRectInset(self.backspaceClearLabel.frame, -12.0, -10.0); return CGRectContainsPoint(hitFrame, point); } - (void)kb_setBackspaceClearHighlighted:(BOOL)highlighted { if (self.backspaceClearHighlighted == highlighted) { return; } self.backspaceClearHighlighted = highlighted; [self kb_refreshBackspaceClearLabelColors]; } - (void)kb_refreshBackspaceClearLabelColors { UILabel *label = self.backspaceClearLabel; label.textColor = [KBSkinManager shared].current.keyTextColor ?: UIColor.blackColor; label.backgroundColor = self.backspaceClearHighlighted ? [self kb_backspaceClearLabelHighlightedColor] : [self kb_backspaceClearLabelNormalColor]; } - (UIColor *)kb_backspaceClearLabelNormalColor { KBSkinTheme *t = [KBSkinManager shared].current; return t.keyHighlightBackground ?: [UIColor colorWithWhite:0.9 alpha:1.0]; } - (UIColor *)kb_backspaceClearLabelHighlightedColor { KBSkinTheme *t = [KBSkinManager shared].current; return t.accentColor ?: t.keyHighlightBackground ?: [UIColor colorWithWhite:0.8 alpha:1.0]; } - (void)kb_playLightHaptic { if (@available(iOS 10.0, *)) { UIImpactFeedbackGenerator *gen = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; [gen prepare]; [gen impactOccurred]; } } - (UILabel *)backspaceClearLabel { if (!_backspaceClearLabel) { UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero]; label.text = @"立刻清空"; label.textAlignment = NSTextAlignmentCenter; label.font = [UIFont systemFontOfSize:12 weight:UIFontWeightSemibold]; label.textColor = [KBSkinManager shared].current.keyTextColor ?: UIColor.blackColor; label.backgroundColor = [self kb_backspaceClearLabelNormalColor]; label.layer.cornerRadius = kKBBackspaceClearLabelCornerRadius; label.layer.masksToBounds = YES; label.hidden = YES; label.userInteractionEnabled = NO; _backspaceClearLabel = label; } return _backspaceClearLabel; } #pragma mark - Clear - (void)kb_clearAllInput { UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton); UIInputViewController *ivc = KBFindInputViewController(start); if (ivc) { NSString *before = ivc.textDocumentProxy.documentContextBeforeInput ?: @""; [[KBBackspaceUndoManager shared] recordClearWithContext:before]; } self.backspaceClearToken += 1; NSUInteger token = self.backspaceClearToken; [self kb_clearAllInputStepForToken:token guard:0 emptyRounds:0]; } - (void)kb_clearAllInputStepForToken:(NSUInteger)token guard:(NSInteger)guard emptyRounds:(NSInteger)emptyRounds { if (token != self.backspaceClearToken) { return; } UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton); UIInputViewController *ivc = KBFindInputViewController(start); if (!ivc) { return; } id proxy = ivc.textDocumentProxy; NSString *before = proxy.documentContextBeforeInput ?: @""; NSInteger count = before.length; NSInteger batch = 0; NSInteger nextEmptyRounds = emptyRounds; BOOL hitBoundary = NO; if (count > 0) { batch = [self kb_clearDeleteCountForContext:before hitBoundary:&hitBoundary]; nextEmptyRounds = 0; } else { batch = kKBBackspaceClearBatchSize; nextEmptyRounds = emptyRounds + 1; } if (batch <= 0) { batch = 1; } if (guard >= kKBBackspaceClearMaxDeletes || nextEmptyRounds > kKBBackspaceClearEmptyContextMaxRounds) { return; } for (NSInteger i = 0; i < batch; i++) { [proxy deleteBackward]; } NSInteger nextGuard = guard + batch; BOOL shouldContinue = NO; if (count > 0 && !hitBoundary) { if (count > batch) { shouldContinue = YES; } else if ([proxy hasText]) { shouldContinue = YES; } } if (!shouldContinue) { return; } __weak typeof(self) weakSelf = self; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kKBBackspaceClearBatchInterval * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ __strong typeof(weakSelf) selfStrong = weakSelf; [selfStrong kb_clearAllInputStepForToken:token guard:nextGuard emptyRounds:nextEmptyRounds]; }); } #pragma mark - Helpers - (UIView *)kb_hostView { if (self.containerView) { return self.containerView; } return self.backspaceButton.superview; } @end