添加变体

This commit is contained in:
2026-03-05 21:21:15 +08:00
parent 3c18579a83
commit bb74a330db
3 changed files with 518 additions and 0 deletions

View File

@@ -17,9 +17,215 @@
#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;
@@ -41,6 +247,14 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
/// /便
@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
@@ -107,6 +321,8 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
- (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];
@@ -153,6 +369,7 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
NSLog(@"[KBKeyboardView] reloadKeys: rows.count < 4, fallback to legacy");
self.kb_uniformCharKeyWidth = 0.0;
[self kb_buildLegacyLayout];
[self kb_bindVariantLongPressIfNeeded];
return;
}
@@ -179,7 +396,11 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
[row.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
}
[self kb_buildLegacyLayout];
[self kb_bindVariantLongPressIfNeeded];
return;
}
[self kb_bindVariantLongPressIfNeeded];
}
- (void)didMoveToWindow {
@@ -404,6 +625,219 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
[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 {