Swift 项目总结 07 - 视图样式可配置化
需求由来
在项目开发过程中,设计师调整设计稿是正常的,但如果调整频率一高,就让我们开发十分抓狂。
我们来进行一个情景模拟(以 AutoLayout 为例):
设计师:这个左边距调多 2 px,这个上边距调少 2 px,这 2 个 view 之间间距调大点,多 2 px 吧,这个文本字体调大一号。
开发:好的,我马上调。(我一顿操作,调整约束值,...)
======== 过了 1 天 ==========
设计师:这个样式有点问题,整体样式我重新设计了一下,你调一下(给了我最新的设计稿)
开发:这个样式调整有点大啊,各种约束都不一样了,你确定要改吗?
设计师:确定。(我一顿操作,删除旧约束代码,添加新约束代码,...)
======== 又过了 1 天 ==========
设计师:这个样式,老板看后和之前对比,觉得还是之前样式好,你换回来吧。
开发:.......
还有一种情况,一个视图在不同地方显示的布局样式是不一样的,这种视图样式配置是非常繁琐的,就像我们使用 ObjC 的 decode
和 encode
代码一样,都是必须但又是无脑的(体力活),我就想搞个东西方便配置视图样式,从这个过程中解脱出来
方案思考
全局配置样式
通过全局变量进行配置(之前的做法):
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哈哈~,求关注求赞