2025-10-28 10:18:10 +08:00
|
|
|
|
//
|
|
|
|
|
|
// KBKeyboardView.m
|
|
|
|
|
|
// CustomKeyboard
|
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
|
|
#import "KBKeyboardView.h"
|
|
|
|
|
|
#import "KBKeyButton.h"
|
|
|
|
|
|
#import "KBKey.h"
|
2025-11-04 16:37:24 +08:00
|
|
|
|
#import "KBResponderUtils.h" // 封装的响应链工具
|
2025-11-04 21:01:46 +08:00
|
|
|
|
#import "KBSkinManager.h"
|
2025-11-20 21:11:27 +08:00
|
|
|
|
#import "KBKeyPreviewView.h"
|
2025-10-28 10:18:10 +08:00
|
|
|
|
|
2025-11-21 21:50:40 +08:00
|
|
|
|
// UI 常量统一管理,方便后续调试样式(以 375 宽设计稿为基准,通过 KBFit 做等比缩放)
|
|
|
|
|
|
#define kKBRowVerticalSpacing KBFit(8.0f)
|
|
|
|
|
|
#define kKBRowHorizontalInset KBFit(6.0f)
|
|
|
|
|
|
#define kKBRowHeight KBFit(40.0f)
|
2025-11-21 16:22:00 +08:00
|
|
|
|
|
|
|
|
|
|
static const NSTimeInterval kKBBackspaceLongPressMinDuration = 0.35;
|
|
|
|
|
|
static const NSTimeInterval kKBBackspaceRepeatInterval = 0.06;
|
2025-12-19 18:14:28 +08:00
|
|
|
|
static const NSTimeInterval kKBBackspaceChunkStartDelay = 0.1;
|
2025-12-19 16:24:47 +08:00
|
|
|
|
static const NSTimeInterval kKBBackspaceChunkRepeatInterval = 0.1;
|
|
|
|
|
|
static const NSTimeInterval kKBBackspaceChunkFastDelay = 1.4;
|
|
|
|
|
|
static const NSInteger kKBBackspaceChunkSize = 6;
|
|
|
|
|
|
static const NSInteger kKBBackspaceChunkSizeFast = 12;
|
2025-12-19 17:06:01 +08:00
|
|
|
|
static const CGFloat kKBBackspaceClearLabelCornerRadius = 8.0;
|
|
|
|
|
|
static const CGFloat kKBBackspaceClearLabelHeight = 26.0;
|
|
|
|
|
|
static const CGFloat kKBBackspaceClearLabelPaddingX = 10.0;
|
|
|
|
|
|
static const CGFloat kKBBackspaceClearLabelTopGap = 6.0;
|
2025-12-19 18:08:51 +08:00
|
|
|
|
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;
|
2025-11-21 16:22:00 +08:00
|
|
|
|
|
|
|
|
|
|
static const NSTimeInterval kKBPreviewShowDuration = 0.08;
|
|
|
|
|
|
static const NSTimeInterval kKBPreviewHideDuration = 0.06;
|
|
|
|
|
|
|
|
|
|
|
|
static const CGFloat kKBSpecialKeySquareMultiplier = 1.2;
|
|
|
|
|
|
static const CGFloat kKBReturnWidthMultiplier = 2.4;
|
|
|
|
|
|
static const CGFloat kKBSpaceWidthMultiplier = 3.0;
|
|
|
|
|
|
|
|
|
|
|
|
// 第二行字母行的左右占位比例(用于居中)
|
|
|
|
|
|
static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
|
|
|
|
|
|
|
2025-12-19 16:24:47 +08:00
|
|
|
|
typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
|
|
|
|
|
KBBackspaceChunkClassUnknown = 0,
|
|
|
|
|
|
KBBackspaceChunkClassWhitespace,
|
|
|
|
|
|
KBBackspaceChunkClassASCIIWord,
|
|
|
|
|
|
KBBackspaceChunkClassPunctuation,
|
|
|
|
|
|
KBBackspaceChunkClassOther
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
@interface KBKeyboardView ()
|
|
|
|
|
|
@property (nonatomic, strong) UIView *row1;
|
|
|
|
|
|
@property (nonatomic, strong) UIView *row2;
|
|
|
|
|
|
@property (nonatomic, strong) UIView *row3;
|
|
|
|
|
|
@property (nonatomic, strong) UIView *row4;
|
|
|
|
|
|
@property (nonatomic, strong) NSArray<NSArray<KBKey *> *> *keysForRows;
|
2025-11-04 16:37:24 +08:00
|
|
|
|
// 长按退格的一次次删除控制标记(不使用 NSTimer,仅用 GCD 递归调度)
|
|
|
|
|
|
@property (nonatomic, assign) BOOL backspaceHoldActive;
|
2025-12-19 16:24:47 +08:00
|
|
|
|
@property (nonatomic, assign) NSTimeInterval backspaceHoldStartTime;
|
2025-12-19 17:06:01 +08:00
|
|
|
|
@property (nonatomic, assign) BOOL backspaceChunkModeActive;
|
|
|
|
|
|
@property (nonatomic, assign) BOOL backspaceClearHighlighted;
|
2025-12-19 18:08:51 +08:00
|
|
|
|
@property (nonatomic, assign) NSUInteger backspaceHoldToken;
|
|
|
|
|
|
@property (nonatomic, assign) BOOL backspaceHasLastTouchPoint;
|
|
|
|
|
|
@property (nonatomic, assign) CGPoint backspaceLastTouchPointInSelf;
|
|
|
|
|
|
@property (nonatomic, assign) NSUInteger backspaceClearToken;
|
2025-12-19 17:06:01 +08:00
|
|
|
|
@property (nonatomic, weak) KBKeyButton *backspaceButton;
|
|
|
|
|
|
@property (nonatomic, strong) UILabel *backspaceClearLabel;
|
2025-11-20 21:11:27 +08:00
|
|
|
|
@property (nonatomic, strong) KBKeyPreviewView *previewView;
|
2025-10-28 10:18:10 +08:00
|
|
|
|
@end
|
|
|
|
|
|
|
|
|
|
|
|
@implementation KBKeyboardView
|
|
|
|
|
|
|
|
|
|
|
|
- (instancetype)initWithFrame:(CGRect)frame {
|
|
|
|
|
|
if (self = [super initWithFrame:frame]) {
|
2025-11-04 21:01:46 +08:00
|
|
|
|
self.backgroundColor = [KBSkinManager shared].current.keyboardBackground;
|
2025-10-28 10:18:10 +08:00
|
|
|
|
_layoutStyle = KBKeyboardLayoutStyleLetters;
|
2025-10-28 19:24:35 +08:00
|
|
|
|
// 默认小写:与需求一致,初始不开启 Shift
|
|
|
|
|
|
_shiftOn = NO;
|
2025-10-28 20:03:43 +08:00
|
|
|
|
_symbolsMoreOn = NO; // 数字面板默认第一页(123)
|
2025-10-28 10:18:10 +08:00
|
|
|
|
[self buildBase];
|
|
|
|
|
|
[self reloadKeys];
|
|
|
|
|
|
}
|
|
|
|
|
|
return self;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 20:03:43 +08:00
|
|
|
|
// 当切换大布局(字母/数字)时,重置数字二级页状态
|
|
|
|
|
|
- (void)setLayoutStyle:(KBKeyboardLayoutStyle)layoutStyle {
|
|
|
|
|
|
_layoutStyle = layoutStyle;
|
|
|
|
|
|
if (_layoutStyle != KBKeyboardLayoutStyleNumbers) {
|
|
|
|
|
|
_symbolsMoreOn = NO;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
#pragma mark - Base Layout
|
|
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
- (void)buildBase {
|
|
|
|
|
|
[self addSubview:self.row1];
|
|
|
|
|
|
[self addSubview:self.row2];
|
|
|
|
|
|
[self addSubview:self.row3];
|
|
|
|
|
|
[self addSubview:self.row4];
|
|
|
|
|
|
|
|
|
|
|
|
[self.row1 mas_makeConstraints:^(MASConstraintMaker *make) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
make.top.equalTo(self.mas_top).offset(kKBRowVerticalSpacing);
|
2025-10-28 10:18:10 +08:00
|
|
|
|
make.left.right.equalTo(self);
|
2025-11-21 16:22:00 +08:00
|
|
|
|
make.height.mas_equalTo(kKBRowHeight);
|
2025-10-28 10:18:10 +08:00
|
|
|
|
}];
|
|
|
|
|
|
[self.row2 mas_makeConstraints:^(MASConstraintMaker *make) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
make.top.equalTo(self.row1.mas_bottom).offset(kKBRowVerticalSpacing);
|
2025-10-28 10:18:10 +08:00
|
|
|
|
make.left.right.equalTo(self);
|
|
|
|
|
|
make.height.equalTo(self.row1);
|
|
|
|
|
|
}];
|
|
|
|
|
|
[self.row3 mas_makeConstraints:^(MASConstraintMaker *make) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
make.top.equalTo(self.row2.mas_bottom).offset(kKBRowVerticalSpacing);
|
2025-10-28 10:18:10 +08:00
|
|
|
|
make.left.right.equalTo(self);
|
|
|
|
|
|
make.height.equalTo(self.row1);
|
|
|
|
|
|
}];
|
|
|
|
|
|
[self.row4 mas_makeConstraints:^(MASConstraintMaker *make) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
make.top.equalTo(self.row3.mas_bottom).offset(kKBRowVerticalSpacing);
|
2025-10-28 10:18:10 +08:00
|
|
|
|
make.left.right.equalTo(self);
|
|
|
|
|
|
make.height.equalTo(self.row1);
|
|
|
|
|
|
make.bottom.equalTo(self.mas_bottom).offset(-6);
|
|
|
|
|
|
}];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
#pragma mark - Public
|
|
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
- (void)reloadKeys {
|
2025-12-19 17:06:01 +08:00
|
|
|
|
self.backspaceButton = nil;
|
|
|
|
|
|
self.backspaceChunkModeActive = NO;
|
|
|
|
|
|
self.backspaceClearHighlighted = NO;
|
|
|
|
|
|
[self kb_hideBackspaceClearLabel];
|
2025-10-28 10:18:10 +08:00
|
|
|
|
// 移除旧按钮
|
|
|
|
|
|
for (UIView *row in @[self.row1, self.row2, self.row3, self.row4]) {
|
|
|
|
|
|
[row.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
self.keysForRows = [self buildKeysForCurrentLayout];
|
2025-11-21 16:22:00 +08:00
|
|
|
|
if (self.keysForRows.count < 4) return;
|
|
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
[self buildRow:self.row1 withKeys:self.keysForRows[0]];
|
2025-11-21 16:22:00 +08:00
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
// 第二行:字母布局时通过左右等宽占位让整行居中
|
2025-11-21 16:22:00 +08:00
|
|
|
|
CGFloat row2Spacer = (self.layoutStyle == KBKeyboardLayoutStyleLetters)
|
|
|
|
|
|
? kKBLettersRow2EdgeSpacerMultiplier : 0.0;
|
2025-10-28 10:18:10 +08:00
|
|
|
|
[self buildRow:self.row2 withKeys:self.keysForRows[1] edgeSpacerMultiplier:row2Spacer];
|
2025-11-21 16:22:00 +08:00
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
[self buildRow:self.row3 withKeys:self.keysForRows[2]];
|
|
|
|
|
|
[self buildRow:self.row4 withKeys:self.keysForRows[3]];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
#pragma mark - Key Model Construction
|
|
|
|
|
|
|
|
|
|
|
|
// 创建当前布局下各行的 KBKey 列表
|
2025-10-28 10:18:10 +08:00
|
|
|
|
- (NSArray<NSArray<KBKey *> *> *)buildKeysForCurrentLayout {
|
|
|
|
|
|
if (self.layoutStyle == KBKeyboardLayoutStyleNumbers) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
return [self buildKeysForNumbersLayout];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return [self buildKeysForLettersLayout];
|
2025-10-28 10:18:10 +08:00
|
|
|
|
}
|
2025-11-21 16:22:00 +08:00
|
|
|
|
}
|
2025-10-28 10:18:10 +08:00
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
#pragma mark - Letters Layout
|
|
|
|
|
|
|
|
|
|
|
|
- (NSArray<NSArray<KBKey *> *> *)buildKeysForLettersLayout {
|
2025-10-28 10:18:10 +08:00
|
|
|
|
// 字母布局(QWERTY)
|
2025-11-21 16:22:00 +08:00
|
|
|
|
NSArray *r1Letters = @[ @"q", @"w", @"e", @"r", @"t", @"y", @"u", @"i", @"o", @"p" ];
|
|
|
|
|
|
NSArray *r2Letters = @[ @"a", @"s", @"d", @"f", @"g", @"h", @"j", @"k", @"l" ];
|
|
|
|
|
|
NSArray *r3Letters = @[ @"z", @"x", @"c", @"v", @"b", @"n", @"m" ];
|
|
|
|
|
|
|
|
|
|
|
|
NSMutableArray *row1 = [NSMutableArray arrayWithCapacity:r1Letters.count];
|
|
|
|
|
|
for (NSString *s in r1Letters) {
|
|
|
|
|
|
[row1 addObject:[self kb_letterKeyWithChar:s]];
|
2025-10-28 19:24:35 +08:00
|
|
|
|
}
|
2025-10-28 10:18:10 +08:00
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
NSMutableArray *row2 = [NSMutableArray arrayWithCapacity:r2Letters.count];
|
|
|
|
|
|
for (NSString *s in r2Letters) {
|
|
|
|
|
|
[row2 addObject:[self kb_letterKeyWithChar:s]];
|
2025-10-28 19:24:35 +08:00
|
|
|
|
}
|
2025-10-28 10:18:10 +08:00
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
// 第三行:Shift + Z...M + Backspace
|
2025-10-28 10:18:10 +08:00
|
|
|
|
NSMutableArray *row3 = [NSMutableArray array];
|
2025-11-18 20:53:47 +08:00
|
|
|
|
KBKey *shift = [KBKey keyWithIdentifier:@"shift"
|
|
|
|
|
|
title:@"⇧"
|
|
|
|
|
|
output:@""
|
|
|
|
|
|
type:KBKeyTypeShift];
|
2025-11-20 20:17:19 +08:00
|
|
|
|
// Shift 键也支持大小写两套皮肤图:
|
|
|
|
|
|
// - shift.caseVariant = Lower 时,使用 KBSkinIconMap 中 "shift" 对应的短名;
|
|
|
|
|
|
// - shift.caseVariant = Upper 时,使用 "shift_upper" 对应的短名。
|
|
|
|
|
|
shift.caseVariant = self.shiftOn ? KBKeyCaseVariantUpper : KBKeyCaseVariantLower;
|
2025-11-18 20:53:47 +08:00
|
|
|
|
[row3 addObject:shift];
|
2025-11-21 16:22:00 +08:00
|
|
|
|
|
|
|
|
|
|
for (NSString *s in r3Letters) {
|
|
|
|
|
|
[row3 addObject:[self kb_letterKeyWithChar:s]];
|
2025-10-28 19:24:35 +08:00
|
|
|
|
}
|
2025-11-21 16:22:00 +08:00
|
|
|
|
|
2025-11-18 20:53:47 +08:00
|
|
|
|
KBKey *backspace = [KBKey keyWithIdentifier:@"backspace"
|
|
|
|
|
|
title:@"⌫"
|
|
|
|
|
|
output:@""
|
|
|
|
|
|
type:KBKeyTypeBackspace];
|
|
|
|
|
|
[row3 addObject:backspace];
|
|
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
NSArray *row4 = [self kb_bottomControlRowKeysForLettersLayout];
|
|
|
|
|
|
|
|
|
|
|
|
return @[row1.copy, row2.copy, row3.copy, row4];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Numbers / Symbols Layout
|
|
|
|
|
|
|
|
|
|
|
|
- (NSArray<NSArray<KBKey *> *> *)buildKeysForNumbersLayout {
|
|
|
|
|
|
// 数字/符号布局:3 行主键 + 底部控制行
|
|
|
|
|
|
NSArray *r1 = nil;
|
|
|
|
|
|
NSArray *r2 = nil;
|
|
|
|
|
|
NSArray *r3 = nil;
|
|
|
|
|
|
|
|
|
|
|
|
if (!self.symbolsMoreOn) {
|
|
|
|
|
|
// 数字第一页(123)
|
|
|
|
|
|
r1 = @[ [KBKey keyWithIdentifier:@"digit_1" title:@"1" output:@"1" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"digit_2" title:@"2" output:@"2" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"digit_3" title:@"3" output:@"3" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"digit_4" title:@"4" output:@"4" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"digit_5" title:@"5" output:@"5" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"digit_6" title:@"6" output:@"6" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"digit_7" title:@"7" output:@"7" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"digit_8" title:@"8" output:@"8" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"digit_9" title:@"9" output:@"9" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"digit_0" title:@"0" output:@"0" type:KBKeyTypeCharacter] ];
|
|
|
|
|
|
r2 = @[ [KBKey keyWithIdentifier:@"sym_minus" title:@"-" output:@"-" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_slash" title:@"/" output:@"/" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_colon" title:@":" output:@":" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_semicolon" title:@";" output:@";" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_paren_l" title:@"(" output:@"(" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_paren_r" title:@")" output:@")" type:KBKeyTypeCharacter],
|
2025-11-25 21:50:07 +08:00
|
|
|
|
[KBKey keyWithIdentifier:@"sym_money" title:@"¥" output:@"¥" type:KBKeyTypeCharacter],
|
2025-11-21 16:22:00 +08:00
|
|
|
|
[KBKey keyWithIdentifier:@"sym_amp" title:@"&" output:@"&" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_at" title:@"@" output:@"@" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_quote_double" title:@"\"" output:@"\"" type:KBKeyTypeCharacter] ];
|
|
|
|
|
|
|
|
|
|
|
|
r3 = [self kb_symbolsCommonThirdRowWithToggleIsMore:NO];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 数字第二页(#+=):前两行替换为更多符号,左下角按钮文案改为“123”
|
|
|
|
|
|
r1 = @[ [KBKey keyWithIdentifier:@"sym_bracket_l" title:@"[" output:@"[" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_bracket_r" title:@"]" output:@"]" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_brace_l" title:@"{" output:@"{" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_brace_r" title:@"}" output:@"}" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_hash" title:@"#" output:@"#" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_percent" title:@"%" output:@"%" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_caret" title:@"^" output:@"^" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_asterisk" title:@"*" output:@"*" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_plus" title:@"+" output:@"+" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_equal" title:@"=" output:@"=" type:KBKeyTypeCharacter] ];
|
|
|
|
|
|
r2 = @[ [KBKey keyWithIdentifier:@"sym_underscore" title:@"_" output:@"_" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_backslash" title:@"\\" output:@"\\" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_pipe" title:@"|" output:@"|" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_tilde" title:@"~" output:@"~" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_lt" title:@"<" output:@"<" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_gt" title:@">" output:@">" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_dollar" title:@"$" output:@"$" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_euro" title:@"€" output:@"€" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_pound" title:@"£" output:@"£" type:KBKeyTypeCharacter],
|
|
|
|
|
|
[KBKey keyWithIdentifier:@"sym_bullet" title:@"•" output:@"•" type:KBKeyTypeCharacter] ];
|
|
|
|
|
|
|
|
|
|
|
|
r3 = [self kb_symbolsCommonThirdRowWithToggleIsMore:YES];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
NSArray *r4 = [self kb_bottomControlRowKeysForNumbersLayout];
|
|
|
|
|
|
return @[r1, r2, r3, r4];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Key Factories
|
|
|
|
|
|
|
|
|
|
|
|
// 字母键工厂:根据 shiftOn 决定显示与输出大小写
|
|
|
|
|
|
- (KBKey *)kb_letterKeyWithChar:(NSString *)charString {
|
|
|
|
|
|
NSParameterAssert(charString.length == 1);
|
|
|
|
|
|
NSString *lower = charString.lowercaseString;
|
|
|
|
|
|
NSString *upper = charString.uppercaseString;
|
|
|
|
|
|
|
|
|
|
|
|
NSString *shown = self.shiftOn ? upper : lower;
|
|
|
|
|
|
NSString *identifier = [NSString stringWithFormat:@"letter_%@", lower];
|
|
|
|
|
|
|
|
|
|
|
|
KBKey *k = [KBKey keyWithIdentifier:identifier
|
|
|
|
|
|
title:shown
|
|
|
|
|
|
output:shown
|
|
|
|
|
|
type:KBKeyTypeCharacter];
|
|
|
|
|
|
k.caseVariant = self.shiftOn ? KBKeyCaseVariantUpper : KBKeyCaseVariantLower;
|
|
|
|
|
|
return k;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 数字布局第三行公共部分(左下角是 123 或 #+=)
|
|
|
|
|
|
- (NSArray<KBKey *> *)kb_symbolsCommonThirdRowWithToggleIsMore:(BOOL)isMorePage {
|
|
|
|
|
|
NSString *identifier = isMorePage ? @"symbols_toggle_123" : @"symbols_toggle_more";
|
|
|
|
|
|
NSString *title = isMorePage ? @"123" : @"#+=";
|
|
|
|
|
|
|
|
|
|
|
|
KBKey *toggle = [KBKey keyWithIdentifier:identifier
|
|
|
|
|
|
title:title
|
|
|
|
|
|
output:@""
|
|
|
|
|
|
type:KBKeyTypeSymbolsToggle];
|
|
|
|
|
|
KBKey *comma = [KBKey keyWithIdentifier:@"sym_comma" title:@"," output:@"," type:KBKeyTypeCharacter];
|
|
|
|
|
|
KBKey *dot = [KBKey keyWithIdentifier:@"sym_dot" title:@"." output:@"." type:KBKeyTypeCharacter];
|
|
|
|
|
|
KBKey *q = [KBKey keyWithIdentifier:@"sym_question" title:@"?" output:@"?" type:KBKeyTypeCharacter];
|
|
|
|
|
|
KBKey *ex = [KBKey keyWithIdentifier:@"sym_exclam" title:@"!" output:@"!" type:KBKeyTypeCharacter];
|
|
|
|
|
|
KBKey *quote = [KBKey keyWithIdentifier:@"sym_quote_single" title:@"'" output:@"'" type:KBKeyTypeCharacter];
|
|
|
|
|
|
KBKey *back = [KBKey keyWithIdentifier:@"backspace"
|
|
|
|
|
|
title:@"⌫"
|
|
|
|
|
|
output:@""
|
|
|
|
|
|
type:KBKeyTypeBackspace];
|
|
|
|
|
|
return @[ toggle, comma, dot, q, ex, quote, back ];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 底部控制行(字母布局)
|
|
|
|
|
|
- (NSArray<KBKey *> *)kb_bottomControlRowKeysForLettersLayout {
|
2025-11-18 20:53:47 +08:00
|
|
|
|
KBKey *mode123 = [KBKey keyWithIdentifier:@"mode_123"
|
|
|
|
|
|
title:@"123"
|
|
|
|
|
|
output:@""
|
|
|
|
|
|
type:KBKeyTypeModeChange];
|
2025-12-15 13:32:47 +08:00
|
|
|
|
KBKey *customAI = [KBKey keyWithIdentifier:@"ai"
|
|
|
|
|
|
title:@"AI"
|
|
|
|
|
|
output:@""
|
|
|
|
|
|
type:KBKeyTypeCustom];
|
2025-12-15 13:24:43 +08:00
|
|
|
|
KBKey *emoji = [KBKey keyWithIdentifier:KBKeyIdentifierEmojiPanel
|
|
|
|
|
|
title:@"😊"
|
|
|
|
|
|
output:@""
|
|
|
|
|
|
type:KBKeyTypeCustom];
|
2025-11-18 20:53:47 +08:00
|
|
|
|
KBKey *space = [KBKey keyWithIdentifier:@"space"
|
|
|
|
|
|
title:@"space"
|
|
|
|
|
|
output:@" "
|
|
|
|
|
|
type:KBKeyTypeSpace];
|
|
|
|
|
|
KBKey *ret = [KBKey keyWithIdentifier:@"return"
|
|
|
|
|
|
title:KBLocalized(@"Send")
|
|
|
|
|
|
output:@"\n"
|
|
|
|
|
|
type:KBKeyTypeReturn];
|
2025-12-15 13:32:47 +08:00
|
|
|
|
return @[ mode123, customAI, emoji, space, ret ];
|
2025-11-21 16:22:00 +08:00
|
|
|
|
}
|
2025-10-28 10:18:10 +08:00
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
// 底部控制行(数字布局)
|
|
|
|
|
|
- (NSArray<KBKey *> *)kb_bottomControlRowKeysForNumbersLayout {
|
|
|
|
|
|
KBKey *modeABC = [KBKey keyWithIdentifier:@"mode_abc"
|
|
|
|
|
|
title:@"abc"
|
|
|
|
|
|
output:@""
|
|
|
|
|
|
type:KBKeyTypeModeChange];
|
2025-12-15 13:32:47 +08:00
|
|
|
|
KBKey *customAI = [KBKey keyWithIdentifier:@"ai"
|
|
|
|
|
|
title:@"AI"
|
|
|
|
|
|
output:@""
|
|
|
|
|
|
type:KBKeyTypeCustom];
|
2025-12-15 13:24:43 +08:00
|
|
|
|
KBKey *emoji = [KBKey keyWithIdentifier:KBKeyIdentifierEmojiPanel
|
|
|
|
|
|
title:@"😊"
|
|
|
|
|
|
output:@""
|
|
|
|
|
|
type:KBKeyTypeCustom];
|
2025-11-21 16:22:00 +08:00
|
|
|
|
KBKey *space = [KBKey keyWithIdentifier:@"space"
|
|
|
|
|
|
title:@"space"
|
|
|
|
|
|
output:@" "
|
|
|
|
|
|
type:KBKeyTypeSpace];
|
|
|
|
|
|
KBKey *ret = [KBKey keyWithIdentifier:@"return"
|
|
|
|
|
|
title:KBLocalized(@"Send")
|
|
|
|
|
|
output:@"\n"
|
|
|
|
|
|
type:KBKeyTypeReturn];
|
2025-12-15 13:32:47 +08:00
|
|
|
|
return @[ modeABC, customAI, emoji, space, ret ];
|
2025-10-28 10:18:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
#pragma mark - Row Building
|
|
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
- (void)buildRow:(UIView *)row withKeys:(NSArray<KBKey *> *)keys {
|
|
|
|
|
|
[self buildRow:row withKeys:keys edgeSpacerMultiplier:0.0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
- (void)buildRow:(UIView *)row
|
|
|
|
|
|
withKeys:(NSArray<KBKey *> *)keys
|
|
|
|
|
|
edgeSpacerMultiplier:(CGFloat)edgeSpacerMultiplier {
|
|
|
|
|
|
|
2025-11-21 13:48:22 +08:00
|
|
|
|
// 第 4 行(底部控制行)使用单独的布局规则:
|
|
|
|
|
|
// 123/ABC、AI、Send 给定尺寸,Space 自动吃掉剩余宽度。
|
|
|
|
|
|
BOOL isBottomControlRow = [self kb_isBottomControlRowWithKeys:keys];
|
|
|
|
|
|
|
2025-11-20 21:53:46 +08:00
|
|
|
|
CGFloat spacing = 0; // 键与键之间的间距
|
2025-10-28 10:18:10 +08:00
|
|
|
|
UIView *previous = nil;
|
|
|
|
|
|
UIView *leftSpacer = nil;
|
|
|
|
|
|
UIView *rightSpacer = nil;
|
2025-11-21 16:22:00 +08:00
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
if (edgeSpacerMultiplier > 0.0) {
|
|
|
|
|
|
leftSpacer = [UIView new];
|
|
|
|
|
|
rightSpacer = [UIView new];
|
|
|
|
|
|
leftSpacer.backgroundColor = [UIColor clearColor];
|
|
|
|
|
|
rightSpacer.backgroundColor = [UIColor clearColor];
|
|
|
|
|
|
[row addSubview:leftSpacer];
|
|
|
|
|
|
[row addSubview:rightSpacer];
|
|
|
|
|
|
[leftSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
make.left.equalTo(row.mas_left).offset(kKBRowHorizontalInset);
|
2025-10-28 10:18:10 +08:00
|
|
|
|
make.centerY.equalTo(row);
|
|
|
|
|
|
make.height.mas_equalTo(1);
|
|
|
|
|
|
}];
|
|
|
|
|
|
[rightSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
make.right.equalTo(row.mas_right).offset(-kKBRowHorizontalInset);
|
2025-10-28 10:18:10 +08:00
|
|
|
|
make.centerY.equalTo(row);
|
|
|
|
|
|
make.height.mas_equalTo(1);
|
|
|
|
|
|
}];
|
|
|
|
|
|
}
|
2025-11-21 16:22:00 +08:00
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
for (NSInteger i = 0; i < keys.count; i++) {
|
|
|
|
|
|
KBKey *key = keys[i];
|
|
|
|
|
|
KBKeyButton *btn = [[KBKeyButton alloc] init];
|
|
|
|
|
|
btn.key = key;
|
|
|
|
|
|
[btn setTitle:key.title forState:UIControlStateNormal];
|
2025-11-19 16:13:30 +08:00
|
|
|
|
// 在设置完标题后,按当前皮肤应用图标与文字显隐
|
|
|
|
|
|
[btn applyThemeForCurrentKey];
|
2025-10-28 10:18:10 +08:00
|
|
|
|
[btn addTarget:self action:@selector(onKeyTapped:) forControlEvents:UIControlEventTouchUpInside];
|
|
|
|
|
|
[row addSubview:btn];
|
|
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
// ⌫ 长按:开始连续逐个删除(无需 NSTimer)。使用 UILongPressGestureRecognizer 识别长按
|
2025-11-04 16:37:24 +08:00
|
|
|
|
if (key.type == KBKeyTypeBackspace) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
UILongPressGestureRecognizer *lp =
|
|
|
|
|
|
[[UILongPressGestureRecognizer alloc] initWithTarget:self
|
|
|
|
|
|
action:@selector(onBackspaceLongPress:)];
|
|
|
|
|
|
lp.minimumPressDuration = kKBBackspaceLongPressMinDuration;
|
2025-12-19 17:06:01 +08:00
|
|
|
|
lp.allowableMovement = CGFLOAT_MAX;
|
2025-11-04 16:37:24 +08:00
|
|
|
|
lp.cancelsTouchesInView = YES; // 被识别为长按时,取消普通点击
|
|
|
|
|
|
[btn addGestureRecognizer:lp];
|
2025-12-19 17:06:01 +08:00
|
|
|
|
self.backspaceButton = btn;
|
2025-11-04 16:37:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 20:11:40 +08:00
|
|
|
|
// Shift 按钮选中态随大小写状态变化
|
|
|
|
|
|
if (key.type == KBKeyTypeShift) {
|
|
|
|
|
|
btn.selected = self.shiftOn;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
|
|
|
|
make.top.bottom.equalTo(row);
|
|
|
|
|
|
if (previous) {
|
|
|
|
|
|
make.left.equalTo(previous.mas_right).offset(spacing);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (leftSpacer) {
|
|
|
|
|
|
make.left.equalTo(leftSpacer.mas_right).offset(spacing);
|
|
|
|
|
|
} else {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
make.left.equalTo(row.mas_left).offset(kKBRowHorizontalInset);
|
2025-10-28 10:18:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}];
|
|
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
// 字符键:等宽
|
2025-10-28 10:18:10 +08:00
|
|
|
|
if (key.type == KBKeyTypeCharacter) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
if (previous && [previous isKindOfClass:[KBKeyButton class]]) {
|
|
|
|
|
|
KBKeyButton *prevBtn = (KBKeyButton *)previous;
|
|
|
|
|
|
if (prevBtn.key.type == KBKeyTypeCharacter) {
|
2025-10-28 10:18:10 +08:00
|
|
|
|
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
|
|
|
|
make.width.equalTo(previous);
|
|
|
|
|
|
}];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
// special keys: 宽度在第二遍统一设置
|
2025-10-28 10:18:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
previous = btn;
|
|
|
|
|
|
}
|
2025-11-21 13:48:22 +08:00
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
// 右侧使用内边距或右占位
|
2025-11-21 16:22:00 +08:00
|
|
|
|
if (previous) {
|
|
|
|
|
|
[previous mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
|
|
|
|
if (rightSpacer) {
|
|
|
|
|
|
make.right.equalTo(rightSpacer.mas_left).offset(-spacing);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
make.right.equalTo(row.mas_right).offset(-kKBRowHorizontalInset);
|
|
|
|
|
|
}
|
|
|
|
|
|
}];
|
|
|
|
|
|
}
|
2025-10-28 10:18:10 +08:00
|
|
|
|
|
2025-11-21 13:48:22 +08:00
|
|
|
|
// 底部控制行:在第一轮已完成左右约束的前提下,仅给 123/ABC、AI、Send 指定宽度,
|
|
|
|
|
|
// Space 不加宽度约束,让其自动填充剩余空间。
|
|
|
|
|
|
if (isBottomControlRow) {
|
|
|
|
|
|
[self kb_applyBottomControlRowWidthInRow:row];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
// 第二遍:以首个字符键为基准,统一设置特殊键宽度倍数
|
|
|
|
|
|
KBKeyButton *firstChar = nil;
|
2025-11-20 20:17:19 +08:00
|
|
|
|
BOOL hasCharacterInRow = NO;
|
2025-11-21 16:22:00 +08:00
|
|
|
|
for (UIView *v in row.subviews) {
|
|
|
|
|
|
if (![v isKindOfClass:[KBKeyButton class]]) continue;
|
|
|
|
|
|
KBKeyButton *b = (KBKeyButton *)v;
|
2025-11-20 20:17:19 +08:00
|
|
|
|
if (b.key.type == KBKeyTypeCharacter) {
|
|
|
|
|
|
firstChar = b;
|
|
|
|
|
|
hasCharacterInRow = YES;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2025-10-28 10:18:10 +08:00
|
|
|
|
}
|
2025-11-21 16:22:00 +08:00
|
|
|
|
// 若该行没有字符键(例如底部控制行之外的特殊行),则使用行内第一个按钮作为基准宽度
|
2025-10-29 12:59:22 +08:00
|
|
|
|
if (!firstChar) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
for (UIView *v in row.subviews) {
|
|
|
|
|
|
if ([v isKindOfClass:[KBKeyButton class]]) {
|
|
|
|
|
|
firstChar = (KBKeyButton *)v;
|
2025-11-20 20:17:19 +08:00
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-29 12:59:22 +08:00
|
|
|
|
}
|
2025-10-28 10:18:10 +08:00
|
|
|
|
if (firstChar) {
|
2025-11-20 20:17:19 +08:00
|
|
|
|
// 如果该行本身没有字符键(如底部控制行),且基准按钮是 123/ABC/AI/#+= 等,
|
|
|
|
|
|
// 也将其约束为 1:1,避免 123/ABC 不是正方形。
|
|
|
|
|
|
if (!hasCharacterInRow &&
|
|
|
|
|
|
(firstChar.key.type == KBKeyTypeModeChange ||
|
|
|
|
|
|
firstChar.key.type == KBKeyTypeSymbolsToggle ||
|
|
|
|
|
|
firstChar.key.type == KBKeyTypeCustom)) {
|
|
|
|
|
|
[firstChar mas_makeConstraints:^(MASConstraintMaker *make) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
make.width.equalTo(firstChar.mas_height).multipliedBy(kKBSpecialKeySquareMultiplier);
|
2025-11-20 20:17:19 +08:00
|
|
|
|
}];
|
|
|
|
|
|
}
|
2025-11-21 16:22:00 +08:00
|
|
|
|
|
|
|
|
|
|
for (UIView *v in row.subviews) {
|
|
|
|
|
|
if (![v isKindOfClass:[KBKeyButton class]]) continue;
|
|
|
|
|
|
KBKeyButton *b = (KBKeyButton *)v;
|
|
|
|
|
|
|
2025-11-05 18:10:56 +08:00
|
|
|
|
// 避免对基准按钮自身添加 self == self * k 的无效约束
|
|
|
|
|
|
if (b == firstChar) continue;
|
2025-10-28 10:18:10 +08:00
|
|
|
|
if (b.key.type == KBKeyTypeCharacter) continue;
|
2025-11-20 19:57:11 +08:00
|
|
|
|
|
2025-11-20 20:17:19 +08:00
|
|
|
|
BOOL isBottomModeKey = (b.key.type == KBKeyTypeModeChange) ||
|
|
|
|
|
|
(b.key.type == KBKeyTypeSymbolsToggle) ||
|
|
|
|
|
|
(b.key.type == KBKeyTypeCustom);
|
2025-11-21 16:22:00 +08:00
|
|
|
|
// 一类键强制近似正方形(宽 ~ 高)
|
2025-11-20 20:17:19 +08:00
|
|
|
|
if (b.key.type == KBKeyTypeShift ||
|
|
|
|
|
|
b.key.type == KBKeyTypeBackspace ||
|
|
|
|
|
|
isBottomModeKey) {
|
2025-11-20 19:57:11 +08:00
|
|
|
|
[b mas_makeConstraints:^(MASConstraintMaker *make) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
make.width.equalTo(b.mas_height).multipliedBy(kKBSpecialKeySquareMultiplier);
|
2025-11-20 19:57:11 +08:00
|
|
|
|
}];
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
CGFloat multiplier = 1.5;
|
2025-11-20 19:57:11 +08:00
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
// Space:宽度更大
|
2025-11-20 19:57:11 +08:00
|
|
|
|
if (b.key.type == KBKeyTypeSpace) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
multiplier = kKBSpaceWidthMultiplier;
|
2025-11-20 19:57:11 +08:00
|
|
|
|
}
|
2025-11-20 20:17:19 +08:00
|
|
|
|
// Send 按钮:宽度为基准键的 2.4 倍
|
2025-11-20 19:57:11 +08:00
|
|
|
|
else if (b.key.type == KBKeyTypeReturn) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
multiplier = kKBReturnWidthMultiplier;
|
2025-11-20 19:57:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 其它特殊键(如 Globe)保持适度放大
|
|
|
|
|
|
else if (b.key.type == KBKeyTypeGlobe) {
|
2025-10-28 10:18:10 +08:00
|
|
|
|
multiplier = 1.5;
|
|
|
|
|
|
}
|
2025-11-20 19:57:11 +08:00
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
[b mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
|
|
|
|
make.width.equalTo(firstChar).multipliedBy(multiplier);
|
|
|
|
|
|
}];
|
|
|
|
|
|
}
|
2025-11-21 16:22:00 +08:00
|
|
|
|
|
2025-11-21 20:59:39 +08:00
|
|
|
|
// 如果有左右占位,则把占位宽度设置为字符键宽度的一定倍数,以实现整体居中;
|
|
|
|
|
|
// 同时强约束左右占位宽度相等,避免在某些系统上由于布局冲突导致只压缩一侧,
|
|
|
|
|
|
// 出现“左侧有空隙,右侧无空隙”的情况。
|
2025-10-28 10:18:10 +08:00
|
|
|
|
if (leftSpacer && rightSpacer) {
|
2025-11-21 20:59:39 +08:00
|
|
|
|
// 1) 左右占位宽度必须相等
|
2025-10-28 10:18:10 +08:00
|
|
|
|
[leftSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
2025-11-21 20:59:39 +08:00
|
|
|
|
make.width.equalTo(rightSpacer);
|
2025-10-28 10:18:10 +08:00
|
|
|
|
}];
|
2025-11-21 20:59:39 +08:00
|
|
|
|
// 2) 同时都接近字符键宽度的 edgeSpacerMultiplier 倍数
|
2025-10-28 10:18:10 +08:00
|
|
|
|
[rightSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
|
|
|
|
make.width.equalTo(firstChar).multipliedBy(edgeSpacerMultiplier);
|
|
|
|
|
|
}];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-21 13:48:22 +08:00
|
|
|
|
#pragma mark - Row Helpers (Bottom Control Row)
|
|
|
|
|
|
|
|
|
|
|
|
// 判断是否为底部控制行:包含 Space + Return,且有 ModeChange/SymbolsToggle,
|
|
|
|
|
|
// 并且不再含字符键。
|
|
|
|
|
|
- (BOOL)kb_isBottomControlRowWithKeys:(NSArray<KBKey *> *)keys {
|
|
|
|
|
|
BOOL hasSpace = NO;
|
|
|
|
|
|
BOOL hasReturn = NO;
|
|
|
|
|
|
BOOL hasModeOrSymbols = NO;
|
|
|
|
|
|
for (KBKey *k in keys) {
|
|
|
|
|
|
if (k.type == KBKeyTypeCharacter) {
|
|
|
|
|
|
return NO;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (k.type == KBKeyTypeSpace) {
|
|
|
|
|
|
hasSpace = YES;
|
|
|
|
|
|
} else if (k.type == KBKeyTypeReturn) {
|
|
|
|
|
|
hasReturn = YES;
|
|
|
|
|
|
} else if (k.type == KBKeyTypeModeChange || k.type == KBKeyTypeSymbolsToggle) {
|
|
|
|
|
|
hasModeOrSymbols = YES;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return hasSpace && hasReturn && hasModeOrSymbols;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 为底部控制行设置宽度:
|
2025-11-21 16:22:00 +08:00
|
|
|
|
// - 123/ABC、AI:正方形(宽 = 行高 * multiplier)
|
|
|
|
|
|
// - Send:宽 = 模式键宽度的 2 倍
|
2025-11-21 13:48:22 +08:00
|
|
|
|
// - Space:不加宽度约束,依靠左右约束自动填充剩余空间。
|
|
|
|
|
|
- (void)kb_applyBottomControlRowWidthInRow:(UIView *)row {
|
|
|
|
|
|
KBKeyButton *modeBtn = nil;
|
2025-12-15 13:32:47 +08:00
|
|
|
|
NSMutableArray<KBKeyButton *> *customButtons = [NSMutableArray array];
|
2025-11-21 13:48:22 +08:00
|
|
|
|
KBKeyButton *spaceBtn = nil;
|
|
|
|
|
|
KBKeyButton *retBtn = nil;
|
|
|
|
|
|
|
|
|
|
|
|
for (UIView *v in row.subviews) {
|
|
|
|
|
|
if (![v isKindOfClass:[KBKeyButton class]]) continue;
|
|
|
|
|
|
KBKeyButton *b = (KBKeyButton *)v;
|
|
|
|
|
|
switch (b.key.type) {
|
|
|
|
|
|
case KBKeyTypeModeChange:
|
|
|
|
|
|
case KBKeyTypeSymbolsToggle:
|
|
|
|
|
|
modeBtn = b;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case KBKeyTypeCustom:
|
2025-12-15 13:32:47 +08:00
|
|
|
|
[customButtons addObject:b];
|
2025-11-21 13:48:22 +08:00
|
|
|
|
break;
|
|
|
|
|
|
case KBKeyTypeSpace:
|
|
|
|
|
|
spaceBtn = b;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case KBKeyTypeReturn:
|
|
|
|
|
|
retBtn = b;
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 13:32:47 +08:00
|
|
|
|
if (!modeBtn || customButtons.count == 0 || !spaceBtn || !retBtn) {
|
2025-11-21 13:48:22 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[modeBtn mas_makeConstraints:^(MASConstraintMaker *make) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
make.width.equalTo(row.mas_height).multipliedBy(kKBSpecialKeySquareMultiplier);
|
2025-11-21 13:48:22 +08:00
|
|
|
|
}];
|
2025-12-15 13:32:47 +08:00
|
|
|
|
for (KBKeyButton *custom in customButtons) {
|
|
|
|
|
|
[custom mas_makeConstraints:^(MASConstraintMaker *make) {
|
|
|
|
|
|
make.width.equalTo(row.mas_height).multipliedBy(kKBSpecialKeySquareMultiplier);
|
|
|
|
|
|
}];
|
|
|
|
|
|
}
|
2025-11-21 13:48:22 +08:00
|
|
|
|
[retBtn mas_makeConstraints:^(MASConstraintMaker *make) {
|
2025-11-21 16:22:00 +08:00
|
|
|
|
make.width.equalTo(modeBtn.mas_width).multipliedBy(2.0);
|
2025-11-21 13:48:22 +08:00
|
|
|
|
}];
|
|
|
|
|
|
// Space 不设置宽度;通过此前已建立的左右约束自动占满剩余宽度。
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
#pragma mark - Actions
|
|
|
|
|
|
|
|
|
|
|
|
- (void)onKeyTapped:(KBKeyButton *)sender {
|
|
|
|
|
|
KBKey *key = sender.key;
|
|
|
|
|
|
if (key.type == KBKeyTypeShift) {
|
|
|
|
|
|
self.shiftOn = !self.shiftOn;
|
|
|
|
|
|
[self reloadKeys];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-10-28 20:03:43 +08:00
|
|
|
|
if (key.type == KBKeyTypeSymbolsToggle) {
|
|
|
|
|
|
// 在数字布局内切换 123 <-> #+=
|
|
|
|
|
|
self.symbolsMoreOn = !self.symbolsMoreOn;
|
|
|
|
|
|
[self reloadKeys];
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-10-28 10:18:10 +08:00
|
|
|
|
if ([self.delegate respondsToSelector:@selector(keyboardView:didTapKey:)]) {
|
|
|
|
|
|
[self.delegate keyboardView:self didTapKey:key];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-20 21:11:27 +08:00
|
|
|
|
// 在字符键按下时,显示一个上方气泡预览(类似系统键盘)。
|
|
|
|
|
|
- (void)showPreviewForButton:(KBKeyButton *)button {
|
|
|
|
|
|
KBKey *key = button.key;
|
|
|
|
|
|
if (key.type != KBKeyTypeCharacter) return;
|
|
|
|
|
|
|
|
|
|
|
|
if (!self.previewView) {
|
|
|
|
|
|
self.previewView = [[KBKeyPreviewView alloc] initWithFrame:CGRectZero];
|
|
|
|
|
|
self.previewView.hidden = YES;
|
|
|
|
|
|
[self addSubview:self.previewView];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
[self.previewView configureWithKey:key icon:button.iconView.image];
|
|
|
|
|
|
|
|
|
|
|
|
// 计算预览视图位置:在按钮上方稍微偏上
|
|
|
|
|
|
CGRect btnFrameInSelf = [button convertRect:button.bounds toView:self];
|
2025-11-21 19:40:57 +08:00
|
|
|
|
// CGFloat previewWidth = MAX(CGRectGetWidth(btnFrameInSelf) * 1.4, 42.0);
|
|
|
|
|
|
CGFloat previewWidth = 42;
|
2025-11-20 21:11:27 +08:00
|
|
|
|
CGFloat previewHeight = CGRectGetHeight(btnFrameInSelf) * 1.2;
|
|
|
|
|
|
CGFloat centerX = CGRectGetMidX(btnFrameInSelf);
|
|
|
|
|
|
CGFloat centerY = CGRectGetMinY(btnFrameInSelf) - previewHeight * 0.6;
|
|
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
// 修复:原来写死 40,这里用真正计算出的 previewWidth
|
|
|
|
|
|
self.previewView.frame = CGRectMake(0, 0, previewWidth, previewHeight);
|
2025-11-20 21:11:27 +08:00
|
|
|
|
self.previewView.center = CGPointMake(centerX, centerY);
|
|
|
|
|
|
self.previewView.alpha = 0.0;
|
|
|
|
|
|
self.previewView.hidden = NO;
|
|
|
|
|
|
|
2025-11-21 16:22:00 +08:00
|
|
|
|
[UIView animateWithDuration:kKBPreviewShowDuration
|
2025-11-20 21:11:27 +08:00
|
|
|
|
delay:0
|
|
|
|
|
|
options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseOut
|
|
|
|
|
|
animations:^{
|
|
|
|
|
|
self.previewView.alpha = 1.0;
|
|
|
|
|
|
}
|
|
|
|
|
|
completion:nil];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)hidePreview {
|
|
|
|
|
|
if (!self.previewView || self.previewView.isHidden) return;
|
2025-11-21 16:22:00 +08:00
|
|
|
|
[UIView animateWithDuration:kKBPreviewHideDuration
|
2025-11-20 21:11:27 +08:00
|
|
|
|
delay:0
|
|
|
|
|
|
options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn
|
|
|
|
|
|
animations:^{
|
|
|
|
|
|
self.previewView.alpha = 0.0;
|
|
|
|
|
|
}
|
|
|
|
|
|
completion:^(BOOL finished) {
|
|
|
|
|
|
self.previewView.hidden = YES;
|
|
|
|
|
|
}];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 16:24:47 +08:00
|
|
|
|
// 长按退格:先连续单删,稍后切换为按段删除;松手停止。(不使用 NSTimer/DisplayLink)
|
2025-11-04 16:37:24 +08:00
|
|
|
|
- (void)onBackspaceLongPress:(UILongPressGestureRecognizer *)gr {
|
2025-12-19 18:08:51 +08:00
|
|
|
|
if (gr) {
|
|
|
|
|
|
self.backspaceLastTouchPointInSelf = [gr locationInView:self];
|
|
|
|
|
|
self.backspaceHasLastTouchPoint = YES;
|
|
|
|
|
|
}
|
2025-11-04 16:37:24 +08:00
|
|
|
|
switch (gr.state) {
|
|
|
|
|
|
case UIGestureRecognizerStateBegan: {
|
2025-12-19 18:08:51 +08:00
|
|
|
|
// 递增 token:使上一次长按(可能尚未触发的 dispatch_after)立即失效,
|
|
|
|
|
|
// 避免“第一次长按后第二次长按失效/异常加速”等并发问题。
|
|
|
|
|
|
self.backspaceHoldToken += 1;
|
|
|
|
|
|
NSUInteger token = self.backspaceHoldToken;
|
2025-11-04 16:37:24 +08:00
|
|
|
|
self.backspaceHoldActive = YES;
|
2025-12-19 16:24:47 +08:00
|
|
|
|
self.backspaceHoldStartTime = [NSDate date].timeIntervalSinceReferenceDate;
|
2025-12-19 17:06:01 +08:00
|
|
|
|
self.backspaceChunkModeActive = NO;
|
|
|
|
|
|
[self kb_setBackspaceClearHighlighted:NO];
|
|
|
|
|
|
[self kb_hideBackspaceClearLabel];
|
2025-12-19 18:08:51 +08:00
|
|
|
|
[self kb_backspaceStepForToken:token];
|
2025-11-04 16:37:24 +08:00
|
|
|
|
} break;
|
2025-12-19 17:06:01 +08:00
|
|
|
|
case UIGestureRecognizerStateChanged: {
|
|
|
|
|
|
[self kb_handleBackspaceLongPressChanged:gr];
|
|
|
|
|
|
} break;
|
2025-11-04 16:37:24 +08:00
|
|
|
|
case UIGestureRecognizerStateEnded:
|
|
|
|
|
|
case UIGestureRecognizerStateCancelled:
|
|
|
|
|
|
case UIGestureRecognizerStateFailed: {
|
2025-12-19 17:06:01 +08:00
|
|
|
|
[self kb_handleBackspaceLongPressEnded:gr];
|
2025-11-04 16:37:24 +08:00
|
|
|
|
} break;
|
|
|
|
|
|
default: break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Helpers
|
|
|
|
|
|
|
|
|
|
|
|
// 单步删除并在需要时安排下一次,直到松手或无内容
|
2025-12-19 18:08:51 +08:00
|
|
|
|
- (void)kb_backspaceStepForToken:(NSUInteger)token {
|
2025-11-04 16:37:24 +08:00
|
|
|
|
if (!self.backspaceHoldActive) { return; }
|
2025-12-19 18:08:51 +08:00
|
|
|
|
if (token != self.backspaceHoldToken) { return; }
|
2025-11-04 16:37:24 +08:00
|
|
|
|
UIInputViewController *ivc = KBFindInputViewController(self);
|
|
|
|
|
|
if (!ivc) { self.backspaceHoldActive = NO; return; }
|
|
|
|
|
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
|
|
|
|
|
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
2025-12-19 16:24:47 +08:00
|
|
|
|
NSTimeInterval elapsed = [NSDate date].timeIntervalSinceReferenceDate - self.backspaceHoldStartTime;
|
2025-12-19 18:08:51 +08:00
|
|
|
|
NSInteger deleteCount = 1;
|
|
|
|
|
|
if (before.length > 0) {
|
|
|
|
|
|
deleteCount = [self kb_backspaceDeleteCountForContext:before elapsed:elapsed];
|
|
|
|
|
|
}
|
2025-12-19 17:06:01 +08:00
|
|
|
|
if (!self.backspaceChunkModeActive && elapsed >= kKBBackspaceChunkStartDelay) {
|
|
|
|
|
|
self.backspaceChunkModeActive = YES;
|
|
|
|
|
|
[self kb_showBackspaceClearLabelIfNeeded];
|
|
|
|
|
|
}
|
2025-12-19 16:24:47 +08:00
|
|
|
|
for (NSInteger i = 0; i < deleteCount; i++) {
|
|
|
|
|
|
[proxy deleteBackward];
|
|
|
|
|
|
}
|
2025-11-04 16:37:24 +08:00
|
|
|
|
|
2025-12-19 16:24:47 +08:00
|
|
|
|
NSTimeInterval interval = [self kb_backspaceRepeatIntervalForElapsed:elapsed];
|
2025-11-04 16:37:24 +08:00
|
|
|
|
__weak typeof(self) weakSelf = self;
|
2025-11-21 16:22:00 +08:00
|
|
|
|
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
2025-12-19 16:24:47 +08:00
|
|
|
|
(int64_t)(interval * NSEC_PER_SEC)),
|
2025-11-21 16:22:00 +08:00
|
|
|
|
dispatch_get_main_queue(), ^{
|
2025-11-04 16:37:24 +08:00
|
|
|
|
__strong typeof(weakSelf) selfStrong = weakSelf;
|
2025-12-19 18:08:51 +08:00
|
|
|
|
[selfStrong kb_backspaceStepForToken:token];
|
2025-11-04 16:37:24 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 16:24:47 +08:00
|
|
|
|
- (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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 18:08:51 +08:00
|
|
|
|
- (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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 17:06:01 +08:00
|
|
|
|
- (void)kb_handleBackspaceLongPressChanged:(UILongPressGestureRecognizer *)gr {
|
|
|
|
|
|
if (!self.backspaceHoldActive) { return; }
|
|
|
|
|
|
NSTimeInterval elapsed = [NSDate date].timeIntervalSinceReferenceDate - self.backspaceHoldStartTime;
|
|
|
|
|
|
if (elapsed < kKBBackspaceChunkStartDelay) { return; }
|
|
|
|
|
|
[self kb_showBackspaceClearLabelIfNeeded];
|
|
|
|
|
|
CGPoint point = [gr locationInView:self];
|
2025-12-19 18:08:51 +08:00
|
|
|
|
self.backspaceLastTouchPointInSelf = point;
|
|
|
|
|
|
self.backspaceHasLastTouchPoint = YES;
|
2025-12-19 17:06:01 +08:00
|
|
|
|
BOOL inside = [self kb_isPointInsideBackspaceClearLabel:point];
|
|
|
|
|
|
[self kb_setBackspaceClearHighlighted:inside];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)kb_handleBackspaceLongPressEnded:(UILongPressGestureRecognizer *)gr {
|
|
|
|
|
|
BOOL shouldClear = self.backspaceClearHighlighted;
|
2025-12-19 18:08:51 +08:00
|
|
|
|
if (!shouldClear) {
|
|
|
|
|
|
CGPoint point = CGPointZero;
|
|
|
|
|
|
if (gr) {
|
|
|
|
|
|
point = [gr locationInView:self];
|
|
|
|
|
|
} else if (self.backspaceHasLastTouchPoint) {
|
|
|
|
|
|
point = self.backspaceLastTouchPointInSelf;
|
|
|
|
|
|
}
|
2025-12-19 17:06:01 +08:00
|
|
|
|
shouldClear = [self kb_isPointInsideBackspaceClearLabel:point];
|
|
|
|
|
|
}
|
|
|
|
|
|
self.backspaceHoldActive = NO;
|
|
|
|
|
|
self.backspaceChunkModeActive = NO;
|
2025-12-19 18:08:51 +08:00
|
|
|
|
self.backspaceHoldToken += 1; // 结束/取消时也使剩余回调失效
|
|
|
|
|
|
self.backspaceHasLastTouchPoint = NO;
|
2025-12-19 17:06:01 +08:00
|
|
|
|
[self kb_hideBackspaceClearLabel];
|
|
|
|
|
|
if (shouldClear) {
|
|
|
|
|
|
[self kb_clearAllInput];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)kb_showBackspaceClearLabelIfNeeded {
|
|
|
|
|
|
if (!self.backspaceButton) { return; }
|
|
|
|
|
|
UILabel *label = self.backspaceClearLabel;
|
|
|
|
|
|
[self kb_refreshBackspaceClearLabelColors];
|
|
|
|
|
|
if (!label.superview) {
|
|
|
|
|
|
[self addSubview:label];
|
|
|
|
|
|
}
|
|
|
|
|
|
[self kb_updateBackspaceClearLabelFrame];
|
|
|
|
|
|
[self bringSubviewToFront:label];
|
|
|
|
|
|
if (label.hidden) {
|
|
|
|
|
|
label.alpha = 0.0;
|
|
|
|
|
|
label.hidden = NO;
|
2025-12-19 18:14:28 +08:00
|
|
|
|
[self kb_playLightHaptic];
|
2025-12-19 17:06:01 +08:00
|
|
|
|
[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 {
|
|
|
|
|
|
if (!self.backspaceButton || !self.backspaceClearLabel) { return; }
|
|
|
|
|
|
CGRect btnFrame = [self.backspaceButton convertRect:self.backspaceButton.bounds toView:self];
|
|
|
|
|
|
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 < kKBRowHorizontalInset) { x = kKBRowHorizontalInset; }
|
|
|
|
|
|
if (x + width > CGRectGetWidth(self.bounds) - kKBRowHorizontalInset) {
|
|
|
|
|
|
x = CGRectGetWidth(self.bounds) - kKBRowHorizontalInset - width;
|
|
|
|
|
|
}
|
|
|
|
|
|
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; }
|
2025-12-19 18:08:51 +08:00
|
|
|
|
[self kb_updateBackspaceClearLabelFrame];
|
|
|
|
|
|
CGRect hitFrame = CGRectInset(self.backspaceClearLabel.frame, -12.0, -10.0);
|
|
|
|
|
|
return CGRectContainsPoint(hitFrame, point);
|
2025-12-19 17:06:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (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];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 18:14:28 +08:00
|
|
|
|
- (void)kb_playLightHaptic {
|
|
|
|
|
|
if (@available(iOS 10.0, *)) {
|
|
|
|
|
|
UIImpactFeedbackGenerator *gen = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
|
|
|
|
|
[gen prepare];
|
|
|
|
|
|
[gen impactOccurred];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-19 17:06:01 +08:00
|
|
|
|
- (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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
- (void)kb_clearAllInput {
|
2025-12-19 18:08:51 +08:00
|
|
|
|
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; }
|
2025-12-19 17:06:01 +08:00
|
|
|
|
UIInputViewController *ivc = KBFindInputViewController(self);
|
|
|
|
|
|
if (!ivc) { return; }
|
|
|
|
|
|
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
2025-12-19 18:08:51 +08:00
|
|
|
|
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;
|
2025-12-19 17:06:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-19 18:08:51 +08:00
|
|
|
|
|
|
|
|
|
|
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];
|
|
|
|
|
|
});
|
2025-12-19 17:06:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#pragma mark - Layout
|
|
|
|
|
|
|
|
|
|
|
|
- (void)layoutSubviews {
|
|
|
|
|
|
[super layoutSubviews];
|
|
|
|
|
|
if (self.backspaceClearLabel && !self.backspaceClearLabel.hidden) {
|
|
|
|
|
|
[self kb_updateBackspaceClearLabelFrame];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 10:18:10 +08:00
|
|
|
|
#pragma mark - Lazy
|
|
|
|
|
|
|
|
|
|
|
|
- (UIView *)row1 { if (!_row1) _row1 = [UIView new]; return _row1; }
|
|
|
|
|
|
- (UIView *)row2 { if (!_row2) _row2 = [UIView new]; return _row2; }
|
|
|
|
|
|
- (UIView *)row3 { if (!_row3) _row3 = [UIView new]; return _row3; }
|
|
|
|
|
|
- (UIView *)row4 { if (!_row4) _row4 = [UIView new]; return _row4; }
|
|
|
|
|
|
|
|
|
|
|
|
@end
|