前言
iOS 實現主題切換,相信在未來的app裡也是會頻繁出現的,盡管現在只是出現在主流的APP,如(QQ、新浪微博、酷狗音樂、網易雲音樂等),但是現在是看顏值、追求個性的年代,所以根據用戶喜好自定義/切換主題也是未來app的必備功能了。
實現思路
為了降低耦合度,決定采用的方案是使用NSObject的分類來實現主題設置,有些讀者可能會想為何不使用UIView的分類而是使用NSObject的分類?建議這部分讀者看一下UIBarItem父類,然後仔細思考一下,就會理解了。
設置主題色
PYThemeColor.png
創建主題色池
將需要設置主題色的控件及其對應屬性/方法添加到主題色池中
調用設置主題色方法時,遍歷主題色池中的控件,使用KVC設置對應屬性或調用對應的方法來實現主題色的設置
代碼實現
建議讀者在理解思路以後先下載源碼大概看一下(縱觀全局)再閱讀以下內容:
源碼地址:https://github.com/iphone5solo/PYTheme
1. 創建主題色池
由於是在NSObject的分類裡面創建,為了方便管理,設置全局變量_themeColorPool,並通過懶加載完成_themeColorPool的實例化。數組中的對象采用為NSDictionary的原因見下一步就會理解了
/** 主題顏色池 */ static NSMutableArray *_themeColorPool; #pragma mark - 懶加載 - (NSMutableArray *)themeColorPool { if (!_themeColorPool) { _themeColorPool = [NSMutableArray array]; } return _themeColorPool; }
2. 添加控件到主題色池中
由於顏色設置有的可以直接通過屬性設置也有的需要通過調用方法才可設置。以UIButton為例,設置背景色可通過屬性button.backgroundColor設置,設置選中狀態時的字體顏色則要調用setTitleColor:forState:方法才可設置,於是,就得提供兩個方法供使用者調用,如下
/** * 添加到主題色池 * selector : 執行方法 * objects : 方法參數數組 * 注意:方法參數必須按順序一一對應,如果涉及到的主題色設置使用 PYTHEME_THEME_COLOR 宏定義代替 * 如果數組中某個參數為nil,需包裝為 [NSNull null] 對象再添加到數組中 */ - (void)py_addToThemeColorPoolWithSelector:(SEL)selector objects:(NSArray *)objects; /** * 添加到主題色池 * propertyName : 屬性名 */ - (void)py_addToThemeColorPool:(NSString *)propertyName;
實現如下:
#pragma mark - Theme Color /** * 添加到主題色池 * selector : 執行方法 * objects : 方法參數數組 * 注意:方法參數必須按順序一一對應,如果涉及到的主題色設置使用 PYTHEME_THEME_COLOR 宏定義代替 * 如果數組中某個參數為nil,需包裝為 [NSNull null] 對象再添加到數組中 */ - (void)py_addToThemeColorPoolWithSelector:(SEL)selector objects:(NSArray *)objects { // 判斷參數是否為空 if (!objects) return; Class appearanceClass = NSClassFromString(@"_UIAppearance"); // 如果對象為_UIAppearance,直接返回 if ([self isMemberOfClass:appearanceClass]) return; // 鍵:對象地址+方法名 值:對象 NSString *pointSelectorString = [NSString stringWithFormat:@"%p%@", self, NSStringFromSelector(selector)]; NSDictionary *dic = @{ pointSelectorString : self, PYTHEME_COLOR_ARGS_KEY : objects }; // 判斷是否已經在主題色池中 if (![[self themeColorPool] containsObject:dic]) { // 不在主題色池中 [[self themeColorPool] addObject:dic]; if (_currentThemeColor) { // 已經設置主題色,直接設置 [self py_performSelector:selector withObjects:objects]; } } } /** * 添加到主題色池 * propertyName : 屬性名 */ - (void)py_addToThemeColorPool:(NSString *)propertyName { // 如果對象為_UIAppearance,直接返回 Class appearanceClass = NSClassFromString(@"_UIAppearance"); if ([self isMemberOfClass:appearanceClass]) return; // 鍵:對象地址+屬性名 值:對象 NSString *pointString = [NSString stringWithFormat:@"%p%@", self, propertyName]; NSDictionary *dic = @{ pointString : self }; // 判斷是否已經在主題色中 if (![[self themeColorPool] containsObject:dic]) { // 不在主題色池中 [[self themeColorPool] addObject:dic]; if (_currentThemeColor) { // 已經設置主題色,直接設置 [self setValue:_currentThemeColor forKey:propertyName]; } } // 遍歷主題色池(移除應該被回收的對象) for (NSDictionary *dict in [[self themeColorPool] copy]) { // 取出key NSString *objectKey = nil; for (NSString *key in [dict allKeys]) { if (![key isEqualToString:PYTHEME_COLOR_ARGS_KEY]) { objectKey = key; break; } } // 取出對象 id object = [dict valueForKey:objectKey]; // 取出對象的引用計數 NSInteger retainCount = [[object valueForKey:@"retainCount"] integerValue]; if (retainCount == 2) { // 對象應該被回收了 [[self themeColorPool] removeObject:dict]; } } }
為了滿足個別需求,所以還是提供一下從主題色池中移除控件的方法
/** * 從主題色池移除 * propertyName : 屬性名 */ - (void)py_removeFromThemeColorPool:(NSString *)propertyName; /** * 從主題色池移除 * selector : 方法選擇器 */ - (void)py_removeFromThemeColorPoolWithSelector:(SEL)selector;
實現如下:
/** * 從主題色池移除 * propertyName : 屬性名 */ - (void)py_removeFromThemeColorPool:(NSString *)propertyName { // 如果對象為_UIAppearance,直接返回 Class appearanceClass = NSClassFromString(@"_UIAppearance"); if ([self isMemberOfClass:appearanceClass]) return; // 鍵:對象地址+屬性名 值:對象 NSString *pointString = [NSString stringWithFormat:@"%p%@", self, propertyName]; NSDictionary *dic = @{ pointString : self }; // 判斷是否已經在主題色池中 if ([[self themeColorPool] containsObject:dic]) { // 在主題色池中 [[self themeColorPool] removeObject:dic]; } } /** * 從主題色池移除 * selector : 方法選擇器 */ - (void)py_removeFromThemeColorPoolWithSelector:(SEL)selector { // 如果對象為_UIAppearance,直接返回 Class appearanceClass = NSClassFromString(@"_UIAppearance"); if ([self isMemberOfClass:appearanceClass]) return; // 鍵:對象地址+方法名 NSString *pointSelectorString = [NSString stringWithFormat:@"%p%@", self, NSStringFromSelector(selector)]; // 遍歷主題色池(移除應該被回收的對象) for (NSDictionary *dict in [[self themeColorPool] copy]) { for (NSString *key in [dict allKeys]) { if ([key isEqualToString:pointSelectorString]) { // 存在,移除 [[self themeColorPool] removeObject:dict]; return; } } } }
3. 設置主題色
實現如下:
/** * 設置主題色 * color : 主題色 */ - (void)py_setThemeColor:(UIColor *)color { _currentThemeColor = color; // 遍歷緩主題池,設置統一主題色 for (NSDictionary *dict in [_themeColorPool copy]) { // 取出key NSString *objectKey = nil; for (NSString *key in [dict allKeys]) { if (![key isEqualToString:PYTHEME_COLOR_ARGS_KEY]) { objectKey = key; break; } } // 取出對象 id object = [dict valueForKey:objectKey]; if ([objectKey containsString:@":"]) { // 方法 // 取出參數 NSArray *args = dict[PYTHEME_COLOR_ARGS_KEY]; // 取出方法 NSString *selectorName = [objectKey substringFromIndex:[[NSString stringWithFormat:@"%p", object] length]]; SEL selector = NSSelectorFromString(selectorName); // 調用方法,設置屬性 [object py_performSelector:selector withObjects:args]; } else { // 成員屬性 // 取出屬性值 NSString *propertyName = [objectKey substringFromIndex:[[NSString stringWithFormat:@"%p", object] length]]; // 給對象的對應屬性賦值(使用KVC) [object setValue:color forKeyPath:propertyName]; } } }
使用
假設有個需求:UINavigationBar的背景顏色和UIButton選中時的字體顏色會隨著主題顏色的變化而變化,實現如下:
將navigationBar的background和UIButton的setTitleColor:forState:方法添加到主題池中,方法參數中如果是設置為主題色的參數則用PYTHEME_THEME_COLOR占位,如果參數為nil,則使用[NSNull null]代替
// 創建導航欄 UINavigationBar *navigationBar = [[UINavigationBar alloc] init]; // 添加到主題色池中 [navigationBar py_addToThemeColorPool:@"barTintColor"]; // 創建按鈕 UIButton *button = [[UIButton alloc] init]; // 添加到主題色中 [button py_addToThemeColorPoolWithSelector:@selector(setTitleColor:forState:) objects:@[PYTHEME_THEME_COLOR, @(UIControlStateSelected)]];
設置主題色
// 設置主題色為紅色 [self py_setThemeColor:[UIColor redColor]];
這裡有一點注意的是[object py_performSelector:selector withObjects:args];這是自己實現的performSelector 多參調用關於這方面的網上已經有很多教程了,這裡就不多介紹了。直接附上的我實現(內部方法,主要考慮到自己的使用):
#pragma mark - performSelector 多參調用 - (id)py_performSelector:(SEL)selector withObjects:(const NSArray *)objects { // 1. 創建方法簽名 // 根據方法來初始化NSMethodSignature NSMethodSignature *methodSignate = [[self class] instanceMethodSignatureForSelector:selector]; if (!methodSignate) { // 沒有該方法 return self; } // 2. 創建invocation對象(包裝方法) NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignate]; // 3. 設置相關屬性 // 調用者 invocation.target = self; // 調用方法 invocation.selector = selector; // 獲取除self、_cmd的參數個數 NSInteger paramsCount = methodSignate.numberOfArguments - 2; // 取最少的,防止越界 NSInteger count = MIN(paramsCount, objects.count); // 用於dictionary的拷貝(用於保住objCopy,避免非法內存訪問) NSMutableDictionary *objCopy = nil; // 設置參數 for (int i = 0; i < count; i++) { // 取出參數對象 id obj = objects[i]; // 如果是主題顏色參數顏色,則設置 if ([obj isKindOfClass:[NSString class]] && [obj isEqualToString:PYTHEME_THEME_COLOR]) { obj = _currentThemeColor; } // 判斷需要設置的參數是否是NSNull, 如果是就設置為nil if ([obj isKindOfClass:[NSNull class]]) { obj = nil; } // 獲取參數類型 const char *argumentType = [methodSignate getArgumentTypeAtIndex:i + 2]; // 判斷參數類型 根據類型轉化數據類型(如果有必要) NSString *argumentTypeString = [NSString stringWithUTF8String:argumentType]; if ([argumentTypeString isEqualToString:@"@"]) { // id // 如果是dictionary,可能存在 PYTHEME_THEME_COLOR if ([obj isKindOfClass:[NSDictionary class]]) { // NSDictionary objCopy = [obj mutableCopy]; // 取出所有鍵 NSArray *keys = [objCopy allKeys]; for (NSString *key in keys) { // 取出值 id value = objCopy[key]; if ([value isKindOfClass:[NSString class]] && [value isEqualToString:PYTHEME_THEME_COLOR]) { // 替換成顏色 [objCopy setValue:_currentThemeColor forKey:key]; } } [invocation setArgument:&objCopy atIndex:i + 2]; } else { // 其他 [invocation setArgument:&obj atIndex:i + 2]; } } else if ([argumentTypeString isEqualToString:@"B"]) { // bool bool objVaule = [obj boolValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"f"]) { // float float objVaule = [obj floatValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"d"]) { // double double objVaule = [obj doubleValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"c"]) { // char char objVaule = [obj charValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"i"]) { // int int objVaule = [obj intValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"I"]) { // unsigned int unsigned int objVaule = [obj unsignedIntValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"S"]) { // unsigned short unsigned short objVaule = [obj unsignedShortValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"L"]) { // unsigned long unsigned long objVaule = [obj unsignedLongValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"s"]) { // shrot short objVaule = [obj shortValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"l"]) { // long long objVaule = [obj longValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"q"]) { // long long long long objVaule = [obj longLongValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"C"]) { // unsigned char unsigned char objVaule = [obj unsignedCharValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"Q"]) { // unsigned long long unsigned long long objVaule = [obj unsignedLongLongValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"{CGRect={CGPoint=dd}{CGSize=dd}}"]) { // CGRect CGRect objVaule = [obj CGRectValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } else if ([argumentTypeString isEqualToString:@"{UIEdgeInsets=dddd}"]) { // UIEdgeInsets UIEdgeInsets objVaule = [obj UIEdgeInsetsValue]; [invocation setArgument:&objVaule atIndex:i + 2]; } } // 4.調用方法 [invocation invoke]; // 5. 設置返回值 id returnValue = nil; if (methodSignate.methodReturnLength != 0) { // 有返回值 // 將返回值賦值給returnValue [invocation getReturnValue:&returnValue]; } return returnValue; }
細節處理
1. 設置主題色的方式
通過屬性直接設置主題色
通過調用方法並以主題色為參數來設置主題色
通過調用方法但主題色被封裝後(如:NSDictionary)作為參數設置主題色
2. 自動管理內存管理
當對象應該被釋放後,下一次當主題色池有新元素添加時,會遍歷主題色池,根據對象的引用計數來決定是否移除對象(實現自動管理內存),因此:主題色池中最多可能會殘留一個對象,這對內存幾乎沒有任何影響,如果要及時釋放對象本人認為可以采用KVO監聽對象的引用計數(未嘗試),但是耗能高,不建議這麼做!
3. 當對象為_UIAppearance類時,不添加到主題色池
了解UIAppearance的讀者應該可以理解,而且使用UIAppearance的目的也為為了設置全局色,所以為了避免沖突,如果使用了該“技術”就不添加到主題色池
設置主題圖片
觀察了新浪微博、酷狗音樂等app,發現設置主題圖片還是很有必要的,而且發現每套主題皮膚/圖片都有對應的主題色,所以在設計接口的時候都考慮了這方面的需求。先看一下設置主題圖片的基本原理,如下:
創建一個主題圖片池(使用懶加載)
將相關控件對象直接添加到主題圖片池中
設置主題圖片時,通過block把主題圖片池中的所有對象傳遞給用戶,用戶實現block,在block中獲得對象,並根據需求設置相關屬性完成主題圖片的設置
代碼實現:
1. 創建一個主題圖片池(使用懶加載)
/** 主題圖片池 */ static NSMutableArray(id) *_themeImagePool;(此處圓括號代替尖括號) - (NSMutableArray *)themeImagePool { if (!_themeImagePool) { _themeImagePool = [NSMutableArray array]; } return _themeImagePool; }
2. 添加相關控件到主題圖片池中
因為在設置圖片是,比較復雜,如UITabBar上面的UIBarItem的圖片、字體顏色等,所以為了滿足大部分用戶的需求,決定采用的是直接存儲控件對象
/** 添加到主題圖片池 */ - (void)py_addToThemeImagePool; /** 從主題圖片池中移除 */ - (void)py_removeFromThemeImagePoo
實現如下:
#pragma mark - Theme Image /** 添加到主題圖片池 */ - (void)py_addToThemeImagePool { // 如果對象為_UIAppearance,直接返回 Class appearanceClass = NSClassFromString(@"_UIAppearance"); if ([self isMemberOfClass:appearanceClass]) return; if ([self isKindOfClass:[UITabBarItem class]]) { // 如果是UITabBarItem,判斷是否有設置圖片 UITabBarItem *item = (UITabBarItem *)self; if (!item.image) { // 沒有設置圖片 item.image = [[UIImage alloc] init]; } if (!item.selectedImage) { // 沒有設置圖片 item.selectedImage = [[UIImage alloc] init]; } } // 判斷是否已經在主題圖片池中 if (![[self themeImagePool] containsObject:self]) { // 不在主題圖片池中 [[self themeImagePool] addObject:self]; } // 遍歷主題圖片池(移除應該被回收的對象) for (id object in [self themeImagePool]) { NSInteger retainCount = [[object valueForKey:@"retainCount"] integerValue]; if (retainCount == 2) { // 對象應該被回收了 [[self themeImagePool] removeObject:self]; } } } /** 從主題圖片池中移除 */ - (void)py_removeFromThemeImagePool { // 如果對象為_UIAppearance,直接返回 Class appearanceClass = NSClassFromString(@"_UIAppearance"); if ([self isMemberOfClass:appearanceClass]) return; // 判斷是否已經在圖片池中 if ([[self themeImagePool] containsObject:self]) { // 在主題圖片池中 [[self themeImagePool] removeObject:self]; } }
3. 設置主題圖片和相關配色
當設置圖片時,會通過block將主題圖片池裡面的所有控件傳遞給用戶,用戶根據需求進行相關設置,如果提供了配色,就會采用上面設置主題色功能來設置主題色
/** * 重新加載主題圖片 * themeColor : 主題色 * block : 設置主題圖片時調用的block */ - (void)py_reloadThemeImageWithThemeColor:(UIColor *)themeColor setting:(PYThemeImageSettingBlock)block;
實現如下:
/** 重新加載主題圖片 */ - (void)py_reloadThemeImageWithThemeColor:(UIColor *)themeColor setting:(PYThemeImageSettingBlock)block { if (themeColor) { // 有主題色,設置主題色 [self py_setThemeColor:themeColor]; } if (block) { // 存在block,直接調用 block([self themeImagePool]); } }
使用
假設現在有這麼一個需求:更換主題圖片時,更換UITabBarItem的圖片
1.將UITabBarItem添加到圖片池
// UITabBarItem [childController.tabBarItem py_addToThemeImagePool];
2.切換主題圖片並設置配色為紅色
// 重新加載主題圖片,並設置主題色為紅色 [self py_reloadThemeImageWithThemeColor:[UIColor redColor] setting:^(const NSArray *objects) { // 根據控件類型完成相關設置 }
總結
篇幅可能有點大,能耐心讀到這裡的讀者相信會有不少收獲的,希望讀者在閱讀此教程的時候,千萬不要學習代碼實現,而是要多思考:為什麼要這樣實現?那樣實現有什麼不好?多學學接口為什麼要這樣設計,那樣設計是不是更合理?當你帶著這些問題再回過頭來去看看源碼時,希望你會有更多的收貨!當然,這裡只是提供了一種思路,你也可以在此基礎上實現夜間模式的切換等。期待你們的實現!
期望
當然如果您有更多的想法想表達或者交流的話,歡迎到留言/評論!因為本人比較喜歡活躍在GitHub社區,所以,如果您有什麼想反饋的也可以issuse me,在這也鼓勵大家去多多發現優秀源碼,並且共享給大家。畢竟分享是雙方獲利的,何樂而不為?
源碼地址:https://github.com/iphone5solo/PYTheme
源碼作者:CoderKo1o