Swift 项目总结 07 - 视图样式可配置化

需求由来

在项目开发过程中,设计师调整设计稿是正常的,但如果调整频率一高,就让我们开发十分抓狂。

我们来进行一个情景模拟(以 AutoLayout 为例):

设计师:这个左边距调多 2 px,这个上边距调少 2 px,这 2 个 view 之间间距调大点,多 2 px 吧,这个文本字体调大一号。

开发:好的,我马上调。(我一顿操作,调整约束值,...)

======== 过了 1 天 ==========

设计师:这个样式有点问题,整体样式我重新设计了一下,你调一下(给了我最新的设计稿)

开发:这个样式调整有点大啊,各种约束都不一样了,你确定要改吗?

设计师:确定。(我一顿操作,删除旧约束代码,添加新约束代码,...)

======== 又过了 1 天 ==========

设计师:这个样式,老板看后和之前对比,觉得还是之前样式好,你换回来吧。

开发:.......

还有一种情况,一个视图在不同地方显示的布局样式是不一样的,这种视图样式配置是非常繁琐的,就像我们使用 ObjC 的 decodeencode 代码一样,都是必须但又是无脑的(体力活),我就想搞个东西方便配置视图样式,从这个过程中解脱出来

方案思考

全局配置样式

通过全局变量进行配置(之前的做法):

extension View {
    // 约束值
    struct Constraint {
        static let topPadding: CGFloat = 30
        static let bottomPadding: CGFloat = 10
        static let leftPadding: CGFloat = 43
        static let rightPadding: CGFloat = 41
    }
    // 颜色
    struct Color {
        static let title = UIColor.red
        static let date = UIColor.white
        static let source = UIColor.black
    }
    // 字体
    struct Font {
        static let title = UIFont.systemFont(ofSize: 16)
        static let date = UIFont.systemFont(ofSize: 13)
        static let source = UIFont.systemFont(ofSize: 13)
    }
}

初始化配置样式

全局配置很不方便,没法在外部修改样式配置,后来想到可以通过初始化传入样式进行配置的:

class ViewStyle {
    // 约束值
    var topPadding: CGFloat = 30
    var bottomPadding: CGFloat = 10
    var leftPadding: CGFloat = 43
    var rightPadding: CGFloat = 41

    // 颜色
    var titleColor = UIColor.red
    var dateColor = UIColor.white
    var sourceColor = UIColor.black

    // 字体
    var titleFont = UIFont.systemFont(ofSize: 16)
    var dateFont = UIFont.systemFont(ofSize: 13)
    var sourceFont = UIFont.systemFont(ofSize: 13)
}

class View: UIView {

    var style: ViewStyle?

    override init(frame: CGRect, style: ViewStyle) {
        super.init(frame: frame)
        self. style = style
        setupSubviews(with: style)
    }
    
    fileprivate func setupSubviews(with style: ViewStyle) {
        // 样式配置代码
    }
}

属性配置样式

初始化配置样式在大部分情况下已经满足需求了,但因为初始化方法有很多,尤其是使用 xib 加载的时候,不好处理。

因为我那段时间正在学习 RxSwift + ReactorKit 框架使用,发现 ReactorKit 框架中 Reactor 协议抽离视图内的业务逻辑处理非常巧妙,让每个视图绑定各自的处理器处理业务逻辑,我就想视图的配置不是也可以和 Reactor 协议一样,每个视图都绑定一个视图样式配置

// MARK: - 视图可配置协议
public protocol ViewConfigurable: class {
    associatedtype ViewStyle
    var viewStyle: ViewStyle? { get set }
    func bind(viewStyle: ViewStyle)
}

/// 为实现该协议的类添加一个伪存储属性(利用 objc 的关联方法实现),用来保存样式配置表
fileprivate var viewStyleKey: String = "viewStyleKey"
extension ViewConfigurable {
    
    var viewStyle: ViewStyle? {
        get {
            return objc_getAssociatedObject(self, &viewStyleKey) as? ViewStyle
        }
        set {
            objc_setAssociatedObject(self, &viewStyleKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
            if let style = newValue {
                self.bind(viewStyle: style)
            }
        }
    }
}

class View: UIView, ViewConfigurable {
    
    func bind(viewStyle: ViewStyle) {
        // 样式配置代码
    }
}

最终方案

我构造了一些常用视图配置项来辅助样式配置,可自己看情况自定义配置项:

// MARK: - 以下是一些常用配置项
/// View 配置项
class ViewConfiguration {
    lazy var backgroundColor: UIColor = UIColor.clear
    lazy var borderWidth: CGFloat = 0
    lazy var borderColor: UIColor = UIColor.clear
    lazy var cornerRadius: CGFloat = 0
    lazy var clipsToBounds: Bool = false
    lazy var contentMode: UIViewContentMode = .scaleToFill
    // 下面属性用于约束值配置
    lazy var padding: UIEdgeInsets = .zero
    lazy var size: CGSize = .zero
}

/// Label 配置项
class LabelConfiguration: ViewConfiguration {
    lazy var numberOfLines: Int = 1
    lazy var textColor: UIColor = UIColor.black
    lazy var textBackgroundColor: UIColor = UIColor.clear
    lazy var font: UIFont = UIFont.systemFont(ofSize: 14)
    lazy var textAlignment: NSTextAlignment = .left
    lazy var lineBreakMode: NSLineBreakMode = .byTruncatingTail
    lazy var lineSpacing: CGFloat = 0
    lazy var characterSpacing: CGFloat = 0
    
    // 属性表,用于属性字符串使用
    var attributes: [String: Any] {
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.lineSpacing = self.lineSpacing
        paragraphStyle.lineBreakMode = self.lineBreakMode
        paragraphStyle.alignment = self.textAlignment
        let attributes: [String: Any] = [
            NSParagraphStyleAttributeName: paragraphStyle,
            NSKernAttributeName: self.characterSpacing,
            NSFontAttributeName: self.font,
            NSForegroundColorAttributeName: self.textColor,
            NSBackgroundColorAttributeName: self.textBackgroundColor
        ]
        return attributes
    }
}

/// Button 配置项
class ButtonConfiguration: ViewConfiguration {
    
    class StateStyle<T> {
        var normal: T?
        var highlighted: T?
        var selected: T?
        var disabled: T?
    }
    
    lazy var titleFont: UIFont = UIFont.systemFont(ofSize: 14)
    lazy var titleColor = StateStyle<UIColor>()
    lazy var image = StateStyle<UIImage>()
    lazy var title = StateStyle<String>()
    lazy var backgroundImage = StateStyle<UIImage>()
    lazy var contentEdgeInsets: UIEdgeInsets = .zero
    lazy var imageEdgeInsets: UIEdgeInsets = .zero
    lazy var titleEdgeInsets: UIEdgeInsets = .zero
}

/// ImageView 配置项
class ImageConfiguration: ViewConfiguration {
    var image: UIImage?
}

配置样式大概类似这样:

/// 样式配置基类
class TestViewStyle {
    lazy var nameLabel = LabelConfiguration()
    lazy var introLabel = LabelConfiguration()
    lazy var subscribeButton = ButtonConfiguration()
    lazy var imageView = ImageConfiguration()
}

/// 样式一
class TestViewStyle1: TestViewStyle {
    
    override init() {
        super.init()
        // 样式
        nameLabel.padding.left = 10
        nameLabel.padding.right = -14
        nameLabel.textColor = UIColor.black
        nameLabel.font = UIFont.systemFont(ofSize: 15)
        
        introLabel.lineSpacing = 10
        introLabel.padding.top = 10
        introLabel.numberOfLines = 0
        introLabel.textColor = UIColor.gray
        introLabel.font = UIFont.systemFont(ofSize: 13)
        introLabel.lineBreakMode = .byCharWrapping
        
        subscribeButton.padding.top = 10
        subscribeButton.size.height = 30
        subscribeButton.image.normal = UIImage(named: "subscribe")
        subscribeButton.image.selected = UIImage(named: "subscribed")
        subscribeButton.title.normal = "订阅"
        subscribeButton.title.selected = "已订"
        subscribeButton.titleColor.normal = UIColor.black
        subscribeButton.titleColor.selected = UIColor.yellow
        subscribeButton.titleFont = UIFont.systemFont(ofSize: 12)
        
        imageView.padding.left = 14
        imageView.padding.top = 20
        imageView.size.width = 60
        imageView.contentMode = .scaleAspectFill
        imageView.borderColor = UIColor.red
        imageView.borderWidth = 3
        imageView.cornerRadius = imageView.size.width * 0.5
        imageView.clipsToBounds = true
    }
}

/// 样式二
class TestViewStyle2: TestViewStyle {
    
    override init() {
        super.init()
        // 样式
        nameLabel.padding = UIEdgeInsets(top: 10, left: 14, bottom: 0, right: -14)
        nameLabel.textColor = UIColor.red
        nameLabel.font = UIFont.systemFont(ofSize: 17)
        
        introLabel.padding.top = 10
        introLabel.numberOfLines = 0
        introLabel.textColor = UIColor.purple
        introLabel.font = UIFont.systemFont(ofSize: 15)
        introLabel.lineBreakMode = .byCharWrapping
        introLabel.lineSpacing = 4
        
        subscribeButton.padding.top = 10
        subscribeButton.size.height = 30
        subscribeButton.image.normal = UIImage(named: "subscribe")
        subscribeButton.image.selected = UIImage(named: "subscribed")
        subscribeButton.title.normal = "订阅"
        subscribeButton.title.selected = "已订"
        subscribeButton.titleColor.normal = UIColor.black
        subscribeButton.titleColor.selected = UIColor.yellow
        subscribeButton.titleFont = UIFont.systemFont(ofSize: 12)
        
        imageView.padding.top = 20
        imageView.size.width = 60
        imageView.contentMode = .scaleAspectFill
        imageView.borderColor = UIColor.red
        imageView.borderWidth = 3
        imageView.clipsToBounds = true
        imageView.cornerRadius = imageView.size.width * 0.5

    }
}

在视图中配置大概这样:

import UIKit
import SnapKit

class TestView: UIView, ViewConfigurable {
    
    fileprivate var nameLabel: UILabel!
    fileprivate var introLabel: UILabel!
    fileprivate var subscribeButton: UIButton!
    fileprivate var imageView: UIImageView!
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupSubviews()
    }
    
    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setupSubviews()
    }
    
    fileprivate func setupSubviews() {
        
        nameLabel = UILabel(frame: self.bounds)
        self.addSubview(nameLabel)
        
        introLabel = UILabel(frame: self.bounds)
        self.addSubview(introLabel)
        
        subscribeButton = UIButton(type: .custom)
        self.addSubview(subscribeButton)
        
        imageView = UIImageView(frame: self.bounds)
        self.addSubview(imageView)
    }
    
    /// 更新视图样式,不要直接调用,通过赋值 self.viewStyle 属性间接调用
    func bind(viewStyle: TestViewStyle) {
        
        /* 对外可配置属性 */
        // 名字
        nameLabel.textColor = viewStyle.nameLabel.textColor
        nameLabel.font = viewStyle.nameLabel.font
        
        // 介绍
        introLabel.numberOfLines = viewStyle.introLabel.numberOfLines
        if let text = introLabel.text {
            introLabel.attributedText = NSAttributedString(string: text, attributes: viewStyle.introLabel.attributes)
        }
        
        // 订阅按钮
        subscribeButton.setTitleColor(viewStyle.subscribeButton.titleColor.normal, for: .normal)
        subscribeButton.setTitleColor(viewStyle.subscribeButton.titleColor.selected, for: .selected)
        subscribeButton.setImage(viewStyle.subscribeButton.image.normal, for: .normal)
        subscribeButton.setImage(viewStyle.subscribeButton.image.selected, for: .selected)
        subscribeButton.setTitle(viewStyle.subscribeButton.title.normal, for: .normal)
        subscribeButton.setTitle(viewStyle.subscribeButton.title.selected, for: .selected)
        subscribeButton.titleLabel?.font = viewStyle.subscribeButton.titleFont
        
        // 头像
        imageView.layer.borderColor = viewStyle.imageView.borderColor.cgColor
        imageView.layer.borderWidth = viewStyle.imageView.borderWidth
        imageView.layer.cornerRadius = viewStyle.imageView.cornerRadius
        imageView.clipsToBounds = viewStyle.imageView.clipsToBounds
        imageView.contentMode = viewStyle.imageView.contentMode
        
        // 更新视图布局,不同布局约束关系直接切换
        if let viewStyle1 = viewStyle as? TestViewStyle1 {
            updateLayoutForStyle1(viewStyle1)
        } else if let viewStyle2 = viewStyle as? TestViewStyle2 {
            updateLayoutForStyle2(viewStyle2)
        }
    }
    
    fileprivate func updateLayoutForStyle1(_ viewStyle: TestViewStyle1) {
        
        imageView.snp.remakeConstraints { (make) in
            make.left.equalTo(self.snp.left).offset(viewStyle.imageView.padding.left)
            make.top.equalTo(self.snp.top).offset(viewStyle.imageView.padding.top)
            make.width.equalTo(viewStyle.imageView.size.width)
            make.height.equalTo(self.imageView.snp.width)
        }
        
        nameLabel.snp.remakeConstraints { (make) in
            make.top.equalTo(self.imageView.snp.top)
            make.left.equalTo(self.imageView.snp.right).offset(viewStyle.nameLabel.padding.left)
            make.right.equalTo(self.snp.right).offset(viewStyle.nameLabel.padding.right)
        }
        
        introLabel.snp.remakeConstraints { (make) in
            make.top.equalTo(self.nameLabel.snp.bottom).offset(viewStyle.introLabel.padding.top)
            make.left.equalTo(self.nameLabel.snp.left)
            make.right.equalTo(self.nameLabel.snp.right)
        }
        
        subscribeButton.snp.remakeConstraints { (make) in
            make.top.equalTo(self.imageView.snp.bottom).offset(viewStyle.subscribeButton.padding.top)
            make.left.equalTo(self.imageView.snp.left)
            make.right.equalTo(self.imageView.snp.right)
            make.height.equalTo(viewStyle.subscribeButton.size.height)
        }
    }
    
    fileprivate func updateLayoutForStyle2(_ viewStyle: TestViewStyle2) {
        imageView.snp.remakeConstraints { (make) in
            make.centerX.equalTo(self.snp.centerX)
            make.top.equalTo(self.snp.top).offset(viewStyle.imageView.padding.top)
            make.width.equalTo(viewStyle.imageView.size.width)
            make.height.equalTo(self.imageView.snp.width)
        }
        
        subscribeButton.snp.remakeConstraints { (make) in
            make.left.equalTo(self.imageView.snp.left)
            make.right.equalTo(self.imageView.snp.right)
            make.centerX.equalTo(self.imageView.snp.centerX)
            make.top.equalTo(self.imageView.snp.bottom).offset(viewStyle.subscribeButton.padding.top)
            make.height.equalTo(viewStyle.subscribeButton.size.height)
        }
        
        nameLabel.snp.remakeConstraints { (make) in
            make.top.equalTo(self.subscribeButton.snp.bottom).offset(viewStyle.nameLabel.padding.top)
            make.left.equalTo(self.snp.left).offset(viewStyle.nameLabel.padding.left)
            make.right.equalTo(self.snp.right).offset(viewStyle.nameLabel.padding.right)
        }
        
        introLabel.snp.remakeConstraints { (make) in
            make.top.equalTo(self.nameLabel.snp.bottom).offset(viewStyle.introLabel.padding.top)
            make.left.equalTo(self.nameLabel.snp.left)
            make.right.equalTo(self.nameLabel.snp.right)
        }
    }
}

外面使用起来就很简单,切换不同布局快捷方便:

class ViewController: UIViewController {
    
    fileprivate var testView: TestView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 初始化
        testView = TestView(frame: CGRect(x: 0, y: 100, width: self.view.frame.size.width, height: 200))
        // 配置样式
        testView.viewStyle = TestViewStyle1()
        self.view.addSubview(testView)
        
        // 更换样式配置
        testView.viewStyle = TestViewStyle2()
    }
}

Demo 源代码在这:ViewStyleProtocolDemo

有什么问题可以在下方评论区提出,写得不好可以提出你的意见,我会合理采纳的,O(∩_∩)O哈哈~,求关注求赞

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,444评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,421评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,036评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,363评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,460评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,502评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,511评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,280评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,736评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,014评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,190评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,848评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,531评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,159评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,411评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,067评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,078评论 2 352

推荐阅读更多精彩内容