/** * Tencent is pleased to support the open source community by making QMUI_iOS available. * Copyright (C) 2016-2020 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. */ // // UISearchBar+QMUI.m // qmui // // Created by QMUI Team on 16/5/26. // #import "UISearchBar+QMUI.h" #import "QMUICore.h" #import "UIImage+QMUI.h" #import "UIView+QMUI.h" @implementation UISearchBar (QMUI) QMUISynthesizeBOOLProperty(qmui_usedAsTableHeaderView, setQmui_usedAsTableHeaderView) QMUISynthesizeUIEdgeInsetsProperty(qmui_textFieldMargins, setQmui_textFieldMargins) QMUISynthesizeBOOLProperty(qmui_fixMaskViewLayoutBugAutomatically, setQmui_fixMaskViewLayoutBugAutomatically) + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ ExtendImplementationOfVoidMethodWithTwoArguments([UISearchBar class], @selector(setShowsCancelButton:animated:), BOOL, BOOL, ^(UISearchBar *selfObject, BOOL firstArgv, BOOL secondArgv) { if (selfObject.qmui_cancelButton && selfObject.qmui_cancelButtonFont) { selfObject.qmui_cancelButton.titleLabel.font = selfObject.qmui_cancelButtonFont; } }); ExtendImplementationOfVoidMethodWithSingleArgument([UISearchBar class], @selector(setPlaceholder:), NSString *, (^(UISearchBar *selfObject, NSString *placeholder) { if (selfObject.qmui_placeholderColor || selfObject.qmui_font) { NSMutableAttributedString *string = selfObject.qmui_textField.attributedPlaceholder.mutableCopy; if (selfObject.qmui_placeholderColor) { [string addAttribute:NSForegroundColorAttributeName value:selfObject.qmui_placeholderColor range:NSMakeRange(0, string.length)]; } if (selfObject.qmui_font) { [string addAttribute:NSFontAttributeName value:selfObject.qmui_font range:NSMakeRange(0, string.length)]; } // 默认移除文字阴影 [string removeAttribute:NSShadowAttributeName range:NSMakeRange(0, string.length)]; selfObject.qmui_textField.attributedPlaceholder = string.copy; } })); // iOS 13 下,UISearchBar 内的 UITextField 的 _placeholderLabel 会在 didMoveToWindow 时被重新设置 textColor,导致我们在 searchBar 添加到界面之前设置的 placeholderColor 失效,所以在这里重新设置一遍 // https://github.com/Tencent/QMUI_iOS/issues/830 if (@available(iOS 13.0, *)) { ExtendImplementationOfVoidMethodWithoutArguments([UISearchBar class], @selector(didMoveToWindow), ^(UISearchBar *selfObject) { if (selfObject.qmui_placeholderColor) { selfObject.placeholder = selfObject.placeholder; } }); } if (@available(iOS 13.0, *)) { // -[_UISearchBarLayout applyLayout] 是 iOS 13 系统新增的方法,该方法可能会在 -[UISearchBar layoutSubviews] 后调用,作进一步的布局调整。 Class _UISearchBarLayoutClass = NSClassFromString([NSString stringWithFormat:@"_%@%@",@"UISearchBar", @"Layout"]); OverrideImplementation(_UISearchBarLayoutClass, NSSelectorFromString(@"applyLayout"), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { return ^(UIView *selfObject) { // call super void (^callSuperBlock)(void) = ^{ void (*originSelectorIMP)(id, SEL); originSelectorIMP = (void (*)(id, SEL))originalIMPProvider(); originSelectorIMP(selfObject, originCMD); }; UISearchBar *searchBar = (UISearchBar *)((UIView *)[selfObject qmui_valueForKey:[NSString stringWithFormat:@"_%@",@"searchBarBackground"]]).superview.superview; NSAssert(searchBar == nil || [searchBar isKindOfClass:[UISearchBar class]], @"not a searchBar"); if (searchBar && searchBar.qmui_searchController.isBeingDismissed && searchBar.qmui_usedAsTableHeaderView) { CGRect previousRect = searchBar.qmui_backgroundView.frame; callSuperBlock(); // applyLayout 方法中会修改 _searchBarBackground 的 frame ,从而覆盖掉 qmui_usedAsTableHeaderView 做出的调整,所以这里还原本次修改。 searchBar.qmui_backgroundView.frame = previousRect; } else { callSuperBlock(); } }; }); // iOS 13 后,cancelButton 的 frame 由 -[_UISearchBarSearchContainerView layoutSubviews] 去修改 Class _UISearchBarSearchContainerViewClass = NSClassFromString([NSString stringWithFormat:@"_%@%@",@"UISearchBarSearch", @"ContainerView"]); ExtendImplementationOfVoidMethodWithoutArguments(_UISearchBarSearchContainerViewClass, @selector(layoutSubviews), ^(UIView *selfObject) { UISearchBar *searchBar = (UISearchBar *)selfObject.superview.superview; NSAssert(searchBar == nil || [searchBar isKindOfClass:[UISearchBar class]], @"not a searchBar"); [searchBar qmui_adjustCancelButtonFrameIfNeeded]; }); } Class UISearchBarTextFieldClass = NSClassFromString([NSString stringWithFormat:@"%@%@",@"UISearchBarText", @"Field"]); OverrideImplementation(UISearchBarTextFieldClass, @selector(setFrame:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { return ^(UITextField *textField, CGRect frame) { UISearchBar *searchBar = nil; if (@available(iOS 13.0, *)) { searchBar = (UISearchBar *)textField.superview.superview.superview; } else { searchBar = (UISearchBar *)textField.superview.superview; } NSAssert(searchBar == nil || [searchBar isKindOfClass:[UISearchBar class]], @"not a searchBar"); if (searchBar) { frame = [searchBar qmui_adjustedSearchTextFieldFrameByOriginalFrame:frame]; } void (*originSelectorIMP)(id, SEL, CGRect); originSelectorIMP = (void (*)(id, SEL, CGRect))originalIMPProvider(); originSelectorIMP(textField, originCMD, frame); [searchBar qmui_searchTextFieldFrameDidChange]; }; }); ExtendImplementationOfVoidMethodWithoutArguments([UISearchBar class], @selector(layoutSubviews), ^(UISearchBar *selfObject) { // 修复 iOS 13 backgroundView 没有撑开到顶部的问题 if (IOS_VERSION >= 13.0 && selfObject.qmui_usedAsTableHeaderView && selfObject.qmui_isActive) { selfObject.qmui_backgroundView.qmui_height = StatusBarHeightConstant + selfObject.qmui_height; selfObject.qmui_backgroundView.qmui_top = -StatusBarHeightConstant; } [selfObject qmui_adjustCancelButtonFrameIfNeeded]; [selfObject qmui_fixDismissingAnimationIfNeeded]; [selfObject qmui_fixSearchResultsScrollViewContentInsetIfNeeded]; }); OverrideImplementation([UISearchBar class], @selector(setFrame:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { return ^(UISearchBar *selfObject, CGRect frame) { frame = [selfObject qmui_adjustedSearchBarFrameByOriginalFrame:frame]; // call super void (*originSelectorIMP)(id, SEL, CGRect); originSelectorIMP = (void (*)(id, SEL, CGRect))originalIMPProvider(); originSelectorIMP(selfObject, originCMD, frame); }; }); // [UIKit Bug] 当 UISearchController.searchBar 作为 tableHeaderView 使用时,顶部可能出现 1px 的间隙导致露出背景色 // https://github.com/Tencent/QMUI_iOS/issues/950 OverrideImplementation([UISearchBar class], NSSelectorFromString(@"_setMaskBounds:"), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) { return ^(UISearchBar *selfObject, CGRect firstArgv) { BOOL shouldFixBug = selfObject.qmui_fixMaskViewLayoutBugAutomatically && selfObject.qmui_searchController && [selfObject.superview isKindOfClass:UITableView.class] && ((UITableView *)selfObject.superview).tableHeaderView == selfObject; if (shouldFixBug) { firstArgv = CGRectMake(CGRectGetMinX(firstArgv), CGRectGetMinY(firstArgv) - PixelOne, CGRectGetWidth(firstArgv), CGRectGetHeight(firstArgv) + PixelOne); } // call super void (*originSelectorIMP)(id, SEL, CGRect); originSelectorIMP = (void (*)(id, SEL, CGRect))originalIMPProvider(); originSelectorIMP(selfObject, originCMD, firstArgv); }; }); ExtendImplementationOfNonVoidMethodWithSingleArgument([UISearchBar class], @selector(initWithFrame:), CGRect, UISearchBar *, ^UISearchBar *(UISearchBar *selfObject, CGRect firstArgv, UISearchBar *originReturnValue) { if (QMUICMIActivated && ShouldFixSearchBarMaskViewLayoutBug) { originReturnValue.qmui_fixMaskViewLayoutBugAutomatically = YES; } return originReturnValue; }); ExtendImplementationOfNonVoidMethodWithSingleArgument([UISearchBar class], @selector(initWithCoder:), NSCoder *, UISearchBar *, ^UISearchBar *(UISearchBar *selfObject, NSCoder *firstArgv, UISearchBar *originReturnValue) { if (QMUICMIActivated && ShouldFixSearchBarMaskViewLayoutBug) { originReturnValue.qmui_fixMaskViewLayoutBugAutomatically = YES; } return originReturnValue; }); }); } static char kAssociatedObjectKey_PlaceholderColor; - (void)setQmui_placeholderColor:(UIColor *)qmui_placeholderColor { objc_setAssociatedObject(self, &kAssociatedObjectKey_PlaceholderColor, qmui_placeholderColor, OBJC_ASSOCIATION_RETAIN_NONATOMIC); if (self.placeholder) { // 触发 setPlaceholder 里更新 placeholder 样式的逻辑 self.placeholder = self.placeholder; } } - (UIColor *)qmui_placeholderColor { return (UIColor *)objc_getAssociatedObject(self, &kAssociatedObjectKey_PlaceholderColor); } static char kAssociatedObjectKey_TextColor; - (void)setQmui_textColor:(UIColor *)qmui_textColor { objc_setAssociatedObject(self, &kAssociatedObjectKey_TextColor, qmui_textColor, OBJC_ASSOCIATION_RETAIN_NONATOMIC); self.qmui_textField.textColor = qmui_textColor; } - (UIColor *)qmui_textColor { return (UIColor *)objc_getAssociatedObject(self, &kAssociatedObjectKey_TextColor); } static char kAssociatedObjectKey_font; - (void)setQmui_font:(UIFont *)qmui_font { objc_setAssociatedObject(self, &kAssociatedObjectKey_font, qmui_font, OBJC_ASSOCIATION_RETAIN_NONATOMIC); if (self.placeholder) { // 触发 setPlaceholder 里更新 placeholder 样式的逻辑 self.placeholder = self.placeholder; } // 更新输入框的文字样式 self.qmui_textField.font = qmui_font; } - (UIFont *)qmui_font { return (UIFont *)objc_getAssociatedObject(self, &kAssociatedObjectKey_font); } - (UITextField *)qmui_textField { UITextField *textField = [self qmui_valueForKey:@"searchField"]; return textField; } - (UIButton *)qmui_cancelButton { UIButton *cancelButton = [self qmui_valueForKey:@"cancelButton"]; return cancelButton; } static char kAssociatedObjectKey_cancelButtonFont; - (void)setQmui_cancelButtonFont:(UIFont *)qmui_cancelButtonFont { objc_setAssociatedObject(self, &kAssociatedObjectKey_cancelButtonFont, qmui_cancelButtonFont, OBJC_ASSOCIATION_RETAIN_NONATOMIC); self.qmui_cancelButton.titleLabel.font = qmui_cancelButtonFont; } - (UIFont *)qmui_cancelButtonFont { return (UIFont *)objc_getAssociatedObject(self, &kAssociatedObjectKey_cancelButtonFont); } - (UISegmentedControl *)qmui_segmentedControl { // 注意,segmentedControl 只是整条 scopeBar 里的一部分,虽然它的 key 叫做“scopeBar” UISegmentedControl *segmentedControl = [self qmui_valueForKey:@"scopeBar"]; return segmentedControl; } - (BOOL)qmui_isActive { return (self.qmui_searchController.isBeingPresented || self.qmui_searchController.isActive); } - (UISearchController *)qmui_searchController { return [self qmui_valueForKey:@"_searchController"]; } - (UIView *)qmui_backgroundView { BeginIgnorePerformSelectorLeaksWarning UIView *backgroundView = [self performSelector:NSSelectorFromString(@"_backgroundView")]; EndIgnorePerformSelectorLeaksWarning return backgroundView; } - (void)qmui_styledAsQMUISearchBar { if (!QMUICMIActivated) { return; } // 搜索框的字号及 placeholder 的字号 self.qmui_font = SearchBarFont; // 搜索框的文字颜色 self.qmui_textColor = SearchBarTextColor; // placeholder 的文字颜色 self.qmui_placeholderColor = SearchBarPlaceholderColor; self.placeholder = @"搜索"; self.autocorrectionType = UITextAutocorrectionTypeNo; self.autocapitalizationType = UITextAutocapitalizationTypeNone; // 设置搜索icon UIImage *searchIconImage = SearchBarSearchIconImage; if (searchIconImage) { if (!CGSizeEqualToSize(searchIconImage.size, CGSizeMake(14, 14))) { NSLog(@"搜索框放大镜图片(SearchBarSearchIconImage)的大小最好为 (14, 14),否则会失真,目前的大小为 %@", NSStringFromCGSize(searchIconImage.size)); } [self setImage:searchIconImage forSearchBarIcon:UISearchBarIconSearch state:UIControlStateNormal]; } // 设置搜索右边的清除按钮的icon UIImage *clearIconImage = SearchBarClearIconImage; if (clearIconImage) { [self setImage:clearIconImage forSearchBarIcon:UISearchBarIconClear state:UIControlStateNormal]; } // 设置SearchBar上的按钮颜色 self.tintColor = SearchBarTintColor; // 输入框背景图 UIImage *searchFieldBackgroundImage = SearchBarTextFieldBackgroundImage; if (searchFieldBackgroundImage) { [self setSearchFieldBackgroundImage:searchFieldBackgroundImage forState:UIControlStateNormal]; } // 输入框边框 UIColor *textFieldBorderColor = SearchBarTextFieldBorderColor; if (textFieldBorderColor) { self.qmui_textField.layer.borderWidth = PixelOne; self.qmui_textField.layer.borderColor = textFieldBorderColor.CGColor; } // 整条bar的背景 // 为了让 searchBar 底部的边框颜色支持修改,背景色不使用 barTintColor 的方式去改,而是用 backgroundImage UIImage *backgroundImage = SearchBarBackgroundImage; if (backgroundImage) { [self setBackgroundImage:backgroundImage forBarPosition:UIBarPositionAny barMetrics:UIBarMetricsDefault]; [self setBackgroundImage:backgroundImage forBarPosition:UIBarPositionAny barMetrics:UIBarMetricsDefaultPrompt]; } } + (UIImage *)qmui_generateTextFieldBackgroundImageWithColor:(UIColor *)color { // 背景图片的高度会决定输入框的高度,在 iOS 11 及以上,系统默认高度是 36,iOS 10 及以下的高度是 28 的搜索输入框的高度计算:QMUIKit/UIKitExtensions/UISearchBar+QMUI.m // 至于圆角,输入框会在 UIView 层面控制,背景图里无需处理 return [[UIImage qmui_imageWithColor:color size:self.qmui_textFieldDefaultSize cornerRadius:0] resizableImageWithCapInsets:UIEdgeInsetsMake(10, 10, 10, 10)]; } + (UIImage *)qmui_generateBackgroundImageWithColor:(UIColor *)backgroundColor borderColor:(UIColor *)borderColor { UIImage *backgroundImage = nil; if (backgroundColor || borderColor) { backgroundImage = [UIImage qmui_imageWithColor:backgroundColor ?: UIColorWhite size:CGSizeMake(10, 10) cornerRadius:0]; if (borderColor) { backgroundImage = [backgroundImage qmui_imageWithBorderColor:borderColor borderWidth:PixelOne borderPosition:QMUIImageBorderPositionBottom]; } backgroundImage = [backgroundImage resizableImageWithCapInsets:UIEdgeInsetsMake(1, 1, 1, 1)]; } return backgroundImage; } #pragma mark - Layout Fix - (BOOL)qmui_shouldFixLayoutWhenUsedAsTableHeaderView { if (@available(iOS 11, *)) { return self.qmui_usedAsTableHeaderView && self.qmui_searchController.hidesNavigationBarDuringPresentation; } return NO; } - (void)qmui_adjustCancelButtonFrameIfNeeded { if (!self.qmui_shouldFixLayoutWhenUsedAsTableHeaderView) return; if ([self qmui_isActive]) { CGRect textFieldFrame = self.qmui_textField.frame; self.qmui_cancelButton.qmui_top = CGRectGetMinYVerticallyCenter(textFieldFrame, self.qmui_cancelButton.frame); if (self.qmui_segmentedControl.superview.qmui_top < self.qmui_textField.qmui_bottom) { // scopeBar 显示在搜索框右边 self.qmui_segmentedControl.superview.qmui_top = CGRectGetMinYVerticallyCenter(textFieldFrame, self.qmui_segmentedControl.superview.frame); } } } - (CGRect)qmui_adjustedSearchBarFrameByOriginalFrame:(CGRect)frame { if (!self.qmui_shouldFixLayoutWhenUsedAsTableHeaderView) return frame; // 重写 setFrame: 是为了这个 issue:https://github.com/Tencent/QMUI_iOS/issues/233 // iOS 11 下用 tableHeaderView 的方式使用 searchBar 的话,进入搜索状态时 y 偏上了,导致间距错乱 // iOS 13 iPad 在退出动画时 y 值可能为负,需要修正 if (self.qmui_searchController.isBeingDismissed && CGRectGetMinY(frame) < 0) { frame = CGRectSetY(frame, 0); } if (![self qmui_isActive]) { return frame; } if (IS_NOTCHED_SCREEN) { // 竖屏 if (CGRectGetMinY(frame) == 38) { // searching frame = CGRectSetY(frame, 44); } // 全面屏 iPad if (CGRectGetMinY(frame) == 18) { // searching frame = CGRectSetY(frame, 24); } // 横屏 if (CGRectGetMinY(frame) == -6) { frame = CGRectSetY(frame, 0); } } else { // 竖屏 if (CGRectGetMinY(frame) == 14) { frame = CGRectSetY(frame, 20); } // 横屏 if (CGRectGetMinY(frame) == -6) { frame = CGRectSetY(frame, 0); } } // 强制在激活状态下 高度也为 56,方便后续做平滑过渡动画 (iOS 11 默认下,非刘海屏的机器激活后为 50,刘海屏激活后为 55) if (frame.size.height != 56) { frame.size.height = 56; } return frame; } - (CGRect)qmui_adjustedSearchTextFieldFrameByOriginalFrame:(CGRect)frame { if (self.qmui_shouldFixLayoutWhenUsedAsTableHeaderView) { if (self.qmui_searchController.isBeingPresented) { BOOL statusBarHidden = NO; if (@available(iOS 13.0, *)) { statusBarHidden = self.window.windowScene.statusBarManager.statusBarHidden; } else { statusBarHidden = UIApplication.sharedApplication.statusBarHidden; } CGFloat visibleHeight = statusBarHidden ? 56 : 50; frame.origin.y = (visibleHeight - self.qmui_textField.qmui_height) / 2; } else if (self.qmui_searchController.isBeingDismissed) { frame.origin.y = (56 - self.qmui_textField.qmui_height) / 2; } } // apply qmui_textFieldMargins if (!UIEdgeInsetsEqualToEdgeInsets(self.qmui_textFieldMargins, UIEdgeInsetsZero)) { frame = CGRectInsetEdges(frame, self.qmui_textFieldMargins); } return frame; } - (void)qmui_searchTextFieldFrameDidChange { // apply SearchBarTextFieldCornerRadius CGFloat textFieldCornerRadius = SearchBarTextFieldCornerRadius; if (textFieldCornerRadius != 0) { textFieldCornerRadius = textFieldCornerRadius > 0 ? textFieldCornerRadius : CGRectGetHeight(self.qmui_textField.frame) / 2.0; } self.qmui_textField.layer.cornerRadius = textFieldCornerRadius; self.qmui_textField.clipsToBounds = textFieldCornerRadius != 0; [self qmui_adjustCancelButtonFrameIfNeeded]; } - (void)qmui_fixDismissingAnimationIfNeeded { if (!self.qmui_shouldFixLayoutWhenUsedAsTableHeaderView) return; if (self.qmui_searchController.isBeingDismissed) { if (IS_NOTCHED_SCREEN && self.frame.origin.y == 43) { // 修复刘海屏下,系统计算少了一个 pt self.frame = CGRectSetY(self.frame, StatusBarHeightConstant); } UIView *searchBarContainerView = self.superview; // 每次激活搜索框,searchBarContainerView 都会重新创建一个 if (searchBarContainerView.layer.masksToBounds == YES) { searchBarContainerView.layer.masksToBounds = NO; // backgroundView 被 searchBarContainerView masksToBounds 裁减掉的底部。 CGFloat backgroundViewBottomClipped = CGRectGetMaxY([searchBarContainerView convertRect:self.qmui_backgroundView.frame fromView:self.qmui_backgroundView.superview]) - CGRectGetHeight(searchBarContainerView.bounds); // UISeachbar 取消激活时,如果 BackgroundView 底部超出了 searchBarContainerView,需要以动画的形式来过渡: if (backgroundViewBottomClipped > 0) { CGFloat previousHeight = self.qmui_backgroundView.qmui_height; [UIView performWithoutAnimation:^{ // 先减去 backgroundViewBottomClipped 使得 backgroundView 和 searchBarContainerView 底部对齐,由于这个时机是包裹在 animationBlock 里的,所以要包裹在 performWithoutAnimation 中来设置 self.qmui_backgroundView.qmui_height -= backgroundViewBottomClipped; }]; // 再还原高度,这里在 animationBlock 中,所以会以动画来过渡这个效果 self.qmui_backgroundView.qmui_height = previousHeight; // 以下代码为了保持原有的顶部的 mask,否则在 NavigationBar 为透明或者磨砂时,会看到 backgroundView CAShapeLayer *maskLayer = [CAShapeLayer layer]; CGMutablePathRef path = CGPathCreateMutable(); CGPathAddRect(path, NULL, CGRectMake(0, 0, searchBarContainerView.qmui_width, previousHeight)); maskLayer.path = path; searchBarContainerView.layer.mask = maskLayer; } } } } - (void)qmui_fixSearchResultsScrollViewContentInsetIfNeeded { if (!self.qmui_shouldFixLayoutWhenUsedAsTableHeaderView) return; if (self.qmui_isActive) { UIViewController *searchResultsController = self.qmui_searchController.searchResultsController; if (searchResultsController && [searchResultsController isViewLoaded]) { UIView *view = searchResultsController.view; UIScrollView *scrollView = [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); } } } } static CGSize textFieldDefaultSize; + (CGSize)qmui_textFieldDefaultSize { if (CGSizeIsEmpty(textFieldDefaultSize)) { textFieldDefaultSize = CGSizeMake(60, 28); // 在 iOS 11 及以上,搜索输入框系统默认高度是 36,iOS 10 及以下的高度是 28 if (@available(iOS 11.0, *)) { textFieldDefaultSize.height = 36; } } return textFieldDefaultSize; } @end