封装Auto Layout

888 阅读2分钟

「这是我参与11月更文挑战的第2天,活动详情查看:2021最后一次更文挑战」。

通过上篇文章我们可以知道尽管 Auto Layout 的 API 多年来得到了极大的改进 - 特别是在 iOS 9 中引入了布局锚点,但是它仍然非常冗长和繁重。今天让我们来做一个简单的封装尝试:

效果

先将代码效果展示出来: 原来的我们用原生的Auto Layout 的 API写法的一个例子:

label.translatesAutoresizingMaskIntoConstraints = false 
 NSLayoutConstraint.activate([
   label.topAnchor.constraint( 
     equalTo: button.bottomAnchor, 
     constant: 20 
   ),    
   label.leadingAnchor.constraint( 
     equalTo: button.leadingAnchor
   ), 
   label.widthAnchor.constraint( 
      lessThanOrEqualTo: view.widthAnchor, 
      constant: -40 
    ) 
  ])

经过我们的封装变成如下写法:

label.layout { 
   $0.top == button.bottomAnchor + 20 
   $0.leading == button.leadingAnchor 
   $0.width <= view.widthAnchor - 40 
}

通过上下一对比,是不是写法简单好多,而且让我们更容易地去布局约束了。那让我们去实现吧。

实现

我们本质上想要做的是将 Auto Layout 的默认基于NSLayoutAnchor API 封装起来(iOS9 ),让它在调用时仍然会产生完全正常的布局约束效果。那么所有布局锚点都使用NSLayoutAnchor这样一个通用类实现,因为不同的锚点根据它们是否用于定位或大小之类的事情而表现不同而已。

首先定义一个协议来实现如下需求(其他类似的需求可以自己添加,方法名最好和系统的保持一致):

protocol LayoutAnchor { 
   func constraint(equalTo anchor: Self, constant: CGFloat) -> NSLayoutConstraint 
   func constraint(greaterThanOrEqualTo anchor: Self, constant: CGFloat) -> NSLayoutConstraint 
   func constraint(lessThanOrEqualTo anchor: Self, constant: CGFloat) -> NSLayoutConstraint 
}

由于NSLayoutAnchor已经实现了上述方法,需要做的就是使其符合我们的新协议,只需添加一个空扩展:

extension NSLayoutAnchor: LayoutAnchor {}

接下来,我们需要一种以更简单的方式引用单个锚点的方法。要做到这一点,我们要定义一个LayoutProperty类型,以DSL方式用来建立topleadingwidth等特性的约束。

struct LayoutProperty<Anchor: LayoutAnchor> { 
   fileprivate let anchor: Anchor 
}

上面的anchor属性fileprivate,这样它只能在我们定义布局的文件中访问,防止将实现细节暴露给外部。

接下来我们需要设计一个LayoutProxy对象来充当为当前视图定义布局的的代理,该对象应该包含所有的布局属性,如常见锚点的属性比如leading,topwidth

class LayoutProxy { 
   lazy var leading  = property(with: view.leadingAnchor) 
   lazy var trailing = property(with: view.trailingAnchor) 
   lazy var top = property(with: view.topAnchor) 
   lazy var bottom = property(with: view.bottomAnchor) 
   lazy var width = property(with: view.widthAnchor) 
   lazy var height = property(with: view.heightAnchor) 
   
   private let view: UIView 
   
   fileprivate init(view: UIView) {                
       self.view = view 
   } 
   
   private func property<A: LayoutAnchor>(with anchor: A) -> LayoutProperty<A> { 
       return LayoutProperty(anchor: anchor) 
   } 
}

上面我们写了所有布局属性lazy特性,是为了以便我们仅在需要时才构造它们。特别是如果我们在不断添加对更多类型锚点的支持情况下。

接下来使用布局属性来添加约束。

extension LayoutProperty { 

  func equal(to otherAnchor: Anchor, offsetBy constant: CGFloat = 0) { 
    anchor.constraint( equalTo: otherAnchor, constant: constant).isActive = true 
  } 
    
  func greaterThanOrEqual(to otherAnchor: Anchor, offsetBy constant: CGFloat = 0) { 
     anchor.constraint(greaterThanOrEqualTo: otherAnchor, constant: constant).isActive = true 
  } 
  
  func lessThanOrEqual(to otherAnchor: Anchor, offsetBy constant: CGFloat = 0) { 
    anchor.constraint(lessThanOrEqualTo: otherAnchor, constant: constant).isActive = true
  } 
}

现在我们已经可以用LayoutProxy来为定义布局的视图手动创建一个实例,然后调用其布局属性上的方法来添加约束,如下所示:

let proxy = LayoutProxy(view: label) 
proxy.top.equal(to: button.bottomAnchor, offsetBy: 20)
proxy.leading.equal(to: button.leadingAnchor)
proxy.width.lessThanOrEqual(to: view.widthAnchor, offsetBy: -40)

这样与最开始的自动布局 API 相比,是不是已经大大减少了冗长!但是看着是不是还是没有第三方库SnapKit那样的效果。那么让我们继续优化吧。

接下来我们为UIView添加一个扩展,让它添加了一个方法,采用闭包的方式来反过来调用视图本身。我们还将借此机会自动设置translatesAutoresizingMaskIntoConstraintsfalse,这进一步使我们的 API 更易于使用,如下所示:

extension UIView { 
   func layout(using closure: (LayoutProxy) -> Void) {    
       translatesAutoresizingMaskIntoConstraints = false
       closure(LayoutProxy(view: self)) 
   }
}

通过写这个一个小小的扩展我们又可以将代码优化如下:

label.layout { 
   $0.top.equal(to: button.bottomAnchor, offsetBy: 20)
   $0.leading.equal(to: button.leadingAnchor)
   $0.width.lessThanOrEqual(to: view.widthAnchor, offsetBy: -40) 
}

这样是不是应该很像我们最后优化的样子了。基本上只用几十行代码就构建了一个代码已读且易于使用的自动布局库(当然是跟官方的布局代码相比哟)。

下一步我们需要自定义操作符来改进我们的布局文件—从重载+-运算符开始,使我们能够将布局锚点和常量组合成一个元组——稍后让我们将它们作为一个单元进行操作:

func +<A: LayoutAnchor>(lhs: A, rhs: CGFloat) -> (A, CGFloat) {
   return (lhs, rhs) 
} 

func -<A: LayoutAnchor>(lhs: A, rhs: CGFloat) -> (A, CGFloat) {
   return (lhs, -rhs) 
}

接下来,让我们添加重载,让我们真正定义约束——从 == 运算符开始。我们需要两个重载,一个是右侧只有锚点,另一个右侧除了锚点还有offset:

func ==<A: LayoutAnchor>(lhs: LayoutProperty<A>,
                        rhs: (A, CGFloat)) {
   lhs.equal(to: rhs.0, offsetBy: rhs.1)
}

func ==<A: LayoutAnchor>(lhs: LayoutProperty<A>, rhs: A) {
   lhs.equal(to: rhs)
}

通过使用这些运算符就能用显示一开始结果展示的语法糖。让我们也对>=<=运算符执行相同的操作:

func >=<A: LayoutAnchor>(lhs: LayoutProperty<A>,
                         rhs: (A, CGFloat)) {
    lhs.greaterThanOrEqual(to: rhs.0, offsetBy: rhs.1)
}

func >=<A: LayoutAnchor>(lhs: LayoutProperty<A>, rhs: A) {
    lhs.greaterThanOrEqual(to: rhs)
}

func <=<A: LayoutAnchor>(lhs: LayoutProperty<A>,
                         rhs: (A, CGFloat)) {
    lhs.lessThanOrEqual(to: rhs.0, offsetBy: rhs.1)
}

func <=<A: LayoutAnchor>(lhs: LayoutProperty<A>, rhs: A) {
    lhs.lessThanOrEqual(to: rhs)
}

现在可以使用新的运算符重载来定义我们所有的布局约束,只需使用一开始结果展示的那样的表达式:

label.layout { 
   $0.top == button.bottomAnchor + 20 
   $0.leading == button.leadingAnchor 
   $0.width <= view.widthAnchor - 40 
}

将其与Apple Auto Layout 原生的写法示进行比较 - 差异是巨大的!😁。本文运用到了DSL自定义操作符protocol等知识,将在接来下的文章里面一一说明。

这里留下一个话题: 当发生布局约束冲突应该怎么办?以及为什么会发生布局问题?

优秀的第三方布局库: