849 lines
35 KiB
Objective-C
849 lines
35 KiB
Objective-C
//
|
||
// 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 (!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
|