186 Commits

Author SHA1 Message Date
1c9013bede 新增获取客服接口 2026-02-24 20:45:15 +08:00
0a16a4f240 修改KBKeyboardPanelModeFunction 必须要登录状态 2026-02-24 18:04:13 +08:00
27d4b2b817 添加hud容错处理 2026-02-24 16:23:57 +08:00
bc623676ca 修改在手机信息页面,复制短信后,键盘按钮不存在, 背景也不存在 2026-02-24 15:24:23 +08:00
5edf1751ff 修改sign。
键盘里ai回复的bug
2026-02-24 14:59:06 +08:00
0ac47925fd 先提交 2026-02-24 13:38:51 +08:00
635ad932c7 修改vip 2026-02-12 20:06:44 +08:00
cbe0a53cac 删除无用的国家化 2026-02-11 21:16:15 +08:00
5c273c3963 修改bug 2026-02-11 21:09:37 +08:00
c9743cb363 处理第一次不滑动界面不传递数据的问题 2026-02-11 20:52:56 +08:00
f0cb69948e bug修复 2026-02-11 19:40:41 +08:00
0144f9cc6d 1 2026-02-11 19:31:12 +08:00
ae4070ae88 1 2026-02-11 19:18:26 +08:00
a83fd918a8 删除无关代码 2026-02-11 18:58:30 +08:00
4168da618e 1 2026-02-11 18:29:00 +08:00
d2ffada83f 添加KeyboardViewControllerHelp文件夹 2026-02-10 19:41:32 +08:00
76d387e08b 修改UI遮挡后面爱心和评论的问题 2026-02-10 19:15:05 +08:00
ea0df4fb19 修改UI 2026-02-10 19:12:09 +08:00
02323fb5f1 处理键盘闪的问题 2026-02-10 18:53:31 +08:00
3c71797b7b 处理键盘崩溃 2026-02-10 13:22:19 +08:00
4c57f16058 1 2026-02-10 10:21:21 +08:00
cb2e8467a7 处理UI 2026-02-09 19:31:47 +08:00
4dfd6f5cbb 处理UI 2026-02-09 16:53:30 +08:00
e4223b3a4c 处理国际化 2026-02-09 16:30:00 +08:00
3d19403539 修改bug 2026-02-09 14:24:31 +08:00
3cb02d5b76 处理键盘ai功能 2026-02-05 16:01:21 +08:00
750b391100 处理键盘语音svip权限弹窗,跳转主app充值 2026-02-05 14:10:24 +08:00
faccf6f10f 1 2026-02-04 23:40:06 +08:00
35b1fc0f1e 处理vip逻辑 2026-02-04 21:49:28 +08:00
b73f225d15 语音vip限制ai弹窗 2026-02-04 20:23:20 +08:00
dd59094a16 处理了评论新增评论,在聊天里的评论数也一同改变 2026-02-04 19:31:30 +08:00
bacaf537f3 调用删除接口 2026-02-04 19:01:58 +08:00
619d356d31 新增接口 2026-02-04 18:45:57 +08:00
db9f07d199 优化长按聊天记录 如果是自己 不要显示举报 2026-02-04 18:39:25 +08:00
3ed120106e 处理二级评论回复用户名称显示问题 2026-02-04 18:29:12 +08:00
ff4edab820 1 2026-02-04 18:10:39 +08:00
3e30f619b9 新增删除弹窗,修改bug 2026-02-04 16:57:19 +08:00
533e23ebfe 1 2026-02-04 16:03:39 +08:00
85fb407717 处理支付 2026-02-04 15:57:22 +08:00
c1b50b407d 2 2026-02-04 15:40:45 +08:00
7c7e2477cb 1 2026-02-04 15:20:53 +08:00
faae0297cb 1 2026-02-04 15:09:03 +08:00
e50eaecbd9 1 2026-02-04 14:59:02 +08:00
879dbb860c 2 2026-02-04 13:14:26 +08:00
b4e4b7b606 处理svip 2026-02-04 12:48:18 +08:00
68a610e0a8 处理kbpayvip 2026-02-04 12:33:01 +08:00
305326aa9a 添加placehold颜色 2026-02-03 20:51:48 +08:00
61095a379f 处理网络 2026-02-03 20:22:28 +08:00
822a814f85 处理搜索bug 2026-02-03 18:03:21 +08:00
0bd0392191 1 2026-02-03 17:00:42 +08:00
b9663037f5 添加侧边栏 2026-02-03 16:54:38 +08:00
a0923c8572 添加消息长按弹窗 2026-02-03 15:53:07 +08:00
d482cfcb7d 处理滚动 2026-02-03 15:01:08 +08:00
9e6d2906f8 修改KBChatUserMessageCell文字居中问题 2026-02-03 14:40:39 +08:00
6f7bb4f960 键盘的背景图添加高斯模糊 2026-02-03 14:22:44 +08:00
fa9af5ff1b 处理聊天信息没回来 切刀下一个collectionviewcell ,信息不对的问题(禁用滚动) 2026-02-03 14:05:29 +08:00
08628bcd1d aihome里统一发布消息控件 2026-02-03 13:31:52 +08:00
19cb29616f 优化发送输入框 2026-02-02 21:25:28 +08:00
6e50cdcd2a 1 2026-02-02 20:36:38 +08:00
f1b52151be 添加音频动画 2026-02-02 19:49:56 +08:00
993ec623af 1 2026-02-02 19:07:00 +08:00
0416a64235 处理为识别到语音 2026-02-02 17:41:23 +08:00
2b75ad90fb 修改举报和UI 2026-02-02 17:07:46 +08:00
0ac9030f80 1 2026-02-02 15:28:00 +08:00
ea9c40f64f 处理语音在B界面删除历史记录有声音问题 2026-02-02 15:27:55 +08:00
48c90fa0be 1 2026-02-02 14:29:42 +08:00
fe59a0cb45 1 2026-02-02 13:26:38 +08:00
81bc50ce17 1 2026-01-31 23:17:58 +08:00
6ae504823b tableview倒置 2026-01-31 22:40:50 +08:00
d2f582b7f8 处理崩溃 2026-01-30 21:38:58 +08:00
cc82396195 基本ok 2026-01-30 21:24:17 +08:00
2ff8a7a4af 处理键盘bug 2026-01-30 13:46:08 +08:00
3c0b7e754c 1 2026-01-30 13:33:23 +08:00
3705db4aab 简单封装 2026-01-30 13:26:02 +08:00
36774a8a2c 处理键盘 2026-01-30 13:17:11 +08:00
36135313d8 处理语音 2026-01-29 20:56:24 +08:00
23c0d14128 处理键盘部分 2026-01-29 19:18:38 +08:00
d0c5cada35 发送文本处理ui和逻辑 2026-01-29 17:56:53 +08:00
b556e6841d 添加举报 2026-01-29 16:42:43 +08:00
26096abbcc 添加个人主页 2026-01-29 16:03:21 +08:00
766c62f3c0 1 2026-01-29 15:53:26 +08:00
07a77149fc 处理评论数 2026-01-29 14:51:42 +08:00
32ebc6fb65 处理滚动底部问题 2026-01-29 14:42:49 +08:00
25fbe9b64e 分类移动文件 2026-01-29 13:44:52 +08:00
4392296616 修复聊天最后一条跑到最上面的问题 2026-01-29 13:13:42 +08:00
ef52cd4872 1 2026-01-28 20:18:18 +08:00
70a8466d9f 1 2026-01-28 19:31:27 +08:00
66d85f78a0 1 2026-01-28 18:58:30 +08:00
93a20cd92a 2 2026-01-28 18:15:58 +08:00
9a54a2ae6c 1 2026-01-28 18:11:46 +08:00
1b9ce1622d 修改动画 2026-01-28 17:21:19 +08:00
b4db79eba8 2 2026-01-28 16:35:47 +08:00
22f77d56ea 隐藏无更多消息文案 2026-01-28 15:32:56 +08:00
d8d5bdc3ae 1 2026-01-28 13:55:11 +08:00
7d583ceb1d 1 2026-01-28 13:43:36 +08:00
51b744ecd7 1 2026-01-28 12:04:31 +08:00
3fd7d2af2e 1 2026-01-27 21:32:52 +08:00
db869552e4 1 2026-01-27 18:53:19 +08:00
b34de116a3 修改布局 2026-01-27 17:49:45 +08:00
e67bc37571 修改bug 2026-01-27 17:03:16 +08:00
2b749cd2b0 1 2026-01-27 16:28:17 +08:00
ce889e1ed0 1 2026-01-27 13:57:32 +08:00
e8b4b2c58a 2 2026-01-26 20:36:51 +08:00
3a5a6395af 1 2026-01-26 18:51:37 +08:00
a22599feda 1 2026-01-26 18:43:07 +08:00
6a177ceebc 新增聊天记录 2026-01-26 18:17:02 +08:00
f9d7579536 新增接口,界面 2026-01-26 16:53:41 +08:00
0fa31418f6 1 2026-01-26 13:51:38 +08:00
77fd46aa34 1 2026-01-23 21:51:37 +08:00
6ad9783bcb 1 2026-01-22 22:03:56 +08:00
edc25c159d 1 2026-01-22 13:47:34 +08:00
06a572c08a 1 2026-01-21 17:59:12 +08:00
36c0b0b210 1 2026-01-21 17:25:38 +08:00
d1d47336c2 处理tabbar 2026-01-19 19:14:20 +08:00
063ceae10f 添加渐变色 2026-01-16 21:37:18 +08:00
552387293c 修改BUG 2026-01-16 20:44:10 +08:00
93489b09d9 1 2026-01-16 20:31:42 +08:00
663cb8493b 处理发送评论 2026-01-16 19:36:41 +08:00
ac0d9584d8 添加二级评论多个的时候的逻辑,默认每次点击出现5条 2026-01-16 19:29:42 +08:00
7fa124d45f 处理一级评论cell头像闪的问题 2026-01-16 19:13:26 +08:00
3dfb8f31e2 处理好评论了 2026-01-16 19:09:54 +08:00
619c02f236 1 2026-01-16 17:41:03 +08:00
28852a8d4b 添加评论 2026-01-16 15:55:08 +08:00
b021fd308f 添加语音websocket等,还没测试 2026-01-16 13:38:03 +08:00
169a1929d7 修改UI 2026-01-15 20:30:03 +08:00
b5da9f35a5 4 2026-01-15 19:14:34 +08:00
8f4deaac4e 删除录音相关文件 2026-01-15 19:00:25 +08:00
d479d1903b 2 2026-01-15 18:49:31 +08:00
32c4138ae0 1 2026-01-15 18:16:56 +08:00
da62d4f411 添加发送ai文本埋点 2026-01-13 17:37:34 +08:00
85dcd72a5d 删除测试精灵3d相关代码
修改上报数据
2026-01-13 16:55:24 +08:00
21fcbe3665 修改暗黑模式功能UI 2026-01-09 19:35:36 +08:00
1b6724f043 1 2026-01-09 19:13:35 +08:00
ef332ecaa1 默认皮肤适配暗黑模式 2026-01-09 13:07:11 +08:00
3d6d673c0b 添加注释 2026-01-09 12:56:53 +08:00
674f09d5b6 处理按钮之间点不到的问题 HitInside 2026-01-08 20:18:37 +08:00
11d8f78b1b 2 2026-01-08 20:04:23 +08:00
bbacef4ff7 1 2026-01-08 18:57:17 +08:00
8e692647d3 2 2026-01-08 17:23:22 +08:00
6f80f969a4 1 2026-01-08 16:54:38 +08:00
bdf2a9af80 修改人设长按排序后,键盘人设要同步问题 2026-01-07 19:05:52 +08:00
e858d35722 修改首页pop对钩问题 2026-01-07 15:45:01 +08:00
f2d5210313 1 2026-01-07 14:32:57 +08:00
1b0af3e2d6 修改颜色 2026-01-07 14:32:49 +08:00
0965cd3c7e 修改了横屏键盘不居中为题 2026-01-07 13:11:23 +08:00
c3909d63da 添加埋点 2026-01-06 19:25:34 +08:00
1096f24c57 修改hud 2025-12-31 17:32:39 +08:00
7ed84fd445 修改分享方式 2025-12-30 20:41:30 +08:00
4e2d7d2908 添加部分通用上报
修改bug 未登录在键盘点击充值要先去跳转登录
2025-12-30 15:27:35 +08:00
34089ddeea 1 2025-12-26 15:51:27 +08:00
6ec98468de Merge branch 'dev_处理立刻清空和撤销删除' into dev_st
# Conflicts:
#	CustomKeyboard/Utils/KBBackspaceLongPressHandler.m
解决冲突
2025-12-26 15:08:14 +08:00
2d5919016f 测试上报 接口报错 2025-12-26 15:02:48 +08:00
c0fa51bb2e 修改用户名UI 2025-12-26 14:48:24 +08:00
6713f36387 修改国际化 2025-12-26 14:38:29 +08:00
f24750458a 修改立刻清空按钮 2025-12-26 14:19:39 +08:00
510a2f4d66 添加邀请链接 2025-12-26 14:07:21 +08:00
ae37730da6 修改 我已经退出界面,然后从新进入界面弹起键盘,为什么撤销删除按钮显示? 2025-12-26 13:55:07 +08:00
203f104ece 修改键盘长按立即清空和撤销删除 2025-12-26 11:47:44 +08:00
8e934dd83a 1 2025-12-25 17:58:33 +08:00
1676916a5c 1 2025-12-25 17:32:34 +08:00
1af5a0e849 添加部分埋点 2025-12-25 17:20:24 +08:00
5b6e0a8fbf 修改key 2025-12-25 13:07:28 +08:00
9968883bab 修改邀请 2025-12-25 13:01:14 +08:00
af5f637d31 1 2025-12-24 20:17:37 +08:00
0a725e845e 处理详情tag的背景色 2025-12-23 20:56:00 +08:00
6a539dc3c5 处理键盘长按删除 撤销出现的bug 2025-12-23 18:05:01 +08:00
73d6ec933a 修改金币超出问题 2025-12-23 15:33:14 +08:00
000d603241 优化下载皮肤❤️ 2025-12-23 15:26:32 +08:00
fbf9fe9f2a 2 2025-12-23 14:37:11 +08:00
8e4d7e1ee8 处理按钮 2025-12-23 14:15:27 +08:00
262eb57b36 修改接口 2025-12-23 14:07:53 +08:00
2e1c261775 修改UI 2025-12-23 14:02:44 +08:00
6ad2079351 修改emoji 更换api 2025-12-23 13:27:25 +08:00
a477592f5d 新增按钮 2025-12-22 21:16:38 +08:00
6f336e8368 国际化 2025-12-22 20:53:24 +08:00
17e038beb1 修改接口 2025-12-22 19:22:12 +08:00
4e6fd90668 1 2025-12-22 19:19:28 +08:00
5cfc76e6c5 修改我的皮肤逻辑 2025-12-22 16:42:56 +08:00
9e33c93763 修改弹窗 2025-12-22 15:49:28 +08:00
1c9ae7bc06 1 2025-12-22 15:37:22 +08:00
472e9ad341 修改联想背景色 2025-12-22 14:02:43 +08:00
19c69f4f6f Merge branch 'dev_联想' into dev_st 2025-12-22 13:47:23 +08:00
8788cbb105 处理UI 2025-12-22 13:46:45 +08:00
ea77e9a5f8 处理苹果bug 默认键盘颜色改为 2025-12-22 13:29:00 +08:00
eaaf0e1ed6 修改UI 2025-12-22 13:08:59 +08:00
8a344b293d 添加联想 2025-12-22 12:54:28 +08:00
615 changed files with 279089 additions and 4109 deletions

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"WebSearch",
"Bash(git checkout:*)"
]
}
}

View File

@@ -6,6 +6,8 @@
<array>
<string>kbkeyboardAppExtension</string>
</array>
<key>NSMicrophoneUsageDescription</key>
<string>需要使用麦克风进行语音输入</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>

View File

@@ -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"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

View File

@@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -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" : {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -8,7 +8,7 @@
// - 兼容后端“/t”作为分段标记可自动替换为制表符“\t”
// - 首段去首个“\t”若首次正文以一个制表符起始允许前导空白可只移除“一个”\t
//
// 暂未使用
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN

View File

@@ -4,7 +4,7 @@
//
// Created by Mac on 2025/11/12.
//
// 暂未使用
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN

View File

@@ -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。

View File

@@ -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";

Binary file not shown.

Binary file not shown.

View 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"
}
]
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@@ -11,10 +11,10 @@ NS_ASSUME_NONNULL_BEGIN
- (instancetype)initWithContainerView:(UIView *)containerView;
/// 配置删除按钮(包含长按删除;可选是否显示“立刻清空”提示)
/// 配置删除按钮(包含长按删除;可选是否显示“上滑清空”提示)
- (void)bindDeleteButton:(nullable UIView *)button showClearLabel:(BOOL)showClearLabel;
/// 触发“立刻清空”逻辑(可用于功能面板的清空按钮)
/// 触发“上滑清空”逻辑(可用于功能面板的清空按钮)
- (void)performClearAction;
@end

View File

@@ -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 == CoreASCII /
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

View File

@@ -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

View File

@@ -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

View 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

View 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
View 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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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];
}
}

View 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

View 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

View File

@@ -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];

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -11,6 +11,7 @@
@property (nonatomic, strong) KBKey *key;
@property (nonatomic, strong) UIImageView *iconView;
@property (nonatomic, strong, nullable) UIColor *customBackgroundColor;
/// 配置基础样式(背景、圆角等)。创建按钮时调用。
- (void)applyDefaultStyle;

View File

@@ -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];

View File

@@ -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 {

View File

@@ -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 ?: @""];
}
}
}

View 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

View 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

View File

@@ -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
View 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

Binary file not shown.

View 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.

View File

@@ -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'

View File

@@ -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
View File

@@ -96,6 +96,6 @@ SPEC CHECKSUMS:
SDWebImage: f29024626962457f3470184232766516dee8dfea
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
PODFILE CHECKSUM: acf7541bd40dd969fa4950d6c000005b2889c85b
PODFILE CHECKSUM: 890d1710715c017d7364a19c871e9bdf0d685fbf
COCOAPODS: 1.16.2

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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

View File

@@ -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}/.

View File

@@ -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}/.

View File

@@ -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

View File

@@ -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

View File

@@ -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" // 语音转文字

View File

@@ -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_urlkey_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) {

Some files were not shown because too many files have changed in this diff Show More