diff --git a/QMUIConfigurationTemplate/QMUIConfigurationTemplate.m b/QMUIConfigurationTemplate/QMUIConfigurationTemplate.m index fbcacfa4..74e36837 100644 --- a/QMUIConfigurationTemplate/QMUIConfigurationTemplate.m +++ b/QMUIConfigurationTemplate/QMUIConfigurationTemplate.m @@ -54,7 +54,7 @@ - (void)applyConfigurationTemplate { #pragma mark - QMUILog QMUICMI.shouldPrintDefaultLog = YES; // ShouldPrintDefaultLog : 是否允许输出 QMUILogLevelDefault 级别的 log QMUICMI.shouldPrintInfoLog = YES; // ShouldPrintInfoLog : 是否允许输出 QMUILogLevelInfo 级别的 log - QMUICMI.shouldPrintWarnLog = YES; // ShouldPrintInfoLog : 是否允许输出 QMUILogLevelWarn 级别的 log + QMUICMI.shouldPrintWarnLog = YES; // ShouldPrintWarnLog : 是否允许输出 QMUILogLevelWarn 级别的 log QMUICMI.shouldPrintQMUIWarnLogToConsole = NO; // ShouldPrintQMUIWarnLogToConsole : 是否在出现 QMUILogWarn 时自动把这些 log 以 QMUIConsole 的方式显示到设备屏幕上 #pragma mark - UIControl @@ -257,19 +257,11 @@ - (void)applyConfigurationTemplate { QMUICMI.badgeContentEdgeInsets = UIEdgeInsetsMake(2, 4, 2, 4); // BadgeContentEdgeInsets : QMUIBadge 上的未读数与圆圈之间的 padding QMUICMI.badgeOffset = CGPointMake(-9, 11); // BadgeOffset : QMUIBadge 上的未读数相对于目标 view 右上角的偏移 QMUICMI.badgeOffsetLandscape = CGPointMake(-9, 6); // BadgeOffsetLandscape : QMUIBadge 上的未读数在横屏下相对于目标 view 右上角的偏移 - BeginIgnoreDeprecatedWarning - QMUICMI.badgeCenterOffset = CGPointMake(14, -10); // BadgeCenterOffset : QMUIBadge 未读数相对于目标 view 中心的偏移 - QMUICMI.badgeCenterOffsetLandscape = CGPointMake(16, -7); // BadgeCenterOffsetLandscape : QMUIBadge 未读数在横屏下相对于目标 view 中心的偏移 - EndIgnoreDeprecatedWarning QMUICMI.updatesIndicatorColor = UIColorRed; // UpdatesIndicatorColor : QMUIBadge 上的未读红点的颜色 QMUICMI.updatesIndicatorSize = CGSizeMake(7, 7); // UpdatesIndicatorSize : QMUIBadge 上的未读红点的大小 QMUICMI.updatesIndicatorOffset = CGPointMake(4, UpdatesIndicatorSize.height);// UpdatesIndicatorOffset : QMUIBadge 未读红点相对于目标 view 右上角的偏移 QMUICMI.updatesIndicatorOffsetLandscape = UpdatesIndicatorOffset; // UpdatesIndicatorOffsetLandscape : QMUIBadge 未读红点在横屏下相对于目标 view 右上角的偏移 - BeginIgnoreDeprecatedWarning - QMUICMI.updatesIndicatorCenterOffset = CGPointMake(14, -10); // UpdatesIndicatorCenterOffset : QMUIBadge 未读红点相对于目标 view 中心的偏移 - QMUICMI.updatesIndicatorCenterOffsetLandscape = CGPointMake(14, -10); // UpdatesIndicatorCenterOffsetLandscape : QMUIBadge 未读红点在横屏下相对于目标 view 中心点的偏移 - EndIgnoreDeprecatedWarning #pragma mark - Others @@ -284,7 +276,6 @@ - (void)applyConfigurationTemplate { QMUICMI.shouldFixTabBarSafeAreaInsetsBug = NO; // ShouldFixTabBarSafeAreaInsetsBug : 是否要对 iOS 11 及以后的版本修复当存在 UITabBar 时,UIScrollView 的 inset.bottom 可能错误的 bug(issue #218 #934),默认为 YES QMUICMI.shouldFixSearchBarMaskViewLayoutBug = NO; // ShouldFixSearchBarMaskViewLayoutBug : 是否自动修复 UISearchController.searchBar 被当作 tableHeaderView 使用时可能出现的布局 bug(issue #950) QMUICMI.shouldPrintQMUIWarnLogToConsole = IS_DEBUG; // ShouldPrintQMUIWarnLogToConsole : 是否在出现 QMUILogWarn 时自动把这些 log 以 QMUIConsole 的方式显示到设备屏幕上 - QMUICMI.sendAnalyticsToQMUITeam = YES; // SendAnalyticsToQMUITeam : 是否允许在 DEBUG 模式下上报 Bundle Identifier 和 Display Name 给 QMUI 统计用 QMUICMI.dynamicPreferredValueForIPad = NO; // DynamicPreferredValueForIPad : 当 iPad 处于 Slide Over 或 Split View 分屏模式下,宏 `PreferredValueForXXX` 是否把 iPad 视为某种屏幕宽度近似的 iPhone 来取值。 QMUICMI.ignoreKVCAccessProhibited = NO; // IgnoreKVCAccessProhibited : 是否全局忽略 iOS 13 对 KVC 访问 UIKit 私有属性的限制 QMUICMI.adjustScrollIndicatorInsetsByContentInsetAdjustment = NO; // AdjustScrollIndicatorInsetsByContentInsetAdjustment : 当将 UIScrollView.contentInsetAdjustmentBehavior 设为 UIScrollViewContentInsetAdjustmentNever 时,是否自动将 UIScrollView.automaticallyAdjustsScrollIndicatorInsets 设为 NO,以保证原本在 iOS 12 下的代码不用修改就能在 iOS 13 下正常控制滚动条的位置。 diff --git a/QMUIKit.podspec b/QMUIKit.podspec index 9d3d49b9..aef548fd 100644 --- a/QMUIKit.podspec +++ b/QMUIKit.podspec @@ -1,24 +1,25 @@ Pod::Spec.new do |s| s.name = "QMUIKit" - s.version = "4.6.3" + s.version = "4.8.0" s.summary = "致力于提高项目 UI 开发效率的解决方案" s.description = <<-DESC QMUI iOS 是一个致力于提高项目 UI 开发效率的解决方案,其设计目的是用于辅助快速搭建一个具备基本设计还原效果的 iOS 项目,同时利用自身提供的丰富控件及兼容处理, 让开发者能专注于业务需求而无需耗费精力在基础代码的设计上。不管是新项目的创建,或是已有项目的维护,均可使开发效率和项目质量得到大幅度提升。 DESC - s.homepage = "https://qmuiteam.com/ios" + s.homepage = "https://github.com/Tencent/QMUI_iOS" s.license = 'MIT' s.author = {"qmuiteam" => "contact@qmuiteam.com"} s.source = {:git => "https://github.com/Tencent/QMUI_iOS.git", :tag => s.version.to_s} #s.source = {:git => "https://github.com/Tencent/QMUI_iOS.git", :branch => 'master'} s.social_media_url = 'https://github.com/Tencent/QMUI_iOS' s.requires_arc = true - s.documentation_url = 'https://qmuiteam.com/ios/page/document.html' + s.documentation_url = 'https://github.com/Tencent/QMUI_iOS' s.screenshot = 'https://cloud.githubusercontent.com/assets/1190261/26751376/63f96538-486a-11e7-81cf-5bc83a945207.png' s.platform = :ios, '13.0' s.frameworks = 'Foundation', 'UIKit', 'CoreGraphics' s.preserve_paths = 'QMUIConfigurationTemplate/*' s.source_files = 'QMUIKit/QMUIKit.h' + s.resource_bundles = {'QMUIKit' => ['QMUIKit/PrivacyInfo.xcprivacy']} s.subspec 'QMUICore' do |ss| ss.source_files = 'QMUIKit/QMUIKit.h', 'QMUIKit/QMUICore', 'QMUIKit/UIKitExtensions', 'QMUIKit/UIKitExtensions/QMUIBarProtocol' @@ -82,6 +83,7 @@ Pod::Spec.new do |s| ss.subspec 'QMUIButton' do |sss| sss.source_files = 'QMUIKit/QMUIComponents/QMUIButton/QMUIButton.{h,m}' + sss.dependency 'QMUIKit/QMUIComponents/QMUILayouter' end ss.subspec 'QMUINavigationButton' do |sss| @@ -112,6 +114,18 @@ Pod::Spec.new do |s| sss.source_files = 'QMUIKit/QMUIComponents/QMUILabel.{h,m}' end + ss.subspec 'QMUILayouter' do |sss| + sss.source_files = 'QMUIKit/QMUIComponents/QMUILayouter/*.{h,m}' + end + + ss.subspec 'QMUISheetPresentation' do |sss| + sss.source_files = 'QMUIKit/QMUIComponents/QMUISheetPresentation/*.{h,m}' + sss.dependency 'QMUIKit/QMUIMainFrame' + sss.dependency 'QMUIKit/QMUIComponents/QMUIMultipleDelegates' + sss.dependency 'QMUIKit/QMUIComponents/QMUIButton' + sss.dependency 'QMUIKit/QMUIComponents/QMUINavigationButton' + end + ss.subspec 'QMUIKeyboardManager' do |sss| sss.source_files = 'QMUIKit/QMUIComponents/QMUIKeyboardManager.{h,m}' sss.dependency 'QMUIKit/QMUIComponents/QMUIAppearance' @@ -243,7 +257,11 @@ Pod::Spec.new do |s| ss.subspec 'QMUIPopupMenuView' do |sss| sss.source_files = 'QMUIKit/QMUIComponents/QMUIPopupMenuView/*.{h,m}' sss.dependency 'QMUIKit/QMUIComponents/QMUIButton' + sss.dependency 'QMUIKit/QMUIComponents/QMUITableView' + sss.dependency 'QMUIKit/QMUIComponents/QMUILabel' + sss.dependency 'QMUIKit/QMUIComponents/QMUILayouter' sss.dependency 'QMUIKit/QMUIComponents/QMUIPopupContainerView' + sss.dependency 'QMUIKit/QMUIComponents/QMUICheckbox' end ss.subspec 'QMUIScrollAnimator' do |sss| @@ -391,6 +409,12 @@ Pod::Spec.new do |s| sss.dependency 'QMUIKit/QMUIComponents/QMUIMultipleDelegates' end + ss.subspec 'QMUICheckbox' do |sss| + sss.source_files = 'QMUIKit/QMUIComponents/QMUICheckbox.{h,m}' + sss.dependency 'QMUIKit/QMUIComponents/QMUIButton' + sss.dependency 'QMUIKit/QMUIResources' + end + end end diff --git a/QMUIKit/PrivacyInfo.xcprivacy b/QMUIKit/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..0734d308 --- /dev/null +++ b/QMUIKit/PrivacyInfo.xcprivacy @@ -0,0 +1,23 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTrackingDomains + + NSPrivacyTracking + + + diff --git a/QMUIKit/QMUIComponents/ImagePickerLibrary/QMUIImagePickerViewController.m b/QMUIKit/QMUIComponents/ImagePickerLibrary/QMUIImagePickerViewController.m index 3eb407a9..d1bd78c5 100644 --- a/QMUIKit/QMUIComponents/ImagePickerLibrary/QMUIImagePickerViewController.m +++ b/QMUIKit/QMUIComponents/ImagePickerLibrary/QMUIImagePickerViewController.m @@ -122,6 +122,16 @@ - (void)viewWillAppear:(BOOL)animated { [self.collectionView reloadData]; } +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + + // 在 pop 回相簿列表时重置标志位以使下次进来 picker 时 collection 可以滚动到正确的初始位置 + // 但不能影响从 picker 进入大图的路径 + if (self.navigationController && ![self.navigationController.viewControllers containsObject:self]) { + self.hasScrollToInitialPosition = NO; + } +} + - (void)showEmptyView { [super showEmptyView]; self.emptyView.backgroundColor = self.view.backgroundColor; // 为了盖住背后的 collectionView,这里加个背景色(不盖住的话会看到 collectionView 先滚到列表顶部然后跳到列表底部) @@ -244,10 +254,6 @@ - (void)scrollToInitialPositionIfNeeded { } } -- (void)willPopInNavigationControllerWithAnimated:(BOOL)animated { - self.hasScrollToInitialPosition = NO; -} - #pragma mark - Getters & Setters @synthesize collectionViewLayout = _collectionViewLayout; diff --git a/QMUIKit/QMUIComponents/NavigationBarTransition/UINavigationBar+Transition.h b/QMUIKit/QMUIComponents/NavigationBarTransition/UINavigationBar+Transition.h index 94d0b067..a0b3b796 100644 --- a/QMUIKit/QMUIComponents/NavigationBarTransition/UINavigationBar+Transition.h +++ b/QMUIKit/QMUIComponents/NavigationBarTransition/UINavigationBar+Transition.h @@ -23,7 +23,13 @@ @interface _QMUITransitionNavigationBar : UINavigationBar +@property(nonatomic, weak) UIViewController *parentViewController; + // 建立假 bar 到真 bar 的关系,内部会通过 qmuinb_copyStylesToBar 同时设置真 bar 到假 bar 的关系 @property(nonatomic, weak) UINavigationBar *originalNavigationBar; + @property(nonatomic, assign) BOOL shouldPreventAppearance; + +// 根据当前的系统导航栏布局,刷新自身在 vc.view 上的布局 +- (void)updateLayout; @end diff --git a/QMUIKit/QMUIComponents/NavigationBarTransition/UINavigationBar+Transition.m b/QMUIKit/QMUIComponents/NavigationBarTransition/UINavigationBar+Transition.m index 9ea83284..3e1c5ea7 100644 --- a/QMUIKit/QMUIComponents/NavigationBarTransition/UINavigationBar+Transition.m +++ b/QMUIKit/QMUIComponents/NavigationBarTransition/UINavigationBar+Transition.m @@ -26,32 +26,63 @@ + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ -#ifdef IOS15_SDK_ALLOWED if (@available(iOS 15.0, *)) { - ExtendImplementationOfVoidMethodWithSingleArgument([UINavigationBar class], @selector(setStandardAppearance:), UINavigationBarAppearance *, ^(UINavigationBar *selfObject, UINavigationBarAppearance *appearance) { - if (selfObject.qmuinb_copyStylesToBar) { - selfObject.qmuinb_copyStylesToBar.standardAppearance = appearance; - } + + OverrideImplementation([UINavigationBar class], @selector(setStandardAppearance:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UINavigationBar *selfObject, UINavigationBarAppearance *appearance) { + + // call super + void (*originSelectorIMP)(id, SEL, UINavigationBarAppearance *); + originSelectorIMP = (void (*)(id, SEL, UINavigationBarAppearance *))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, appearance); + + if (selfObject.qmuinb_copyStylesToBar) { + selfObject.qmuinb_copyStylesToBar.standardAppearance = appearance; + } + }; }); - ExtendImplementationOfVoidMethodWithSingleArgument([UINavigationBar class], @selector(setScrollEdgeAppearance:), UINavigationBarAppearance *, ^(UINavigationBar *selfObject, UINavigationBarAppearance *appearance) { - if (selfObject.qmuinb_copyStylesToBar) { - selfObject.qmuinb_copyStylesToBar.scrollEdgeAppearance = appearance; - } + OverrideImplementation([UINavigationBar class], @selector(setScrollEdgeAppearance:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UINavigationBar *selfObject, UINavigationBarAppearance *appearance) { + + // call super + void (*originSelectorIMP)(id, SEL, UINavigationBarAppearance *); + originSelectorIMP = (void (*)(id, SEL, UINavigationBarAppearance *))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, appearance); + + if (selfObject.qmuinb_copyStylesToBar) { + selfObject.qmuinb_copyStylesToBar.standardAppearance = appearance; + } + }; }); } -#endif - ExtendImplementationOfVoidMethodWithSingleArgument([UINavigationBar class], @selector(setBarStyle:), UIBarStyle, ^(UINavigationBar *selfObject, UIBarStyle barStyle) { - if (selfObject.qmuinb_copyStylesToBar && selfObject.qmuinb_copyStylesToBar.barStyle != barStyle) { - selfObject.qmuinb_copyStylesToBar.barStyle = barStyle; - } + OverrideImplementation([UINavigationBar class], @selector(setBarStyle:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UINavigationBar *selfObject, UIBarStyle barStyle) { + + // call super + void (*originSelectorIMP)(id, SEL, UIBarStyle); + originSelectorIMP = (void (*)(id, SEL, UIBarStyle))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, barStyle); + + if (selfObject.qmuinb_copyStylesToBar && selfObject.qmuinb_copyStylesToBar.barStyle != barStyle) { + selfObject.qmuinb_copyStylesToBar.barStyle = barStyle; + } + }; }); - ExtendImplementationOfVoidMethodWithSingleArgument([UINavigationBar class], @selector(setBarTintColor:), UIColor *, ^(UINavigationBar *selfObject, UIColor *barTintColor) { - if (selfObject.qmuinb_copyStylesToBar && ![selfObject.qmuinb_copyStylesToBar.barTintColor isEqual:barTintColor]) { - selfObject.qmuinb_copyStylesToBar.barTintColor = barTintColor; - } + OverrideImplementation([UINavigationBar class], @selector(setBarTintColor:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UINavigationBar *selfObject, UIColor *barTintColor) { + + // call super + void (*originSelectorIMP)(id, SEL, UIColor *); + originSelectorIMP = (void (*)(id, SEL, UIColor *))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, barTintColor); + + if (selfObject.qmuinb_copyStylesToBar && ![selfObject.qmuinb_copyStylesToBar.barTintColor isEqual:barTintColor]) { + selfObject.qmuinb_copyStylesToBar.barTintColor = barTintColor; + } + }; }); OverrideImplementation([UINavigationBar class], @selector(setBackgroundImage:forBarMetrics:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { @@ -68,10 +99,18 @@ + (void)load { }; }); - ExtendImplementationOfVoidMethodWithSingleArgument([UINavigationBar class], @selector(setShadowImage:), UIImage *, ^(UINavigationBar *selfObject, UIImage *firstArgv) { - if (selfObject.qmuinb_copyStylesToBar) { - selfObject.qmuinb_copyStylesToBar.shadowImage = firstArgv; - } + OverrideImplementation([UINavigationBar class], @selector(setShadowImage:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UINavigationBar *selfObject, UIImage *shadowImage) { + + // call super + void (*originSelectorIMP)(id, SEL, UIImage *); + originSelectorIMP = (void (*)(id, SEL, UIImage *))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, shadowImage); + + if (selfObject.qmuinb_copyStylesToBar) { + selfObject.qmuinb_copyStylesToBar.shadowImage = shadowImage; + } + }; }); OverrideImplementation([UINavigationBar class], @selector(setQmui_effect:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { @@ -211,6 +250,8 @@ - (void)setOriginalNavigationBar:(UINavigationBar *)originBar { // 只复制当前 originBar 的样式,所以复制完立马就清空 originBar.qmuinb_copyStylesToBar = self; originBar.qmuinb_copyStylesToBar = nil; + + [self updateLayout]; } - (void)layoutSubviews { @@ -228,4 +269,13 @@ - (void)didAddSubview:(UIView *)subview { } } +- (void)updateLayout { + if ([self.parentViewController isViewLoaded] && self.originalNavigationBar) { + [self.parentViewController.view bringSubviewToFront:self]; + UIView *backgroundView = self.originalNavigationBar.qmui_backgroundView; + CGRect rect = [backgroundView.superview convertRect:backgroundView.frame toView:self.parentViewController.view]; + self.frame = CGRectSetX(rect, 0);// push/pop 过程中系统的导航栏转换过来的 x 可能是 112、-112 + } +} + @end diff --git a/QMUIKit/QMUIComponents/NavigationBarTransition/UINavigationController+NavigationBarTransition.m b/QMUIKit/QMUIComponents/NavigationBarTransition/UINavigationController+NavigationBarTransition.m index 93c979c4..21982331 100644 --- a/QMUIKit/QMUIComponents/NavigationBarTransition/UINavigationController+NavigationBarTransition.m +++ b/QMUIKit/QMUIComponents/NavigationBarTransition/UINavigationController+NavigationBarTransition.m @@ -113,7 +113,7 @@ + (void)load { switch (action) { case QMUINavigationActionDidPush: case QMUINavigationActionWillPop: - case QMUINavigationActionWillSet: { + case QMUINavigationActionDidSet: { BOOL shouldCustomNavigationBarTransition = [weakNavigationController shouldCustomTransitionAutomaticallyForOperation:UINavigationControllerOperationPush firstViewController:disappearingViewController secondViewController:appearingViewController]; if (shouldCustomNavigationBarTransition) { @@ -173,9 +173,7 @@ + (void)load { OverrideImplementation([UIViewController class], @selector(viewWillLayoutSubviews), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { return ^(UIViewController *selfObject) { - if (selfObject.transitionNavigationBar) { - [selfObject layoutTransitionNavigationBar]; - } + [selfObject.transitionNavigationBar updateLayout]; // call super void (*originSelectorIMP)(id, SEL); @@ -233,24 +231,26 @@ - (void)addTransitionNavigationBarAndBindNavigationBar:(BOOL)shouldBind { } _QMUITransitionNavigationBar *customBar = [[_QMUITransitionNavigationBar alloc] init]; + customBar.parentViewController = self; self.transitionNavigationBar = customBar; // iOS 15 里,假 bar 在 add 到界面上时会被强制同步为 UIAppearance 的值,不管你之前是否设置过自己的样式。而且在那个 runloop 内不管你后续怎么更新 standardAppearance,都会呈现出 UIAppearance 里的统一的值的样式。所以这里一方面屏蔽 didMoveToWindow,从而避免在这时候应用 UIAppearance,另一方面要保证先 add 到界面上再同步当前导航栏的样式。 - // 经测试只有 push 时需要这么处理,pop 没问题 + // 经测试只有 push 或 push 动画的 set 需要这么处理,pop 及 pop 动画的 set 没问题 // iOS 14 及以下没这种问题。 -#ifdef IOS15_SDK_ALLOWED + // https://github.com/Tencent/QMUI_iOS/issues/1501 if (@available(iOS 15.0, *)) { - if (self.navigationController.qmui_navigationAction == QMUINavigationActionDidPush) { + BOOL isPush = self.navigationController.qmui_navigationAction == QMUINavigationActionDidPush; + BOOL isSet = self.navigationController.qmui_navigationAction == QMUINavigationActionDidSet; + BOOL isPopAnimation = isSet && self.navigationController.qmui_lastOperation == UINavigationControllerOperationPop; + if (isPush || (isSet && !isPopAnimation)) { customBar.shouldPreventAppearance = YES; } } -#endif [self.view addSubview:customBar]; customBar.originalNavigationBar = self.navigationController.navigationBar;// 注意这里内部不会保留真 bar 和假 bar 的 copy 关系 if (shouldBind) { self.navigationController.navigationBar.qmuinb_copyStylesToBar = customBar; } - [self layoutTransitionNavigationBar]; } - (void)removeTransitionNavigationBar { @@ -264,15 +264,6 @@ - (void)removeTransitionNavigationBar { } } -- (void)layoutTransitionNavigationBar { - if (self.isViewLoaded && self.navigationController) { - UIView *backgroundView = self.navigationController.navigationBar.qmui_backgroundView; - CGRect rect = [backgroundView.superview convertRect:backgroundView.frame toView:self.view]; - self.transitionNavigationBar.frame = CGRectSetX(rect, 0);// push/pop 过程中系统的导航栏转换过来的 x 可能是 112、-112 - [self.view bringSubviewToFront:self.transitionNavigationBar];// 避免在后续被其他 subviews 盖住 - } -} - #pragma mark - 工具方法 // 根据当前的viewController,统一处理导航栏的显隐、样式 @@ -410,8 +401,8 @@ - (void)renderNavigationBarTitleAppearanceAnimated:(BOOL)animated { // 导航栏title的颜色 if ([vc respondsToSelector:@selector(qmui_titleViewTintColor)]) { UIColor *tintColor = [vc qmui_titleViewTintColor]; - if ([vc.navigationItem.titleView isKindOfClass:QMUINavigationTitleView.class]) { - ((QMUINavigationTitleView *)vc.navigationItem.titleView).tintColor = tintColor; + if (vc.navigationItem.titleView.qmui_useAsNavigationTitleView) { + vc.navigationItem.titleView.tintColor = tintColor; } else if (!vc.navigationItem.titleView) { NSMutableDictionary *titleTextAttributes = (navigationController.navigationBar.titleTextAttributes ?: @{}).mutableCopy; titleTextAttributes[NSForegroundColorAttributeName] = tintColor; @@ -421,8 +412,8 @@ - (void)renderNavigationBarTitleAppearanceAnimated:(BOOL)animated { } } else if (QMUICMIActivated) { UIColor *tintColor = NavBarTitleColor; - if ([vc.navigationItem.titleView isKindOfClass:QMUINavigationTitleView.class]) { - ((QMUINavigationTitleView *)vc.navigationItem.titleView).tintColor = tintColor; + if (vc.navigationItem.titleView.qmui_useAsNavigationTitleView) { + vc.navigationItem.titleView.tintColor = tintColor; } else if (!vc.navigationItem.titleView) { NSMutableDictionary *titleTextAttributes = (navigationController.navigationBar.titleTextAttributes ?: @{}).mutableCopy; titleTextAttributes[NSForegroundColorAttributeName] = tintColor; @@ -478,6 +469,7 @@ - (BOOL)hideNavigationBarWhenTransitioning { return NO; } +// 对于有一个界面隐藏了导航栏的情况,我们也要做自定义的动画去干预,因为如果左右两个界面导航栏样式不同,你不去干预的话,push/pop 瞬间导航栏会变成即将显示的那个界面的样式,这不符合预期 - (BOOL)shouldCustomTransitionAutomaticallyForOperation:(UINavigationControllerOperation)operation firstViewController:(UIViewController *)viewController1 secondViewController:(UIViewController *)viewController2 { UIViewController *vc1 = (UIViewController *)viewController1; diff --git a/QMUIKit/QMUIComponents/QMUIAlertController.h b/QMUIKit/QMUIComponents/QMUIAlertController.h index 534f4eac..ca1a82be 100644 --- a/QMUIKit/QMUIComponents/QMUIAlertController.h +++ b/QMUIKit/QMUIComponents/QMUIAlertController.h @@ -93,7 +93,7 @@ typedef NS_ENUM(NSInteger, QMUIAlertControllerStyle) { UIView *_scrollWrapView; // 包含上下两个 scrollView 的容器 UIScrollView *_headerScrollView; // 上半部分的内容的 scrollView,例如 title、message UIScrollView *_buttonScrollView; // 所有按钮的容器,特别的,actionSheet 下的取消按钮不放在这里面,因为它不参与滚动 - UIControl *_maskView; // 背后占满整个屏幕的半透明黑色遮罩 + UIControl *_dimmingView; // 背后占满整个屏幕的半透明黑色遮罩 } /// alert距离屏幕四边的间距,默认UIEdgeInsetsMake(0, 0, 0, 0)。alert的宽度最终是通过屏幕宽度减去水平的 alertContentMargin 和 alertContentMaximumWidth 决定的。 @@ -278,8 +278,8 @@ typedef NS_ENUM(NSInteger, QMUIAlertControllerStyle) { */ @property(nonatomic, assign) BOOL orderActionsByAddedOrdered; -/// maskView是否响应点击,alert默认为NO,sheet默认为YES -@property(nonatomic, assign) BOOL shouldRespondMaskViewTouch; +/// dimmingView 是否响应点击,alert 默认为NO,sheet 默认为YES +@property(nonatomic, assign) BOOL shouldRespondDimmingViewTouch; /// 在 iPhoneX 机器上是否延伸底部背景色。因为在 iPhoneX 上我们会把整个面板往上移动 safeArea 的距离,如果你的面板本来就配置成撑满全屏的样式,那么就会露出底部的空隙,isExtendBottomLayout 可以帮助你把空暇填补上。默认为NO。 /// @warning: 只对 sheet 类型有效 diff --git a/QMUIKit/QMUIComponents/QMUIAlertController.m b/QMUIKit/QMUIComponents/QMUIAlertController.m index c75b865e..32a201a2 100644 --- a/QMUIKit/QMUIComponents/QMUIAlertController.m +++ b/QMUIKit/QMUIComponents/QMUIAlertController.m @@ -180,7 +180,7 @@ @interface QMUIAlertController () screenSpaceHeight - 20) { screenSpaceHeight -= 20; CGFloat contentH = fmin(CGRectGetHeight(self.headerScrollView.bounds), screenSpaceHeight / 2); @@ -655,7 +655,7 @@ - (void)viewDidLayoutSubviews { self.scrollWrapView.frame = CGRectMake(0, 0, CGRectGetWidth(self.scrollWrapView.bounds), contentHeight); self.mainVisualEffectView.frame = self.scrollWrapView.bounds; - self.containerView.qmui_frameApplyTransform = CGRectMake((CGRectGetWidth(self.view.bounds) - CGRectGetWidth(self.containerView.frame)) / 2, SafeAreaInsetsConstantForDeviceWithNotch.top + (screenSpaceHeight - contentHeight - self.keyboardHeight) / 2, CGRectGetWidth(self.containerView.frame), CGRectGetHeight(self.scrollWrapView.bounds)); + self.containerView.qmui_frameApplyTransform = CGRectMake((CGRectGetWidth(self.view.bounds) - CGRectGetWidth(self.containerView.frame)) / 2, SafeAreaInsetsConstantForDeviceWithNotch.top + (screenSpaceHeight - contentHeight) / 2, CGRectGetWidth(self.containerView.frame), CGRectGetHeight(self.scrollWrapView.bounds)); } else if (self.preferredStyle == QMUIAlertControllerStyleActionSheet) { @@ -759,7 +759,7 @@ - (void)viewDidLayoutSubviews { } // 把上下的margin都加上用于跟整个屏幕的高度做比较 CGFloat contentHeight = contentOriginY + UIEdgeInsetsGetVerticalValue(self.sheetContentMargin); - CGFloat screenSpaceHeight = CGRectGetHeight(self.view.bounds); + CGFloat screenSpaceHeight = CGRectGetHeight(self.view.bounds) - SafeAreaInsetsConstantForDeviceWithNotch.top - (self.isExtendBottomLayout ? 0 : SafeAreaInsetsConstantForDeviceWithNotch.bottom); if (contentHeight > screenSpaceHeight) { CGFloat cancelButtonAreaHeight = (self.cancelAction ? (CGRectGetHeight(self.cancelAction.button.bounds) + self.sheetCancelButtonMarginTop) : 0); screenSpaceHeight = screenSpaceHeight - cancelButtonAreaHeight - UIEdgeInsetsGetVerticalValue(self.sheetContentMargin); @@ -789,7 +789,7 @@ - (void)viewDidLayoutSubviews { contentHeight -= self.sheetContentMargin.top; } - self.containerView.qmui_frameApplyTransform = CGRectMake((CGRectGetWidth(self.view.bounds) - CGRectGetWidth(self.containerView.frame)) / 2, screenSpaceHeight - contentHeight - SafeAreaInsetsConstantForDeviceWithNotch.bottom, CGRectGetWidth(self.containerView.frame), contentHeight + (self.isExtendBottomLayout ? SafeAreaInsetsConstantForDeviceWithNotch.bottom : 0)); + self.containerView.qmui_frameApplyTransform = CGRectMake((CGRectGetWidth(self.view.bounds) - CGRectGetWidth(self.containerView.frame)) / 2, SafeAreaInsetsConstantForDeviceWithNotch.top + screenSpaceHeight - contentHeight, CGRectGetWidth(self.containerView.frame), contentHeight + (self.isExtendBottomLayout ? SafeAreaInsetsConstantForDeviceWithNotch.bottom : 0)); self.extendLayer.frame = CGRectFlatMake(0, CGRectGetHeight(self.containerView.bounds) - SafeAreaInsetsConstantForDeviceWithNotch.bottom - 1, CGRectGetWidth(self.containerView.bounds), SafeAreaInsetsConstantForDeviceWithNotch.bottom + 1); } @@ -845,7 +845,7 @@ - (void)customModalPresentationControllerAnimation { weakSelf.containerView.alpha = 0; weakSelf.containerView.layer.transform = CATransform3DMakeScale(1.2, 1.2, 1.0); [UIView animateWithDuration:0.25f delay:0 options:QMUIViewAnimationOptionsCurveOut animations:^{ - weakSelf.maskView.alpha = 1; + weakSelf.dimmingView.alpha = 1; weakSelf.containerView.alpha = 1; weakSelf.containerView.layer.transform = CATransform3DMakeScale(1.0, 1.0, 1.0); } completion:^(BOOL finished) { @@ -856,7 +856,7 @@ - (void)customModalPresentationControllerAnimation { } else if (self.preferredStyle == QMUIAlertControllerStyleActionSheet) { weakSelf.containerView.layer.transform = CATransform3DMakeTranslation(0, CGRectGetHeight(weakSelf.view.bounds) - CGRectGetMinY(weakSelf.containerView.frame), 0); [UIView animateWithDuration:0.25f delay:0 options:QMUIViewAnimationOptionsCurveOut animations:^{ - weakSelf.maskView.alpha = 1; + weakSelf.dimmingView.alpha = 1; weakSelf.containerView.layer.transform = CATransform3DIdentity; } completion:^(BOOL finished) { if (completion) { @@ -869,7 +869,7 @@ - (void)customModalPresentationControllerAnimation { self.modalPresentationViewController.hidingAnimation = ^(UIView *dimmingView, CGRect containerBounds, CGFloat keyboardHeight, void(^completion)(BOOL finished)) { if (self.preferredStyle == QMUIAlertControllerStyleAlert) { [UIView animateWithDuration:0.25f delay:0 options:QMUIViewAnimationOptionsCurveOut animations:^{ - weakSelf.maskView.alpha = 0; + weakSelf.dimmingView.alpha = 0; weakSelf.containerView.alpha = 0; } completion:^(BOOL finished) { weakSelf.containerView.alpha = 1; @@ -879,7 +879,7 @@ - (void)customModalPresentationControllerAnimation { }]; } else if (self.preferredStyle == QMUIAlertControllerStyleActionSheet) { [UIView animateWithDuration:0.25f delay:0 options:QMUIViewAnimationOptionsCurveOut animations:^{ - weakSelf.maskView.alpha = 0; + weakSelf.dimmingView.alpha = 0; weakSelf.containerView.layer.transform = CATransform3DMakeTranslation(0, CGRectGetHeight(weakSelf.view.bounds) - CGRectGetMinY(weakSelf.containerView.frame), 0); } completion:^(BOOL finished) { if (completion) { @@ -919,7 +919,7 @@ - (void)showWithAnimated:(BOOL)animated { __weak __typeof(self)weakSelf = self; [self.modalPresentationViewController showWithAnimated:animated completion:^(BOOL finished) { - weakSelf.maskView.alpha = 1; + weakSelf.dimmingView.alpha = 1; weakSelf.willShow = NO; weakSelf.showing = YES; if (weakSelf.isNeedsHideAfterAlertShowed) { @@ -963,7 +963,7 @@ - (void)hideWithAnimated:(BOOL)animated completion:(void (^)(void))completion { weakSelf.modalPresentationViewController = nil; weakSelf.willShow = NO; weakSelf.showing = NO; - weakSelf.maskView.alpha = 0; + weakSelf.dimmingView.alpha = 0; if (self.preferredStyle == QMUIAlertControllerStyleAlert) { weakSelf.containerView.alpha = 0; } else { @@ -1166,22 +1166,22 @@ - (void)updateAction { return [self.alertTextFields copy]; } -- (void)handleMaskViewEvent:(id)sender { - if (_shouldRespondMaskViewTouch) { +- (void)handleDimmingViewEvent:(id)sender { + if (_shouldRespondDimmingViewTouch) { [self hideWithAnimated:YES completion:NULL]; } } #pragma mark - Getters & Setters -- (UIControl *)maskView { - if (!_maskView) { - _maskView = [[UIControl alloc] init]; - _maskView.alpha = 0; - _maskView.backgroundColor = UIColorMask; - [_maskView addTarget:self action:@selector(handleMaskViewEvent:) forControlEvents:UIControlEventTouchUpInside]; +- (UIControl *)dimmingView { + if (!_dimmingView) { + _dimmingView = [[UIControl alloc] init]; + _dimmingView.alpha = 0; + _dimmingView.backgroundColor = UIColorMask; + [_dimmingView addTarget:self action:@selector(handleDimmingViewEvent:) forControlEvents:UIControlEventTouchUpInside]; } - return _maskView; + return _dimmingView; } - (UIView *)containerView { diff --git a/QMUIKit/QMUIComponents/QMUIBadge/QMUIBadgeLabel.h b/QMUIKit/QMUIComponents/QMUIBadge/QMUIBadgeLabel.h new file mode 100644 index 00000000..7d589c3c --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUIBadge/QMUIBadgeLabel.h @@ -0,0 +1,17 @@ +// +// QMUIBadgeLabel.h +// QMUIKit +// +// Created by molice on 2023/7/26. +// Copyright © 2023 QMUI Team. All rights reserved. +// + +#import "QMUILabel.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface QMUIBadgeLabel : QMUILabel + +@end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUIComponents/QMUIBadge/QMUIBadgeLabel.m b/QMUIKit/QMUIComponents/QMUIBadge/QMUIBadgeLabel.m new file mode 100644 index 00000000..27e1d516 --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUIBadge/QMUIBadgeLabel.m @@ -0,0 +1,56 @@ +// +// QMUIBadgeLabel.m +// QMUIKit +// +// Created by molice on 2023/7/26. +// Copyright © 2023 QMUI Team. All rights reserved. +// + +#import "QMUIBadgeLabel.h" +#import "QMUICore.h" + +@implementation QMUIBadgeLabel + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + self.clipsToBounds = YES; + self.textAlignment = NSTextAlignmentCenter; + if (@available(iOS 13.0, *)) { + self.layer.cornerCurve = kCACornerCurveContinuous; + } + + if (QMUICMIActivated) { + self.backgroundColor = BadgeBackgroundColor; + self.textColor = BadgeTextColor; + self.font = BadgeFont; + self.contentEdgeInsets = BadgeContentEdgeInsets; + } else { + self.backgroundColor = UIColorRed; + self.textColor = UIColorWhite; + self.font = UIFontBoldMake(11); + self.contentEdgeInsets = UIEdgeInsetsMake(2, 4, 2, 4); + } + } + return self; +} + +- (CGSize)sizeThatFits:(CGSize)size { + if (self.attributedText.length == 1) { + NSMutableAttributedString *text = self.attributedText.mutableCopy; + [text replaceCharactersInRange:NSMakeRange(0, 1) withString:@"8"]; + CGSize textSize = [text boundingRectWithSize:CGSizeMax options:NSStringDrawingUsesLineFragmentOrigin context:nil].size; + CGSize result = CGSizeFlatted(CGSizeMake(textSize.width + UIEdgeInsetsGetHorizontalValue(self.contentEdgeInsets), textSize.height + UIEdgeInsetsGetVerticalValue(self.contentEdgeInsets))); + result.width = MAX(result.width, result.height); + result.height = result.width; + return result; + } + CGSize result = [super sizeThatFits:size]; + return result; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + self.layer.cornerRadius = MIN(CGRectGetWidth(self.bounds) / 2, CGRectGetHeight(self.bounds) / 2); +} + +@end diff --git a/QMUIKit/QMUIComponents/QMUIBadge/QMUIBadgeProtocol.h b/QMUIKit/QMUIComponents/QMUIBadge/QMUIBadgeProtocol.h index 62b05e53..ee673fff 100644 --- a/QMUIKit/QMUIComponents/QMUIBadge/QMUIBadgeProtocol.h +++ b/QMUIKit/QMUIComponents/QMUIBadge/QMUIBadgeProtocol.h @@ -16,28 +16,32 @@ #import #import -// TODO: molice 等废弃 qmui_badgeCenterOffset 系列接口后再删除 -#import "QMUICore.h" - NS_ASSUME_NONNULL_BEGIN -@class QMUILabel; - @protocol QMUIBadgeProtocol #pragma mark - Badge -/// 用数字设置未读数,0表示不显示未读数 +/// 用数字设置未读数,0表示不显示未读数。 +/// @note 仅当 qmui_badgeView 为 UILabel 及其子类时才会自动设置到 qmui_badgeView 上。 @property(nonatomic, assign) NSUInteger qmui_badgeInteger; /// 用字符串设置未读数,nil 表示不显示未读数 +/// @note 仅当 qmui_badgeView 为 UILabel 及其子类时才会自动设置到 qmui_badgeView 上。 @property(nonatomic, copy, nullable) NSString *qmui_badgeString; @property(nonatomic, strong, nullable) UIColor *qmui_badgeBackgroundColor; + +/// 未读数的文字颜色 +/// @note 仅当 qmui_badgeView 为 UILabel 及其子类时才会自动设置到 qmui_badgeView 上。 @property(nonatomic, strong, nullable) UIColor *qmui_badgeTextColor; + +/// 未读数的字体 +/// @note 仅当 qmui_badgeView 为 UILabel 及其子类时才会自动设置到 qmui_badgeView 上。 @property(nonatomic, strong, nullable) UIFont *qmui_badgeFont; /// 未读数字与圆圈之间的 padding,会影响最终 badge 的大小。当只有一位数字时,会取宽/高中最大的值作为最终的宽高,以保证整个 badge 是正圆。 +/// /// @note 仅当 qmui_badgeView 为 QMUILabel 及其子类时才会自动设置到 qmui_badgeView 上。 @property(nonatomic, assign) UIEdgeInsets qmui_badgeContentEdgeInsets; /// 默认 badge 的布局处于 view 右上角(x = view.width, y = -badge height),通过这个属性可以调整 badge 相对于默认原点的偏移,x 正值表示向右,y 正值表示向下。 @@ -47,11 +51,11 @@ NS_ASSUME_NONNULL_BEGIN /// 横屏下使用,其他同 @c qmui_badgeOffset 。 @property(nonatomic, assign) CGPoint qmui_badgeOffsetLandscape; -/// 在这两个属性被删除之前,如果不主动设置 @c qmui_badgeOffset 和 @c qmui_badgeOffsetLandscape ,则依然使用旧的逻辑,一旦设置过两个新属性,则旧属性会失效。 -@property(nonatomic, assign) CGPoint qmui_badgeCenterOffset DEPRECATED_MSG_ATTRIBUTE("QMUIBadge 不再以中心为布局参考点,请改为使用 qmui_badgeOffset"); -@property(nonatomic, assign) CGPoint qmui_badgeCenterOffsetLandscape DEPRECATED_MSG_ATTRIBUTE("QMUIBadge 不再以中心为布局参考点,请改为使用 qmui_badgeOffsetLandscape"); +/// 未读数的 view,默认是 QMUIBadgeLabel,也可设置为自定义的 view。自定义 view 如果是 UILabel 类型则内部会自动为其设置 text、textColor,但如果是其他类型的 view 则需要业务自行处理。 +@property(nonatomic, strong, nullable) __kindof UIView *qmui_badgeView; -@property(nonatomic, strong, readonly, nullable) QMUILabel *qmui_badgeLabel; +/// badgeView 布局完成后的回调。因为 badgeView 必定在当前 view 的 layoutSubviews 执行完之后才布局,所以业务很难在自己的 layoutSubviews 里重新调整 badgeView 的布局,所以提供一个 block。 +@property(nonatomic, copy, nullable) void (^qmui_badgeViewDidLayoutBlock)(__kindof UIView *aView, __kindof UIView *aBadgeView); #pragma mark - UpdatesIndicator @@ -68,11 +72,11 @@ NS_ASSUME_NONNULL_BEGIN /// 横屏下使用,其他同 @c qmui_updatesIndicatorOffset 。 @property(nonatomic, assign) CGPoint qmui_updatesIndicatorOffsetLandscape; -/// 在这两个属性被删除之前,如果不主动设置 @c qmui_updatesIndicatorOffset 和 @c qmui_updatesIndicatorOffsetLandscape ,则依然使用旧的逻辑,一旦设置过两个新属性,则旧属性会失效。 -@property(nonatomic, assign) CGPoint qmui_updatesIndicatorCenterOffset DEPRECATED_MSG_ATTRIBUTE("QMUIBadge 不再以中心为布局参考点,请改为使用 qmui_updatesIndicatorOffset"); -@property(nonatomic, assign) CGPoint qmui_updatesIndicatorCenterOffsetLandscape DEPRECATED_MSG_ATTRIBUTE("QMUIBadge 不再以中心为布局参考点,请改为使用 qmui_updatesIndicatorOffsetLandscape"); +/// 未读红点的 view,支持设置为自定义 view。 +@property(nonatomic, strong, nullable) __kindof UIView *qmui_updatesIndicatorView; -@property(nonatomic, strong, readonly, nullable) UIView *qmui_updatesIndicatorView; +/// updatesIndicatorView 布局完成后的回调。因为 updatesIndicatorView 必定在当前 view 的 layoutSubviews 执行完之后才布局,所以业务很难在自己的 layoutSubviews 里重新调整 updatesIndicatorView 的布局,所以提供一个 block。 +@property(nonatomic, copy, nullable) void (^qmui_updatesIndicatorViewDidLayoutBlock)(__kindof UIView *aView, __kindof UIView *aUpdatesIndicatorView); @end diff --git a/QMUIKit/QMUIComponents/QMUIBadge/UIBarItem+QMUIBadge.m b/QMUIKit/QMUIComponents/QMUIBadge/UIBarItem+QMUIBadge.m index 62aabfcc..9dbb0490 100644 --- a/QMUIKit/QMUIComponents/QMUIBadge/UIBarItem+QMUIBadge.m +++ b/QMUIKit/QMUIComponents/QMUIBadge/UIBarItem+QMUIBadge.m @@ -65,13 +65,6 @@ - (void)qmuibaritem_didInitialize { self.qmui_updatesIndicatorSize = UpdatesIndicatorSize; self.qmui_updatesIndicatorOffset = UpdatesIndicatorOffset; self.qmui_updatesIndicatorOffsetLandscape = UpdatesIndicatorOffsetLandscape; - - BeginIgnoreDeprecatedWarning - self.qmui_badgeCenterOffset = BadgeCenterOffset; - self.qmui_badgeCenterOffsetLandscape = BadgeCenterOffsetLandscape; - self.qmui_updatesIndicatorCenterOffset = UpdatesIndicatorCenterOffset; - self.qmui_updatesIndicatorCenterOffsetLandscape = UpdatesIndicatorCenterOffsetLandscape; - EndIgnoreClangWarning } } @@ -160,34 +153,20 @@ - (CGPoint)qmui_badgeOffsetLandscape { return [((NSNumber *)objc_getAssociatedObject(self, &kAssociatedObjectKey_badgeOffsetLandscape)) CGPointValue]; } -BeginIgnoreDeprecatedWarning -BeginIgnoreClangWarning(-Wdeprecated-implementations) - -static char kAssociatedObjectKey_badgeCenterOffset; -- (void)setQmui_badgeCenterOffset:(CGPoint)qmui_badgeCenterOffset { - objc_setAssociatedObject(self, &kAssociatedObjectKey_badgeCenterOffset, [NSValue valueWithCGPoint:qmui_badgeCenterOffset], OBJC_ASSOCIATION_RETAIN_NONATOMIC); - self.qmui_view.qmui_badgeCenterOffset = qmui_badgeCenterOffset; +- (void)setQmui_badgeView:(__kindof UIView *)qmui_badgeView { + self.qmui_view.qmui_badgeView = qmui_badgeView; } -- (CGPoint)qmui_badgeCenterOffset { - return [((NSValue *)objc_getAssociatedObject(self, &kAssociatedObjectKey_badgeCenterOffset)) CGPointValue]; +- (__kindof UIView *)qmui_badgeView { + return self.qmui_view.qmui_badgeView; } -static char kAssociatedObjectKey_badgeCenterOffsetLandscape; -- (void)setQmui_badgeCenterOffsetLandscape:(CGPoint)qmui_badgeCenterOffsetLandscape { - objc_setAssociatedObject(self, &kAssociatedObjectKey_badgeCenterOffsetLandscape, [NSValue valueWithCGPoint:qmui_badgeCenterOffsetLandscape], OBJC_ASSOCIATION_RETAIN_NONATOMIC); - self.qmui_view.qmui_badgeCenterOffsetLandscape = qmui_badgeCenterOffsetLandscape; +- (void)setQmui_badgeViewDidLayoutBlock:(void (^)(__kindof UIView * _Nonnull, __kindof UIView * _Nonnull))qmui_badgeViewDidLayoutBlock { + self.qmui_view.qmui_badgeViewDidLayoutBlock = qmui_badgeViewDidLayoutBlock; } -- (CGPoint)qmui_badgeCenterOffsetLandscape { - return [((NSValue *)objc_getAssociatedObject(self, &kAssociatedObjectKey_badgeCenterOffsetLandscape)) CGPointValue]; -} - -EndIgnoreClangWarning -EndIgnoreDeprecatedWarning - -- (QMUILabel *)qmui_badgeLabel { - return self.qmui_view.qmui_badgeLabel; +- (void (^)(__kindof UIView * _Nonnull, __kindof UIView * _Nonnull))qmui_badgeViewDidLayoutBlock { + return self.qmui_view.qmui_badgeViewDidLayoutBlock; } #pragma mark - UpdatesIndicator @@ -245,34 +224,20 @@ - (CGPoint)qmui_updatesIndicatorOffsetLandscape { return [((NSNumber *)objc_getAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorOffsetLandscape)) CGPointValue]; } -BeginIgnoreDeprecatedWarning -BeginIgnoreClangWarning(-Wdeprecated-implementations) - -static char kAssociatedObjectKey_updatesIndicatorCenterOffset; -- (void)setQmui_updatesIndicatorCenterOffset:(CGPoint)qmui_updatesIndicatorCenterOffset { - objc_setAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorCenterOffset, [NSValue valueWithCGPoint:qmui_updatesIndicatorCenterOffset], OBJC_ASSOCIATION_RETAIN_NONATOMIC); - self.qmui_view.qmui_updatesIndicatorCenterOffset = qmui_updatesIndicatorCenterOffset; -} - -- (CGPoint)qmui_updatesIndicatorCenterOffset { - return [((NSValue *)objc_getAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorCenterOffset)) CGPointValue]; +- (void)setQmui_updatesIndicatorView:(__kindof UIView *)qmui_updatesIndicatorView { + self.qmui_view.qmui_updatesIndicatorView = qmui_updatesIndicatorView; } -static char kAssociatedObjectKey_updatesIndicatorCenterOffsetLandscape; -- (void)setQmui_updatesIndicatorCenterOffsetLandscape:(CGPoint)qmui_updatesIndicatorCenterOffsetLandscape { - objc_setAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorCenterOffsetLandscape, [NSValue valueWithCGPoint:qmui_updatesIndicatorCenterOffsetLandscape], OBJC_ASSOCIATION_RETAIN_NONATOMIC); - self.qmui_view.qmui_updatesIndicatorCenterOffsetLandscape = qmui_updatesIndicatorCenterOffsetLandscape; +- (UIView *)qmui_updatesIndicatorView { + return self.qmui_view.qmui_updatesIndicatorView; } -- (CGPoint)qmui_updatesIndicatorCenterOffsetLandscape { - return [((NSValue *)objc_getAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorCenterOffsetLandscape)) CGPointValue]; +- (void)setQmui_updatesIndicatorViewDidLayoutBlock:(void (^)(__kindof UIView * _Nonnull, __kindof UIView * _Nonnull))qmui_updatesIndicatorViewDidLayoutBlock { + self.qmui_view.qmui_updatesIndicatorViewDidLayoutBlock = qmui_updatesIndicatorViewDidLayoutBlock; } -EndIgnoreClangWarning -EndIgnoreDeprecatedWarning - -- (UIView *)qmui_updatesIndicatorView { - return self.qmui_view.qmui_updatesIndicatorView; +- (void (^)(__kindof UIView * _Nonnull, __kindof UIView * _Nonnull))qmui_updatesIndicatorViewDidLayoutBlock { + return self.qmui_view.qmui_updatesIndicatorViewDidLayoutBlock; } #pragma mark - Common @@ -292,13 +257,6 @@ - (void)updateViewDidSetBlockIfNeeded { view.qmui_updatesIndicatorOffset = item.qmui_updatesIndicatorOffset; view.qmui_updatesIndicatorOffsetLandscape = item.qmui_updatesIndicatorOffsetLandscape; - BeginIgnoreDeprecatedWarning - view.qmui_badgeCenterOffset = item.qmui_badgeCenterOffset; - view.qmui_badgeCenterOffsetLandscape = item.qmui_badgeCenterOffsetLandscape; - view.qmui_updatesIndicatorCenterOffset = item.qmui_updatesIndicatorCenterOffset; - view.qmui_updatesIndicatorCenterOffsetLandscape = item.qmui_updatesIndicatorCenterOffsetLandscape; - EndIgnoreDeprecatedWarning - view.qmui_badgeString = item.qmui_badgeString; view.qmui_shouldShowUpdatesIndicator = item.qmui_shouldShowUpdatesIndicator; }; diff --git a/QMUIKit/QMUIComponents/QMUIBadge/UIView+QMUIBadge.m b/QMUIKit/QMUIComponents/QMUIBadge/UIView+QMUIBadge.m index 98bf2ad4..1bb929a5 100644 --- a/QMUIKit/QMUIComponents/QMUIBadge/UIView+QMUIBadge.m +++ b/QMUIKit/QMUIComponents/QMUIBadge/UIView+QMUIBadge.m @@ -18,34 +18,17 @@ #import "QMUILabel.h" #import "UIView+QMUI.h" #import "UITabBarItem+QMUI.h" - -@protocol _QMUIBadgeViewProtocol - -@required - -@property(nonatomic, assign) CGPoint offset; -@property(nonatomic, assign) CGPoint offsetLandscape; -@property(nonatomic, assign) CGPoint centerOffset; -@property(nonatomic, assign) CGPoint centerOffsetLandscape; - -@end - -@interface _QMUIBadgeLabel : QMUILabel <_QMUIBadgeViewProtocol> -@end - -@interface _QMUIUpdatesIndicatorView : UIView <_QMUIBadgeViewProtocol> -@end +#import "QMUIBadgeLabel.h" @interface UIView () - -@property(nonatomic, strong, readwrite) _QMUIBadgeLabel *qmui_badgeLabel; -@property(nonatomic, strong, readwrite) _QMUIUpdatesIndicatorView *qmui_updatesIndicatorView; @property(nullable, nonatomic, strong) void (^qmuibdg_layoutSubviewsBlock)(__kindof UIView *view); @end @implementation UIView (QMUIBadge) QMUISynthesizeIdStrongProperty(qmuibdg_layoutSubviewsBlock, setQmuibdg_layoutSubviewsBlock) +QMUISynthesizeIdCopyProperty(qmui_badgeViewDidLayoutBlock, setQmui_badgeViewDidLayoutBlock) +QMUISynthesizeIdCopyProperty(qmui_updatesIndicatorViewDidLayoutBlock, setQmui_updatesIndicatorViewDidLayoutBlock) + (void)load { static dispatch_once_t onceToken; @@ -93,13 +76,6 @@ - (void)qmuibdg_didInitialize { self.qmui_updatesIndicatorSize = UpdatesIndicatorSize; self.qmui_updatesIndicatorOffset = UpdatesIndicatorOffset; self.qmui_updatesIndicatorOffsetLandscape = UpdatesIndicatorOffsetLandscape; - - BeginIgnoreDeprecatedWarning - self.qmui_badgeCenterOffset = BadgeCenterOffset; - self.qmui_badgeCenterOffsetLandscape = BadgeCenterOffsetLandscape; - self.qmui_updatesIndicatorCenterOffset = UpdatesIndicatorCenterOffset; - self.qmui_updatesIndicatorCenterOffsetLandscape = UpdatesIndicatorCenterOffsetLandscape; - EndIgnoreDeprecatedWarning } } @@ -119,30 +95,23 @@ - (NSUInteger)qmui_badgeInteger { - (void)setQmui_badgeString:(NSString *)qmui_badgeString { objc_setAssociatedObject(self, &kAssociatedObjectKey_badgeString, qmui_badgeString, OBJC_ASSOCIATION_COPY_NONATOMIC); if (qmui_badgeString.length) { - if (!self.qmui_badgeLabel) { - self.qmui_badgeLabel = [[_QMUIBadgeLabel alloc] init]; - self.qmui_badgeLabel.clipsToBounds = YES; - self.qmui_badgeLabel.textAlignment = NSTextAlignmentCenter; - self.qmui_badgeLabel.backgroundColor = self.qmui_badgeBackgroundColor; - self.qmui_badgeLabel.textColor = self.qmui_badgeTextColor; - self.qmui_badgeLabel.font = self.qmui_badgeFont; - self.qmui_badgeLabel.contentEdgeInsets = self.qmui_badgeContentEdgeInsets; - self.qmui_badgeLabel.offset = self.qmui_badgeOffset; - self.qmui_badgeLabel.offsetLandscape = self.qmui_badgeOffsetLandscape; - BeginIgnoreDeprecatedWarning - self.qmui_badgeLabel.centerOffset = self.qmui_badgeCenterOffset; - self.qmui_badgeLabel.centerOffsetLandscape = self.qmui_badgeCenterOffsetLandscape; - EndIgnoreDeprecatedWarning - [self addSubview:self.qmui_badgeLabel]; - - [self updateLayoutSubviewsBlockIfNeeded]; + if (!self.qmui_badgeView) { + QMUIBadgeLabel *badgeLabel = [[QMUIBadgeLabel alloc] init]; + badgeLabel.backgroundColor = self.qmui_badgeBackgroundColor; + badgeLabel.textColor = self.qmui_badgeTextColor; + badgeLabel.font = self.qmui_badgeFont; + badgeLabel.contentEdgeInsets = self.qmui_badgeContentEdgeInsets; + self.qmui_badgeView = badgeLabel; + } + if ([self.qmui_badgeView respondsToSelector:@selector(setText:)]) { + ((UILabel *)self.qmui_badgeView).text = qmui_badgeString; } - self.qmui_badgeLabel.text = qmui_badgeString; - self.qmui_badgeLabel.hidden = NO; + self.qmui_badgeView.hidden = NO; [self setNeedsUpdateBadgeLabelLayout]; + QMUIAssert(!self.clipsToBounds, @"QMUIBadge", @"clipsToBounds should be NO when showing badgeString"); self.clipsToBounds = NO; } else { - self.qmui_badgeLabel.hidden = YES; + self.qmui_badgeView.hidden = YES; } } @@ -153,7 +122,7 @@ - (NSString *)qmui_badgeString { static char kAssociatedObjectKey_badgeBackgroundColor; - (void)setQmui_badgeBackgroundColor:(UIColor *)qmui_badgeBackgroundColor { objc_setAssociatedObject(self, &kAssociatedObjectKey_badgeBackgroundColor, qmui_badgeBackgroundColor, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - self.qmui_badgeLabel.backgroundColor = qmui_badgeBackgroundColor; + self.qmui_badgeView.backgroundColor = qmui_badgeBackgroundColor; } - (UIColor *)qmui_badgeBackgroundColor { @@ -163,7 +132,9 @@ - (UIColor *)qmui_badgeBackgroundColor { static char kAssociatedObjectKey_badgeTextColor; - (void)setQmui_badgeTextColor:(UIColor *)qmui_badgeTextColor { objc_setAssociatedObject(self, &kAssociatedObjectKey_badgeTextColor, qmui_badgeTextColor, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - self.qmui_badgeLabel.textColor = qmui_badgeTextColor; + if ([self.qmui_badgeView isKindOfClass:UILabel.class]) { + ((UILabel *)self.qmui_badgeView).textColor = qmui_badgeTextColor; + } } - (UIColor *)qmui_badgeTextColor { @@ -173,8 +144,8 @@ - (UIColor *)qmui_badgeTextColor { static char kAssociatedObjectKey_badgeFont; - (void)setQmui_badgeFont:(UIFont *)qmui_badgeFont { objc_setAssociatedObject(self, &kAssociatedObjectKey_badgeFont, qmui_badgeFont, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - if (self.qmui_badgeLabel) { - self.qmui_badgeLabel.font = qmui_badgeFont; + if ([self.qmui_badgeView isKindOfClass:UILabel.class]) { + ((UILabel *)self.qmui_badgeView).font = qmui_badgeFont; [self setNeedsUpdateBadgeLabelLayout]; } } @@ -186,8 +157,8 @@ - (UIFont *)qmui_badgeFont { static char kAssociatedObjectKey_badgeContentEdgeInsets; - (void)setQmui_badgeContentEdgeInsets:(UIEdgeInsets)qmui_badgeContentEdgeInsets { objc_setAssociatedObject(self, &kAssociatedObjectKey_badgeContentEdgeInsets, [NSValue valueWithUIEdgeInsets:qmui_badgeContentEdgeInsets], OBJC_ASSOCIATION_RETAIN_NONATOMIC); - if (self.qmui_badgeLabel) { - self.qmui_badgeLabel.contentEdgeInsets = qmui_badgeContentEdgeInsets; + if ([self.qmui_badgeView isKindOfClass:QMUILabel.class]) { + ((QMUILabel *)self.qmui_badgeView).contentEdgeInsets = qmui_badgeContentEdgeInsets; [self setNeedsUpdateBadgeLabelLayout]; } } @@ -199,7 +170,7 @@ - (UIEdgeInsets)qmui_badgeContentEdgeInsets { static char kAssociatedObjectKey_badgeOffset; - (void)setQmui_badgeOffset:(CGPoint)qmui_badgeOffset { objc_setAssociatedObject(self, &kAssociatedObjectKey_badgeOffset, @(qmui_badgeOffset), OBJC_ASSOCIATION_RETAIN_NONATOMIC); - self.qmui_badgeLabel.offset = qmui_badgeOffset; + [self setNeedsUpdateBadgeLabelLayout]; } - (CGPoint)qmui_badgeOffset { @@ -209,51 +180,30 @@ - (CGPoint)qmui_badgeOffset { static char kAssociatedObjectKey_badgeOffsetLandscape; - (void)setQmui_badgeOffsetLandscape:(CGPoint)qmui_badgeOffsetLandscape { objc_setAssociatedObject(self, &kAssociatedObjectKey_badgeOffsetLandscape, @(qmui_badgeOffsetLandscape), OBJC_ASSOCIATION_RETAIN_NONATOMIC); - self.qmui_badgeLabel.offsetLandscape = qmui_badgeOffsetLandscape; + [self setNeedsUpdateBadgeLabelLayout]; } - (CGPoint)qmui_badgeOffsetLandscape { return [((NSNumber *)objc_getAssociatedObject(self, &kAssociatedObjectKey_badgeOffsetLandscape)) CGPointValue]; } -BeginIgnoreDeprecatedWarning -BeginIgnoreClangWarning(-Wdeprecated-implementations) - -static char kAssociatedObjectKey_badgeCenterOffset; -- (void)setQmui_badgeCenterOffset:(CGPoint)qmui_badgeCenterOffset { - objc_setAssociatedObject(self, &kAssociatedObjectKey_badgeCenterOffset, [NSValue valueWithCGPoint:qmui_badgeCenterOffset], OBJC_ASSOCIATION_RETAIN_NONATOMIC); - self.qmui_badgeLabel.centerOffset = qmui_badgeCenterOffset; -} - -- (CGPoint)qmui_badgeCenterOffset { - return [((NSValue *)objc_getAssociatedObject(self, &kAssociatedObjectKey_badgeCenterOffset)) CGPointValue]; -} - -static char kAssociatedObjectKey_badgeCenterOffsetLandscape; -- (void)setQmui_badgeCenterOffsetLandscape:(CGPoint)qmui_badgeCenterOffsetLandscape { - objc_setAssociatedObject(self, &kAssociatedObjectKey_badgeCenterOffsetLandscape, [NSValue valueWithCGPoint:qmui_badgeCenterOffsetLandscape], OBJC_ASSOCIATION_RETAIN_NONATOMIC); - self.qmui_badgeLabel.centerOffsetLandscape = qmui_badgeCenterOffsetLandscape; -} - -- (CGPoint)qmui_badgeCenterOffsetLandscape { - return [((NSValue *)objc_getAssociatedObject(self, &kAssociatedObjectKey_badgeCenterOffsetLandscape)) CGPointValue]; -} - -EndIgnoreClangWarning -EndIgnoreDeprecatedWarning - -static char kAssociatedObjectKey_badgeLabel; -- (void)setQmui_badgeLabel:(UILabel *)qmui_badgeLabel { - objc_setAssociatedObject(self, &kAssociatedObjectKey_badgeLabel, qmui_badgeLabel, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +static char kAssociatedObjectKey_badgeView; +- (void)setQmui_badgeView:(UIView *)qmui_badgeView { + objc_setAssociatedObject(self, &kAssociatedObjectKey_badgeView, qmui_badgeView, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + if (qmui_badgeView) { + [self updateLayoutSubviewsBlockIfNeeded]; + [self addSubview:qmui_badgeView]; + [self setNeedsUpdateBadgeLabelLayout]; + } } -- (_QMUIBadgeLabel *)qmui_badgeLabel { - return (_QMUIBadgeLabel *)objc_getAssociatedObject(self, &kAssociatedObjectKey_badgeLabel); +- (__kindof UIView *)qmui_badgeView { + return (UIView *)objc_getAssociatedObject(self, &kAssociatedObjectKey_badgeView); } - (void)setNeedsUpdateBadgeLabelLayout { - if (self.qmui_badgeString.length) { - [self setNeedsLayout]; + if (self.qmui_badgeView && !self.qmui_badgeView.hidden) { + [self qmuibdg_layoutSubviews]; } } @@ -264,19 +214,12 @@ - (void)setQmui_shouldShowUpdatesIndicator:(BOOL)qmui_shouldShowUpdatesIndicator objc_setAssociatedObject(self, &kAssociatedObjectKey_shouldShowUpdatesIndicator, @(qmui_shouldShowUpdatesIndicator), OBJC_ASSOCIATION_RETAIN_NONATOMIC); if (qmui_shouldShowUpdatesIndicator) { if (!self.qmui_updatesIndicatorView) { - self.qmui_updatesIndicatorView = [[_QMUIUpdatesIndicatorView alloc] qmui_initWithSize:self.qmui_updatesIndicatorSize]; + self.qmui_updatesIndicatorView = [[UIView alloc] qmui_initWithSize:self.qmui_updatesIndicatorSize]; self.qmui_updatesIndicatorView.layer.cornerRadius = CGRectGetHeight(self.qmui_updatesIndicatorView.bounds) / 2; self.qmui_updatesIndicatorView.backgroundColor = self.qmui_updatesIndicatorColor; - self.qmui_updatesIndicatorView.offset = self.qmui_updatesIndicatorOffset; - self.qmui_updatesIndicatorView.offsetLandscape = self.qmui_updatesIndicatorOffsetLandscape; - BeginIgnoreDeprecatedWarning - self.qmui_updatesIndicatorView.centerOffset = self.qmui_updatesIndicatorCenterOffset; - self.qmui_updatesIndicatorView.centerOffsetLandscape = self.qmui_updatesIndicatorCenterOffsetLandscape; - EndIgnoreDeprecatedWarning - [self addSubview:self.qmui_updatesIndicatorView]; - [self updateLayoutSubviewsBlockIfNeeded]; } [self setNeedsUpdateIndicatorLayout]; + QMUIAssert(!self.clipsToBounds, @"QMUIBadge", @"clipsToBounds should be NO when showing updatesIndicator"); self.clipsToBounds = NO; self.qmui_updatesIndicatorView.hidden = NO; } else { @@ -316,7 +259,6 @@ - (CGSize)qmui_updatesIndicatorSize { - (void)setQmui_updatesIndicatorOffset:(CGPoint)qmui_updatesIndicatorOffset { objc_setAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorOffset, @(qmui_updatesIndicatorOffset), OBJC_ASSOCIATION_RETAIN_NONATOMIC); if (self.qmui_updatesIndicatorView) { - self.qmui_updatesIndicatorView.offset = qmui_updatesIndicatorOffset; [self setNeedsUpdateIndicatorLayout]; } } @@ -329,7 +271,6 @@ - (CGPoint)qmui_updatesIndicatorOffset { - (void)setQmui_updatesIndicatorOffsetLandscape:(CGPoint)qmui_updatesIndicatorOffsetLandscape { objc_setAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorOffsetLandscape, @(qmui_updatesIndicatorOffsetLandscape), OBJC_ASSOCIATION_RETAIN_NONATOMIC); if (self.qmui_updatesIndicatorView) { - self.qmui_updatesIndicatorView.offsetLandscape = qmui_updatesIndicatorOffsetLandscape; [self setNeedsUpdateIndicatorLayout]; } } @@ -338,50 +279,23 @@ - (CGPoint)qmui_updatesIndicatorOffsetLandscape { return [((NSNumber *)objc_getAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorOffsetLandscape)) CGPointValue]; } -BeginIgnoreDeprecatedWarning -BeginIgnoreClangWarning(-Wdeprecated-implementations) - -static char kAssociatedObjectKey_updatesIndicatorCenterOffset; -- (void)setQmui_updatesIndicatorCenterOffset:(CGPoint)qmui_updatesIndicatorCenterOffset { - objc_setAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorCenterOffset, [NSValue valueWithCGPoint:qmui_updatesIndicatorCenterOffset], OBJC_ASSOCIATION_RETAIN_NONATOMIC); - if (self.qmui_updatesIndicatorView) { - self.qmui_updatesIndicatorView.centerOffset = qmui_updatesIndicatorCenterOffset; - [self setNeedsUpdateIndicatorLayout]; - } -} - -- (CGPoint)qmui_updatesIndicatorCenterOffset { - return [((NSValue *)objc_getAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorCenterOffset)) CGPointValue]; -} - -static char kAssociatedObjectKey_updatesIndicatorCenterOffsetLandscape; -- (void)setQmui_updatesIndicatorCenterOffsetLandscape:(CGPoint)qmui_updatesIndicatorCenterOffsetLandscape { - objc_setAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorCenterOffsetLandscape, [NSValue valueWithCGPoint:qmui_updatesIndicatorCenterOffsetLandscape], OBJC_ASSOCIATION_RETAIN_NONATOMIC); - if (self.qmui_updatesIndicatorView) { - self.qmui_updatesIndicatorView.centerOffsetLandscape = qmui_updatesIndicatorCenterOffsetLandscape; - [self setNeedsUpdateIndicatorLayout]; - } -} - -- (CGPoint)qmui_updatesIndicatorCenterOffsetLandscape { - return [((NSValue *)objc_getAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorCenterOffsetLandscape)) CGPointValue]; -} - -EndIgnoreClangWarning -EndIgnoreDeprecatedWarning - static char kAssociatedObjectKey_updatesIndicatorView; -- (void)setQmui_updatesIndicatorView:(UIView *)qmui_updatesIndicatorView { +- (void)setQmui_updatesIndicatorView:(__kindof UIView *)qmui_updatesIndicatorView { objc_setAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorView, qmui_updatesIndicatorView, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + if (qmui_updatesIndicatorView) { + [self updateLayoutSubviewsBlockIfNeeded]; + [self addSubview:qmui_updatesIndicatorView]; + [self setNeedsUpdateIndicatorLayout]; + } } -- (_QMUIUpdatesIndicatorView *)qmui_updatesIndicatorView { - return (_QMUIUpdatesIndicatorView *)objc_getAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorView); +- (__kindof UIView *)qmui_updatesIndicatorView { + return (UIView *)objc_getAssociatedObject(self, &kAssociatedObjectKey_updatesIndicatorView); } - (void)setNeedsUpdateIndicatorLayout { if (self.qmui_shouldShowUpdatesIndicator) { - [self setNeedsLayout]; + [self qmuibdg_layoutSubviews]; } } @@ -405,7 +319,8 @@ - (void)updateLayoutSubviewsBlockIfNeeded { } } -- (UIView *)findBarButtonImageViewIfOffsetByTopRight:(BOOL)offsetByTopRight { +// 不管 image 还是 text 的 UIBarButtonItem 都获取内部的 _UIModernBarButton 即可 +- (UIView *)findBarButtonContentView { NSString *classString = NSStringFromClass(self.class); if ([classString isEqualToString:@"UITabBarButton"]) { // 特别的,对于 UITabBarItem,将 imageView 作为参考 view @@ -413,17 +328,10 @@ - (UIView *)findBarButtonImageViewIfOffsetByTopRight:(BOOL)offsetByTopRight { return imageView; } - // 如果使用 centerOffset 则不特殊处理 UIBarButtonItem,以保持与旧版的逻辑一致 - // TODO: molice 等废弃 qmui_badgeCenterOffset 系列接口后再删除 - if (!offsetByTopRight) return nil; - if ([classString isEqualToString:@"_UIButtonBarButton"]) { for (UIView *subview in self.subviews) { if ([subview isKindOfClass:UIButton.class]) { - UIView *imageView = ((UIButton *)subview).imageView; - if (imageView && !imageView.hidden) { - return imageView; - } + return subview; } } } @@ -433,110 +341,36 @@ - (UIView *)findBarButtonImageViewIfOffsetByTopRight:(BOOL)offsetByTopRight { - (void)qmuibdg_layoutSubviews { - void (^layoutBlock)(UIView *view, UIView<_QMUIBadgeViewProtocol> *badgeView) = ^void(UIView *view, UIView<_QMUIBadgeViewProtocol> *badgeView) { - BOOL offsetByTopRight = !CGPointEqualToPoint(badgeView.offset, QMUIBadgeInvalidateOffset) || !CGPointEqualToPoint(badgeView.offsetLandscape, QMUIBadgeInvalidateOffset); - CGPoint offset = IS_LANDSCAPE ? (offsetByTopRight ? badgeView.offsetLandscape : badgeView.centerOffsetLandscape) : (offsetByTopRight ? badgeView.offset : badgeView.centerOffset); + void (^layoutBlock)(UIView *view, UIView *badgeView) = ^void(UIView *view, UIView *badgeView) { + BeginIgnoreDeprecatedWarning + CGPoint offset = badgeView == view.qmui_badgeView + ? (IS_LANDSCAPE ? view.qmui_badgeOffsetLandscape : view.qmui_badgeOffset) + : (IS_LANDSCAPE ? view.qmui_updatesIndicatorOffsetLandscape : view.qmui_updatesIndicatorOffset); + EndIgnoreDeprecatedWarning - UIView *imageView = [view findBarButtonImageViewIfOffsetByTopRight:offsetByTopRight]; - if (imageView) { - CGRect imageViewFrame = [view convertRect:imageView.frame fromView:imageView.superview]; - if (offsetByTopRight) { - badgeView.frame = CGRectSetXY(badgeView.frame, CGRectGetMaxX(imageViewFrame) + offset.x, CGRectGetMinY(imageViewFrame) - CGRectGetHeight(badgeView.frame) + offset.y); - } else { - badgeView.center = CGPointMake(CGRectGetMidX(imageViewFrame) + offset.x, CGRectGetMidY(imageViewFrame) + offset.y); - } + UIView *contentView = [view findBarButtonContentView]; + if (contentView) { + CGRect imageViewFrame = [view convertRect:contentView.frame fromView:contentView.superview]; + badgeView.frame = CGRectSetXY(badgeView.frame, CGRectGetMaxX(imageViewFrame) + offset.x, CGRectGetMinY(imageViewFrame) - CGRectGetHeight(badgeView.frame) + offset.y); } else { - if (offsetByTopRight) { - badgeView.frame = CGRectSetXY(badgeView.frame, CGRectGetWidth(view.bounds) + offset.x, - CGRectGetHeight(badgeView.frame) + offset.y); - } else { - badgeView.center = CGPointMake(CGRectGetMidX(view.bounds) + offset.x, CGRectGetMidY(view.bounds) + offset.y); - } + badgeView.frame = CGRectSetXY(badgeView.frame, CGRectGetWidth(view.bounds) + offset.x, - CGRectGetHeight(badgeView.frame) + offset.y); } [view bringSubviewToFront:badgeView]; }; if (self.qmui_updatesIndicatorView && !self.qmui_updatesIndicatorView.hidden) { layoutBlock(self, self.qmui_updatesIndicatorView); + if (self.qmui_updatesIndicatorViewDidLayoutBlock) { + self.qmui_updatesIndicatorViewDidLayoutBlock(self, self.qmui_updatesIndicatorView); + } } - if (self.qmui_badgeLabel && !self.qmui_badgeLabel.hidden) { - [self.qmui_badgeLabel sizeToFit]; - self.qmui_badgeLabel.layer.cornerRadius = MIN(self.qmui_badgeLabel.qmui_height / 2, self.qmui_badgeLabel.qmui_width / 2); - layoutBlock(self, self.qmui_badgeLabel); - } -} - -@end - -@implementation _QMUIUpdatesIndicatorView - -@synthesize offset = _offset, offsetLandscape = _offsetLandscape, centerOffset = _centerOffset, centerOffsetLandscape = _centerOffsetLandscape; - -- (void)setOffset:(CGPoint)offset { - _offset = offset; - if (!IS_LANDSCAPE) { - [self.superview setNeedsLayout]; - } -} - -- (void)setOffsetLandscape:(CGPoint)offsetLandscape { - _offsetLandscape = offsetLandscape; - if (IS_LANDSCAPE) { - [self.superview setNeedsLayout]; - } -} - -- (void)setCenterOffset:(CGPoint)centerOffset { - _centerOffset = centerOffset; - if (!IS_LANDSCAPE) { - [self.superview setNeedsLayout]; - } -} - -- (void)setCenterOffsetLandscape:(CGPoint)centerOffsetLandscape { - _centerOffsetLandscape = centerOffsetLandscape; - if (IS_LANDSCAPE) { - [self.superview setNeedsLayout]; - } -} - -@end - -@implementation _QMUIBadgeLabel - -@synthesize offset = _offset, offsetLandscape = _offsetLandscape, centerOffset = _centerOffset, centerOffsetLandscape = _centerOffsetLandscape; - -- (void)setOffset:(CGPoint)offset { - _offset = offset; - if (!IS_LANDSCAPE) { - [self.superview setNeedsLayout]; - } -} - -- (void)setOffsetLandscape:(CGPoint)offsetLandscape { - _offsetLandscape = offsetLandscape; - if (IS_LANDSCAPE) { - [self.superview setNeedsLayout]; - } -} - -- (void)setCenterOffset:(CGPoint)centerOffset { - _centerOffset = centerOffset; - if (!IS_LANDSCAPE) { - [self.superview setNeedsLayout]; - } -} - -- (void)setCenterOffsetLandscape:(CGPoint)centerOffsetLandscape { - _centerOffsetLandscape = centerOffsetLandscape; - if (IS_LANDSCAPE) { - [self.superview setNeedsLayout]; + if (self.qmui_badgeView && !self.qmui_badgeView.hidden) { + [self.qmui_badgeView sizeToFit]; + layoutBlock(self, self.qmui_badgeView); + if (self.qmui_badgeViewDidLayoutBlock) { + self.qmui_badgeViewDidLayoutBlock(self, self.qmui_badgeView); + } } } -- (CGSize)sizeThatFits:(CGSize)size { - CGSize result = [super sizeThatFits:size]; - result = CGSizeMake(MAX(result.width, result.height), result.height); - return result; -} - @end diff --git a/QMUIKit/QMUIComponents/QMUIButton/QMUIButton.h b/QMUIKit/QMUIComponents/QMUIButton/QMUIButton.h index 771a13ba..88f9ed47 100644 --- a/QMUIKit/QMUIComponents/QMUIButton/QMUIButton.h +++ b/QMUIKit/QMUIComponents/QMUIButton/QMUIButton.h @@ -48,6 +48,11 @@ extern const CGFloat QMUIButtonCornerRadiusAdjustsBounds; */ - (void)didInitialize NS_REQUIRES_SUPER; +@property(nonatomic, strong, nullable) NSString *subtitle; +@property(nonatomic, strong, readonly) UILabel *subtitleLabel; +@property(nonatomic, assign) IBInspectable UIEdgeInsets subtitleEdgeInsets; +@property(nonatomic, strong, nullable) IBInspectable UIColor *subtitleColor; + /** * 让按钮的文字颜色自动跟随tintColor调整(系统默认titleColor是不跟随的)
* 默认为NO diff --git a/QMUIKit/QMUIComponents/QMUIButton/QMUIButton.m b/QMUIKit/QMUIComponents/QMUIButton/QMUIButton.m index 61933b34..28944c00 100644 --- a/QMUIKit/QMUIComponents/QMUIButton/QMUIButton.m +++ b/QMUIKit/QMUIComponents/QMUIButton/QMUIButton.m @@ -17,6 +17,7 @@ #import "QMUICore.h" #import "CALayer+QMUI.h" #import "UIButton+QMUI.h" +#import "QMUILayouter.h" const CGFloat QMUIButtonCornerRadiusAdjustsBounds = -1; @@ -28,10 +29,13 @@ @interface QMUIButton () @implementation QMUIButton +@synthesize subtitleLabel = _qmuisubtitleLabel; + - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { self.tintColor = ButtonTintColor; [self setTitleColor:self.tintColor forState:UIControlStateNormal];// 初始化时 adjustsTitleTintColorAutomatically 还是 NO,所以这里手动把 titleColor 设置为 tintColor 的值 + self.subtitleColor = self.tintColor; // iOS7以后的button,sizeToFit后默认会自带一个上下的contentInsets,为了保证按钮大小即为内容大小,这里直接去掉,改为一个最小的值。 self.contentEdgeInsets = UIEdgeInsetsMake(CGFLOAT_MIN, 0, CGFLOAT_MIN, 0); @@ -58,6 +62,33 @@ - (void)didInitialize { // 图片默认在按钮左边,与系统UIButton保持一致 self.imagePosition = QMUIButtonImagePositionLeft; + + _qmuisubtitleLabel = [[UILabel alloc] init]; + _qmuisubtitleLabel.textColor = self.subtitleColor; + _qmuisubtitleLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; + + self.subtitleEdgeInsets = UIEdgeInsetsMake(4, 0, 0, 0); +} + +- (void)setSubtitle:(NSString *)subtitle { + _subtitle = subtitle; + if (subtitle.length) { + [self addSubview:_qmuisubtitleLabel]; + _qmuisubtitleLabel.text = subtitle; + } else { + [_qmuisubtitleLabel removeFromSuperview]; + } + [self setNeedsLayout]; +} + +- (void)setSubtitleEdgeInsets:(UIEdgeInsets)subtitleEdgeInsets { + _subtitleEdgeInsets = subtitleEdgeInsets; + [self setNeedsLayout]; +} + +- (void)setSubtitleColor:(UIColor *)subtitleColor { + _subtitleColor = subtitleColor; + _qmuisubtitleLabel.textColor = subtitleColor; } // 系统访问 self.imageView 会触发 layout,而私有方法 _imageView 则是简单地访问 imageView,所以在 QMUIButton layoutSubviews 里应该用这个方法 @@ -68,72 +99,116 @@ - (UIImageView *)_qmui_imageView { EndIgnorePerformSelectorLeaksWarning } -- (CGSize)sizeThatFits:(CGSize)size { - // 如果调用 sizeToFit,那么传进来的 size 就是当前按钮的 size,此时的计算不要去限制宽高 - // 系统 UIButton 不管任何时候,对 sizeThatFits:CGSizeZero 都会返回真实的内容大小,这里对齐 - if (CGSizeEqualToSize(self.bounds.size, size) || CGSizeIsEmpty(size)) { - size = CGSizeMax; - } +- (QMUILayouterItem *)generateLayouterForLayout:(BOOL)forLayout { + __weak __typeof(self)weakSelf = self; + + QMUILayouterAlignment horizontal = [@[ + @(QMUILayouterAlignmentCenter), + @(QMUILayouterAlignmentLeading), + @(QMUILayouterAlignmentTrailing), + @(QMUILayouterAlignmentFill), + @(QMUILayouterAlignmentLeading), + @(QMUILayouterAlignmentTrailing), + ][self.contentHorizontalAlignment] integerValue]; + QMUILayouterAlignment vertical = [@[ + @(QMUILayouterAlignmentCenter), + @(QMUILayouterAlignmentLeading), + @(QMUILayouterAlignmentTrailing), + @(QMUILayouterAlignmentFill), + ][self.contentVerticalAlignment] integerValue]; BOOL isImageViewShowing = !!self.currentImage; - BOOL isTitleLabelShowing = !!self.currentTitle || self.currentAttributedTitle; - CGSize imageTotalSize = CGSizeZero;// 包含 imageEdgeInsets 那些空间 - CGSize titleTotalSize = CGSizeZero;// 包含 titleEdgeInsets 那些空间 - CGFloat spacingBetweenImageAndTitle = flat(isImageViewShowing && isTitleLabelShowing ? self.spacingBetweenImageAndTitle : 0);// 如果图片或文字某一者没显示,则这个 spacing 不考虑进布局 - UIEdgeInsets contentEdgeInsets = UIEdgeInsetsRemoveFloatMin(self.contentEdgeInsets); - CGSize resultSize = CGSizeZero; - CGSize contentLimitSize = CGSizeMake(size.width - UIEdgeInsetsGetHorizontalValue(contentEdgeInsets), size.height - UIEdgeInsetsGetVerticalValue(contentEdgeInsets)); + QMUILayouterItem *image = [QMUILayouterItem itemWithView:isImageViewShowing ? (forLayout ? self._qmui_imageView : self.imageView) : nil margin:self.imageEdgeInsets]; + image.visibleBlock = ^BOOL(QMUILayouterItem * _Nonnull aItem) { + return !!weakSelf.currentImage; + }; + image.sizeThatFitsBlock = ^CGSize(QMUILayouterItem * _Nonnull aItem, CGSize size, CGSize superResult) { + // 某些时机下存在 image 但 imageView.image 尚为 nil 导致计算出来的尺寸错误,所以这里做个保护(ed4d87e86af12110b2c14359ef287be959c70af0) + if (aItem.visible && CGSizeIsEmpty(superResult) && [aItem.view.superview isKindOfClass:QMUIButton.class]) { + QMUIButton *btn = (QMUIButton *)aItem.view.superview; + return btn.currentImage.size; + } + return superResult; + }; + QMUILayouterItem *title = [QMUILayouterItem itemWithView:self.titleLabel margin:self.titleEdgeInsets grow:QMUILayouterGrowNever shrink:QMUILayouterShrinkDefault]; + title.visibleBlock = ^BOOL(QMUILayouterItem * _Nonnull aItem) { + return !!weakSelf.currentTitle || !!weakSelf.currentAttributedTitle; + }; + QMUILayouterItem *subtitle = [QMUILayouterItem itemWithView:self.subtitleLabel margin:self.subtitleEdgeInsets grow:QMUILayouterGrowNever shrink:QMUILayouterShrinkDefault]; + QMUILayouterLinearVertical *titles = [QMUILayouterLinearVertical itemWithChildItems:@[ + title, + subtitle, + ] spacingBetweenItems:0 horizontal:horizontal vertical:vertical]; + titles.shrink = QMUILayouterShrinkDefault; - switch (self.imagePosition) { - case QMUIButtonImagePositionTop: - case QMUIButtonImagePositionBottom: { - // 图片和文字上下排版时,宽度以文字或图片的最大宽度为最终宽度 - if (isImageViewShowing) { - CGFloat imageLimitWidth = contentLimitSize.width - UIEdgeInsetsGetHorizontalValue(self.imageEdgeInsets); - CGSize imageSize = self.imageView.image ? [self.imageView sizeThatFits:CGSizeMake(imageLimitWidth, CGFLOAT_MAX)] : self.currentImage.size; - imageSize.width = MIN(imageSize.width, imageLimitWidth);// QMUIButton sizeThatFits 时 self._imageView 为 nil 但 self.imageView 有值,而开启了 Bold Text 时,系统的 self.imageView sizeThatFits 返回值会比没开启 BoldText 时多 1pt(不知道为什么文字加粗与否会影响 imageView...),从而保证开启 Bold Text 后文字依然能完整展示出来,所以这里应该用 self.imageView 而不是 self._imageView - imageTotalSize = CGSizeMake(imageSize.width + UIEdgeInsetsGetHorizontalValue(self.imageEdgeInsets), imageSize.height + UIEdgeInsetsGetVerticalValue(self.imageEdgeInsets)); - } - - if (isTitleLabelShowing) { - CGSize titleLimitSize = CGSizeMake(contentLimitSize.width - UIEdgeInsetsGetHorizontalValue(self.titleEdgeInsets), contentLimitSize.height - imageTotalSize.height - spacingBetweenImageAndTitle - UIEdgeInsetsGetVerticalValue(self.titleEdgeInsets)); - CGSize titleSize = [self.titleLabel sizeThatFits:titleLimitSize]; - titleSize.height = MIN(titleSize.height, titleLimitSize.height); - titleTotalSize = CGSizeMake(titleSize.width + UIEdgeInsetsGetHorizontalValue(self.titleEdgeInsets), titleSize.height + UIEdgeInsetsGetVerticalValue(self.titleEdgeInsets)); + if (self.imagePosition == QMUIButtonImagePositionTop || self.imagePosition == QMUIButtonImagePositionBottom) { + if (vertical == QMUILayouterAlignmentFill) { + if (image.visible && title.visible && !subtitle.visible) { + titles.grow = QMUILayouterGrowMost; + title.grow = QMUILayouterGrowMost; + } else if (image.visible && !title.visible && subtitle.visible) { + titles.grow = QMUILayouterGrowMost; + subtitle.grow = QMUILayouterGrowMost; + } else if (!image.visible && title.visible && subtitle.visible) { + titles.grow = QMUILayouterGrowMost; + title.grow = QMUILayouterGrowMost; } - - resultSize.width = UIEdgeInsetsGetHorizontalValue(contentEdgeInsets); - resultSize.width += MAX(imageTotalSize.width, titleTotalSize.width); - resultSize.height = UIEdgeInsetsGetVerticalValue(contentEdgeInsets) + imageTotalSize.height + spacingBetweenImageAndTitle + titleTotalSize.height; } - break; - - case QMUIButtonImagePositionLeft: - case QMUIButtonImagePositionRight: { - // 图片和文字水平排版时,高度以文字或图片的最大高度为最终高度 - // 注意这里有一个和系统不一致的行为:当 titleLabel 为多行时,系统的 sizeThatFits: 计算结果固定是单行的,所以当 QMUIButtonImagePositionLeft 并且titleLabel 多行的情况下,QMUIButton 计算的结果与系统不一致 - - if (isImageViewShowing) { - CGFloat imageLimitHeight = contentLimitSize.height - UIEdgeInsetsGetVerticalValue(self.imageEdgeInsets); - CGSize imageSize = self.imageView.image ? [self.imageView sizeThatFits:CGSizeMake(CGFLOAT_MAX, imageLimitHeight)] : self.currentImage.size; - imageSize.height = MIN(imageSize.height, imageLimitHeight);// QMUIButton sizeThatFits 时 self._imageView 为 nil 但 self.imageView 有值,而开启了 Bold Text 时,系统的 self.imageView sizeThatFits 返回值会比没开启 BoldText 时多 1pt(不知道为什么文字加粗与否会影响 imageView...),从而保证开启 Bold Text 后文字依然能完整展示出来,所以这里应该用 self.imageView 而不是 self._imageView - imageTotalSize = CGSizeMake(imageSize.width + UIEdgeInsetsGetHorizontalValue(self.imageEdgeInsets), imageSize.height + UIEdgeInsetsGetVerticalValue(self.imageEdgeInsets)); + } else if (self.imagePosition == QMUIButtonImagePositionLeft || self.imagePosition == QMUIButtonImagePositionRight) { + if (horizontal == QMUILayouterAlignmentFill) { + if (image.visible && (title.visible || subtitle.visible)) { + titles.grow = QMUILayouterGrowMost; } - - if (isTitleLabelShowing) { - CGSize titleLimitSize = CGSizeMake(contentLimitSize.width - UIEdgeInsetsGetHorizontalValue(self.titleEdgeInsets) - imageTotalSize.width - spacingBetweenImageAndTitle, contentLimitSize.height - UIEdgeInsetsGetVerticalValue(self.titleEdgeInsets)); - CGSize titleSize = [self.titleLabel sizeThatFits:titleLimitSize]; - titleSize.height = MIN(titleSize.height, titleLimitSize.height); - titleTotalSize = CGSizeMake(titleSize.width + UIEdgeInsetsGetHorizontalValue(self.titleEdgeInsets), titleSize.height + UIEdgeInsetsGetVerticalValue(self.titleEdgeInsets)); + } + if (vertical == QMUILayouterAlignmentFill) { + if (title.visible) { + title.grow = QMUILayouterGrowMost; + } else if (subtitle.visible) { + subtitle.grow = QMUILayouterGrowMost; } - - resultSize.width = UIEdgeInsetsGetHorizontalValue(contentEdgeInsets) + imageTotalSize.width + spacingBetweenImageAndTitle + titleTotalSize.width; - resultSize.height = UIEdgeInsetsGetVerticalValue(contentEdgeInsets); - resultSize.height += MAX(imageTotalSize.height, titleTotalSize.height); } - break; } - return resultSize; + + switch (self.imagePosition) { + case QMUIButtonImagePositionTop: { + return [QMUILayouterLinearVertical itemWithChildItems:@[ + image, + titles, + ] spacingBetweenItems:self.spacingBetweenImageAndTitle horizontal:horizontal vertical:vertical]; + } + case QMUIButtonImagePositionBottom: { + return [QMUILayouterLinearVertical itemWithChildItems:@[ + titles, + image, + ] spacingBetweenItems:self.spacingBetweenImageAndTitle horizontal:horizontal vertical:vertical]; + } + case QMUIButtonImagePositionLeft: { + return [QMUILayouterLinearHorizontal itemWithChildItems:@[ + image, + titles, + ] spacingBetweenItems:self.spacingBetweenImageAndTitle horizontal:horizontal vertical:vertical]; + } + case QMUIButtonImagePositionRight: { + return [QMUILayouterLinearHorizontal itemWithChildItems:@[ + titles, + image, + ] spacingBetweenItems:self.spacingBetweenImageAndTitle horizontal:horizontal vertical:vertical]; + } + } +} + +- (CGSize)sizeThatFits:(CGSize)size { + // 如果调用 sizeToFit,那么传进来的 size 就是当前按钮的 size,此时的计算不要去限制宽高 + // 系统 UIButton 不管任何时候,对 sizeThatFits:CGSizeZero 都会返回真实的内容大小,这里对齐 + if (CGSizeEqualToSize(self.bounds.size, size) || CGSizeIsEmpty(size)) { + size = CGSizeMax; + } + + QMUILayouterItem *layouter = [self generateLayouterForLayout:NO]; + CGSize result = [layouter sizeThatFits:size]; + result.width += UIEdgeInsetsGetHorizontalValue(self.contentEdgeInsets); + result.height += UIEdgeInsetsGetVerticalValue(self.contentEdgeInsets); + return result; } - (CGSize)intrinsicContentSize { @@ -151,297 +226,25 @@ - (void)layoutSubviews { self.layer.cornerRadius = CGRectGetHeight(self.bounds) / 2; } - BOOL isImageViewShowing = !!self.currentImage; - BOOL isTitleLabelShowing = !!self.currentTitle || !!self.currentAttributedTitle; - CGSize imageLimitSize = CGSizeZero; - CGSize titleLimitSize = CGSizeZero; - CGSize imageTotalSize = CGSizeZero;// 包含 imageEdgeInsets 那些空间 - CGSize titleTotalSize = CGSizeZero;// 包含 titleEdgeInsets 那些空间 - CGFloat spacingBetweenImageAndTitle = flat(isImageViewShowing && isTitleLabelShowing ? self.spacingBetweenImageAndTitle : 0);// 如果图片或文字某一者没显示,则这个 spacing 不考虑进布局 - CGRect imageFrame = CGRectZero; - CGRect titleFrame = CGRectZero; - UIEdgeInsets contentEdgeInsets = UIEdgeInsetsRemoveFloatMin(self.contentEdgeInsets); - CGSize contentSize = CGSizeMake(CGRectGetWidth(self.bounds) - UIEdgeInsetsGetHorizontalValue(contentEdgeInsets), CGRectGetHeight(self.bounds) - UIEdgeInsetsGetVerticalValue(contentEdgeInsets)); - - // 图片的布局原则都是尽量完整展示,所以不管 imagePosition 的值是什么,这个计算过程都是相同的 - if (isImageViewShowing) { - imageLimitSize = CGSizeMake(contentSize.width - UIEdgeInsetsGetHorizontalValue(self.imageEdgeInsets), contentSize.height - UIEdgeInsetsGetVerticalValue(self.imageEdgeInsets)); - CGSize imageSize = self._qmui_imageView.image ? [self._qmui_imageView sizeThatFits:imageLimitSize] : self.currentImage.size; - imageSize.width = MIN(imageLimitSize.width, imageSize.width); - imageSize.height = MIN(imageLimitSize.height, imageSize.height); - imageFrame = CGRectMakeWithSize(imageSize); - imageTotalSize = CGSizeMake(imageSize.width + UIEdgeInsetsGetHorizontalValue(self.imageEdgeInsets), imageSize.height + UIEdgeInsetsGetVerticalValue(self.imageEdgeInsets)); - } + QMUILayouterItem *layouter = [self generateLayouterForLayout:YES]; + layouter.frame = CGRectInsetEdges(self.bounds, self.contentEdgeInsets); + [layouter layoutIfNeeded]; - // UIButton 如果本身大小为 (0,0),此时设置一个 imageEdgeInsets 会让 imageView 的 bounds 错误,导致后续 imageView 的 subviews 布局时会产生偏移,因此这里做一次保护 - // https://github.com/Tencent/QMUI_iOS/issues/1012 - void (^makesureBoundsPositive)(UIView *) = ^void(UIView *view) { - CGRect bounds = view.bounds; - if (CGRectGetMinX(bounds) < 0 || CGRectGetMinY(bounds) < 0) { - bounds = CGRectMakeWithSize(bounds.size); - view.bounds = bounds; - } - }; - if (isImageViewShowing) { - makesureBoundsPositive(self._qmui_imageView); - } - if (isTitleLabelShowing) { - makesureBoundsPositive(self.titleLabel); - } - - if (self.imagePosition == QMUIButtonImagePositionTop || self.imagePosition == QMUIButtonImagePositionBottom) { - - if (isTitleLabelShowing) { - titleLimitSize = CGSizeMake(contentSize.width - UIEdgeInsetsGetHorizontalValue(self.titleEdgeInsets), contentSize.height - imageTotalSize.height - spacingBetweenImageAndTitle - UIEdgeInsetsGetVerticalValue(self.titleEdgeInsets)); - CGSize titleSize = [self.titleLabel sizeThatFits:titleLimitSize]; - titleSize.width = MIN(titleLimitSize.width, titleSize.width); - titleSize.height = MIN(titleLimitSize.height, titleSize.height); - titleFrame = CGRectMakeWithSize(titleSize); - titleTotalSize = CGSizeMake(titleSize.width + UIEdgeInsetsGetHorizontalValue(self.titleEdgeInsets), titleSize.height + UIEdgeInsetsGetVerticalValue(self.titleEdgeInsets)); - } - - switch (self.contentHorizontalAlignment) { - case UIControlContentHorizontalAlignmentLeft: - imageFrame = isImageViewShowing ? CGRectSetX(imageFrame, contentEdgeInsets.left + self.imageEdgeInsets.left) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetX(titleFrame, contentEdgeInsets.left + self.titleEdgeInsets.left) : titleFrame; - break; - case UIControlContentHorizontalAlignmentCenter: - imageFrame = isImageViewShowing ? CGRectSetX(imageFrame, contentEdgeInsets.left + self.imageEdgeInsets.left + CGFloatGetCenter(imageLimitSize.width, CGRectGetWidth(imageFrame))) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetX(titleFrame, contentEdgeInsets.left + self.titleEdgeInsets.left + CGFloatGetCenter(titleLimitSize.width, CGRectGetWidth(titleFrame))) : titleFrame; - break; - case UIControlContentHorizontalAlignmentRight: - imageFrame = isImageViewShowing ? CGRectSetX(imageFrame, CGRectGetWidth(self.bounds) - contentEdgeInsets.right - self.imageEdgeInsets.right - CGRectGetWidth(imageFrame)) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetX(titleFrame, CGRectGetWidth(self.bounds) - contentEdgeInsets.right - self.titleEdgeInsets.right - CGRectGetWidth(titleFrame)) : titleFrame; - break; - case UIControlContentHorizontalAlignmentFill: - if (isImageViewShowing) { - imageFrame = CGRectSetX(imageFrame, contentEdgeInsets.left + self.imageEdgeInsets.left); - imageFrame = CGRectSetWidth(imageFrame, imageLimitSize.width); - } - if (isTitleLabelShowing) { - titleFrame = CGRectSetX(titleFrame, contentEdgeInsets.left + self.titleEdgeInsets.left); - titleFrame = CGRectSetWidth(titleFrame, titleLimitSize.width); - } - break; - default: - break; - } - - if (self.imagePosition == QMUIButtonImagePositionTop) { - switch (self.contentVerticalAlignment) { - case UIControlContentVerticalAlignmentTop: - imageFrame = isImageViewShowing ? CGRectSetY(imageFrame, contentEdgeInsets.top + self.imageEdgeInsets.top) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetY(titleFrame, contentEdgeInsets.top + imageTotalSize.height + spacingBetweenImageAndTitle + self.titleEdgeInsets.top) : titleFrame; - break; - case UIControlContentVerticalAlignmentCenter: { - CGFloat contentHeight = imageTotalSize.height + spacingBetweenImageAndTitle + titleTotalSize.height; - CGFloat minY = CGFloatGetCenter(contentSize.height, contentHeight) + contentEdgeInsets.top; - imageFrame = isImageViewShowing ? CGRectSetY(imageFrame, minY + self.imageEdgeInsets.top) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetY(titleFrame, minY + imageTotalSize.height + spacingBetweenImageAndTitle + self.titleEdgeInsets.top) : titleFrame; - } - break; - case UIControlContentVerticalAlignmentBottom: - titleFrame = isTitleLabelShowing ? CGRectSetY(titleFrame, CGRectGetHeight(self.bounds) - contentEdgeInsets.bottom - self.titleEdgeInsets.bottom - CGRectGetHeight(titleFrame)) : titleFrame; - imageFrame = isImageViewShowing ? CGRectSetY(imageFrame, CGRectGetHeight(self.bounds) - contentEdgeInsets.bottom - titleTotalSize.height - spacingBetweenImageAndTitle - self.imageEdgeInsets.bottom - CGRectGetHeight(imageFrame)) : imageFrame; - break; - case UIControlContentVerticalAlignmentFill: { - if (isImageViewShowing && isTitleLabelShowing) { - - // 同时显示图片和 label 的情况下,图片高度按本身大小显示,剩余空间留给 label - imageFrame = isImageViewShowing ? CGRectSetY(imageFrame, contentEdgeInsets.top + self.imageEdgeInsets.top) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetY(titleFrame, contentEdgeInsets.top + imageTotalSize.height + spacingBetweenImageAndTitle + self.titleEdgeInsets.top) : titleFrame; - titleFrame = isTitleLabelShowing ? CGRectSetHeight(titleFrame, CGRectGetHeight(self.bounds) - contentEdgeInsets.bottom - self.titleEdgeInsets.bottom - CGRectGetMinY(titleFrame)) : titleFrame; - - } else if (isImageViewShowing) { - imageFrame = CGRectSetY(imageFrame, contentEdgeInsets.top + self.imageEdgeInsets.top); - imageFrame = CGRectSetHeight(imageFrame, contentSize.height - UIEdgeInsetsGetVerticalValue(self.imageEdgeInsets)); - } else { - titleFrame = CGRectSetY(titleFrame, contentEdgeInsets.top + self.titleEdgeInsets.top); - titleFrame = CGRectSetHeight(titleFrame, contentSize.height - UIEdgeInsetsGetVerticalValue(self.titleEdgeInsets)); - } - } - break; - } - } else { - switch (self.contentVerticalAlignment) { - case UIControlContentVerticalAlignmentTop: - titleFrame = isTitleLabelShowing ? CGRectSetY(titleFrame, contentEdgeInsets.top + self.titleEdgeInsets.top) : titleFrame; - imageFrame = isImageViewShowing ? CGRectSetY(imageFrame, contentEdgeInsets.top + titleTotalSize.height + spacingBetweenImageAndTitle + self.imageEdgeInsets.top) : imageFrame; - break; - case UIControlContentVerticalAlignmentCenter: { - CGFloat contentHeight = imageTotalSize.height + titleTotalSize.height + spacingBetweenImageAndTitle; - CGFloat minY = CGFloatGetCenter(contentSize.height, contentHeight) + contentEdgeInsets.top; - titleFrame = isTitleLabelShowing ? CGRectSetY(titleFrame, minY + self.titleEdgeInsets.top) : titleFrame; - imageFrame = isImageViewShowing ? CGRectSetY(imageFrame, minY + titleTotalSize.height + spacingBetweenImageAndTitle + self.imageEdgeInsets.top) : imageFrame; - } - break; - case UIControlContentVerticalAlignmentBottom: - imageFrame = isImageViewShowing ? CGRectSetY(imageFrame, CGRectGetHeight(self.bounds) - contentEdgeInsets.bottom - self.imageEdgeInsets.bottom - CGRectGetHeight(imageFrame)) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetY(titleFrame, CGRectGetHeight(self.bounds) - contentEdgeInsets.bottom - imageTotalSize.height - spacingBetweenImageAndTitle - self.titleEdgeInsets.bottom - CGRectGetHeight(titleFrame)) : titleFrame; - break; - case UIControlContentVerticalAlignmentFill: { - if (isImageViewShowing && isTitleLabelShowing) { - - // 同时显示图片和 label 的情况下,图片高度按本身大小显示,剩余空间留给 label - imageFrame = CGRectSetY(imageFrame, CGRectGetHeight(self.bounds) - contentEdgeInsets.bottom - self.imageEdgeInsets.bottom - CGRectGetHeight(imageFrame)); - titleFrame = CGRectSetY(titleFrame, contentEdgeInsets.top + self.titleEdgeInsets.top); - titleFrame = CGRectSetHeight(titleFrame, CGRectGetHeight(self.bounds) - contentEdgeInsets.bottom - imageTotalSize.height - spacingBetweenImageAndTitle - self.titleEdgeInsets.bottom - CGRectGetMinY(titleFrame)); - - } else if (isImageViewShowing) { - imageFrame = CGRectSetY(imageFrame, contentEdgeInsets.top + self.imageEdgeInsets.top); - imageFrame = CGRectSetHeight(imageFrame, contentSize.height - UIEdgeInsetsGetVerticalValue(self.imageEdgeInsets)); - } else { - titleFrame = CGRectSetY(titleFrame, contentEdgeInsets.top + self.titleEdgeInsets.top); - titleFrame = CGRectSetHeight(titleFrame, contentSize.height - UIEdgeInsetsGetVerticalValue(self.titleEdgeInsets)); - } - } - break; - } - } - - if (isImageViewShowing) { - imageFrame = CGRectFlatted(imageFrame); - self._qmui_imageView.frame = imageFrame; - } - if (isTitleLabelShowing) { - titleFrame = CGRectFlatted(titleFrame); - self.titleLabel.frame = titleFrame; - } - - } else if (self.imagePosition == QMUIButtonImagePositionLeft || self.imagePosition == QMUIButtonImagePositionRight) { - - if (isTitleLabelShowing) { - titleLimitSize = CGSizeMake(contentSize.width - UIEdgeInsetsGetHorizontalValue(self.titleEdgeInsets) - imageTotalSize.width - spacingBetweenImageAndTitle, contentSize.height - UIEdgeInsetsGetVerticalValue(self.titleEdgeInsets)); - CGSize titleSize = [self.titleLabel sizeThatFits:titleLimitSize]; - titleSize.width = MIN(titleLimitSize.width, titleSize.width); - titleSize.height = MIN(titleLimitSize.height, titleSize.height); - titleFrame = CGRectMakeWithSize(titleSize); - titleTotalSize = CGSizeMake(titleSize.width + UIEdgeInsetsGetHorizontalValue(self.titleEdgeInsets), titleSize.height + UIEdgeInsetsGetVerticalValue(self.titleEdgeInsets)); - } - - switch (self.contentVerticalAlignment) { - case UIControlContentVerticalAlignmentTop: - imageFrame = isImageViewShowing ? CGRectSetY(imageFrame, contentEdgeInsets.top + self.imageEdgeInsets.top) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetY(titleFrame, contentEdgeInsets.top + self.titleEdgeInsets.top) : titleFrame; - - break; - case UIControlContentVerticalAlignmentCenter: - imageFrame = isImageViewShowing ? CGRectSetY(imageFrame, contentEdgeInsets.top + CGFloatGetCenter(contentSize.height, CGRectGetHeight(imageFrame)) + self.imageEdgeInsets.top) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetY(titleFrame, contentEdgeInsets.top + CGFloatGetCenter(contentSize.height, CGRectGetHeight(titleFrame)) + self.titleEdgeInsets.top) : titleFrame; - break; - case UIControlContentVerticalAlignmentBottom: - imageFrame = isImageViewShowing ? CGRectSetY(imageFrame, CGRectGetHeight(self.bounds) - contentEdgeInsets.bottom - self.imageEdgeInsets.bottom - CGRectGetHeight(imageFrame)) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetY(titleFrame, CGRectGetHeight(self.bounds) - contentEdgeInsets.bottom - self.titleEdgeInsets.bottom - CGRectGetHeight(titleFrame)) : titleFrame; - break; - case UIControlContentVerticalAlignmentFill: - if (isImageViewShowing) { - imageFrame = CGRectSetY(imageFrame, contentEdgeInsets.top + self.imageEdgeInsets.top); - imageFrame = CGRectSetHeight(imageFrame, contentSize.height - UIEdgeInsetsGetVerticalValue(self.imageEdgeInsets)); - } - if (isTitleLabelShowing) { - titleFrame = CGRectSetY(titleFrame, contentEdgeInsets.top + self.titleEdgeInsets.top); - titleFrame = CGRectSetHeight(titleFrame, contentSize.height - UIEdgeInsetsGetVerticalValue(self.titleEdgeInsets)); - } - break; - } - - if (self.imagePosition == QMUIButtonImagePositionLeft) { - switch (self.contentHorizontalAlignment) { - case UIControlContentHorizontalAlignmentLeft: - imageFrame = isImageViewShowing ? CGRectSetX(imageFrame, contentEdgeInsets.left + self.imageEdgeInsets.left) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetX(titleFrame, contentEdgeInsets.left + imageTotalSize.width + spacingBetweenImageAndTitle + self.titleEdgeInsets.left) : titleFrame; - break; - case UIControlContentHorizontalAlignmentCenter: { - CGFloat contentWidth = imageTotalSize.width + spacingBetweenImageAndTitle + titleTotalSize.width; - CGFloat minX = contentEdgeInsets.left + CGFloatGetCenter(contentSize.width, contentWidth); - imageFrame = isImageViewShowing ? CGRectSetX(imageFrame, minX + self.imageEdgeInsets.left) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetX(titleFrame, minX + imageTotalSize.width + spacingBetweenImageAndTitle + self.titleEdgeInsets.left) : titleFrame; - } - break; - case UIControlContentHorizontalAlignmentRight: { - if (imageTotalSize.width + spacingBetweenImageAndTitle + titleTotalSize.width > contentSize.width) { - // 图片和文字总宽超过按钮宽度,则优先完整显示图片 - imageFrame = isImageViewShowing ? CGRectSetX(imageFrame, contentEdgeInsets.left + self.imageEdgeInsets.left) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetX(titleFrame, contentEdgeInsets.left + imageTotalSize.width + spacingBetweenImageAndTitle + self.titleEdgeInsets.left) : titleFrame; - } else { - // 内容不超过按钮宽度,则靠右布局即可 - titleFrame = isTitleLabelShowing ? CGRectSetX(titleFrame, CGRectGetWidth(self.bounds) - contentEdgeInsets.right - self.titleEdgeInsets.right - CGRectGetWidth(titleFrame)) : titleFrame; - imageFrame = isImageViewShowing ? CGRectSetX(imageFrame, CGRectGetWidth(self.bounds) - contentEdgeInsets.right - titleTotalSize.width - spacingBetweenImageAndTitle - imageTotalSize.width + self.imageEdgeInsets.left) : imageFrame; - } - } - break; - case UIControlContentHorizontalAlignmentFill: { - if (isImageViewShowing && isTitleLabelShowing) { - // 同时显示图片和 label 的情况下,图片按本身宽度显示,剩余空间留给 label - imageFrame = CGRectSetX(imageFrame, contentEdgeInsets.left + self.imageEdgeInsets.left); - titleFrame = CGRectSetX(titleFrame, contentEdgeInsets.left + imageTotalSize.width + spacingBetweenImageAndTitle + self.titleEdgeInsets.left); - titleFrame = CGRectSetWidth(titleFrame, CGRectGetWidth(self.bounds) - contentEdgeInsets.right - self.titleEdgeInsets.right - CGRectGetMinX(titleFrame)); - } else if (isImageViewShowing) { - imageFrame = CGRectSetX(imageFrame, contentEdgeInsets.left + self.imageEdgeInsets.left); - imageFrame = CGRectSetWidth(imageFrame, contentSize.width - UIEdgeInsetsGetHorizontalValue(self.imageEdgeInsets)); - } else { - titleFrame = CGRectSetX(titleFrame, contentEdgeInsets.left + self.titleEdgeInsets.left); - titleFrame = CGRectSetWidth(titleFrame, contentSize.width - UIEdgeInsetsGetHorizontalValue(self.titleEdgeInsets)); - } - } - break; - default: - break; - } - } else { - switch (self.contentHorizontalAlignment) { - case UIControlContentHorizontalAlignmentLeft: { - if (imageTotalSize.width + spacingBetweenImageAndTitle + titleTotalSize.width > contentSize.width) { - // 图片和文字总宽超过按钮宽度,则优先完整显示图片 - imageFrame = isImageViewShowing ? CGRectSetX(imageFrame, CGRectGetWidth(self.bounds) - contentEdgeInsets.right - self.imageEdgeInsets.right - CGRectGetWidth(imageFrame)) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetX(titleFrame, CGRectGetWidth(self.bounds) - contentEdgeInsets.right - imageTotalSize.width - spacingBetweenImageAndTitle - titleTotalSize.width + self.titleEdgeInsets.left) : titleFrame; - } else { - // 内容不超过按钮宽度,则靠左布局即可 - titleFrame = isTitleLabelShowing ? CGRectSetX(titleFrame, contentEdgeInsets.left + self.titleEdgeInsets.left) : titleFrame; - imageFrame = isImageViewShowing ? CGRectSetX(imageFrame, contentEdgeInsets.left + titleTotalSize.width + spacingBetweenImageAndTitle + self.imageEdgeInsets.left) : imageFrame; - } - } - break; - case UIControlContentHorizontalAlignmentCenter: { - CGFloat contentWidth = imageTotalSize.width + spacingBetweenImageAndTitle + titleTotalSize.width; - CGFloat minX = contentEdgeInsets.left + CGFloatGetCenter(contentSize.width, contentWidth); - titleFrame = isTitleLabelShowing ? CGRectSetX(titleFrame, minX + self.titleEdgeInsets.left) : titleFrame; - imageFrame = isImageViewShowing ? CGRectSetX(imageFrame, minX + titleTotalSize.width + spacingBetweenImageAndTitle + self.imageEdgeInsets.left) : imageFrame; - } - break; - case UIControlContentHorizontalAlignmentRight: - imageFrame = isImageViewShowing ? CGRectSetX(imageFrame, CGRectGetWidth(self.bounds) - contentEdgeInsets.right - self.imageEdgeInsets.right - CGRectGetWidth(imageFrame)) : imageFrame; - titleFrame = isTitleLabelShowing ? CGRectSetX(titleFrame, CGRectGetWidth(self.bounds) - contentEdgeInsets.right - imageTotalSize.width - spacingBetweenImageAndTitle - self.titleEdgeInsets.right - CGRectGetWidth(titleFrame)) : titleFrame; - break; - case UIControlContentHorizontalAlignmentFill: { - if (isImageViewShowing && isTitleLabelShowing) { - // 图片按自身大小显示,剩余空间由标题占满 - imageFrame = CGRectSetX(imageFrame, CGRectGetWidth(self.bounds) - contentEdgeInsets.right - self.imageEdgeInsets.right - CGRectGetWidth(imageFrame)); - titleFrame = CGRectSetX(titleFrame, contentEdgeInsets.left + self.titleEdgeInsets.left); - titleFrame = CGRectSetWidth(titleFrame, CGRectGetMinX(imageFrame) - self.imageEdgeInsets.left - spacingBetweenImageAndTitle - self.titleEdgeInsets.right - CGRectGetMinX(titleFrame)); - - } else if (isImageViewShowing) { - imageFrame = CGRectSetX(imageFrame, contentEdgeInsets.left + self.imageEdgeInsets.left); - imageFrame = CGRectSetWidth(imageFrame, contentSize.width - UIEdgeInsetsGetHorizontalValue(self.imageEdgeInsets)); - } else { - titleFrame = CGRectSetX(titleFrame, contentEdgeInsets.left + self.titleEdgeInsets.left); - titleFrame = CGRectSetWidth(titleFrame, contentSize.width - UIEdgeInsetsGetHorizontalValue(self.titleEdgeInsets)); - } - } - break; - default: - break; - } - } - - if (isImageViewShowing) { - imageFrame = CGRectFlatted(imageFrame); - self._qmui_imageView.frame = imageFrame; + // UIButton 有一个特性是不管哪种 alignment,imageView 的宽高必定不超过 button 的宽高(也不管 imageView 的宽高比例是否产生变化),从而保证就算设置了超过 button 大小的 image,也会在 button 容器内部显示。这里对齐系统的特性 + BOOL isImageViewShowing = !!self.currentImage; + if (isImageViewShowing && !CGRectIsEmpty(self.bounds)) { + UIImageView *imageView = self._qmui_imageView; + CGRect rect = imageView.frame; + CGRect limitRect = CGRectInsetEdges(CGRectInsetEdges(self.bounds, self.contentEdgeInsets), self.imageEdgeInsets); + if (CGRectGetWidth(rect) > CGRectGetWidth(limitRect)) { + rect = CGRectSetWidth(rect, CGRectGetWidth(limitRect)); + rect = CGRectSetX(rect, self.contentEdgeInsets.left + self.imageEdgeInsets.left); } - if (isTitleLabelShowing) { - titleFrame = CGRectFlatted(titleFrame); - self.titleLabel.frame = titleFrame; + if (CGRectGetHeight(rect) > CGRectGetHeight(limitRect)) { + rect = CGRectSetHeight(rect, CGRectGetHeight(limitRect)); + rect = CGRectSetY(rect, self.contentEdgeInsets.top + self.imageEdgeInsets.top); } + imageView.frame = rect; } } @@ -501,10 +304,8 @@ - (void)setHighlighted:(BOOL)highlighted { - (void)setEnabled:(BOOL)enabled { [super setEnabled:enabled]; - if (!enabled && self.adjustsButtonWhenDisabled) { - self.alpha = ButtonDisabledAlpha; - } else { - self.alpha = 1; + if (self.adjustsButtonWhenDisabled) { + self.alpha = enabled ? 1 : ButtonDisabledAlpha; } } @@ -532,14 +333,16 @@ - (void)setAdjustsTitleTintColorAutomatically:(BOOL)adjustsTitleTintColorAutomat } - (void)updateTitleColorIfNeeded { - if (self.adjustsTitleTintColorAutomatically && self.currentTitleColor) { + if (!self.adjustsTitleTintColorAutomatically) return; + if (self.currentTitleColor) { [self setTitleColor:self.tintColor forState:UIControlStateNormal]; } - if (self.adjustsTitleTintColorAutomatically && self.currentAttributedTitle) { + if (self.currentAttributedTitle) { NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithAttributedString:self.currentAttributedTitle]; [attributedString addAttribute:NSForegroundColorAttributeName value:self.tintColor range:NSMakeRange(0, attributedString.length)]; [self setAttributedTitle:attributedString forState:UIControlStateNormal]; } + self.subtitleColor = self.tintColor; } - (void)setAdjustsImageTintColorAutomatically:(BOOL)adjustsImageTintColorAutomatically { @@ -576,7 +379,7 @@ - (void)updateImageRenderingModeIfNeeded { } - (void)setImage:(UIImage *)image forState:(UIControlState)state { - if (self.adjustsImageTintColorAutomatically) { + if (self.adjustsImageTintColorAutomatically && image.renderingMode != UIImageRenderingModeAlwaysOriginal) { image = [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; } diff --git a/QMUIKit/QMUIComponents/QMUIButton/QMUINavigationButton.m b/QMUIKit/QMUIComponents/QMUIButton/QMUINavigationButton.m index 5779769d..b79bdebb 100644 --- a/QMUIKit/QMUIComponents/QMUIButton/QMUINavigationButton.m +++ b/QMUIKit/QMUIComponents/QMUIButton/QMUINavigationButton.m @@ -24,6 +24,8 @@ #import "NSString+QMUI.h" #import "UINavigationController+QMUI.h" #import "UINavigationItem+QMUI.h" +#import "UINavigationBar+QMUI.h" +#import "NSArray+QMUI.h" typedef NS_ENUM(NSInteger, QMUINavigationButtonPosition) { QMUINavigationButtonPositionNone = -1, // 不处于navigationBar最左(右)边的按钮,则使用None。用None则不会在alignmentRectInsets里调整位置 @@ -107,6 +109,7 @@ - (void)renderButtonStyle { } break; case QMUINavigationButtonTypeBack: { + self.qmui_outsideEdge = UIEdgeInsetsMake(-12, -12, -24, -24); UIImage *backIndicatorImage = UINavigationBar.qmui_appearanceConfigured.backIndicatorImage; if (!backIndicatorImage) { // 配置表没有自定义的图片,则按照系统的返回按钮图片样式创建一张,颜色按照 tintColor 来 @@ -362,7 +365,7 @@ + (void)load { for (NSUInteger index = 0; index < sizeof(selectors) / sizeof(SEL); index++) { SEL originalSelector = selectors[index]; SEL swizzledSelector = NSSelectorFromString([@"qmui_" stringByAppendingString:NSStringFromSelector(originalSelector)]); - ExchangeImplementations([self class], originalSelector, swizzledSelector); + ExchangeImplementations([UINavigationItem class], originalSelector, swizzledSelector); } }); } @@ -477,6 +480,50 @@ + (void)load { }; }); + // 系统的 UIBarButtonItem 响应区域比较大,如果用 customView 则响应区域只有 customView.frame 的大小,这里专门扩大它 + // 对没用 customView 的不处理 + OverrideImplementation([UINavigationBar class], @selector(hitTest:withEvent:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^UIView *(UINavigationBar *selfObject, CGPoint firstArgv, UIEvent *secondArgv) { + + // call super + UIView * (*originSelectorIMP)(id, SEL, CGPoint, UIEvent *); + originSelectorIMP = (UIView * (*)(id, SEL, CGPoint, UIEvent *))originalIMPProvider(); + UIView * result = originSelectorIMP(selfObject, originCMD, firstArgv, secondArgv); + + // result 有值意味着该事件本应属于 bar 的,这时候才干预。 + // 属于 bar 但又分配给容器而不是精准的某个内容 view,此时才考虑扩大点击范围的识别。 + BOOL hitNothing = result == selfObject.qmui_contentView || [NSStringFromClass(result.class) containsString:@"StackView"]; + if (!hitNothing) return result; + + NSMutableArray *customViews = [[NSMutableArray alloc] init]; + if (selfObject.topItem.titleView) { + [customViews addObject:selfObject.topItem.titleView]; + } + [customViews addObjectsFromArray:[selfObject.topItem.leftBarButtonItems qmui_compactMapWithBlock:^id _Nullable(UIBarButtonItem * _Nonnull item) { + return item.customView ?: nil; + }]]; + [customViews addObjectsFromArray:[selfObject.topItem.rightBarButtonItems qmui_compactMapWithBlock:^id _Nullable(UIBarButtonItem * _Nonnull item) { + return item.customView ?: nil; + }]]; + UIView *hitTestingView = [customViews qmui_firstMatchWithBlock:^BOOL(UIView * _Nonnull item) { + if (!CGRectIsEmpty(item.frame) && !item.hidden && item.alpha > 0.01 && item.window) { + if ([item isKindOfClass:UIControl.class] && !((UIControl *)item).enabled) { + return NO; + } + CGRect rect = [selfObject convertRect:item.bounds fromView:item]; + rect = CGRectInsetEdges(rect, item.qmui_outsideEdge); + if (CGRectContainsPoint(rect, firstArgv)) { + return YES; + } + } + return NO; + }]; + if (hitTestingView) { + return hitTestingView; + } + return result; + }; + }); }); } diff --git a/QMUIKit/QMUIComponents/QMUICellHeightCache.m b/QMUIKit/QMUIComponents/QMUICellHeightCache.m index 787ca5c2..75875590 100644 --- a/QMUIKit/QMUIComponents/QMUICellHeightCache.m +++ b/QMUIKit/QMUIComponents/QMUICellHeightCache.m @@ -222,7 +222,7 @@ + (void)load { for (NSUInteger index = 0; index < sizeof(selectors) / sizeof(SEL); ++index) { SEL originalSelector = selectors[index]; SEL swizzledSelector = NSSelectorFromString([@"qmuiTableCache_" stringByAppendingString:NSStringFromSelector(originalSelector)]); - ExchangeImplementations([self class], originalSelector, swizzledSelector); + ExchangeImplementations([UITableView class], originalSelector, swizzledSelector); } }); } @@ -383,8 +383,8 @@ - (__kindof UITableViewCell *)templateCellForReuseIdentifier:(NSString *)identif // 没有的话,则需要通过register来注册一个cell,否则会crash if (!templateCell) { templateCell = [self dequeueReusableCellWithIdentifier:identifier]; - QMUIAssert(templateCell != nil, @"QMUICellHeightCache", @"通过 %s %@ 无法得到一个 cell 对象", __func__, identifier); } + QMUIAssert(templateCell != nil, @"QMUICellHeightCache", @"通过 %s %@ 无法得到一个 cell 对象", __func__, identifier); templateCell.contentView.translatesAutoresizingMaskIntoConstraints = NO; templateCellsByIdentifiers[identifier] = templateCell; } @@ -526,7 +526,7 @@ + (void)load { for (NSUInteger index = 0; index < sizeof(selectors) / sizeof(SEL); index++) { SEL originalSelector = selectors[index]; SEL swizzledSelector = NSSelectorFromString([@"qmuiCollectionCache_" stringByAppendingString:NSStringFromSelector(originalSelector)]); - ExchangeImplementations([self class], originalSelector, swizzledSelector); + ExchangeImplementations([UICollectionView class], originalSelector, swizzledSelector); } }); } diff --git a/QMUIKit/QMUIComponents/QMUICheckbox.h b/QMUIKit/QMUIComponents/QMUICheckbox.h new file mode 100644 index 00000000..df36f94b --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUICheckbox.h @@ -0,0 +1,40 @@ +// +// QMUICheckbox.h +// QMUIKit +// +// Created by molice on 2024/8/1. +// Copyright © 2024 QMUI Team. All rights reserved. +// + +#import +#import "QMUIButton.h" + +NS_ASSUME_NONNULL_BEGIN + +/// 圆形勾选控件,selected = YES 表示勾选,indeterminate = YES 表示半选,enabled = NO 表示禁用。 +/// 由于父类是 QMUIButton,所以可以通过 setTitle:forState: 轻松实现左边 checkbox 右边说明文本的效果。 +/// 尺寸可以通过 checkboxSize 修改,颜色可通过 tintColor 修改。 +/// 点击勾选的交互需要由业务自己实现。 +@interface QMUICheckbox : QMUIButton + +/// 置为半选状态。可以理解为一个 Checkbox 的 indeterminate 和 checked(selected) 是平级的、互斥的,当该属性被设置为 YES 时,会将 selected 置为 NO,当 selected 被置为 YES 时,会将该属性置为 NO。 +@property(nonatomic, assign) BOOL indeterminate; + +/// 指定 checkbox 图片的尺寸(如果存在 title,不影响 title 的尺寸) +/// 默认为(16, 16) +@property(nonatomic, assign) CGSize checkboxSize; + +/// 未勾选的状态,置为 nil 则使用组件默认图 +@property(nonatomic, strong) UIImage *normalImage; + +/// 勾选的状态,置为 nil 则使用组件默认图 +@property(nonatomic, strong) UIImage *selectedImage; + +/// 半勾选的状态,置为 nil 则使用组件默认图 +@property(nonatomic, strong) UIImage *indeterminateImage; + +/// 未勾选且禁用的状态(如果是已勾选的禁用,会直接沿用该状态的图片,只有未勾选的禁用可以有单独的图),置为 nil 则使用组件默认图 +@property(nonatomic, strong) UIImage *disabledImage; +@end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUIComponents/QMUICheckbox.m b/QMUIKit/QMUIComponents/QMUICheckbox.m new file mode 100644 index 00000000..77d6046e --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUICheckbox.m @@ -0,0 +1,104 @@ +// +// QMUICheckbox.m +// QMUIKit +// +// Created by molice on 2024/8/1. +// Copyright © 2024 QMUI Team. All rights reserved. +// + +#import "QMUICheckbox.h" +#import "QMUICore.h" +#import "CALayer+QMUI.h" +#import "UIView+QMUI.h" + +@interface QMUICheckbox () +@property(nonatomic, strong) UIImageView *indeterminateImageView; +@property(nonatomic, strong) CALayer *imageViewMaks; +@end + +@implementation QMUICheckbox + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + self.normalImage = self.normalImage; + self.selectedImage = self.selectedImage; + self.indeterminateImage = self.indeterminateImage; + self.disabledImage = self.disabledImage; + + _checkboxSize = self.currentImage.size; + self.imageView.contentMode = UIViewContentModeScaleToFill; + self.qmui_outsideEdge = UIEdgeInsetsMake(-8, -8, -8, -8); + } + return self; +} + +- (void)setNormalImage:(UIImage *)normalImage { + _normalImage = normalImage ?: [[QMUIHelper imageWithName:@"QMUI_checkbox16"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + [self setImage:_normalImage forState:UIControlStateNormal]; +} + +- (void)setSelectedImage:(UIImage *)selectedImage { + _selectedImage = selectedImage ?: [[QMUIHelper imageWithName:@"QMUI_checkbox16_checked"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + [self setImage:_selectedImage forState:UIControlStateSelected]; + [self setImage:_selectedImage forState:UIControlStateSelected|UIControlStateHighlighted]; + [self setImage:_selectedImage forState:UIControlStateSelected|UIControlStateDisabled]; +} + +- (void)setDisabledImage:(UIImage *)disabledImage { + _disabledImage = disabledImage ?: [[QMUIHelper imageWithName:@"QMUI_checkbox16_disabled"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + [self setImage:_disabledImage forState:UIControlStateDisabled]; +} + +- (void)setIndeterminateImage:(UIImage *)indeterminateImage { + _indeterminateImage = indeterminateImage ?: [[QMUIHelper imageWithName:@"QMUI_checkbox16_indeterminate"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; +} + +- (void)setIndeterminate:(BOOL)indeterminate { + BOOL valueChanged = _indeterminate != indeterminate; + if (!valueChanged) return; + + _indeterminate = indeterminate; + if (indeterminate) { + if (self.selected) { + self.selected = NO; + } + if (!self.indeterminateImageView) { + self.indeterminateImageView = [[UIImageView alloc] init]; + self.indeterminateImageView.contentMode = UIViewContentModeScaleToFill; + [self addSubview:self.indeterminateImageView]; + } + if (!self.imageViewMaks) { + self.imageViewMaks = CALayer.layer; + [self.imageViewMaks qmui_removeDefaultAnimations]; + } + self.indeterminateImageView.image = self.indeterminateImage; + self.indeterminateImageView.hidden = NO; + self.imageView.layer.mask = self.imageViewMaks;// 保持 imageView 布局不变的情况下让 imageView 不可见 + [self setNeedsLayout]; + } else { + self.indeterminateImageView.hidden = YES; + self.imageView.layer.mask = nil; + } +} + +- (void)setSelected:(BOOL)selected { + [super setSelected:selected]; + if (selected && self.indeterminate) { + self.indeterminate = NO; + } +} + +- (void)layoutSubviews { + [super layoutSubviews]; + self.indeterminateImageView.frame = self.imageView.frame; +} + +- (void)setCheckboxSize:(CGSize)checkboxSize { + if (CGSizeIsEmpty(checkboxSize)) return; + _checkboxSize = checkboxSize; + self.imageView.qmui_fixedSize = checkboxSize; + self.indeterminateImageView.qmui_fixedSize = checkboxSize; + [self setNeedsLayout]; +} + +@end diff --git a/QMUIKit/QMUIComponents/QMUIConsole/QMUIConsoleViewController.m b/QMUIKit/QMUIComponents/QMUIConsole/QMUIConsoleViewController.m index 7dd96dc7..696aff20 100644 --- a/QMUIKit/QMUIComponents/QMUIConsole/QMUIConsoleViewController.m +++ b/QMUIKit/QMUIComponents/QMUIConsole/QMUIConsoleViewController.m @@ -234,13 +234,13 @@ - (void)initSubviews { [self.containerView addSubview:self.toolbar]; __weak __typeof(self)weakSelf = self; - self.levelMenu = [self generatePopupMenuView]; + self.levelMenu = [self generatePopupMenuViewWithSelectedArray:self.selectedLevels]; self.levelMenu.willHideBlock = ^(BOOL hidesByUserTap, BOOL animated) { weakSelf.toolbar.levelButton.selected = weakSelf.selectedLevels.count > 0; }; self.levelMenu.sourceView = self.toolbar.levelButton; - self.nameMenu = [self generatePopupMenuView]; + self.nameMenu = [self generatePopupMenuViewWithSelectedArray:self.selectedNames]; self.nameMenu.willHideBlock = ^(BOOL hidesByUserTap, BOOL animated) { weakSelf.toolbar.nameButton.selected = weakSelf.selectedNames.count > 0; }; @@ -365,7 +365,7 @@ - (void)printLog { NSArray *matchedItems = [self.showingLogItems qmui_filterWithBlock:^BOOL(QMUIConsoleLogItem * _Nonnull item) { return item.searchResults.count > 0; }]; - NSArray *> *matchedResults = [matchedItems qmui_mapWithBlock:^id _Nonnull(QMUIConsoleLogItem * _Nonnull item) { + NSArray *> *matchedResults = [matchedItems qmui_mapWithBlock:^id _Nonnull(QMUIConsoleLogItem * _Nonnull item, NSInteger index) { return item.searchResults; }]; self.searchResultsTotalCount = 0; @@ -541,52 +541,52 @@ - (void)updateToolbarButtonState { self.toolbar.nameButton.enabled = self.logItems.count > 0; } -- (QMUIPopupMenuView *)generatePopupMenuView { +- (QMUIPopupMenuView *)generatePopupMenuViewWithSelectedArray:(NSArray *)selectedArray { QMUIPopupMenuView *menuView = [[QMUIPopupMenuView alloc] init]; menuView.padding = UIEdgeInsetsMake(3, 6, 3, 6); menuView.cornerRadius = 3; menuView.arrowSize = CGSizeMake(8, 4); menuView.borderWidth = 0; menuView.itemHeight = 28; - menuView.itemTitleFont = UIFontMake(12); - menuView.itemTitleColor = UIColorMake(53, 60, 70); menuView.maskViewBackgroundColor = nil; menuView.backgroundColor = UIColorWhite; - menuView.itemConfigurationHandler = ^(QMUIPopupMenuView *aMenuView, QMUIPopupMenuButtonItem *aItem, NSInteger section, NSInteger index) { - aItem.highlightedBackgroundColor = [[UIColor blackColor] colorWithAlphaComponent:.15]; - QMUIButton *button = aItem.button; + menuView.itemViewConfigurationHandler = ^(QMUIPopupMenuView * _Nonnull aMenuView, __kindof QMUIPopupMenuItem * _Nonnull aItem, QMUIPopupMenuItemView * _Nonnull aItemView, NSInteger section, NSInteger index) { + aItemView.button.highlightedBackgroundColor = [[UIColor blackColor] colorWithAlphaComponent:.15]; + QMUIButton *button = aItemView.button; + button.titleLabel.font = UIFontMake(12); + button.tintColorAdjustsTitleAndImage = UIColorMake(53, 60, 70); button.imagePosition = QMUIButtonImagePositionRight; button.spacingBetweenImageAndTitle = 10; - UIImage *selectedImage = [UIImage qmui_imageWithShape:QMUIImageShapeCheckmark size:CGSizeMake(12, 9) lineWidth:1 tintColor:aMenuView.itemTitleColor]; + UIImage *selectedImage = [[UIImage qmui_imageWithShape:QMUIImageShapeCheckmark size:CGSizeMake(12, 9) lineWidth:1 tintColor:UIColor.blackColor] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; UIImage *normalImage = [UIImage qmui_imageWithColor:UIColorClear size:selectedImage.size cornerRadius:0]; [button setImage:normalImage forState:UIControlStateNormal];// 无图像也弄一张空白图,以保证 state 变化时布局不跳动 [button setImage:selectedImage forState:UIControlStateSelected]; [button setImage:selectedImage forState:UIControlStateSelected|UIControlStateHighlighted]; + button.contentHorizontalAlignment = UIControlContentHorizontalAlignmentFill; + button.selected = [selectedArray containsObject:aItem.title]; }; menuView.hidden = YES; [self.view addSubview:menuView]; return menuView; } -- (NSArray *)popupMenuItemsByTitleBlock:(nullable NSString * (^)(QMUIConsoleLogItem *logItem))titleBlock selectedArray:(NSMutableArray *)selectedArray { +- (NSArray *)popupMenuItemsByTitleBlock:(nullable NSString * (^)(QMUIConsoleLogItem *logItem))titleBlock selectedArray:(NSMutableArray *)selectedArray { __weak __typeof(self)weakSelf = self; - NSMutableArray *items = [[NSMutableArray alloc] init]; + NSMutableArray *items = [[NSMutableArray alloc] init]; NSMutableSet *itemTitles = [[NSMutableSet alloc] init]; [self.logItems enumerateObjectsUsingBlock:^(QMUIConsoleLogItem * _Nonnull logItem, NSUInteger idx, BOOL * _Nonnull stop) { [itemTitles addObject:titleBlock(logItem)]; }]; [[itemTitles sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:NSStringFromSelector(@selector(description)) ascending:YES]]] enumerateObjectsUsingBlock:^(NSString * _Nonnull title, NSUInteger idx, BOOL * _Nonnull stop) { - QMUIPopupMenuButtonItem *item = [QMUIPopupMenuButtonItem itemWithImage:nil title:title handler:^(QMUIPopupMenuButtonItem *aItem) { - aItem.button.selected = !aItem.button.selected; - if (aItem.button.selected) { + QMUIPopupMenuItem *item = [QMUIPopupMenuItem itemWithTitle:title handler:^(__kindof QMUIPopupMenuItem * _Nonnull aItem, QMUIPopupMenuItemView * _Nonnull aItemView, NSInteger section, NSInteger index) { + aItemView.button.selected = !aItemView.button.selected; + if (aItemView.button.selected) { [selectedArray addObject:title]; } else { [selectedArray removeObject:title]; } [weakSelf printLog]; }]; - item.button.contentHorizontalAlignment = UIControlContentHorizontalAlignmentFill; - item.button.selected = [selectedArray containsObject:title]; [items addObject:item]; }]; return items.copy; diff --git a/QMUIKit/QMUIComponents/QMUIKeyboardManager.m b/QMUIKit/QMUIComponents/QMUIKeyboardManager.m index ad1fb54a..178df3ec 100644 --- a/QMUIKit/QMUIComponents/QMUIKeyboardManager.m +++ b/QMUIKit/QMUIComponents/QMUIKeyboardManager.m @@ -19,6 +19,7 @@ #import "QMUIAppearance.h" #import "QMUIMultipleDelegates.h" #import "NSArray+QMUI.h" +#import "UIView+QMUI.h" @class QMUIKeyboardViewFrameObserver; @protocol QMUIKeyboardViewFrameObserverDelegate @@ -830,38 +831,8 @@ + (CGFloat)distanceFromMinYToBottomInView:(UIView *)view keyboardRect:(CGRect)re return distance; } -+ (UIWindow *)keyboardWindow { - for (UIWindow *window in UIApplication.sharedApplication.windows) { - if ([self positionedKeyboardViewInWindow:window]) { - return window; - } - } - - UIWindow *window = [UIApplication.sharedApplication.windows qmui_firstMatchWithBlock:^BOOL(__kindof UIWindow * _Nonnull item) { - return [NSStringFromClass(item.class) isEqualToString:@"UIRemoteKeyboardWindow"]; - }]; - if (window) { - return window; - } - - window = [UIApplication.sharedApplication.windows qmui_firstMatchWithBlock:^BOOL(__kindof UIWindow * _Nonnull item) { - return [NSStringFromClass(item.class) isEqualToString:@"UITextEffectsWindow"]; - }]; - return window; -} - -+ (UIView *)keyboardView { - for (UIWindow *window in UIApplication.sharedApplication.windows) { - UIView *view = [self positionedKeyboardViewInWindow:window]; - if (view) { - return view; - } - } - return nil; -} - /** - 从给定的 window 里寻找代表键盘当前布局位置的 view。 + 从所有 window 里寻找代表键盘当前布局位置的 view。 iOS 15 及以前(包括用 Xcode 13 编译的 App 运行在 iOS 16 上的场景),键盘的 UI 层级是: |- UIApplication.windows |- UIRemoteKeyboardWindow @@ -883,28 +854,48 @@ + (UIView *)keyboardView { 所以只要找到 UIInputSetHostView 即可,优先从 UIRemoteKeyboardWindow 找,不存在的话则从 UITextEffectsWindow 找。 */ -+ (UIView *)positionedKeyboardViewInWindow:(UIWindow *)window { ++ (UIView *)keyboardView { + UIView *inputSetHostView = [[UIApplication.sharedApplication.windows qmui_filterWithBlock:^BOOL(__kindof UIWindow * _Nonnull window) { + return [NSStringFromClass(window.class) isEqualToString:@"UIRemoteKeyboardWindow"]; + }] qmui_compactMapWithBlock:^id _Nullable(__kindof UIWindow * _Nonnull window) { + return [self inputSetHostViewInWindow:window]; + }].firstObject; - if (!window) return nil; + if (inputSetHostView) return inputSetHostView; - NSString *windowName = NSStringFromClass(window.class); - if ([windowName isEqualToString:@"UIRemoteKeyboardWindow"]) { - UIView *result = [[window.subviews qmui_firstMatchWithBlock:^BOOL(__kindof UIView * _Nonnull subview) { - return [NSStringFromClass(subview.class) isEqualToString:@"UIInputSetContainerView"]; - }].subviews qmui_firstMatchWithBlock:^BOOL(__kindof UIView * _Nonnull subview) { - return [NSStringFromClass(subview.class) isEqualToString:@"UIInputSetHostView"]; - }]; - return result; - } - if ([windowName isEqualToString:@"UITextEffectsWindow"]) { - UIView *result = [[window.subviews qmui_firstMatchWithBlock:^BOOL(__kindof UIView * _Nonnull subview) { - return [NSStringFromClass(subview.class) isEqualToString:@"UIInputSetContainerView"]; - }].subviews qmui_firstMatchWithBlock:^BOOL(__kindof UIView * _Nonnull subview) { - return [NSStringFromClass(subview.class) isEqualToString:@"UIInputSetHostView"] && subview.subviews.count; - }]; - return result; + inputSetHostView = [[UIApplication.sharedApplication.windows qmui_filterWithBlock:^BOOL(__kindof UIWindow * _Nonnull window) { + return [NSStringFromClass(window.class) isEqualToString:@"UITextEffectsWindow"]; + }] qmui_compactMapWithBlock:^id _Nullable(__kindof UIWindow * _Nonnull window) { + return [self inputSetHostViewInWindow:window]; + }].firstObject; + + return inputSetHostView; +} + ++ (UIView *)inputSetHostViewInWindow:(UIWindow *)window { + UIView *result = [[window.subviews qmui_firstMatchWithBlock:^BOOL(__kindof UIView * _Nonnull subview) { + return [NSStringFromClass(subview.class) isEqualToString:@"UIInputSetContainerView"]; + }].subviews qmui_firstMatchWithBlock:^BOOL(__kindof UIView * _Nonnull subview) { + return [NSStringFromClass(subview.class) isEqualToString:@"UIInputSetHostView"] && subview.subviews.count; + }]; + return result; +} + ++ (UIWindow *)keyboardWindow { + UIView *inputSetHostView = [self keyboardView]; + if (inputSetHostView) return inputSetHostView.window; + + UIWindow *window = [UIApplication.sharedApplication.windows qmui_firstMatchWithBlock:^BOOL(__kindof UIWindow * _Nonnull item) { + return [NSStringFromClass(item.class) isEqualToString:@"UIRemoteKeyboardWindow"]; + }]; + if (window) { + return window; } - return nil; + + window = [UIApplication.sharedApplication.windows qmui_firstMatchWithBlock:^BOOL(__kindof UIWindow * _Nonnull item) { + return [NSStringFromClass(item.class) isEqualToString:@"UITextEffectsWindow"]; + }]; + return window; } + (BOOL)isKeyboardVisible { @@ -935,7 +926,10 @@ + (CGRect)currentKeyboardFrame { + (CGFloat)visibleKeyboardHeight { UIView *keyboardView = [self keyboardView]; - UIWindow *keyboardWindow = keyboardView.window; + // iPad“侧拉”模式打开的 App,App Window 和键盘 Window 尺寸不同,如果以键盘 Window 为准则会认为键盘一直在屏幕上,从而出现误判,所以这里改为用 App Window。 + // iPhone、iPad 全屏/分屏/台前调度,都没这个问题 +// UIWindow *keyboardWindow = keyboardView.window; + UIWindow *keyboardWindow = UIApplication.sharedApplication.delegate.window; if (!keyboardView || !keyboardWindow) { return 0; } else { @@ -945,7 +939,8 @@ + (CGFloat)visibleKeyboardHeight { return 0; } - CGRect visibleRect = CGRectIntersection(CGRectFlatted(keyboardWindow.bounds), CGRectFlatted(keyboardView.frame)); + CGRect keyboardFrame = [keyboardWindow qmui_convertRect:keyboardView.bounds fromView:keyboardView]; + CGRect visibleRect = CGRectIntersection(keyboardWindow.bounds, keyboardFrame); if (CGRectIsValidated(visibleRect)) { return CGRectGetHeight(visibleRect); } diff --git a/QMUIKit/QMUIComponents/QMUILabel.m b/QMUIKit/QMUIComponents/QMUILabel.m index 70c7522f..f50ddfad 100644 --- a/QMUIKit/QMUIComponents/QMUILabel.m +++ b/QMUIKit/QMUIComponents/QMUILabel.m @@ -177,8 +177,7 @@ - (void)handleLongPressGestureRecognizer:(UIGestureRecognizer *)gestureRecognize UIMenuController *menuController = [UIMenuController sharedMenuController]; UIMenuItem *copyMenuItem = [[UIMenuItem alloc] initWithTitle:self.menuItemTitleForCopyAction ?: @"复制" action:@selector(copyString:)]; [[UIMenuController sharedMenuController] setMenuItems:@[copyMenuItem]]; - [menuController setTargetRect:self.frame inView:self.superview]; - [menuController setMenuVisible:YES animated:YES]; + [menuController showMenuFromView:self.superview rect:self.frame]; self.highlighted = YES; } else if (gestureRecognizer.state == UIGestureRecognizerStatePossible) { diff --git a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuBaseItem.h b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouter.h similarity index 64% rename from QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuBaseItem.h rename to QMUIKit/QMUIComponents/QMUILayouter/QMUILayouter.h index f0cd9fde..391cbc8f 100644 --- a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuBaseItem.h +++ b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouter.h @@ -7,18 +7,13 @@ */ // -// QMUIPopupMenuBaseItem.h +// QMUILayouter.h // QMUIKit // -// Created by QMUI Team on 2018/8/21. +// Created by QMUI Team on 2024/1/2. // +#import #import -#import "QMUIPopupMenuItemProtocol.h" - -/** - 用于 QMUIPopupMenuView 的 item 基类,便于自定义各种类型的 item。若有 subview 请直接添加到 self 上,自身大小的计算请写到 sizeThatFits:,布局写到 layoutSubviews。 - */ -@interface QMUIPopupMenuBaseItem : UIView - -@end +#import "QMUILayouterLinearHorizontal.h" +#import "QMUILayouterLinearVertical.h" diff --git a/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterItem.h b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterItem.h new file mode 100644 index 00000000..7323fd28 --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterItem.h @@ -0,0 +1,135 @@ +/** + * Tencent is pleased to support the open source community by making QMUI_iOS available. + * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +// +// QMUILayouterItem.h +// QMUIKit +// +// Created by QMUI Team on 2024/1/2. +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, QMUILayouterAlignment) { + /// 对水平容器来说是从左往右,对竖直容器来说是从上往下。若容器大小不足以容纳所有 item,则末尾的 item 大小会被强制裁剪以保证不溢出。 + QMUILayouterAlignmentLeading, + + /// 对水平容器来说是从左往右然后整体右对齐父容器,对竖直容器来说是从上往下然后整体底对齐父容器。若 item 超过父容器大小,则与 QMUILayouterAlignmentLeading 一致。 + QMUILayouterAlignmentTrailing, + + /// 对水平容器来说是从左往右然后整体在父容器里居中,对竖直容器来说是从上往下然后整体在父容器里居中。若 item 超过父容器大小,则与 QMUILayouterAlignmentLeading 一致。 + QMUILayouterAlignmentCenter, + + /// 当表示与容器布局方向相同的方向时(例如 Linear 的水平,或 Vertical 的竖直),仅当子元素个数为1时有效,会在指定方向上撑满父容器。当子元素个数大于1时与 QMUILayouterAlignmentLeading 一致。 + /// 当表示与容器布局方向垂直的方向时(例如 Linear 的竖直,或 Vertical 的水平),则所有子元素均会在指定方向上撑满父容器。 + QMUILayouterAlignmentFill, +}; + +/// 表示父容器还有剩余空间时当前 item 也保持自身尺寸不变,不去拉伸填充剩余空间 +extern const CGFloat QMUILayouterGrowNever; + +/// 表示父容器还有剩余空间时当前 item 以最高优先级去填充(一般用1就行,不需要用到 Most) +extern const CGFloat QMUILayouterGrowMost; + +/// 表示父容器空间不足以容纳所有 item,不得已要压缩 item 时,不要压缩当前 item +extern const CGFloat QMUILayouterShrinkNever; + +/// 表示父容器空间不足以容纳所有 item,不得已要压缩 item 时,允许压缩当前 item(按各自尺寸比例) +extern const CGFloat QMUILayouterShrinkDefault; + +/// 表示父容器空间不足以容纳所有 item,不得已要压缩 item 时,使当前 item 以最高优先级压缩 +extern const CGFloat QMUILayouterShrinkMost; + +@interface QMUILayouterItem : NSObject + +/// 通常用于生成一个子元素角色的 item,不允许拉伸也不允许缩放。 ++ (instancetype)itemWithView:(__kindof UIView *)view margin:(UIEdgeInsets)margin; + +/// 通常用于生成一个子元素角色的 item ++ (instancetype)itemWithView:(__kindof UIView *)view margin:(UIEdgeInsets)margin grow:(CGFloat)grow shrink:(CGFloat)shrink; + +/// 关联的实体 view,如果当前 item 是虚拟布局容器,也可以不存在关联的实体 view。 +/// @note 一般将 view 添加到界面上后再赋值给这个属性,这样可确保后续的运算最准确。 +@property(nonatomic, weak, nullable) __kindof UIView *view; + +/// frame 的值变化时才会设置给 view 且标记为在下一次 runloop 里需要刷新布局。 +@property(nonatomic, assign) CGRect frame; + +/// 给 parentItem 布局自己时使用,自己内部 layout 时不使用,也不包含在自身的 sizeThatFits: 结果里。 +@property(nonatomic, assign) UIEdgeInsets margin; + +/// 表示父容器在布局自己时可忽略 item 自身的宽度,仅通过将所有 grow 大于0的 item 按各自 grow 比例计算得到宽度,例如一行里有两个 item,一个 item 宽度为自身内容宽度,另一个 item 撑满容器剩余空间。默认为 QMUILayouterGrowNever,也即自适应内容,设置为 QMUILayouterGrowMost 或某个大于0的数值可按比例撑满容器。 +/// @warning 仅在支持比例布局的容器里有效(例如 LinearHorizontal、LinearVertical) +@property(nonatomic, assign) CGFloat grow; + +/// 当父容器空间不足以容纳所有 item 时,由每个 item 的 shrink 值及 item 的尺寸来决定该压缩哪个 item 的尺寸、压缩多少。默认为 QMUILayouterShrinkNever,值越大则压缩得越狠。 +@property(nonatomic, assign) CGFloat shrink; + +/// 最大的尺寸,在自身 sizeThatFits、父容器 grow 时生效,在 setFrame 时不限制(也即非要的话你也可以设置一个突破限制的尺寸),默认为 CGSizeMax +@property(nonatomic, assign) CGSize maximumSize; + +/// 最小的尺寸,在自身 sizeThatFits、父容器 shrink 时生效,在 setFrame 时不限制(也即非要的话你也可以设置一个突破限制的尺寸),默认为 CGSizeZero +@property(nonatomic, assign) CGSize minimumSize; + +/// 当前 item 是否可视,仅可视的 item 会参与布局运算。 +@property(nonatomic, assign, readonly) BOOL visible; + +/// 允许业务自定义 visible 的逻辑。 +@property(nonatomic, copy, nullable) BOOL (^visibleBlock)(QMUILayouterItem *aItem); + +/// 父容器,在 setChildItems: 时会将父子关系关联起来。 +@property(nonatomic, weak, readonly, nullable) __kindof QMUILayouterItem *parentItem; + +/// 所有子元素 +@property(nonatomic, strong) NSArray *childItems; + +/// 所有 visible 为 YES 的子元素,布局运算时使用这个。 +@property(nonatomic, weak, readonly, nullable) NSArray *visibleChildItems; + +// 便捷方法,会自动判空 +@property(nonatomic, weak, readonly, nullable) QMUILayouterItem *visibleChildItem0; +@property(nonatomic, weak, readonly, nullable) QMUILayouterItem *visibleChildItem1; +@property(nonatomic, weak, readonly, nullable) QMUILayouterItem *visibleChildItem2; +@property(nonatomic, weak, readonly, nullable) QMUILayouterItem *visibleChildItem3; + +/// 计算在特定宽高下的自身尺寸,注意 self.margin 不参与其中。通常将 height 传 CGFLOAT_MAX 以得到一个自适应内容的大小。 +- (CGSize)sizeThatFits:(CGSize)size; + +/// 允许业务自定义 sizeThatFits: 的逻辑(注意这个主要用于父容器布局时询问子元素大小用,不用于元素计算自身内容大小时用),在调用完 block 后才进行 min/height 保护。 +@property(nonatomic, copy, nullable) CGSize (^sizeThatFitsBlock)(QMUILayouterItem *aItem, CGSize size, CGSize superResult); + +/// 保持 x/y 不变,将自身大小设置为不受宽高限制的尺寸,并将布局标记为需要被刷新。 +- (void)sizeToFit; + +/// 标记需要刷新布局,在同一个 runloop 里的所有 setNeedsLayout 会统一在下一个 runloop 里才一起布局。 +- (void)setNeedsLayout; + +/// 如果当前布局待刷新,则立即刷新,以便得到最新的布局结果。 +- (void)layoutIfNeeded; + +/// 是否在指定 view 的坐标系里显示自身及所有子元素的布局边框(颜色随机),请在 layoutSubviews、viewDidLayoutSubviews 里调用(也即每次参数 view 的布局发生变化时)。 +- (void)showDebugBorderRecursivelyInView:(UIView *)view; + +/// 一般用作调试时区分用,业务随意赋值。 +@property(nonatomic, copy, nullable) NSString *identifier; +@end + +@interface QMUILayouterItem (UISubclassingHooks) + +/// 子类计算自身大小的逻辑请写在这个方法里,如果是外部希望得知当前元素的大小,请调用 sizeThatFits: 或 sizeToFit。 +/// @param shouldConsiderBlock 计算大小时是否需要考虑 sizeThatFitsBlock:,如果当前是外部询问元素大小,参数为 YES,如果是内部希望得知内容实际大小,参数为 NO。 +- (CGSize)sizeThatFits:(CGSize)size shouldConsiderBlock:(BOOL)shouldConsiderBlock; + +/// 子类重写布局时使用,外部不要直接调用它。可视情况自行决定是否要调用 super。 +- (void)layout; +@end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterItem.m b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterItem.m new file mode 100644 index 00000000..9dea640a --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterItem.m @@ -0,0 +1,286 @@ +/** + * Tencent is pleased to support the open source community by making QMUI_iOS available. + * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +// +// QMUILayouterItem.m +// QMUIKit +// +// Created by QMUI Team on 2024/1/2. +// + +#import "QMUILayouterItem.h" +#import "QMUICore.h" +#import "NSArray+QMUI.h" +#import "NSString+QMUI.h" +#import "CALayer+QMUI.h" +#import "UIColor+QMUI.h" + +const CGFloat QMUILayouterGrowNever = 0.0; +const CGFloat QMUILayouterGrowMost = 99.0; +const CGFloat QMUILayouterShrinkDefault = 1.0; +const CGFloat QMUILayouterShrinkNever = 0.0; +const CGFloat QMUILayouterShrinkMost = 99.0; + +@interface QMUILayouterItem () +@property(nonatomic, strong) CALayer *debugBorderLayer; +@end + +@implementation QMUILayouterItem { + BOOL _shouldInvalidateLayout; +} + ++ (instancetype)itemWithView:(__kindof UIView *)view margin:(UIEdgeInsets)margin { + return [self itemWithView:view margin:margin grow:QMUILayouterGrowNever shrink:QMUILayouterShrinkNever]; +} + ++ (instancetype)itemWithView:(__kindof UIView *)view margin:(UIEdgeInsets)margin grow:(CGFloat)grow shrink:(CGFloat)shrink { + QMUILayouterItem *item = [[self alloc] init]; + item.view = view; + item.margin = margin; + item.grow = grow; + item.shrink = shrink; + return item; +} + +- (instancetype)init { + if (self = [super init]) { + _maximumSize = CGSizeMax; + _minimumSize = CGSizeZero; + } + return self; +} + +- (NSString *)description { + NSString * (^growName)(CGFloat grow) = ^NSString * (CGFloat grow) { + if (grow == QMUILayouterGrowNever) return @"Never"; + if (grow == QMUILayouterGrowMost) return @"Most"; + return [NSString stringWithFormat:@"%.1f", grow]; + }; + NSString * (^shrinkName)(CGFloat shrink) = ^NSString * (CGFloat shrink) { + if (shrink == QMUILayouterShrinkDefault) return @"Default"; + if (shrink == QMUILayouterShrinkNever) return @"Never"; + if (shrink == QMUILayouterShrinkMost) return @"Most"; + return [NSString stringWithFormat:@"%.1f", shrink]; + }; + return [NSString qmui_stringByConcat:[super description], @", visible = ", StringFromBOOL(self.visible), @", frame = ", NSStringFromCGRect(self.frame), @", margin = ", NSStringFromUIEdgeInsets(self.margin), @", grow = ", growName(self.grow), @", shrink = ", shrinkName(self.shrink), (self.visibleChildItems.count ? [NSString stringWithFormat:@", visibleChild(%@)", @(self.visibleChildItems.count)] : @""), (self.view ? [NSString stringWithFormat:@", view = <%@: %p>", NSStringFromClass(self.view.class), self.view] : @""), nil]; +} + +@synthesize frame = _frame; +- (void)setFrame:(CGRect)frame { + // QMUIViewSelfSizingHeight 的功能 + if (isinf(frame.size.height)) { + if (frame.size.width > 0) { + CGFloat height = flat([self sizeThatFits:CGSizeMake(CGRectGetWidth(frame), CGFLOAT_MAX) shouldConsiderBlock:NO].height); + frame = CGRectSetHeight(frame, height); + } else { + frame.size.height = _frame.size.height; + } + } + BOOL frameChanged = !CGRectEqualToRect(self.frame, frame); + _frame = frame; + self.view.frame = frame; + if (frameChanged) { + [self setNeedsLayout]; + } +} + +- (CGRect)frame { + // 每个 item 不一定都存在 view,可能它只是一个虚拟的布局节点,所以这里要区分 + if (self.view) { + return self.view.frame; + } + return _frame; +} + +- (void)setView:(__kindof UIView *)view { + BOOL valueChanged = _view != view; + _view = view; + if (valueChanged) { + [self setNeedsLayout]; + } +} + +- (void)setMargin:(UIEdgeInsets)margin { + BOOL valueChanged = UIEdgeInsetsEqualToEdgeInsets(_margin, margin); + _margin = margin; + if (valueChanged) { + [self.parentItem setNeedsLayout]; + } +} + +- (void)setGrow:(CGFloat)grow { + NSAssert(grow >= 0, @"negative values are invalid for grow."); + grow = MAX(0, grow); + BOOL valueChanged = _grow != grow; + _grow = grow; + if (valueChanged) { + [self.parentItem setNeedsLayout]; + } +} + +- (void)setShrink:(CGFloat)shrink { + NSAssert(shrink >= 0, @"negative values are invalid for grow."); + shrink = MAX(0, shrink); + BOOL valueChanged = _shrink != shrink; + _shrink = shrink; + if (valueChanged) { + [self.parentItem setNeedsLayout]; + } +} + +- (BOOL)visible { + if (self.visibleBlock) return self.visibleBlock(self); + return self.view.superview && !self.view.hidden; +} + +- (QMUILayouterItem *)visibleChildItem0 { + return [self visibleChildItemAtIndex:0]; +} + +- (QMUILayouterItem *)visibleChildItem1 { + return [self visibleChildItemAtIndex:1]; +} + +- (QMUILayouterItem *)visibleChildItem2 { + return [self visibleChildItemAtIndex:2]; +} + +- (QMUILayouterItem *)visibleChildItem3 { + return [self visibleChildItemAtIndex:3]; +} + +- (QMUILayouterItem *)visibleChildItemAtIndex:(NSUInteger)index { + return index < self.visibleChildItems.count ? self.visibleChildItems[index] : nil; +} + +- (void)setChildItems:(NSArray *)childItems { + [_childItems enumerateObjectsUsingBlock:^(QMUILayouterItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + obj->_parentItem = nil; + }]; + _childItems = childItems; + [childItems enumerateObjectsUsingBlock:^(QMUILayouterItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + obj->_parentItem = self; + }]; +} + +- (NSArray *)visibleChildItems { + return self.childItems.count ? [self.childItems qmui_filterWithBlock:^BOOL(QMUILayouterItem * _Nonnull item) { + return item.visible; + }] : nil; +} + +- (CGSize)sizeThatFits:(CGSize)size { + return [self sizeThatFits:size shouldConsiderBlock:YES]; +} + +- (void)sizeToFit { + CGSize prefersSize = CGSizeMax; + // 参照系统 UILabel 的 sizeToFit 方式(在当前宽度下计算高度) + if ([self.view isKindOfClass:UILabel.class] && CGRectGetWidth(self.frame) > 0) { + prefersSize.width = CGRectGetWidth(self.frame); + } + CGSize size = [self sizeThatFits:prefersSize]; + self.frame = CGRectSetSize(self.frame, size); +} + +- (void)setNeedsLayout { + if (_shouldInvalidateLayout) return; + _shouldInvalidateLayout = YES; + dispatch_async(dispatch_get_main_queue(), ^{ + if (self->_shouldInvalidateLayout) { + [self layoutIfNeeded]; + } + }); +} + +- (void)layoutIfNeeded { + [self layout]; + [self layoutDebugBorderLayer]; + _shouldInvalidateLayout = NO; +} + +- (CALayer *)generateDebugBorderLayerContainer { + CALayer *layer = CALayer.layer; + layer.name = @"QMUILayouterDebugBorderLayerContainer"; + [layer qmui_removeDefaultAnimations]; + return layer; +} + +- (CALayer *)generateDebugBorderLayer { + CALayer *layer = CALayer.layer; + layer.name = @"QMUILayouterDebugBorderLayer"; + [layer qmui_removeDefaultAnimations]; + UIColor *color = UIColor.qmui_randomColor; + layer.backgroundColor = [color colorWithAlphaComponent:.1].CGColor; + layer.borderColor = color.CGColor; + layer.borderWidth = 1; + return layer; +} + +- (void)showDebugBorderRecursivelyInView:(UIView *)view { + if (!view) return; + CALayer *container = [view.layer.sublayers qmui_firstMatchWithBlock:^BOOL(__kindof CALayer * _Nonnull item) { + return [item.name isEqualToString:@"QMUILayouterDebugBorderLayerContainer"]; + }]; + if (!container) { + container = [self generateDebugBorderLayerContainer]; + [view.layer addSublayer:container]; + } + [container.sublayers.copy enumerateObjectsUsingBlock:^(__kindof CALayer * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + if ([obj.name isEqualToString:@"QMUILayouterDebugBorderLayer"]) [obj removeFromSuperlayer]; + }]; + container.frame = view.bounds; + [self showDebugBorderInContainer:container]; + [self.childItems enumerateObjectsUsingBlock:^(QMUILayouterItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + [obj showDebugBorderInContainer:container]; + }]; +} + +- (void)showDebugBorderInContainer:(CALayer *)container { + if (!container) return; + if (!self.debugBorderLayer) { + self.debugBorderLayer = [self generateDebugBorderLayer]; + [container addSublayer:self.debugBorderLayer]; + } else if (self.debugBorderLayer.superlayer != container) { + [self.debugBorderLayer removeFromSuperlayer]; + [container addSublayer:self.debugBorderLayer]; + } +} + +- (void)layoutDebugBorderLayer { + if (!self.debugBorderLayer || !self.debugBorderLayer.superlayer) return; + if (self.view) { + UIView *containerView = (UIView *)self.debugBorderLayer.superlayer.superlayer.delegate; + CGRect frame = [self.view convertRect:self.view.bounds toView:containerView]; + self.debugBorderLayer.frame = frame; + } else { + self.debugBorderLayer.frame = self.frame; + } +} + +@end + +@implementation QMUILayouterItem (UISubclassingHooks) + +- (void)layout { +} + +- (CGSize)sizeThatFits:(CGSize)size shouldConsiderBlock:(BOOL)shouldConsiderBlock { + if (CGSizeEqualToSize(self.view.bounds.size, size) || CGSizeIsEmpty(size)) { + size = CGSizeMax; + } + CGSize result = [self.view sizeThatFits:size]; + if (shouldConsiderBlock && self.sizeThatFitsBlock) { + result = self.sizeThatFitsBlock(self, size, result); + } + result.width = MIN(self.maximumSize.width, MAX(self.minimumSize.width, result.width)); + result.height = MIN(self.maximumSize.height, MAX(self.minimumSize.height, result.height)); + return result; +} + +@end diff --git a/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterLinearHorizontal.h b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterLinearHorizontal.h new file mode 100644 index 00000000..98e3655d --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterLinearHorizontal.h @@ -0,0 +1,46 @@ +/** + * Tencent is pleased to support the open source community by making QMUI_iOS available. + * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +// +// QMUILayouterLinearHorizontal.h +// QMUIKit +// +// Created by QMUI Team on 2024/1/2. +// + +#import +#import +#import "QMUILayouterItem.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + 水平方向的线性布局,若容器大小不足以容纳所有 item,则末尾的 item 大小会被强制裁剪以保证不溢出。 + 子元素可通过设置自己的 grow 来达到撑满容器的效果。 + */ +@interface QMUILayouterLinearHorizontal : QMUILayouterItem + ++ (instancetype)itemWithChildItems:(NSArray *)childItems + spacingBetweenItems:(CGFloat)spacingBetweenItems; + ++ (instancetype)itemWithChildItems:(NSArray *)childItems + spacingBetweenItems:(CGFloat)spacingBetweenItems + horizontal:(QMUILayouterAlignment)horizontal + vertical:(QMUILayouterAlignment)vertical; + +/// 子元素之间的间距 +@property(nonatomic, assign) CGFloat spacingBetweenItems; + +/// 子元素水平方向上的布局方式,默认为 QMUILayouterAlignmentLeading,每种 enum 的布局说明请查看 enum 定义。 +@property(nonatomic, assign) QMUILayouterAlignment childHorizontalAlignment; + +/// 子元素竖直方向上的布局方式,默认为 QMUILayouterAlignmentLeading,每种 enum 的布局说明请查看 enum 定义。 +@property(nonatomic, assign) QMUILayouterAlignment childVerticalAlignment; +@end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterLinearHorizontal.m b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterLinearHorizontal.m new file mode 100644 index 00000000..ecedf972 --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterLinearHorizontal.m @@ -0,0 +1,189 @@ +/** + * Tencent is pleased to support the open source community by making QMUI_iOS available. + * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +// +// QMUILayouterLinearHorizontal.m +// QMUIKit +// +// Created by QMUI Team on 2024/1/2. +// + +#import "QMUILayouterLinearHorizontal.h" +#import "QMUICore.h" +#import "NSArray+QMUI.h" +#import "UIView+QMUI.h" + +@implementation QMUILayouterLinearHorizontal + ++ (instancetype)itemWithChildItems:(NSArray *)childItems spacingBetweenItems:(CGFloat)spacingBetweenItems { + return [self itemWithChildItems:childItems spacingBetweenItems:spacingBetweenItems horizontal:QMUILayouterAlignmentLeading vertical:QMUILayouterAlignmentLeading]; +} + ++ (instancetype)itemWithChildItems:(NSArray *)childItems spacingBetweenItems:(CGFloat)spacingBetweenItems horizontal:(QMUILayouterAlignment)horizontal vertical:(QMUILayouterAlignment)vertical { + QMUILayouterLinearHorizontal *item = [[self alloc] init]; + item.childItems = childItems; + item.spacingBetweenItems = spacingBetweenItems; + item.childHorizontalAlignment = horizontal; + item.childVerticalAlignment = vertical; + return item; +} + +- (NSString *)description { + NSString * (^alignmentName)(QMUILayouterAlignment alignment) = ^NSString *(QMUILayouterAlignment alignment) { + return @[@"Leading", @"Trailing", @"Center", @"Fill"][alignment]; + }; + return [NSString qmui_stringByConcat:[super description], @", horizontal = ", alignmentName(self.childHorizontalAlignment), @", vertical = ", alignmentName(self.childVerticalAlignment), nil]; +} + +// 容器性质的 layouter,不存在关联的实体 view,则始终认为是可视的,如果是 parentItem 的 parentItem 不可见,则由 parentItem 自己去管 +- (BOOL)visible { + if (self.visibleBlock) return self.visibleBlock(self); + return self.visibleChildItems.count; +} + +- (CGSize)sizeThatFits:(CGSize)size shouldConsiderBlock:(BOOL)shouldConsiderBlock { + NSArray *childItems = self.visibleChildItems; + if (!childItems.count) return self.minimumSize; + if (CGSizeEqualToSize(self.frame.size, size) || CGSizeIsEmpty(size)) { + size = CGSizeMax; + } + __block CGSize contentSize = CGSizeZero; + __block CGFloat totalShrink = QMUILayouterShrinkNever; + __block NSMutableDictionary *cachedSize = NSMutableDictionary.new; + [childItems enumerateObjectsUsingBlock:^(QMUILayouterItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + CGSize s = [obj sizeThatFits:CGSizeMax]; + cachedSize[[NSString stringWithFormat:@"%p", obj]] = [NSValue valueWithCGSize:s]; + contentSize.width += s.width + UIEdgeInsetsGetHorizontalValue(obj.margin) + self.spacingBetweenItems; + contentSize.height = MAX(contentSize.height, s.height + UIEdgeInsetsGetVerticalValue(obj.margin)); + + if (obj.shrink > QMUILayouterShrinkNever) { + totalShrink += s.width * obj.shrink; + } + }]; + contentSize.width -= self.spacingBetweenItems; + if (contentSize.width <= size.width || totalShrink == QMUILayouterShrinkNever) { + if (shouldConsiderBlock && self.sizeThatFitsBlock) { + contentSize = self.sizeThatFitsBlock(self, size, contentSize); + } + contentSize.width = MIN(self.maximumSize.width, MAX(self.minimumSize.width, contentSize.width)); + contentSize.height = MIN(self.maximumSize.height, MAX(self.minimumSize.height, contentSize.height)); + return contentSize; + } + + __block CGSize resultSize = CGSizeZero; + [childItems enumerateObjectsUsingBlock:^(QMUILayouterItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + CGSize s = cachedSize[[NSString stringWithFormat:@"%p", obj]].CGSizeValue; + if (obj.shrink > QMUILayouterGrowNever) { + CGFloat spaceToShrink = contentSize.width - size.width; + CGFloat w = s.width - spaceToShrink * s.width * obj.shrink / totalShrink; + CGFloat h = [obj sizeThatFits:CGSizeMake(w, CGFLOAT_MAX)].height; + s.width = w; + s.height = h; + } + resultSize.width += s.width + UIEdgeInsetsGetHorizontalValue(obj.margin) + self.spacingBetweenItems; + resultSize.height = MAX(resultSize.height, s.height + UIEdgeInsetsGetVerticalValue(obj.margin)); + }]; + resultSize.width -= self.spacingBetweenItems; + if (shouldConsiderBlock && self.sizeThatFitsBlock) { + resultSize = self.sizeThatFitsBlock(self, size, resultSize); + } + resultSize.width = MIN(self.maximumSize.width, MAX(self.minimumSize.width, resultSize.width)); + resultSize.height = MIN(self.maximumSize.height, MAX(self.minimumSize.height, resultSize.height)); + return resultSize; +} + +- (void)layout { + NSArray *childItems = self.visibleChildItems; + CGSize contentSize = [self sizeThatFits:CGSizeMax shouldConsiderBlock:NO]; + + __block CGFloat totalGrow = QMUILayouterGrowNever; + __block CGFloat spaceToGrow = CGRectGetWidth(self.frame);// 父容器里待填充的多余空间(容器总大小减去所有固定的值,包括 spacingBetweenItems、所有 item 的 margin 区域、grow = Never 的 item 的 width之后,剩下的空间) + __block CGFloat totalShrink = QMUILayouterShrinkNever; + [childItems enumerateObjectsUsingBlock:^(QMUILayouterItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + [obj sizeToFit]; + + spaceToGrow -= CGRectGetWidth(obj.frame) + UIEdgeInsetsGetHorizontalValue(obj.margin) + self.spacingBetweenItems; + if (obj.grow > QMUILayouterGrowNever) { + totalGrow += obj.grow; + } + + if (obj.shrink > QMUILayouterShrinkNever) { + totalShrink += CGRectGetWidth(obj.frame) * obj.shrink; + } + }]; + spaceToGrow += self.spacingBetweenItems; + BOOL shouldCalcGrow = totalGrow > QMUILayouterGrowNever && contentSize.width < CGRectGetWidth(self.frame); + BOOL shouldCalcShrink = totalShrink > QMUILayouterShrinkNever && contentSize.width > CGRectGetWidth(self.frame); + + __block CGFloat minX = CGRectGetMinX(self.frame); + __block CGFloat minY = CGRectGetMinY(self.frame); + __block CGFloat maxX = CGRectGetMaxX(self.frame); + __block CGFloat maxY = CGRectGetMaxY(self.frame); + QMUILayouterAlignment childHorizontalAlignment = self.childHorizontalAlignment; + QMUILayouterAlignment childVerticalAlignment = self.childVerticalAlignment; + + // 不需要考虑 grow/shrink 的情况,先把 minX 算出来 + if (!shouldCalcGrow && !shouldCalcShrink && childHorizontalAlignment != QMUILayouterAlignmentLeading) { + if (contentSize.width >= CGRectGetWidth(self.frame)) { + // 不管哪种布局方式,只要内容超过容器,统一按 Leading 处理 + childHorizontalAlignment = QMUILayouterAlignmentLeading; + } else if (childHorizontalAlignment == QMUILayouterAlignmentTrailing) { + minX = MAX(minX, CGRectGetMaxX(self.frame) - contentSize.width); + childHorizontalAlignment = QMUILayouterAlignmentLeading; + } else if (childHorizontalAlignment == QMUILayouterAlignmentCenter) { + minX = MAX(minX, minX + CGFloatGetCenter(CGRectGetWidth(self.frame), contentSize.width)); + childHorizontalAlignment = QMUILayouterAlignmentLeading; + } else if (childHorizontalAlignment == QMUILayouterAlignmentFill) { + if (childItems.count > 1) { + // 与容器相同方向的 Fill 仅在只有一个子元素时有效,超过一个子元素则视为 Leading + // 如果你希望多个 childItem 可拉伸铺满,应该用 childItem.grow 来控制,而不是用 Fill + childHorizontalAlignment = QMUILayouterAlignmentLeading; + } else { + // 一个子元素的情况,直接布局掉算了 + QMUILayouterItem *obj = self.visibleChildItem0; + obj.frame = CGRectSetX(obj.frame, minX + obj.margin.left); + obj.frame = CGRectSetWidth(obj.frame, maxX - obj.margin.right - CGRectGetMinX(obj.frame)); + } + } + } + + [childItems enumerateObjectsUsingBlock:^(QMUILayouterItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + obj.frame = CGRectSetX(obj.frame, minX + obj.margin.left); + if (shouldCalcGrow && obj.grow > QMUILayouterGrowNever) { + CGFloat w = CGRectGetWidth(obj.frame) + spaceToGrow * obj.grow / totalGrow; + obj.frame = CGRectSetSize(obj.frame, CGSizeMake(w, QMUIViewSelfSizingHeight)); + } + if (shouldCalcShrink && obj.shrink > QMUILayouterGrowNever) { + CGFloat spaceToShrink = contentSize.width - CGRectGetWidth(self.frame); + CGFloat w = CGRectGetWidth(obj.frame) - spaceToShrink * CGRectGetWidth(obj.frame) * obj.shrink / totalShrink; + w = MAX(0, w); + obj.frame = CGRectSetSize(obj.frame, CGSizeMake(w, QMUIViewSelfSizingHeight)); + obj.frame = CGRectSetHeight(obj.frame, MIN(CGRectGetHeight(self.frame) - UIEdgeInsetsGetVerticalValue(obj.margin), CGRectGetHeight(obj.frame))); + } + if (CGRectGetMaxX(obj.frame) + obj.margin.right > maxX) { + obj.frame = CGRectSetWidth(obj.frame, maxX - obj.margin.right - CGRectGetMinX(obj.frame)); + } + + minX = CGRectGetMaxX(obj.frame) + obj.margin.right + self.spacingBetweenItems; + + if (childVerticalAlignment == QMUILayouterAlignmentTrailing) { + obj.frame = CGRectSetY(obj.frame, maxY - obj.margin.bottom - CGRectGetHeight(obj.frame)); + } else if (childVerticalAlignment == QMUILayouterAlignmentCenter) { + obj.frame = CGRectSetY(obj.frame, minY + obj.margin.top + CGFloatGetCenter(maxY - minY - UIEdgeInsetsGetVerticalValue(obj.margin), CGRectGetHeight(obj.frame))); + } else if (childVerticalAlignment == QMUILayouterAlignmentFill) { + obj.frame = CGRectSetY(obj.frame, minY + obj.margin.top); + obj.frame = CGRectSetHeight(obj.frame, maxY - obj.margin.bottom - CGRectGetMinY(obj.frame)); + } else { + obj.frame = CGRectSetY(obj.frame, minY + obj.margin.top); + } + + [obj layoutIfNeeded]; + }]; +} + +@end diff --git a/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterLinearVertical.h b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterLinearVertical.h new file mode 100644 index 00000000..95ab8e70 --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterLinearVertical.h @@ -0,0 +1,46 @@ +/** + * Tencent is pleased to support the open source community by making QMUI_iOS available. + * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +// +// QMUILayouterLinearVertical.h +// QMUIKit +// +// Created by QMUI Team on 2024/1/2. +// + +#import +#import +#import "QMUILayouterItem.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + 竖直方向的线性布局,若容器大小不足以容纳所有 item,则末尾的 item 大小会被强制裁剪以保证不溢出。 + 子元素可通过设置自己的 grow 来达到撑满容器的效果。 + */ +@interface QMUILayouterLinearVertical : QMUILayouterItem + ++ (instancetype)itemWithChildItems:(NSArray *)childItems + spacingBetweenItems:(CGFloat)spacingBetweenItems; + ++ (instancetype)itemWithChildItems:(NSArray *)childItems + spacingBetweenItems:(CGFloat)spacingBetweenItems + horizontal:(QMUILayouterAlignment)horizontal + vertical:(QMUILayouterAlignment)vertical; + +/// 子元素之间的间距 +@property(nonatomic, assign) CGFloat spacingBetweenItems; + +/// 子元素水平方向上的布局方式,默认为 QMUILayouterAlignmentLeading,每种 enum 的布局说明请查看 enum 定义。 +@property(nonatomic, assign) QMUILayouterAlignment childHorizontalAlignment; + +/// 子元素竖直方向上的布局方式,默认为 QMUILayouterAlignmentLeading,每种 enum 的布局说明请查看 enum 定义。 +@property(nonatomic, assign) QMUILayouterAlignment childVerticalAlignment; +@end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterLinearVertical.m b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterLinearVertical.m new file mode 100644 index 00000000..5e66320d --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUILayouter/QMUILayouterLinearVertical.m @@ -0,0 +1,187 @@ +/** + * Tencent is pleased to support the open source community by making QMUI_iOS available. + * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +// +// QMUILayouterLinearVertical.m +// QMUIKit +// +// Created by QMUI Team on 2024/1/2. +// + +#import "QMUILayouterLinearVertical.h" +#import "QMUICore.h" +#import "NSString+QMUI.h" + +@implementation QMUILayouterLinearVertical + ++ (instancetype)itemWithChildItems:(NSArray *)childItems spacingBetweenItems:(CGFloat)spacingBetweenItems { + return [self itemWithChildItems:childItems spacingBetweenItems:spacingBetweenItems horizontal:QMUILayouterAlignmentLeading vertical:QMUILayouterAlignmentLeading]; +} + ++ (instancetype)itemWithChildItems:(NSArray *)childItems spacingBetweenItems:(CGFloat)spacingBetweenItems horizontal:(QMUILayouterAlignment)horizontal vertical:(QMUILayouterAlignment)vertical { + QMUILayouterLinearVertical *item = [[self alloc] init]; + item.childItems = childItems; + item.spacingBetweenItems = spacingBetweenItems; + item.childHorizontalAlignment = horizontal; + item.childVerticalAlignment = vertical; + return item; +} + +- (NSString *)description { + NSString * (^alignmentName)(QMUILayouterAlignment alignment) = ^NSString *(QMUILayouterAlignment alignment) { + return @[@"Leading", @"Trailing", @"Center", @"Fill"][alignment]; + }; + return [NSString qmui_stringByConcat:[super description], @", horizontal = ", alignmentName(self.childHorizontalAlignment), @", vertical = ", alignmentName(self.childVerticalAlignment), nil]; +} + +// 容器性质的 layouter,不存在关联的实体 view,则始终认为是可视的,如果是 parentItem 的 parentItem 不可见,则由 parentItem 自己去管 +- (BOOL)visible { + if (self.visibleBlock) return self.visibleBlock(self); + return self.visibleChildItems.count; +} + +- (CGSize)sizeThatFits:(CGSize)size shouldConsiderBlock:(BOOL)shouldConsiderBlock { + NSArray *childItems = self.visibleChildItems; + if (!childItems.count) return self.minimumSize; + if (CGSizeEqualToSize(self.frame.size, size) || CGSizeIsEmpty(size)) { + size = CGSizeMax; + } + __block CGSize contentSize = CGSizeZero; + __block CGFloat totalShrink = QMUILayouterShrinkNever; + __block NSMutableDictionary *cachedSize = NSMutableDictionary.new; + [childItems enumerateObjectsUsingBlock:^(QMUILayouterItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + CGSize s = [obj sizeThatFits:CGSizeMake(size.width - UIEdgeInsetsGetHorizontalValue(obj.margin), CGFLOAT_MAX)]; + cachedSize[[NSString stringWithFormat:@"%p", obj]] = [NSValue valueWithCGSize:s]; + contentSize.width = MAX(contentSize.width, s.width + UIEdgeInsetsGetHorizontalValue(obj.margin)); + contentSize.height += s.height + UIEdgeInsetsGetVerticalValue(obj.margin) + self.spacingBetweenItems; + + if (obj.shrink > QMUILayouterShrinkNever) { + totalShrink += s.height * obj.shrink; + } + }]; + contentSize.height -= self.spacingBetweenItems; + if (contentSize.height <= size.height || totalShrink == QMUILayouterShrinkNever) { + if (shouldConsiderBlock && self.sizeThatFitsBlock) { + contentSize = self.sizeThatFitsBlock(self, size, contentSize); + } + contentSize.width = MIN(self.maximumSize.width, MAX(self.minimumSize.width, contentSize.width)); + contentSize.height = MIN(self.maximumSize.height, MAX(self.minimumSize.height, contentSize.height)); + return contentSize; + } + + __block CGSize resultSize = CGSizeZero; + [childItems enumerateObjectsUsingBlock:^(QMUILayouterItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + CGSize s = cachedSize[[NSString stringWithFormat:@"%p", obj]].CGSizeValue; + if (obj.shrink > QMUILayouterShrinkNever) { + CGFloat spaceToShrink = contentSize.height - size.height; + CGFloat h = s.height - spaceToShrink * s.height * obj.shrink / totalShrink; + s.height = h; + } + resultSize.width = MAX(resultSize.width, s.width + UIEdgeInsetsGetHorizontalValue(obj.margin)); + resultSize.height += s.height + UIEdgeInsetsGetVerticalValue(obj.margin) + self.spacingBetweenItems; + }]; + resultSize.height -= self.spacingBetweenItems; + if (shouldConsiderBlock && self.sizeThatFitsBlock) { + resultSize = self.sizeThatFitsBlock(self, size, resultSize); + } + resultSize.width = MIN(self.maximumSize.width, MAX(self.minimumSize.width, resultSize.width)); + resultSize.height = MIN(self.maximumSize.height, MAX(self.minimumSize.height, resultSize.height)); + return resultSize; +} + +- (void)layout { + NSArray *childItems = self.visibleChildItems; + CGSize contentSize = [self sizeThatFits:CGSizeMake(CGRectGetWidth(self.frame), CGFLOAT_MAX) shouldConsiderBlock:NO]; + __block CGFloat totalGrow = QMUILayouterGrowNever; + __block CGFloat spaceToGrow = CGRectGetHeight(self.frame);// 父容器里待填充的多余空间(容器总大小减去所有固定的值,包括 spacingBetweenItems、所有 item 的 margin 区域、grow = Never 的 item 的 width之后,剩下的空间) + __block CGFloat totalShrink = QMUILayouterShrinkNever; + [childItems enumerateObjectsUsingBlock:^(QMUILayouterItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + CGFloat itemMaxWidth = CGRectGetWidth(self.frame) - UIEdgeInsetsGetHorizontalValue(obj.margin); + CGSize itemSize = [obj sizeThatFits:CGSizeMake(itemMaxWidth, CGFLOAT_MAX)]; + itemSize.width = MIN(itemMaxWidth, itemSize.width); + obj.frame = CGRectSetSize(obj.frame, itemSize); + + spaceToGrow -= CGRectGetHeight(obj.frame) + UIEdgeInsetsGetVerticalValue(obj.margin) + self.spacingBetweenItems; + if (obj.grow > QMUILayouterGrowNever) { + totalGrow += obj.grow; + } + + if (obj.shrink > QMUILayouterShrinkNever) { + totalShrink += CGRectGetHeight(obj.frame) * obj.shrink; + } + }]; + spaceToGrow += self.spacingBetweenItems; + BOOL shouldCalcGrow = totalGrow > QMUILayouterGrowNever && contentSize.height < CGRectGetHeight(self.frame); + BOOL shouldCalcShrink = totalShrink > QMUILayouterShrinkNever && contentSize.height > CGRectGetHeight(self.frame); + + __block CGFloat minX = CGRectGetMinX(self.frame); + __block CGFloat minY = CGRectGetMinY(self.frame); + __block CGFloat maxX = CGRectGetMaxX(self.frame); + __block CGFloat maxY = CGRectGetMaxY(self.frame); + QMUILayouterAlignment childVerticalAlignment = self.childVerticalAlignment; + QMUILayouterAlignment childHorizontalAlignment = self.childHorizontalAlignment; + + // 不需要考虑 grow/shrink 的情况,先把 minX 算出来 + if (!shouldCalcGrow && !shouldCalcShrink && childVerticalAlignment != QMUILayouterAlignmentLeading) { + if (contentSize.height >= CGRectGetHeight(self.frame)) { + // 不管哪种布局方式,只要内容超过容器,统一按 Leading 处理 + childVerticalAlignment = QMUILayouterAlignmentLeading; + } else if (childVerticalAlignment == QMUILayouterAlignmentTrailing) { + minY = MAX(minY, CGRectGetMaxY(self.frame) - contentSize.height); + childVerticalAlignment = QMUILayouterAlignmentLeading; + } else if (childVerticalAlignment == QMUILayouterAlignmentCenter) { + minY = MAX(minY, minY + CGFloatGetCenter(CGRectGetHeight(self.frame), contentSize.height)); + childVerticalAlignment = QMUILayouterAlignmentLeading; + } else if (childVerticalAlignment == QMUILayouterAlignmentFill) { + if (childItems.count > 1) { + // 与容器相同方向的 Fill 仅在只有一个子元素时有效,超过一个子元素则视为 Leading + // 如果你希望多个 childItem 可拉伸铺满,应该用 childItem.grow 来控制,而不是用 Fill + childVerticalAlignment = QMUILayouterAlignmentLeading; + } else { + // 一个子元素的情况,直接布局掉算了 + QMUILayouterItem *obj = self.visibleChildItem0; + obj.frame = CGRectSetY(obj.frame, minY + obj.margin.top); + obj.frame = CGRectSetHeight(obj.frame, maxY - obj.margin.bottom - CGRectGetMinY(obj.frame)); + } + } + } + + [childItems enumerateObjectsUsingBlock:^(QMUILayouterItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + obj.frame = CGRectSetY(obj.frame, minY + obj.margin.top); + if (shouldCalcGrow && obj.grow > QMUILayouterGrowNever) { + CGFloat h = CGRectGetHeight(obj.frame) + spaceToGrow * obj.grow / totalGrow; + obj.frame = CGRectSetHeight(obj.frame, h); + } + if (shouldCalcShrink && obj.shrink > QMUILayouterShrinkNever) { + CGFloat spaceToShrink = contentSize.height - CGRectGetHeight(self.frame); + CGFloat h = CGRectGetHeight(obj.frame) - spaceToShrink * CGRectGetHeight(obj.frame) * obj.shrink / totalShrink; + h = MAX(0, h); + obj.frame = CGRectSetHeight(obj.frame, h); + } + if (CGRectGetMaxY(obj.frame) + obj.margin.bottom > maxY) { + obj.frame = CGRectSetHeight(obj.frame, maxY - obj.margin.bottom - CGRectGetMinY(obj.frame)); + } + + minY = CGRectGetMaxY(obj.frame) + obj.margin.bottom + self.spacingBetweenItems; + + if (childHorizontalAlignment == QMUILayouterAlignmentTrailing) { + obj.frame = CGRectSetX(obj.frame, maxX - obj.margin.right - CGRectGetWidth(obj.frame)); + } else if (childHorizontalAlignment == QMUILayouterAlignmentCenter) { + obj.frame = CGRectSetX(obj.frame, minX + obj.margin.left + CGFloatGetCenter(maxX - minX - UIEdgeInsetsGetHorizontalValue(obj.margin), CGRectGetWidth(obj.frame))); + } else if (childHorizontalAlignment == QMUILayouterAlignmentFill) { + obj.frame = CGRectSetX(obj.frame, minX + obj.margin.left); + obj.frame = CGRectSetWidth(obj.frame, maxX - obj.margin.right - CGRectGetMinX(obj.frame)); + } else { + obj.frame = CGRectSetX(obj.frame, minX + obj.margin.left); + } + + [obj layoutIfNeeded]; + }]; +} + +@end diff --git a/QMUIKit/QMUIComponents/QMUILogManagerViewController.m b/QMUIKit/QMUIComponents/QMUILogManagerViewController.m index d87e432b..dead13a4 100644 --- a/QMUIKit/QMUIComponents/QMUILogManagerViewController.m +++ b/QMUIKit/QMUIComponents/QMUILogManagerViewController.m @@ -168,25 +168,25 @@ - (void)handleMenuItemEvent { menuView.maximumWidth = 124; menuView.safetyMarginsOfSuperview = UIEdgeInsetsSetRight(menuView.safetyMarginsOfSuperview, 6); menuView.items = @[ - [QMUIPopupMenuButtonItem itemWithImage:nil title:@"开启全部" handler:^(QMUIPopupMenuButtonItem *aItem) { - for (NSString *logName in self.allNames) { - [[QMUILogger sharedInstance].logNameManager setEnabled:YES forLogName:logName]; - } - [self reloadData]; - [aItem.menuView hideWithAnimated:YES]; - }], - [QMUIPopupMenuButtonItem itemWithImage:nil title:@"禁用全部" handler:^(QMUIPopupMenuButtonItem *aItem) { - for (NSString *logName in self.allNames) { - [[QMUILogger sharedInstance].logNameManager setEnabled:NO forLogName:logName]; - } - [self reloadData]; - [aItem.menuView hideWithAnimated:YES]; - }], - [QMUIPopupMenuButtonItem itemWithImage:nil title:@"清空全部" handler:^(QMUIPopupMenuButtonItem *aItem) { - [[QMUILogger sharedInstance].logNameManager removeAllNames]; - [self reloadData]; - [aItem.menuView hideWithAnimated:YES]; - }]]; + [QMUIPopupMenuItem itemWithTitle:@"开启全部" handler:^(__kindof QMUIPopupMenuItem * _Nonnull aItem, __kindof UIControl * _Nonnull aItemView, NSInteger section, NSInteger index) { + for (NSString *logName in self.allNames) { + [[QMUILogger sharedInstance].logNameManager setEnabled:YES forLogName:logName]; + } + [self reloadData]; + [aItem.menuView hideWithAnimated:YES]; + }], + [QMUIPopupMenuItem itemWithTitle:@"禁用全部" handler:^(__kindof QMUIPopupMenuItem * _Nonnull aItem, __kindof UIControl * _Nonnull aItemView, NSInteger section, NSInteger index) { + for (NSString *logName in self.allNames) { + [[QMUILogger sharedInstance].logNameManager setEnabled:NO forLogName:logName]; + } + [self reloadData]; + [aItem.menuView hideWithAnimated:YES]; + }], + [QMUIPopupMenuItem itemWithTitle:@"清空全部" handler:^(__kindof QMUIPopupMenuItem * _Nonnull aItem, __kindof UIControl * _Nonnull aItemView, NSInteger section, NSInteger index) { + [[QMUILogger sharedInstance].logNameManager removeAllNames]; + [self reloadData]; + [aItem.menuView hideWithAnimated:YES]; + }]]; menuView.sourceBarItem = self.navigationItem.rightBarButtonItem; [menuView showWithAnimated:YES]; } diff --git a/QMUIKit/QMUIComponents/QMUIModalPresentationViewController.h b/QMUIKit/QMUIComponents/QMUIModalPresentationViewController.h index d5d42f60..b44011fa 100644 --- a/QMUIKit/QMUIComponents/QMUIModalPresentationViewController.h +++ b/QMUIKit/QMUIComponents/QMUIModalPresentationViewController.h @@ -142,6 +142,12 @@ typedef NS_ENUM(NSUInteger, QMUIModalPresentationAnimationStyle) { */ @property(nullable, nonatomic, strong, readonly) UIWindow *window; +/** + 如果 modal 是以 window 形式显示的话,通过这个属性来决定 window 是否需要以 keyWindow 形式存在(keyWindow 一般用于与键盘交互的场景,没输入框可以不用开启它) + 默认为 YES。 + */ +@property(nonatomic, assign) BOOL shouldBecomeKeyWindow; + /** 如果 modal 是以 window 形式显示的话,控制在 modal 显示时是否要自动把 App 主界面置灰。 默认为 YES。 diff --git a/QMUIKit/QMUIComponents/QMUIModalPresentationViewController.m b/QMUIKit/QMUIComponents/QMUIModalPresentationViewController.m index 5339e995..5355412f 100644 --- a/QMUIKit/QMUIComponents/QMUIModalPresentationViewController.m +++ b/QMUIKit/QMUIComponents/QMUIModalPresentationViewController.m @@ -98,6 +98,7 @@ - (void)didInitialize { self.shouldDimmedAppAutomatically = YES; self.onlyRespondsToKeyboardEventFromDescendantViews = YES; + self.shouldBecomeKeyWindow = YES; self.modalTransitionStyle = UIModalTransitionStyleCrossDissolve; self.modalPresentationStyle = UIModalPresentationCustom; @@ -512,7 +513,11 @@ - (void)showWithAnimated:(BOOL)animated completion:(void (^)(BOOL))completion { [self updateWindowStatusBarCapture]; } self.window.rootViewController = self; - [self.window makeKeyAndVisible]; + if (self.shouldBecomeKeyWindow) { + [self.window makeKeyAndVisible]; + } else { + self.window.hidden = NO; + } } - (void)hidingAnimationWithCompletion:(void (^)(BOOL))completion { diff --git a/QMUIKit/QMUIComponents/QMUINavigationTitleView.h b/QMUIKit/QMUIComponents/QMUINavigationTitleView.h index 72e0d19e..180f9163 100644 --- a/QMUIKit/QMUIComponents/QMUINavigationTitleView.h +++ b/QMUIKit/QMUIComponents/QMUINavigationTitleView.h @@ -72,6 +72,7 @@ typedef NS_ENUM(NSInteger, QMUINavigationTitleViewAccessoryType) { @property(nonatomic, weak) id delegate; @property(nonatomic, assign) QMUINavigationTitleViewStyle style; @property(nonatomic, assign, getter=isActive) BOOL active; +@property(nonatomic, assign) UIEdgeInsets padding UI_APPEARANCE_SELECTOR; @property(nonatomic, assign) CGFloat maximumWidth UI_APPEARANCE_SELECTOR; #pragma mark - Titles @@ -85,6 +86,13 @@ typedef NS_ENUM(NSInteger, QMUINavigationTitleViewAccessoryType) { /// 当 tintColor 发生变化时是否要自动把 titleLabel、subtitleLabel、loadingView 的颜色也更新为 tintColor 的色值,默认为 YES,如果你自己修改了 titleLabel、subtitleLabel、loadingView 的颜色,需要把这个值置为 NO @property(nonatomic, assign) BOOL adjustsSubviewsTintColorAutomatically UI_APPEARANCE_SELECTOR; +/** + * 是否自动调整 highlighted 时的样式,默认为YES。
+ * 当值为 YES 时,标题 highlighted 时会改变自身的 alpha 属性为 UIControlHighlightedAlpha + * 适用于比如说整个 titleView 不需要接受点击,但 accessoryView 需要接受点击,此时就应该 titleView.userInteractionEnabled = YES、titleView.adjustsSubviewsWhenHighlighted = NO + */ +@property(nonatomic, assign) BOOL adjustsSubviewsWhenHighlighted; + /// 水平布局下的标题字体,默认为 NavBarTitleFont @property(nonatomic, strong) UIFont *horizontalTitleFont UI_APPEARANCE_SELECTOR; @@ -178,6 +186,7 @@ typedef NS_ENUM(NSInteger, QMUINavigationTitleViewAccessoryType) { @interface UIView (QMUINavigationTitleView) -/// 标记当前 view 是用于自定义的导航栏标题,QMUI 可以帮你自动处理系统的一些布局 bug。对于 QMUINavigationTitleView 而言默认值为 YES,其他 UIView 默认值为 NO +/// 标记当前 view 是用于自定义的导航栏标题,QMUI 可以帮你自动处理系统的一些布局 bug,并且保证 pop 时导航栏标题颜色不会被前一个界面影响。 +/// 对于 QMUINavigationTitleView 而言默认值为 YES,其他 UIView 默认值为 NO。 @property(nonatomic, assign) BOOL qmui_useAsNavigationTitleView; @end diff --git a/QMUIKit/QMUIComponents/QMUINavigationTitleView.m b/QMUIKit/QMUIComponents/QMUINavigationTitleView.m index 25c3ff9c..fb4a4bcd 100644 --- a/QMUIKit/QMUIComponents/QMUINavigationTitleView.m +++ b/QMUIKit/QMUIComponents/QMUINavigationTitleView.m @@ -49,10 +49,10 @@ - (instancetype)initWithStyle:(QMUINavigationTitleViewStyle)style frame:(CGRect) if (self = [super initWithFrame:frame]) { self.qmui_useAsNavigationTitleView = YES; + self.qmui_outsideEdge = UIEdgeInsetsMake(-10, 0, -10, 0); [self addTarget:self action:@selector(handleTouchTitleViewEvent) forControlEvents:UIControlEventTouchUpInside]; _contentView = [[UIView alloc] init]; - _contentView.userInteractionEnabled = NO; [self addSubview:self.contentView]; _titleLabel = [[UILabel alloc] init]; @@ -81,7 +81,8 @@ - (instancetype)initWithStyle:(QMUINavigationTitleViewStyle)style frame:(CGRect) self.horizontalTitleFont = QMUINavigationTitleView.appearance.horizontalTitleFont ?: UINavigationBar.qmui_appearanceConfigured.titleTextAttributes[NSFontAttributeName]; self.horizontalSubtitleFont = QMUINavigationTitleView.appearance.horizontalSubtitleFont ?: self.horizontalTitleFont; - self.adjustsSubviewsTintColorAutomatically = YES; + self.adjustsSubviewsTintColorAutomatically = QMUINavigationTitleView.appearance.adjustsSubviewsTintColorAutomatically; + self.adjustsSubviewsWhenHighlighted = QMUINavigationTitleView.appearance.adjustsSubviewsWhenHighlighted; self.tintColor = QMUICMIActivated ? NavBarTitleColor : UINavigationBar.qmui_appearanceConfigured.titleTextAttributes[NSForegroundColorAttributeName]; } return self; @@ -223,7 +224,9 @@ - (CGSize)contentSize { - (CGSize)sizeThatFits:(CGSize)size { CGSize resultSize = [self contentSize]; + resultSize.width += UIEdgeInsetsGetHorizontalValue(self.padding); resultSize.width = MIN(resultSize.width, self.maximumWidth); + resultSize.height += UIEdgeInsetsGetVerticalValue(self.padding); return resultSize; } @@ -234,13 +237,13 @@ - (void)layoutSubviews { [super layoutSubviews]; - self.contentView.frame = self.bounds; + self.contentView.frame = CGRectInsetEdges(self.bounds, self.padding); BOOL alignLeft = self.contentHorizontalAlignment == UIControlContentHorizontalAlignmentLeft; BOOL alignRight = self.contentHorizontalAlignment == UIControlContentHorizontalAlignmentRight; // 通过sizeThatFit计算出来的size,如果大于可使用的最大宽度,则会被系统改为最大限制的最大宽度 - CGSize maxSize = self.bounds.size; + CGSize maxSize = self.contentView.bounds.size; // 实际内容的size,小于等于maxSize CGSize contentSize = [self contentSize]; @@ -379,6 +382,11 @@ - (void)setMaximumWidth:(CGFloat)maximumWidth { [self refreshLayout]; } +- (void)setPadding:(UIEdgeInsets)padding { + _padding = padding; + [self refreshLayout]; +} + - (void)setContentHorizontalAlignment:(UIControlContentHorizontalAlignment)contentHorizontalAlignment { [super setContentHorizontalAlignment:contentHorizontalAlignment]; [self refreshLayout]; @@ -545,7 +553,8 @@ - (void)setNeedsLoadingView:(BOOL)needsLoadingView { _needsLoadingView = needsLoadingView; if (needsLoadingView) { if (!self.loadingView) { - _loadingView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:NavBarActivityIndicatorViewStyle size:self.loadingViewSize]; + _loadingView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:NavBarActivityIndicatorViewStyle]; + self.loadingView.qmui_size = self.loadingViewSize; self.loadingView.color = self.tintColor; [self.loadingView stopAnimating]; [self.contentView addSubview:self.loadingView]; @@ -626,7 +635,17 @@ - (void)tintColorDidChange { - (void)setHighlighted:(BOOL)highlighted { [super setHighlighted:highlighted]; - self.alpha = highlighted ? UIControlHighlightedAlpha : 1; + if (self.adjustsSubviewsWhenHighlighted) { + self.alpha = highlighted ? UIControlHighlightedAlpha : 1; + } +} + +- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { + UIView *result = [super hitTest:point withEvent:event]; + if (result == self.contentView) { + return self; + } + return result; } - (void)handleTouchTitleViewEvent { @@ -710,6 +729,7 @@ + (void)initialize { + (void)setDefaultAppearance { QMUINavigationTitleView *appearance = [QMUINavigationTitleView appearance]; appearance.adjustsSubviewsTintColorAutomatically = YES; + appearance.adjustsSubviewsWhenHighlighted = YES; appearance.maximumWidth = CGFLOAT_MAX; appearance.loadingViewSize = CGSizeMake(18, 18); appearance.loadingViewMarginRight = 3; @@ -778,7 +798,7 @@ - (void)qmui_updateTitleViewToMatchScrollOffsetInViewController:(UIViewControlle return; } - QMUIAssert(viewController.navigationController == self, @"QMUINavigationTitleView", @"不存在 UINavigationController"); + if (viewController.navigationController != self) return; QMUINavigationTitleView *navigationTitleView = (QMUINavigationTitleView *)titleView; UIView *largeTitleView = self.navigationBar.qmui_largeTitleView; diff --git a/QMUIKit/QMUIComponents/QMUIPopupContainerView.h b/QMUIKit/QMUIComponents/QMUIPopupContainerView.h index 5cd28a26..2da5a690 100644 --- a/QMUIKit/QMUIComponents/QMUIPopupContainerView.h +++ b/QMUIKit/QMUIComponents/QMUIPopupContainerView.h @@ -25,6 +25,12 @@ typedef NS_ENUM(NSUInteger, QMUIPopupContainerViewLayoutDirection) { QMUIPopupContainerViewLayoutDirectionRight }; +typedef NS_ENUM(NSUInteger, QMUIPopupContainerViewLayoutAlignment) { + QMUIPopupContainerViewLayoutAlignmentCenter, + QMUIPopupContainerViewLayoutAlignmentLeading, + QMUIPopupContainerViewLayoutAlignmentTrailing, +}; + /** * 带箭头的小tips浮层,自带 imageView 和 textLabel,可展示简单的图文信息,支持 UIViewContentModeTop/UIViewContentModeBottom/UIViewContentModeCenter 三种布局方式。 * QMUIPopupContainerView 支持以两种方式显示在界面上: @@ -50,9 +56,11 @@ typedef NS_ENUM(NSUInteger, QMUIPopupContainerViewLayoutDirection) { */ @interface QMUIPopupContainerView : UIControl { CAShapeLayer *_backgroundLayer; + CAShapeLayer *_borderLayer;// CAShapeLayer 的特性是有一半 stroke 会和 fill 重叠,而我们希望的是 stroke 在 fill 外面,所以只能分开两个 layer 实现 border 和 background UIImageView *_arrowImageView; CGFloat _arrowMinX; CGFloat _arrowMinY; + BOOL _shouldInvalidateLayout; } @property(nonatomic, assign) BOOL debug; @@ -92,6 +100,7 @@ typedef NS_ENUM(NSUInteger, QMUIPopupContainerViewLayoutDirection) { @property(nonatomic, assign) CGSize arrowSize UI_APPEARANCE_SELECTOR; /// 三角箭头的图片,通常用于默认的三角样式不满足需求时。当使用了 arrowImage 后,arrowSize 将会被固定为 arrowImage.size。 +/// 当 borderWidth 大于0时,arrowImage 会与所在那一侧的 border 重叠,所以你的切图需要预留一部分 borderWidth 的区域以盖住边框。 /// 图片必须为箭头向下的方向 @property(nonatomic, strong, nullable) UIImage *arrowImage UI_APPEARANCE_SELECTOR; @@ -101,7 +110,7 @@ typedef NS_ENUM(NSUInteger, QMUIPopupContainerViewLayoutDirection) { /// 最小宽度(指整个控件的宽度,而不是contentView部分),默认为0 @property(nonatomic, assign) CGFloat minimumWidth UI_APPEARANCE_SELECTOR; -/// 最大高度(指整个控件的高度,而不是contentView部分),默认为CGFLOAT_MAX +/// 最大高度(指整个控件的高度,而不是contentView部分),默认为CGFLOAT_MAX,会在布局时被动态修改。 @property(nonatomic, assign) CGFloat maximumHeight UI_APPEARANCE_SELECTOR; /// 最小高度(指整个控件的高度,而不是contentView部分),默认为0 @@ -113,10 +122,22 @@ typedef NS_ENUM(NSUInteger, QMUIPopupContainerViewLayoutDirection) { /// 最终的布局方向(preferLayoutDirection只是期望的方向,但有可能那个方向已经没有剩余空间可摆放控件了,所以会自动变换) @property(nonatomic, assign, readonly) QMUIPopupContainerViewLayoutDirection currentLayoutDirection; +/// 计算布局时期望浮层与目标位置的对齐方式,默认为 QMUIPopupContainerViewLayoutAlignmentCenter,也即浮层和目标位置相对居中。 +/// 对 preferLayoutDirection 为 Above/Below 而言,Leading 表示浮层的左侧与目标位置左边缘对齐,Trailing 表示浮层的右侧与目标位置右边缘对齐。 +/// 对 preferLayoutDirection 为 Left/Right 而言,Leading 表示浮层的顶端与目标位置顶边缘对齐,Trailing 表示浮层的底端与目标位置底边缘对齐。 +/// 如果预期的对齐方式无法被满足时,会根据 usesOppositeLayoutAlignmentIfNeeded 的值来决定备选方案。 +@property(nonatomic, assign) QMUIPopupContainerViewLayoutAlignment preferLayoutAlignment UI_APPEARANCE_SELECTOR; + +/// 表示 preferLayoutAlignment 在极端情况下无法满足调用方设置的值时,应该以什么方式作为备选。 +/// 若当前属性值为 YES,则表示用相反的对齐方式去尝试(例如 preferLayoutAlignment = QMUIPopupContainerViewLayoutAlignmentLeading 则在极端情况下会用 QMUIPopupContainerViewLayoutAlignmentTrailing 作为备选),若当前属性值为 NO 则表示保持对齐方向不变,让浮层的边缘紧贴着 safetyMarginsOfSuperview 即可。 +/// 默认为 YES。 +/// @warning 对 QMUIPopupContainerViewLayoutAlignmentCenter 无意义,因为 QMUIPopupContainerViewLayoutAlignmentCenter 没有所谓的相反概念。 +@property(nonatomic, assign) BOOL usesOppositeLayoutAlignmentIfNeeded UI_APPEARANCE_SELECTOR; + /// 最终布局时箭头距离目标边缘的距离,默认为5 @property(nonatomic, assign) CGFloat distanceBetweenSource UI_APPEARANCE_SELECTOR; -/// 最终布局时与父节点的边缘的临界点,默认为(10, 10, 10, 10) +/// 最终布局时与父节点的边缘的临界点,默认为(10, 10, 10, 10),注意这里的值不需要由业务考虑 safeAreaInsets,内部会自己叠加。 @property(nonatomic, assign) UIEdgeInsets safetyMarginsOfSuperview UI_APPEARANCE_SELECTOR; /// 允许用一个自定的 view 作为背景,会自动将其 mask 为圆角带箭头的造型,当同时使用 backgroundView 和 arrowImage 时,arrowImage 只作为遮罩使用(也即使用它的造型,不显示它的图片内容)。 @@ -147,7 +168,10 @@ typedef NS_ENUM(NSUInteger, QMUIPopupContainerViewLayoutDirection) { /// rect 需要处于 QMUIPopupContainerView 所在的坐标系内,例如如果 popup 使用 addSubview: 的方式添加到界面,则 sourceRect 应该是 superview 坐标系内的;如果 popup 使用 window 的方式展示,则 sourceRect 需要转换为 window 坐标系内。 @property(nonatomic, assign) CGRect sourceRect; -/// 立即刷新当前 popup 的布局,前提是 popup 已经被 show 过。 +/// 标记为需要更新布局,会在下一次 runloop 里统一调用 updateLayout。一般情况请用这个方法,避免直接用 updateLayout,从而获取更佳的性能。 +- (void)setNeedsUpdateLayout; + +/// 立即刷新当前 popup 的布局,前提是 popup.isShowing 为 YES。 - (void)updateLayout; - (void)showWithAnimated:(BOOL)animated; @@ -156,6 +180,22 @@ typedef NS_ENUM(NSUInteger, QMUIPopupContainerViewLayoutDirection) { - (void)hideWithAnimated:(BOOL)animated completion:(void (^ __nullable)(BOOL finished))completion; - (BOOL)isShowing; +/// 允许业务自定义显示动画,请在这个 block 里写自己的动画实现,并在恰当的时候调用参数里的 animation() 和 completion()。 +/// @param defaultAnimation 组件里默认的显示动画(默认实现是 scale+alpha),业务可自行决定是否要调用它 +/// @param completion 动画结束的回调,业务必须在自己动画结束时主动调用它 +/// @param isWindowMode 是否正在以 window 模式展示 +/// @param rootView popup 组件当前所在的容器,如果是 window 模式,则是内部自己创建的 window.rootViewController.view,若是其他模式则是业务的 view +/// @param popup 当前 popup 实例 +@property(nonatomic, copy) void (^showingAnimationBlock)(void (^defaultAnimation)(void), void (^completion)(BOOL finished), BOOL isWindowMode, UIView * _Nullable rootView, __kindof QMUIPopupContainerView *popup); + +/// 允许业务自定义隐藏动画,请在这个 block 里写自己的动画实现,并在恰当的时候调用参数里的 animation() 和 completion()。 +/// @param defaultAnimation 组件里默认的显示动画(默认实现是 scale+alpha),业务可自行决定是否要调用它 +/// @param completion 动画结束的回调,业务必须在自己动画结束时主动调用它 +/// @param isWindowMode 是否正在以 window 模式展示 +/// @param rootView popup 组件当前所在的容器,如果是 window 模式,则是内部自己创建的 window.rootViewController.view,若是其他模式则是业务的 view +/// @param popup 当前 popup 实例 +@property(nonatomic, copy) void (^hidingAnimationBlock)(void (^defaultAnimation)(void), void (^completion)(BOOL finished), BOOL isWindowMode, UIView * _Nullable rootView, __kindof QMUIPopupContainerView *popup); + /** * 即将显示时的回调 * 注:如果需要使用例如 didShowBlock 的时机,请使用 @showWithAnimated:completion: 的 completion 参数来实现。 diff --git a/QMUIKit/QMUIComponents/QMUIPopupContainerView.m b/QMUIKit/QMUIComponents/QMUIPopupContainerView.m index f85ca951..dd76738c 100644 --- a/QMUIKit/QMUIComponents/QMUIPopupContainerView.m +++ b/QMUIKit/QMUIComponents/QMUIPopupContainerView.m @@ -101,7 +101,7 @@ - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { } - (void)setBackgroundView:(UIView *)backgroundView { - if (_backgroundView && !backgroundView) { + if (_backgroundView && _backgroundView != backgroundView) { [_backgroundView removeFromSuperview]; } _backgroundView = backgroundView; @@ -110,6 +110,7 @@ - (void)setBackgroundView:(UIView *)backgroundView { // backgroundView 必须盖在 _backgroundLayer、_arrowImageView 上面,否则背景色、阴影、箭头图片都会盖在 backgroundView 上方,影响表现 [self sendSubviewToBack:_arrowImageView]; [self.layer qmui_sendSublayerToBack:_backgroundLayer]; + [self.layer qmui_sendSublayerToBack:_borderLayer]; if (!_backgroundViewMaskLayer) { _copiedBackgroundLayer = [CAShapeLayer layer]; [_copiedBackgroundLayer qmui_removeDefaultAnimations]; @@ -144,17 +145,17 @@ - (void)setMaskViewBackgroundColor:(UIColor *)maskViewBackgroundColor { - (void)setShadow:(NSShadow *)shadow { _shadow = shadow; - _backgroundLayer.qmui_shadow = shadow; + _borderLayer.qmui_shadow = shadow; } - (void)setBorderColor:(UIColor *)borderColor { _borderColor = borderColor; - _backgroundLayer.strokeColor = borderColor.CGColor; + _borderLayer.strokeColor = borderColor.CGColor; } - (void)setBorderWidth:(CGFloat)borderWidth { _borderWidth = borderWidth; - _backgroundLayer.lineWidth = _borderWidth; + _borderLayer.lineWidth = _borderWidth; } - (void)setCornerRadius:(CGFloat)cornerRadius { @@ -171,6 +172,20 @@ - (void)setHighlighted:(BOOL)highlighted { } } +- (void)setPreferLayoutAlignment:(QMUIPopupContainerViewLayoutAlignment)preferLayoutAlignment { + _preferLayoutAlignment = preferLayoutAlignment; + if (self.isShowing) { + [self setNeedsUpdateLayout]; + } +} + +- (void)setDistanceBetweenSource:(CGFloat)distanceBetweenSource { + _distanceBetweenSource = distanceBetweenSource; + if (self.isShowing) { + [self setNeedsUpdateLayout]; + } +} + - (CGSize)sizeThatFits:(CGSize)size { CGSize contentLimitSize = [self contentSizeInSize:size]; CGSize contentSize = CGSizeZero; @@ -188,90 +203,44 @@ - (void)layoutSubviews { BOOL isUsingArrowImage = !!self.arrowImage; CGAffineTransform arrowImageTransform = CGAffineTransformIdentity; CGPoint arrowImagePosition = CGPointZero; - CGSize arrowSize = self.arrowSizeAuto; - CGRect roundedRect = CGRectMake(self.borderWidth / 2.0 + (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionRight ? arrowSize.width : 0), - self.borderWidth / 2.0 + (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionBelow ? arrowSize.height : 0), - CGRectGetWidth(self.bounds) - self.borderWidth - self.arrowSpacingInHorizontal, - CGRectGetHeight(self.bounds) - self.borderWidth - self.arrowSpacingInVertical); - CGFloat cornerRadius = self.cornerRadius; - - CGPoint leftTopArcCenter = CGPointMake(CGRectGetMinX(roundedRect) + cornerRadius, CGRectGetMinY(roundedRect) + cornerRadius); - CGPoint leftBottomArcCenter = CGPointMake(leftTopArcCenter.x, CGRectGetMaxY(roundedRect) - cornerRadius); - CGPoint rightTopArcCenter = CGPointMake(CGRectGetMaxX(roundedRect) - cornerRadius, leftTopArcCenter.y); - CGPoint rightBottomArcCenter = CGPointMake(rightTopArcCenter.x, leftBottomArcCenter.y); - - // 从左上角逆时针绘制 - UIBezierPath *path = [UIBezierPath bezierPath]; - [path moveToPoint:CGPointMake(leftTopArcCenter.x, CGRectGetMinY(roundedRect))]; - [path addArcWithCenter:leftTopArcCenter radius:cornerRadius startAngle:M_PI * 1.5 endAngle:M_PI clockwise:NO]; - - if (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionRight) { - // 箭头向左 - if (isUsingArrowImage) { - arrowImageTransform = CGAffineTransformMakeRotation(AngleWithDegrees(90)); - arrowImagePosition = CGPointMake(arrowSize.width / 2, _arrowMinY + arrowSize.height / 2); - } else { - [path addLineToPoint:CGPointMake(CGRectGetMinX(roundedRect), _arrowMinY)]; - [path addLineToPoint:CGPointMake(CGRectGetMinX(roundedRect) - arrowSize.width, _arrowMinY + arrowSize.height / 2)]; - [path addLineToPoint:CGPointMake(CGRectGetMinX(roundedRect), _arrowMinY + arrowSize.height)]; - } - } - - [path addLineToPoint:CGPointMake(CGRectGetMinX(roundedRect), leftBottomArcCenter.y)]; - [path addArcWithCenter:leftBottomArcCenter radius:cornerRadius startAngle:M_PI endAngle:M_PI * 0.5 clockwise:NO]; - - if (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionAbove) { - // 箭头向下 - if (isUsingArrowImage) { - arrowImagePosition = CGPointMake(_arrowMinX + arrowSize.width / 2, CGRectGetHeight(self.bounds) - arrowSize.height / 2); - } else { - [path addLineToPoint:CGPointMake(_arrowMinX, CGRectGetMaxY(roundedRect))]; - [path addLineToPoint:CGPointMake(_arrowMinX + arrowSize.width / 2, CGRectGetMaxY(roundedRect) + arrowSize.height)]; - [path addLineToPoint:CGPointMake(_arrowMinX + arrowSize.width, CGRectGetMaxY(roundedRect))]; - } - } - - [path addLineToPoint:CGPointMake(rightBottomArcCenter.x, CGRectGetMaxY(roundedRect))]; - [path addArcWithCenter:rightBottomArcCenter radius:cornerRadius startAngle:M_PI * 0.5 endAngle:0.0 clockwise:NO]; - - if (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionLeft) { - // 箭头向右 - if (isUsingArrowImage) { - arrowImageTransform = CGAffineTransformMakeRotation(AngleWithDegrees(-90)); - arrowImagePosition = CGPointMake(CGRectGetWidth(self.bounds) - arrowSize.width / 2, _arrowMinY + arrowSize.height / 2); - } else { - [path addLineToPoint:CGPointMake(CGRectGetMaxX(roundedRect), _arrowMinY + arrowSize.height)]; - [path addLineToPoint:CGPointMake(CGRectGetMaxX(roundedRect) + arrowSize.width, _arrowMinY + arrowSize.height / 2)]; - [path addLineToPoint:CGPointMake(CGRectGetMaxX(roundedRect), _arrowMinY)]; + if (isUsingArrowImage) { + switch (self.currentLayoutDirection) { + case QMUIPopupContainerViewLayoutDirectionRight: { + arrowImageTransform = CGAffineTransformMakeRotation(AngleWithDegrees(90)); + arrowImagePosition = CGPointMake(arrowSize.width / 2, _arrowMinY + arrowSize.height / 2); + } + break; + case QMUIPopupContainerViewLayoutDirectionAbove: { + arrowImagePosition = CGPointMake(_arrowMinX + arrowSize.width / 2, CGRectGetHeight(self.bounds) - arrowSize.height / 2); + } + break; + case QMUIPopupContainerViewLayoutDirectionLeft: { + arrowImageTransform = CGAffineTransformMakeRotation(AngleWithDegrees(-90)); + arrowImagePosition = CGPointMake(CGRectGetWidth(self.bounds) - arrowSize.width / 2, _arrowMinY + arrowSize.height / 2); + } + break; + case QMUIPopupContainerViewLayoutDirectionBelow: { + arrowImageTransform = CGAffineTransformMakeRotation(AngleWithDegrees(-180)); + arrowImagePosition = CGPointMake(_arrowMinX + arrowSize.width / 2, arrowSize.height / 2); + } + break; + default: + break; } + _arrowImageView.transform = arrowImageTransform; + _arrowImageView.center = arrowImagePosition; } - [path addLineToPoint:CGPointMake(CGRectGetMaxX(roundedRect), rightTopArcCenter.y)]; - [path addArcWithCenter:rightTopArcCenter radius:cornerRadius startAngle:0.0 endAngle:M_PI * 1.5 clockwise:NO]; + UIBezierPath *borderPath = [self generatePathForBorder:YES]; + _borderLayer.path = borderPath.CGPath; + _borderLayer.shadowPath = borderPath.CGPath; + _borderLayer.frame = self.bounds; - if (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionBelow) { - // 箭头向上 - if (isUsingArrowImage) { - arrowImageTransform = CGAffineTransformMakeRotation(AngleWithDegrees(-180)); - arrowImagePosition = CGPointMake(_arrowMinX + arrowSize.width / 2, arrowSize.height / 2); - } else { - [path addLineToPoint:CGPointMake(_arrowMinX + arrowSize.width, CGRectGetMinY(roundedRect))]; - [path addLineToPoint:CGPointMake(_arrowMinX + arrowSize.width / 2, CGRectGetMinY(roundedRect) - arrowSize.height)]; - [path addLineToPoint:CGPointMake(_arrowMinX, CGRectGetMinY(roundedRect))]; - } - } - [path closePath]; - - _backgroundLayer.path = path.CGPath; - _backgroundLayer.shadowPath = path.CGPath; + UIBezierPath *backgroundPath = [self generatePathForBorder:NO]; + _backgroundLayer.path = backgroundPath.CGPath; _backgroundLayer.frame = self.bounds; - if (isUsingArrowImage) { - _arrowImageView.transform = arrowImageTransform; - _arrowImageView.center = arrowImagePosition; - } - if (self.backgroundView) { self.backgroundView.frame = self.bounds; _backgroundViewMaskLayer.frame = self.bounds; @@ -291,10 +260,12 @@ - (void)layoutSubviews { - (void)layoutDefaultSubviews { self.contentView.frame = CGRectMake( - self.borderWidth + self.contentEdgeInsets.left + (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionRight ? self.arrowSizeAuto.width : 0), - self.borderWidth + self.contentEdgeInsets.top + (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionBelow ? self.arrowSizeAuto.height : 0), - CGRectGetWidth(self.bounds) - self.borderWidth * 2 - UIEdgeInsetsGetHorizontalValue(self.contentEdgeInsets) - self.arrowSpacingInHorizontal, - CGRectGetHeight(self.bounds) - self.borderWidth * 2 - UIEdgeInsetsGetVerticalValue(self.contentEdgeInsets) - self.arrowSpacingInVertical); + self.contentEdgeInsets.left + (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionRight ? self.arrowSizeAuto.width : self.borderWidth), + self.contentEdgeInsets.top + (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionBelow ? self.arrowSizeAuto.height : self.borderWidth), + CGRectGetWidth(self.bounds) - self.borderWidth - UIEdgeInsetsGetHorizontalValue(self.contentEdgeInsets) - self.arrowSpacingInHorizontal - (self.arrowSpacingInHorizontal > 0 ? 0 : self.borderWidth), + CGRectGetHeight(self.bounds) - self.borderWidth - UIEdgeInsetsGetVerticalValue(self.contentEdgeInsets) - self.arrowSpacingInVertical - (self.arrowSpacingInVertical > 0 ? 0 : self.borderWidth)); + // 让点击响应区域与肉眼看到的圆角矩形保持一致,否则 contentView 内部的 subviews 就算要扩大点击区域也会受限制 + self.contentView.qmui_outsideEdge = UIEdgeInsetsMake(MIN(0, -self.contentEdgeInsets.top), MIN(0, -self.contentEdgeInsets.left), MIN(0, -self.contentEdgeInsets.bottom), MIN(0, -self.contentEdgeInsets.right)); // contentView的圆角取一个比整个path的圆角小的最大值(极限情况下如果self.contentEdgeInsets.left比self.cornerRadius还大,那就意味着contentView不需要圆角了) // 这么做是为了尽量去掉contentView对内容不必要的裁剪,以免有些东西被裁剪了看不到 CGFloat contentViewCornerRadius = fabs(MIN(CGRectGetMinX(self.contentView.frame) - self.cornerRadius, 0)); @@ -310,12 +281,12 @@ - (void)layoutDefaultSubviews { } else if (self.contentMode == UIViewContentModeBottom) { _imageView.frame = CGRectSetY(_imageView.frame, CGRectGetHeight(self.contentView.bounds) - self.imageEdgeInsets.bottom - CGRectGetHeight(_imageView.frame)); } else { - _imageView.frame = CGRectSetY(_imageView.frame, self.imageEdgeInsets.top + CGFloatGetCenter(CGRectGetHeight(self.contentView.bounds), CGRectGetHeight(_imageView.frame))); + _imageView.frame = CGRectSetY(_imageView.frame, self.imageEdgeInsets.top + CGFloatGetCenter(CGRectGetHeight(self.contentView.bounds) - UIEdgeInsetsGetVerticalValue(self.imageEdgeInsets), CGRectGetHeight(_imageView.frame))); } } if (isTextLabelShowing) { CGFloat textLabelMinX = (isImageViewShowing ? ceil(CGRectGetMaxX(_imageView.frame) + self.imageEdgeInsets.right) : 0) + self.textEdgeInsets.left; - CGSize textLabelLimitSize = CGSizeMake(ceil(CGRectGetWidth(self.contentView.bounds) - textLabelMinX), ceil(CGRectGetHeight(self.contentView.bounds) - self.textEdgeInsets.top - self.textEdgeInsets.bottom)); + CGSize textLabelLimitSize = CGSizeMake(ceil(CGRectGetWidth(self.contentView.bounds) - textLabelMinX - self.textEdgeInsets.right), ceil(CGRectGetHeight(self.contentView.bounds) - UIEdgeInsetsGetVerticalValue(self.textEdgeInsets))); CGSize textLabelSize = [_textLabel sizeThatFits:textLabelLimitSize]; _textLabel.frame = CGRectMake(textLabelMinX, 0, textLabelLimitSize.width, ceil(textLabelSize.height)); if (self.contentMode == UIViewContentModeTop) { @@ -323,11 +294,79 @@ - (void)layoutDefaultSubviews { } else if (self.contentMode == UIViewContentModeBottom) { _textLabel.frame = CGRectSetY(_textLabel.frame, CGRectGetHeight(self.contentView.bounds) - self.textEdgeInsets.bottom - CGRectGetHeight(_textLabel.frame)); } else { - _textLabel.frame = CGRectSetY(_textLabel.frame, self.textEdgeInsets.top + CGFloatGetCenter(CGRectGetHeight(self.contentView.bounds), CGRectGetHeight(_textLabel.frame))); + _textLabel.frame = CGRectSetY(_textLabel.frame, self.textEdgeInsets.top + CGFloatGetCenter(CGRectGetHeight(self.contentView.bounds) - UIEdgeInsetsGetVerticalValue(self.textEdgeInsets), CGRectGetHeight(_textLabel.frame))); } } } +- (UIBezierPath *)generatePathForBorder:(BOOL)forBorder { + BOOL isUsingArrowImage = !!self.arrowImage; + CGSize arrowSize = self.arrowSizeAuto; + CGFloat offset = forBorder ? self.borderWidth / 2.0 : self.borderWidth; + CGRect roundedRect = CGRectMake(offset + (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionRight ? arrowSize.width - self.borderWidth : 0), + offset + (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionBelow ? arrowSize.height - self.borderWidth : 0), + CGRectGetWidth(self.bounds) - offset * 2 - self.arrowSpacingInHorizontal + (self.arrowSpacingInHorizontal > 0 ? self.borderWidth : 0), + CGRectGetHeight(self.bounds) - offset * 2 - self.arrowSpacingInVertical + (self.arrowSpacingInVertical > 0 ? self.borderWidth : 0)); + CGFloat cornerRadius = forBorder ? self.cornerRadius : (self.cornerRadius - self.borderWidth / 2.0); + + CGPoint leftTopArcCenter = CGPointMake(CGRectGetMinX(roundedRect) + cornerRadius, CGRectGetMinY(roundedRect) + cornerRadius); + CGPoint leftBottomArcCenter = CGPointMake(leftTopArcCenter.x, CGRectGetMaxY(roundedRect) - cornerRadius); + CGPoint rightTopArcCenter = CGPointMake(CGRectGetMaxX(roundedRect) - cornerRadius, leftTopArcCenter.y); + CGPoint rightBottomArcCenter = CGPointMake(rightTopArcCenter.x, leftBottomArcCenter.y); + + // 从左上角逆时针绘制 + UIBezierPath *path = [UIBezierPath bezierPath]; + [path moveToPoint:CGPointMake(leftTopArcCenter.x, CGRectGetMinY(roundedRect))]; + [path addArcWithCenter:leftTopArcCenter radius:cornerRadius startAngle:M_PI * 1.5 endAngle:M_PI clockwise:NO]; + + if (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionRight) { + // 箭头向左 + if (!isUsingArrowImage) { + [path addLineToPoint:CGPointMake(CGRectGetMinX(roundedRect), _arrowMinY)]; + [path addLineToPoint:CGPointMake(CGRectGetMinX(roundedRect) - arrowSize.width, _arrowMinY + arrowSize.height / 2)]; + [path addLineToPoint:CGPointMake(CGRectGetMinX(roundedRect), _arrowMinY + arrowSize.height)]; + } + } + + [path addLineToPoint:CGPointMake(CGRectGetMinX(roundedRect), leftBottomArcCenter.y)]; + [path addArcWithCenter:leftBottomArcCenter radius:cornerRadius startAngle:M_PI endAngle:M_PI * 0.5 clockwise:NO]; + + if (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionAbove) { + // 箭头向下 + if (!isUsingArrowImage) { + [path addLineToPoint:CGPointMake(_arrowMinX, CGRectGetMaxY(roundedRect))]; + [path addLineToPoint:CGPointMake(_arrowMinX + arrowSize.width / 2, CGRectGetMaxY(roundedRect) + arrowSize.height)]; + [path addLineToPoint:CGPointMake(_arrowMinX + arrowSize.width, CGRectGetMaxY(roundedRect))]; + } + } + + [path addLineToPoint:CGPointMake(rightBottomArcCenter.x, CGRectGetMaxY(roundedRect))]; + [path addArcWithCenter:rightBottomArcCenter radius:cornerRadius startAngle:M_PI * 0.5 endAngle:0.0 clockwise:NO]; + + if (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionLeft) { + // 箭头向右 + if (!isUsingArrowImage) { + [path addLineToPoint:CGPointMake(CGRectGetMaxX(roundedRect), _arrowMinY + arrowSize.height)]; + [path addLineToPoint:CGPointMake(CGRectGetMaxX(roundedRect) + arrowSize.width, _arrowMinY + arrowSize.height / 2)]; + [path addLineToPoint:CGPointMake(CGRectGetMaxX(roundedRect), _arrowMinY)]; + } + } + + [path addLineToPoint:CGPointMake(CGRectGetMaxX(roundedRect), rightTopArcCenter.y)]; + [path addArcWithCenter:rightTopArcCenter radius:cornerRadius startAngle:0.0 endAngle:M_PI * 1.5 clockwise:NO]; + + if (self.currentLayoutDirection == QMUIPopupContainerViewLayoutDirectionBelow) { + // 箭头向上 + if (!isUsingArrowImage) { + [path addLineToPoint:CGPointMake(_arrowMinX + arrowSize.width, CGRectGetMinY(roundedRect))]; + [path addLineToPoint:CGPointMake(_arrowMinX + arrowSize.width / 2, CGRectGetMinY(roundedRect) - arrowSize.height)]; + [path addLineToPoint:CGPointMake(_arrowMinX, CGRectGetMinY(roundedRect))]; + } + } + [path closePath]; + return path; +} + - (void)setSourceBarItem:(__kindof UIBarItem *)sourceBarItem { if (_sourceBarItem && _sourceBarItem != sourceBarItem) { _sourceBarItem.qmui_viewLayoutDidChangeBlock = nil; @@ -374,6 +413,16 @@ - (void)setSourceRect:(CGRect)sourceRect { } } +- (void)setNeedsUpdateLayout { + if (_shouldInvalidateLayout) return; + _shouldInvalidateLayout = YES; + dispatch_async(dispatch_get_main_queue(), ^{ + if (self->_shouldInvalidateLayout) { + [self updateLayout]; + } + }); +} + - (void)updateLayout { // call setter to layout immediately if (self.sourceBarItem) { @@ -383,6 +432,7 @@ - (void)updateLayout { } else { self.sourceRect = self.sourceRect; } + _shouldInvalidateLayout = NO; } // 参数 targetRect 在 window 模式下是 window 的坐标系内的,如果是 subview 模式下则是 superview 坐标系内的 @@ -425,14 +475,36 @@ - (void)layoutWithTargetRect:(CGRect)targetRect { if (self.isVerticalLayoutDirection) { // 保护tips最往左只能到达self.safetyMarginsAvoidSafeAreaInsets.left - CGFloat a = CGRectGetMidX(targetRect) - tipSize.width / 2; + CGFloat a = 0; + switch (self.preferLayoutAlignment) { + case QMUIPopupContainerViewLayoutAlignmentLeading: + a = CGRectGetMinX(targetRect); + break; + case QMUIPopupContainerViewLayoutAlignmentTrailing: + a = CGRectGetMaxX(targetRect) - tipSize.width; + break; + default: + a = CGRectGetMidX(targetRect) - tipSize.width / 2; + break; + } tipMinX = MAX(CGRectGetMinX(containerRect) + self.safetyMarginsAvoidSafeAreaInsets.left, a); CGFloat tipMaxX = tipMinX + tipSize.width; if (tipMaxX + self.safetyMarginsAvoidSafeAreaInsets.right > CGRectGetMaxX(containerRect)) { // 右边超出了 // 先尝试把右边超出的部分往左边挪,看是否会令左边到达临界点 - CGFloat distanceCanMoveToLeft = tipMaxX - (CGRectGetMaxX(containerRect) - self.safetyMarginsAvoidSafeAreaInsets.right); + CGFloat distanceCanMoveToLeft = 0; + if (self.preferLayoutAlignment == QMUIPopupContainerViewLayoutAlignmentLeading && self.usesOppositeLayoutAlignmentIfNeeded) { + distanceCanMoveToLeft = tipMaxX - MIN(CGRectGetMaxX(targetRect), CGRectGetMaxX(containerRect) - self.safetyMarginsOfSuperview.right);// targetRect 可能溢出屏幕外,需要保护 + if (tipMinX - distanceCanMoveToLeft >= CGRectGetMinX(containerRect) + self.safetyMarginsAvoidSafeAreaInsets.left) { + // 可以往左边挪,走下面的统一逻辑 + } else { + // 不可以往左边挪,那就算了按原始 alignment 来对待 + distanceCanMoveToLeft = tipMaxX - (CGRectGetMaxX(containerRect) - self.safetyMarginsAvoidSafeAreaInsets.right); + } + } else { + distanceCanMoveToLeft = tipMaxX - (CGRectGetMaxX(containerRect) - self.safetyMarginsAvoidSafeAreaInsets.right); + } if (tipMinX - distanceCanMoveToLeft >= CGRectGetMinX(containerRect) + self.safetyMarginsAvoidSafeAreaInsets.left) { // 可以往左边挪 tipMinX -= distanceCanMoveToLeft; @@ -488,7 +560,9 @@ - (void)layoutWithTargetRect:(CGRect)targetRect { // 调整浮层里的箭头的位置 CGPoint targetRectCenter = CGPointGetCenterWithRect(targetRect); CGFloat selfMidX = targetRectCenter.x - CGRectGetMinX(self.frame); - _arrowMinX = selfMidX - self.arrowSizeAuto.width / 2; + CGFloat arrowMinimumMinX = self.cornerRadius; + CGFloat arrowMaximumMinX = CGRectGetWidth(self.bounds) - self.cornerRadius - self.arrowSize.width; + _arrowMinX = MIN(arrowMaximumMinX, MAX(arrowMinimumMinX, selfMidX - self.arrowSizeAuto.width / 2)); } else { // 保护tips最往上只能到达self.safetyMarginsAvoidSafeAreaInsets.top CGFloat a = CGRectGetMidY(targetRect) - tipSize.height / 2; @@ -639,28 +713,43 @@ - (void)showWithAnimated:(BOOL)animated completion:(void (^)(BOOL))completion { if (animated) { if (isShowingByWindowMode) { - self.popupWindow.alpha = 0; + self.popupWindow.rootViewController.view.alpha = 0;// 请操作 vc.view.alpha 而不是 window.alpha,如果是后者,会导致 popup 显示出来前有一小段时间无法屏蔽界面的触摸事件,从而引发一些状态混乱问题 } else { self.alpha = 0; } self.layer.transform = CATransform3DMakeScale(0.98, 0.98, 1); - [UIView animateWithDuration:0.4 delay:0 usingSpringWithDamping:0.3 initialSpringVelocity:12 options:UIViewAnimationOptionCurveLinear animations:^{ - self.layer.transform = CATransform3DMakeScale(1, 1, 1); - } completion:^(BOOL finished) { - if (completion) { - completion(finished); - } - }]; - [UIView animateWithDuration:0.2 delay:0 options:UIViewAnimationOptionCurveLinear animations:^{ - if (isShowingByWindowMode) { - self.popupWindow.alpha = 1; - } else { - self.alpha = 1; - } - } completion:nil]; + if (self.showingAnimationBlock) { + self.showingAnimationBlock(^{ + self.layer.transform = CATransform3DMakeScale(1, 1, 1); + if (isShowingByWindowMode) { + self.popupWindow.rootViewController.view.alpha = 1; + } else { + self.alpha = 1; + } + }, ^(BOOL finished) { + if (completion) { + completion(finished); + } + }, isShowingByWindowMode, self.popupWindow.rootViewController.view, self); + } else { + [UIView animateWithDuration:0.4 delay:0 usingSpringWithDamping:0.3 initialSpringVelocity:12 options:UIViewAnimationOptionCurveLinear animations:^{ + self.layer.transform = CATransform3DMakeScale(1, 1, 1); + } completion:^(BOOL finished) { + if (completion) { + completion(finished); + } + }]; + [UIView animateWithDuration:0.2 delay:0 options:UIViewAnimationOptionCurveLinear animations:^{ + if (isShowingByWindowMode) { + self.popupWindow.rootViewController.view.alpha = 1; + } else { + self.alpha = 1; + } + } completion:nil]; + } } else { if (isShowingByWindowMode) { - self.popupWindow.alpha = 1; + self.popupWindow.rootViewController.view.alpha = 1; } else { self.alpha = 1; } @@ -682,15 +771,25 @@ - (void)hideWithAnimated:(BOOL)animated completion:(void (^)(BOOL))completion { BOOL isShowingByWindowMode = !!self.popupWindow; if (animated) { - [UIView animateWithDuration:0.2 delay:0 options:UIViewAnimationOptionCurveLinear animations:^{ + void (^a)(void) = ^void(void) { if (isShowingByWindowMode) { - self.popupWindow.alpha = 0; + self.popupWindow.rootViewController.view.alpha = 0; } else { self.alpha = 0; } - } completion:^(BOOL finished) { + }; + void (^c)(BOOL finished) = ^void(BOOL finished) { [self hideCompletionWithWindowMode:isShowingByWindowMode completion:completion]; - }]; + }; + if (self.hidingAnimationBlock) { + self.hidingAnimationBlock(a, c, isShowingByWindowMode, self.popupWindow.rootViewController.view, self); + } else { + [UIView animateWithDuration:0.2 delay:0 options:UIViewAnimationOptionCurveLinear animations:^{ + a(); + } completion:^(BOOL finished) { + c(finished); + }]; + } } else { [self hideCompletionWithWindowMode:isShowingByWindowMode completion:completion]; } @@ -755,17 +854,17 @@ - (void)initPopupContainerViewWindowIfNeeded { /// 根据一个给定的大小(包含箭头,不含 distanceBetweenSource ),计算出符合这个大小的内容大小(去掉箭头和白色内部的 contentEdgeInsets 后) - (CGSize)contentSizeInSize:(CGSize)size { - CGSize contentSize = CGSizeMake(size.width - UIEdgeInsetsGetHorizontalValue(self.contentEdgeInsets) - self.borderWidth * 2 - self.arrowSpacingInHorizontal, size.height - UIEdgeInsetsGetVerticalValue(self.contentEdgeInsets) - self.borderWidth * 2 - self.arrowSpacingInVertical); + CGSize contentSize = CGSizeMake(size.width - UIEdgeInsetsGetHorizontalValue(self.contentEdgeInsets) - self.borderWidth - self.arrowSpacingInHorizontal - (self.arrowSpacingInHorizontal > 0 ? 0 : self.borderWidth), size.height - UIEdgeInsetsGetVerticalValue(self.contentEdgeInsets) - self.borderWidth - self.arrowSpacingInVertical - (self.arrowSpacingInVertical > 0 ? 0 : self.borderWidth)); return contentSize; } /// 根据内容大小和外部限制的大小,计算出合适的self size(包含箭头) - (CGSize)sizeWithContentSize:(CGSize)contentSize sizeThatFits:(CGSize)sizeThatFits { - CGFloat resultWidth = contentSize.width + UIEdgeInsetsGetHorizontalValue(self.contentEdgeInsets) + self.borderWidth * 2 + self.arrowSpacingInHorizontal; + CGFloat resultWidth = contentSize.width + UIEdgeInsetsGetHorizontalValue(self.contentEdgeInsets) + self.borderWidth + self.arrowSpacingInHorizontal + (self.arrowSpacingInHorizontal > 0 ? 0 : self.borderWidth);// 当存在箭头时,箭头会叠在 border 上,所以这里只加一条边 resultWidth = MAX(MIN(resultWidth, self.maximumWidth), self.minimumWidth);// 宽度必须在最小值和最大值之间 resultWidth = flat(resultWidth); - CGFloat resultHeight = contentSize.height + UIEdgeInsetsGetVerticalValue(self.contentEdgeInsets) + self.borderWidth * 2 + self.arrowSpacingInVertical; + CGFloat resultHeight = contentSize.height + UIEdgeInsetsGetVerticalValue(self.contentEdgeInsets) + self.borderWidth + self.arrowSpacingInVertical + (self.arrowSpacingInVertical > 0 ? 0 : self.borderWidth);// 当存在箭头时,箭头会叠在 border 上,所以这里只加一条边 resultHeight = MAX(MIN(resultHeight, self.maximumHeight), self.minimumHeight); resultHeight = flat(resultHeight); @@ -802,6 +901,9 @@ - (void)setArrowImage:(UIImage *)arrowImage { - (void)setArrowSize:(CGSize)arrowSize { if (!self.arrowImage) { _arrowSize = arrowSize; + if (self.isShowing) { + [self setNeedsUpdateLayout]; + } } } @@ -818,6 +920,34 @@ - (CGFloat)arrowSpacingInVertical { return self.isVerticalLayoutDirection ? self.arrowSizeAuto.height : 0; } +- (void)setMinimumWidth:(CGFloat)minimumWidth { + _minimumWidth = minimumWidth; + if (self.isShowing) { + [self setNeedsUpdateLayout]; + } +} + +- (void)setMaximumWidth:(CGFloat)maximumWidth { + _maximumWidth = maximumWidth; + if (self.isShowing) { + [self setNeedsUpdateLayout]; + } +} + +- (void)setMinimumHeight:(CGFloat)minimumHeight { + _minimumHeight = minimumHeight; + if (self.isShowing) { + [self setNeedsUpdateLayout]; + } +} + +- (void)setMaximumHeight:(CGFloat)maximumHeight { + _maximumHeight = maximumHeight; + if (self.isShowing) { + [self setNeedsUpdateLayout]; + } +} + - (UIEdgeInsets)safetyMarginsAvoidSafeAreaInsets { UIEdgeInsets result = self.safetyMarginsOfSuperview; if (self.isHorizontalLayoutDirection) { @@ -835,6 +965,11 @@ - (UIEdgeInsets)safetyMarginsAvoidSafeAreaInsets { @implementation QMUIPopupContainerView (UISubclassingHooks) - (void)didInitialize { + _borderLayer = [CAShapeLayer layer]; + [_borderLayer qmui_removeDefaultAnimations]; + _borderLayer.fillColor = UIColor.clearColor.CGColor; + [self.layer addSublayer:_borderLayer]; + _backgroundLayer = [CAShapeLayer layer]; [_backgroundLayer qmui_removeDefaultAnimations]; [self.layer addSublayer:_backgroundLayer]; @@ -859,16 +994,16 @@ - (CGSize)sizeThatFitsInContentView:(CGSize)size { BOOL isImageViewShowing = [self isSubviewShowing:_imageView]; if (isImageViewShowing) { CGSize imageViewSize = [_imageView sizeThatFits:size]; - resultSize.width += ceil(imageViewSize.width) + self.imageEdgeInsets.left; - resultSize.height += ceil(imageViewSize.height) + self.imageEdgeInsets.top; + resultSize.width += ceil(imageViewSize.width) + UIEdgeInsetsGetHorizontalValue(self.imageEdgeInsets); + resultSize.height += ceil(imageViewSize.height) + UIEdgeInsetsGetVerticalValue(self.imageEdgeInsets); } BOOL isTextLabelShowing = [self isSubviewShowing:_textLabel]; if (isTextLabelShowing) { - CGSize textLabelLimitSize = CGSizeMake(size.width - resultSize.width - self.imageEdgeInsets.right, size.height); + CGSize textLabelLimitSize = CGSizeMake(size.width - resultSize.width - UIEdgeInsetsGetHorizontalValue(self.textEdgeInsets), size.height); CGSize textLabelSize = [_textLabel sizeThatFits:textLabelLimitSize]; - resultSize.width += (isImageViewShowing ? self.imageEdgeInsets.right : 0) + ceil(textLabelSize.width) + self.textEdgeInsets.left; - resultSize.height = MAX(resultSize.height, ceil(textLabelSize.height) + self.textEdgeInsets.top); + resultSize.width += ceil(textLabelSize.width) + UIEdgeInsetsGetHorizontalValue(self.textEdgeInsets); + resultSize.height = MAX(resultSize.height, ceil(textLabelSize.height) + UIEdgeInsetsGetVerticalValue(self.textEdgeInsets)); } return resultSize; } @@ -893,6 +1028,7 @@ + (void)setDefaultAppearance { appearance.maximumHeight = CGFLOAT_MAX; appearance.minimumHeight = 0; appearance.preferLayoutDirection = QMUIPopupContainerViewLayoutDirectionAbove; + appearance.usesOppositeLayoutAlignmentIfNeeded = YES; appearance.distanceBetweenSource = 5; appearance.safetyMarginsOfSuperview = UIEdgeInsetsMake(10, 10, 10, 10); appearance.backgroundColor = UIColorWhite;// 如果先设置了 UIView.appearance.backgroundColor,再使用最传统的 method_exchangeImplementations 交换 UIView.setBackgroundColor 方法,则会 crash。QMUI 这里是在 +initialize 时设置的,业务如果要 hook -[UIView setBackgroundColor:] 则需要比 +initialize 更早才行 diff --git a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuBaseItem.m b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuBaseItem.m deleted file mode 100644 index 6218e86b..00000000 --- a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuBaseItem.m +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Tencent is pleased to support the open source community by making QMUI_iOS available. - * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. - * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - * http://opensource.org/licenses/MIT - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - */ - -// -// QMUIPopupMenuBaseItem.m -// QMUIKit -// -// Created by QMUI Team on 2018/8/21. -// - -#import "QMUIPopupMenuBaseItem.h" - -@implementation QMUIPopupMenuBaseItem - -@synthesize title = _title; -@synthesize height = _height; -@synthesize handler = _handler; -@synthesize menuView = _menuView; - -- (instancetype)init { - if (self = [super init]) { - self.height = -1; - } - return self; -} - -- (void)updateAppearance { - -} - -@end diff --git a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuButtonItem.h b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuButtonItem.h deleted file mode 100644 index c08b492a..00000000 --- a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuButtonItem.h +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Tencent is pleased to support the open source community by making QMUI_iOS available. - * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. - * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - * http://opensource.org/licenses/MIT - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - */ - -// -// QMUIPopupMenuButtonItem.h -// QMUIKit -// -// Created by QMUI Team on 2018/8/21. -// - -#import "QMUIPopupMenuBaseItem.h" - -NS_ASSUME_NONNULL_BEGIN - -@class QMUIButton; - -/** - * 配合 QMUIPopupMenuView 使用,用于可点击的菜单项。 - * 支持显示图片和标题,以及点击事件的回调。 - * 可在 QMUIPopupMenuView 里统一修改菜单项的样式,如果某个菜单项需要特殊调整,可通过 QMUIPopupMenuButtonItem.button 拿到 view 并进行调整。 - */ -@interface QMUIPopupMenuButtonItem : QMUIPopupMenuBaseItem - -/// item 里的图片,默认在左边,也可通过 item.button.imagePosition 修改图片的位置 -@property(nonatomic, strong, nullable) UIImage *image; - -/// 每个 item 都通过一个 button 来显示内容,可直接修改 button 的相关属性达到自定义的效果,button 的 tintColor 为 nil,因此可以自动响应 QMUIPopupMenuView 的 tintColor 变化。 -@property(nonatomic, strong, readonly, nonnull) QMUIButton *button; - -/// item 被点击时的背景色,默认为 TableViewCellSelectedBackgroundColor,与列表的 cell 点击背景色一致。 -@property(nonatomic, strong, nullable) UIColor *highlightedBackgroundColor UI_APPEARANCE_SELECTOR; - -/// item 里图片和文字之间的间距,默认为 6,只有同时存在图片和文字时这个属性才会生效。 -@property(nonatomic, assign) CGFloat imageMarginRight UI_APPEARANCE_SELECTOR; - -/** - 推荐的初始化方法 - - @param image item 的图片 - @param title item 的文字 - @param handler item 点击时的事件回调,需要在这里自行隐藏 aMenuView - @return item - */ -+ (instancetype)itemWithImage:(nullable UIImage *)image title:(nullable NSString *)title handler:(nullable void (^)(__kindof QMUIPopupMenuButtonItem *aItem))handler; - -@end - -NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuButtonItem.m b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuButtonItem.m deleted file mode 100644 index 35c4baa8..00000000 --- a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuButtonItem.m +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Tencent is pleased to support the open source community by making QMUI_iOS available. - * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. - * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - * http://opensource.org/licenses/MIT - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - */ - -// -// QMUIPopupMenuButtonItem.m -// QMUIKit -// -// Created by QMUI Team on 2018/8/21. -// - -#import "QMUIPopupMenuButtonItem.h" -#import "QMUIButton.h" -#import "UIControl+QMUI.h" -#import "QMUIPopupMenuView.h" -#import "QMUICore.h" - -@interface QMUIPopupMenuButtonItem (UIAppearance) - -- (void)updateAppearanceForMenuButtonItem; -@end - -@implementation QMUIPopupMenuButtonItem - -+ (instancetype)itemWithImage:(UIImage *)image title:(NSString *)title handler:(nullable void (^)(__kindof QMUIPopupMenuButtonItem *))handler { - QMUIPopupMenuButtonItem *item = [[self alloc] init]; - item.image = image; - item.title = title; - item.handler = handler; - return item; -} - -- (instancetype)init { - if (self = [super init]) { - self.height = -1; - - _button = [[QMUIButton alloc] init]; - self.button.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeft; - self.button.tintColor = nil; - self.button.qmui_automaticallyAdjustTouchHighlightedInScrollView = YES; - [self.button addTarget:self action:@selector(handleButtonEvent:) forControlEvents:UIControlEventTouchUpInside]; - [self addSubview:self.button]; - - [self updateAppearanceForMenuButtonItem]; - } - return self; -} - -- (CGSize)sizeThatFits:(CGSize)size { - return [self.button sizeThatFits:size]; -} - -- (void)layoutSubviews { - [super layoutSubviews]; - self.button.frame = self.bounds; -} - -- (void)setTitle:(NSString *)title { - [super setTitle:title]; - [self.button setTitle:title forState:UIControlStateNormal]; -} - -- (void)setImage:(UIImage *)image { - _image = image; - [self.button setImage:image forState:UIControlStateNormal]; - [self updateButtonImageEdgeInsets]; -} - -- (void)setImageMarginRight:(CGFloat)imageMarginRight { - _imageMarginRight = imageMarginRight; - [self updateButtonImageEdgeInsets]; -} - -- (void)updateButtonImageEdgeInsets { - if (self.button.currentImage) { - self.button.imageEdgeInsets = UIEdgeInsetsSetRight(self.button.imageEdgeInsets, self.imageMarginRight); - } -} - -- (void)setHighlightedBackgroundColor:(UIColor *)highlightedBackgroundColor { - _highlightedBackgroundColor = highlightedBackgroundColor; - self.button.highlightedBackgroundColor = highlightedBackgroundColor; -} - -- (void)handleButtonEvent:(id)sender { - if (self.menuView.willHandleButtonItemEventBlock) { - BOOL found = NO; - for (NSInteger section = 0, sectionCount = self.menuView.itemSections.count; section < sectionCount; section ++) { - NSArray *items = self.menuView.itemSections[section]; - for (NSInteger row = 0, rowCount = items.count; row < rowCount; row ++) { - QMUIPopupMenuBaseItem *item = items[row]; - if (item == self) { - self.menuView.willHandleButtonItemEventBlock(self.menuView, self, section, row); - found = YES; - break; - } - } - if (found) { - break; - } - } - } - if (self.handler) { - self.handler(self); - } -} - -- (void)updateAppearance { - self.button.titleLabel.font = self.menuView.itemTitleFont; - [self.button setTitleColor:self.menuView.itemTitleColor forState:UIControlStateNormal]; - UIEdgeInsets contentEdgeInsets = self.button.contentEdgeInsets; - contentEdgeInsets.left = self.menuView.padding.left; - contentEdgeInsets.right = self.menuView.padding.right; - self.button.contentEdgeInsets = contentEdgeInsets; -} - -@end - -@implementation QMUIPopupMenuButtonItem (UIAppearance) - -+ (void)initialize { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - [self setDefaultAppearanceForPopupMenuView]; - }); -} - -+ (void)setDefaultAppearanceForPopupMenuView { - QMUIPopupMenuButtonItem *appearance = [QMUIPopupMenuButtonItem appearance]; - appearance.highlightedBackgroundColor = TableViewCellSelectedBackgroundColor; - appearance.imageMarginRight = 6; -} - -- (void)updateAppearanceForMenuButtonItem { - QMUIPopupMenuButtonItem *appearance = [QMUIPopupMenuButtonItem appearance]; - self.highlightedBackgroundColor = appearance.highlightedBackgroundColor; - self.imageMarginRight = appearance.imageMarginRight; -} - -@end diff --git a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItem.h b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItem.h new file mode 100644 index 00000000..9e6e443f --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItem.h @@ -0,0 +1,55 @@ +// +// QMUIPopupMenuItem.h +// QMUIKit +// +// Created by molice on 2024/6/17. +// Copyright © 2024 QMUI Team. All rights reserved. +// + +#import +#import + +@class QMUIPopupMenuView; +@class QMUIPopupMenuItemView; +@protocol QMUIPopupMenuItemViewProtocol; + +NS_ASSUME_NONNULL_BEGIN + +@interface QMUIPopupMenuItem : NSObject + +/// item 里的文字 +@property(nonatomic, copy, nullable) NSString *title; + +/// item 里的第二行文字 +@property(nonatomic, copy, nullable) NSString *subtitle; + +/// item 里的图片,默认会以 template 形式渲染,也即由 tintColor 决定颜色,可显式声明为 AlwaysOriginal 来以图片原本的颜色显示。 +@property(nonatomic, strong, nullable) UIImage *image; + +/// item 的高度,默认为 -1,-1 表示高度以 QMUIPopupMenuView.itemHeight 为准。如果设置为 QMUIViewSelfSizingHeight,则表示高度由 -[self sizeThatFits:] 返回的值决定。 +@property(nonatomic, assign) CGFloat height; + +/// 每次将 item 关联到 itemView 上时都会调用这个 block,可以理解为在 @c QMUIPopupMenuView.itemViewConfigurationHandler 之后立马会调用 @c QMUIPopupMenuItem.configurationBlock 。 +/// 业务可利用这个 block 做一些自定义的配置 itemView 的行为。 +@property(nonatomic, copy) void (^configurationBlock)(__kindof QMUIPopupMenuItem *aItem, __kindof UIControl *aItemView, NSInteger section, NSInteger index); + +/// item 被点击时的事件处理接口 +/// @note 需要在内部自行隐藏 QMUIPopupMenuView。 +@property(nonatomic, copy, nullable) void (^handler)(__kindof QMUIPopupMenuItem *aItem, __kindof UIControl *aItemView, NSInteger section, NSInteger index); + +/// 当前 item 所在的 QMUIPopupMenuView 的引用,只有在 item 被添加到菜单之后才有值。 +@property(nonatomic, weak, nullable) __kindof QMUIPopupMenuView *menuView; + ++ (instancetype)itemWithTitle:(nullable NSString *)title + handler:(void (^ __nullable)(__kindof QMUIPopupMenuItem *aItem, __kindof UIControl *aItemView, NSInteger section, NSInteger index))handler; ++ (instancetype)itemWithImage:(nullable UIImage *)image + title:(nullable NSString *)title + handler:(void (^ __nullable)(__kindof QMUIPopupMenuItem *aItem, __kindof UIControl *aItemView, NSInteger section, NSInteger index))handler; ++ (instancetype)itemWithImage:(nullable UIImage *)image + title:(nullable NSString *)title + subtitle:(nullable NSString *)subtitle + handler:(void (^ __nullable)(__kindof QMUIPopupMenuItem *aItem, __kindof UIControl *aItemView, NSInteger section, NSInteger index))handler; + +@end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItem.m b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItem.m new file mode 100644 index 00000000..134def9d --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItem.m @@ -0,0 +1,38 @@ +// +// QMUIPopupMenuItem.m +// QMUIKit +// +// Created by molice on 2024/6/17. +// Copyright © 2024 QMUI Team. All rights reserved. +// + +#import "QMUIPopupMenuItem.h" + +@implementation QMUIPopupMenuItem + +- (instancetype)init { + self = [super init]; + if (self) { + _height = -1; + } + return self; +} + ++ (instancetype)itemWithImage:(UIImage *)image title:(NSString *)title subtitle:(NSString *)subtitle handler:(void (^ _Nullable)(__kindof QMUIPopupMenuItem * _Nonnull, __kindof UIControl * _Nonnull, NSInteger, NSInteger))handler { + QMUIPopupMenuItem *item = [[self alloc] init]; + item.image = image; + item.title = title; + item.subtitle = subtitle; + item.handler = handler; + return item; +} + ++ (instancetype)itemWithTitle:(NSString *)title handler:(void (^ _Nullable)(__kindof QMUIPopupMenuItem * _Nonnull, __kindof UIControl * _Nonnull, NSInteger, NSInteger))handler { + return [self itemWithImage:nil title:title subtitle:nil handler:handler]; +} + ++ (instancetype)itemWithImage:(UIImage *)image title:(NSString *)title handler:(void (^ _Nullable)(__kindof QMUIPopupMenuItem * _Nonnull, __kindof UIControl * _Nonnull, NSInteger, NSInteger))handler { + return [self itemWithImage:image title:title subtitle:nil handler:handler]; +} + +@end diff --git a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItemProtocol.h b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItemProtocol.h deleted file mode 100644 index 44431c57..00000000 --- a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItemProtocol.h +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Tencent is pleased to support the open source community by making QMUI_iOS available. - * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. - * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at - * http://opensource.org/licenses/MIT - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - */ - -// -// QMUIPopupMenuItemProtocol.h -// QMUIKit -// -// Created by QMUI Team on 2018/8/21. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@class QMUIPopupMenuView; - -@protocol QMUIPopupMenuItemProtocol - -/// item 里的文字 -@property(nonatomic, copy, nullable) NSString *title; - -/// item 的高度,默认为 -1,-1 表示高度以 QMUIPopupMenuView.itemHeight 为准。如果设置为 QMUIViewSelfSizingHeight,则表示高度由 -[self sizeThatFits:] 返回的值决定。 -@property(nonatomic, assign) CGFloat height; - -/// item 被点击时的事件处理接口,QMUIPopupMenuBaseItem 里仅声明,只有 QMUIPopupMenuButtonItem 会自动调用。若继承 QMUIPopupMenuBaseItem 衍生自己的子类,也需要手动调用它。 -/// @note 需要在内部自行隐藏 QMUIPopupMenuView。 -@property(nonatomic, copy, nullable) void (^handler)(__kindof NSObject *aItem); - -/// 当前 item 所在的 QMUIPopupMenuView 的引用,只有在 item 被添加到菜单之后才有值。 -@property(nonatomic, weak, nullable) QMUIPopupMenuView *menuView; - -/// item 被添加到 menuView 之后(也即 menuView 属性有值了)会被调用,可在这个方法里更新 item 的样式(因为某些样式可能需要从 menuView 那边读取) -- (void)updateAppearance; - -@end - -NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItemView.h b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItemView.h new file mode 100644 index 00000000..ecf6da90 --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItemView.h @@ -0,0 +1,34 @@ +// +// QMUIPopupMenuItemView.h +// QMUIKit +// +// Created by molice on 2024/6/17. +// Copyright © 2024 QMUI Team. All rights reserved. +// + +#import +#import "QMUIPopupMenuItemViewProtocol.h" + +NS_ASSUME_NONNULL_BEGIN + +@class QMUIButton; +@class QMUICheckbox; + +@interface QMUIPopupMenuItemView : UIControl + +/// 图片、文本、第二行文本所在的 view,不接受事件,点击事件由 self 接管。 +@property(nonatomic, strong, readonly) QMUIButton *button; + +/// 当菜单进入选择模式时,代表被选中的勾。非选择模式时不存在。 +@property(nonatomic, strong, readonly, nullable) UIImageView *checkmark; + +/// 当菜单进入选择模式时,代表被选中的圆形勾,不接受事件,勾选状态由菜单控制。非选择模式时不存在。 +@property(nonatomic, strong, readonly, nullable) QMUICheckbox *checkbox; + +@property(nonatomic, strong, nullable) UIColor *highlightedBackgroundColor; + +@property(nonatomic, assign) UIEdgeInsets padding; +@property(nonatomic, assign) CGFloat spacingBetweenButtonAndCheck; +@end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItemView.m b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItemView.m new file mode 100644 index 00000000..878d9b43 --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItemView.m @@ -0,0 +1,186 @@ +// +// QMUIPopupMenuItemView.m +// QMUIKit +// +// Created by molice on 2024/6/17. +// Copyright © 2024 QMUI Team. All rights reserved. +// + +#import "QMUIPopupMenuItemView.h" +#import "QMUICore.h" +#import "UIControl+QMUI.h" +#import "QMUIPopupMenuView.h" +#import "QMUILayouter.h" +#import "QMUIButton.h" +#import "QMUICheckbox.h" +#import "UIView+QMUI.h" + +@interface QMUIPopupMenuItemView () +@property(nonatomic, assign) QMUIPopupMenuSelectedStyle selectedStyle; +@property(nonatomic, assign) QMUIPopupMenuSelectedLayout selectedLayout; +@end + +@implementation QMUIPopupMenuItemView + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + _button = [[QMUIButton alloc] init]; + _button.userInteractionEnabled = NO; + _button.contentHorizontalAlignment = UIControlContentHorizontalAlignmentLeading; + _button.spacingBetweenImageAndTitle = 12; + _button.titleLabel.font = UIFontMake(16); + _button.subtitleLabel.font = UIFontMake(14); + _button.subtitleLabel.alpha = .6; + _button.adjustsTitleTintColorAutomatically = YES; + _button.tintColor = nil;// 跟随 superview + [self addSubview:_button]; + + _padding = UIEdgeInsetsMake(8, 0, 8, 0); + _spacingBetweenButtonAndCheck = 16; + + if (QMUICMIActivated) { + self.highlightedBackgroundColor = TableViewGroupedCellSelectedBackgroundColor; + } + } + return self; +} + +- (QMUILayouterItem *)generateLayouter { + QMUILayouterItem *button = [QMUILayouterItem itemWithView:self.button margin:UIEdgeInsetsZero grow:1 shrink:QMUILayouterShrinkDefault]; + UIView *checkView = self.selectedStyle == QMUIPopupMenuSelectedStyleCheckmark ? self.checkmark : (self.selectedStyle == QMUIPopupMenuSelectedStyleCheckbox ? self.checkbox : nil); + QMUILayouterItem *check = checkView ? [QMUILayouterItem itemWithView:checkView margin:UIEdgeInsetsZero grow:QMUILayouterGrowNever shrink:QMUILayouterShrinkNever] : nil; + check.visibleBlock = ^BOOL(QMUILayouterItem * _Nonnull aItem) { + return YES;// 不管 checkView 显示与否都一定占位,避免切换 selected 过程中内容宽度跳动 + }; + NSArray *items = nil; + if (check) { + if (self.selectedLayout == QMUIPopupMenuSelectedLayoutAtEnd) { + items = @[button, check]; + } else if (self.selectedLayout == QMUIPopupMenuSelectedLayoutAtStart) { + items = @[check, button]; + } + } else { + items = @[button]; + } + QMUILayouterLinearHorizontal *h = [QMUILayouterLinearHorizontal itemWithChildItems:items spacingBetweenItems:_spacingBetweenButtonAndCheck horizontal:QMUILayouterAlignmentFill vertical:QMUILayouterAlignmentCenter]; + h.margin = self.padding; + QMUILayouterLinearHorizontal *container = [QMUILayouterLinearHorizontal itemWithChildItems:@[h] spacingBetweenItems:0 horizontal:QMUILayouterAlignmentFill vertical:QMUILayouterAlignmentCenter]; + return container; +} + +- (CGSize)sizeThatFits:(CGSize)size { + return [[self generateLayouter] sizeThatFits:size]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + QMUILayouterItem *l = [self generateLayouter]; + l.frame = self.bounds; + [l layoutIfNeeded]; +} + +- (void)setEnabled:(BOOL)enabled { + [super setEnabled:enabled]; + [self updateAlphaState]; +} + +- (void)setHighlighted:(BOOL)highlighted { + [super setHighlighted:highlighted]; + [self updateAlphaState]; +} + +- (void)setSelected:(BOOL)selected { + [super setSelected:selected]; + self.button.selected = selected;// 同步状态以使 button 上也可以感知到 selected + if (self.selectedStyle == QMUIPopupMenuSelectedStyleCheckmark) { + self.checkmark.hidden = !selected; + } else if (self.selectedStyle == QMUIPopupMenuSelectedStyleCheckbox) { + self.checkbox.hidden = NO; + self.checkbox.selected = selected; + } else { + self.checkmark.hidden = YES; + self.checkbox.hidden = YES; + self.checkbox.selected = NO; + } +} + +- (void)setSelectedStyle:(QMUIPopupMenuSelectedStyle)selectedStyle { + _selectedStyle = selectedStyle; + if (selectedStyle == QMUIPopupMenuSelectedStyleCheckmark) { + if (!_checkmark) { + _checkmark = [[UIImageView alloc] initWithImage:[TableViewCellCheckmarkImage imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]]; + [self addSubview:_checkmark]; + } + _checkmark.hidden = !self.selected; + _checkbox.hidden = YES; + } else if (selectedStyle == QMUIPopupMenuSelectedStyleCheckbox) { + if (!_checkbox) { + _checkbox = QMUICheckbox.new; + _checkbox.tintColor = nil; + _checkbox.userInteractionEnabled = NO; + [self addSubview:_checkbox]; + } + _checkbox.hidden = NO; + _checkbox.selected = self.selected; + _checkmark.hidden = YES; + } else { + _checkmark.hidden = YES; + _checkbox.hidden = YES; + } + [self setNeedsLayout]; +} + +- (void)setSelectedLayout:(QMUIPopupMenuSelectedLayout)selectedLayout { + _selectedLayout = selectedLayout; + [self setNeedsLayout]; +} + +- (void)updateAlphaState { + if (!self.enabled) { + [self.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + obj.alpha = UIControlDisabledAlpha; + }]; + if (self.highlightedBackgroundColor) { + self.backgroundColor = nil; + } + return; + } + + [self.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + obj.alpha = 1; + }]; + if (self.highlighted) { + if (self.highlightedBackgroundColor) { + self.backgroundColor = self.highlightedBackgroundColor; + } + return; + } + if (self.highlightedBackgroundColor) { + self.backgroundColor = nil; + } +} + +#pragma mark - + +@synthesize item = _item; +- (void)setItem:(__kindof QMUIPopupMenuItem *)item { + _item = item; + [self.button setImage:item.image.renderingMode == UIImageRenderingModeAutomatic ? [item.image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate] : item.image forState:UIControlStateNormal]; + [self.button setTitle:item.title forState:UIControlStateNormal]; + self.button.subtitle = item.subtitle; + + QMUIPopupMenuView *menu = item.menuView; + + self.padding = UIEdgeInsetsMake(self.padding.top, menu.padding.left, self.padding.bottom, menu.padding.right); + + if (menu.allowsSelection) { + self.selectedStyle = menu.selectedStyle; + self.selectedLayout = menu.selectedLayout; + } else { + self.selectedStyle = (QMUIPopupMenuSelectedStyle)-1;// 表示清空 + } + + [self setNeedsLayout]; +} + +@end diff --git a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItemViewProtocol.h b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItemViewProtocol.h new file mode 100644 index 00000000..18686536 --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuItemViewProtocol.h @@ -0,0 +1,23 @@ +// +// QMUIPopupMenuItemViewProtocol.h +// QMUIKit +// +// Created by molice on 2024/6/17. +// Copyright © 2024 QMUI Team. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@class QMUIPopupMenuItem; + +@protocol QMUIPopupMenuItemViewProtocol + +@required + +/// 当前 itemView 关联的 item,在 cellForRow 时会被设置。itemView 内所有与 item 强相关的内容均应在 setItem: 方法里设置。 +@property(nonatomic, weak, nullable) __kindof QMUIPopupMenuItem *item; +@end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuView.h b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuView.h index 169e083f..94538d7d 100644 --- a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuView.h +++ b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuView.h @@ -15,12 +15,25 @@ #import #import "QMUIPopupContainerView.h" -#import "QMUIPopupMenuItemProtocol.h" -#import "QMUIPopupMenuBaseItem.h" -#import "QMUIPopupMenuButtonItem.h" +#import "QMUIPopupMenuItemViewProtocol.h" +#import "QMUIPopupMenuItem.h" +#import "QMUITableView.h" +#import "QMUILabel.h" +#import "QMUIPopupMenuItemView.h" NS_ASSUME_NONNULL_BEGIN +typedef NS_ENUM(NSInteger, QMUIPopupMenuSelectedStyle) { + QMUIPopupMenuSelectedStyleCheckmark, // 小勾 + QMUIPopupMenuSelectedStyleCheckbox, // 圆形勾 + QMUIPopupMenuSelectedStyleCustom, // 自定义,默认不做任何表现,交给业务自行处理 +}; + +typedef NS_ENUM(NSInteger, QMUIPopupMenuSelectedLayout) { + QMUIPopupMenuSelectedLayoutAtEnd, + QMUIPopupMenuSelectedLayoutAtStart, +}; + /** * 用于弹出浮层里显示一行一行的菜单的控件。 * 使用方式: @@ -30,9 +43,17 @@ NS_ASSUME_NONNULL_BEGIN * 4. 通过为 sourceBarItem/sourceView/sourceRect 三者中的一个赋值,来决定浮层布局的位置(参考父类)。 * 5. 调用 showWithAnimated: 即可显示(参考父类)。 * - * 注意,QMUIPopupMenuView 的大小默认是按内容自适应的(item 的 sizeThatFits),但同时又受 maximumWidth/minimumWidth 的限制。 + * 注意,QMUIPopupMenuView 的大小默认是按内容自适应的(item 的 sizeThatFits),但同时又受 adjustsWidthAutomatically/maximumWidth/minimumWidth 的控制。 + * + * 关于颜色的设置: + * 1. 如果整个菜单的颜色(包括图片、title、subtitle、checkmark、checkbox)均一致,则直接通过 menu.tintColor 设置即可,默认情况下这些元素的 tintColor 都是 nil,也即跟随 superview 的 tintColor 走。 + * 2. 如果 item 里某个元素的颜色与整体相比有差异化的诉求,则需要继承 QMUIPopupMenuItemView 实现一个子类,在子类的 setHighlighted:、setSelected:、tintColorDidChange 里处理,然后通过 menu.itemViewGenerator 返回这个子类。 + * 3. 特别的,QMUIPopupMenuItem.image 默认会以 AlwaysTemplate 方式渲染,也即由 tintColor 决定图片颜色,可显式声明为 AlwaysOriginal 来保持图片原始的颜色。 */ -@interface QMUIPopupMenuView : QMUIPopupContainerView +@interface QMUIPopupMenuView : QMUIPopupContainerView + +/// contentView 里的 scrollView,所有 itemButton 都是放在这里面的。 +@property(nonatomic, strong, readonly) QMUITableView *tableView; /// 是否需要显示每个 item 之间的分隔线,默认为 NO,当为 YES 时,每个 section 除了最后一个 item 外其他 item 底部都会显示分隔线。分隔线显示在当前 item 上方,不占位。 @property(nonatomic, assign) BOOL shouldShowItemSeparator UI_APPEARANCE_SELECTOR; @@ -43,23 +64,29 @@ NS_ASSUME_NONNULL_BEGIN /// item 分隔线的位置偏移,默认为 UIEdgeInsetsZero。item 分隔线的默认布局是 menuView 宽度减去左右 padding,如果你希望分隔线左右贴边则可为这个属性设置一个负值的 left/right。 @property(nonatomic, assign) UIEdgeInsets itemSeparatorInset UI_APPEARANCE_SELECTOR; -/// 是否显示 section 和 section 之间的分隔线,默认为 NO,当为 YES 时,除了最后一个 section,其他 section 底部都会显示一条分隔线。分隔线会显示在所在的 item 之上,不占位。 +/// item 分隔线的高度,默认为 PixelOne。分隔线拥有自己的占位,不与 item 重叠。 +@property(nonatomic, assign) CGFloat itemSeparatorHeight UI_APPEARANCE_SELECTOR; + +/// 是否显示 section 和 section 之间的分隔线,默认为 NO,当为 YES 时,除了最后一个 section,其他 section 底部都会显示一条分隔线。分隔线拥有自己的占位,不与 item、sectionSpacing 重叠。 @property(nonatomic, assign) BOOL shouldShowSectionSeparator UI_APPEARANCE_SELECTOR; -/// section 分隔线的颜色,默认为 UIColorSeparator。 +/// section 分隔线的颜色,默认为 UIColorSeparator。分隔线拥有自己的占位,不与 sectionSpacing 重叠。 @property(nonatomic, strong, nullable) UIColor *sectionSeparatorColor UI_APPEARANCE_SELECTOR; /// section 分隔线的位置偏移,默认为 UIEdgeInsetsZero。section 分隔线的默认布局是撑满整个 menuView,如果你不希望分隔线左右贴边则可为这个属性设置一个 left/right 不为 0 的值即可。 @property(nonatomic, assign) UIEdgeInsets sectionSeparatorInset UI_APPEARANCE_SELECTOR; +/// section 分隔线的高度,默认为 PixelOne。 +@property(nonatomic, assign) CGFloat sectionSeparatorHeight UI_APPEARANCE_SELECTOR; + /// section 之间的间隔,默认为0,也即贴合到一起。 @property(nonatomic, assign) CGFloat sectionSpacing UI_APPEARANCE_SELECTOR; -/// item 里文字的字体,默认为 UIFontMake(16)。 -@property(nonatomic, strong, nullable) UIFont *itemTitleFont UI_APPEARANCE_SELECTOR; +/// section 之间的间隔颜色,当 sectionSpacing > 0 时才有意义,默认为 UIColorSeparator。 +@property(nonatomic, strong, nullable) UIColor *sectionSpacingColor UI_APPEARANCE_SELECTOR; -/// item 里文字的颜色,默认为 UIColorBlue -@property(nonatomic, strong, nullable) UIColor *itemTitleColor UI_APPEARANCE_SELECTOR; +/// 批量设置 sectionTitleLabel 的样式 +@property(nonatomic, copy, nullable) void (^sectionTitleConfigurationHandler)(__kindof QMUIPopupMenuView *aMenuView, QMUILabel *sectionTitleLabel, NSInteger section); /// 整个 menuView 内部上下左右的 padding,其中 padding.left/right 会被作为 item.button.contentEdgeInsets.left/right,也即每个 item 的宽度一定是撑满整个 menuView 的。 @property(nonatomic, assign) UIEdgeInsets padding UI_APPEARANCE_SELECTOR; @@ -68,17 +95,67 @@ NS_ASSUME_NONNULL_BEGIN /// 如果将 itemHeight 设置为 QMUIViewSelfSizingHeight 则会以 item sizeThatFits: 返回的结果作为最终的 item 高度。 @property(nonatomic, assign) CGFloat itemHeight UI_APPEARANCE_SELECTOR; -/// 批量设置 item 的样式 -@property(nonatomic, copy, nullable) void (^itemConfigurationHandler)(QMUIPopupMenuView *aMenuView, __kindof QMUIPopupMenuBaseItem *aItem, NSInteger section, NSInteger index); +/// 默认 YES,也即会自动计算每个 item 的宽度,取其中最宽的值作为整个 menu 的宽度。 +/// 当数据量大的情况下请手动置为 NO 并改为用 maximumWidth、minimumWidth 控制 menu 宽度,从而获取更优的性能。 +@property(nonatomic, assign) BOOL adjustsWidthAutomatically; + +/// item、sectionTitle 之间是否复用以提升性能,默认为 NO。 +/// 当数据量大或有复杂异步场景的情况下可改为 YES。 +/// 若需要修改值,建议在设置 items/sectionItems 之前就先设置好。 +@property(nonatomic, assign) BOOL shouldReuseItems; + +/// 当需要创建一个 itemView 时会试图从这个 block 获取,若业务没实现这个 block,则默认返回一个 @c QMUIPopupMenuItemView 实例。 +@property(nonatomic, copy, nullable) __kindof UIControl * (^itemViewGenerator)(__kindof QMUIPopupMenuView *aMenuView); -/// 如果 items 是 QMUIPopupMenuButtonItem 或其子类,则当任一 item 被点击前,都会先调用这个 block。 -@property(nonatomic, copy, nullable) void (^willHandleButtonItemEventBlock)(QMUIPopupMenuView *aMenuView, __kindof QMUIPopupMenuButtonItem *aItem, NSInteger section, NSInteger index); +/// 批量设置 itemView 的样式 +@property(nonatomic, copy, nullable) void (^itemViewConfigurationHandler)(__kindof QMUIPopupMenuView *aMenuView, __kindof QMUIPopupMenuItem *aItem, __kindof UIControl *aItemView, NSInteger section, NSInteger index); /// 设置 item,均处于同一个 section 内 -@property(nonatomic, copy, nullable) NSArray<__kindof QMUIPopupMenuBaseItem *> *items; +@property(nonatomic, copy, nullable) NSArray<__kindof QMUIPopupMenuItem *> *items; /// 设置多个 section 的多个 item -@property(nonatomic, copy, nullable) NSArray *> *itemSections; +@property(nonatomic, copy, nullable) NSArray *> *itemSections; + +/// 为每个 section 设置标题,不需要显示标题的 section 请使用空字符串占位。必须保证 @c sectionTitles 和 @c itemSections 长度相等。 +/// @note 请在设置 item、itemSections 之前先设置本属性。 +@property(nonatomic, copy, nullable) NSArray *sectionTitles; + +/// 是否允许出现勾选,默认为 NO。 +@property(nonatomic, assign) BOOL allowsSelection; + +/// 是否允许多选,默认为 NO。当置为 YES 时会同时把 @c allowsSelection 也置为 YES。所以如果你只是想判断当前是否处于勾选状态,不关心单选还是多选,则直接访问 @c allowsSelection 即可。 +@property(nonatomic, assign) BOOL allowsMultipleSelection; + +/// 勾选的样式,默认为 checkmark。 +@property(nonatomic, assign) QMUIPopupMenuSelectedStyle selectedStyle; + +/// 勾选出现的位置,默认为 AtEnd,也即在按钮右侧。 +@property(nonatomic, assign) QMUIPopupMenuSelectedLayout selectedLayout; + +/// 当前选中的 item 序号,若当前是多选,则会返回第一个被选中的 item 的序号。 +/// 若想清空选中状态,可赋值为 @c NSNotFound ,默认为 @c NSNotFound 。 +/// @warning 仅用于单 section 的场景,多 section 场景请使用 @c selectedItemIndexPath 。 +@property(nonatomic, assign) NSInteger selectedItemIndex; + +/// 当前选中的 item 序号,若当前是多选,则会返回第一个被选中的 item 的序号。 +/// 若想清空选中状态,可赋值为 @c nil ,默认为 @c nil 。 +/// @note 可用于多 section 的场景。 +@property(nonatomic, strong, nullable) NSIndexPath *selectedItemIndexPath; + +/// 当前选中的所有 item 的序号。 +/// 若想清空选中状态,可赋值为 @c nil ,默认为 @c nil 。 +@property(nonatomic, strong, nullable) NSArray *selectedItemIndexPaths; + +/// 当处于 @c allowsSelection 模式时,默认每个 item 都可被选中。如果希望某个 item 不参与 selected 操作,可通过该 block 返回 NO 来实现。 +/// 如果想实现“最少选择n个”或“选择任意一个后无法再清空选择”的交互,也可通过这个 block 实现。 +@property(nonatomic, copy, nullable) BOOL (^shouldSelectItemBlock)(__kindof QMUIPopupMenuItem *aItem, __kindof UIControl *aItemView, NSInteger section, NSInteger index); + +/// 固定显示在菜单底部的 view,不跟随滚动,大小通过调用自身的 sizeThatFits: 获取。 +/// @note 菜单的 padding 会作用在 item 上(也即列表),不会作用在 bottomAccessoryView 上,bottomAccessoryView 始终都是宽度撑满菜单,底部紧贴菜单。 +@property(nonatomic, strong, nullable) __kindof UIView *bottomAccessoryView; + +/// 刷新当前菜单的内容及布局 +- (void)reload; @end diff --git a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuView.m b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuView.m index b8dd3644..9e158517 100644 --- a/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuView.m +++ b/QMUIKit/QMUIComponents/QMUIPopupMenuView/QMUIPopupMenuView.m @@ -18,12 +18,92 @@ #import "UIView+QMUI.h" #import "CALayer+QMUI.h" #import "NSArray+QMUI.h" +#import "UIFont+QMUI.h" +#import "UITableViewCell+QMUI.h" -@interface QMUIPopupMenuView () +@interface QMUIPopupMenuCell : UITableViewCell +@property(nonatomic, strong) __kindof UIControl *itemView; +@end + +@implementation QMUIPopupMenuCell + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { + if (self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]) { + self.selectionStyle = UITableViewCellSelectionStyleNone; + self.backgroundColor = UIColor.clearColor; + } + return self; +} + +- (void)setItemView:(__kindof UIControl *)itemView { + if (_itemView) return; + _itemView = itemView; + [self.contentView addSubview:itemView]; +} + +- (CGSize)sizeThatFits:(CGSize)size { + CGSize result = [self.itemView sizeThatFits:size]; + result.height += self.qmui_borderWidth; + return result; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + self.itemView.frame = CGRectInsetEdges(self.contentView.bounds, UIEdgeInsetsMake(0, 0, self.qmui_borderWidth, 0)); +} + +@end + +@interface QMUIPopupMenuSectionHeaderView : UITableViewHeaderFooterView +@property(nonatomic, strong) QMUILabel *label; +@end + +@implementation QMUIPopupMenuSectionHeaderView + +- (instancetype)initWithReuseIdentifier:(NSString *)reuseIdentifier { + if (self = [super initWithReuseIdentifier:reuseIdentifier]) { + _label = QMUILabel.new; + _label.numberOfLines = 0; + _label.font = UIFontMediumMake(13); + _label.textColor = UIColorGray; + _label.contentEdgeInsets = UIEdgeInsetsMake(12, 16, 2, 16); + [self.contentView addSubview:self.label]; + } + return self; +} + +- (CGSize)sizeThatFits:(CGSize)size { + return [self.label sizeThatFits:size]; +} -@property(nonatomic, strong) UIScrollView *scrollView; -@property(nonatomic, strong) NSMutableArray *itemSeparatorLayers; -@property(nonatomic, strong) NSMutableArray *sectionSeparatorLayers; +- (void)layoutSubviews { + [super layoutSubviews]; + self.label.frame = self.contentView.bounds; +} + +@end + +@interface QMUIPopupMenuSectionFooterView : UITableViewHeaderFooterView +@end + +@implementation QMUIPopupMenuSectionFooterView + +- (instancetype)initWithReuseIdentifier:(NSString *)reuseIdentifier { + if (self = [super initWithReuseIdentifier:reuseIdentifier]) { + self.backgroundView = [[UIView alloc] init];// 去掉默认的背景,以便屏蔽系统对背景色的控制 + } + return self; +} + +// 系统的 UITableViewHeaderFooterView 不允许修改 backgroundColor,都应该放到 backgroundView 里,但却没有在文档中写明,只有不小心误用时才会在 Xcode 控制台里提示,所以这里做个转换,保护误用的情况。 +- (void)setBackgroundColor:(UIColor *)backgroundColor { +// [super setBackgroundColor:backgroundColor]; + self.backgroundView.backgroundColor = backgroundColor; +} + +@end + +@interface QMUIPopupMenuView () @end @interface QMUIPopupMenuView (UIAppearance) @@ -33,238 +113,453 @@ - (void)updateAppearanceForPopupMenuView; @implementation QMUIPopupMenuView -- (void)setItems:(NSArray<__kindof QMUIPopupMenuBaseItem *> *)items { - [_items enumerateObjectsUsingBlock:^(__kindof QMUIPopupMenuBaseItem * _Nonnull item, NSUInteger idx, BOOL * _Nonnull stop) { - item.menuView = nil; - }]; +- (void)setItems:(NSArray<__kindof QMUIPopupMenuItem *> *)items { _items = items; - if (!items) { - self.itemSections = nil; - } else { - self.itemSections = @[_items]; - } + self.itemSections = items ? @[_items] : nil; } -- (void)setItemSections:(NSArray *> *)itemSections { - [_itemSections qmui_enumerateNestedArrayWithBlock:^(__kindof QMUIPopupMenuBaseItem * item, BOOL *stop) { +- (void)setItemSections:(NSArray *> *)itemSections { + [_itemSections qmui_enumerateNestedArrayWithBlock:^(__kindof QMUIPopupMenuItem *item, BOOL *stop) { item.menuView = nil; }]; _itemSections = itemSections; - [self configureItems]; + [_itemSections qmui_enumerateNestedArrayWithBlock:^(__kindof QMUIPopupMenuItem *item, BOOL * _Nonnull stop) { + item.menuView = self; + }]; + [self reload];// 涉及到数据的必须立即刷新,否则容易因为异步导致 cell 里的 view 和当前的 item 不匹配的 bug } -- (void)setItemConfigurationHandler:(void (^)(QMUIPopupMenuView *, __kindof QMUIPopupMenuBaseItem *, NSInteger, NSInteger))itemConfigurationHandler { - _itemConfigurationHandler = [itemConfigurationHandler copy]; - if (_itemConfigurationHandler && self.itemSections.count) { - for (NSInteger section = 0, sectionCount = self.itemSections.count; section < sectionCount; section ++) { - NSArray *items = self.itemSections[section]; - for (NSInteger row = 0, rowCount = items.count; row < rowCount; row ++) { - QMUIPopupMenuBaseItem *item = items[row]; - _itemConfigurationHandler(self, item, section, row); - } +- (void)setSectionTitles:(NSArray *)sectionTitles { + _sectionTitles = sectionTitles; + [self reload]; +} + +- (void)setItemViewConfigurationHandler:(void (^)(__kindof QMUIPopupMenuView * _Nonnull, __kindof QMUIPopupMenuItem * _Nonnull, __kindof UIControl * _Nonnull, NSInteger, NSInteger))itemViewConfigurationHandler { + _itemViewConfigurationHandler = [itemViewConfigurationHandler copy]; + [self setNeedsReload]; +} + +- (void)setSectionTitleConfigurationHandler:(void (^)(__kindof QMUIPopupMenuView * _Nonnull, QMUILabel * _Nonnull, NSInteger))sectionTitleConfigurationHandler { + _sectionTitleConfigurationHandler = [sectionTitleConfigurationHandler copy]; + [self setNeedsReload]; +} + +- (void)setPadding:(UIEdgeInsets)padding { + _padding = padding; + self.tableView.contentInset = UIEdgeInsetsMake(padding.top, self.tableView.contentInset.left, padding.bottom, self.tableView.contentInset.right); + [self setNeedsReload]; +} + +- (void)setShouldShowItemSeparator:(BOOL)shouldShowItemSeparator { + _shouldShowItemSeparator = shouldShowItemSeparator; + [self setNeedsReload]; +} + +- (void)setItemSeparatorInset:(UIEdgeInsets)itemSeparatorInset { + _itemSeparatorInset = itemSeparatorInset; + [self setNeedsReload]; +} + +- (void)setShouldShowSectionSeparator:(BOOL)shouldShowSectionSeparator { + _shouldShowSectionSeparator = shouldShowSectionSeparator; + [self setNeedsReload]; +} + +- (void)setSectionSeparatorHeight:(CGFloat)sectionSeparatorHeight { + _sectionSeparatorHeight = sectionSeparatorHeight; + [self setNeedsReload]; +} + +- (void)setItemHeight:(CGFloat)itemHeight { + _itemHeight = itemHeight; + [self setNeedsReload]; +} + +- (void)setSelectedStyle:(QMUIPopupMenuSelectedStyle)selectedStyle { + _selectedStyle = selectedStyle; + [self setNeedsReload]; +} + +- (void)setSelectedLayout:(QMUIPopupMenuSelectedLayout)selectedLayout { + _selectedLayout = selectedLayout; + [self setNeedsReload]; +} + +- (void)setAllowsSelection:(BOOL)allowsSelection { + _allowsSelection = allowsSelection; + if (!allowsSelection) { + self.selectedItemIndexPaths = nil; + } + [self setNeedsReload]; +} + +- (void)setAllowsMultipleSelection:(BOOL)allowsMultipleSelection { + _allowsMultipleSelection = allowsMultipleSelection; + if (allowsMultipleSelection) { + _allowsSelection = YES; + } else { + if (self.selectedItemIndexPaths.count > 1) { + self.selectedItemIndexPaths = [self.selectedItemIndexPaths subarrayWithRange:NSMakeRange(0, 1)]; } } + [self setNeedsReload]; } -- (void)configureItems { - __block NSInteger globalItemIndex = 0; - __block NSInteger separatorIndex = 0; - - // 移除所有 item - [self.scrollView qmui_removeAllSubviews]; - [self.itemSeparatorLayers enumerateObjectsUsingBlock:^(CALayer * _Nonnull layer, NSUInteger idx, BOOL * _Nonnull stop) { - layer.hidden = YES; - }]; - [self.sectionSeparatorLayers enumerateObjectsUsingBlock:^(CALayer * _Nonnull layer, NSUInteger idx, BOOL * _Nonnull stop) { - layer.hidden = YES; - }]; - - [self enumerateItemsWithBlock:^(QMUIPopupMenuBaseItem *item, NSInteger section, NSInteger sectionCount, NSInteger row, NSInteger rowCount) { - item.menuView = self; - [item updateAppearance]; - if (self.itemConfigurationHandler) { - self.itemConfigurationHandler(self, item, section, row); +BeginIgnoreClangWarning(-Wunused-property-ivar) +- (void)setSelectedItemIndex:(NSInteger)selectedItemIndex { + if (selectedItemIndex == NSNotFound) { + self.selectedItemIndexPath = nil; + } else { + self.selectedItemIndexPath = [NSIndexPath indexPathForRow:selectedItemIndex inSection:0]; + } +} + +- (void)setSelectedItemIndexPath:(NSIndexPath *)selectedItemIndexPath { + self.selectedItemIndexPaths = selectedItemIndexPath ? @[selectedItemIndexPath] : nil; +} +EndIgnoreClangWarning + +- (void)setSelectedItemIndexPaths:(NSArray *)selectedItemIndexPaths { + if (!selectedItemIndexPaths.count) { + _selectedItemIndex = NSNotFound; + _selectedItemIndexPath = nil; + } else { + _selectedItemIndex = selectedItemIndexPaths.firstObject.row; + _selectedItemIndexPath = selectedItemIndexPaths.firstObject; + } + _selectedItemIndexPaths = selectedItemIndexPaths; + [self setNeedsReload]; +} + +- (void)setNeedsReload { + if (_shouldInvalidateLayout) return; + _shouldInvalidateLayout = YES; + dispatch_async(dispatch_get_main_queue(), ^{ + if (self->_shouldInvalidateLayout) { + [self reload]; } - [self.scrollView addSubview:item]; - - // 配置分隔线,注意每一个 section 里的最后一行是不显示分隔线的 - BOOL shouldShowItemSeparator = self.shouldShowItemSeparator && row < rowCount - 1; - if (shouldShowItemSeparator) { - CALayer *separatorLayer = nil; - if (separatorIndex < self.itemSeparatorLayers.count) { - separatorLayer = self.itemSeparatorLayers[separatorIndex]; - } else { - separatorLayer = [CALayer qmui_separatorLayer]; - [self.scrollView.layer addSublayer:separatorLayer]; - [self.itemSeparatorLayers addObject:separatorLayer]; + }); +} + +- (void)reload { + [self.tableView reloadData]; + if (self.isShowing) { + [self updateLayout];// updateLayout 的 super 实现里会把 _shouldInvalidateLayout 置为 NO + } +} + +- (void)updateLayout { + [self setNeedsLayout]; + [self layoutIfNeeded]; + [super updateLayout]; +} + +- (NSIndexPath *)indexPathForItem:(__kindof QMUIPopupMenuItem *)aItem { + for (NSInteger section = 0, sectionCount = self.itemSections.count; section < sectionCount; section ++) { + NSArray<__kindof QMUIPopupMenuItem *> *items = self.itemSections[section]; + for (NSInteger row = 0, rowCount = items.count; row < rowCount; row ++) { + QMUIPopupMenuItem *item = items[row]; + if (item == aItem) { + return [NSIndexPath indexPathForRow:row inSection:section]; } - separatorLayer.hidden = NO; - separatorLayer.backgroundColor = self.itemSeparatorColor.CGColor; - separatorIndex++; } - - globalItemIndex++; - }]; + } + return nil; +} + +- (void)handleItemViewEvent:(UIControl *)itemView { + NSIndexPath *indexPath = [self indexPathForItem:itemView.item]; + if (!indexPath) { + NSAssert(NO, @"the indexPath for the item could not be found"); + return; + } - for (NSInteger section = 0, sectionCount = self.itemSections.count; section < sectionCount; section ++) { - BOOL shouldShowSectionSeparator = self.shouldShowSectionSeparator && section < sectionCount - 1; - if (shouldShowSectionSeparator) { - CALayer *separatorLayer = nil; - if (section < self.sectionSeparatorLayers.count) { - separatorLayer = self.sectionSeparatorLayers[section]; + if (self.allowsSelection) { + BOOL shouldSelectItem = YES; + if (self.shouldSelectItemBlock) { + shouldSelectItem = self.shouldSelectItemBlock(itemView.item, itemView, indexPath.section, indexPath.row); + } + if (shouldSelectItem) { + NSMutableArray *selectedIndexPaths = self.selectedItemIndexPaths ? self.selectedItemIndexPaths.mutableCopy : [[NSMutableArray alloc] init]; + if (self.allowsMultipleSelection) { + if (itemView.selected) { + [selectedIndexPaths removeObject:indexPath]; + } else { + [selectedIndexPaths addObject:indexPath]; + } } else { - separatorLayer = [CALayer qmui_separatorLayer]; - [self.scrollView.layer addSublayer:separatorLayer]; - [self.sectionSeparatorLayers addObject:separatorLayer]; + // 单选,得把其他选中都清除 + [selectedIndexPaths removeAllObjects]; + if (!itemView.selected) { + [selectedIndexPaths addObject:indexPath]; + } } - separatorLayer.hidden = NO; - separatorLayer.backgroundColor = self.sectionSeparatorColor.CGColor; + self.selectedItemIndexPaths = selectedIndexPaths.copy; } } + + if (itemView.item.handler) { + itemView.item.handler(itemView.item, itemView, indexPath.section, indexPath.row); + } } -- (void)setItemSeparatorInset:(UIEdgeInsets)itemSeparatorInset { - _itemSeparatorInset = itemSeparatorInset; - [self setNeedsLayout]; +- (void)setBottomAccessoryView:(__kindof UIView *)bottomAccessoryView { + if (bottomAccessoryView != _bottomAccessoryView) { + [_bottomAccessoryView removeFromSuperview]; + } + _bottomAccessoryView = bottomAccessoryView; + [self.contentView addSubview:_bottomAccessoryView]; + [self setNeedsUpdateLayout]; } -- (void)setItemSeparatorColor:(UIColor *)itemSeparatorColor { - _itemSeparatorColor = itemSeparatorColor; - [self.itemSeparatorLayers enumerateObjectsUsingBlock:^(CALayer * _Nonnull layer, NSUInteger idx, BOOL * _Nonnull stop) { - layer.backgroundColor = itemSeparatorColor.CGColor; - }]; +- (void)tintColorDidChange { + [super tintColorDidChange]; + [self setNeedsReload]; } -- (void)setSectionSeparatorInset:(UIEdgeInsets)sectionSeparatorInset { - _sectionSeparatorInset = sectionSeparatorInset; - [self setNeedsLayout]; +- (NSString *)reuseIdentifierAtIndexPath:(NSIndexPath *)indexPath forType:(NSInteger)type { + if (self.shouldReuseItems) { + return @[@"cell", @"header", @"footer"][type]; + } + if (type == 0) { + QMUIPopupMenuItem *item = self.itemSections[indexPath.section][indexPath.row]; + return [NSString stringWithFormat:@"cell_%p", item]; + } + if (type == 1) { + return [NSString stringWithFormat:@"header_%p", self.itemSections[indexPath.section]]; + } + if (type == 2) { + return [NSString stringWithFormat:@"footer_%p", self.itemSections[indexPath.section]]; + } + return nil; } -- (void)setSectionSpacing:(CGFloat)sectionSpacing { - _sectionSpacing = sectionSpacing; - [self setNeedsLayout]; +#pragma mark - + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { + return self.itemSections.count; } -- (void)setSectionSeparatorColor:(UIColor *)sectionSeparatorColor { - _sectionSeparatorColor = sectionSeparatorColor; - [self.sectionSeparatorLayers enumerateObjectsUsingBlock:^(CALayer * _Nonnull layer, NSUInteger idx, BOOL * _Nonnull stop) { - layer.backgroundColor = sectionSeparatorColor.CGColor; - }]; +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return self.itemSections[section].count; } -- (void)setItemTitleFont:(UIFont *)itemTitleFont { - _itemTitleFont = itemTitleFont; - [self enumerateItemsWithBlock:^(QMUIPopupMenuBaseItem *item, NSInteger section, NSInteger sectionCount, NSInteger row, NSInteger rowCount) { - [item updateAppearance]; - }]; +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + NSString *identifier = [self reuseIdentifierAtIndexPath:indexPath forType:0]; + QMUIPopupMenuCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier]; + if (!cell) { + cell = [[QMUIPopupMenuCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:identifier]; + } + if (!cell.itemView) { + UIControl *itemView = nil; + if (self.itemViewGenerator) { + itemView = self.itemViewGenerator(self); + } else { + itemView = [[QMUIPopupMenuItemView alloc] init]; + } + cell.itemView = itemView; + } + + cell.itemView.tintColor = self.tintColor; + + QMUITableViewCellPosition position = [tableView qmui_positionForRowAtIndexPath:indexPath]; + if (self.shouldShowItemSeparator && !(position & QMUITableViewCellPositionLastInSection)) { + cell.qmui_borderPosition = QMUIViewBorderPositionBottom; + cell.qmui_borderWidth = self.itemSeparatorHeight; + cell.qmui_borderInsets = UIEdgeInsetsMake(self.itemSeparatorInset.bottom, self.itemSeparatorInset.right, self.itemSeparatorInset.top, self.itemSeparatorInset.left); + cell.qmui_borderColor = self.itemSeparatorColor; + } else { + cell.qmui_borderWidth = 0; + cell.qmui_borderPosition = QMUIViewBorderPositionNone; + } + + QMUIPopupMenuItem *item = self.itemSections[indexPath.section][indexPath.row]; + cell.itemView.item = item; + [cell.itemView addTarget:self action:@selector(handleItemViewEvent:) forControlEvents:UIControlEventTouchUpInside]; + + if ([self.selectedItemIndexPaths containsObject:indexPath]) { + cell.itemView.selected = YES; + } else { + cell.itemView.selected = NO; + } + + // 这个 block 是给业务自定义的机会,所以要放在最后面才能覆盖 + if (self.itemViewConfigurationHandler) { + self.itemViewConfigurationHandler(self, item, cell.itemView, indexPath.section, indexPath.row); + } + + if (item.configurationBlock) { + item.configurationBlock(item, cell.itemView, indexPath.section, indexPath.row); + } + + return cell; } -- (void)setItemTitleColor:(UIColor *)itemTitleColor { - _itemTitleColor = itemTitleColor; - [self enumerateItemsWithBlock:^(QMUIPopupMenuBaseItem *item, NSInteger section, NSInteger sectionCount, NSInteger row, NSInteger rowCount) { - [item updateAppearance]; - }]; +- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { + QMUIPopupMenuItem *item = self.itemSections[indexPath.section][indexPath.row]; + if (item.height == QMUIViewSelfSizingHeight) { + return UITableViewAutomaticDimension; + } + if (item.height >= 0 || self.itemHeight != QMUIViewSelfSizingHeight) { + CGFloat height = item.height >= 0 ? item.height : self.itemHeight; + QMUITableViewCellPosition position = [tableView qmui_positionForRowAtIndexPath:indexPath]; + if (self.shouldShowItemSeparator && !(position & QMUITableViewCellPositionLastInSection)) { + height += self.itemSeparatorHeight; + } + return height; + } + return UITableViewAutomaticDimension;// self.itemHeight == QMUIViewSelfSizingHeight } -- (void)setPadding:(UIEdgeInsets)padding { - _padding = padding; - [self enumerateItemsWithBlock:^(QMUIPopupMenuBaseItem *item, NSInteger section, NSInteger sectionCount, NSInteger row, NSInteger rowCount) { - [item updateAppearance]; - }]; +- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { + if (section >= self.sectionTitles.count) return nil; + NSString *string = self.sectionTitles[section]; + if (!string.length) return nil; + NSString *identifier = [self reuseIdentifierAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:section] forType:1]; + QMUIPopupMenuSectionHeaderView *header = [tableView dequeueReusableHeaderFooterViewWithIdentifier:identifier]; + if (!header) { + header = [[QMUIPopupMenuSectionHeaderView alloc] initWithReuseIdentifier:identifier]; + } + header.label.text = string; + if (self.sectionTitleConfigurationHandler) { + self.sectionTitleConfigurationHandler(self, header.label, section); + } + return header; } -- (void)enumerateItemsWithBlock:(void (^)(QMUIPopupMenuBaseItem *item, NSInteger section, NSInteger sectionCount, NSInteger row, NSInteger rowCount))block { - if (!block) return; - for (NSInteger section = 0, sectionCount = self.itemSections.count; section < sectionCount; section ++) { - NSArray *items = self.itemSections[section]; - for (NSInteger row = 0, rowCount = items.count; row < rowCount; row ++) { - QMUIPopupMenuBaseItem *item = items[row]; - block(item, section, sectionCount, row, rowCount); +- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { + if (section >= self.sectionTitles.count) return CGFLOAT_MIN; + NSString *string = self.sectionTitles[section]; + if (!string.length) return CGFLOAT_MIN; + return UITableViewAutomaticDimension; +} + +- (UIView *)tableView:(UITableView *)tableView viewForFooterInSection:(NSInteger)section { + BOOL shouldShowSectionSeparator = self.shouldShowSectionSeparator && self.sectionSeparatorHeight; + BOOL shouldShowSectionFooter = shouldShowSectionSeparator || self.sectionSpacing > 0; + if (shouldShowSectionFooter && section != tableView.numberOfSections - 1) { + NSString *identifier = [self reuseIdentifierAtIndexPath:[NSIndexPath indexPathForRow:0 inSection:section] forType:2]; + QMUIPopupMenuSectionFooterView *footer = [tableView dequeueReusableHeaderFooterViewWithIdentifier:identifier]; + if (!footer) { + footer = [[QMUIPopupMenuSectionFooterView alloc] initWithReuseIdentifier:identifier]; + } + if (shouldShowSectionSeparator) { + footer.qmui_borderPosition = QMUIViewBorderPositionTop; + footer.qmui_borderWidth = self.sectionSeparatorHeight; + footer.qmui_borderColor = self.sectionSeparatorColor; + footer.qmui_borderInsets = self.sectionSeparatorInset; + } else { + footer.qmui_borderPosition = QMUIViewBorderPositionNone; + } + if (self.sectionSpacing > 0) { + footer.backgroundColor = self.sectionSpacingColor; + } else { + footer.backgroundColor = nil; } + return footer; } + return nil; +} + +- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section { + if (section == tableView.numberOfSections - 1) { + return CGFLOAT_MIN; + } + CGFloat height = 0; + if (self.shouldShowSectionSeparator && self.sectionSeparatorHeight) { + height += self.sectionSeparatorHeight; + } + if (self.sectionSpacing > 0) { + height += self.sectionSpacing; + } + return height > 0 ? height : CGFLOAT_MIN; } #pragma mark - (UISubclassingHooks) - (void)didInitialize { [super didInitialize]; + _adjustsWidthAutomatically = YES; + _selectedItemIndex = NSNotFound; self.contentEdgeInsets = UIEdgeInsetsZero; - self.scrollView = [[UIScrollView alloc] init]; - self.scrollView.scrollsToTop = NO; - self.scrollView.showsHorizontalScrollIndicator = NO; - self.scrollView.showsVerticalScrollIndicator = NO; - self.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; - [self.contentView addSubview:self.scrollView]; - - self.itemSeparatorLayers = [[NSMutableArray alloc] init]; - self.sectionSeparatorLayers = [[NSMutableArray alloc] init]; + _tableView = [[QMUITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleGrouped]; + self.tableView.scrollsToTop = NO; + self.tableView.alwaysBounceHorizontal = NO; + self.tableView.alwaysBounceVertical = NO; + self.tableView.showsHorizontalScrollIndicator = NO; + self.tableView.showsVerticalScrollIndicator = NO; + self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone; + self.tableView.backgroundColor = nil; + self.tableView.tableHeaderView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 0, CGFLOAT_MIN)]; + self.tableView.tableFooterView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 0, CGFLOAT_MIN)];// 避免尾部出现20pt空白 + self.tableView.backgroundView = UIView.new; + self.tableView.estimatedRowHeight = self.itemHeight; + self.tableView.estimatedSectionHeaderHeight = 20; + self.tableView.dataSource = self; + self.tableView.delegate = self; + [self.contentView addSubview:self.tableView]; [self updateAppearanceForPopupMenuView]; } - (CGSize)sizeThatFitsInContentView:(CGSize)size { - __block CGFloat width = 0; - __block CGFloat height = UIEdgeInsetsGetVerticalValue(self.padding); - [self.itemSections qmui_enumerateNestedArrayWithBlock:^(__kindof QMUIPopupMenuBaseItem *item, BOOL *stop) { - CGSize itemSize = [item sizeThatFits:CGSizeMake(size.width, CGFLOAT_MAX)]; - CGFloat itemHeight = item.height; - if (itemHeight < 0) { - itemHeight = self.itemHeight; + __block CGSize result = [self.tableView sizeThatFits:CGSizeMake(size.width, CGFLOAT_MAX)]; + if (self.adjustsWidthAutomatically) { + self.tableView.frame = CGRectMakeWithSize(result); + [self.tableView layoutIfNeeded]; + result = CGSizeZero; + [self.itemSections enumerateObjectsUsingBlock:^(NSArray<__kindof QMUIPopupMenuItem *> * _Nonnull sectionItems, NSUInteger section, BOOL * _Nonnull aStop) { + if (self.sectionTitles.count > section && self.sectionTitles[section].length) { + QMUIPopupMenuSectionHeaderView *header = (QMUIPopupMenuSectionHeaderView *)[self.tableView headerViewForSection:section]; + CGSize headerSize = [header sizeThatFits:CGSizeMake(size.width, CGFLOAT_MAX)]; + result.height += headerSize.height; + result.width = MAX(result.width, MIN(headerSize.width, size.width)); + } + [sectionItems enumerateObjectsUsingBlock:^(__kindof QMUIPopupMenuItem * _Nonnull rowItem, NSUInteger row, BOOL * _Nonnull bStop) { + QMUIPopupMenuCell *cell = [self.tableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:row inSection:section]]; + CGSize itemSize = [cell.itemView sizeThatFits:CGSizeMake(size.width, CGFLOAT_MAX)]; + CGFloat itemHeight = rowItem.height; + if (itemHeight < 0) { + itemHeight = self.itemHeight; + } + // QMUIViewSelfSizingHeight + if (isinf(itemHeight)) { + itemHeight = itemSize.height; + } + if (self.shouldShowItemSeparator) { + itemHeight += self.itemSeparatorHeight;// 每个 section 结尾的那个 item 不需要算分隔线高度,在下文减去 + } + result.height += itemHeight; + result.width = MAX(result.width, MIN(itemSize.width, size.width)); + }]; + }]; + result.height += (self.itemSections.count - 1) * self.sectionSpacing; + if (self.shouldShowSectionSeparator) { + result.height += (self.itemSections.count - 1) * self.sectionSeparatorHeight; } - // QMUIViewSelfSizingHeight - if (isinf(itemHeight)) { - itemHeight = itemSize.height; + if (self.shouldShowItemSeparator) { + result.height -= self.itemSections.count * self.itemSeparatorHeight;// 减去每个 section 结尾的那个 item 的分隔线 } - height += itemHeight; - width = MAX(width, MIN(itemSize.width, size.width)); - }]; - height += (self.itemSections.count - 1) * self.sectionSpacing; - size.width = width; - size.height = height; - return size; + } + if (self.bottomAccessoryView) { + CGSize accessoryViewSize = [self.bottomAccessoryView sizeThatFits:CGSizeMake(size.width, CGFLOAT_MAX)]; + result.height += accessoryViewSize.height; + } + result.height += UIEdgeInsetsGetVerticalValue(self.padding);// contentInset 不在系统 sizeThatFits: 返回结果内,要自己加 + return result; } - (void)layoutSubviews { [super layoutSubviews]; - self.scrollView.frame = self.contentView.bounds; - - CGFloat minY = self.padding.top; - CGFloat contentWidth = CGRectGetWidth(self.scrollView.bounds); - NSInteger separatorIndex = 0; - for (NSInteger section = 0, sectionCount = self.itemSections.count; section < sectionCount; section ++) { - NSArray *items = self.itemSections[section]; - for (NSInteger row = 0, rowCount = items.count; row < rowCount; row ++) { - QMUIPopupMenuBaseItem *item = items[row]; - CGFloat itemHeight = item.height; - if (itemHeight < 0) { - itemHeight = self.itemHeight; - } - if (isinf(itemHeight)) { - itemHeight = [item sizeThatFits:CGSizeMake(contentWidth, CGFLOAT_MAX)].height; - } - item.frame = CGRectMake(0, minY, contentWidth, itemHeight); - minY = CGRectGetMaxY(item.frame); - - if (self.shouldShowItemSeparator && row < rowCount - 1) { - CALayer *layer = self.itemSeparatorLayers[separatorIndex]; - if (!layer.hidden) { - layer.frame = CGRectMake(self.padding.left + self.itemSeparatorInset.left, minY - PixelOne + self.itemSeparatorInset.top - self.itemSeparatorInset.bottom, contentWidth - UIEdgeInsetsGetHorizontalValue(self.padding) - UIEdgeInsetsGetHorizontalValue(self.itemSeparatorInset), PixelOne); - separatorIndex++; - } - } - } - - if (section < sectionCount - 1) { - if (self.shouldShowSectionSeparator) { - self.sectionSeparatorLayers[section].frame = CGRectMake(0, minY - PixelOne + self.sectionSeparatorInset.top - self.sectionSeparatorInset.bottom, contentWidth - UIEdgeInsetsGetHorizontalValue(self.sectionSeparatorInset), PixelOne); - } - - minY += self.sectionSpacing; - } + CGRect contentRect = self.contentView.bounds; + if (self.bottomAccessoryView) { + CGSize accessoryViewSize = [self.bottomAccessoryView sizeThatFits:CGSizeMake(CGRectGetWidth(contentRect), CGFLOAT_MAX)]; + self.bottomAccessoryView.frame = CGRectMake(0, CGRectGetHeight(contentRect) - accessoryViewSize.height, CGRectGetWidth(contentRect), accessoryViewSize.height); + contentRect = CGRectSetHeight(contentRect, CGRectGetMinY(self.bottomAccessoryView.frame)); } - minY += self.padding.bottom; - self.scrollView.contentSize = CGSizeMake(contentWidth, minY); + self.tableView.frame = contentRect; } @end @@ -280,14 +575,16 @@ + (void)initialize { + (void)setDefaultAppearanceForPopupMenuView { QMUIPopupMenuView *appearance = [QMUIPopupMenuView appearance]; - appearance.shouldShowItemSeparator = NO; + appearance.shouldShowItemSeparator = YES; appearance.itemSeparatorColor = UIColorSeparator; appearance.itemSeparatorInset = UIEdgeInsetsZero; - appearance.shouldShowSectionSeparator = NO; + appearance.itemSeparatorHeight = PixelOne; + appearance.shouldShowSectionSeparator = YES; appearance.sectionSeparatorColor = UIColorSeparator; appearance.sectionSeparatorInset = UIEdgeInsetsZero; - appearance.itemTitleFont = UIFontMake(16); - appearance.itemTitleColor = UIColorBlue; + appearance.sectionSeparatorHeight = PixelOne; + appearance.sectionSpacing = 8; + appearance.sectionSpacingColor = UIColorSeparator; appearance.padding = UIEdgeInsetsMake([QMUIPopupContainerView appearance].cornerRadius / 2.0, 16, [QMUIPopupContainerView appearance].cornerRadius / 2.0, 16); appearance.itemHeight = 44; } @@ -297,12 +594,14 @@ - (void)updateAppearanceForPopupMenuView { self.shouldShowItemSeparator = appearance.shouldShowItemSeparator; self.itemSeparatorColor = appearance.itemSeparatorColor; self.itemSeparatorInset = appearance.itemSeparatorInset; + self.itemSeparatorHeight = appearance.itemSeparatorHeight; self.shouldShowSectionSeparator = appearance.shouldShowSectionSeparator; + self.sectionSeparatorHeight = appearance.sectionSeparatorHeight; self.sectionSeparatorColor = appearance.sectionSeparatorColor; self.sectionSeparatorInset = appearance.sectionSeparatorInset; + self.sectionSeparatorHeight = appearance.sectionSeparatorHeight; self.sectionSpacing = appearance.sectionSpacing; - self.itemTitleFont = appearance.itemTitleFont; - self.itemTitleColor = appearance.itemTitleColor; + self.sectionSpacingColor = appearance.sectionSpacingColor; self.padding = appearance.padding; self.itemHeight = appearance.itemHeight; } diff --git a/QMUIKit/QMUIComponents/QMUISearchController.h b/QMUIKit/QMUIComponents/QMUISearchController.h index db18529e..cc579338 100644 --- a/QMUIKit/QMUIComponents/QMUISearchController.h +++ b/QMUIKit/QMUIComponents/QMUISearchController.h @@ -16,6 +16,8 @@ #import "QMUICommonViewController.h" #import "QMUICommonTableViewController.h" +NS_ASSUME_NONNULL_BEGIN + @class QMUIEmptyView; @class QMUISearchController; @@ -32,7 +34,7 @@ * 搜索框文字发生变化时的回调,请自行调用 `[tableView reloadData]` 来更新界面。 * @warning 搜索框文字为空(例如第一次点击搜索框进入搜索状态时,或者文字全被删掉了,或者点击搜索框的×)也会走进来,此时参数searchString为@"",这是为了和系统的UISearchController保持一致 */ -- (void)searchController:(QMUISearchController *)searchController updateResultsForSearchString:(NSString *)searchString; +- (void)searchController:(QMUISearchController *)searchController updateResultsForSearchString:(nullable NSString *)searchString; @optional - (void)willPresentSearchController:(QMUISearchController *)searchController; @@ -56,6 +58,11 @@ */ @interface QMUISearchController : QMUICommonViewController +- (nullable instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; +- (instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil NS_UNAVAILABLE; + +- (instancetype)initWithContentsViewController:(UIViewController *)viewController resultsViewController:(__kindof UIViewController *)resultsViewController NS_DESIGNATED_INITIALIZER; + /** * 在某个指定的 UIViewController 上创建一个与其绑定的 searchController,并指定结果列表的 style。 * @param viewController 要在哪个viewController上添加搜索功能 @@ -70,18 +77,24 @@ @property(nonatomic, weak) id searchResultsDelegate; +/// 内部使用的系统的 UISearchController 的引用 +@property(nonatomic, strong, readonly) UISearchController *searchController; + +/// 等价于 self.searchController.searchResultsController,展示搜索结果的 viewController。若通过 initWithContentsViewController:resultsTableViewStyle: 初始化,则默认的 searchResultsController 为 QMUICommonTableViewController 的子类。 +@property(nonatomic, strong, readonly, nullable) __kindof UIViewController *searchResultsController; + /// 搜索框 @property(nonatomic, strong, readonly) UISearchBar *searchBar; -/// 搜索结果列表 -@property(nonatomic, strong, readonly) QMUITableView *tableView; +/// 搜索结果列表,仅当通过 initWithContentsViewController: 或 initWithContentsViewController:resultsTableViewStyle: 初始化时才有效。 +@property(nonatomic, strong, readonly, nullable) QMUITableView *tableView; /// 在搜索文字为空时会展示的一个 view,通常用于实现“最近搜索”之类的功能。launchView 最终会被布局为撑满搜索框以下的所有空间。 -@property(nonatomic, strong) UIView *launchView; +@property(nonatomic, strong, nullable) UIView *launchView; /// 升起键盘时的半透明遮罩,nil 表示用系统的,非 nil 则用自己的。默认为 nil。 /// @note 如果使用了 launchView 则该属性无效。 -@property(nonatomic, strong) UIColor *dimmingColor; +@property(nonatomic, strong, nullable) UIColor *dimmingColor; /// 控制以无动画的形式进入/退出搜索状态 @property(nonatomic, assign, getter=isActive) BOOL active; @@ -95,6 +108,14 @@ /// 进入搜索状态时是否要把原界面的 navigationBar 推走,默认为 YES @property(nonatomic, assign) BOOL hidesNavigationBarDuringPresentation; + +/// 在展示搜索结果或者 launchView 时是否支持左侧屏幕边缘向右滑退出搜索,默认为 NO +/// @warning 使用截图的方式实现,所以暂不支持横竖屏切换,请自行屏蔽横竖屏场景 +@property(nonatomic, assign) BOOL supportsSwipeToDismissSearch; + +/// 当开启了 supportsSwipeToDismissSearch 则在 willPresentSearchController: 里会创建这个手势对象 +@property(nonatomic, strong, readonly, nullable) UIScreenEdgePanGestureRecognizer *swipeGestureRecognizer; + @end @@ -115,7 +136,7 @@ * * @see QMUITableViewDelegate */ -@property(nonatomic, strong, readonly) QMUISearchController *searchController; +@property(nonatomic, strong, readonly, nullable) QMUISearchController *searchController; /** * 获取当前的 searchBar,注意只有当 `shouldShowSearchBar` 为 `YES` 时才有用 @@ -124,7 +145,7 @@ * * @see QMUITableViewDelegate */ -@property(nonatomic, strong, readonly) UISearchBar *searchBar; +@property(nonatomic, strong, readonly, nullable) UISearchBar *searchBar; /** * 是否应该在显示空界面时自动隐藏搜索框 @@ -143,3 +164,5 @@ - (void)initSearchController; @end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUIComponents/QMUISearchController.m b/QMUIKit/QMUIComponents/QMUISearchController.m index c9fec143..0a77d8c2 100644 --- a/QMUIKit/QMUIComponents/QMUISearchController.m +++ b/QMUIKit/QMUIComponents/QMUISearchController.m @@ -24,6 +24,8 @@ #import "NSObject+QMUI.h" #import "UIView+QMUI.h" #import "UIViewController+QMUI.h" +#import "UISearchController+QMUI.h" +#import "UIGestureRecognizer+QMUI.h" BeginIgnoreDeprecatedWarning @@ -43,7 +45,12 @@ @implementation QMUISearchResultsTableViewController - (void)initTableView { [super initTableView]; + + // UISearchController.searchBar 作为 UITableView.tableHeaderView 时,进入搜索状态,搜索结果列表顶部有一大片空白 + // 不要让系统自适应了,否则在搜索结果(navigationBar 隐藏)push 进入下一级界面(navigationBar 显示)过程中系统自动调整的 contentInset 会跳来跳去 + // https://github.com/Tencent/QMUI_iOS/issues/1473 self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever; + self.tableView.keyboardDismissMode = UIScrollViewKeyboardDismissModeOnDrag; if ([self.delegate respondsToSelector:@selector(didLoadTableViewInSearchResultsTableViewController:)]) { [self.delegate didLoadTableViewInSearchResultsTableViewController:self]; @@ -62,89 +69,25 @@ - (void)viewDidLayoutSubviews { @end -@interface QMUICustomSearchController : UISearchController - -@property(nonatomic, strong) UIView *customDimmingView; -@property(nonatomic, strong) UIColor *dimmingColor; -@end - -@implementation QMUICustomSearchController - -- (instancetype)initWithSearchResultsController:(UIViewController *)searchResultsController { - if (self = [super initWithSearchResultsController:searchResultsController]) { - if (@available(iOS 15.0, *)) { - self.dimsBackgroundDuringPresentation = YES;// iOS 15 开始该默认值为 NO 了,为了保持与旧版本一致的表现,这里改默认值 - } - } - return self; -} - -- (void)setCustomDimmingView:(UIView *)customDimmingView { - if (_customDimmingView != customDimmingView) { - [_customDimmingView removeFromSuperview]; - } - _customDimmingView = customDimmingView; - - self.dimsBackgroundDuringPresentation = !_customDimmingView; - if ([self isViewLoaded]) { - [self addCustomDimmingView]; - } -} - -- (void)viewWillAppear:(BOOL)animated { - [super viewWillAppear:animated]; - [self addCustomDimmingView]; -} - -- (void)addCustomDimmingView { - UIView *superviewOfDimmingView = self.searchResultsController.view.superview; - if (self.customDimmingView && self.customDimmingView.superview != superviewOfDimmingView) { - [superviewOfDimmingView insertSubview:self.customDimmingView atIndex:0]; - [self layoutCustomDimmingView]; - } -} - -- (void)layoutCustomDimmingView { - UIView *searchBarContainerView = nil; - for (UIView *subview in self.view.subviews) { - if ([NSStringFromClass(subview.class) isEqualToString:@"_UISearchBarContainerView"]) { - searchBarContainerView = subview; - break; - } - } - - self.customDimmingView.frame = CGRectInsetEdges(self.customDimmingView.superview.bounds, UIEdgeInsetsMake(searchBarContainerView ? CGRectGetMaxY(searchBarContainerView.frame) : 0, 0, 0, 0)); -} - -- (void)viewDidLayoutSubviews { - [super viewDidLayoutSubviews]; - - if (self.customDimmingView) { - [UIView animateWithDuration:[CATransaction animationDuration] animations:^{ - [self layoutCustomDimmingView]; - }]; - } -} - -@end - -@interface QMUISearchController () - -@property(nonatomic,strong) QMUICustomSearchController *searchController; +@interface QMUISearchController () +@property(nonatomic, strong) UIView *snapshotView; +@property(nonatomic, strong) UIView *snapshotMaskView; +@property(nonatomic, assign) BOOL dismissBySwipe; +@property(nonatomic, assign) BOOL hasSetShowsCancelButton; @end @implementation QMUISearchController -- (instancetype)initWithContentsViewController:(UIViewController *)viewController resultsTableViewStyle:(UITableViewStyle)resultsTableViewStyle { - if (self = [self initWithNibName:nil bundle:nil]) { +- (instancetype)initWithContentsViewController:(UIViewController *)viewController resultsViewController:(__kindof UIViewController *)resultsViewController { + if (self = [super initWithNibName:nil bundle:nil]) { // 将 definesPresentationContext 置为 YES 有两个作用: // 1、保证从搜索结果界面进入子界面后,顶部的searchBar不会依然停留在navigationBar上 // 2、使搜索结果界面的tableView的contentInset.top正确适配searchBar viewController.definesPresentationContext = YES; + [QMUISearchController fixDefinesPresentationContextBug]; - QMUISearchResultsTableViewController *searchResultsViewController = [[QMUISearchResultsTableViewController alloc] initWithStyle:resultsTableViewStyle]; - searchResultsViewController.delegate = self; - self.searchController = [[QMUICustomSearchController alloc] initWithSearchResultsController:searchResultsViewController]; + _searchController = [[UISearchController alloc] initWithSearchResultsController:resultsViewController]; + self.searchController.obscuresBackgroundDuringPresentation = YES;// iOS 15 开始该默认值为 NO 了,为了保持与旧版本一致的表现,这里改默认值 self.searchController.searchResultsUpdater = self; self.searchController.delegate = self; _searchBar = self.searchController.searchBar; @@ -159,10 +102,41 @@ - (instancetype)initWithContentsViewController:(UIViewController *)viewControlle return self; } +- (instancetype)initWithContentsViewController:(UIViewController *)viewController resultsTableViewStyle:(UITableViewStyle)resultsTableViewStyle { + QMUISearchResultsTableViewController *searchResultsViewController = [[QMUISearchResultsTableViewController alloc] initWithStyle:resultsTableViewStyle]; + if (self = [self initWithContentsViewController:viewController resultsViewController:searchResultsViewController]) { + searchResultsViewController.delegate = self; + } + return self; +} + - (instancetype)initWithContentsViewController:(UIViewController *)viewController { return [self initWithContentsViewController:viewController resultsTableViewStyle:UITableViewStylePlain]; } ++ (void)fixDefinesPresentationContextBug { + [QMUIHelper executeBlock:^{ + // 修复当处于搜索状态时被 -[UINavigationController popToRootViewControllerAnimated:] 强制切走界面可能引发内存泄露的问题 + // https://github.com/Tencent/QMUI_iOS/issues/1541 + OverrideImplementation([UIViewController class], @selector(didMoveToParentViewController:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UIViewController *selfObject, UIViewController *parentViewController) { + + // call super + void (*originSelectorIMP)(id, SEL, UIViewController *); + originSelectorIMP = (void (*)(id, SEL, UIViewController *))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, parentViewController); + + if (!parentViewController) { + if (selfObject.definesPresentationContext && selfObject.presentedViewController.presentingViewController == selfObject && [selfObject.presentedViewController isKindOfClass:UISearchController.class]) { + QMUILogWarn(@"QMUISearchController", @"fix #1541, didMoveToParent, %@", selfObject); + [selfObject dismissViewControllerAnimated:NO completion:nil]; + } + } + }; + }); + } oncePerIdentifier:@"QMUISearchController presentation"]; +} + - (void)viewDidLoad { [super viewDidLoad]; // 主动触发 loadView,如果不这么做,那么有可能直到 QMUISearchController 被销毁,这期间 self.searchController 都没有被触发 loadView,然后在 dealloc 时就会报错,提示尝试在释放 self.searchController 时触发了 self.searchController 的 loadView @@ -177,37 +151,7 @@ - (void)setSearchResultsDelegate:(id)searchResults - (void)setDimmingColor:(UIColor *)dimmingColor { _dimmingColor = dimmingColor; - self.searchController.dimmingColor = dimmingColor; - [QMUIHelper executeBlock:^{ - // - [UIDimmingView updateBackgroundColor] - OverrideImplementation(NSClassFromString([NSString qmui_stringByConcat:@"UI", @"Dimming", @"View", nil]), NSSelectorFromString([NSString qmui_stringByConcat:@"update", @"Background", @"Color", nil]), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { - return ^(UIView *selfObject) { - - for (UIView *subview in selfObject.superview.subviews) { - if ([NSStringFromClass(subview.class) isEqualToString:[NSString qmui_stringByConcat:@"_", @"UISearchController", @"View", nil]]) { - UISearchController *searchController = subview.qmui_viewController; - if ([searchController isKindOfClass:UISearchController.class]) { - if ([searchController respondsToSelector:@selector(dimmingColor)]) { - BeginIgnorePerformSelectorLeaksWarning - UIColor *color = [searchController performSelector:@selector(dimmingColor)]; - EndIgnorePerformSelectorLeaksWarning - if (color) { - [selfObject qmui_performSelector:@selector(setDimmingColor:) withArguments:&color, nil]; - } - } - } - - break; - } - } - - // call super - void (*originSelectorIMP)(id, SEL); - originSelectorIMP = (void (*)(id, SEL))originalIMPProvider(); - originSelectorIMP(selfObject, originCMD); - }; - }); - } oncePerIdentifier:@"QMUISearchController dimmingColor"]; + self.searchController.qmui_dimmingColor = dimmingColor; } - (BOOL)isActive { @@ -219,16 +163,38 @@ - (void)setActive:(BOOL)active { } - (void)setActive:(BOOL)active animated:(BOOL)animated { - self.searchController.active = active; + if (!animated) { + [UIView performWithoutAnimation:^{ + self.searchController.active = active; + // animated:NO 的情况下设置 active:NO,取消按钮无法自动消失(系统 bug),所以这里手动管理 + // 如果是 animated:YES 或者 active:YES 则没这个问题 + // 这里修改了 searchBar.showsCancelButton 属性会让 automaticallyShowsCancelButton 变为 NO,且不能在这时候立马把它改为 YES,否则会立马出现取消按钮,所以改为在下一次 willPresentSearchController: 里重置为系统自动管理。 + if (!active && self.searchController.automaticallyShowsCancelButton) { + self.searchController.searchBar.showsCancelButton = NO; + self.hasSetShowsCancelButton = YES; + } + }]; + } else { + self.searchController.active = active; + } } - (UITableView *)tableView { - return ((QMUICommonTableViewController *)self.searchController.searchResultsController).tableView; + if ([self.searchResultsController respondsToSelector:@selector(tableView)]) { + BeginIgnorePerformSelectorLeaksWarning + return [self.searchResultsController performSelector:@selector(tableView)]; + EndIgnorePerformSelectorLeaksWarning + } + return nil; +} + +- (__kindof UIViewController *)searchResultsController { + return self.searchController.searchResultsController; } -- (void)setLaunchView:(UIView *)dimmingView { - _launchView = dimmingView; - self.searchController.customDimmingView = _launchView; +- (void)setLaunchView:(UIView *)launchView { + _launchView = launchView; + self.searchController.qmui_launchView = launchView; } - (BOOL)hidesNavigationBarDuringPresentation { @@ -249,6 +215,113 @@ - (void)setQmui_preferredStatusBarStyleBlock:(UIStatusBarStyle (^)(void))qmui_pr self.searchController.qmui_preferredStatusBarStyleBlock = qmui_preferredStatusBarStyleBlock; } +- (void)handleSwipe:(UIScreenEdgePanGestureRecognizer *)gestureRecognizer { + if (!self.launchView && (!self.searchController.searchResultsController.viewLoaded || self.searchController.searchResultsController.view.hidden)) return; + CGFloat snapshotInitialX = -112; + switch (gestureRecognizer.state) { + case UIGestureRecognizerStatePossible: + return; + case UIGestureRecognizerStateBegan: { + [self.searchController.view endEditing:YES]; + [self.searchController.view.superview insertSubview:self.snapshotView belowSubview:self.searchController.view]; + self.snapshotView.transform = CGAffineTransformMakeTranslation(snapshotInitialX, 0); + self.snapshotMaskView.alpha = 1; + QMUILogInfo(@"QMUISearchController", @"swipeGesture snapshot added to search view"); + } + return; + case UIGestureRecognizerStateChanged: { + CGFloat transition = MIN(MAX(0, [gestureRecognizer translationInView:gestureRecognizer.view].x), CGRectGetWidth(self.searchController.view.superview.bounds)); + self.searchController.view.transform = CGAffineTransformMakeTranslation(transition, 0); + double percent = transition / CGRectGetWidth(self.searchController.view.superview.bounds); + self.snapshotView.transform = CGAffineTransformMakeTranslation(snapshotInitialX * (1 - percent), 0); + self.snapshotMaskView.alpha = 1 - percent; + } + return; + case UIGestureRecognizerStateEnded: { + CGPoint velocity = [gestureRecognizer velocityInView:gestureRecognizer.view]; + if (CGRectGetMinX(self.searchController.view.frame) > CGRectGetWidth(self.searchController.view.superview.bounds) / 4 && velocity.x > 0) { + NSTimeInterval duration = 0.2 * (CGRectGetWidth(self.searchController.view.superview.bounds) - CGRectGetMinX(self.searchController.view.frame)) / CGRectGetWidth(self.searchController.view.superview.bounds); + [UIApplication.sharedApplication beginIgnoringInteractionEvents]; + [UIView animateWithDuration:duration delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{ + self.searchController.view.transform = CGAffineTransformMakeTranslation(CGRectGetWidth(self.searchController.view.superview.bounds), 0); + self.snapshotView.transform = CGAffineTransformIdentity; + self.snapshotMaskView.alpha = 0; + } completion:^(BOOL finished) { + self.dismissBySwipe = YES; + // 盖到最上面,挡住退出搜索过程中可能出现的界面闪烁 + [self.snapshotView removeFromSuperview]; + [UIApplication.sharedApplication.delegate.window addSubview:self.snapshotView]; + QMUILogInfo(@"QMUISearchController", @"swipeGesture snapshot change superview to window"); + self.active = NO; + self.searchController.view.transform = CGAffineTransformIdentity; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.15 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self cleanSnapshotObjects]; + self.dismissBySwipe = NO; + [UIApplication.sharedApplication endIgnoringInteractionEvents]; + }); + }]; + return; + } + } + default: + break; + } + + // reset to active:YES + [UIApplication.sharedApplication beginIgnoringInteractionEvents]; + NSTimeInterval duration = 0.2 * CGRectGetMinX(self.searchController.view.frame) / CGRectGetWidth(self.searchController.view.superview.bounds); + [UIView animateWithDuration:duration delay:0 options:UIViewAnimationOptionCurveEaseInOut animations:^{ + self.searchController.view.transform = CGAffineTransformIdentity; + self.snapshotView.transform = CGAffineTransformMakeTranslation(snapshotInitialX, 0); + self.snapshotMaskView.alpha = 1; + } completion:^(BOOL finished) { + [UIApplication.sharedApplication endIgnoringInteractionEvents]; + QMUILogInfo(@"QMUISearchController", @"swipeGesture cancelled"); + }]; +} + +- (void)createSnapshotObjects { + if (!self.snapshotMaskView) { + self.snapshotMaskView = [[UIView alloc] init]; + self.snapshotMaskView.backgroundColor = [UIColor.blackColor colorWithAlphaComponent:.1]; + } + self.snapshotView = [UIApplication.sharedApplication.delegate.window snapshotViewAfterScreenUpdates:NO]; + self.snapshotMaskView.frame = self.snapshotView.bounds; + [self.snapshotView addSubview:self.snapshotMaskView]; + if (!self.swipeGestureRecognizer) { + _swipeGestureRecognizer = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(handleSwipe:)]; + self.swipeGestureRecognizer.edges = UIRectEdgeLeft; + self.swipeGestureRecognizer.delegate = self; + } + [UIApplication.sharedApplication.delegate.window addGestureRecognizer:self.swipeGestureRecognizer]; +} + +- (void)resetSnapshotObjects { + self.snapshotView.transform = CGAffineTransformIdentity; + [self.snapshotView removeFromSuperview]; +} + +- (void)cleanSnapshotObjects { + [self.snapshotView removeFromSuperview]; + [self.snapshotMaskView removeFromSuperview]; + self.snapshotView = nil; + [UIApplication.sharedApplication.delegate.window removeGestureRecognizer:self.swipeGestureRecognizer]; + QMUILogInfo(@"QMUISearchController", @"swipeGesture clean all objects"); +} + +#pragma mark - + +// 由于手势是加在 window 上的,所以任何时候都可能被触发(比如在搜索结果里弹出 toast 或 present 到新的界面),所以这里要做保护,只有在搜索结果肉眼可见的情况下才响应手势 +- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer { + if (gestureRecognizer == self.swipeGestureRecognizer) { + UIView *targetView = [gestureRecognizer qmui_targetView]; + if (![targetView isDescendantOfView:self.searchController.view]) { + return NO; + } + } + return YES; +} + #pragma mark - QMUIEmptyView - (void)showEmptyView { @@ -287,6 +360,11 @@ - (void)didLoadTableViewInSearchResultsTableViewController:(QMUISearchResultsTab #pragma mark - - (void)updateSearchResultsForSearchController:(UISearchController *)searchController { + // 先触发手势返回再取消,从而让截图添加到屏幕上。然后再点搜索框的×按钮清空列表,此时要保证背后的截图也一起去除 + NSString *text = searchController.searchBar.text; + if (self.supportsSwipeToDismissSearch && !text.length && !searchController.qmui_alwaysShowSearchResultsController) { + [self resetSnapshotObjects]; + } if ([self.searchResultsDelegate respondsToSelector:@selector(searchController:updateResultsForSearchString:)]) { [self.searchResultsDelegate searchController:self updateResultsForSearchString:searchController.searchBar.text]; } @@ -295,6 +373,17 @@ - (void)updateSearchResultsForSearchController:(UISearchController *)searchContr #pragma mark - - (void)willPresentSearchController:(UISearchController *)searchController { + if (self.supportsSwipeToDismissSearch) { + [self createSnapshotObjects]; + QMUILogInfo(@"QMUISearchController", @"swipeGesture added"); + } + + // 走到这里意味着曾经因为 setActive:NO animated:NO 而不得不手动修改 searchBar.showsCancelButton 属性,导致 automaticallyShowsCancelButton 为 NO,系统无法自动显示取消按钮,所以这里在进入搜索前恢复自动管理 + if (self.hasSetShowsCancelButton) { + self.searchController.automaticallyShowsCancelButton = YES; + self.hasSetShowsCancelButton = NO; + } + if (self.searchController.qmui_prefersStatusBarHiddenBlock || self.searchController.qmui_preferredStatusBarStyleBlock) { [self.searchController setNeedsStatusBarAppearanceUpdate]; } @@ -316,6 +405,11 @@ - (void)willDismissSearchController:(UISearchController *)searchController { if ([self.searchResultsDelegate respondsToSelector:@selector(willDismissSearchController:)]) { [self.searchResultsDelegate willDismissSearchController:self]; } + + // 先手势返回触发各种对象的初始化,然后又取消手势,正常点取消按钮退出搜索,此时就不应该看到背后有截图存在了 + if (!self.dismissBySwipe) { + [self cleanSnapshotObjects]; + } } - (void)didDismissSearchController:(UISearchController *)searchController { @@ -325,6 +419,10 @@ - (void)didDismissSearchController:(UISearchController *)searchController { if ([self.searchResultsDelegate respondsToSelector:@selector(didDismissSearchController:)]) { [self.searchResultsDelegate didDismissSearchController:self]; } + + if (self.supportsSwipeToDismissSearch && !self.dismissBySwipe) { + [self cleanSnapshotObjects]; + } } @end @@ -420,3 +518,19 @@ - (void)searchController:(QMUISearchController *)searchController updateResultsF } @end + +@implementation UINavigationController (Search) + +// 修复当处于搜索状态时被 window.rootViewController = xxx 强制切走界面可能引发内存泄露的问题 +// 这种场景会调用 nav 的 dealloc 但不会触发 child 的 didMoveToParentViewController:,所以只能重写 dealloc 处理一遍 +// https://github.com/Tencent/QMUI_iOS/issues/1541 +- (void)dealloc { + [self.childViewControllers.copy enumerateObjectsUsingBlock:^(__kindof UIViewController * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + if (obj.definesPresentationContext && obj.presentedViewController.presentingViewController == obj && [obj.presentedViewController isKindOfClass:UISearchController.class]) { + QMUILogWarn(@"QMUISearchController", @"fix #1541, dealloc, %@", obj); + [obj dismissViewControllerAnimated:NO completion:nil]; + } + }]; +} + +@end diff --git a/QMUIKit/QMUIComponents/QMUISheetPresentation/QMUISheetPresentationNavigationBar.h b/QMUIKit/QMUIComponents/QMUISheetPresentation/QMUISheetPresentationNavigationBar.h new file mode 100644 index 00000000..af674424 --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUISheetPresentation/QMUISheetPresentationNavigationBar.h @@ -0,0 +1,21 @@ +// +// QMUISheetPresentationNavigationBar.h +// QMUIKit +// +// Created by molice on 2024/2/27. +// Copyright © 2024 QMUI Team. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface QMUISheetPresentationNavigationBar : UIView + +@property(nonatomic, strong, nullable) UINavigationItem *navigationItem; + +@property(nonatomic, strong) UILabel *titleLabel; +@property(nonatomic, strong) __kindof UIView *titleView; +@end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUIComponents/QMUISheetPresentation/QMUISheetPresentationNavigationBar.m b/QMUIKit/QMUIComponents/QMUISheetPresentation/QMUISheetPresentationNavigationBar.m new file mode 100644 index 00000000..8109d242 --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUISheetPresentation/QMUISheetPresentationNavigationBar.m @@ -0,0 +1,61 @@ +// +// QMUISheetPresentationNavigationBar.m +// QMUIKit +// +// Created by molice on 2024/2/27. +// Copyright © 2024 QMUI Team. All rights reserved. +// + +#import "QMUISheetPresentationNavigationBar.h" +#import "QMUICore.h" +#import "QMUIButton.h" +#import "QMUINavigationButton.h" + +@interface QMUISheetPresentationNavigationBar () +@property(nonatomic, strong) QMUINavigationButton *backButton; +@property(nonatomic, strong) QMUIButton *leftButton; +@property(nonatomic, strong) QMUIButton *rightButton; +@end + +@implementation QMUISheetPresentationNavigationBar + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self) { + self.backgroundColor = UIColor.whiteColor; + + self.titleLabel = [[UILabel alloc] init]; + if (QMUICMIActivated) { + self.titleLabel.font = NavBarTitleFont; + self.titleLabel.textColor = NavBarTitleColor; + } + } + return self; +} + +- (void)setNavigationItem:(UINavigationItem *)navigationItem { + if (_navigationItem != navigationItem) { + self.titleLabel.text = nil; + [self.titleView removeFromSuperview]; + } + _navigationItem = navigationItem; + if (navigationItem.titleView) { + self.titleView = navigationItem.titleView; + } else if (navigationItem.title.length) { + self.titleLabel.text = navigationItem.title; + self.titleView = self.titleLabel; + } + [self addSubview:self.titleView]; +} + +- (CGSize)sizeThatFits:(CGSize)size { + return CGSizeMake(size.width, 56); +} + +- (void)layoutSubviews { + [super layoutSubviews]; + [self.titleView sizeToFit]; + self.titleView.center = CGPointMake(CGRectGetWidth(self.bounds) / 2, CGRectGetHeight(self.bounds) / 2); +} + +@end diff --git a/QMUIKit/QMUIComponents/QMUISheetPresentation/QMUISheetPresentationSupports.h b/QMUIKit/QMUIComponents/QMUISheetPresentation/QMUISheetPresentationSupports.h new file mode 100644 index 00000000..e68d6eaf --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUISheetPresentation/QMUISheetPresentationSupports.h @@ -0,0 +1,79 @@ +// +// QMUISheetPresentationSupports.h +// QMUIKit +// +// Created by molice on 2024/2/27. +// Copyright © 2024 QMUI Team. All rights reserved. +// + +#import +#import +#import "QMUINavigationController.h" + +NS_ASSUME_NONNULL_BEGIN + +@class QMUISheetPresentationNavigationBar; + +/// 当某个界面以半屏浮层方式显示时,可通过 vc.qmui_sheetPresentation 获取该界面的半屏浮层配置对象,通过该对象来修改浮层的样式、行为。 +/// 业务不应该自己构造一个新实例。 +@interface QMUISheetPresentation : NSObject + +/// 弹出时背后的遮罩颜色,默认为 UIColorMask(若有使用配置表)或 0.35 alpha 的黑色,可通过设为 nil 来去除遮罩。 +@property(nonatomic, strong, nullable) UIColor *dimmingColor; + +/// 是否模态弹出,YES 表示点击遮罩无响应,NO 表示点击遮罩自动关闭面板。默认为 NO。当设置为 YES 时也会同时屏蔽 swipe、pull 手势(你可以手动再打开)。 +@property(nonatomic, assign) BOOL modal; + +/// 是否支持侧滑关闭面板,默认为 YES。 +@property(nonatomic, assign) BOOL supportsSwipeToDismiss; + +/// 是否支持下拉关闭面板,默认为 YES。 +@property(nonatomic, assign) BOOL supportsPullToDismiss; + +/// 是否需要显示浮层顶部的仿原生导航栏(可自动显示 vc.title、vc.navigationItem 按钮),默认为 YES。 +@property(nonatomic, assign) BOOL shouldShowNavigationBar; + +/// 浮层左上角、右上角的圆角值,默认为10。 +@property(nonatomic, assign) CGFloat cornerRadius; + +/// 计算当前浮层在给定宽高下的内容大小,若希望表达无限制,则使用 CGFLOAT_MAX。 +/// 业务不需要考虑 navigationBar、safeAreaInsets,组件会自己加上。 +/// 也不需要考虑最大最小值保护,组件会自己处理。 +/// 若不设置则使用默认宽高(高度固定200pt)。 +@property(nonatomic, copy, nullable) CGSize (^preferredSheetContentSizeBlock)(QMUISheetPresentation *aSheetPresentation, CGSize aContainerSize); + +- (instancetype)init NS_UNAVAILABLE; +@end + +@interface UIViewController (QMUISheetSupports) + +/// 是否以 QMUISheetPresented 方式展示,在 viewDidLoad 及以后的时机都可以使用。 +/// @warning qmui_isPresentedInSheet 为 YES 的情况下,qmui_isPresented 为 NO,请注意区分这两者。 +@property(nonatomic, assign, readonly) BOOL qmui_isPresentedInSheet; + +/// 用于配置当前半屏浮层效果的对象,懒加载,业务如需修改值,直接访问并设置即可。 +/// 注意如果当前界面并非使用半屏浮层方式显示,这个属性依然会返回值。 +@property(nonatomic, strong, readonly) QMUISheetPresentation *qmui_sheetPresentation; + +/// 获取当前浮层里的仿原生导航栏,可对其进行样式、内容等设置,一般在 viewWillAppear: 时进行。 +@property(nonatomic, strong, readonly) QMUISheetPresentationNavigationBar *qmui_sheetPresentationNavigationBar; + +/// 当前浮层的侧滑手势对象,在 viewWillAppear: 及以后的时机都可以使用,业务可以自行修改 .delegate = xxx,但所有方法均需使用 QMUISheetPresentation.supportsSwipeToDismiss 值来判断当前手势是否有效。 +@property(nonatomic, strong, readonly) UIScreenEdgePanGestureRecognizer *qmui_sheetPresentationSwipeGestureRecognizer; + +/// 当前浮层的下拉手势对象,在 viewWillAppear: 及以后的时机都可以使用,业务可以自行修改 .delegate = xxx,但所有方法均需使用 QMUISheetPresentation.supportsPullToDismiss 值来判断当前手势是否有效。 +@property(nonatomic, strong, readonly) UIPanGestureRecognizer *qmui_sheetPresentationPullGestureRecognizer; + +/// 必要时业务可通过该方法主动刷新浮层布局,内部会自动判断当前若正在显示浮层,则以动画形式刷新布局,否则在下一个 runloop 才刷新。 +- (void)qmui_invalidateSheetPresentationLayout; +@end + +@interface QMUINavigationController (QMUISheetSupports) + +/// 将指定界面放到一个导航容器里并以半屏浮层的形式显示出来,浮层的样式、尺寸可通过 rootViewController.qmui_sheetPresentation 来配置。 +/// 构造完直接用系统的 present 方法把返回值显示出来即可。 +/// rootViewController 内部可用标准的 self.navigationController pushXxx/popXxx 写法来切换界面。 +- (instancetype)qmui_initWithSheetRootViewController:(UIViewController *)rootViewController; +@end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUIComponents/QMUISheetPresentation/QMUISheetPresentationSupports.m b/QMUIKit/QMUIComponents/QMUISheetPresentation/QMUISheetPresentationSupports.m new file mode 100644 index 00000000..2af2936f --- /dev/null +++ b/QMUIKit/QMUIComponents/QMUISheetPresentation/QMUISheetPresentationSupports.m @@ -0,0 +1,414 @@ +// +// QMUISheetPresentationSupports.m +// QMUIKit +// +// Created by molice on 2024/2/27. +// Copyright © 2024 QMUI Team. All rights reserved. +// + +#import "QMUISheetPresentationSupports.h" +#import "QMUICore.h" +#import "UIView+QMUI.h" +#import "UIViewController+QMUI.h" +#import "QMUISheetPresentationNavigationBar.h" +#import "QMUIMultipleDelegates.h" + +// QMUISheet 模式下升起半屏的导航时,专用于存放第一个 vc 的带半透明背景的容器,由它负责决定业务 vc 的半屏布局 +@interface QMUISheetRootContainerViewController : UIViewController +@property(nonatomic, strong, readonly) UIControl *dimmingControl; +@property(nonatomic, strong, readonly) UIView *containerView; +@property(nonatomic, strong, readonly) QMUISheetPresentationNavigationBar *navigationBar; +@property(nonatomic, strong, readonly) UIViewController *rootViewController; + +@property(nonatomic, strong) UIPercentDrivenInteractiveTransition *interactiveTransition; +@property(nonatomic, strong) UIScreenEdgePanGestureRecognizer *edgePan; +@property(nonatomic, strong) UIPanGestureRecognizer *pullPan; + +@property(nonatomic, assign) BOOL shouldPerformPresentAnimation; +- (void)layout; +@end + +@interface QMUISheetRootControllerAnimator : NSObject +@property(nonatomic, assign) BOOL isPresenting; +@property(nonatomic, weak) QMUISheetRootContainerViewController *containerViewController; +@end + +@implementation QMUISheetRootControllerAnimator + +- (NSTimeInterval)transitionDuration:(id)transitionContext { + return .25;// 在 viewSafeAreaInsetsDidChange 里也有一个 duration,两者保持一致 +} + +- (void)animateTransition:(id)transitionContext { + + if (self.isPresenting) { + UIView *containerView = transitionContext.containerView; + UIView *toView = [transitionContext viewForKey:UITransitionContextToViewKey];// 这个是 UINavigationController.view + + // 把 layout 独立一个方法,不直接调用 [self.view setNeedsLayout] 是因为后者的做法会影响业务界面生命周期方法的时序(具体参考上方 animateTransition 的注释) + // 此时 nav 里的导航栏等 subviews 已经布局好,但 containerRootVc 尚未被添加到 nav 里,所以它的 safeAreaInsets 不准确(为0),所以无法在此刻就计算出一个准确的浮层高度,所以通过标志位的方式延后到 viewSafeAreaInsetsDidChange 里处理 + self.containerViewController.shouldPerformPresentAnimation = YES; + [containerView addSubview:toView]; + toView.frame = containerView.bounds; + [transitionContext completeTransition:!transitionContext.transitionWasCancelled]; + return; + } + + [UIView qmui_animateWithAnimated:transitionContext.animated duration:[self transitionDuration:transitionContext] delay:0 options:UIViewAnimationOptionCurveEaseOut animations:^{ + self.containerViewController.dimmingControl.alpha = 0; + self.containerViewController.containerView.transform = CGAffineTransformMakeTranslation(0, CGRectGetHeight(self.containerViewController.containerView.frame)); + } completion:^(BOOL finished) { + [transitionContext completeTransition:!transitionContext.transitionWasCancelled]; + }]; +} + +@end + +@implementation QMUISheetRootContainerViewController + +- (instancetype)initWithRootViewController:(UIViewController *)rootViewController { + if (self = [self init]) { + _rootViewController = rootViewController; + [self addChildViewController:rootViewController]; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + _dimmingControl = [[UIControl alloc] init]; + self.dimmingControl.backgroundColor = self.rootViewController.qmui_sheetPresentation.dimmingColor; + self.dimmingControl.alpha = 0; + [self.dimmingControl addTarget:self action:@selector(handleDimmingControlEvent) forControlEvents:UIControlEventTouchUpInside]; + [self.view addSubview:self.dimmingControl]; + + _containerView = [[UIView alloc] init]; + self.containerView.layer.cornerRadius = self.rootViewController.qmui_sheetPresentation.cornerRadius; + self.containerView.layer.maskedCorners = kCALayerMinXMinYCorner|kCALayerMaxXMinYCorner; + self.containerView.layer.cornerCurve = kCACornerCurveContinuous; + self.containerView.clipsToBounds = YES; + [self.view addSubview:self.containerView]; + + [self.containerView addSubview:self.rootViewController.view]; + + _navigationBar = [[QMUISheetPresentationNavigationBar alloc] init]; + self.navigationBar.hidden = !self.rootViewController.qmui_sheetPresentation.shouldShowNavigationBar; + self.navigationBar.navigationItem = self.rootViewController.navigationItem; + [self.containerView addSubview:self.navigationBar]; + + [self.rootViewController didMoveToParentViewController:self]; + + self.edgePan = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(handleEdgePan:)]; + self.edgePan.edges = UIRectEdgeLeft; + self.edgePan.qmui_multipleDelegatesEnabled = YES; + self.edgePan.delegate = self; + [self.view addGestureRecognizer:self.edgePan]; + + self.pullPan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePullPan:)]; + self.pullPan.qmui_multipleDelegatesEnabled = YES; + self.pullPan.delegate = self; + [self.pullPan requireGestureRecognizerToFail:self.edgePan]; + [self.view addGestureRecognizer:self.pullPan]; +} + +- (UINavigationItem *)navigationItem { + return self.rootViewController.navigationItem; +} + +- (void)viewSafeAreaInsetsDidChange { + [super viewSafeAreaInsetsDidChange]; + if (!self.shouldPerformPresentAnimation) return; + + CGFloat bottom = self.view.safeAreaInsets.bottom; + if (IS_NOTCHED_SCREEN && bottom <= 0) return; + + self.dimmingControl.alpha = 0; + [self layout]; + self.containerView.transform = CGAffineTransformMakeTranslation(0, CGRectGetHeight(self.containerView.frame)); + [UIView animateWithDuration:.25 delay:0 options:QMUIViewAnimationOptionsCurveOut animations:^{ + self.dimmingControl.alpha = 1; + self.containerView.transform = CGAffineTransformIdentity; + } completion:nil]; + + self.shouldPerformPresentAnimation = NO; +} + +// 把 layout 独立一个方法,不直接调用 [self.view setNeedsLayout] 是因为后者的做法会影响业务界面生命周期方法的时序(iOS 17 上验证,iOS 15 顺序一致,但两个 layout 方法会调用多两次)。 +// 如果普通 push,时序应该是 viewWillAppear:-viewIsAppearing:-viewWillLayoutSubviews-viewDidLayoutSubviews,而在 viewSafeAreaInsetsDidChange 里做动画前就调用 [self.view setNeedsLayout],时序会变成 viewWillLayoutSubviews-viewDidLayoutSubviews-viewWillAppear:-viewIsAppearing:,这令业务界面无法用一套代码同时兼容普通 push 模式和 sheet 模式。 +- (void)layout { + self.dimmingControl.frame = self.view.bounds; + + CGFloat navigationBarHeight = 0; + if (!self.navigationBar.hidden) { + [self.navigationBar sizeToFit]; + navigationBarHeight = CGRectGetHeight(self.navigationBar.frame); + } + CGFloat maximumWidth = MIN(QMUIHelper.screenSizeFor67InchAndiPhone14Later.width, CGRectGetWidth(self.view.bounds)); + CGFloat maximumHeight = CGRectGetHeight(self.view.bounds); + CGSize size = CGSizeZero; + if (self.rootViewController.qmui_sheetPresentation.preferredSheetContentSizeBlock) { + size = self.rootViewController.qmui_sheetPresentation.preferredSheetContentSizeBlock(self.rootViewController.qmui_sheetPresentation, CGSizeMake(MIN(maximumWidth, CGRectGetWidth(self.view.bounds)), maximumHeight)); + } else { + size = CGSizeMake(maximumWidth, 200);// 随便搞个默认值 + } + if (size.height != CGFLOAT_MAX && !isinf(size.height)) {// 如果业务传过来 CGFLOAT_MAX 则表示它希望撑满高度,此时就不要再进行叠加运算了,否则会因为溢出而产生错误的高度 + size.height = navigationBarHeight + size.height + self.view.safeAreaInsets.bottom; + } + CGSize containerSize = CGSizeMake(MIN(maximumWidth, size.width), MIN(maximumHeight, size.height)); + self.containerView.qmui_frameApplyTransform = CGRectMake(CGFloatGetCenter(CGRectGetWidth(self.view.bounds), containerSize.width), CGRectGetHeight(self.view.bounds) - containerSize.height, containerSize.width, containerSize.height); + if (!self.navigationBar.hidden) { + self.navigationBar.frame = CGRectMake(0, 0, CGRectGetWidth(self.containerView.bounds), navigationBarHeight); + [self.navigationBar setNeedsLayout]; + } + self.rootViewController.view.frame = CGRectMake(0, navigationBarHeight, CGRectGetWidth(self.containerView.bounds), CGRectGetHeight(self.containerView.bounds) - navigationBarHeight); +} + +- (void)viewDidLayoutSubviews { + [super viewDidLayoutSubviews]; + [self layout]; +} + +- (UIInterfaceOrientationMask)supportedInterfaceOrientations { + return self.rootViewController.supportedInterfaceOrientations; +} + +- (BOOL)prefersStatusBarHidden { + return self.rootViewController.prefersStatusBarHidden; +} + +- (UIViewController *)childViewControllerForStatusBarStyle { + return self.rootViewController; +} + +- (UIViewController *)childViewControllerForStatusBarHidden { + return self.rootViewController; +} + +- (UIViewController *)childViewControllerForHomeIndicatorAutoHidden { + return self.rootViewController; +} + +- (UIViewController *)qmui_visibleViewControllerIfExist { + return self.rootViewController; +} + +- (void)handleDimmingControlEvent { + if (!self.rootViewController.qmui_sheetPresentation.modal) { + [self dismissViewControllerAnimated:YES completion:nil]; + } +} + +- (void)handleEdgePan:(UIScreenEdgePanGestureRecognizer *)pan { + CGFloat process = [pan translationInView:pan.view].x / CGRectGetWidth(self.navigationController.view.bounds); + process = MIN(1.0, MAX(0.0, process)); + switch (pan.state) { + case UIGestureRecognizerStateBegan: + self.interactiveTransition = [[UIPercentDrivenInteractiveTransition alloc] init]; + [self dismissViewControllerAnimated:YES completion:nil]; + break; + case UIGestureRecognizerStateChanged: + [self.interactiveTransition updateInteractiveTransition:process]; + break; + case UIGestureRecognizerStateEnded: + case UIGestureRecognizerStateCancelled: { + CGPoint velocity = [pan velocityInView:pan.view]; + BOOL shouldFinish = velocity.x >= 0 && ((velocity.x > 800 && process > 0.1) || (velocity.x <= 800 && process > 0.2)); + if (shouldFinish) { + [self.interactiveTransition finishInteractiveTransition]; + } else { + [self.interactiveTransition cancelInteractiveTransition]; + } + self.interactiveTransition = nil; + } + break; + default: + break; + } +} + +- (void)handlePullPan:(UIPanGestureRecognizer *)pan { + CGFloat process = [pan translationInView:pan.view].y / CGRectGetHeight(self.containerView.frame); + process = MIN(1.0, MAX(0.0, process)); + switch (pan.state) { + case UIGestureRecognizerStateBegan: + self.interactiveTransition = [[UIPercentDrivenInteractiveTransition alloc] init]; + [self dismissViewControllerAnimated:YES completion:nil]; + break; + case UIGestureRecognizerStateChanged: + [self.interactiveTransition updateInteractiveTransition:process]; + break; + case UIGestureRecognizerStateEnded: + case UIGestureRecognizerStateCancelled: { + CGPoint velocity = [pan velocityInView:pan.view]; + BOOL shouldFinish = velocity.y >= 0 && ((velocity.y > 800 && process > 0.1) || (velocity.y <= 800 && process > 0.2)); + if (shouldFinish) { + [self.interactiveTransition finishInteractiveTransition]; + } else { + [self.interactiveTransition cancelInteractiveTransition]; + } + self.interactiveTransition = nil; + } + break; + default: + break; + } +} + +#pragma mark - + +- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer { + if (gestureRecognizer == self.edgePan && !self.rootViewController.qmui_sheetPresentation.supportsSwipeToDismiss) return NO; + if (gestureRecognizer == self.pullPan && !self.rootViewController.qmui_sheetPresentation.supportsPullToDismiss) return NO; + return YES; +} + +- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch { + if (gestureRecognizer != self.edgePan && gestureRecognizer != self.pullPan) { + return YES; + } + // navigationBar 上的按钮优先响应点击,不响应手势 + BOOL result = !([touch.view isDescendantOfView:self.navigationBar] && [touch.view isKindOfClass:UIControl.class]); + return result; +} + +#pragma mark - + +- (BOOL)preferredNavigationBarHidden { + return YES; +} + +- (BOOL)shouldCustomizeNavigationBarTransitionIfHideable { + return YES; +} + +#pragma mark - + +- (id)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source { + QMUISheetRootControllerAnimator *animator = [[QMUISheetRootControllerAnimator alloc] init]; + animator.isPresenting = YES; + animator.containerViewController = self; + return animator; +} + +- (id)animationControllerForDismissedController:(UIViewController *)dismissed { + QMUISheetRootControllerAnimator *animator = [[QMUISheetRootControllerAnimator alloc] init]; + animator.containerViewController = self; + return animator; +} + +- (id)interactionControllerForDismissal:(id)animator { + return self.interactiveTransition; +} + +@end + + +@interface QMUISheetPresentation () + +/// 对应 UINavigationController.rootViewController,也即承载浮层的全屏容器 +@property(nonatomic, weak, nullable) QMUISheetRootContainerViewController *containerViewController; + +/// 对应浮层内正在展示的实际界面 +@property(nonatomic, weak, nullable) UIViewController *rootViewController; +@end + +@implementation QMUISheetPresentation + +- (instancetype)initWithContainerViewController:(QMUISheetRootContainerViewController *)containerViewController { + if (self = [super init]) { + _supportsSwipeToDismiss = YES; + _supportsPullToDismiss = YES; + _shouldShowNavigationBar = YES; + _dimmingColor = QMUICMIActivated ? UIColorMask : [UIColor.blackColor colorWithAlphaComponent:.35]; + _cornerRadius = 10; + + self.containerViewController = containerViewController; + self.rootViewController = self.containerViewController.rootViewController; + } + return self; +} + +- (void)setModal:(BOOL)modal { + _modal = modal; + + // 开启 modal 时关闭手势,业务可手动再打开 + if (modal) { + self.supportsSwipeToDismiss = NO; + self.supportsPullToDismiss = NO; + } +} + +- (void)setShouldShowNavigationBar:(BOOL)shouldShowNavigationBar { + _shouldShowNavigationBar = shouldShowNavigationBar; + self.containerViewController.navigationBar.hidden = !shouldShowNavigationBar; + [self.containerViewController.view setNeedsLayout]; +} + +- (void)setCornerRadius:(CGFloat)cornerRadius { + _cornerRadius = cornerRadius; + self.containerViewController.containerView.layer.cornerRadius = cornerRadius; +} + +@end + +@implementation UIViewController (QMUISheetSupports) + +- (BOOL)qmui_isPresentedInSheet { + return [self.parentViewController isKindOfClass:QMUISheetRootContainerViewController.class]; +} + +static char kAssociatedObjectKey_QMUISheetPresentation; +- (QMUISheetPresentation *)qmui_sheetPresentation { + QMUISheetPresentation *result = (QMUISheetPresentation *)objc_getAssociatedObject(self, &kAssociatedObjectKey_QMUISheetPresentation); + if (!result) { + result = [[QMUISheetPresentation alloc] initWithContainerViewController:nil]; + objc_setAssociatedObject(self, &kAssociatedObjectKey_QMUISheetPresentation, result, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + return result; +} + +- (QMUISheetPresentationNavigationBar *)qmui_sheetPresentationNavigationBar { + return self.qmui_sheetPresentation.containerViewController.navigationBar; +} + +- (UIScreenEdgePanGestureRecognizer *)qmui_sheetPresentationSwipeGestureRecognizer { + return self.qmui_sheetPresentation.containerViewController.edgePan; +} + +- (UIPanGestureRecognizer *)qmui_sheetPresentationPullGestureRecognizer { + return self.qmui_sheetPresentation.containerViewController.pullPan; +} + +- (void)qmui_invalidateSheetPresentationLayout { + if (self.qmui_sheetPresentation.containerViewController.viewLoaded) { + [self.qmui_sheetPresentation.containerViewController.view setNeedsLayout]; + if (self.view.window) { + [UIView animateWithDuration:.25 delay:0 options:QMUIViewAnimationOptionsCurveOut animations:^{ + [self.qmui_sheetPresentation.containerViewController.view layoutIfNeeded]; + } completion:nil]; + } + } +} + +@end + +@implementation QMUINavigationController (QMUISheetSupports) + +- (instancetype)qmui_initWithSheetRootViewController:(UIViewController *)rootViewController { + QMUISheetRootContainerViewController *container = [[QMUISheetRootContainerViewController alloc] initWithRootViewController:rootViewController]; + rootViewController.qmui_sheetPresentation.containerViewController = container; + rootViewController.qmui_sheetPresentation.rootViewController = rootViewController; + + __typeof(self)results = [self initWithRootViewController:container]; + results.modalPresentationStyle = UIModalPresentationCustom; + results.transitioningDelegate = container; + return results; +} + ++ (void)qmuiss_hookViewControllerIfNeeded { + // TODO: navigationItem 变化时更新 navigationBar +// [QMUIHelper executeBlock:^{ +// } oncePerIdentifier:@"QMUISheetPresentation"]; +} + +@end diff --git a/QMUIKit/QMUIComponents/QMUITextField.m b/QMUIKit/QMUIComponents/QMUITextField.m index 3ad8978d..44ce23f9 100644 --- a/QMUIKit/QMUIComponents/QMUITextField.m +++ b/QMUIKit/QMUIComponents/QMUITextField.m @@ -143,6 +143,7 @@ - (NSUInteger)lengthWithString:(NSString *)string { #pragma mark - Positioning Overrides +// 这样写已经可以让 sizeThatFits 时高度加上 textInsets 的值了 - (CGRect)textRectForBounds:(CGRect)bounds { bounds = CGRectInsetEdges(bounds, self.textInsets); CGRect resultRect = [super textRectForBounds:bounds]; @@ -192,9 +193,6 @@ - (BOOL)textField:(QMUITextField *)textField shouldChangeCharactersInRange:(NSRa // 如果是中文输入法正在输入拼音的过程中(markedTextRange 不为 nil),是不应该限制字数的(例如输入“huang”这5个字符,其实只是为了输入“黄”这一个字符),所以在 shouldChange 这里不会限制,而是放在 didChange 那里限制。 if (textField.markedTextRange) { - if ([textField.delegate respondsToSelector:@selector(textField:shouldChangeCharactersInRange:replacementString:originalValue:)]) { - return [textField.delegate textField:textField shouldChangeCharactersInRange:range replacementString:string originalValue:YES]; - } return YES; } @@ -213,9 +211,6 @@ - (BOOL)textField:(QMUITextField *)textField shouldChangeCharactersInRange:(NSRa if (!string.length && range.length > 0) { // 允许删除,这段必须放在上面 #377、#1170 的逻辑后面 - if ([textField.delegate respondsToSelector:@selector(textField:shouldChangeCharactersInRange:replacementString:originalValue:)]) { - return [textField.delegate textField:textField shouldChangeCharactersInRange:range replacementString:string originalValue:YES]; - } return YES; } @@ -273,8 +268,20 @@ - (void)handleTextChangeEvent:(QMUITextField *)textField { if (!textField.markedTextRange) { if ([textField lengthWithString:textField.text] > textField.maximumTextLength) { - textField.text = [textField.text qmui_substringAvoidBreakingUpCharacterSequencesWithRange:NSMakeRange(0, textField.maximumTextLength) lessValue:YES countingNonASCIICharacterAsTwo:textField.shouldCountingNonASCIICharacterAsTwo]; - + NSString *text = nil; + NSInteger lastLength = textField.text.length - NSMaxRange(textField.qmui_selectedRange);// selectedRange 是系统的,所以这里按 shouldCountingNonASCIICharacterAsTwo = NO 来计算 + if (lastLength > 0) { + // 光标在中间就触发了最长文本限制,要从前面截断,不要影响光标后面的原始文本 + NSString *lastText = [textField.text substringFromIndex:NSMaxRange(textField.qmui_selectedRange)]; + NSInteger lastLengthInQMUI = [textField lengthWithString:lastText]; + NSInteger preLengthInQMUI = textField.maximumTextLength - lastLengthInQMUI; + NSString *preText = [textField.text qmui_substringAvoidBreakingUpCharacterSequencesToIndex:preLengthInQMUI lessValue:YES countingNonASCIICharacterAsTwo:textField.shouldCountingNonASCIICharacterAsTwo]; + text = [preText stringByAppendingString:lastText]; + } else { + text = [textField.text qmui_substringAvoidBreakingUpCharacterSequencesWithRange:NSMakeRange(0, textField.maximumTextLength) lessValue:YES countingNonASCIICharacterAsTwo:textField.shouldCountingNonASCIICharacterAsTwo]; + } + textField.text = text; + textField.qmui_selectedRange = NSMakeRange(textField.text.length - lastLength, 0); if ([textField.delegate respondsToSelector:@selector(textField:didPreventTextChangeInRange:replacementString:)]) { [textField.delegate textField:textField didPreventTextChangeInRange:textField.qmui_selectedRange replacementString:nil]; } diff --git a/QMUIKit/QMUIComponents/QMUITextView.h b/QMUIKit/QMUIComponents/QMUITextView.h index 642cd071..f6c59915 100644 --- a/QMUIKit/QMUIComponents/QMUITextView.h +++ b/QMUIKit/QMUIComponents/QMUITextView.h @@ -101,6 +101,11 @@ */ @property(nonatomic, assign) CGFloat maximumHeight; +/** + 在 textView:shouldChangeTextInRange:replacementText: 里可用这个属性判断当前是否点击了删除。特别注意,当输入框为空时继续点删除也会触发,且这种情况只能通过这个属性区分,无法用别的判断方式。 + */ +@property(nonatomic, assign) BOOL isDeletingDuringTextChange; + /** * 控制输入框是否要出现“粘贴”menu * @param sender 触发这次询问事件的来源 diff --git a/QMUIKit/QMUIComponents/QMUITextView.m b/QMUIKit/QMUIComponents/QMUITextView.m index ca98ea5f..0f319661 100644 --- a/QMUIKit/QMUIComponents/QMUITextView.m +++ b/QMUIKit/QMUIComponents/QMUITextView.m @@ -98,6 +98,8 @@ - (void)didInitialize { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleTextChanged:) name:UITextViewTextDidChangeNotification object:nil]; self.postInitializationMethodCalled = YES; + + [self hookKeyboardDeleteEventIfNeeded]; } - (void)dealloc { @@ -368,6 +370,33 @@ - (void)setContentOffset:(CGPoint)contentOffset { } } +- (void)hookKeyboardDeleteEventIfNeeded { + // - [UITextView keyboardInputShouldDelete:] + // - (BOOL) keyboardInputShouldDelete:(id)arg1; + SEL selector = NSSelectorFromString([NSString qmui_stringByConcat:@"keyboard", @"Input", @"ShouldDelete", @":", nil]); + if (![self respondsToSelector:selector]) { + QMUIAssert(NO, @"QMUITextView", @"-[UITextView %@] not found.", NSStringFromSelector(selector)); + return; + } + [QMUIHelper executeBlock:^{ + OverrideImplementation([QMUITextView class], selector, ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^BOOL(QMUITextView *selfObject, id firstArgv) { + + selfObject.isDeletingDuringTextChange = YES; + + // call super + BOOL (*originSelectorIMP)(id, SEL, id); + originSelectorIMP = (BOOL (*)(id, SEL, id))originalIMPProvider(); + BOOL result = originSelectorIMP(selfObject, originCMD, firstArgv);// 这里会触发 shouldChangeText + + selfObject.isDeletingDuringTextChange = NO; + + return result; + }; + }); + } oncePerIdentifier:@"QMUITextView delete"]; +} + #pragma mark - - (BOOL)canPerformAction:(SEL)action withSender:(id)sender { @@ -411,9 +440,6 @@ - (BOOL)textView:(QMUITextView *)textView shouldChangeTextInRange:(NSRange)range // 如果是中文输入法正在输入拼音的过程中(markedTextRange 不为 nil),是不应该限制字数的(例如输入“huang”这5个字符,其实只是为了输入“黄”这一个字符) // 注意当点击了候选词后触发的那一次 textView:shouldChangeTextInRange:replacementText:,此时的 marktedTextRange 依然存在,尚未被清除,所以这种情况下的字符长度限制逻辑会交给 handleTextChanged: 那边处理。 if (textView.markedTextRange) { - if ([textView.delegate respondsToSelector:@selector(textView:shouldChangeTextInRange:replacementText:originalValue:)]) { - return [textView.delegate textView:textView shouldChangeTextInRange:range replacementText:text originalValue:YES]; - } return YES; } @@ -432,9 +458,6 @@ - (BOOL)textView:(QMUITextView *)textView shouldChangeTextInRange:(NSRange)range if (!text.length && range.length > 0) { // 允许删除,这段必须放在上面 #377、#1170 的逻辑后面 - if ([textView.delegate respondsToSelector:@selector(textView:shouldChangeTextInRange:replacementText:originalValue:)]) { - return [textView.delegate textView:textView shouldChangeTextInRange:range replacementText:text originalValue:YES]; - } return YES; } @@ -453,7 +476,7 @@ - (BOOL)textView:(QMUITextView *)textView shouldChangeTextInRange:(NSRange)range if ([textView lengthWithString:allowedText] <= substringLength) { BOOL shouldChange = YES; if ([textView.delegate respondsToSelector:@selector(textView:shouldChangeTextInRange:replacementText:originalValue:)]) { - shouldChange = [textView.delegate textView:textView shouldChangeTextInRange:range replacementText:text originalValue:YES]; + shouldChange = [textView.delegate textView:textView shouldChangeTextInRange:range replacementText:text originalValue:shouldChange]; } if (!shouldChange) { return NO; @@ -477,7 +500,8 @@ - (BOOL)textView:(QMUITextView *)textView shouldChangeTextInRange:(NSRange)range } if ([textView.delegate respondsToSelector:@selector(textView:shouldChangeTextInRange:replacementText:originalValue:)]) { - return [textView.delegate textView:textView shouldChangeTextInRange:range replacementText:text originalValue:YES]; + BOOL delegateValue = [textView.delegate textView:textView shouldChangeTextInRange:range replacementText:text originalValue:YES]; + return delegateValue; } return YES; diff --git a/QMUIKit/QMUIComponents/QMUITheme/QMUIThemePrivate.m b/QMUIKit/QMUIComponents/QMUITheme/QMUIThemePrivate.m index 4ffc1e0e..558dcae2 100644 --- a/QMUIKit/QMUIComponents/QMUITheme/QMUIThemePrivate.m +++ b/QMUIKit/QMUIComponents/QMUITheme/QMUIThemePrivate.m @@ -38,7 +38,6 @@ #import "QMUIImagePreviewView.h" #import "QMUILabel.h" #import "QMUIPopupContainerView.h" -#import "QMUIPopupMenuButtonItem.h" #import "QMUIPopupMenuView.h" #import "QMUITextField.h" #import "QMUITextView.h" @@ -130,10 +129,10 @@ + (void)load { NSStringFromSelector(@selector(maskViewBackgroundColor)), NSStringFromSelector(@selector(borderColor)), NSStringFromSelector(@selector(arrowImage)),], - NSStringFromClass(QMUIPopupMenuButtonItem.class): @[NSStringFromSelector(@selector(highlightedBackgroundColor)),], NSStringFromClass(QMUIPopupMenuView.class): @[NSStringFromSelector(@selector(itemSeparatorColor)), NSStringFromSelector(@selector(sectionSeparatorColor)), - NSStringFromSelector(@selector(itemTitleColor))], + NSStringFromSelector(@selector(sectionSpacingColor)),], + NSStringFromClass(QMUIPopupMenuItemView.class): @[NSStringFromSelector(@selector(highlightedBackgroundColor))], NSStringFromClass(QMUITextField.class): @[NSStringFromSelector(@selector(placeholderColor)),], NSStringFromClass(QMUITextView.class): @[NSStringFromSelector(@selector(placeholderColor)),], NSStringFromClass(QMUIToastBackgroundView.class): @[NSStringFromSelector(@selector(styleColor)),], diff --git a/QMUIKit/QMUIComponents/QMUITheme/UIImage+QMUITheme.h b/QMUIKit/QMUIComponents/QMUITheme/UIImage+QMUITheme.h index a7402793..af9eeaf6 100644 --- a/QMUIKit/QMUIComponents/QMUITheme/UIImage+QMUITheme.h +++ b/QMUIKit/QMUIComponents/QMUITheme/UIImage+QMUITheme.h @@ -22,9 +22,6 @@ NS_ASSUME_NONNULL_BEGIN @required -/// 获取当前 image 的标记名称,仅对 QMUIThemeImage 有效,其他 class 返回 nil。 -@property(nonatomic, copy, readonly) NSString *qmui_name; - /// 获取当前 UIImage 的实际图片(返回的图片必定不是 dynamic image) @property(nonatomic, strong, readonly) UIImage *qmui_rawImage; @@ -74,7 +71,7 @@ NS_ASSUME_NONNULL_BEGIN /** 内部用,标志 QMUIThemeImage 对 UIImage (QMUI) 里使用动态颜色生成动态图片的适配 hook 是否已生效。例如在配置表这种“加载时机特别早”的场景,此时 UIImage (QMUITheme) +load 方法尚未被调用,这些 hook 还没生效,此时如果你使用 [UIImage qmui_imageWithTintColor:dynamicColor] 得到的 image 是无法自动响应 theme 切换的。 */ -@property(class, nonatomic, assign) BOOL qmui_generatorSupportsDynamicColor; +@property(class, nonatomic, assign, readonly) BOOL qmui_generatorSupportsDynamicColor; @end diff --git a/QMUIKit/QMUIComponents/QMUITheme/UIImage+QMUITheme.m b/QMUIKit/QMUIComponents/QMUITheme/UIImage+QMUITheme.m index c7a94bee..35dffade 100644 --- a/QMUIKit/QMUIComponents/QMUITheme/UIImage+QMUITheme.m +++ b/QMUIKit/QMUIComponents/QMUITheme/UIImage+QMUITheme.m @@ -21,7 +21,7 @@ #import "UIImage+QMUI.h" #import -@interface UIImage (QMUITheme) +@interface UIImage () @property(nonatomic, assign) BOOL qmui_shouldUseSystemIMP; + (nullable UIImage *)qmui_dynamicImageWithOriginalImage:(UIImage *)image tintColor:(UIColor *)tintColor originalActionBlock:(UIImage * (^)(UIImage *aImage, UIColor *aTintColor))originalActionBlock; @@ -163,6 +163,10 @@ - (instancetype)init { return ((id (*)(id, SEL))[NSObject instanceMethodForSelector:_cmd])(self, _cmd); } +- (NSString *)qmui_name { + return self.name; +} + - (BOOL)respondsToSelector:(SEL)aSelector { if ([super respondsToSelector:aSelector]) { return YES; @@ -339,10 +343,6 @@ - (UIImage *)imageByApplyingSymbolConfiguration:(UIImageSymbolConfiguration *)co #pragma mark - -- (NSString *)qmui_name { - return self.name; -} - - (UIImage *)qmui_rawImage { if (!_themeProvider) return nil; QMUIThemeManager *manager = [QMUIThemeManagerCenter themeManagerWithName:self.managerName]; @@ -548,10 +548,6 @@ + (nullable UIImage *)qmui_dynamicImageWithOriginalImage:(UIImage *)image tintCo #pragma mark - -- (NSString *)qmui_name { - return nil; -} - - (UIImage *)qmui_rawImage { return self; } diff --git a/QMUIKit/QMUIComponents/QMUITheme/UIView+QMUITheme.m b/QMUIKit/QMUIComponents/QMUITheme/UIView+QMUITheme.m index b258ee7d..32f91c15 100644 --- a/QMUIKit/QMUIComponents/QMUITheme/UIView+QMUITheme.m +++ b/QMUIKit/QMUIComponents/QMUITheme/UIView+QMUITheme.m @@ -152,16 +152,22 @@ - (void)qmui_themeDidChangeByManager:(QMUIThemeManager *)manager identifier:(__k } if ([self isKindOfClass:UITextView.class]) { - UITextView *textView = (UITextView *)self; +#ifdef IOS16_SDK_ALLOWED if (@available(iOS 16.0, *)) { - // iOS 16 里无法通过 setNeedsDisplay 去刷新文本颜色了,所以只能重新把 textColor 设置一遍 - // 测过 textColor 和 typingAttributes[NSForegroundColorAttributeName] 是互通的,所以只操作任意一个即可 - if (textView.textColor.qmui_isQMUIDynamicColor) { - textView.textColor = textView.textColor; + // iOS 16 里使用 TextKit 2 的输入框无法通过 setNeedsDisplay 去刷新文本颜色了,所以改为用这种方式去刷新 + // 以下语句对 iOS 16 里因为访问 UITextView.layoutManager 而回退到 TextKit 1 的输入框无效,但由于 TextKit 1 本来就可以正常刷新,所以没问题。 + // 注意要考虑输入框内可能存在多种颜色的富文本场景 + UITextView *textView = (UITextView *)self; + NSTextRange *textRange = textView.textLayoutManager.textContentManager.documentRange; + if (textRange) { + [textView.textLayoutManager invalidateLayoutForRange:textRange]; } } else { +#endif [self setNeedsDisplay]; +#ifdef IOS16_SDK_ALLOWED } +#endif } // 输入框、搜索框的键盘跟随主题变化 diff --git a/QMUIKit/QMUIComponents/QMUIWindowSizeMonitor.m b/QMUIKit/QMUIComponents/QMUIWindowSizeMonitor.m index 55a1ca55..a926adad 100644 --- a/QMUIKit/QMUIComponents/QMUIWindowSizeMonitor.m +++ b/QMUIKit/QMUIComponents/QMUIWindowSizeMonitor.m @@ -18,7 +18,7 @@ @interface NSObject (QMUIWindowSizeMonitor_Private) -@property(nonatomic, readonly) NSMutableArray *qwsm_windowSizeChangeHandlers; +@property(nonatomic, readonly) NSMutableDictionary *qwsm_windowSizeChangeHandlers; @end @@ -58,21 +58,21 @@ - (void)qmui_addSizeObserverForWindow:(UIWindow *)window handler:(QMUIWindowSize }; void * blockFuncPtr = ((__bridge struct Block_literal *)handler)->__FuncPtr; - for (QMUIWindowSizeObserverHandler handler in self.qwsm_windowSizeChangeHandlers) { + for (QMUIWindowSizeObserverHandler handler in self.qwsm_windowSizeChangeHandlers.allKeys) { // 由于利用 block 的 __FuncPtr 指针来判断同一个实现的 block 过滤掉,防止重复添加监听 if (((__bridge struct Block_literal *)handler)->__FuncPtr == blockFuncPtr) { return; } } - [self.qwsm_windowSizeChangeHandlers addObject:handler]; + self.qwsm_windowSizeChangeHandlers[(id)handler] = [[QMUIWeakObjectContainer alloc] initWithObject:window]; [window qwsm_addSizeObserver:self]; } -- (NSMutableArray *)qwsm_windowSizeChangeHandlers { - NSMutableArray *_handlers = objc_getAssociatedObject(self, _cmd); +- (NSMutableDictionary *)qwsm_windowSizeChangeHandlers { + NSMutableDictionary *_handlers = objc_getAssociatedObject(self, _cmd); if (!_handlers) { - _handlers = [NSMutableArray array]; + _handlers = [[NSMutableDictionary alloc] init]; objc_setAssociatedObject(self, _cmd, _handlers, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } return _handlers; @@ -158,10 +158,11 @@ - (void)qwsm_notifyWithNewSize:(CGSize)newSize { // notify sizeObservers for (NSUInteger i = 0, count = self.qwsm_sizeObservers.count; i < count; i++) { NSObject *object = [self.qwsm_sizeObservers pointerAtIndex:i]; - for (NSUInteger i = 0, count = object.qwsm_windowSizeChangeHandlers.count; i < count; i++) { - QMUIWindowSizeObserverHandler handler = object.qwsm_windowSizeChangeHandlers[i]; - handler(newSize); - } + [object.qwsm_windowSizeChangeHandlers enumerateKeysAndObjectsUsingBlock:^(QMUIWindowSizeObserverHandler _Nonnull key, QMUIWeakObjectContainer * _Nonnull obj, BOOL * _Nonnull stop) { + if (obj.object == self) { + key(newSize); + } + }]; } // send ‘windowDidTransitionToSize:’ to responders for (NSUInteger i = 0, count = self.qwsm_canReceiveWindowDidTransitionToSizeResponders.count; i < count; i++) { diff --git a/QMUIKit/QMUIComponents/QMUIZoomImageView.m b/QMUIKit/QMUIComponents/QMUIZoomImageView.m index 225f79de..9059d605 100644 --- a/QMUIKit/QMUIComponents/QMUIZoomImageView.m +++ b/QMUIKit/QMUIComponents/QMUIZoomImageView.m @@ -396,7 +396,7 @@ - (void)handleDidEndZooming { - (BOOL)enabledZoomImageView { BOOL enabledZoom = YES; - BOOL isLivePhoto = isLivePhoto = !!self.livePhoto; + BOOL isLivePhoto = !!self.livePhoto; if ([self.delegate respondsToSelector:@selector(enabledZoomViewInZoomImageView:)]) { enabledZoom = [self.delegate enabledZoomViewInZoomImageView:self]; } else if (!self.image && !isLivePhoto && !self.videoPlayerItem) { diff --git a/QMUIKit/QMUIComponents/ToastView/QMUIToastView.h b/QMUIKit/QMUIComponents/ToastView/QMUIToastView.h index f4e629c3..61123762 100644 --- a/QMUIKit/QMUIComponents/ToastView/QMUIToastView.h +++ b/QMUIKit/QMUIComponents/ToastView/QMUIToastView.h @@ -111,7 +111,7 @@ typedef NS_ENUM(NSInteger, QMUIToastViewPosition) { /** * 会盖住整个superView,防止手指可以点击到ToastView下面的内容,默认透明。 */ -@property(nonatomic, strong, readonly) UIView *maskView; +@property(nonatomic, strong, readonly) UIView *dimmingView; /**s * 承载Toast内容的UIView,可以自定义并赋值给contentView。如果contentView需要跟随ToastView的tintColor变化而变化,可以重写自定义view的`tintColorDidChange`来实现。默认使用`QMUIToastContentView`实现。 diff --git a/QMUIKit/QMUIComponents/ToastView/QMUIToastView.m b/QMUIKit/QMUIComponents/ToastView/QMUIToastView.m index e05c727d..af83e6f2 100644 --- a/QMUIKit/QMUIComponents/ToastView/QMUIToastView.m +++ b/QMUIKit/QMUIComponents/ToastView/QMUIToastView.m @@ -74,9 +74,9 @@ - (void)didInitialize { self.backgroundColor = UIColorClear; self.layer.allowsGroupOpacity = NO; - _maskView = [[UIView alloc] init]; - self.maskView.backgroundColor = UIColorClear; - [self addSubview:self.maskView]; + _dimmingView = [[UIView alloc] init]; + self.dimmingView.backgroundColor = UIColorClear; + [self addSubview:self.dimmingView]; [self registerNotifications]; } @@ -144,7 +144,7 @@ - (void)layoutSubviews { [super layoutSubviews]; self.frame = self.parentView.bounds; - self.maskView.frame = self.bounds; + self.dimmingView.frame = self.bounds; CGFloat contentWidth = CGRectGetWidth(self.parentView.bounds); CGFloat contentHeight = CGRectGetHeight(self.parentView.bounds); diff --git a/QMUIKit/QMUICore/QMUICommonDefines.h b/QMUIKit/QMUICore/QMUICommonDefines.h index 17b9e739..8f77b6d4 100644 --- a/QMUIKit/QMUICore/QMUICommonDefines.h +++ b/QMUIKit/QMUICore/QMUICommonDefines.h @@ -71,6 +71,16 @@ #define IOS16_SDK_ALLOWED YES #endif +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 170000 +/// 当前编译使用的 Base SDK 版本为 iOS 17.0 及以上 +#define IOS17_SDK_ALLOWED YES +#endif + +#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 180000 +/// 当前编译使用的 Base SDK 版本为 iOS 18.0 及以上 +#define IOS18_SDK_ALLOWED YES +#endif + #pragma mark - Clang #define ArgumentToString(macro) #macro @@ -142,6 +152,8 @@ #define IS_67INCH_SCREEN [QMUIHelper is67InchScreen] /// iPhone XS Max #define IS_65INCH_SCREEN [QMUIHelper is65InchScreen] +/// iPhone 14 Pro / 15 Pro +#define IS_61INCH_SCREEN_AND_IPHONE14PRO [QMUIHelper is61InchScreenAndiPhone14ProLater] /// iPhone 12 / 12 Pro #define IS_61INCH_SCREEN_AND_IPHONE12 [QMUIHelper is61InchScreenAndiPhone12Later] /// iPhone XR @@ -167,6 +179,9 @@ /// 是否放大模式(iPhone 6及以上的设备支持放大模式,iPhone X 除外) #define IS_ZOOMEDMODE [QMUIHelper isZoomedMode] +/// 当前设备是否拥有灵动岛 +#define IS_DYNAMICISLAND_DEVICE [QMUIHelper isDynamicIslandDevice] + #pragma mark - 变量-布局相关 /// 获取一个像素 @@ -198,7 +213,7 @@ #define NavigationContentTop (StatusBarHeight + NavigationBarHeight) /// 同上,这里用于获取它的静态常量值 -#define NavigationContentTopConstant (StatusBarHeightConstant + NavigationBarHeight) +#define NavigationContentTopConstant (QMUIHelper.navigationBarMaxYConstant) /// 判断当前是否是处于分屏模式的 iPad 或 iOS 16.1 的台前调度模式 #define IS_SPLIT_SCREEN_IPAD (IS_IPAD && APPLICATION_WIDTH != SCREEN_WIDTH) @@ -303,7 +318,7 @@ AddAccessibilityHint(NSObject *obj, NSString *hint) { /// 与 NSAssert 的差异在于,当你使用 NSAssert 时,整条语句默认不会出现在 Release 包里,但 QMUIAssert 依然会存在。 /// 用法:QMUIAssert(a != b, @"UIView (QMUI)", @"xxxx") /// 用法:QMUIAssert(a != b, @"UIView (QMUI)", @"%@, xxx", @"xxx") -#define QMUIAssert(_condition, _categoryName, ...) ({if (!(_condition)) {QMUILogWarn(_categoryName, __VA_ARGS__);if (QMUICMIActivated && !ShouldPrintQMUIWarnLogToConsole) {NSAssert(NO, __VA_ARGS__);}}}) +#define QMUIAssert(_condition, _categoryName, ...) ({if (!(_condition)) {QMUILogWarn(_categoryName, __VA_ARGS__);if (!QMUICMIActivated || !ShouldPrintQMUIWarnLogToConsole) {NSAssert(NO, __VA_ARGS__);}}}) #pragma mark - Selector @@ -326,11 +341,12 @@ setterWithGetter(SEL getter) { /** * 某些地方可能会将 CGFLOAT_MIN 作为一个数值参与计算(但其实 CGFLOAT_MIN 更应该被视为一个标志位而不是数值),可能导致一些精度问题,所以提供这个方法快速将 CGFLOAT_MIN 转换为 0 + * 某些情况可能计算出来是0.0000000x,也靠这个方法抹去尾数。 * issue: https://github.com/Tencent/QMUI_iOS/issues/203 */ CG_INLINE CGFloat removeFloatMin(CGFloat floatValue) { - return floatValue == CGFLOAT_MIN ? 0 : floatValue; + return fabs(floatValue) <= 0.001 ? 0 : floatValue; } /** @@ -340,9 +356,19 @@ removeFloatMin(CGFloat floatValue) { */ CG_INLINE CGFloat flatSpecificScale(CGFloat floatValue, CGFloat scale) { + if (isinf(floatValue) || floatValue == CGFLOAT_MAX) return floatValue; floatValue = removeFloatMin(floatValue); scale = scale ?: ScreenScale; - CGFloat flattedValue = ceil(floatValue * scale) / scale; + // 这里因为浮点精度的问题,可能会出现一些偏差,例如 161.66666666666669 算出来可能是162,161.66666666666666 算出来是161.66666666667,为了解决这种场景,这里同时用 ceil 和 round 算一遍再取最接近的那个结果 + NSInteger pixelValue1 = ceil(floatValue * scale); + NSInteger pixelValue2 = round(floatValue * scale); + NSInteger pixelValue = 0; + if (fabs(pixelValue1 - floatValue) <= fabs(pixelValue2 - floatValue)) { + pixelValue = pixelValue1; + } else { + pixelValue = pixelValue2; + } + CGFloat flattedValue = pixelValue / scale; return flattedValue; } diff --git a/QMUIKit/QMUICore/QMUIConfiguration.h b/QMUIKit/QMUICore/QMUIConfiguration.h index 7ad84adb..872cee9a 100644 --- a/QMUIKit/QMUICore/QMUIConfiguration.h +++ b/QMUIKit/QMUICore/QMUIConfiguration.h @@ -261,15 +261,11 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, assign) UIEdgeInsets badgeContentEdgeInsets; @property(nonatomic, assign) CGPoint badgeOffset; @property(nonatomic, assign) CGPoint badgeOffsetLandscape; -@property(nonatomic, assign) CGPoint badgeCenterOffset DEPRECATED_MSG_ATTRIBUTE("请改为使用 badgeOffset"); -@property(nonatomic, assign) CGPoint badgeCenterOffsetLandscape DEPRECATED_MSG_ATTRIBUTE("请改为使用 badgeOffsetLandscape"); @property(nonatomic, strong, nullable) UIColor *updatesIndicatorColor; @property(nonatomic, assign) CGSize updatesIndicatorSize; @property(nonatomic, assign) CGPoint updatesIndicatorOffset; @property(nonatomic, assign) CGPoint updatesIndicatorOffsetLandscape; -@property(nonatomic, assign) CGPoint updatesIndicatorCenterOffset DEPRECATED_MSG_ATTRIBUTE("请改为使用 updatesIndicatorOffset"); -@property(nonatomic, assign) CGPoint updatesIndicatorCenterOffsetLandscape DEPRECATED_MSG_ATTRIBUTE("请改为使用 updatesIndicatorOffsetLandscape"); #pragma mark - Others @@ -283,7 +279,6 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, assign) BOOL navigationBarHiddenInitially; @property(nonatomic, assign) BOOL shouldFixTabBarSafeAreaInsetsBug; @property(nonatomic, assign) BOOL shouldFixSearchBarMaskViewLayoutBug; -@property(nonatomic, assign) BOOL sendAnalyticsToQMUITeam; @property(nonatomic, assign) BOOL dynamicPreferredValueForIPad; @property(nonatomic, assign) BOOL ignoreKVCAccessProhibited API_AVAILABLE(ios(13.0)); @property(nonatomic, assign) BOOL adjustScrollIndicatorInsetsByContentInsetAdjustment API_AVAILABLE(ios(13.0)); diff --git a/QMUIKit/QMUICore/QMUIConfiguration.m b/QMUIKit/QMUICore/QMUIConfiguration.m index 8a320046..ef74deb8 100644 --- a/QMUIKit/QMUICore/QMUIConfiguration.m +++ b/QMUIKit/QMUICore/QMUIConfiguration.m @@ -119,31 +119,11 @@ - (void)applyInitialTemplate { } } - if (IS_DEBUG && self.sendAnalyticsToQMUITeam) { - [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidFinishLaunchingNotification object:nil queue:[NSOperationQueue new] usingBlock:^(NSNotification * _Nonnull note) { - // 这里根据是否能成功获取到 classesref 来统计信息,以供后续确认对 classesref 为 nil 的保护是否真的必要 - [self sendAnalyticsWithQuery:classes ? @"findByObjc=true" : nil]; - }]; - } - if (classes) free(classes); QMUI_hasAppliedInitialTemplate = YES; } -- (void)sendAnalyticsWithQuery:(NSString *)query { - NSString *identifier = [NSBundle mainBundle].bundleIdentifier.qmui_stringByEncodingUserInputQuery; - NSString *displayName = ((NSString *)([NSBundle mainBundle].infoDictionary[@"CFBundleDisplayName"] ?: [NSBundle mainBundle].infoDictionary[@"CFBundleName"])).qmui_stringByEncodingUserInputQuery; - NSString *QMUIVersion = QMUI_VERSION.qmui_stringByEncodingUserInputQuery;// 如果不以 framework 方式引入 QMUI 的话,是无法通过 CFBundleShortVersionString 获取到 QMUI 所在的 bundle 的版本号的,所以这里改为用脚本生成的变量来获取 - NSString *queryString = [NSString stringWithFormat:@"appId=%@&appName=%@&version=%@&platform=iOS", identifier, displayName, QMUIVersion]; - if (query.length > 0) queryString = [NSString stringWithFormat:@"%@&%@", queryString, query]; - NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"https://qmuiteam.com/analytics/usageReport"]]; - request.HTTPMethod = @"POST"; - request.HTTPBody = [queryString dataUsingEncoding:NSUTF8StringEncoding]; - NSURLSession *session = [NSURLSession sharedSession]; - [[session dataTaskWithRequest:request] resume]; -} - #pragma mark - Initialize default values - (void)initDefaultConfiguration { @@ -193,12 +173,16 @@ - (void)initDefaultConfiguration { self.navBarHighlightedAlpha = 0.2f; self.navBarDisabledAlpha = 0.2f; self.sizeNavBarBackIndicatorImageAutomatically = YES; - self.navBarCloseButtonImage = [UIImage qmui_imageWithShape:QMUIImageShapeNavClose size:CGSizeMake(16, 16) tintColor:self.navBarTintColor]; - self.navBarLoadingMarginRight = 3; self.navBarAccessoryViewMarginLeft = 5; self.navBarActivityIndicatorViewStyle = UIActivityIndicatorViewStyleGray; - self.navBarAccessoryViewTypeDisclosureIndicatorImage = [[UIImage qmui_imageWithShape:QMUIImageShapeTriangle size:CGSizeMake(8, 5) tintColor:self.navBarTitleColor] qmui_imageWithOrientation:UIImageOrientationDown]; + + // XCTest 会在 dispatch_once 里访问 UIScreen 引发死锁,所以屏蔽掉 + // https://github.com/Tencent/QMUI_iOS/issues/1479 + if (!IS_XCTEST) { + self.navBarCloseButtonImage = [UIImage qmui_imageWithShape:QMUIImageShapeNavClose size:CGSizeMake(16, 16) tintColor:self.navBarTintColor]; + self.navBarAccessoryViewTypeDisclosureIndicatorImage = [[UIImage qmui_imageWithShape:QMUIImageShapeTriangle size:CGSizeMake(8, 5) tintColor:self.navBarTitleColor] qmui_imageWithOrientation:UIImageOrientationDown]; + } #pragma mark - Toolbar @@ -277,13 +261,14 @@ - (void)initDefaultConfiguration { self.shouldPrintDefaultLog = YES; self.shouldPrintInfoLog = YES; self.shouldPrintWarnLog = YES; - self.shouldPrintQMUIWarnLogToConsole = IS_DEBUG; + self.shouldPrintQMUIWarnLogToConsole = IS_DEBUG && !IS_XCTEST; #pragma mark - QMUIBadge - self.badgeOffset = QMUIBadgeInvalidateOffset; - self.badgeOffsetLandscape = QMUIBadgeInvalidateOffset; - self.updatesIndicatorOffset = QMUIBadgeInvalidateOffset; - self.updatesIndicatorOffsetLandscape = QMUIBadgeInvalidateOffset; + self.badgeOffset = CGPointMake(-9, 11); + self.badgeOffsetLandscape = CGPointMake(-9, 6); + self.updatesIndicatorSize = CGSizeMake(7, 7); + self.updatesIndicatorOffset = CGPointMake(4, self.updatesIndicatorSize.height); + self.updatesIndicatorOffsetLandscape = self.updatesIndicatorOffset; #pragma mark - Others @@ -291,7 +276,6 @@ - (void)initDefaultConfiguration { self.needsBackBarButtonItemTitle = YES; self.preventConcurrentNavigationControllerTransitions = YES; self.shouldFixTabBarSafeAreaInsetsBug = YES; - self.sendAnalyticsToQMUITeam = YES; } #pragma mark - Switch Setter diff --git a/QMUIKit/QMUICore/QMUIConfigurationMacros.h b/QMUIKit/QMUICore/QMUIConfigurationMacros.h index 7fc368a3..2ecfa753 100644 --- a/QMUIKit/QMUICore/QMUIConfigurationMacros.h +++ b/QMUIKit/QMUICore/QMUIConfigurationMacros.h @@ -250,15 +250,11 @@ #define BadgeContentEdgeInsets [QMUICMI badgeContentEdgeInsets] #define BadgeOffset [QMUICMI badgeOffset] #define BadgeOffsetLandscape [QMUICMI badgeOffsetLandscape] -#define BadgeCenterOffset [QMUICMI badgeCenterOffset] -#define BadgeCenterOffsetLandscape [QMUICMI badgeCenterOffsetLandscape] #define UpdatesIndicatorColor [QMUICMI updatesIndicatorColor] #define UpdatesIndicatorSize [QMUICMI updatesIndicatorSize] #define UpdatesIndicatorOffset [QMUICMI updatesIndicatorOffset] #define UpdatesIndicatorOffsetLandscape [QMUICMI updatesIndicatorOffsetLandscape] -#define UpdatesIndicatorCenterOffset [QMUICMI updatesIndicatorCenterOffset] -#define UpdatesIndicatorCenterOffsetLandscape [QMUICMI updatesIndicatorCenterOffsetLandscape] #pragma mark - Others @@ -272,7 +268,6 @@ #define NavigationBarHiddenInitially [QMUICMI navigationBarHiddenInitially] // preferredNavigationBarHidden 的初始值,默认为NO #define ShouldFixTabBarSafeAreaInsetsBug [QMUICMI shouldFixTabBarSafeAreaInsetsBug] // 是否要对 iOS 11 及以后的版本修复当存在 UITabBar 时,UIScrollView 的 inset.bottom 可能错误的 bug(issue #218 #934),默认为 YES #define ShouldFixSearchBarMaskViewLayoutBug [QMUICMI shouldFixSearchBarMaskViewLayoutBug] // 是否自动修复 UISearchController.searchBar 被当作 tableHeaderView 使用时可能出现的布局 bug(issue #950) -#define SendAnalyticsToQMUITeam [QMUICMI sendAnalyticsToQMUITeam] // 是否允许在 DEBUG 模式下上报 Bundle Identifier 和 Display Name 给 QMUI 统计用 #define DynamicPreferredValueForIPad [QMUICMI dynamicPreferredValueForIPad] // 当 iPad 处于 Slide Over 或 Split View 分屏模式下,宏 `PreferredValueForXXX` 是否把 iPad 视为某种屏幕宽度近似的 iPhone 来取值。 #define IgnoreKVCAccessProhibited [QMUICMI ignoreKVCAccessProhibited] // 是否全局忽略 iOS 13 对 KVC 访问 UIKit 私有属性的限制 #define AdjustScrollIndicatorInsetsByContentInsetAdjustment [QMUICMI adjustScrollIndicatorInsetsByContentInsetAdjustment] // 当将 UIScrollView.contentInsetAdjustmentBehavior 设为 UIScrollViewContentInsetAdjustmentNever 时,是否自动将 UIScrollView.automaticallyAdjustsScrollIndicatorInsets 设为 NO,以保证原本在 iOS 12 下的代码不用修改就能在 iOS 13 下正常控制滚动条的位置。 diff --git a/QMUIKit/QMUICore/QMUIHelper.h b/QMUIKit/QMUICore/QMUIHelper.h index 3bafb08c..1129501b 100644 --- a/QMUIKit/QMUICore/QMUIHelper.h +++ b/QMUIKit/QMUICore/QMUIHelper.h @@ -19,9 +19,6 @@ NS_ASSUME_NONNULL_BEGIN -// TODO: molice 等废弃 qmui_badgeCenterOffset 系列接口后再删除 -extern const CGPoint QMUIBadgeInvalidateOffset; - @interface QMUIHelper : NSObject + (instancetype)sharedInstance; @@ -166,6 +163,9 @@ extern const CGPoint QMUIBadgeInvalidateOffset; /// @NEW_DEVICE_CHECKER @property(class, nonatomic, readonly) BOOL isRegularScreen; +/// iPhone 16 Pro Max +@property(class, nonatomic, readonly) BOOL is69InchScreen; + /// iPhone 14 Pro Max @property(class, nonatomic, readonly) BOOL is67InchScreenAndiPhone14Later; @@ -175,9 +175,15 @@ extern const CGPoint QMUIBadgeInvalidateOffset; /// iPhone XS Max / 11 Pro Max @property(class, nonatomic, readonly) BOOL is65InchScreen; +/// iPhone 16 Pro +@property(class, nonatomic, readonly) BOOL is63InchScreen; + /// iPhone 12 / 12 Pro @property(class, nonatomic, readonly) BOOL is61InchScreenAndiPhone12Later; +/// iPhone 14 Pro / 15 Pro +@property(class, nonatomic, readonly) BOOL is61InchScreenAndiPhone14ProLater; + /// iPhone XR / 11 @property(class, nonatomic, readonly) BOOL is61InchScreen; @@ -199,9 +205,12 @@ extern const CGPoint QMUIBadgeInvalidateOffset; /// iPhone 4 @property(class, nonatomic, readonly) BOOL is35InchScreen; +@property(class, nonatomic, readonly) CGSize screenSizeFor69Inch; @property(class, nonatomic, readonly) CGSize screenSizeFor67InchAndiPhone14Later; @property(class, nonatomic, readonly) CGSize screenSizeFor67Inch; @property(class, nonatomic, readonly) CGSize screenSizeFor65Inch; +@property(class, nonatomic, readonly) CGSize screenSizeFor63Inch; +@property(class, nonatomic, readonly) CGSize screenSizeFor61InchAndiPhone14ProLater; @property(class, nonatomic, readonly) CGSize screenSizeFor61InchAndiPhone12Later; @property(class, nonatomic, readonly) CGSize screenSizeFor61Inch; @property(class, nonatomic, readonly) CGSize screenSizeFor58Inch; @@ -224,6 +233,10 @@ extern const CGPoint QMUIBadgeInvalidateOffset; /// @NEW_DEVICE_CHECKER @property(class, nonatomic, readonly) BOOL isZoomedMode; +/// 当前设备是否拥有灵动岛 +/// @NEW_DEVICE_CHECKER +@property(class, nonatomic, readonly) BOOL isDynamicIslandDevice; + /** 在 iPad 分屏模式下可获得实际运行区域的窗口大小,如需适配 iPad 分屏,建议用这个方法来代替 [UIScreen mainScreen].bounds.size @return 应用运行的窗口大小 @@ -231,10 +244,16 @@ extern const CGPoint QMUIBadgeInvalidateOffset; @property(class, nonatomic, readonly) CGSize applicationSize; /** - 静态的导航栏高度,在导航栏不可见时也会根据机型返回导航栏的固定高度 + 静态的状态栏高度,在状态栏不可见时也会根据机型返回状态栏的固定高度 + @NEW_DEVICE_CHECKER */ @property(class, nonatomic, readonly) CGFloat statusBarHeightConstant; +/** + 静态的导航栏高度,在导航栏不可见时也会根据机型返回导航栏的固定高度 + */ +@property(class, nonatomic, readonly) CGFloat navigationBarMaxYConstant; + @end @interface QMUIHelper (UIApplication) @@ -270,4 +289,15 @@ extern const CGPoint QMUIBadgeInvalidateOffset; @end +@interface QMUIHelper (Text) + +/** + 该方法计算一个 baselineOffset,使得指定字体的文本在指定高度里能达到视觉上的垂直居中(系统默认是底对齐)。 + @param height 单行文本占据的高度,通常可传入文本的 lineHeight 或者 UILabel 的 height。 + @param font 当前文本的字体。 + @return 可使文本垂直居中的 baselineOffset 偏移值,正值往上,负值往下。注意如果某段 NSAttributedString 通过 NSParagraphStyle 指定了行高,则负值的 baselineOffset 对其无效。 + */ ++ (CGFloat)baselineOffsetWhenVerticalAlignCenterInHeight:(CGFloat)height withFont:(UIFont *)font; +@end + NS_ASSUME_NONNULL_END diff --git a/QMUIKit/QMUICore/QMUIHelper.m b/QMUIKit/QMUICore/QMUIHelper.m index 3d721513..c313d23b 100644 --- a/QMUIKit/QMUICore/QMUIHelper.m +++ b/QMUIKit/QMUICore/QMUIHelper.m @@ -20,11 +20,11 @@ #import "NSString+QMUI.h" #import "UIInterface+QMUI.h" #import "NSObject+QMUI.h" +#import "NSArray+QMUI.h" #import #import #import -const CGPoint QMUIBadgeInvalidateOffset = {-1000, -1000}; NSString *const kQMUIResourcesBundleName = @"QMUIResources"; @interface _QMUIPortraitViewController : UIViewController @@ -243,10 +243,15 @@ + (NSString *)deviceModel { return [NSString stringWithFormat:@"%s", getenv("SIMULATOR_MODEL_IDENTIFIER")]; } - // See https://www.theiphonewiki.com/wiki/Models for identifiers - struct utsname systemInfo; - uname(&systemInfo); - return [NSString stringWithCString:systemInfo.machine encoding:NSUTF8StringEncoding]; + // See https://gist.github.com/adamawolf/3048717 for identifiers + static dispatch_once_t onceToken; + static NSString *model; + dispatch_once(&onceToken, ^{ + struct utsname systemInfo; + uname(&systemInfo); + model = [NSString stringWithCString:systemInfo.machine encoding:NSUTF8StringEncoding]; + }); + return model; } + (NSString *)deviceName { @@ -260,7 +265,7 @@ + (NSString *)deviceName { } NSDictionary *dict = @{ - // See https://www.theiphonewiki.com/wiki/Models + // See https://gist.github.com/adamawolf/3048717 @"iPhone1,1" : @"iPhone 1G", @"iPhone1,2" : @"iPhone 3G", @"iPhone2,1" : @"iPhone 3GS", @@ -309,6 +314,14 @@ + (NSString *)deviceName { @"iPhone14,8" : @"iPhone 14 Plus", @"iPhone15,2" : @"iPhone 14 Pro", @"iPhone15,3" : @"iPhone 14 Pro Max", + @"iPhone15,4" : @"iPhone 15", + @"iPhone15,5" : @"iPhone 15 Plus", + @"iPhone16,1" : @"iPhone 15 Pro", + @"iPhone16,2" : @"iPhone 15 Pro Max", + @"iPhone17,1" : @"iPhone 16 Pro", + @"iPhone17,2" : @"iPhone 16 Pro Max", + @"iPhone17,3" : @"iPhone 16", + @"iPhone17,4" : @"iPhone 16 Plus", @"iPad1,1" : @"iPad 1", @"iPad2,1" : @"iPad 2 (WiFi)", @@ -381,6 +394,18 @@ + (NSString *)deviceName { @"iPad13,11" : @"iPad Pro (12.9 inch, 5th generation)", @"iPad14,1" : @"iPad mini (6th generation)", @"iPad14,2" : @"iPad mini (6th generation)", + @"iPad14,3" : @"iPad Pro 11 inch 4th Gen", + @"iPad14,4" : @"iPad Pro 11 inch 4th Gen", + @"iPad14,5" : @"iPad Pro 12.9 inch 6th Gen", + @"iPad14,6" : @"iPad Pro 12.9 inch 6th Gen", + @"iPad14,8" : @"iPad Air 6th Gen", + @"iPad14,9" : @"iPad Air 6th Gen", + @"iPad14,10" : @"iPad Air 7th Gen", + @"iPad14,11" : @"iPad Air 7th Gen", + @"iPad16,3" : @"iPad Pro 11 inch 5th Gen", + @"iPad16,4" : @"iPad Pro 11 inch 5th Gen", + @"iPad16,5" : @"iPad Pro 12.9 inch 7th Gen", + @"iPad16,6" : @"iPad Pro 12.9 inch 7th Gen", @"iPod1,1" : @"iPod touch 1", @"iPod2,1" : @"iPod touch 2", @@ -419,6 +444,24 @@ + (NSString *)deviceName { @"Watch6,2" : @"Apple Watch Series 6 44mm", @"Watch6,3" : @"Apple Watch Series 6 40mm", @"Watch6,4" : @"Apple Watch Series 6 44mm", + @"Watch6,6" : @"Apple Watch Series 7 41mm case (GPS)", + @"Watch6,7" : @"Apple Watch Series 7 45mm case (GPS)", + @"Watch6,8" : @"Apple Watch Series 7 41mm case (GPS+Cellular)", + @"Watch6,9" : @"Apple Watch Series 7 45mm case (GPS+Cellular)", + @"Watch6,10" : @"Apple Watch SE 40mm case (GPS)", + @"Watch6,11" : @"Apple Watch SE 44mm case (GPS)", + @"Watch6,12" : @"Apple Watch SE 40mm case (GPS+Cellular)", + @"Watch6,13" : @"Apple Watch SE 44mm case (GPS+Cellular)", + @"Watch6,14" : @"Apple Watch Series 8 41mm case (GPS)", + @"Watch6,15" : @"Apple Watch Series 8 45mm case (GPS)", + @"Watch6,16" : @"Apple Watch Series 8 41mm case (GPS+Cellular)", + @"Watch6,17" : @"Apple Watch Series 8 45mm case (GPS+Cellular)", + @"Watch6,18" : @"Apple Watch Ultra", + @"Watch7,1" : @"Apple Watch Series 9 41mm case (GPS)", + @"Watch7,2" : @"Apple Watch Series 9 45mm case (GPS)", + @"Watch7,3" : @"Apple Watch Series 9 41mm case (GPS+Cellular)", + @"Watch7,4" : @"Apple Watch Series 9 45mm case (GPS+Cellular)", + @"Watch7,5" : @"Apple Watch Ultra 2", @"AudioAccessory1,1" : @"HomePod", @"AudioAccessory1,2" : @"HomePod", @@ -445,7 +488,7 @@ + (NSString *)deviceName { + (BOOL)isIPad { if (isIPad < 0) { // [[[UIDevice currentDevice] model] isEqualToString:@"iPad"] 无法判断模拟器 iPad,所以改为以下方式 - isIPad = UI_USER_INTERFACE_IDIOM() == UIUserInterfaceIdiomPad ? 1 : 0; + isIPad = UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPad ? 1 : 0; } return isIPad > 0; } @@ -521,9 +564,26 @@ + (BOOL)isNotchedScreen { } + (BOOL)isRegularScreen { + if ([@[ + @"iPhone 14 Pro", + @"iPhone 15", + @"iPhone 16", + ] qmui_firstMatchWithBlock:^BOOL(NSString *item) { + return [QMUIHelper.deviceName hasPrefix:item]; + }]) { + return YES; + } return [self isIPad] || (!IS_ZOOMEDMODE && ([self is67InchScreenAndiPhone14Later] || [self is67InchScreen] || [self is65InchScreen] || [self is61InchScreen] || [self is55InchScreen])); } +static NSInteger is69InchScreen = -1; ++ (BOOL)is69InchScreen { + if (is69InchScreen < 0) { + is69InchScreen = CGSizeEqualToSize(CGSizeMake(DEVICE_WIDTH, DEVICE_HEIGHT), self.screenSizeFor69Inch) ? 1 : 0; + } + return is69InchScreen > 0; +} + static NSInteger is67InchScreenAndiPhone14Later = -1; + (BOOL)is67InchScreenAndiPhone14Later { if (is67InchScreenAndiPhone14Later < 0) { @@ -550,6 +610,22 @@ + (BOOL)is65InchScreen { return is65InchScreen > 0; } +static NSInteger is63InchScreen = -1; ++ (BOOL)is63InchScreen { + if (is63InchScreen < 0) { + is63InchScreen = CGSizeEqualToSize(CGSizeMake(DEVICE_WIDTH, DEVICE_HEIGHT), self.screenSizeFor63Inch) ? 1 : 0; + } + return is63InchScreen > 0; +} + +static NSInteger is61InchScreenAndiPhone14ProLater = -1; ++ (BOOL)is61InchScreenAndiPhone14ProLater { + if (is61InchScreenAndiPhone14ProLater < 0) { + is61InchScreenAndiPhone14ProLater = (DEVICE_WIDTH == self.screenSizeFor61InchAndiPhone14ProLater.width && DEVICE_HEIGHT == self.screenSizeFor61InchAndiPhone14ProLater.height) ? 1 : 0; + } + return is61InchScreenAndiPhone14ProLater > 0; +} + static NSInteger is61InchScreenAndiPhone12Later = -1; + (BOOL)is61InchScreenAndiPhone12Later { if (is61InchScreenAndiPhone12Later < 0) { @@ -616,6 +692,10 @@ + (BOOL)is35InchScreen { return is35InchScreen > 0; } ++ (CGSize)screenSizeFor69Inch { + return CGSizeMake(440, 956); +} + + (CGSize)screenSizeFor67InchAndiPhone14Later { return CGSizeMake(430, 932);// iPhone 14 Pro Max } @@ -628,10 +708,18 @@ + (CGSize)screenSizeFor65Inch { return CGSizeMake(414, 896); } ++ (CGSize)screenSizeFor61InchAndiPhone14ProLater { + return CGSizeMake(393, 852); +} + + (CGSize)screenSizeFor61InchAndiPhone12Later { return CGSizeMake(390, 844); } ++ (CGSize)screenSizeFor63Inch { + return CGSizeMake(402, 874); +} + + (CGSize)screenSizeFor61Inch { return CGSizeMake(414, 896); } @@ -691,6 +779,63 @@ + (UIEdgeInsets)safeAreaInsetsForDeviceWithNotch { static NSDictionary *> *dict; if (!dict) { dict = @{ + // iPhone 16 Pro + @"iPhone17,1": @{ + @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(62, 0, 34, 0)], + @(UIInterfaceOrientationLandscapeLeft): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, 62, 21, 62)], + }, + // iPhone 16 Pro Max + @"iPhone17,2": @{ + @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(62, 0, 34, 0)], + @(UIInterfaceOrientationLandscapeLeft): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, 62, 21, 62)], + }, + // iPhone 16 + @"iPhone17,3": @{ + @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(59, 0, 34, 0)], + @(UIInterfaceOrientationLandscapeLeft): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, 59, 21, 59)], + }, + // iPhone 16 Plus + @"iPhone17,4": @{ + @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(59, 0, 34, 0)], + @(UIInterfaceOrientationLandscapeLeft): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, 59, 21, 59)], + }, + // iPhone 15 + @"iPhone15,4": @{ + @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(47, 0, 34, 0)], + @(UIInterfaceOrientationLandscapeLeft): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, 47, 21, 47)], + }, + @"iPhone15,4-Zoom": @{ + @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(48, 0, 28, 0)], + @(UIInterfaceOrientationLandscapeLeft): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, 48, 21, 48)], + }, + // iPhone 15 Plus + @"iPhone15,5": @{ + @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(47, 0, 34, 0)], + @(UIInterfaceOrientationLandscapeLeft): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, 47, 21, 47)], + }, + @"iPhone15,5-Zoom": @{ + @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(41, 0, 30, 0)], + @(UIInterfaceOrientationLandscapeLeft): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, 41, 21, 41)], + }, + // iPhone 15 Pro + @"iPhone16,1": @{ + @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(59, 0, 34, 0)], + @(UIInterfaceOrientationLandscapeLeft): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, 59, 21, 59)], + }, + @"iPhone16,1-Zoom": @{ + @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(48, 0, 28, 0)], + @(UIInterfaceOrientationLandscapeLeft): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, 48, 21, 48)], + }, + // iPhone 15 Pro Max + @"iPhone16,2": @{ + @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(59, 0, 34, 0)], + @(UIInterfaceOrientationLandscapeLeft): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, 59, 21, 59)], + }, + @"iPhone16,2-Zoom": @{ + @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(51, 0, 31, 0)], + @(UIInterfaceOrientationLandscapeLeft): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, 51, 21, 51)], + }, + // iPhone 14 @"iPhone14,7": @{ @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(47, 0, 34, 0)], @@ -728,6 +873,7 @@ + (UIEdgeInsets)safeAreaInsetsForDeviceWithNotch { @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(51, 0, 31, 0)], @(UIInterfaceOrientationLandscapeLeft): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(0, 51, 21, 51)], }, + // iPhone 13 mini @"iPhone14,4": @{ @(UIInterfaceOrientationPortrait): [NSValue valueWithUIEdgeInsets:UIEdgeInsetsMake(50, 0, 34, 0)], @@ -827,7 +973,7 @@ + (UIEdgeInsets)safeAreaInsetsForDeviceWithNotch { NSString *deviceKey = [QMUIHelper deviceModel]; if (!dict[deviceKey]) { - deviceKey = @"iPhone15,2";// 默认按最新的机型处理,因为新出的设备肯定更大概率与上一代设备相似 + deviceKey = @"iPhone16,1";// 默认按最新的机型处理,因为新出的设备肯定更大概率与上一代设备相似 } if ([QMUIHelper isZoomedMode]) { deviceKey = [NSString stringWithFormat:@"%@-Zoom", deviceKey]; @@ -887,6 +1033,20 @@ + (BOOL)isZoomedMode { return nativeScale > scale; } ++ (BOOL)isDynamicIslandDevice { + if (!IS_IPHONE) return NO; + if ([@[ + @"iPhone 14 Pro", + @"iPhone 15", + @"iPhone 16", + ] qmui_firstMatchWithBlock:^BOOL(NSString *item) { + return [QMUIHelper.deviceName hasPrefix:item]; + }]) { + return YES; + } + return NO; +} + - (void)handleAppSizeWillChange:(NSNotification *)notification { preferredLayoutWidth = -1; } @@ -913,15 +1073,6 @@ + (CGFloat)statusBarHeightConstant { NSString *deviceModel = [QMUIHelper deviceModel]; if (!UIApplication.sharedApplication.statusBarHidden) { -#ifndef IOS16_SDK_ALLOWED - // Xcode 14 SDK 编译的才能在 iPhone 14 Pro 上读取到正确的值,否则会读到 iPhone 13 Pro 的值,过渡期间做个兼容 - if (!IS_LANDSCAPE && - ([deviceModel isEqualToString:@"iPhone15,2"] || - [deviceModel isEqualToString:@"iPhone15,3"]) - ) { - return 54; - } -#endif return UIApplication.sharedApplication.statusBarFrame.size.height; } @@ -938,8 +1089,13 @@ + (CGFloat)statusBarHeightConstant { // iPhone 13 Mini return 48; } - if ([deviceModel isEqualToString:@"iPhone15,2"] || [deviceModel isEqualToString:@"iPhone15,3"]) { - // iPhone 14 Pro & iPhone 14 Pro Max + if ([@[ + @"iPhone 14 Pro", + @"iPhone 15", + @"iPhone 16", + ] qmui_firstMatchWithBlock:^BOOL(NSString *item) { + return [QMUIHelper.deviceName hasPrefix:item]; + }]) { return 54; } if (IS_61INCH_SCREEN_AND_IPHONE12 || IS_67INCH_SCREEN) { @@ -948,6 +1104,33 @@ + (CGFloat)statusBarHeightConstant { return (IS_54INCH_SCREEN && IOS_VERSION >= 15.0) ? 50 : 44; } ++ (CGFloat)navigationBarMaxYConstant { + CGFloat result = QMUIHelper.statusBarHeightConstant; + if (IS_IPAD) { + result += 50; + } else if (IS_LANDSCAPE) { + result += PreferredValueForVisualDevice(44, 32); + } else { + result += 44; + if ([@[ + @"iPhone 16 Pro", + ] qmui_firstMatchWithBlock:^BOOL(NSString *item) { + return [QMUIHelper.deviceName hasPrefix:item]; + }]) { + result += 2 + PixelOne;// 56.333 + } else if ([@[ + @"iPhone 14 Pro", + @"iPhone 15", + @"iPhone 16", + ] qmui_firstMatchWithBlock:^BOOL(NSString *item) { + return [QMUIHelper.deviceName hasPrefix:item]; + }]) { + result -= PixelOne;// 53.667 + } + } + return result; +} + @end @implementation QMUIHelper (UIApplication) @@ -1026,6 +1209,22 @@ + (BOOL)isCurrentSystemLowerThanVersion:(NSString *)targetVersion { @end +@implementation QMUIHelper (Text) + ++ (CGFloat)baselineOffsetWhenVerticalAlignCenterInHeight:(CGFloat)height withFont:(UIFont *)font { + CGFloat capHeightCenter = height + font.descender - font.capHeight / 2; + CGFloat verticalCenter = height / 2;// 以这一点为中心点 + CGFloat baselineOffset = capHeightCenter - verticalCenter; + // ≤ iOS 16.3.1 的设备上,1pt baseline 会让文本向上移动 2pt,≥ iOS 16.4 均为 1:1 移动。 + if (@available(iOS 16.4, *)) { + } else { + baselineOffset = baselineOffset / 2; + } + return baselineOffset; +} + +@end + @implementation QMUIHelper + (void)load { diff --git a/QMUIKit/QMUICore/QMUIRuntime.m b/QMUIKit/QMUICore/QMUIRuntime.m index 9efcf4ef..7cb5780c 100644 --- a/QMUIKit/QMUICore/QMUIRuntime.m +++ b/QMUIKit/QMUICore/QMUIRuntime.m @@ -164,11 +164,29 @@ static BOOL strendswith(const char *str, const char *suffix) { return strncmp(str + lenstr - lensuffix, suffix, lensuffix) == 0; } -static const headerType *getProjectImageHeader() { +static const headerType *getProjectImageHeader(void) { const uint32_t imageCount = _dyld_image_count(); NSString *executablePath = NSBundle.mainBundle.executablePath; if (!executablePath) return nil; const headerType *target_image_header = 0; +#ifdef IOS18_SDK_ALLOWED +#if DEBUG + // Xcode16之后,优先查找debug.dylib + NSString *debugImagePath = [NSString stringWithFormat:@"%@.debug.dylib", executablePath]; + for (uint32_t i = 0; i < imageCount; i++) { + const char *image_name = _dyld_get_image_name(i); + NSString *imagePath = [NSString stringWithUTF8String:image_name]; + if ([imagePath isEqualToString:debugImagePath]) { + target_image_header = (headerType *)_dyld_get_image_header(i); + break; + } + } + + if (target_image_header) { + return target_image_header; + } +#endif +#endif for (uint32_t i = 0; i < imageCount; i++) { const char *image_name = _dyld_get_image_name(i);// name 是一串完整的文件路径,以 image 名结尾 NSString *imagePath = [NSString stringWithUTF8String:image_name]; diff --git a/QMUIKit/QMUIKit.h b/QMUIKit/QMUIKit.h index 918cdf61..40a5be59 100644 --- a/QMUIKit/QMUIKit.h +++ b/QMUIKit/QMUIKit.h @@ -13,7 +13,7 @@ #ifndef QMUIKit_h #define QMUIKit_h -static NSString * const QMUI_VERSION = @"4.6.3"; +static NSString * const QMUI_VERSION = @"4.8.0"; #if __has_include("CAAnimation+QMUI.h") #import "CAAnimation+QMUI.h" @@ -39,6 +39,10 @@ static NSString * const QMUI_VERSION = @"4.6.3"; #import "NSCharacterSet+QMUI.h" #endif +#if __has_include("NSDictionary+QMUI.h") +#import "NSDictionary+QMUI.h" +#endif + #if __has_include("NSMethodSignature+QMUI.h") #import "NSMethodSignature+QMUI.h" #endif @@ -63,6 +67,10 @@ static NSString * const QMUI_VERSION = @"4.6.3"; #import "NSPointerArray+QMUI.h" #endif +#if __has_include("NSRegularExpression+QMUI.h") +#import "NSRegularExpression+QMUI.h" +#endif + #if __has_include("NSShadow+QMUI.h") #import "NSShadow+QMUI.h" #endif @@ -103,6 +111,10 @@ static NSString * const QMUI_VERSION = @"4.6.3"; #import "QMUIAssetsManager.h" #endif +#if __has_include("QMUIBadgeLabel.h") +#import "QMUIBadgeLabel.h" +#endif + #if __has_include("QMUIBadgeProtocol.h") #import "QMUIBadgeProtocol.h" #endif @@ -127,6 +139,10 @@ static NSString * const QMUI_VERSION = @"4.6.3"; #import "QMUICellSizeKeyCache.h" #endif +#if __has_include("QMUICheckbox.h") +#import "QMUICheckbox.h" +#endif + #if __has_include("QMUICollectionViewPagingLayout.h") #import "QMUICollectionViewPagingLayout.h" #endif @@ -243,6 +259,22 @@ static NSString * const QMUI_VERSION = @"4.6.3"; #import "QMUILabel.h" #endif +#if __has_include("QMUILayouter.h") +#import "QMUILayouter.h" +#endif + +#if __has_include("QMUILayouterItem.h") +#import "QMUILayouterItem.h" +#endif + +#if __has_include("QMUILayouterLinearHorizontal.h") +#import "QMUILayouterLinearHorizontal.h" +#endif + +#if __has_include("QMUILayouterLinearVertical.h") +#import "QMUILayouterLinearVertical.h" +#endif + #if __has_include("QMUILog+QMUIConsole.h") #import "QMUILog+QMUIConsole.h" #endif @@ -319,16 +351,16 @@ static NSString * const QMUI_VERSION = @"4.6.3"; #import "QMUIPopupContainerView.h" #endif -#if __has_include("QMUIPopupMenuBaseItem.h") -#import "QMUIPopupMenuBaseItem.h" +#if __has_include("QMUIPopupMenuItem.h") +#import "QMUIPopupMenuItem.h" #endif -#if __has_include("QMUIPopupMenuButtonItem.h") -#import "QMUIPopupMenuButtonItem.h" +#if __has_include("QMUIPopupMenuItemView.h") +#import "QMUIPopupMenuItemView.h" #endif -#if __has_include("QMUIPopupMenuItemProtocol.h") -#import "QMUIPopupMenuItemProtocol.h" +#if __has_include("QMUIPopupMenuItemViewProtocol.h") +#import "QMUIPopupMenuItemViewProtocol.h" #endif #if __has_include("QMUIPopupMenuView.h") @@ -355,6 +387,14 @@ static NSString * const QMUI_VERSION = @"4.6.3"; #import "QMUISegmentedControl.h" #endif +#if __has_include("QMUISheetPresentationNavigationBar.h") +#import "QMUISheetPresentationNavigationBar.h" +#endif + +#if __has_include("QMUISheetPresentationSupports.h") +#import "QMUISheetPresentationSupports.h" +#endif + #if __has_include("QMUIStaticTableViewCellData.h") #import "QMUIStaticTableViewCellData.h" #endif diff --git a/QMUIKit/QMUIMainFrame/QMUINavigationController.m b/QMUIKit/QMUIMainFrame/QMUINavigationController.m index c3b62ed7..bedcc0ac 100644 --- a/QMUIKit/QMUIMainFrame/QMUINavigationController.m +++ b/QMUIKit/QMUIMainFrame/QMUINavigationController.m @@ -129,6 +129,7 @@ @implementation QMUINavigationController - (void)qmui_didInitialize { [super qmui_didInitialize]; + self.qmui_alwaysInvokeAppearanceMethods = YES; self.qmui_multipleDelegatesEnabled = YES; self.delegator = [[_QMUINavigationControllerDelegator alloc] init]; self.delegator.navigationController = self; @@ -313,16 +314,10 @@ - (void)pushViewController:(UIViewController *)viewController animated:(BOOL)ani animated = NO; } - if (self.isViewLoaded) { - if (self.view.window) { - // 增加 self.view.window 作为判断条件是因为当 UINavigationController 不可见时(例如上面盖着一个 prenset 起来的 vc,或者 nav 所在的 tabBar 切到别的 tab 去了),pushViewController 会被执行,但 navigationController:didShowViewController:animated: 的 delegate 不会被触发,导致 isViewControllerTransiting 的标志位无法正确恢复,所以做个保护。 - // https://github.com/Tencent/QMUI_iOS/issues/261 - if (animated) { - self.isViewControllerTransiting = YES; - } - } else { - QMUILogWarn(NSStringFromClass(self.class), @"push 的时候 navigationController 不可见(例如上面盖着一个 prenset vc,或者切到别的 tab,可能导致一些 UINavigationControllerDelegate 不会被调用"); - } + // 增加 self.view.window 作为判断条件是因为当 UINavigationController 不可见时(例如上面盖着一个 present 起来的 vc,或者 nav 所在的 tabBar 切到别的 tab 去了),pushViewController 会被执行,但 navigationController:didShowViewController:animated: 的 delegate 不会被触发,导致 isViewControllerTransiting 的标志位无法正确恢复,所以做个保护。 + // https://github.com/Tencent/QMUI_iOS/issues/261 + if (animated && self.isViewLoaded && self.view.window) { + self.isViewControllerTransiting = YES; } // 在 push 前先设置好返回按钮的文字 @@ -398,12 +393,12 @@ - (void)handleInteractivePopGestureRecognizer:(UIScreenEdgePanGestureRecognizer if (state == UIGestureRecognizerStateEnded) { if (self.transitionCoordinator.cancelled) { - QMUILog(NSStringFromClass(self.class), @"手势返回放弃了"); + QMUILog(NSStringFromClass(self.class), @"interactivePopGestureRecognizer canceled"); UIViewController *temp = viewControllerWillDisappear; viewControllerWillDisappear = viewControllerWillAppear; viewControllerWillAppear = temp; } else { - QMUILog(NSStringFromClass(self.class), @"执行手势返回"); + QMUILog(NSStringFromClass(self.class), @"interactivePopGestureRecognizer triggered"); } } @@ -635,16 +630,18 @@ + (void)load { dispatch_once(&onceToken, ^{ // 在先设置了 title 再设置 titleView 时,保证 titleView 的样式能正确。 OverrideImplementation([UINavigationItem class], @selector(setTitleView:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { - return ^(UINavigationItem *selfObject, QMUINavigationTitleView *titleView) { + return ^(UINavigationItem *selfObject, UIView *titleView) { // call super void (*originSelectorIMP)(id, SEL, UIView *); originSelectorIMP = (void (*)(id, SEL, UIView *))originalIMPProvider(); originSelectorIMP(selfObject, originCMD, titleView); - if ([titleView isKindOfClass:QMUINavigationTitleView.class]) { + if (titleView.qmui_useAsNavigationTitleView) { if ([selfObject.qmui_viewController respondsToSelector:@selector(qmui_titleViewTintColor)]) { titleView.tintColor = ((id)selfObject.qmui_viewController).qmui_titleViewTintColor; + } else if (QMUICMIActivated) { + titleView.tintColor = NavBarTitleColor; } } }; diff --git a/QMUIKit/QMUIResources/Images.xcassets/Contents.json b/QMUIKit/QMUIResources/Images.xcassets/Contents.json index da4a164c..73c00596 100644 --- a/QMUIKit/QMUIResources/Images.xcassets/Contents.json +++ b/QMUIKit/QMUIResources/Images.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16.imageset/Contents.json b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16.imageset/Contents.json new file mode 100644 index 00000000..048a0ef7 --- /dev/null +++ b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "QMUI_checkbox16.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16.imageset/QMUI_checkbox16.pdf b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16.imageset/QMUI_checkbox16.pdf new file mode 100644 index 00000000..901fb7ef Binary files /dev/null and b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16.imageset/QMUI_checkbox16.pdf differ diff --git a/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_checked.imageset/Contents.json b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_checked.imageset/Contents.json new file mode 100644 index 00000000..b7519be4 --- /dev/null +++ b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_checked.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "QMUI_checkbox16_checked.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_checked.imageset/QMUI_checkbox16_checked.pdf b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_checked.imageset/QMUI_checkbox16_checked.pdf new file mode 100644 index 00000000..159ab67b Binary files /dev/null and b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_checked.imageset/QMUI_checkbox16_checked.pdf differ diff --git a/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_disabled.imageset/Contents.json b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_disabled.imageset/Contents.json new file mode 100644 index 00000000..651d2cff --- /dev/null +++ b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_disabled.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "QMUI_checkbox16_disabled.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_disabled.imageset/QMUI_checkbox16_disabled.pdf b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_disabled.imageset/QMUI_checkbox16_disabled.pdf new file mode 100644 index 00000000..ba38d674 Binary files /dev/null and b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_disabled.imageset/QMUI_checkbox16_disabled.pdf differ diff --git a/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_indeterminate.imageset/Contents.json b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_indeterminate.imageset/Contents.json new file mode 100644 index 00000000..98c84032 --- /dev/null +++ b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_indeterminate.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "QMUI_checkbox16_indeterminate.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_indeterminate.imageset/QMUI_checkbox16_indeterminate.pdf b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_indeterminate.imageset/QMUI_checkbox16_indeterminate.pdf new file mode 100644 index 00000000..31d6ea37 Binary files /dev/null and b/QMUIKit/QMUIResources/Images.xcassets/QMUI_checkbox16_indeterminate.imageset/QMUI_checkbox16_indeterminate.pdf differ diff --git a/QMUIKit/UIKitExtensions/CALayer+QMUI.h b/QMUIKit/UIKitExtensions/CALayer+QMUI.h index c05f5fad..6c3b8603 100644 --- a/QMUIKit/UIKitExtensions/CALayer+QMUI.h +++ b/QMUIKit/UIKitExtensions/CALayer+QMUI.h @@ -52,6 +52,18 @@ typedef NS_OPTIONS (NSUInteger, QMUICornerMask) { */ @property(nonatomic, strong, nullable) NSShadow *qmui_shadow; +/** + 只有当前 layer 里被返回的路径包裹住的内容才能被看到,路径之外的区域被裁剪掉。 + 该 block 会在 layer 大小发生变化时被调用,所以请根据 aLayer.bounds 计算实时的路径。 + */ +@property(nonatomic, copy, nullable) UIBezierPath * (^qmui_maskPathBlock)(__kindof CALayer *aLayer); + +/** + 与 qmui_maskPathBlock 相反,返回的路径会将当前 layer 的内容裁切掉,例如假设返回一个 layer 中间的矩形路径,则这个矩形会被挖空,其他区域正常显示。 + 该 block 会在 layer 大小发生变化时被调用,所以请根据 aLayer.bounds 计算实时的路径。 + */ +@property(nonatomic, copy, nullable) UIBezierPath * (^qmui_evenOddMaskPathBlock)(__kindof CALayer *aLayer); + /// 获取指定 name 值的 layer,包括 self 和 self.sublayers,会一直往 sublayers 查找直到找到目标 layer。 - (nullable __kindof CALayer *)qmui_layerWithName:(NSString *)name; diff --git a/QMUIKit/UIKitExtensions/CALayer+QMUI.m b/QMUIKit/UIKitExtensions/CALayer+QMUI.m index 225b2e68..8eff35fb 100644 --- a/QMUIKit/UIKitExtensions/CALayer+QMUI.m +++ b/QMUIKit/UIKitExtensions/CALayer+QMUI.m @@ -165,6 +165,65 @@ - (NSShadow *)qmui_shadow { return (NSShadow *)objc_getAssociatedObject(self, &kAssociatedObjectKey_shadow); } +static char kAssociatedObjectKey_maskPathBlock; +- (void)setQmui_maskPathBlock:(UIBezierPath * _Nonnull (^)(__kindof CALayer * _Nonnull))qmui_maskPathBlock { + objc_setAssociatedObject(self, &kAssociatedObjectKey_maskPathBlock, qmui_maskPathBlock, OBJC_ASSOCIATION_COPY_NONATOMIC); + if (qmui_maskPathBlock) { + [CALayer qmui_hookMaskIfNeeded]; + CAShapeLayer *mask = CAShapeLayer.layer; + self.mask = mask; + [self setNeedsLayout]; + } else { + self.mask = nil; + } +} + +- (UIBezierPath * _Nonnull (^)(__kindof CALayer * _Nonnull))qmui_maskPathBlock { + return (UIBezierPath * _Nonnull (^)(__kindof CALayer * _Nonnull))objc_getAssociatedObject(self, &kAssociatedObjectKey_maskPathBlock); +} + +static char kAssociatedObjectKey_evenOddMaskPathBlock; +- (void)setQmui_evenOddMaskPathBlock:(UIBezierPath * _Nonnull (^)(__kindof CALayer * _Nonnull))qmui_evenOddMaskPathBlock { + objc_setAssociatedObject(self, &kAssociatedObjectKey_evenOddMaskPathBlock, qmui_evenOddMaskPathBlock, OBJC_ASSOCIATION_COPY_NONATOMIC); + if (qmui_evenOddMaskPathBlock) { + [CALayer qmui_hookMaskIfNeeded]; + CAShapeLayer *mask = CAShapeLayer.layer; + mask.fillRule = kCAFillRuleEvenOdd; + self.mask = mask; + [self setNeedsLayout]; + } else { + self.mask = nil; + } +} + +- (UIBezierPath * _Nonnull (^)(__kindof CALayer * _Nonnull))qmui_evenOddMaskPathBlock { + return (UIBezierPath * _Nonnull (^)(__kindof CALayer * _Nonnull))objc_getAssociatedObject(self, &kAssociatedObjectKey_evenOddMaskPathBlock); +} + ++ (void)qmui_hookMaskIfNeeded { + [QMUIHelper executeBlock:^{ + OverrideImplementation([CALayer class], @selector(layoutSublayers), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(CALayer *selfObject) { + + // call super + void (*originSelectorIMP)(id, SEL); + originSelectorIMP = (void (*)(id, SEL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD); + + if (selfObject.qmui_maskPathBlock && [selfObject.mask isKindOfClass:CAShapeLayer.class]) { + ((CAShapeLayer *)selfObject.mask).path = selfObject.qmui_maskPathBlock(selfObject).CGPath; + } + if (selfObject.qmui_evenOddMaskPathBlock && [selfObject.mask isKindOfClass:CAShapeLayer.class]) { + UIBezierPath *path = [UIBezierPath bezierPathWithRect:selfObject.bounds]; + UIBezierPath *maskPath = selfObject.qmui_evenOddMaskPathBlock(selfObject); + [path appendPath:maskPath]; + ((CAShapeLayer *)selfObject.mask).path = path.CGPath; + } + }; + }); + } oncePerIdentifier:@"CALayer (QMUI) mask"]; +} + - (__kindof CALayer *)qmui_layerWithName:(NSString *)name { if ([self.name isEqualToString:name]) return self; for (CALayer *sublayer in self.sublayers) { diff --git a/QMUIKit/UIKitExtensions/NSArray+QMUI.h b/QMUIKit/UIKitExtensions/NSArray+QMUI.h index e3e2d30c..8aaa2f1d 100644 --- a/QMUIKit/UIKitExtensions/NSArray+QMUI.h +++ b/QMUIKit/UIKitExtensions/NSArray+QMUI.h @@ -48,9 +48,14 @@ NS_ASSUME_NONNULL_BEGIN - (ObjectType _Nullable)qmui_firstMatchWithBlock:(BOOL (NS_NOESCAPE^)(ObjectType item))block; /** -* 转换数组元素,将每个 item 都经过 block 转换成一遍 返回转换后的新数组 +* 转换数组元素,将每个 item 都经过 block 转换成一遍后返回一个等长的数组。 */ -- (NSArray *)qmui_mapWithBlock:(id (NS_NOESCAPE^)(ObjectType item))block; +- (NSArray *)qmui_mapWithBlock:(id (NS_NOESCAPE^)(ObjectType item, NSInteger index))block; + +/** +* 转换数组元素,将每个 item 经过 block 转换为另一个元素,如果希望移除该 item,可返回 nil。当所有元素都被移除时,本方法返回空的容器。 +*/ +- (NSArray *)qmui_compactMapWithBlock:(id _Nullable (NS_NOESCAPE^)(ObjectType item))block; @end diff --git a/QMUIKit/UIKitExtensions/NSArray+QMUI.m b/QMUIKit/UIKitExtensions/NSArray+QMUI.m index d32edc56..c6d824a9 100644 --- a/QMUIKit/UIKitExtensions/NSArray+QMUI.m +++ b/QMUIKit/UIKitExtensions/NSArray+QMUI.m @@ -96,14 +96,28 @@ - (id)qmui_firstMatchWithBlock:(BOOL (NS_NOESCAPE^)(id _Nonnull))block { return nil; } -- (NSArray *)qmui_mapWithBlock:(id (NS_NOESCAPE^)(id item))block { +- (NSArray *)qmui_mapWithBlock:(id (NS_NOESCAPE^)(id item, NSInteger index))block { if (!block) { return self; } NSMutableArray *result = [[NSMutableArray alloc] initWithCapacity:self.count]; for (NSInteger i = 0; i < self.count; i++) { - [result addObject:block(self[i])]; + [result addObject:block(self[i], i)]; + } + return [result copy]; +} + +- (NSArray *)qmui_compactMapWithBlock:(id _Nullable (NS_NOESCAPE^)(id _Nonnull))block { + if (!block) { + return self; + } + NSMutableArray *result = [[NSMutableArray alloc] initWithCapacity:self.count]; + for (NSInteger i = 0; i < self.count; i++) { + id item = block(self[i]); + if (item) { + [result addObject:item]; + } } return [result copy]; } diff --git a/QMUIKit/UIKitExtensions/NSAttributedString+QMUI.h b/QMUIKit/UIKitExtensions/NSAttributedString+QMUI.h index 4cbb39f4..8d45c49b 100644 --- a/QMUIKit/UIKitExtensions/NSAttributedString+QMUI.h +++ b/QMUIKit/UIKitExtensions/NSAttributedString+QMUI.h @@ -68,6 +68,26 @@ UIKIT_EXTERN NSAttributedStringKey const QMUIImageMarginsAttributeName; */ + (instancetype)qmui_attributedStringWithFixedSpace:(CGFloat)width; +/** + 获取当前富文本里的文字水平对齐方式,如果存在多个 paragraphStyle 则以第一个的 alignment 值为准。 + 如果当前文本长度为0或不存在 paragraphStyle 属性,则返回默认的 NSTextAlignmentLeft。 + */ +@property(nonatomic, assign, readonly) NSTextAlignment qmui_textAlignment; + +@end + +@interface NSMutableAttributedString (QMUI) + +/** + 通过修改 paragraphStyle 来为当前富文本设置水平对齐方式,若不存在 paragraphStyle 则会帮你创建一个。 + */ +@property(nonatomic, assign) NSTextAlignment qmui_textAlignment; + +/** + 修改当前富文本里的 paragraphStyle 属性,若存在多个不同 paragraphStyle 则每个都会调用一次 block。 + 若不存在 paragraphStyle 则会帮你创建一个,且 range 为整个文本长度。 + */ +- (void)qmui_applyParagraphStyle:(void (^)(NSMutableParagraphStyle *aParagraphStyle, NSRange aRange))block; @end @interface UIImage (QMUI_NSAttributedStringSupports) diff --git a/QMUIKit/UIKitExtensions/NSAttributedString+QMUI.m b/QMUIKit/UIKitExtensions/NSAttributedString+QMUI.m index 5ee761d5..96a09334 100644 --- a/QMUIKit/UIKitExtensions/NSAttributedString+QMUI.m +++ b/QMUIKit/UIKitExtensions/NSAttributedString+QMUI.m @@ -65,6 +65,14 @@ + (instancetype)qmui_attributedStringWithFixedSpace:(CGFloat)width { return [self qmui_attributedStringWithImage:image]; } +- (NSTextAlignment)qmui_textAlignment { + if (!self.length) return NSTextAlignmentLeft; + NSParagraphStyle *p = [self attribute:NSParagraphStyleAttributeName atIndex:0 effectiveRange:nil]; + if (!p) return NSTextAlignmentLeft; + NSTextAlignment alignment = p.alignment; + return alignment; +} + #pragma mark - - (NSUInteger)qmui_lengthWhenCountingNonASCIICharacterAsTwo { @@ -105,6 +113,34 @@ - (instancetype)qmui_stringByRemoveLastCharacter { @end +@implementation NSMutableAttributedString (QMUI) + +- (void)qmui_applyParagraphStyle:(void (^)(NSMutableParagraphStyle * _Nonnull, NSRange))block { + if (!self.length || !block) return; + __block BOOL applied = NO; + [self enumerateAttribute:NSParagraphStyleAttributeName inRange:NSMakeRange(0, self.length) options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired usingBlock:^(NSParagraphStyle * _Nullable value, NSRange range, BOOL * _Nonnull stop) { + applied = YES; + NSMutableParagraphStyle *p = value.mutableCopy; + block(p, range); + [self addAttribute:NSParagraphStyleAttributeName value:p.copy range:range]; + }]; + if (!applied) { + NSMutableParagraphStyle *p = NSMutableParagraphStyle.new; + NSRange range = NSMakeRange(0, self.length); + block(p, range); + [self addAttribute:NSParagraphStyleAttributeName value:p.copy range:range]; + } +} + + +- (void)setQmui_textAlignment:(NSTextAlignment)qmui_textAlignment { + [self qmui_applyParagraphStyle:^(NSMutableParagraphStyle * _Nonnull aParagraphStyle, NSRange aRange) { + aParagraphStyle.alignment = qmui_textAlignment; + }]; +} + +@end + @implementation UIImage (QMUI_NSAttributedStringSupports) + (void)load { diff --git a/QMUIKit/UIKitExtensions/NSDictionary+QMUI.h b/QMUIKit/UIKitExtensions/NSDictionary+QMUI.h new file mode 100644 index 00000000..74e6d175 --- /dev/null +++ b/QMUIKit/UIKitExtensions/NSDictionary+QMUI.h @@ -0,0 +1,38 @@ +/** + * Tencent is pleased to support the open source community by making QMUI_iOS available. + * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +// +// NSDictionary+QMUI.h +// QMUIKit +// +// Created by molice on 2023/7/21. +// Copyright © 2023 QMUI Team. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSDictionary (QMUI) + +/** +* 转换字典的元素,将每个 key-value 经过 block 转换为另一个 key-value,如果希望移除该 item,可返回 nil。当所有元素都被移除时,本方法返回空的容器。 + 对应 -[NSArray(QMUI) qmui_compactMapWithBlock],是觉得没必要区分 compact 和非 compact 了。 +*/ +- (NSDictionary * _Nullable)qmui_mapWithBlock:(NSDictionary * _Nullable (NS_NOESCAPE^)(KeyType key, ObjectType value))block; + +/** + 深度转换字典的元素,同 qmui_mapWithBlock:,但区别在于如果 object 是一个 NSDictionary,则它会递归再 map,最终把所有的 key-value 都转换一遍。 + + @warning 面对嵌套 dictionary 时,本方法的 block 里的参数 value 有可能会传 NSDictionary 类型,但实际上你对其转换后的返回值只有 key 会被使用,value 会被丢弃。 + */ +- (NSDictionary * _Nullable)qmui_deepMapWithBlock:(NSDictionary * _Nullable (NS_NOESCAPE^)(KeyType key, ObjectType value))block; + +@end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/UIKitExtensions/NSDictionary+QMUI.m b/QMUIKit/UIKitExtensions/NSDictionary+QMUI.m new file mode 100644 index 00000000..72ff1974 --- /dev/null +++ b/QMUIKit/UIKitExtensions/NSDictionary+QMUI.m @@ -0,0 +1,65 @@ +/** + * Tencent is pleased to support the open source community by making QMUI_iOS available. + * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +// +// NSDictionary+QMUI.m +// QMUIKit +// +// Created by molice on 2023/7/21. +// Copyright © 2023 QMUI Team. All rights reserved. +// + +#import "NSDictionary+QMUI.h" + +@implementation NSDictionary (QMUI) + +- (NSDictionary *)qmui_mapWithBlock:(NSDictionary * _Nullable (NS_NOESCAPE^)(id _Nonnull, id _Nonnull))block { + if (!block) { + return self; + } + + NSMutableDictionary *temp = NSMutableDictionary.new; + [self enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) { + NSDictionary *mapped = block(key, obj); + if (!mapped) { + return; + } + id k = mapped.allKeys.firstObject; + id o = mapped.allValues.firstObject; + temp[k] = o; + }]; + return temp.copy; +} + +- (NSDictionary *)qmui_deepMapWithBlock:(NSDictionary * _Nullable (NS_NOESCAPE^)(id _Nonnull, id _Nonnull))block { + if (!block) { + return self; + } + + NSMutableDictionary *temp = NSMutableDictionary.new; + [self enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) { + if ([obj isKindOfClass:NSDictionary.class]) { + obj = [obj qmui_deepMapWithBlock:block]; + } + NSDictionary *mapped = block(key, obj); + if (!mapped) { + return; + } + id k = mapped.allKeys.firstObject; + id o = nil; + if ([obj isKindOfClass:NSDictionary.class]) { + o = obj;// 返回值 mapped.value 被丢弃了,实际上将 obj 作为 value + } else { + o = mapped.allValues.firstObject; + } + temp[k] = o; + }]; + return temp.copy; +} + +@end diff --git a/QMUIKit/UIKitExtensions/NSRegularExpression+QMUI.h b/QMUIKit/UIKitExtensions/NSRegularExpression+QMUI.h new file mode 100644 index 00000000..d721a7bb --- /dev/null +++ b/QMUIKit/UIKitExtensions/NSRegularExpression+QMUI.h @@ -0,0 +1,29 @@ +/** + * Tencent is pleased to support the open source community by making QMUI_iOS available. + * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +// +// NSRegularExpression+QMUI.h +// QMUIKit +// +// Created by QMUI Team on 2024/2/21. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NSRegularExpression (QMUI) + +/// 某些场景频繁构造 NSRegularExpression 耗时较大,所以这里提供一个缓存的方式,如果你的场景非频繁,可以不用。 ++ (nullable NSRegularExpression *)qmui_cachedRegularExpressionWithPattern:(NSString *)pattern options:(NSRegularExpressionOptions)options; + +/// 某些场景频繁构造 NSRegularExpression 耗时较大,所以这里提供一个缓存的方式,如果你的场景非频繁,可以不用。等价于 options 为 NSRegularExpressionCaseInsensitive。 ++ (nullable NSRegularExpression *)qmui_cachedRegularExpressionWithPattern:(NSString *)pattern; +@end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/UIKitExtensions/NSRegularExpression+QMUI.m b/QMUIKit/UIKitExtensions/NSRegularExpression+QMUI.m new file mode 100644 index 00000000..a0138885 --- /dev/null +++ b/QMUIKit/UIKitExtensions/NSRegularExpression+QMUI.m @@ -0,0 +1,44 @@ +/** + * Tencent is pleased to support the open source community by making QMUI_iOS available. + * Copyright (C) 2016-2021 THL A29 Limited, a Tencent company. All rights reserved. + * Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + * http://opensource.org/licenses/MIT + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + */ + +// +// NSRegularExpression+QMUI.m +// QMUIKit +// +// Created by QMUI Team on 2024/2/21. +// + +#import "NSRegularExpression+QMUI.h" + +@implementation NSRegularExpression (QMUI) + ++ (NSRegularExpression *)qmui_cachedRegularExpressionWithPattern:(NSString *)pattern options:(NSRegularExpressionOptions)options { + if (!pattern.length) return nil; + + static NSCache *cache = nil; + if (!cache) { + cache = [[NSCache alloc] init]; + cache.name = @"NSRegularExpression (QMUI)"; + cache.countLimit = 100; + } + + NSString *key = [NSString stringWithFormat:@"%@_%@", pattern, @(options)]; + NSRegularExpression *reg = [cache objectForKey:key]; + if (!reg) { + reg = [NSRegularExpression regularExpressionWithPattern:pattern options:options error:nil]; + if (!reg) return nil; + [cache setObject:reg forKey:key]; + } + return reg; +} + ++ (NSRegularExpression *)qmui_cachedRegularExpressionWithPattern:(NSString *)pattern { + return [self qmui_cachedRegularExpressionWithPattern:pattern options:NSRegularExpressionCaseInsensitive]; +} + +@end diff --git a/QMUIKit/UIKitExtensions/NSString+QMUI.h b/QMUIKit/UIKitExtensions/NSString+QMUI.h index d2add912..38a3facd 100644 --- a/QMUIKit/UIKitExtensions/NSString+QMUI.h +++ b/QMUIKit/UIKitExtensions/NSString+QMUI.h @@ -135,7 +135,7 @@ NS_ASSUME_NONNULL_BEGIN @param pattern 正则表达式 @return 匹配到的第一个结果,如果没有匹配成功则返回 nil */ -- (NSString *)qmui_stringMatchedByPattern:(NSString *)pattern; +- (nullable NSString *)qmui_stringMatchedByPattern:(NSString *)pattern; /** 用正则表达式匹配字符串,返回匹配到的第一个结果里的指定分组(由参数 index 指定)。 @@ -144,7 +144,7 @@ NS_ASSUME_NONNULL_BEGIN @param index 要返回第几个分组,0表示整个正则表达式匹配到的结果,1表示匹配到的结果里的第1个分组(第1个括号) @return 返回匹配到的第一个结果里的指定分组,如果 index 超过总分组数则返回 nil。匹配失败也返回 nil。 */ -- (NSString *)qmui_stringMatchedByPattern:(NSString *)pattern groupIndex:(NSInteger)index; +- (nullable NSString *)qmui_stringMatchedByPattern:(NSString *)pattern groupIndex:(NSInteger)index; /** 用正则表达式匹配字符串,返回匹配到的第一个结果里的指定分组(由参数 name 指定)。 @@ -153,7 +153,7 @@ NS_ASSUME_NONNULL_BEGIN @param name 要返回的分组名称,可通过 pattern 里的 ? 语法对分组进行命名。 @return 返回匹配到的第一个结果里的指定分组,如果 name 不存在则返回 nil。匹配失败也返回 nil。 */ -- (NSString *)qmui_stringMatchedByPattern:(NSString *)pattern groupName:(NSString *)name; +- (nullable NSString *)qmui_stringMatchedByPattern:(NSString *)pattern groupName:(NSString *)name; /** * 用正则表达式匹配字符串并将其替换为指定的另一个字符串,大小写不敏感 diff --git a/QMUIKit/UIKitExtensions/NSString+QMUI.m b/QMUIKit/UIKitExtensions/NSString+QMUI.m index 42ccc633..b2fa90ad 100644 --- a/QMUIKit/UIKitExtensions/NSString+QMUI.m +++ b/QMUIKit/UIKitExtensions/NSString+QMUI.m @@ -19,6 +19,7 @@ #import "NSArray+QMUI.h" #import "NSCharacterSet+QMUI.h" #import "QMUIStringPrivate.h" +#import "NSRegularExpression+QMUI.h" @implementation NSString (QMUI) @@ -58,7 +59,9 @@ - (NSString *)qmui_trimLineBreakCharacter { - (NSString *)qmui_md5 { const char *cStr = [self UTF8String]; unsigned char result[CC_MD5_DIGEST_LENGTH]; + BeginIgnoreDeprecatedWarning CC_MD5(cStr, (CC_LONG)strlen(cStr), result); + EndIgnoreDeprecatedWarning return [NSString stringWithFormat: @"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x", result[0], result[1], result[2], result[3], @@ -156,8 +159,7 @@ - (NSString *)qmui_removeMagicalChar { return self; } - NSError *error = nil; - NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"[\u0300-\u036F]" options:NSRegularExpressionCaseInsensitive error:&error]; + NSRegularExpression *regex = [NSRegularExpression qmui_cachedRegularExpressionWithPattern:@"[\u0300-\u036F]"]; NSString *modifiedString = [regex stringByReplacingMatchesInString:self options:NSMatchingReportProgress range:NSMakeRange(0, self.length) withTemplate:@""]; return modifiedString; } @@ -169,7 +171,7 @@ - (NSString *)qmui_stringMatchedByPattern:(NSString *)pattern { - (NSString *)qmui_stringMatchedByPattern:(NSString *)pattern groupIndex:(NSInteger)index { if (pattern.length <= 0 || index < 0) return nil; - NSRegularExpression *regx = [NSRegularExpression regularExpressionWithPattern:pattern options:NSRegularExpressionCaseInsensitive error:nil]; + NSRegularExpression *regx = [NSRegularExpression qmui_cachedRegularExpressionWithPattern:pattern]; NSTextCheckingResult *result = [regx firstMatchInString:self options:NSMatchingReportCompletion range:NSMakeRange(0, self.length)]; if (result.numberOfRanges > index) { NSRange range = [result rangeAtIndex:index]; @@ -181,7 +183,7 @@ - (NSString *)qmui_stringMatchedByPattern:(NSString *)pattern groupIndex:(NSInte - (NSString *)qmui_stringMatchedByPattern:(NSString *)pattern groupName:(NSString *)name { if (pattern.length <= 0) return nil; - NSRegularExpression *regx = [NSRegularExpression regularExpressionWithPattern:pattern options:NSRegularExpressionCaseInsensitive error:nil]; + NSRegularExpression *regx = [NSRegularExpression qmui_cachedRegularExpressionWithPattern:pattern]; NSTextCheckingResult *result = [regx firstMatchInString:self options:NSMatchingReportCompletion range:NSMakeRange(0, self.length)]; if (result.numberOfRanges > 1) { NSRange range = [result rangeWithName:name]; @@ -195,9 +197,8 @@ - (NSString *)qmui_stringMatchedByPattern:(NSString *)pattern groupName:(NSStrin } - (NSString *)qmui_stringByReplacingPattern:(NSString *)pattern withString:(NSString *)replacement { - NSError *error = nil; - NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:pattern options:NSRegularExpressionCaseInsensitive error:&error]; - if (error) { + NSRegularExpression *regex = [NSRegularExpression qmui_cachedRegularExpressionWithPattern:pattern]; + if (!regex) { return self; } return [regex stringByReplacingMatchesInString:self options:NSMatchingReportCompletion range:NSMakeRange(0, self.length) withTemplate:replacement]; diff --git a/QMUIKit/UIKitExtensions/QMUIStringPrivate.m b/QMUIKit/UIKitExtensions/QMUIStringPrivate.m index 7b088422..a3bbd368 100644 --- a/QMUIKit/UIKitExtensions/QMUIStringPrivate.m +++ b/QMUIKit/UIKitExtensions/QMUIStringPrivate.m @@ -100,7 +100,12 @@ + (id)substring:(id)aString avoidBreakingUpCharacterSequencesFromIndex:(NSUInteg NSString *string = attributedString.string ?: (NSString *)aString; NSUInteger length = countingNonASCIICharacterAsTwo ? string.qmui_lengthWhenCountingNonASCIICharacterAsTwo : string.length; QMUIAssert(index <= length, @"QMUIStringPrivate", @"%s, index %@ out of bounds. string = %@", __func__, @(index), attributedString ?: string); - if (index >= length) return @""; + if (index >= length) { + if (attributedString) { + return [[attributedString.class alloc] init]; + } + return @""; + }; index = countingNonASCIICharacterAsTwo ? [self transformIndexToDefaultMode:index inString:string] : index;// 实际计算都按照系统默认的 length 规则来 NSRange range = [string rangeOfComposedCharacterSequenceAtIndex:index]; index = range.length == 1 ? index : (lessValue ? NSMaxRange(range) : range.location); @@ -117,8 +122,20 @@ + (id)substring:(id)aString avoidBreakingUpCharacterSequencesToIndex:(NSUInteger NSString *string = attributedString.string ?: (NSString *)aString; NSUInteger length = countingNonASCIICharacterAsTwo ? string.qmui_lengthWhenCountingNonASCIICharacterAsTwo : string.length; QMUIAssert(index <= length, @"QMUIStringPrivate", @"%s, index %@ out of bounds. string = %@", __func__, @(index), attributedString ?: string); - if (index == 0 || index > length) return @""; - if (index == length) return [aString copy];// 根据系统 -[NSString substringToIndex:] 的注释,在 index 等于 length 时会返回 self 的 copy。 + if (index == 0 || index > length) { + if (attributedString) { + return [[attributedString.class alloc] init]; + } + return @""; + } + if (index == length) {// 根据系统 -[NSString substringToIndex:] 的注释,在 index 等于 length 时会返回 self 的 copy。 + if (attributedString) { + if ([attributedString isKindOfClass:NSMutableAttributedString.class]) { + return [aString mutableCopy]; + } + } + return [aString copy]; + } index = countingNonASCIICharacterAsTwo ? [self transformIndexToDefaultMode:index inString:string] : index;// 实际计算都按照系统默认的 length 规则来 NSRange range = [string rangeOfComposedCharacterSequenceAtIndex:index]; index = range.length == 1 ? index : (lessValue ? range.location : NSMaxRange(range)); @@ -135,7 +152,12 @@ + (id)substring:(id)aString avoidBreakingUpCharacterSequencesWithRange:(NSRange) NSString *string = attributedString.string ?: (NSString *)aString; NSUInteger length = countingNonASCIICharacterAsTwo ? string.qmui_lengthWhenCountingNonASCIICharacterAsTwo : string.length; QMUIAssert(NSMaxRange(range) <= length, @"QMUIStringPrivate", @"%s, range %@ out of bounds. string = %@", __func__, NSStringFromRange(range), attributedString ?: string); - if (NSMaxRange(range) > length) return @""; + if (NSMaxRange(range) > length) { + if (attributedString) { + return [[attributedString.class alloc] init]; + } + return @""; + } range = countingNonASCIICharacterAsTwo ? [self transformRangeToDefaultMode:range lessValue:lessValue inString:string] : range;// 实际计算都按照系统默认的 length 规则来 NSRange characterSequencesRange = lessValue ? [self downRoundRangeOfComposedCharacterSequences:range inString:string] : [string rangeOfComposedCharacterSequencesForRange:range]; if (attributedString) { @@ -166,11 +188,54 @@ @implementation QMUIStringPrivate (Safety) + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ + [self qmuisafety_UIKeyboardImpl]; + [self qmuisafety_NSRegularExpression]; [self qmuisafety_NSString]; [self qmuisafety_NSAttributedString]; }); } +static BOOL QMUIAvoidSubstring = NO; ++ (void)qmuisafety_UIKeyboardImpl { + // UIKeyboardImpl + // - (void) handleKeyWithString:(id)arg1 forKeyEvent:(id)arg2 executionContext:(id)arg3; + OverrideImplementation(NSClassFromString([NSString qmui_stringByConcat:@"UIKeyb", @"oard", @"Impl", nil]), NSSelectorFromString([NSString qmui_stringByConcat:@"handleKeyWithString:", @"forKeyEvent:", @"executionContext:", nil]), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(NSObject *selfObject, NSString *string, UIPressesEvent *event, NSObject *context) { + + QMUIAvoidSubstring = YES; + + // call super + void (*originSelectorIMP)(id, SEL, NSString *, UIPressesEvent *, NSObject *); + originSelectorIMP = (void (*)(id, SEL, id, id, id))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, string, event, context); + + QMUIAvoidSubstring = NO; + }; + }); +} + ++ (void)qmuisafety_NSRegularExpression { + // 避免 stringByReplacingMatchesInString 无效 + // https://github.com/Tencent/QMUI_iOS/issues/1542 + // -[NSRegularExpression(NSReplacement) stringByReplacingMatchesInString:options:range:withTemplate:] + // - (id) stringByReplacingMatchesInString:(id)arg1 options:(unsigned long)arg2 range:(struct _NSRange)arg3 withTemplate:(id)arg4; + OverrideImplementation([NSRegularExpression class], @selector(stringByReplacingMatchesInString:options:range:withTemplate:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^NSString *(NSRegularExpression *selfObject, NSString *string, NSMatchingOptions options, NSRange range, NSString *templ) { + + QMUIAvoidSubstring = YES; + + // call super + NSString * (*originSelectorIMP)(id, SEL, NSString *, NSMatchingOptions, NSRange, NSString *); + originSelectorIMP = (NSString * (*)(id, SEL, NSString *, NSMatchingOptions, NSRange, NSString *))originalIMPProvider(); + NSString * result = originSelectorIMP(selfObject, originCMD, string, options, range, templ); + + QMUIAvoidSubstring = NO; + + return result; + }; + }); +} + + (void)qmuisafety_NSString { OverrideImplementation([NSString class], @selector(substringFromIndex:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { return ^NSString *(NSString *selfObject, NSUInteger index) { @@ -186,8 +251,9 @@ + (void)qmuisafety_NSString { } // 保护从 emoji 等 ComposedCharacterSequence 中间裁剪的场景 + // 系统 emoji 键盘输入过程中一定会调用 substringFromIndex:text.length - 1,导致触发我们这个警告,这里特殊保护一下 { - if (index < selfObject.length) { + if (index < selfObject.length && !QMUIAvoidSubstring) { NSRange range = [selfObject rangeOfComposedCharacterSequenceAtIndex:index]; BOOL isValidatedIndex = range.location == index || NSMaxRange(range) == index; if (!isValidatedIndex) { @@ -272,7 +338,7 @@ + (void)qmuisafety_NSString { if (NSMaxRange(range) < selfObject.length) { NSRange range2 = [selfObject rangeOfComposedCharacterSequencesForRange:range]; BOOL isValidddatedRange = range.length == 0 || NSEqualRanges(range, range2); - if (!isValidddatedRange) { + if (!isValidddatedRange && !QMUIAvoidSubstring) { NSString *logString = [NSString stringWithFormat:@"试图在 ComposedCharacterSequence 中间用 %@ 裁剪字符串,可能导致乱码、crash。原字符串为“%@”(%@),range 为 %@,命中的 ComposedCharacterSequence range 为 %@", NSStringFromSelector(originCMD), selfObject, @(selfObject.length), NSStringFromRange(range), NSStringFromRange(range2)]; QMUIAssert(NO, @"QMUIStringSafety", @"%@", logString); range = range2; @@ -288,6 +354,23 @@ + (void)qmuisafety_NSString { return result; }; }); + + // 保护 -[NSMutableAttributedString appendAttributedString:] 遇到参数为 nil 时会命中系统 assert: nil argument 的场景 + // -[__NSCFString replaceCharactersInRange:withString:] + OverrideImplementation(NSClassFromString(@"__NSCFString"), @selector(replaceCharactersInRange:withString:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(NSString *selfObject, NSRange firstArgv, id secondArgv) { + + if (!secondArgv) { + QMUIAssert(NO, @"QMUIStringPrivate", @"replaceCharactersInRange:withString: 参数 nil 会命中系统 Assert 导致 crash"); + secondArgv = @""; + } + + // call super + void (*originSelectorIMP)(id, SEL, NSRange, id); + originSelectorIMP = (void (*)(id, SEL, NSRange, id))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, firstArgv, secondArgv); + }; + }); } + (void)qmuisafety_NSAttributedString { diff --git a/QMUIKit/UIKitExtensions/UIActivityIndicatorView+QMUI.h b/QMUIKit/UIKitExtensions/UIActivityIndicatorView+QMUI.h index 3c2257b6..9735c50d 100644 --- a/QMUIKit/UIKitExtensions/UIActivityIndicatorView+QMUI.h +++ b/QMUIKit/UIKitExtensions/UIActivityIndicatorView+QMUI.h @@ -15,19 +15,16 @@ #import -@interface UIActivityIndicatorView (QMUI) +NS_ASSUME_NONNULL_BEGIN /** - * 创建一个指定大小的UIActivityIndicatorView - * - * 系统的UIActivityIndicatorView尺寸是由UIActivityIndicatorViewStyle决定的,固定不变。因此创建后通过CGAffineTransformMakeScale将其缩放到指定大小。self.frame获取的值也是缩放后的值,不影响布局。 - * init 后也可以通过 UIView(QMUI).qmui_size 修改大小。 - * - * @param style UIActivityIndicatorViewStyle - * @param size UIActivityIndicatorView的大小 - * - * @return UIActivityIndicatorView对象 + 内部通过重写系统方法来让 UIActivityIndicatorView 支持 setFrame: 方式修改尺寸,业务就像使用一个普通 UIView 一样去使用它即可。 */ -- (instancetype)initWithActivityIndicatorStyle:(UIActivityIndicatorViewStyle)style size:(CGSize)size; +@interface UIActivityIndicatorView (QMUI) + +/// 内部转圈的那个 imageView +@property(nonatomic, strong, readonly) UIImageView *qmui_animatingView; @end + +NS_ASSUME_NONNULL_END diff --git a/QMUIKit/UIKitExtensions/UIActivityIndicatorView+QMUI.m b/QMUIKit/UIKitExtensions/UIActivityIndicatorView+QMUI.m index cd2e5ea4..128cdce1 100644 --- a/QMUIKit/UIKitExtensions/UIActivityIndicatorView+QMUI.m +++ b/QMUIKit/UIKitExtensions/UIActivityIndicatorView+QMUI.m @@ -15,21 +15,73 @@ #import "UIActivityIndicatorView+QMUI.h" #import "UIView+QMUI.h" +#import "QMUICore.h" + +@interface UIActivityIndicatorView () +@property(nonatomic, assign) CGSize qmuiai_size; +@end @implementation UIActivityIndicatorView (QMUI) -- (instancetype)initWithActivityIndicatorStyle:(UIActivityIndicatorViewStyle)style size:(CGSize)size { - if (self = [self initWithActivityIndicatorStyle:style]) { - self.qmui_size = size; - } - return self; +QMUISynthesizeCGSizeProperty(qmuiai_size, setQmuiai_size) + ++ (void)load { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + /** + 系统会在你调用 setFrame: 时把 loading 设置为你希望的 rect,但 sizeToFit 又回去了,所以这里需要通过重写 setFrame: 来记录希望的 size,在 sizeThatFits: 里返回。 + 另外内部的 animatingImageView 始终会保持默认大小,所以需要重写 layoutSubviews 让 animatingImageView 可改变尺寸。 + */ + OverrideImplementation([UIActivityIndicatorView class], @selector(setFrame:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UIActivityIndicatorView *selfObject, CGRect firstArgv) { + + // call super + void (*originSelectorIMP)(id, SEL, CGRect); + originSelectorIMP = (void (*)(id, SEL, CGRect))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, firstArgv); + + selfObject.qmuiai_size = firstArgv.size; + }; + }); + + OverrideImplementation([UIActivityIndicatorView class], @selector(sizeThatFits:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^CGSize(UIActivityIndicatorView *selfObject, CGSize firstArgv) { + if (selfObject.qmuiai_size.width > 0) { + return selfObject.qmuiai_size; + } + + // call super + CGSize (*originSelectorIMP)(id, SEL, CGSize); + originSelectorIMP = (CGSize (*)(id, SEL, CGSize))originalIMPProvider(); + CGSize result = originSelectorIMP(selfObject, originCMD, firstArgv); + return result; + }; + }); + + OverrideImplementation([UIActivityIndicatorView class], @selector(layoutSubviews), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UIActivityIndicatorView *selfObject) { + + // call super + void (*originSelectorIMP)(id, SEL); + originSelectorIMP = (void (*)(id, SEL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD); + + if (selfObject.qmuiai_size.width > 0) { + selfObject.qmui_animatingView.frame = selfObject.bounds; + } + }; + }); + }); } -- (void)setQmui_size:(CGSize)size { -// [super setQmui_size:qmui_size]; - CGSize initialSize = self.bounds.size; - CGFloat scale = size.width / initialSize.width; - self.transform = CGAffineTransformMakeScale(scale, scale); +- (UIImageView *)qmui_animatingView { + SEL sel = NSSelectorFromString(@"_animatingImageView"); + if ([self respondsToSelector:sel]) { + BeginIgnorePerformSelectorLeaksWarning + return [self performSelector:sel]; + EndIgnorePerformSelectorLeaksWarning + } + return nil; } @end diff --git a/QMUIKit/UIKitExtensions/UIBarItem+QMUI.m b/QMUIKit/UIKitExtensions/UIBarItem+QMUI.m index 7799dac8..4812e800 100644 --- a/QMUIKit/UIKitExtensions/UIBarItem+QMUI.m +++ b/QMUIKit/UIKitExtensions/UIBarItem+QMUI.m @@ -28,13 +28,13 @@ + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - // UIBarButtonItem -setView: + // -[UIBarButtonItem setView:] // @warning 如果作为 UIToolbar.items 使用,则 customView 的情况下,iOS 10 及以下的版本不会调用 setView:,所以那种情况改为在 setToolbarItems:animated: 时调用,代码见下方 ExtendImplementationOfVoidMethodWithSingleArgument([UIBarButtonItem class], @selector(setView:), UIView *, ^(UIBarButtonItem *selfObject, UIView *firstArgv) { [UIBarItem setView:firstArgv inBarButtonItem:selfObject]; }); - // UITabBarItem -setView: + // -[UITabBarItem setView:] ExtendImplementationOfVoidMethodWithSingleArgument([UITabBarItem class], @selector(setView:), UIView *, ^(UITabBarItem *selfObject, UIView *firstArgv) { [UIBarItem setView:firstArgv inBarItem:selfObject]; }); diff --git a/QMUIKit/UIKitExtensions/UIControl+QMUI.m b/QMUIKit/UIKitExtensions/UIControl+QMUI.m index 7e2e889b..130c165f 100644 --- a/QMUIKit/UIKitExtensions/UIControl+QMUI.m +++ b/QMUIKit/UIKitExtensions/UIControl+QMUI.m @@ -80,13 +80,14 @@ - (void)setQmui_automaticallyAdjustTouchHighlightedInScrollView:(BOOL)qmui_autom selfObject.qmuictl_canSetHighlighted = NO; if (selfObject.touchInside) { [selfObject setHighlighted:YES]; + __weak __typeof(selfObject)weakSelf = selfObject;// 避免 dispatch retain 住 self,因为这期间可能 self 已经被 remove 了,如果还触发它的点击事件,可能导致业务逻辑异常 dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.02 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ // 如果延迟时间太长,会导致快速点击两次,事件会触发两次 // 对于 3D Touch 的机器,如果点击按钮的时候在按钮上停留事件稍微长一点点,那么 touchesEnded 会被调用两次 // 把 super touchEnded 放到延迟里调用会导致长按无法触发点击,先这么改,再想想怎么办。// [selfObject qmui_touchesEnded:touches withEvent:event]; - [selfObject sendActionsForAllTouchEventsIfCan]; - if (selfObject.highlighted) { - [selfObject setHighlighted:NO]; + [weakSelf sendActionsForAllTouchEventsIfCan]; + if (weakSelf.highlighted) { + [weakSelf setHighlighted:NO]; } }); } else { diff --git a/QMUIKit/UIKitExtensions/UIGestureRecognizer+QMUI.m b/QMUIKit/UIKitExtensions/UIGestureRecognizer+QMUI.m index d5a0ff67..203eee65 100644 --- a/QMUIKit/UIKitExtensions/UIGestureRecognizer+QMUI.m +++ b/QMUIKit/UIKitExtensions/UIGestureRecognizer+QMUI.m @@ -15,6 +15,7 @@ #import "UIGestureRecognizer+QMUI.h" #import "QMUICore.h" +#import "UIView+QMUI.h" @implementation UIGestureRecognizer (QMUI) @@ -27,7 +28,16 @@ + (void)load { // 检测常见的错误,例如在 viewWillAppear: 里把系统手势返回禁用,会导致从下一个界面手势返回到当前界面的瞬间,手势返回无效,界面处于混乱状态,无法接受任何点击事件 // _UIParallaxTransitionPanGestureRecognizer if ([NSStringFromClass(selfObject.class) containsString:@"_UIParallaxTransition"] && selfObject.enabled && !firstArgv && (selfObject.state == UIGestureRecognizerStateBegan || selfObject.state == UIGestureRecognizerStateChanged)) { - QMUIAssert(NO, @"UIGestureRecognizer (QMUI)", @"在手势进行过程中把手势禁用,可能让界面状态出现错乱!"); + NSString *desc = @"disabling interactivePopGestureRecognizer during its execution may lead to interface state inconsistency!"; + UINavigationController *navController = selfObject.view.qmui_viewController; + if ([navController isKindOfClass:UINavigationController.class]) { + UIViewController *fromVc = [navController.transitionCoordinator viewControllerForKey:UITransitionContextFromViewControllerKey]; + UIViewController *toVc = [navController.transitionCoordinator viewControllerForKey:UITransitionContextToViewControllerKey]; + if (fromVc || toVc) { + desc = [NSString stringWithFormat:@"%@ fromVc: %@, toVc: %@", desc, NSStringFromClass(fromVc.class), NSStringFromClass(toVc.class)]; + } + } + QMUIAssert(NO, @"UIGestureRecognizer (QMUI)", @"%@", desc); } // call super diff --git a/QMUIKit/UIKitExtensions/UIImage+QMUI.h b/QMUIKit/UIKitExtensions/UIImage+QMUI.h index d6a33bd6..06af72f7 100644 --- a/QMUIKit/UIKitExtensions/UIImage+QMUI.h +++ b/QMUIKit/UIKitExtensions/UIImage+QMUI.h @@ -70,6 +70,9 @@ typedef NS_ENUM(NSInteger, QMUIImageGradientType) { */ + (nullable UIImage *)qmui_imageWithSize:(CGSize)size opaque:(BOOL)opaque scale:(CGFloat)scale actions:(void (^)(CGContextRef contextRef))actionBlock; +/// 获取当前图片在 ImageAsset 里的名字(若有),且即便经过 imageWithRenderingMode 转换后也依然可以正常保留该名字(系统默认转换后就丢失名字了) +@property(nonatomic, copy, readonly, nullable) NSString *qmui_name; + /// 当前图片是否是可拉伸/平铺的,也即通过 resizableImageWithCapInsets: 处理过的图片 @property(nonatomic, assign, readonly) BOOL qmui_resizable; diff --git a/QMUIKit/UIKitExtensions/UIImage+QMUI.m b/QMUIKit/UIKitExtensions/UIImage+QMUI.m index b4d88cb4..5e54ab01 100644 --- a/QMUIKit/UIKitExtensions/UIImage+QMUI.m +++ b/QMUIKit/UIKitExtensions/UIImage+QMUI.m @@ -55,6 +55,22 @@ + (void)load { return result; }; }); + + OverrideImplementation([UIImage class], @selector(imageWithRenderingMode:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^UIImage *(UIImage *selfObject, UIImageRenderingMode mode) { + + // call super + UIImage * (*originSelectorIMP)(id, SEL, UIImageRenderingMode); + originSelectorIMP = (UIImage * (*)(id, SEL, UIImageRenderingMode))originalIMPProvider(); + UIImage * result = originSelectorIMP(selfObject, originCMD, mode); + + NSString *name = selfObject.qmui_name; + if (![result.qmui_name isEqualToString:name]) { + [result qmui_bindObject:name forKey:kQMUIImageNameKey]; + } + return result; + }; + }); }); } @@ -74,6 +90,25 @@ + (UIImage *)qmui_imageWithSize:(CGSize)size opaque:(BOOL)opaque scale:(CGFloat) return imageOut; } +static NSString * const kQMUIImageNameKey = @"kQMUIImageNameKey"; +- (NSString *)qmui_name { + NSString *name = [self qmui_getBoundObjectForKey:kQMUIImageNameKey]; + if (name.length) { + return name; + } + UIImageAsset *asset = [self valueForKey:@"_imageAsset"];// UIImage.imageAsset 是懒加载的,如果当前 image 并非从 Asset 里获取的,直接访问 getter 也会导致它构造一个 UIImageAsset 对象出来,导致后续的 assetName 为随机字符串,所以这里通过 valueForKey: 的方式直接访问 Ivar + SEL selector = NSSelectorFromString(@"assetName"); + if ([asset respondsToSelector:selector]) { + BeginIgnorePerformSelectorLeaksWarning + name = [asset performSelector:selector]; + EndIgnorePerformSelectorLeaksWarning + if (name.length) { + return name; + } + } + return nil; +} + - (BOOL)qmui_resizable { BOOL result; [self qmui_performSelector:NSSelectorFromString(@"_isResizable") withPrimitiveReturnValue:&result]; @@ -620,7 +655,7 @@ + (UIImage *)qmui_imageWithGradientColors:(NSArray *)colors type:(QMU cLocations[i] = locations[i].qmui_CGFloatValue; } - CGGradientRef gradient = CGGradientCreateWithColors(spaceRef, (CFArrayRef)[colors qmui_mapWithBlock:^id _Nonnull(UIColor * _Nonnull item) { + CGGradientRef gradient = CGGradientCreateWithColors(spaceRef, (CFArrayRef)[colors qmui_mapWithBlock:^id _Nonnull(UIColor * _Nonnull item, NSInteger index) { return (id)item.CGColor; }], cLocations); if (type == QMUIImageGradientTypeRadial) { diff --git a/QMUIKit/UIKitExtensions/UIInterface+QMUI.m b/QMUIKit/UIKitExtensions/UIInterface+QMUI.m index cfdf9920..18b01795 100644 --- a/QMUIKit/UIKitExtensions/UIInterface+QMUI.m +++ b/QMUIKit/UIKitExtensions/UIInterface+QMUI.m @@ -21,6 +21,8 @@ @implementation QMUIHelper (QMUI_Interface) QMUISynthesizeNSIntegerProperty(lastOrientationChangedByHelper, setLastOrientationChangedByHelper) - (void)handleDeviceOrientationNotification:(NSNotification *)notification { + QMUILogInfo(@"Interface (QMUI)", @"device orientation did change to %@", @(((UIDevice *)([notification.object isKindOfClass:UIDevice.class] ? notification.object : UIDevice.currentDevice)).orientation)); + // 如果是由 setValue:forKey: 方式修改方向而走到这个 notification 的话,理论上是不需要重置为 Unknown 的,但因为在 UIViewController (QMUI) 那边会再次记录旋转前的值,所以这里就算重置也无所谓 [QMUIHelper sharedInstance].lastOrientationChangedByHelper = UIDeviceOrientationUnknown; } @@ -178,6 +180,8 @@ + (void)load { } - (BOOL)qmui_rotateToInterfaceOrientation:(UIInterfaceOrientation)interfaceOrientation { + QMUILogInfo(@"Interface (QMUI)", @"try rotating to %@", @(interfaceOrientation)); + #ifdef IOS16_SDK_ALLOWED if (@available(iOS 16.0, *)) { diff --git a/QMUIKit/UIKitExtensions/UILabel+QMUI.h b/QMUIKit/UIKitExtensions/UILabel+QMUI.h index da10c627..17995ffc 100644 --- a/QMUIKit/UIKitExtensions/UILabel+QMUI.h +++ b/QMUIKit/UIKitExtensions/UILabel+QMUI.h @@ -60,6 +60,18 @@ extern const CGFloat QMUILineHeightIdentity; */ @property(nonatomic, assign) CGFloat qmui_lineHeight; +/** + 获取当前 font.capHeight 的中心点在 label.bounds.size.height 里的y值(代表字符的中心点位置),从而令业务在试图将文本垂直居中时可以基于该属性的返回值去计算。 + @warning 仅对单行文本有意义,对 label 的高度没有要求,但 label 不应该调整过 baselineOffset + */ +@property(nonatomic, assign, readonly) CGFloat qmui_centerOfCapHeight; + +/** + 获取当前 font.xHeight 的中心点在 label.bounds.size.height 里的y值(代表x这种矮的字符的中心点位置),从而令业务在试图将文本垂直居中时可以基于该属性的返回值去计算。 + @warning 仅对单行文本有意义,对 label 的高度没有要求,但 label 不应该调整过 baselineOffset + */ +@property(nonatomic, assign, readonly) CGFloat qmui_centerOfXHeight; + /** * 将目标UILabel的样式属性设置到当前UILabel上 * diff --git a/QMUIKit/UIKitExtensions/UILabel+QMUI.m b/QMUIKit/UIKitExtensions/UILabel+QMUI.m index e6d19b64..8cbd04b9 100644 --- a/QMUIKit/UIKitExtensions/UILabel+QMUI.m +++ b/QMUIKit/UIKitExtensions/UILabel+QMUI.m @@ -42,11 +42,20 @@ + (void)load { for (NSUInteger index = 0; index < sizeof(selectors) / sizeof(SEL); index++) { SEL originalSelector = selectors[index]; SEL swizzledSelector = NSSelectorFromString([@"qmuilb_" stringByAppendingString:NSStringFromSelector(originalSelector)]); - ExchangeImplementations([self class], originalSelector, swizzledSelector); + ExchangeImplementations([UILabel class], originalSelector, swizzledSelector); } }); } +- (instancetype)qmui_initWithFont:(UIFont *)font textColor:(UIColor *)textColor { + BeginIgnoreClangWarning(-Wunused-value) + [self init]; + EndIgnoreClangWarning + self.font = font; + self.textColor = textColor; + return self; +} + - (void)qmuilb_setText:(NSString *)text { if (!text) { [self qmuilb_setText:text]; @@ -96,7 +105,7 @@ - (void)setQmui_textAttributes:(NSDictionary *)qmui_t NSMutableArray *willRemovedAttributes = [NSMutableArray array]; [string enumerateAttributesInRange:NSMakeRange(0, string.length) options:0 usingBlock:^(NSDictionary * _Nonnull attrs, NSRange range, BOOL * _Nonnull stop) { // 如果存在 kern 属性,则只有 range 是第一个字至倒数第二个字,才有可能是通过 qmui_textAttribtus 设置的 - if (NSEqualRanges(range, NSMakeRange(0, string.length - 1)) && [attrs[NSKernAttributeName] isEqualToNumber:prevTextAttributes[NSKernAttributeName]]) { + if (NSEqualRanges(range, NSMakeRange(0, string.length - 1)) && [attrs[NSKernAttributeName] isEqual:prevTextAttributes[NSKernAttributeName]]) { [string removeAttribute:NSKernAttributeName range:NSMakeRange(0, string.length - 1)]; } // 上面排除掉 kern 属性后,如果 range 不是整个字符串,那肯定不是通过 qmui_textAttributes 设置的 @@ -160,7 +169,7 @@ - (NSAttributedString *)attributedStringWithKernAndLineHeightAdjusted:(NSAttribu [attributedString addAttribute:NSParagraphStyleAttributeName value:paraStyle range:NSMakeRange(0, attributedString.length)]; // iOS 默认文字底对齐,改了行高要自己调整才能保证文字一直在 label 里垂直居中 - CGFloat baselineOffset = (self.qmui_lineHeight - self.font.lineHeight) / 4;// 实际测量得知,baseline + 1,文字会往上移动 2pt,所以这里为了垂直居中,需要 / 4。 + CGFloat baselineOffset = [QMUIHelper baselineOffsetWhenVerticalAlignCenterInHeight:self.qmui_lineHeight withFont:self.font]; [attributedString addAttribute:NSBaselineOffsetAttributeName value:@(baselineOffset) range:NSMakeRange(0, attributedString.length)]; } @@ -240,13 +249,24 @@ - (BOOL)_hasSetQmuiLineHeight { return !!objc_getAssociatedObject(self, &kAssociatedObjectKey_lineHeight); } -- (instancetype)qmui_initWithFont:(UIFont *)font textColor:(UIColor *)textColor { - BeginIgnoreClangWarning(-Wunused-value) - [self init]; - EndIgnoreClangWarning - self.font = font; - self.textColor = textColor; - return self; +- (CGFloat)qmui_centerOfCapHeight { + NSRange range = NSMakeRange(0, self.attributedText.length); + UIFont *font = [self.attributedText attribute:NSFontAttributeName atIndex:0 effectiveRange:&range]; + if (!font) { + font = self.font; + } + CGFloat center = CGRectGetHeight(self.bounds) + font.descender - font.capHeight / 2; + return center; +} + +- (CGFloat)qmui_centerOfXHeight { + NSRange range = NSMakeRange(0, self.attributedText.length); + UIFont *font = [self.attributedText attribute:NSFontAttributeName atIndex:0 effectiveRange:&range]; + if (!font) { + font = self.font; + } + CGFloat center = CGRectGetHeight(self.bounds) + font.descender - font.xHeight / 2; + return center; } - (void)qmui_setTheSameAppearanceAsLabel:(UILabel *)label { @@ -293,13 +313,18 @@ - (void)setQmui_showPrincipalLines:(BOOL)qmui_showPrincipalLines { if (!self.qmui_layoutSubviewsBlock) { self.qmui_layoutSubviewsBlock = ^(UILabel * _Nonnull label) { + if (!label.attributedText.length) return; if (!label.qmuilb_principalLineLayer || label.qmuilb_principalLineLayer.hidden) return; label.qmuilb_principalLineLayer.frame = label.bounds; NSRange range = NSMakeRange(0, label.attributedText.length); - CGFloat baselineOffset = [[label.attributedText attribute:NSBaselineOffsetAttributeName atIndex:0 effectiveRange:&range] doubleValue]; - CGFloat lineOffset = baselineOffset * 2; + CGFloat lineOffset = [[label.attributedText attribute:NSBaselineOffsetAttributeName atIndex:0 effectiveRange:&range] doubleValue]; + // ≤ iOS 15 的设备上,1pt baseline 会让文本向上移动 2pt,≥ iOS 16 均为 1:1 移动。 + if (@available(iOS 16.0, *)) { + } else { + lineOffset = lineOffset * 2; + } UIFont *font = label.font; CGFloat maxX = CGRectGetWidth(label.bounds); CGFloat maxY = CGRectGetHeight(label.bounds); diff --git a/QMUIKit/UIKitExtensions/UIMenuController+QMUI.m b/QMUIKit/UIKitExtensions/UIMenuController+QMUI.m index 3f9d426a..572dba84 100644 --- a/QMUIKit/UIKitExtensions/UIMenuController+QMUI.m +++ b/QMUIKit/UIKitExtensions/UIMenuController+QMUI.m @@ -14,91 +14,131 @@ #import "UIMenuController+QMUI.h" #import "QMUICore.h" - -@interface UIMenuController () - -@property(nonatomic, assign) NSInteger qmui_originWindowLevel; -@property(nonatomic, assign) BOOL qmui_windowLevelChanged; - -@end +#import "NSArray+QMUI.h" @implementation UIMenuController (QMUI) -QMUISynthesizeNSIntegerProperty(qmui_originWindowLevel, setQmui_originWindowLevel); -QMUISynthesizeBOOLProperty(qmui_windowLevelChanged, setQmui_windowLevelChanged); - static UIWindow *kMenuControllerWindow = nil; -static BOOL kHasAddedMenuControllerNotification = NO; + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - OverrideImplementation(object_getClass([UIMenuController class]), @selector(sharedMenuController), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { - return ^(UIMenuController *selfObject) { - - // call super - UIMenuController *(*originSelectorIMP)(id, SEL); - originSelectorIMP = (UIMenuController *(*)(id, SEL))originalIMPProvider(); - UIMenuController *menuController = originSelectorIMP(selfObject, originCMD); - - /// 修复 issue:https://github.com/Tencent/QMUI_iOS/issues/659 - if (!kHasAddedMenuControllerNotification) { - kHasAddedMenuControllerNotification = YES; - [[NSNotificationCenter defaultCenter] addObserver:menuController selector:@selector(handleMenuWillShowNotification:) name:UIMenuControllerWillShowMenuNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:menuController selector:@selector(handleMenuWillHideNotification:) name:UIMenuControllerWillHideMenuNotification object:nil]; - } - - return menuController; - }; - }); + if (@available(iOS 16.0, *)) { + // iOS 16 开始改为用 UIEditMenuInteraction,以前的做法也无效了,所以用 hook 的方式解决 + // https://github.com/Tencent/QMUI_iOS/issues/1538 + + // UIEditMenuInteraction + // - (void)presentEditMenuWithConfiguration:(UIEditMenuConfiguration *)configuration; + OverrideImplementation([UIEditMenuInteraction class], @selector(presentEditMenuWithConfiguration:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UIEditMenuInteraction *selfObject, UIEditMenuConfiguration *configuration) { + + // call super + void (*originSelectorIMP)(id, SEL, UIEditMenuConfiguration *); + originSelectorIMP = (void (*)(id, SEL, UIEditMenuConfiguration *))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, configuration); + + // 走到 present 的时候 window 可能还没构造,所以这里延迟一下再调用 + dispatch_async(dispatch_get_main_queue(), ^{ + [UIMenuController qmuimc_handleMenuWillShow]; + }); + }; + }); + + OverrideImplementation([UIEditMenuInteraction class], @selector(dismissMenu), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UIEditMenuInteraction *selfObject) { + + // call super + void (*originSelectorIMP)(id, SEL); + originSelectorIMP = (void (*)(id, SEL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD); + + [UIMenuController qmuimc_handleMenuWillHide]; + }; + }); + + } else if (@available(iOS 13.0, *)) { + // +[UIMenuController sharedMenuController] + OverrideImplementation(object_getClass([UIMenuController class]), @selector(sharedMenuController), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UIMenuController *selfObject) { + + // call super + UIMenuController *(*originSelectorIMP)(id, SEL); + originSelectorIMP = (UIMenuController *(*)(id, SEL))originalIMPProvider(); + UIMenuController *menuController = originSelectorIMP(selfObject, originCMD); + + /// 修复 issue:https://github.com/Tencent/QMUI_iOS/issues/659 + /// UIMenuController 本身就是单例,这里就不考虑释放了 + if (![menuController qmui_getBoundBOOLForKey:@"kHasAddedNotification"]) { + [menuController qmui_bindBOOL:YES forKey:@"kHasAddedNotification"]; + [NSNotificationCenter.defaultCenter addObserverForName:UIMenuControllerWillShowMenuNotification object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification * _Nonnull notification) { + [UIMenuController qmuimc_handleMenuWillShow]; + }]; + [NSNotificationCenter.defaultCenter addObserverForName:UIMenuControllerWillHideMenuNotification object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification * _Nonnull notification) { + [UIMenuController qmuimc_handleMenuWillHide]; + }]; + } + + return menuController; + }; + }); + } }); } -- (void)handleMenuWillShowNotification:(NSNotification *)notification { - UIWindow *window = [self menuControllerWindow]; - UIWindow *targetWindow = [self windowForFirstResponder]; ++ (void)qmuimc_handleMenuWillShow { + UIWindow *window = [UIMenuController qmuimc_menuControllerWindow]; + UIWindow *targetWindow = [UIMenuController qmuimc_firstResponderWindowExceptMainWindow]; if (window && targetWindow && ![QMUIHelper isKeyboardVisible]) { - QMUILog(NSStringFromClass(self.class), @"show menu - cur window level = %@, origin window level = %@ target window level = %@", @(window.windowLevel), @(self.qmui_originWindowLevel), @(targetWindow.windowLevel)); - self.qmui_windowLevelChanged = YES; - self.qmui_originWindowLevel = window.windowLevel; + QMUILog(@"UIMenuController", @"show menu - cur window level = %@, origin window level = %@ target window level = %@", @(window.windowLevel), @([window qmui_getBoundLongForKey:@"kOriginalWindowLevel"]), @(targetWindow.windowLevel)); + [window qmui_bindLong:window.windowLevel forKey:@"kOriginalWindowLevel"]; + [window qmui_bindBOOL:YES forKey:@"kWindowLevelChanged"]; window.windowLevel = targetWindow.windowLevel + 1; } } -- (void)handleMenuWillHideNotification:(NSNotification *)notification { - UIWindow *window = [self menuControllerWindow]; - if (window && self.qmui_windowLevelChanged) { - QMUILog(NSStringFromClass(self.class), @"hide menu - cur window level = %@, origin window level = %@", @(window.windowLevel), @(self.qmui_originWindowLevel)); - window.windowLevel = self.qmui_originWindowLevel; - self.qmui_originWindowLevel = 0; - self.qmui_windowLevelChanged = NO; ++ (void)qmuimc_handleMenuWillHide { + UIWindow *window = [UIMenuController qmuimc_menuControllerWindow]; + if (window && [window qmui_getBoundBOOLForKey:@"kWindowLevelChanged"]) { + QMUILog(@"UIMenuController", @"hide menu - cur window level = %@, origin window level = %@", @(window.windowLevel), @([window qmui_getBoundLongForKey:@"kOriginalWindowLevel"])); + window.windowLevel = [window qmui_getBoundLongForKey:@"kOriginalWindowLevel"]; + [window qmui_bindLong:0 forKey:@"kOriginalWindowLevel"]; + [window qmui_bindBOOL:NO forKey:@"kWindowLevelChanged"]; } } -- (UIWindow *)menuControllerWindow { ++ (UIWindow *)qmuimc_menuControllerWindow { if (kMenuControllerWindow && !kMenuControllerWindow.hidden) { return kMenuControllerWindow; } [UIApplication.sharedApplication.windows enumerateObjectsUsingBlock:^(__kindof UIWindow * _Nonnull window, NSUInteger idx, BOOL * _Nonnull stop) { NSString *windowString = [NSString stringWithFormat:@"UI%@%@", @"Text", @"EffectsWindow"]; if ([window isKindOfClass:NSClassFromString(windowString)] && !window.hidden) { - [window.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull subview, NSUInteger idx, BOOL * _Nonnull stop) { - NSString *targetView = [NSString stringWithFormat:@"UI%@%@", @"Callout", @"Bar"]; - if ([subview isKindOfClass:NSClassFromString(targetView)]) { + if (@available(iOS 16.0, *)) { + UIView *view = [window.subviews qmui_firstMatchWithBlock:^BOOL(__kindof UIView * _Nonnull item) { + return [NSStringFromClass(item.class) isEqualToString:[NSString qmui_stringByConcat:@"_", @"UI", @"EditMenu", @"ContainerView", nil]]; + }]; + if (view) { kMenuControllerWindow = window; - *stop = YES; } - }]; + } else { + [window.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull subview, NSUInteger idx, BOOL * _Nonnull stop) { + NSString *targetView = [NSString stringWithFormat:@"UI%@%@", @"Callout", @"Bar"]; + if ([subview isKindOfClass:NSClassFromString(targetView)]) { + kMenuControllerWindow = window; + *stop = YES; + } + }]; + } } }]; return kMenuControllerWindow; } -- (UIWindow *)windowForFirstResponder { ++ (UIWindow *)qmuimc_firstResponderWindowExceptMainWindow { __block UIWindow *resultWindow = nil; [UIApplication.sharedApplication.windows enumerateObjectsUsingBlock:^(__kindof UIWindow * _Nonnull window, NSUInteger idx, BOOL * _Nonnull stop) { if (window != UIApplication.sharedApplication.delegate.window) { - UIResponder *responder = [self findFirstResponderInView:window]; + UIResponder *responder = [UIMenuController qmuimc_findFirstResponderInView:window]; if (responder) { resultWindow = window; *stop = YES; @@ -108,12 +148,12 @@ - (UIWindow *)windowForFirstResponder { return resultWindow; } -- (UIResponder *)findFirstResponderInView:(UIView *)view { ++ (UIResponder *)qmuimc_findFirstResponderInView:(UIView *)view { if (view.isFirstResponder) { return view; } for (UIView *subView in view.subviews) { - id responder = [self findFirstResponderInView:subView]; + id responder = [UIMenuController qmuimc_findFirstResponderInView:subView]; if (responder) { return responder; } diff --git a/QMUIKit/UIKitExtensions/UINavigationBar+QMUI.m b/QMUIKit/UIKitExtensions/UINavigationBar+QMUI.m index b0bde16f..8d82a777 100644 --- a/QMUIKit/UIKitExtensions/UINavigationBar+QMUI.m +++ b/QMUIKit/UIKitExtensions/UINavigationBar+QMUI.m @@ -341,7 +341,14 @@ + (void)load { originSelectorIMP(selfObject, originCMD, firstArgv); // 这里只希望识别 UINavigationController 自带的 navigationBar,不希望处理业务自己 new 的 bar,所以用 superview 是否为 UILayoutContainerView 来作为判断条件。 - if ([NSStringFromClass(selfObject.superview.class) hasPrefix:@"UILayoutContainer"] && !selfObject.window) { + BOOL isSystemBar = [NSStringFromClass(selfObject.superview.class) hasPrefix:@"UILayoutContainer"]; + BOOL alreadyMoveToWindow = !!selfObject.window; + BOOL isPresenting = NO; + if (!alreadyMoveToWindow) { + UINavigationController *nav = [selfObject.qmui_viewController isKindOfClass:UINavigationController.class] ? selfObject.qmui_viewController : nil; + isPresenting = nav && nav.presentedViewController; + } + if (isSystemBar && !alreadyMoveToWindow && !isPresenting) { QMUIAssert(NO, @"UINavigationBar (QMUI)", @"试图在 UINavigationBar 尚未添加到 window 上时就修改它的样式,可能导致 UINavigationBar 的样式无法与全局保持一致。"); } }; diff --git a/QMUIKit/UIKitExtensions/UINavigationController+QMUI.h b/QMUIKit/UIKitExtensions/UINavigationController+QMUI.h index 87483b3d..1a80c2d0 100644 --- a/QMUIKit/UIKitExtensions/UINavigationController+QMUI.h +++ b/QMUIKit/UIKitExtensions/UINavigationController+QMUI.h @@ -57,12 +57,19 @@ typedef void (^QMUINavigationActionDidChangeBlock)(QMUINavigationAction action, */ - (void)qmui_addNavigationActionDidChangeBlock:(QMUINavigationActionDidChangeBlock)block; +/// 系统的设定是当 UINavigationController 不可见时(例如上面盖着一个 present vc,或者切到别的 tab),push/pop 操作均不会调用 vc 的生命周期方法(viewDidLoad 也是在 nav 恢复可视时才触发),所以提供这个属性用于当你希望这种情况下依然调用生命周期方法时,你可以打开它。默认为 NO。 +/// @warning 由于强制在 push/pop 时触发生命周期方法,所以会导致 vc 的 viewDidLoad 等方法比系统默认的更早调用,知悉即可。 +@property(nonatomic, assign) BOOL qmui_alwaysInvokeAppearanceMethods; + /// 是否在 push 的过程中 @property(nonatomic, readonly) BOOL qmui_isPushing; /// 是否在 pop 的过程中,包括手势、以及代码触发的 pop @property(nonatomic, readonly) BOOL qmui_isPopping; +/// 以系统私有方法的方式去判断当前正在进行 push 动画还是 pop 动画,注意 setViewControllers 直接表现也是 push 或 pop 动画,可以通过 qmui_lastOperation 得知,但 qmui_isPushing、qmui_isPopping 无法区分 setViewControllers 的情况。 +@property(nonatomic, readonly) UINavigationControllerOperation qmui_lastOperation; + /// 获取顶部的 ViewController,相比于系统的方法,这个方法能获取到 pop 的转场过程中顶部还没有完全消失的 ViewController (请注意:这种情况下,获取到的 topViewController 已经不在栈内) @property(nullable, nonatomic, readonly) UIViewController *qmui_topViewController; diff --git a/QMUIKit/UIKitExtensions/UINavigationController+QMUI.m b/QMUIKit/UIKitExtensions/UINavigationController+QMUI.m index f530efa7..f5a15064 100644 --- a/QMUIKit/UIKitExtensions/UINavigationController+QMUI.m +++ b/QMUIKit/UIKitExtensions/UINavigationController+QMUI.m @@ -34,6 +34,7 @@ @interface UINavigationController () @implementation UINavigationController (QMUI) +QMUISynthesizeBOOLProperty(qmui_alwaysInvokeAppearanceMethods, setQmui_alwaysInvokeAppearanceMethods) QMUISynthesizeIdStrongProperty(qmuinc_navigationActionDidChangeBlocks, setQmuinc_navigationActionDidChangeBlocks) QMUISynthesizeIdWeakProperty(qmui_endedTransitionTopViewController, setQmui_endedTransitionTopViewController) QMUISynthesizeIdWeakProperty(qmui_interactivePopGestureRecognizerDelegate, setQmui_interactivePopGestureRecognizerDelegate) @@ -175,8 +176,13 @@ + (void)load { OverrideImplementation([UINavigationController class], @selector(pushViewController:animated:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { return ^(UINavigationController *selfObject, UIViewController *viewController, BOOL animated) { - if (selfObject.presentedViewController) { - QMUILogWarn(NSStringFromClass(originClass), @"push 的时候 UINavigationController 存在一个盖在上面的 presentedViewController,可能导致一些 UINavigationControllerDelegate 不会被调用"); + BOOL shouldInvokeAppearanceMethod = NO; + + if (selfObject.isViewLoaded && !selfObject.view.window) { + QMUILogWarn(NSStringFromClass(originClass), @"push 的时候 navigationController 不可见(例如上面盖着一个 present vc,或者切到别的 tab,可能导致 vc 的生命周期方法或者 UINavigationControllerDelegate 不会被调用"); + if (selfObject.qmui_alwaysInvokeAppearanceMethods) { + shouldInvokeAppearanceMethod = YES; + } } if ([selfObject.viewControllers containsObject:viewController]) { @@ -191,9 +197,10 @@ + (void)load { originSelectorIMP(selfObject, originCMD, viewController, animated); }; - BOOL willPushActually = viewController && ![viewController isKindOfClass:UITabBarController.class] && ![selfObject.viewControllers containsObject:viewController]; + BOOL willPushActually = viewController && ![selfObject.viewControllers containsObject:viewController]; if (!willPushActually) { + QMUIAssert(NO, @"UINavigationController (QMUI)", @"调用了 pushViewController 但实际上没 push 成功,viewController:%@", viewController); callSuperBlock(); return; } @@ -203,6 +210,11 @@ + (void)load { [selfObject setQmui_navigationAction:QMUINavigationActionWillPush animated:animated appearingViewController:appearingViewController disappearingViewControllers:disappearingViewControllers]; + if (shouldInvokeAppearanceMethod) { + [disappearingViewControllers.lastObject beginAppearanceTransition:NO animated:animated]; + [appearingViewController beginAppearanceTransition:YES animated:animated]; + } + callSuperBlock(); [selfObject setQmui_navigationAction:QMUINavigationActionDidPush animated:animated appearingViewController:appearingViewController disappearingViewControllers:disappearingViewControllers]; @@ -210,6 +222,11 @@ + (void)load { [selfObject qmui_animateAlongsideTransition:nil completion:^(id _Nonnull context) { [selfObject setQmui_navigationAction:QMUINavigationActionPushCompleted animated:animated appearingViewController:appearingViewController disappearingViewControllers:disappearingViewControllers]; [selfObject setQmui_navigationAction:QMUINavigationActionUnknow animated:animated appearingViewController:nil disappearingViewControllers:nil]; + + if (shouldInvokeAppearanceMethod) { + [disappearingViewControllers.lastObject endAppearanceTransition]; + [appearingViewController endAppearanceTransition]; + } }]; }; }); @@ -236,11 +253,25 @@ + (void)load { return callSuperBlock(); } + BOOL shouldInvokeAppearanceMethod = NO; + + if (selfObject.isViewLoaded && !selfObject.view.window) { + QMUILogWarn(NSStringFromClass(originClass), @"pop 的时候 navigationController 不可见(例如上面盖着一个 present vc,或者切到别的 tab,可能导致 vc 的生命周期方法或者 UINavigationControllerDelegate 不会被调用"); + if (selfObject.qmui_alwaysInvokeAppearanceMethods) { + shouldInvokeAppearanceMethod = YES; + } + } + UIViewController *appearingViewController = selfObject.viewControllers[selfObject.viewControllers.count - 2]; NSArray *disappearingViewControllers = selfObject.viewControllers.lastObject ? @[selfObject.viewControllers.lastObject] : nil; [selfObject setQmui_navigationAction:QMUINavigationActionWillPop animated:animated appearingViewController:appearingViewController disappearingViewControllers:disappearingViewControllers]; + if (shouldInvokeAppearanceMethod) { + [disappearingViewControllers.lastObject beginAppearanceTransition:NO animated:animated]; + [appearingViewController beginAppearanceTransition:YES animated:animated]; + } + UIViewController *result = callSuperBlock(); // UINavigationController 不可见时 return 值可能为 nil @@ -253,6 +284,11 @@ + (void)load { void (^transitionCompletion)(void) = ^void(void) { [selfObject setQmui_navigationAction:QMUINavigationActionPopCompleted animated:animated appearingViewController:appearingViewController disappearingViewControllers:disappearingViewControllers]; [selfObject setQmui_navigationAction:QMUINavigationActionUnknow animated:animated appearingViewController:nil disappearingViewControllers:nil]; + + if (shouldInvokeAppearanceMethod) { + [disappearingViewControllers.lastObject endAppearanceTransition]; + [appearingViewController endAppearanceTransition]; + } }; if (!result) { // 如果系统的 pop 没有成功,实际上提交给 animateAlongsideTransition:completion: 的 completion 并不会被执行,所以这里改为手动调用 @@ -423,18 +459,27 @@ - (void)qmui_addNavigationActionDidChangeBlock:(QMUINavigationActionDidChangeBlo [self.qmuinc_navigationActionDidChangeBlocks addObject:block]; } -// TODO: molice 改为用 QMUINavigationAction 判断 - (BOOL)qmui_isPushing { BOOL isPushing = self.qmui_navigationAction > QMUINavigationActionWillPush && self.qmui_navigationAction <= QMUINavigationActionPushCompleted; return isPushing; } -// TODO: molice 改为用 QMUINavigationAction 判断 - (BOOL)qmui_isPopping { BOOL isPopping = self.qmui_navigationAction > QMUINavigationActionWillPop && self.qmui_navigationAction <= QMUINavigationActionPopCompleted; return isPopping; } +- (UINavigationControllerOperation)qmui_lastOperation { + // -[UINavigationController lastOperation] + SEL operationSEL = NSSelectorFromString([NSString qmui_stringByConcat:@"last", @"Operation", nil]); + if ([self respondsToSelector:operationSEL]) { + UINavigationControllerOperation operation = UINavigationControllerOperationNone; + [self qmui_performSelector:operationSEL withPrimitiveReturnValue:&operation]; + return operation; + } + return UINavigationControllerOperationNone; +} + - (UIViewController *)qmui_topViewController { if (self.qmui_isPushing) { return self.topViewController; @@ -443,7 +488,17 @@ - (UIViewController *)qmui_topViewController { } - (nullable UIViewController *)qmui_rootViewController { - return self.viewControllers.firstObject; + UIViewController *rootViewController = self.viewControllers.firstObject; + // 系统 UINavigationController 的 popToViewController、popToRootViewController、setViewControllers 三种 pop 的方式都有一个共同的特点,假如此时有3个及以上的 vc 例如 [A,B,C],从当前界面 pop 到非相邻的界面,例如C到A,执行完 pop 操作后立马访问 UINavigationController.viewControllers 预期应该得到 [A],实际上会得到 [C,A],过一会(nav.view layoutIfNeeded 之后)才变成正确的 [A]。同理,[A,B,C,D]时从 D pop 到 B,预期得到[A,B],实际得到[D,A,B],也即这种情况它总是会把当前界面塞到 viewControllers 数组里的第一个,这就导致这期间访问基于 viewControllers 数组实现的功能(例如 qmui_rootViewController、qmui_previousViewController),都可能出错,所以这里对上述情况做特殊保护。 + // 如果 pop 操作时只有2个vc,则没这种问题。 + if (self.viewControllers.count > 1 && self.qmui_isPopping && self.transitionCoordinator) { + id transitionCoordinator = self.transitionCoordinator; + UIViewController *fromVc = [transitionCoordinator viewControllerForKey:UITransitionContextFromViewControllerKey]; + if (rootViewController == fromVc) { + rootViewController = self.viewControllers[1]; + } + } + return rootViewController; } - (void)qmui_pushViewController:(UIViewController *)viewController animated:(BOOL)animated completion:(void (^)(void))completion { @@ -526,9 +581,14 @@ - (BOOL)_gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiv if ([originGestureDelegate respondsToSelector:_cmd]) { BOOL originalValue = YES; [originGestureDelegate qmui_performSelector:_cmd withPrimitiveReturnValue:&originalValue arguments:&gestureRecognizer, &event, nil]; - if (!originalValue && [self.parentViewController shouldForceEnableInteractivePopGestureRecognizer]) { + if (!originalValue + // 在开启 forceEnableInteractivePopGestureRecognizer 的界面被 push 的过程中快速手势返回,容易导致 App 卡死 + // https://github.com/Tencent/QMUI_iOS/issues/1498 + && self.parentViewController.qmui_navigationAction == QMUINavigationActionUnknow + && [self.parentViewController shouldForceEnableInteractivePopGestureRecognizer]) { return YES; } + return originalValue; } } diff --git a/QMUIKit/UIKitExtensions/UIScrollView+QMUI.h b/QMUIKit/UIKitExtensions/UIScrollView+QMUI.h index 4601ed96..c4005762 100644 --- a/QMUIKit/UIKitExtensions/UIScrollView+QMUI.h +++ b/QMUIKit/UIKitExtensions/UIScrollView+QMUI.h @@ -15,6 +15,13 @@ #import +typedef NS_ENUM(NSInteger, QMUIScrollPosition) { + QMUIScrollPositionNone, // 滚动到临近的区域(可能是 Top 也可能是 Bottom) + QMUIScrollPositionTop, // 滚动到可视区域最顶部 + QMUIScrollPositionMiddle, // 滚动到可视区域中间 + QMUIScrollPositionBottom, // 滚动到可视区域底部 +}; + @interface UIScrollView (QMUI) /// 判断UIScrollView是否已经处于顶部(当UIScrollView内容不够多不可滚动时,也认为是在顶部) @@ -68,7 +75,10 @@ /// 等同于[self qmui_scrollToBottomAnimated:NO] - (void)qmui_scrollToBottom; -// 立即停止滚动,用于那种手指已经离开屏幕但列表还在滚动的情况。 +/// 将 scroll 坐标系内的指定 rect 滚动到指定位置。 +- (void)qmui_scrollToRect:(CGRect)rect atPosition:(QMUIScrollPosition)scrollPosition animated:(BOOL)animated; + +/// 立即停止滚动,用于那种手指已经离开屏幕但列表还在滚动的情况。 - (void)qmui_stopDeceleratingIfNeeded; /** diff --git a/QMUIKit/UIKitExtensions/UIScrollView+QMUI.m b/QMUIKit/UIKitExtensions/UIScrollView+QMUI.m index f438dd12..9ea0a5a0 100644 --- a/QMUIKit/UIKitExtensions/UIScrollView+QMUI.m +++ b/QMUIKit/UIKitExtensions/UIScrollView+QMUI.m @@ -81,7 +81,7 @@ - (BOOL)qmui_alreadyAtBottom { return YES; } - if (((NSInteger)self.contentOffset.y) == ((NSInteger)self.contentSize.height + self.adjustedContentInset.bottom - CGRectGetHeight(self.bounds))) { + if (CGFloatEqualToFloat(self.contentOffset.y, self.contentSize.height + self.adjustedContentInset.bottom - CGRectGetHeight(self.bounds))) { return YES; } @@ -160,4 +160,24 @@ - (void)qmui_setContentInset:(UIEdgeInsets)contentInset animated:(BOOL)animated } completion:nil]; } +- (void)qmui_scrollToRect:(CGRect)rect atPosition:(QMUIScrollPosition)scrollPosition animated:(BOOL)animated { + if (!self.qmui_canScroll) return; + BOOL fullyVisible = CGRectContainsRect(self.bounds, CGRectInsetEdges(rect, UIEdgeInsetsMake(0.5, 0.5, 0.5, 0.5)));// 四周故意减小一点点,避免小数点精度误差导致误以为无法 contains + if (fullyVisible) return; + if (scrollPosition == QMUIScrollPositionNone) { + [self scrollRectToVisible:rect animated:animated]; + return; + } + CGFloat targetY = self.contentOffset.y; + if (scrollPosition == QMUIScrollPositionTop) { + targetY = CGRectGetMinY(rect); + } else if (scrollPosition == QMUIScrollPositionBottom) { + targetY = CGRectGetMaxY(rect) - CGRectGetHeight(self.bounds); + } else if (scrollPosition == QMUIScrollPositionMiddle) { + targetY = CGRectGetMinY(rect) - (CGRectGetHeight(self.bounds) - CGRectGetHeight(rect)) / 2; + } + CGFloat offsetY = MIN(self.contentSize.height + self.adjustedContentInset.bottom - CGRectGetHeight(self.bounds), MAX(-self.adjustedContentInset.top, targetY)); + self.contentOffset = CGPointMake(self.contentOffset.x, offsetY); +} + @end diff --git a/QMUIKit/UIKitExtensions/UISearchBar+QMUI.h b/QMUIKit/UIKitExtensions/UISearchBar+QMUI.h index 612ec3f6..53c8cf46 100644 --- a/QMUIKit/UIKitExtensions/UISearchBar+QMUI.h +++ b/QMUIKit/UIKitExtensions/UISearchBar+QMUI.h @@ -51,6 +51,10 @@ NS_ASSUME_NONNULL_BEGIN /// 支持根据 active 的值的不同来设置不一样的输入框位置偏移,当使用这个 block 后 @c qmui_textFieldMargins 无效。 @property(nonatomic, copy) UIEdgeInsets (^qmui_textFieldMarginsBlock)(__kindof UISearchBar *searchBar, BOOL active); +/// 当 UITableView 右侧出现 A-Z 那种索引条时,必要的情况下(例如全面屏 iPhone 的横屏状态,右侧已经存在较大的 safeAreaInsets,足以容纳 indexBar,则这种情况下系统就不会再调整了)系统会自动调整列表内容的布局(包括 sectionHeaderFooter、cell、作为 tableHeaderView 使用的 UISearchBar),在右侧腾出空间,以避免列表内容与 indexBar 重叠。 +/// 这个属性用于控制这种行为在 UISearchBar 里是否生效,默认为 YES,置为 NO 则可确保 UISearchBar 的布局在 indexBar 显示、隐藏时均保持一致,不产生跳动。弊端是如果屏幕较矮,且 indexBar 内容较多,则 searchBar 输入框右侧可能与 indexBar 产生重叠,请知悉。 +@property(nonatomic, assign) BOOL qmui_adjustTextFieldLayoutForIndexBar; + /// 获取 searchBar 的背景 view,为一个 UIImageView 的子类 UISearchBarBackground,在 searchBar 初始化完即可被获取 @property(nullable, nonatomic, weak, readonly) UIView *qmui_backgroundView; @@ -95,6 +99,10 @@ NS_ASSUME_NONNULL_BEGIN /// https://github.com/Tencent/QMUI_iOS/issues/950 @property(nonatomic, assign) BOOL qmui_fixMaskViewLayoutBugAutomatically; +/// 是否需要自动修复 UISearchController.searchBar 作为 UITableView.tableHeaderView 时进入搜索状态,搜索结果列表顶部有一大片空白的 bug,默认为 YES。 +/// https://github.com/Tencent/QMUI_iOS/issues/1473 +@property(nonatomic, assign) BOOL qmui_shouldFixSearchResultsContentInset; + - (void)qmui_styledAsQMUISearchBar; /// 生成指定颜色的搜索框输入框背景图,大小与系统默认的保持一致,只是颜色不同 diff --git a/QMUIKit/UIKitExtensions/UISearchBar+QMUI.m b/QMUIKit/UIKitExtensions/UISearchBar+QMUI.m index 3ed6068d..4e50a0b5 100644 --- a/QMUIKit/UIKitExtensions/UISearchBar+QMUI.m +++ b/QMUIKit/UIKitExtensions/UISearchBar+QMUI.m @@ -17,6 +17,7 @@ #import "QMUICore.h" #import "UIImage+QMUI.h" #import "UIView+QMUI.h" +#import "UIViewController+QMUI.h" @interface UISearchBar () @@ -30,6 +31,7 @@ @implementation UISearchBar (QMUI) QMUISynthesizeBOOLProperty(qmui_usedAsTableHeaderView, setQmui_usedAsTableHeaderView) QMUISynthesizeBOOLProperty(qmui_alwaysEnableCancelButton, setQmui_alwaysEnableCancelButton) QMUISynthesizeBOOLProperty(qmui_fixMaskViewLayoutBugAutomatically, setQmui_fixMaskViewLayoutBugAutomatically) +QMUISynthesizeBOOLProperty(qmui_shouldFixSearchResultsContentInset, setQmui_shouldFixSearchResultsContentInset) QMUISynthesizeUIEdgeInsetsProperty(qmuisb_customTextFieldMargins, setQmuisb_customTextFieldMargins) QMUISynthesizeCGFloatProperty(qmuisb_centerPlaceholderCachedWidth1, setQmuisb_centerPlaceholderCachedWidth1) QMUISynthesizeCGFloatProperty(qmuisb_centerPlaceholderCachedWidth2, setQmuisb_centerPlaceholderCachedWidth2) @@ -241,7 +243,7 @@ - (void)qmuisb_didInitialize { self.qmui_alwaysEnableCancelButton = YES; self.qmui_showsLeftAccessoryView = YES; self.qmui_showsRightAccessoryView = YES; - + self.qmui_shouldFixSearchResultsContentInset = YES; if (QMUICMIActivated && ShouldFixSearchBarMaskViewLayoutBug) { self.qmui_fixMaskViewLayoutBugAutomatically = YES; } @@ -376,6 +378,35 @@ - (void)setQmui_textFieldMarginsBlock:(UIEdgeInsets (^)(__kindof UISearchBar * _ return (UIEdgeInsets (^)(__kindof UISearchBar * _Nonnull, BOOL))objc_getAssociatedObject(self, &kAssociatedObjectKey_textFieldMarginsBlock); } +static char kAssociatedObjectKey_adjustTextFieldLayoutForIndexBar; +- (void)setQmui_adjustTextFieldLayoutForIndexBar:(BOOL)adjustTextFieldLayoutForIndexBar { + objc_setAssociatedObject(self, &kAssociatedObjectKey_adjustTextFieldLayoutForIndexBar, @(adjustTextFieldLayoutForIndexBar), OBJC_ASSOCIATION_RETAIN_NONATOMIC); + if (!adjustTextFieldLayoutForIndexBar) { + [QMUIHelper executeBlock:^{ + // 系统内部的调用关系是:-[UITableView reloadData]→-[UITableView _updateIndexFrame]→[tableHeaderView isKindOfClass:UISearchBar]→-[UISearchBar _updateInsetsForTableView:]→-[UITableView _indexBarExtentFromEdge],所以只需要跳过 _updateInsetsForTableView: 即可屏蔽该特性 + // - [UISearchBar _updateInsetsForTableView:] + // - (void) _updateInsetsForTableView:(id)arg1; (0x184a14f24) + OverrideImplementation([UISearchBar class], NSSelectorFromString([NSString qmui_stringByConcat:@"_", @"updateInsets", @"ForTableView", @":", nil]), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UISearchBar *selfObject, UITableView *firstArgv) { + + if (!selfObject.qmui_adjustTextFieldLayoutForIndexBar) return; + + // call super + void (*originSelectorIMP)(id, SEL, UITableView *); + originSelectorIMP = (void (*)(id, SEL, UITableView *))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, firstArgv); + }; + }); + } oncePerIdentifier:@"UISearchBar (QMUI) adjustIndexBar"]; + } +} + +- (BOOL)qmui_adjustTextFieldLayoutForIndexBar { + NSNumber *value = (NSNumber *)objc_getAssociatedObject(self, &kAssociatedObjectKey_adjustTextFieldLayoutForIndexBar); + if (!value) return YES; + return value.boolValue; +} + - (UISegmentedControl *)qmui_segmentedControl { UISegmentedControl *segmentedControl = [self qmui_valueForKey:@"scopeBar"]; return segmentedControl; @@ -490,7 +521,10 @@ - (void)qmui_setShowsLeftAccessoryView:(BOOL)showsLeftAccessoryView animated:(BO self.qmui_leftAccessoryView.transform = CGAffineTransformMakeTranslation(-CGRectGetMaxX(self.qmui_leftAccessoryView.frame), 0); [self qmuisb_updateCustomTextFieldMargins]; } completion:^(BOOL finished) { - self.qmui_leftAccessoryView.hidden = YES; + // 快速在 show/hide 之间切换,容易出现状态错误,所以做个保护 + if (showsLeftAccessoryView == self.qmui_showsLeftAccessoryView) { + self.qmui_leftAccessoryView.hidden = YES; + } self.qmui_leftAccessoryView.transform = CGAffineTransformIdentity; }]; } @@ -567,7 +601,10 @@ - (void)qmui_setShowsRightAccessoryView:(BOOL)showsRightAccessoryView animated:( self.qmui_rightAccessoryView.transform = CGAffineTransformMakeTranslation(CGRectGetWidth(self.qmui_rightAccessoryView.superview.bounds) - CGRectGetMinX(self.qmui_rightAccessoryView.frame), 0); [self qmuisb_updateCustomTextFieldMargins]; } completion:^(BOOL finished) { - self.qmui_rightAccessoryView.hidden = YES; + // 快速在 show/hide 之间切换,容易出现状态错误,所以做个保护 + if (showsRightAccessoryView == self.qmui_showsRightAccessoryView) { + self.qmui_rightAccessoryView.hidden = YES; + } self.qmui_rightAccessoryView.transform = CGAffineTransformIdentity; self.qmui_rightAccessoryView.alpha = 1; }]; @@ -809,13 +846,17 @@ - (void)qmuisb_fixDismissingAnimationIfNeeded { CGPathAddRect(path, NULL, CGRectMake(0, 0, searchBarContainerView.qmui_width, previousHeight)); maskLayer.path = path; searchBarContainerView.layer.mask = maskLayer; + CGPathRelease(path); } } } } +// UISearchController.searchBar 作为 UITableView.tableHeaderView 时,进入搜索状态,搜索结果列表顶部有一大片空白 +// 不要让系统自适应了,否则在搜索结果(navigationBar 隐藏)push 进入下一级界面(navigationBar 显示)过程中系统自动调整的 contentInset 会跳来跳去 +// https://github.com/Tencent/QMUI_iOS/issues/1473 - (void)qmuisb_fixSearchResultsScrollViewContentInsetIfNeeded { - if (!self.qmuisb_shouldFixLayoutWhenUsedAsTableHeaderView) return; + if (!self.qmuisb_shouldFixLayoutWhenUsedAsTableHeaderView || !self.qmui_shouldFixSearchResultsContentInset) return; if (self.qmui_isActive) { UIViewController *searchResultsController = self.qmui_searchController.searchResultsController; if (searchResultsController && [searchResultsController isViewLoaded]) { @@ -824,8 +865,10 @@ - (void)qmuisb_fixSearchResultsScrollViewContentInsetIfNeeded { [view isKindOfClass:UIScrollView.class] ? view : [view.subviews.firstObject isKindOfClass:UIScrollView.class] ? view.subviews.firstObject : nil; UIView *searchBarContainerView = self.superview; - if (scrollView && searchBarContainerView) { - scrollView.contentInset = UIEdgeInsetsMake(searchBarContainerView.qmui_height, 0, 0, 0); + if (scrollView && scrollView.contentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentNever && searchBarContainerView) { + CGFloat containerHeight = CGRectGetHeight(searchBarContainerView.frame); + scrollView.contentInset = UIEdgeInsetsMake(containerHeight, 0, scrollView.safeAreaInsets.bottom, 0); + scrollView.scrollIndicatorInsets = scrollView.contentInset; } } } diff --git a/QMUIKit/UIKitExtensions/UISearchController+QMUI.h b/QMUIKit/UIKitExtensions/UISearchController+QMUI.h index c1de24dd..27a934a8 100644 --- a/QMUIKit/UIKitExtensions/UISearchController+QMUI.h +++ b/QMUIKit/UIKitExtensions/UISearchController+QMUI.h @@ -18,6 +18,25 @@ NS_ASSUME_NONNULL_BEGIN @interface UISearchController (QMUI) +/// 系统默认是只有搜索框文本不为空时才会显示搜索结果,将该属性置为 YES 可以做到只要 active 就能显示搜索结果列表。 +/// 该属性与 qmui_launchView、obscuresBackgroundDuringPresentation 互斥,打开该属性时会强制清除互斥属性(但如果你非要在打开该属性之后,再重新为这两个互斥属性赋值,也是可以的)。 +/// 默认为 NO。 +@property(nonatomic, assign) BOOL qmui_alwaysShowSearchResultsController; + +/// 当 A 里构造了一个 UISearchController(称为B),当B进入搜索状态后,再 push/present 到其他界面,B的 viewWillAppear: 等生命周期方法并不会被调用,但A的生命周期方法会被调用,这令搜索业务难以感知当前的界面状态。 +/// 若将当前属性置为 YES,则会保证A的生命周期方法被调用时也触发B的生命周期方法。 +/// 默认为 NO。 +@property(nonatomic, assign) BOOL qmui_forwardAppearanceMethodsFromPresentingController; + +/// 升起键盘时的半透明遮罩,nil 表示用系统的,非 nil 则用自己的。默认为 nil。 +/// @note 如果使用了 launchView 则该属性无效。 +@property(nonatomic, strong, nullable) UIColor *qmui_dimmingColor; + +/// 在搜索文字为空时会展示的一个 view,通常用于实现“最近搜索”之类的功能。launchView 最终会被布局为撑满搜索框以下的所有空间。 +@property(nonatomic, strong, nullable) UIView *qmui_launchView; + +/// 获取进入搜索状态后 searchBar 在 UISearchController.view 坐标系内的 maxY 值,方便 searchResultsController 布局。 +@property(nonatomic, assign, readonly) CGFloat qmui_searchBarMaxY; @end NS_ASSUME_NONNULL_END diff --git a/QMUIKit/UIKitExtensions/UISearchController+QMUI.m b/QMUIKit/UIKitExtensions/UISearchController+QMUI.m index 145e0203..8b277d63 100644 --- a/QMUIKit/UIKitExtensions/UISearchController+QMUI.m +++ b/QMUIKit/UIKitExtensions/UISearchController+QMUI.m @@ -13,23 +13,26 @@ // #import "UISearchController+QMUI.h" +#import "QMUICore.h" #import "UIViewController+QMUI.h" #import "UINavigationController+QMUI.h" #import "UIView+QMUI.h" -#import "QMUICore.h" +#import "NSArray+QMUI.h" @implementation UISearchController (QMUI) + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ + + // -[_UISearchControllerView didMoveToWindow] + // 修复 https://github.com/Tencent/QMUI_iOS/issues/680 中提到的问题二:当有一个 TableViewController A,A 的 seachBar 被激活且 searchResultsController 正在显示的情况下,A.navigationController push 一个新的 viewController B,B 用 pop 手势返回到一半松手放弃返回,此时 B 再 push 一个新的 viewController 时,在转场过程中会看到 searchResultsController 的内容。 OverrideImplementation(NSClassFromString(@"_UISearchControllerView"), @selector(didMoveToWindow), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { return ^(UIView *selfObject) { void (*originSelectorIMP)(id, SEL); originSelectorIMP = (void (*)(id, SEL))originalIMPProvider(); originSelectorIMP(selfObject, originCMD); - // 修复 https://github.com/Tencent/QMUI_iOS/issues/680 中提到的问题二:当有一个 TableViewController A,A 的 seachBar 被激活且 searchResultsController 正在显示的情况下,A.navigationController push 一个新的 viewController B,B 用 pop 手势返回到一半松手放弃返回,此时 B 再 push 一个新的 viewController 时,在转场过程中会看到 searchResultsController 的内容。 if (selfObject.window && [selfObject.superview isKindOfClass:NSClassFromString(@"UITransitionView")]) { UIView *transitionView = selfObject.superview; UISearchController *searchController = [selfObject qmui_viewController]; @@ -46,7 +49,231 @@ + (void)load { }; }); + + // - [UISearchController viewDidLayoutSubviews] + OverrideImplementation([UISearchController class], @selector(viewDidLayoutSubviews), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UISearchController *selfObject) { + + // call super + void (*originSelectorIMP)(id, SEL); + originSelectorIMP = (void (*)(id, SEL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD); + + // 某些场景(比如 setActive:YES animated:NO)会在 _UISearchBarContainerView 被添加到 view 上之后调用 -[UISearchController viewDidLayoutSubviews] 但不会调用 -[searchResultsController viewDidLayoutSubviews],导致搜索结果界面里如果使用 qmui_searchBarMaxY 等依赖于 _UISearchBarContainerView 的方法时就会得到错误结果,所以这里每次都主动刷新搜索结果界面的布局。 + if (selfObject.searchResultsController.isViewLoaded && selfObject.searchResultsController.view.superview.superview == selfObject.view) { + [selfObject.searchResultsController.view setNeedsLayout]; + } + + if (selfObject.qmui_launchView) { + [UIView animateWithDuration:[CATransaction animationDuration] animations:^{ + [selfObject qmuisc_layoutLaunchViewIfNeeded]; + }]; + } + }; + }); }); } +static char kAssociatedObjectKey_alwaysShowSearchResultsController; +- (void)setQmui_alwaysShowSearchResultsController:(BOOL)qmui_alwaysShowSearchResultsController { + BOOL hasSet = !!objc_getAssociatedObject(self, &kAssociatedObjectKey_alwaysShowSearchResultsController); + objc_setAssociatedObject(self, &kAssociatedObjectKey_alwaysShowSearchResultsController, @(qmui_alwaysShowSearchResultsController), OBJC_ASSOCIATION_RETAIN_NONATOMIC); + if (qmui_alwaysShowSearchResultsController) { + self.qmui_launchView = nil; + self.obscuresBackgroundDuringPresentation = NO; + } else if (hasSet) { + // 用变量 hasSet 表示用过 qmui_alwaysShowSearchResultsController 属性再关回去时才需要重置,否则就不用干预 + self.obscuresBackgroundDuringPresentation = YES; + return; + } + [QMUIHelper executeBlock:^{ + // - [UISearchController _updateVisibilityOfSearchResultsForSearchBar:] + // - (void) _updateVisibilityOfSearchResultsForSearchBar:(id)arg1; + OverrideImplementation([UISearchController class], NSSelectorFromString([NSString qmui_stringByConcat:@"_", @"updateVisibility", @"OfSearchResults", @"ForSearchBar:", nil]), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UISearchController *selfObject, UISearchBar *searchBar) { + + // call super + void (*originSelectorIMP)(id, SEL, UISearchBar *); + originSelectorIMP = (void (*)(id, SEL, UISearchBar *))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, searchBar); + + if (selfObject.qmui_alwaysShowSearchResultsController) { + selfObject.searchResultsController.view.hidden = NO; + } + }; + }); + } oncePerIdentifier:@"UISearchController (QMUI) alwaysShowResults"]; +} + +- (BOOL)qmui_alwaysShowSearchResultsController { + return [((NSNumber *)objc_getAssociatedObject(self, &kAssociatedObjectKey_alwaysShowSearchResultsController)) boolValue]; +} + +static char kAssociatedObjectKey_forwardAppearance; +- (void)setQmui_forwardAppearanceMethodsFromPresentingController:(BOOL)qmui_forwardAppearanceMethodsFromPresentingController { + objc_setAssociatedObject(self, &kAssociatedObjectKey_forwardAppearance, @(qmui_forwardAppearanceMethodsFromPresentingController), OBJC_ASSOCIATION_RETAIN_NONATOMIC); + if (qmui_forwardAppearanceMethodsFromPresentingController) { + [QMUIHelper executeBlock:^{ + OverrideImplementation([UIViewController class], @selector(viewWillAppear:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UISearchController *selfObject, BOOL firstArgv) { + + // call super + void (*originSelectorIMP)(id, SEL, BOOL); + originSelectorIMP = (void (*)(id, SEL, BOOL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, firstArgv); + + UISearchController *searchController = [selfObject.presentedViewController isKindOfClass:UISearchController.class] ? (UISearchController *)selfObject.presentedViewController : nil; + if (searchController && searchController.qmui_forwardAppearanceMethodsFromPresentingController && searchController.active) { + [searchController beginAppearanceTransition:YES animated:firstArgv]; + } + }; + }); + + OverrideImplementation([UIViewController class], @selector(viewDidAppear:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UISearchController *selfObject, BOOL firstArgv) { + + // call super + void (*originSelectorIMP)(id, SEL, BOOL); + originSelectorIMP = (void (*)(id, SEL, BOOL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, firstArgv); + + UISearchController *searchController = [selfObject.presentedViewController isKindOfClass:UISearchController.class] ? (UISearchController *)selfObject.presentedViewController : nil; + if (searchController && searchController.qmui_forwardAppearanceMethodsFromPresentingController && searchController.active) { + [searchController endAppearanceTransition]; + } + }; + }); + + OverrideImplementation([UIViewController class], @selector(viewWillDisappear:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UISearchController *selfObject, BOOL firstArgv) { + + // call super + void (*originSelectorIMP)(id, SEL, BOOL); + originSelectorIMP = (void (*)(id, SEL, BOOL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, firstArgv); + + UISearchController *searchController = [selfObject.presentedViewController isKindOfClass:UISearchController.class] ? (UISearchController *)selfObject.presentedViewController : nil; + if (searchController && searchController.qmui_forwardAppearanceMethodsFromPresentingController && searchController.active) { + [searchController beginAppearanceTransition:NO animated:firstArgv]; + } + }; + }); + + OverrideImplementation([UIViewController class], @selector(viewDidDisappear:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UISearchController *selfObject, BOOL firstArgv) { + + // call super + void (*originSelectorIMP)(id, SEL, BOOL); + originSelectorIMP = (void (*)(id, SEL, BOOL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, firstArgv); + + UISearchController *searchController = [selfObject.presentedViewController isKindOfClass:UISearchController.class] ? (UISearchController *)selfObject.presentedViewController : nil; + if (searchController && searchController.qmui_forwardAppearanceMethodsFromPresentingController && searchController.active) { + [searchController endAppearanceTransition]; + } + }; + }); + } oncePerIdentifier:@"UISearchController (QMUI) forwardAppearance"]; + } +} + +- (BOOL)qmui_forwardAppearanceMethodsFromPresentingController { + return [((NSNumber *)objc_getAssociatedObject(self, &kAssociatedObjectKey_forwardAppearance)) boolValue]; +} + +- (CGFloat)qmui_searchBarMaxY { + if (!self.viewLoaded) return 0; + + UIView *searchBarContainerView = [self.view.subviews qmui_firstMatchWithBlock:^BOOL(__kindof UIView * _Nonnull subview) { + return [NSStringFromClass(subview.class) isEqualToString:@"_UISearchBarContainerView"]; + }]; + CGFloat maxY = searchBarContainerView ? CGRectGetMaxY(searchBarContainerView.frame) : 0; + return maxY; +} + +static char kAssociatedObjectKey_dimmingColor; +- (void)setQmui_dimmingColor:(UIColor *)qmui_dimmingColor { + objc_setAssociatedObject(self, &kAssociatedObjectKey_dimmingColor, qmui_dimmingColor, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [QMUIHelper executeBlock:^{ + // - [UIDimmingView updateBackgroundColor] + OverrideImplementation(NSClassFromString([NSString qmui_stringByConcat:@"UI", @"Dimming", @"View", nil]), NSSelectorFromString([NSString qmui_stringByConcat:@"update", @"Background", @"Color", nil]), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UIView *selfObject) { + for (UIView *subview in selfObject.superview.subviews) { + // _UISearchControllerView + if ([NSStringFromClass(subview.class) isEqualToString:[NSString qmui_stringByConcat:@"_", @"UISearchController", @"View", nil]]) { + UISearchController *searchController = subview.qmui_viewController; + if ([searchController isKindOfClass:UISearchController.class]) { + UIColor *color = searchController.qmui_dimmingColor; + if (color) { + // - [UIDimmingView setDimmingColor:] + [selfObject qmui_performSelector:NSSelectorFromString(@"setDimmingColor:") withArguments:&color, nil]; + } + } else { + QMUIAssert(NO, @"UISearchController (QMUI)", @"qmui_dimmingColor 找到的 vc 类型错误"); + } + break; + } + } + + // call super + void (*originSelectorIMP)(id, SEL); + originSelectorIMP = (void (*)(id, SEL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD); + }; + }); + } oncePerIdentifier:@"QMUISearchController dimmingColor"]; +} + +- (UIColor *)qmui_dimmingColor { + return (UIColor *)objc_getAssociatedObject(self, &kAssociatedObjectKey_dimmingColor); +} + +static char kAssociatedObjectKey_launchView; +- (void)setQmui_launchView:(UIView *)qmui_launchView { + if (self.qmui_launchView != qmui_launchView) { + [self.qmui_launchView removeFromSuperview]; + } + objc_setAssociatedObject(self, &kAssociatedObjectKey_launchView, qmui_launchView, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + if (qmui_launchView) { + [QMUIHelper executeBlock:^{ + // - [UISearchController viewWillAppear:] + OverrideImplementation([UISearchController class], @selector(viewWillAppear:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UISearchController *selfObject, BOOL firstArgv) { + + // call super + void (*originSelectorIMP)(id, SEL, BOOL); + originSelectorIMP = (void (*)(id, SEL, BOOL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, firstArgv); + + [selfObject qmuisc_addLaunchViewIfNeeded]; + }; + }); + } oncePerIdentifier:@"UISearchController (QMUI) launchView"]; + } + + self.obscuresBackgroundDuringPresentation = !qmui_launchView; + if (self.viewLoaded) { + [self qmuisc_addLaunchViewIfNeeded]; + } +} + +- (UIView *)qmui_launchView { + return (UIView *)objc_getAssociatedObject(self, &kAssociatedObjectKey_launchView); +} + +- (void)qmuisc_addLaunchViewIfNeeded { + if (!self.qmui_launchView) return; + UIView *superviewOfLaunchView = self.searchResultsController.view.superview; + if (self.qmui_launchView.superview != superviewOfLaunchView) { + [superviewOfLaunchView insertSubview:self.qmui_launchView atIndex:0]; + [self qmuisc_layoutLaunchViewIfNeeded]; + } +} + +- (void)qmuisc_layoutLaunchViewIfNeeded { + if (!self.qmui_launchView || !self.viewLoaded) return; + self.qmui_launchView.frame = CGRectInsetEdges(self.qmui_launchView.superview.bounds, UIEdgeInsetsMake(self.qmui_searchBarMaxY, 0, 0, 0)); +} + @end diff --git a/QMUIKit/UIKitExtensions/UISlider+QMUI.m b/QMUIKit/UIKitExtensions/UISlider+QMUI.m index e5d942bf..70747401 100644 --- a/QMUIKit/UIKitExtensions/UISlider+QMUI.m +++ b/QMUIKit/UIKitExtensions/UISlider+QMUI.m @@ -376,7 +376,7 @@ - (void)qmuisl_layoutStepControls { // 根据当前 thumbView 的位置,控制重叠的那个 stepControl 的事件响应和显隐,由于 slider 可能是 continuous 的,所以这段逻辑必须每次 layout 都调用,不能放在 layoutCachedKey 的保护里 CGRect thumbRect = self.qmui_thumbView.frame; CGRect trackRect = [self trackRectForBounds:self.bounds]; - NSUInteger step = (CGRectGetMidX(thumbRect) - CGRectGetMinX(trackRect)) / CGRectGetWidth(trackRect) * (count - 1); + NSUInteger step = round((CGRectGetMidX(thumbRect) - CGRectGetMinX(trackRect)) / CGRectGetWidth(trackRect) * (count - 1)); [self.qmuisl_stepControls enumerateObjectsUsingBlock:^(QMUISliderStepControl * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { obj.userInteractionEnabled = idx != step;// 让 stepControl 不要影响 thumbView 的事件 obj.indicator.hidden = idx == step; diff --git a/QMUIKit/UIKitExtensions/UITabBar+QMUI.m b/QMUIKit/UIKitExtensions/UITabBar+QMUI.m index 00658c7f..f45c428c 100644 --- a/QMUIKit/UIKitExtensions/UITabBar+QMUI.m +++ b/QMUIKit/UIKitExtensions/UITabBar+QMUI.m @@ -43,22 +43,23 @@ + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - OverrideImplementation([UITabBar class], @selector(setItems:animated:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { - return ^void(UITabBar *selfObject, NSArray *items, BOOL animated) { + // -[UITabBar addSubview:] + OverrideImplementation([UITabBar class], @selector(addSubview:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UITabBar *selfObject, UIView *firstArgv) { // call super - void (*originSelectorIMP)(id, SEL, NSArray *, BOOL); - originSelectorIMP = (void (*)(id, SEL, NSArray *, BOOL))originalIMPProvider(); - originSelectorIMP(selfObject, originCMD, items, animated); + void (*originSelectorIMP)(id, SEL, UIView *); + originSelectorIMP = (void (*)(id, SEL, UIView *))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, firstArgv); - [items enumerateObjectsUsingBlock:^(UITabBarItem * _Nonnull item, NSUInteger idx, BOOL * _Nonnull stop) { - // 双击 tabBarItem 的功能需要在设置完 item 后才能获取到 qmui_view 来实现 - UIControl *itemView = (UIControl *)item.qmui_view; - [itemView addTarget:selfObject action:@selector(handleTabBarItemViewEvent:) forControlEvents:UIControlEventTouchUpInside]; - }]; + if ([NSStringFromClass(firstArgv.class) isEqualToString:@"UITabBarButton"]) { + UIControl *button = (UIControl *)firstArgv; + [button addTarget:selfObject action:@selector(qmuitb_handleTabBarItemViewEvent:) forControlEvents:UIControlEventTouchUpInside]; + } }; }); + // -[UITabBar setSelectedItem:] OverrideImplementation([UITabBar class], @selector(setSelectedItem:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { return ^(UITabBar *selfObject, UITabBarItem *selectedItem) { @@ -243,7 +244,7 @@ + (void)load { }); } -- (void)handleTabBarItemViewEvent:(UIControl *)itemView { +- (void)qmuitb_handleTabBarItemViewEvent:(UIControl *)itemView { if (!self.canItemRespondDoubleTouch) { return; @@ -255,7 +256,7 @@ - (void)handleTabBarItemViewEvent:(UIControl *)itemView { // 如果一定时间后仍未触发双击,则废弃当前的点击状态 dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(.25 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self revertTabBarItemTouch]; + [self qmuitb_revertTabBarItemTouch]; }); NSInteger selectedIndex = [self.items indexOfObject:self.selectedItem]; @@ -265,7 +266,7 @@ - (void)handleTabBarItemViewEvent:(UIControl *)itemView { self.lastTouchedTabBarItemViewIndex = selectedIndex; } else if (self.lastTouchedTabBarItemViewIndex != selectedIndex) { // 后续的点击如果与第一次点击的 index 不一致,则认为是重新开始一次新的点击 - [self revertTabBarItemTouch]; + [self qmuitb_revertTabBarItemTouch]; self.lastTouchedTabBarItemViewIndex = selectedIndex; return; } @@ -277,11 +278,11 @@ - (void)handleTabBarItemViewEvent:(UIControl *)itemView { if (item.qmui_doubleTapBlock) { item.qmui_doubleTapBlock(item, selectedIndex); } - [self revertTabBarItemTouch]; + [self qmuitb_revertTabBarItemTouch]; } } -- (void)revertTabBarItemTouch { +- (void)qmuitb_revertTabBarItemTouch { self.lastTouchedTabBarItemViewIndex = kLastTouchedTabBarItemIndexNone; self.tabBarItemViewTouchCount = 0; } diff --git a/QMUIKit/UIKitExtensions/UITabBarItem+QMUI.h b/QMUIKit/UIKitExtensions/UITabBarItem+QMUI.h index a36ff53c..47fc81c0 100644 --- a/QMUIKit/UIKitExtensions/UITabBarItem+QMUI.h +++ b/QMUIKit/UIKitExtensions/UITabBarItem+QMUI.h @@ -21,8 +21,8 @@ NS_ASSUME_NONNULL_BEGIN /** * 双击 tabBarItem 时的回调,默认为 nil。 - * @arg tabBarItem 被双击的 UITabBarItem - * @arg index 被双击的 UITabBarItem 的序号 + * @param tabBarItem 被双击的 UITabBarItem,若需要拿到当前的 view 则通过 qmui_view 获取。 + * @param index 被双击的 UITabBarItem 的序号 */ @property(nonatomic, copy, nullable) void (^qmui_doubleTapBlock)(UITabBarItem *tabBarItem, NSInteger index); diff --git a/QMUIKit/UIKitExtensions/UITableView+QMUI.m b/QMUIKit/UIKitExtensions/UITableView+QMUI.m index e25fffd6..7850986a 100644 --- a/QMUIKit/UIKitExtensions/UITableView+QMUI.m +++ b/QMUIKit/UIKitExtensions/UITableView+QMUI.m @@ -95,30 +95,27 @@ + (void)load { // [UIKit Bug] 将 UISearchBar 作为 tableHeaderView 使用的 UITableView,如果同时设置了 estimatedRowHeight,则 contentSize 会错乱,导致滚动异常 // https://github.com/Tencent/QMUI_iOS/issues/1161 - void (^fixBugOfTableViewContentSize)(UITableView *) = ^void(UITableView *tableView) { - BOOL estimatesRowHeight = NO; - [tableView qmui_performSelector:NSSelectorFromString(@"_estimatesRowHeights") withPrimitiveReturnValue:&estimatesRowHeight]; - if (estimatesRowHeight && [tableView.tableHeaderView isKindOfClass:UISearchBar.class]) { - BeginIgnorePerformSelectorLeaksWarning - [tableView performSelector:NSSelectorFromString(@"_updateContentSize")]; - EndIgnorePerformSelectorLeaksWarning - } - }; - - /* - (void)_coalesceContentSizeUpdateWithDelta:(double)arg1; */ - OverrideImplementation([UITableView class], NSSelectorFromString(@"_coalesceContentSizeUpdateWithDelta:"), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { - return ^(UITableView *selfObject, CGFloat firstArgv) { - - // call super - void (*originSelectorIMP)(id, SEL, CGFloat); - originSelectorIMP = (void (*)(id, SEL, CGFloat))originalIMPProvider(); - originSelectorIMP(selfObject, originCMD, firstArgv); - - if (fixBugOfTableViewContentSize) { - fixBugOfTableViewContentSize(selfObject); - } - }; - }); + if (@available(iOS 15.0, *)) { + } else { + /* - (void)_coalesceContentSizeUpdateWithDelta:(double)arg1; */ + OverrideImplementation([UITableView class], NSSelectorFromString(@"_coalesceContentSizeUpdateWithDelta:"), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UITableView *selfObject, CGFloat firstArgv) { + + // call super + void (*originSelectorIMP)(id, SEL, CGFloat); + originSelectorIMP = (void (*)(id, SEL, CGFloat))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, firstArgv); + + BOOL estimatesRowHeight = NO; + [selfObject qmui_performSelector:NSSelectorFromString(@"_estimatesRowHeights") withPrimitiveReturnValue:&estimatesRowHeight]; + if (estimatesRowHeight && [selfObject.tableHeaderView isKindOfClass:UISearchBar.class]) { + BeginIgnorePerformSelectorLeaksWarning + [selfObject performSelector:NSSelectorFromString(@"_updateContentSize")]; + EndIgnorePerformSelectorLeaksWarning + } + }; + }); + } OverrideImplementation([UITableView class], @selector(reloadData), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { return ^(UITableView *selfObject) { @@ -474,13 +471,14 @@ - (void)alertEstimatedHeightUsageIfDetected { BOOL usingEstimatedSectionHeaderHeight = [self.delegate respondsToSelector:@selector(tableView:estimatedHeightForHeaderInSection:)] || self.estimatedSectionHeaderHeight > 0; BOOL usingEstimatedSectionFooterHeight = [self.delegate respondsToSelector:@selector(tableView:estimatedHeightForFooterInSection:)] || self.estimatedSectionFooterHeight > 0; + if (!IS_DEBUG) return; if (usingEstimatedRowHeight || usingEstimatedSectionHeaderHeight || usingEstimatedSectionFooterHeight) { [self QMUISymbolicUsingTableViewEstimatedHeightMakeWarning]; } } - (void)QMUISymbolicUsingTableViewEstimatedHeightMakeWarning { - QMUILog(@"UITableView (QMUI)", @"当开启了 UITableView 的 estimatedRow(SectionHeader / SectionFooter)Height 功能后,不应该手动修改 contentOffset 和 contentSize,也会影响 contentSize、sizeThatFits:、rectForXxx 等方法的计算,请注意确认当前是否存在不合理的业务代码。可添加 '%@' 的 Symbolic Breakpoint 以捕捉此类信息\n%@", NSStringFromSelector(_cmd), [NSThread callStackSymbols]); + QMUILog(@"UITableView (QMUI)", @"当开启了 UITableView 的 estimatedRow(SectionHeader / SectionFooter)Height 功能后,不应该手动修改 contentOffset 和 contentSize,也会影响 contentSize、sizeThatFits:、rectForXxx 等方法的计算,请注意确认当前是否存在不合理的业务代码。可添加 'QMUISymbolicUsingTableViewEstimatedHeightMakeWarning' 的 Symbolic Breakpoint 以捕捉此类信息。"); } - (void)qmui_performBatchUpdates:(void (NS_NOESCAPE ^ _Nullable)(void))updates completion:(void (^ _Nullable)(BOOL finished))completion { diff --git a/QMUIKit/UIKitExtensions/UITableViewCell+QMUI.h b/QMUIKit/UIKitExtensions/UITableViewCell+QMUI.h index b441cd7c..be562302 100644 --- a/QMUIKit/UIKitExtensions/UITableViewCell+QMUI.h +++ b/QMUIKit/UIKitExtensions/UITableViewCell+QMUI.h @@ -74,8 +74,12 @@ extern const UIEdgeInsets QMUITableViewCellSeparatorInsetsNone; @property(nonatomic, copy, nullable) void (^qmui_setSelectedBlock)(BOOL selected, BOOL animated); /** - 获取当前 cell 的 accessoryView,优先级分别是:编辑状态下的 editingAccessoryView -> 编辑状态下的系统自己的 accessoryView -> 普通状态下的自定义 accessoryView -> 普通状态下系统自己的 accessoryView。 + 获取当前 cell 的 accessoryView,优先级分别是:当前肉眼可视的 view(比如进入排序模式时的 reorderControl) ->编辑状态下的 editingAccessoryView -> 编辑状态下的系统自己的 accessoryView -> 普通状态下的自定义 accessoryView -> 普通状态下系统自己的 accessoryView。 + @note 对于系统的 UITableViewCellAccessoryDetailDisclosureButton,iOS 12 及以下是一个 UITableViewCellDetailDisclosureView,而 iOS 13 及以上被拆成两个独立的 view,此时 qmui_accessoryView 只能返回布局上更靠左的那个 view。 + 如果你给 cell 设置了自己的 accessoryView,但此时 cell 进入排序模式,系统会把你的 accessoryView 隐藏掉,强制显示为 reorderControl,此时 UITableViewCell.accessoryView 返回的是你自己设置的 view,而 UITableViewCell.qmui_accessoryView 返回的是当前可视的 view(也即 reorderControl)。 + + @warning 一般在 willDisplayCell 里使用,cellForRow 里可能太早了很多 view 尚未被创建,会返回 nil */ @property(nonatomic, strong, readonly, nullable) __kindof UIView *qmui_accessoryView; diff --git a/QMUIKit/UIKitExtensions/UITableViewCell+QMUI.m b/QMUIKit/UIKitExtensions/UITableViewCell+QMUI.m index 42f1e3af..e482082b 100644 --- a/QMUIKit/UIKitExtensions/UITableViewCell+QMUI.m +++ b/QMUIKit/UIKitExtensions/UITableViewCell+QMUI.m @@ -106,6 +106,9 @@ - (void)setQmui_cellPosition:(QMUITableViewCellPosition)qmui_cellPosition { if (shouldShowSeparatorInTableView) { [self qmuiTbc_createSeparatorLayerIfNeeded]; [self qmuiTbc_createTopSeparatorLayerIfNeeded]; + } else { + self.qmuiTbc_separatorLayer.hidden = YES; + self.qmuiTbc_topSeparatorLayer.hidden = YES; } } @@ -232,6 +235,19 @@ - (UIColor *)qmui_selectedBackgroundColor { } - (UIView *)qmui_accessoryView { + // 优先获取当前肉眼可见的 view,包括系统的排序、删除、checkbox 等,仅在 willDisplayCell 内有效,cellForRow 太早了拿不到 + BeginIgnorePerformSelectorLeaksWarning + SEL managerSEL = NSSelectorFromString(@"_accessoryManager"); + if ([self respondsToSelector:managerSEL]) { + id manager = [self performSelector:managerSEL]; + NSDictionary *accessoryViews = [manager performSelector:NSSelectorFromString(@"accessoryViews")]; + UIView *view = accessoryViews.allValues.firstObject; + if (view) { + return view; + } + } + EndIgnorePerformSelectorLeaksWarning + if (self.editing) { if (self.editingAccessoryView) { return self.editingAccessoryView; diff --git a/QMUIKit/UIKitExtensions/UITextField+QMUI.h b/QMUIKit/UIKitExtensions/UITextField+QMUI.h index 74a1e7c6..5d4ced27 100644 --- a/QMUIKit/UIKitExtensions/UITextField+QMUI.h +++ b/QMUIKit/UIKitExtensions/UITextField+QMUI.h @@ -20,6 +20,10 @@ NS_ASSUME_NONNULL_BEGIN @interface UITextField (QMUI) +/// UITextView 在输入框开头继续按删除按键,也会触发 shouldChange 的 delegate,但 UITextField 没这个行为,所以提供这个属性,当置为 YES 时,行为与 UITextView 一致,在输入框开头删除也会询问 delegate 并传 range(0, 0) 和空的 text。 +/// 默认为 NO。 +@property(nonatomic, assign) BOOL qmui_respondsToDeleteActionAtLeading; + /// UITextField 只有 selectedTextRange 属性(在 UITextInput 协议里定义),相对而言没有 NSRange 那么直观,因此这里提供 NSRange 类型的操作方式可以主动设置光标的位置或选中的区域 @property(nonatomic, assign) NSRange qmui_selectedRange; diff --git a/QMUIKit/UIKitExtensions/UITextField+QMUI.m b/QMUIKit/UIKitExtensions/UITextField+QMUI.m index 8e99c188..3ac2ea59 100644 --- a/QMUIKit/UIKitExtensions/UITextField+QMUI.m +++ b/QMUIKit/UIKitExtensions/UITextField+QMUI.m @@ -20,6 +20,33 @@ @implementation UITextField (QMUI) ++ (void)load { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + + // iOS 13 及以下版本需要重写该方法才能替换 + // - (id) _clearButtonImageForState:(unsigned long)arg1; + // https://github.com/Tencent/QMUI_iOS/issues/1477 + if (@available(iOS 14.0, *)) { + } else { + OverrideImplementation([UITextField class], NSSelectorFromString(@"_clearButtonImageForState:"), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^UIImage *(UITextField *selfObject, UIControlState firstArgv) { + + if (selfObject.qmui_clearButtonImage && (firstArgv & UIControlStateNormal) == UIControlStateNormal) { + return selfObject.qmui_clearButtonImage; + } + + // call super + UIImage *(*originSelectorIMP)(id, SEL, UIControlState); + originSelectorIMP = (UIImage *(*)(id, SEL, UIControlState))originalIMPProvider(); + UIImage *result = originSelectorIMP(selfObject, originCMD, firstArgv); + return result; + }; + }); + } + }); +} + - (void)setQmui_selectedRange:(NSRange)qmui_selectedRange { self.selectedTextRange = [self qmui_convertUITextRangeFromNSRange:qmui_selectedRange]; } @@ -32,14 +59,15 @@ - (UIButton *)qmui_clearButton { return [self qmui_valueForKey:@"clearButton"]; } -// - (id) _clearButtonImageForState:(unsigned long)arg1; static char kAssociatedObjectKey_clearButtonImage; - (void)setQmui_clearButtonImage:(UIImage *)qmui_clearButtonImage { objc_setAssociatedObject(self, &kAssociatedObjectKey_clearButtonImage, qmui_clearButtonImage, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - [self.qmui_clearButton setImage:qmui_clearButtonImage forState:UIControlStateNormal]; - // 如果当前 clearButton 正在显示的时候把自定义图片去掉,需要重新 layout 一次才能让系统默认图片显示出来 - if (!qmui_clearButtonImage) { - [self setNeedsLayout]; + if (@available(iOS 14.0, *)) { + [self.qmui_clearButton setImage:qmui_clearButtonImage forState:UIControlStateNormal]; + // 如果当前 clearButton 正在显示的时候把自定义图片去掉,需要重新 layout 一次才能让系统默认图片显示出来 + if (!qmui_clearButtonImage) { + [self setNeedsLayout]; + } } } @@ -63,4 +91,32 @@ - (UITextRange *)qmui_convertUITextRangeFromNSRange:(NSRange)range { return [self textRangeFromPosition:startPosition toPosition:endPosition]; } +static char kAssociatedObjectKey_respondsToDeleteActionAtLeading; +- (void)setQmui_respondsToDeleteActionAtLeading:(BOOL)respondsToDeleteActionAtLeading { + objc_setAssociatedObject(self, &kAssociatedObjectKey_respondsToDeleteActionAtLeading, @(respondsToDeleteActionAtLeading), OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [QMUIHelper executeBlock:^{ + OverrideImplementation([UITextField class], @selector(deleteBackward), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UITextField *selfObject) { + + BOOL deletingAtLeading = NSEqualRanges(selfObject.qmui_selectedRange, NSMakeRange(0, 0)); + if (selfObject.qmui_respondsToDeleteActionAtLeading && deletingAtLeading) { + QMUILog(@"UITextField (QMUI)", @"光标已在输入框开头的情况下依然按下删除按键。"); + if ([selfObject.delegate respondsToSelector:@selector(textField:shouldChangeCharactersInRange:replacementString:)]) { + [selfObject.delegate textField:selfObject shouldChangeCharactersInRange:NSMakeRange(0, 0) replacementString:@""]; + } + } + + // call super + void (*originSelectorIMP)(id, SEL); + originSelectorIMP = (void (*)(id, SEL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD); + }; + }); + } oncePerIdentifier:@"UITextField (QMUI) delete"]; +} + +- (BOOL)qmui_respondsToDeleteActionAtLeading { + return [((NSNumber *)objc_getAssociatedObject(self, &kAssociatedObjectKey_respondsToDeleteActionAtLeading)) boolValue]; +} + @end diff --git a/QMUIKit/UIKitExtensions/UITextView+QMUI.h b/QMUIKit/UIKitExtensions/UITextView+QMUI.h index b698a0e8..d28423e1 100644 --- a/QMUIKit/UIKitExtensions/UITextView+QMUI.h +++ b/QMUIKit/UIKitExtensions/UITextView+QMUI.h @@ -20,6 +20,10 @@ NS_ASSUME_NONNULL_BEGIN @interface UITextView (QMUI) +/** + 立即刷新当前的 contentSize + */ +- (void)qmui_updateContentSize; /** * UITextView 只有 selectedTextRange 属性(在协议里定义),这里拓展了一个方法可以将 UITextRange 类型的 selectedTextRange 转换为 NSRange 类型的 selectedRange diff --git a/QMUIKit/UIKitExtensions/UITextView+QMUI.m b/QMUIKit/UIKitExtensions/UITextView+QMUI.m index 2b193c7d..63b4ac13 100644 --- a/QMUIKit/UIKitExtensions/UITextView+QMUI.m +++ b/QMUIKit/UIKitExtensions/UITextView+QMUI.m @@ -19,6 +19,39 @@ @implementation UITextView (QMUI) +#ifdef IOS17_SDK_ALLOWED ++ (void)load { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + // UIScrollView.clipsToBounds 默认值为 YES,但如果是 Xcode 15 编译的包,UITextView.scrollEnabled = NO 时会强制把 clipsToBounds 置为 NO,导致 UITextView 设置了 backgroundColor 和 cornerRadius 时会看不到圆角(因为背景色溢出了),所以这里统一改回去 clipsToBounds = YES + if (@available(iOS 17.0, *)) { + OverrideImplementation([UITextView class], @selector(setScrollEnabled:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UITextView *selfObject, BOOL firstArgv) { + + // call super + void (*originSelectorIMP)(id, SEL, BOOL); + originSelectorIMP = (void (*)(id, SEL, BOOL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, firstArgv); + + if (!firstArgv) { + selfObject.clipsToBounds = YES; + } + }; + }); + } + }); +} +#endif + +- (void)qmui_updateContentSize { + SEL selector = NSSelectorFromString([NSString qmui_stringByConcat:@"_", @"updateContentSize", nil]); + if ([self respondsToSelector:selector]) { + BeginIgnorePerformSelectorLeaksWarning + [self performSelector:selector]; + EndIgnorePerformSelectorLeaksWarning + } +} + - (NSRange)qmui_selectedRange { return [self qmui_convertNSRangeFromUITextRange:self.selectedTextRange]; } diff --git a/QMUIKit/UIKitExtensions/UITraitCollection+QMUI.m b/QMUIKit/UIKitExtensions/UITraitCollection+QMUI.m index 3a779c06..843ea3de 100644 --- a/QMUIKit/UIKitExtensions/UITraitCollection+QMUI.m +++ b/QMUIKit/UIKitExtensions/UITraitCollection+QMUI.m @@ -14,7 +14,6 @@ #import "UITraitCollection+QMUI.h" #import "QMUICore.h" -#import @implementation UITraitCollection (QMUI) diff --git a/QMUIKit/UIKitExtensions/UIView+QMUI.h b/QMUIKit/UIKitExtensions/UIView+QMUI.h index dde51154..e72e3f7d 100644 --- a/QMUIKit/UIKitExtensions/UIView+QMUI.h +++ b/QMUIKit/UIKitExtensions/UIView+QMUI.h @@ -31,6 +31,7 @@ NS_ASSUME_NONNULL_BEGIN /** 将要设置的 frame 用 CGRectApplyAffineTransformWithAnchorPoint 处理后再设置 + 注意这个方式会导致 self.bounds 也受 transform 的影响(系统默认行为是 frame 受 transform 影响,center 和 bounds 不会),如果有需要访问 self.bounds 的情况,请避免使用这个方式。 */ @property(nonatomic, assign) CGRect qmui_frameApplyTransform; @@ -44,7 +45,9 @@ NS_ASSUME_NONNULL_BEGIN */ @property(nonatomic, assign, readonly) BOOL qmui_tintColorCustomized; -/// 响应区域需要改变的大小,负值表示往外扩大,正值表示往内缩小。特别地,如果对 UISlider 使用,则扩大的是圆点的区域。 +/// 响应区域需要改变的大小,负值表示往外扩大,正值表示往内缩小。 +/// 特别地,如果对 UISlider 使用,则扩大的是圆点的区域。 +/// 当你引入了 QMUINavigationButton,它会使 UIBarButtonItem.customView 也可使用 qmui_outsideEdge(默认不可以,因为 customView 的父容器和 customView 一样大,所以 UINavigationBar 感知不到 customView 有 qmui_outsideEdge)。 @property(nonatomic,assign) UIEdgeInsets qmui_outsideEdge; /** @@ -113,7 +116,7 @@ NS_ASSUME_NONNULL_BEGIN 当 hitTest:withEvent: 被调用时会调用这个 block,就不用重写方法了 @param point 事件产生的 point @param event 事件 - @param super 的返回结果 + @param originalView super 的返回结果 */ @property(nullable, nonatomic, copy) __kindof UIView * _Nullable (^qmui_hitTestBlock)(CGPoint point, UIEvent * _Nullable event, __kindof UIView * _Nullable originalView); @@ -191,6 +194,9 @@ extern const CGFloat QMUIViewSelfSizingHeight; /// 等价于 CGRectGetMaxX(frame) @property(nonatomic, assign) CGFloat qmui_right; +/// 以 center = xxx 的方式将 frame 的 origin 设置为指定的值,由于用的是 center,所以可以兼容 transform 场景。 +@property(nonatomic, assign) CGPoint qmui_origin; + /// 等价于 CGRectGetWidth(frame) @property(nonatomic, assign) CGFloat qmui_width; diff --git a/QMUIKit/UIKitExtensions/UIView+QMUI.m b/QMUIKit/UIKitExtensions/UIView+QMUI.m index f8f7c0bd..faf17139 100644 --- a/QMUIKit/UIKitExtensions/UIView+QMUI.m +++ b/QMUIKit/UIKitExtensions/UIView+QMUI.m @@ -539,6 +539,14 @@ - (void)setQmui_right:(CGFloat)right { self.frame = CGRectSetX(self.frame, right - CGRectGetWidth(self.frame)); } +- (CGPoint)qmui_origin { + return self.frame.origin; +} + +- (void)setQmui_origin:(CGPoint)qmui_origin { + self.center = CGPointMake(qmui_origin.x + CGRectGetWidth(self.frame) / 2, qmui_origin.y + CGRectGetHeight(self.frame) / 2); +} + - (CGFloat)qmui_width { return CGRectGetWidth(self.frame); } @@ -575,6 +583,8 @@ - (void)setQmui_fixedSize:(CGSize)qmui_fixedSize { return superResult; }; self.qmui_size = qmui_fixedSize; + } else { + self.qmui_sizeThatFitsBlock = nil; } } @@ -682,7 +692,7 @@ - (void)setQmui_shouldShowDebugColor:(BOOL)qmui_shouldShowDebugColor { [selfObject renderColorWithSubviews:selfObject.subviews]; } else if (objc_getAssociatedObject(selfObject, &kAssociatedObjectKey_shouldShowDebugColor)) { // 设置过 qmui_shouldShowDebugColor,但当前的值为 NO 的情况,则无脑清空所有背景色(可能会把业务自己设置的背景色去掉,由于是调试功能,无所谓) - selfObject.backgroundColor = nil; + selfObject.backgroundColor = UIColor.clearColor; [selfObject renderColorWithSubviews:selfObject.subviews]; } }); @@ -764,7 +774,7 @@ - (void)renderColorWithSubviews:(NSArray *)subviews { if (view.qmui_shouldShowDebugColor) { view.backgroundColor = [view debugColor]; } else { - view.backgroundColor = nil; + view.backgroundColor = UIColor.clearColor; } } } diff --git a/QMUIKit/UIKitExtensions/UIViewController+QMUI.h b/QMUIKit/UIKitExtensions/UIViewController+QMUI.h index e852c4dd..51848248 100644 --- a/QMUIKit/UIKitExtensions/UIViewController+QMUI.h +++ b/QMUIKit/UIKitExtensions/UIViewController+QMUI.h @@ -147,6 +147,7 @@ typedef NS_OPTIONS(NSUInteger, QMUIViewControllerVisibleState) { @interface UIViewController (Data) /// 当数据加载完(什么时候算是“加载完”需要通过属性 qmui_dataLoaded 来设置)并且界面已经走过 viewDidAppear: 时,这个 block 会被执行,执行结束后 block 会被清空,以避免重复调用。 +/// @warning 注意,如果你在 viewWillAppear: 里设置该 block,则要留意在下一级界面手势返回触发后又取消,会触发前一个界面的 viewWillAppear:、viewDidDisappear:,过程中不会触发 viewDidAppear:,所以这次设置的 block 并没有人消费它。 @property(nullable, nonatomic, copy) void (^qmui_didAppearAndLoadDataBlock)(void); /// 请在你的数据加载完成时手动修改这个属性为 YES,如果此时界面已经走过 viewDidAppear:,则 qmui_didAppearAndLoadDataBlock 会被立即执行,如果此时界面尚未走 viewDidAppear:,则等到 viewDidAppear: 时,qmui_didAppearAndLoadDataBlock 就会被自动执行。 diff --git a/QMUIKit/UIKitExtensions/UIViewController+QMUI.m b/QMUIKit/UIKitExtensions/UIViewController+QMUI.m index 44d43385..de9fbbcf 100644 --- a/QMUIKit/UIKitExtensions/UIViewController+QMUI.m +++ b/QMUIKit/UIKitExtensions/UIViewController+QMUI.m @@ -245,12 +245,24 @@ - (QMUIViewControllerVisibleState)qmui_visibleState { } - (UIViewController *)qmui_previousViewController { + UIViewController *previousViewController = nil; NSArray *viewControllers = self.navigationController.viewControllers; NSUInteger index = [viewControllers indexOfObject:self]; if (index != NSNotFound && index > 0) { - return viewControllers[index - 1]; + previousViewController = viewControllers[index - 1]; + + // 系统 UINavigationController 的 popToViewController、popToRootViewController、setViewControllers 三种 pop 的方式都有一个共同的特点,假如此时有3个及以上的 vc 例如 [A,B,C],从当前界面 pop 到非相邻的界面,例如C到A,执行完 pop 操作后立马访问 UINavigationController.viewControllers 预期应该得到 [A],实际上会得到 [C,A],过一会(nav.view layoutIfNeeded 之后)才变成正确的 [A]。同理,[A,B,C,D]时从 D pop 到 B,预期得到[A,B],实际得到[D,A,B],也即这种情况它总是会把当前界面塞到 viewControllers 数组里的第一个,这就导致这期间访问基于 viewControllers 数组实现的功能(例如 qmui_rootViewController、qmui_previousViewController),都可能出错,所以这里对上述情况做特殊保护。 + // 如果 pop 操作时只有2个vc,则没这种问题。 + if (self.navigationController.qmui_isPopping && self.navigationController.transitionCoordinator) { + id transitionCoordinator = self.navigationController.transitionCoordinator; + UIViewController *fromVc = [transitionCoordinator viewControllerForKey:UITransitionContextFromViewControllerKey]; + UIViewController *toVc = [transitionCoordinator viewControllerForKey:UITransitionContextToViewControllerKey]; + if (self == toVc && previousViewController == fromVc && index == 1) { + previousViewController = nil; + } + } } - return nil; + return previousViewController; } - (NSString *)qmui_previousViewControllerTitle { @@ -479,11 +491,19 @@ @implementation UIViewController (Data) + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - ExtendImplementationOfVoidMethodWithSingleArgument([UIViewController class], @selector(viewDidAppear:), BOOL, ^(UIViewController *selfObject, BOOL animated) { - if (selfObject.qmui_didAppearAndLoadDataBlock && selfObject.qmui_dataLoaded) { - selfObject.qmui_didAppearAndLoadDataBlock(); - selfObject.qmui_didAppearAndLoadDataBlock = nil; - } + OverrideImplementation([UIViewController class], @selector(viewDidAppear:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UIViewController *selfObject, BOOL animated) { + + // call super + void (*originSelectorIMP)(id, SEL, BOOL); + originSelectorIMP = (void (*)(id, SEL, BOOL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD, animated); + + if (selfObject.qmui_didAppearAndLoadDataBlock && selfObject.qmui_dataLoaded) { + selfObject.qmui_didAppearAndLoadDataBlock(); + selfObject.qmui_didAppearAndLoadDataBlock = nil; + } + }; }); }); } diff --git a/QMUIKit/UIKitExtensions/UIWindow+QMUI.h b/QMUIKit/UIKitExtensions/UIWindow+QMUI.h index b2a6690c..41745ca0 100644 --- a/QMUIKit/UIKitExtensions/UIWindow+QMUI.h +++ b/QMUIKit/UIKitExtensions/UIWindow+QMUI.h @@ -28,4 +28,14 @@ @warning 如果你自己创建的 window 不满足以上2点,那么就算 qmui_capturesStatusBarAppearance 为 YES,也无法得到 statusBar 的控制权。 */ @property(nonatomic, assign) BOOL qmui_capturesStatusBarAppearance; + +/** + 1. 支持以 property 形式修改值,但不支持重写 getter 来修改。 + 2. 对低于 iOS 15 的系统也支持。 + */ +@property(nonatomic, assign) BOOL qmui_canBecomeKeyWindow; + +/// 当前 window 因各种原因(例如其他 window 显式调用 makeKey、当前 keyWindow 被隐藏导致系统自动流转 keyWindow、主动向自身调用 resignKeyWindow 等)导致从 keyWindow 转变为非 keyWindow 时会询问这个 block,业务可在这个 block 里干预当前的流转。 +/// 实际场景例如,背后 window 正在显示一个带输入框的 webView 网页,输入框聚焦以升起键盘,此时你再新开一个更高 windowLevel 的 window,盖在 webView 上并且 makeKey,就会发现你的 window 依然被键盘挡住,因为 webView 有个特性是如果有输入框聚焦,则 webView 内部会不断地尝试将输入框 becomeFirstResponder 并且让输入框所在的 window makeKey,这就会抢占了我们刚刚手动盖上来的 window 的 key,所以此时就可以给新开的 window 使用本 block,返回 NO,使 webView 无法抢占 keyWindow,从而避免键盘遮挡。 +@property(nonatomic, copy) BOOL (^qmui_canResignKeyWindowBlock)(UIWindow *selfObject, UIWindow *windowWillBecomeKey); @end diff --git a/QMUIKit/UIKitExtensions/UIWindow+QMUI.m b/QMUIKit/UIKitExtensions/UIWindow+QMUI.m index 8c910015..57e03b66 100644 --- a/QMUIKit/UIKitExtensions/UIWindow+QMUI.m +++ b/QMUIKit/UIKitExtensions/UIWindow+QMUI.m @@ -54,4 +54,78 @@ + (void)load { }); } +static char kAssociatedObjectKey_canBecomeKeyWindow; +- (void)setQmui_canBecomeKeyWindow:(BOOL)qmui_canBecomeKeyWindow { + [self qmuiw_hookIfNeeded]; + objc_setAssociatedObject(self, &kAssociatedObjectKey_canBecomeKeyWindow, @(qmui_canBecomeKeyWindow), OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +- (BOOL)qmui_canBecomeKeyWindow { + NSNumber *value = objc_getAssociatedObject(self, &kAssociatedObjectKey_canBecomeKeyWindow); + if (!value) { + return YES; + } + return value.boolValue; +} + +static char kAssociatedObjectKey_canResignKeyWindowBlock; +- (void)setQmui_canResignKeyWindowBlock:(BOOL (^)(UIWindow *, UIWindow *))qmui_canResignKeyWindowBlock { + [self qmuiw_hookIfNeeded]; + objc_setAssociatedObject(self, &kAssociatedObjectKey_canResignKeyWindowBlock, qmui_canResignKeyWindowBlock, OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +- (BOOL (^)(UIWindow *, UIWindow *))qmui_canResignKeyWindowBlock { + return (BOOL (^)(UIWindow *, UIWindow *))objc_getAssociatedObject(self, &kAssociatedObjectKey_canResignKeyWindowBlock); +} + +- (void)qmuiw_hookIfNeeded { + [QMUIHelper executeBlock:^{ + // - [UIWindow canBecomeKeyWindow] + SEL sel1 = @selector(canBecomeKeyWindow); + // - [UIWindow _canBecomeKeyWindow] + SEL sel2 = NSSelectorFromString([NSString stringWithFormat:@"_%@", NSStringFromSelector(sel1)]); + SEL sel = [self respondsToSelector:sel1] ? sel1 : ([self respondsToSelector:sel2] ? sel2 : nil); + if (sel) { + OverrideImplementation([UIWindow class], sel, ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^BOOL(UIWindow *selfObject) { + // call super + BOOL (*originSelectorIMP)(id, SEL); + originSelectorIMP = (BOOL (*)(id, SEL))originalIMPProvider(); + BOOL result = originSelectorIMP(selfObject, originCMD); + + BOOL hasSet = !!objc_getAssociatedObject(selfObject, &kAssociatedObjectKey_canBecomeKeyWindow); + if (hasSet) { + result = selfObject.qmui_canBecomeKeyWindow; + } + + BeginIgnoreDeprecatedWarning + UIWindow *keyWindow = UIApplication.sharedApplication.keyWindow; + if (result && keyWindow && keyWindow != selfObject && keyWindow.qmui_canResignKeyWindowBlock) { + result = keyWindow.qmui_canResignKeyWindowBlock(keyWindow, selfObject); + } + EndIgnoreDeprecatedWarning + + return result; + }; + }); + } else { + QMUIAssert(NO, @"UIWindow (QMUI)", @"%f 不存在方法 -[UIWindow _canBecomeKeyWindow]", IOS_VERSION); + } + + OverrideImplementation([UIWindow class], @selector(resignKeyWindow), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { + return ^(UIWindow *selfObject) { + + if (selfObject.isKeyWindow && selfObject.qmui_canResignKeyWindowBlock && !selfObject.qmui_canResignKeyWindowBlock(selfObject, selfObject)) { + return; + } + + // call super + void (*originSelectorIMP)(id, SEL); + originSelectorIMP = (void (*)(id, SEL))originalIMPProvider(); + originSelectorIMP(selfObject, originCMD); + }; + }); + } oncePerIdentifier:@"UIWindow (QMUI) keyWindow"]; +} + @end diff --git a/QMUIKitTests/UIKitExtensions/NSStringTests.m b/QMUIKitTests/UIKitExtensions/NSStringTests.m index e67c861e..8a0bb5bf 100644 --- a/QMUIKitTests/UIKitExtensions/NSStringTests.m +++ b/QMUIKitTests/UIKitExtensions/NSStringTests.m @@ -64,7 +64,7 @@ - (void)testStringMatching { XCTAssertNil([string qmui_stringMatchedByPattern:@"str" groupName:@"number"]); XCTAssertEqualObjects([string qmui_stringMatchedByPattern:@"ing(?[\\d\\.]+)" groupName:@"number"], @"0.05"); - XCTAssertNil([string qmui_stringMatchedByPattern:@"ing(?[\\d\\.]+)" groupName:@"num"]); + XCTAssertThrows([string qmui_stringMatchedByPattern:@"ing(?[\\d\\.]+)" groupName:@"num"]); } - (void)testSubstring1 { @@ -143,12 +143,13 @@ - (void)testSubstring4 { BOOL countingNonASCIICharacterAsTwo = NO; NSString *text2 = [text qmui_substringAvoidBreakingUpCharacterSequencesToIndex:toIndex lessValue:lessValue countingNonASCIICharacterAsTwo:countingNonASCIICharacterAsTwo]; - XCTAssertEqual(text2.length, toIndex + 1); + XCTAssertEqual(text2.length, toIndex); NSString *zh2 = [zh qmui_substringAvoidBreakingUpCharacterSequencesToIndex:toIndex lessValue:lessValue countingNonASCIICharacterAsTwo:countingNonASCIICharacterAsTwo]; - XCTAssertEqual(zh2.length, toIndex + 1); + XCTAssertEqual(zh2.length, toIndex); + NSString *zh3 = [zh substringToIndex:toIndex]; - XCTAssertTrue((lessValue && zh2.length == zh3.length) || (!lessValue && zh2.length > zh3.length)); + XCTAssertTrue((!countingNonASCIICharacterAsTwo && zh2.length == zh3.length) || (countingNonASCIICharacterAsTwo && zh2.length > zh3.length)); NSString *emoji2 = [emoji qmui_substringAvoidBreakingUpCharacterSequencesToIndex:toIndex lessValue:lessValue countingNonASCIICharacterAsTwo:countingNonASCIICharacterAsTwo]; NSString *emoji3 = [emoji substringToIndex:[emoji rangeOfComposedCharacterSequenceAtIndex:toIndex].location]; @@ -281,4 +282,30 @@ - (void)testAttributedString { }]; } +- (void)testAttributedString2 { + NSAttributedString *nilString = nil; + NSAttributedString *emptyString = NSAttributedString.new; + NSAttributedString *emptyString2 = [[NSAttributedString alloc] initWithString:@"" attributes:@{NSFontAttributeName: UIFontMake(16), NSParagraphStyleAttributeName: [NSParagraphStyle qmui_paragraphStyleWithLineHeight:20 lineBreakMode:NSLineBreakByWordWrapping textAlignment:NSTextAlignmentCenter]}]; + NSAttributedString *paraString = [[NSAttributedString alloc] initWithString:@"你好啊" attributes:@{NSParagraphStyleAttributeName: [NSParagraphStyle qmui_paragraphStyleWithLineHeight:20 lineBreakMode:NSLineBreakByWordWrapping textAlignment:NSTextAlignmentCenter]}]; + NSAttributedString *nonParaString = [[NSAttributedString alloc] initWithString:@"你好啊" attributes:@{NSFontAttributeName: UIFontMake(16)}]; + NSMutableAttributedString *multiParaString = [[NSMutableAttributedString alloc] initWithString:@"片段1片段2" attributes:@{NSFontAttributeName: UIFontMake(16)}]; + [multiParaString addAttribute:NSParagraphStyleAttributeName value:[NSParagraphStyle qmui_paragraphStyleWithLineHeight:20 lineBreakMode:NSLineBreakByWordWrapping textAlignment:NSTextAlignmentCenter] range:NSMakeRange(0, 3)]; + [multiParaString addAttribute:NSParagraphStyleAttributeName value:[NSParagraphStyle qmui_paragraphStyleWithLineHeight:40 lineBreakMode:NSLineBreakByWordWrapping textAlignment:NSTextAlignmentRight] range:NSMakeRange(3, 3)]; + + XCTAssertEqual(nilString.qmui_textAlignment, NSTextAlignmentLeft); + XCTAssertEqual(emptyString.qmui_textAlignment, NSTextAlignmentLeft); + XCTAssertEqual(emptyString2.qmui_textAlignment, NSTextAlignmentLeft);// 就算显式写了文本属性,但因为文本长度为0,所以得到的也是默认值 Left + XCTAssertEqual(paraString.qmui_textAlignment, NSTextAlignmentCenter); + XCTAssertEqual(nonParaString.qmui_textAlignment, NSTextAlignmentLeft); + XCTAssertEqual(multiParaString.qmui_textAlignment, NSTextAlignmentCenter);// 子字符串拥有不同段落属性的,取第一个子字符串的段落属性的值 + + + NSMutableAttributedString *paraString2 = (NSMutableAttributedString *)paraString.mutableCopy; + paraString2.qmui_textAlignment = NSTextAlignmentRight; + XCTAssertEqual(paraString2.qmui_textAlignment, NSTextAlignmentRight); + + multiParaString.qmui_textAlignment = NSTextAlignmentRight; + XCTAssertEqual(multiParaString.qmui_textAlignment, NSTextAlignmentRight); +} + @end diff --git a/README.md b/README.md index 598b0630..fb1a1ff5 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ QMUI iOS 是一个致力于提高项目 UI 开发效率的解决方案,其设计目的是用于辅助快速搭建一个具备基本设计还原效果的 iOS 项目,同时利用自身提供的丰富控件及兼容处理, 让开发者能专注于业务需求而无需耗费精力在基础代码的设计上。不管是新项目的创建,或是已有项目的维护,均可使开发效率和项目质量得到大幅度提升。 -官网:[http://qmuiteam.com/ios](http://qmuiteam.com/ios) - [![QMUI Team Name](https://img.shields.io/badge/Team-QMUI-brightgreen.svg?style=flat)](https://github.com/QMUI "QMUI Team") [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](http://opensource.org/licenses/MIT "Feel free to contribute.") +开发者:深圳市腾讯计算机系统有限公司 + ## 功能特性 ### 全局 UI 配置 @@ -37,7 +37,9 @@ QMUI iOS 是一个致力于提高项目 UI 开发效率的解决方案,其设 ## 使用方法 -请查看官网的[开始使用](http://qmuiteam.com/ios/page/start.html)。 +``` +pod 'QMUIKit' +``` ## 代码示例 @@ -57,9 +59,7 @@ QMUI iOS 是一个致力于提高项目 UI 开发效率的解决方案,其设 ## 隐私政策 -如果你想了解使用 QMUI iOS 过程中涉及到的隐私政策,可阅读:[QMUI iOS SDK 个人信息保护规则](https://qmuiteam.com/ios/privacy/)。 - -其中特别注意的是,从 2.8.0 版本开始,QMUIKit 默认会在 Debug 模式下启动 App 时发送当前 App 的 Bundle Identifier 和 Display Name 给 QMUI 作统计用,Release 下不会发送。你也可以通过配置表的 `SendAnalyticsToQMUITeam` 开关将统计关闭。统计的代码在 [QMUIConfiguration.m:134-145](https://github.com/Tencent/QMUI_iOS/blob/master/QMUIKit/QMUICore/QMUIConfiguration.m#L134-L145),可直接查看。 +如果你想了解使用 QMUI iOS 过程中涉及到的隐私政策,可阅读:[QMUI iOS SDK 个人信息保护规则](https://github.com/Tencent/QMUI_iOS/wiki/QMUI-iOS-SDK%E4%B8%AA%E4%BA%BA%E4%BF%A1%E6%81%AF%E4%BF%9D%E6%8A%A4%E8%A7%84%E5%88%99)。 ## 设计资源 diff --git a/qmui.xcodeproj/project.pbxproj b/qmui.xcodeproj/project.pbxproj index bbfa853f..9381ac23 100644 --- a/qmui.xcodeproj/project.pbxproj +++ b/qmui.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 08B399CA22E18A3B000A8A45 /* UITraitCollection+QMUI.m in Sources */ = {isa = PBXBuildFile; fileRef = 08B399C822E18A3B000A8A45 /* UITraitCollection+QMUI.m */; }; 1178D5692198258700AA30E5 /* NSURL+QMUI.h in Headers */ = {isa = PBXBuildFile; fileRef = 1178D5672198258700AA30E5 /* NSURL+QMUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; 1178D56A2198258700AA30E5 /* NSURL+QMUI.m in Sources */ = {isa = PBXBuildFile; fileRef = 1178D5682198258700AA30E5 /* NSURL+QMUI.m */; }; + 3CB960C42BB40725005626A6 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3CB960C32BB40725005626A6 /* PrivacyInfo.xcprivacy */; }; AA8860BA2107455C005E4054 /* QMUIWeakObjectContainer.h in Headers */ = {isa = PBXBuildFile; fileRef = AA8860B82107455C005E4054 /* QMUIWeakObjectContainer.h */; settings = {ATTRIBUTES = (Public, ); }; }; AA8860BB2107455C005E4054 /* QMUIWeakObjectContainer.m in Sources */ = {isa = PBXBuildFile; fileRef = AA8860B92107455C005E4054 /* QMUIWeakObjectContainer.m */; }; CD046C412018668900092035 /* QMUILogItem.h in Headers */ = {isa = PBXBuildFile; fileRef = CD046C3F2018668900092035 /* QMUILogItem.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -35,10 +36,17 @@ CD18CDFE20EE167200EED53C /* UITableViewCell+QMUI.h in Headers */ = {isa = PBXBuildFile; fileRef = CD18CDFC20EE167200EED53C /* UITableViewCell+QMUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; CD18CDFF20EE167200EED53C /* UITableViewCell+QMUI.m in Sources */ = {isa = PBXBuildFile; fileRef = CD18CDFD20EE167200EED53C /* UITableViewCell+QMUI.m */; }; CD19F4D821E4AB3900BD4687 /* QMUILab.h in Headers */ = {isa = PBXBuildFile; fileRef = CD19F4D721E4AB3900BD4687 /* QMUILab.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CD2B19712A715D6200E8ED18 /* QMUIBadgeLabel.h in Headers */ = {isa = PBXBuildFile; fileRef = CD2B196F2A715D6200E8ED18 /* QMUIBadgeLabel.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CD2B19722A715D6200E8ED18 /* QMUIBadgeLabel.m in Sources */ = {isa = PBXBuildFile; fileRef = CD2B19702A715D6200E8ED18 /* QMUIBadgeLabel.m */; }; CD349BAD2160AF75008653D4 /* QMUIScrollAnimator.h in Headers */ = {isa = PBXBuildFile; fileRef = CD349BAB2160AF75008653D4 /* QMUIScrollAnimator.h */; settings = {ATTRIBUTES = (Public, ); }; }; CD349BAE2160AF75008653D4 /* QMUIScrollAnimator.m in Sources */ = {isa = PBXBuildFile; fileRef = CD349BAC2160AF75008653D4 /* QMUIScrollAnimator.m */; }; CD349BB72160B83D008653D4 /* QMUINavigationBarScrollingSnapAnimator.h in Headers */ = {isa = PBXBuildFile; fileRef = CD349BB52160B83D008653D4 /* QMUINavigationBarScrollingSnapAnimator.h */; settings = {ATTRIBUTES = (Public, ); }; }; CD349BB82160B83D008653D4 /* QMUINavigationBarScrollingSnapAnimator.m in Sources */ = {isa = PBXBuildFile; fileRef = CD349BB62160B83D008653D4 /* QMUINavigationBarScrollingSnapAnimator.m */; }; + CD40021B2C1F6BB0003D2127 /* QMUIPopupMenuItem.m in Sources */ = {isa = PBXBuildFile; fileRef = CD40021A2C1F6BB0003D2127 /* QMUIPopupMenuItem.m */; }; + CD40021C2C1F6BB0003D2127 /* QMUIPopupMenuItem.h in Headers */ = {isa = PBXBuildFile; fileRef = CD4002192C1F6BB0003D2127 /* QMUIPopupMenuItem.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CD40021E2C1F6E1C003D2127 /* QMUIPopupMenuItemViewProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = CD40021D2C1F6E1C003D2127 /* QMUIPopupMenuItemViewProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CD4002212C1F81CE003D2127 /* QMUIPopupMenuItemView.m in Sources */ = {isa = PBXBuildFile; fileRef = CD4002202C1F81CE003D2127 /* QMUIPopupMenuItemView.m */; }; + CD4002222C1F81CE003D2127 /* QMUIPopupMenuItemView.h in Headers */ = {isa = PBXBuildFile; fileRef = CD40021F2C1F81CE003D2127 /* QMUIPopupMenuItemView.h */; settings = {ATTRIBUTES = (Public, ); }; }; CD43CB17207B98A10090346B /* QMUIButton.h in Headers */ = {isa = PBXBuildFile; fileRef = CD43CB15207B98A10090346B /* QMUIButton.h */; settings = {ATTRIBUTES = (Public, ); }; }; CD43CB18207B98A10090346B /* QMUIButton.m in Sources */ = {isa = PBXBuildFile; fileRef = CD43CB16207B98A10090346B /* QMUIButton.m */; }; CD43CB1B207B98B60090346B /* QMUINavigationButton.h in Headers */ = {isa = PBXBuildFile; fileRef = CD43CB19207B98B60090346B /* QMUINavigationButton.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -56,6 +64,10 @@ CD513E2E283527CE004A549D /* UITabBar+QMUIBarProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = CD513E2C283527CE004A549D /* UITabBar+QMUIBarProtocol.m */; }; CD513E31283527DB004A549D /* UINavigationBar+QMUIBarProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = CD513E2F283527DB004A549D /* UINavigationBar+QMUIBarProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; CD513E32283527DB004A549D /* UINavigationBar+QMUIBarProtocol.m in Sources */ = {isa = PBXBuildFile; fileRef = CD513E30283527DB004A549D /* UINavigationBar+QMUIBarProtocol.m */; }; + CD5E43212B85F7200030CFDA /* NSRegularExpression+QMUI.h in Headers */ = {isa = PBXBuildFile; fileRef = CD5E431F2B85F71F0030CFDA /* NSRegularExpression+QMUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CD5E43222B85F7200030CFDA /* NSRegularExpression+QMUI.m in Sources */ = {isa = PBXBuildFile; fileRef = CD5E43202B85F71F0030CFDA /* NSRegularExpression+QMUI.m */; }; + CD60DB512C5BC5D1005109B3 /* QMUICheckbox.h in Headers */ = {isa = PBXBuildFile; fileRef = CD60DB4F2C5BC5D1005109B3 /* QMUICheckbox.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CD60DB522C5BC5D1005109B3 /* QMUICheckbox.m in Sources */ = {isa = PBXBuildFile; fileRef = CD60DB502C5BC5D1005109B3 /* QMUICheckbox.m */; }; CD6631DB1FD929F4004DF7E8 /* QMUITableViewHeaderFooterView.h in Headers */ = {isa = PBXBuildFile; fileRef = CD6631D91FD929F4004DF7E8 /* QMUITableViewHeaderFooterView.h */; settings = {ATTRIBUTES = (Public, ); }; }; CD6631DC1FD929F4004DF7E8 /* QMUITableViewHeaderFooterView.m in Sources */ = {isa = PBXBuildFile; fileRef = CD6631DA1FD929F4004DF7E8 /* QMUITableViewHeaderFooterView.m */; }; CD669A0D25F79DA40036D6B2 /* UICollectionViewCell+QMUI.h in Headers */ = {isa = PBXBuildFile; fileRef = CD669A0B25F79DA40036D6B2 /* UICollectionViewCell+QMUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -66,6 +78,12 @@ CD6BE1572058C73600BE093E /* UITableView+QMUICellHeightKeyCache.m in Sources */ = {isa = PBXBuildFile; fileRef = CD6BE1552058C73600BE093E /* UITableView+QMUICellHeightKeyCache.m */; }; CD70C43A276340B300D212F5 /* UISlider+QMUI.h in Headers */ = {isa = PBXBuildFile; fileRef = CD70C438276340B300D212F5 /* UISlider+QMUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; CD70C43B276340B300D212F5 /* UISlider+QMUI.m in Sources */ = {isa = PBXBuildFile; fileRef = CD70C439276340B300D212F5 /* UISlider+QMUI.m */; }; + CD72E7C12B440DF000AC528A /* QMUILayouterItem.h in Headers */ = {isa = PBXBuildFile; fileRef = CD72E7BF2B440DF000AC528A /* QMUILayouterItem.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CD72E7C22B440DF000AC528A /* QMUILayouterItem.m in Sources */ = {isa = PBXBuildFile; fileRef = CD72E7C02B440DF000AC528A /* QMUILayouterItem.m */; }; + CD72E7C72B44AF2F00AC528A /* QMUILayouterLinearHorizontal.h in Headers */ = {isa = PBXBuildFile; fileRef = CD72E7C52B44AF2F00AC528A /* QMUILayouterLinearHorizontal.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CD72E7C82B44AF2F00AC528A /* QMUILayouterLinearHorizontal.m in Sources */ = {isa = PBXBuildFile; fileRef = CD72E7C62B44AF2F00AC528A /* QMUILayouterLinearHorizontal.m */; }; + CD72E7CB2B44AF8800AC528A /* QMUILayouterLinearVertical.h in Headers */ = {isa = PBXBuildFile; fileRef = CD72E7C92B44AF8800AC528A /* QMUILayouterLinearVertical.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CD72E7CC2B44AF8800AC528A /* QMUILayouterLinearVertical.m in Sources */ = {isa = PBXBuildFile; fileRef = CD72E7CA2B44AF8800AC528A /* QMUILayouterLinearVertical.m */; }; CD745E2C21CA5B8F006EC132 /* QMUIImagePreviewView.h in Headers */ = {isa = PBXBuildFile; fileRef = CD745E2821CA5B8E006EC132 /* QMUIImagePreviewView.h */; settings = {ATTRIBUTES = (Public, ); }; }; CD745E2D21CA5B8F006EC132 /* QMUIImagePreviewViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = CD745E2921CA5B8E006EC132 /* QMUIImagePreviewViewController.m */; }; CD745E2E21CA5B8F006EC132 /* QMUIImagePreviewViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = CD745E2A21CA5B8E006EC132 /* QMUIImagePreviewViewController.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -77,6 +95,8 @@ CD7A9A0D22C4AA2F0093DAB4 /* QMUIThemeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CD7A9A0C22C4AA2F0093DAB4 /* QMUIThemeTests.m */; }; CD7D402F231FA2900007DF6C /* QMUIThemeManagerCenter.h in Headers */ = {isa = PBXBuildFile; fileRef = CD7D402D231FA2900007DF6C /* QMUIThemeManagerCenter.h */; settings = {ATTRIBUTES = (Public, ); }; }; CD7D4030231FA2900007DF6C /* QMUIThemeManagerCenter.m in Sources */ = {isa = PBXBuildFile; fileRef = CD7D402E231FA2900007DF6C /* QMUIThemeManagerCenter.m */; }; + CD81252A2A69CB5900132EC7 /* NSDictionary+QMUI.h in Headers */ = {isa = PBXBuildFile; fileRef = CD8125282A69CB5900132EC7 /* NSDictionary+QMUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CD81252B2A69CB5900132EC7 /* NSDictionary+QMUI.m in Sources */ = {isa = PBXBuildFile; fileRef = CD8125292A69CB5900132EC7 /* NSDictionary+QMUI.m */; }; CD82C0AF206A2C3D0046EED2 /* QMUIMultipleDelegates.h in Headers */ = {isa = PBXBuildFile; fileRef = CD82C0AD206A2C3D0046EED2 /* QMUIMultipleDelegates.h */; settings = {ATTRIBUTES = (Public, ); }; }; CD82C0B0206A2C3D0046EED2 /* QMUIMultipleDelegates.m in Sources */ = {isa = PBXBuildFile; fileRef = CD82C0AE206A2C3D0046EED2 /* QMUIMultipleDelegates.m */; }; CD82C0B3206A82520046EED2 /* NSObject+QMUIMultipleDelegates.h in Headers */ = {isa = PBXBuildFile; fileRef = CD82C0B1206A82520046EED2 /* NSObject+QMUIMultipleDelegates.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -262,6 +282,10 @@ CDC870301F68D63B000E8829 /* QMUICommonViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = CDC86FB81F68D5F9000E8829 /* QMUICommonViewController.m */; }; CDC870311F68D63B000E8829 /* QMUINavigationController.m in Sources */ = {isa = PBXBuildFile; fileRef = CDC86FBA1F68D5F9000E8829 /* QMUINavigationController.m */; }; CDC870321F68D63B000E8829 /* QMUITabBarViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = CDC86FBC1F68D5F9000E8829 /* QMUITabBarViewController.m */; }; + CDCD27032B8E0B6200D3500A /* QMUISheetPresentationSupports.m in Sources */ = {isa = PBXBuildFile; fileRef = CDCD27012B8E0B6200D3500A /* QMUISheetPresentationSupports.m */; }; + CDCD27042B8E0B6200D3500A /* QMUISheetPresentationSupports.h in Headers */ = {isa = PBXBuildFile; fileRef = CDCD27022B8E0B6200D3500A /* QMUISheetPresentationSupports.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CDCD27072B8E0C2700D3500A /* QMUISheetPresentationNavigationBar.h in Headers */ = {isa = PBXBuildFile; fileRef = CDCD27052B8E0C2700D3500A /* QMUISheetPresentationNavigationBar.h */; settings = {ATTRIBUTES = (Public, ); }; }; + CDCD27082B8E0C2700D3500A /* QMUISheetPresentationNavigationBar.m in Sources */ = {isa = PBXBuildFile; fileRef = CDCD27062B8E0C2700D3500A /* QMUISheetPresentationNavigationBar.m */; }; CDD071FD2060F82700343AB6 /* QMUICellHeightCache.h in Headers */ = {isa = PBXBuildFile; fileRef = CDD071FB2060F82700343AB6 /* QMUICellHeightCache.h */; settings = {ATTRIBUTES = (Public, ); }; }; CDD071FE2060F82700343AB6 /* QMUICellHeightCache.m in Sources */ = {isa = PBXBuildFile; fileRef = CDD071FC2060F82700343AB6 /* QMUICellHeightCache.m */; }; CDD12D3C1FBB320E00114EA9 /* NSArray+QMUI.h in Headers */ = {isa = PBXBuildFile; fileRef = CDD12D3A1FBB320E00114EA9 /* NSArray+QMUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -270,11 +294,6 @@ CDD759A822BBE68900BC8F36 /* CAAnimation+QMUI.m in Sources */ = {isa = PBXBuildFile; fileRef = CDD759A622BBE68600BC8F36 /* CAAnimation+QMUI.m */; }; CDD759A922BBE68900BC8F36 /* CAAnimation+QMUI.h in Headers */ = {isa = PBXBuildFile; fileRef = CDD759A722BBE68600BC8F36 /* CAAnimation+QMUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; CDD7C0D4212300A000D6FA1E /* QMUIRuntime.h in Headers */ = {isa = PBXBuildFile; fileRef = CDD7C0D3212300A000D6FA1E /* QMUIRuntime.h */; settings = {ATTRIBUTES = (Public, ); }; }; - CDD7C2B5212C4E0600D6FA1E /* QMUIPopupMenuItemProtocol.h in Headers */ = {isa = PBXBuildFile; fileRef = CDD7C2B4212C4E0600D6FA1E /* QMUIPopupMenuItemProtocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; - CDD7C2B8212C4E7400D6FA1E /* QMUIPopupMenuBaseItem.h in Headers */ = {isa = PBXBuildFile; fileRef = CDD7C2B6212C4E7400D6FA1E /* QMUIPopupMenuBaseItem.h */; settings = {ATTRIBUTES = (Public, ); }; }; - CDD7C2B9212C4E7400D6FA1E /* QMUIPopupMenuBaseItem.m in Sources */ = {isa = PBXBuildFile; fileRef = CDD7C2B7212C4E7400D6FA1E /* QMUIPopupMenuBaseItem.m */; }; - CDD7C2BC212C510B00D6FA1E /* QMUIPopupMenuButtonItem.h in Headers */ = {isa = PBXBuildFile; fileRef = CDD7C2BA212C510B00D6FA1E /* QMUIPopupMenuButtonItem.h */; settings = {ATTRIBUTES = (Public, ); }; }; - CDD7C2BD212C510B00D6FA1E /* QMUIPopupMenuButtonItem.m in Sources */ = {isa = PBXBuildFile; fileRef = CDD7C2BB212C510B00D6FA1E /* QMUIPopupMenuButtonItem.m */; }; CDD7C2C0212C528500D6FA1E /* QMUIPopupMenuView.m in Sources */ = {isa = PBXBuildFile; fileRef = CDD7C2BE212C528400D6FA1E /* QMUIPopupMenuView.m */; }; CDD7C2C1212C528500D6FA1E /* QMUIPopupMenuView.h in Headers */ = {isa = PBXBuildFile; fileRef = CDD7C2BF212C528400D6FA1E /* QMUIPopupMenuView.h */; settings = {ATTRIBUTES = (Public, ); }; }; CDE418FB20761A0F002ED021 /* UIBarItem+QMUI.h in Headers */ = {isa = PBXBuildFile; fileRef = CDE418F920761A0F002ED021 /* UIBarItem+QMUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -287,6 +306,7 @@ CDEA6D0A1F4B07E700F627AF /* UIGestureRecognizer+QMUI.m in Sources */ = {isa = PBXBuildFile; fileRef = CDEA6D071F4B07E700F627AF /* UIGestureRecognizer+QMUI.m */; }; CDF2D69C207F7E3F009E04DD /* NSPointerArray+QMUI.h in Headers */ = {isa = PBXBuildFile; fileRef = CDF2D69A207F7E3F009E04DD /* NSPointerArray+QMUI.h */; settings = {ATTRIBUTES = (Public, ); }; }; CDF2D69D207F7E3F009E04DD /* NSPointerArray+QMUI.m in Sources */ = {isa = PBXBuildFile; fileRef = CDF2D69B207F7E3F009E04DD /* NSPointerArray+QMUI.m */; }; + CDFCDDA02B43FF07005E1219 /* QMUILayouter.h in Headers */ = {isa = PBXBuildFile; fileRef = CDFCDD9E2B43FF07005E1219 /* QMUILayouter.h */; settings = {ATTRIBUTES = (Public, ); }; }; CDFE9575293FB1DE007AE1AA /* QMUIKit.podspec in Resources */ = {isa = PBXBuildFile; fileRef = CDFE9574293FB1DE007AE1AA /* QMUIKit.podspec */; }; CDFF5FB62369926300B63B92 /* Photos.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CDFF5FB52369926300B63B92 /* Photos.framework */; settings = {ATTRIBUTES = (Weak, ); }; }; D00881762677B5870061CABF /* UIButtonTests.m in Sources */ = {isa = PBXBuildFile; fileRef = D00881752677B5870061CABF /* UIButtonTests.m */; }; @@ -357,6 +377,7 @@ 08B399C822E18A3B000A8A45 /* UITraitCollection+QMUI.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UITraitCollection+QMUI.m"; sourceTree = ""; }; 1178D5672198258700AA30E5 /* NSURL+QMUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSURL+QMUI.h"; sourceTree = ""; }; 1178D5682198258700AA30E5 /* NSURL+QMUI.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSURL+QMUI.m"; sourceTree = ""; }; + 3CB960C32BB40725005626A6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 6D03A56D1B53895D003BDDE4 /* Photos.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Photos.framework; path = System/Library/Frameworks/Photos.framework; sourceTree = SDKROOT; }; AA8860B82107455C005E4054 /* QMUIWeakObjectContainer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUIWeakObjectContainer.h; sourceTree = ""; }; AA8860B92107455C005E4054 /* QMUIWeakObjectContainer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUIWeakObjectContainer.m; sourceTree = ""; }; @@ -377,10 +398,17 @@ CD18CDFC20EE167200EED53C /* UITableViewCell+QMUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UITableViewCell+QMUI.h"; sourceTree = ""; }; CD18CDFD20EE167200EED53C /* UITableViewCell+QMUI.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UITableViewCell+QMUI.m"; sourceTree = ""; }; CD19F4D721E4AB3900BD4687 /* QMUILab.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUILab.h; sourceTree = ""; }; + CD2B196F2A715D6200E8ED18 /* QMUIBadgeLabel.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUIBadgeLabel.h; sourceTree = ""; }; + CD2B19702A715D6200E8ED18 /* QMUIBadgeLabel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUIBadgeLabel.m; sourceTree = ""; }; CD349BAB2160AF75008653D4 /* QMUIScrollAnimator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUIScrollAnimator.h; sourceTree = ""; }; CD349BAC2160AF75008653D4 /* QMUIScrollAnimator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUIScrollAnimator.m; sourceTree = ""; }; CD349BB52160B83D008653D4 /* QMUINavigationBarScrollingSnapAnimator.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUINavigationBarScrollingSnapAnimator.h; sourceTree = ""; }; CD349BB62160B83D008653D4 /* QMUINavigationBarScrollingSnapAnimator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUINavigationBarScrollingSnapAnimator.m; sourceTree = ""; }; + CD4002192C1F6BB0003D2127 /* QMUIPopupMenuItem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUIPopupMenuItem.h; sourceTree = ""; }; + CD40021A2C1F6BB0003D2127 /* QMUIPopupMenuItem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUIPopupMenuItem.m; sourceTree = ""; }; + CD40021D2C1F6E1C003D2127 /* QMUIPopupMenuItemViewProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUIPopupMenuItemViewProtocol.h; sourceTree = ""; }; + CD40021F2C1F81CE003D2127 /* QMUIPopupMenuItemView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUIPopupMenuItemView.h; sourceTree = ""; }; + CD4002202C1F81CE003D2127 /* QMUIPopupMenuItemView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUIPopupMenuItemView.m; sourceTree = ""; }; CD43CB15207B98A10090346B /* QMUIButton.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = QMUIButton.h; sourceTree = ""; }; CD43CB16207B98A10090346B /* QMUIButton.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = QMUIButton.m; sourceTree = ""; }; CD43CB19207B98B60090346B /* QMUINavigationButton.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUINavigationButton.h; sourceTree = ""; }; @@ -405,6 +433,10 @@ CD513E2C283527CE004A549D /* UITabBar+QMUIBarProtocol.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UITabBar+QMUIBarProtocol.m"; sourceTree = ""; }; CD513E2F283527DB004A549D /* UINavigationBar+QMUIBarProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UINavigationBar+QMUIBarProtocol.h"; sourceTree = ""; }; CD513E30283527DB004A549D /* UINavigationBar+QMUIBarProtocol.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UINavigationBar+QMUIBarProtocol.m"; sourceTree = ""; }; + CD5E431F2B85F71F0030CFDA /* NSRegularExpression+QMUI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSRegularExpression+QMUI.h"; sourceTree = ""; }; + CD5E43202B85F71F0030CFDA /* NSRegularExpression+QMUI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSRegularExpression+QMUI.m"; sourceTree = ""; }; + CD60DB4F2C5BC5D1005109B3 /* QMUICheckbox.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUICheckbox.h; sourceTree = ""; }; + CD60DB502C5BC5D1005109B3 /* QMUICheckbox.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUICheckbox.m; sourceTree = ""; }; CD6631D91FD929F4004DF7E8 /* QMUITableViewHeaderFooterView.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUITableViewHeaderFooterView.h; sourceTree = ""; }; CD6631DA1FD929F4004DF7E8 /* QMUITableViewHeaderFooterView.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUITableViewHeaderFooterView.m; sourceTree = ""; }; CD669A0B25F79DA40036D6B2 /* UICollectionViewCell+QMUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UICollectionViewCell+QMUI.h"; sourceTree = ""; }; @@ -415,6 +447,12 @@ CD6BE1552058C73600BE093E /* UITableView+QMUICellHeightKeyCache.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UITableView+QMUICellHeightKeyCache.m"; sourceTree = ""; }; CD70C438276340B300D212F5 /* UISlider+QMUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UISlider+QMUI.h"; sourceTree = ""; }; CD70C439276340B300D212F5 /* UISlider+QMUI.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UISlider+QMUI.m"; sourceTree = ""; }; + CD72E7BF2B440DF000AC528A /* QMUILayouterItem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUILayouterItem.h; sourceTree = ""; }; + CD72E7C02B440DF000AC528A /* QMUILayouterItem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUILayouterItem.m; sourceTree = ""; }; + CD72E7C52B44AF2F00AC528A /* QMUILayouterLinearHorizontal.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUILayouterLinearHorizontal.h; sourceTree = ""; }; + CD72E7C62B44AF2F00AC528A /* QMUILayouterLinearHorizontal.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUILayouterLinearHorizontal.m; sourceTree = ""; }; + CD72E7C92B44AF8800AC528A /* QMUILayouterLinearVertical.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUILayouterLinearVertical.h; sourceTree = ""; }; + CD72E7CA2B44AF8800AC528A /* QMUILayouterLinearVertical.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUILayouterLinearVertical.m; sourceTree = ""; }; CD745E2821CA5B8E006EC132 /* QMUIImagePreviewView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = QMUIImagePreviewView.h; sourceTree = ""; }; CD745E2921CA5B8E006EC132 /* QMUIImagePreviewViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = QMUIImagePreviewViewController.m; sourceTree = ""; }; CD745E2A21CA5B8E006EC132 /* QMUIImagePreviewViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = QMUIImagePreviewViewController.h; sourceTree = ""; }; @@ -426,6 +464,8 @@ CD7A9A0C22C4AA2F0093DAB4 /* QMUIThemeTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = QMUIThemeTests.m; sourceTree = ""; }; CD7D402D231FA2900007DF6C /* QMUIThemeManagerCenter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUIThemeManagerCenter.h; sourceTree = ""; }; CD7D402E231FA2900007DF6C /* QMUIThemeManagerCenter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUIThemeManagerCenter.m; sourceTree = ""; }; + CD8125282A69CB5900132EC7 /* NSDictionary+QMUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSDictionary+QMUI.h"; sourceTree = ""; }; + CD8125292A69CB5900132EC7 /* NSDictionary+QMUI.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSDictionary+QMUI.m"; sourceTree = ""; }; CD82C0AD206A2C3D0046EED2 /* QMUIMultipleDelegates.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUIMultipleDelegates.h; sourceTree = ""; }; CD82C0AE206A2C3D0046EED2 /* QMUIMultipleDelegates.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUIMultipleDelegates.m; sourceTree = ""; }; CD82C0B1206A82520046EED2 /* NSObject+QMUIMultipleDelegates.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSObject+QMUIMultipleDelegates.h"; sourceTree = ""; }; @@ -612,6 +652,10 @@ CDC86FBA1F68D5F9000E8829 /* QMUINavigationController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUINavigationController.m; sourceTree = ""; }; CDC86FBB1F68D5F9000E8829 /* QMUITabBarViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUITabBarViewController.h; sourceTree = ""; }; CDC86FBC1F68D5F9000E8829 /* QMUITabBarViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUITabBarViewController.m; sourceTree = ""; }; + CDCD27012B8E0B6200D3500A /* QMUISheetPresentationSupports.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = QMUISheetPresentationSupports.m; sourceTree = ""; }; + CDCD27022B8E0B6200D3500A /* QMUISheetPresentationSupports.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = QMUISheetPresentationSupports.h; sourceTree = ""; }; + CDCD27052B8E0C2700D3500A /* QMUISheetPresentationNavigationBar.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUISheetPresentationNavigationBar.h; sourceTree = ""; }; + CDCD27062B8E0C2700D3500A /* QMUISheetPresentationNavigationBar.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUISheetPresentationNavigationBar.m; sourceTree = ""; }; CDD071FB2060F82700343AB6 /* QMUICellHeightCache.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = QMUICellHeightCache.h; sourceTree = ""; }; CDD071FC2060F82700343AB6 /* QMUICellHeightCache.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = QMUICellHeightCache.m; sourceTree = ""; }; CDD12D3A1FBB320E00114EA9 /* NSArray+QMUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSArray+QMUI.h"; sourceTree = ""; }; @@ -620,11 +664,6 @@ CDD759A622BBE68600BC8F36 /* CAAnimation+QMUI.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "CAAnimation+QMUI.m"; sourceTree = ""; }; CDD759A722BBE68600BC8F36 /* CAAnimation+QMUI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "CAAnimation+QMUI.h"; sourceTree = ""; }; CDD7C0D3212300A000D6FA1E /* QMUIRuntime.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUIRuntime.h; sourceTree = ""; }; - CDD7C2B4212C4E0600D6FA1E /* QMUIPopupMenuItemProtocol.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUIPopupMenuItemProtocol.h; sourceTree = ""; }; - CDD7C2B6212C4E7400D6FA1E /* QMUIPopupMenuBaseItem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUIPopupMenuBaseItem.h; sourceTree = ""; }; - CDD7C2B7212C4E7400D6FA1E /* QMUIPopupMenuBaseItem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUIPopupMenuBaseItem.m; sourceTree = ""; }; - CDD7C2BA212C510B00D6FA1E /* QMUIPopupMenuButtonItem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUIPopupMenuButtonItem.h; sourceTree = ""; }; - CDD7C2BB212C510B00D6FA1E /* QMUIPopupMenuButtonItem.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QMUIPopupMenuButtonItem.m; sourceTree = ""; }; CDD7C2BE212C528400D6FA1E /* QMUIPopupMenuView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = QMUIPopupMenuView.m; sourceTree = ""; }; CDD7C2BF212C528400D6FA1E /* QMUIPopupMenuView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = QMUIPopupMenuView.h; sourceTree = ""; }; CDE418F920761A0F002ED021 /* UIBarItem+QMUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIBarItem+QMUI.h"; sourceTree = ""; }; @@ -637,6 +676,7 @@ CDEA6D071F4B07E700F627AF /* UIGestureRecognizer+QMUI.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIGestureRecognizer+QMUI.m"; sourceTree = ""; }; CDF2D69A207F7E3F009E04DD /* NSPointerArray+QMUI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSPointerArray+QMUI.h"; sourceTree = ""; }; CDF2D69B207F7E3F009E04DD /* NSPointerArray+QMUI.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSPointerArray+QMUI.m"; sourceTree = ""; }; + CDFCDD9E2B43FF07005E1219 /* QMUILayouter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QMUILayouter.h; sourceTree = ""; }; CDFE9574293FB1DE007AE1AA /* QMUIKit.podspec */ = {isa = PBXFileReference; lastKnownFileType = text; path = QMUIKit.podspec; sourceTree = SOURCE_ROOT; }; CDFF5FB52369926300B63B92 /* Photos.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Photos.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.15.sdk/System/Library/Frameworks/Photos.framework; sourceTree = DEVELOPER_DIR; }; D00881752677B5870061CABF /* UIButtonTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UIButtonTests.m; sourceTree = ""; }; @@ -912,6 +952,7 @@ isa = PBXGroup; children = ( CDB8CA2E1DCC870700769DF0 /* Info.plist */, + 3CB960C32BB40725005626A6 /* PrivacyInfo.xcprivacy */, CDC86F3F1F68D5F9000E8829 /* QMUIComponents */, CDC86FAC1F68D5F9000E8829 /* QMUICore */, CDB8CA2F1DCC870700769DF0 /* QMUIKit.h */, @@ -934,6 +975,8 @@ CDB8CA791DCC870700769DF0 /* NSAttributedString+QMUI.m */, CDA4083C214F7E2500740888 /* NSCharacterSet+QMUI.h */, CDA4083D214F7E2500740888 /* NSCharacterSet+QMUI.m */, + CD8125282A69CB5900132EC7 /* NSDictionary+QMUI.h */, + CD8125292A69CB5900132EC7 /* NSDictionary+QMUI.m */, CD4EA4BD2275FA0100A55066 /* NSMethodSignature+QMUI.h */, CD4EA4BE2275FA0100A55066 /* NSMethodSignature+QMUI.m */, CD1817E32010CC4000F8CDEC /* NSNumber+QMUI.h */, @@ -944,6 +987,8 @@ CDB8CA7D1DCC870700769DF0 /* NSParagraphStyle+QMUI.m */, CDF2D69A207F7E3F009E04DD /* NSPointerArray+QMUI.h */, CDF2D69B207F7E3F009E04DD /* NSPointerArray+QMUI.m */, + CD5E431F2B85F71F0030CFDA /* NSRegularExpression+QMUI.h */, + CD5E43202B85F71F0030CFDA /* NSRegularExpression+QMUI.m */, CD96A2B728C74CCA00E87728 /* NSShadow+QMUI.h */, CD96A2B828C74CCA00E87728 /* NSShadow+QMUI.m */, CDB8CA7E1DCC870700769DF0 /* NSString+QMUI.h */, @@ -1066,6 +1111,8 @@ CDD071FC2060F82700343AB6 /* QMUICellHeightCache.m */, CD6BE1472058C61000BE093E /* QMUICellHeightKeyCache */, D021DE34205E801100FFA408 /* QMUICellSizeKeyCache */, + CD60DB4F2C5BC5D1005109B3 /* QMUICheckbox.h */, + CD60DB502C5BC5D1005109B3 /* QMUICheckbox.m */, CDC86F5D1F68D5F9000E8829 /* QMUICollectionViewPagingLayout.h */, CDC86F5E1F68D5F9000E8829 /* QMUICollectionViewPagingLayout.m */, CD8AA7A821E8B9D600BA7369 /* QMUIConsole */, @@ -1086,6 +1133,7 @@ CDC86F6E1F68D5F9000E8829 /* QMUIKeyboardManager.m */, CDC86F6F1F68D5F9000E8829 /* QMUILabel.h */, CDC86F701F68D5F9000E8829 /* QMUILabel.m */, + CDFCDD9D2B43FE41005E1219 /* QMUILayouter */, CD046C3E2018665F00092035 /* QMUILog */, CD9D6E6C210C8DEC0004E222 /* QMUILogger+QMUIConfigurationTemplate.h */, CD9D6E6D210C8DEC0004E222 /* QMUILogger+QMUIConfigurationTemplate.m */, @@ -1114,6 +1162,7 @@ CDC86F861F68D5F9000E8829 /* QMUISearchController.m */, CDC86F871F68D5F9000E8829 /* QMUISegmentedControl.h */, CDC86F881F68D5F9000E8829 /* QMUISegmentedControl.m */, + CDCD27002B8E0B6200D3500A /* QMUISheetPresentation */, CDC86F8B1F68D5F9000E8829 /* QMUITableView.h */, CDC86F8C1F68D5F9000E8829 /* QMUITableView.m */, CDC86F8D1F68D5F9000E8829 /* QMUITableViewCell.h */, @@ -1243,20 +1292,45 @@ path = QMUIMainFrame; sourceTree = ""; }; + CDCD27002B8E0B6200D3500A /* QMUISheetPresentation */ = { + isa = PBXGroup; + children = ( + CDCD27052B8E0C2700D3500A /* QMUISheetPresentationNavigationBar.h */, + CDCD27062B8E0C2700D3500A /* QMUISheetPresentationNavigationBar.m */, + CDCD27022B8E0B6200D3500A /* QMUISheetPresentationSupports.h */, + CDCD27012B8E0B6200D3500A /* QMUISheetPresentationSupports.m */, + ); + path = QMUISheetPresentation; + sourceTree = ""; + }; CDD7C2B3212C4DED00D6FA1E /* QMUIPopupMenuView */ = { isa = PBXGroup; children = ( - CDD7C2B6212C4E7400D6FA1E /* QMUIPopupMenuBaseItem.h */, - CDD7C2B7212C4E7400D6FA1E /* QMUIPopupMenuBaseItem.m */, - CDD7C2BA212C510B00D6FA1E /* QMUIPopupMenuButtonItem.h */, - CDD7C2BB212C510B00D6FA1E /* QMUIPopupMenuButtonItem.m */, - CDD7C2B4212C4E0600D6FA1E /* QMUIPopupMenuItemProtocol.h */, CDD7C2BF212C528400D6FA1E /* QMUIPopupMenuView.h */, CDD7C2BE212C528400D6FA1E /* QMUIPopupMenuView.m */, + CD4002192C1F6BB0003D2127 /* QMUIPopupMenuItem.h */, + CD40021A2C1F6BB0003D2127 /* QMUIPopupMenuItem.m */, + CD40021D2C1F6E1C003D2127 /* QMUIPopupMenuItemViewProtocol.h */, + CD40021F2C1F81CE003D2127 /* QMUIPopupMenuItemView.h */, + CD4002202C1F81CE003D2127 /* QMUIPopupMenuItemView.m */, ); path = QMUIPopupMenuView; sourceTree = ""; }; + CDFCDD9D2B43FE41005E1219 /* QMUILayouter */ = { + isa = PBXGroup; + children = ( + CDFCDD9E2B43FF07005E1219 /* QMUILayouter.h */, + CD72E7BF2B440DF000AC528A /* QMUILayouterItem.h */, + CD72E7C02B440DF000AC528A /* QMUILayouterItem.m */, + CD72E7C52B44AF2F00AC528A /* QMUILayouterLinearHorizontal.h */, + CD72E7C62B44AF2F00AC528A /* QMUILayouterLinearHorizontal.m */, + CD72E7C92B44AF8800AC528A /* QMUILayouterLinearVertical.h */, + CD72E7CA2B44AF8800AC528A /* QMUILayouterLinearVertical.m */, + ); + path = QMUILayouter; + sourceTree = ""; + }; D021DE34205E801100FFA408 /* QMUICellSizeKeyCache */ = { isa = PBXGroup; children = ( @@ -1271,6 +1345,8 @@ D0D0D81720C2B95A000A33D8 /* QMUIBadge */ = { isa = PBXGroup; children = ( + CD2B196F2A715D6200E8ED18 /* QMUIBadgeLabel.h */, + CD2B19702A715D6200E8ED18 /* QMUIBadgeLabel.m */, D0BEFA99247D427A0006D1B9 /* QMUIBadgeProtocol.h */, D0D0D81820C2B973000A33D8 /* UIBarItem+QMUIBadge.h */, D0D0D81920C2B973000A33D8 /* UIBarItem+QMUIBadge.m */, @@ -1307,6 +1383,11 @@ isa = PBXHeadersBuildPhase; buildActionMask = 2147483647; files = ( + CD60DB512C5BC5D1005109B3 /* QMUICheckbox.h in Headers */, + CD40021E2C1F6E1C003D2127 /* QMUIPopupMenuItemViewProtocol.h in Headers */, + CD4002222C1F81CE003D2127 /* QMUIPopupMenuItemView.h in Headers */, + CD40021C2C1F6BB0003D2127 /* QMUIPopupMenuItem.h in Headers */, + CD81252A2A69CB5900132EC7 /* NSDictionary+QMUI.h in Headers */, CD96A2B928C74CCA00E87728 /* NSShadow+QMUI.h in Headers */, CD513E2D283527CE004A549D /* UITabBar+QMUIBarProtocol.h in Headers */, CD513E31283527DB004A549D /* UINavigationBar+QMUIBarProtocol.h in Headers */, @@ -1343,6 +1424,7 @@ CD046C452018670900092035 /* QMUILogNameManager.h in Headers */, CD046C412018668900092035 /* QMUILogItem.h in Headers */, CD046C492018688F00092035 /* QMUILogger.h in Headers */, + CD5E43212B85F7200030CFDA /* NSRegularExpression+QMUI.h in Headers */, CD8AA7AF21E8BF0B00BA7369 /* QMUIConsoleToolbar.h in Headers */, CD8AA7AB21E8B9D600BA7369 /* QMUIConsole.h in Headers */, CD19F4D821E4AB3900BD4687 /* QMUILab.h in Headers */, @@ -1358,9 +1440,6 @@ CD0A1BAA273512D5002A1A54 /* QMUIStringPrivate.h in Headers */, CDA4083E214F7E2500740888 /* NSCharacterSet+QMUI.h in Headers */, CDD7C2C1212C528500D6FA1E /* QMUIPopupMenuView.h in Headers */, - CDD7C2BC212C510B00D6FA1E /* QMUIPopupMenuButtonItem.h in Headers */, - CDD7C2B8212C4E7400D6FA1E /* QMUIPopupMenuBaseItem.h in Headers */, - CDD7C2B5212C4E0600D6FA1E /* QMUIPopupMenuItemProtocol.h in Headers */, CDD7C0D4212300A000D6FA1E /* QMUIRuntime.h in Headers */, CD9D6E6E210C8DEC0004E222 /* QMUILogger+QMUIConfigurationTemplate.h in Headers */, CD18CDFE20EE167200EED53C /* UITableViewCell+QMUI.h in Headers */, @@ -1399,6 +1478,13 @@ CDC86FC01F68D617000E8829 /* QMUIAlbumViewController.h in Headers */, CDC86FC11F68D617000E8829 /* QMUIImagePickerCollectionViewCell.h in Headers */, CDC86FC21F68D617000E8829 /* QMUIImagePickerHelper.h in Headers */, + CDCD27072B8E0C2700D3500A /* QMUISheetPresentationNavigationBar.h in Headers */, + CDCD27042B8E0B6200D3500A /* QMUISheetPresentationSupports.h in Headers */, + CD72E7C72B44AF2F00AC528A /* QMUILayouterLinearHorizontal.h in Headers */, + CD72E7CB2B44AF8800AC528A /* QMUILayouterLinearVertical.h in Headers */, + CD72E7C12B440DF000AC528A /* QMUILayouterItem.h in Headers */, + CDFCDDA02B43FF07005E1219 /* QMUILayouter.h in Headers */, + CD2B19712A715D6200E8ED18 /* QMUIBadgeLabel.h in Headers */, CDC86FC31F68D617000E8829 /* QMUIImagePickerPreviewViewController.h in Headers */, CDC86FC41F68D617000E8829 /* QMUIImagePickerViewController.h in Headers */, CDC86FC61F68D617000E8829 /* UINavigationController+NavigationBarTransition.h in Headers */, @@ -1568,6 +1654,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3CB960C42BB40725005626A6 /* PrivacyInfo.xcprivacy in Resources */, CD0BD68B234F6C34005E47CE /* Images.xcassets in Resources */, CDFE9575293FB1DE007AE1AA /* QMUIKit.podspec in Resources */, ); @@ -1619,17 +1706,17 @@ CD349BAE2160AF75008653D4 /* QMUIScrollAnimator.m in Sources */, CDB8CB601DCC870700769DF0 /* NSParagraphStyle+QMUI.m in Sources */, CDB8CBB81DCC870800769DF0 /* UIImage+QMUI.m in Sources */, + CD81252B2A69CB5900132EC7 /* NSDictionary+QMUI.m in Sources */, + CD72E7C82B44AF2F00AC528A /* QMUILayouterLinearHorizontal.m in Sources */, CDB8CB581DCC870700769DF0 /* NSAttributedString+QMUI.m in Sources */, CDB8CB5C1DCC870700769DF0 /* NSObject+QMUI.m in Sources */, CDB8CBA41DCC870700769DF0 /* UIButton+QMUI.m in Sources */, - CDD7C2B9212C4E7400D6FA1E /* QMUIPopupMenuBaseItem.m in Sources */, CDB8CBD81DCC870800769DF0 /* UITableView+QMUI.m in Sources */, CD82C0B0206A2C3D0046EED2 /* QMUIMultipleDelegates.m in Sources */, CDD7C2C0212C528500D6FA1E /* QMUIPopupMenuView.m in Sources */, CD18CDFF20EE167200EED53C /* UITableViewCell+QMUI.m in Sources */, CDB8CBE41DCC870800769DF0 /* UIWindow+QMUI.m in Sources */, CD43CB18207B98A10090346B /* QMUIButton.m in Sources */, - CDD7C2BD212C510B00D6FA1E /* QMUIPopupMenuButtonItem.m in Sources */, CD0A1BAB273512D5002A1A54 /* QMUIStringPrivate.m in Sources */, CD6BE14F2058C64E00BE093E /* QMUICellHeightKeyCache.m in Sources */, CDA4083F214F7E2500740888 /* NSCharacterSet+QMUI.m in Sources */, @@ -1645,6 +1732,7 @@ CDEA6D0A1F4B07E700F627AF /* UIGestureRecognizer+QMUI.m in Sources */, CDB8CBB41DCC870800769DF0 /* UIFont+QMUI.m in Sources */, CDB8CBD01DCC870800769DF0 /* UISearchBar+QMUI.m in Sources */, + CD4002212C1F81CE003D2127 /* QMUIPopupMenuItemView.m in Sources */, CD84F31F1E52DBEA00546111 /* UITabBar+QMUI.m in Sources */, FE1FBCB11E8BA79000C6C01A /* UITextView+QMUI.m in Sources */, CD745E3321CA5BBB006EC132 /* QMUIImagePreviewViewTransitionAnimator.m in Sources */, @@ -1654,7 +1742,9 @@ CD9D6E6F210C8DEC0004E222 /* QMUILogger+QMUIConfigurationTemplate.m in Sources */, CDAA653B22BBC3340004C6BB /* QMUIThemeManager.m in Sources */, CDB8CBA01DCC870700769DF0 /* UIBezierPath+QMUI.m in Sources */, + CD60DB522C5BC5D1005109B3 /* QMUICheckbox.m in Sources */, CD96A2BA28C74CCA00E87728 /* NSShadow+QMUI.m in Sources */, + CD72E7CC2B44AF8800AC528A /* QMUILayouterLinearVertical.m in Sources */, CDB8CBB01DCC870800769DF0 /* UIControl+QMUI.m in Sources */, D032060F2488E38900BB28E7 /* UITableViewHeaderFooterView+QMUI.m in Sources */, D021DE38205E809500FFA408 /* UICollectionView+QMUICellSizeKeyCache.m in Sources */, @@ -1692,8 +1782,10 @@ CDC870091F68D63B000E8829 /* QMUIEmotionView.m in Sources */, CDC8700A1F68D63B000E8829 /* QMUIEmptyView.m in Sources */, CDC8700B1F68D63B000E8829 /* QMUIFloatLayoutView.m in Sources */, + CD40021B2C1F6BB0003D2127 /* QMUIPopupMenuItem.m in Sources */, FECD352322BBC3BB00DC69DE /* QMUIAnimationHelper.m in Sources */, CD046C422018668900092035 /* QMUILogItem.m in Sources */, + CD5E43222B85F7200030CFDA /* NSRegularExpression+QMUI.m in Sources */, D0BEFA98247D42510006D1B9 /* UIView+QMUIBadge.m in Sources */, D033BC102549A32D00674526 /* UINavigationItem+QMUI.m in Sources */, CDC8700C1F68D63B000E8829 /* QMUIGridView.m in Sources */, @@ -1717,6 +1809,7 @@ CDC870161F68D63B000E8829 /* QMUIPieProgressView.m in Sources */, CDC870171F68D63B000E8829 /* QMUIPopupContainerView.m in Sources */, CDE77518274FB9430066A767 /* UIBlurEffect+QMUI.m in Sources */, + CDCD27082B8E0C2700D3500A /* QMUISheetPresentationNavigationBar.m in Sources */, CDC163C7204D441000E4CC13 /* QMUILogManagerViewController.m in Sources */, CDC870191F68D63B000E8829 /* QMUIEmotionInputManager.m in Sources */, CD669A0E25F79DA40036D6B2 /* UICollectionViewCell+QMUI.m in Sources */, @@ -1730,6 +1823,7 @@ CDC8701E1F68D63B000E8829 /* QMUITableView.m in Sources */, CDC8701F1F68D63B000E8829 /* QMUITableViewCell.m in Sources */, CDC870201F68D63B000E8829 /* QMUITestView.m in Sources */, + CDCD27032B8E0B6200D3500A /* QMUISheetPresentationSupports.m in Sources */, CDC870211F68D63B000E8829 /* QMUITextField.m in Sources */, CDD759A822BBE68900BC8F36 /* CAAnimation+QMUI.m in Sources */, FECD352B22BBC93500DC69DE /* QMUIWindowSizeMonitor.m in Sources */, @@ -1739,6 +1833,7 @@ CDAB2D272357481700C96B31 /* UITextInputTraits+QMUI.m in Sources */, FE8710FD22E499EC00DF1354 /* UIMenuController+QMUI.m in Sources */, 083551AA2438C0D000B8FEAB /* CALayer+QMUIViewAnimation.m in Sources */, + CD72E7C22B440DF000AC528A /* QMUILayouterItem.m in Sources */, D09D4BDC24BF1561002D29FF /* UIVisualEffectView+QMUI.m in Sources */, D062F65F22BD0DBD00737AD2 /* UIView+QMUITheme.m in Sources */, CDC870261F68D63B000E8829 /* QMUIStaticTableViewCellData.m in Sources */, @@ -1748,6 +1843,7 @@ CD745E2F21CA5B8F006EC132 /* QMUIImagePreviewView.m in Sources */, 08230CED233D285B00BF9CB1 /* UISearchController+QMUI.m in Sources */, CDC870281F68D63B000E8829 /* UITableView+QMUIStaticCell.m in Sources */, + CD2B19722A715D6200E8ED18 /* QMUIBadgeLabel.m in Sources */, CDC870291F68D63B000E8829 /* QMUIToastAnimator.m in Sources */, D0D0D81B20C2B973000A33D8 /* UIBarItem+QMUIBadge.m in Sources */, CDC8702A1F68D63B000E8829 /* QMUIToastBackgroundView.m in Sources */, @@ -1973,7 +2069,7 @@ "@loader_path/Frameworks", ); MACH_O_TYPE = mh_dylib; - MARKETING_VERSION = 4.6.3; + MARKETING_VERSION = 4.8.0; MTL_ENABLE_DEBUG_INFO = YES; PRODUCT_BUNDLE_IDENTIFIER = com.qmui.QMUIKit; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2017,7 +2113,7 @@ "@loader_path/Frameworks", ); MACH_O_TYPE = mh_dylib; - MARKETING_VERSION = 4.6.3; + MARKETING_VERSION = 4.8.0; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = com.qmui.QMUIKit; PRODUCT_NAME = "$(TARGET_NAME)";