2026-02-24 13:38:51 +08:00
//
// KeyboardViewController + Legacy . m
// CustomKeyboard
//
// Created by Mac on 2025 / 10 / 27.
//
# import "KeyboardViewController.h"
# import "KBKeyBoardMainView.h"
# import "KBAuthManager.h"
# import "KBBackspaceUndoManager.h"
# import "KBFullAccessManager.h"
# import "KBFunctionView.h"
2026-03-05 14:30:07 +08:00
# import "../Utils/KBExtensionAppLauncher.h"
2026-02-24 13:38:51 +08:00
# import "KBInputBufferManager.h"
# import "KBKey.h"
# import "KBKeyboardSubscriptionProduct.h"
# import "KBKeyboardSubscriptionView.h"
# import "KBChatMessage.h"
# import "KBChatPanelView.h"
# import "KBChatLimitPopView.h"
# import "KBSkinInstallBridge.h"
# import "KBSkinManager.h"
# import "KBSuggestionEngine.h"
# import "KBNetworkManager.h"
# import "KBVM.h"
# import "Masonry.h"
# import "UIImage+KBColor.h"
# import < AVFoundation / AVFoundation . h >
# import < SDWebImage / SDWebImage . h >
# if DEBUG
# import < mach / mach . h >
# endif
// # import "KBLog.h"
// 提 前 声 明 一 个 类 别 , 使 编 译 器 在 static 回 调 中 识 别 kb_consumePendingShopSkin
// 方 法 。
@ interface KeyboardViewController ( KBSkinShopBridge )
- ( void ) kb_consumePendingShopSkin ;
@ end
// 以 375 宽 设 计 稿 为 基 准 的 键 盘 总 高 度
static const CGFloat kKBKeyboardBaseHeight = 250.0 f ;
static const CGFloat kKBChatPanelHeight = 180 ;
static const NSUInteger kKBChatMessageLimit = 6 ;
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 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 ] ;
}
} ) ;
}
@ interface KeyboardViewController ( ) < KBKeyBoardMainViewDelegate ,
KBFunctionViewDelegate ,
KBKeyboardSubscriptionViewDelegate ,
KBChatPanelViewDelegate ,
KBChatLimitPopViewDelegate >
@ 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 ) 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 ;
@ end
# if DEBUG
static NSInteger sKBKeyboardVCAliveCount = 0 ;
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 ;
}
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 ;
# if DEBUG
BOOL _kb _debugDidCountAlive ;
# endif
}
- ( void ) viewDidLoad {
[ 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
} ] ;
// 皮 肤 变 化 时 , 立 即 应 用
__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 ] ;
CFNotificationCenterAddObserver (
CFNotificationCenterGetDarwinNotifyCenter ( ) ,
( __bridge const void * ) ( self ) , KBSkinInstallNotificationCallback ,
( __bridge CFStringRef ) KBDarwinSkinInstallRequestNotification , NULL ,
CFNotificationSuspensionBehaviorDeliverImmediately ) ;
[ self kb_consumePendingShopSkin ] ;
[ self kb_applyDefaultSkinIfNeeded ] ;
}
- ( 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 ) viewWillDisappear : ( BOOL ) animated {
[ super viewWillDisappear : animated ] ;
[ [ KBBackspaceUndoManager shared ] registerNonClearAction ] ;
[ self kb_releaseMemoryWhenKeyboardHidden ] ;
# if DEBUG
NSLog ( @ "[Keyboard] viewWillDisappear self=%p mem=%@" ,
self , KBFormatMB ( KBPhysFootprintBytes ( ) ) ) ;
# endif
}
- ( void ) viewDidDisappear : ( BOOL ) animated {
[ super viewDidDisappear : animated ] ;
// 再 兜 底 一 次 , 防 止 某 些 宿 主 只 触 发 willDisappear 而 未 触 发 didDisappear 。
[ self kb_releaseMemoryWhenKeyboardHidden ] ;
}
- ( 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 ] ;
}
}
}
- ( void ) textDidChange : ( id < UITextInput > ) textInput {
[ super textDidChange : textInput ] ;
[ [ KBInputBufferManager shared ]
updateFromExternalContextBefore : self . textDocumentProxy
. documentContextBeforeInput
after : self . textDocumentProxy
. documentContextAfterInput ] ;
}
- ( 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 - Private
// 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 @ "" ;
}
2026-03-04 13:44:56 +08:00
NSCharacterSet * letters = [ self kb_allowedSuggestionCharacterSet ] ;
2026-02-24 13:38:51 +08:00
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 ;
}
2026-03-04 13:44:56 +08:00
NSCharacterSet * letters = [ self kb_allowedSuggestionCharacterSet ] ;
2026-02-24 13:38:51 +08:00
for ( NSUInteger i = 0 ; i < text . length ; i + + ) {
if ( ! [ letters characterIsMember : [ text characterAtIndex : i ] ] ) {
return NO ;
}
}
return YES ;
}
2026-03-04 13:44:56 +08:00
- ( NSCharacterSet * ) kb_allowedSuggestionCharacterSet {
switch ( self . suggestionEngine . engineType ) {
case KBSuggestionEngineTypeSpanish :
return [ self kb_spanishSuggestionCharacterSet ] ;
case KBSuggestionEngineTypeBopomofo :
return [ self kb_bopomofoSuggestionCharacterSet ] ;
case KBSuggestionEngineTypeLatin :
2026-03-04 14:15:45 +08:00
case KBSuggestionEngineTypeEnglish :
2026-03-04 13:44:56 +08:00
case KBSuggestionEngineTypePortuguese :
case KBSuggestionEngineTypeIndonesian :
case KBSuggestionEngineTypePinyinSimplified :
case KBSuggestionEngineTypePinyinTraditional :
default :
return [ self kb_latinSuggestionCharacterSet ] ;
}
}
- ( NSCharacterSet * ) kb_latinSuggestionCharacterSet {
static NSCharacterSet * set = nil ;
static dispatch_once _t onceToken ;
dispatch_once ( & onceToken , ^ {
set = [ NSCharacterSet characterSetWithCharactersInString :
@ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
"áÁàÀâÂãÃäÄåÅæÆçÇ"
"éÉèÈêÊëË"
"íÍìÌîÎïÏ"
"ñÑ"
"óÓòÒôÔõÕöÖøØ"
"úÚùÙûÛüÜ"
"ýÝÿ" ] ;
} ) ;
return set ;
}
- ( NSCharacterSet * ) kb_spanishSuggestionCharacterSet {
static NSCharacterSet * set = nil ;
static dispatch_once _t onceToken ;
dispatch_once ( & onceToken , ^ {
set = [ NSCharacterSet characterSetWithCharactersInString :
@ "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
"áÁéÉíÍóÓúÚñÑüÜ" ] ;
} ) ;
return set ;
}
- ( NSCharacterSet * ) kb_bopomofoSuggestionCharacterSet {
static NSCharacterSet * set = nil ;
static dispatch_once _t onceToken ;
dispatch_once ( & onceToken , ^ {
set = [ NSCharacterSet characterSetWithCharactersInString :
@ "ㄅㄆㄇㄈㄉㄊㄋㄌㄍㄎㄏㄐㄑㄒㄓㄔㄕㄖㄗㄘㄙㄧㄨㄩㄚㄛㄜㄝㄞㄟㄠㄡㄢㄣㄤㄥㄦ"
"˙ˊˇˋ" ] ;
} ) ;
return set ;
}
2026-02-24 13:38:51 +08:00
- ( 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 ;
}
// / 切 换 显 示 功 能 面 板 / 键 盘 主 视 图
- ( void ) showFunctionPanel : ( BOOL ) show {
// 简 单 显 隐 切 换 , 复 用 相 同 的 布 局 区 域
if ( show ) {
[ self showChatPanel : NO ] ;
[ self kb_ensureFunctionViewIfNeeded ] ;
}
if ( _functionView ) {
_functionView . hidden = ! show ;
} else if ( show ) {
// ensure 后 按 理 已 存 在 ; 这 里 兜 底 一 次 , 避 免 异 常 情 况 下 状 态 不 一 致
self . functionView . hidden = NO ;
}
self . keyBoardMainView . hidden = show ;
if ( show ) {
[ [ KBMaiPointReporter sharedReporter ]
reportPageExposureWithEventName : @ "enter_keyboard_function_panel"
pageId : @ "keyboard_function_panel"
extra : nil
completion : nil ] ;
[ self hideSubscriptionPanel ] ;
} else {
[ [ KBMaiPointReporter sharedReporter ]
reportPageExposureWithEventName : @ "enter_keyboard_main_panel"
pageId : @ "keyboard_main_panel"
extra : nil
completion : nil ] ;
}
// 可 选 : 把 当 前 显 示 的 视 图 置 顶 , 避 免 层 级 遮 挡
if ( show ) {
if ( _functionView ) {
[ self . contentView bringSubviewToFront : _functionView ] ;
}
} else {
[ self . contentView bringSubviewToFront : self . keyBoardMainView ] ;
}
}
// / 显 示 / 隐 藏 聊 天 面 板 ( 覆 盖 整 个 键 盘 区 域 )
- ( void ) showChatPanel : ( BOOL ) show {
if ( show = = self . chatPanelVisible ) {
return ;
}
self . chatPanelVisible = show ;
if ( show ) {
// 记 录 打 开 聊 天 面 板 时 宿 主 输 入 框 已 有 的 文 本 , 发 送 时 只 取 新 增 部 分
[ [ KBInputBufferManager shared ] refreshFromProxyIfPossible : self . textDocumentProxy ] ;
self . chatPanelBaselineText = [ KBInputBufferManager shared ] . liveText ? : @ "" ;
[ self kb_ensureChatPanelViewIfNeeded ] ;
self . chatPanelView . hidden = NO ;
self . chatPanelView . alpha = 0.0 ;
[ self . contentView bringSubviewToFront : self . chatPanelView ] ;
if ( _functionView ) {
_functionView . hidden = YES ;
}
[ self hideSubscriptionPanel ] ;
[ UIView animateWithDuration : 0.2
delay : 0
options : UIViewAnimationOptionCurveEaseOut
animations : ^ {
self . chatPanelView . alpha = 1.0 ;
}
completion : nil ] ;
} else {
// 从 未 创 建 过 聊 天 面 板 时 , 直 接 返 回 , 避 免 show / hide 触 发 额 外 内 存 分 配
if ( ! _chatPanelView ) {
[ self kb_updateKeyboardLayoutIfNeeded ] ;
return ;
}
[ UIView animateWithDuration : 0.18
delay : 0
options : UIViewAnimationOptionCurveEaseIn
animations : ^ {
self . chatPanelView . alpha = 0.0 ;
}
completion : ^ ( BOOL finished ) {
self . chatPanelView . hidden = YES ;
} ] ;
}
[ self kb_updateKeyboardLayoutIfNeeded ] ;
}
// 延 迟 创 建 : 仅 在 用 户 真 正 打 开 功 能 面 板 时 才 创 建 / 布 局 , 降 低 默 认 内 存 占 用 。
- ( 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 ;
if ( _subscriptionView ) {
[ _subscriptionView removeFromSuperview ] ;
_subscriptionView = nil ;
}
}
- ( void ) showSubscriptionPanel {
// 1 ) 先 判 断 权 限 : 未 开 启 “ 完 全 访 问 ” 则 走 引 导 逻 辑
if ( ! [ [ KBFullAccessManager shared ] hasFullAccess ] ) {
// 未 开 启 完 全 访 问 : 保 持 原 有 引 导 路 径
2026-03-07 13:29:29 +08:00
// [ KBHUD showInfo : KBLocalized ( @ "Processing..." ) ] ;
2026-02-24 13:38:51 +08:00
[ [ KBFullAccessManager shared ] ensureFullAccessOrGuideInView : self . view ] ;
return ;
}
// 点 击 充 值 要 先 判 断 是 否 登 录
// 2 ) 权 限 没 问 题 , 再 判 断 是 否 登 录 : 未 登 录 -> 直 接 拉 起 主 App , 由 主 App
// 负 责 完 成 登 录
if ( ! KBAuthManager . shared . isLoggedIn ) {
2026-03-05 14:30:07 +08:00
NSURL * ul = [ NSURL URLWithString : [ NSString stringWithFormat : @ "%@?src=keyboard" , KB_UL _LOGIN ] ] ;
NSURL * scheme =
[ NSURL URLWithString : [ NSString stringWithFormat : @ "%@://login?src=keyboard" , KB_APP _SCHEME ] ] ;
__weak typeof ( self ) weakSelf = self ;
[ KBExtensionAppLauncher openPrimaryURL : ul
fallbackURL : scheme
usingInputController : self
source : ( self . view ? : ( UIResponder * ) weakSelf )
completion : nil ] ;
2026-02-24 13:38:51 +08:00
return ;
}
[ [ KBMaiPointReporter sharedReporter ]
reportPageExposureWithEventName : @ "enter_keyboard_subscription_panel"
pageId : @ "keyboard_subscription_panel"
extra : nil
completion : nil ] ;
[ self showFunctionPanel : NO ] ;
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 ] ;
[ UIView animateWithDuration : 0.25
delay : 0
options : UIViewAnimationOptionCurveEaseOut
animations : ^ {
panel . alpha = 1.0 ;
panel . transform = CGAffineTransformIdentity ;
}
completion : nil ] ;
}
- ( void ) hideSubscriptionPanel {
if ( ! self . subscriptionView || self . subscriptionView . hidden ) {
return ;
}
CGFloat height = CGRectGetHeight ( self . subscriptionView . bounds ) ;
if ( height <= 0 ) {
height = CGRectGetHeight ( self . contentView . 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 {
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 showFunctionPanel : YES ] ;
[ 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 showChatPanel : NO ] ;
[ self showFunctionPanel : YES ] ;
[ self kb_clearCurrentWord ] ;
return ;
}
if ( index = = 1 ) {
[ self showFunctionPanel : NO ] ;
[ self showChatPanel : YES ] ;
return ;
}
[ self showFunctionPanel : NO ] ;
[ self showChatPanel : NO ] ;
}
- ( 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 ) 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 ] ;
}
// 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 {
[ [ 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 ) {
2026-03-05 14:30:07 +08:00
NSURL * ul = [ NSURL URLWithString : [ NSString stringWithFormat : @ "%@?src=keyboard" , KB_UL _LOGIN ] ] ;
NSURL * scheme =
[ NSURL URLWithString : [ NSString stringWithFormat : @ "%@://login?src=keyboard" , KB_APP _SCHEME ] ] ;
__weak typeof ( self ) weakSelf = self ;
[ KBExtensionAppLauncher openPrimaryURL : ul
fallbackURL : scheme
usingInputController : self
source : ( self . view ? : ( UIResponder * ) weakSelf )
completion : nil ] ;
2026-02-24 13:38:51 +08:00
return ;
}
NSString * schemeStr =
[ NSString stringWithFormat : @ "%@://recharge?src=keyboard" , KB_APP _SCHEME ] ;
NSURL * scheme = [ NSURL URLWithString : schemeStr ] ;
//
// if ( ! ul && ! scheme ) { return ; }
//
// 从 当 前 视 图 作 为 起 点 , 通 过 响 应 链 找 到 UIApplication 再 调 起 主 App
2026-03-05 14:30:07 +08:00
__weak typeof ( self ) weakSelf = self ;
NSURL * ul = [ NSURL URLWithString : [ NSString stringWithFormat : @ "%@?src=keyboard" , KB_UL _RECHARGE ] ] ;
[ KBExtensionAppLauncher openPrimaryURL : ul
fallbackURL : scheme
usingInputController : self
source : ( self . view ? : ( UIResponder * ) weakSelf )
completion : ^ ( BOOL success ) {
if ( success ) {
return ;
}
dispatch_async ( dispatch_get _main _queue ( ) , ^ {
2026-03-09 17:34:08 +08:00
[ KBHUD showInfo : KBLocalized ( @ "This app does not allow the keyboard to open the main app directly. Please return to the Home screen and open the app manually to recharge" ) ] ;
2026-03-05 14:30:07 +08:00
} ) ;
} ] ;
2026-02-24 13:38:51 +08:00
}
- ( void ) functionViewDidRequestSubscription : ( KBFunctionView * ) functionView {
[ self showSubscriptionPanel ] ;
}
# 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 showChatPanel : NO ] ;
}
# 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 ) {
2026-03-07 13:29:29 +08:00
[ KBHUD showInfo : KBLocalized ( @ "Please enter content" ) ] ;
2026-02-24 13:38:51 +08:00
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 ] ) {
2026-03-07 13:29:29 +08:00
[ KBHUD showInfo : KBLocalized ( @ "Please enable Full Access to continue" ) ] ;
2026-02-24 13:38:51 +08:00
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 ( ) , ^ {
2026-03-07 13:29:29 +08:00
NSString * displayText = KBLocalized ( @ "Voice reply" ) ;
2026-02-24 13:38:51 +08:00
KBChatMessage * incoming =
[ KBChatMessage messageWithText : displayText
outgoing : NO
audioFilePath : mockPath ] ;
2026-03-07 13:29:29 +08:00
incoming . displayName = KBLocalized ( @ "AI Assistant" ) ;
2026-02-24 13:38:51 +08:00
[ 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
2026-03-07 13:29:29 +08:00
? : KBLocalized ( @ "Request failed" ) ;
2026-02-24 13:38:51 +08:00
[ 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 ) {
2026-03-07 13:29:29 +08:00
[ KBHUD showInfo : KBLocalized ( @ "Failed to parse audio data" ) ] ;
2026-02-24 13:38:51 +08:00
return ;
}
[ self kb_handleChatAudioData : data
fileExtension : @ "m4a"
displayText : displayText ] ;
return ;
}
2026-03-07 13:29:29 +08:00
[ KBHUD showInfo : KBLocalized ( @ "No audio file received" ) ] ;
2026-02-24 13:38:51 +08:00
} ) ;
} ] ;
}
# 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 ] ;
2026-03-07 13:29:29 +08:00
[ KBHUD showInfo : response . message ? : KBLocalized ( @ "Request failed" ) ] ;
2026-02-24 13:38:51 +08:00
return ;
}
NSLog ( @ "[KB] ✅ 收到回复: %@" , response . data . aiResponse ) ;
if ( response . data . aiResponse . length = = 0 ) {
[ self . chatPanelView kb_removeLoadingAssistantMessage ] ;
2026-03-07 13:29:29 +08:00
[ KBHUD showInfo : KBLocalized ( @ "No reply content received" ) ] ;
2026-02-24 13:38:51 +08:00
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 ) {
2026-03-07 13:29:29 +08:00
[ KBHUD showInfo : response . errorMessage ? : KBLocalized ( @ "Download failed" ) ] ;
2026-02-24 13:38:51 +08:00
return ;
}
if ( ! response . audioData || response . audioData . length = = 0 ) {
2026-03-07 13:29:29 +08:00
[ KBHUD showInfo : KBLocalized ( @ "No audio data received" ) ] ;
2026-02-24 13:38:51 +08:00
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 ) {
2026-03-07 13:29:29 +08:00
[ KBHUD showInfo : KBLocalized ( @ "Audio data is empty" ) ] ;
2026-02-24 13:38:51 +08:00
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 ] ) {
2026-03-07 13:29:29 +08:00
[ KBHUD showInfo : KBLocalized ( @ "Failed to save audio" ) ] ;
2026-02-24 13:38:51 +08:00
return ;
}
2026-03-07 13:29:29 +08:00
NSString * text = displayText . length > 0 ? displayText : KBLocalized ( @ "Voice message" ) ;
2026-02-24 13:38:51 +08:00
KBChatMessage * incoming =
[ KBChatMessage messageWithText : text
outgoing : NO
audioFilePath : filePath ] ;
2026-03-07 13:29:29 +08:00
incoming . displayName = KBLocalized ( @ "AI Assistant" ) ;
2026-02-24 13:38:51 +08:00
[ 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 ] ) {
2026-03-07 13:29:29 +08:00
[ KBHUD showInfo : KBLocalized ( @ "Audio file does not exist" ) ] ;
2026-02-24 13:38:51 +08:00
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 ) {
2026-03-07 13:29:29 +08:00
[ KBHUD showInfo : KBLocalized ( @ "Audio playback failed" ) ] ;
2026-02-24 13:38:51 +08:00
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 ) ;
2026-03-07 13:29:29 +08:00
[ KBHUD showInfo : KBLocalized ( @ "Audio playback failed" ) ] ;
2026-02-24 13:38:51 +08:00
return ;
}
self . chatAudioPlayer = player ;
player . volume = 1.0 ;
[ player prepareToPlay ] ;
[ player play ] ;
NSLog ( @ "[Keyboard] 开始播放音频,时长: %.2f秒" , player . duration ) ;
}
# 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 ] ;
}
2026-03-08 21:29:10 +08:00
- ( void ) subscriptionViewDidTapAgreement : ( KBKeyboardSubscriptionView * ) view {
( void ) view ;
[ self hideSubscriptionPanel ] ;
NSString * query = [ NSString stringWithFormat : @ "type=%@&src=keyboard" ,
@ "membership" ] ;
NSString * ulString = [ NSString stringWithFormat : @ "%@?%@" , KB_UL _LEGAL , query ] ;
NSString * schemeString =
[ NSString stringWithFormat : @ "%@://legal?%@" , KB_APP _SCHEME , query ] ;
NSURL * ul = [ NSURL URLWithString : ulString ] ;
NSURL * scheme = [ NSURL URLWithString : schemeString ] ;
__weak typeof ( self ) weakSelf = self ;
[ KBExtensionAppLauncher openPrimaryURL : ul
fallbackURL : scheme
usingInputController : self
source : ( self . view ? : ( UIResponder * ) weakSelf )
completion : ^ ( BOOL success ) {
if ( success ) {
return ;
}
dispatch_async ( dispatch_get _main _queue ( ) , ^ {
[ KBHUD showInfo : KBLocalized ( @ "Please open the App to view the agreement" ) ] ;
} ) ;
} ] ;
}
2026-02-24 13:38:51 +08:00
# 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 ] ;
2026-03-05 14:30:07 +08:00
NSURL * ul = [ NSURL URLWithString : [ NSString stringWithFormat : @ "%@?src=keyboard&vipType=svip" , KB_UL _RECHARGE ] ] ;
__weak typeof ( self ) weakSelf = self ;
[ KBExtensionAppLauncher openPrimaryURL : ul
fallbackURL : scheme
usingInputController : self
source : ( self . view ? : ( UIResponder * ) weakSelf )
completion : ^ ( BOOL success ) {
if ( success ) {
return ;
}
dispatch_async ( dispatch_get _main _queue ( ) , ^ {
[ KBHUD showInfo : KBLocalized ( @ "Please open the App to finish purchase" ) ] ;
} ) ;
} ] ;
2026-02-24 13:38:51 +08:00
}
# 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 ;
}
- ( 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 ;
}
# 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 ] ;
2026-03-05 14:30:07 +08:00
NSString * ulString = [ NSString stringWithFormat : @ "%@?src=keyboard&%@" , KB_UL _RECHARGE , query ] ;
NSURL * ul = [ NSURL URLWithString : ulString ] ;
__weak typeof ( self ) weakSelf = self ;
[ KBExtensionAppLauncher openPrimaryURL : ul
fallbackURL : scheme
usingInputController : self
source : ( self . view ? : ( UIResponder * ) weakSelf )
completion : ^ ( BOOL success ) {
if ( success ) {
return ;
}
dispatch_async ( dispatch_get _main _queue ( ) , ^ {
[ KBHUD showInfo : KBLocalized ( @ "Please open the App to finish purchase" ) ] ;
} ) ;
} ] ;
2026-02-24 13:38:51 +08:00
}
+ ( 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 ) dealloc {
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 ;
}
CFNotificationCenterRemoveObserver (
CFNotificationCenterGetDarwinNotifyCenter ( ) ,
( __bridge const void * ) ( self ) ,
( __bridge CFStringRef ) KBDarwinSkinInstallRequestNotification , NULL ) ;
# 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 ) 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 ) 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 ) 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 {
@ 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 , 或 #2 C2C2E )
// 但 为 了 完 美 匹 配 , 我 们 使 用 动 态 颜 色 并 直 接 设 置 为 背 景
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 : 0 xDEDFE4 ] ;
UIColor * bottomColor = [ UIColor colorWithHex : 0 xD1D3DB ] ;
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 , 否 则 会 导 致 功 能 面 板 提 前 创 建 , 占 用 内 存 。
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
2026-03-09 17:34:08 +08:00
showInfo : KBLocalized (
@ "Theme resource preparation failed, please try again later" ) ] ;
2026-02-24 13:38:51 +08:00
}
return ;
}
[ weakSelf kb_applyTheme ] ;
[ KBHUD showInfo : KBLocalized (
2026-03-09 17:34:08 +08:00
@ "Theme updated, try it now" ) ] ;
2026-02-24 13:38:51 +08:00
} ] ;
}
# pragma mark - Default Skin
- ( 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 ) {
// 已 通 过 通 知 触 发 主 题 刷 新 , 这 里 无 需 额 外 处 理
} ] ;
}
# pragma mark - Layout Helpers
- ( 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 ] ;
}
# pragma mark - Lazy
- ( 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 ;
}
@ end