iOS 布局机制
iOS 布局机制大概分这么几个层次:
- frame layout
- autoresizing
- auto layout
frame layout
即通过设置view的frame/bounds属性值进而控制view相对于superview的位置和大小;
设置view的frame/bounds一般都是根据屏幕大小计算的,最常见的做法是将屏幕宽高定义为常量,还有就是创建UIView分类实现x,y,width,height等方法直接获取view的宽高等;
let kScreenWidth = UIScreen.main.bounds.width
let kScreenHeight = UIScreen.main.bounds.height
extension UIView {
public var width:CGFloat {
get {
return self.frame.width
}set {
var rect = self.frame
rect.size.width = newValue
self.frame = rect
}
}
}
autoresizing
基于autoresizing机制,能够让subview和superview维持一定的布局关系,当superview 的size改变时,subview也会 做出相应的调整;一般用于各种屏幕的适配以及横竖屏的适配;
可以通过UIView的属性autoresizingMask实现autoresizing:
public struct AutoresizingMask : OptionSet {
public init(rawValue: UInt)
// 自动调整view与父视图左边距,以保证右边距不变
public static var flexibleLeftMargin: UIView.AutoresizingMask { get }
// 自动调整view的宽度,保证左边距和右边距不变
public static var flexibleWidth: UIView.AutoresizingMask { get }
// 自动调整view与父视图右边距,以保证左边距不变
public static var flexibleRightMargin: UIView.AutoresizingMask { get }
// 自动调整view与父视图上边距,以保证下边距不变
public static var flexibleTopMargin: UIView.AutoresizingMask { get }
// 自动调整view的高度,以保证上边距和下边距不变
public static var flexibleHeight: UIView.AutoresizingMask { get }
// 自动调整view与父视图的下边距,以保证上边距不变
public static var flexibleBottomMargin: UIView.AutoresizingMask { get }
}
横竖屏适配的场景:
let view = UIView.init()
view.backgroundColor = UIColor.blue
view.frame = CGRect.init(x: 0, y: 0, width: kScreenWidth, height: 200)
view.autoresizingMask = .flexibleWidth
self.view.addSubview(view)
autoresizingMask也可以设置多种:
view.autoresizingMask = [.flexibleWidth,.flexibleBottomMargin]
作用是:view的宽度按照父视图的宽度比例进行缩放,距离父视图顶部距离不变
auto layout
基于 autoresizing 机制,我们只能处理subview和superview的关系,而无法处理兄弟view 之间的关系,也无法反向处理,如让superview依据subview的大小进行调整。而auto layout能很好的处理各种关系,它是一种基于约束的布局系统,可以根据元素上设置的约束自动调整元素的位置和大小。
autoresizing 和 auto layout 只能二选一,若要对某个view 采用auto layout布局,则需要设置其translatesAutoresizingMaskIntoConstraints属性值为false。
代码实现auto layout大致有三种方式:
- UIKit框架提供的自动布局的NSLayoutConstraint方法
/**
设置约束
@param view1 指定需要添加约束的视图一
@param attr1 指定视图一需要约束的属性
@param relation 指定视图一和视图二添加约束的关系
@param view2 指定视图一依赖关系的视图二;attr1为height,width时可为nil
@param attr2 指定视图一所依赖的视图二的属性,若view2=nil,该属性设置notAnAttribute
@param multiplier 视图一相对视图二约束系数
@param c 视图一相对视图二约束偏移量
@return 返回生成的约束对象
*/
public convenience init(item view1: Any, attribute attr1: NSLayoutConstraint.Attribute, relatedBy relation: NSLayoutConstraint.Relation, toItem view2: Any?, attribute attr2: NSLayoutConstraint.Attribute, multiplier: CGFloat, constant c: CGFloat)
let view = UIView.init()
view.backgroundColor = UIColor.blue
self.view.addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
let constraint1 = NSLayoutConstraint.init(item: view, attribute: .leading, relatedBy: .equal, toItem: self.view, attribute: .leading, multiplier: 1, constant: 0)
let constraint2 = NSLayoutConstraint.init(item: view, attribute: .top, relatedBy: .equal, toItem: self.view, attribute: .top, multiplier: 1, constant: 0)
let constraint3 = NSLayoutConstraint.init(item: view, attribute: .trailing, relatedBy: .equal, toItem: self.view, attribute: .trailing, multiplier: 1, constant: 0)
let constraint4 = NSLayoutConstraint.init(item: view, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: 200)
self.view.addConstraints([constraint1,constraint2,constraint3,constraint4])
- VFL(Visual Format Language)
VFL是一种声明性语言,VFL允许你通过一个格式化后的代码字符串迅速定义视图的自动布局约束。
VFL语法
/**
VFL设置约束
@param format VFL语句,如:“H:|-0-[view]-0-|”
@param opts 描述VFL中的所有对象的属性和布局的方向,默认directionLeadingToTrailing
@param metrics VFL语句中使用到的变量,key值为VFL语句中的变量名
@param views VFL语句中使用到的视图
*/
open class func constraints(withVisualFormat format: String, options opts: NSLayoutConstraint.FormatOptions = [], metrics: [String : Any]?, views: [String : Any]) -> [NSLayoutConstraint]
let view = UIView.init()
view.backgroundColor = UIColor.blue
self.view.addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
// H:水平方向,V:垂直方向;|父视图,
let constraint1 = NSLayoutConstraint.constraints(withVisualFormat: "H:|-0-[view]-0-|", options: .directionLeadingToTrailing, metrics: nil, views: ["view":view])
let constraint2 = NSLayoutConstraint.constraints(withVisualFormat: "V:|-0-[view(height)]", options: .directionLeadingToTrailing, metrics: ["height" : 200], views: ["view":view])
self.view.addConstraints(constraint1)
self.view.addConstraints(constraint2)
- 第三方库 SnapKit
auto layout反向处理
auto layout能反向处理,如让superview依据subview的大小进行调整:
let superview = UIView.init()
superview.backgroundColor = UIColor.orange
self.view.addSubview(superview)
let subview1 = UIView.init()
subview1.backgroundColor = UIColor.red
superview.addSubview(subview1)
let subview2 = UIView.init()
subview2.backgroundColor = UIColor.blue
superview.addSubview(subview2)
superview.snp.makeConstraints { (make) in
make.leading.equalTo(10)
make.top.equalTo(20)
make.width.equalTo(100)
}
subview1.snp.makeConstraints { (make) in
make.leading.top.equalToSuperview().offset(10)
make.trailing.equalToSuperview().offset(-10)
make.height.equalTo(100)
}
subview2.snp.makeConstraints { (make) in
make.leading.trailing.height.equalTo(subview1)
make.top.equalTo(subview1.snp.bottom).offset(10)
make.bottom.equalToSuperview().offset(-10)
}
auto layout获取布局前size
在布局完成前,我们不能通过view.frame.size获取view的 size(这时size为{0,0})。有时候,我们需要在auto layout system对view完成布局前就知道它的size,例如UITableViewCell需要回调tableView:heightForRowAtIndexPath:返回每行的高度,这时可以使用systemLayoutSizeFittingSize:
方法实现:
self.view.addSubview(label)
label.snp.makeConstraints { (make) in
make.leading.top.equalToSuperview().offset(20)
}
let size = label.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
自适应布局
自适应布局,即控件size由其content动态决定;具有content的控件如UILabel、UIButton等能直接计算出content(如UILabel的text、UIButton的title,UIImageView的image)的大小。
auto layout system在布局时,如果不知道该为view分配多大的size,就会回调view的intrinsicContentSize
方法,该方法会给auto layout system一个合适的size,system根据此 size对view的大小进行设置。对于UILabel这类控件,intrinsicContentSize返回的是根据其content计算出的size。有些view不包含content,例如UIView,这种 view 被认为has no intrinsic size
,它们的intrinsicContentSize返回的值是(-1,-1);
UIScrollView及其子类,虽然也包含 content,由于它们是滚动的,auto layout system在对这类view进行布局时总会存在一些未定因素,这些 view的intrinsicContentSize也直接返回(-1,-1);
因此,对于能返回正确的intrinsicContentSize的view,auto layout可以使view自适应:
let label = UILabel.init()
label.backgroundColor = UIColor.orange
label.translatesAutoresizingMaskIntoConstraints = false
label.text = "chang a chang"
self.view.addSubview(label)
let constraint1 = NSLayoutConstraint.init(item: label, attribute: .leading, relatedBy: .equal, toItem: self.view, attribute: .leading, multiplier: 1, constant: 10)
let constraint2 = NSLayoutConstraint.init(item: label, attribute: .top, relatedBy: .equal, toItem: self.view, attribute: .top, multiplier: 1, constant: 20)
self.view.addConstraints([constraint1,constraint2])
对于多行文字的UILabel,需要指定Label的宽度才能自适应:
指定Label的宽度的两种方式:设置preferredMaxLayoutWidth 属性:
label.numberOfLines = 0
label.text = "chang a chang chang a chang chang a chang chang a chang chang a chang"
self.view.addSubview(label)
let constraint1 = NSLayoutConstraint.init(item: label, attribute: .leading, relatedBy: .equal, toItem: self.view, attribute: .leading, multiplier: 1, constant: 10)
let constraint2 = NSLayoutConstraint.init(item: label, attribute: .top, relatedBy: .equal, toItem: self.view, attribute: .top, multiplier: 1, constant: 20)
self.view.addConstraints([constraint1,constraint2])
label.preferredMaxLayoutWidth = 100
设置label宽度约束:
let constraint1 = NSLayoutConstraint.init(item: label, attribute: .leading, relatedBy: .equal, toItem: self.view, attribute: .leading, multiplier: 1, constant: 10)
let constraint2 = NSLayoutConstraint.init(item: label, attribute: .top, relatedBy: .equal, toItem: self.view, attribute: .top, multiplier: 1, constant: 20)
let constraint3 = NSLayoutConstraint.init(item: label, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: 100)
self.view.addConstraints([constraint1,constraint2,constraint3])
对于frame layout的控件,可以调用sizeToFit
或sizeThatFits
方法做到自适应size:
// 单行
label.x = 10
label.y = 20
label.text = "chang a chang"
label.sizeToFit()
self.view.addSubview(label)
// 多行
label.numberOfLines = 0
label.text = "chang a chang chang a chang chang a chang chang a chang chang a chang"
let size = label.sizeThatFits(CGSize(width: 100, height: 0))
label.frame = CGRect(origin: CGPoint(x: 10, y: 20), size: size)
self.view.addSubview(label)
对于intrinsicContentSize不能返回正常size的view,同样也可以使用sizeToFit
或sizeThatFits
方法手动做到自适应:
let textview = UITextView.init()
textview.backgroundColor = UIColor.orange
textview.translatesAutoresizingMaskIntoConstraints = false
textview.text = "chang a chang chang a chang chang a chang chang a chang"
self.view.addSubview(textview)
let size = textview.sizeThatFits(CGSize(width: 100, height: 0))
let constraint1 = NSLayoutConstraint.init(item: textview, attribute: .leading, relatedBy: .equal, toItem: self.view, attribute: .leading, multiplier: 1, constant: 10)
let constraint2 = NSLayoutConstraint.init(item: textview, attribute: .top, relatedBy: .equal, toItem: self.view, attribute: .top, multiplier: 1, constant: 20)
let constraint3 = NSLayoutConstraint.init(item: textview, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: 100)
let constraint4 = NSLayoutConstraint.init(item: textview, attribute: .height, relatedBy: .equal, toItem: nil, attribute: .notAnAttribute, multiplier: 1, constant: size.height)
self.view.addConstraints([constraint1,constraint2,constraint3,constraint4])
setNeedsLayout和layoutIfNeeded
setNeedsLayout
的作用就是标记了一个flag,表示该控件需要刷新布局,此方法记录调整的布局请求并立即返回,它不会强制立即更新,而是会等待下一个更新周期才进行刷新页面布局;调整视图的子视图的布局时,系统默认会在主线程调用此方法的;我们也可以手动调用这个方法,标记该控件是需要刷新的,这样可以将所有的布局更新合并到一个更新周期,适合用来优化性能。
layoutIfNeeded
,调用该方法会立即更新所有标记了flag的控件布局;
setNeedsLayout和layoutIfNeeded一般都是配合使用的,控件先调用setNeedsLayout再调用layoutIfNeeded就能实现立即更新布局的需求;(在控件第一次显示之前,肯定是有flag的,所以不用setNeedsLayout直接调用layoutIfNeeded也会进行立即更新);
之前一节中的auto layout获取布局前size使用layoutIfNeeded方法同样能获取到size:
label.snp.makeConstraints { (make) in
make.leading.top.equalToSuperview().offset(20)
}
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
print(label.frame) // (20.000000000000007, 20.0, 112.66666666666667, 20.333333333333332)
在使用Autolayout时,动画的使用和以前也不同了,frame layout时我们是修改frame,现在我们可以通过修改约束, 然后在动画时调用layoutIfNeeded就行了:
override func touchesBegan(_ touches: Set, with event: UIEvent?) {
self.label?.snp.remakeConstraints { (make) in
make.leading.top.equalToSuperview().offset(20)
make.height.equalTo(200)
}
UIView.animate(withDuration: 0.5) {
self.view.layoutIfNeeded()
}
}
布局优先级
抗拉伸、抗压缩优先级
-
- 抗拉伸
在讲抗拉、抗压优先级前,我们之间看一个示例:
let titleLbl = UILabel()
titleLbl.backgroundColor = .orange
titleLbl.text = "标题xxxxxxx"
view.addSubview(titleLbl)
let timeLbl = UILabel()
timeLbl.backgroundColor = .orange
timeLbl.text = "时间2019-01-01"
view.addSubview(timeLbl)
titleLbl.snp.makeConstraints { make in
make.leading.equalTo(15)
make.centerY.equalToSuperview()
}
timeLbl.snp.makeConstraints { make in
make.leading.equalTo(titleLbl.snp.trailing).offset(10)
make.trailing.equalTo(-15)
make.centerY.equalTo(titleLbl)
}
只是一个常用的 左右布局 界面; 实际效果如下:
因为约束的影响,左边的标题label并不是自适应宽度的,宽度远远长过实际字符长度;
这种现象称为被拉伸了;抗拉伸优先级顾名思义就是控制哪个控件优先不被拉伸;
上述示例默认情况,titleLbl的抗拉伸优先级低于timeLbl的抗拉伸优先级,因此titleLbl被优先拉伸,timeLbl没有拉伸;
可通过代码调整优先级:
open func setContentCompressionResistancePriority(_ priority: UILayoutPriority, for axis: NSLayoutConstraint.Axis)
titleLbl.setContentHuggingPriority(.defaultHigh, for: .horizontal)
timeLbl.setContentHuggingPriority(.defaultLow, for: .horizontal)
增加上述代码后,实际效果:
如果label的背景颜色和父视图背景颜色一样的,只修改timeLbl的对齐方式为.right,实际上的效果和不修改抗拉伸优先级是一样的;
但是这只是这种简单布局的情况,如果控件多了,还是必须得设置优先级;
比如在标题后加个按钮,按钮固定显示贴在标题文字右边;
let button = UIButton(type: .detailDisclosure)
view.addSubview(button)
button.snp.makeConstraints { make in
make.leading.equalTo(titleLbl.snp.trailing).offset(5)
make.centerY.equalTo(titleLbl)
make.width.height.equalTo(30)
}
默认情况、和修改优先级后的实际效果:
- 抗压缩
以上的示例是比较理想的情况:左右两边的文字都是在长度范围内;
如果标题比较长的情况,默认情况实际效果如下:
标题显示完全了,占用了大部分长度;
因此时间则只能显示部分了,远远少于实际长度,这种现象称为被拉伸了;
同样也可以代码跳转抗压缩优先级:
open func setContentCompressionResistancePriority(_ priority: UILayoutPriority, for axis: NSLayoutConstraint.Axis)
修改后的效果:
约束优先级
NSLayoutConstraint提供了priority属性,以控制同一约束的优先级;
/* If a constraint's priority level is less than required, then it is optional. Higher priority constraints are met before lower priority constraints.
Constraint satisfaction is not all or nothing. If a constraint 'a == b' is optional, that means we will attempt to minimize 'abs(a-b)'.
This property may only be modified as part of initial set up or when optional. After a constraint has been added to a view, an exception will be thrown if the priority is changed from/to NSLayoutPriorityRequired.
*/
open var priority: UILayoutPriority
snpKit基于此,也封装了该功能:
@discardableResult
public func priority(_ amount: ConstraintPriority) -> ConstraintMakerFinalizable {
self.description.priority = amount.value
return self
}
约束优先级应用场景
动态布局:当其中一个控件不需要显示,其余控件自动调整约束;
示例:
有3个控件紧密并排显示;但是某些情况需要不显示中间绿色的控件,要求其余2个控件还是紧密并排;
一个粗暴的做法就是,加个判断写2个布局;那如果要求是不显示第一个红色控件,其余2个自动对齐左边,又需要写一堆判断;
简单有效的做法,就是使用priority,约束自动根据优先级更新
rBox.backgroundColor = .red
gBox.backgroundColor = .green
bBox.backgroundColor = .blue
view.addSubview(rBox)
view.addSubview(gBox)
view.addSubview(bBox)
rBox.snp.makeConstraints { make in
make.top.equalTo(100)
make.leading.equalTo(10)
make.width.height.equalTo(100)
}
gBox.snp.makeConstraints { make in
make.centerY.equalTo(rBox)
make.width.height.equalTo(rBox)
make.leading.equalTo(rBox.snp.trailing).offset(10)
make.leading.equalTo(10).priority(.low)
}
bBox.snp.makeConstraints { make in
make.centerY.equalTo(rBox)
make.width.height.equalTo(rBox)
make.leading.equalTo(gBox.snp.trailing).offset(10)
make.leading.equalTo(rBox.snp.trailing).offset(10).priority(.medium)
make.leading.equalTo(10).priority(.low)
}
不显示哪个控件,就直接删除哪个控件的约束(或直接删除控件),即可实现效果;
gBox.snp.removeConstraints()
// gBox.removeFromSuperview()
参考:
深入理解 Auto Layout 第一弹