Files
keyboard/CustomKeyboard/View/KBKeyboardView/KBKeyboardView.m
2026-03-06 10:45:13 +08:00

854 lines
35 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// KBKeyboardView.m
// CustomKeyboard
//
#import "KBKeyboardView.h"
#import "KBKeyButton.h"
#import "KBKey.h"
#import "KBBackspaceLongPressHandler.h"
#import "KBKeyboardLayoutConfig.h"
#import "KBKeyboardLayoutResolver.h"
#import "KBKeyboardKeyFactory.h"
#import "KBKeyboardLayoutEngine.h"
#import "KBKeyboardRowBuilder.h"
#import "KBKeyboardInputHandler.h"
#import "KBKeyboardLegacyBuilder.h"
#import "KBKeyboardLegacyLayoutProvider.h"
#import "KBKeyboardInteractionHandler.h"
#import "KBKeyboardRowContainerBuilder.h"
#import "KBSkinManager.h"
#import <Masonry/Masonry.h>
#import <objc/runtime.h>
// 第二行字母行的左右占位比例(用于居中)
static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
static const NSTimeInterval kKBKeyVariantLongPressMinDuration = 0.35;
static const CGFloat kKBKeyVariantPopupPaddingX = 8.0;
static const CGFloat kKBKeyVariantPopupPaddingY = 6.0;
static const CGFloat kKBKeyVariantItemWidth = 34.0;
static const CGFloat kKBKeyVariantItemHeight = 40.0;
static const CGFloat kKBKeyVariantPopupAnchorGap = 6.0;
static NSString * const kKBDiacriticsConfigFileName = @"kb_diacritics_map";
static const void *kKBDiacriticsLongPressBoundKey = &kKBDiacriticsLongPressBoundKey;
static inline NSString *kb_normalizeLanguageCode(NSString *languageCode) {
NSString *lc = (languageCode ?: @"").lowercaseString;
return lc.length > 0 ? lc : @"en";
}
static inline NSString *kb_baseLanguageCode(NSString *languageCode) {
NSString *lc = kb_normalizeLanguageCode(languageCode);
NSRange r = [lc rangeOfString:@"-"];
if (r.location == NSNotFound) { return lc; }
if (r.location == 0) { return lc; }
return [lc substringToIndex:r.location];
}
static NSDictionary<NSString *, NSDictionary<NSString *, NSArray<NSString *> *> *> *kb_diacriticsLanguagesMap(void) {
static NSDictionary<NSString *, NSDictionary<NSString *, NSArray<NSString *> *> *> *cached = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
NSString *path = [[NSBundle mainBundle] pathForResource:kKBDiacriticsConfigFileName ofType:@"json"];
NSData *data = path.length ? [NSData dataWithContentsOfFile:path] : nil;
if (data.length == 0) { return; }
NSError *error = nil;
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
if (error || ![json isKindOfClass:[NSDictionary class]]) {
NSLog(@"[KBKeyboardView] Failed to parse %@.json: %@", kKBDiacriticsConfigFileName, error);
return;
}
NSDictionary *dict = (NSDictionary *)json;
id languages = dict[@"languages"];
if (![languages isKindOfClass:[NSDictionary class]]) { return; }
cached = (NSDictionary *)languages;
});
return cached ?: @{};
}
static inline NSUInteger kb_composedCharacterCount(NSString *string) {
if (string.length == 0) { return 0; }
__block NSUInteger count = 0;
[string enumerateSubstringsInRange:NSMakeRange(0, string.length)
options:NSStringEnumerationByComposedCharacterSequences
usingBlock:^(__unused NSString *substring,
__unused NSRange substringRange,
__unused NSRange enclosingRange,
__unused BOOL *stop) {
count += 1;
}];
return count;
}
@interface KBKeyVariantPopupView : UIView
@property (nonatomic, copy) NSArray<NSString *> *variants;
@property (nonatomic, assign) NSInteger selectedIndex;
- (void)configureWithVariants:(NSArray<NSString *> *)variants selectedIndex:(NSInteger)selectedIndex;
- (void)updateSelectionWithPointInSelf:(CGPoint)point;
- (nullable NSString *)selectedVariant;
- (CGSize)preferredSize;
- (void)applyTheme;
@end
#pragma mark - KBKeyVariantPopupView
@interface KBKeyVariantPopupView ()
@property (nonatomic, strong) UIView *contentView;
@property (nonatomic, strong) NSMutableArray<UILabel *> *itemLabels;
@end
@implementation KBKeyVariantPopupView
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
self.layer.cornerRadius = 10.0;
self.layer.masksToBounds = NO;
self.layer.borderWidth = 0.5;
self.layer.borderColor = [UIColor colorWithWhite:0 alpha:0.15].CGColor;
self.layer.shadowColor = [UIColor colorWithWhite:0 alpha:0.28].CGColor;
self.layer.shadowOpacity = 0.7;
self.layer.shadowOffset = CGSizeMake(0, 2);
self.layer.shadowRadius = 6.0;
[self addSubview:self.contentView];
[self.contentView mas_makeConstraints:^(MASConstraintMaker *make) {
make.edges.equalTo(self).insets(UIEdgeInsetsMake(kKBKeyVariantPopupPaddingY,
kKBKeyVariantPopupPaddingX,
kKBKeyVariantPopupPaddingY,
kKBKeyVariantPopupPaddingX));
}];
[self applyTheme];
}
return self;
}
- (void)applyTheme {
KBSkinTheme *t = [KBSkinManager shared].current;
UIColor *bg = t.keyBackground ?: [UIColor whiteColor];
self.backgroundColor = bg;
}
- (UIView *)contentView {
if (!_contentView) {
_contentView = [UIView new];
_contentView.backgroundColor = [UIColor clearColor];
}
return _contentView;
}
- (NSMutableArray<UILabel *> *)itemLabels {
if (!_itemLabels) {
_itemLabels = [NSMutableArray array];
}
return _itemLabels;
}
- (CGSize)preferredSize {
NSUInteger count = self.variants.count;
if (count == 0) { return CGSizeMake(0, 0); }
CGFloat width = kKBKeyVariantPopupPaddingX * 2 + kKBKeyVariantItemWidth * (CGFloat)count;
CGFloat height = kKBKeyVariantPopupPaddingY * 2 + kKBKeyVariantItemHeight;
return CGSizeMake(width, height);
}
- (void)configureWithVariants:(NSArray<NSString *> *)variants selectedIndex:(NSInteger)selectedIndex {
self.variants = variants ?: @[];
self.selectedIndex = MAX(0, MIN(selectedIndex, (NSInteger)self.variants.count - 1));
[self.itemLabels makeObjectsPerformSelector:@selector(removeFromSuperview)];
[self.itemLabels removeAllObjects];
UILabel *previous = nil;
for (NSInteger i = 0; i < (NSInteger)self.variants.count; i++) {
UILabel *label = [UILabel new];
label.textAlignment = NSTextAlignmentCenter;
label.text = self.variants[i];
label.font = [UIFont systemFontOfSize:20 weight:UIFontWeightSemibold];
label.layer.cornerRadius = 8.0;
label.layer.masksToBounds = YES;
[self.contentView addSubview:label];
[self.itemLabels addObject:label];
[label mas_makeConstraints:^(MASConstraintMaker *make) {
make.top.bottom.equalTo(self.contentView);
make.width.mas_equalTo(kKBKeyVariantItemWidth);
if (previous) {
make.left.equalTo(previous.mas_right);
} else {
make.left.equalTo(self.contentView.mas_left);
}
}];
previous = label;
}
if (previous) {
[previous mas_makeConstraints:^(MASConstraintMaker *make) {
make.right.equalTo(self.contentView.mas_right);
}];
}
[self kb_refreshSelectionAppearance];
}
- (void)updateSelectionWithPointInSelf:(CGPoint)point {
if (self.variants.count == 0) { return; }
CGPoint p = [self convertPoint:point toView:self.contentView];
CGFloat contentWidth = CGRectGetWidth(self.contentView.bounds);
if (contentWidth <= 0) { return; }
NSInteger count = (NSInteger)self.variants.count;
CGFloat unit = contentWidth / (CGFloat)count;
NSInteger index = (NSInteger)floor(p.x / MAX(unit, 1.0));
index = MAX(0, MIN(index, count - 1));
if (index == self.selectedIndex) { return; }
self.selectedIndex = index;
[self kb_refreshSelectionAppearance];
}
- (nullable NSString *)selectedVariant {
if (self.selectedIndex < 0 || self.selectedIndex >= (NSInteger)self.variants.count) { return nil; }
return self.variants[self.selectedIndex];
}
- (void)kb_refreshSelectionAppearance {
KBSkinTheme *t = [KBSkinManager shared].current;
UIColor *textColor = t.keyTextColor ?: [UIColor blackColor];
UIColor *selectedBg = t.keyHighlightBackground ?: (t.accentColor ?: [UIColor colorWithWhite:0 alpha:0.12]);
for (NSInteger i = 0; i < (NSInteger)self.itemLabels.count; i++) {
UILabel *label = self.itemLabels[i];
label.textColor = textColor;
label.backgroundColor = (i == self.selectedIndex) ? selectedBg : [UIColor clearColor];
}
}
@end
@interface KBKeyboardView ()
@property (nonatomic, strong) NSMutableArray<UIView *> *rowViews;
@property (nonatomic, strong) NSArray<NSArray<KBKey *> *> *keysForRows;
@property (nonatomic, strong) KBBackspaceLongPressHandler *backspaceHandler;
@property (nonatomic, strong) KBKeyboardLayoutConfig *layoutConfig;
@property (nonatomic, strong) KBKeyboardKeyFactory *keyFactory;
@property (nonatomic, strong) KBKeyboardLayoutEngine *layoutEngine;
@property (nonatomic, strong) KBKeyboardRowBuilder *rowBuilder;
@property (nonatomic, strong) KBKeyboardInputHandler *inputHandler;
@property (nonatomic, strong) KBKeyboardLegacyBuilder *legacyBuilder;
@property (nonatomic, strong) KBKeyboardLegacyLayoutProvider *legacyLayoutProvider;
@property (nonatomic, strong) KBKeyboardInteractionHandler *interactionHandler;
@property (nonatomic, strong) KBKeyboardRowContainerBuilder *rowContainerBuilder;
/// 跨行统一字符键宽度按最多字符键的行计算0 表示不启用
@property (nonatomic, assign) CGFloat kb_uniformCharKeyWidth;
/// 记录当前行间距,便于切换布局时判断是否需要重建容器
@property (nonatomic, assign) CGFloat kb_currentRowSpacing;
/// 记录当前顶/底间距,便于切换布局时判断是否需要重建容器
@property (nonatomic, assign) CGFloat kb_currentTopInset;
@property (nonatomic, assign) CGFloat kb_currentBottomInset;
/// 长按字符变体(如葡语重音字符)
@property (nonatomic, strong) KBKeyVariantPopupView *kb_variantPopupView;
@property (nonatomic, weak) KBKeyButton *kb_variantAnchorButton;
@property (nonatomic, copy) NSString *kb_variantBaseOutput;
@property (nonatomic, assign) BOOL kb_variantBaseDeleted;
@property (nonatomic, assign) NSUInteger kb_variantBaseDeleteCount;
@property (nonatomic, copy) NSString *kb_currentLanguageCode;
@end
@implementation KBKeyboardView
- (instancetype)initWithFrame:(CGRect)frame {
if (self = [super initWithFrame:frame]) {
self.backgroundColor = [UIColor clearColor];
_layoutStyle = KBKeyboardLayoutStyleLetters;
// 默认小写:与需求一致,初始不开启 Shift
_shiftOn = NO;
_symbolsMoreOn = NO; // 数字面板默认第一页123
// 从 App Group 读取当前 profileId 并设置布局
NSString *profileId = [[KBKeyboardLayoutResolver sharedResolver] currentProfileId];
if (profileId.length > 0) {
_currentLayoutJsonId = [[KBKeyboardLayoutResolver sharedResolver] layoutJsonIdForProfileId:profileId];
NSLog(@"[KBKeyboardView] Loaded profileId: %@, layoutJsonId: %@", profileId, _currentLayoutJsonId);
} else {
_currentLayoutJsonId = @"letters";
NSLog(@"[KBKeyboardView] No profileId found, using default 'letters'");
}
self.layoutConfig = [KBKeyboardLayoutConfig sharedConfig];
self.backspaceHandler = [[KBBackspaceLongPressHandler alloc] initWithContainerView:self];
[self buildBase];
}
return self;
}
// 当切换大布局(字母/数字)时,重置数字二级页状态
- (void)setLayoutStyle:(KBKeyboardLayoutStyle)layoutStyle {
_layoutStyle = layoutStyle;
if (_layoutStyle != KBKeyboardLayoutStyleNumbers) {
_symbolsMoreOn = NO;
}
}
#pragma mark - Base Layout
- (void)buildBase {
KBKeyboardLayout *layout = [self kb_currentLayout];
NSArray<KBKeyboardRowConfig *> *rows = layout.rows ?: @[];
if (rows.count == 0) {
// Fallback: 至少创建 4 行容器
rows = @[[KBKeyboardRowConfig new], [KBKeyboardRowConfig new],
[KBKeyboardRowConfig new], [KBKeyboardRowConfig new]];
}
CGFloat rowSpacing = [self.layoutEngine rowSpacingForLayout:layout];
CGFloat topInset = [self.layoutEngine topInsetForLayout:layout];
CGFloat bottomInset = [self.layoutEngine bottomInsetForLayout:layout];
self.kb_currentRowSpacing = rowSpacing;
self.kb_currentTopInset = topInset;
self.kb_currentBottomInset = bottomInset;
[self.rowContainerBuilder rebuildRowContainersForRows:rows
inContainer:self
rowViews:self.rowViews
rowSpacing:rowSpacing
topInset:topInset
bottomInset:bottomInset];
[self.interactionHandler bringPreviewToFrontIfNeededInContainer:self];
}
#pragma mark - Public
- (void)reloadKeys {
[self.backspaceHandler bindDeleteButton:nil showClearLabel:NO];
[self kb_hideVariantPopupIfNeeded];
self.kb_currentLanguageCode = [[KBKeyboardLayoutResolver sharedResolver] currentLanguageCode] ?: @"en";
KBKeyboardLayout *layout = [self kb_currentLayout];
CGFloat rowSpacing = [self.layoutEngine rowSpacingForLayout:layout];
CGFloat topInset = [self.layoutEngine topInsetForLayout:layout];
CGFloat bottomInset = [self.layoutEngine bottomInsetForLayout:layout];
NSLog(@"[KBKeyboardView] reloadKeys: layoutName=%@ rows=%lu shiftRows=%lu shiftOn=%d",
self.currentLayoutJsonId, (unsigned long)layout.rows.count, (unsigned long)layout.shiftRows.count, self.shiftOn);
NSArray<KBKeyboardRowConfig *> *rows = nil;
if (self.shiftOn && layout.shiftRows.count > 0) {
rows = layout.shiftRows;
} else {
rows = layout.rows ?: @[];
}
NSLog(@"[KBKeyboardView] reloadKeys: usingRows=%lu currentContainers=%lu",
(unsigned long)rows.count, (unsigned long)self.rowViews.count);
// 行数变化时(如从 4 行布局切到 5 行注音布局),重建行容器
if (rows.count >= 4 &&
(rows.count != self.rowViews.count ||
fabs(self.kb_currentRowSpacing - rowSpacing) > 0.1 ||
fabs(self.kb_currentTopInset - topInset) > 0.1 ||
fabs(self.kb_currentBottomInset - bottomInset) > 0.1)) {
self.kb_currentRowSpacing = rowSpacing;
self.kb_currentTopInset = topInset;
self.kb_currentBottomInset = bottomInset;
[self.rowContainerBuilder rebuildRowContainersForRows:rows
inContainer:self
rowViews:self.rowViews
rowSpacing:rowSpacing
topInset:topInset
bottomInset:bottomInset];
[self.interactionHandler bringPreviewToFrontIfNeededInContainer:self];
}
// 移除旧按钮
for (UIView *row in self.rowViews) {
[row.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
}
if (rows.count < 4) {
NSLog(@"[KBKeyboardView] reloadKeys: rows.count < 4, fallback to legacy");
self.kb_uniformCharKeyWidth = 0.0;
[self kb_buildLegacyLayout];
[self kb_bindVariantLongPressIfNeeded];
return;
}
// 计算跨行统一字符键宽度(若各行字符键数量不同,则按最多键的行为基准)
self.kb_uniformCharKeyWidth = [self.layoutEngine calculateUniformCharKeyWidthForRows:rows
keyFactory:self.keyFactory
shiftOn:self.shiftOn];
for (NSUInteger i = 0; i < rows.count && i < self.rowViews.count; i++) {
[self.rowBuilder buildRow:self.rowViews[i]
withRowConfig:rows[i]
uniformCharWidth:self.kb_uniformCharKeyWidth
shiftOn:self.shiftOn
backspaceHandler:self.backspaceHandler
target:self
action:@selector(onKeyTapped:)];
}
NSUInteger totalButtons = [self kb_totalKeyButtonCount];
NSLog(@"[KBKeyboardView] reloadKeys: totalButtons=%lu", (unsigned long)totalButtons);
if (totalButtons == 0) {
NSLog(@"[KBKeyboardView] config layout produced no keys, fallback to legacy.");
for (UIView *row in self.rowViews) {
[row.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
}
[self kb_buildLegacyLayout];
[self kb_bindVariantLongPressIfNeeded];
return;
}
[self kb_bindVariantLongPressIfNeeded];
}
- (void)didMoveToWindow {
[super didMoveToWindow];
if (!self.window) { return; }
if ([self kb_totalKeyButtonCount] > 0) { return; }
// 兜底:系统编辑菜单切出切回等场景下,若按键丢失则自动重建。
[self reloadKeys];
// 自动重建后再触发一次上层主题应用,避免“按键恢复了但皮肤背景没恢复”。
UIView *container = self.superview;
if ([container respondsToSelector:@selector(kb_applyTheme)]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[container performSelector:@selector(kb_applyTheme)];
#pragma clang diagnostic pop
}
}
- (NSUInteger)kb_totalKeyButtonCount {
NSUInteger total = 0;
for (UIView *row in self.rowViews) {
total += [self.interactionHandler collectKeyButtonsInView:row].count;
}
return total;
}
#pragma mark - Hit Test
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *hit = [super hitTest:point withEvent:event];
return [self.interactionHandler resolveHitView:hit
point:point
container:self
rowViews:self.rowViews];
}
#pragma mark - Config Helpers
- (KBKeyboardLayoutConfig *)kb_layoutConfig {
if (!self.layoutConfig) {
self.layoutConfig = [KBKeyboardLayoutConfig sharedConfig];
}
return self.layoutConfig;
}
- (KBKeyboardKeyFactory *)keyFactory {
if (!_keyFactory) {
_keyFactory = [[KBKeyboardKeyFactory alloc] initWithLayoutConfig:[self kb_layoutConfig]];
}
return _keyFactory;
}
- (KBKeyboardLayoutEngine *)layoutEngine {
if (!_layoutEngine) {
_layoutEngine = [[KBKeyboardLayoutEngine alloc] initWithLayoutConfig:[self kb_layoutConfig]];
}
return _layoutEngine;
}
- (KBKeyboardRowBuilder *)rowBuilder {
if (!_rowBuilder) {
_rowBuilder = [[KBKeyboardRowBuilder alloc] initWithLayoutConfig:[self kb_layoutConfig]
layoutEngine:self.layoutEngine
keyFactory:self.keyFactory];
}
return _rowBuilder;
}
- (KBKeyboardInputHandler *)inputHandler {
if (!_inputHandler) {
_inputHandler = [[KBKeyboardInputHandler alloc] init];
__weak typeof(self) weakSelf = self;
_inputHandler.onToggleShift = ^{
__strong typeof(weakSelf) self = weakSelf;
if (!self) { return; }
self.shiftOn = !self.shiftOn;
[self reloadKeys];
};
_inputHandler.onToggleSymbols = ^{
__strong typeof(weakSelf) self = weakSelf;
if (!self) { return; }
self.symbolsMoreOn = !self.symbolsMoreOn;
[self reloadKeys];
};
_inputHandler.onKeyTapped = ^(KBKey *key) {
__strong typeof(weakSelf) self = weakSelf;
if (!self) { return; }
if ([self.delegate respondsToSelector:@selector(keyboardView:didTapKey:)]) {
[self.delegate keyboardView:self didTapKey:key];
}
};
}
return _inputHandler;
}
- (KBKeyboardLegacyBuilder *)legacyBuilder {
if (!_legacyBuilder) {
_legacyBuilder = [[KBKeyboardLegacyBuilder alloc] init];
}
return _legacyBuilder;
}
- (KBKeyboardLegacyLayoutProvider *)legacyLayoutProvider {
if (!_legacyLayoutProvider) {
_legacyLayoutProvider = [[KBKeyboardLegacyLayoutProvider alloc] init];
}
return _legacyLayoutProvider;
}
- (KBKeyboardInteractionHandler *)interactionHandler {
if (!_interactionHandler) {
_interactionHandler = [[KBKeyboardInteractionHandler alloc] init];
}
return _interactionHandler;
}
- (KBKeyboardRowContainerBuilder *)rowContainerBuilder {
if (!_rowContainerBuilder) {
_rowContainerBuilder = [[KBKeyboardRowContainerBuilder alloc] init];
}
return _rowContainerBuilder;
}
- (KBKeyboardLayout *)kb_layoutForName:(NSString *)name {
return [[self kb_layoutConfig] layoutForName:name];
}
- (KBKeyboardLayout *)kb_currentLayout {
NSString *baseLayoutName = self.currentLayoutJsonId.length > 0 ? self.currentLayoutJsonId : @"letters";
if (self.layoutStyle == KBKeyboardLayoutStyleNumbers) {
// 优先查找当前语言的数字/符号布局,如 letters_es_numbers / letters_es_symbols
// 如果不存在,回退到通用布局 numbers / symbolsMore
NSString *numbersName = [NSString stringWithFormat:@"%@_numbers", baseLayoutName];
NSString *symbolsName = [NSString stringWithFormat:@"%@_symbols", baseLayoutName];
NSString *targetName = self.symbolsMoreOn ? symbolsName : numbersName;
KBKeyboardLayout *layout = [self kb_layoutForName:targetName];
if (layout && layout.rows.count >= 4) {
return layout;
}
// 回退到通用布局
return [self kb_layoutForName:(self.symbolsMoreOn ? @"symbolsMore" : @"numbers")];
}
return [self kb_layoutForName:baseLayoutName];
}
- (void)reloadLayoutWithProfileId:(NSString *)profileId {
if (profileId.length == 0) {
NSLog(@"[KBKeyboardView] reloadLayoutWithProfileId: empty profileId, ignoring");
return;
}
NSString *newLayoutJsonId = [[KBKeyboardLayoutResolver sharedResolver] layoutJsonIdForProfileId:profileId];
if ([newLayoutJsonId isEqualToString:self.currentLayoutJsonId]) {
NSLog(@"[KBKeyboardView] Layout already loaded: %@", newLayoutJsonId);
return;
}
NSLog(@"[KBKeyboardView] Switching layout from %@ to %@", self.currentLayoutJsonId, newLayoutJsonId);
self.currentLayoutJsonId = newLayoutJsonId;
// 重新加载键盘布局
[self reloadKeys];
}
- (void)kb_buildLegacyLayout {
self.keysForRows = [self.legacyLayoutProvider keysForLayoutStyleIsNumbers:(self.layoutStyle == KBKeyboardLayoutStyleNumbers)
shiftOn:self.shiftOn
symbolsMoreOn:self.symbolsMoreOn
keyFactory:self.keyFactory];
if (self.keysForRows.count < 4) { return; }
if (self.rowViews.count < 4) { return; }
[self.legacyBuilder buildRow:self.rowViews[0]
withKeys:self.keysForRows[0]
edgeSpacerMultiplier:0.0
shiftOn:self.shiftOn
backspaceHandler:self.backspaceHandler
target:self
action:@selector(onKeyTapped:)];
CGFloat row2Spacer = (self.layoutStyle == KBKeyboardLayoutStyleLetters)
? kKBLettersRow2EdgeSpacerMultiplier : 0.0;
[self.legacyBuilder buildRow:self.rowViews[1]
withKeys:self.keysForRows[1]
edgeSpacerMultiplier:row2Spacer
shiftOn:self.shiftOn
backspaceHandler:self.backspaceHandler
target:self
action:@selector(onKeyTapped:)];
[self.legacyBuilder buildRow:self.rowViews[2]
withKeys:self.keysForRows[2]
edgeSpacerMultiplier:0.0
shiftOn:self.shiftOn
backspaceHandler:self.backspaceHandler
target:self
action:@selector(onKeyTapped:)];
[self.legacyBuilder buildRow:self.rowViews[3]
withKeys:self.keysForRows[3]
edgeSpacerMultiplier:0.0
shiftOn:self.shiftOn
backspaceHandler:self.backspaceHandler
target:self
action:@selector(onKeyTapped:)];
}
#pragma mark - Actions
- (void)onKeyTapped:(KBKeyButton *)sender {
[self.inputHandler handleKeyTap:sender.key];
}
// 在字符键按下时,显示一个上方气泡预览(类似系统键盘)。
- (void)showPreviewForButton:(KBKeyButton *)button {
[self.interactionHandler showPreviewForButton:button inContainer:self];
}
- (void)hidePreview {
[self.interactionHandler hidePreview];
}
#pragma mark - Key Variant (Diacritics)
- (void)kb_hideVariantPopupIfNeeded {
if (!self.kb_variantPopupView || self.kb_variantPopupView.hidden) { return; }
self.kb_variantPopupView.hidden = YES;
self.kb_variantPopupView.alpha = 1.0;
self.kb_variantAnchorButton = nil;
self.kb_variantBaseOutput = nil;
self.kb_variantBaseDeleted = NO;
self.kb_variantBaseDeleteCount = 0;
}
- (void)kb_bindVariantLongPressIfNeeded {
NSString *lc = kb_normalizeLanguageCode(self.kb_currentLanguageCode);
NSString *baseLc = kb_baseLanguageCode(self.kb_currentLanguageCode);
NSDictionary<NSString *, NSDictionary<NSString *, NSArray<NSString *> *> *> *languages = kb_diacriticsLanguagesMap();
if (languages.count == 0) { return; }
NSDictionary *commonMap = [languages[@"common"] isKindOfClass:[NSDictionary class]] ? languages[@"common"] : nil;
if (!commonMap && ![languages[lc] isKindOfClass:[NSDictionary class]] && ![languages[baseLc] isKindOfClass:[NSDictionary class]]) { return; }
for (UIView *row in self.rowViews) {
NSArray<KBKeyButton *> *buttons = [self.interactionHandler collectKeyButtonsInView:row];
for (KBKeyButton *btn in buttons) {
if (![btn isKindOfClass:[KBKeyButton class]]) { continue; }
if (btn.key.type != KBKeyTypeCharacter) { continue; }
NSArray<NSString *> *variants = [self kb_variantsForKey:btn.key languageCode:lc];
if (variants.count <= 1) { continue; }
NSNumber *bound = objc_getAssociatedObject(btn, kKBDiacriticsLongPressBoundKey);
if (bound.boolValue) { continue; }
objc_setAssociatedObject(btn, kKBDiacriticsLongPressBoundKey, @(YES), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self
action:@selector(onKeyVariantLongPress:)];
longPress.minimumPressDuration = kKBKeyVariantLongPressMinDuration;
longPress.allowableMovement = CGFLOAT_MAX;
longPress.cancelsTouchesInView = NO;
[btn addGestureRecognizer:longPress];
}
}
}
- (void)onKeyVariantLongPress:(UILongPressGestureRecognizer *)gr {
KBKeyButton *button = (KBKeyButton *)gr.view;
if (![button isKindOfClass:[KBKeyButton class]]) { return; }
if (button.key.type != KBKeyTypeCharacter) { return; }
NSString *lc = kb_normalizeLanguageCode(self.kb_currentLanguageCode);
NSArray<NSString *> *variants = [self kb_variantsForKey:button.key languageCode:lc];
if (variants.count <= 1) { return; }
switch (gr.state) {
case UIGestureRecognizerStateBegan: {
[self hidePreview];
self.kb_variantAnchorButton = button;
self.kb_variantBaseOutput = (button.key.output.length > 0 ? button.key.output : button.key.title) ?: @"";
self.kb_variantBaseDeleted = NO;
self.kb_variantBaseDeleteCount = kb_composedCharacterCount(self.kb_variantBaseOutput);
if (@available(iOS 10.0, *)) {
UIImpactFeedbackGenerator *gen = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
[gen prepare];
[gen impactOccurred];
}
if (!self.kb_variantPopupView) {
self.kb_variantPopupView = [[KBKeyVariantPopupView alloc] initWithFrame:CGRectZero];
self.kb_variantPopupView.hidden = YES;
[self addSubview:self.kb_variantPopupView];
} else if (self.kb_variantPopupView.superview != self) {
[self addSubview:self.kb_variantPopupView];
}
[self.kb_variantPopupView applyTheme];
[self.kb_variantPopupView configureWithVariants:variants selectedIndex:0];
CGSize preferred = [self.kb_variantPopupView preferredSize];
CGRect anchorFrame = [button convertRect:button.bounds toView:self];
CGFloat x = CGRectGetMidX(anchorFrame) - preferred.width * 0.5;
CGFloat maxX = CGRectGetWidth(self.bounds) - 6.0 - preferred.width;
x = MIN(MAX(6.0, x), MAX(6.0, maxX));
CGFloat y = CGRectGetMinY(anchorFrame) - preferred.height - kKBKeyVariantPopupAnchorGap;
y = MAX(2.0, y);
self.kb_variantPopupView.frame = CGRectMake(x, y, preferred.width, preferred.height);
self.kb_variantPopupView.alpha = 0.0;
self.kb_variantPopupView.hidden = NO;
[self bringSubviewToFront:self.kb_variantPopupView];
[UIView animateWithDuration:0.12
delay:0
options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseOut
animations:^{
self.kb_variantPopupView.alpha = 1.0;
}
completion:nil];
} break;
case UIGestureRecognizerStateChanged: {
if (self.kb_variantPopupView.hidden) { return; }
CGPoint p = [gr locationInView:self.kb_variantPopupView];
[self.kb_variantPopupView updateSelectionWithPointInSelf:p];
if (!self.kb_variantBaseDeleted && self.kb_variantPopupView.selectedIndex != 0) {
[self kb_emitBackspaceCountForVariantReplacement:self.kb_variantBaseDeleteCount];
self.kb_variantBaseDeleted = YES;
}
} break;
case UIGestureRecognizerStateEnded: {
if (self.kb_variantPopupView.hidden) { return; }
CGPoint p = [gr locationInView:self.kb_variantPopupView];
[self.kb_variantPopupView updateSelectionWithPointInSelf:p];
[self kb_commitSelectedVariantAndCleanupWithIdentifier:button.key.identifier];
} break;
case UIGestureRecognizerStateCancelled:
case UIGestureRecognizerStateFailed: {
if (self.kb_variantPopupView.hidden) { return; }
// 取消时尽量保持“原本已经插入的 base 字符”不变:若已删除则补回
if (self.kb_variantBaseDeleted && self.kb_variantBaseOutput.length > 0) {
[self kb_emitCharacterText:self.kb_variantBaseOutput identifier:button.key.identifier];
}
[self kb_hideVariantPopupIfNeeded];
} break;
default:
break;
}
}
- (void)kb_emitBackspaceCountForVariantReplacement:(NSUInteger)count {
if (count == 0) { count = 1; }
if (![self.delegate respondsToSelector:@selector(keyboardView:didTapKey:)]) { return; }
KBKey *backspace = [KBKey keyWithTitle:@"" type:KBKeyTypeBackspace];
for (NSUInteger i = 0; i < count; i++) {
[self.delegate keyboardView:self didTapKey:backspace];
}
}
- (void)kb_emitCharacterText:(NSString *)text identifier:(NSString *)identifier {
if (text.length == 0) { return; }
if (![self.delegate respondsToSelector:@selector(keyboardView:didTapKey:)]) { return; }
KBKey *key = [KBKey keyWithIdentifier:identifier title:text output:text type:KBKeyTypeCharacter];
[self.delegate keyboardView:self didTapKey:key];
}
- (void)kb_commitSelectedVariantAndCleanupWithIdentifier:(NSString *)identifier {
NSInteger index = self.kb_variantPopupView.selectedIndex;
NSString *selected = [self.kb_variantPopupView selectedVariant] ?: @"";
NSString *base = self.kb_variantBaseOutput ?: @"";
if (index <= 0 || selected.length == 0) {
if (self.kb_variantBaseDeleted && base.length > 0) {
[self kb_emitCharacterText:base identifier:identifier];
}
[self kb_hideVariantPopupIfNeeded];
return;
}
if (!self.kb_variantBaseDeleted) {
[self kb_emitBackspaceCountForVariantReplacement:self.kb_variantBaseDeleteCount];
self.kb_variantBaseDeleted = YES;
}
[self kb_emitCharacterText:selected identifier:identifier];
[self kb_hideVariantPopupIfNeeded];
}
- (NSArray<NSString *> *)kb_variantsForKey:(KBKey *)key languageCode:(NSString *)languageCode {
if (!key || key.type != KBKeyTypeCharacter) { return @[]; }
NSString *base = (key.output.length > 0 ? key.output : key.title) ?: @"";
if (base.length == 0) { return @[]; }
NSDictionary<NSString *, NSDictionary<NSString *, NSArray<NSString *> *> *> *languages = kb_diacriticsLanguagesMap();
NSString *lc = kb_normalizeLanguageCode(languageCode);
NSDictionary<NSString *, NSArray<NSString *> *> *langMap = [languages[lc] isKindOfClass:[NSDictionary class]] ? languages[lc] : nil;
if (langMap.count == 0) {
NSString *baseLc = kb_baseLanguageCode(languageCode);
langMap = [languages[baseLc] isKindOfClass:[NSDictionary class]] ? languages[baseLc] : nil;
}
NSDictionary<NSString *, NSArray<NSString *> *> *commonMap = [languages[@"common"] isKindOfClass:[NSDictionary class]] ? languages[@"common"] : nil;
if (langMap.count == 0 && commonMap.count == 0) { return @[]; }
BOOL hasLetter = NO;
NSCharacterSet *letters = [NSCharacterSet letterCharacterSet];
for (NSUInteger i = 0; i < base.length; i++) {
unichar c = [base characterAtIndex:i];
if ([letters characterIsMember:c]) {
hasLetter = YES;
break;
}
}
BOOL upper = hasLetter && [base isEqualToString:base.uppercaseString] && ![base isEqualToString:base.lowercaseString];
NSString *queryKey = upper ? base.lowercaseString : base;
id raw = langMap[queryKey];
if (!(raw && [raw isKindOfClass:[NSArray class]] && ((NSArray *)raw).count > 0)) {
raw = commonMap[queryKey];
}
if (![raw isKindOfClass:[NSArray class]]) { return @[]; }
NSMutableArray<NSString *> *variants = [NSMutableArray array];
for (id obj in (NSArray *)raw) {
if (![obj isKindOfClass:[NSString class]]) { continue; }
NSString *s = (NSString *)obj;
if (s.length == 0) { continue; }
[variants addObject:s];
}
if (variants.count == 0) { return @[]; }
if (![[variants firstObject] isEqualToString:queryKey]) {
[variants insertObject:queryKey atIndex:0];
}
if (variants.count <= 1) { return @[]; }
if (!upper) {
return variants.copy;
}
NSMutableArray<NSString *> *upperVariants = [NSMutableArray arrayWithCapacity:variants.count];
for (NSString *s in variants) {
[upperVariants addObject:s.uppercaseString];
}
return upperVariants.copy;
}
#pragma mark - Lazy
- (NSMutableArray<UIView *> *)rowViews {
if (!_rowViews) _rowViews = [NSMutableArray array];
return _rowViews;
}
@end