杨锴
2025-04-16 09a372bc45fde16fd42257ab6f78b8deeecf720b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
/**
 * 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.
 */
//
//  UIView+QMUITheme.m
//  QMUIKit
//
//  Created by MoLice on 2019/6/21.
//
 
#import "UIView+QMUITheme.h"
#import "QMUICore.h"
#import "UIView+QMUI.h"
#import "UIColor+QMUI.h"
#import "UIImage+QMUI.h"
#import "UIImage+QMUITheme.h"
#import "UIVisualEffect+QMUITheme.h"
#import "QMUIThemeManagerCenter.h"
#import "CALayer+QMUI.h"
#import "QMUIThemeManager.h"
#import "QMUIThemePrivate.h"
#import "NSObject+QMUI.h"
#import "UITextInputTraits+QMUI.h"
 
@implementation UIView (QMUITheme)
 
QMUISynthesizeIdCopyProperty(qmui_themeDidChangeBlock, setQmui_themeDidChangeBlock)
 
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        OverrideImplementation([UIView class], @selector(setHidden:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) {
            return ^(UIView *selfObject, BOOL firstArgv) {
                
                BOOL valueChanged = selfObject.hidden != firstArgv;
                
                // call super
                void (*originSelectorIMP)(id, SEL, BOOL);
                originSelectorIMP = (void (*)(id, SEL, BOOL))originalIMPProvider();
                originSelectorIMP(selfObject, originCMD, firstArgv);
                
                if (valueChanged) {
                    // UIView.qmui_currentThemeIdentifier 只是为了实现判断当前的 theme 是否有发生变化,所以可以构造成一个 string,但怎么避免每次 hidden 切换时都要遍历所有的 subviews?
                    [selfObject _qmui_themeDidChangeByManager:nil identifier:nil theme:nil shouldEnumeratorSubviews:YES];
                }
            };
        });
        
        OverrideImplementation([UIView class], @selector(setAlpha:), ^id(__unsafe_unretained Class originClass, SEL originCMD, IMP (^originalIMPProvider)(void)) {
            return ^(UIView *selfObject, CGFloat firstArgv) {
                
                BOOL willShow = selfObject.alpha <= 0 && firstArgv > 0.01;
                
                // call super
                void (*originSelectorIMP)(id, SEL, CGFloat);
                originSelectorIMP = (void (*)(id, SEL, CGFloat))originalIMPProvider();
                originSelectorIMP(selfObject, originCMD, firstArgv);
                
                if (willShow) {
                    // 只设置 identifier 就可以了,内部自然会去同步更新 theme
                    [selfObject _qmui_themeDidChangeByManager:nil identifier:nil theme:nil shouldEnumeratorSubviews:YES];
                }
            };
        });
        
        // 这几个 class 实现了自己的 didMoveToWindow 且没有调用 super,所以需要每个都替换一遍方法
        NSArray<Class> *classes = @[UIView.class,
                                    UICollectionView.class,
                                    UITextField.class,
                                    UISearchBar.class,
                                    NSClassFromString(@"UITableViewLabel")];
        if (NSClassFromString(@"WKWebView")) {
            classes = [classes arrayByAddingObject:NSClassFromString(@"WKWebView")];
        }
        [classes enumerateObjectsUsingBlock:^(Class  _Nonnull class, NSUInteger idx, BOOL * _Nonnull stop) {
            ExtendImplementationOfVoidMethodWithoutArguments(class, @selector(didMoveToWindow), ^(UIView *selfObject) {
                // enumerateSubviews 为 NO 是因为当某个 view 的 didMoveToWindow 被触发时,它的每个 subview 的 didMoveToWindow 也都会被触发,所以不需要遍历 subview 了
                if (selfObject.window) {
                    [selfObject _qmui_themeDidChangeByManager:nil identifier:nil theme:nil shouldEnumeratorSubviews:NO];
                }
            });
        }];
    });
}
 
- (void)qmui_registerThemeColorProperties:(NSArray<NSString *> *)getters {
    [getters enumerateObjectsUsingBlock:^(NSString * _Nonnull getterString, NSUInteger idx, BOOL * _Nonnull stop) {
        SEL getter = NSSelectorFromString(getterString);
        SEL setter = setterWithGetter(getter);
        NSString *setterString = NSStringFromSelector(setter);
        QMUIAssert([self respondsToSelector:getter], @"UIView (QMUITheme)", @"register theme color fails, %@ does not have method called %@", NSStringFromClass(self.class), getterString);
        QMUIAssert([self respondsToSelector:setter], @"UIView (QMUITheme)", @"register theme color fails, %@ does not have method called %@", NSStringFromClass(self.class), setterString);
        
        if (!self.qmuiTheme_themeColorProperties) {
            self.qmuiTheme_themeColorProperties = NSMutableDictionary.new;
        }
        self.qmuiTheme_themeColorProperties[getterString] = setterString;
    }];
}
 
- (void)qmui_unregisterThemeColorProperties:(NSArray<NSString *> *)getters {
    if (!self.qmuiTheme_themeColorProperties) return;
    
    [getters enumerateObjectsUsingBlock:^(NSString * _Nonnull getterString, NSUInteger idx, BOOL * _Nonnull stop) {
        [self.qmuiTheme_themeColorProperties removeObjectForKey:getterString];
    }];
}
 
- (void)qmui_themeDidChangeByManager:(QMUIThemeManager *)manager identifier:(__kindof NSObject<NSCopying> *)identifier theme:(__kindof NSObject *)theme {
    if (![self _qmui_visible]) return;
    
    // 常见的 view 在 QMUIThemePrivate 里注册了 getter,在这里被调用
    [self.qmuiTheme_themeColorProperties enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull getterString, NSString * _Nonnull setterString, BOOL * _Nonnull stop) {
        
        SEL getter = NSSelectorFromString(getterString);
        SEL setter = NSSelectorFromString(setterString);
        
        // 由于 tintColor 属性自带向下传递的性质,并且当值为 nil 时会自动从 superview 读取值,所以不需要在这里遍历修改,否则取出 tintColor 后再设置回去,会打破这个传递链
        if (getter == @selector(tintColor)) {
            if (!self.qmui_tintColorCustomized) return;
        }
        
        // 如果某个 UITabBarItem 处于选中状态,此时发生了主题变化,执行了 UITabBarSwappableImageView.image = image 的动作,就会把 selectedImage 设置为 normal image,无法恢复。所以对 UITabBarSwappableImageView 屏蔽掉 setImage 的刷新操作
        // https://github.com/Tencent/QMUI_iOS/issues/1122
        if ([self isKindOfClass:NSClassFromString(@"UITabBarSwappableImageView")] && getter == @selector(image)) {
            return;
        }
        
        // 注意,需要遍历的属性不一定都是 UIColor 类型,也有可能是 NSAttributedString,例如 UITextField.attributedText
        BeginIgnorePerformSelectorLeaksWarning
        id value = [self performSelector:getter];
        if (!value) return;
        BOOL isValidatedColor = [value isKindOfClass:QMUIThemeColor.class] && (!manager || [((QMUIThemeColor *)value).managerName isEqual:manager.name]);
        BOOL isValidatedImage = [value isKindOfClass:QMUIThemeImage.class] && (!manager || [((QMUIThemeImage *)value).managerName isEqual:manager.name]);
        BOOL isValidatedEffect = [value isKindOfClass:QMUIThemeVisualEffect.class] && (!manager || [((QMUIThemeVisualEffect *)value).managerName isEqual:manager.name]);
        BOOL isOtherObject = ![value isKindOfClass:UIColor.class] && ![value isKindOfClass:UIImage.class] && ![value isKindOfClass:UIVisualEffect.class];// 支持所有非 color、image、effect 的其他对象,例如 NSAttributedString
        if (isOtherObject || isValidatedColor || isValidatedImage || isValidatedEffect) {
            [self performSelector:setter withObject:value];
        }
        EndIgnorePerformSelectorLeaksWarning
    }];
    
    // 特殊的 view 特殊处理
    // iOS 10-11 里当 UILabel.attributedText 的文字颜色都相同时,也无法使用 setNeedsDisplay 刷新样式,但只要某个 range 颜色不同就没问题,iOS 9、12-13 也没问题,这个通过 UILabel (QMUIThemeCompatibility) 兼容。
    if ([self isKindOfClass:UILabel.class]) {
        [self setNeedsDisplay];
    }
    
    if ([self isKindOfClass:UITextView.class]) {
#ifdef IOS16_SDK_ALLOWED
        if (@available(iOS 16.0, *)) {
            // 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
    }
    
    // 输入框、搜索框的键盘跟随主题变化
    if (QMUICMIActivated) {
        static NSArray<Class> *inputClasses = nil;
        if (!inputClasses) inputClasses = @[UITextField.class, UITextView.class, UISearchBar.class];// 这里的 Class 与 UITextInputTraits(QMUI) 对齐
        [inputClasses enumerateObjectsUsingBlock:^(Class  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            if ([self isKindOfClass:obj]) {
                NSObject<UITextInputTraits> *input = (NSObject<UITextInputTraits> *)self;
                if ([input respondsToSelector:@selector(keyboardAppearance)]) {
                    if (input.keyboardAppearance != KeyboardAppearance && !input.qmui_hasCustomizedKeyboardAppearance) {
                        input.qmui_keyboardAppearance = KeyboardAppearance;
                    }
                }
                *stop = YES;
            }
        }];
    }
    
    /** 这里去掉动画有 2 个原因:
     1. iOS 13 进入后台时会对 currentTraitCollection.userInterfaceStyle 做一次取反进行截图,以便在后台切换 Drak/Light 后能够更新 app 多任务缩略图,QMUI 响应了这个操作去调整取反后的 layer 的颜色,而在对 layer 设置属性的时候,如果包含了动画会导致截图不到最终的状态,这样会导致在后台切换 Drak/Light 后多任务缩略图无法及时更新。
     2. 对于 UIView 层,修改 backgroundColor 默认是没有动画的,而 CALayer 修改 backgroundColor 会有隐式动画,这里为了在响应主题变化时颜色同步更新,统一把 CALayer 的动画去掉
     */
    [CALayer qmui_performWithoutAnimation:^{
        [self.layer qmui_setNeedsUpdateDynamicStyle];
    }];
    
    if (self.qmui_themeDidChangeBlock) {
        self.qmui_themeDidChangeBlock();
    }
}
 
@end
 
@implementation UIView (QMUITheme_Private)
 
QMUISynthesizeIdStrongProperty(qmuiTheme_themeColorProperties, setQmuiTheme_themeColorProperties)
 
- (BOOL)_qmui_visible {
    BOOL hidden = self.hidden;
    if ([self respondsToSelector:@selector(prepareForReuse)]) {
        hidden = NO;// UITableViewCell 在 prepareForReuse 前会被 setHidden:YES,然后再被 setHidden:NO,然而后者是无效的,执行完之后依然是 hidden 为 YES,导致认为非 visible 而无法触发 themeDidChange,所以这里对 UITableViewCell 做特殊处理
    }
    return !hidden && self.alpha > 0.01 && self.window;
}
 
- (void)_qmui_themeDidChangeByManager:(QMUIThemeManager *)manager identifier:(__kindof NSObject<NSCopying> *)identifier theme:(__kindof NSObject *)theme shouldEnumeratorSubviews:(BOOL)shouldEnumeratorSubviews {
    [self qmui_themeDidChangeByManager:manager identifier:identifier theme:theme];
    if (shouldEnumeratorSubviews) {
        [self.subviews enumerateObjectsUsingBlock:^(__kindof UIView * _Nonnull subview, NSUInteger idx, BOOL * _Nonnull stop) {
            [subview _qmui_themeDidChangeByManager:manager identifier:identifier theme:theme shouldEnumeratorSubviews:YES];
        }];
    }
}
 
@end