Compare commits
186 Commits
main
...
1c9013bede
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c9013bede | |||
| 0a16a4f240 | |||
| 27d4b2b817 | |||
| bc623676ca | |||
| 5edf1751ff | |||
| 0ac47925fd | |||
| 635ad932c7 | |||
| cbe0a53cac | |||
| 5c273c3963 | |||
| c9743cb363 | |||
| f0cb69948e | |||
| 0144f9cc6d | |||
| ae4070ae88 | |||
| a83fd918a8 | |||
| 4168da618e | |||
| d2ffada83f | |||
| 76d387e08b | |||
| ea0df4fb19 | |||
| 02323fb5f1 | |||
| 3c71797b7b | |||
| 4c57f16058 | |||
| cb2e8467a7 | |||
| 4dfd6f5cbb | |||
| e4223b3a4c | |||
| 3d19403539 | |||
| 3cb02d5b76 | |||
| 750b391100 | |||
| faccf6f10f | |||
| 35b1fc0f1e | |||
| b73f225d15 | |||
| dd59094a16 | |||
| bacaf537f3 | |||
| 619d356d31 | |||
| db9f07d199 | |||
| 3ed120106e | |||
| ff4edab820 | |||
| 3e30f619b9 | |||
| 533e23ebfe | |||
| 85fb407717 | |||
| c1b50b407d | |||
| 7c7e2477cb | |||
| faae0297cb | |||
| e50eaecbd9 | |||
| 879dbb860c | |||
| b4e4b7b606 | |||
| 68a610e0a8 | |||
| 305326aa9a | |||
| 61095a379f | |||
| 822a814f85 | |||
| 0bd0392191 | |||
| b9663037f5 | |||
| a0923c8572 | |||
| d482cfcb7d | |||
| 9e6d2906f8 | |||
| 6f7bb4f960 | |||
| fa9af5ff1b | |||
| 08628bcd1d | |||
| 19cb29616f | |||
| 6e50cdcd2a | |||
| f1b52151be | |||
| 993ec623af | |||
| 0416a64235 | |||
| 2b75ad90fb | |||
| 0ac9030f80 | |||
| ea9c40f64f | |||
| 48c90fa0be | |||
| fe59a0cb45 | |||
| 81bc50ce17 | |||
| 6ae504823b | |||
| d2f582b7f8 | |||
| cc82396195 | |||
| 2ff8a7a4af | |||
| 3c0b7e754c | |||
| 3705db4aab | |||
| 36774a8a2c | |||
| 36135313d8 | |||
| 23c0d14128 | |||
| d0c5cada35 | |||
| b556e6841d | |||
| 26096abbcc | |||
| 766c62f3c0 | |||
| 07a77149fc | |||
| 32ebc6fb65 | |||
| 25fbe9b64e | |||
| 4392296616 | |||
| ef52cd4872 | |||
| 70a8466d9f | |||
| 66d85f78a0 | |||
| 93a20cd92a | |||
| 9a54a2ae6c | |||
| 1b9ce1622d | |||
| b4db79eba8 | |||
| 22f77d56ea | |||
| d8d5bdc3ae | |||
| 7d583ceb1d | |||
| 51b744ecd7 | |||
| 3fd7d2af2e | |||
| db869552e4 | |||
| b34de116a3 | |||
| e67bc37571 | |||
| 2b749cd2b0 | |||
| ce889e1ed0 | |||
| e8b4b2c58a | |||
| 3a5a6395af | |||
| a22599feda | |||
| 6a177ceebc | |||
| f9d7579536 | |||
| 0fa31418f6 | |||
| 77fd46aa34 | |||
| 6ad9783bcb | |||
| edc25c159d | |||
| 06a572c08a | |||
| 36c0b0b210 | |||
| d1d47336c2 | |||
| 063ceae10f | |||
| 552387293c | |||
| 93489b09d9 | |||
| 663cb8493b | |||
| ac0d9584d8 | |||
| 7fa124d45f | |||
| 3dfb8f31e2 | |||
| 619c02f236 | |||
| 28852a8d4b | |||
| b021fd308f | |||
| 169a1929d7 | |||
| b5da9f35a5 | |||
| 8f4deaac4e | |||
| d479d1903b | |||
| 32c4138ae0 | |||
| da62d4f411 | |||
| 85dcd72a5d | |||
| 21fcbe3665 | |||
| 1b6724f043 | |||
| ef332ecaa1 | |||
| 3d6d673c0b | |||
| 674f09d5b6 | |||
| 11d8f78b1b | |||
| bbacef4ff7 | |||
| 8e692647d3 | |||
| 6f80f969a4 | |||
| bdf2a9af80 | |||
| e858d35722 | |||
| f2d5210313 | |||
| 1b0af3e2d6 | |||
| 0965cd3c7e | |||
| c3909d63da | |||
| 1096f24c57 | |||
| 7ed84fd445 | |||
| 4e2d7d2908 | |||
| 34089ddeea | |||
| 6ec98468de | |||
| 2d5919016f | |||
| c0fa51bb2e | |||
| 6713f36387 | |||
| f24750458a | |||
| 510a2f4d66 | |||
| ae37730da6 | |||
| 203f104ece | |||
| 8e934dd83a | |||
| 1676916a5c | |||
| 1af5a0e849 | |||
| 5b6e0a8fbf | |||
| 9968883bab | |||
| af5f637d31 | |||
| 0a725e845e | |||
| 6a539dc3c5 | |||
| 73d6ec933a | |||
| 000d603241 | |||
| fbf9fe9f2a | |||
| 8e4d7e1ee8 | |||
| 262eb57b36 | |||
| 2e1c261775 | |||
| 6ad2079351 | |||
| a477592f5d | |||
| 6f336e8368 | |||
| 17e038beb1 | |||
| 4e6fd90668 | |||
| 5cfc76e6c5 | |||
| 9e33c93763 | |||
| 1c9ae7bc06 | |||
| 472e9ad341 | |||
| 19c69f4f6f | |||
| 8788cbb105 | |||
| ea77e9a5f8 | |||
| eaaf0e1ed6 | |||
| 8a344b293d |
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"WebSearch",
|
||||
"Bash(git checkout:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
<array>
|
||||
<string>kbkeyboardAppExtension</string>
|
||||
</array>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要使用麦克风进行语音输入</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "切图 270@2x.png",
|
||||
"filename" : "切图 271@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "切图 270@3x.png",
|
||||
"filename" : "切图 271@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 11 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_key_icon.imageset/切图 271@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_key_icon.imageset/切图 271@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
22
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_close.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_limit_close@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_limit_close@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_close.imageset/ai_limit_close@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_close.imageset/ai_limit_close@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
22
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_goto.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_limit_goto@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_limit_goto@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_goto.imageset/ai_limit_goto@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_goto.imageset/ai_limit_goto@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
22
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_icon.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_limit_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "ai_limit_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_icon.imageset/ai_limit_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 358 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/ai_limit_icon.imageset/ai_limit_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 733 KiB |
@@ -5,12 +5,12 @@
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "key_123@2x.png",
|
||||
"filename" : "close_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "key_123@3x.png",
|
||||
"filename" : "close_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/close_icon.imageset/close_icon@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/close_icon.imageset/close_icon@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
@@ -4,15 +4,79 @@
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "kb_del_icon@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"filename" : "kb_del_icon@2x 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "切图 256@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "kb_del_icon@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "light"
|
||||
}
|
||||
],
|
||||
"filename" : "kb_del_icon@3x 1.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"filename" : "切图 256@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/kb_del_icon@2x 1.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/kb_del_icon@3x 1.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/切图 256@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 1008 B |
BIN
CustomKeyboard/KeyboardAssets.xcassets/kb_del_icon.imageset/切图 256@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
@@ -5,12 +5,12 @@
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "key_ai@2x.png",
|
||||
"filename" : "key_revoke@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "key_ai@3x.png",
|
||||
"filename" : "key_revoke@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
BIN
CustomKeyboard/KeyboardAssets.xcassets/key_revoke.imageset/key_revoke@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
CustomKeyboard/KeyboardAssets.xcassets/key_revoke.imageset/key_revoke@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
@@ -5,493 +5,237 @@
|
||||
// Created by Mac on 2025/10/27.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController.h"
|
||||
#import "KBKeyBoardMainView.h"
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBKey.h"
|
||||
#import "KBFunctionView.h"
|
||||
#import "KBSettingView.h"
|
||||
#import "Masonry.h"
|
||||
#import "KBAuthManager.h"
|
||||
#import "KBFullAccessManager.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBSkinInstallBridge.h"
|
||||
#import "KBHostAppLauncher.h"
|
||||
#import "KBKeyboardSubscriptionView.h"
|
||||
#import "KBKeyboardSubscriptionProduct.h"
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBChatLimitPopView.h"
|
||||
#import "KBChatPanelView.h"
|
||||
#import "KBFullAccessManager.h"
|
||||
#import "KBFunctionView.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
#import "KBKeyBoardMainView.h"
|
||||
#import "KBKeyboardSubscriptionView.h"
|
||||
#import "KBLocalizationManager.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBSuggestionEngine.h"
|
||||
#import <SDWebImage/SDWebImage.h>
|
||||
|
||||
// 提前声明一个类别,使编译器在 static 回调中识别 kb_consumePendingShopSkin 方法。
|
||||
@interface KeyboardViewController (KBSkinShopBridge)
|
||||
- (void)kb_consumePendingShopSkin;
|
||||
@end
|
||||
#if DEBUG
|
||||
#import <mach/mach.h>
|
||||
#endif
|
||||
|
||||
// 以 375 宽设计稿为基准的键盘总高度(包括顶部工具栏)
|
||||
static const CGFloat kKBKeyboardDesignHeight = 250.0f;
|
||||
#if DEBUG
|
||||
static NSInteger sKBKeyboardVCAliveCount = 0;
|
||||
|
||||
static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
void *observer,
|
||||
CFStringRef name,
|
||||
const void *object,
|
||||
CFDictionaryRef userInfo) {
|
||||
KeyboardViewController *strongSelf = (__bridge KeyboardViewController *)observer;
|
||||
if (!strongSelf) { return; }
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([strongSelf respondsToSelector:@selector(kb_consumePendingShopSkin)]) {
|
||||
[strongSelf kb_consumePendingShopSkin];
|
||||
}
|
||||
});
|
||||
static uint64_t KBPhysFootprintBytes(void) {
|
||||
task_vm_info_data_t vmInfo;
|
||||
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
|
||||
kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO,
|
||||
(task_info_t)&vmInfo, &count);
|
||||
if (kr != KERN_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
return (uint64_t)vmInfo.phys_footprint;
|
||||
}
|
||||
|
||||
@interface KeyboardViewController () <KBKeyBoardMainViewDelegate, KBFunctionViewDelegate, KBKeyboardSubscriptionViewDelegate>
|
||||
@property (nonatomic, strong) UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选)
|
||||
@property (nonatomic, strong) KBKeyBoardMainView *keyBoardMainView; // 功能面板视图(点击工具栏第0个时显示)
|
||||
@property (nonatomic, strong) KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示)
|
||||
@property (nonatomic, strong) KBSettingView *settingView; // 设置页
|
||||
@property (nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层)
|
||||
@property (nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView;
|
||||
@end
|
||||
static NSString *KBFormatMB(uint64_t bytes) {
|
||||
double mb = (double)bytes / 1024.0 / 1024.0;
|
||||
return [NSString stringWithFormat:@"%.1fMB", mb];
|
||||
}
|
||||
#endif
|
||||
|
||||
@implementation KeyboardViewController
|
||||
|
||||
{
|
||||
BOOL _kb_didTriggerLoginDeepLinkOnce;
|
||||
BOOL _kb_didTriggerLoginDeepLinkOnce;
|
||||
#if DEBUG
|
||||
BOOL _kb_debugDidCountAlive;
|
||||
#endif
|
||||
}
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
[self setupUI];
|
||||
// 指定 HUD 的承载视图(扩展里无法取到 App 的 KeyWindow)
|
||||
[KBHUD setContainerView:self.view];
|
||||
// 绑定完全访问管理器,便于统一感知和联动网络开关
|
||||
[[KBFullAccessManager shared] bindInputController:self];
|
||||
__unused id token = [[NSNotificationCenter defaultCenter] addObserverForName:KBFullAccessChangedNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(__unused NSNotification * _Nonnull note) {
|
||||
// 如需,可在此刷新与完全访问相关的 UI
|
||||
}];
|
||||
[super viewDidLoad];
|
||||
#if DEBUG
|
||||
if (!_kb_debugDidCountAlive) {
|
||||
_kb_debugDidCountAlive = YES;
|
||||
sKBKeyboardVCAliveCount += 1;
|
||||
}
|
||||
NSLog(@"[Keyboard] KeyboardViewController viewDidLoad alive=%ld self=%p mem=%@",
|
||||
(long)sKBKeyboardVCAliveCount, self, KBFormatMB(KBPhysFootprintBytes()));
|
||||
#endif
|
||||
// 撤销删除是“上一段删除操作”的临时状态;键盘被系统回收/重建或跨页面回来时应当清空,避免误显示。
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
[self setupUI];
|
||||
self.suggestionEngine = [KBSuggestionEngine shared];
|
||||
self.currentWord = @"";
|
||||
// 指定 HUD 的承载视图(扩展里无法取到 App 的 KeyWindow)
|
||||
[KBHUD setContainerView:self.view];
|
||||
// 绑定完全访问管理器,便于统一感知和联动网络开关
|
||||
[[KBFullAccessManager shared] bindInputController:self];
|
||||
self.kb_fullAccessObserverToken = [[NSNotificationCenter defaultCenter]
|
||||
addObserverForName:KBFullAccessChangedNotification
|
||||
object:nil
|
||||
queue:[NSOperationQueue mainQueue]
|
||||
usingBlock:^(__unused NSNotification *_Nonnull note){
|
||||
// 如需,可在此刷新与完全访问相关的 UI
|
||||
}];
|
||||
|
||||
// 皮肤变化时,立即应用
|
||||
__unused id token2 = [[NSNotificationCenter defaultCenter] addObserverForName:KBSkinDidChangeNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(__unused NSNotification * _Nonnull note) {
|
||||
[self kb_applyTheme];
|
||||
}];
|
||||
[self kb_applyTheme];
|
||||
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge const void *)(self),
|
||||
KBSkinInstallNotificationCallback,
|
||||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification,
|
||||
NULL,
|
||||
CFNotificationSuspensionBehaviorDeliverImmediately);
|
||||
[self kb_consumePendingShopSkin];
|
||||
|
||||
// 皮肤变化时,立即应用
|
||||
__weak typeof(self) weakSelf = self;
|
||||
self.kb_skinObserverToken = [[NSNotificationCenter defaultCenter]
|
||||
addObserverForName:KBSkinDidChangeNotification
|
||||
object:nil
|
||||
queue:[NSOperationQueue mainQueue]
|
||||
usingBlock:^(__unused NSNotification *_Nonnull note) {
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
[self kb_applyTheme];
|
||||
}];
|
||||
[self kb_applyTheme];
|
||||
[self kb_registerDarwinSkinInstallObserver];
|
||||
[self kb_consumePendingShopSkin];
|
||||
[self kb_applyDefaultSkinIfNeeded];
|
||||
}
|
||||
|
||||
- (void)viewWillAppear:(BOOL)animated{
|
||||
[super viewWillAppear:animated];
|
||||
[[KBLocalizationManager shared] reloadFromSharedStorageIfNeeded];
|
||||
- (void)didReceiveMemoryWarning {
|
||||
[super didReceiveMemoryWarning];
|
||||
// 扩展进程内存上限较小:在系统发出内存警告时主动清理可重建的缓存,降低被系统杀死概率。
|
||||
self.kb_cachedGradientImage = nil;
|
||||
[self.kb_defaultGradientLayer removeFromSuperlayer];
|
||||
self.kb_defaultGradientLayer = nil;
|
||||
[[KBSkinManager shared] clearRuntimeImageCaches];
|
||||
[[SDImageCache sharedImageCache] clearMemory];
|
||||
}
|
||||
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
[super viewWillAppear:animated];
|
||||
// FIX: iOS 26 键盘闪烁问题 —— 恢复键盘正确高度
|
||||
// setupUI 中高度初始为 0(防止系统预渲染快照闪烁),此处恢复为实际键盘高度。
|
||||
// 此时系统已准备好键盘滑入动画,恢复高度后键盘将正常从底部滑入。
|
||||
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
|
||||
if (self.kb_heightConstraint) {
|
||||
self.kb_heightConstraint.constant = keyboardHeight;
|
||||
}
|
||||
// 进入/重新进入输入界面时,清理上一次会话残留的撤销状态与缓存,避免显示“撤销删除”但实际上已不可撤销。
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
[[KBInputBufferManager shared] resetWithText:@""];
|
||||
[[KBLocalizationManager shared] reloadFromSharedStorageIfNeeded];
|
||||
// 键盘再次出现时,恢复 HUD 容器与主题(viewDidDisappear 里可能已清理图片/缓存)。
|
||||
[KBHUD setContainerView:self.view];
|
||||
[self kb_ensureKeyBoardMainViewIfNeeded];
|
||||
[self kb_applyTheme];
|
||||
#if DEBUG
|
||||
NSLog(@"[Keyboard] viewWillAppear self=%p mem=%@",
|
||||
self, KBFormatMB(KBPhysFootprintBytes()));
|
||||
#endif
|
||||
// 注意:微信/QQ 等宿主的 documentContext 可能是“截断窗口”,这里只更新
|
||||
// liveText,不要把它当作全文 manualSnapshot。
|
||||
[[KBInputBufferManager shared]
|
||||
updateFromExternalContextBefore:self.textDocumentProxy
|
||||
.documentContextBeforeInput
|
||||
after:self.textDocumentProxy
|
||||
.documentContextAfterInput];
|
||||
}
|
||||
|
||||
- (void)setupUI {
|
||||
self.view.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
|
||||
// 按屏幕宽度对设计值做等比缩放,避免在不同机型上键盘整体高度失真导致皮肤被压缩/拉伸
|
||||
CGFloat keyboardHeight = KBFit(kKBKeyboardDesignHeight);
|
||||
CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;
|
||||
CGFloat outerVerticalInset = KBFit(4.0f);
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
[super viewWillDisappear:animated];
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
[self kb_releaseMemoryWhenKeyboardHidden];
|
||||
#if DEBUG
|
||||
NSLog(@"[Keyboard] viewWillDisappear self=%p mem=%@",
|
||||
self, KBFormatMB(KBPhysFootprintBytes()));
|
||||
#endif
|
||||
}
|
||||
|
||||
NSLayoutConstraint *h = [self.view.heightAnchor constraintEqualToConstant:keyboardHeight];
|
||||
NSLayoutConstraint *w = [self.view.widthAnchor constraintEqualToConstant:screenWidth];
|
||||
- (void)viewDidDisappear:(BOOL)animated {
|
||||
[super viewDidDisappear:animated];
|
||||
// 再兜底一次,防止某些宿主只触发 willDisappear 而未触发 didDisappear。
|
||||
[self kb_releaseMemoryWhenKeyboardHidden];
|
||||
}
|
||||
|
||||
h.priority = UILayoutPriorityRequired;
|
||||
w.priority = UILayoutPriorityRequired;
|
||||
[NSLayoutConstraint activateConstraints:@[h, w]];
|
||||
// 关闭 UIInputView 自适应(某些系统版本会尝试放大为全屏高度导致冲突)
|
||||
if ([self.view isKindOfClass:[UIInputView class]]) {
|
||||
UIInputView *iv = (UIInputView *)self.view;
|
||||
if ([iv respondsToSelector:@selector(setAllowsSelfSizing:)]) {
|
||||
iv.allowsSelfSizing = NO;
|
||||
}
|
||||
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
|
||||
[super traitCollectionDidChange:previousTraitCollection];
|
||||
if (@available(iOS 13.0, *)) {
|
||||
if (previousTraitCollection.userInterfaceStyle !=
|
||||
self.traitCollection.userInterfaceStyle) {
|
||||
self.kb_cachedGradientImage = nil;
|
||||
[self kb_applyDefaultSkinIfNeeded];
|
||||
}
|
||||
// 背景图铺底
|
||||
[self.view addSubview:self.bgImageView];
|
||||
[self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.view);
|
||||
}];
|
||||
// 预置功能面板(默认隐藏),与键盘区域共享相同布局
|
||||
self.functionView.hidden = YES;
|
||||
[self.view addSubview:self.functionView];
|
||||
[self.functionView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self.view);
|
||||
make.top.equalTo(self.view).offset(0);
|
||||
make.bottom.equalTo(self.view).offset(0);
|
||||
}];
|
||||
|
||||
[self.view addSubview:self.keyBoardMainView];
|
||||
[self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self.view);
|
||||
make.top.equalTo(self.view).offset(0);
|
||||
make.bottom.equalTo(self.view.mas_bottom).offset(-0);
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
/// 切换显示功能面板/键盘主视图
|
||||
- (void)showFunctionPanel:(BOOL)show {
|
||||
// 简单显隐切换,复用相同的布局区域
|
||||
self.functionView.hidden = !show;
|
||||
self.keyBoardMainView.hidden = show;
|
||||
|
||||
if (show) {
|
||||
[self hideSubscriptionPanel];
|
||||
}
|
||||
|
||||
// 可选:把当前显示的视图置顶,避免层级遮挡
|
||||
if (show) {
|
||||
[self.view bringSubviewToFront:self.functionView];
|
||||
} else {
|
||||
[self.view bringSubviewToFront:self.keyBoardMainView];
|
||||
}
|
||||
- (void)textDidChange:(id<UITextInput>)textInput {
|
||||
[super textDidChange:textInput];
|
||||
[[KBInputBufferManager shared]
|
||||
updateFromExternalContextBefore:self.textDocumentProxy
|
||||
.documentContextBeforeInput
|
||||
after:self.textDocumentProxy
|
||||
.documentContextAfterInput];
|
||||
}
|
||||
|
||||
/// 显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出
|
||||
- (void)showSettingView:(BOOL)show {
|
||||
if (show) {
|
||||
// if (!self.settingView) {
|
||||
self.settingView = [[KBSettingView alloc] init];
|
||||
self.settingView.hidden = YES;
|
||||
[self.view addSubview:self.settingView];
|
||||
[self.settingView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
// 与键盘主视图完全等同的区域,保证高度、宽度一致
|
||||
make.edges.equalTo(self.keyBoardMainView);
|
||||
}];
|
||||
[self.settingView.backButton addTarget:self action:@selector(onTapSettingsBack) forControlEvents:UIControlEventTouchUpInside];
|
||||
// }
|
||||
[self.view bringSubviewToFront:self.settingView];
|
||||
// 以 keyBoardMainView 的实际宽度为准,避免首次添加时 self.view 宽度尚未计算
|
||||
[self.view layoutIfNeeded];
|
||||
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
|
||||
if (w <= 0) { w = CGRectGetWidth(self.view.bounds); }
|
||||
if (w <= 0) { w = [UIScreen mainScreen].bounds.size.width; }
|
||||
self.settingView.transform = CGAffineTransformMakeTranslation(w, 0);
|
||||
self.settingView.hidden = NO;
|
||||
[UIView animateWithDuration:0.25 delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{
|
||||
self.settingView.transform = CGAffineTransformIdentity;
|
||||
} completion:nil];
|
||||
} else {
|
||||
if (!self.settingView || self.settingView.hidden) return;
|
||||
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
|
||||
if (w <= 0) { w = CGRectGetWidth(self.view.bounds); }
|
||||
if (w <= 0) { w = [UIScreen mainScreen].bounds.size.width; }
|
||||
[UIView animateWithDuration:0.22 delay:0 options:UIViewAnimationOptionCurveEaseIn animations:^{
|
||||
self.settingView.transform = CGAffineTransformMakeTranslation(w, 0);
|
||||
} completion:^(BOOL finished) {
|
||||
self.settingView.hidden = YES;
|
||||
}];
|
||||
}
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
[super viewDidAppear:animated];
|
||||
// if (!_kb_didTriggerLoginDeepLinkOnce) {
|
||||
// _kb_didTriggerLoginDeepLinkOnce = YES;
|
||||
// // 仅在未登录时尝试拉起主App登录
|
||||
// if (!KBAuthManager.shared.isLoggedIn) {
|
||||
// [self kb_tryOpenContainerForLoginIfNeeded];
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
- (void)showSubscriptionPanel {
|
||||
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
// 未开启完全访问:保持原有引导路径
|
||||
// [KBHUD showInfo:KBLocalized(@"处理中…")];
|
||||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view];
|
||||
return;
|
||||
}
|
||||
// 点击充值要先判断是否登录
|
||||
// 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App 负责完成登录
|
||||
if (!KBAuthManager.shared.isLoggedIn) {
|
||||
NSString *schemeStr = [NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
return;
|
||||
}
|
||||
[self showFunctionPanel:NO];
|
||||
KBKeyboardSubscriptionView *panel = self.subscriptionView;
|
||||
if (!panel.superview) {
|
||||
panel.hidden = YES;
|
||||
[self.view addSubview:panel];
|
||||
[panel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.keyBoardMainView);
|
||||
}];
|
||||
}
|
||||
[self.view bringSubviewToFront:panel];
|
||||
panel.hidden = NO;
|
||||
panel.alpha = 0.0;
|
||||
CGFloat height = CGRectGetHeight(self.view.bounds);
|
||||
if (height <= 0) { height = 260; }
|
||||
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
||||
[panel refreshProductsIfNeeded];
|
||||
[UIView animateWithDuration:0.25 delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{
|
||||
panel.alpha = 1.0;
|
||||
panel.transform = CGAffineTransformIdentity;
|
||||
} completion:nil];
|
||||
- (void)viewDidLayoutSubviews {
|
||||
[super viewDidLayoutSubviews];
|
||||
// [self kb_updateKeyboardLayoutIfNeeded];
|
||||
|
||||
// 首次布局完成后显示,避免闪烁
|
||||
if (self.contentView.hidden) {
|
||||
self.contentView.hidden = NO;
|
||||
}
|
||||
if (self.kb_defaultGradientLayer) {
|
||||
self.kb_defaultGradientLayer.frame = self.bgImageView.bounds;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)hideSubscriptionPanel {
|
||||
if (!self.subscriptionView || self.subscriptionView.hidden) { return; }
|
||||
CGFloat height = CGRectGetHeight(self.subscriptionView.bounds);
|
||||
if (height <= 0) { height = CGRectGetHeight(self.view.bounds); }
|
||||
KBKeyboardSubscriptionView *panel = self.subscriptionView;
|
||||
[UIView animateWithDuration:0.22 delay:0 options:UIViewAnimationOptionCurveEaseIn animations:^{
|
||||
panel.alpha = 0.0;
|
||||
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
||||
} completion:^(BOOL finished) {
|
||||
panel.hidden = YES;
|
||||
panel.alpha = 1.0;
|
||||
panel.transform = CGAffineTransformIdentity;
|
||||
}];
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - KBKeyBoardMainViewDelegate
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapKey:(KBKey *)key {
|
||||
if (key.type != KBKeyTypeShift && key.type != KBKeyTypeModeChange) {
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
}
|
||||
switch (key.type) {
|
||||
case KBKeyTypeCharacter:
|
||||
[self.textDocumentProxy insertText:key.output ?: key.title ?: @""]; break;
|
||||
case KBKeyTypeBackspace:
|
||||
[self.textDocumentProxy deleteBackward]; break;
|
||||
case KBKeyTypeSpace:
|
||||
[self.textDocumentProxy insertText:@" "]; break;
|
||||
case KBKeyTypeReturn:
|
||||
[self.textDocumentProxy insertText:@"\n"]; break;
|
||||
case KBKeyTypeGlobe:
|
||||
[self advanceToNextInputMode]; break;
|
||||
case KBKeyTypeCustom:
|
||||
// 点击自定义键切换到功能面板
|
||||
[self showFunctionPanel:YES];
|
||||
break;
|
||||
case KBKeyTypeModeChange:
|
||||
case KBKeyTypeShift:
|
||||
// 这些已在 KBKeyBoardMainView/KBKeyboardView 内部处理
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didTapToolActionAtIndex:(NSInteger)index {
|
||||
if (index == 0) {
|
||||
[self showFunctionPanel:YES];
|
||||
return;
|
||||
}
|
||||
[self showFunctionPanel:NO];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView {
|
||||
[self showSettingView:YES];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didSelectEmoji:(NSString *)emoji {
|
||||
if (emoji.length == 0) { return; }
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
[self.textDocumentProxy insertText:emoji];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView {
|
||||
[[KBBackspaceUndoManager shared] performUndoFromResponder:self.view];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainViewDidTapEmojiSearch:(KBKeyBoardMainView *)keyBoardMainView {
|
||||
[KBHUD showInfo:KBLocalized(@"Search coming soon")];
|
||||
}
|
||||
|
||||
// MARK: - KBFunctionViewDelegate
|
||||
- (void)functionView:(KBFunctionView *)functionView didTapToolActionAtIndex:(NSInteger)index {
|
||||
// 需求:当 index == 0 时,切回键盘主视图
|
||||
if (index == 0) {
|
||||
[self showFunctionPanel:NO];
|
||||
}
|
||||
}
|
||||
- (void)functionView:(KBFunctionView *_Nullable)functionView didRightTapToolActionAtIndex:(NSInteger)index{
|
||||
NSString *schemeStr = [NSString stringWithFormat:@"%@://recharge?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
//
|
||||
// if (!ul && !scheme) { return; }
|
||||
//
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
|
||||
if (!ok) {
|
||||
// 失败兜底:给个文案提示
|
||||
// 比如:请回到桌面手动打开 XXX App 进行设置/充值
|
||||
[KBHUD showInfo:@"请回到桌面手动打开App进行充值"];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)functionViewDidRequestSubscription:(KBFunctionView *)functionView {
|
||||
[self showSubscriptionPanel];
|
||||
}
|
||||
|
||||
#pragma mark - KBKeyboardSubscriptionViewDelegate
|
||||
|
||||
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view {
|
||||
[self hideSubscriptionPanel];
|
||||
}
|
||||
|
||||
- (void)subscriptionView:(KBKeyboardSubscriptionView *)view didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product {
|
||||
[self hideSubscriptionPanel];
|
||||
[self kb_openRechargeForProduct:product];
|
||||
}
|
||||
|
||||
#pragma mark - lazy
|
||||
- (KBKeyBoardMainView *)keyBoardMainView{
|
||||
if (!_keyBoardMainView) {
|
||||
_keyBoardMainView = [[KBKeyBoardMainView alloc] init];
|
||||
_keyBoardMainView.delegate = self;
|
||||
}
|
||||
return _keyBoardMainView;
|
||||
}
|
||||
|
||||
- (KBFunctionView *)functionView{
|
||||
if (!_functionView) {
|
||||
_functionView = [[KBFunctionView alloc] init];
|
||||
_functionView.delegate = self; // 监听功能面板顶部Bar点击
|
||||
}
|
||||
return _functionView;
|
||||
}
|
||||
|
||||
- (KBSettingView *)settingView {
|
||||
if (!_settingView) {
|
||||
_settingView = [[KBSettingView alloc] init];
|
||||
}
|
||||
return _settingView;
|
||||
}
|
||||
|
||||
- (KBKeyboardSubscriptionView *)subscriptionView {
|
||||
if (!_subscriptionView) {
|
||||
_subscriptionView = [[KBKeyboardSubscriptionView alloc] init];
|
||||
_subscriptionView.delegate = self;
|
||||
_subscriptionView.hidden = YES;
|
||||
_subscriptionView.alpha = 0.0;
|
||||
}
|
||||
return _subscriptionView;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product {
|
||||
if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class] || product.productId.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"Product unavailable")];
|
||||
return;
|
||||
}
|
||||
NSString *encodedId = [self.class kb_urlEncodedString:product.productId];
|
||||
NSString *title = [product displayTitle];
|
||||
NSString *encodedTitle = [self.class kb_urlEncodedString:title];
|
||||
NSMutableArray<NSString *> *params = [NSMutableArray arrayWithObjects:@"autoPay=1", @"prefill=1", nil];
|
||||
if (encodedId.length) {
|
||||
[params addObject:[NSString stringWithFormat:@"productId=%@", encodedId]];
|
||||
}
|
||||
if (encodedTitle.length) {
|
||||
[params addObject:[NSString stringWithFormat:@"productTitle=%@", encodedTitle]];
|
||||
}
|
||||
NSString *query = [params componentsJoinedByString:@"&"];
|
||||
NSString *urlString = [NSString stringWithFormat:@"%@://recharge?src=keyboard&%@", KB_APP_SCHEME, query];
|
||||
NSURL *scheme = [NSURL URLWithString:urlString];
|
||||
BOOL success = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
if (!success) {
|
||||
[KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")];
|
||||
}
|
||||
}
|
||||
|
||||
+ (NSString *)kb_urlEncodedString:(NSString *)value {
|
||||
if (value.length == 0) { return @""; }
|
||||
NSString *reserved = @"!*'();:@&=+$,/?%#[]";
|
||||
NSMutableCharacterSet *allowed = [[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy];
|
||||
[allowed removeCharactersInString:reserved];
|
||||
return [value stringByAddingPercentEncodingWithAllowedCharacters:allowed] ?: @"";
|
||||
}
|
||||
|
||||
- (void)onTapSettingsBack {
|
||||
[self showSettingView:NO];
|
||||
- (void)viewWillTransitionToSize:(CGSize)size
|
||||
withTransitionCoordinator:
|
||||
(id<UIViewControllerTransitionCoordinator>)coordinator {
|
||||
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[coordinator
|
||||
animateAlongsideTransition:^(
|
||||
id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
|
||||
[weakSelf kb_updateKeyboardLayoutIfNeeded];
|
||||
}
|
||||
completion:^(
|
||||
__unused id<
|
||||
UIViewControllerTransitionCoordinatorContext> _Nonnull context) {
|
||||
[weakSelf kb_updateKeyboardLayoutIfNeeded];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
CFNotificationCenterRemoveObserver(CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge const void *)(self),
|
||||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification,
|
||||
NULL);
|
||||
if (self.kb_fullAccessObserverToken) {
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
removeObserver:self.kb_fullAccessObserverToken];
|
||||
self.kb_fullAccessObserverToken = nil;
|
||||
}
|
||||
if (self.kb_skinObserverToken) {
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
removeObserver:self.kb_skinObserverToken];
|
||||
self.kb_skinObserverToken = nil;
|
||||
}
|
||||
[self kb_unregisterDarwinSkinInstallObserver];
|
||||
#if DEBUG
|
||||
if (_kb_debugDidCountAlive) {
|
||||
sKBKeyboardVCAliveCount -= 1;
|
||||
}
|
||||
NSLog(@"[Keyboard] KeyboardViewController dealloc alive=%ld self=%p mem=%@",
|
||||
(long)sKBKeyboardVCAliveCount, self, KBFormatMB(KBPhysFootprintBytes()));
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
// 当键盘第一次显示时,尝试唤起主 App 以提示登录(由主 App 决定是否真的弹登录)。
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
[super viewDidAppear:animated];
|
||||
// if (!_kb_didTriggerLoginDeepLinkOnce) {
|
||||
// _kb_didTriggerLoginDeepLinkOnce = YES;
|
||||
// // 仅在未登录时尝试拉起主App登录
|
||||
// if (!KBAuthManager.shared.isLoggedIn) {
|
||||
// [self kb_tryOpenContainerForLoginIfNeeded];
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
//- (void)kb_tryOpenContainerForLoginIfNeeded {
|
||||
// // 使用与主 App 一致的自定义 Scheme
|
||||
// NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"%@@//login?src=keyboard", KB_APP_SCHEME]];
|
||||
// if (!url) return;
|
||||
// KBWeakSelf
|
||||
// [self.extensionContext openURL:url completionHandler:^(__unused BOOL success) {
|
||||
// // 即使失败也不重复尝试;避免打扰。
|
||||
// __unused typeof(weakSelf) selfStrong = weakSelf;
|
||||
// }];
|
||||
//}
|
||||
|
||||
#pragma mark - Theme
|
||||
|
||||
- (void)kb_applyTheme {
|
||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||
UIImage *img = [[KBSkinManager shared] currentBackgroundImage];
|
||||
self.bgImageView.image = img;
|
||||
BOOL hasImg = (img != nil);
|
||||
self.view.backgroundColor = hasImg ? [UIColor clearColor] : t.keyboardBackground;
|
||||
self.keyBoardMainView.backgroundColor = hasImg ? [UIColor clearColor] : t.keyboardBackground;
|
||||
// 触发键区按主题重绘
|
||||
if ([self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) {
|
||||
// method declared in KBKeyBoardMainView.h
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[self.keyBoardMainView performSelector:@selector(kb_applyTheme)];
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
if ([self.functionView respondsToSelector:@selector(kb_applyTheme)]) {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[self.functionView performSelector:@selector(kb_applyTheme)];
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_consumePendingShopSkin {
|
||||
KBWeakSelf
|
||||
[KBSkinInstallBridge consumePendingRequestFromBundle:NSBundle.mainBundle
|
||||
completion:^(BOOL success, NSError * _Nullable error) {
|
||||
if (!success) {
|
||||
if (error) {
|
||||
NSLog(@"[Keyboard] skin request failed: %@", error);
|
||||
[KBHUD showInfo:KBLocalized(@"皮肤资源准备失败,请稍后再试")];
|
||||
}
|
||||
return;
|
||||
}
|
||||
[weakSelf kb_applyTheme];
|
||||
[KBHUD showInfo:KBLocalized(@"皮肤已更新,立即体验吧")];
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIImageView *)bgImageView {
|
||||
if (!_bgImageView) {
|
||||
_bgImageView = [[UIImageView alloc] init];
|
||||
_bgImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
_bgImageView.clipsToBounds = YES;
|
||||
}
|
||||
return _bgImageView;
|
||||
}
|
||||
@end
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
//
|
||||
// KeyboardViewController+Chat.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBChatLimitPopView.h"
|
||||
#import "KBChatMessage.h"
|
||||
#import "KBChatPanelView.h"
|
||||
#import "KBFullAccessManager.h"
|
||||
#import "KBHostAppLauncher.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
#import "KBNetworkManager.h"
|
||||
#import "KBVM.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
static const NSUInteger kKBChatMessageLimit = 6;
|
||||
|
||||
@implementation KeyboardViewController (Chat)
|
||||
|
||||
#pragma mark - KBChatPanelViewDelegate
|
||||
|
||||
- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text {
|
||||
NSString *trim =
|
||||
[text stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if (trim.length == 0) {
|
||||
return;
|
||||
}
|
||||
[self kb_sendChatText:trim];
|
||||
}
|
||||
|
||||
- (void)chatPanelView:(KBChatPanelView *)view
|
||||
didTapMessage:(KBChatMessage *)message {
|
||||
if (message.audioFilePath.length == 0) {
|
||||
return;
|
||||
}
|
||||
[self kb_playChatAudioAtPath:message.audioFilePath];
|
||||
}
|
||||
|
||||
- (void)chatPanelView:(KBChatPanelView *)view
|
||||
didTapVoiceButtonForMessage:(KBChatMessage *)message {
|
||||
if (!message)
|
||||
return;
|
||||
|
||||
// 如果有 audioData,直接播放
|
||||
if (message.audioData && message.audioData.length > 0) {
|
||||
[self kb_playChatAudioData:message.audioData];
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果有 audioFilePath,播放文件
|
||||
if (message.audioFilePath.length > 0) {
|
||||
[self kb_playChatAudioAtPath:message.audioFilePath];
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[Keyboard] 没有音频数据可播放");
|
||||
}
|
||||
|
||||
- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view {
|
||||
// 清空 chatPanelView 内部的消息
|
||||
[view kb_reloadWithMessages:@[]];
|
||||
if (self.chatAudioPlayer.isPlaying) {
|
||||
[self.chatAudioPlayer stop];
|
||||
}
|
||||
self.chatAudioPlayer = nil;
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||
}
|
||||
|
||||
#pragma mark - Chat Helpers
|
||||
|
||||
- (void)kb_handleChatSendAction {
|
||||
if (!self.chatPanelVisible) {
|
||||
return;
|
||||
}
|
||||
[[KBInputBufferManager shared]
|
||||
refreshFromProxyIfPossible:self.textDocumentProxy];
|
||||
NSString *fullText = [KBInputBufferManager shared].liveText ?: @"";
|
||||
|
||||
// 去掉打开聊天面板前宿主输入框里已有的基线文本,只取新增部分
|
||||
NSString *baseline = self.chatPanelBaselineText ?: @"";
|
||||
NSString *rawText = fullText;
|
||||
if (baseline.length > 0 && [fullText hasPrefix:baseline]) {
|
||||
rawText = [fullText substringFromIndex:baseline.length];
|
||||
}
|
||||
|
||||
NSString *trim =
|
||||
[rawText stringByTrimmingCharactersInSet:
|
||||
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if (trim.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"请输入内容")];
|
||||
return;
|
||||
}
|
||||
[self kb_sendChatText:trim];
|
||||
// 只清除新增的文本,保留基线文本
|
||||
[self kb_clearHostInputForText:rawText];
|
||||
}
|
||||
|
||||
- (void)kb_sendChatText:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return;
|
||||
}
|
||||
NSLog(@"[KB] 发送消息: %@", text);
|
||||
|
||||
KBChatMessage *outgoing = [KBChatMessage userMessageWithText:text];
|
||||
outgoing.avatarURL = [self kb_sharedUserAvatarURL];
|
||||
[self.chatPanelView kb_addUserMessage:text];
|
||||
[self kb_prefetchAvatarForMessage:outgoing];
|
||||
|
||||
if (![[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view]) {
|
||||
[KBHUD showInfo:KBLocalized(@"请开启完全访问后使用")];
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加 loading 消息
|
||||
[self.chatPanelView kb_addLoadingAssistantMessage];
|
||||
|
||||
// 调用新的聊天接口
|
||||
[self kb_requestChatMessageWithContent:text];
|
||||
}
|
||||
|
||||
#pragma mark - Chat Limit Pop
|
||||
|
||||
- (void)kb_showChatLimitPopWithMessage:(NSString *)message {
|
||||
[self kb_dismissChatLimitPop];
|
||||
|
||||
UIControl *mask = [[UIControl alloc] init];
|
||||
mask.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.4];
|
||||
mask.alpha = 0.0;
|
||||
[mask addTarget:self
|
||||
action:@selector(kb_dismissChatLimitPop)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.contentView addSubview:mask];
|
||||
[mask mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.contentView);
|
||||
}];
|
||||
|
||||
CGFloat width = 252.0;
|
||||
CGFloat height = 252.0 + 18.0 + 53.0 + 18.0 + 28.0;
|
||||
KBChatLimitPopView *content =
|
||||
[[KBChatLimitPopView alloc] initWithFrame:CGRectMake(0, 0, width, height)];
|
||||
content.message = message ?: @"";
|
||||
content.delegate = self;
|
||||
[mask addSubview:content];
|
||||
[content mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(mask);
|
||||
make.width.mas_equalTo(width);
|
||||
make.height.mas_equalTo(height);
|
||||
}];
|
||||
|
||||
self.chatLimitMaskView = mask;
|
||||
[self.contentView bringSubviewToFront:mask];
|
||||
[UIView animateWithDuration:0.18
|
||||
animations:^{
|
||||
mask.alpha = 1.0;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_dismissChatLimitPop {
|
||||
if (!self.chatLimitMaskView) {
|
||||
return;
|
||||
}
|
||||
UIControl *mask = self.chatLimitMaskView;
|
||||
self.chatLimitMaskView = nil;
|
||||
[UIView animateWithDuration:0.15
|
||||
animations:^{
|
||||
mask.alpha = 0.0;
|
||||
}
|
||||
completion:^(__unused BOOL finished) {
|
||||
[mask removeFromSuperview];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_clearHostInputForText:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return;
|
||||
}
|
||||
NSUInteger count = [self kb_composedCharacterCountForString:text];
|
||||
for (NSUInteger i = 0; i < count; i++) {
|
||||
[self.textDocumentProxy deleteBackward];
|
||||
}
|
||||
[[KBInputBufferManager shared] clearAllLiveText];
|
||||
[self kb_clearCurrentWord];
|
||||
}
|
||||
|
||||
- (NSUInteger)kb_composedCharacterCountForString:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return 0;
|
||||
}
|
||||
__block NSUInteger count = 0;
|
||||
[text enumerateSubstringsInRange:NSMakeRange(0, text.length)
|
||||
options:NSStringEnumerationByComposedCharacterSequences
|
||||
usingBlock:^(__unused NSString *substring,
|
||||
__unused NSRange substringRange,
|
||||
__unused NSRange enclosingRange,
|
||||
__unused BOOL *stop) {
|
||||
count += 1;
|
||||
}];
|
||||
return count;
|
||||
}
|
||||
|
||||
- (NSString *)kb_sharedUserAvatarURL {
|
||||
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||
NSString *url = [ud stringForKey:AppGroup_UserAvatarURL];
|
||||
return url ?: @"";
|
||||
}
|
||||
|
||||
- (void)kb_prefetchAvatarForMessage:(KBChatMessage *)message {
|
||||
if (!message || message.avatarImage) {
|
||||
return;
|
||||
}
|
||||
NSString *urlString = message.avatarURL ?: @"";
|
||||
if (urlString.length == 0) {
|
||||
return;
|
||||
}
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
return;
|
||||
}
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBVM shared] downloadAvatarFromURL:urlString
|
||||
completion:^(UIImage *image, NSError *error) {
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self || !image)
|
||||
return;
|
||||
|
||||
message.avatarImage = image;
|
||||
[self kb_reloadChatRowForMessage:message];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_reloadChatRowForMessage:(KBChatMessage *)message {
|
||||
// 头像预加载完成后不需要刷新表格
|
||||
// 因为键盘扩展的聊天面板不显示头像,所以这里直接返回
|
||||
// 如果将来需要显示头像,可以只刷新特定行而不是整个表格
|
||||
}
|
||||
|
||||
- (void)kb_requestChatAudioForText:(NSString *)text {
|
||||
NSString *mockPath = [self kb_mockChatAudioPath];
|
||||
if (mockPath.length > 0) {
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.35 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
NSString *displayText = KBLocalized(@"语音回复");
|
||||
KBChatMessage *incoming =
|
||||
[KBChatMessage messageWithText:displayText
|
||||
outgoing:NO
|
||||
audioFilePath:mockPath];
|
||||
incoming.displayName = KBLocalized(@"AI助手");
|
||||
[self kb_appendChatMessage:incoming];
|
||||
[self kb_playChatAudioAtPath:mockPath];
|
||||
});
|
||||
return;
|
||||
}
|
||||
NSDictionary *payload = @{@"message" : text ?: @""};
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBNetworkManager shared] POST:API_AI_TALK
|
||||
jsonBody:payload
|
||||
headers:nil
|
||||
completion:^(NSDictionary *json, NSURLResponse *response,
|
||||
NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self) {
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
NSString *tip = error.localizedDescription
|
||||
?: KBLocalized(@"请求失败");
|
||||
[KBHUD showInfo:tip];
|
||||
return;
|
||||
}
|
||||
NSString *displayText =
|
||||
[self kb_chatTextFromJSON:json];
|
||||
NSString *audioURL =
|
||||
[self kb_chatAudioURLFromJSON:json];
|
||||
NSString *audioBase64 =
|
||||
[self kb_chatAudioBase64FromJSON:json];
|
||||
if (audioURL.length > 0) {
|
||||
[self kb_downloadChatAudioFromURL:audioURL
|
||||
displayText:displayText];
|
||||
return;
|
||||
}
|
||||
if (audioBase64.length > 0) {
|
||||
NSData *data = [[NSData alloc]
|
||||
initWithBase64EncodedString:audioBase64
|
||||
options:0];
|
||||
if (data.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频数据解析失败")];
|
||||
return;
|
||||
}
|
||||
[self kb_handleChatAudioData:data
|
||||
fileExtension:@"m4a"
|
||||
displayText:displayText];
|
||||
return;
|
||||
}
|
||||
[KBHUD showInfo:KBLocalized(@"未获取到音频文件")];
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - New Chat API (with typewriter effect and audio preload)
|
||||
|
||||
/// 调用新的聊天接口(返回文本和 audioId)
|
||||
- (void)kb_requestChatMessageWithContent:(NSString *)content {
|
||||
if (content.length == 0) {
|
||||
[self.chatPanelView kb_removeLoadingAssistantMessage];
|
||||
return;
|
||||
}
|
||||
|
||||
NSInteger companionId = [[KBVM shared] selectedCompanionIdFromAppGroup];
|
||||
NSLog(@"[KB] 请求聊天: companionId=%ld", (long)companionId);
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBVM shared] sendChatMessageWithContent:content
|
||||
companionId:companionId
|
||||
completion:^(KBChatResponse *response) {
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self)
|
||||
return;
|
||||
|
||||
if (response.code != 0) {
|
||||
if (response.code == 50030) {
|
||||
NSLog(@"[KB] ⚠️ 次数用尽: %@",
|
||||
response.message);
|
||||
[self.chatPanelView
|
||||
kb_removeLoadingAssistantMessage];
|
||||
[self kb_showChatLimitPopWithMessage:
|
||||
response.message];
|
||||
return;
|
||||
}
|
||||
NSLog(@"[KB] ❌ 请求失败: %@",
|
||||
response.message);
|
||||
[self.chatPanelView
|
||||
kb_removeLoadingAssistantMessage];
|
||||
[KBHUD showInfo:response.message
|
||||
?: KBLocalized(@"请求失败")];
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[KB] ✅ 收到回复: %@",
|
||||
response.data.aiResponse);
|
||||
|
||||
if (response.data.aiResponse.length == 0) {
|
||||
[self.chatPanelView
|
||||
kb_removeLoadingAssistantMessage];
|
||||
[KBHUD showInfo:KBLocalized(@"未获取到回复内容")];
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加 AI 消息(带打字机效果)
|
||||
NSLog(@"[KB] 准备添加 AI 消息");
|
||||
[self.chatPanelView
|
||||
kb_addAssistantMessage:response.data.aiResponse
|
||||
audioId:response.data.audioId];
|
||||
NSLog(@"[KB] AI 消息添加完成");
|
||||
|
||||
// 如果有 audioId,开始预加载音频
|
||||
if (response.data.audioId.length > 0) {
|
||||
[self kb_preloadAudioWithAudioId:
|
||||
response.data.audioId];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
/// 从 AppGroup 获取选中的 persona companionId
|
||||
- (NSInteger)kb_selectedCompanionId {
|
||||
return [[KBVM shared] selectedCompanionIdFromAppGroup];
|
||||
}
|
||||
|
||||
#pragma mark - Audio Preload
|
||||
|
||||
/// 预加载音频(轮询获取 audioURL)
|
||||
- (void)kb_preloadAudioWithAudioId:(NSString *)audioId {
|
||||
if (audioId.length == 0)
|
||||
return;
|
||||
|
||||
NSLog(@"[Keyboard] 开始预加载音频,audioId: %@", audioId);
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBVM shared] pollAudioURLWithAudioId:audioId
|
||||
maxRetries:10
|
||||
interval:1.0
|
||||
completion:^(KBAudioResponse *response) {
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self)
|
||||
return;
|
||||
|
||||
if (!response.success ||
|
||||
response.audioURL.length == 0) {
|
||||
NSLog(@"[Keyboard] ❌ 预加载音频 URL 获取失败: %@",
|
||||
response.errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"[Keyboard] ✅ 预加载音频 URL 获取成功");
|
||||
|
||||
// 下载音频
|
||||
[[KBVM shared]
|
||||
downloadAudioFromURL:response.audioURL
|
||||
completion:^(
|
||||
KBAudioResponse *audioResponse) {
|
||||
if (!audioResponse.success) {
|
||||
NSLog(@"[Keyboard] ❌ 预加载音频下载失败: %@",
|
||||
audioResponse.errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新最后一条 AI 消息的音频数据
|
||||
[self.chatPanelView
|
||||
kb_updateLastAssistantMessageWithAudioData:
|
||||
audioResponse.audioData
|
||||
duration:
|
||||
audioResponse.duration];
|
||||
NSLog(@"[Keyboard] ✅ 预加载音频完成,音频时长: %.2f秒",
|
||||
audioResponse.duration);
|
||||
}];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_downloadChatAudioFromURL:(NSString *)audioURL
|
||||
displayText:(NSString *)displayText {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[KBVM shared] downloadAudioFromURL:audioURL
|
||||
completion:^(KBAudioResponse *response) {
|
||||
__strong typeof(weakSelf) self = weakSelf;
|
||||
if (!self)
|
||||
return;
|
||||
|
||||
if (!response.success) {
|
||||
[KBHUD showInfo:response.errorMessage
|
||||
?: KBLocalized(@"下载失败")];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.audioData ||
|
||||
response.audioData.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"未获取到音频数据")];
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *ext = @"m4a";
|
||||
NSURL *url = [NSURL URLWithString:audioURL];
|
||||
if (url.pathExtension.length > 0) {
|
||||
ext = url.pathExtension;
|
||||
}
|
||||
[self kb_handleChatAudioData:response.audioData
|
||||
fileExtension:ext
|
||||
displayText:displayText];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_handleChatAudioData:(NSData *)data
|
||||
fileExtension:(NSString *)extension
|
||||
displayText:(NSString *)displayText {
|
||||
if (data.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频数据为空")];
|
||||
return;
|
||||
}
|
||||
NSString *ext = extension.length > 0 ? extension : @"m4a";
|
||||
NSString *fileName = [NSString
|
||||
stringWithFormat:@"kb_chat_%@.%@",
|
||||
@((long long)([NSDate date].timeIntervalSince1970 *
|
||||
1000)),
|
||||
ext];
|
||||
NSString *filePath =
|
||||
[NSTemporaryDirectory() stringByAppendingPathComponent:fileName];
|
||||
if (![data writeToFile:filePath atomically:YES]) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频保存失败")];
|
||||
return;
|
||||
}
|
||||
NSString *text =
|
||||
displayText.length > 0 ? displayText : KBLocalized(@"语音消息");
|
||||
KBChatMessage *incoming =
|
||||
[KBChatMessage messageWithText:text outgoing:NO audioFilePath:filePath];
|
||||
incoming.displayName = KBLocalized(@"AI助手");
|
||||
[self kb_appendChatMessage:incoming];
|
||||
}
|
||||
|
||||
- (void)kb_appendChatMessage:(KBChatMessage *)message {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
[self.chatMessages addObject:message];
|
||||
if (self.chatMessages.count > kKBChatMessageLimit) {
|
||||
NSUInteger overflow = self.chatMessages.count - kKBChatMessageLimit;
|
||||
NSArray<KBChatMessage *> *removed =
|
||||
[self.chatMessages subarrayWithRange:NSMakeRange(0, overflow)];
|
||||
[self.chatMessages removeObjectsInRange:NSMakeRange(0, overflow)];
|
||||
for (KBChatMessage *msg in removed) {
|
||||
if (msg.audioFilePath.length > 0) {
|
||||
NSString *tmpRoot = NSTemporaryDirectory();
|
||||
if (tmpRoot.length > 0 && [msg.audioFilePath hasPrefix:tmpRoot]) {
|
||||
[[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath
|
||||
error:nil];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[self.chatPanelView kb_reloadWithMessages:self.chatMessages];
|
||||
}
|
||||
|
||||
- (NSString *)kb_mockChatAudioPath {
|
||||
NSString *path = [[NSBundle mainBundle] pathForResource:@"ai_test"
|
||||
ofType:@"m4a"];
|
||||
return path ?: @"";
|
||||
}
|
||||
|
||||
- (NSString *)kb_chatTextFromJSON:(NSDictionary *)json {
|
||||
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
|
||||
NSString *text =
|
||||
[self kb_stringValueInDict:data keys:@[ @"text", @"message", @"content" ]];
|
||||
if (text.length == 0) {
|
||||
text = [self kb_stringValueInDict:json
|
||||
keys:@[ @"text", @"message", @"content" ]];
|
||||
}
|
||||
return text ?: @"";
|
||||
}
|
||||
|
||||
- (NSString *)kb_chatAudioURLFromJSON:(NSDictionary *)json {
|
||||
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
|
||||
NSArray<NSString *> *keys =
|
||||
@[ @"audioUrl", @"audioURL", @"audio_url", @"url", @"fileUrl",
|
||||
@"file_url", @"audioFileUrl", @"audio_file_url" ];
|
||||
NSString *url = [self kb_stringValueInDict:data keys:keys];
|
||||
if (url.length == 0) {
|
||||
url = [self kb_stringValueInDict:json keys:keys];
|
||||
}
|
||||
return url ?: @"";
|
||||
}
|
||||
|
||||
- (NSString *)kb_chatAudioBase64FromJSON:(NSDictionary *)json {
|
||||
NSDictionary *data = [self kb_chatDataDictionaryFromJSON:json];
|
||||
NSArray<NSString *> *keys =
|
||||
@[ @"audioBase64", @"audio_base64", @"audioData", @"audio_data",
|
||||
@"base64" ];
|
||||
NSString *b64 = [self kb_stringValueInDict:data keys:keys];
|
||||
if (b64.length == 0) {
|
||||
b64 = [self kb_stringValueInDict:json keys:keys];
|
||||
}
|
||||
return b64 ?: @"";
|
||||
}
|
||||
|
||||
- (NSDictionary *)kb_chatDataDictionaryFromJSON:(NSDictionary *)json {
|
||||
if (![json isKindOfClass:[NSDictionary class]]) {
|
||||
return @{};
|
||||
}
|
||||
id dataObj = json[@"data"] ?: json[@"result"] ?: json[@"response"];
|
||||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||
return (NSDictionary *)dataObj;
|
||||
}
|
||||
return @{};
|
||||
}
|
||||
|
||||
- (NSString *)kb_stringValueInDict:(NSDictionary *)dict
|
||||
keys:(NSArray<NSString *> *)keys {
|
||||
if (![dict isKindOfClass:[NSDictionary class]]) {
|
||||
return @"";
|
||||
}
|
||||
for (NSString *key in keys) {
|
||||
id value = dict[key];
|
||||
if ([value isKindOfClass:[NSString class]] &&
|
||||
((NSString *)value).length > 0) {
|
||||
return (NSString *)value;
|
||||
}
|
||||
}
|
||||
return @"";
|
||||
}
|
||||
|
||||
- (void)kb_playChatAudioAtPath:(NSString *)path {
|
||||
if (path.length == 0) {
|
||||
return;
|
||||
}
|
||||
NSURL *url = [NSURL fileURLWithPath:path];
|
||||
if (![NSFileManager.defaultManager fileExistsAtPath:path]) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频文件不存在")];
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) {
|
||||
NSURL *currentURL = self.chatAudioPlayer.url;
|
||||
if ([currentURL isEqual:url]) {
|
||||
[self.chatAudioPlayer stop];
|
||||
self.chatAudioPlayer = nil;
|
||||
return;
|
||||
}
|
||||
[self.chatAudioPlayer stop];
|
||||
self.chatAudioPlayer = nil;
|
||||
}
|
||||
|
||||
NSError *sessionError = nil;
|
||||
AVAudioSession *session = [AVAudioSession sharedInstance];
|
||||
if ([session respondsToSelector:@selector(setCategory:options:error:)]) {
|
||||
[session setCategory:AVAudioSessionCategoryPlayback
|
||||
withOptions:AVAudioSessionCategoryOptionDuckOthers
|
||||
error:&sessionError];
|
||||
} else {
|
||||
[session setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
|
||||
}
|
||||
[session setActive:YES error:nil];
|
||||
|
||||
NSError *playerError = nil;
|
||||
AVAudioPlayer *player =
|
||||
[[AVAudioPlayer alloc] initWithContentsOfURL:url error:&playerError];
|
||||
if (playerError || !player) {
|
||||
[KBHUD showInfo:KBLocalized(@"音频播放失败")];
|
||||
return;
|
||||
}
|
||||
self.chatAudioPlayer = player;
|
||||
[player prepareToPlay];
|
||||
[player play];
|
||||
}
|
||||
|
||||
/// 播放音频数据
|
||||
- (void)kb_playChatAudioData:(NSData *)audioData {
|
||||
if (!audioData || audioData.length == 0) {
|
||||
NSLog(@"[Keyboard] 音频数据为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果正在播放,先停止
|
||||
if (self.chatAudioPlayer && self.chatAudioPlayer.isPlaying) {
|
||||
[self.chatAudioPlayer stop];
|
||||
self.chatAudioPlayer = nil;
|
||||
}
|
||||
|
||||
// 配置音频会话
|
||||
NSError *sessionError = nil;
|
||||
AVAudioSession *session = [AVAudioSession sharedInstance];
|
||||
if ([session respondsToSelector:@selector(setCategory:options:error:)]) {
|
||||
[session setCategory:AVAudioSessionCategoryPlayback
|
||||
withOptions:AVAudioSessionCategoryOptionDuckOthers
|
||||
error:&sessionError];
|
||||
} else {
|
||||
[session setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
|
||||
}
|
||||
[session setActive:YES error:nil];
|
||||
|
||||
// 创建播放器
|
||||
NSError *playerError = nil;
|
||||
AVAudioPlayer *player =
|
||||
[[AVAudioPlayer alloc] initWithData:audioData error:&playerError];
|
||||
if (playerError || !player) {
|
||||
NSLog(@"[Keyboard] 音频播放器初始化失败: %@",
|
||||
playerError.localizedDescription);
|
||||
[KBHUD showInfo:KBLocalized(@"音频播放失败")];
|
||||
return;
|
||||
}
|
||||
|
||||
self.chatAudioPlayer = player;
|
||||
player.volume = 1.0;
|
||||
[player prepareToPlay];
|
||||
[player play];
|
||||
|
||||
NSLog(@"[Keyboard] 开始播放音频,时长: %.2f秒", player.duration);
|
||||
}
|
||||
|
||||
#pragma mark - KBChatLimitPopViewDelegate
|
||||
|
||||
- (void)chatLimitPopViewDidTapCancel:(KBChatLimitPopView *)view {
|
||||
[self kb_dismissChatLimitPop];
|
||||
}
|
||||
|
||||
- (void)chatLimitPopViewDidTapRecharge:(KBChatLimitPopView *)view {
|
||||
[self kb_dismissChatLimitPop];
|
||||
NSString *urlString =
|
||||
[NSString stringWithFormat:@"%@://recharge?src=keyboard&vipType=svip",
|
||||
KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:urlString];
|
||||
BOOL success = [KBHostAppLauncher openHostAppURL:scheme
|
||||
fromResponder:self.view];
|
||||
if (!success) {
|
||||
[KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// KeyboardViewController+Layout.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
// 以 375 宽设计稿为基准的键盘总高度
|
||||
static const CGFloat kKBKeyboardBaseHeight = 250.0f;
|
||||
static const CGFloat kKBChatPanelHeight = 180;
|
||||
|
||||
@implementation KeyboardViewController (Layout)
|
||||
|
||||
- (CGFloat)kb_portraitWidth {
|
||||
CGSize s = [UIScreen mainScreen].bounds.size;
|
||||
return MIN(s.width, s.height);
|
||||
}
|
||||
|
||||
- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width {
|
||||
if (width <= 0) {
|
||||
width = KB_DESIGN_WIDTH;
|
||||
}
|
||||
CGFloat scale = width / KB_DESIGN_WIDTH;
|
||||
CGFloat baseHeight = kKBKeyboardBaseHeight * scale;
|
||||
CGFloat chatHeight = kKBChatPanelHeight * scale;
|
||||
if (self.chatPanelVisible) {
|
||||
return baseHeight + chatHeight;
|
||||
}
|
||||
return baseHeight;
|
||||
}
|
||||
|
||||
- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width {
|
||||
if (width <= 0) {
|
||||
width = KB_DESIGN_WIDTH;
|
||||
}
|
||||
CGFloat scale = width / KB_DESIGN_WIDTH;
|
||||
return kKBKeyboardBaseHeight * scale;
|
||||
}
|
||||
|
||||
- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width {
|
||||
if (width <= 0) {
|
||||
width = KB_DESIGN_WIDTH;
|
||||
}
|
||||
CGFloat scale = width / KB_DESIGN_WIDTH;
|
||||
return kKBChatPanelHeight * scale;
|
||||
}
|
||||
|
||||
- (void)kb_updateKeyboardLayoutIfNeeded {
|
||||
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
|
||||
CGFloat keyboardBaseHeight =
|
||||
[self kb_keyboardBaseHeightForWidth:portraitWidth];
|
||||
CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
|
||||
CGFloat containerWidth = CGRectGetWidth(self.view.superview.bounds);
|
||||
if (containerWidth <= 0) {
|
||||
containerWidth = CGRectGetWidth(self.view.window.bounds);
|
||||
}
|
||||
if (containerWidth <= 0) {
|
||||
containerWidth = CGRectGetWidth([UIScreen mainScreen].bounds);
|
||||
}
|
||||
|
||||
BOOL widthChanged = (fabs(self.kb_lastPortraitWidth - portraitWidth) >= 0.5);
|
||||
BOOL heightChanged =
|
||||
(fabs(self.kb_lastKeyboardHeight - keyboardHeight) >= 0.5);
|
||||
if (!widthChanged && !heightChanged && containerWidth > 0 &&
|
||||
self.kb_widthConstraint.constant == containerWidth) {
|
||||
return;
|
||||
}
|
||||
self.kb_lastPortraitWidth = portraitWidth;
|
||||
self.kb_lastKeyboardHeight = keyboardHeight;
|
||||
|
||||
if (self.kb_heightConstraint) {
|
||||
self.kb_heightConstraint.constant = keyboardHeight;
|
||||
}
|
||||
if (containerWidth > 0 && self.kb_widthConstraint) {
|
||||
self.kb_widthConstraint.constant = containerWidth;
|
||||
}
|
||||
if (self.contentWidthConstraint) {
|
||||
[self.contentWidthConstraint setOffset:portraitWidth];
|
||||
}
|
||||
if (self.contentHeightConstraint) {
|
||||
[self.contentHeightConstraint setOffset:keyboardHeight];
|
||||
}
|
||||
if (self.keyBoardMainHeightConstraint) {
|
||||
[self.keyBoardMainHeightConstraint setOffset:keyboardBaseHeight];
|
||||
}
|
||||
if (self.chatPanelHeightConstraint) {
|
||||
[self.chatPanelHeightConstraint setOffset:chatPanelHeight];
|
||||
}
|
||||
[self.view layoutIfNeeded];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
//
|
||||
// KeyboardViewController+Panels.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBAuthManager.h"
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBChatMessage.h"
|
||||
#import "KBChatPanelView.h"
|
||||
#import "KBFunctionView.h"
|
||||
#import "KBHostAppLauncher.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
#import "KBKey.h"
|
||||
#import "KBKeyBoardMainView.h"
|
||||
#import "KBKeyboardSubscriptionView.h"
|
||||
#import "KBSettingView.h"
|
||||
#import "Masonry.h"
|
||||
#import <SDWebImage/SDWebImage.h>
|
||||
#import <AVFoundation/AVAudioPlayer.h>
|
||||
|
||||
@implementation KeyboardViewController (Panels)
|
||||
|
||||
#pragma mark - Panel Mode
|
||||
|
||||
- (void)kb_setPanelMode:(KBKeyboardPanelMode)mode animated:(BOOL)animated {
|
||||
if (mode == self.kb_panelMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
KBKeyboardPanelMode fromMode = self.kb_panelMode;
|
||||
self.kb_panelMode = mode;
|
||||
|
||||
// 主键盘视图是基础承载:确保存在(键盘隐藏后会被释放)
|
||||
[self kb_ensureKeyBoardMainViewIfNeeded];
|
||||
|
||||
// 1) 先收起所有面板(再展开目标面板),避免互相调用导致漏关/层级错乱
|
||||
[self kb_setSubscriptionPanelVisible:NO animated:animated];
|
||||
[self kb_setSettingViewVisible:NO animated:animated];
|
||||
[self kb_setChatPanelVisible:NO animated:animated];
|
||||
[self kb_setFunctionPanelVisible:NO];
|
||||
|
||||
// 2) 再展开目标面板
|
||||
switch (mode) {
|
||||
case KBKeyboardPanelModeFunction:
|
||||
if (!KBAuthManager.shared.isLoggedIn) {
|
||||
NSString *schemeStr =
|
||||
[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
return;
|
||||
}
|
||||
[self kb_setFunctionPanelVisible:YES];
|
||||
break;
|
||||
case KBKeyboardPanelModeChat:
|
||||
[self kb_setChatPanelVisible:YES animated:animated];
|
||||
break;
|
||||
case KBKeyboardPanelModeSettings:
|
||||
[self kb_setSettingViewVisible:YES animated:animated];
|
||||
break;
|
||||
case KBKeyboardPanelModeSubscription:
|
||||
[self kb_setSubscriptionPanelVisible:YES animated:animated];
|
||||
break;
|
||||
case KBKeyboardPanelModeMain:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// 3) 事件埋点:保持原逻辑(仅功能面板/主面板会互相曝光)
|
||||
if (mode == KBKeyboardPanelModeFunction) {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportPageExposureWithEventName:@"enter_keyboard_function_panel"
|
||||
pageId:@"keyboard_function_panel"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
} else if (mode == KBKeyboardPanelModeMain &&
|
||||
fromMode == KBKeyboardPanelModeFunction) {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportPageExposureWithEventName:@"enter_keyboard_main_panel"
|
||||
pageId:@"keyboard_main_panel"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
} else if (mode == KBKeyboardPanelModeSettings) {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportPageExposureWithEventName:@"enter_keyboard_settings"
|
||||
pageId:@"keyboard_settings"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
} else if (mode == KBKeyboardPanelModeSubscription) {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportPageExposureWithEventName:@"enter_keyboard_subscription_panel"
|
||||
pageId:@"keyboard_subscription_panel"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
}
|
||||
|
||||
// 4) 层级:保证当前面板在最上层
|
||||
if (mode == KBKeyboardPanelModeSubscription) {
|
||||
[self.contentView bringSubviewToFront:self.subscriptionView];
|
||||
} else if (mode == KBKeyboardPanelModeSettings) {
|
||||
[self.contentView bringSubviewToFront:self.settingView];
|
||||
} else if (mode == KBKeyboardPanelModeChat) {
|
||||
[self.contentView bringSubviewToFront:self.chatPanelView];
|
||||
} else if (mode == KBKeyboardPanelModeFunction) {
|
||||
[self.contentView bringSubviewToFront:self.functionView];
|
||||
} else {
|
||||
[self.contentView bringSubviewToFront:self.keyBoardMainView];
|
||||
}
|
||||
}
|
||||
|
||||
/// 对外兼容:切换显示功能面板/键盘主视图
|
||||
- (void)showFunctionPanel:(BOOL)show {
|
||||
if (show) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeFunction animated:NO];
|
||||
return;
|
||||
}
|
||||
if (self.kb_panelMode == KBKeyboardPanelModeFunction) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:NO];
|
||||
}
|
||||
}
|
||||
|
||||
/// 对外兼容:显示/隐藏设置页(高度与 keyBoardMainView 一致),右侧滑入/滑出
|
||||
- (void)showSettingView:(BOOL)show {
|
||||
if (show) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeSettings animated:YES];
|
||||
return;
|
||||
}
|
||||
if (self.kb_panelMode == KBKeyboardPanelModeSettings) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||
}
|
||||
}
|
||||
|
||||
/// 对外兼容:显示/隐藏聊天面板(覆盖整个键盘区域)
|
||||
- (void)showChatPanel:(BOOL)show {
|
||||
if (show) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeChat animated:YES];
|
||||
return;
|
||||
}
|
||||
if (self.kb_panelMode == KBKeyboardPanelModeChat) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_setFunctionPanelVisible:(BOOL)visible {
|
||||
if (visible) {
|
||||
[self kb_ensureFunctionViewIfNeeded];
|
||||
}
|
||||
if (_functionView) {
|
||||
_functionView.hidden = !visible;
|
||||
} else if (visible) {
|
||||
// ensure 后按理已存在;这里兜底一次,避免异常情况下状态不一致
|
||||
self.functionView.hidden = NO;
|
||||
}
|
||||
self.keyBoardMainView.hidden = visible;
|
||||
}
|
||||
|
||||
- (void)kb_setChatPanelVisible:(BOOL)visible animated:(BOOL)animated {
|
||||
if (visible == self.chatPanelVisible) {
|
||||
return;
|
||||
}
|
||||
self.chatPanelVisible = visible;
|
||||
|
||||
if (visible) {
|
||||
// 记录打开聊天面板时宿主输入框已有的文本,发送时只取新增部分
|
||||
[[KBInputBufferManager shared]
|
||||
refreshFromProxyIfPossible:self.textDocumentProxy];
|
||||
self.chatPanelBaselineText = [KBInputBufferManager shared].liveText ?: @"";
|
||||
[self kb_ensureChatPanelViewIfNeeded];
|
||||
self.chatPanelView.hidden = NO;
|
||||
self.chatPanelView.alpha = 0.0;
|
||||
if (animated) {
|
||||
[UIView animateWithDuration:0.2
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseOut
|
||||
animations:^{
|
||||
self.chatPanelView.alpha = 1.0;
|
||||
}
|
||||
completion:nil];
|
||||
} else {
|
||||
self.chatPanelView.alpha = 1.0;
|
||||
}
|
||||
} else {
|
||||
// 从未创建过聊天面板时,直接返回,避免 show/hide 触发额外内存分配
|
||||
if (!_chatPanelView) {
|
||||
[self kb_updateKeyboardLayoutIfNeeded];
|
||||
return;
|
||||
}
|
||||
if (animated) {
|
||||
[UIView animateWithDuration:0.18
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseIn
|
||||
animations:^{
|
||||
self.chatPanelView.alpha = 0.0;
|
||||
}
|
||||
completion:^(BOOL finished) {
|
||||
self.chatPanelView.hidden = YES;
|
||||
}];
|
||||
} else {
|
||||
self.chatPanelView.alpha = 0.0;
|
||||
self.chatPanelView.hidden = YES;
|
||||
}
|
||||
}
|
||||
[self kb_updateKeyboardLayoutIfNeeded];
|
||||
}
|
||||
|
||||
- (void)kb_setSettingViewVisible:(BOOL)visible animated:(BOOL)animated {
|
||||
if (visible) {
|
||||
KBSettingView *settingView = self.settingView;
|
||||
if (!settingView.superview) {
|
||||
settingView.hidden = YES;
|
||||
[self.contentView addSubview:settingView];
|
||||
[settingView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.contentView);
|
||||
}];
|
||||
[settingView.backButton addTarget:self
|
||||
action:@selector(onTapSettingsBack)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
[self.contentView bringSubviewToFront:settingView];
|
||||
// 以 keyBoardMainView 的实际宽度为准,避免首次添加时 self.view 宽度尚未计算
|
||||
[self.contentView layoutIfNeeded];
|
||||
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
|
||||
if (w <= 0) {
|
||||
w = CGRectGetWidth(self.contentView.bounds);
|
||||
}
|
||||
if (w <= 0) {
|
||||
w = [self kb_portraitWidth];
|
||||
}
|
||||
settingView.transform = CGAffineTransformMakeTranslation(w, 0);
|
||||
settingView.hidden = NO;
|
||||
if (animated) {
|
||||
[UIView animateWithDuration:0.25
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseOut
|
||||
animations:^{
|
||||
settingView.transform = CGAffineTransformIdentity;
|
||||
}
|
||||
completion:nil];
|
||||
} else {
|
||||
settingView.transform = CGAffineTransformIdentity;
|
||||
}
|
||||
} else {
|
||||
KBSettingView *settingView = _settingView;
|
||||
if (!settingView) {
|
||||
return;
|
||||
}
|
||||
if (!settingView.superview || settingView.hidden) {
|
||||
return;
|
||||
}
|
||||
CGFloat w = CGRectGetWidth(self.keyBoardMainView.bounds);
|
||||
if (w <= 0) {
|
||||
w = CGRectGetWidth(self.contentView.bounds);
|
||||
}
|
||||
if (w <= 0) {
|
||||
w = [self kb_portraitWidth];
|
||||
}
|
||||
if (animated) {
|
||||
[UIView animateWithDuration:0.22
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseIn
|
||||
animations:^{
|
||||
settingView.transform = CGAffineTransformMakeTranslation(w, 0);
|
||||
}
|
||||
completion:^(BOOL finished) {
|
||||
settingView.hidden = YES;
|
||||
}];
|
||||
} else {
|
||||
settingView.transform = CGAffineTransformMakeTranslation(w, 0);
|
||||
settingView.hidden = YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_setSubscriptionPanelVisible:(BOOL)visible animated:(BOOL)animated {
|
||||
if (visible) {
|
||||
KBKeyboardSubscriptionView *panel = self.subscriptionView;
|
||||
if (!panel.superview) {
|
||||
panel.hidden = YES;
|
||||
[self.contentView addSubview:panel];
|
||||
[panel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.contentView);
|
||||
}];
|
||||
}
|
||||
[self.contentView bringSubviewToFront:panel];
|
||||
panel.hidden = NO;
|
||||
panel.alpha = 0.0;
|
||||
CGFloat height = CGRectGetHeight(self.contentView.bounds);
|
||||
if (height <= 0) {
|
||||
height = 260;
|
||||
}
|
||||
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
||||
[panel refreshProductsIfNeeded];
|
||||
if (animated) {
|
||||
[UIView animateWithDuration:0.25
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseOut
|
||||
animations:^{
|
||||
panel.alpha = 1.0;
|
||||
panel.transform = CGAffineTransformIdentity;
|
||||
}
|
||||
completion:nil];
|
||||
} else {
|
||||
panel.alpha = 1.0;
|
||||
panel.transform = CGAffineTransformIdentity;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
KBKeyboardSubscriptionView *panel = _subscriptionView;
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
if (!panel.superview || panel.hidden) {
|
||||
return;
|
||||
}
|
||||
CGFloat height = CGRectGetHeight(panel.bounds);
|
||||
if (height <= 0) {
|
||||
height = CGRectGetHeight(self.contentView.bounds);
|
||||
}
|
||||
if (animated) {
|
||||
[UIView animateWithDuration:0.22
|
||||
delay:0
|
||||
options:UIViewAnimationOptionCurveEaseIn
|
||||
animations:^{
|
||||
panel.alpha = 0.0;
|
||||
panel.transform = CGAffineTransformMakeTranslation(0, height);
|
||||
}
|
||||
completion:^(BOOL finished) {
|
||||
panel.hidden = YES;
|
||||
panel.alpha = 1.0;
|
||||
panel.transform = CGAffineTransformIdentity;
|
||||
}];
|
||||
} else {
|
||||
panel.hidden = YES;
|
||||
panel.alpha = 1.0;
|
||||
panel.transform = CGAffineTransformIdentity;
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟创建:仅在用户真正打开功能面板时才创建/布局,降低默认内存占用。
|
||||
- (void)kb_ensureFunctionViewIfNeeded {
|
||||
if (_functionView && _functionView.superview) {
|
||||
return;
|
||||
}
|
||||
KBFunctionView *v = self.functionView;
|
||||
if (!v.superview) {
|
||||
v.hidden = YES;
|
||||
[self.contentView addSubview:v];
|
||||
[v mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.contentView);
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟创建:仅在用户打开聊天面板时才创建/布局。
|
||||
- (void)kb_ensureChatPanelViewIfNeeded {
|
||||
if (_chatPanelView && _chatPanelView.superview) {
|
||||
return;
|
||||
}
|
||||
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||
CGFloat chatPanelHeight = [self kb_chatPanelHeightForWidth:portraitWidth];
|
||||
KBChatPanelView *v = self.chatPanelView;
|
||||
if (!v.superview) {
|
||||
[self.contentView addSubview:v];
|
||||
[v mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self.contentView);
|
||||
make.bottom.equalTo(self.keyBoardMainView.mas_top);
|
||||
self.chatPanelHeightConstraint =
|
||||
make.height.mas_equalTo(chatPanelHeight);
|
||||
}];
|
||||
v.hidden = YES;
|
||||
}
|
||||
}
|
||||
|
||||
// 延迟创建:键盘主面板(按键区)在隐藏时会被释放;再次显示时需要重建。
|
||||
- (void)kb_ensureKeyBoardMainViewIfNeeded {
|
||||
if (_keyBoardMainView && _keyBoardMainView.superview) {
|
||||
return;
|
||||
}
|
||||
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||
CGFloat keyboardBaseHeight =
|
||||
[self kb_keyboardBaseHeightForWidth:portraitWidth];
|
||||
KBKeyBoardMainView *v = self.keyBoardMainView;
|
||||
if (!v.superview) {
|
||||
[self.contentView addSubview:v];
|
||||
[v mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self.contentView);
|
||||
make.bottom.equalTo(self.contentView);
|
||||
self.keyBoardMainHeightConstraint =
|
||||
make.height.mas_equalTo(keyboardBaseHeight);
|
||||
}];
|
||||
}
|
||||
[self.contentView bringSubviewToFront:v];
|
||||
}
|
||||
|
||||
// 键盘隐藏时释放可重建资源(背景图/缓存/非必需面板),降低扩展内存峰值。
|
||||
- (void)kb_releaseMemoryWhenKeyboardHidden {
|
||||
[KBHUD setContainerView:nil];
|
||||
self.bgImageView.image = nil;
|
||||
self.kb_cachedGradientImage = nil;
|
||||
[self.kb_defaultGradientLayer removeFromSuperlayer];
|
||||
self.kb_defaultGradientLayer = nil;
|
||||
[[SDImageCache sharedImageCache] clearMemory];
|
||||
|
||||
// 聊天相关可能持有音频数据/临时文件,键盘隐藏时直接清空,避免累计占用。
|
||||
if (self.chatAudioPlayer) {
|
||||
[self.chatAudioPlayer stop];
|
||||
self.chatAudioPlayer = nil;
|
||||
}
|
||||
if (_chatMessages.count > 0) {
|
||||
NSString *tmpRoot = NSTemporaryDirectory();
|
||||
for (KBChatMessage *msg in _chatMessages.copy) {
|
||||
if (tmpRoot.length > 0 && msg.audioFilePath.length > 0 &&
|
||||
[msg.audioFilePath hasPrefix:tmpRoot]) {
|
||||
[[NSFileManager defaultManager] removeItemAtPath:msg.audioFilePath
|
||||
error:nil];
|
||||
}
|
||||
}
|
||||
[_chatMessages removeAllObjects];
|
||||
}
|
||||
|
||||
if (_keyBoardMainView) {
|
||||
[_keyBoardMainView removeFromSuperview];
|
||||
_keyBoardMainView = nil;
|
||||
}
|
||||
self.keyBoardMainHeightConstraint = nil;
|
||||
|
||||
if (_functionView) {
|
||||
[_functionView removeFromSuperview];
|
||||
_functionView = nil;
|
||||
}
|
||||
if (_chatPanelView) {
|
||||
[_chatPanelView removeFromSuperview];
|
||||
_chatPanelView = nil;
|
||||
}
|
||||
self.chatPanelVisible = NO;
|
||||
self.kb_panelMode = KBKeyboardPanelModeMain;
|
||||
|
||||
if (_subscriptionView) {
|
||||
[_subscriptionView removeFromSuperview];
|
||||
_subscriptionView = nil;
|
||||
}
|
||||
if (_settingView) {
|
||||
[_settingView removeFromSuperview];
|
||||
_settingView = nil;
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - KBKeyBoardMainViewDelegate
|
||||
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||||
didTapKey:(KBKey *)key {
|
||||
switch (key.type) {
|
||||
case KBKeyTypeCharacter: {
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
NSString *text = key.output ?: key.title ?: @"";
|
||||
[self.textDocumentProxy insertText:text];
|
||||
[self kb_updateCurrentWordWithInsertedText:text];
|
||||
[[KBInputBufferManager shared] appendText:text];
|
||||
} break;
|
||||
case KBKeyTypeBackspace:
|
||||
[[KBInputBufferManager shared]
|
||||
refreshFromProxyIfPossible:self.textDocumentProxy];
|
||||
[[KBInputBufferManager shared]
|
||||
prepareSnapshotForDeleteWithContextBefore:
|
||||
self.textDocumentProxy.documentContextBeforeInput
|
||||
after:
|
||||
self.textDocumentProxy
|
||||
.documentContextAfterInput];
|
||||
[[KBBackspaceUndoManager shared]
|
||||
captureAndDeleteBackwardFromProxy:self.textDocumentProxy
|
||||
count:1];
|
||||
[self kb_scheduleContextRefreshResetSuppression:NO];
|
||||
[[KBInputBufferManager shared] applyHoldDeleteCount:1];
|
||||
break;
|
||||
case KBKeyTypeSpace:
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
[self.textDocumentProxy insertText:@" "];
|
||||
[self kb_clearCurrentWord];
|
||||
[[KBInputBufferManager shared] appendText:@" "];
|
||||
break;
|
||||
case KBKeyTypeReturn:
|
||||
if (self.chatPanelVisible) {
|
||||
[self kb_handleChatSendAction];
|
||||
break;
|
||||
}
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
[self.textDocumentProxy insertText:@"\n"];
|
||||
[self kb_clearCurrentWord];
|
||||
[[KBInputBufferManager shared] appendText:@"\n"];
|
||||
break;
|
||||
case KBKeyTypeGlobe:
|
||||
[self advanceToNextInputMode];
|
||||
break;
|
||||
case KBKeyTypeCustom:
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
// 点击自定义键切换到功能面板
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeFunction animated:NO];
|
||||
[self kb_clearCurrentWord];
|
||||
break;
|
||||
case KBKeyTypeModeChange:
|
||||
case KBKeyTypeShift:
|
||||
// 这些已在 KBKeyBoardMainView/KBKeyboardView 内部处理
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||||
didTapToolActionAtIndex:(NSInteger)index {
|
||||
NSDictionary *extra = @{@"index" : @(index)};
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_toolbar_action"
|
||||
pageId:@"keyboard_main_panel"
|
||||
elementId:@"toolbar_action"
|
||||
extra:extra
|
||||
completion:nil];
|
||||
if (index == 0) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeFunction animated:YES];
|
||||
[self kb_clearCurrentWord];
|
||||
return;
|
||||
}
|
||||
if (index == 1) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeChat animated:YES];
|
||||
return;
|
||||
}
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainViewDidTapSettings:(KBKeyBoardMainView *)keyBoardMainView {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_settings_btn"
|
||||
pageId:@"keyboard_main_panel"
|
||||
elementId:@"settings_btn"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeSettings animated:YES];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||||
didSelectEmoji:(NSString *)emoji {
|
||||
if (emoji.length == 0) {
|
||||
return;
|
||||
}
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
[self.textDocumentProxy insertText:emoji];
|
||||
[self kb_clearCurrentWord];
|
||||
[[KBInputBufferManager shared] appendText:emoji];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainViewDidTapUndo:(KBKeyBoardMainView *)keyBoardMainView {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_undo_btn"
|
||||
pageId:@"keyboard_main_panel"
|
||||
elementId:@"undo_btn"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
[[KBBackspaceUndoManager shared] performUndoFromResponder:self.view];
|
||||
[self kb_scheduleContextRefreshResetSuppression:YES];
|
||||
}
|
||||
|
||||
- (void)keyBoardMainViewDidTapEmojiSearch:
|
||||
(KBKeyBoardMainView *)keyBoardMainView {
|
||||
// [[KBMaiPointReporter sharedReporter]
|
||||
// reportClickWithEventName:@"click_keyboard_emoji_search_btn"
|
||||
// pageId:@"keyboard_main_panel"
|
||||
// elementId:@"emoji_search_btn"
|
||||
// extra:nil
|
||||
// completion:nil];
|
||||
[KBHUD showInfo:KBLocalized(@"Search coming soon")];
|
||||
}
|
||||
|
||||
// MARK: - KBFunctionViewDelegate
|
||||
|
||||
- (void)functionView:(KBFunctionView *)functionView
|
||||
didTapToolActionAtIndex:(NSInteger)index {
|
||||
// 需求:当 index == 0 时,切回键盘主视图
|
||||
if (index == 0) {
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:NO];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)functionView:(KBFunctionView *_Nullable)functionView
|
||||
didRightTapToolActionAtIndex:(NSInteger)index {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_function_right_action"
|
||||
pageId:@"keyboard_function_panel"
|
||||
elementId:@"right_action"
|
||||
extra:@{@"action" : @"login_or_recharge"}
|
||||
completion:nil];
|
||||
if (!KBAuthManager.shared.isLoggedIn) {
|
||||
NSString *schemeStr =
|
||||
[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
return;
|
||||
}
|
||||
NSString *schemeStr =
|
||||
[NSString stringWithFormat:@"%@://recharge?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
|
||||
if (!ok) {
|
||||
// 失败兜底:给个文案提示
|
||||
// 比如:请回到桌面手动打开 XXX App 进行设置/充值
|
||||
[KBHUD showInfo:@"请回到桌面手动打开App进行充值"];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)functionViewDidRequestSubscription:(KBFunctionView *)functionView {
|
||||
[self showSubscriptionPanel];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)onTapSettingsBack {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_settings_back_btn"
|
||||
pageId:@"keyboard_settings"
|
||||
elementId:@"back_btn"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// KeyboardViewController+Private.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@class AVAudioPlayer;
|
||||
@class CAGradientLayer;
|
||||
@class KBChatMessage;
|
||||
@class KBChatPanelView;
|
||||
@class KBFunctionView;
|
||||
@class KBKeyBoardMainView;
|
||||
@class KBKeyboardSubscriptionView;
|
||||
@class KBSettingView;
|
||||
@class KBSuggestionEngine;
|
||||
|
||||
@protocol KBChatLimitPopViewDelegate;
|
||||
@protocol KBChatPanelViewDelegate;
|
||||
@protocol KBFunctionViewDelegate;
|
||||
@protocol KBKeyBoardMainViewDelegate;
|
||||
@protocol KBKeyboardSubscriptionViewDelegate;
|
||||
|
||||
typedef NS_ENUM(NSInteger, KBKeyboardPanelMode) {
|
||||
KBKeyboardPanelModeMain = 0,
|
||||
KBKeyboardPanelModeFunction,
|
||||
KBKeyboardPanelModeChat,
|
||||
KBKeyboardPanelModeSettings,
|
||||
KBKeyboardPanelModeSubscription,
|
||||
};
|
||||
|
||||
@interface KeyboardViewController () <KBKeyBoardMainViewDelegate,
|
||||
KBFunctionViewDelegate,
|
||||
KBKeyboardSubscriptionViewDelegate,
|
||||
KBChatPanelViewDelegate,
|
||||
KBChatLimitPopViewDelegate>
|
||||
{
|
||||
UIButton *_nextKeyboardButton;
|
||||
UIView *_contentView;
|
||||
KBKeyBoardMainView *_keyBoardMainView;
|
||||
KBFunctionView *_functionView;
|
||||
KBSettingView *_settingView;
|
||||
UIImageView *_bgImageView;
|
||||
KBChatPanelView *_chatPanelView;
|
||||
KBKeyboardSubscriptionView *_subscriptionView;
|
||||
KBSuggestionEngine *_suggestionEngine;
|
||||
NSString *_currentWord;
|
||||
UIControl *_chatLimitMaskView;
|
||||
MASConstraint *_contentWidthConstraint;
|
||||
MASConstraint *_contentHeightConstraint;
|
||||
MASConstraint *_keyBoardMainHeightConstraint;
|
||||
MASConstraint *_chatPanelHeightConstraint;
|
||||
NSLayoutConstraint *_kb_heightConstraint;
|
||||
NSLayoutConstraint *_kb_widthConstraint;
|
||||
CGFloat _kb_lastPortraitWidth;
|
||||
CGFloat _kb_lastKeyboardHeight;
|
||||
UIImage *_kb_cachedGradientImage;
|
||||
CGSize _kb_cachedGradientSize;
|
||||
CAGradientLayer *_kb_defaultGradientLayer;
|
||||
NSString *_kb_lastAppliedThemeKey;
|
||||
NSMutableArray<KBChatMessage *> *_chatMessages;
|
||||
AVAudioPlayer *_chatAudioPlayer;
|
||||
BOOL _suppressSuggestions;
|
||||
BOOL _chatPanelVisible;
|
||||
NSString *_chatPanelBaselineText;
|
||||
id _kb_fullAccessObserverToken;
|
||||
id _kb_skinObserverToken;
|
||||
KBKeyboardPanelMode _kb_panelMode;
|
||||
}
|
||||
|
||||
@property(nonatomic, strong)
|
||||
UIButton *nextKeyboardButton; // 系统“下一个键盘”按钮(可选)
|
||||
@property(nonatomic, strong) UIView *contentView;
|
||||
@property(nonatomic, strong) KBKeyBoardMainView
|
||||
*keyBoardMainView; // 功能面板视图(点击工具栏第0个时显示)
|
||||
@property(nonatomic, strong)
|
||||
KBFunctionView *functionView; // 功能面板视图(点击工具栏第0个时显示)
|
||||
@property(nonatomic, strong) KBSettingView *settingView; // 设置页
|
||||
@property(nonatomic, strong) UIImageView *bgImageView; // 背景图(在底层)
|
||||
@property(nonatomic, strong) KBChatPanelView *chatPanelView;
|
||||
@property(nonatomic, strong) KBKeyboardSubscriptionView *subscriptionView;
|
||||
@property(nonatomic, strong) KBSuggestionEngine *suggestionEngine;
|
||||
@property(nonatomic, copy) NSString *currentWord;
|
||||
@property(nonatomic, assign) BOOL suppressSuggestions;
|
||||
@property(nonatomic, strong) UIControl *chatLimitMaskView;
|
||||
@property(nonatomic, strong) MASConstraint *contentWidthConstraint;
|
||||
@property(nonatomic, strong) MASConstraint *contentHeightConstraint;
|
||||
@property(nonatomic, strong) MASConstraint *keyBoardMainHeightConstraint;
|
||||
@property(nonatomic, strong) MASConstraint *chatPanelHeightConstraint;
|
||||
@property(nonatomic, strong) NSLayoutConstraint *kb_heightConstraint;
|
||||
@property(nonatomic, strong) NSLayoutConstraint *kb_widthConstraint;
|
||||
@property(nonatomic, assign) CGFloat kb_lastPortraitWidth;
|
||||
@property(nonatomic, assign) CGFloat kb_lastKeyboardHeight;
|
||||
@property(nonatomic, strong) UIImage *kb_cachedGradientImage;
|
||||
@property(nonatomic, assign) CGSize kb_cachedGradientSize;
|
||||
@property(nonatomic, strong, nullable) CAGradientLayer *kb_defaultGradientLayer;
|
||||
@property(nonatomic, copy, nullable) NSString *kb_lastAppliedThemeKey;
|
||||
@property(nonatomic, strong) NSMutableArray<KBChatMessage *> *chatMessages;
|
||||
@property(nonatomic, strong) AVAudioPlayer *chatAudioPlayer;
|
||||
@property(nonatomic, assign) BOOL chatPanelVisible;
|
||||
@property(nonatomic, copy) NSString *chatPanelBaselineText; // 打开聊天面板时宿主输入框已有的文本
|
||||
@property(nonatomic, strong, nullable) id kb_fullAccessObserverToken;
|
||||
@property(nonatomic, strong, nullable) id kb_skinObserverToken;
|
||||
@property(nonatomic, assign) KBKeyboardPanelMode kb_panelMode;
|
||||
|
||||
@end
|
||||
|
||||
@interface KeyboardViewController (KBPrivate)
|
||||
|
||||
// UI
|
||||
- (void)setupUI;
|
||||
- (nullable KBFunctionView *)kb_functionViewIfCreated;
|
||||
|
||||
// Panels
|
||||
- (void)showFunctionPanel:(BOOL)show;
|
||||
- (void)showSettingView:(BOOL)show;
|
||||
- (void)showChatPanel:(BOOL)show;
|
||||
- (void)showSubscriptionPanel;
|
||||
- (void)hideSubscriptionPanel;
|
||||
- (void)kb_setPanelMode:(KBKeyboardPanelMode)mode animated:(BOOL)animated;
|
||||
- (void)kb_ensureFunctionViewIfNeeded;
|
||||
- (void)kb_ensureChatPanelViewIfNeeded;
|
||||
- (void)kb_ensureKeyBoardMainViewIfNeeded;
|
||||
- (void)kb_releaseMemoryWhenKeyboardHidden;
|
||||
|
||||
// Suggestions
|
||||
- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text;
|
||||
- (void)kb_clearCurrentWord;
|
||||
- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression;
|
||||
- (void)kb_refreshCurrentWordFromDocumentContextResetSuppression:
|
||||
(BOOL)resetSuppression;
|
||||
- (void)kb_updateSuggestionsForCurrentWord;
|
||||
|
||||
// Chat
|
||||
- (void)kb_handleChatSendAction;
|
||||
|
||||
// Theme
|
||||
- (void)kb_applyTheme;
|
||||
- (void)kb_applyDefaultSkinIfNeeded;
|
||||
- (void)kb_consumePendingShopSkin;
|
||||
- (void)kb_registerDarwinSkinInstallObserver;
|
||||
- (void)kb_unregisterDarwinSkinInstallObserver;
|
||||
|
||||
// Layout
|
||||
- (CGFloat)kb_portraitWidth;
|
||||
- (CGFloat)kb_keyboardHeightForWidth:(CGFloat)width;
|
||||
- (CGFloat)kb_keyboardBaseHeightForWidth:(CGFloat)width;
|
||||
- (CGFloat)kb_chatPanelHeightForWidth:(CGFloat)width;
|
||||
- (void)kb_updateKeyboardLayoutIfNeeded;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// KeyboardViewController+Subscription.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBAuthManager.h"
|
||||
#import "KBFullAccessManager.h"
|
||||
#import "KBHostAppLauncher.h"
|
||||
#import "KBKeyboardSubscriptionProduct.h"
|
||||
#import "KBKeyboardSubscriptionView.h"
|
||||
|
||||
@implementation KeyboardViewController (Subscription)
|
||||
|
||||
- (void)showSubscriptionPanel {
|
||||
// 1) 先判断权限:未开启“完全访问”则走引导逻辑
|
||||
if (![[KBFullAccessManager shared] hasFullAccess]) {
|
||||
// 未开启完全访问:保持原有引导路径
|
||||
// [KBHUD showInfo:KBLocalized(@"处理中…")];
|
||||
[[KBFullAccessManager shared] ensureFullAccessOrGuideInView:self.view];
|
||||
return;
|
||||
}
|
||||
// 点击充值要先判断是否登录
|
||||
// 2) 权限没问题,再判断是否登录:未登录 -> 直接拉起主 App,由主 App 负责完成登录
|
||||
if (!KBAuthManager.shared.isLoggedIn) {
|
||||
NSString *schemeStr =
|
||||
[NSString stringWithFormat:@"%@://login?src=keyboard", KB_APP_SCHEME];
|
||||
NSURL *scheme = [NSURL URLWithString:schemeStr];
|
||||
// 从当前视图作为起点,通过响应链找到 UIApplication 再调起主 App
|
||||
BOOL ok = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
return;
|
||||
}
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeSubscription animated:YES];
|
||||
}
|
||||
|
||||
- (void)hideSubscriptionPanel {
|
||||
if (self.kb_panelMode != KBKeyboardPanelModeSubscription) {
|
||||
return;
|
||||
}
|
||||
[self kb_setPanelMode:KBKeyboardPanelModeMain animated:YES];
|
||||
}
|
||||
|
||||
#pragma mark - KBKeyboardSubscriptionViewDelegate
|
||||
|
||||
- (void)subscriptionViewDidTapClose:(KBKeyboardSubscriptionView *)view {
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_subscription_close_btn"
|
||||
pageId:@"keyboard_subscription_panel"
|
||||
elementId:@"close_btn"
|
||||
extra:nil
|
||||
completion:nil];
|
||||
[self hideSubscriptionPanel];
|
||||
}
|
||||
|
||||
- (void)subscriptionView:(KBKeyboardSubscriptionView *)view
|
||||
didTapPurchaseForProduct:(KBKeyboardSubscriptionProduct *)product {
|
||||
NSMutableDictionary *extra = [NSMutableDictionary dictionary];
|
||||
if ([product.productId isKindOfClass:NSString.class] &&
|
||||
product.productId.length > 0) {
|
||||
extra[@"product_id"] = product.productId;
|
||||
}
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"click_keyboard_subscription_product_btn"
|
||||
pageId:@"keyboard_subscription_panel"
|
||||
elementId:@"product_btn"
|
||||
extra:extra.copy
|
||||
completion:nil];
|
||||
[self hideSubscriptionPanel];
|
||||
[self kb_openRechargeForProduct:product];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)kb_openRechargeForProduct:(KBKeyboardSubscriptionProduct *)product {
|
||||
if (![product isKindOfClass:KBKeyboardSubscriptionProduct.class] ||
|
||||
product.productId.length == 0) {
|
||||
[KBHUD showInfo:KBLocalized(@"Product unavailable")];
|
||||
return;
|
||||
}
|
||||
NSString *encodedId = [self.class kb_urlEncodedString:product.productId];
|
||||
NSString *title = [product displayTitle];
|
||||
NSString *encodedTitle = [self.class kb_urlEncodedString:title];
|
||||
NSMutableArray<NSString *> *params =
|
||||
[NSMutableArray arrayWithObjects:@"autoPay=1", @"prefill=1", nil];
|
||||
if (encodedId.length) {
|
||||
[params addObject:[NSString stringWithFormat:@"productId=%@", encodedId]];
|
||||
}
|
||||
if (encodedTitle.length) {
|
||||
[params
|
||||
addObject:[NSString stringWithFormat:@"productTitle=%@", encodedTitle]];
|
||||
}
|
||||
NSString *query = [params componentsJoinedByString:@"&"];
|
||||
NSString *urlString = [NSString
|
||||
stringWithFormat:@"%@://recharge?src=keyboard&%@", KB_APP_SCHEME, query];
|
||||
NSURL *scheme = [NSURL URLWithString:urlString];
|
||||
BOOL success = [KBHostAppLauncher openHostAppURL:scheme fromResponder:self.view];
|
||||
if (!success) {
|
||||
[KBHUD showInfo:KBLocalized(@"Please open the App to finish purchase")];
|
||||
}
|
||||
}
|
||||
|
||||
+ (NSString *)kb_urlEncodedString:(NSString *)value {
|
||||
if (value.length == 0) {
|
||||
return @"";
|
||||
}
|
||||
NSString *reserved = @"!*'();:@&=+$,/?%#[]";
|
||||
NSMutableCharacterSet *allowed =
|
||||
[[NSCharacterSet URLQueryAllowedCharacterSet] mutableCopy];
|
||||
[allowed removeCharactersInString:reserved];
|
||||
return [value stringByAddingPercentEncodingWithAllowedCharacters:allowed]
|
||||
?: @"";
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,178 @@
|
||||
//
|
||||
// KeyboardViewController+Suggestions.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
#import "KBKeyBoardMainView.h"
|
||||
#import "KBSuggestionEngine.h"
|
||||
|
||||
@implementation KeyboardViewController (Suggestions)
|
||||
|
||||
// MARK: - Suggestions
|
||||
|
||||
- (void)kb_updateCurrentWordWithInsertedText:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return;
|
||||
}
|
||||
if ([self kb_isAlphabeticString:text]) {
|
||||
NSString *current = self.currentWord ?: @"";
|
||||
self.currentWord = [current stringByAppendingString:text];
|
||||
self.suppressSuggestions = NO;
|
||||
[self kb_updateSuggestionsForCurrentWord];
|
||||
} else {
|
||||
[self kb_clearCurrentWord];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_clearCurrentWord {
|
||||
self.currentWord = @"";
|
||||
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||||
self.suppressSuggestions = NO;
|
||||
}
|
||||
|
||||
- (void)kb_scheduleContextRefreshResetSuppression:(BOOL)resetSuppression {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self kb_refreshCurrentWordFromDocumentContextResetSuppression:
|
||||
resetSuppression];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)kb_refreshCurrentWordFromDocumentContextResetSuppression:
|
||||
(BOOL)resetSuppression {
|
||||
NSString *context = self.textDocumentProxy.documentContextBeforeInput ?: @"";
|
||||
NSString *word = [self kb_extractTrailingWordFromContext:context];
|
||||
self.currentWord = word ?: @"";
|
||||
if (resetSuppression) {
|
||||
self.suppressSuggestions = NO;
|
||||
}
|
||||
[self kb_updateSuggestionsForCurrentWord];
|
||||
}
|
||||
|
||||
- (NSString *)kb_extractTrailingWordFromContext:(NSString *)context {
|
||||
if (context.length == 0) {
|
||||
return @"";
|
||||
}
|
||||
static NSCharacterSet *letters = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
letters = [NSCharacterSet
|
||||
characterSetWithCharactersInString:
|
||||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"];
|
||||
});
|
||||
|
||||
NSInteger idx = (NSInteger)context.length - 1;
|
||||
while (idx >= 0) {
|
||||
unichar ch = [context characterAtIndex:(NSUInteger)idx];
|
||||
if (![letters characterIsMember:ch]) {
|
||||
break;
|
||||
}
|
||||
idx -= 1;
|
||||
}
|
||||
NSUInteger start = (NSUInteger)(idx + 1);
|
||||
if (start >= context.length) {
|
||||
return @"";
|
||||
}
|
||||
return [context substringFromIndex:start];
|
||||
}
|
||||
|
||||
- (BOOL)kb_isAlphabeticString:(NSString *)text {
|
||||
if (text.length == 0) {
|
||||
return NO;
|
||||
}
|
||||
static NSCharacterSet *letters = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
letters = [NSCharacterSet
|
||||
characterSetWithCharactersInString:
|
||||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"];
|
||||
});
|
||||
for (NSUInteger i = 0; i < text.length; i++) {
|
||||
if (![letters characterIsMember:[text characterAtIndex:i]]) {
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (void)kb_updateSuggestionsForCurrentWord {
|
||||
NSString *prefix = self.currentWord ?: @"";
|
||||
if (prefix.length == 0) {
|
||||
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||||
return;
|
||||
}
|
||||
if (self.suppressSuggestions) {
|
||||
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||||
return;
|
||||
}
|
||||
NSArray<NSString *> *items =
|
||||
[self.suggestionEngine suggestionsForPrefix:prefix limit:5];
|
||||
NSArray<NSString *> *cased = [self kb_applyCaseToSuggestions:items
|
||||
prefix:prefix];
|
||||
[self.keyBoardMainView kb_setSuggestions:cased];
|
||||
}
|
||||
|
||||
- (NSArray<NSString *> *)kb_applyCaseToSuggestions:(NSArray<NSString *> *)items
|
||||
prefix:(NSString *)prefix {
|
||||
if (items.count == 0 || prefix.length == 0) {
|
||||
return items;
|
||||
}
|
||||
BOOL allUpper = [prefix isEqualToString:prefix.uppercaseString];
|
||||
BOOL firstUpper = [[prefix substringToIndex:1]
|
||||
isEqualToString:[[prefix substringToIndex:1] uppercaseString]];
|
||||
|
||||
if (!allUpper && !firstUpper) {
|
||||
return items;
|
||||
}
|
||||
|
||||
NSMutableArray<NSString *> *result =
|
||||
[NSMutableArray arrayWithCapacity:items.count];
|
||||
for (NSString *word in items) {
|
||||
if (allUpper) {
|
||||
[result addObject:word.uppercaseString];
|
||||
} else {
|
||||
NSString *first = [[word substringToIndex:1] uppercaseString];
|
||||
NSString *rest = (word.length > 1) ? [word substringFromIndex:1] : @"";
|
||||
[result addObject:[first stringByAppendingString:rest]];
|
||||
}
|
||||
}
|
||||
return result.copy;
|
||||
}
|
||||
|
||||
// MARK: - KBKeyBoardMainViewDelegate (Suggestion)
|
||||
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView
|
||||
didSelectSuggestion:(NSString *)suggestion {
|
||||
if (suggestion.length == 0) {
|
||||
return;
|
||||
}
|
||||
NSDictionary *extra = @{@"suggestion_len" : @(suggestion.length)};
|
||||
// [[KBMaiPointReporter sharedReporter]
|
||||
// reportClickWithEventName:@"click_keyboard_suggestion_item"
|
||||
// pageId:@"keyboard_main_panel"
|
||||
// elementId:@"suggestion_item"
|
||||
// extra:extra
|
||||
// completion:nil];
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
NSString *current = self.currentWord ?: @"";
|
||||
if (current.length > 0) {
|
||||
for (NSUInteger i = 0; i < current.length; i++) {
|
||||
[self.textDocumentProxy deleteBackward];
|
||||
}
|
||||
}
|
||||
[self.textDocumentProxy insertText:suggestion];
|
||||
self.currentWord = suggestion;
|
||||
[self.suggestionEngine recordSelection:suggestion];
|
||||
self.suppressSuggestions = YES;
|
||||
[self.keyBoardMainView kb_setSuggestions:@[]];
|
||||
[[KBInputBufferManager shared] replaceTailWithText:suggestion
|
||||
deleteCount:current.length];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
//
|
||||
// KeyboardViewController+Theme.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBFunctionView.h"
|
||||
#import "KBKeyBoardMainView.h"
|
||||
#import "KBSkinInstallBridge.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "UIImage+KBColor.h"
|
||||
#import <QuartzCore/QuartzCore.h>
|
||||
|
||||
static NSString *const kKBDefaultSkinIdLight = @"normal_them";
|
||||
static NSString *const kKBDefaultSkinZipNameLight = @"normal_them";
|
||||
static NSString *const kKBDefaultSkinIdDark = @"normal_hei_them";
|
||||
static NSString *const kKBDefaultSkinZipNameDark = @"normal_hei_them";
|
||||
|
||||
// 提前声明一个类别,使编译器在 static 回调中识别 kb_consumePendingShopSkin 方法。
|
||||
@interface KeyboardViewController (KBSkinShopBridge)
|
||||
- (void)kb_consumePendingShopSkin;
|
||||
@end
|
||||
|
||||
static void KBSkinInstallNotificationCallback(CFNotificationCenterRef center,
|
||||
void *observer, CFStringRef name,
|
||||
const void *object,
|
||||
CFDictionaryRef userInfo) {
|
||||
KeyboardViewController *strongSelf =
|
||||
(__bridge KeyboardViewController *)observer;
|
||||
if (!strongSelf) {
|
||||
return;
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if ([strongSelf respondsToSelector:@selector(kb_consumePendingShopSkin)]) {
|
||||
[strongSelf kb_consumePendingShopSkin];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@implementation KeyboardViewController (Theme)
|
||||
|
||||
- (void)kb_registerDarwinSkinInstallObserver {
|
||||
CFNotificationCenterAddObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge const void *)(self), KBSkinInstallNotificationCallback,
|
||||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL,
|
||||
CFNotificationSuspensionBehaviorDeliverImmediately);
|
||||
}
|
||||
|
||||
- (void)kb_unregisterDarwinSkinInstallObserver {
|
||||
CFNotificationCenterRemoveObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(),
|
||||
(__bridge const void *)(self),
|
||||
(__bridge CFStringRef)KBDarwinSkinInstallRequestNotification, NULL);
|
||||
}
|
||||
|
||||
- (void)kb_applyTheme {
|
||||
@autoreleasepool {
|
||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||
UIImage *img = nil;
|
||||
BOOL isDefaultTheme = [self kb_isDefaultKeyboardTheme:t];
|
||||
BOOL isDarkMode = [self kb_isDarkModeActive];
|
||||
|
||||
NSString *skinId = t.skinId ?: @"";
|
||||
NSString *themeKey =
|
||||
[NSString stringWithFormat:@"%@|default=%d|dark=%d", skinId,
|
||||
isDefaultTheme, isDarkMode];
|
||||
BOOL themeChanged =
|
||||
(self.kb_lastAppliedThemeKey.length == 0 ||
|
||||
![self.kb_lastAppliedThemeKey isEqualToString:themeKey]);
|
||||
if (themeChanged) {
|
||||
self.kb_lastAppliedThemeKey = themeKey;
|
||||
}
|
||||
|
||||
CGSize size = self.bgImageView.bounds.size;
|
||||
if (isDefaultTheme) {
|
||||
if (isDarkMode) {
|
||||
// 暗黑模式:直接使用背景色,不使用图片渲染
|
||||
// 这样可以避免图片渲染时的色彩空间转换导致颜色不一致
|
||||
img = nil;
|
||||
self.bgImageView.image = nil;
|
||||
[self.kb_defaultGradientLayer removeFromSuperlayer];
|
||||
self.kb_defaultGradientLayer = nil;
|
||||
// 使用与系统键盘底部完全相同的颜色
|
||||
if (@available(iOS 13.0, *)) {
|
||||
// iOS 系统键盘使用的实际颜色 (RGB: 44, 44, 46 in sRGB, 或 #2C2C2E)
|
||||
// 但为了完美匹配,我们使用动态颜色并直接设置为背景
|
||||
UIColor *kbBgColor =
|
||||
[UIColor colorWithDynamicProvider:^UIColor *_Nonnull(
|
||||
UITraitCollection *_Nonnull traitCollection) {
|
||||
if (traitCollection.userInterfaceStyle ==
|
||||
UIUserInterfaceStyleDark) {
|
||||
// 暗黑模式下系统键盘实际背景色
|
||||
return [UIColor colorWithRed:43.0 / 255.0
|
||||
green:43.0 / 255.0
|
||||
blue:43.0 / 255.0
|
||||
alpha:1.0];
|
||||
} else {
|
||||
return [UIColor colorWithRed:209.0 / 255.0
|
||||
green:211.0 / 255.0
|
||||
blue:219.0 / 255.0
|
||||
alpha:1.0];
|
||||
}
|
||||
}];
|
||||
self.contentView.backgroundColor = kbBgColor;
|
||||
self.bgImageView.backgroundColor = kbBgColor;
|
||||
} else {
|
||||
UIColor *darkColor = [UIColor colorWithRed:43.0 / 255.0
|
||||
green:43.0 / 255.0
|
||||
blue:43.0 / 255.0
|
||||
alpha:1.0];
|
||||
self.contentView.backgroundColor = darkColor;
|
||||
self.bgImageView.backgroundColor = darkColor;
|
||||
}
|
||||
} else {
|
||||
// 浅色模式:使用渐变层(避免生成大位图导致内存上涨)
|
||||
if (size.width <= 0 || size.height <= 0) {
|
||||
[self.view layoutIfNeeded];
|
||||
size = self.bgImageView.bounds.size;
|
||||
}
|
||||
if (size.width <= 0 || size.height <= 0) {
|
||||
size = self.view.bounds.size;
|
||||
}
|
||||
if (size.width <= 0 || size.height <= 0) {
|
||||
size = [UIScreen mainScreen].bounds.size;
|
||||
}
|
||||
UIColor *topColor = [UIColor colorWithHex:0xDEDFE4];
|
||||
UIColor *bottomColor = [UIColor colorWithHex:0xD1D3DB];
|
||||
UIColor *resolvedTopColor = topColor;
|
||||
UIColor *resolvedBottomColor = bottomColor;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
resolvedTopColor =
|
||||
[topColor resolvedColorWithTraitCollection:self.traitCollection];
|
||||
resolvedBottomColor =
|
||||
[bottomColor resolvedColorWithTraitCollection:self.traitCollection];
|
||||
}
|
||||
CAGradientLayer *layer = self.kb_defaultGradientLayer;
|
||||
if (!layer) {
|
||||
layer = [CAGradientLayer layer];
|
||||
layer.startPoint = CGPointMake(0.5, 0.0);
|
||||
layer.endPoint = CGPointMake(0.5, 1.0);
|
||||
[self.bgImageView.layer insertSublayer:layer atIndex:0];
|
||||
self.kb_defaultGradientLayer = layer;
|
||||
}
|
||||
layer.colors =
|
||||
@[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ];
|
||||
layer.frame = (CGRect){CGPointZero, size};
|
||||
img = nil;
|
||||
self.bgImageView.image = nil;
|
||||
self.contentView.backgroundColor = [UIColor clearColor];
|
||||
self.bgImageView.backgroundColor = [UIColor clearColor];
|
||||
}
|
||||
NSLog(@"===");
|
||||
} else {
|
||||
// 自定义皮肤:清除背景色,使用皮肤图片
|
||||
self.contentView.backgroundColor = [UIColor clearColor];
|
||||
self.bgImageView.backgroundColor = [UIColor clearColor];
|
||||
[self.kb_defaultGradientLayer removeFromSuperlayer];
|
||||
self.kb_defaultGradientLayer = nil;
|
||||
img = [[KBSkinManager shared] currentBackgroundImage];
|
||||
}
|
||||
NSLog(@"⌨️[Keyboard] apply theme id=%@ hasBg=%d", t.skinId, (img != nil));
|
||||
[self kb_logSkinDiagnosticsWithTheme:t backgroundImage:img];
|
||||
self.bgImageView.image = img;
|
||||
|
||||
// 触发键区按主题重绘
|
||||
if (themeChanged &&
|
||||
[self.keyBoardMainView respondsToSelector:@selector(kb_applyTheme)]) {
|
||||
// method declared in KBKeyBoardMainView.h
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[self.keyBoardMainView performSelector:@selector(kb_applyTheme)];
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
// 注意:这里不能直接访问 self.functionView,否则会导致功能面板提前创建,占用内存。
|
||||
KBFunctionView *functionView = [self kb_functionViewIfCreated];
|
||||
if (themeChanged && functionView &&
|
||||
[functionView respondsToSelector:@selector(kb_applyTheme)]) {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
|
||||
[functionView performSelector:@selector(kb_applyTheme)];
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (BOOL)kb_isDefaultKeyboardTheme:(KBSkinTheme *)theme {
|
||||
NSString *skinId = theme.skinId ?: @"";
|
||||
if (skinId.length == 0 || [skinId isEqualToString:@"default"]) {
|
||||
return YES;
|
||||
}
|
||||
if ([skinId isEqualToString:kKBDefaultSkinIdLight]) {
|
||||
return YES;
|
||||
}
|
||||
return [skinId isEqualToString:kKBDefaultSkinIdDark];
|
||||
}
|
||||
|
||||
- (BOOL)kb_isDarkModeActive {
|
||||
if (@available(iOS 13.0, *)) {
|
||||
return self.traitCollection.userInterfaceStyle == UIUserInterfaceStyleDark;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (NSString *)kb_defaultSkinIdForCurrentStyle {
|
||||
return [self kb_isDarkModeActive] ? kKBDefaultSkinIdDark
|
||||
: kKBDefaultSkinIdLight;
|
||||
}
|
||||
|
||||
- (NSString *)kb_defaultSkinZipNameForCurrentStyle {
|
||||
return [self kb_isDarkModeActive] ? kKBDefaultSkinZipNameDark
|
||||
: kKBDefaultSkinZipNameLight;
|
||||
}
|
||||
|
||||
- (UIImage *)kb_defaultGradientImageWithSize:(CGSize)size
|
||||
topColor:(UIColor *)topColor
|
||||
bottomColor:(UIColor *)bottomColor {
|
||||
if (size.width <= 0 || size.height <= 0) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
// 尺寸未变则复用缓存,避免反复创建图片撑爆键盘扩展内存
|
||||
if (self.kb_cachedGradientImage &&
|
||||
CGSizeEqualToSize(self.kb_cachedGradientSize, size)) {
|
||||
return self.kb_cachedGradientImage;
|
||||
}
|
||||
|
||||
UIColor *resolvedTopColor = topColor;
|
||||
UIColor *resolvedBottomColor = bottomColor;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
resolvedTopColor =
|
||||
[topColor resolvedColorWithTraitCollection:self.traitCollection];
|
||||
resolvedBottomColor =
|
||||
[bottomColor resolvedColorWithTraitCollection:self.traitCollection];
|
||||
}
|
||||
|
||||
CAGradientLayer *layer = [CAGradientLayer layer];
|
||||
layer.frame = CGRectMake(0, 0, size.width, size.height);
|
||||
layer.startPoint = CGPointMake(0.5, 0.0);
|
||||
layer.endPoint = CGPointMake(0.5, 1.0);
|
||||
layer.colors =
|
||||
@[ (id)resolvedTopColor.CGColor, (id)resolvedBottomColor.CGColor ];
|
||||
|
||||
UIGraphicsBeginImageContextWithOptions(size, YES, 0);
|
||||
[layer renderInContext:UIGraphicsGetCurrentContext()];
|
||||
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
|
||||
UIGraphicsEndImageContext();
|
||||
|
||||
self.kb_cachedGradientImage = image;
|
||||
self.kb_cachedGradientSize = size;
|
||||
return image;
|
||||
}
|
||||
|
||||
- (void)kb_logSkinDiagnosticsWithTheme:(KBSkinTheme *)theme
|
||||
backgroundImage:(UIImage *)image {
|
||||
#if DEBUG
|
||||
NSString *skinId = theme.skinId ?: @"";
|
||||
NSString *name = theme.name ?: @"";
|
||||
NSMutableArray<NSString *> *roots = [NSMutableArray array];
|
||||
NSURL *containerURL = [[NSFileManager defaultManager]
|
||||
containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
||||
if (containerURL.path.length > 0) {
|
||||
[roots addObject:containerURL.path];
|
||||
}
|
||||
NSString *cacheRoot = NSSearchPathForDirectoriesInDomains(
|
||||
NSCachesDirectory, NSUserDomainMask, YES)
|
||||
.firstObject;
|
||||
if (cacheRoot.length > 0) {
|
||||
[roots addObject:cacheRoot];
|
||||
}
|
||||
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
NSMutableArray<NSString *> *lines = [NSMutableArray array];
|
||||
for (NSString *root in roots) {
|
||||
NSString *iconsDir = [[root stringByAppendingPathComponent:@"Skins"]
|
||||
stringByAppendingPathComponent:skinId];
|
||||
iconsDir = [iconsDir stringByAppendingPathComponent:@"icons"];
|
||||
BOOL isDir = NO;
|
||||
BOOL exists = [fm fileExistsAtPath:iconsDir isDirectory:&isDir] && isDir;
|
||||
NSArray *contents =
|
||||
exists ? [fm contentsOfDirectoryAtPath:iconsDir error:nil] : nil;
|
||||
NSUInteger count = contents.count;
|
||||
BOOL hasQ =
|
||||
exists &&
|
||||
[fm fileExistsAtPath:[iconsDir
|
||||
stringByAppendingPathComponent:@"key_q.png"]];
|
||||
BOOL hasQUp =
|
||||
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
|
||||
@"key_q_up.png"]];
|
||||
BOOL hasDel =
|
||||
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
|
||||
@"key_del.png"]];
|
||||
BOOL hasShift =
|
||||
exists &&
|
||||
[fm fileExistsAtPath:[iconsDir
|
||||
stringByAppendingPathComponent:@"key_up.png"]];
|
||||
BOOL hasShiftUpper =
|
||||
exists && [fm fileExistsAtPath:[iconsDir stringByAppendingPathComponent:
|
||||
@"key_up_upper.png"]];
|
||||
NSString *line = [NSString
|
||||
stringWithFormat:@"root=%@ icons=%@ exist=%d count=%tu key_q=%d "
|
||||
@"key_q_up=%d key_del=%d key_up=%d key_up_upper=%d",
|
||||
root, iconsDir, exists, count, hasQ, hasQUp, hasDel,
|
||||
hasShift, hasShiftUpper];
|
||||
[lines addObject:line];
|
||||
}
|
||||
|
||||
NSLog(@"[Keyboard] theme id=%@ name=%@ hasBg=%d\n%@", skinId, name,
|
||||
(image != nil), [lines componentsJoinedByString:@"\n"]);
|
||||
#endif
|
||||
}
|
||||
|
||||
- (void)kb_consumePendingShopSkin {
|
||||
KBWeakSelf [KBSkinInstallBridge
|
||||
consumePendingRequestFromBundle:NSBundle.mainBundle
|
||||
completion:^(BOOL success,
|
||||
NSError *_Nullable error) {
|
||||
if (!success) {
|
||||
if (error) {
|
||||
NSLog(@"[Keyboard] skin request failed: %@",
|
||||
error);
|
||||
[KBHUD
|
||||
showInfo:
|
||||
KBLocalized(
|
||||
@"皮肤资源准备失败,请稍后再试")];
|
||||
}
|
||||
return;
|
||||
}
|
||||
[weakSelf kb_applyTheme];
|
||||
[KBHUD showInfo:KBLocalized(
|
||||
@"皮肤已更新,立即体验吧")];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_applyDefaultSkinIfNeeded {
|
||||
NSDictionary *pending = [KBSkinInstallBridge pendingRequestPayload];
|
||||
if (pending.count > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *currentId = [KBSkinManager shared].current.skinId ?: @"";
|
||||
BOOL isDefault =
|
||||
(currentId.length == 0 || [currentId isEqualToString:@"default"]);
|
||||
BOOL isLightDefault = [currentId isEqualToString:kKBDefaultSkinIdLight];
|
||||
BOOL isDarkDefault = [currentId isEqualToString:kKBDefaultSkinIdDark];
|
||||
if (!isDefault && !isLightDefault && !isDarkDefault) {
|
||||
// 用户已应用自定义皮肤:不随深色模式切换默认皮肤
|
||||
return;
|
||||
}
|
||||
NSString *targetId = [self kb_defaultSkinIdForCurrentStyle];
|
||||
NSString *targetZip = [self kb_defaultSkinZipNameForCurrentStyle];
|
||||
if (currentId.length > 0 && [currentId isEqualToString:targetId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
NSError *applyError = nil;
|
||||
if ([KBSkinInstallBridge applyInstalledSkinWithId:targetId error:&applyError]) {
|
||||
return;
|
||||
}
|
||||
|
||||
[KBSkinInstallBridge publishBundleSkinRequestWithId:targetId
|
||||
name:targetId
|
||||
zipName:targetZip
|
||||
iconShortNames:nil];
|
||||
[KBSkinInstallBridge
|
||||
consumePendingRequestFromBundle:NSBundle.mainBundle
|
||||
completion:^(__unused BOOL success,
|
||||
__unused NSError *_Nullable error) {
|
||||
// 已通过通知触发主题刷新,这里无需额外处理
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,151 @@
|
||||
//
|
||||
// KeyboardViewController+UI.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// Created by Codex on 2026/02/22.
|
||||
//
|
||||
|
||||
#import "KeyboardViewController+Private.h"
|
||||
|
||||
#import "KBChatMessage.h"
|
||||
#import "KBChatPanelView.h"
|
||||
#import "KBFunctionView.h"
|
||||
#import "KBKeyBoardMainView.h"
|
||||
#import "KBKeyboardSubscriptionView.h"
|
||||
#import "KBSettingView.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@implementation KeyboardViewController (UI)
|
||||
|
||||
- (void)setupUI {
|
||||
self.view.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
|
||||
// 按“短边”宽度等比缩放,横屏保持竖屏布局比例
|
||||
CGFloat portraitWidth = [self kb_portraitWidth];
|
||||
CGFloat keyboardHeight = [self kb_keyboardHeightForWidth:portraitWidth];
|
||||
CGFloat keyboardBaseHeight = [self kb_keyboardBaseHeightForWidth:portraitWidth];
|
||||
CGFloat screenWidth = CGRectGetWidth([UIScreen mainScreen].bounds);
|
||||
|
||||
// FIX: iOS 26 键盘闪烁问题
|
||||
// iOS 26 在键盘滑入动画开始前,会对 self.view 做一次离屏预渲染快照(非实时 view),
|
||||
// 该快照会短暂显示在屏幕中间。如果此时 view 已有完整高度和内容,用户就会看到
|
||||
// 键盘 UI 在屏幕中间闪现一帧,然后键盘才从底部正常滑入。
|
||||
// 解决方案:初始高度设为 0,让系统快照时无内容可渲染;
|
||||
// 在 viewWillAppear: 中恢复正确高度,此时系统已准备好滑入动画。
|
||||
// (iOS 18 及更早版本无此预渲染机制,不受影响)
|
||||
NSLayoutConstraint *h = [self.view.heightAnchor constraintEqualToConstant:0];
|
||||
NSLayoutConstraint *w =
|
||||
[self.view.widthAnchor constraintEqualToConstant:screenWidth];
|
||||
self.kb_heightConstraint = h;
|
||||
self.kb_widthConstraint = w;
|
||||
|
||||
h.priority = UILayoutPriorityRequired;
|
||||
w.priority = UILayoutPriorityRequired;
|
||||
[NSLayoutConstraint activateConstraints:@[ h, w ]];
|
||||
// 关闭 UIInputView 自适应(某些系统版本会尝试放大为全屏高度导致冲突)
|
||||
if ([self.view isKindOfClass:[UIInputView class]]) {
|
||||
UIInputView *iv = (UIInputView *)self.view;
|
||||
if ([iv respondsToSelector:@selector(setAllowsSelfSizing:)]) {
|
||||
iv.allowsSelfSizing = NO;
|
||||
}
|
||||
}
|
||||
// 内容容器:横屏时保持竖屏宽度,居中显示
|
||||
[self.view addSubview:self.contentView];
|
||||
[self.contentView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerX.equalTo(self.view);
|
||||
make.bottom.equalTo(self.view);
|
||||
self.contentWidthConstraint = make.width.mas_equalTo(portraitWidth);
|
||||
self.contentHeightConstraint = make.height.mas_equalTo(keyboardHeight);
|
||||
}];
|
||||
|
||||
// 背景图铺底(仅在内容容器内)
|
||||
[self.contentView addSubview:self.bgImageView];
|
||||
[self.bgImageView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.contentView);
|
||||
}];
|
||||
|
||||
[self.contentView addSubview:self.keyBoardMainView];
|
||||
[self.keyBoardMainView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self.contentView);
|
||||
make.bottom.equalTo(self.contentView);
|
||||
self.keyBoardMainHeightConstraint =
|
||||
make.height.mas_equalTo(keyboardBaseHeight);
|
||||
}];
|
||||
|
||||
// 初始隐藏,避免布局完成前闪烁
|
||||
self.contentView.hidden = YES;
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (nullable KBFunctionView *)kb_functionViewIfCreated {
|
||||
return _functionView;
|
||||
}
|
||||
|
||||
- (UIView *)contentView {
|
||||
if (!_contentView) {
|
||||
_contentView = [[UIView alloc] init];
|
||||
_contentView.backgroundColor = [UIColor clearColor];
|
||||
}
|
||||
return _contentView;
|
||||
}
|
||||
|
||||
- (UIImageView *)bgImageView {
|
||||
if (!_bgImageView) {
|
||||
_bgImageView = [[UIImageView alloc] init];
|
||||
_bgImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
_bgImageView.clipsToBounds = YES;
|
||||
}
|
||||
return _bgImageView;
|
||||
}
|
||||
|
||||
- (KBKeyBoardMainView *)keyBoardMainView {
|
||||
if (!_keyBoardMainView) {
|
||||
_keyBoardMainView = [[KBKeyBoardMainView alloc] init];
|
||||
_keyBoardMainView.delegate = self;
|
||||
}
|
||||
return _keyBoardMainView;
|
||||
}
|
||||
|
||||
- (KBFunctionView *)functionView {
|
||||
if (!_functionView) {
|
||||
_functionView = [[KBFunctionView alloc] init];
|
||||
_functionView.delegate = self; // 监听功能面板顶部Bar点击
|
||||
}
|
||||
return _functionView;
|
||||
}
|
||||
|
||||
- (KBSettingView *)settingView {
|
||||
if (!_settingView) {
|
||||
_settingView = [[KBSettingView alloc] init];
|
||||
}
|
||||
return _settingView;
|
||||
}
|
||||
|
||||
- (KBChatPanelView *)chatPanelView {
|
||||
if (!_chatPanelView) {
|
||||
NSLog(@"[Keyboard] ⚠️ chatPanelView 被创建!");
|
||||
_chatPanelView = [[KBChatPanelView alloc] init];
|
||||
_chatPanelView.delegate = self;
|
||||
}
|
||||
return _chatPanelView;
|
||||
}
|
||||
|
||||
- (NSMutableArray<KBChatMessage *> *)chatMessages {
|
||||
if (!_chatMessages) {
|
||||
_chatMessages = [NSMutableArray array];
|
||||
}
|
||||
return _chatMessages;
|
||||
}
|
||||
|
||||
- (KBKeyboardSubscriptionView *)subscriptionView {
|
||||
if (!_subscriptionView) {
|
||||
_subscriptionView = [[KBKeyboardSubscriptionView alloc] init];
|
||||
_subscriptionView.delegate = self;
|
||||
_subscriptionView.hidden = YES;
|
||||
_subscriptionView.alpha = 0.0;
|
||||
}
|
||||
return _subscriptionView;
|
||||
}
|
||||
|
||||
@end
|
||||
23
CustomKeyboard/Manager/KBSuggestionEngine.h
Normal file
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// KBSuggestionEngine.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// Simple local suggestion engine (prefix match + lightweight ranking).
|
||||
@interface KBSuggestionEngine : NSObject
|
||||
|
||||
+ (instancetype)shared;
|
||||
|
||||
/// Returns suggestions for prefix (lowercase expected), limited by count.
|
||||
- (NSArray<NSString *> *)suggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit;
|
||||
|
||||
/// Record a selection to slightly boost ranking next time.
|
||||
- (void)recordSelection:(NSString *)word;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
167
CustomKeyboard/Manager/KBSuggestionEngine.m
Normal file
@@ -0,0 +1,167 @@
|
||||
//
|
||||
// KBSuggestionEngine.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBSuggestionEngine.h"
|
||||
#import "KBConfig.h"
|
||||
|
||||
@interface KBSuggestionEngine ()
|
||||
@property (nonatomic, copy) NSArray<NSString *> *words;
|
||||
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *selectionCounts;
|
||||
@property (nonatomic, strong) NSSet<NSString *> *priorityWords;
|
||||
@end
|
||||
|
||||
@implementation KBSuggestionEngine
|
||||
|
||||
+ (instancetype)shared {
|
||||
static KBSuggestionEngine *engine;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
engine = [[KBSuggestionEngine alloc] init];
|
||||
});
|
||||
return engine;
|
||||
}
|
||||
|
||||
- (instancetype)init {
|
||||
if (self = [super init]) {
|
||||
_selectionCounts = [NSMutableDictionary dictionary];
|
||||
NSArray<NSString *> *defaults = [self.class kb_defaultWords];
|
||||
_priorityWords = [NSSet setWithArray:defaults];
|
||||
_words = [self kb_loadWords];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (NSArray<NSString *> *)suggestionsForPrefix:(NSString *)prefix limit:(NSUInteger)limit {
|
||||
if (prefix.length == 0 || limit == 0) { return @[]; }
|
||||
NSString *lower = prefix.lowercaseString;
|
||||
NSMutableArray<NSString *> *matches = [NSMutableArray array];
|
||||
|
||||
for (NSString *word in self.words) {
|
||||
if ([word hasPrefix:lower]) {
|
||||
[matches addObject:word];
|
||||
if (matches.count >= limit * 3) {
|
||||
// Avoid scanning too many matches for long lists.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.count == 0) { return @[]; }
|
||||
|
||||
[matches sortUsingComparator:^NSComparisonResult(NSString *a, NSString *b) {
|
||||
NSInteger ca = self.selectionCounts[a].integerValue;
|
||||
NSInteger cb = self.selectionCounts[b].integerValue;
|
||||
if (ca != cb) {
|
||||
return (cb > ca) ? NSOrderedAscending : NSOrderedDescending;
|
||||
}
|
||||
BOOL pa = [self.priorityWords containsObject:a];
|
||||
BOOL pb = [self.priorityWords containsObject:b];
|
||||
if (pa != pb) {
|
||||
return pa ? NSOrderedAscending : NSOrderedDescending;
|
||||
}
|
||||
return [a compare:b];
|
||||
}];
|
||||
|
||||
if (matches.count > limit) {
|
||||
return [matches subarrayWithRange:NSMakeRange(0, limit)];
|
||||
}
|
||||
return matches.copy;
|
||||
}
|
||||
|
||||
- (void)recordSelection:(NSString *)word {
|
||||
if (word.length == 0) { return; }
|
||||
NSString *key = word.lowercaseString;
|
||||
NSInteger count = self.selectionCounts[key].integerValue + 1;
|
||||
self.selectionCounts[key] = @(count);
|
||||
}
|
||||
|
||||
#pragma mark - Defaults
|
||||
|
||||
- (NSArray<NSString *> *)kb_loadWords {
|
||||
NSMutableOrderedSet<NSString *> *set = [[NSMutableOrderedSet alloc] init];
|
||||
[set addObjectsFromArray:[self.class kb_defaultWords]];
|
||||
|
||||
NSArray<NSString *> *paths = [self kb_wordListPaths];
|
||||
for (NSString *path in paths) {
|
||||
if (path.length == 0) { continue; }
|
||||
NSString *content = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
|
||||
if (content.length == 0) { continue; }
|
||||
NSArray<NSString *> *lines = [content componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]];
|
||||
for (NSString *line in lines) {
|
||||
NSString *word = [self kb_sanitizedWordFromLine:line];
|
||||
if (word.length == 0) { continue; }
|
||||
[set addObject:word];
|
||||
}
|
||||
}
|
||||
|
||||
NSArray<NSString *> *result = set.array ?: @[];
|
||||
return result;
|
||||
}
|
||||
|
||||
- (NSArray<NSString *> *)kb_wordListPaths {
|
||||
NSMutableArray<NSString *> *paths = [NSMutableArray array];
|
||||
// 1) App Group override (allows server-downloaded large list).
|
||||
NSURL *containerURL = [[NSFileManager defaultManager] containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
||||
if (containerURL.path.length > 0) {
|
||||
NSString *groupPath = [[containerURL path] stringByAppendingPathComponent:@"kb_words.txt"];
|
||||
[paths addObject:groupPath];
|
||||
}
|
||||
// 2) Bundle fallback.
|
||||
NSString *bundlePath = [[NSBundle mainBundle] pathForResource:@"kb_words" ofType:@"txt"];
|
||||
if (bundlePath.length > 0) {
|
||||
[paths addObject:bundlePath];
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
- (NSString *)kb_sanitizedWordFromLine:(NSString *)line {
|
||||
NSString *trimmed = [[line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]] lowercaseString];
|
||||
if (trimmed.length == 0) { return @""; }
|
||||
static NSCharacterSet *letters = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
letters = [NSCharacterSet characterSetWithCharactersInString:@"abcdefghijklmnopqrstuvwxyz"];
|
||||
});
|
||||
for (NSUInteger i = 0; i < trimmed.length; i++) {
|
||||
if (![letters characterIsMember:[trimmed characterAtIndex:i]]) {
|
||||
return @"";
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
+ (NSArray<NSString *> *)kb_defaultWords {
|
||||
return @[
|
||||
@"a", @"an", @"and", @"are", @"as", @"at",
|
||||
@"app", @"ap", @"apple", @"apply", @"april", @"application",
|
||||
@"about", @"above", @"after", @"again", @"against", @"all",
|
||||
@"am", @"among", @"amount", @"any", @"around",
|
||||
@"be", @"because", @"been", @"before", @"being", @"below",
|
||||
@"best", @"between", @"both", @"but", @"by",
|
||||
@"can", @"could", @"come", @"common", @"case",
|
||||
@"do", @"does", @"down", @"day",
|
||||
@"each", @"early", @"end", @"even", @"every",
|
||||
@"for", @"from", @"first", @"found", @"free",
|
||||
@"get", @"good", @"great", @"go",
|
||||
@"have", @"has", @"had", @"help", @"how",
|
||||
@"in", @"is", @"it", @"if", @"into",
|
||||
@"just", @"keep", @"kind", @"know",
|
||||
@"like", @"look", @"long", @"last",
|
||||
@"make", @"more", @"most", @"my",
|
||||
@"new", @"no", @"not", @"now",
|
||||
@"of", @"on", @"one", @"or", @"other", @"our", @"out",
|
||||
@"people", @"place", @"please",
|
||||
@"quick", @"quite",
|
||||
@"right", @"read", @"real",
|
||||
@"see", @"say", @"some", @"such", @"so",
|
||||
@"the", @"to", @"this", @"that", @"them", @"then", @"there", @"they", @"these", @"time",
|
||||
@"use", @"up", @"under",
|
||||
@"very",
|
||||
@"we", @"with", @"what", @"when", @"where", @"who", @"why", @"will", @"would",
|
||||
@"you", @"your"
|
||||
];
|
||||
}
|
||||
|
||||
@end
|
||||
49
CustomKeyboard/Model/KBChatMessage.h
Normal file
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// KBChatMessage.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBChatMessage : NSObject
|
||||
|
||||
@property (nonatomic, copy) NSString *text;
|
||||
@property (nonatomic, assign) BOOL outgoing;
|
||||
@property (nonatomic, copy, nullable) NSString *audioFilePath;
|
||||
@property (nonatomic, copy, nullable) NSString *avatarURL;
|
||||
@property (nonatomic, copy, nullable) NSString *displayName;
|
||||
@property (nonatomic, strong, nullable) UIImage *avatarImage;
|
||||
|
||||
/// 是否处于加载状态
|
||||
@property (nonatomic, assign) BOOL isLoading;
|
||||
/// 是否完成(用于打字机效果)
|
||||
@property (nonatomic, assign) BOOL isComplete;
|
||||
/// 是否需要打字机效果
|
||||
@property (nonatomic, assign) BOOL needsTypewriterEffect;
|
||||
/// 音频 ID(用于异步加载音频)
|
||||
@property (nonatomic, copy, nullable) NSString *audioId;
|
||||
/// 音频数据(缓存)
|
||||
@property (nonatomic, strong, nullable) NSData *audioData;
|
||||
/// 音频时长(秒)
|
||||
@property (nonatomic, assign) NSTimeInterval audioDuration;
|
||||
|
||||
+ (instancetype)messageWithText:(NSString *)text
|
||||
outgoing:(BOOL)outgoing
|
||||
audioFilePath:(nullable NSString *)audioFilePath;
|
||||
|
||||
/// 创建用户消息
|
||||
+ (instancetype)userMessageWithText:(NSString *)text;
|
||||
|
||||
/// 创建 AI 消息(带 audioId)
|
||||
+ (instancetype)assistantMessageWithText:(NSString *)text
|
||||
audioId:(nullable NSString *)audioId;
|
||||
|
||||
/// 创建加载中的 AI 消息
|
||||
+ (instancetype)loadingAssistantMessage;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
55
CustomKeyboard/Model/KBChatMessage.m
Normal file
@@ -0,0 +1,55 @@
|
||||
//
|
||||
// KBChatMessage.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBChatMessage.h"
|
||||
|
||||
@implementation KBChatMessage
|
||||
|
||||
+ (instancetype)messageWithText:(NSString *)text
|
||||
outgoing:(BOOL)outgoing
|
||||
audioFilePath:(NSString *)audioFilePath {
|
||||
KBChatMessage *msg = [[KBChatMessage alloc] init];
|
||||
msg.text = text ?: @"";
|
||||
msg.outgoing = outgoing;
|
||||
msg.audioFilePath = audioFilePath;
|
||||
msg.isComplete = YES;
|
||||
msg.isLoading = NO;
|
||||
msg.needsTypewriterEffect = NO;
|
||||
return msg;
|
||||
}
|
||||
|
||||
+ (instancetype)userMessageWithText:(NSString *)text {
|
||||
KBChatMessage *msg = [[KBChatMessage alloc] init];
|
||||
msg.text = text ?: @"";
|
||||
msg.outgoing = YES;
|
||||
msg.isComplete = YES;
|
||||
msg.isLoading = NO;
|
||||
msg.needsTypewriterEffect = NO;
|
||||
return msg;
|
||||
}
|
||||
|
||||
+ (instancetype)assistantMessageWithText:(NSString *)text
|
||||
audioId:(NSString *)audioId {
|
||||
KBChatMessage *msg = [[KBChatMessage alloc] init];
|
||||
msg.text = text ?: @"";
|
||||
msg.outgoing = NO;
|
||||
msg.audioId = audioId;
|
||||
msg.isComplete = NO;
|
||||
msg.isLoading = NO;
|
||||
msg.needsTypewriterEffect = YES;
|
||||
return msg;
|
||||
}
|
||||
|
||||
+ (instancetype)loadingAssistantMessage {
|
||||
KBChatMessage *msg = [[KBChatMessage alloc] init];
|
||||
msg.text = @"";
|
||||
msg.outgoing = NO;
|
||||
msg.isComplete = NO;
|
||||
msg.isLoading = YES;
|
||||
msg.needsTypewriterEffect = NO;
|
||||
return msg;
|
||||
}
|
||||
|
||||
@end
|
||||
96
CustomKeyboard/Model/KBKeyboardLayoutConfig.h
Normal file
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// KBKeyboardLayoutConfig.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// 键盘布局配置模型(由 JSON 驱动)
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBKeyboardLayoutMetrics : NSObject
|
||||
@property (nonatomic, strong, nullable) NSNumber *rowSpacing;
|
||||
@property (nonatomic, strong, nullable) NSNumber *topInset;
|
||||
@property (nonatomic, strong, nullable) NSNumber *bottomInset;
|
||||
@property (nonatomic, strong, nullable) NSNumber *keyHeight;
|
||||
@property (nonatomic, strong, nullable) NSNumber *edgeInset;
|
||||
@property (nonatomic, strong, nullable) NSNumber *gap;
|
||||
@property (nonatomic, strong, nullable) NSNumber *letterWidth;
|
||||
@property (nonatomic, strong, nullable) NSNumber *controlWidth;
|
||||
@property (nonatomic, strong, nullable) NSNumber *sendWidth;
|
||||
@property (nonatomic, strong, nullable) NSNumber *symbolsWideWidth;
|
||||
@property (nonatomic, strong, nullable) NSNumber *symbolsSideWidth;
|
||||
@end
|
||||
|
||||
@interface KBKeyboardLayoutFonts : NSObject
|
||||
@property (nonatomic, strong, nullable) NSNumber *letter;
|
||||
@property (nonatomic, strong, nullable) NSNumber *digit;
|
||||
@property (nonatomic, strong, nullable) NSNumber *symbol;
|
||||
@property (nonatomic, strong, nullable) NSNumber *mode;
|
||||
@property (nonatomic, strong, nullable) NSNumber *space;
|
||||
@property (nonatomic, strong, nullable) NSNumber *send;
|
||||
@end
|
||||
|
||||
@interface KBKeyboardKeyDef : NSObject
|
||||
@property (nonatomic, copy, nullable) NSString *type;
|
||||
@property (nonatomic, copy, nullable) NSString *title;
|
||||
@property (nonatomic, copy, nullable) NSString *selectedTitle;
|
||||
@property (nonatomic, copy, nullable) NSString *symbolName;
|
||||
@property (nonatomic, copy, nullable) NSString *selectedSymbolName;
|
||||
@property (nonatomic, copy, nullable) NSString *font;
|
||||
@property (nonatomic, copy, nullable) NSString *width;
|
||||
@property (nonatomic, strong, nullable) NSNumber *widthValue;
|
||||
@property (nonatomic, copy, nullable) NSString *backgroundColor;
|
||||
@end
|
||||
|
||||
@interface KBKeyboardRowItem : NSObject
|
||||
@property (nonatomic, copy, nullable) NSString *itemId;
|
||||
@property (nonatomic, copy, nullable) NSString *width;
|
||||
@property (nonatomic, strong, nullable) NSNumber *widthValue;
|
||||
+ (NSArray<KBKeyboardRowItem *> *)itemsFromRawArray:(NSArray *)raw;
|
||||
@end
|
||||
|
||||
@interface KBKeyboardRowSegments : NSObject
|
||||
@property (nonatomic, strong, nullable) NSArray *left;
|
||||
@property (nonatomic, strong, nullable) NSArray *center;
|
||||
@property (nonatomic, strong, nullable) NSArray *right;
|
||||
- (NSArray<KBKeyboardRowItem *> *)leftItems;
|
||||
- (NSArray<KBKeyboardRowItem *> *)centerItems;
|
||||
- (NSArray<KBKeyboardRowItem *> *)rightItems;
|
||||
@end
|
||||
|
||||
@interface KBKeyboardRowConfig : NSObject
|
||||
@property (nonatomic, strong, nullable) NSNumber *height;
|
||||
@property (nonatomic, strong, nullable) NSNumber *insetLeft;
|
||||
@property (nonatomic, strong, nullable) NSNumber *insetRight;
|
||||
@property (nonatomic, strong, nullable) NSNumber *gap;
|
||||
@property (nonatomic, copy, nullable) NSString *align;
|
||||
@property (nonatomic, strong, nullable) NSArray *items;
|
||||
@property (nonatomic, strong, nullable) KBKeyboardRowSegments *segments;
|
||||
- (NSArray<KBKeyboardRowItem *> *)resolvedItems;
|
||||
@end
|
||||
|
||||
@interface KBKeyboardLayout : NSObject
|
||||
@property (nonatomic, strong, nullable) NSArray<KBKeyboardRowConfig *> *rows;
|
||||
@end
|
||||
|
||||
@interface KBKeyboardLayoutConfig : NSObject
|
||||
@property (nonatomic, assign) CGFloat designWidth;
|
||||
@property (nonatomic, strong, nullable) KBKeyboardLayoutMetrics *metrics;
|
||||
@property (nonatomic, strong, nullable) KBKeyboardLayoutFonts *fonts;
|
||||
@property (nonatomic, copy, nullable) NSString *defaultKeyBackground;
|
||||
@property (nonatomic, strong, nullable) NSDictionary<NSString *, KBKeyboardKeyDef *> *keyDefs;
|
||||
@property (nonatomic, strong, nullable) NSDictionary<NSString *, KBKeyboardLayout *> *layouts;
|
||||
|
||||
+ (nullable instancetype)sharedConfig;
|
||||
+ (nullable instancetype)configFromJSONData:(NSData *)data;
|
||||
- (CGFloat)scaledValue:(CGFloat)designValue;
|
||||
- (CGFloat)keyboardAreaDesignHeight;
|
||||
- (CGFloat)keyboardAreaScaledHeight;
|
||||
- (nullable KBKeyboardLayout *)layoutForName:(NSString *)name;
|
||||
- (nullable KBKeyboardKeyDef *)keyDefForIdentifier:(NSString *)identifier;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
187
CustomKeyboard/Model/KBKeyboardLayoutConfig.m
Normal file
@@ -0,0 +1,187 @@
|
||||
//
|
||||
// KBKeyboardLayoutConfig.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBKeyboardLayoutConfig.h"
|
||||
#import <MJExtension/MJExtension.h>
|
||||
#import "KBConfig.h"
|
||||
|
||||
static NSString * const kKBKeyboardLayoutConfigFileName = @"kb_keyboard_layout_config";
|
||||
|
||||
@implementation KBKeyboardLayoutMetrics
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardLayoutFonts
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardKeyDef
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardRowItem
|
||||
|
||||
+ (NSDictionary *)mj_replacedKeyFromPropertyName {
|
||||
return @{ @"itemId": @"id" };
|
||||
}
|
||||
|
||||
+ (NSArray<KBKeyboardRowItem *> *)itemsFromRawArray:(NSArray *)raw {
|
||||
if (![raw isKindOfClass:[NSArray class]] || raw.count == 0) {
|
||||
return @[];
|
||||
}
|
||||
NSMutableArray<KBKeyboardRowItem *> *items = [NSMutableArray arrayWithCapacity:raw.count];
|
||||
for (id obj in raw) {
|
||||
if ([obj isKindOfClass:[NSString class]]) {
|
||||
KBKeyboardRowItem *item = [KBKeyboardRowItem new];
|
||||
item.itemId = (NSString *)obj;
|
||||
[items addObject:item];
|
||||
continue;
|
||||
}
|
||||
if ([obj isKindOfClass:[NSDictionary class]]) {
|
||||
KBKeyboardRowItem *item = [KBKeyboardRowItem mj_objectWithKeyValues:obj];
|
||||
if (item.itemId.length == 0) {
|
||||
NSString *fallback = ((NSDictionary *)obj)[@"id"];
|
||||
if ([fallback isKindOfClass:[NSString class]]) {
|
||||
item.itemId = fallback;
|
||||
}
|
||||
}
|
||||
if (item.itemId.length > 0) {
|
||||
[items addObject:item];
|
||||
}
|
||||
}
|
||||
}
|
||||
return items.copy;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardRowSegments
|
||||
|
||||
- (NSArray<KBKeyboardRowItem *> *)leftItems {
|
||||
return [KBKeyboardRowItem itemsFromRawArray:self.left ?: @[]];
|
||||
}
|
||||
|
||||
- (NSArray<KBKeyboardRowItem *> *)centerItems {
|
||||
return [KBKeyboardRowItem itemsFromRawArray:self.center ?: @[]];
|
||||
}
|
||||
|
||||
- (NSArray<KBKeyboardRowItem *> *)rightItems {
|
||||
return [KBKeyboardRowItem itemsFromRawArray:self.right ?: @[]];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardRowConfig
|
||||
|
||||
- (NSArray<KBKeyboardRowItem *> *)resolvedItems {
|
||||
return [KBKeyboardRowItem itemsFromRawArray:self.items ?: @[]];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardLayout
|
||||
|
||||
+ (NSDictionary *)mj_objectClassInArray {
|
||||
return @{ @"rows": [KBKeyboardRowConfig class] };
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardLayoutConfig
|
||||
|
||||
+ (instancetype)sharedConfig {
|
||||
static KBKeyboardLayoutConfig *config = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
NSString *path = [[NSBundle mainBundle] pathForResource:kKBKeyboardLayoutConfigFileName ofType:@"json"];
|
||||
NSData *data = path.length ? [NSData dataWithContentsOfFile:path] : nil;
|
||||
config = data ? [KBKeyboardLayoutConfig configFromJSONData:data] : nil;
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
+ (instancetype)configFromJSONData:(NSData *)data {
|
||||
if (data.length == 0) { return nil; }
|
||||
NSError *error = nil;
|
||||
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
||||
if (error || ![json isKindOfClass:[NSDictionary class]]) {
|
||||
return nil;
|
||||
}
|
||||
NSDictionary *dict = (NSDictionary *)json;
|
||||
KBKeyboardLayoutConfig *config = [KBKeyboardLayoutConfig mj_objectWithKeyValues:dict];
|
||||
|
||||
NSDictionary *keyDefsRaw = dict[@"keyDefs"];
|
||||
if ([keyDefsRaw isKindOfClass:[NSDictionary class]]) {
|
||||
NSMutableDictionary<NSString *, KBKeyboardKeyDef *> *defs = [NSMutableDictionary dictionaryWithCapacity:keyDefsRaw.count];
|
||||
[keyDefsRaw enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
||||
if (![key isKindOfClass:[NSString class]] || ![obj isKindOfClass:[NSDictionary class]]) {
|
||||
return;
|
||||
}
|
||||
KBKeyboardKeyDef *def = [KBKeyboardKeyDef mj_objectWithKeyValues:obj];
|
||||
if (def) {
|
||||
defs[key] = def;
|
||||
}
|
||||
}];
|
||||
config.keyDefs = defs.copy;
|
||||
}
|
||||
|
||||
NSDictionary *layoutsRaw = dict[@"layouts"];
|
||||
if ([layoutsRaw isKindOfClass:[NSDictionary class]]) {
|
||||
NSMutableDictionary<NSString *, KBKeyboardLayout *> *layouts = [NSMutableDictionary dictionaryWithCapacity:layoutsRaw.count];
|
||||
[layoutsRaw enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
||||
if (![key isKindOfClass:[NSString class]] || ![obj isKindOfClass:[NSDictionary class]]) {
|
||||
return;
|
||||
}
|
||||
KBKeyboardLayout *layout = [KBKeyboardLayout mj_objectWithKeyValues:obj];
|
||||
if (layout) {
|
||||
layouts[key] = layout;
|
||||
}
|
||||
}];
|
||||
config.layouts = layouts.copy;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
- (CGFloat)scaledValue:(CGFloat)designValue {
|
||||
CGFloat baseWidth = (self.designWidth > 0.0) ? self.designWidth : KB_DESIGN_WIDTH;
|
||||
CGFloat scale = KBScreenWidth() / baseWidth;
|
||||
return designValue * scale;
|
||||
}
|
||||
|
||||
- (CGFloat)keyboardAreaDesignHeight {
|
||||
KBKeyboardLayout *layout = [self layoutForName:@"letters"] ?: self.layouts.allValues.firstObject;
|
||||
NSUInteger rowCount = layout.rows.count;
|
||||
if (rowCount == 0) { return 0.0; }
|
||||
|
||||
CGFloat rowSpacing = self.metrics.rowSpacing.doubleValue;
|
||||
CGFloat topInset = self.metrics.topInset.doubleValue;
|
||||
CGFloat bottomInset = self.metrics.bottomInset.doubleValue;
|
||||
|
||||
CGFloat total = topInset + bottomInset + rowSpacing * (rowCount - 1);
|
||||
for (KBKeyboardRowConfig *row in layout.rows) {
|
||||
CGFloat height = row.height.doubleValue;
|
||||
if (height <= 0.0) {
|
||||
height = self.metrics.keyHeight.doubleValue;
|
||||
}
|
||||
if (height <= 0.0) { height = 40.0; }
|
||||
total += height;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
- (CGFloat)keyboardAreaScaledHeight {
|
||||
CGFloat designHeight = [self keyboardAreaDesignHeight];
|
||||
return designHeight > 0.0 ? [self scaledValue:designHeight] : 0.0;
|
||||
}
|
||||
|
||||
- (KBKeyboardLayout *)layoutForName:(NSString *)name {
|
||||
if (name.length == 0) { return nil; }
|
||||
return self.layouts[name];
|
||||
}
|
||||
|
||||
- (KBKeyboardKeyDef *)keyDefForIdentifier:(NSString *)identifier {
|
||||
if (identifier.length == 0) { return nil; }
|
||||
return self.keyDefs[identifier];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -64,6 +64,15 @@ typedef void(^KBNetworkDataCompletion)(NSData *_Nullable data,
|
||||
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
||||
completion:(KBNetworkCompletion)completion;
|
||||
|
||||
/// POST multipart 上传文件(常用于语音/图片等文件)
|
||||
- (nullable NSURLSessionDataTask *)uploadFile:(NSString *)path
|
||||
fileURL:(NSURL *)fileURL
|
||||
name:(NSString *)name
|
||||
mimeType:(NSString *)mimeType
|
||||
parameters:(nullable NSDictionary *)parameters
|
||||
headers:(nullable NSDictionary<NSString *, NSString *> *)headers
|
||||
completion:(KBNetworkCompletion)completion;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -41,38 +41,10 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
}
|
||||
|
||||
- (void)getSignWithParare:(NSDictionary *)bodyParams{
|
||||
|
||||
NSString *appId = @"loveKeyboard";
|
||||
NSString *secret = @"kZJM39HYvhxwbJkG1fmquQRVkQiLAh2H"; // 和服务端保持一致
|
||||
NSString *timestamp = [KBSignUtils currentTimestamp];
|
||||
NSString *nonce = [KBSignUtils generateNonceWithLength:16];
|
||||
// 1. 组装参与签名的所有参数
|
||||
NSMutableDictionary<NSString *, NSString *> *signParams = [NSMutableDictionary dictionary];
|
||||
signParams[@"appId"] = appId;
|
||||
signParams[@"timestamp"] = timestamp;
|
||||
signParams[@"nonce"] = nonce;
|
||||
// 把 body 里的字段也加入签名参数
|
||||
[bodyParams enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
|
||||
if ([obj isKindOfClass:[NSString class]]) {
|
||||
signParams[key] = obj;
|
||||
} else {
|
||||
signParams[key] = [obj description];
|
||||
}
|
||||
}];
|
||||
NSString *sign = [KBSignUtils signWithParams:signParams secret:secret];
|
||||
|
||||
// 将签名相关字段合并进默认请求头
|
||||
NSDictionary<NSString *, NSString *> *signHeaders = [KBSignUtils signHeadersWithBodyParams:bodyParams];
|
||||
NSMutableDictionary<NSString *, NSString *> *headers =
|
||||
[self.defaultHeaders mutableCopy] ?: [NSMutableDictionary dictionary];
|
||||
|
||||
if (sign.length > 0) {
|
||||
headers[@"X-Sign"] = sign;
|
||||
}
|
||||
headers[@"X-App-Id"] = appId;
|
||||
headers[@"X-Timestamp"] = timestamp;
|
||||
headers[@"X-Nonce"] = nonce;
|
||||
|
||||
// 触发 copy 语义,确保对外仍是不可变字典
|
||||
[headers addEntriesFromDictionary:signHeaders ?: @{}];
|
||||
self.defaultHeaders = headers;
|
||||
}
|
||||
|
||||
@@ -124,6 +96,84 @@ NSErrorDomain const KBNetworkErrorDomain = @"com.company.keyboard.network";
|
||||
return [self startAFJSONTaskWithRequest:req completion:completion];
|
||||
}
|
||||
|
||||
- (NSURLSessionDataTask *)uploadFile:(NSString *)path
|
||||
fileURL:(NSURL *)fileURL
|
||||
name:(NSString *)name
|
||||
mimeType:(NSString *)mimeType
|
||||
parameters:(NSDictionary *)parameters
|
||||
headers:(NSDictionary<NSString *, NSString *> *)headers
|
||||
completion:(KBNetworkCompletion)completion {
|
||||
[self getSignWithParare:parameters];
|
||||
if (![self ensureEnabled:completion]) return nil;
|
||||
NSString *urlString = [self buildURLStringWithPath:path];
|
||||
if (!urlString) { [self fail:KBNetworkErrorInvalidURL completion:completion]; return nil; }
|
||||
if (!fileURL) {
|
||||
if (completion) completion(nil, nil, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid file")}]);
|
||||
return nil;
|
||||
}
|
||||
AFHTTPRequestSerializer *serializer = [AFHTTPRequestSerializer serializer];
|
||||
serializer.timeoutInterval = self.timeout;
|
||||
NSError *error = nil;
|
||||
NSMutableURLRequest *req = [serializer multipartFormRequestWithMethod:@"POST"
|
||||
URLString:urlString
|
||||
parameters:parameters
|
||||
constructingBodyWithBlock:^(id<AFMultipartFormData> formData) {
|
||||
NSString *safeName = (name.length > 0) ? name : @"file";
|
||||
NSString *fileName = fileURL.lastPathComponent ?: @"upload.bin";
|
||||
NSString *type = (mimeType.length > 0) ? mimeType : @"application/octet-stream";
|
||||
[formData appendPartWithFileURL:fileURL name:safeName fileName:fileName mimeType:type error:nil];
|
||||
} error:&error];
|
||||
if (error || !req) {
|
||||
if (completion) completion(nil, nil, error ?: [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidURL userInfo:@{NSLocalizedDescriptionKey: KBLocalized(@"Invalid URL")}]);
|
||||
return nil;
|
||||
}
|
||||
[self applyHeaders:headers toMutableRequest:req contentType:nil];
|
||||
self.manager.responseSerializer = [AFHTTPResponseSerializer serializer];
|
||||
NSURLSessionUploadTask *task = [self.manager uploadTaskWithStreamedRequest:req progress:nil completionHandler:^(NSURLResponse *response, id responseObject, NSError *error) {
|
||||
if (error) {
|
||||
if (completion) completion(nil, response, error);
|
||||
return;
|
||||
}
|
||||
NSData *data = (NSData *)responseObject;
|
||||
if (![data isKindOfClass:[NSData class]]) {
|
||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"No data")}]);
|
||||
return;
|
||||
}
|
||||
NSString *ct = nil;
|
||||
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
|
||||
ct = ((NSHTTPURLResponse *)response).allHeaderFields[@"Content-Type"];
|
||||
}
|
||||
BOOL looksJSON = (ct && [[ct lowercaseString] containsString:@"json"]);
|
||||
if (!looksJSON) {
|
||||
const unsigned char *bytes = data.bytes;
|
||||
NSUInteger len = data.length;
|
||||
for (NSUInteger i = 0; !looksJSON && i < len; i++) {
|
||||
unsigned char c = bytes[i];
|
||||
if (c == ' ' || c == '\n' || c == '\r' || c == '\t') continue;
|
||||
looksJSON = (c == '{' || c == '[');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (looksJSON) {
|
||||
NSError *jsonErr = nil;
|
||||
id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonErr];
|
||||
if (jsonErr) {
|
||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorDecodeFailed userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Failed to parse JSON")}]);
|
||||
return;
|
||||
}
|
||||
if (![json isKindOfClass:[NSDictionary class]]) {
|
||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
|
||||
return;
|
||||
}
|
||||
if (completion) completion((NSDictionary *)json, response, nil);
|
||||
} else {
|
||||
if (completion) completion(nil, response, [NSError errorWithDomain:KBNetworkErrorDomain code:KBNetworkErrorInvalidResponse userInfo:@{NSLocalizedDescriptionKey:KBLocalized(@"Invalid response")}]);
|
||||
}
|
||||
}];
|
||||
[task resume];
|
||||
return task;
|
||||
}
|
||||
|
||||
- (NSURLSessionDataTask *)GETData:(NSString *)path
|
||||
parameters:(NSDictionary *)parameters
|
||||
headers:(NSDictionary<NSString *,NSString *> *)headers
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// - 兼容后端“/t”作为分段标记:可自动替换为制表符“\t”
|
||||
// - 首段去首个“\t”:若首次正文以一个制表符起始(允许前导空白),可只移除“一个”\t
|
||||
//
|
||||
|
||||
// 暂未使用
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Created by Mac on 2025/11/12.
|
||||
//
|
||||
|
||||
// 暂未使用
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
#import "Masonry.h"
|
||||
#import "KBHUD.h" // 复用 App 内的 HUD 封装
|
||||
#import "KBLocalizationManager.h" // 复用多语言封装(可在扩展内使用)
|
||||
#import "KBMaiPointReporter.h"
|
||||
//#import "KBLog.h"
|
||||
|
||||
|
||||
// 通用链接(Universal Links)统一配置
|
||||
// 配置好 AASA 与 Associated Domains 后,只需修改这里即可切换域名/path。
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
/* 字母 g(小写) */
|
||||
"letter_g_lower" = "key_g";
|
||||
/* 字母 G(大写) */
|
||||
"letter_g_upper" = "key_f_up";
|
||||
"letter_g_upper" = "key_g_up";
|
||||
|
||||
/* 字母 h(小写) */
|
||||
"letter_h_lower" = "key_h";
|
||||
@@ -242,7 +242,7 @@
|
||||
/* 自定义 AI 功能键 */
|
||||
"ai" = "key_ai";
|
||||
/* Emoji功能键 */
|
||||
"emoji" = "key_emoji";
|
||||
//"emoji" = "key_emoji";
|
||||
"emoji_panel" = "key_emoji";
|
||||
/* 发送/换行键 */
|
||||
"return" = "key_send";
|
||||
|
||||
|
||||
BIN
CustomKeyboard/Resource/ai_test.m4a
Normal file
414
CustomKeyboard/Resource/kb_keyboard_layout_config.json
Normal file
@@ -0,0 +1,414 @@
|
||||
{
|
||||
"__comment": "键盘布局配置:所有尺寸为设计稿值(会按 designWidth 等比缩放)",
|
||||
"designWidth": 375,
|
||||
"__comment_designWidth": "设计稿宽度(如 375),用于计算缩放比例",
|
||||
"defaultKeyBackground": "#FFFFFF",
|
||||
"__comment_defaultKeyBackground": "无皮肤时按键默认背景色",
|
||||
"metrics": {
|
||||
"__comment": "全局尺寸参数(单位:pt,按 designWidth 缩放)",
|
||||
"rowSpacing": 8,
|
||||
"__comment_rowSpacing": "行间距(垂直)",
|
||||
"topInset": 8,
|
||||
"__comment_topInset": "键盘顶部内边距",
|
||||
"bottomInset": 6,
|
||||
"__comment_bottomInset": "键盘底部内边距",
|
||||
"keyHeight": 41,
|
||||
"__comment_keyHeight": "默认按键高度",
|
||||
"edgeInset": 4,
|
||||
"__comment_edgeInset": "行左右内边距(默认)",
|
||||
"gap": 5,
|
||||
"__comment_gap": "按键之间水平间距",
|
||||
"letterWidth": 32,
|
||||
"__comment_letterWidth": "字母键默认宽度",
|
||||
"controlWidth": 41,
|
||||
"__comment_controlWidth": "控制键宽度(如 shift/backspace/123)",
|
||||
"sendWidth": 88,
|
||||
"__comment_sendWidth": "send 键宽度",
|
||||
"symbolsWideWidth": 47,
|
||||
"__comment_symbolsWideWidth": "符号第3行中间大键宽度",
|
||||
"symbolsSideWidth": 41,
|
||||
"__comment_symbolsSideWidth": "符号第3行左右控制键宽度"
|
||||
},
|
||||
"fonts": {
|
||||
"__comment": "字体大小(pt)",
|
||||
"letter": 20,
|
||||
"__comment_letter": "字母键字体大小",
|
||||
"digit": 20,
|
||||
"__comment_digit": "数字键字体大小",
|
||||
"symbol": 18,
|
||||
"__comment_symbol": "符号键字体大小",
|
||||
"mode": 14,
|
||||
"__comment_mode": "模式切换键字体大小(ABC/#+=/123)",
|
||||
"space": 18,
|
||||
"__comment_space": "空格键字体大小",
|
||||
"send": 18,
|
||||
"__comment_send": "发送键字体大小"
|
||||
},
|
||||
"keyDefs": {
|
||||
"__comment": "特殊功能键配置(id 对应布局中的 item)",
|
||||
"shift": {
|
||||
"__comment": "大小写切换键",
|
||||
"type": "shift",
|
||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||
"title": "⇧",
|
||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||
"symbolName": "shift",
|
||||
"__comment_symbolName": "无皮肤时使用 SF Symbol 名称",
|
||||
"selectedSymbolName": "shift.fill",
|
||||
"__comment_selectedSymbolName": "选中态 SF Symbol 名称",
|
||||
"font": "symbol",
|
||||
"__comment_font": "使用 fonts 中哪一类字号",
|
||||
"width": "controlWidth",
|
||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||
"backgroundColor": "#B7BBC4",
|
||||
"__comment_backgroundColor": "按键背景色"
|
||||
},
|
||||
"backspace": {
|
||||
"__comment": "删除键",
|
||||
"type": "backspace",
|
||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||
"title": "⌫",
|
||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||
"font": "symbol",
|
||||
"__comment_font": "使用 fonts 中哪一类字号",
|
||||
"width": "controlWidth",
|
||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||
"backgroundColor": "#B7BBC4",
|
||||
"__comment_backgroundColor": "按键背景色"
|
||||
},
|
||||
"mode_123": {
|
||||
"__comment": "字母面板左下角 123",
|
||||
"type": "mode",
|
||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||
"title": "123",
|
||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||
"font": "mode",
|
||||
"__comment_font": "使用 fonts 中哪一类字号",
|
||||
"width": "controlWidth",
|
||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||
"backgroundColor": "#B7BBC4",
|
||||
"__comment_backgroundColor": "按键背景色"
|
||||
},
|
||||
"mode_abc": {
|
||||
"__comment": "数字面板左下角 ABC",
|
||||
"type": "mode",
|
||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||
"title": "ABC",
|
||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||
"font": "mode",
|
||||
"__comment_font": "使用 fonts 中哪一类字号",
|
||||
"width": "controlWidth",
|
||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||
"backgroundColor": "#B7BBC4",
|
||||
"__comment_backgroundColor": "按键背景色"
|
||||
},
|
||||
"symbols_toggle_more": {
|
||||
"__comment": "数字面板内 123 -> #+=",
|
||||
"type": "symbolsToggle",
|
||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||
"title": "#+=",
|
||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||
"font": "mode",
|
||||
"__comment_font": "使用 fonts 中哪一类字号",
|
||||
"width": "symbolsSideWidth",
|
||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||
"backgroundColor": "#B7BBC4",
|
||||
"__comment_backgroundColor": "按键背景色"
|
||||
},
|
||||
"symbols_toggle_123": {
|
||||
"__comment": "数字面板内 #+= -> 123",
|
||||
"type": "symbolsToggle",
|
||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||
"title": "123",
|
||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||
"font": "mode",
|
||||
"__comment_font": "使用 fonts 中哪一类字号",
|
||||
"width": "symbolsSideWidth",
|
||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||
"backgroundColor": "#B7BBC4",
|
||||
"__comment_backgroundColor": "按键背景色"
|
||||
},
|
||||
"emoji": {
|
||||
"__comment": "emoji 功能键",
|
||||
"type": "custom",
|
||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||
"title": "😁",
|
||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||
"font": "symbol",
|
||||
"__comment_font": "使用 fonts 中哪一类字号",
|
||||
"width": "controlWidth",
|
||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||
"backgroundColor": "#B7BBC4",
|
||||
"__comment_backgroundColor": "按键背景色"
|
||||
},
|
||||
"space": {
|
||||
"__comment": "空格键",
|
||||
"type": "space",
|
||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||
"title": "space",
|
||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||
"font": "space",
|
||||
"__comment_font": "使用 fonts 中哪一类字号",
|
||||
"width": "flex",
|
||||
"__comment_width": "flex 表示自动占满剩余空间"
|
||||
},
|
||||
"send": {
|
||||
"__comment": "发送键",
|
||||
"type": "return",
|
||||
"__comment_type": "类型:shift/backspace/mode/symbolsToggle/space/return/custom",
|
||||
"title": "send",
|
||||
"__comment_title": "按钮文本(无皮肤时显示)",
|
||||
"font": "send",
|
||||
"__comment_font": "使用 fonts 中哪一类字号",
|
||||
"width": "sendWidth",
|
||||
"__comment_width": "宽度:引用 metrics 中字段或具体数值",
|
||||
"backgroundColor": "#B7BBC4",
|
||||
"__comment_backgroundColor": "按键背景色"
|
||||
}
|
||||
},
|
||||
"layouts": {
|
||||
"__comment": "布局集合:letters/numbers/symbolsMore",
|
||||
"letters": {
|
||||
"__comment": "字母布局(小写/大写共用)",
|
||||
"rows": [
|
||||
{
|
||||
"__comment": "字母第一行 qwertyuiop",
|
||||
"align": "left",
|
||||
"__comment_align": "对齐方式:left/center",
|
||||
"insetLeft": 4,
|
||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||
"insetRight": 4,
|
||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||
"gap": 5,
|
||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||
"items": [
|
||||
"letter:q", "letter:w", "letter:e", "letter:r", "letter:t",
|
||||
"letter:y", "letter:u", "letter:i", "letter:o", "letter:p"
|
||||
],
|
||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||
},
|
||||
{
|
||||
"__comment": "字母第二行 asdfghjkl",
|
||||
"align": "center",
|
||||
"__comment_align": "对齐方式:left/center",
|
||||
"insetLeft": 0,
|
||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||
"insetRight": 0,
|
||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||
"gap": 5,
|
||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||
"items": [
|
||||
"letter:a", "letter:s", "letter:d", "letter:f", "letter:g",
|
||||
"letter:h", "letter:j", "letter:k", "letter:l"
|
||||
],
|
||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||
},
|
||||
{
|
||||
"__comment": "字母第三行:左 shift,中间字母,右 backspace",
|
||||
"align": "left",
|
||||
"__comment_align": "对齐方式:left/center",
|
||||
"insetLeft": 4,
|
||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||
"insetRight": 4,
|
||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||
"gap": 5,
|
||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||
"segments": {
|
||||
"__comment": "分段布局:left/center/right",
|
||||
"left": [
|
||||
{ "id": "shift", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
||||
],
|
||||
"__comment_left": "左侧固定按钮",
|
||||
"center": [
|
||||
"letter:z", "letter:x", "letter:c", "letter:v", "letter:b", "letter:n", "letter:m"
|
||||
],
|
||||
"__comment_center": "中间字母键集合,整体居中",
|
||||
"right": [
|
||||
{ "id": "backspace", "width": "controlWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.controlWidth" }
|
||||
],
|
||||
"__comment_right": "右侧固定按钮"
|
||||
}
|
||||
},
|
||||
{
|
||||
"__comment": "字母第四行:123/emoji/space/send",
|
||||
"align": "left",
|
||||
"__comment_align": "对齐方式:left/center",
|
||||
"insetLeft": 4,
|
||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||
"insetRight": 4,
|
||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||
"gap": 5,
|
||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||
"items": [
|
||||
"mode_123", "emoji", "space", "send"
|
||||
],
|
||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"numbers": {
|
||||
"__comment": "数字面板布局(123 页)",
|
||||
"rows": [
|
||||
{
|
||||
"__comment": "数字第一行 1234567890",
|
||||
"align": "left",
|
||||
"__comment_align": "对齐方式:left/center",
|
||||
"insetLeft": 4,
|
||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||
"insetRight": 4,
|
||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||
"gap": 5,
|
||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||
"items": [
|
||||
"digit:1", "digit:2", "digit:3", "digit:4", "digit:5",
|
||||
"digit:6", "digit:7", "digit:8", "digit:9", "digit:0"
|
||||
],
|
||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||
},
|
||||
{
|
||||
"__comment": "数字第二行 - / : ; ( ) ¥ & @ “",
|
||||
"align": "left",
|
||||
"__comment_align": "对齐方式:left/center",
|
||||
"insetLeft": 4,
|
||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||
"insetRight": 4,
|
||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||
"gap": 5,
|
||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||
"items": [
|
||||
"sym:-", "sym:/", "sym::", "sym:;", "sym:(",
|
||||
"sym:)", "sym:¥", "sym:&", "sym:@", "sym:“"
|
||||
],
|
||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||
},
|
||||
{
|
||||
"__comment": "数字第三行:#+= / 中间符号 / 删除",
|
||||
"align": "center",
|
||||
"__comment_align": "对齐方式:left/center",
|
||||
"insetLeft": 4,
|
||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||
"insetRight": 4,
|
||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||
"gap": 5,
|
||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||
"segments": {
|
||||
"__comment": "分段布局:left/center/right",
|
||||
"left": [
|
||||
{ "id": "symbols_toggle_more", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
|
||||
],
|
||||
"__comment_left": "左侧切换按钮",
|
||||
"center": [
|
||||
{ "id": "sym:.", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||
{ "id": "sym:,", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||
{ "id": "sym:?", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||
{ "id": "sym:!", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||
{ "id": "sym:‘", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" }
|
||||
],
|
||||
"__comment_center": "中间符号键集合,整体居中",
|
||||
"right": [
|
||||
{ "id": "backspace", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
|
||||
],
|
||||
"__comment_right": "右侧删除键"
|
||||
}
|
||||
},
|
||||
{
|
||||
"__comment": "数字第四行:ABC/emoji/space/send",
|
||||
"align": "left",
|
||||
"__comment_align": "对齐方式:left/center",
|
||||
"insetLeft": 4,
|
||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||
"insetRight": 4,
|
||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||
"gap": 5,
|
||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||
"items": [
|
||||
"mode_abc", "emoji", "space", "send"
|
||||
],
|
||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||
}
|
||||
]
|
||||
},
|
||||
"symbolsMore": {
|
||||
"__comment": "符号面板布局(#+= 页)",
|
||||
"rows": [
|
||||
{
|
||||
"__comment": "符号第一行 [ ] { } # % ^ * + =",
|
||||
"align": "left",
|
||||
"__comment_align": "对齐方式:left/center",
|
||||
"insetLeft": 4,
|
||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||
"insetRight": 4,
|
||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||
"gap": 5,
|
||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||
"items": [
|
||||
"sym:[", "sym:]", "sym:{", "sym:}", "sym:#",
|
||||
"sym:%", "sym:^", "sym:*", "sym:+", "sym:="
|
||||
],
|
||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||
},
|
||||
{
|
||||
"__comment": "符号第二行 _ \\ | ~ < > € ¥ $ ·",
|
||||
"align": "left",
|
||||
"__comment_align": "对齐方式:left/center",
|
||||
"insetLeft": 4,
|
||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||
"insetRight": 4,
|
||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||
"gap": 5,
|
||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||
"items": [
|
||||
"sym:_", "sym:\\", "sym:|", "sym:~", "sym:<",
|
||||
"sym:>", "sym:€", "sym:¥", "sym:$", "sym:·"
|
||||
],
|
||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||
},
|
||||
{
|
||||
"__comment": "符号第三行:123 / 中间符号 / 删除",
|
||||
"align": "center",
|
||||
"__comment_align": "对齐方式:left/center",
|
||||
"insetLeft": 4,
|
||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||
"insetRight": 4,
|
||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||
"gap": 5,
|
||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||
"segments": {
|
||||
"__comment": "分段布局:left/center/right",
|
||||
"left": [
|
||||
{ "id": "symbols_toggle_123", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
|
||||
],
|
||||
"__comment_left": "左侧切换按钮",
|
||||
"center": [
|
||||
{ "id": "sym:.", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||
{ "id": "sym:,", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||
{ "id": "sym:?", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||
{ "id": "sym:!", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" },
|
||||
{ "id": "sym:‘", "width": "symbolsWideWidth", "__comment_id": "符号键 id", "__comment_width": "宽度引用 metrics.symbolsWideWidth" }
|
||||
],
|
||||
"__comment_center": "中间符号键集合,整体居中",
|
||||
"right": [
|
||||
{ "id": "backspace", "width": "symbolsSideWidth", "__comment_id": "引用 keyDefs 的 id", "__comment_width": "宽度引用 metrics.symbolsSideWidth" }
|
||||
],
|
||||
"__comment_right": "右侧删除键"
|
||||
}
|
||||
},
|
||||
{
|
||||
"__comment": "符号第四行:ABC/emoji/space/send",
|
||||
"align": "left",
|
||||
"__comment_align": "对齐方式:left/center",
|
||||
"insetLeft": 4,
|
||||
"__comment_insetLeft": "本行左边距(覆盖 metrics.edgeInset)",
|
||||
"insetRight": 4,
|
||||
"__comment_insetRight": "本行右边距(覆盖 metrics.edgeInset)",
|
||||
"gap": 5,
|
||||
"__comment_gap": "本行按键间距(覆盖 metrics.gap)",
|
||||
"items": [
|
||||
"mode_abc", "emoji", "space", "send"
|
||||
],
|
||||
"__comment_items": "本行按键列表;letter:x/digit:x/sym:x 或 keyDefs 中的 id"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
234454
CustomKeyboard/Resource/kb_words.txt
Normal file
BIN
CustomKeyboard/Resource/normal_hei_them.zip
Normal file
BIN
CustomKeyboard/Resource/normal_them.zip
Normal file
@@ -11,10 +11,10 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
- (instancetype)initWithContainerView:(UIView *)containerView;
|
||||
|
||||
/// 配置删除按钮(包含长按删除;可选是否显示“立刻清空”提示)
|
||||
/// 配置删除按钮(包含长按删除;可选是否显示“上滑清空”提示)
|
||||
- (void)bindDeleteButton:(nullable UIView *)button showClearLabel:(BOOL)showClearLabel;
|
||||
|
||||
/// 触发“立刻清空”逻辑(可用于功能面板的清空按钮)
|
||||
/// 触发“上滑清空”逻辑(可用于功能面板的清空按钮)
|
||||
- (void)performClearAction;
|
||||
|
||||
@end
|
||||
|
||||
@@ -7,24 +7,25 @@
|
||||
#import "KBResponderUtils.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
|
||||
static const NSTimeInterval kKBBackspaceLongPressMinDuration = 0.35;
|
||||
static const NSTimeInterval kKBBackspaceRepeatInterval = 0.06;
|
||||
static const NSTimeInterval kKBBackspaceChunkStartDelay = 1.0;
|
||||
static const NSTimeInterval kKBBackspaceChunkStartDelay = 0.6;
|
||||
static const NSTimeInterval kKBBackspaceChunkRepeatInterval = 0.1;
|
||||
static const NSTimeInterval kKBBackspaceChunkFastDelay = 1.4;
|
||||
static const NSInteger kKBBackspaceChunkSize = 6;
|
||||
static const NSInteger kKBBackspaceChunkSizeFast = 12;
|
||||
static const NSTimeInterval kKBBackspaceChunkFastDelay = 1.2;
|
||||
static const NSInteger kKBBackspaceChunkSize = 8;
|
||||
static const NSInteger kKBBackspaceChunkSizeFast = 16;
|
||||
static const CGFloat kKBBackspaceClearLabelCornerRadius = 8.0;
|
||||
static const CGFloat kKBBackspaceClearLabelHeight = 26.0;
|
||||
static const CGFloat kKBBackspaceClearLabelHeight = 34;
|
||||
static const CGFloat kKBBackspaceClearLabelPaddingX = 10.0;
|
||||
static const CGFloat kKBBackspaceClearLabelTopGap = 6.0;
|
||||
static const CGFloat kKBBackspaceClearLabelHorizontalInset = 6.0;
|
||||
static const NSInteger kKBBackspaceClearBatchSize = 24;
|
||||
static const NSTimeInterval kKBBackspaceClearBatchInterval = 0.005;
|
||||
static const NSTimeInterval kKBBackspaceClearBatchInterval = 0.02;
|
||||
static const NSInteger kKBBackspaceClearMaxDeletes = 10000;
|
||||
static const NSInteger kKBBackspaceClearEmptyContextMaxRounds = 40;
|
||||
static const NSInteger kKBBackspaceClearMaxStep = 80;
|
||||
static const NSInteger kKBBackspaceClearDeletesPerTick = 10;
|
||||
|
||||
typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
KBBackspaceChunkClassUnknown = 0,
|
||||
@@ -34,6 +35,12 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
KBBackspaceChunkClassOther
|
||||
};
|
||||
|
||||
typedef NS_ENUM(NSInteger, KBClearPhase) {
|
||||
KBClearPhaseSkipWhitespace = 0,
|
||||
KBClearPhaseSkipTrailingBoundary,
|
||||
KBClearPhaseDeleteUntilBoundary
|
||||
};
|
||||
|
||||
@interface KBBackspaceLongPressHandler ()
|
||||
@property (nonatomic, weak) UIView *containerView;
|
||||
@property (nonatomic, weak) UIView *backspaceButton;
|
||||
@@ -48,6 +55,9 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
@property (nonatomic, assign) CGPoint backspaceLastTouchPointInSelf;
|
||||
@property (nonatomic, assign) NSUInteger backspaceClearToken;
|
||||
@property (nonatomic, strong) UILabel *backspaceClearLabel;
|
||||
@property (nonatomic, copy) NSString *pendingClearBefore;
|
||||
@property (nonatomic, copy) NSString *pendingClearAfter;
|
||||
@property (nonatomic, assign) KBClearPhase backspaceClearPhase;
|
||||
@end
|
||||
|
||||
@implementation KBBackspaceLongPressHandler
|
||||
@@ -55,6 +65,7 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
- (instancetype)initWithContainerView:(UIView *)containerView {
|
||||
if (self = [super init]) {
|
||||
_containerView = containerView;
|
||||
_backspaceClearPhase = KBClearPhaseSkipWhitespace;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
@@ -73,6 +84,8 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
self.backspaceHasLastTouchPoint = NO;
|
||||
self.backspaceHoldToken += 1;
|
||||
[self kb_hideBackspaceClearLabel];
|
||||
self.pendingClearBefore = nil;
|
||||
self.pendingClearAfter = nil;
|
||||
|
||||
if (!button) { return; }
|
||||
|
||||
@@ -99,7 +112,18 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
}
|
||||
switch (gr.state) {
|
||||
case UIGestureRecognizerStateBegan: {
|
||||
[[KBBackspaceUndoManager shared] registerNonClearAction];
|
||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (ivc) {
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
[[KBInputBufferManager shared] refreshFromProxyIfPossible:proxy];
|
||||
[[KBInputBufferManager shared] prepareSnapshotForDeleteWithContextBefore:proxy.documentContextBeforeInput
|
||||
after:proxy.documentContextAfterInput];
|
||||
}
|
||||
if (self.showClearLabelEnabled) {
|
||||
[self kb_capturePendingClearSnapshotIfNeeded];
|
||||
[[KBInputBufferManager shared] beginPendingClearSnapshot];
|
||||
}
|
||||
self.backspaceHoldToken += 1;
|
||||
NSUInteger token = self.backspaceHoldToken;
|
||||
self.backspaceHoldActive = YES;
|
||||
@@ -134,6 +158,7 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
if (!ivc) { self.backspaceHoldActive = NO; return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||
if (before.length == 0) { before = [KBInputBufferManager shared].liveText ?: @""; }
|
||||
NSTimeInterval elapsed = [NSDate date].timeIntervalSinceReferenceDate - self.backspaceHoldStartTime;
|
||||
NSInteger deleteCount = 1;
|
||||
if (before.length > 0) {
|
||||
@@ -145,9 +170,8 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
[self kb_showBackspaceClearLabelIfNeeded];
|
||||
}
|
||||
}
|
||||
for (NSInteger i = 0; i < deleteCount; i++) {
|
||||
[proxy deleteBackward];
|
||||
}
|
||||
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:(NSUInteger)deleteCount];
|
||||
[[KBInputBufferManager shared] applyHoldDeleteCount:(NSUInteger)deleteCount];
|
||||
|
||||
NSTimeInterval interval = [self kb_backspaceRepeatIntervalForElapsed:elapsed];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
@@ -186,34 +210,77 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||
asciiWordSet = [NSCharacterSet characterSetWithCharactersInString:
|
||||
@"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_"];
|
||||
punctuationSet = [NSCharacterSet punctuationCharacterSet];
|
||||
NSMutableCharacterSet *punct = [[NSCharacterSet punctuationCharacterSet] mutableCopy];
|
||||
// 补齐常见中文/全角标点(避免 chunk 总是只删 1 个符号)
|
||||
[punct addCharactersInString:@",。!?;:、()【】《》“”‘’·…—"];
|
||||
punctuationSet = [punct copy];
|
||||
});
|
||||
|
||||
__block NSInteger deleteCount = 0;
|
||||
__block KBBackspaceChunkClass chunkClass = KBBackspaceChunkClassUnknown;
|
||||
typedef NS_ENUM(NSInteger, KBBackspaceChunkPhase) {
|
||||
KBBackspaceChunkPhaseWhitespace = 0,
|
||||
KBBackspaceChunkPhasePunctuation,
|
||||
KBBackspaceChunkPhaseCore
|
||||
};
|
||||
__block KBBackspaceChunkPhase phase = KBBackspaceChunkPhaseWhitespace;
|
||||
__block KBBackspaceChunkClass coreClass = 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) {
|
||||
if (deleteCount >= maxCount) {
|
||||
*stop = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
deleteCount += 1;
|
||||
KBBackspaceChunkClass currentClass = KBBackspaceChunkClassOther;
|
||||
if ([substring rangeOfCharacterFromSet:whitespaceSet].location != NSNotFound) {
|
||||
currentClass = KBBackspaceChunkClassWhitespace;
|
||||
} else if ([substring rangeOfCharacterFromSet:punctuationSet].location != NSNotFound) {
|
||||
currentClass = KBBackspaceChunkClassPunctuation;
|
||||
} else if ([substring rangeOfCharacterFromSet:asciiWordSet].location != NSNotFound) {
|
||||
currentClass = KBBackspaceChunkClassASCIIWord;
|
||||
}
|
||||
|
||||
BOOL consumed = NO;
|
||||
while (!consumed) {
|
||||
if (phase == KBBackspaceChunkPhaseWhitespace) {
|
||||
if (currentClass == KBBackspaceChunkClassWhitespace) {
|
||||
deleteCount += 1;
|
||||
consumed = YES;
|
||||
} else {
|
||||
phase = KBBackspaceChunkPhasePunctuation;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase == KBBackspaceChunkPhasePunctuation) {
|
||||
if (currentClass == KBBackspaceChunkClassPunctuation) {
|
||||
deleteCount += 1;
|
||||
consumed = YES;
|
||||
} else {
|
||||
phase = KBBackspaceChunkPhaseCore;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// phase == Core:连续删同一类(ASCII 单词 / 其它),让效果更像微信“几个字一组”
|
||||
if (coreClass == KBBackspaceChunkClassUnknown) {
|
||||
coreClass = currentClass;
|
||||
}
|
||||
if (currentClass != coreClass) {
|
||||
*stop = YES;
|
||||
consumed = YES;
|
||||
continue;
|
||||
}
|
||||
deleteCount += 1;
|
||||
consumed = YES;
|
||||
}
|
||||
|
||||
if (deleteCount >= maxCount) {
|
||||
*stop = YES;
|
||||
return;
|
||||
}
|
||||
}];
|
||||
|
||||
@@ -222,13 +289,16 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
|
||||
- (NSInteger)kb_clearDeleteCountForContext:(NSString *)context
|
||||
hitBoundary:(BOOL *)hitBoundary {
|
||||
if (context.length == 0) { return kKBBackspaceClearBatchSize; }
|
||||
if (context.length == 0) {
|
||||
if (hitBoundary) { *hitBoundary = NO; }
|
||||
return 1;
|
||||
}
|
||||
|
||||
static NSCharacterSet *sentenceBoundarySet = nil;
|
||||
static NSCharacterSet *whitespaceSet = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
||||
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?"];
|
||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||
});
|
||||
|
||||
@@ -303,6 +373,12 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
shouldClear = [self kb_isPointInsideBackspaceClearLabel:point];
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
NSLog(@"[kb_handleBackspaceLongPressEnded] shouldClear=%@ highlighted=%@ labelHidden=%@",
|
||||
shouldClear ? @"YES" : @"NO",
|
||||
self.backspaceClearHighlighted ? @"YES" : @"NO",
|
||||
self.backspaceClearLabel.hidden ? @"YES" : @"NO");
|
||||
#endif
|
||||
self.backspaceHoldActive = NO;
|
||||
self.backspaceChunkModeActive = NO;
|
||||
self.backspaceHoldToken += 1;
|
||||
@@ -310,6 +386,11 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
[self kb_hideBackspaceClearLabel];
|
||||
if (shouldClear) {
|
||||
[self kb_clearAllInput];
|
||||
} else {
|
||||
self.pendingClearBefore = nil;
|
||||
self.pendingClearAfter = nil;
|
||||
[[KBInputBufferManager shared] clearPendingClearSnapshot];
|
||||
[[KBInputBufferManager shared] commitLiveToManual];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +482,9 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
- (UILabel *)backspaceClearLabel {
|
||||
if (!_backspaceClearLabel) {
|
||||
UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
|
||||
label.text = @"立刻清空";
|
||||
label.text = KBLocalized(@"Clear");
|
||||
label.textAlignment = NSTextAlignmentCenter;
|
||||
label.font = [UIFont systemFontOfSize:12 weight:UIFontWeightSemibold];
|
||||
label.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
||||
label.textColor = [KBSkinManager shared].current.keyTextColor ?: UIColor.blackColor;
|
||||
label.backgroundColor = [self kb_backspaceClearLabelNormalColor];
|
||||
label.layer.cornerRadius = kKBBackspaceClearLabelCornerRadius;
|
||||
@@ -421,10 +502,14 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (ivc) {
|
||||
NSString *before = ivc.textDocumentProxy.documentContextBeforeInput ?: @"";
|
||||
[[KBBackspaceUndoManager shared] recordClearWithContext:before];
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
[[KBInputBufferManager shared] refreshFromProxyIfPossible:proxy];
|
||||
}
|
||||
self.pendingClearBefore = nil;
|
||||
self.pendingClearAfter = nil;
|
||||
[[KBInputBufferManager shared] clearPendingClearSnapshot];
|
||||
self.backspaceClearToken += 1;
|
||||
self.backspaceClearPhase = KBClearPhaseSkipWhitespace;
|
||||
NSUInteger token = self.backspaceClearToken;
|
||||
[self kb_clearAllInputStepForToken:token guard:0 emptyRounds:0];
|
||||
}
|
||||
@@ -437,40 +522,101 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (!ivc) { return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||
NSInteger count = before.length;
|
||||
NSInteger batch = 0;
|
||||
NSInteger nextEmptyRounds = emptyRounds;
|
||||
BOOL hitBoundary = NO;
|
||||
if (count > 0) {
|
||||
batch = [self kb_clearDeleteCountForContext:before hitBoundary:&hitBoundary];
|
||||
nextEmptyRounds = 0;
|
||||
} else {
|
||||
batch = kKBBackspaceClearBatchSize;
|
||||
nextEmptyRounds = emptyRounds + 1;
|
||||
}
|
||||
if (batch <= 0) { batch = 1; }
|
||||
static NSCharacterSet *stopBoundarySet = nil;
|
||||
static NSCharacterSet *trailingBoundarySet = nil;
|
||||
static NSCharacterSet *trailingWhitespaceSet = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
// stopBoundary: 遇到这些符号就停(不删除它)
|
||||
// - 句末符号:. ! ? 。!?
|
||||
// - 省略号:…(中文里“……”常用作句/段落的停顿)
|
||||
// - 换行:\n \r(段落边界,避免一次“清空”跨段把全文删完)
|
||||
stopBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?…\n\r\u2028\u2029"];
|
||||
|
||||
if (guard >= kKBBackspaceClearMaxDeletes ||
|
||||
nextEmptyRounds > kKBBackspaceClearEmptyContextMaxRounds) {
|
||||
// trailingBoundary: 允许作为“尾部句末符号”先删掉,再继续删上一句(更接近微信体验)
|
||||
// 注意:不要把换行/省略号放进来,否则可能跨段/跨停顿继续删。
|
||||
trailingBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?。!?"];
|
||||
|
||||
// trailingWhitespace: 只跳过空格/Tab(不包含换行,换行由 stopBoundarySet 处理)
|
||||
trailingWhitespaceSet = [NSCharacterSet whitespaceCharacterSet];
|
||||
});
|
||||
KBClearPhase phase = self.backspaceClearPhase;
|
||||
|
||||
NSInteger deletedThisTick = 0;
|
||||
BOOL shouldStop = NO;
|
||||
NSString *lastBefore = nil;
|
||||
for (NSInteger i = 0; i < kKBBackspaceClearDeletesPerTick; i++) {
|
||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||
if (before.length == 0) {
|
||||
nextEmptyRounds += 1;
|
||||
// 宿主(微信/QQ 等)可能在长文本场景下返回空 context,即使还有很多内容。
|
||||
// 为了避免一次“清空”误删全文:一旦拿不到 before,就立刻停止本次清空。
|
||||
shouldStop = YES;
|
||||
break;
|
||||
}
|
||||
nextEmptyRounds = 0;
|
||||
|
||||
if (lastBefore && [before isEqualToString:lastBefore] && deletedThisTick > 0) {
|
||||
// 宿主未及时刷新 context,留到下一 tick 再继续,避免越界/重复记录
|
||||
break;
|
||||
}
|
||||
lastBefore = before;
|
||||
|
||||
// 取最后一个组合字符
|
||||
__block NSString *lastChar = @"";
|
||||
[before enumerateSubstringsInRange:NSMakeRange(0, before.length)
|
||||
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
||||
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
||||
lastChar = substring ?: @"";
|
||||
*stop = YES;
|
||||
}];
|
||||
if (lastChar.length == 0) { break; }
|
||||
|
||||
BOOL isWhitespace = ([lastChar rangeOfCharacterFromSet:trailingWhitespaceSet].location != NSNotFound);
|
||||
BOOL isStopBoundary = ([lastChar rangeOfCharacterFromSet:stopBoundarySet].location != NSNotFound);
|
||||
BOOL isTrailingBoundary = ([lastChar rangeOfCharacterFromSet:trailingBoundarySet].location != NSNotFound);
|
||||
|
||||
if (phase == KBClearPhaseSkipWhitespace) {
|
||||
if (isWhitespace) {
|
||||
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:1];
|
||||
[[KBInputBufferManager shared] applyClearDeleteCount:1];
|
||||
deletedThisTick += 1;
|
||||
continue;
|
||||
}
|
||||
phase = KBClearPhaseSkipTrailingBoundary;
|
||||
}
|
||||
|
||||
if (phase == KBClearPhaseSkipTrailingBoundary) {
|
||||
if (isTrailingBoundary) {
|
||||
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:1];
|
||||
[[KBInputBufferManager shared] applyClearDeleteCount:1];
|
||||
deletedThisTick += 1;
|
||||
continue;
|
||||
}
|
||||
phase = KBClearPhaseDeleteUntilBoundary;
|
||||
}
|
||||
|
||||
// phase == DeleteUntilBoundary
|
||||
if (isStopBoundary) {
|
||||
shouldStop = YES; // 保留该句末符号
|
||||
break;
|
||||
}
|
||||
[[KBBackspaceUndoManager shared] captureAndDeleteBackwardFromProxy:proxy count:1];
|
||||
[[KBInputBufferManager shared] applyClearDeleteCount:1];
|
||||
deletedThisTick += 1;
|
||||
if (guard + deletedThisTick >= kKBBackspaceClearMaxDeletes) { break; }
|
||||
if (deletedThisTick >= kKBBackspaceClearMaxStep) { break; }
|
||||
}
|
||||
|
||||
self.backspaceClearPhase = phase;
|
||||
NSInteger nextGuard = guard + deletedThisTick;
|
||||
if (nextGuard >= kKBBackspaceClearMaxDeletes ||
|
||||
nextEmptyRounds > kKBBackspaceClearEmptyContextMaxRounds ||
|
||||
shouldStop) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (NSInteger i = 0; i < batch; i++) {
|
||||
[proxy deleteBackward];
|
||||
}
|
||||
|
||||
NSInteger nextGuard = guard + batch;
|
||||
BOOL shouldContinue = NO;
|
||||
if (count > 0 && !hitBoundary) {
|
||||
if (count > batch) {
|
||||
shouldContinue = YES;
|
||||
} else if ([proxy hasText]) {
|
||||
shouldContinue = YES;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldContinue) { return; }
|
||||
__weak typeof(self) weakSelf = self;
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
|
||||
(int64_t)(kKBBackspaceClearBatchInterval * NSEC_PER_SEC)),
|
||||
@@ -489,4 +635,28 @@ typedef NS_ENUM(NSInteger, KBBackspaceChunkClass) {
|
||||
return self.backspaceButton.superview;
|
||||
}
|
||||
|
||||
- (void)kb_captureDeletionSnapshotIfNeeded {
|
||||
if ([KBBackspaceUndoManager shared].hasUndo) { return; }
|
||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (!ivc) { return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
[[KBBackspaceUndoManager shared] recordDeletionSnapshotBefore:proxy.documentContextBeforeInput
|
||||
after:proxy.documentContextAfterInput];
|
||||
}
|
||||
|
||||
- (void)kb_capturePendingClearSnapshotIfNeeded {
|
||||
if (self.pendingClearBefore.length > 0 || self.pendingClearAfter.length > 0) { return; }
|
||||
UIResponder *start = (UIResponder *)([self kb_hostView] ?: self.backspaceButton);
|
||||
UIInputViewController *ivc = KBFindInputViewController(start);
|
||||
if (!ivc) { return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
self.pendingClearBefore = proxy.documentContextBeforeInput ?: @"";
|
||||
self.pendingClearAfter = proxy.documentContextAfterInput ?: @"";
|
||||
#if DEBUG
|
||||
NSLog(@"[kb_capturePendingClearSnapshotIfNeeded/before] len=%lu text=%@", (unsigned long)self.pendingClearBefore.length, self.pendingClearBefore);
|
||||
NSLog(@"[kb_capturePendingClearSnapshotIfNeeded/after] len=%lu text=%@", (unsigned long)self.pendingClearAfter.length, self.pendingClearAfter);
|
||||
#endif
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -15,13 +15,19 @@ extern NSNotificationName const KBBackspaceUndoStateDidChangeNotification;
|
||||
|
||||
+ (instancetype)shared;
|
||||
|
||||
/// 记录一次“立刻清空”删除的内容(基于 documentContextBeforeInput)
|
||||
- (void)recordClearWithContext:(NSString *)context;
|
||||
/// 记录一次删除前的快照(不改变撤销按钮显示)。
|
||||
- (void)recordDeletionSnapshotBefore:(NSString *)before after:(NSString *)after;
|
||||
|
||||
/// 记录一次“立刻清空”删除的内容(基于 documentContextBeforeInput/AfterInput)。
|
||||
- (void)recordClearWithContextBefore:(NSString *)before after:(NSString *)after;
|
||||
|
||||
/// 记录本次将被 deleteBackward 的内容,并执行 deleteBackward(支持多次累计,撤销时一次性插回)。
|
||||
- (void)captureAndDeleteBackwardFromProxy:(id<UITextDocumentProxy>)proxy count:(NSUInteger)count;
|
||||
|
||||
/// 在指定 responder 处执行撤销(向光标处插回删除的内容)
|
||||
- (void)performUndoFromResponder:(UIResponder *)responder;
|
||||
|
||||
/// 非清空行为触发时,清理撤销状态
|
||||
/// 非删除行为触发时,清理撤销状态
|
||||
- (void)registerNonClearAction;
|
||||
|
||||
@end
|
||||
|
||||
@@ -5,13 +5,38 @@
|
||||
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBResponderUtils.h"
|
||||
#import "KBInputBufferManager.h"
|
||||
|
||||
NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspaceUndoStateDidChangeNotification";
|
||||
|
||||
#if DEBUG
|
||||
static NSString *KBLogString(NSString *tag, NSString *text) {
|
||||
NSString *safeTag = tag ?: @"";
|
||||
NSString *safeText = text ?: @"";
|
||||
if (safeText.length <= 2000) {
|
||||
return [NSString stringWithFormat:@"[%@] len=%lu text=%@", safeTag, (unsigned long)safeText.length, safeText];
|
||||
}
|
||||
NSString *head = [safeText substringToIndex:800];
|
||||
NSString *tail = [safeText substringFromIndex:safeText.length - 800];
|
||||
return [NSString stringWithFormat:@"[%@] len=%lu head=%@ ... tail=%@", safeTag, (unsigned long)safeText.length, head, tail];
|
||||
}
|
||||
#define KB_UNDO_LOG(tag, text) NSLog(@"%@", KBLogString((tag), (text)))
|
||||
#else
|
||||
#define KB_UNDO_LOG(tag, text) do {} while(0)
|
||||
#endif
|
||||
|
||||
typedef NS_ENUM(NSInteger, KBUndoSnapshotSource) {
|
||||
KBUndoSnapshotSourceNone = 0,
|
||||
KBUndoSnapshotSourceDeletionSnapshot,
|
||||
KBUndoSnapshotSourceClear
|
||||
};
|
||||
|
||||
@interface KBBackspaceUndoManager ()
|
||||
@property (nonatomic, strong) NSMutableArray<NSString *> *segments; // deletion order (last -> first)
|
||||
@property (nonatomic, assign) BOOL lastActionWasClear;
|
||||
@property (nonatomic, copy) NSString *undoText;
|
||||
@property (nonatomic, assign) NSInteger undoAfterLength;
|
||||
@property (nonatomic, assign) BOOL hasUndo;
|
||||
@property (nonatomic, assign) KBUndoSnapshotSource snapshotSource;
|
||||
@property (nonatomic, strong) NSMutableArray<NSString *> *undoDeletedPieces;
|
||||
@end
|
||||
|
||||
@implementation KBBackspaceUndoManager
|
||||
@@ -27,42 +52,191 @@ NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspa
|
||||
|
||||
- (instancetype)init {
|
||||
if (self = [super init]) {
|
||||
_segments = [NSMutableArray array];
|
||||
_undoText = @"";
|
||||
_undoAfterLength = 0;
|
||||
_snapshotSource = KBUndoSnapshotSourceNone;
|
||||
_undoDeletedPieces = [NSMutableArray array];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)recordClearWithContext:(NSString *)context {
|
||||
if (context.length == 0) { return; }
|
||||
NSString *segment = [self kb_segmentForClearFromContext:context];
|
||||
if (segment.length == 0) { return; }
|
||||
- (void)captureAndDeleteBackwardFromProxy:(id<UITextDocumentProxy>)proxy count:(NSUInteger)count {
|
||||
if (!proxy || count == 0) { return; }
|
||||
|
||||
if (!self.lastActionWasClear) {
|
||||
[self.segments removeAllObjects];
|
||||
NSString *selected = proxy.selectedText ?: @"";
|
||||
NSString *ctxBefore = proxy.documentContextBeforeInput ?: @"";
|
||||
NSString *ctxAfter = proxy.documentContextAfterInput ?: @"";
|
||||
NSUInteger ctxLen = ctxBefore.length + ctxAfter.length;
|
||||
BOOL isSelectAllLike = (selected.length > 0 &&
|
||||
(ctxLen == 0 || selected.length >= MAX((NSUInteger)40, ctxLen * 2)));
|
||||
if (isSelectAllLike) {
|
||||
// “全选删除”在微信/QQ中通常拿不到可靠的全文,因此禁用撤销,避免插回错误/不完整内容。
|
||||
if (self.hasUndo) {
|
||||
[self registerNonClearAction];
|
||||
}
|
||||
#if DEBUG
|
||||
KB_UNDO_LOG(@"captureAndDelete/selectAllDisableUndo", selected);
|
||||
#endif
|
||||
[proxy deleteBackward];
|
||||
[[KBInputBufferManager shared] resetWithText:@""];
|
||||
return;
|
||||
}
|
||||
[self.segments addObject:segment];
|
||||
self.lastActionWasClear = YES;
|
||||
|
||||
if (!self.hasUndo) {
|
||||
[self.undoDeletedPieces removeAllObjects];
|
||||
self.undoText = @"";
|
||||
self.undoAfterLength = 0;
|
||||
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
||||
[self kb_updateHasUndo:YES];
|
||||
}
|
||||
|
||||
BOOL didAppend = NO;
|
||||
NSString *lastObservedBefore = nil;
|
||||
for (NSUInteger i = 0; i < count; i++) {
|
||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||
if (before.length > 0) {
|
||||
// 若宿主在同一 runloop 内不更新 context,则跳过记录,避免把同一个字符重复记录成“多句”。
|
||||
if (lastObservedBefore && [before isEqualToString:lastObservedBefore]) {
|
||||
// still delete, but don't record
|
||||
} else {
|
||||
NSString *piece = [self kb_lastComposedCharacterFromString:before];
|
||||
if (piece.length > 0) {
|
||||
[self.undoDeletedPieces addObject:piece];
|
||||
didAppend = YES;
|
||||
}
|
||||
lastObservedBefore = before;
|
||||
}
|
||||
}
|
||||
[proxy deleteBackward];
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
if (didAppend) {
|
||||
NSUInteger piecesCount = self.undoDeletedPieces.count;
|
||||
if (piecesCount <= 20) {
|
||||
KB_UNDO_LOG(@"captureAndDelete/undoInsertTextNow", [self kb_buildUndoInsertTextFromPieces]);
|
||||
} else if (piecesCount % 50 == 0) {
|
||||
NSString *lastPiece = self.undoDeletedPieces.lastObject ?: @"";
|
||||
NSLog(@"[captureAndDelete/undoPieces] pieces=%lu lastPiece=%@",
|
||||
(unsigned long)piecesCount,
|
||||
lastPiece);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
- (void)recordDeletionSnapshotBefore:(NSString *)before after:(NSString *)after {
|
||||
if (self.hasUndo) { return; }
|
||||
NSString *pending = [KBInputBufferManager shared].pendingClearSnapshot;
|
||||
NSString *manual = [KBInputBufferManager shared].manualSnapshot;
|
||||
NSString *fallbackText = (pending.length > 0) ? pending : ((manual.length > 0) ? manual : [KBInputBufferManager shared].liveText);
|
||||
if (fallbackText.length > 0) {
|
||||
self.undoText = fallbackText;
|
||||
self.undoAfterLength = 0;
|
||||
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
||||
KB_UNDO_LOG(@"recordDeletionSnapshot/fallback", self.undoText);
|
||||
[self kb_updateHasUndo:YES];
|
||||
return;
|
||||
}
|
||||
NSString *safeBefore = before ?: @"";
|
||||
NSString *safeAfter = after ?: @"";
|
||||
NSString *full = [safeBefore stringByAppendingString:safeAfter];
|
||||
if (full.length == 0) { return; }
|
||||
self.undoText = full;
|
||||
self.undoAfterLength = (NSInteger)safeAfter.length;
|
||||
self.snapshotSource = KBUndoSnapshotSourceDeletionSnapshot;
|
||||
KB_UNDO_LOG(@"recordDeletionSnapshot/context", self.undoText);
|
||||
[self kb_updateHasUndo:YES];
|
||||
}
|
||||
|
||||
- (void)recordClearWithContextBefore:(NSString *)before after:(NSString *)after {
|
||||
NSString *pending = [KBInputBufferManager shared].pendingClearSnapshot;
|
||||
NSString *manual = [KBInputBufferManager shared].manualSnapshot;
|
||||
NSString *fallbackText = (pending.length > 0) ? pending : ((manual.length > 0) ? manual : [KBInputBufferManager shared].liveText);
|
||||
|
||||
NSString *safeBefore = before ?: @"";
|
||||
NSString *safeAfter = after ?: @"";
|
||||
NSString *contextText = [[safeBefore stringByAppendingString:safeAfter] copy];
|
||||
|
||||
NSString *candidate = (fallbackText.length > 0) ? fallbackText : contextText;
|
||||
NSInteger candidateAfterLen = (fallbackText.length > 0) ? 0 : (NSInteger)safeAfter.length;
|
||||
|
||||
if (candidate.length == 0) { return; }
|
||||
|
||||
KB_UNDO_LOG(@"recordClear/candidate", candidate);
|
||||
|
||||
if (self.undoText.length > 0) {
|
||||
if (self.snapshotSource == KBUndoSnapshotSourceClear) {
|
||||
KB_UNDO_LOG(@"recordClear/ignored(alreadyClear)", self.undoText);
|
||||
[self kb_updateHasUndo:YES];
|
||||
return;
|
||||
}
|
||||
if (self.snapshotSource == KBUndoSnapshotSourceDeletionSnapshot) {
|
||||
if (candidate.length > self.undoText.length) {
|
||||
self.undoText = candidate;
|
||||
self.undoAfterLength = candidateAfterLen;
|
||||
KB_UNDO_LOG(@"recordClear/upgradedFromDeletion", self.undoText);
|
||||
} else {
|
||||
KB_UNDO_LOG(@"recordClear/keepDeletionSnapshot", self.undoText);
|
||||
}
|
||||
self.snapshotSource = KBUndoSnapshotSourceClear;
|
||||
[self kb_updateHasUndo:YES];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.undoText = candidate;
|
||||
self.undoAfterLength = candidateAfterLen;
|
||||
self.snapshotSource = KBUndoSnapshotSourceClear;
|
||||
KB_UNDO_LOG(@"recordClear/set", self.undoText);
|
||||
[self kb_updateHasUndo:YES];
|
||||
}
|
||||
|
||||
- (void)performUndoFromResponder:(UIResponder *)responder {
|
||||
if (self.segments.count == 0) { return; }
|
||||
if (!self.hasUndo) { return; }
|
||||
UIInputViewController *ivc = KBFindInputViewController(responder);
|
||||
if (!ivc) { return; }
|
||||
id<UITextDocumentProxy> proxy = ivc.textDocumentProxy;
|
||||
NSString *text = [self kb_buildUndoText];
|
||||
if (text.length == 0) { return; }
|
||||
[proxy insertText:text];
|
||||
|
||||
[self.segments removeAllObjects];
|
||||
self.lastActionWasClear = NO;
|
||||
NSString *curBefore = proxy.documentContextBeforeInput ?: @"";
|
||||
NSString *curAfter = proxy.documentContextAfterInput ?: @"";
|
||||
KB_UNDO_LOG(@"performUndo/currentBefore", curBefore);
|
||||
KB_UNDO_LOG(@"performUndo/currentAfter", curAfter);
|
||||
NSString *insertText = [self kb_buildUndoInsertTextFromPieces];
|
||||
if (insertText.length > 0) {
|
||||
KB_UNDO_LOG(@"performUndo/insertDeletedText", insertText);
|
||||
[proxy insertText:insertText];
|
||||
[[KBInputBufferManager shared] appendText:insertText];
|
||||
} else if (self.undoText.length > 0) {
|
||||
KB_UNDO_LOG(@"performUndo/fallbackUndoText", self.undoText);
|
||||
[self kb_clearAllTextForProxy:proxy];
|
||||
[proxy insertText:self.undoText];
|
||||
if (self.undoAfterLength > 0 &&
|
||||
[proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) {
|
||||
[proxy adjustTextPositionByCharacterOffset:-self.undoAfterLength];
|
||||
}
|
||||
[[KBInputBufferManager shared] resetWithText:self.undoText];
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
self.undoText = @"";
|
||||
self.undoAfterLength = 0;
|
||||
self.snapshotSource = KBUndoSnapshotSourceNone;
|
||||
[self.undoDeletedPieces removeAllObjects];
|
||||
[self kb_updateHasUndo:NO];
|
||||
}
|
||||
|
||||
- (void)registerNonClearAction {
|
||||
self.lastActionWasClear = NO;
|
||||
if (self.segments.count == 0) { return; }
|
||||
[self.segments removeAllObjects];
|
||||
if (!self.hasUndo) { return; }
|
||||
if (self.undoText.length > 0) {
|
||||
KB_UNDO_LOG(@"registerNonClearAction/clearUndoText", self.undoText);
|
||||
}
|
||||
if (self.undoDeletedPieces.count > 0) {
|
||||
KB_UNDO_LOG(@"registerNonClearAction/clearDeletedPieces", [self kb_buildUndoInsertTextFromPieces]);
|
||||
}
|
||||
self.undoText = @"";
|
||||
self.undoAfterLength = 0;
|
||||
self.snapshotSource = KBUndoSnapshotSourceNone;
|
||||
[self.undoDeletedPieces removeAllObjects];
|
||||
[self kb_updateHasUndo:NO];
|
||||
}
|
||||
|
||||
@@ -74,97 +248,57 @@ NSNotificationName const KBBackspaceUndoStateDidChangeNotification = @"KBBackspa
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:KBBackspaceUndoStateDidChangeNotification object:self];
|
||||
}
|
||||
|
||||
- (NSString *)kb_segmentForClearFromContext:(NSString *)context {
|
||||
NSInteger length = context.length;
|
||||
if (length == 0) { return @""; }
|
||||
- (NSString *)kb_lastComposedCharacterFromString:(NSString *)text {
|
||||
if (text.length == 0) { return @""; }
|
||||
__block NSString *last = @"";
|
||||
[text enumerateSubstringsInRange:NSMakeRange(0, text.length)
|
||||
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
||||
usingBlock:^(NSString *substring, __unused NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
||||
last = substring ?: @"";
|
||||
*stop = YES;
|
||||
}];
|
||||
return last ?: @"";
|
||||
}
|
||||
|
||||
static NSCharacterSet *sentenceBoundarySet = nil;
|
||||
static NSCharacterSet *whitespaceSet = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
sentenceBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
NSInteger start = (boundaryIndex == NSNotFound) ? 0 : (boundaryIndex + 1);
|
||||
if (start >= length) { return @""; }
|
||||
return [context substringFromIndex:start];
|
||||
}
|
||||
|
||||
- (NSString *)kb_buildUndoText {
|
||||
if (self.segments.count == 0) { return @""; }
|
||||
NSArray<NSString *> *ordered = [[self.segments reverseObjectEnumerator] allObjects];
|
||||
- (NSString *)kb_buildUndoInsertTextFromPieces {
|
||||
if (self.undoDeletedPieces.count == 0) { return @""; }
|
||||
NSMutableString *result = [NSMutableString string];
|
||||
for (NSInteger i = 0; i < ordered.count; i++) {
|
||||
NSString *segment = ordered[i] ?: @"";
|
||||
if (segment.length == 0) { continue; }
|
||||
if (i < ordered.count - 1) {
|
||||
segment = [self kb_replaceTrailingBoundaryWithComma:segment];
|
||||
}
|
||||
[result appendString:segment];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
for (NSInteger i = (NSInteger)self.undoDeletedPieces.count - 1; i >= 0; i--) {
|
||||
NSString *piece = self.undoDeletedPieces[(NSUInteger)i] ?: @"";
|
||||
if (piece.length == 0) { continue; }
|
||||
[result appendString:piece];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
- (NSString *)kb_replaceTrailingBoundaryWithComma:(NSString *)segment {
|
||||
if (segment.length == 0) { return segment; }
|
||||
static const NSInteger kKBUndoClearMaxRounds = 200;
|
||||
|
||||
static NSCharacterSet *boundarySet = nil;
|
||||
static NSCharacterSet *englishBoundarySet = nil;
|
||||
static NSCharacterSet *whitespaceSet = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
boundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;。!?;…\n"];
|
||||
englishBoundarySet = [NSCharacterSet characterSetWithCharactersInString:@".!?;"];
|
||||
whitespaceSet = [NSCharacterSet whitespaceAndNewlineCharacterSet];
|
||||
});
|
||||
- (void)kb_clearAllTextForProxy:(id<UITextDocumentProxy>)proxy {
|
||||
if (!proxy) { return; }
|
||||
|
||||
NSInteger idx = segment.length - 1;
|
||||
while (idx >= 0) {
|
||||
unichar ch = [segment characterAtIndex:idx];
|
||||
if ([whitespaceSet characterIsMember:ch]) {
|
||||
idx -= 1;
|
||||
continue;
|
||||
if ([proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) {
|
||||
NSInteger guard = 0;
|
||||
NSString *contextAfter = proxy.documentContextAfterInput ?: @"";
|
||||
while (contextAfter.length > 0 && guard < kKBUndoClearMaxRounds) {
|
||||
NSInteger offset = (NSInteger)contextAfter.length;
|
||||
[proxy adjustTextPositionByCharacterOffset:offset];
|
||||
for (NSUInteger i = 0; i < contextAfter.length; i++) {
|
||||
[proxy deleteBackward];
|
||||
}
|
||||
guard += 1;
|
||||
contextAfter = proxy.documentContextAfterInput ?: @"";
|
||||
}
|
||||
if (![boundarySet characterIsMember:ch]) {
|
||||
return segment;
|
||||
}
|
||||
NSString *comma = [englishBoundarySet characterIsMember:ch] ? @"," : @",";
|
||||
NSMutableString *mutable = [segment mutableCopy];
|
||||
NSRange r = NSMakeRange(idx, 1);
|
||||
[mutable replaceCharactersInRange:r withString:comma];
|
||||
return mutable;
|
||||
}
|
||||
|
||||
return segment;
|
||||
NSInteger guard = 0;
|
||||
NSString *contextBefore = proxy.documentContextBeforeInput ?: @"";
|
||||
while (contextBefore.length > 0 && guard < kKBUndoClearMaxRounds) {
|
||||
for (NSUInteger i = 0; i < contextBefore.length; i++) {
|
||||
[proxy deleteBackward];
|
||||
}
|
||||
guard += 1;
|
||||
contextBefore = proxy.documentContextBeforeInput ?: @"";
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
34
CustomKeyboard/Utils/KBInputBufferManager.h
Normal file
@@ -0,0 +1,34 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@protocol UITextDocumentProxy;
|
||||
|
||||
@interface KBInputBufferManager : NSObject
|
||||
|
||||
+ (instancetype)shared;
|
||||
|
||||
@property (nonatomic, copy, readonly) NSString *liveText;
|
||||
@property (nonatomic, copy, readonly) NSString *manualSnapshot;
|
||||
@property (nonatomic, copy, readonly) NSString *pendingClearSnapshot;
|
||||
|
||||
- (void)seedIfEmptyWithContextBefore:(nullable NSString *)before after:(nullable NSString *)after;
|
||||
- (void)updateFromExternalContextBefore:(nullable NSString *)before after:(nullable NSString *)after;
|
||||
- (void)refreshFromProxyIfPossible:(nullable id<UITextDocumentProxy>)proxy;
|
||||
- (void)prepareSnapshotForDeleteWithContextBefore:(nullable NSString *)before
|
||||
after:(nullable NSString *)after;
|
||||
- (void)beginPendingClearSnapshot;
|
||||
- (void)clearPendingClearSnapshot;
|
||||
- (void)resetWithText:(NSString *)text;
|
||||
- (void)appendText:(NSString *)text;
|
||||
- (void)deleteBackwardByCount:(NSUInteger)count;
|
||||
- (void)replaceTailWithText:(NSString *)text deleteCount:(NSUInteger)count;
|
||||
- (void)applyHoldDeleteCount:(NSUInteger)count;
|
||||
- (void)applyClearDeleteCount:(NSUInteger)count;
|
||||
- (void)clearAllLiveText;
|
||||
- (void)commitLiveToManual;
|
||||
- (void)restoreManualSnapshot;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
279
CustomKeyboard/Utils/KBInputBufferManager.m
Normal file
@@ -0,0 +1,279 @@
|
||||
#import "KBInputBufferManager.h"
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
#if DEBUG
|
||||
static NSString *KBLogString2(NSString *tag, NSString *text) {
|
||||
NSString *safeTag = tag ?: @"";
|
||||
NSString *safeText = text ?: @"";
|
||||
if (safeText.length <= 2000) {
|
||||
return [NSString stringWithFormat:@"[%@] len=%lu text=%@", safeTag, (unsigned long)safeText.length, safeText];
|
||||
}
|
||||
NSString *head = [safeText substringToIndex:800];
|
||||
NSString *tail = [safeText substringFromIndex:safeText.length - 800];
|
||||
return [NSString stringWithFormat:@"[%@] len=%lu head=%@ ... tail=%@", safeTag, (unsigned long)safeText.length, head, tail];
|
||||
}
|
||||
#define KB_BUF_LOG(tag, text) NSLog(@"❤️=%@", KBLogString2((tag), (text)))
|
||||
#else
|
||||
#define KB_BUF_LOG(tag, text) do {} while(0)
|
||||
#endif
|
||||
|
||||
@interface KBInputBufferManager ()
|
||||
@property (nonatomic, copy, readwrite) NSString *liveText;
|
||||
@property (nonatomic, copy, readwrite) NSString *manualSnapshot;
|
||||
@property (nonatomic, copy, readwrite) NSString *pendingClearSnapshot;
|
||||
@property (nonatomic, assign) BOOL manualSnapshotDirty;
|
||||
@end
|
||||
|
||||
@implementation KBInputBufferManager
|
||||
|
||||
+ (instancetype)shared {
|
||||
static KBInputBufferManager *mgr = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
mgr = [[KBInputBufferManager alloc] init];
|
||||
});
|
||||
return mgr;
|
||||
}
|
||||
|
||||
- (instancetype)init {
|
||||
if (self = [super init]) {
|
||||
_liveText = @"";
|
||||
_manualSnapshot = @"";
|
||||
_pendingClearSnapshot = @"";
|
||||
_manualSnapshotDirty = NO;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)seedIfEmptyWithContextBefore:(NSString *)before after:(NSString *)after {
|
||||
if (self.liveText.length > 0 || self.manualSnapshot.length > 0) { return; }
|
||||
NSString *safeBefore = before ?: @"";
|
||||
NSString *safeAfter = after ?: @"";
|
||||
NSString *full = [safeBefore stringByAppendingString:safeAfter];
|
||||
if (full.length == 0) { return; }
|
||||
self.liveText = full;
|
||||
self.manualSnapshot = full;
|
||||
self.manualSnapshotDirty = NO;
|
||||
KB_BUF_LOG(@"seedIfEmpty", full);
|
||||
}
|
||||
|
||||
- (void)updateFromExternalContextBefore:(NSString *)before after:(NSString *)after {
|
||||
NSString *safeBefore = before ?: @"";
|
||||
NSString *safeAfter = after ?: @"";
|
||||
NSString *context = [safeBefore stringByAppendingString:safeAfter];
|
||||
if (context.length == 0) { return; }
|
||||
|
||||
// 微信/QQ 等宿主通常只提供光标附近“截断窗口”,不应当作为全文快照。
|
||||
// 这里只更新 liveText,给删除/清空逻辑做参考;manualSnapshot 仅由键盘自身输入/撤销来维护。
|
||||
self.liveText = context;
|
||||
self.manualSnapshotDirty = YES;
|
||||
#if DEBUG
|
||||
static NSUInteger sExternalLogCounter = 0;
|
||||
sExternalLogCounter += 1;
|
||||
if (sExternalLogCounter % 12 == 0) {
|
||||
KB_BUF_LOG(@"updateFromExternalContext/liveOnly", context);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
- (void)refreshFromProxyIfPossible:(id<UITextDocumentProxy>)proxy {
|
||||
NSString *harvested = [self kb_harvestFullTextFromProxy:proxy];
|
||||
if (harvested.length == 0) {
|
||||
KB_BUF_LOG(@"refreshFromProxy/failedOrUnsupported", @"");
|
||||
return;
|
||||
}
|
||||
|
||||
BOOL manualEmpty = (self.manualSnapshot.length == 0);
|
||||
BOOL longerThanManual = (harvested.length > self.manualSnapshot.length);
|
||||
if (!(manualEmpty || longerThanManual)) {
|
||||
KB_BUF_LOG(@"refreshFromProxy/ignoredShorter", harvested);
|
||||
return;
|
||||
}
|
||||
|
||||
self.liveText = harvested;
|
||||
self.manualSnapshot = harvested;
|
||||
self.manualSnapshotDirty = NO;
|
||||
KB_BUF_LOG(@"refreshFromProxy/accepted", harvested);
|
||||
}
|
||||
|
||||
- (void)prepareSnapshotForDeleteWithContextBefore:(NSString *)before
|
||||
after:(NSString *)after {
|
||||
NSString *safeBefore = before ?: @"";
|
||||
NSString *safeAfter = after ?: @"";
|
||||
NSString *context = [safeBefore stringByAppendingString:safeAfter];
|
||||
|
||||
BOOL manualValid = (self.manualSnapshot.length > 0 &&
|
||||
(context.length == 0 ||
|
||||
(self.manualSnapshot.length >= context.length &&
|
||||
[self.manualSnapshot rangeOfString:context].location != NSNotFound)));
|
||||
if (manualValid) { return; }
|
||||
|
||||
if (self.liveText.length > 0) {
|
||||
self.manualSnapshot = self.liveText;
|
||||
self.manualSnapshotDirty = NO;
|
||||
KB_BUF_LOG(@"prepareSnapshotForDelete/fromLiveText", self.manualSnapshot);
|
||||
return;
|
||||
}
|
||||
if (context.length > 0) {
|
||||
self.manualSnapshot = context;
|
||||
self.manualSnapshotDirty = NO;
|
||||
KB_BUF_LOG(@"prepareSnapshotForDelete/fromContext", self.manualSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
- (void)beginPendingClearSnapshot {
|
||||
if (self.pendingClearSnapshot.length > 0) { return; }
|
||||
if (self.manualSnapshot.length > 0) {
|
||||
self.pendingClearSnapshot = self.manualSnapshot;
|
||||
KB_BUF_LOG(@"beginPendingClearSnapshot/fromManual", self.pendingClearSnapshot);
|
||||
return;
|
||||
}
|
||||
if (self.liveText.length > 0) {
|
||||
self.pendingClearSnapshot = self.liveText;
|
||||
KB_BUF_LOG(@"beginPendingClearSnapshot/fromLive", self.pendingClearSnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
- (void)clearPendingClearSnapshot {
|
||||
self.pendingClearSnapshot = @"";
|
||||
}
|
||||
|
||||
- (void)resetWithText:(NSString *)text {
|
||||
NSString *safe = text ?: @"";
|
||||
self.liveText = safe;
|
||||
self.manualSnapshot = safe;
|
||||
self.pendingClearSnapshot = @"";
|
||||
self.manualSnapshotDirty = NO;
|
||||
KB_BUF_LOG(@"resetWithText", safe);
|
||||
}
|
||||
|
||||
- (void)appendText:(NSString *)text {
|
||||
if (text.length == 0) { return; }
|
||||
[self kb_syncManualSnapshotIfNeeded];
|
||||
self.liveText = [self.liveText stringByAppendingString:text];
|
||||
self.manualSnapshot = [self.manualSnapshot stringByAppendingString:text];
|
||||
}
|
||||
|
||||
- (void)deleteBackwardByCount:(NSUInteger)count {
|
||||
if (count == 0) { return; }
|
||||
self.liveText = [self kb_stringByDeletingComposedCharacters:count from:self.liveText];
|
||||
self.manualSnapshot = [self kb_stringByDeletingComposedCharacters:count from:self.manualSnapshot];
|
||||
}
|
||||
|
||||
- (void)replaceTailWithText:(NSString *)text deleteCount:(NSUInteger)count {
|
||||
[self kb_syncManualSnapshotIfNeeded];
|
||||
[self deleteBackwardByCount:count];
|
||||
[self appendText:text];
|
||||
}
|
||||
|
||||
- (void)applyHoldDeleteCount:(NSUInteger)count {
|
||||
if (count == 0) { return; }
|
||||
self.liveText = [self kb_stringByDeletingComposedCharacters:count from:self.liveText];
|
||||
self.manualSnapshotDirty = YES;
|
||||
}
|
||||
|
||||
- (void)applyClearDeleteCount:(NSUInteger)count {
|
||||
if (count == 0) { return; }
|
||||
self.liveText = [self kb_stringByDeletingComposedCharacters:count from:self.liveText];
|
||||
self.manualSnapshotDirty = YES;
|
||||
}
|
||||
|
||||
- (void)clearAllLiveText {
|
||||
self.liveText = @"";
|
||||
self.pendingClearSnapshot = @"";
|
||||
self.manualSnapshotDirty = YES;
|
||||
}
|
||||
|
||||
- (void)commitLiveToManual {
|
||||
self.manualSnapshot = self.liveText ?: @"";
|
||||
self.manualSnapshotDirty = NO;
|
||||
KB_BUF_LOG(@"commitLiveToManual", self.manualSnapshot);
|
||||
}
|
||||
|
||||
- (void)restoreManualSnapshot {
|
||||
self.liveText = self.manualSnapshot ?: @"";
|
||||
}
|
||||
|
||||
#pragma mark - Helpers
|
||||
|
||||
- (void)kb_syncManualSnapshotIfNeeded {
|
||||
if (!self.manualSnapshotDirty) { return; }
|
||||
self.manualSnapshot = self.liveText ?: @"";
|
||||
self.manualSnapshotDirty = NO;
|
||||
}
|
||||
|
||||
- (NSString *)kb_stringByDeletingComposedCharacters:(NSUInteger)count
|
||||
from:(NSString *)text {
|
||||
if (count == 0) { return text ?: @""; }
|
||||
NSString *source = text ?: @"";
|
||||
if (source.length == 0) { return @""; }
|
||||
|
||||
__block NSUInteger removed = 0;
|
||||
__block NSUInteger endIndex = source.length;
|
||||
[source enumerateSubstringsInRange:NSMakeRange(0, source.length)
|
||||
options:NSStringEnumerationByComposedCharacterSequences | NSStringEnumerationReverse
|
||||
usingBlock:^(__unused NSString *substring, NSRange substringRange, __unused NSRange enclosingRange, BOOL *stop) {
|
||||
removed += 1;
|
||||
endIndex = substringRange.location;
|
||||
if (removed >= count) {
|
||||
*stop = YES;
|
||||
}
|
||||
}];
|
||||
if (removed < count) { return @""; }
|
||||
return [source substringToIndex:endIndex];
|
||||
}
|
||||
|
||||
- (NSString *)kb_harvestFullTextFromProxy:(id<UITextDocumentProxy>)proxy {
|
||||
if (!proxy) { return @""; }
|
||||
if (![proxy respondsToSelector:@selector(adjustTextPositionByCharacterOffset:)]) { return @""; }
|
||||
|
||||
static const NSInteger kKBHarvestMaxRounds = 160;
|
||||
static const NSInteger kKBHarvestMaxChars = 50000;
|
||||
|
||||
NSInteger movedToEnd = 0;
|
||||
NSInteger movedLeft = 0;
|
||||
NSMutableArray<NSString *> *chunks = [NSMutableArray array];
|
||||
NSInteger totalChars = 0;
|
||||
|
||||
@try {
|
||||
NSInteger guard = 0;
|
||||
NSString *after = proxy.documentContextAfterInput ?: @"";
|
||||
while (after.length > 0 && guard < kKBHarvestMaxRounds) {
|
||||
NSInteger step = (NSInteger)after.length;
|
||||
[(id)proxy adjustTextPositionByCharacterOffset:step];
|
||||
movedToEnd += step;
|
||||
guard += 1;
|
||||
after = proxy.documentContextAfterInput ?: @"";
|
||||
}
|
||||
|
||||
guard = 0;
|
||||
NSString *before = proxy.documentContextBeforeInput ?: @"";
|
||||
while (before.length > 0 && guard < kKBHarvestMaxRounds && totalChars < kKBHarvestMaxChars) {
|
||||
[chunks addObject:before];
|
||||
totalChars += (NSInteger)before.length;
|
||||
NSInteger step = (NSInteger)before.length;
|
||||
[(id)proxy adjustTextPositionByCharacterOffset:-step];
|
||||
movedLeft += step;
|
||||
guard += 1;
|
||||
before = proxy.documentContextBeforeInput ?: @"";
|
||||
}
|
||||
} @finally {
|
||||
if (movedLeft != 0) {
|
||||
[(id)proxy adjustTextPositionByCharacterOffset:movedLeft];
|
||||
}
|
||||
if (movedToEnd != 0) {
|
||||
[(id)proxy adjustTextPositionByCharacterOffset:-movedToEnd];
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.count == 0) { return @""; }
|
||||
NSMutableString *result = [NSMutableString stringWithCapacity:(NSUInteger)totalChars];
|
||||
for (NSInteger i = (NSInteger)chunks.count - 1; i >= 0; i--) {
|
||||
NSString *part = chunks[(NSUInteger)i] ?: @"";
|
||||
if (part.length == 0) { continue; }
|
||||
[result appendString:part];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@end
|
||||
105
CustomKeyboard/VM/KBVM.h
Normal file
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// KBVM.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// 键盘扩展的 ViewModel,封装网络请求逻辑
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
@class KBChatDataModel;
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// 聊天响应模型
|
||||
@interface KBChatResponse : NSObject
|
||||
@property (nonatomic, strong, nullable) KBChatDataModel *data;
|
||||
//@property (nonatomic, copy, nullable) NSString *audioId;
|
||||
@property (nonatomic, copy, nullable) NSString *message;
|
||||
@property (nonatomic, assign) BOOL success;
|
||||
@property (nonatomic, assign) NSInteger code;
|
||||
|
||||
@end
|
||||
|
||||
@interface KBChatDataModel : NSObject
|
||||
@property (nonatomic, copy, nullable) NSString *aiResponse;
|
||||
@property (nonatomic, copy, nullable) NSString *audioId;
|
||||
@property (nonatomic, copy, nullable) NSString *llmDuration;
|
||||
|
||||
|
||||
@end
|
||||
|
||||
/// 音频响应模型
|
||||
@interface KBAudioResponse : NSObject
|
||||
@property (nonatomic, copy, nullable) NSString *audioURL;
|
||||
@property (nonatomic, strong, nullable) NSData *audioData;
|
||||
@property (nonatomic, assign) NSTimeInterval duration;
|
||||
@property (nonatomic, copy, nullable) NSString *errorMessage;
|
||||
@property (nonatomic, assign) BOOL success;
|
||||
@end
|
||||
|
||||
/// 聊天请求回调
|
||||
typedef void(^KBChatCompletion)(KBChatResponse *response);
|
||||
/// 音频 URL 回调
|
||||
typedef void(^KBAudioURLCompletion)(KBAudioResponse *response);
|
||||
/// 音频数据回调
|
||||
typedef void(^KBAudioDataCompletion)(KBAudioResponse *response);
|
||||
/// 头像回调
|
||||
typedef void(^KBAvatarCompletion)(UIImage * _Nullable image, NSError * _Nullable error);
|
||||
|
||||
@interface KBVM : NSObject
|
||||
|
||||
+ (instancetype)shared;
|
||||
|
||||
#pragma mark - Chat API
|
||||
|
||||
/// 发送聊天消息
|
||||
/// @param content 消息内容
|
||||
/// @param companionId 人设 ID
|
||||
/// @param completion 回调
|
||||
- (void)sendChatMessageWithContent:(NSString *)content
|
||||
companionId:(NSInteger)companionId
|
||||
completion:(KBChatCompletion)completion;
|
||||
|
||||
#pragma mark - Audio API
|
||||
|
||||
/// 获取音频 URL(单次请求)
|
||||
/// @param audioId 音频 ID
|
||||
/// @param completion 回调
|
||||
- (void)fetchAudioURLWithAudioId:(NSString *)audioId
|
||||
completion:(KBAudioURLCompletion)completion;
|
||||
|
||||
/// 轮询获取音频 URL(自动重试)
|
||||
/// @param audioId 音频 ID
|
||||
/// @param maxRetries 最大重试次数
|
||||
/// @param interval 重试间隔(秒)
|
||||
/// @param completion 回调
|
||||
- (void)pollAudioURLWithAudioId:(NSString *)audioId
|
||||
maxRetries:(NSInteger)maxRetries
|
||||
interval:(NSTimeInterval)interval
|
||||
completion:(KBAudioURLCompletion)completion;
|
||||
|
||||
/// 下载音频数据
|
||||
/// @param urlString 音频 URL
|
||||
/// @param completion 回调
|
||||
- (void)downloadAudioFromURL:(NSString *)urlString
|
||||
completion:(KBAudioDataCompletion)completion;
|
||||
|
||||
#pragma mark - Avatar API
|
||||
|
||||
/// 下载头像图片
|
||||
/// @param urlString 头像 URL
|
||||
/// @param completion 回调
|
||||
- (void)downloadAvatarFromURL:(NSString *)urlString
|
||||
completion:(KBAvatarCompletion)completion;
|
||||
|
||||
#pragma mark - Helper
|
||||
|
||||
/// 从 AppGroup 获取选中的 persona companionId
|
||||
- (NSInteger)selectedCompanionIdFromAppGroup;
|
||||
|
||||
/// 从 AppGroup 获取选中的 persona 信息
|
||||
- (nullable NSDictionary *)selectedPersonaFromAppGroup;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
337
CustomKeyboard/VM/KBVM.m
Normal file
@@ -0,0 +1,337 @@
|
||||
//
|
||||
// KBVM.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBVM.h"
|
||||
#import "KBNetworkManager.h"
|
||||
#import "KBConfig.h"
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <MJExtension/MJExtension.h>
|
||||
|
||||
@implementation KBChatResponse
|
||||
@end
|
||||
|
||||
@implementation KBChatDataModel
|
||||
@end
|
||||
|
||||
@implementation KBAudioResponse
|
||||
@end
|
||||
|
||||
@interface KBVM ()
|
||||
@property (nonatomic, strong) NSCache<NSString *, UIImage *> *avatarCache;
|
||||
@end
|
||||
|
||||
@implementation KBVM
|
||||
|
||||
+ (instancetype)shared {
|
||||
static KBVM *instance = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
instance = [[KBVM alloc] init];
|
||||
});
|
||||
return instance;
|
||||
}
|
||||
|
||||
- (instancetype)init {
|
||||
if (self = [super init]) {
|
||||
_avatarCache = [[NSCache alloc] init];
|
||||
_avatarCache.countLimit = 20;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Chat API
|
||||
|
||||
- (void)sendChatMessageWithContent:(NSString *)content
|
||||
companionId:(NSInteger)companionId
|
||||
completion:(KBChatCompletion)completion {
|
||||
if (content.length == 0) {
|
||||
if (completion) {
|
||||
KBChatResponse *response = [[KBChatResponse alloc] init];
|
||||
response.success = NO;
|
||||
response.message = @"内容为空";
|
||||
completion(response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *encodedContent = [content stringByAddingPercentEncodingWithAllowedCharacters:
|
||||
[NSCharacterSet URLQueryAllowedCharacterSet]];
|
||||
NSString *path = [NSString stringWithFormat:@"%@?content=%@&companionId=%ld",
|
||||
API_AI_CHAT_MESSAGE, encodedContent ?: @"", (long)companionId];
|
||||
NSDictionary *params = @{
|
||||
@"content": content ?: @"",
|
||||
@"companionId": @(companionId)
|
||||
};
|
||||
|
||||
[[KBNetworkManager shared] POST:path
|
||||
jsonBody:params
|
||||
headers:nil
|
||||
completion:^(NSDictionary *json, NSURLResponse *response, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
KBChatResponse *chatResponse = [KBChatResponse mj_objectWithKeyValues:json];
|
||||
if (chatResponse.code != 0) {
|
||||
chatResponse.success = NO;
|
||||
// chatResponse.errorMessage = error.localizedDescription ?: @"请求失败";
|
||||
if (completion) completion(chatResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// // 解析文本
|
||||
// chatResponse.text = [self p_parseTextFromJSON:json];
|
||||
// // 解析 audioId
|
||||
// chatResponse.audioId = [self p_parseAudioIdFromJSON:json];
|
||||
|
||||
// chatResponse.success = (chatResponse.text.length > 0);
|
||||
// if (!chatResponse.success) {
|
||||
// chatResponse.errorMessage = @"未获取到回复内容";
|
||||
// }
|
||||
|
||||
if (completion) completion(chatResponse);
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Audio API
|
||||
|
||||
- (void)fetchAudioURLWithAudioId:(NSString *)audioId
|
||||
completion:(KBAudioURLCompletion)completion {
|
||||
if (audioId.length == 0) {
|
||||
if (completion) {
|
||||
KBAudioResponse *response = [[KBAudioResponse alloc] init];
|
||||
response.success = NO;
|
||||
response.errorMessage = @"audioId 为空";
|
||||
completion(response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *path = [NSString stringWithFormat:@"/chat/audio/%@", audioId];
|
||||
|
||||
[[KBNetworkManager shared] GET:path
|
||||
parameters:nil
|
||||
headers:nil
|
||||
completion:^(NSDictionary *json, NSURLResponse *response, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
KBAudioResponse *audioResponse = [[KBAudioResponse alloc] init];
|
||||
|
||||
if (error) {
|
||||
audioResponse.success = NO;
|
||||
audioResponse.errorMessage = error.localizedDescription;
|
||||
if (completion) completion(audioResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析 audioURL
|
||||
NSString *audioURL = [self p_parseAudioURLFromJSON:json];
|
||||
audioResponse.audioURL = audioURL;
|
||||
audioResponse.success = (audioURL.length > 0);
|
||||
|
||||
if (completion) completion(audioResponse);
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)pollAudioURLWithAudioId:(NSString *)audioId
|
||||
maxRetries:(NSInteger)maxRetries
|
||||
interval:(NSTimeInterval)interval
|
||||
completion:(KBAudioURLCompletion)completion {
|
||||
[self p_pollAudioURLWithAudioId:audioId
|
||||
retryCount:0
|
||||
maxRetries:maxRetries
|
||||
interval:interval
|
||||
completion:completion];
|
||||
}
|
||||
|
||||
- (void)p_pollAudioURLWithAudioId:(NSString *)audioId
|
||||
retryCount:(NSInteger)retryCount
|
||||
maxRetries:(NSInteger)maxRetries
|
||||
interval:(NSTimeInterval)interval
|
||||
completion:(KBAudioURLCompletion)completion {
|
||||
|
||||
[self fetchAudioURLWithAudioId:audioId completion:^(KBAudioResponse *response) {
|
||||
if (response.success && response.audioURL.length > 0) {
|
||||
// 成功获取到 URL
|
||||
if (completion) completion(response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果还没达到最大重试次数,继续轮询
|
||||
if (retryCount < maxRetries - 1) {
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(interval * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
[self p_pollAudioURLWithAudioId:audioId
|
||||
retryCount:retryCount + 1
|
||||
maxRetries:maxRetries
|
||||
interval:interval
|
||||
completion:completion];
|
||||
});
|
||||
} else {
|
||||
// 达到最大重试次数
|
||||
KBAudioResponse *failResponse = [[KBAudioResponse alloc] init];
|
||||
failResponse.success = NO;
|
||||
failResponse.errorMessage = [NSString stringWithFormat:@"轮询失败,已重试 %ld 次", (long)maxRetries];
|
||||
if (completion) completion(failResponse);
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)downloadAudioFromURL:(NSString *)urlString
|
||||
completion:(KBAudioDataCompletion)completion {
|
||||
if (urlString.length == 0) {
|
||||
if (completion) {
|
||||
KBAudioResponse *response = [[KBAudioResponse alloc] init];
|
||||
response.success = NO;
|
||||
response.errorMessage = @"URL 为空";
|
||||
completion(response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
[[KBNetworkManager shared] GETData:urlString
|
||||
parameters:nil
|
||||
headers:nil
|
||||
completion:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
KBAudioResponse *audioResponse = [[KBAudioResponse alloc] init];
|
||||
|
||||
if (error || !data || data.length == 0) {
|
||||
audioResponse.success = NO;
|
||||
audioResponse.errorMessage = error.localizedDescription ?: @"下载失败";
|
||||
if (completion) completion(audioResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
audioResponse.audioData = data;
|
||||
|
||||
// 计算音频时长
|
||||
NSError *playerError = nil;
|
||||
AVAudioPlayer *player = [[AVAudioPlayer alloc] initWithData:data error:&playerError];
|
||||
if (!playerError && player) {
|
||||
audioResponse.duration = player.duration;
|
||||
}
|
||||
|
||||
audioResponse.success = YES;
|
||||
if (completion) completion(audioResponse);
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Avatar API
|
||||
|
||||
- (void)downloadAvatarFromURL:(NSString *)urlString
|
||||
completion:(KBAvatarCompletion)completion {
|
||||
if (urlString.length == 0) {
|
||||
if (completion) completion(nil, nil);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
UIImage *cached = [self.avatarCache objectForKey:urlString];
|
||||
if (cached) {
|
||||
if (completion) completion(cached, nil);
|
||||
return;
|
||||
}
|
||||
|
||||
[[KBNetworkManager shared] GETData:urlString
|
||||
parameters:nil
|
||||
headers:nil
|
||||
completion:^(NSData *data, NSURLResponse *response, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (error || data.length == 0) {
|
||||
if (completion) completion(nil, error);
|
||||
return;
|
||||
}
|
||||
|
||||
UIImage *image = [UIImage imageWithData:data];
|
||||
if (image) {
|
||||
[self.avatarCache setObject:image forKey:urlString];
|
||||
}
|
||||
if (completion) completion(image, nil);
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
#pragma mark - Helper
|
||||
|
||||
- (NSInteger)selectedCompanionIdFromAppGroup {
|
||||
NSDictionary *persona = [self selectedPersonaFromAppGroup];
|
||||
if (persona) {
|
||||
id companionIdObj = persona[@"personaId"] ?: persona[@"companionId"] ?: persona[@"id"];
|
||||
if ([companionIdObj respondsToSelector:@selector(integerValue)]) {
|
||||
return [companionIdObj integerValue];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
- (nullable NSDictionary *)selectedPersonaFromAppGroup {
|
||||
NSUserDefaults *shared = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
|
||||
return [shared objectForKey:@"AppGroup_SelectedPersona"];
|
||||
}
|
||||
|
||||
#pragma mark - Private Parse Methods
|
||||
|
||||
/// 解析聊天文本
|
||||
- (NSString *)p_parseTextFromJSON:(NSDictionary *)json {
|
||||
if (![json isKindOfClass:[NSDictionary class]]) return @"";
|
||||
|
||||
id dataObj = json[@"data"];
|
||||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||
NSDictionary *data = (NSDictionary *)dataObj;
|
||||
// 优先读取 aiResponse 字段
|
||||
NSArray *keys = @[@"aiResponse", @"content", @"text", @"message"];
|
||||
for (NSString *key in keys) {
|
||||
id value = data[key];
|
||||
if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) {
|
||||
return (NSString *)value;
|
||||
}
|
||||
}
|
||||
} else if ([dataObj isKindOfClass:[NSString class]]) {
|
||||
return (NSString *)dataObj;
|
||||
}
|
||||
|
||||
return @"";
|
||||
}
|
||||
|
||||
/// 解析 audioId
|
||||
- (NSString *)p_parseAudioIdFromJSON:(NSDictionary *)json {
|
||||
if (![json isKindOfClass:[NSDictionary class]]) return nil;
|
||||
|
||||
id dataObj = json[@"data"];
|
||||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||
NSDictionary *data = (NSDictionary *)dataObj;
|
||||
NSString *audioId = data[@"audioId"];
|
||||
if ([audioId isKindOfClass:[NSString class]] && audioId.length > 0) {
|
||||
return audioId;
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容其他字段名
|
||||
NSArray *keys = @[@"audioId", @"audio_id"];
|
||||
for (NSString *key in keys) {
|
||||
id value = json[key];
|
||||
if ([value isKindOfClass:[NSString class]] && ((NSString *)value).length > 0) {
|
||||
return (NSString *)value;
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
/// 解析 audioURL
|
||||
- (NSString *)p_parseAudioURLFromJSON:(NSDictionary *)json {
|
||||
if (![json isKindOfClass:[NSDictionary class]]) return nil;
|
||||
|
||||
id dataObj = json[@"data"];
|
||||
if ([dataObj isKindOfClass:[NSDictionary class]]) {
|
||||
NSDictionary *data = (NSDictionary *)dataObj;
|
||||
id audioUrlObj = data[@"audioUrl"] ?: data[@"url"];
|
||||
if (audioUrlObj && ![audioUrlObj isKindOfClass:[NSNull class]] && [audioUrlObj isKindOfClass:[NSString class]]) {
|
||||
return (NSString *)audioUrlObj;
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
@end
|
||||
40
CustomKeyboard/View/Chat/KBChatAssistantCell.h
Normal file
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// KBChatAssistantCell.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// AI 消息 Cell(左侧显示,带语音按钮和打字机效果)
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
@class KBChatMessage;
|
||||
@class KBChatAssistantCell;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@protocol KBChatAssistantCellDelegate <NSObject>
|
||||
@optional
|
||||
/// 点击语音播放按钮
|
||||
- (void)assistantCell:(KBChatAssistantCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message;
|
||||
@end
|
||||
|
||||
@interface KBChatAssistantCell : UITableViewCell
|
||||
|
||||
@property (nonatomic, weak) id<KBChatAssistantCellDelegate> delegate;
|
||||
|
||||
- (void)configureWithMessage:(KBChatMessage *)message;
|
||||
|
||||
/// 更新语音播放状态
|
||||
- (void)updateVoicePlayingState:(BOOL)isPlaying;
|
||||
|
||||
/// 显示语音加载动画
|
||||
- (void)showVoiceLoadingAnimation;
|
||||
|
||||
/// 隐藏语音加载动画
|
||||
- (void)hideVoiceLoadingAnimation;
|
||||
|
||||
/// 停止打字机效果
|
||||
- (void)stopTypewriterEffect;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
346
CustomKeyboard/View/Chat/KBChatAssistantCell.m
Normal file
@@ -0,0 +1,346 @@
|
||||
//
|
||||
// KBChatAssistantCell.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// AI 消息 Cell(左侧显示,带语音按钮和打字机效果)
|
||||
//
|
||||
|
||||
#import "KBChatAssistantCell.h"
|
||||
#import "KBChatMessage.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@interface KBChatAssistantCell ()
|
||||
|
||||
@property (nonatomic, strong) UIButton *voiceButton;
|
||||
@property (nonatomic, strong) UILabel *durationLabel;
|
||||
@property (nonatomic, strong) UIView *bubbleView;
|
||||
@property (nonatomic, strong) UILabel *messageLabel;
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *voiceLoadingIndicator;
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *messageLoadingIndicator;
|
||||
@property (nonatomic, strong) KBChatMessage *currentMessage;
|
||||
|
||||
/// 打字机效果
|
||||
@property (nonatomic, strong) NSTimer *typewriterTimer;
|
||||
@property (nonatomic, copy) NSString *fullText;
|
||||
@property (nonatomic, assign) NSInteger currentCharIndex;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBChatAssistantCell
|
||||
|
||||
- (instancetype)initWithStyle:(UITableViewCellStyle)style
|
||||
reuseIdentifier:(NSString *)reuseIdentifier {
|
||||
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
||||
if (self) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
self.contentView.backgroundColor = [UIColor clearColor];
|
||||
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
[self setupUI];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setupUI {
|
||||
[self.contentView addSubview:self.voiceButton];
|
||||
[self.contentView addSubview:self.durationLabel];
|
||||
[self.contentView addSubview:self.voiceLoadingIndicator];
|
||||
[self.contentView addSubview:self.messageLoadingIndicator];
|
||||
[self.contentView addSubview:self.bubbleView];
|
||||
[self.bubbleView addSubview:self.messageLabel];
|
||||
|
||||
// 语音按钮
|
||||
[self.voiceButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.contentView).offset(12);
|
||||
make.top.equalTo(self.contentView).offset(6);
|
||||
make.width.height.mas_equalTo(20);
|
||||
}];
|
||||
|
||||
// 语音时长
|
||||
[self.durationLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.voiceButton.mas_right).offset(4);
|
||||
make.centerY.equalTo(self.voiceButton);
|
||||
}];
|
||||
|
||||
// 语音加载指示器
|
||||
[self.voiceLoadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self.voiceButton);
|
||||
}];
|
||||
|
||||
// 消息加载指示器
|
||||
[self.messageLoadingIndicator mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.contentView).offset(12);
|
||||
make.top.equalTo(self.voiceButton);
|
||||
}];
|
||||
|
||||
// 气泡
|
||||
[self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.voiceButton.mas_bottom).offset(4);
|
||||
make.bottom.equalTo(self.contentView).offset(-4);
|
||||
make.left.equalTo(self.contentView).offset(12);
|
||||
make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.7);
|
||||
}];
|
||||
|
||||
// 消息文本
|
||||
[self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.bubbleView).offset(8);
|
||||
make.bottom.equalTo(self.bubbleView).offset(-8);
|
||||
make.left.equalTo(self.bubbleView).offset(12);
|
||||
make.right.equalTo(self.bubbleView).offset(-12);
|
||||
make.height.greaterThanOrEqualTo(@18);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)configureWithMessage:(KBChatMessage *)message {
|
||||
NSLog(@"[KBChatAssistantCell] ========== configureWithMessage ==========");
|
||||
NSLog(@"[KBChatAssistantCell] text: %@", message.text);
|
||||
NSLog(@"[KBChatAssistantCell] outgoing: %d, isLoading: %d, isComplete: %d, needsTypewriter: %d",
|
||||
message.outgoing, message.isLoading, message.isComplete, message.needsTypewriterEffect);
|
||||
|
||||
// 先停止之前的打字机效果
|
||||
[self stopTypewriterEffect];
|
||||
|
||||
self.currentMessage = message;
|
||||
|
||||
// 处理 loading 状态
|
||||
if (message.isLoading) {
|
||||
NSLog(@"[KBChatAssistantCell] 显示 loading 状态");
|
||||
self.messageLabel.attributedText = nil;
|
||||
self.messageLabel.text = @"";
|
||||
self.bubbleView.hidden = YES;
|
||||
self.voiceButton.hidden = YES;
|
||||
self.durationLabel.hidden = YES;
|
||||
[self.messageLoadingIndicator startAnimating];
|
||||
return;
|
||||
}
|
||||
|
||||
// 非 loading 状态
|
||||
[self.messageLoadingIndicator stopAnimating];
|
||||
self.bubbleView.hidden = NO;
|
||||
|
||||
// 语音按钮显示逻辑
|
||||
BOOL hasAudio = (message.audioId.length > 0) || (message.audioData.length > 0);
|
||||
self.voiceButton.hidden = !hasAudio;
|
||||
self.durationLabel.hidden = !hasAudio;
|
||||
NSLog(@"[KBChatAssistantCell] hasAudio: %d, audioId: %@", hasAudio, message.audioId);
|
||||
|
||||
// 语音时长
|
||||
if (message.audioDuration > 0) {
|
||||
NSInteger seconds = (NSInteger)ceil(message.audioDuration);
|
||||
self.durationLabel.text = [NSString stringWithFormat:@"%ld\"", (long)seconds];
|
||||
} else {
|
||||
self.durationLabel.text = @"";
|
||||
}
|
||||
|
||||
// 打字机效果
|
||||
if (message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) {
|
||||
NSLog(@"[KBChatAssistantCell] ✅ 启动打字机效果");
|
||||
[self startTypewriterEffectWithText:message.text];
|
||||
} else {
|
||||
NSLog(@"[KBChatAssistantCell] 直接显示文本(不使用打字机)");
|
||||
self.messageLabel.attributedText = nil;
|
||||
self.messageLabel.text = message.text ?: @"";
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Typewriter Effect
|
||||
|
||||
- (void)startTypewriterEffectWithText:(NSString *)text {
|
||||
if (text.length == 0) return;
|
||||
|
||||
self.fullText = text;
|
||||
self.currentCharIndex = 0;
|
||||
|
||||
// 先设置完整文本让布局计算正确高度
|
||||
self.messageLabel.text = text;
|
||||
[self.contentView setNeedsLayout];
|
||||
[self.contentView layoutIfNeeded];
|
||||
|
||||
// 应用打字机效果
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||
value:[UIColor clearColor]
|
||||
range:NSMakeRange(0, text.length)];
|
||||
[attributedText addAttribute:NSFontAttributeName
|
||||
value:self.messageLabel.font
|
||||
range:NSMakeRange(0, text.length)];
|
||||
self.messageLabel.attributedText = attributedText;
|
||||
|
||||
self.typewriterTimer = [NSTimer scheduledTimerWithTimeInterval:0.03
|
||||
target:self
|
||||
selector:@selector(typewriterTick)
|
||||
userInfo:nil
|
||||
repeats:YES];
|
||||
[[NSRunLoop currentRunLoop] addTimer:self.typewriterTimer forMode:NSRunLoopCommonModes];
|
||||
[self typewriterTick];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)typewriterTick {
|
||||
NSString *text = self.fullText;
|
||||
if (!text || text.length == 0) {
|
||||
[self stopTypewriterEffect];
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.currentCharIndex < text.length) {
|
||||
self.currentCharIndex++;
|
||||
|
||||
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||
UIColor *textColor = [UIColor whiteColor];
|
||||
|
||||
if (self.currentCharIndex > 0) {
|
||||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||
value:textColor
|
||||
range:NSMakeRange(0, self.currentCharIndex)];
|
||||
}
|
||||
if (self.currentCharIndex < text.length) {
|
||||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||
value:[UIColor clearColor]
|
||||
range:NSMakeRange(self.currentCharIndex, text.length - self.currentCharIndex)];
|
||||
}
|
||||
[attributedText addAttribute:NSFontAttributeName
|
||||
value:self.messageLabel.font
|
||||
range:NSMakeRange(0, text.length)];
|
||||
|
||||
self.messageLabel.attributedText = attributedText;
|
||||
} else {
|
||||
[self stopTypewriterEffect];
|
||||
|
||||
// 显示完整文本
|
||||
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||
value:[UIColor whiteColor]
|
||||
range:NSMakeRange(0, text.length)];
|
||||
[attributedText addAttribute:NSFontAttributeName
|
||||
value:self.messageLabel.font
|
||||
range:NSMakeRange(0, text.length)];
|
||||
self.messageLabel.attributedText = attributedText;
|
||||
|
||||
// 标记完成
|
||||
if (self.currentMessage) {
|
||||
self.currentMessage.isComplete = YES;
|
||||
self.currentMessage.needsTypewriterEffect = NO;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)stopTypewriterEffect {
|
||||
if (self.typewriterTimer && self.typewriterTimer.isValid) {
|
||||
[self.typewriterTimer invalidate];
|
||||
}
|
||||
self.typewriterTimer = nil;
|
||||
self.currentCharIndex = 0;
|
||||
self.fullText = nil;
|
||||
}
|
||||
|
||||
#pragma mark - Voice Button
|
||||
|
||||
- (void)updateVoicePlayingState:(BOOL)isPlaying {
|
||||
UIImage *icon = nil;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
icon = isPlaying ? [UIImage systemImageNamed:@"pause.circle.fill"] : [UIImage systemImageNamed:@"play.circle.fill"];
|
||||
}
|
||||
[self.voiceButton setImage:icon forState:UIControlStateNormal];
|
||||
}
|
||||
|
||||
- (void)showVoiceLoadingAnimation {
|
||||
[self.voiceButton setImage:nil forState:UIControlStateNormal];
|
||||
[self.voiceLoadingIndicator startAnimating];
|
||||
}
|
||||
|
||||
- (void)hideVoiceLoadingAnimation {
|
||||
[self.voiceLoadingIndicator stopAnimating];
|
||||
UIImage *icon = nil;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
icon = [UIImage systemImageNamed:@"play.circle.fill"];
|
||||
}
|
||||
[self.voiceButton setImage:icon forState:UIControlStateNormal];
|
||||
}
|
||||
|
||||
- (void)voiceButtonTapped {
|
||||
if ([self.delegate respondsToSelector:@selector(assistantCell:didTapVoiceButtonForMessage:)]) {
|
||||
[self.delegate assistantCell:self didTapVoiceButtonForMessage:self.currentMessage];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Reuse
|
||||
|
||||
- (void)prepareForReuse {
|
||||
[super prepareForReuse];
|
||||
[self stopTypewriterEffect];
|
||||
self.messageLabel.text = @"";
|
||||
self.messageLabel.attributedText = nil;
|
||||
[self.messageLoadingIndicator stopAnimating];
|
||||
[self.voiceLoadingIndicator stopAnimating];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self stopTypewriterEffect];
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIButton *)voiceButton {
|
||||
if (!_voiceButton) {
|
||||
_voiceButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
UIImage *icon = nil;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
icon = [UIImage systemImageNamed:@"play.circle.fill"];
|
||||
}
|
||||
[_voiceButton setImage:icon forState:UIControlStateNormal];
|
||||
_voiceButton.tintColor = [UIColor whiteColor];
|
||||
[_voiceButton addTarget:self action:@selector(voiceButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
return _voiceButton;
|
||||
}
|
||||
|
||||
- (UILabel *)durationLabel {
|
||||
if (!_durationLabel) {
|
||||
_durationLabel = [[UILabel alloc] init];
|
||||
_durationLabel.font = [UIFont systemFontOfSize:11];
|
||||
_durationLabel.textColor = [UIColor whiteColor];
|
||||
}
|
||||
return _durationLabel;
|
||||
}
|
||||
|
||||
- (UIActivityIndicatorView *)voiceLoadingIndicator {
|
||||
if (!_voiceLoadingIndicator) {
|
||||
_voiceLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||
_voiceLoadingIndicator.color = [UIColor whiteColor];
|
||||
_voiceLoadingIndicator.hidesWhenStopped = YES;
|
||||
}
|
||||
return _voiceLoadingIndicator;
|
||||
}
|
||||
|
||||
- (UIActivityIndicatorView *)messageLoadingIndicator {
|
||||
if (!_messageLoadingIndicator) {
|
||||
_messageLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||
_messageLoadingIndicator.color = [UIColor whiteColor];
|
||||
_messageLoadingIndicator.hidesWhenStopped = YES;
|
||||
}
|
||||
return _messageLoadingIndicator;
|
||||
}
|
||||
|
||||
- (UIView *)bubbleView {
|
||||
if (!_bubbleView) {
|
||||
_bubbleView = [[UIView alloc] init];
|
||||
_bubbleView.backgroundColor = [UIColor colorWithRed:0.2 green:0.2 blue:0.2 alpha:0.7];
|
||||
_bubbleView.layer.cornerRadius = 12;
|
||||
_bubbleView.layer.masksToBounds = YES;
|
||||
}
|
||||
return _bubbleView;
|
||||
}
|
||||
|
||||
- (UILabel *)messageLabel {
|
||||
if (!_messageLabel) {
|
||||
_messageLabel = [[UILabel alloc] init];
|
||||
_messageLabel.numberOfLines = 0;
|
||||
_messageLabel.font = [UIFont systemFontOfSize:14];
|
||||
_messageLabel.textColor = [UIColor whiteColor];
|
||||
_messageLabel.lineBreakMode = NSLineBreakByWordWrapping;
|
||||
}
|
||||
return _messageLabel;
|
||||
}
|
||||
|
||||
@end
|
||||
49
CustomKeyboard/View/Chat/KBChatPanelView.h
Normal file
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// KBChatPanelView.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
@class KBChatPanelView, KBChatMessage;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@protocol KBChatPanelViewDelegate <NSObject>
|
||||
@optional
|
||||
- (void)chatPanelView:(KBChatPanelView *)view didSendText:(NSString *)text;
|
||||
- (void)chatPanelView:(KBChatPanelView *)view didTapMessage:(KBChatMessage *)message;
|
||||
- (void)chatPanelViewDidTapClose:(KBChatPanelView *)view;
|
||||
/// 点击语音播放按钮
|
||||
- (void)chatPanelView:(KBChatPanelView *)view didTapVoiceButtonForMessage:(KBChatMessage *)message;
|
||||
@end
|
||||
|
||||
@interface KBChatPanelView : UIView
|
||||
|
||||
@property (nonatomic, weak) id<KBChatPanelViewDelegate> delegate;
|
||||
|
||||
@property (nonatomic, strong, readonly) UITableView *tableView;
|
||||
|
||||
//- (void)kb_setBackgroundImage:(nullable UIImage *)image;
|
||||
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)messages;
|
||||
|
||||
/// 添加用户消息
|
||||
- (void)kb_addUserMessage:(NSString *)text;
|
||||
|
||||
/// 添加 loading 状态的 AI 消息
|
||||
- (void)kb_addLoadingAssistantMessage;
|
||||
|
||||
/// 移除 loading 状态的 AI 消息
|
||||
- (void)kb_removeLoadingAssistantMessage;
|
||||
|
||||
/// 添加 AI 消息(带打字机效果)
|
||||
- (void)kb_addAssistantMessage:(NSString *)text audioId:(nullable NSString *)audioId;
|
||||
|
||||
/// 更新最后一条 AI 消息的音频数据
|
||||
- (void)kb_updateLastAssistantMessageWithAudioData:(NSData *)audioData duration:(NSTimeInterval)duration;
|
||||
|
||||
/// 滚动到底部
|
||||
- (void)kb_scrollToBottom;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
348
CustomKeyboard/View/Chat/KBChatPanelView.m
Normal file
@@ -0,0 +1,348 @@
|
||||
//
|
||||
// KBChatPanelView.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBChatPanelView.h"
|
||||
#import "KBChatMessage.h"
|
||||
#import "KBChatUserCell.h"
|
||||
#import "KBChatAssistantCell.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
static NSString * const kUserCellIdentifier = @"KBChatUserCell";
|
||||
static NSString * const kAssistantCellIdentifier = @"KBChatAssistantCell";
|
||||
static const NSUInteger kKBChatMessageLimit = 10;
|
||||
|
||||
@interface KBChatPanelView () <UITableViewDataSource, UITableViewDelegate, KBChatAssistantCellDelegate>
|
||||
@property (nonatomic, strong) UIView *headerView;
|
||||
@property (nonatomic, strong) UILabel *titleLabel;
|
||||
@property (nonatomic, strong) UIButton *closeButton;
|
||||
@property (nonatomic, strong) UITableView *tableViewInternal;
|
||||
@property (nonatomic, strong) NSMutableArray<KBChatMessage *> *messages;
|
||||
@end
|
||||
|
||||
@implementation KBChatPanelView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
self.messages = [NSMutableArray array];
|
||||
|
||||
[self addSubview:self.headerView];
|
||||
[self addSubview:self.tableViewInternal];
|
||||
|
||||
[self.headerView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self);
|
||||
make.top.equalTo(self.mas_top);
|
||||
make.height.mas_equalTo(KBFit(36.0f));
|
||||
}];
|
||||
|
||||
[self.tableViewInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self);
|
||||
make.top.equalTo(self.headerView.mas_bottom).offset(4);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(-8);
|
||||
}];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
#pragma mark - Public
|
||||
|
||||
- (void)kb_reloadWithMessages:(NSArray<KBChatMessage *> *)messages {
|
||||
NSLog(@"[Panel] ⚠️ kb_reloadWithMessages 被调用,传入 %lu 条消息", (unsigned long)messages.count);
|
||||
|
||||
[self.messages removeAllObjects];
|
||||
if (messages.count > 0) {
|
||||
[self.messages addObjectsFromArray:messages];
|
||||
}
|
||||
[self.tableViewInternal reloadData];
|
||||
[self kb_scrollToBottom];
|
||||
}
|
||||
|
||||
- (void)kb_addUserMessage:(NSString *)text {
|
||||
if (text.length == 0) return;
|
||||
|
||||
NSLog(@"[Panel] 添加用户消息: %@,当前消息数: %lu", text, (unsigned long)self.messages.count);
|
||||
|
||||
KBChatMessage *msg = [KBChatMessage userMessageWithText:text];
|
||||
[self kb_appendMessage:msg];
|
||||
|
||||
NSLog(@"[Panel] 添加后消息数: %lu", (unsigned long)self.messages.count);
|
||||
}
|
||||
|
||||
- (void)kb_addLoadingAssistantMessage {
|
||||
NSLog(@"[Panel] 添加 loading 消息,当前消息数: %lu", (unsigned long)self.messages.count);
|
||||
|
||||
KBChatMessage *msg = [KBChatMessage loadingAssistantMessage];
|
||||
[self kb_appendMessage:msg];
|
||||
|
||||
NSLog(@"[Panel] 添加后消息数: %lu", (unsigned long)self.messages.count);
|
||||
}
|
||||
|
||||
- (void)kb_removeLoadingAssistantMessage {
|
||||
NSLog(@"[Panel] 移除 loading 消息,当前消息数: %lu", (unsigned long)self.messages.count);
|
||||
|
||||
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||
KBChatMessage *msg = self.messages[i];
|
||||
// 只移除 AI 消息(outgoing == NO)且是 loading 状态的
|
||||
if (!msg.outgoing && msg.isLoading) {
|
||||
NSLog(@"[Panel] ✅ 找到 loading 消息,移除索引: %ld", (long)i);
|
||||
[self.messages removeObjectAtIndex:i];
|
||||
|
||||
// 使用 beginUpdates/endUpdates 包裹删除操作
|
||||
[self.tableViewInternal beginUpdates];
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:i inSection:0];
|
||||
[self.tableViewInternal deleteRowsAtIndexPaths:@[indexPath]
|
||||
withRowAnimation:UITableViewRowAnimationNone];
|
||||
[self.tableViewInternal endUpdates];
|
||||
|
||||
NSLog(@"[Panel] 移除后消息数: %lu", (unsigned long)self.messages.count);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_addAssistantMessage:(NSString *)text audioId:(NSString *)audioId {
|
||||
NSLog(@"[Panel] ========== kb_addAssistantMessage ==========");
|
||||
NSLog(@"[Panel] 当前消息数: %lu", (unsigned long)self.messages.count);
|
||||
|
||||
// 查找 loading 消息的索引
|
||||
NSInteger loadingIndex = -1;
|
||||
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||
KBChatMessage *msg = self.messages[i];
|
||||
if (!msg.outgoing && msg.isLoading) {
|
||||
loadingIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建 AI 消息
|
||||
KBChatMessage *msg = [KBChatMessage assistantMessageWithText:text audioId:audioId];
|
||||
msg.displayName = KBLocalized(@"AI助手");
|
||||
NSLog(@"[Panel] 创建 AI 消息,needsTypewriter: %d", msg.needsTypewriterEffect);
|
||||
|
||||
// 使用批量更新,避免界面跳动
|
||||
[self.tableViewInternal beginUpdates];
|
||||
|
||||
if (loadingIndex >= 0) {
|
||||
// 移除 loading 消息
|
||||
NSLog(@"[Panel] 移除 loading 索引: %ld", (long)loadingIndex);
|
||||
[self.messages removeObjectAtIndex:loadingIndex];
|
||||
NSIndexPath *deleteIndexPath = [NSIndexPath indexPathForRow:loadingIndex inSection:0];
|
||||
[self.tableViewInternal deleteRowsAtIndexPaths:@[deleteIndexPath]
|
||||
withRowAnimation:UITableViewRowAnimationNone];
|
||||
}
|
||||
|
||||
// 添加 AI 消息
|
||||
NSInteger insertIndex = self.messages.count;
|
||||
[self.messages addObject:msg];
|
||||
NSLog(@"[Panel] 插入 AI 消息索引: %ld", (long)insertIndex);
|
||||
NSIndexPath *insertIndexPath = [NSIndexPath indexPathForRow:insertIndex inSection:0];
|
||||
[self.tableViewInternal insertRowsAtIndexPaths:@[insertIndexPath]
|
||||
withRowAnimation:UITableViewRowAnimationNone];
|
||||
|
||||
[self.tableViewInternal endUpdates];
|
||||
|
||||
// 滚动到底部
|
||||
[self kb_scrollToBottom];
|
||||
|
||||
NSLog(@"[Panel] 添加后消息数: %lu", (unsigned long)self.messages.count);
|
||||
}
|
||||
|
||||
- (void)kb_updateLastAssistantMessageWithAudioData:(NSData *)audioData duration:(NSTimeInterval)duration {
|
||||
NSLog(@"[Panel] 更新音频数据,duration: %.2f", duration);
|
||||
for (NSInteger i = self.messages.count - 1; i >= 0; i--) {
|
||||
KBChatMessage *msg = self.messages[i];
|
||||
// 只更新 AI 消息(outgoing == NO)且非 loading 状态的
|
||||
if (!msg.outgoing && !msg.isLoading) {
|
||||
msg.audioData = audioData;
|
||||
msg.audioDuration = duration;
|
||||
|
||||
// 不刷新 Cell,避免打断打字机效果
|
||||
if (duration > 0) {
|
||||
msg.needsTypewriterEffect = NO;
|
||||
msg.isComplete = YES;
|
||||
}
|
||||
NSLog(@"[Panel] ✅ 音频数据已更新");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_scrollToBottom {
|
||||
if (self.messages.count == 0) return;
|
||||
|
||||
NSLog(@"[Panel] 滚动到底部,消息数: %lu", (unsigned long)self.messages.count);
|
||||
|
||||
[self.tableViewInternal layoutIfNeeded];
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:self.messages.count - 1 inSection:0];
|
||||
[self.tableViewInternal scrollToRowAtIndexPath:indexPath
|
||||
atScrollPosition:UITableViewScrollPositionBottom
|
||||
animated:NO]; // 改为 NO,避免动画导致跳动
|
||||
}
|
||||
|
||||
#pragma mark - Private
|
||||
|
||||
- (void)kb_appendMessage:(KBChatMessage *)message {
|
||||
if (!message) return;
|
||||
|
||||
NSInteger oldCount = self.messages.count;
|
||||
[self.messages addObject:message];
|
||||
NSLog(@"[Panel] kb_appendMessage: oldCount=%ld, newCount=%lu", (long)oldCount, (unsigned long)self.messages.count);
|
||||
|
||||
// 限制消息数量
|
||||
if (self.messages.count > kKBChatMessageLimit) {
|
||||
NSUInteger overflow = self.messages.count - kKBChatMessageLimit;
|
||||
[self.messages removeObjectsInRange:NSMakeRange(0, overflow)];
|
||||
NSLog(@"[Panel] 消息超限,reloadData");
|
||||
[self.tableViewInternal reloadData];
|
||||
} else {
|
||||
NSLog(@"[Panel] 插入新行: %ld", (long)oldCount);
|
||||
[self.tableViewInternal beginUpdates];
|
||||
NSIndexPath *indexPath = [NSIndexPath indexPathForRow:oldCount inSection:0];
|
||||
[self.tableViewInternal insertRowsAtIndexPaths:@[indexPath]
|
||||
withRowAnimation:UITableViewRowAnimationNone];
|
||||
[self.tableViewInternal endUpdates];
|
||||
}
|
||||
|
||||
// 直接滚动,不用 dispatch_async
|
||||
[self kb_scrollToBottom];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)kb_onTapClose {
|
||||
if ([self.delegate respondsToSelector:@selector(chatPanelViewDidTapClose:)]) {
|
||||
[self.delegate chatPanelViewDidTapClose:self];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - UITableViewDataSource
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
|
||||
return self.messages.count;
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
if (indexPath.row >= self.messages.count) {
|
||||
NSLog(@"[Panel] ❌ cellForRow 索引越界: %ld >= %lu", (long)indexPath.row, (unsigned long)self.messages.count);
|
||||
return [[UITableViewCell alloc] init];
|
||||
}
|
||||
|
||||
KBChatMessage *msg = self.messages[indexPath.row];
|
||||
NSLog(@"[Panel] cellForRow[%ld]: outgoing=%d, isLoading=%d", (long)indexPath.row, msg.outgoing, msg.isLoading);
|
||||
|
||||
if (msg.outgoing) {
|
||||
// 用户消息(右侧)
|
||||
KBChatUserCell *cell = [tableView dequeueReusableCellWithIdentifier:kUserCellIdentifier forIndexPath:indexPath];
|
||||
[cell configureWithMessage:msg];
|
||||
return cell;
|
||||
} else {
|
||||
// AI 消息(左侧)
|
||||
KBChatAssistantCell *cell = [tableView dequeueReusableCellWithIdentifier:kAssistantCellIdentifier forIndexPath:indexPath];
|
||||
cell.delegate = self;
|
||||
[cell configureWithMessage:msg];
|
||||
return cell;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - UITableViewDelegate
|
||||
|
||||
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
return UITableViewAutomaticDimension;
|
||||
}
|
||||
|
||||
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
return 60.0;
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
if (indexPath.row >= self.messages.count) { return; }
|
||||
KBChatMessage *msg = self.messages[indexPath.row];
|
||||
if ([self.delegate respondsToSelector:@selector(chatPanelView:didTapMessage:)]) {
|
||||
[self.delegate chatPanelView:self didTapMessage:msg];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - KBChatAssistantCellDelegate
|
||||
|
||||
- (void)assistantCell:(KBChatAssistantCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message {
|
||||
if ([self.delegate respondsToSelector:@selector(chatPanelView:didTapVoiceButtonForMessage:)]) {
|
||||
[self.delegate chatPanelView:self didTapVoiceButtonForMessage:message];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UITableView *)tableViewInternal {
|
||||
if (!_tableViewInternal) {
|
||||
_tableViewInternal = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
|
||||
_tableViewInternal.backgroundColor = [UIColor clearColor];
|
||||
_tableViewInternal.backgroundView = nil;
|
||||
_tableViewInternal.separatorStyle = UITableViewCellSeparatorStyleNone;
|
||||
_tableViewInternal.dataSource = self;
|
||||
_tableViewInternal.delegate = self;
|
||||
_tableViewInternal.estimatedRowHeight = 60.0;
|
||||
_tableViewInternal.rowHeight = UITableViewAutomaticDimension;
|
||||
// 注册两种 Cell
|
||||
[_tableViewInternal registerClass:KBChatUserCell.class forCellReuseIdentifier:kUserCellIdentifier];
|
||||
[_tableViewInternal registerClass:KBChatAssistantCell.class forCellReuseIdentifier:kAssistantCellIdentifier];
|
||||
if (@available(iOS 11.0, *)) {
|
||||
_tableViewInternal.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
|
||||
}
|
||||
}
|
||||
return _tableViewInternal;
|
||||
}
|
||||
|
||||
- (UIView *)headerView {
|
||||
if (!_headerView) {
|
||||
_headerView = [[UIView alloc] init];
|
||||
_headerView.backgroundColor = [UIColor clearColor];
|
||||
[_headerView addSubview:self.titleLabel];
|
||||
[_headerView addSubview:self.closeButton];
|
||||
|
||||
[self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(_headerView.mas_left).offset(12);
|
||||
make.centerY.equalTo(_headerView);
|
||||
}];
|
||||
|
||||
[self.closeButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(_headerView.mas_right).offset(-12);
|
||||
make.centerY.equalTo(_headerView);
|
||||
make.width.height.mas_equalTo(KBFit(24.0f));
|
||||
}];
|
||||
}
|
||||
return _headerView;
|
||||
}
|
||||
|
||||
- (UILabel *)titleLabel {
|
||||
if (!_titleLabel) {
|
||||
_titleLabel = [[UILabel alloc] init];
|
||||
_titleLabel.hidden = true;
|
||||
_titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightMedium];
|
||||
_titleLabel.textColor =
|
||||
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x1B1F1A]
|
||||
darkColor:[UIColor whiteColor]];
|
||||
_titleLabel.text = KBLocalized(@"AI对话");
|
||||
}
|
||||
return _titleLabel;
|
||||
}
|
||||
|
||||
- (UIButton *)closeButton {
|
||||
if (!_closeButton) {
|
||||
_closeButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
UIImage *icon = [UIImage imageNamed:@"close_icon"];
|
||||
[_closeButton setImage:icon forState:UIControlStateNormal];
|
||||
_closeButton.backgroundColor = [UIColor clearColor];
|
||||
[_closeButton addTarget:self
|
||||
action:@selector(kb_onTapClose)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
return _closeButton;
|
||||
}
|
||||
|
||||
#pragma mark - Expose
|
||||
|
||||
- (UITableView *)tableView { return self.tableViewInternal; }
|
||||
|
||||
@end
|
||||
19
CustomKeyboard/View/Chat/KBChatUserCell.h
Normal file
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// KBChatUserCell.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
// 用户消息 Cell(右侧显示)
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
@class KBChatMessage;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface KBChatUserCell : UITableViewCell
|
||||
|
||||
- (void)configureWithMessage:(KBChatMessage *)message;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
85
CustomKeyboard/View/Chat/KBChatUserCell.m
Normal file
@@ -0,0 +1,85 @@
|
||||
//
|
||||
// KBChatUserCell.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
// 用户消息 Cell(右侧显示)
|
||||
//
|
||||
|
||||
#import "KBChatUserCell.h"
|
||||
#import "KBChatMessage.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@interface KBChatUserCell ()
|
||||
|
||||
@property (nonatomic, strong) UIView *bubbleView;
|
||||
@property (nonatomic, strong) UILabel *messageLabel;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBChatUserCell
|
||||
|
||||
- (instancetype)initWithStyle:(UITableViewCellStyle)style
|
||||
reuseIdentifier:(NSString *)reuseIdentifier {
|
||||
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
||||
if (self) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
self.contentView.backgroundColor = [UIColor clearColor];
|
||||
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
[self setupUI];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setupUI {
|
||||
[self.contentView addSubview:self.bubbleView];
|
||||
[self.bubbleView addSubview:self.messageLabel];
|
||||
|
||||
[self.bubbleView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.contentView).offset(4);
|
||||
make.bottom.equalTo(self.contentView).offset(-4);
|
||||
make.right.equalTo(self.contentView).offset(-12);
|
||||
make.width.lessThanOrEqualTo(self.contentView).multipliedBy(0.7);
|
||||
make.height.greaterThanOrEqualTo(@36);
|
||||
}];
|
||||
|
||||
[self.messageLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.bubbleView).offset(8);
|
||||
make.bottom.equalTo(self.bubbleView).offset(-8);
|
||||
make.left.equalTo(self.bubbleView).offset(12);
|
||||
make.right.equalTo(self.bubbleView).offset(-12);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)configureWithMessage:(KBChatMessage *)message {
|
||||
self.messageLabel.text = message.text ?: @"";
|
||||
}
|
||||
|
||||
- (void)prepareForReuse {
|
||||
[super prepareForReuse];
|
||||
self.messageLabel.text = @"";
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIView *)bubbleView {
|
||||
if (!_bubbleView) {
|
||||
_bubbleView = [[UIView alloc] init];
|
||||
_bubbleView.backgroundColor = [UIColor colorWithHex:0x02BEAC];
|
||||
_bubbleView.layer.cornerRadius = 12;
|
||||
_bubbleView.layer.masksToBounds = YES;
|
||||
}
|
||||
return _bubbleView;
|
||||
}
|
||||
|
||||
- (UILabel *)messageLabel {
|
||||
if (!_messageLabel) {
|
||||
_messageLabel = [[UILabel alloc] init];
|
||||
_messageLabel.numberOfLines = 0;
|
||||
_messageLabel.font = [UIFont systemFontOfSize:14];
|
||||
_messageLabel.textColor = [UIColor whiteColor];
|
||||
_messageLabel.lineBreakMode = NSLineBreakByWordWrapping;
|
||||
}
|
||||
return _messageLabel;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#import "KBFunctionTagListView.h"
|
||||
#import "KBFunctionTagCell.h"
|
||||
#import "KBMaiPointReporter.h"
|
||||
|
||||
static NSString * const kKBFunctionTagCellId2 = @"KBFunctionTagCellId2";
|
||||
static CGFloat const kKBItemSpace = 4;
|
||||
@@ -66,8 +67,23 @@ static CGFloat const kKBItemSpace = 4;
|
||||
- (CGFloat)collectionView:(UICollectionView *)collectionView layout:(UICollectionViewLayout*)collectionViewLayout minimumLineSpacingForSectionAtIndex:(NSInteger)section { return kKBItemSpace; }
|
||||
|
||||
- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
|
||||
KBTagItemModel *model = (indexPath.item < self.items.count) ? self.items[indexPath.item] : [KBTagItemModel new];
|
||||
NSInteger personaId = 0;
|
||||
if ([model isKindOfClass:KBTagItemModel.class]) {
|
||||
personaId = model.characterId > 0 ? model.characterId : model.tagId;
|
||||
}
|
||||
NSMutableDictionary *extra = [NSMutableDictionary dictionary];
|
||||
extra[@"index"] = @(indexPath.item);
|
||||
extra[@"id"] = @(personaId);
|
||||
if ([model.characterName isKindOfClass:NSString.class] && model.characterName.length > 0) {
|
||||
extra[@"name"] = model.characterName;
|
||||
}
|
||||
[[KBMaiPointReporter sharedReporter] reportClickWithEventName:@"click_keyboard_function_tag_item"
|
||||
pageId:@"keyboard_function_panel"
|
||||
elementId:@"renshe_item"
|
||||
extra:extra.copy
|
||||
completion:nil];
|
||||
if ([self.delegate respondsToSelector:@selector(tagListView:didSelectIndex:title:)]) {
|
||||
KBTagItemModel *model = (indexPath.item < self.items.count) ? self.items[indexPath.item] : [KBTagItemModel new];
|
||||
[self.delegate tagListView:self didSelectIndex:indexPath.item title:model.characterName];
|
||||
}
|
||||
}
|
||||
|
||||
38
CustomKeyboard/View/KBChatMessageCell.h
Normal file
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// KBChatMessageCell.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
@class KBChatMessage;
|
||||
@class KBChatMessageCell;
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@protocol KBChatMessageCellDelegate <NSObject>
|
||||
@optional
|
||||
/// 点击语音播放按钮
|
||||
- (void)chatMessageCell:(KBChatMessageCell *)cell didTapVoiceButtonForMessage:(KBChatMessage *)message;
|
||||
@end
|
||||
|
||||
@interface KBChatMessageCell : UITableViewCell
|
||||
|
||||
@property (nonatomic, weak) id<KBChatMessageCellDelegate> delegate;
|
||||
|
||||
- (void)kb_configureWithMessage:(KBChatMessage *)message;
|
||||
|
||||
/// 更新语音播放状态
|
||||
- (void)kb_updateVoicePlayingState:(BOOL)isPlaying;
|
||||
|
||||
/// 显示语音加载动画
|
||||
- (void)kb_showVoiceLoadingAnimation;
|
||||
|
||||
/// 隐藏语音加载动画
|
||||
- (void)kb_hideVoiceLoadingAnimation;
|
||||
|
||||
/// 停止打字机效果
|
||||
- (void)kb_stopTypewriterEffect;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
495
CustomKeyboard/View/KBChatMessageCell.m
Normal file
@@ -0,0 +1,495 @@
|
||||
//
|
||||
// KBChatMessageCell.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBChatMessageCell.h"
|
||||
#import "KBChatMessage.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@interface KBChatMessageCell ()
|
||||
|
||||
@property (nonatomic, strong) UIImageView *avatarView;
|
||||
@property (nonatomic, strong) UILabel *nameLabel;
|
||||
@property (nonatomic, strong) UIView *bubbleView;
|
||||
@property (nonatomic, strong) UILabel *messageLabel;
|
||||
@property (nonatomic, strong) UIImageView *audioIconView;
|
||||
@property (nonatomic, strong) UILabel *audioLabel;
|
||||
|
||||
/// 语音播放按钮
|
||||
@property (nonatomic, strong) UIButton *voiceButton;
|
||||
/// 语音时长标签
|
||||
@property (nonatomic, strong) UILabel *durationLabel;
|
||||
/// 语音加载指示器
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *voiceLoadingIndicator;
|
||||
/// 消息加载指示器(AI 回复 loading)
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *messageLoadingIndicator;
|
||||
|
||||
/// 当前消息
|
||||
@property (nonatomic, strong) KBChatMessage *currentMessage;
|
||||
|
||||
/// 打字机效果
|
||||
@property (nonatomic, strong) NSTimer *typewriterTimer;
|
||||
@property (nonatomic, copy) NSString *fullText;
|
||||
@property (nonatomic, assign) NSInteger currentCharIndex;
|
||||
|
||||
@end
|
||||
|
||||
@implementation KBChatMessageCell
|
||||
|
||||
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
|
||||
if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
self.contentView.backgroundColor = [UIColor clearColor];
|
||||
self.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
|
||||
[self.contentView addSubview:self.avatarView];
|
||||
[self.contentView addSubview:self.nameLabel];
|
||||
[self.contentView addSubview:self.voiceButton];
|
||||
[self.contentView addSubview:self.durationLabel];
|
||||
[self.contentView addSubview:self.voiceLoadingIndicator];
|
||||
[self.contentView addSubview:self.messageLoadingIndicator];
|
||||
[self.contentView addSubview:self.bubbleView];
|
||||
[self.bubbleView addSubview:self.messageLabel];
|
||||
[self.bubbleView addSubview:self.audioIconView];
|
||||
[self.bubbleView addSubview:self.audioLabel];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)kb_configureWithMessage:(KBChatMessage *)message {
|
||||
// 先停止之前的打字机效果
|
||||
[self kb_stopTypewriterEffect];
|
||||
|
||||
self.currentMessage = message;
|
||||
|
||||
BOOL outgoing = message.outgoing;
|
||||
BOOL audioMessage = (!outgoing && message.audioFilePath.length > 0);
|
||||
UIColor *bubbleColor = outgoing ? [UIColor colorWithHex:0x02BEAC] : [UIColor colorWithWhite:1 alpha:0.95];
|
||||
UIColor *incomingTextColor =
|
||||
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x1B1F1A]
|
||||
darkColor:[UIColor whiteColor]];
|
||||
UIColor *textColor = outgoing ? [UIColor whiteColor] : incomingTextColor;
|
||||
UIColor *nameColor =
|
||||
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0x6B6F7A]
|
||||
darkColor:[UIColor colorWithHex:0xC7CBD4]];
|
||||
|
||||
self.bubbleView.backgroundColor = bubbleColor;
|
||||
self.messageLabel.textColor = textColor;
|
||||
self.audioLabel.textColor = textColor;
|
||||
self.audioIconView.tintColor = textColor;
|
||||
self.audioLabel.text =
|
||||
(message.text.length > 0) ? message.text : KBLocalized(@"语音回复");
|
||||
self.messageLabel.hidden = audioMessage;
|
||||
self.audioIconView.hidden = !audioMessage;
|
||||
self.audioLabel.hidden = !audioMessage;
|
||||
|
||||
UIImage *avatarImage = message.avatarImage;
|
||||
if (!avatarImage) {
|
||||
avatarImage = [self kb_defaultAvatarImage];
|
||||
}
|
||||
self.avatarView.image = avatarImage;
|
||||
self.avatarView.backgroundColor =
|
||||
avatarImage ? [UIColor clearColor] : [UIColor colorWithWhite:0.9 alpha:1.0];
|
||||
self.nameLabel.hidden = outgoing;
|
||||
self.nameLabel.textColor = nameColor;
|
||||
self.nameLabel.text =
|
||||
(message.displayName.length > 0) ? message.displayName : KBLocalized(@"AI助手");
|
||||
|
||||
// 处理 loading 状态
|
||||
if (message.isLoading && !outgoing) {
|
||||
self.bubbleView.hidden = YES;
|
||||
self.voiceButton.hidden = YES;
|
||||
self.durationLabel.hidden = YES;
|
||||
[self.messageLoadingIndicator startAnimating];
|
||||
[self kb_layoutForOutgoing:outgoing audioMessage:NO];
|
||||
return;
|
||||
}
|
||||
|
||||
// 非 loading 状态
|
||||
[self.messageLoadingIndicator stopAnimating];
|
||||
self.bubbleView.hidden = NO;
|
||||
|
||||
// 语音按钮显示逻辑(仅 AI 消息且有 audioId 或 audioData)
|
||||
BOOL hasAudio = (!outgoing) && (message.audioId.length > 0 || message.audioData.length > 0);
|
||||
self.voiceButton.hidden = !hasAudio;
|
||||
self.durationLabel.hidden = !hasAudio;
|
||||
if (hasAudio && message.audioDuration > 0) {
|
||||
NSInteger seconds = (NSInteger)ceil(message.audioDuration);
|
||||
self.durationLabel.text = [NSString stringWithFormat:@"%ld\"", (long)seconds];
|
||||
} else {
|
||||
self.durationLabel.text = @"";
|
||||
}
|
||||
|
||||
// 打字机效果
|
||||
if (!outgoing && message.needsTypewriterEffect && !message.isComplete && message.text.length > 0) {
|
||||
[self kb_startTypewriterEffectWithText:message.text];
|
||||
} else {
|
||||
self.messageLabel.attributedText = nil;
|
||||
self.messageLabel.text = message.text ?: @"";
|
||||
}
|
||||
|
||||
[self kb_layoutForOutgoing:outgoing audioMessage:audioMessage];
|
||||
}
|
||||
|
||||
- (void)kb_layoutForOutgoing:(BOOL)outgoing audioMessage:(BOOL)audioMessage {
|
||||
CGFloat avatarSize = 28.0;
|
||||
[self.avatarView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.height.mas_equalTo(avatarSize);
|
||||
make.top.equalTo(self.contentView.mas_top).offset(6);
|
||||
if (outgoing) {
|
||||
make.right.equalTo(self.contentView.mas_right).offset(-8);
|
||||
} else {
|
||||
make.left.equalTo(self.contentView.mas_left).offset(8);
|
||||
}
|
||||
}];
|
||||
|
||||
if (outgoing) {
|
||||
[self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.contentView.mas_top).offset(0);
|
||||
make.left.equalTo(self.contentView.mas_left);
|
||||
}];
|
||||
// 用户消息不显示语音按钮
|
||||
[self.voiceButton mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.height.mas_equalTo(0);
|
||||
make.left.top.equalTo(self.contentView);
|
||||
}];
|
||||
[self.durationLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.height.mas_equalTo(0);
|
||||
make.left.top.equalTo(self.contentView);
|
||||
}];
|
||||
} else {
|
||||
[self.nameLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
||||
make.top.equalTo(self.contentView.mas_top).offset(2);
|
||||
make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12);
|
||||
}];
|
||||
// AI 消息语音按钮
|
||||
[self.voiceButton mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
||||
make.top.equalTo(self.nameLabel.mas_bottom).offset(4);
|
||||
make.width.height.mas_equalTo(20);
|
||||
}];
|
||||
[self.durationLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.voiceButton.mas_right).offset(4);
|
||||
make.centerY.equalTo(self.voiceButton);
|
||||
}];
|
||||
[self.voiceLoadingIndicator mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self.voiceButton);
|
||||
}];
|
||||
}
|
||||
|
||||
// 消息加载指示器
|
||||
[self.messageLoadingIndicator mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
if (outgoing) {
|
||||
make.right.equalTo(self.avatarView.mas_left).offset(-10);
|
||||
} else {
|
||||
make.left.equalTo(self.avatarView.mas_right).offset(10);
|
||||
}
|
||||
make.top.equalTo(self.nameLabel.mas_bottom).offset(8);
|
||||
}];
|
||||
|
||||
[self.bubbleView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.lessThanOrEqualTo(self.contentView.mas_width).multipliedBy(0.65);
|
||||
if (outgoing) {
|
||||
make.top.equalTo(self.contentView.mas_top).offset(6);
|
||||
make.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
|
||||
make.right.equalTo(self.avatarView.mas_left).offset(-6);
|
||||
} else {
|
||||
// AI 消息:气泡在语音按钮下方
|
||||
make.top.equalTo(self.voiceButton.mas_bottom).offset(4);
|
||||
make.bottom.equalTo(self.contentView.mas_bottom).offset(-6);
|
||||
make.left.equalTo(self.avatarView.mas_right).offset(6);
|
||||
make.right.lessThanOrEqualTo(self.contentView.mas_right).offset(-12);
|
||||
}
|
||||
}];
|
||||
|
||||
if (audioMessage) {
|
||||
[self.messageLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.height.mas_equalTo(0);
|
||||
make.left.equalTo(self.bubbleView.mas_left);
|
||||
make.top.equalTo(self.bubbleView.mas_top);
|
||||
}];
|
||||
[self.audioIconView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.bubbleView.mas_left).offset(10);
|
||||
make.centerY.equalTo(self.bubbleView);
|
||||
make.width.height.mas_equalTo(16);
|
||||
}];
|
||||
[self.audioLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.audioIconView.mas_right).offset(6);
|
||||
make.centerY.equalTo(self.bubbleView);
|
||||
make.right.equalTo(self.bubbleView.mas_right).offset(-10);
|
||||
make.top.greaterThanOrEqualTo(self.bubbleView.mas_top).offset(8);
|
||||
make.bottom.lessThanOrEqualTo(self.bubbleView.mas_bottom).offset(-8);
|
||||
}];
|
||||
} else {
|
||||
[self.audioIconView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.height.mas_equalTo(0);
|
||||
make.left.equalTo(self.bubbleView.mas_left);
|
||||
make.top.equalTo(self.bubbleView.mas_top);
|
||||
}];
|
||||
[self.audioLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.height.mas_equalTo(0);
|
||||
make.left.equalTo(self.audioIconView.mas_right);
|
||||
make.top.equalTo(self.bubbleView.mas_top);
|
||||
}];
|
||||
[self.messageLabel mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.bubbleView).insets(UIEdgeInsetsMake(8, 10, 8, 10));
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Typewriter Effect
|
||||
|
||||
- (void)kb_startTypewriterEffectWithText:(NSString *)text {
|
||||
if (text.length == 0) return;
|
||||
|
||||
self.fullText = text;
|
||||
self.currentCharIndex = 0;
|
||||
|
||||
// 先设置完整文本让布局计算正确高度
|
||||
self.messageLabel.text = text;
|
||||
[self.contentView setNeedsLayout];
|
||||
[self.contentView layoutIfNeeded];
|
||||
|
||||
// 应用打字机效果
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||
value:[UIColor clearColor]
|
||||
range:NSMakeRange(0, text.length)];
|
||||
[attributedText addAttribute:NSFontAttributeName
|
||||
value:self.messageLabel.font
|
||||
range:NSMakeRange(0, text.length)];
|
||||
self.messageLabel.attributedText = attributedText;
|
||||
|
||||
self.typewriterTimer = [NSTimer scheduledTimerWithTimeInterval:0.03
|
||||
target:self
|
||||
selector:@selector(kb_typewriterTick)
|
||||
userInfo:nil
|
||||
repeats:YES];
|
||||
[[NSRunLoop currentRunLoop] addTimer:self.typewriterTimer forMode:NSRunLoopCommonModes];
|
||||
[self kb_typewriterTick];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)kb_typewriterTick {
|
||||
NSString *text = self.fullText;
|
||||
if (!text || text.length == 0) {
|
||||
[self kb_stopTypewriterEffect];
|
||||
return;
|
||||
}
|
||||
|
||||
if (self.currentCharIndex < text.length) {
|
||||
self.currentCharIndex++;
|
||||
|
||||
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||
UIColor *textColor = self.messageLabel.textColor ?: [UIColor blackColor];
|
||||
|
||||
if (self.currentCharIndex > 0) {
|
||||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||
value:textColor
|
||||
range:NSMakeRange(0, self.currentCharIndex)];
|
||||
}
|
||||
if (self.currentCharIndex < text.length) {
|
||||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||
value:[UIColor clearColor]
|
||||
range:NSMakeRange(self.currentCharIndex, text.length - self.currentCharIndex)];
|
||||
}
|
||||
[attributedText addAttribute:NSFontAttributeName
|
||||
value:self.messageLabel.font
|
||||
range:NSMakeRange(0, text.length)];
|
||||
|
||||
self.messageLabel.attributedText = attributedText;
|
||||
} else {
|
||||
[self kb_stopTypewriterEffect];
|
||||
|
||||
// 显示完整文本
|
||||
NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:text];
|
||||
UIColor *textColor = self.messageLabel.textColor ?: [UIColor blackColor];
|
||||
[attributedText addAttribute:NSForegroundColorAttributeName
|
||||
value:textColor
|
||||
range:NSMakeRange(0, text.length)];
|
||||
[attributedText addAttribute:NSFontAttributeName
|
||||
value:self.messageLabel.font
|
||||
range:NSMakeRange(0, text.length)];
|
||||
self.messageLabel.attributedText = attributedText;
|
||||
|
||||
// 标记完成
|
||||
if (self.currentMessage) {
|
||||
self.currentMessage.isComplete = YES;
|
||||
self.currentMessage.needsTypewriterEffect = NO;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_stopTypewriterEffect {
|
||||
if (self.typewriterTimer && self.typewriterTimer.isValid) {
|
||||
[self.typewriterTimer invalidate];
|
||||
}
|
||||
self.typewriterTimer = nil;
|
||||
self.currentCharIndex = 0;
|
||||
self.fullText = nil;
|
||||
}
|
||||
|
||||
#pragma mark - Voice Button
|
||||
|
||||
- (void)kb_updateVoicePlayingState:(BOOL)isPlaying {
|
||||
UIImage *icon = nil;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
icon = isPlaying ? [UIImage systemImageNamed:@"pause.circle.fill"] : [UIImage systemImageNamed:@"play.circle.fill"];
|
||||
}
|
||||
[self.voiceButton setImage:icon forState:UIControlStateNormal];
|
||||
}
|
||||
|
||||
- (void)kb_showVoiceLoadingAnimation {
|
||||
[self.voiceButton setImage:nil forState:UIControlStateNormal];
|
||||
[self.voiceLoadingIndicator startAnimating];
|
||||
}
|
||||
|
||||
- (void)kb_hideVoiceLoadingAnimation {
|
||||
[self.voiceLoadingIndicator stopAnimating];
|
||||
UIImage *icon = nil;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
icon = [UIImage systemImageNamed:@"play.circle.fill"];
|
||||
}
|
||||
[self.voiceButton setImage:icon forState:UIControlStateNormal];
|
||||
}
|
||||
|
||||
- (void)kb_onVoiceButtonTapped {
|
||||
if ([self.delegate respondsToSelector:@selector(chatMessageCell:didTapVoiceButtonForMessage:)]) {
|
||||
[self.delegate chatMessageCell:self didTapVoiceButtonForMessage:self.currentMessage];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Reuse
|
||||
|
||||
- (void)prepareForReuse {
|
||||
[super prepareForReuse];
|
||||
[self kb_stopTypewriterEffect];
|
||||
self.messageLabel.text = @"";
|
||||
self.messageLabel.attributedText = nil;
|
||||
[self.messageLoadingIndicator stopAnimating];
|
||||
[self.voiceLoadingIndicator stopAnimating];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[self kb_stopTypewriterEffect];
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIImageView *)avatarView {
|
||||
if (!_avatarView) {
|
||||
_avatarView = [[UIImageView alloc] init];
|
||||
_avatarView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
_avatarView.layer.cornerRadius = 14;
|
||||
_avatarView.layer.masksToBounds = YES;
|
||||
_avatarView.backgroundColor = [UIColor colorWithWhite:0.9 alpha:1.0];
|
||||
_avatarView.tintColor =
|
||||
[UIColor kb_dynamicColorWithLightColor:[UIColor colorWithHex:0xB9BDC8]
|
||||
darkColor:[UIColor colorWithHex:0x6B6F7A]];
|
||||
}
|
||||
return _avatarView;
|
||||
}
|
||||
|
||||
- (UILabel *)nameLabel {
|
||||
if (!_nameLabel) {
|
||||
_nameLabel = [[UILabel alloc] init];
|
||||
_nameLabel.font = [UIFont systemFontOfSize:11];
|
||||
_nameLabel.textColor = [UIColor colorWithHex:0x6B6F7A];
|
||||
_nameLabel.numberOfLines = 1;
|
||||
}
|
||||
return _nameLabel;
|
||||
}
|
||||
|
||||
- (UIView *)bubbleView {
|
||||
if (!_bubbleView) {
|
||||
_bubbleView = [[UIView alloc] init];
|
||||
_bubbleView.layer.cornerRadius = 12;
|
||||
_bubbleView.layer.masksToBounds = YES;
|
||||
}
|
||||
return _bubbleView;
|
||||
}
|
||||
|
||||
- (UILabel *)messageLabel {
|
||||
if (!_messageLabel) {
|
||||
_messageLabel = [[UILabel alloc] init];
|
||||
_messageLabel.font = [UIFont systemFontOfSize:14];
|
||||
_messageLabel.numberOfLines = 0;
|
||||
}
|
||||
return _messageLabel;
|
||||
}
|
||||
|
||||
- (UIImageView *)audioIconView {
|
||||
if (!_audioIconView) {
|
||||
_audioIconView = [[UIImageView alloc] init];
|
||||
_audioIconView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
_audioIconView.tintColor = [UIColor colorWithHex:0x1B1F1A];
|
||||
UIImage *icon = nil;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
icon = [UIImage systemImageNamed:@"waveform"];
|
||||
}
|
||||
_audioIconView.image = icon;
|
||||
}
|
||||
return _audioIconView;
|
||||
}
|
||||
|
||||
- (UILabel *)audioLabel {
|
||||
if (!_audioLabel) {
|
||||
_audioLabel = [[UILabel alloc] init];
|
||||
_audioLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
|
||||
_audioLabel.numberOfLines = 1;
|
||||
}
|
||||
return _audioLabel;
|
||||
}
|
||||
|
||||
- (UIButton *)voiceButton {
|
||||
if (!_voiceButton) {
|
||||
_voiceButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
UIImage *icon = nil;
|
||||
if (@available(iOS 13.0, *)) {
|
||||
icon = [UIImage systemImageNamed:@"play.circle.fill"];
|
||||
}
|
||||
[_voiceButton setImage:icon forState:UIControlStateNormal];
|
||||
_voiceButton.tintColor = [UIColor whiteColor];
|
||||
[_voiceButton addTarget:self action:@selector(kb_onVoiceButtonTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
return _voiceButton;
|
||||
}
|
||||
|
||||
- (UILabel *)durationLabel {
|
||||
if (!_durationLabel) {
|
||||
_durationLabel = [[UILabel alloc] init];
|
||||
_durationLabel.font = [UIFont systemFontOfSize:11];
|
||||
_durationLabel.textColor = [UIColor whiteColor];
|
||||
}
|
||||
return _durationLabel;
|
||||
}
|
||||
|
||||
- (UIActivityIndicatorView *)voiceLoadingIndicator {
|
||||
if (!_voiceLoadingIndicator) {
|
||||
_voiceLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||
_voiceLoadingIndicator.color = [UIColor whiteColor];
|
||||
_voiceLoadingIndicator.hidesWhenStopped = YES;
|
||||
}
|
||||
return _voiceLoadingIndicator;
|
||||
}
|
||||
|
||||
- (UIActivityIndicatorView *)messageLoadingIndicator {
|
||||
if (!_messageLoadingIndicator) {
|
||||
_messageLoadingIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||
_messageLoadingIndicator.color = [UIColor whiteColor];
|
||||
_messageLoadingIndicator.hidesWhenStopped = YES;
|
||||
}
|
||||
return _messageLoadingIndicator;
|
||||
}
|
||||
|
||||
- (UIImage *)kb_defaultAvatarImage {
|
||||
if (@available(iOS 13.0, *)) {
|
||||
return [UIImage systemImageNamed:@"person.circle.fill"];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -18,6 +18,7 @@
|
||||
@end
|
||||
|
||||
@implementation KBFunctionBarView
|
||||
static const CGFloat kKBBackButtonWidth = 40;
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame{
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
@@ -83,14 +84,14 @@
|
||||
UIButton *appButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
appButton.tag = 100; // 左侧 index = 0
|
||||
UIImage *appImage = [UIImage imageNamed:@"App_icon"];
|
||||
[appButton setImage:appImage forState:UIControlStateNormal];
|
||||
[appButton setBackgroundImage:appImage forState:UIControlStateNormal];
|
||||
appButton.imageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
appButton.adjustsImageWhenHighlighted = YES;
|
||||
[appButton addTarget:self action:@selector(onLeftTap:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.leftContainer addSubview:appButton];
|
||||
[appButton mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self.leftContainer);
|
||||
make.width.height.mas_equalTo(34); // 设计图尺寸
|
||||
make.width.height.mas_equalTo(kKBBackButtonWidth); // 设计图尺寸
|
||||
}];
|
||||
self.leftButtonsInternal = @[appButton];
|
||||
|
||||
|
||||
@@ -6,118 +6,125 @@
|
||||
//
|
||||
|
||||
#import "KBFunctionTagCell.h"
|
||||
#import "KBFunctionView.h"
|
||||
#import "Masonry.h"
|
||||
|
||||
@interface KBFunctionTagCell ()
|
||||
@property (nonatomic, strong) UILabel *emojiLabel;
|
||||
@property (nonatomic, strong) UILabel *titleLabelInternal;
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *loadingView;
|
||||
@property(nonatomic, strong) UILabel *emojiLabel;
|
||||
@property(nonatomic, strong) UILabel *titleLabelInternal;
|
||||
@property(nonatomic, strong) UIActivityIndicatorView *loadingView;
|
||||
@end
|
||||
|
||||
@implementation KBFunctionTagCell
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.contentView.backgroundColor = [UIColor colorWithWhite:1 alpha:0.9];
|
||||
self.contentView.layer.cornerRadius = 12;
|
||||
self.contentView.layer.masksToBounds = YES;
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.contentView.backgroundColor = [KBFunctionView kb_cellBackgroundColor];
|
||||
self.contentView.layer.cornerRadius = 12;
|
||||
self.contentView.layer.masksToBounds = YES;
|
||||
|
||||
// 小菊花:默认隐藏,放在整体内容右侧偏内的位置
|
||||
[self.contentView addSubview:self.loadingView];
|
||||
[self.loadingView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self.contentView);
|
||||
make.width.height.mas_equalTo(16);
|
||||
}];
|
||||
// 小菊花:默认隐藏,放在整体内容右侧偏内的位置
|
||||
[self.contentView addSubview:self.loadingView];
|
||||
[self.loadingView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.center.equalTo(self.contentView);
|
||||
make.width.height.mas_equalTo(16);
|
||||
}];
|
||||
|
||||
// 中心容器:将 icon + title 组合整体水平居中
|
||||
UIView *centerContainer = [[UIView alloc] init];
|
||||
centerContainer.backgroundColor = [UIColor clearColor];
|
||||
[self.contentView addSubview:centerContainer];
|
||||
[centerContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerX.equalTo(self.contentView.mas_centerX);
|
||||
make.centerY.equalTo(self.contentView.mas_centerY);
|
||||
make.left.greaterThanOrEqualTo(self.contentView.mas_left).offset(6);
|
||||
make.right.lessThanOrEqualTo(self.contentView).offset(-6);
|
||||
}];
|
||||
// 中心容器:将 icon + title 组合整体水平居中
|
||||
UIView *centerContainer = [[UIView alloc] init];
|
||||
centerContainer.backgroundColor = [UIColor clearColor];
|
||||
[self.contentView addSubview:centerContainer];
|
||||
[centerContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerX.equalTo(self.contentView.mas_centerX);
|
||||
make.centerY.equalTo(self.contentView.mas_centerY);
|
||||
make.left.greaterThanOrEqualTo(self.contentView.mas_left).offset(6);
|
||||
make.right.lessThanOrEqualTo(self.contentView).offset(-6);
|
||||
}];
|
||||
|
||||
[centerContainer addSubview:self.emojiLabel];
|
||||
[centerContainer addSubview:self.titleLabelInternal];
|
||||
[centerContainer addSubview:self.emojiLabel];
|
||||
[centerContainer addSubview:self.titleLabelInternal];
|
||||
|
||||
[self.emojiLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(centerContainer.mas_left);
|
||||
make.centerY.equalTo(centerContainer.mas_centerY);
|
||||
// 留出一点余量,避免 emoji 字形在右侧被裁剪
|
||||
make.width.height.mas_equalTo(24);
|
||||
}];
|
||||
[self.titleLabelInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.emojiLabel.mas_right).offset(3);
|
||||
make.top.equalTo(centerContainer.mas_top);
|
||||
make.bottom.equalTo(centerContainer.mas_bottom);
|
||||
make.right.equalTo(centerContainer.mas_right);
|
||||
}];
|
||||
}
|
||||
return self;
|
||||
[self.emojiLabel mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(centerContainer.mas_left);
|
||||
make.centerY.equalTo(centerContainer.mas_centerY);
|
||||
// 留出一点余量,避免 emoji 字形在右侧被裁剪
|
||||
make.width.height.mas_equalTo(24);
|
||||
}];
|
||||
[self.titleLabelInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(self.emojiLabel.mas_right).offset(3);
|
||||
make.top.equalTo(centerContainer.mas_top);
|
||||
make.bottom.equalTo(centerContainer.mas_bottom);
|
||||
make.right.equalTo(centerContainer.mas_right);
|
||||
}];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setItemModel:(KBTagItemModel *)itemModel{
|
||||
_itemModel = itemModel;
|
||||
self.emojiLabel.text = itemModel.emoji;
|
||||
self.titleLabelInternal.text = itemModel.characterName;
|
||||
- (void)setItemModel:(KBTagItemModel *)itemModel {
|
||||
_itemModel = itemModel;
|
||||
self.emojiLabel.text = itemModel.emoji;
|
||||
self.titleLabelInternal.text = itemModel.characterName;
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UILabel *)emojiLabel {
|
||||
if (!_emojiLabel) {
|
||||
_emojiLabel = [[UILabel alloc] init];
|
||||
_emojiLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_emojiLabel.font = [KBFont medium:20];
|
||||
_emojiLabel.adjustsFontSizeToFitWidth = YES;
|
||||
|
||||
}
|
||||
return _emojiLabel;
|
||||
if (!_emojiLabel) {
|
||||
_emojiLabel = [[UILabel alloc] init];
|
||||
_emojiLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_emojiLabel.font = [KBFont medium:20];
|
||||
_emojiLabel.adjustsFontSizeToFitWidth = YES;
|
||||
}
|
||||
return _emojiLabel;
|
||||
}
|
||||
|
||||
- (UILabel *)titleLabelInternal {
|
||||
if (!_titleLabelInternal) {
|
||||
_titleLabelInternal = [[UILabel alloc] init];
|
||||
_titleLabelInternal.font = [KBFont medium:10];
|
||||
_titleLabelInternal.textColor = [UIColor colorWithHex:0x1B1F1A];
|
||||
// 最多两行,文本过长时末尾截断
|
||||
_titleLabelInternal.numberOfLines = 2;
|
||||
_titleLabelInternal.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
}
|
||||
return _titleLabelInternal;
|
||||
if (!_titleLabelInternal) {
|
||||
_titleLabelInternal = [[UILabel alloc] init];
|
||||
_titleLabelInternal.font = [KBFont medium:10];
|
||||
_titleLabelInternal.textColor = [KBFunctionView kb_cellTextColor];
|
||||
// 最多两行,文本过长时末尾截断
|
||||
_titleLabelInternal.numberOfLines = 2;
|
||||
_titleLabelInternal.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
}
|
||||
return _titleLabelInternal;
|
||||
}
|
||||
|
||||
#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
|
||||
static UIActivityIndicatorViewStyle KBSpinnerStyle(void) { return UIActivityIndicatorViewStyleMedium; }
|
||||
static UIActivityIndicatorViewStyle KBSpinnerStyle(void) {
|
||||
return UIActivityIndicatorViewStyleMedium;
|
||||
}
|
||||
#else
|
||||
static UIActivityIndicatorViewStyle KBSpinnerStyle(void) { return UIActivityIndicatorViewStyleGray; }
|
||||
static UIActivityIndicatorViewStyle KBSpinnerStyle(void) {
|
||||
return UIActivityIndicatorViewStyleGray;
|
||||
}
|
||||
#endif
|
||||
|
||||
- (UIActivityIndicatorView *)loadingView {
|
||||
if (!_loadingView) {
|
||||
_loadingView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:KBSpinnerStyle()];
|
||||
_loadingView.hidesWhenStopped = YES;
|
||||
_loadingView.color = [UIColor grayColor];
|
||||
_loadingView.hidden = YES;
|
||||
}
|
||||
return _loadingView;
|
||||
if (!_loadingView) {
|
||||
_loadingView = [[UIActivityIndicatorView alloc]
|
||||
initWithActivityIndicatorStyle:KBSpinnerStyle()];
|
||||
_loadingView.hidesWhenStopped = YES;
|
||||
_loadingView.color = [UIColor grayColor];
|
||||
_loadingView.hidden = YES;
|
||||
}
|
||||
return _loadingView;
|
||||
}
|
||||
|
||||
#pragma mark - Expose
|
||||
|
||||
- (UILabel *)titleLabel { return self.titleLabelInternal; }
|
||||
- (UILabel *)titleLabel {
|
||||
return self.titleLabelInternal;
|
||||
}
|
||||
|
||||
- (void)setLoading:(BOOL)loading {
|
||||
if (loading) {
|
||||
self.loadingView.hidden = NO;
|
||||
[self.loadingView startAnimating];
|
||||
} else {
|
||||
[self.loadingView stopAnimating];
|
||||
self.loadingView.hidden = YES;
|
||||
}
|
||||
if (loading) {
|
||||
self.loadingView.hidden = NO;
|
||||
[self.loadingView startAnimating];
|
||||
} else {
|
||||
[self.loadingView stopAnimating];
|
||||
self.loadingView.hidden = YES;
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -6,13 +6,16 @@
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
@class KBFunctionBarView, KBFunctionPasteView,KBFunctionView;
|
||||
@class KBFunctionBarView, KBFunctionPasteView, KBFunctionView;
|
||||
|
||||
@protocol KBFunctionViewDelegate <NSObject>
|
||||
@optional
|
||||
- (void)functionView:(KBFunctionView *_Nullable)functionView didTapToolActionAtIndex:(NSInteger)index;
|
||||
- (void)functionView:(KBFunctionView *_Nullable)functionView didRightTapToolActionAtIndex:(NSInteger)index;
|
||||
- (void)functionViewDidRequestSubscription:(KBFunctionView *_Nullable)functionView;
|
||||
- (void)functionView:(KBFunctionView *_Nullable)functionView
|
||||
didTapToolActionAtIndex:(NSInteger)index;
|
||||
- (void)functionView:(KBFunctionView *_Nullable)functionView
|
||||
didRightTapToolActionAtIndex:(NSInteger)index;
|
||||
- (void)functionViewDidRequestSubscription:
|
||||
(KBFunctionView *_Nullable)functionView;
|
||||
|
||||
@end
|
||||
|
||||
@@ -21,24 +24,33 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// 整个功能面板视图:顶部Bar + 粘贴区 + 标签列表 + 右侧操作按钮
|
||||
@interface KBFunctionView : UIView
|
||||
|
||||
@property (nonatomic, weak) id<KBFunctionViewDelegate> delegate;
|
||||
@property(nonatomic, weak) id<KBFunctionViewDelegate> delegate;
|
||||
|
||||
|
||||
@property (nonatomic, strong, readonly) UICollectionView *collectionView; // 话术分类/标签列表
|
||||
@property (nonatomic, strong, readonly) NSArray<NSString *> *items; // 简单数据源(演示用)
|
||||
@property(nonatomic, strong, readonly)
|
||||
UICollectionView *collectionView; // 话术分类/标签列表
|
||||
@property(nonatomic, strong, readonly)
|
||||
NSArray<NSString *> *items; // 简单数据源(演示用)
|
||||
|
||||
// 子视图暴露,便于外部接入事件
|
||||
@property (nonatomic, strong, readonly) KBFunctionBarView *barView;
|
||||
@property (nonatomic, strong, readonly) KBFunctionPasteView *pasteView;
|
||||
@property(nonatomic, strong, readonly) KBFunctionBarView *barView;
|
||||
@property(nonatomic, strong, readonly) KBFunctionPasteView *pasteView;
|
||||
|
||||
@property (nonatomic, strong, readonly) UIButton *pasteButton; // 右侧-粘贴
|
||||
@property (nonatomic, strong, readonly) UIButton *deleteButton; // 右侧-删除
|
||||
@property (nonatomic, strong, readonly) UIButton *clearButton; // 右侧-清空
|
||||
@property (nonatomic, strong, readonly) UIButton *sendButton; // 右侧-发送
|
||||
@property(nonatomic, strong, readonly) UIButton *pasteButton; // 右侧-粘贴
|
||||
@property(nonatomic, strong, readonly) UIButton *deleteButton; // 右侧-删除
|
||||
@property(nonatomic, strong, readonly) UIButton *clearButton; // 右侧-清空
|
||||
@property(nonatomic, strong, readonly) UIButton *sendButton; // 右侧-发送
|
||||
|
||||
/// 应用当前皮肤(更新背景/强调色)
|
||||
- (void)kb_applyTheme;
|
||||
|
||||
#pragma mark - Theme Colors (用于 Cell 获取暗黑模式颜色)
|
||||
|
||||
/// Cell 背景色:暗黑 #707070,浅色 白色90%透明度
|
||||
+ (UIColor *)kb_cellBackgroundColor;
|
||||
|
||||
/// Cell 文字颜色:暗黑 #FFFFFF,浅色 #1B1F1A
|
||||
+ (UIColor *)kb_cellTextColor;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -31,6 +31,9 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// emoji 面板点击搜索
|
||||
- (void)keyBoardMainViewDidTapEmojiSearch:(KBKeyBoardMainView *)keyBoardMainView;
|
||||
|
||||
/// 选择了联想词
|
||||
- (void)keyBoardMainView:(KBKeyBoardMainView *)keyBoardMainView didSelectSuggestion:(NSString *)suggestion;
|
||||
@end
|
||||
|
||||
@interface KBKeyBoardMainView : UIView
|
||||
@@ -39,6 +42,9 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
/// 应用当前皮肤(会触发键区重载以应用按键颜色)
|
||||
- (void)kb_applyTheme;
|
||||
|
||||
/// 更新联想候选
|
||||
- (void)kb_setSuggestions:(NSArray<NSString *> *)suggestions;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -11,29 +11,50 @@
|
||||
#import "KBFunctionView.h"
|
||||
#import "KBKey.h"
|
||||
#import "KBEmojiPanelView.h"
|
||||
#import "KBSuggestionBarView.h"
|
||||
#import "Masonry.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBKeyboardLayoutConfig.h"
|
||||
|
||||
@interface KBKeyBoardMainView ()<KBToolBarDelegate, KBKeyboardViewDelegate, KBEmojiPanelViewDelegate>
|
||||
@interface KBKeyBoardMainView ()<KBToolBarDelegate, KBKeyboardViewDelegate, KBEmojiPanelViewDelegate, KBSuggestionBarViewDelegate>
|
||||
@property (nonatomic, strong) KBToolBar *topBar;
|
||||
@property (nonatomic, strong) KBSuggestionBarView *suggestionBar;
|
||||
@property (nonatomic, strong) KBKeyboardView *keyboardView;
|
||||
@property (nonatomic, strong) KBEmojiPanelView *emojiView;
|
||||
@property (nonatomic, assign) BOOL emojiPanelVisible;
|
||||
@property (nonatomic, assign) BOOL suggestionBarHasItems;
|
||||
// 注意:功能面板的展示/隐藏由外部控制器决定,此处不再直接管理显隐
|
||||
@end
|
||||
@implementation KBKeyBoardMainView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [KBSkinManager shared].current.keyboardBackground;
|
||||
// self.backgroundColor = [KBSkinManager shared].current.keyboardBackground;
|
||||
self.backgroundColor = [UIColor colorWithHex:0xD1D3DB];
|
||||
|
||||
// 顶部栏
|
||||
self.topBar = [[KBToolBar alloc] init];
|
||||
self.topBar.delegate = self;
|
||||
[self addSubview:self.topBar];
|
||||
|
||||
// 联想栏
|
||||
self.suggestionBar = [[KBSuggestionBarView alloc] init];
|
||||
self.suggestionBar.delegate = self;
|
||||
self.suggestionBar.hidden = YES;
|
||||
[self addSubview:self.suggestionBar];
|
||||
|
||||
// 键盘区域(高度按照设计值做等比缩放,避免不同机型上按键被压缩/拉伸)
|
||||
CGFloat keyboardAreaHeight = KBFit(200.0f);
|
||||
KBKeyboardLayoutConfig *layoutConfig = [KBKeyboardLayoutConfig sharedConfig];
|
||||
if (layoutConfig) {
|
||||
CGFloat configHeight = [layoutConfig keyboardAreaScaledHeight];
|
||||
if (configHeight > 0.0) {
|
||||
keyboardAreaHeight = configHeight;
|
||||
}
|
||||
}
|
||||
CGFloat bottomInset = KBFit(4.0f);
|
||||
// CGFloat topBarHeight = KBFit(40.0f);
|
||||
CGFloat barSpacing = KBFit(6.0f);
|
||||
|
||||
self.keyboardView = [[KBKeyboardView alloc] init];
|
||||
@@ -54,16 +75,43 @@
|
||||
make.edges.equalTo(self);
|
||||
}];
|
||||
|
||||
// [self.topBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
// make.left.right.equalTo(self);
|
||||
// make.top.equalTo(self.mas_top).offset(0);
|
||||
// make.height.mas_equalTo(topBarHeight);
|
||||
// }];
|
||||
[self.topBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self);
|
||||
make.top.equalTo(self.mas_top).offset(0);
|
||||
make.bottom.equalTo(self.keyboardView.mas_top).offset(0);
|
||||
}];
|
||||
|
||||
[self.suggestionBar mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.right.equalTo(self);
|
||||
make.top.equalTo(self.topBar);
|
||||
make.bottom.equalTo(self.topBar);
|
||||
}];
|
||||
|
||||
[self.keyboardView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.topBar.mas_bottom).offset(barSpacing);
|
||||
}];
|
||||
// 首次创建时立即生成按键,避免只出现 4 行容器但按键未渲染的情况。
|
||||
[self.keyboardView reloadKeys];
|
||||
// 同步应用一次主题,确保键盘背景与按键皮肤在首帧可见。
|
||||
[self kb_applyTheme];
|
||||
// 功能面板切换交由外部控制器处理;此处不直接创建/管理
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(kb_undoStateChanged)
|
||||
name:KBBackspaceUndoStateDidChangeNotification
|
||||
object:nil];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
- (void)setEmojiPanelVisible:(BOOL)visible animated:(BOOL)animated {
|
||||
if (self.emojiPanelVisible == visible) return;
|
||||
self.emojiPanelVisible = visible;
|
||||
@@ -74,17 +122,24 @@
|
||||
} else {
|
||||
self.keyboardView.hidden = NO;
|
||||
self.topBar.hidden = NO;
|
||||
self.suggestionBar.hidden = !self.suggestionBarHasItems;
|
||||
}
|
||||
|
||||
void (^changes)(void) = ^{
|
||||
self.emojiView.alpha = visible ? 1.0 : 0.0;
|
||||
self.keyboardView.alpha = visible ? 0.0 : 1.0;
|
||||
self.topBar.alpha = visible ? 0.0 : 1.0;
|
||||
self.suggestionBar.alpha = visible ? 0.0 : ([self kb_shouldShowSuggestions] ? 1.0 : 0.0);
|
||||
};
|
||||
void (^completion)(BOOL) = ^(BOOL finished) {
|
||||
self.emojiView.hidden = !visible;
|
||||
self.keyboardView.hidden = visible;
|
||||
self.topBar.hidden = visible;
|
||||
if (visible) {
|
||||
self.suggestionBar.hidden = YES;
|
||||
} else {
|
||||
self.suggestionBar.hidden = ![self kb_shouldShowSuggestions];
|
||||
}
|
||||
};
|
||||
|
||||
if (animated) {
|
||||
@@ -204,17 +259,50 @@
|
||||
|
||||
- (void)kb_applyTheme {
|
||||
KBSkinManager *mgr = [KBSkinManager shared];
|
||||
BOOL hasImg = ([mgr currentBackgroundImage] != nil);
|
||||
UIColor *bg = mgr.current.keyboardBackground;
|
||||
self.backgroundColor = hasImg ? [UIColor clearColor] : bg;
|
||||
self.keyboardView.backgroundColor = hasImg ? [UIColor clearColor] : bg;
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
self.keyboardView.backgroundColor = [UIColor clearColor];
|
||||
if ([self.topBar respondsToSelector:@selector(kb_applyTheme)]) {
|
||||
[self.topBar kb_applyTheme];
|
||||
}
|
||||
[self.suggestionBar applyTheme:mgr.current];
|
||||
[self.keyboardView reloadKeys];
|
||||
if (self.emojiView) {
|
||||
[self.emojiView applyTheme:mgr.current];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Suggestions
|
||||
|
||||
- (void)kb_setSuggestions:(NSArray<NSString *> *)suggestions {
|
||||
self.suggestionBarHasItems = (suggestions.count > 0);
|
||||
[self.suggestionBar updateSuggestions:suggestions];
|
||||
[self kb_applySuggestionVisibility];
|
||||
}
|
||||
|
||||
#pragma mark - KBSuggestionBarViewDelegate
|
||||
|
||||
- (void)suggestionBarView:(KBSuggestionBarView *)view didSelectSuggestion:(NSString *)suggestion {
|
||||
if ([self.delegate respondsToSelector:@selector(keyBoardMainView:didSelectSuggestion:)]) {
|
||||
[self.delegate keyBoardMainView:self didSelectSuggestion:suggestion];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_undoStateChanged {
|
||||
[self kb_applySuggestionVisibility];
|
||||
}
|
||||
|
||||
- (BOOL)kb_shouldShowSuggestions {
|
||||
if (self.emojiPanelVisible) { return NO; }
|
||||
if (![KBBackspaceUndoManager shared].hasUndo && self.suggestionBarHasItems) {
|
||||
return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (void)kb_applySuggestionVisibility {
|
||||
BOOL shouldShow = [self kb_shouldShowSuggestions];
|
||||
self.suggestionBar.hidden = !shouldShow;
|
||||
self.suggestionBar.alpha = shouldShow ? 1.0 : 0.0;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
@property (nonatomic, strong) KBKey *key;
|
||||
@property (nonatomic, strong) UIImageView *iconView;
|
||||
@property (nonatomic, strong, nullable) UIColor *customBackgroundColor;
|
||||
|
||||
/// 配置基础样式(背景、圆角等)。创建按钮时调用。
|
||||
- (void)applyDefaultStyle;
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
#import "KBKeyButton.h"
|
||||
#import "KBKey.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import <QuartzCore/QuartzCore.h>
|
||||
|
||||
@interface KBKeyButton ()
|
||||
// 内部缓存:便于从按钮查找到所属的 KBKeyboardView
|
||||
@property (nonatomic, weak, readonly) UIView *kb_keyboardContainer;
|
||||
@property (nonatomic, strong) UIImageView *normalImageView; /// 没有皮肤的时候展示
|
||||
@property (nonatomic, strong) UIColor *baseBackgroundColor; /// 无按下状态下,由皮肤/主题决定的底色(由 normalImageView 展示)
|
||||
@property (nonatomic, strong) CAGradientLayer *bottomShadowLayer;
|
||||
|
||||
@end
|
||||
|
||||
@@ -24,8 +26,8 @@
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.normalImageView.topAnchor constraintEqualToAnchor:self.topAnchor],
|
||||
[self.normalImageView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
|
||||
[self.normalImageView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:2],
|
||||
[self.normalImageView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-2],
|
||||
[self.normalImageView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
|
||||
[self.normalImageView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
|
||||
]];
|
||||
[self applyDefaultStyle];
|
||||
}
|
||||
@@ -48,6 +50,7 @@
|
||||
|
||||
// 初始状态下根据主题设置底色(给没有皮肤图的按键使用)
|
||||
[self refreshStateAppearance];
|
||||
[self kb_setupBottomShadowIfNeeded];
|
||||
|
||||
// 懒创建图标视图,用于后续皮肤按键小图标展示
|
||||
if (!self.iconView) {
|
||||
@@ -61,8 +64,8 @@
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[iv.topAnchor constraintEqualToAnchor:self.topAnchor],
|
||||
[iv.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
|
||||
[iv.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:2],
|
||||
[iv.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-2],
|
||||
[iv.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
|
||||
[iv.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
|
||||
]];
|
||||
self.iconView = iv;
|
||||
|
||||
@@ -72,6 +75,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
- (void)layoutSubviews {
|
||||
[super layoutSubviews];
|
||||
if (!self.bottomShadowLayer) { return; }
|
||||
CGRect bounds = self.normalImageView.bounds;
|
||||
CGFloat shadowHeight = 2;
|
||||
if (CGRectGetHeight(bounds) <= 0 || CGRectGetWidth(bounds) <= 0) {
|
||||
return;
|
||||
}
|
||||
// 有皮肤图时,强制隐藏文字,避免图标与文本叠加
|
||||
if (self.iconView.image != nil) {
|
||||
self.titleLabel.hidden = YES;
|
||||
}
|
||||
self.bottomShadowLayer.frame = CGRectMake(0,
|
||||
CGRectGetHeight(bounds) - shadowHeight,
|
||||
CGRectGetWidth(bounds),
|
||||
shadowHeight);
|
||||
}
|
||||
|
||||
- (void)setKey:(KBKey *)key {
|
||||
_key = key;
|
||||
}
|
||||
@@ -121,14 +142,25 @@
|
||||
[self refreshStateAppearance];
|
||||
}
|
||||
|
||||
- (void)setCustomBackgroundColor:(UIColor *)customBackgroundColor {
|
||||
_customBackgroundColor = customBackgroundColor;
|
||||
[self refreshStateAppearance];
|
||||
}
|
||||
|
||||
- (void)refreshStateAppearance {
|
||||
// 选中态用于 Shift/CapsLock 等特殊按键的高亮显示
|
||||
KBSkinTheme *t = [KBSkinManager shared].current;
|
||||
UIColor *base = nil;
|
||||
if (self.isSelected) {
|
||||
base = t.keyHighlightBackground ?: t.keyBackground;
|
||||
if (self.customBackgroundColor) {
|
||||
base = t.keyHighlightBackground ?: self.customBackgroundColor;
|
||||
}
|
||||
} else {
|
||||
base = t.keyBackground;
|
||||
base = self.customBackgroundColor ?: t.keyBackground;
|
||||
}
|
||||
if (self.customBackgroundColor && self.key.type == KBKeyTypeShift) {
|
||||
base = self.customBackgroundColor;
|
||||
}
|
||||
if (!base) {
|
||||
base = [UIColor whiteColor];
|
||||
@@ -138,6 +170,13 @@
|
||||
// 按键背景统一由 normalImageView 控制,按钮本身透明
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
|
||||
if (self.key.type == KBKeyTypeShift) {
|
||||
UIColor *textColor = self.isSelected ? [UIColor blackColor] : (t.keyTextColor ?: [UIColor blackColor]);
|
||||
[self setTitleColor:textColor forState:UIControlStateNormal];
|
||||
[self setTitleColor:textColor forState:UIControlStateHighlighted];
|
||||
[self setTitleColor:textColor forState:UIControlStateSelected];
|
||||
}
|
||||
|
||||
// 有皮肤图时仅展示 icon,不再显示普通背景色
|
||||
if (self.iconView.image != nil || self.normalImageView.hidden) {
|
||||
return;
|
||||
@@ -169,6 +208,7 @@
|
||||
|
||||
BOOL hasIcon = (iconImg != nil);
|
||||
self.normalImageView.hidden = hasIcon;
|
||||
self.bottomShadowLayer.hidden = hasIcon;
|
||||
if (hasIcon) {
|
||||
// 有图标:仅显示图片,完全隐藏文字
|
||||
[self setTitle:@"" forState:UIControlStateNormal];
|
||||
@@ -184,6 +224,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_setupBottomShadowIfNeeded {
|
||||
if (self.bottomShadowLayer) { return; }
|
||||
CAGradientLayer *layer = [CAGradientLayer layer];
|
||||
layer.startPoint = CGPointMake(0.5, 0.0);
|
||||
layer.endPoint = CGPointMake(0.5, 1.0);
|
||||
layer.colors = @[
|
||||
(id)[UIColor colorWithWhite:0 alpha:0.5].CGColor,
|
||||
(id)[UIColor colorWithWhite:0 alpha:0.7].CGColor
|
||||
];
|
||||
[self.normalImageView.layer addSublayer:layer];
|
||||
// self.bottomShadowLayer = layer;
|
||||
}
|
||||
|
||||
- (UIImageView *)normalImageView{
|
||||
if (!_normalImageView) {
|
||||
_normalImageView = [[UIImageView alloc] init];
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#import "KBSkinManager.h"
|
||||
#import "KBKeyPreviewView.h"
|
||||
#import "KBBackspaceLongPressHandler.h"
|
||||
#import "KBKeyboardLayoutConfig.h"
|
||||
|
||||
// UI 常量统一管理,方便后续调试样式(以 375 宽设计稿为基准,通过 KBFit 做等比缩放)
|
||||
#define kKBRowVerticalSpacing KBFit(8.0f)
|
||||
@@ -33,20 +34,21 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
|
||||
@property (nonatomic, strong) NSArray<NSArray<KBKey *> *> *keysForRows;
|
||||
@property (nonatomic, strong) KBBackspaceLongPressHandler *backspaceHandler;
|
||||
@property (nonatomic, strong) KBKeyPreviewView *previewView;
|
||||
@property (nonatomic, strong) KBKeyboardLayoutConfig *layoutConfig;
|
||||
@end
|
||||
|
||||
@implementation KBKeyboardView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [KBSkinManager shared].current.keyboardBackground;
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
_layoutStyle = KBKeyboardLayoutStyleLetters;
|
||||
// 默认小写:与需求一致,初始不开启 Shift
|
||||
_shiftOn = NO;
|
||||
_symbolsMoreOn = NO; // 数字面板默认第一页(123)
|
||||
self.layoutConfig = [KBKeyboardLayoutConfig sharedConfig];
|
||||
self.backspaceHandler = [[KBBackspaceLongPressHandler alloc] initWithContainerView:self];
|
||||
[self buildBase];
|
||||
[self reloadKeys];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
@@ -67,26 +69,39 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
|
||||
[self addSubview:self.row3];
|
||||
[self addSubview:self.row4];
|
||||
|
||||
KBKeyboardLayoutConfig *config = [self kb_layoutConfig];
|
||||
KBKeyboardLayout *layout = [self kb_layoutForName:@"letters"];
|
||||
NSArray<KBKeyboardRowConfig *> *rows = layout.rows ?: @[];
|
||||
|
||||
CGFloat rowSpacing = [self kb_metricValue:config.metrics.rowSpacing fallback:nil defaultValue:8.0];
|
||||
CGFloat topInset = [self kb_metricValue:config.metrics.topInset fallback:nil defaultValue:8.0];
|
||||
CGFloat bottomInset = [self kb_metricValue:config.metrics.bottomInset fallback:nil defaultValue:6.0];
|
||||
|
||||
CGFloat row1Height = [self kb_rowHeightForRow:(rows.count > 0 ? rows[0] : nil)];
|
||||
CGFloat row2Height = [self kb_rowHeightForRow:(rows.count > 1 ? rows[1] : nil)];
|
||||
CGFloat row3Height = [self kb_rowHeightForRow:(rows.count > 2 ? rows[2] : nil)];
|
||||
CGFloat row4Height = [self kb_rowHeightForRow:(rows.count > 3 ? rows[3] : nil)];
|
||||
|
||||
[self.row1 mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.mas_top).offset(kKBRowVerticalSpacing);
|
||||
make.top.equalTo(self.mas_top).offset(topInset);
|
||||
make.left.right.equalTo(self);
|
||||
make.height.mas_equalTo(kKBRowHeight);
|
||||
make.height.mas_equalTo(row1Height);
|
||||
}];
|
||||
[self.row2 mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.row1.mas_bottom).offset(kKBRowVerticalSpacing);
|
||||
make.top.equalTo(self.row1.mas_bottom).offset(rowSpacing);
|
||||
make.left.right.equalTo(self);
|
||||
make.height.equalTo(self.row1);
|
||||
make.height.mas_equalTo(row2Height);
|
||||
}];
|
||||
[self.row3 mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.row2.mas_bottom).offset(kKBRowVerticalSpacing);
|
||||
make.top.equalTo(self.row2.mas_bottom).offset(rowSpacing);
|
||||
make.left.right.equalTo(self);
|
||||
make.height.equalTo(self.row1);
|
||||
make.height.mas_equalTo(row3Height);
|
||||
}];
|
||||
[self.row4 mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.equalTo(self.row3.mas_bottom).offset(kKBRowVerticalSpacing);
|
||||
make.top.equalTo(self.row3.mas_bottom).offset(rowSpacing);
|
||||
make.left.right.equalTo(self);
|
||||
make.height.equalTo(self.row1);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(-6);
|
||||
make.height.mas_equalTo(row4Height);
|
||||
make.bottom.equalTo(self.mas_bottom).offset(-bottomInset);
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -99,18 +114,125 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
|
||||
[row.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
||||
}
|
||||
|
||||
self.keysForRows = [self buildKeysForCurrentLayout];
|
||||
if (self.keysForRows.count < 4) return;
|
||||
KBKeyboardLayout *layout = [self kb_currentLayout];
|
||||
NSArray<KBKeyboardRowConfig *> *rows = layout.rows ?: @[];
|
||||
if (rows.count < 4) {
|
||||
[self kb_buildLegacyLayout];
|
||||
return;
|
||||
}
|
||||
|
||||
[self buildRow:self.row1 withKeys:self.keysForRows[0]];
|
||||
[self buildRow:self.row1 withRowConfig:rows[0]];
|
||||
[self buildRow:self.row2 withRowConfig:rows[1]];
|
||||
[self buildRow:self.row3 withRowConfig:rows[2]];
|
||||
[self buildRow:self.row4 withRowConfig:rows[3]];
|
||||
|
||||
// 第二行:字母布局时通过左右等宽占位让整行居中
|
||||
CGFloat row2Spacer = (self.layoutStyle == KBKeyboardLayoutStyleLetters)
|
||||
? kKBLettersRow2EdgeSpacerMultiplier : 0.0;
|
||||
[self buildRow:self.row2 withKeys:self.keysForRows[1] edgeSpacerMultiplier:row2Spacer];
|
||||
NSUInteger totalButtons = [self kb_totalKeyButtonCount];
|
||||
if (totalButtons == 0) {
|
||||
NSLog(@"[KBKeyboardView] config layout produced no keys, fallback to legacy.");
|
||||
for (UIView *row in @[self.row1, self.row2, self.row3, self.row4]) {
|
||||
[row.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
||||
}
|
||||
[self kb_buildLegacyLayout];
|
||||
}
|
||||
}
|
||||
|
||||
[self buildRow:self.row3 withKeys:self.keysForRows[2]];
|
||||
[self buildRow:self.row4 withKeys:self.keysForRows[3]];
|
||||
- (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.row1, self.row2, self.row3, self.row4]) {
|
||||
total += [self kb_collectKeyButtonsInView:row].count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
#pragma mark - Hit Test
|
||||
|
||||
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
|
||||
UIView *hit = [super hitTest:point withEvent:event];
|
||||
if ([hit isKindOfClass:[KBKeyButton class]]) {
|
||||
return hit;
|
||||
}
|
||||
if ([self kb_isHitInsideKeyRows:hit]) {
|
||||
KBKeyButton *btn = [self kb_nearestKeyButtonForPoint:point];
|
||||
if (btn) { return btn; }
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
- (BOOL)kb_isHitInsideKeyRows:(UIView *)hitView {
|
||||
if (!hitView) { return NO; }
|
||||
if (hitView == self) { return YES; }
|
||||
if ([hitView isDescendantOfView:self.row1]) { return YES; }
|
||||
if ([hitView isDescendantOfView:self.row2]) { return YES; }
|
||||
if ([hitView isDescendantOfView:self.row3]) { return YES; }
|
||||
if ([hitView isDescendantOfView:self.row4]) { return YES; }
|
||||
return NO;
|
||||
}
|
||||
|
||||
- (KBKeyButton *)kb_nearestKeyButtonForPoint:(CGPoint)point {
|
||||
KBKeyButton *best = nil;
|
||||
CGFloat bestDistance = CGFLOAT_MAX;
|
||||
NSArray<UIView *> *rows = @[self.row1, self.row2, self.row3, self.row4];
|
||||
|
||||
UIView *targetRow = nil;
|
||||
for (UIView *row in rows) {
|
||||
CGRect rowFrame = [self convertRect:row.bounds fromView:row];
|
||||
if (CGRectContainsPoint(rowFrame, point)) {
|
||||
targetRow = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
NSArray<UIView *> *candidateRows = targetRow ? @[targetRow] : rows;
|
||||
for (UIView *row in candidateRows) {
|
||||
NSArray<KBKeyButton *> *buttons = [self kb_collectKeyButtonsInView:row];
|
||||
for (KBKeyButton *btn in buttons) {
|
||||
CGRect frame = [self convertRect:btn.frame fromView:btn.superview];
|
||||
CGFloat dx = point.x - CGRectGetMidX(frame);
|
||||
CGFloat dy = point.y - CGRectGetMidY(frame);
|
||||
CGFloat dist = (dx * dx) + (dy * dy);
|
||||
if (dist < bestDistance) {
|
||||
bestDistance = dist;
|
||||
best = btn;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
- (NSArray<KBKeyButton *> *)kb_collectKeyButtonsInView:(UIView *)view {
|
||||
if (!view) { return @[]; }
|
||||
NSMutableArray<KBKeyButton *> *buttons = [NSMutableArray array];
|
||||
[self kb_collectKeyButtonsInView:view into:buttons];
|
||||
return buttons.copy;
|
||||
}
|
||||
|
||||
- (void)kb_collectKeyButtonsInView:(UIView *)view
|
||||
into:(NSMutableArray<KBKeyButton *> *)buttons {
|
||||
for (UIView *sub in view.subviews) {
|
||||
if ([sub isKindOfClass:[KBKeyButton class]]) {
|
||||
[buttons addObject:(KBKeyButton *)sub];
|
||||
continue;
|
||||
}
|
||||
if (sub.subviews.count > 0) {
|
||||
[self kb_collectKeyButtonsInView:sub into:buttons];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Key Model Construction
|
||||
@@ -315,6 +437,152 @@ static const CGFloat kKBLettersRow2EdgeSpacerMultiplier = 0.5;
|
||||
|
||||
#pragma mark - Row Building
|
||||
|
||||
- (void)buildRow:(UIView *)row withRowConfig:(KBKeyboardRowConfig *)rowConfig {
|
||||
if (!row || !rowConfig) { return; }
|
||||
CGFloat gap = [self kb_gapForRow:rowConfig];
|
||||
CGFloat insetLeft = [self kb_insetLeftForRow:rowConfig];
|
||||
CGFloat insetRight = [self kb_insetRightForRow:rowConfig];
|
||||
|
||||
if (rowConfig.segments) {
|
||||
KBKeyboardRowSegments *segments = rowConfig.segments;
|
||||
NSArray<KBKeyboardRowItem *> *leftItems = [segments leftItems];
|
||||
NSArray<KBKeyboardRowItem *> *centerItems = [segments centerItems];
|
||||
NSArray<KBKeyboardRowItem *> *rightItems = [segments rightItems];
|
||||
UIView *leftContainer = [UIView new];
|
||||
UIView *centerContainer = [UIView new];
|
||||
UIView *rightContainer = [UIView new];
|
||||
[row addSubview:leftContainer];
|
||||
[row addSubview:centerContainer];
|
||||
[row addSubview:rightContainer];
|
||||
|
||||
[leftContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(row.mas_left).offset(insetLeft);
|
||||
make.top.bottom.equalTo(row);
|
||||
}];
|
||||
[rightContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(row.mas_right).offset(-insetRight);
|
||||
make.top.bottom.equalTo(row);
|
||||
}];
|
||||
[centerContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.centerX.equalTo(row);
|
||||
make.top.bottom.equalTo(row);
|
||||
make.left.greaterThanOrEqualTo(leftContainer.mas_right).offset(gap);
|
||||
make.right.lessThanOrEqualTo(rightContainer.mas_left).offset(-gap);
|
||||
}];
|
||||
|
||||
if (leftItems.count == 0) {
|
||||
[leftContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.mas_equalTo(0);
|
||||
}];
|
||||
}
|
||||
if (centerItems.count == 0) {
|
||||
[centerContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.mas_equalTo(0);
|
||||
}];
|
||||
}
|
||||
if (rightItems.count == 0) {
|
||||
[rightContainer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.mas_equalTo(0);
|
||||
}];
|
||||
}
|
||||
|
||||
[self kb_buildButtonsInContainer:leftContainer
|
||||
items:leftItems
|
||||
gap:gap
|
||||
insetLeft:0
|
||||
insetRight:0
|
||||
alignCenter:NO];
|
||||
[self kb_buildButtonsInContainer:centerContainer
|
||||
items:centerItems
|
||||
gap:gap
|
||||
insetLeft:0
|
||||
insetRight:0
|
||||
alignCenter:NO];
|
||||
[self kb_buildButtonsInContainer:rightContainer
|
||||
items:rightItems
|
||||
gap:gap
|
||||
insetLeft:0
|
||||
insetRight:0
|
||||
alignCenter:NO];
|
||||
return;
|
||||
}
|
||||
|
||||
BOOL alignCenter = [rowConfig.align.lowercaseString isEqualToString:@"center"];
|
||||
[self kb_buildButtonsInContainer:row
|
||||
items:[rowConfig resolvedItems]
|
||||
gap:gap
|
||||
insetLeft:insetLeft
|
||||
insetRight:insetRight
|
||||
alignCenter:alignCenter];
|
||||
}
|
||||
|
||||
- (void)kb_buildButtonsInContainer:(UIView *)container
|
||||
items:(NSArray<KBKeyboardRowItem *> *)items
|
||||
gap:(CGFloat)gap
|
||||
insetLeft:(CGFloat)insetLeft
|
||||
insetRight:(CGFloat)insetRight
|
||||
alignCenter:(BOOL)alignCenter {
|
||||
if (items.count == 0) { return; }
|
||||
|
||||
UIView *leftSpacer = nil;
|
||||
UIView *rightSpacer = nil;
|
||||
if (alignCenter) {
|
||||
leftSpacer = [UIView new];
|
||||
rightSpacer = [UIView new];
|
||||
[container addSubview:leftSpacer];
|
||||
[container addSubview:rightSpacer];
|
||||
[leftSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.left.equalTo(container.mas_left).offset(insetLeft);
|
||||
make.top.bottom.equalTo(container);
|
||||
}];
|
||||
[rightSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(container.mas_right).offset(-insetRight);
|
||||
make.top.bottom.equalTo(container);
|
||||
}];
|
||||
[leftSpacer mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.equalTo(rightSpacer);
|
||||
}];
|
||||
}
|
||||
|
||||
KBKeyButton *previous = nil;
|
||||
for (KBKeyboardRowItem *item in items) {
|
||||
KBKeyButton *btn = [self kb_buttonForItem:item];
|
||||
if (!btn) { continue; }
|
||||
[container addSubview:btn];
|
||||
|
||||
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.top.bottom.equalTo(container);
|
||||
if (previous) {
|
||||
make.left.equalTo(previous.mas_right).offset(gap);
|
||||
} else {
|
||||
if (leftSpacer) {
|
||||
make.left.equalTo(leftSpacer.mas_right).offset(gap);
|
||||
} else {
|
||||
make.left.equalTo(container.mas_left).offset(insetLeft);
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
CGFloat width = [self kb_widthForItem:item key:btn.key];
|
||||
if (width > 0.0) {
|
||||
[btn mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.width.mas_equalTo(width);
|
||||
}];
|
||||
}
|
||||
|
||||
previous = btn;
|
||||
}
|
||||
|
||||
if (!previous) { return; }
|
||||
[previous mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
if (rightSpacer) {
|
||||
make.right.equalTo(rightSpacer.mas_left).offset(-gap);
|
||||
} else {
|
||||
make.right.equalTo(container.mas_right).offset(-insetRight);
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)buildRow:(UIView *)row withKeys:(NSArray<KBKey *> *)keys {
|
||||
[self buildRow:row withKeys:keys edgeSpacerMultiplier:0.0];
|
||||
}
|
||||
@@ -358,7 +626,7 @@ edgeSpacerMultiplier:(CGFloat)edgeSpacerMultiplier {
|
||||
[btn setTitle:key.title forState:UIControlStateNormal];
|
||||
// 在设置完标题后,按当前皮肤应用图标与文字显隐
|
||||
[btn applyThemeForCurrentKey];
|
||||
[btn addTarget:self action:@selector(onKeyTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[btn addTarget:self action:@selector(onKeyTapped:) forControlEvents:UIControlEventTouchDown];
|
||||
[row addSubview:btn];
|
||||
|
||||
if (key.type == KBKeyTypeBackspace) {
|
||||
@@ -581,6 +849,386 @@ edgeSpacerMultiplier:(CGFloat)edgeSpacerMultiplier {
|
||||
// Space 不设置宽度;通过此前已建立的左右约束自动占满剩余宽度。
|
||||
}
|
||||
|
||||
#pragma mark - Config Helpers
|
||||
|
||||
- (KBKeyboardLayoutConfig *)kb_layoutConfig {
|
||||
if (!self.layoutConfig) {
|
||||
self.layoutConfig = [KBKeyboardLayoutConfig sharedConfig];
|
||||
}
|
||||
return self.layoutConfig;
|
||||
}
|
||||
|
||||
- (KBKeyboardLayout *)kb_layoutForName:(NSString *)name {
|
||||
return [[self kb_layoutConfig] layoutForName:name];
|
||||
}
|
||||
|
||||
- (KBKeyboardLayout *)kb_currentLayout {
|
||||
if (self.layoutStyle == KBKeyboardLayoutStyleNumbers) {
|
||||
return [self kb_layoutForName:(self.symbolsMoreOn ? @"symbolsMore" : @"numbers")];
|
||||
}
|
||||
return [self kb_layoutForName:@"letters"];
|
||||
}
|
||||
|
||||
- (void)kb_buildLegacyLayout {
|
||||
self.keysForRows = [self buildKeysForCurrentLayout];
|
||||
if (self.keysForRows.count < 4) { return; }
|
||||
|
||||
[self buildRow:self.row1 withKeys:self.keysForRows[0]];
|
||||
|
||||
CGFloat row2Spacer = (self.layoutStyle == KBKeyboardLayoutStyleLetters)
|
||||
? kKBLettersRow2EdgeSpacerMultiplier : 0.0;
|
||||
[self buildRow:self.row2 withKeys:self.keysForRows[1] edgeSpacerMultiplier:row2Spacer];
|
||||
|
||||
[self buildRow:self.row3 withKeys:self.keysForRows[2]];
|
||||
[self buildRow:self.row4 withKeys:self.keysForRows[3]];
|
||||
}
|
||||
|
||||
- (CGFloat)kb_scaledValue:(CGFloat)designValue {
|
||||
KBKeyboardLayoutConfig *config = [self kb_layoutConfig];
|
||||
if (config) {
|
||||
return [config scaledValue:designValue];
|
||||
}
|
||||
return KBFit(designValue);
|
||||
}
|
||||
|
||||
- (CGFloat)kb_numberValue:(NSNumber *)value defaultValue:(CGFloat)defaultValue {
|
||||
if ([value isKindOfClass:[NSNumber class]]) {
|
||||
return value.doubleValue;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
- (CGFloat)kb_metricValue:(NSNumber *)value fallback:(NSNumber *)fallback defaultValue:(CGFloat)defaultValue {
|
||||
CGFloat v = [self kb_numberValue:value defaultValue:-1.0];
|
||||
if (v < 0.0) {
|
||||
v = [self kb_numberValue:fallback defaultValue:defaultValue];
|
||||
}
|
||||
if (v < 0.0) {
|
||||
v = defaultValue;
|
||||
}
|
||||
return [self kb_scaledValue:v];
|
||||
}
|
||||
|
||||
- (CGFloat)kb_rowHeightForRow:(KBKeyboardRowConfig *)row {
|
||||
KBKeyboardLayoutConfig *config = [self kb_layoutConfig];
|
||||
NSNumber *height = row.height ?: config.metrics.keyHeight;
|
||||
CGFloat value = [self kb_numberValue:height defaultValue:40.0];
|
||||
return [self kb_scaledValue:value];
|
||||
}
|
||||
|
||||
- (CGFloat)kb_gapForRow:(KBKeyboardRowConfig *)row {
|
||||
KBKeyboardLayoutConfig *config = [self kb_layoutConfig];
|
||||
return [self kb_metricValue:row.gap fallback:config.metrics.gap defaultValue:5.0];
|
||||
}
|
||||
|
||||
- (CGFloat)kb_insetLeftForRow:(KBKeyboardRowConfig *)row {
|
||||
KBKeyboardLayoutConfig *config = [self kb_layoutConfig];
|
||||
return [self kb_metricValue:row.insetLeft fallback:config.metrics.edgeInset defaultValue:0.0];
|
||||
}
|
||||
|
||||
- (CGFloat)kb_insetRightForRow:(KBKeyboardRowConfig *)row {
|
||||
KBKeyboardLayoutConfig *config = [self kb_layoutConfig];
|
||||
return [self kb_metricValue:row.insetRight fallback:config.metrics.edgeInset defaultValue:0.0];
|
||||
}
|
||||
|
||||
- (KBKeyButton *)kb_buttonForItem:(KBKeyboardRowItem *)item {
|
||||
if (item.itemId.length == 0) { return nil; }
|
||||
KBKeyboardKeyDef *def = [[self kb_layoutConfig] keyDefForIdentifier:item.itemId];
|
||||
KBKey *key = [self kb_keyForItemId:item.itemId];
|
||||
if (!key) { return nil; }
|
||||
|
||||
KBKeyButton *btn = [[KBKeyButton alloc] init];
|
||||
btn.key = key;
|
||||
[btn setTitle:key.title forState:UIControlStateNormal];
|
||||
|
||||
UIColor *bgColor = [self kb_backgroundColorForItem:item keyDef:def];
|
||||
if (bgColor) {
|
||||
btn.customBackgroundColor = bgColor;
|
||||
}
|
||||
|
||||
CGFloat fontSize = [self kb_fontSizeForItem:item key:key];
|
||||
if (fontSize > 0.0) {
|
||||
btn.titleLabel.font = [UIFont systemFontOfSize:fontSize weight:UIFontWeightSemibold];
|
||||
}
|
||||
|
||||
[btn applyThemeForCurrentKey];
|
||||
[btn addTarget:self action:@selector(onKeyTapped:) forControlEvents:UIControlEventTouchDown];
|
||||
|
||||
if (key.type == KBKeyTypeBackspace) {
|
||||
[self.backspaceHandler bindDeleteButton:btn showClearLabel:YES];
|
||||
}
|
||||
if (key.type == KBKeyTypeShift) {
|
||||
btn.selected = self.shiftOn;
|
||||
}
|
||||
[self kb_applySymbolIfNeededForButton:btn keyDef:def fontSize:fontSize];
|
||||
return btn;
|
||||
}
|
||||
|
||||
- (void)kb_applySymbolIfNeededForButton:(KBKeyButton *)button
|
||||
keyDef:(KBKeyboardKeyDef *)def
|
||||
fontSize:(CGFloat)fontSize {
|
||||
if (!button || !def) { return; }
|
||||
if (button.iconView.image != nil) { return; }
|
||||
NSString *symbolName = button.isSelected ? def.selectedSymbolName : def.symbolName;
|
||||
if (symbolName.length == 0) { return; }
|
||||
|
||||
UIImage *image = [UIImage systemImageNamed:symbolName];
|
||||
if (!image) { return; }
|
||||
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:fontSize weight:UIFontWeightSemibold];
|
||||
image = [image imageWithConfiguration:config];
|
||||
|
||||
button.iconView.image = image;
|
||||
button.iconView.hidden = NO;
|
||||
button.iconView.contentMode = UIViewContentModeCenter;
|
||||
button.titleLabel.hidden = YES;
|
||||
|
||||
UIColor *textColor = [KBSkinManager shared].current.keyTextColor ?: [UIColor blackColor];
|
||||
button.iconView.tintColor = button.isSelected ? [UIColor blackColor] : textColor;
|
||||
}
|
||||
|
||||
- (UIColor *)kb_backgroundColorForItem:(KBKeyboardRowItem *)item keyDef:(KBKeyboardKeyDef *)def {
|
||||
NSString *hex = def.backgroundColor;
|
||||
if (hex.length == 0) {
|
||||
hex = [self kb_layoutConfig].defaultKeyBackground;
|
||||
}
|
||||
if (hex.length == 0) { return nil; }
|
||||
return [KBSkinManager colorFromHexString:hex defaultColor:nil];
|
||||
}
|
||||
|
||||
- (CGFloat)kb_metricWidthForKey:(NSString *)key {
|
||||
KBKeyboardLayoutMetrics *m = [self kb_layoutConfig].metrics;
|
||||
if ([key isEqualToString:@"letterWidth"]) { return m.letterWidth.doubleValue; }
|
||||
if ([key isEqualToString:@"controlWidth"]) { return m.controlWidth.doubleValue; }
|
||||
if ([key isEqualToString:@"sendWidth"]) { return m.sendWidth.doubleValue; }
|
||||
if ([key isEqualToString:@"symbolsWideWidth"]) { return m.symbolsWideWidth.doubleValue; }
|
||||
if ([key isEqualToString:@"symbolsSideWidth"]) { return m.symbolsSideWidth.doubleValue; }
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
- (CGFloat)kb_widthForItem:(KBKeyboardRowItem *)item key:(KBKey *)key {
|
||||
CGFloat width = 0.0;
|
||||
if (item.widthValue.doubleValue > 0.0) {
|
||||
width = item.widthValue.doubleValue;
|
||||
} else if (item.width.length > 0) {
|
||||
if ([item.width.lowercaseString isEqualToString:@"flex"]) {
|
||||
return 0.0;
|
||||
}
|
||||
width = [self kb_metricWidthForKey:item.width];
|
||||
if (width <= 0.0) {
|
||||
width = item.width.doubleValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (width <= 0.0) {
|
||||
KBKeyboardLayoutMetrics *m = [self kb_layoutConfig].metrics;
|
||||
if ([item.itemId hasPrefix:@"letter:"] ||
|
||||
[item.itemId hasPrefix:@"digit:"] ||
|
||||
[item.itemId hasPrefix:@"sym:"]) {
|
||||
width = m.letterWidth.doubleValue;
|
||||
} else if (key.type == KBKeyTypeReturn) {
|
||||
width = m.sendWidth.doubleValue;
|
||||
} else if (key.type == KBKeyTypeSpace) {
|
||||
return 0.0;
|
||||
} else {
|
||||
width = m.controlWidth.doubleValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (width <= 0.0) {
|
||||
if ([item.itemId hasPrefix:@"letter:"] ||
|
||||
[item.itemId hasPrefix:@"digit:"] ||
|
||||
[item.itemId hasPrefix:@"sym:"]) {
|
||||
width = 32.0;
|
||||
} else if (key.type == KBKeyTypeReturn) {
|
||||
width = 88.0;
|
||||
} else if (key.type == KBKeyTypeSpace) {
|
||||
return 0.0;
|
||||
} else {
|
||||
width = 41.0;
|
||||
}
|
||||
}
|
||||
|
||||
return width > 0.0 ? [self kb_scaledValue:width] : 0.0;
|
||||
}
|
||||
|
||||
- (CGFloat)kb_fontSizeForItem:(KBKeyboardRowItem *)item key:(KBKey *)key {
|
||||
NSString *fontKey = nil;
|
||||
if ([item.itemId hasPrefix:@"letter:"]) {
|
||||
fontKey = @"letter";
|
||||
} else if ([item.itemId hasPrefix:@"digit:"]) {
|
||||
fontKey = @"digit";
|
||||
} else if ([item.itemId hasPrefix:@"sym:"]) {
|
||||
fontKey = @"symbol";
|
||||
} else {
|
||||
KBKeyboardKeyDef *def = [[self kb_layoutConfig] keyDefForIdentifier:item.itemId];
|
||||
fontKey = def.font;
|
||||
}
|
||||
|
||||
if (fontKey.length == 0) {
|
||||
switch (key.type) {
|
||||
case KBKeyTypeModeChange:
|
||||
case KBKeyTypeSymbolsToggle:
|
||||
fontKey = @"mode";
|
||||
break;
|
||||
case KBKeyTypeSpace:
|
||||
fontKey = @"space";
|
||||
break;
|
||||
case KBKeyTypeReturn:
|
||||
fontKey = @"send";
|
||||
break;
|
||||
default:
|
||||
fontKey = @"symbol";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [self kb_fontSizeForFontKey:fontKey];
|
||||
}
|
||||
|
||||
- (CGFloat)kb_fontSizeForFontKey:(NSString *)fontKey {
|
||||
KBKeyboardLayoutFonts *fonts = [self kb_layoutConfig].fonts;
|
||||
CGFloat size = 0.0;
|
||||
if ([fontKey isEqualToString:@"letter"]) { size = fonts.letter.doubleValue; }
|
||||
else if ([fontKey isEqualToString:@"digit"]) { size = fonts.digit.doubleValue; }
|
||||
else if ([fontKey isEqualToString:@"symbol"]) { size = fonts.symbol.doubleValue; }
|
||||
else if ([fontKey isEqualToString:@"mode"]) { size = fonts.mode.doubleValue; }
|
||||
else if ([fontKey isEqualToString:@"space"]) { size = fonts.space.doubleValue; }
|
||||
else if ([fontKey isEqualToString:@"send"]) { size = fonts.send.doubleValue; }
|
||||
if (size <= 0.0) { size = 18.0; }
|
||||
return [self kb_scaledValue:size];
|
||||
}
|
||||
|
||||
- (KBKey *)kb_keyForItemId:(NSString *)itemId {
|
||||
if (itemId.length == 0) { return nil; }
|
||||
KBKeyboardKeyDef *def = [[self kb_layoutConfig] keyDefForIdentifier:itemId];
|
||||
if (def) {
|
||||
return [self kb_keyFromDef:def identifier:itemId];
|
||||
}
|
||||
|
||||
NSRange range = [itemId rangeOfString:@":"];
|
||||
if (range.location != NSNotFound) {
|
||||
NSString *prefix = [itemId substringToIndex:range.location];
|
||||
NSString *value = [itemId substringFromIndex:range.location + 1];
|
||||
if ([prefix isEqualToString:@"letter"]) {
|
||||
if (value.length == 1) {
|
||||
return [self kb_letterKeyWithChar:value];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
if ([prefix isEqualToString:@"digit"]) {
|
||||
NSString *identifier = [NSString stringWithFormat:@"digit_%@", value];
|
||||
KBKey *k = [KBKey keyWithIdentifier:identifier title:value output:value type:KBKeyTypeCharacter];
|
||||
k.caseVariant = KBKeyCaseVariantNone;
|
||||
return k;
|
||||
}
|
||||
if ([prefix isEqualToString:@"sym"]) {
|
||||
NSString *identifier = [self kb_identifierForSymbol:value];
|
||||
KBKey *k = [KBKey keyWithIdentifier:identifier title:value output:value type:KBKeyTypeCharacter];
|
||||
k.caseVariant = KBKeyCaseVariantNone;
|
||||
return k;
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (KBKey *)kb_keyFromDef:(KBKeyboardKeyDef *)def identifier:(NSString *)identifier {
|
||||
KBKeyType type = [self kb_keyTypeForDef:def];
|
||||
NSString *title = def.title ?: @"";
|
||||
if (type == KBKeyTypeShift && self.shiftOn && def.selectedTitle.length > 0) {
|
||||
title = def.selectedTitle;
|
||||
}
|
||||
NSString *output = @"";
|
||||
switch (type) {
|
||||
case KBKeyTypeSpace:
|
||||
output = @" ";
|
||||
break;
|
||||
case KBKeyTypeReturn:
|
||||
output = @"\n";
|
||||
break;
|
||||
default:
|
||||
output = @"";
|
||||
break;
|
||||
}
|
||||
|
||||
NSString *finalId = identifier;
|
||||
if ([identifier isEqualToString:@"emoji"]) {
|
||||
finalId = KBKeyIdentifierEmojiPanel;
|
||||
} else if ([identifier isEqualToString:@"send"]) {
|
||||
finalId = @"return";
|
||||
}
|
||||
|
||||
KBKey *k = [KBKey keyWithIdentifier:finalId title:title output:output type:type];
|
||||
if (type == KBKeyTypeShift) {
|
||||
k.caseVariant = self.shiftOn ? KBKeyCaseVariantUpper : KBKeyCaseVariantLower;
|
||||
} else {
|
||||
k.caseVariant = KBKeyCaseVariantNone;
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
- (KBKeyType)kb_keyTypeForDef:(KBKeyboardKeyDef *)def {
|
||||
NSString *type = def.type.lowercaseString;
|
||||
if ([type isEqualToString:@"shift"]) return KBKeyTypeShift;
|
||||
if ([type isEqualToString:@"backspace"]) return KBKeyTypeBackspace;
|
||||
if ([type isEqualToString:@"mode"]) return KBKeyTypeModeChange;
|
||||
if ([type isEqualToString:@"symbolstoggle"]) return KBKeyTypeSymbolsToggle;
|
||||
if ([type isEqualToString:@"space"]) return KBKeyTypeSpace;
|
||||
if ([type isEqualToString:@"return"]) return KBKeyTypeReturn;
|
||||
if ([type isEqualToString:@"globe"]) return KBKeyTypeGlobe;
|
||||
return KBKeyTypeCustom;
|
||||
}
|
||||
|
||||
- (NSString *)kb_identifierForSymbol:(NSString *)symbol {
|
||||
if (symbol.length == 0) { return nil; }
|
||||
static NSDictionary<NSString *, NSString *> *map = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
dispatch_once(&onceToken, ^{
|
||||
map = @{
|
||||
@"-": @"sym_minus",
|
||||
@"/": @"sym_slash",
|
||||
@":": @"sym_colon",
|
||||
@";": @"sym_semicolon",
|
||||
@"(": @"sym_paren_l",
|
||||
@")": @"sym_paren_r",
|
||||
@"¥": @"sym_money",
|
||||
@"¥": @"sym_money",
|
||||
@"&": @"sym_amp",
|
||||
@"@": @"sym_at",
|
||||
@"\"": @"sym_quote_double",
|
||||
@"“": @"sym_quote_double",
|
||||
@"”": @"sym_quote_double",
|
||||
@".": @"sym_dot",
|
||||
@",": @"sym_comma",
|
||||
@"?": @"sym_question",
|
||||
@"!": @"sym_exclam",
|
||||
@"'": @"sym_quote_single",
|
||||
@"‘": @"sym_quote_single",
|
||||
@"’": @"sym_quote_single",
|
||||
@"[": @"sym_bracket_l",
|
||||
@"]": @"sym_bracket_r",
|
||||
@"{": @"sym_brace_l",
|
||||
@"}": @"sym_brace_r",
|
||||
@"#": @"sym_hash",
|
||||
@"%": @"sym_percent",
|
||||
@"^": @"sym_caret",
|
||||
@"*": @"sym_asterisk",
|
||||
@"+": @"sym_plus",
|
||||
@"=": @"sym_equal",
|
||||
@"_": @"sym_underscore",
|
||||
@"\\": @"sym_backslash",
|
||||
@"|": @"sym_pipe",
|
||||
@"~": @"sym_tilde",
|
||||
@"<": @"sym_lt",
|
||||
@">": @"sym_gt",
|
||||
@"€": @"sym_euro",
|
||||
@"$": @"sym_dollar",
|
||||
@"·": @"sym_bullet"
|
||||
};
|
||||
});
|
||||
return map[symbol];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)onKeyTapped:(KBKeyButton *)sender {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
#import "KBStreamTextView.h"
|
||||
#import "KBResponderUtils.h" // 通过响应链找到 UIInputViewController,并将文本输出到宿主
|
||||
#import "KBInputBufferManager.h"
|
||||
|
||||
@interface KBStreamTextView ()
|
||||
|
||||
@@ -359,7 +360,6 @@ static inline NSString *KBTrimRight(NSString *s) {
|
||||
contextAfter = proxy.documentContextAfterInput ?: @"";
|
||||
}
|
||||
}
|
||||
|
||||
NSString *contextBefore = proxy.documentContextBeforeInput ?: @"";
|
||||
while (contextBefore.length > 0) {
|
||||
for (NSUInteger i = 0; i < contextBefore.length; i++) {
|
||||
@@ -371,6 +371,17 @@ static inline NSString *KBTrimRight(NSString *s) {
|
||||
if (rawText.length > 0) {
|
||||
[proxy insertText:rawText];
|
||||
}
|
||||
NSMutableDictionary *extra = [NSMutableDictionary dictionary];
|
||||
extra[@"send_text"] = rawText ? rawText : @"";
|
||||
extra[@"index"] = index ? rawText : 0;
|
||||
|
||||
[[KBMaiPointReporter sharedReporter]
|
||||
reportClickWithEventName:@"send_stream_text_action"
|
||||
pageId:@"keyboard_StreamTextView"
|
||||
elementId:@"handle_label"
|
||||
extra:extra.copy
|
||||
completion:nil];
|
||||
[[KBInputBufferManager shared] resetWithText:rawText ?: @""];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
26
CustomKeyboard/View/KBSuggestionBarView.h
Normal file
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// KBSuggestionBarView.h
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@class KBSuggestionBarView;
|
||||
@class KBSkinTheme;
|
||||
|
||||
@protocol KBSuggestionBarViewDelegate <NSObject>
|
||||
- (void)suggestionBarView:(KBSuggestionBarView *)view didSelectSuggestion:(NSString *)suggestion;
|
||||
@end
|
||||
|
||||
@interface KBSuggestionBarView : UIView
|
||||
|
||||
@property (nonatomic, weak) id<KBSuggestionBarViewDelegate> delegate;
|
||||
|
||||
- (void)updateSuggestions:(NSArray<NSString *> *)suggestions;
|
||||
- (void)applyTheme:(KBSkinTheme *)theme;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
114
CustomKeyboard/View/KBSuggestionBarView.m
Normal file
@@ -0,0 +1,114 @@
|
||||
//
|
||||
// KBSuggestionBarView.m
|
||||
// CustomKeyboard
|
||||
//
|
||||
|
||||
#import "KBSuggestionBarView.h"
|
||||
#import "Masonry.h"
|
||||
#import "KBSkinManager.h"
|
||||
|
||||
@interface KBSuggestionBarView ()
|
||||
@property (nonatomic, strong) UIScrollView *scrollView;
|
||||
@property (nonatomic, strong) UIStackView *stackView;
|
||||
@property (nonatomic, copy) NSArray<NSString *> *items;
|
||||
@property (nonatomic, strong) UIColor *pillColor;
|
||||
@property (nonatomic, strong) UIColor *textColor;
|
||||
@end
|
||||
|
||||
@implementation KBSuggestionBarView
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
self.backgroundColor = [UIColor clearColor];
|
||||
[self setupUI];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setupUI {
|
||||
[self addSubview:self.scrollView];
|
||||
[self.scrollView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self);
|
||||
}];
|
||||
|
||||
[self.scrollView addSubview:self.stackView];
|
||||
[self.stackView mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.edges.equalTo(self.scrollView).insets(UIEdgeInsetsMake(0, 8, 0, 8));
|
||||
make.height.equalTo(self.scrollView);
|
||||
}];
|
||||
|
||||
[self applyTheme:[KBSkinManager shared].current];
|
||||
}
|
||||
|
||||
- (void)updateSuggestions:(NSArray<NSString *> *)suggestions {
|
||||
self.items = suggestions ?: @[];
|
||||
|
||||
for (UIView *view in self.stackView.arrangedSubviews) {
|
||||
[self.stackView removeArrangedSubview:view];
|
||||
[view removeFromSuperview];
|
||||
}
|
||||
|
||||
for (NSString *item in self.items) {
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
btn.layer.cornerRadius = 12.0;
|
||||
btn.layer.masksToBounds = YES;
|
||||
btn.backgroundColor = self.pillColor ?: [UIColor colorWithWhite:1 alpha:0.9];
|
||||
btn.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
|
||||
[btn setTitle:item forState:UIControlStateNormal];
|
||||
[btn setTitleColor:self.textColor ?: [UIColor blackColor] forState:UIControlStateNormal];
|
||||
btn.contentEdgeInsets = UIEdgeInsetsMake(4, 10, 4, 10);
|
||||
[btn addTarget:self action:@selector(onTapSuggestion:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.stackView addArrangedSubview:btn];
|
||||
}
|
||||
|
||||
self.hidden = (self.items.count == 0);
|
||||
}
|
||||
|
||||
- (void)applyTheme:(KBSkinTheme *)theme {
|
||||
UIColor *bg = theme.keyBackground ?: [UIColor whiteColor];
|
||||
UIColor *text = theme.keyTextColor ?: [UIColor blackColor];
|
||||
UIColor *barBg = [UIColor colorWithHex:0xD1D3DB];
|
||||
self.backgroundColor = barBg;
|
||||
self.pillColor = bg;
|
||||
self.textColor = text;
|
||||
|
||||
for (UIView *view in self.stackView.arrangedSubviews) {
|
||||
if (![view isKindOfClass:[UIButton class]]) { continue; }
|
||||
UIButton *btn = (UIButton *)view;
|
||||
btn.backgroundColor = self.pillColor;
|
||||
[btn setTitleColor:self.textColor forState:UIControlStateNormal];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)onTapSuggestion:(UIButton *)sender {
|
||||
NSString *title = sender.currentTitle ?: @"";
|
||||
if (title.length == 0) { return; }
|
||||
if ([self.delegate respondsToSelector:@selector(suggestionBarView:didSelectSuggestion:)]) {
|
||||
[self.delegate suggestionBarView:self didSelectSuggestion:title];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIScrollView *)scrollView {
|
||||
if (!_scrollView) {
|
||||
_scrollView = [[UIScrollView alloc] init];
|
||||
_scrollView.showsHorizontalScrollIndicator = NO;
|
||||
_scrollView.alwaysBounceHorizontal = YES;
|
||||
}
|
||||
return _scrollView;
|
||||
}
|
||||
|
||||
- (UIStackView *)stackView {
|
||||
if (!_stackView) {
|
||||
_stackView = [[UIStackView alloc] init];
|
||||
_stackView.axis = UILayoutConstraintAxisHorizontal;
|
||||
_stackView.alignment = UIStackViewAlignmentCenter;
|
||||
_stackView.spacing = 8.0;
|
||||
}
|
||||
return _stackView;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -9,22 +9,29 @@
|
||||
#import "KBResponderUtils.h" // 查找 UIInputViewController,用于系统切换输入法
|
||||
#import "KBBackspaceUndoManager.h"
|
||||
#import "KBSkinManager.h"
|
||||
#import <ImageIO/ImageIO.h>
|
||||
|
||||
@interface KBToolBar ()
|
||||
@property (nonatomic, strong) UIView *leftContainer;
|
||||
@property (nonatomic, strong) NSArray<UIButton *> *leftButtonsInternal;
|
||||
//@property (nonatomic, strong) UIButton *settingsButtonInternal;
|
||||
@property (nonatomic, strong) UIButton *globeButtonInternal; // 可选:系统“切换输入法”键
|
||||
@property (nonatomic, strong) UIImageView *avatarImageView; // 右侧头像(AppGroup persona_cover.jpg)
|
||||
@property (nonatomic, strong) UIButton *undoButtonInternal; // 右侧撤销删除
|
||||
@property (nonatomic, assign) BOOL kbNeedsInputModeSwitchKey;
|
||||
@property (nonatomic, assign) BOOL kbUndoVisible;
|
||||
@property (nonatomic, assign) BOOL kbAvatarVisible;
|
||||
@property (nonatomic, copy, nullable) NSString *kb_cachedPersonaCoverPath;
|
||||
@property (nonatomic, strong, nullable) UIImage *kb_cachedPersonaCoverImage;
|
||||
@end
|
||||
|
||||
@implementation KBToolBar
|
||||
|
||||
static NSString * const kKBAIKeyIdentifier = @"ai";
|
||||
static const CGFloat kKBAIButtonWidth = 40;
|
||||
static const CGFloat kKBAIButtonHeight = 40;
|
||||
static NSString * const kKBAIKeyIdentifier = @"ai";
|
||||
static NSString * const kKBUndoKeyIdentifier = @"key_revoke";
|
||||
static const CGFloat kKBAIButtonWidth = 40;
|
||||
static const CGFloat kKBAIButtonHeight = 40;
|
||||
static const CGFloat kKBAvatarSize = 40;
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame{
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
@@ -76,6 +83,7 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
// [self addSubview:self.settingsButtonInternal];
|
||||
[self addSubview:self.globeButtonInternal];
|
||||
[self addSubview:self.undoButtonInternal];
|
||||
[self addSubview:self.avatarImageView];
|
||||
|
||||
// 右侧设置按钮
|
||||
// [self.settingsButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
@@ -91,12 +99,7 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
make.width.height.mas_equalTo(32);
|
||||
}];
|
||||
|
||||
// 右侧撤销按钮
|
||||
[self.undoButtonInternal mas_makeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(self.mas_right).offset(-12);
|
||||
make.centerY.equalTo(self.mas_centerY);
|
||||
make.height.mas_equalTo(32);
|
||||
}];
|
||||
[self kb_updateRightControlsConstraints];
|
||||
|
||||
[self kb_updateLeftContainerConstraints];
|
||||
|
||||
@@ -169,6 +172,8 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
|
||||
- (void)kb_applyTheme {
|
||||
[self kb_updateAIButtonAppearance];
|
||||
[self kb_updateUndoButtonAppearance];
|
||||
[self kb_updateAvatarAppearance];
|
||||
}
|
||||
|
||||
- (void)kb_updateAIButtonAppearance {
|
||||
@@ -205,6 +210,92 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)kb_updateUndoButtonAppearance {
|
||||
if (!self.undoButtonInternal) { return; }
|
||||
|
||||
KBSkinManager *skinManager = [KBSkinManager shared];
|
||||
UIImage *icon = [skinManager iconImageForKeyIdentifier:kKBUndoKeyIdentifier caseVariant:0];
|
||||
if (!icon) {
|
||||
icon = [UIImage imageNamed:@"key_revoke"];
|
||||
}
|
||||
|
||||
if (icon) {
|
||||
[self.undoButtonInternal setImage:icon forState:UIControlStateNormal];
|
||||
[self.undoButtonInternal setImage:icon forState:UIControlStateHighlighted];
|
||||
[self.undoButtonInternal setImage:icon forState:UIControlStateSelected];
|
||||
} else {
|
||||
[self.undoButtonInternal setImage:nil forState:UIControlStateNormal];
|
||||
[self.undoButtonInternal setImage:nil forState:UIControlStateHighlighted];
|
||||
[self.undoButtonInternal setImage:nil forState:UIControlStateSelected];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Avatar
|
||||
|
||||
- (void)kb_updateAvatarAppearance {
|
||||
UIImage *img = [self kb_personaCoverImageFromAppGroup];
|
||||
BOOL shouldShow = (img != nil);
|
||||
self.avatarImageView.image = img;
|
||||
if (self.kbAvatarVisible == shouldShow) {
|
||||
self.avatarImageView.hidden = !shouldShow;
|
||||
return;
|
||||
}
|
||||
self.kbAvatarVisible = shouldShow;
|
||||
self.avatarImageView.hidden = !shouldShow;
|
||||
[self kb_updateRightControlsConstraints];
|
||||
[self kb_updateLeftContainerConstraints];
|
||||
[self setNeedsLayout];
|
||||
[self layoutIfNeeded];
|
||||
}
|
||||
|
||||
- (nullable UIImage *)kb_personaCoverImageFromAppGroup {
|
||||
NSURL *containerURL = [[NSFileManager defaultManager]
|
||||
containerURLForSecurityApplicationGroupIdentifier:AppGroup];
|
||||
if (!containerURL) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSString *imagePath =
|
||||
[[containerURL path] stringByAppendingPathComponent:@"persona_cover.jpg"];
|
||||
if (imagePath.length == 0 ||
|
||||
![[NSFileManager defaultManager] fileExistsAtPath:imagePath]) {
|
||||
self.kb_cachedPersonaCoverPath = nil;
|
||||
self.kb_cachedPersonaCoverImage = nil;
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (self.kb_cachedPersonaCoverImage &&
|
||||
[self.kb_cachedPersonaCoverPath isEqualToString:imagePath]) {
|
||||
return self.kb_cachedPersonaCoverImage;
|
||||
}
|
||||
|
||||
// 头像仅 40pt,直接按像素上限缩略解码,避免每次显示键盘都 full decode 一张大 JPG 顶爆扩展内存。
|
||||
NSUInteger maxPixel = 256;
|
||||
NSURL *url = [NSURL fileURLWithPath:imagePath];
|
||||
CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)url, NULL);
|
||||
if (!source) {
|
||||
return nil;
|
||||
}
|
||||
NSDictionary *opts = @{
|
||||
(__bridge id)kCGImageSourceCreateThumbnailFromImageAlways : @YES,
|
||||
(__bridge id)kCGImageSourceCreateThumbnailWithTransform : @YES,
|
||||
(__bridge id)kCGImageSourceThumbnailMaxPixelSize : @(maxPixel),
|
||||
};
|
||||
CGImageRef cg = CGImageSourceCreateThumbnailAtIndex(source, 0, (__bridge CFDictionaryRef)opts);
|
||||
CFRelease(source);
|
||||
if (!cg) {
|
||||
return nil;
|
||||
}
|
||||
UIImage *img = [UIImage imageWithCGImage:cg
|
||||
scale:[UIScreen mainScreen].scale
|
||||
orientation:UIImageOrientationUp];
|
||||
CGImageRelease(cg);
|
||||
|
||||
self.kb_cachedPersonaCoverPath = imagePath;
|
||||
self.kb_cachedPersonaCoverImage = img;
|
||||
return img;
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)onLeftAction:(UIButton *)sender {
|
||||
@@ -225,6 +316,16 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onAvatarTap {
|
||||
if (!self.kbAvatarVisible || self.avatarImageView.hidden) {
|
||||
return;
|
||||
}
|
||||
// 复用原“语音”入口的 index=1 逻辑(外部会按 index 做面板切换)
|
||||
if ([self.delegate respondsToSelector:@selector(toolBar:didTapActionAtIndex:)]) {
|
||||
[self.delegate toolBar:self didTapActionAtIndex:1];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Lazy
|
||||
|
||||
- (UIView *)leftContainer {
|
||||
@@ -235,6 +336,23 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
return _leftContainer;
|
||||
}
|
||||
|
||||
- (UIImageView *)avatarImageView {
|
||||
if (!_avatarImageView) {
|
||||
_avatarImageView = [[UIImageView alloc] init];
|
||||
_avatarImageView.hidden = YES;
|
||||
_avatarImageView.backgroundColor = [UIColor colorWithWhite:1 alpha:0.9];
|
||||
_avatarImageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
_avatarImageView.layer.cornerRadius = kKBAvatarSize * 0.5;
|
||||
_avatarImageView.layer.masksToBounds = YES;
|
||||
_avatarImageView.userInteractionEnabled = YES;
|
||||
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc]
|
||||
initWithTarget:self
|
||||
action:@selector(onAvatarTap)];
|
||||
[_avatarImageView addGestureRecognizer:tap];
|
||||
}
|
||||
return _avatarImageView;
|
||||
}
|
||||
|
||||
//- (UIButton *)settingsButtonInternal {
|
||||
// if (!_settingsButtonInternal) {
|
||||
// _settingsButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
@@ -262,14 +380,15 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
|
||||
- (UIButton *)undoButtonInternal {
|
||||
if (!_undoButtonInternal) {
|
||||
_undoButtonInternal = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
_undoButtonInternal.layer.cornerRadius = 16;
|
||||
_undoButtonInternal.layer.masksToBounds = YES;
|
||||
_undoButtonInternal.backgroundColor = [UIColor colorWithWhite:1 alpha:0.9];
|
||||
_undoButtonInternal.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
|
||||
[_undoButtonInternal setTitle:@"撤销删除" forState:UIControlStateNormal];
|
||||
[_undoButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
|
||||
_undoButtonInternal.contentEdgeInsets = UIEdgeInsetsMake(0, 10, 0, 10);
|
||||
_undoButtonInternal = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
// _undoButtonInternal.layer.cornerRadius = 16;
|
||||
// _undoButtonInternal.layer.masksToBounds = YES;
|
||||
// _undoButtonInternal.backgroundColor = [UIColor colorWithWhite:1 alpha:0.9];
|
||||
// _undoButtonInternal.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightMedium];
|
||||
// [_undoButtonInternal setTitle:@"撤销删除" forState:UIControlStateNormal];
|
||||
// [_undoButtonInternal setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
|
||||
// _undoButtonInternal.contentEdgeInsets = UIEdgeInsetsMake(0, 10, 0, 10);
|
||||
[_undoButtonInternal setImage:[UIImage imageNamed:@"key_revoke"] forState:UIControlStateNormal];
|
||||
_undoButtonInternal.hidden = YES;
|
||||
_undoButtonInternal.alpha = 0.0;
|
||||
[_undoButtonInternal addTarget:self action:@selector(onUndo) forControlEvents:UIControlEventTouchUpInside];
|
||||
@@ -320,6 +439,8 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
}
|
||||
if (self.kbUndoVisible) {
|
||||
make.right.equalTo(self.undoButtonInternal.mas_left).offset(-8);
|
||||
} else if (self.kbAvatarVisible) {
|
||||
make.right.equalTo(self.avatarImageView.mas_left).offset(-8);
|
||||
} else {
|
||||
make.right.equalTo(self).offset(-12);
|
||||
}
|
||||
@@ -329,6 +450,24 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_updateRightControlsConstraints {
|
||||
[self.avatarImageView mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
make.right.equalTo(self).offset(-12);
|
||||
make.centerY.equalTo(self).offset(0);
|
||||
make.width.height.mas_equalTo(kKBAvatarSize);
|
||||
}];
|
||||
[self.undoButtonInternal mas_remakeConstraints:^(MASConstraintMaker *make) {
|
||||
if (self.kbAvatarVisible) {
|
||||
make.right.equalTo(self.avatarImageView.mas_left).offset(-8);
|
||||
} else {
|
||||
make.right.equalTo(self).offset(-12);
|
||||
}
|
||||
make.centerY.equalTo(self.mas_centerY);
|
||||
make.height.mas_equalTo(32);
|
||||
make.width.mas_equalTo(84);
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)kb_undoStateChanged {
|
||||
[self kb_updateUndoVisibilityAnimated:YES];
|
||||
}
|
||||
@@ -363,6 +502,7 @@ static const CGFloat kKBAIButtonHeight = 40;
|
||||
- (void)didMoveToWindow {
|
||||
[super didMoveToWindow];
|
||||
[self kb_refreshGlobeVisibility];
|
||||
[self kb_updateAvatarAppearance];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
148
KBMaiPointEventTable.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# KBMaiPoint 埋点事件表(统一口径:iOS / Android / 后端)
|
||||
|
||||
## 统一约定(全端一致)
|
||||
|
||||
### 1)事件类型(event_type)
|
||||
- 页面曝光:`page_exposure`
|
||||
- 点击事件:`click`
|
||||
|
||||
> iOS 侧可映射为:`KBMaiPointGenericReportTypePage / KBMaiPointGenericReportTypeClick`
|
||||
|
||||
### 2)事件名称(event_name)
|
||||
- 统一使用 `lower_snake_case`,不绑定任何端的类名/资源名
|
||||
- 页面曝光统一前缀:`enter_`
|
||||
- 点击事件统一前缀:`click_`
|
||||
|
||||
### 3)事件参数(value / params)
|
||||
- **所有事件都固定带**:`token`(`NSString`,有就传真实值;没有就传空字符串 `""`)
|
||||
- 建议额外固定带:`page_id`(页面/区域统一ID)
|
||||
- 点击类事件建议固定带:`element_id`(控件/入口统一ID)
|
||||
- 列表/集合类点击建议带:`index`(`NSInteger`)与业务 `id`(如 `theme_id` / `product_id`)
|
||||
|
||||
参数示例(最小):
|
||||
```json
|
||||
{ "token": "", "page_id": "shop", "element_id": "search_btn" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## A. 主工程(keyBoard)
|
||||
|
||||
### A1)页面曝光(触发:VC 的 `viewDidAppear`)
|
||||
|
||||
| 注释 | 事件类型 | 事件名称 | page_id | iOS 对应页面 | Android 对应页面 | 触发时机 | 事件参数(示例) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 进入首页 | page_exposure | enter_home_main | home_main | HomeMainVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"home_main" }` |
|
||||
| 进入首页Tab容器 | page_exposure | enter_home | home | HomeVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"home" }` |
|
||||
| 进入热门页 | page_exposure | enter_home_hot | home_hot | HomeHotVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"home_hot" }` |
|
||||
| 进入排行榜页 | page_exposure | enter_home_rank | home_rank | HomeRankVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"home_rank" }` |
|
||||
| 进入排行榜内容页 | page_exposure | enter_home_rank_content | home_rank_content | HomeRankContentVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"home_rank_content" }` |
|
||||
| 进入首页底部弹层 | page_exposure | enter_home_sheet | home_sheet | HomeSheetVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"home_sheet" }` |
|
||||
| 进入社区页 | page_exposure | enter_community | community | KBCommunityVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"community" }` |
|
||||
| 进入搜索页 | page_exposure | enter_search | search | KBSearchVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"search" }` |
|
||||
| 进入搜索结果页 | page_exposure | enter_search_result | search_result | KBSearchResultVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"search_result" }` |
|
||||
| 进入商店页 | page_exposure | enter_shop | shop | KBShopVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"shop" }` |
|
||||
| 进入商店分类列表页 | page_exposure | enter_shop_item_list | shop_item_list | KBShopItemVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"shop_item_list" }` |
|
||||
| 进入皮肤详情页 | page_exposure | enter_skin_detail | skin_detail | KBSkinDetailVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"skin_detail", "theme_id":"" }` |
|
||||
| 进入我的页 | page_exposure | enter_my | my | MyVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"my" }` |
|
||||
| 进入我的皮肤页 | page_exposure | enter_my_skin | my_skin | MySkinVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"my_skin" }` |
|
||||
| 进入我的键盘配置页 | page_exposure | enter_my_keyboard | my_keyboard | KBMyKeyBoardVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"my_keyboard" }` |
|
||||
| 进入个人信息页 | page_exposure | enter_person_info | person_info | KBPersonInfoVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"person_info" }` |
|
||||
| 进入反馈页 | page_exposure | enter_feedback | feedback | KBFeedBackVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"feedback" }` |
|
||||
| 进入公告页 | page_exposure | enter_notice | notice | KBNoticeVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"notice" }` |
|
||||
| 进入消费记录页 | page_exposure | enter_consumption_record | consumption_record | KBConsumptionRecordVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"consumption_record" }` |
|
||||
| 进入VIP购买页 | page_exposure | enter_vip_pay | vip_pay | KBVipPay | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"vip_pay" }` |
|
||||
| 进入积分充值页 | page_exposure | enter_points_recharge | points_recharge | KBJfPay | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"points_recharge" }` |
|
||||
| 进入登录页 | page_exposure | enter_login | login | KBLoginVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"login" }` |
|
||||
| 进入邮箱登录页 | page_exposure | enter_login_email | login_email | KBEmailLoginVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"login_email" }` |
|
||||
| 进入邮箱注册页 | page_exposure | enter_register_email | register_email | KBEmailRegistVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"register_email" }` |
|
||||
| 进入注册验证码页 | page_exposure | enter_register_verify_email | register_verify_email | KBRegistVerEmailVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"register_verify_email" }` |
|
||||
| 进入忘记密码页 | page_exposure | enter_forgot_password_email | forgot_password_email | KBForgetPwdVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"forgot_password_email" }` |
|
||||
| 进入忘记密码验证码页 | page_exposure | enter_forgot_password_verify | forgot_password_verify | KBForgetVerPwdVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"forgot_password_verify" }` |
|
||||
| 进入忘记密码新密码页 | page_exposure | enter_forgot_password_newpwd | forgot_password_newpwd | KBForgetPwdNewPwdVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"forgot_password_newpwd" }` |
|
||||
| 进入键盘权限引导页(App内) | page_exposure | enter_keyboard_permission_guide | keyboard_permission_guide | KBPermissionViewController | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"keyboard_permission_guide" }` |
|
||||
| 进入首次引导页 | page_exposure | enter_guide | guide | KBGuideVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"guide" }` |
|
||||
| 进入性别选择页 | page_exposure | enter_sex_select | sex_select | KBSexSelVC | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"sex_select" }` |
|
||||
| 进入WebView页 | page_exposure | enter_webview | webview | KBWebViewViewController | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"webview", "url":"" }` |
|
||||
|
||||
> 测试/工具页(建议仅 DEBUG 或按需接入):`KBTestVC / KBLangTestVC / KBSkinCenterVC / ViewController / LoginViewController / KBLoginSheetViewController`。
|
||||
|
||||
### A2)点击事件(按钮/列表/入口)
|
||||
|
||||
| 注释 | 事件类型 | 事件名称 | page_id | element_id | iOS 对应控件/方法 | Android 对应控件 | 触发时机 | 事件参数(示例) |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 首页点击“购买会员” | click | click_home_buy_vip_btn | home_main | buy_vip_btn | HomeHeadView `onTapBuyAction` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"home_main", "element_id":"buy_vip_btn" }` |
|
||||
| 首页点击“权限悬浮按钮” | click | click_home_permission_float_btn | home_main | permission_float_btn | HomeMainVC `keyPermissButton.clickDragViewBlock` | Android 自定义 | 点击悬浮按钮 | `{ "token":"", "page_id":"home_main", "element_id":"permission_float_btn" }` |
|
||||
| 权限引导页点击“去设置” | click | click_permission_open_settings_btn | keyboard_permission_guide | open_settings_btn | KBPermissionViewController `openSettings` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_permission_guide", "element_id":"open_settings_btn" }` |
|
||||
| 权限引导页点击“关闭” | click | click_permission_close_btn | keyboard_permission_guide | close_btn | KBPermissionViewController `closeButtonAction` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_permission_guide", "element_id":"close_btn" }` |
|
||||
| 商店页点击“搜索” | click | click_shop_search_btn | shop | search_btn | KBShopVC `searchBtnAction` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"shop", "element_id":"search_btn" }` |
|
||||
| 商店页点击“我的皮肤” | click | click_shop_my_skin_btn | shop | my_skin_btn | KBShopVC `skinBtnAction` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"shop", "element_id":"my_skin_btn" }` |
|
||||
| 商店列表点击皮肤卡片 | click | click_shop_theme_card | shop_item_list | theme_card | KBShopItemVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"shop_item_list", "element_id":"theme_card", "theme_id":"", "index":0 }` |
|
||||
| 皮肤详情点击“下载/购买” | click | click_skin_download_btn | skin_detail | download_btn | KBSkinDetailVC `handleDownloadAction` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"skin_detail", "element_id":"download_btn", "theme_id":"", "purchased":0 }` |
|
||||
| 皮肤详情点击“推荐皮肤” | click | click_skin_recommend_card | skin_detail | recommend_card | KBSkinDetailVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"skin_detail", "element_id":"recommend_card", "from_theme_id":"", "to_theme_id":"", "index":0 }` |
|
||||
| 搜索栏点击搜索 | click | click_search_submit | search | search_submit | KBSearchBarView `onSearch` | Android 自定义 | 点击搜索 | `{ "token":"", "page_id":"search", "element_id":"search_submit", "keyword_len":0 }` |
|
||||
| 搜索页点击历史词条 | click | click_search_history_item | search | history_item | KBSearchVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"search", "element_id":"history_item", "index":0 }` |
|
||||
| 搜索页点击“展开更多历史” | click | click_search_history_more | search | history_more | KBSearchVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"search", "element_id":"history_more" }` |
|
||||
| 搜索页点击“清空历史” | click | click_search_clear_history | search | clear_history | KBSearchVC `clearHistory`(header trash) | Android 自定义 | 点击垃圾桶 | `{ "token":"", "page_id":"search", "element_id":"clear_history" }` |
|
||||
| 搜索页点击推荐皮肤 | click | click_search_recommend_theme | search | recommend_theme_card | KBSearchVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"search", "element_id":"recommend_theme_card", "theme_id":"", "index":0 }` |
|
||||
| 搜索结果页点击皮肤 | click | click_search_result_theme | search_result | result_theme_card | KBSearchResultVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"search_result", "element_id":"result_theme_card", "theme_id":"", "index":0 }` |
|
||||
| 我的页点击菜单项 | click | click_my_menu_item | my | menu_item | MyVC `didSelectRowAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"my", "element_id":"menu_item", "item_id":"", "item_title":"" }` |
|
||||
| 我的页点击“邀请”成功复制 | click | click_my_invite_copy | my | invite_copy | MyVC(邀请分支) | Android 自定义 | 复制时机 | `{ "token":"", "page_id":"my", "element_id":"invite_copy" }` |
|
||||
| 反馈页点击提交 | click | click_feedback_commit_btn | feedback | commit_btn | KBFeedBackVC `onTapCommit` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"feedback", "element_id":"commit_btn", "content_len":0 }` |
|
||||
| 个人信息点击更换头像 | click | click_person_avatar_edit | person_info | avatar_edit | KBPersonInfoVC `onTapAvatarEdit` | Android 自定义 | tapGesture | `{ "token":"", "page_id":"person_info", "element_id":"avatar_edit" }` |
|
||||
| 个人信息点击退出登录 | click | click_person_logout_btn | person_info | logout_btn | KBPersonInfoVC `onTapLogout` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"person_info", "element_id":"logout_btn" }` |
|
||||
| 我的键盘页点击保存 | click | click_my_keyboard_save_btn | my_keyboard | save_btn | KBMyKeyBoardVC `onSave` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"my_keyboard", "element_id":"save_btn" }` |
|
||||
| 我的皮肤页点击编辑/取消 | click | click_my_skin_toggle_edit | my_skin | toggle_edit | MySkinVC `onToggleEdit` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"my_skin", "element_id":"toggle_edit", "editing":0 }` |
|
||||
| 我的皮肤页点击删除 | click | click_my_skin_delete_btn | my_skin | delete_btn | MySkinVC `onDelete` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"my_skin", "element_id":"delete_btn", "selected_count":0 }` |
|
||||
| 我的皮肤页点击皮肤(进入详情) | click | click_my_skin_theme_card | my_skin | theme_card | MySkinVC `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"my_skin", "element_id":"theme_card", "theme_id":"", "index":0 }` |
|
||||
| 登录页点击 Apple 登录 | click | click_login_apple_btn | login | apple_btn | KBLoginVC `onTapAppleLogin` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"login", "element_id":"apple_btn" }` |
|
||||
| 登录页点击邮箱登录 | click | click_login_email_btn | login | email_btn | KBLoginVC `onTapEmailLogin` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"login", "element_id":"email_btn" }` |
|
||||
| 登录页点击注册 | click | click_login_signup_btn | login | signup_btn | KBLoginVC `onTapSignUp` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"login", "element_id":"signup_btn" }` |
|
||||
| 登录页点击忘记密码 | click | click_login_forgot_btn | login | forgot_btn | KBLoginVC `onTapForgotPassword` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"login", "element_id":"forgot_btn" }` |
|
||||
| 邮箱登录页点击提交 | click | click_login_email_submit_btn | login_email | submit_btn | KBEmailLoginVC `onTapSubmit` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"login_email", "element_id":"submit_btn" }` |
|
||||
| 邮箱注册页点击提交 | click | click_register_email_submit_btn | register_email | submit_btn | KBEmailRegistVC `onTapSubmit` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"register_email", "element_id":"submit_btn" }` |
|
||||
| 注册验证码页点击确认 | click | click_register_verify_confirm_btn | register_verify_email | confirm_btn | KBRegistVerEmailVC `onTapConfirm` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"register_verify_email", "element_id":"confirm_btn" }` |
|
||||
| 忘记密码(邮箱)点击下一步 | click | click_forgot_email_next_btn | forgot_password_email | next_btn | KBForgetPwdVC `onTapNext` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"forgot_password_email", "element_id":"next_btn" }` |
|
||||
| 忘记密码(验证码)点击下一步 | click | click_forgot_verify_next_btn | forgot_password_verify | next_btn | KBForgetVerPwdVC `onTapNext` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"forgot_password_verify", "element_id":"next_btn" }` |
|
||||
| 忘记密码(新密码)点击下一步 | click | click_forgot_newpwd_next_btn | forgot_password_newpwd | next_btn | KBForgetPwdNewPwdVC `onTapNext` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"forgot_password_newpwd", "element_id":"next_btn" }` |
|
||||
| VIP页选择套餐 | click | click_vip_select_plan | vip_pay | plan_item | KBVipPay `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"vip_pay", "element_id":"plan_item", "product_id":"", "index":0 }` |
|
||||
| VIP页点击支付 | click | click_vip_pay_btn | vip_pay | pay_btn | KBVipPay `onTapPayButton` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"vip_pay", "element_id":"pay_btn", "product_id":"" }` |
|
||||
| VIP页点击恢复购买 | click | click_vip_restore_btn | vip_pay | restore_btn | KBVipPay `onTapRestoreButton` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"vip_pay", "element_id":"restore_btn" }` |
|
||||
| VIP页点击关闭 | click | click_vip_close_btn | vip_pay | close_btn | KBVipPay `onTapClose` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"vip_pay", "element_id":"close_btn" }` |
|
||||
| 积分充值页选择商品 | click | click_points_select_product | points_recharge | product_item | KBJfPay `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"points_recharge", "element_id":"product_item", "product_id":"", "index":0 }` |
|
||||
| 积分充值页点击充值 | click | click_points_pay_btn | points_recharge | pay_btn | KBJfPay `onTapPayButton` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"points_recharge", "element_id":"pay_btn", "product_id":"" }` |
|
||||
| 引导页点击复制示例1 | click | click_guide_copy_example_1 | guide | copy_example_1 | KBGuideTopCell `kb_onTapQ1` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"guide", "element_id":"copy_example_1" }` |
|
||||
| 引导页点击复制示例2 | click | click_guide_copy_example_2 | guide | copy_example_2 | KBGuideTopCell `kb_onTapQ2` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"guide", "element_id":"copy_example_2" }` |
|
||||
|
||||
---
|
||||
|
||||
## B. 键盘扩展(CustomKeyboard)
|
||||
|
||||
### B1)页面曝光(触发:显示/切换时机)
|
||||
|
||||
| 注释 | 事件类型 | 事件名称 | page_id | iOS 对应页面/视图 | Android 对应页面 | 触发时机 | 事件参数(示例) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 键盘首次显示 | page_exposure | enter_keyboard | keyboard | KeyboardViewController | Android 自定义 | viewDidAppear | `{ "token":"", "page_id":"keyboard" }` |
|
||||
| 打开功能面板 | page_exposure | enter_keyboard_function_panel | keyboard_function_panel | KBFunctionView | Android 自定义 | showFunctionPanel:YES | `{ "token":"", "page_id":"keyboard_function_panel" }` |
|
||||
| 关闭功能面板(回到主键盘) | page_exposure | enter_keyboard_main_panel | keyboard_main_panel | KBKeyBoardMainView | Android 自定义 | showFunctionPanel:NO | `{ "token":"", "page_id":"keyboard_main_panel" }` |
|
||||
| 打开设置页 | page_exposure | enter_keyboard_settings | keyboard_settings | KBSettingView | Android 自定义 | showSettingView:YES | `{ "token":"", "page_id":"keyboard_settings" }` |
|
||||
| 打开订阅/充值面板 | page_exposure | enter_keyboard_subscription_panel | keyboard_subscription_panel | KBKeyboardSubscriptionView | Android 自定义 | showSubscriptionPanel | `{ "token":"", "page_id":"keyboard_subscription_panel" }` |
|
||||
|
||||
### B2)点击事件(键盘工具栏 / 功能面板 / 订阅面板)
|
||||
|
||||
| 注释 | 事件类型 | 事件名称 | page_id | element_id | iOS 对应控件/方法 | Android 对应控件 | 触发时机 | 事件参数(示例) |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 点击键盘顶部工具栏(index=0 打开功能面板) | click | click_keyboard_toolbar_action | keyboard_main_panel | toolbar_action | KBKeyBoardMainViewDelegate `didTapToolActionAtIndex:` | Android 自定义 | 点击工具栏 | `{ "token":"", "page_id":"keyboard_main_panel", "element_id":"toolbar_action", "index":0 }` |
|
||||
| 点击键盘设置按钮 | click | click_keyboard_settings_btn | keyboard_main_panel | settings_btn | `keyBoardMainViewDidTapSettings:` | Android 自定义 | 点击设置 | `{ "token":"", "page_id":"keyboard_main_panel", "element_id":"settings_btn" }` |
|
||||
| 点击设置页返回 | click | click_keyboard_settings_back_btn | keyboard_settings | back_btn | KeyboardViewController `onTapSettingsBack` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_settings", "element_id":"back_btn" }` |
|
||||
| 点击撤销删除 | click | click_keyboard_undo_btn | keyboard_main_panel | undo_btn | `keyBoardMainViewDidTapUndo:` | Android 自定义 | 点击撤销 | `{ "token":"", "page_id":"keyboard_main_panel", "element_id":"undo_btn" }` |
|
||||
| 点击表情面板搜索 | click | click_keyboard_emoji_search_btn | keyboard_main_panel | emoji_search_btn | `keyBoardMainViewDidTapEmojiSearch:` | Android 自定义 | 点击搜索 | `{ "token":"", "page_id":"keyboard_main_panel", "element_id":"emoji_search_btn" }` |
|
||||
| 点击联想词条 | click | click_keyboard_suggestion_item | keyboard_main_panel | suggestion_item | `didSelectSuggestion:` | Android 自定义 | 点击候选 | `{ "token":"", "page_id":"keyboard_main_panel", "element_id":"suggestion_item", "index":0 }` |
|
||||
| 功能面板点击“粘贴” | click | click_keyboard_function_paste_btn | keyboard_function_panel | paste_btn | KBFunctionView `onTapPaste` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_function_panel", "element_id":"paste_btn" }` |
|
||||
| 功能面板点击“删除” | click | click_keyboard_function_delete_btn | keyboard_function_panel | delete_btn | KBFunctionView `onTapDelete` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_function_panel", "element_id":"delete_btn" }` |
|
||||
| 功能面板点击“清空” | click | click_keyboard_function_clear_btn | keyboard_function_panel | clear_btn | KBFunctionView `onTapClear` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_function_panel", "element_id":"clear_btn" }` |
|
||||
| 功能面板点击“发送” | click | click_keyboard_function_send_btn | keyboard_function_panel | send_btn | KBFunctionView `onTapSend` | Android 自定义 | touchUpInside | `{ "token":"", "page_id":"keyboard_function_panel", "element_id":"send_btn" }` |
|
||||
| 功能面板点击“人设/标签”条目 | click | click_keyboard_function_tag_item | keyboard_function_panel | renshe_item | KBFunctionTagListView `didSelectItemAtIndexPath` | Android 自定义 | didSelect | `{ "token":"", "page_id":"keyboard_function_panel", "element_id":"renshe_item", "index":0, "id":456, "name":"" }` |
|
||||
| 功能面板右侧点击“登录/充值”入口(未登录走登录) | click | click_keyboard_function_right_action | keyboard_function_panel | right_action | KeyboardViewController `didRightTapToolActionAtIndex:` | Android 自定义 | 点击右侧入口 | `{ "token":"", "page_id":"keyboard_function_panel", "element_id":"right_action", "action":"login_or_recharge" }` |
|
||||
| 订阅面板点击关闭 | click | click_keyboard_subscription_close_btn | keyboard_subscription_panel | close_btn | `subscriptionViewDidTapClose:` | Android 自定义 | 点击关闭 | `{ "token":"", "page_id":"keyboard_subscription_panel", "element_id":"close_btn" }` |
|
||||
| 订阅面板点击购买某商品 | click | click_keyboard_subscription_product_btn | keyboard_subscription_panel | product_btn | `didTapPurchaseForProduct:` | Android 自定义 | 点击购买 | `{ "token":"", "page_id":"keyboard_subscription_panel", "element_id":"product_btn", "product_id":"", "index":0 }` |
|
||||
BIN
KBMaiPointEventTable.xlsx
Normal file
@@ -0,0 +1,42 @@
|
||||
Feb 5 20:30:09 macbookpro com.apple.dt.xcodebuild[56551] <Error>: Unable to deliver request ({
|
||||
"developer_dir" = "/Applications/Xcode.app/Contents/Developer";
|
||||
request = "set_developer_dir";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:30:22 macbookpro com.apple.dt.xcodebuild[56567] <Error>: Unable to deliver request ({
|
||||
"developer_dir" = "/Applications/Xcode.app/Contents/Developer";
|
||||
request = "set_developer_dir";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: Unable to deliver request ({
|
||||
"developer_dir" = "/Applications/Xcode.app/Contents/Developer";
|
||||
request = "set_developer_dir";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Warning>: Unable to discover any Simulator runtimes. Developer Directory is /Applications/Xcode.app/Contents/Developer.
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: Could not kickstart simdiskimaged; SimDiskImageManager services will not be available: Error Domain=NSPOSIXErrorDomain Code=53 "Software caused connection abort" UserInfo={NSLocalizedDescription=Error returned in reply from CoreSimulatorService: Connection invalid}
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: simdiskimaged returned error (invalid), marking disconnected.
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: Could not get list of trusted mount directories: Error Domain=com.apple.CoreSimulator.SimError Code=410 "The service used to manage runtime disk images (simdiskimaged) crashed or is not responding" UserInfo={NSLocalizedDescription=The service used to manage runtime disk images (simdiskimaged) crashed or is not responding}
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: simdiskimaged returned error (invalid), marking disconnected.
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: Unable to deliver request ({
|
||||
request = "notification_subscription";
|
||||
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:31:06 macbookpro com.apple.dt.xcodebuild[56684] <Error>: Unable to deliver request ({
|
||||
request = "notification_subscription";
|
||||
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: Unable to deliver request ({
|
||||
"developer_dir" = "/Applications/Xcode.app/Contents/Developer";
|
||||
request = "set_developer_dir";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Warning>: Unable to discover any Simulator runtimes. Developer Directory is /Applications/Xcode.app/Contents/Developer.
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: Could not kickstart simdiskimaged; SimDiskImageManager services will not be available: Error Domain=NSPOSIXErrorDomain Code=53 "Software caused connection abort" UserInfo={NSLocalizedDescription=Error returned in reply from CoreSimulatorService: Connection invalid}
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: simdiskimaged returned error (invalid), marking disconnected.
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: Could not get list of trusted mount directories: Error Domain=com.apple.CoreSimulator.SimError Code=410 "The service used to manage runtime disk images (simdiskimaged) crashed or is not responding" UserInfo={NSLocalizedDescription=The service used to manage runtime disk images (simdiskimaged) crashed or is not responding}
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: simdiskimaged returned error (invalid), marking disconnected.
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: Unable to deliver request ({
|
||||
request = "notification_subscription";
|
||||
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
Feb 5 20:31:55 macbookpro com.apple.dt.xcodebuild[56806] <Error>: Unable to deliver request ({
|
||||
request = "notification_subscription";
|
||||
"set_path" = "/Users/mac/Library/Developer/CoreSimulator/Devices";
|
||||
}) because we are not connected to CoreSimulatorService.
|
||||
1
Podfile
@@ -29,6 +29,7 @@ target 'CustomKeyboard' do
|
||||
use_frameworks!
|
||||
|
||||
pod 'AFNetworking','4.0.1'
|
||||
pod 'SDWebImage', '5.21.1'
|
||||
|
||||
pod 'Masonry', '1.1.0'
|
||||
pod 'MBProgressHUD', '1.2.0'
|
||||
|
||||
@@ -96,6 +96,6 @@ SPEC CHECKSUMS:
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
|
||||
|
||||
PODFILE CHECKSUM: acf7541bd40dd969fa4950d6c000005b2889c85b
|
||||
PODFILE CHECKSUM: 890d1710715c017d7364a19c871e9bdf0d685fbf
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
2
Pods/Manifest.lock
generated
@@ -96,6 +96,6 @@ SPEC CHECKSUMS:
|
||||
SDWebImage: f29024626962457f3470184232766516dee8dfea
|
||||
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
|
||||
|
||||
PODFILE CHECKSUM: acf7541bd40dd969fa4950d6c000005b2889c85b
|
||||
PODFILE CHECKSUM: 890d1710715c017d7364a19c871e9bdf0d685fbf
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
1922
Pods/Pods.xcodeproj/project.pbxproj
generated
@@ -104,6 +104,30 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
## SDWebImage
|
||||
|
||||
Copyright (c) 2009-2020 Olivier Poitrey rs@dailymotion.com
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished
|
||||
to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
|
||||
|
||||
## SSZipArchive
|
||||
|
||||
Copyright (c) 2013-2021, ZipArchive, https://github.com/ZipArchive
|
||||
|
||||
@@ -145,6 +145,36 @@ THE SOFTWARE.</string>
|
||||
<key>Type</key>
|
||||
<string>PSGroupSpecifier</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>FooterText</key>
|
||||
<string>Copyright (c) 2009-2020 Olivier Poitrey rs@dailymotion.com
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished
|
||||
to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
</string>
|
||||
<key>License</key>
|
||||
<string>MIT</string>
|
||||
<key>Title</key>
|
||||
<string>SDWebImage</string>
|
||||
<key>Type</key>
|
||||
<string>PSGroupSpecifier</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>FooterText</key>
|
||||
<string>Copyright (c) 2013-2021, ZipArchive, https://github.com/ZipArchive
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking/AFNetworking.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet/DZNEmptyDataSet.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD/MBProgressHUD.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension/MJExtension.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry/Masonry.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive/SSZipArchive.framework/Headers"
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking/AFNetworking.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet/DZNEmptyDataSet.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD/MBProgressHUD.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension/MJExtension.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry/Masonry.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive/SSZipArchive.framework/Headers"
|
||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' '@executable_path/../../Frameworks'
|
||||
OTHER_LDFLAGS = $(inherited) -l"iconv" -l"z" -framework "AFNetworking" -framework "CoreGraphics" -framework "DZNEmptyDataSet" -framework "Foundation" -framework "MBProgressHUD" -framework "MJExtension" -framework "Masonry" -framework "QuartzCore" -framework "SSZipArchive" -framework "Security" -framework "UIKit"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "-F${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "-F${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "-F${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "-F${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "-F${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
||||
OTHER_LDFLAGS = $(inherited) -l"iconv" -l"z" -framework "AFNetworking" -framework "CoreGraphics" -framework "DZNEmptyDataSet" -framework "Foundation" -framework "ImageIO" -framework "MBProgressHUD" -framework "MJExtension" -framework "Masonry" -framework "QuartzCore" -framework "SDWebImage" -framework "SSZipArchive" -framework "Security" -framework "UIKit"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "-F${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "-F${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "-F${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "-F${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "-F${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage" "-F${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||
PODS_PODFILE_DIR_PATH = ${SRCROOT}/.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
||||
GCC_PREPROCESSOR_DEFINITIONS = $(inherited) COCOAPODS=1
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking/AFNetworking.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet/DZNEmptyDataSet.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD/MBProgressHUD.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension/MJExtension.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry/Masonry.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive/SSZipArchive.framework/Headers"
|
||||
HEADER_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking/AFNetworking.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet/DZNEmptyDataSet.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD/MBProgressHUD.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/MJExtension/MJExtension.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/Masonry/Masonry.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.framework/Headers" "${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive/SSZipArchive.framework/Headers"
|
||||
LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' '@executable_path/../../Frameworks'
|
||||
OTHER_LDFLAGS = $(inherited) -l"iconv" -l"z" -framework "AFNetworking" -framework "CoreGraphics" -framework "DZNEmptyDataSet" -framework "Foundation" -framework "MBProgressHUD" -framework "MJExtension" -framework "Masonry" -framework "QuartzCore" -framework "SSZipArchive" -framework "Security" -framework "UIKit"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "-F${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "-F${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "-F${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "-F${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "-F${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
||||
OTHER_LDFLAGS = $(inherited) -l"iconv" -l"z" -framework "AFNetworking" -framework "CoreGraphics" -framework "DZNEmptyDataSet" -framework "Foundation" -framework "ImageIO" -framework "MBProgressHUD" -framework "MJExtension" -framework "Masonry" -framework "QuartzCore" -framework "SDWebImage" -framework "SSZipArchive" -framework "Security" -framework "UIKit"
|
||||
OTHER_MODULE_VERIFIER_FLAGS = $(inherited) "-F${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking" "-F${PODS_CONFIGURATION_BUILD_DIR}/DZNEmptyDataSet" "-F${PODS_CONFIGURATION_BUILD_DIR}/MBProgressHUD" "-F${PODS_CONFIGURATION_BUILD_DIR}/MJExtension" "-F${PODS_CONFIGURATION_BUILD_DIR}/Masonry" "-F${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage" "-F${PODS_CONFIGURATION_BUILD_DIR}/SSZipArchive"
|
||||
PODS_BUILD_DIR = ${BUILD_DIR}
|
||||
PODS_CONFIGURATION_BUILD_DIR = ${PODS_BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)
|
||||
PODS_PODFILE_DIR_PATH = ${SRCROOT}/.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage
|
||||
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO
|
||||
CONFIGURATION_BUILD_DIR = ${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage
|
||||
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = NO
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
#define API_UPDATA_INFO @"/user/updateInfo" // 更新用户
|
||||
|
||||
#define KB_API_USER_DETAIL @"/user/detail" // 用户详情
|
||||
#define API_USER_INVITE_CODE @"/user/inviteCode" // 查询邀请码
|
||||
#define API_USER_CUSTOMER_MAIL @"/user/customerMail" // 获取客服邮箱
|
||||
#define API_CHARACTER_LIST @"/character/list" // 排行榜角色列表(综合)
|
||||
#define API_NOT_LOGIN_CHARACTER_LIST @"/character/listWithNotLogin" //未登录用户人设列表
|
||||
|
||||
@@ -56,6 +58,10 @@
|
||||
#define API_THEME_DOWNLOAD @"/themes/download" // 主题下载信息
|
||||
#define API_THEME_RECOMMENDED @"/themes/recommended" // 推荐主题列表
|
||||
#define API_THEME_SEARCH @"/themes/search" // 搜索主题(themeName)
|
||||
#define API_USER_THEMES_BATCH_DELETE @"/user-themes/batch-delete" // 批量删除用户主题
|
||||
#define API_THEME_PURCHASE_LIST @"/themes/purchase/list" // 查询主题购买记录
|
||||
#define API_THEME_RESTORE @"/themes/restore" // 恢复已删除的主题
|
||||
#define API_WALLET_TRANSACTIONS @"/wallet/transactions" // 分页查询钱包交易记录
|
||||
|
||||
/// pay
|
||||
#define API_VALIDATE_RECEIPT @"/apple/validate-receipt" // 排行榜标签列表
|
||||
@@ -63,7 +69,12 @@
|
||||
#define API_SUBSCRIPTION_PRODUCT_LIST @"/products/subscription/list" // 查询订阅商品列表
|
||||
|
||||
/// AI
|
||||
#define API_AI_TALK @"/chat/talk" // 排行榜标签列表
|
||||
#define API_AI_TALK @"/chat/talk"
|
||||
#define API_AI_VOICE_TALK @"/chat/voice" // 语音对话(替换为后端真实路径)
|
||||
#define API_AI_CHAT_SYNC @"/chat/sync" // 同步对话
|
||||
#define API_AI_CHAT_MESSAGE @"/chat/message" // 文本润色
|
||||
#define API_AI_AUDIO_UPLOAD @"/chat/audio/upload" // 语音上传(替换为后端真实路径)
|
||||
#define API_AI_SPEECH_TRANSCRIBE @"/speech/transcribe" // 语音转文字
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
/// 键盘 -> 主 App 订阅页预填充数据(用于免二次请求)
|
||||
#define AppGroup_SubscriptionPrefillPayload @"AppGroup_SubscriptionPrefillPayload"
|
||||
|
||||
/// 用户头像 URL(主 App 写入,键盘扩展读取)
|
||||
#define AppGroup_UserAvatarURL @"AppGroup_UserAvatarURL"
|
||||
|
||||
/// 皮肤图标加载模式:
|
||||
/// 0 = 使用本地 Assets 图片名(key_icons 的 value 写成图片名,例如 "kb_q_melon")
|
||||
/// 1 = 使用远程 Zip 皮肤包(skinJSON 中提供 zip_url;key_icons 的 value 写成 Zip 内图标文件名,例如 "key_a")
|
||||
@@ -38,7 +41,8 @@
|
||||
// 基础baseUrl
|
||||
#ifndef KB_BASE_URL
|
||||
//#define KB_BASE_URL @"https://m1.apifoxmock.com/m1/5438099-5113192-default/"
|
||||
#define KB_BASE_URL @"http://192.168.2.21:7529/api"
|
||||
//#define KB_BASE_URL @"http://192.168.2.22:7529/api"
|
||||
#define KB_BASE_URL @"https://devcallback.loveamorkey.com/api"
|
||||
#endif
|
||||
|
||||
#import "KBFont.h"
|
||||
@@ -87,7 +91,8 @@
|
||||
|
||||
#if __OBJC__
|
||||
static inline CGFloat KBScreenWidth(void) {
|
||||
return [UIScreen mainScreen].bounds.size.width;
|
||||
CGSize size = [UIScreen mainScreen].bounds.size;
|
||||
return MIN(size.width, size.height);
|
||||
}
|
||||
|
||||
static inline CGFloat KBScaleFactor(void) {
|
||||
|
||||