diff --git a/CustomKeyboard/KeyboardViewController.m b/CustomKeyboard/KeyboardViewController.m index e27dcff..9b85403 100644 --- a/CustomKeyboard/KeyboardViewController.m +++ b/CustomKeyboard/KeyboardViewController.m @@ -234,7 +234,7 @@ static CGFloat KEYBOARDHEIGHT = 256 + 20; // 使用与主 App 一致的自定义 Scheme NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"%@@//login?src=keyboard", KB_APP_SCHEME]]; if (!url) return; - __weak typeof(self) weakSelf = self; + KBWeakSelf [self.extensionContext openURL:url completionHandler:^(__unused BOOL success) { // 即使失败也不重复尝试;避免打扰。 __unused typeof(weakSelf) selfStrong = weakSelf; diff --git a/CustomKeyboard/View/KBFunctionView.m b/CustomKeyboard/View/KBFunctionView.m index 5612ba7..cfe0dd4 100644 --- a/CustomKeyboard/View/KBFunctionView.m +++ b/CustomKeyboard/View/KBFunctionView.m @@ -259,7 +259,7 @@ static NSString * const kKBFunctionTagCellId = @"KBFunctionTagCellId"; // 未开启“完全访问”时不做自动读取,避免宿主/系统拒绝并刷错误日志 if (![[KBFullAccessManager shared] hasFullAccess]) return; if (self.pasteboardTimer) return; - __weak typeof(self) weakSelf = self; + KBWeakSelf self.pasteboardTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 repeats:YES block:^(NSTimer * _Nonnull timer) { __strong typeof(weakSelf) self = weakSelf; if (!self) return; UIPasteboard *pb = [UIPasteboard generalPasteboard]; diff --git a/CustomKeyboard/View/KBKeyboardView.m b/CustomKeyboard/View/KBKeyboardView.m index 8c3fce9..f9b53d7 100644 --- a/CustomKeyboard/View/KBKeyboardView.m +++ b/CustomKeyboard/View/KBKeyboardView.m @@ -338,7 +338,6 @@ if (before.length <= 0) { self.backspaceHoldActive = NO; return; } [proxy deleteBackward]; // 每次仅删 1 个 - // 轻量递归调度下一次;不使用 NSTimer,避免复杂状态管理 __weak typeof(self) weakSelf = self; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.06 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ __strong typeof(weakSelf) selfStrong = weakSelf; diff --git a/keyBoard.xcodeproj/project.pbxproj b/keyBoard.xcodeproj/project.pbxproj index 322d1bb..5408caa 100644 --- a/keyBoard.xcodeproj/project.pbxproj +++ b/keyBoard.xcodeproj/project.pbxproj @@ -75,6 +75,8 @@ 048909F72EC0AAAA00FABA60 /* KBCategoryTitleView.m in Sources */ = {isa = PBXBuildFile; fileRef = 048909F32EC0AAAA00FABA60 /* KBCategoryTitleView.m */; }; 04890A042EC0BBBB00FABA60 /* KBCategoryTitleImageCell.m in Sources */ = {isa = PBXBuildFile; fileRef = 04890A012EC0BBBB00FABA60 /* KBCategoryTitleImageCell.m */; }; 04890A052EC0BBBB00FABA60 /* KBCategoryTitleImageView.m in Sources */ = {isa = PBXBuildFile; fileRef = 04890A032EC0BBBB00FABA60 /* KBCategoryTitleImageView.m */; }; + 049FB20B2EC1C13800FAB05D /* KBSkinBottomActionView.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB20A2EC1C13800FAB05D /* KBSkinBottomActionView.m */; }; + 049FB20E2EC1CD2800FAB05D /* KBAlert.m in Sources */ = {isa = PBXBuildFile; fileRef = 049FB20D2EC1CD2800FAB05D /* KBAlert.m */; }; 04A9FE0F2EB481100020DB6D /* KBHUD.m in Sources */ = {isa = PBXBuildFile; fileRef = 04FC97082EB31B14007BD342 /* KBHUD.m */; }; 04A9FE132EB4D0D20020DB6D /* KBFullAccessManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 04A9FE112EB4D0D20020DB6D /* KBFullAccessManager.m */; }; 04A9FE162EB873C80020DB6D /* UIViewController+Extension.m in Sources */ = {isa = PBXBuildFile; fileRef = 04A9FE152EB873C80020DB6D /* UIViewController+Extension.m */; }; @@ -264,6 +266,10 @@ 04890A012EC0BBBB00FABA60 /* KBCategoryTitleImageCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBCategoryTitleImageCell.m; sourceTree = ""; }; 04890A022EC0BBBB00FABA60 /* KBCategoryTitleImageView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBCategoryTitleImageView.h; sourceTree = ""; }; 04890A032EC0BBBB00FABA60 /* KBCategoryTitleImageView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBCategoryTitleImageView.m; sourceTree = ""; }; + 049FB2092EC1C13800FAB05D /* KBSkinBottomActionView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBSkinBottomActionView.h; sourceTree = ""; }; + 049FB20A2EC1C13800FAB05D /* KBSkinBottomActionView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBSkinBottomActionView.m; sourceTree = ""; }; + 049FB20C2EC1CD2800FAB05D /* KBAlert.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBAlert.h; sourceTree = ""; }; + 049FB20D2EC1CD2800FAB05D /* KBAlert.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBAlert.m; sourceTree = ""; }; 04A9A67D2EB9E1690023B8F4 /* KBResponderUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBResponderUtils.h; sourceTree = ""; }; 04A9FE102EB4D0D20020DB6D /* KBFullAccessManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KBFullAccessManager.h; sourceTree = ""; }; 04A9FE112EB4D0D20020DB6D /* KBFullAccessManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KBFullAccessManager.m; sourceTree = ""; }; @@ -544,6 +550,10 @@ 048908B82EBDC11200FABA60 /* V */ = { isa = PBXGroup; children = ( + 04FC97072EB31B14007BD342 /* KBHUD.h */, + 04FC97082EB31B14007BD342 /* KBHUD.m */, + 049FB20C2EC1CD2800FAB05D /* KBAlert.h */, + 049FB20D2EC1CD2800FAB05D /* KBAlert.m */, ); path = V; sourceTree = ""; @@ -826,6 +836,8 @@ 048908EB2EBF849300FABA60 /* KBSkinTagsContainerCell.m */, 048908ED2EBF861800FABA60 /* KBSkinSectionTitleCell.h */, 048908EE2EBF861800FABA60 /* KBSkinSectionTitleCell.m */, + 049FB2092EC1C13800FAB05D /* KBSkinBottomActionView.h */, + 049FB20A2EC1C13800FAB05D /* KBSkinBottomActionView.m */, ); path = V; sourceTree = ""; @@ -967,8 +979,6 @@ 04FC970B2EB334F8007BD342 /* UIImageView+KBWebImage.m */, 04FC970C2EB334F8007BD342 /* KBWebImageManager.h */, 04FC970D2EB334F8007BD342 /* KBWebImageManager.m */, - 04FC97072EB31B14007BD342 /* KBHUD.h */, - 04FC97082EB31B14007BD342 /* KBHUD.m */, 04A9FE142EB873C80020DB6D /* UIViewController+Extension.h */, 04A9FE152EB873C80020DB6D /* UIViewController+Extension.m */, 047C655A2EBCD08E0035E841 /* UIView+KBShadow.h */, @@ -1346,6 +1356,7 @@ 0477BE002EBC6A330055D639 /* HomeRankVC.m in Sources */, 047C650D2EBC8A840035E841 /* KBPanModalView.m in Sources */, 043FBCD22EAF97630036AFE1 /* KBPermissionViewController.m in Sources */, + 049FB20E2EC1CD2800FAB05D /* KBAlert.m in Sources */, 04A9FE162EB873C80020DB6D /* UIViewController+Extension.m in Sources */, 04C6EABE2EAF86530089C901 /* AppDelegate.m in Sources */, 04FC95F12EB339A7007BD342 /* LoginViewController.m in Sources */, @@ -1365,7 +1376,7 @@ 047C65102EBCA8DD0035E841 /* HomeRankContentVC.m in Sources */, 047C655C2EBCD0F80035E841 /* UIView+KBShadow.m in Sources */, 048908C32EBE32B800FABA60 /* KBSearchVC.m in Sources */, - 7276DDA82EC1B28300804C36 /* KBWebViewViewController.m in Sources */, + 049FB20B2EC1C13800FAB05D /* KBSkinBottomActionView.m in Sources */, 047C655E2EBCD5B20035E841 /* UIImage+KBColor.m in Sources */, 04FC95DD2EB202A3007BD342 /* KBGuideVC.m in Sources */, 04FC95E52EB220B5007BD342 /* UIColor+Extension.m in Sources */, diff --git a/keyBoard/Class/Base/V/UIScrollView+KBEmptyView.h b/keyBoard/Class/Base/V/UIScrollView+KBEmptyView.h index c1b15ae..cb5c419 100644 --- a/keyBoard/Class/Base/V/UIScrollView+KBEmptyView.h +++ b/keyBoard/Class/Base/V/UIScrollView+KBEmptyView.h @@ -3,7 +3,6 @@ // keyBoard // // 统一封装基于 LYEmptyView 的空态视图挂载方法,适用于 UITableView/UICollectionView。 -// 注意:仅在对应页面已通过 CocoaPods 集成 LYEmptyView 时生效。 // #import diff --git a/keyBoard/Class/Categories/UIViewController+Extension.m b/keyBoard/Class/Categories/UIViewController+Extension.m index de0def3..5f0441c 100644 --- a/keyBoard/Class/Categories/UIViewController+Extension.m +++ b/keyBoard/Class/Categories/UIViewController+Extension.m @@ -34,13 +34,25 @@ static inline __kindof UIWindow *KBActiveWindow(void) { + (UIViewController *)kb_topMostViewController { UIWindow *window = KBActiveWindow(); - UIViewController *root = window.rootViewController; - if (!root) return nil; - UIViewController *top = root; - while (top.presentedViewController) { - top = top.presentedViewController; + UIViewController *vc = window.rootViewController; + if (!vc) return nil; + // 展开容器控制器 + while (1) { + if ([vc isKindOfClass:UINavigationController.class]) { + vc = ((UINavigationController *)vc).visibleViewController ?: vc; + continue; + } + if ([vc isKindOfClass:UITabBarController.class]) { + vc = ((UITabBarController *)vc).selectedViewController ?: vc; + continue; + } + if (vc.presentedViewController) { + vc = vc.presentedViewController; + continue; + } + break; } - return top; + return vc; } @end diff --git a/keyBoard/Class/Common/V/KBAlert.h b/keyBoard/Class/Common/V/KBAlert.h new file mode 100644 index 0000000..e41dfef --- /dev/null +++ b/keyBoard/Class/Common/V/KBAlert.h @@ -0,0 +1,83 @@ +// +// KBAlert.h +// keyBoard +// +// 系统 UIAlertController 的轻量封装:更易用、更健壮。 +// 特性: +// - 任意线程调用;内部切回主线程 +// - 自动找到可展示的 VC(使用顶层 presented VC) +// - Alert 队列:避免“正在展示时再次 present 失败”的崩溃/警告 +// - iPad ActionSheet 自动设置 popover 锚点(无需关心 sourceView) +// - 常见场景一行调用:提示/确认/输入框/操作表 +// - 可选指定展示的 VC(如在 Extension 内) +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^KBAlertBoolHandler)(BOOL ok); +typedef void (^KBAlertIndexHandler)(NSInteger index); +typedef void (^KBAlertTextHandler)(NSString * _Nullable text, BOOL ok); + +@interface KBAlert : NSObject + +/// 指定一个缺省的“用于 present 的 VC”。 +/// - 适用:App Extension 或自管理容器场景;App 内一般无需设置。 +/// - 注意:弱引用;随时可被释放。 ++ (void)setDefaultPresenter:(nullable __kindof UIViewController *)presenter; + +#pragma mark - 简单提示(单按钮) +/// 标准 OK 提示;按钮文案默认为“好”或“OK”。 ++ (void)showTitle:(nullable NSString *)title message:(nullable NSString *)message; +/// 自定义按钮文案与回调。 ++ (void)showTitle:(nullable NSString *)title + message:(nullable NSString *)message + button:(nullable NSString *)button + completion:(dispatch_block_t)completion; + +#pragma mark - 确认对话框(双按钮) +/// 默认“取消/确定”按钮,回调 ok=YES 表示点击了确定。 ++ (void)confirmTitle:(nullable NSString *)title + message:(nullable NSString *)message + completion:(KBAlertBoolHandler)completion; +/// 自定义按钮文案。 ++ (void)confirmTitle:(nullable NSString *)title + message:(nullable NSString *)message + ok:(nullable NSString *)okTitle + cancel:(nullable NSString *)cancelTitle + completion:(KBAlertBoolHandler)completion; + +/// 自定义“确定/取消”按钮颜色(为 nil 时使用系统默认) ++ (void)confirmTitle:(nullable NSString *)title + message:(nullable NSString *)message + ok:(nullable NSString *)okTitle + cancel:(nullable NSString *)cancelTitle + okColor:(nullable UIColor *)okColor + cancelColor:(nullable UIColor *)cancelColor + completion:(KBAlertBoolHandler)completion; + +#pragma mark - 输入框(单行) +/// 带单个输入框;ok=YES 时返回输入内容。 ++ (void)promptTitle:(nullable NSString *)title + message:(nullable NSString *)message + placeholder:(nullable NSString *)placeholder + ok:(nullable NSString *)okTitle + cancel:(nullable NSString *)cancelTitle + keyboardType:(UIKeyboardType)type + configuration:(void (^ _Nullable)(UITextField *tf))config + completion:(KBAlertTextHandler)completion; + +#pragma mark - 操作表(ActionSheet) +/// 操作表;index 为点击项序号,按 actions 顺序从 0 开始;取消返回 -1。 ++ (void)actionSheetTitle:(nullable NSString *)title + message:(nullable NSString *)message + actions:(NSArray *)actions + cancel:(nullable NSString *)cancelTitle + destructiveIndex:(NSInteger)destructiveIndex // 传入 -1 表示无 + fromView:(nullable UIView *)anchorView // iPad 可指定锚点;传 nil 自动居中 + completion:(KBAlertIndexHandler)completion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/keyBoard/Class/Common/V/KBAlert.m b/keyBoard/Class/Common/V/KBAlert.m new file mode 100644 index 0000000..ee1a196 --- /dev/null +++ b/keyBoard/Class/Common/V/KBAlert.m @@ -0,0 +1,243 @@ +// +// KBAlert.m +// keyBoard +// + +#import "KBAlert.h" +#import +#import "UIViewController+Extension.h" + +@implementation KBAlert + +// 简单的串行展示队列,避免重复 present 引发的异常 +static NSMutableArray *sQueue; +static BOOL sPresenting = NO; +static __weak UIViewController *sDefaultPresenter = nil; // 可选外部指定的 presenter(弱引用) + +#pragma mark - Helpers + ++ (void)initialize { + if (self == KBAlert.class) { + sQueue = [NSMutableArray array]; + } +} + ++ (void)onMain:(dispatch_block_t)blk { + if (NSThread.isMainThread) { blk(); } else { dispatch_async(dispatch_get_main_queue(), blk); } +} + ++ (UIViewController *)presentingVC { + // 优先使用外部指定的 presenter;否则找顶层 + UIViewController *vc = sDefaultPresenter; + if (!vc) { + vc = [UIViewController kb_topMostViewController]; + } + return vc; +} + ++ (void)enqueuePresent:(dispatch_block_t)presenter { + [self onMain:^{ + [sQueue addObject:[presenter copy]]; + if (!sPresenting) { [self _dequeueAndPresentNext]; } + }]; +} + ++ (void)_dequeueAndPresentNext { + if (sPresenting || sQueue.count == 0) { return; } + sPresenting = YES; + dispatch_block_t blk = sQueue.firstObject; + [sQueue removeObjectAtIndex:0]; + blk(); +} + +// 在任意 action 执行后调用,标记结束并尝试继续队列 ++ (void)_markFinishedAndContinue { + // 延迟到下一个 runloop 以确保系统完成 dismiss 流程 + dispatch_async(dispatch_get_main_queue(), ^{ + sPresenting = NO; + [self _dequeueAndPresentNext]; + }); +} + +// iPad 的 ActionSheet 需要 popover 锚点;此处自动兜底 ++ (void)configurePopoverIfNeeded:(UIAlertController *)ac anchorView:(UIView *)anchorView inVC:(UIViewController *)vc { + if (ac.preferredStyle != UIAlertControllerStyleActionSheet) { return; } + UIPopoverPresentationController *pop = ac.popoverPresentationController; + if (!pop) { return; } + if (anchorView) { + pop.sourceView = anchorView; + pop.sourceRect = anchorView.bounds; + } else if (vc.view) { + pop.sourceView = vc.view; + CGSize sz = vc.view.bounds.size; + CGRect r = CGRectMake(MAX(0, sz.width * 0.5 - 1), MAX(0, sz.height * 0.5 - 1), 2, 2); + pop.sourceRect = r; // 居中一个很小的矩形,视觉近似居中弹出 + pop.permittedArrowDirections = 0; + } +} + +#pragma mark - Public API + ++ (void)setDefaultPresenter:(UIViewController *)presenter { sDefaultPresenter = presenter; } + ++ (void)showTitle:(NSString *)title message:(NSString *)message { + [self showTitle:title message:message button:nil completion:nil]; +} + ++ (void)showTitle:(NSString *)title + message:(NSString *)message + button:(NSString *)button + completion:(dispatch_block_t)completion { + [self enqueuePresent:^{ + UIViewController *vc = [self presentingVC]; + if (!vc) { // 无法获取 presenter,直接结束队列 + [self _markFinishedAndContinue]; + return; + } + NSString *ok = button ?: (NSLocalizedString(@"OK", nil).length ? NSLocalizedString(@"OK", nil) : @"好"); + UIAlertController *ac = [UIAlertController alertControllerWithTitle:(title ?: @"") + message:(message ?: @"") + preferredStyle:UIAlertControllerStyleAlert]; + __weak UIAlertController *wac = ac; + UIAlertAction *okAct = [UIAlertAction actionWithTitle:ok style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction * _Nonnull action) { + if (completion) { completion(); } + // 防止异常:确保 alert 被关闭 + [wac dismissViewControllerAnimated:YES completion:nil]; + [KBAlert _markFinishedAndContinue]; + }]; + [ac addAction:okAct]; + [vc presentViewController:ac animated:YES completion:nil]; + }]; +} + ++ (void)confirmTitle:(NSString *)title message:(NSString *)message completion:(KBAlertBoolHandler)completion { + [self confirmTitle:title message:message ok:nil cancel:nil completion:completion]; +} + ++ (void)confirmTitle:(NSString *)title + message:(NSString *)message + ok:(NSString *)okTitle + cancel:(NSString *)cancelTitle + completion:(KBAlertBoolHandler)completion { + [self confirmTitle:title message:message ok:okTitle cancel:cancelTitle okColor:nil cancelColor:nil completion:completion]; +} + +// KVC 方式设置 action 文字颜色(非公开 API,做保护) +static inline void KBSetActionTitleColor(UIAlertAction *action, UIColor *color) { + if (!color || !action) return; + @try { [action setValue:color forKey:@"titleTextColor"]; } @catch (__unused NSException *e) {} +} + ++ (void)confirmTitle:(NSString *)title + message:(NSString *)message + ok:(NSString *)okTitle + cancel:(NSString *)cancelTitle + okColor:(UIColor *)okColor + cancelColor:(UIColor *)cancelColor + completion:(KBAlertBoolHandler)completion { + [self enqueuePresent:^{ + UIViewController *vc = [self presentingVC]; + if (!vc) { [self _markFinishedAndContinue]; return; } + NSString *ok = okTitle ?: @"确定"; + NSString *cancel = cancelTitle ?: @"取消"; + UIAlertController *ac = [UIAlertController alertControllerWithTitle:(title ?: @"") + message:(message ?: @"") + preferredStyle:UIAlertControllerStyleAlert]; + __weak UIAlertController *wac = ac; + UIAlertAction *cancelAct = [UIAlertAction actionWithTitle:cancel style:UIAlertActionStyleCancel handler:^(__unused UIAlertAction * _Nonnull a) { + if (completion) { completion(NO); } + [wac dismissViewControllerAnimated:YES completion:nil]; + [KBAlert _markFinishedAndContinue]; + }]; + UIAlertAction *okAct = [UIAlertAction actionWithTitle:ok style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction * _Nonnull a) { + if (completion) { completion(YES); } + [wac dismissViewControllerAnimated:YES completion:nil]; + [KBAlert _markFinishedAndContinue]; + }]; + // 自定义颜色 + KBSetActionTitleColor(okAct, okColor); + KBSetActionTitleColor(cancelAct, cancelColor); + + [ac addAction:cancelAct]; + [ac addAction:okAct]; + [vc presentViewController:ac animated:YES completion:nil]; + }]; +} + ++ (void)promptTitle:(NSString *)title + message:(NSString *)message + placeholder:(NSString *)placeholder + ok:(NSString *)okTitle + cancel:(NSString *)cancelTitle + keyboardType:(UIKeyboardType)type + configuration:(void (^)(UITextField * _Nonnull))config + completion:(KBAlertTextHandler)completion { + [self enqueuePresent:^{ + UIViewController *vc = [self presentingVC]; + if (!vc) { [self _markFinishedAndContinue]; return; } + NSString *ok = okTitle ?: @"确定"; + NSString *cancel = cancelTitle ?: @"取消"; + UIAlertController *ac = [UIAlertController alertControllerWithTitle:(title ?: @"") + message:(message ?: @"") + preferredStyle:UIAlertControllerStyleAlert]; + __weak UIAlertController *wac = ac; + [ac addTextFieldWithConfigurationHandler:^(UITextField * _Nonnull tf) { + tf.placeholder = placeholder; + tf.keyboardType = type; + if (config) { config(tf); } + }]; + UIAlertAction *cancelAct = [UIAlertAction actionWithTitle:cancel style:UIAlertActionStyleCancel handler:^(__unused UIAlertAction * _Nonnull a) { + if (completion) { completion(nil, NO); } + [wac dismissViewControllerAnimated:YES completion:nil]; + [KBAlert _markFinishedAndContinue]; + }]; + UIAlertAction *okAct = [UIAlertAction actionWithTitle:ok style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction * _Nonnull a) { + NSString *text = wac.textFields.firstObject.text; + if (completion) { completion(text, YES); } + [wac dismissViewControllerAnimated:YES completion:nil]; + [KBAlert _markFinishedAndContinue]; + }]; + [ac addAction:cancelAct]; + [ac addAction:okAct]; + [vc presentViewController:ac animated:YES completion:nil]; + }]; +} + ++ (void)actionSheetTitle:(NSString *)title + message:(NSString *)message + actions:(NSArray *)actions + cancel:(NSString *)cancelTitle + destructiveIndex:(NSInteger)destructiveIndex + fromView:(UIView *)anchorView + completion:(KBAlertIndexHandler)completion { + [self enqueuePresent:^{ + UIViewController *vc = [self presentingVC]; + if (!vc) { [self _markFinishedAndContinue]; return; } + UIAlertController *ac = [UIAlertController alertControllerWithTitle:(title ?: @"") + message:(message ?: @"") + preferredStyle:UIAlertControllerStyleActionSheet]; + __weak UIAlertController *wac = ac; + [actions enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + UIAlertActionStyle style = (destructiveIndex >= 0 && (NSInteger)idx == destructiveIndex) ? UIAlertActionStyleDestructive : UIAlertActionStyleDefault; + UIAlertAction *act = [UIAlertAction actionWithTitle:obj ?: @"" style:style handler:^(__unused UIAlertAction * _Nonnull a) { + if (completion) { completion((NSInteger)idx); } + [wac dismissViewControllerAnimated:YES completion:nil]; + [KBAlert _markFinishedAndContinue]; + }]; + [ac addAction:act]; + }]; + if (cancelTitle.length) { + UIAlertAction *cancel = [UIAlertAction actionWithTitle:cancelTitle style:UIAlertActionStyleCancel handler:^(__unused UIAlertAction * _Nonnull a) { + if (completion) { completion(-1); } + [wac dismissViewControllerAnimated:YES completion:nil]; + [KBAlert _markFinishedAndContinue]; + }]; + [ac addAction:cancel]; + } + + [self configurePopoverIfNeeded:ac anchorView:anchorView inVC:vc]; + [vc presentViewController:ac animated:YES completion:nil]; + }]; +} + +@end diff --git a/keyBoard/Class/Categories/KBHUD.h b/keyBoard/Class/Common/V/KBHUD.h similarity index 100% rename from keyBoard/Class/Categories/KBHUD.h rename to keyBoard/Class/Common/V/KBHUD.h diff --git a/keyBoard/Class/Categories/KBHUD.m b/keyBoard/Class/Common/V/KBHUD.m similarity index 100% rename from keyBoard/Class/Categories/KBHUD.m rename to keyBoard/Class/Common/V/KBHUD.m diff --git a/keyBoard/Class/Home/VC/HomeHotVC.m b/keyBoard/Class/Home/VC/HomeHotVC.m index 1a8e47d..ae5f5ae 100644 --- a/keyBoard/Class/Home/VC/HomeHotVC.m +++ b/keyBoard/Class/Home/VC/HomeHotVC.m @@ -81,9 +81,9 @@ - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { [tableView deselectRowAtIndexPath:indexPath animated:YES]; -// KBSearchVC *vc = [[KBSearchVC alloc] init]; + KBSearchVC *vc = [[KBSearchVC alloc] init]; // [self.navigationController pushViewController:vc animated:true]; -// UINavigationController *nav = KB_CURRENT_NAV; + UINavigationController *nav = KB_CURRENT_NAV; // [nav pushViewController:vc animated:true]; NSLog(@"==="); diff --git a/keyBoard/Class/Home/VC/HomeRankContentVC.m b/keyBoard/Class/Home/VC/HomeRankContentVC.m index 43bf2f8..2f6687e 100644 --- a/keyBoard/Class/Home/VC/HomeRankContentVC.m +++ b/keyBoard/Class/Home/VC/HomeRankContentVC.m @@ -75,7 +75,7 @@ desc:d[@"desc"] people:d[@"people"] added:added]; - __weak typeof(self) weakSelf = self; + KBWeakSelf cell.onTapAction = ^{ // 切换添加/已添加状态并刷新该项 NSMutableArray *m = [weakSelf.dataSource mutableCopy]; diff --git a/keyBoard/Class/Login/VC/KBLoginSheetViewController.m b/keyBoard/Class/Login/VC/KBLoginSheetViewController.m index 04b6629..ac631c1 100644 --- a/keyBoard/Class/Login/VC/KBLoginSheetViewController.m +++ b/keyBoard/Class/Login/VC/KBLoginSheetViewController.m @@ -132,7 +132,7 @@ - (void)onContinue { LoginViewController *login = [LoginViewController new]; - __weak typeof(self) weakSelf = self; + KBWeakSelf login.onLoginSuccess = ^(NSDictionary * _Nonnull userInfo) { __strong typeof(weakSelf) self = weakSelf; if (!self) return; [self dismissViewControllerAnimated:YES completion:^{ diff --git a/keyBoard/Class/Login/VC/LoginViewController.m b/keyBoard/Class/Login/VC/LoginViewController.m index 7d4fc9a..ee9e78c 100644 --- a/keyBoard/Class/Login/VC/LoginViewController.m +++ b/keyBoard/Class/Login/VC/LoginViewController.m @@ -114,7 +114,7 @@ } - (void)handleAppleIDButtonPress API_AVAILABLE(ios(13.0)) { - __weak typeof(self) weakSelf = self; + KBWeakSelf [[AppleSignInManager shared] signInFromViewController:self completion:^(ASAuthorizationAppleIDCredential * _Nullable credential, NSError * _Nullable error) { __strong typeof(weakSelf) selfStrong = weakSelf; if (error) { diff --git a/keyBoard/Class/Me/V/KBSkinBottomActionView.h b/keyBoard/Class/Me/V/KBSkinBottomActionView.h new file mode 100644 index 0000000..d80f4c9 --- /dev/null +++ b/keyBoard/Class/Me/V/KBSkinBottomActionView.h @@ -0,0 +1,37 @@ +// +// KBSkinBottomActionView.h +// keyBoard +// +// 底部操作条(圆角胶囊样式),支持点击。用于皮肤详情页下载/购买。 +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// 底部操作条(45 高,左右圆角)。 +/// 结构: [ Title [coinIcon] [price] ] 居中对齐 +@interface KBSkinBottomActionView : UIControl + +/// 标题,例如:@"Download" +@property (nonatomic, copy) NSString *titleText; + +/// 价格/金币数,例如:@"20" +@property (nonatomic, copy) NSString *priceText; + +/// 图标(可选),例如金币图 +@property (nonatomic, strong, nullable) UIImage *iconImage; + +/// 点击回调(也可直接 addTarget 使用) +@property (nonatomic, copy, nullable) void (^tapHandler)(void); + +/// 配置便捷方法 +- (void)configWithTitle:(nullable NSString *)title price:(nullable NSString *)price icon:(nullable UIImage *)icon; + +/// 建议固定高度 ++ (CGFloat)preferredHeight; + +@end + +NS_ASSUME_NONNULL_END + diff --git a/keyBoard/Class/Me/V/KBSkinBottomActionView.m b/keyBoard/Class/Me/V/KBSkinBottomActionView.m new file mode 100644 index 0000000..0ccba7a --- /dev/null +++ b/keyBoard/Class/Me/V/KBSkinBottomActionView.m @@ -0,0 +1,153 @@ +// +// KBSkinBottomActionView.m +// keyBoard +// +// + +#import "KBSkinBottomActionView.h" + +@interface KBSkinBottomActionView () +@property (nonatomic, strong) UIView *contentView; // 内部容器,使三项整体居中 +@property (nonatomic, strong) UILabel *titleLabel; // 左侧标题 +@property (nonatomic, strong) UIImageView *coinImageView; // 中间图标(可选) +@property (nonatomic, strong) UILabel *priceLabel; // 右侧价格 +@end + +@implementation KBSkinBottomActionView + ++ (CGFloat)preferredHeight { return 45.0; } + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + self.backgroundColor = [UIColor colorWithHex:0x02BEAC]; + self.layer.masksToBounds = YES; // 圆角生效 + + // 高亮态轻微透明,突出点击感 + [self addTarget:self action:@selector(onTouchDown) forControlEvents:UIControlEventTouchDown]; + [self addTarget:self action:@selector(onTouchUp) forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchUpOutside | UIControlEventTouchCancel]; + + // 内部容器,承载三个元素,容器整体水平/垂直居中 + [self addSubview:self.contentView]; + [self.contentView mas_makeConstraints:^(MASConstraintMaker *make) { + make.centerX.equalTo(self); + make.centerY.equalTo(self); + // 防止文本过长时超出左右边界 + make.left.greaterThanOrEqualTo(self).offset(16); + make.right.lessThanOrEqualTo(self).offset(-16); + }]; + + // 三个元素放进容器,左右顺序:Title - Icon - Price + [self.contentView addSubview:self.titleLabel]; + [self.contentView addSubview:self.coinImageView]; + [self.contentView addSubview:self.priceLabel]; + + [self.titleLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.contentView); + make.centerY.equalTo(self.contentView); + }]; + [self.coinImageView mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.titleLabel.mas_right).offset(8); + make.centerY.equalTo(self.contentView); + make.width.height.mas_equalTo(18); + }]; + [self.priceLabel mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.coinImageView.mas_right).offset(6); + make.right.equalTo(self.contentView); + make.centerY.equalTo(self.contentView); + }]; + + // 默认文案 + self.titleText = @"Download"; + self.priceText = @"20"; + UIImage *img = [UIImage systemImageNamed:@"circle.fill"]; + self.iconImage = img; // 若项目没有金币图标,用系统占位(黄色) + self.coinImageView.tintColor = [UIColor colorWithRed:1.0 green:0.85 blue:0.2 alpha:1.0]; + // 点击回调(可选) + [self addTarget:self action:@selector(handleTap) forControlEvents:UIControlEventTouchUpInside]; + } + return self; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + // 圆角随高度自适应 + self.layer.cornerRadius = CGRectGetHeight(self.bounds) * 0.5; +} + +#pragma mark - Public + +- (void)configWithTitle:(nullable NSString *)title price:(nullable NSString *)price icon:(nullable UIImage *)icon { + if (title.length) self.titleText = title; + if (price.length) self.priceText = price; + if (icon) self.iconImage = icon; +} + +#pragma mark - Actions + +- (void)onTouchDown { self.alpha = 0.85; } +- (void)onTouchUp { self.alpha = 1.0; } + +- (void)handleTap { + if (self.tapHandler) { self.tapHandler(); } +} + +#pragma mark - Setters + +- (void)setTitleText:(NSString *)titleText { + _titleText = [titleText copy]; + self.titleLabel.text = _titleText; +} + +- (void)setPriceText:(NSString *)priceText { + _priceText = [priceText copy]; + self.priceLabel.text = _priceText; +} + +- (void)setIconImage:(UIImage *)iconImage { + _iconImage = iconImage; + self.coinImageView.image = _iconImage; +} + +#pragma mark - Lazy + +- (UILabel *)titleLabel { + if (!_titleLabel) { + _titleLabel = [[UILabel alloc] init]; + _titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold]; + _titleLabel.textColor = [UIColor whiteColor]; + _titleLabel.textAlignment = NSTextAlignmentCenter; + } + return _titleLabel; +} + +- (UIView *)contentView { + if (!_contentView) { + _contentView = [[UIView alloc] init]; + _contentView.backgroundColor = [UIColor clearColor]; + // 让容器尽量包裹内容 + [_contentView setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal]; + [_contentView setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal]; + } + return _contentView; +} + +- (UIImageView *)coinImageView { + if (!_coinImageView) { + _coinImageView = [[UIImageView alloc] init]; + _coinImageView.contentMode = UIViewContentModeScaleAspectFit; + _coinImageView.clipsToBounds = YES; + } + return _coinImageView; +} + +- (UILabel *)priceLabel { + if (!_priceLabel) { + _priceLabel = [[UILabel alloc] init]; + _priceLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold]; + _priceLabel.textColor = [UIColor whiteColor]; + _priceLabel.textAlignment = NSTextAlignmentCenter; + } + return _priceLabel; +} + +@end diff --git a/keyBoard/Class/Me/VC/KBSkinDetailVC.m b/keyBoard/Class/Me/VC/KBSkinDetailVC.m index 10e002b..1a9ab8e 100644 --- a/keyBoard/Class/Me/VC/KBSkinDetailVC.m +++ b/keyBoard/Class/Me/VC/KBSkinDetailVC.m @@ -8,8 +8,9 @@ #import "UICollectionViewLeftAlignedLayout.h" #import "KBSkinDetailHeaderCell.h" #import "KBSkinTagsContainerCell.h" -#import "KBSkinCardCell.h" // 已有的 皮肤卡片 cell(用作 2 列网格) +#import "KBSkinCardCell.h" #import "KBSkinSectionTitleCell.h" +#import "KBSkinBottomActionView.h" static NSString * const kHeaderCellId = @"kHeaderCellId"; static NSString * const kTagsContainerCellId = @"kTagsContainerCellId"; @@ -26,6 +27,7 @@ typedef NS_ENUM(NSInteger, KBSkinDetailSection) { @interface KBSkinDetailVC () @property (nonatomic, strong) UICollectionView *collectionView; // 主列表 +@property (nonatomic, strong) KBSkinBottomActionView *bottomBar; // 底部操作条 @property (nonatomic, copy) NSArray *tags; // 标签数据 @property (nonatomic, copy) NSArray *gridData; // 底部网格数据 @end @@ -44,9 +46,26 @@ typedef NS_ENUM(NSInteger, KBSkinDetailSection) { @{ @"title": @"Dopamine" }, @{ @"title": @"Dopamine" }, ]; + // 1. 列表 [self.view addSubview:self.collectionView]; + + // 2. 底部操作条(左右 15,高 45,可点击) + [self.view addSubview:self.bottomBar]; + [self.bottomBar mas_makeConstraints:^(MASConstraintMaker *make) { + make.left.equalTo(self.view).offset(15); + make.right.equalTo(self.view).offset(-15); + make.height.mas_equalTo([KBSkinBottomActionView preferredHeight]); + if (@available(iOS 11.0, *)) { + make.bottom.equalTo(self.view.mas_safeAreaLayoutGuideBottom); + } else { + make.bottom.equalTo(self.view); + } + }]; + + // 列表底部距离操作条顶部 10 [self.collectionView mas_makeConstraints:^(MASConstraintMaker *make) { - make.edges.equalTo(self.view); + make.top.left.right.equalTo(self.view); + make.bottom.equalTo(self.bottomBar.mas_top).offset(-10); }]; } @@ -156,4 +175,27 @@ typedef NS_ENUM(NSInteger, KBSkinDetailSection) { return _collectionView; } +#pragma mark - Lazy (Bottom Bar) + +- (KBSkinBottomActionView *)bottomBar { + if (!_bottomBar) { + _bottomBar = [[KBSkinBottomActionView alloc] init]; + // 中文注释:配置文案与图标,可根据业务传入金币图/价格 + [_bottomBar configWithTitle:@"Download" price:@"20" icon:nil]; + KBWeakSelf + _bottomBar.tapHandler = ^{ + // 示例:点击下载/购买 +// [KBHUD showText:@"点击了下载"]; + // TODO: 在此处触发下载/购买逻辑 + [weakSelf handleDownloadAction]; + }; + } + return _bottomBar; +} + +#pragma mark - Actions +- (void)handleDownloadAction { + // 预留:下载/购买动作 +} + @end diff --git a/keyBoard/Class/Me/VC/MySkinVC.m b/keyBoard/Class/Me/VC/MySkinVC.m index b700196..511c630 100644 --- a/keyBoard/Class/Me/VC/MySkinVC.m +++ b/keyBoard/Class/Me/VC/MySkinVC.m @@ -55,7 +55,7 @@ static NSString * const kMySkinCellId = @"kMySkinCellId"; }]; // 空态视图(LYEmptyView)统一样式 + 重试按钮 - __weak typeof(self) weakSelf = self; + KBWeakSelf [self.collectionView kb_makeDefaultEmptyViewWithImage:nil title:@"暂无皮肤" detail:@"下拉刷新试试" diff --git a/keyBoard/Class/Me/VC/MyVC.m b/keyBoard/Class/Me/VC/MyVC.m index d977520..a422724 100644 --- a/keyBoard/Class/Me/VC/MyVC.m +++ b/keyBoard/Class/Me/VC/MyVC.m @@ -29,10 +29,10 @@ } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{ - MySkinVC *vc = [[MySkinVC alloc] init]; -// KBSkinDetailVC *vc = [[KBSkinDetailVC alloc] init]; +// MySkinVC *vc = [[MySkinVC alloc] init]; + KBSkinDetailVC *vc = [[KBSkinDetailVC alloc] init]; - [self.navigationController pushViewController:vc animated:true]; +// [self.navigationController pushViewController:vc animated:true]; } /* diff --git a/keyBoard/Class/Search/V/KBSearchSectionHeader.h b/keyBoard/Class/Search/V/KBSearchSectionHeader.h index 4faea23..7048c54 100644 --- a/keyBoard/Class/Search/V/KBSearchSectionHeader.h +++ b/keyBoard/Class/Search/V/KBSearchSectionHeader.h @@ -20,10 +20,14 @@ NS_ASSUME_NONNULL_BEGIN /// 配置标题与是否显示垃圾桶 - (void)configWithTitle:(NSString *)title showTrash:(BOOL)showTrash; -/// 清除按钮点击回调 +/// 清除按钮点击回调(用户在弹窗中点击“确定”后触发) @property (nonatomic, copy) void(^onTapTrash)(void); +/// “确定”按钮颜色(默认黑色) +@property (nonatomic, strong, nullable) UIColor *confirmColor; +/// “取消”按钮颜色(默认黑色) +@property (nonatomic, strong, nullable) UIColor *cancelColor; + @end NS_ASSUME_NONNULL_END - diff --git a/keyBoard/Class/Search/V/KBSearchSectionHeader.m b/keyBoard/Class/Search/V/KBSearchSectionHeader.m index 792f27f..220e97f 100644 --- a/keyBoard/Class/Search/V/KBSearchSectionHeader.m +++ b/keyBoard/Class/Search/V/KBSearchSectionHeader.m @@ -4,6 +4,7 @@ // #import "KBSearchSectionHeader.h" +#import "KBAlert.h" @interface KBSearchSectionHeader () @property (nonatomic, strong, readwrite) UILabel *titleLabel; @@ -15,6 +16,8 @@ - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { [self setupUI]; + _confirmColor = [UIColor redColor]; + _cancelColor = [UIColor blackColor]; } return self; } @@ -39,7 +42,19 @@ } - (void)tapTrash { - if (self.onTapTrash) { self.onTapTrash(); } + // 弹出确认框:是否删除所有历史记录 + __weak typeof(self) weakSelf = self; + [KBAlert confirmTitle:@"清空历史" + message:@"是否删除所有历史记录?" + ok:@"确定" + cancel:@"取消" + okColor:weakSelf.confirmColor + cancelColor:weakSelf.cancelColor + completion:^(BOOL ok) { + if (ok && weakSelf.onTapTrash) { + weakSelf.onTapTrash(); + } + }]; } - (void)configWithTitle:(NSString *)title showTrash:(BOOL)showTrash { @@ -69,4 +84,3 @@ } @end - diff --git a/keyBoard/Class/Search/VC/KBSearchResultVC.m b/keyBoard/Class/Search/VC/KBSearchResultVC.m index fbec781..66847c9 100644 --- a/keyBoard/Class/Search/VC/KBSearchResultVC.m +++ b/keyBoard/Class/Search/VC/KBSearchResultVC.m @@ -152,7 +152,7 @@ static NSString * const kResultCellId = @"KBSkinCardCell"; if (!_searchBarView) { _searchBarView = [[KBSearchBarView alloc] init]; _searchBarView.placeholder = @"Themes"; - __weak typeof(self) weakSelf = self; + KBWeakSelf _searchBarView.onSearch = ^(NSString * _Nonnull keyword) { [weakSelf performSearch:keyword]; }; diff --git a/keyBoard/Class/Search/VC/KBSearchVC.m b/keyBoard/Class/Search/VC/KBSearchVC.m index bb6c52b..6382757 100644 --- a/keyBoard/Class/Search/VC/KBSearchVC.m +++ b/keyBoard/Class/Search/VC/KBSearchVC.m @@ -285,7 +285,7 @@ typedef NS_ENUM(NSInteger, KBSearchSection) { if (indexPath.section == KBSearchSectionHistory) { // 当没有历史时,外部通过 sizeForHeader 返回 0,这里仍设置但不会显示 [header configWithTitle:@"Historical Search" showTrash:self.historyWords.count > 0]; - __weak typeof(self) weakSelf = self; + KBWeakSelf header.onTapTrash = ^{ [weakSelf clearHistory]; }; } else { [header configWithTitle:@"Recommended Skin" showTrash:NO]; @@ -363,7 +363,7 @@ typedef NS_ENUM(NSInteger, KBSearchSection) { if (!_searchBarView) { _searchBarView = [[KBSearchBarView alloc] init]; _searchBarView.placeholder = @"Themes"; - __weak typeof(self) weakSelf = self; + KBWeakSelf _searchBarView.onSearch = ^(NSString * _Nonnull keyword) { // 置顶到历史 + 打开结果页 [weakSelf performSearch:keyword]; diff --git a/keyBoard/Class/Shop/VC/KBShopItemVC.m b/keyBoard/Class/Shop/VC/KBShopItemVC.m index 55e252f..b5be936 100644 --- a/keyBoard/Class/Shop/VC/KBShopItemVC.m +++ b/keyBoard/Class/Shop/VC/KBShopItemVC.m @@ -27,7 +27,7 @@ }]; // 刷新组件(演示:2 秒后结束) - __weak typeof(self) weakSelf = self; + KBWeakSelf if (self.isNeedHeader) { self.collectionView.mj_header = [MJRefreshNormalHeader headerWithRefreshingBlock:^{ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ diff --git a/keyBoard/KeyBoardPrefixHeader.pch b/keyBoard/KeyBoardPrefixHeader.pch index 7a7c720..ec0d2b3 100644 --- a/keyBoard/KeyBoardPrefixHeader.pch +++ b/keyBoard/KeyBoardPrefixHeader.pch @@ -21,6 +21,7 @@ /// 系统 #import #import "KBHUD.h" +#import "KBAlert.h" #import "KBNetworkManager.h"