再谈 Swift 换肤功能
在之前我写的 iOS应用主题(图片,颜色)统一管理 一文中,曾介绍了 Swift 皮肤切换功能,但由于那时对 Swift 的理解不够深,所以现在再看之前写的那篇文章,感觉其中的实现很糟糕,所以今天再来谈谈 Swift 的换肤功能。读该文前,建议先读下上述文章。
首先,当然是先上 demo
接着就是效果图:
theme.gif实现
这个换肤功能的代码量大概就在二百行左右,核心代码就50行左右,这里就不多说,先看下核心代码的:
// Protocols.swift
protocol ThemeProtocol {
}
extension ThemeProtocol where Self: UIView {
func addThemeObserver() {
print("addViewThemeObserver")
NotificationCenter.default.addObserver(self, selector: #selector(updateTheme), name: NSNotification.Name(rawValue: kUpdateTheme), object: nil)
}
func removeThemeObserver() {
print("removeViewThemeObserver")
NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: kUpdateTheme), object: nil)
}
}
extension ThemeProtocol where Self: UIViewController {
func addThemeObserver() {
print("addViewControllerThemeObserver")
NotificationCenter.default.addObserver(self, selector: #selector(updateTheme), name: NSNotification.Name(rawValue: kUpdateTheme), object: nil)
}
func removeThemeObserver() {
print("removeViewControllerThemeObserver")
NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: kUpdateTheme), object: nil)
}
}
extension UIView {
func updateTheme() {
print("update view theme")
}
}
extension UIViewController {
func updateTheme() {
print("update view controller theme")
}
}
换肤其实就是一个监听者模式,一般情况下,涉及到换肤功能的,要么是在 UIViewController 中,要么就是在 UIView 中,这里先定义一个 ThemeProtocol 协议,然后通过协议的扩展来实现 UIView 和 UIViewController 对换肤功能的监听或移除监听方法,但因为协议的扩展是 Swift 中仅有的,在 OC 中并不支持,所以不能在协议扩展中实现 updateTheme 方法,这里通过扩展 UIView 和 UIViewController 来实现 updateTheme 方法。
我们在 UIView 或 UIViewController 中实现 ThemeProtocol 协议后, 我们就可以对换肤功能进行监听,其它没有实现 ThemeProtocol 协议的相关 UIView 或 UIViewController 就不会受影响,实现如下:
class TestView: UIView, ThemeProtocol {
override init(frame: CGRect) {
super.init(frame: frame)
// 添加监听
addThemeObserver()
self.backgroundColor = UIColor("bg_testview")
}
// 换肤动作
override func updateTheme() {
super.updateTheme()
self.backgroundColor = UIColor("bg_testview")
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
// 移除添加
removeThemeObserver()
}
}
在上篇文章中,为了实现对主题的监听,是通过实现一个基类来实现的,但是这也导致了耦合度奇高,可以移植性差,通过上述的方法,就可以很好地解决这个问题了。
核心内容其实就是上面这些,剩下的内容就和 iOS应用主题(图片,颜色)统一管理 这篇文章几乎一样了,就是实现一个 ThemeManager 类,通过切换 bundle 来对图片和颜色资源进行管理,这里就不详细说了,代码也比较简单,直接下载 demo 看就可以了,这里就上一张目录图:
316641E1-4335-430C-A03D-6F688DD30932.png附录
ThemeManager.swift 内容:
import UIKit
let kUpdateTheme = "kUpdateTheme"
let kThemeStyle = "kThemeStyle"
final class ThemeManager: NSObject {
var style: ThemeStyle {
return themeStyle
}
static var instance = ThemeManager()
private var themeBundleName: String {
switch themeStyle {
case .black:
return "blackTheme"
default:
return "defaultTheme"
}
}
private var themeStyle: ThemeStyle = .default
private var themeColors: NSDictionary?
private override init() {
super.init()
if let style = UserDefaults.standard.object(forKey: kThemeStyle) as? Int {
themeStyle = ThemeStyle(rawValue: style)!
} else {
UserDefaults.standard.set(themeStyle.rawValue, forKey: kThemeStyle)
UserDefaults.standard.synchronize()
}
themeColors = getThemeColors()
}
private func getThemeColors() -> NSDictionary? {
let bundleName = themeBundleName
guard let themeBundlePath = Bundle.path(forResource: bundleName, ofType: "bundle", inDirectory: Bundle.main.bundlePath) else {
return nil
}
guard let themeBundle = Bundle(path: themeBundlePath) else {
return nil
}
guard let path = themeBundle.path(forResource: "themeColor", ofType: "txt") else {
return nil
}
let url = URL(fileURLWithPath: path)
let data = try! Data(contentsOf: url)
do {
return try JSONSerialization.jsonObject(with: data, options: [JSONSerialization.ReadingOptions(rawValue: 0)]) as? NSDictionary
} catch {
return nil
}
}
func updateThemeStyle(_ style: ThemeStyle) {
if themeStyle.rawValue == style.rawValue {
return
}
themeStyle = style
UserDefaults.standard.set(style.rawValue, forKey: kThemeStyle)
UserDefaults.standard.synchronize()
themeColors = getThemeColors()
NotificationCenter.default.post(name: NSNotification.Name(rawValue: kUpdateTheme), object: nil)
}
func themeColor(_ colorName: String) -> Int {
guard let hexString = themeColors?.value(forKey: colorName) as? String else {
assert(true, "Invalid color key")
return 0
}
let colorValue = Int(strtoul(hexString, nil, 16))
return colorValue
}
}
Extensions.swift 内容:
import UIKit
extension UIImage {
static func loadImage(_ imageName: String) -> UIImage? {
return loadImage(imageName, style: ThemeManager.instance.style)
}
// 如果明确资源不受 theme 变化而变化,使用这个接口会更快
static func loadDefaultImage(_ imageName: String) -> UIImage? {
return loadImage(imageName, style: .default)
}
static func loadImage(_ imageName: String, style: ThemeStyle) -> UIImage? {
if imageName.isEmpty || imageName.characters.count == 0 {
return nil
}
var bundleName = "defaultTheme"
switch style {
case .black:
bundleName = "blackTheme"
default:
bundleName = "defaultTheme"
}
guard let themeBundlePath = Bundle.path(forResource: bundleName, ofType: "bundle", inDirectory: Bundle.main.bundlePath) else {
return nil
}
guard let themeBundle = Bundle(path: themeBundlePath) else {
return nil
}
var isImageUnder3x = false
var nameAndType = imageName.components(separatedBy: ".")
var name = nameAndType.first!
let type = nameAndType.count > 1 ? nameAndType[1] : "png"
var imagePath = themeBundle.path(forResource: "image/" + name, ofType: type)
let nameLength = name.characters.count
if imagePath == nil && name.hasSuffix("@2x") && nameLength > 3 {
let index = name.index(name.endIndex, offsetBy: -3)
name = name.substring(with: Range<String.Index>(name.startIndex ..< index))
}
if imagePath == nil && !name.hasSuffix("@2x") {
let name2x = name + "@2x";
imagePath = themeBundle.path(forResource: "image/" + name2x, ofType: type)
if imagePath == nil && !name.hasSuffix("3x") {
let name3x = name + "@3x"
imagePath = themeBundle.path(forResource: "image/" + name3x, ofType: type)
isImageUnder3x = true
}
}
var image: UIImage?
if let imagePath = imagePath {
image = UIImage(contentsOfFile: imagePath)
} else {
// 如果当前 bundle 里面不存在这张图片的路径,那就去默认的 bundle 里面找,
// 为什么要这样做呢,因为部分资源在不同 theme 中是一样的,就不需要导入重复的资源,使应用包的大小变大
image = UIImage.loadDefaultImage(imageName)
}
if #available(iOS 8, *) {
return image
}
if !isImageUnder3x {
return image
}
return image?.scaledImageFrom3x()
}
private func scaledImageFrom3x() -> UIImage {
let theRate: CGFloat = 1.0 / 3.0
let oldSize = self.size
let scaleWidth = CGFloat(oldSize.width) * theRate
let scaleHeight = CGFloat(oldSize.height) * theRate
var scaleRect = CGRect.zero
scaleRect.size.width = scaleWidth
scaleRect.size.height = scaleHeight
UIGraphicsBeginImageContextWithOptions(scaleRect.size, false, UIScreen.main.scale)
draw(in: scaleRect)
let newImage = UIGraphicsGetImageFromCurrentImageContext()!
UIGraphicsEndImageContext()
return newImage
}
}
extension UIColor {
convenience init(red: Int, green: Int, blue: Int) {
assert(red >= 0 && red <= 255, "Invalid red component")
assert(green >= 0 && green <= 255, "Invalid green component")
assert(blue >= 0 && blue <= 255, "Invalid blue component")
self.init(red: CGFloat(red) / 255.0, green: CGFloat(green) / 255.0, blue: CGFloat(blue) / 255.0, alpha: 1.0)
}
convenience init(_ colorName: String) {
let netHex = ThemeManager.instance.themeColor(colorName)
self.init(red:(netHex >> 16) & 0xff, green:(netHex >> 8) & 0xff, blue:netHex & 0xff)
}
}