164e3d6e5303fbdb266a61d9913c3fd8
高级开发应该掌握的自动布局技术

作者:谢涛,天天果园 iOS 工程师

构建 app 时使用的自动布局技术,其实就是建立视图与视图之间关系。而约束是建立视图间关系的纽带,帮助我们的 app 可以适应各种尺寸的屏幕,在应对花样百出的布局需求时游刃有余。

前言

如果你以前从未使用过Autolayout,现在网上已经有很多很优秀的教程,包括往届 WWDC 中 sessions 视频资源都可供查看学习。在本文中将不再重复基本的使用方法,更多的去介绍一些更加复杂的场景中的应用,本文中技术结合实例使你更容易理解吸收。让我们一起来看看与Autolayout相关的六种技术与应用,这些内容都非常实用,在日常开发中一定会经常使用到,相信本文一定不会让你失望。

1. 运行时变换布局(Changing layout at runtime)

通常我们不仅可以在 app 中使用约束来对视图进行简单的定位,也可以组合使用以达到更复杂的效果。我们今天要讲的第一种技术点,便在运行时改变布局。如下图,在我们界面的顶部,有一个滑块区域。现在我们需要一个将滑块视图上移并且最终隐藏的功能。

1.1 利用高度约束隐藏视图

通常我们希望约束在设置好之后不需要再次调整,尽量让结构清晰简单。现在我们来思考一下,从布局的角度使用最简单的方式实现这个功能,一般情况下,我们把这个区域视图高度缩短至0即可。但是如果我们真的添加上一个高度约束,并且设置为0。我们将在 Interface Builder 中发现一些警告。

1.2 避免冲突

在图片中可以看到布局中的这些红线,这意味着我们设置的约束存在着一些冲突。之所以出现冲突,是因为我们设置的这些约束让布局引擎去做了一些不能同时并存的事情。而这个冲突出现是因为我们设置了高度为0的同时,无法保持足够的高度以满足该控件内部的内容显示。

为了解决这个问题,我们将 slider 和 label 所在的视图放进一个warppingView中,如图中橙色方框。在我们缩短warppingView的高度时,我们也要保证warppingView内部子视图的高度,并且满足子视图相关的约束,在启用 clips ToBounds 属性后,超出warppingView内部坐标系范围的内部控件在显示时将被裁剪掉。这样就达到了隐藏视图元素的效果,如下图效果,灰色区域将被裁剪不显示。

让我们来看看在 Xcode 中是如何做到的,我们需要在运行时控制warppingView的高度,所以我们将为warppingView手动创建一个高度约束zeroHeightConstraint,在运行时设置zeroHeightConstraint为0,并且在用户点击 Edit 按钮时,激活该约束。这样我们仍然会和之前一样出现冲突的情况,我们需要将滑块区域视图底部到warppingView底部边缘的约束禁用,避免了约束冲突,这样warppingView就可以正常缩短高度了。

1.3 实现代码

接下来看看完整代码,在我们控制器的子类中,我们持有3个属性:

  • warppingView:外部容器视图
  • edgeConstraint:底部边缘的约束
  • zeroHeightConstraint:一个存储0高度约束的属性
@IBOutlet var warppingView: UIView!
@IBOutlet var edgeConstraint: NSLayoutConstraint!
var zeroHeightConstraint : NSLayoutConstraint!

我们创建了按钮点击事件,在响应按钮事件函数中,我们首先要保证zeroHeightConstraint已被创建。接着我们还希望这一个事件让视图可以在显示和隐藏间切换,所以我们要对一些约束做禁用和激活操作,做完这些就会得到我们想要的切换效果。

@IBAction func toggleDistanceControls(_ sender: Any) {
        if zeroHeightConstraint == nil {
            zeroHeightConstraint = warppingView.heightAnchor.constraint(equalToConstant: 0)
        }

        let shouldShow = !edgeConstraint.isActive

        if shouldShow {
            zeroHeightConstraint.isActive = false
            edgeConstraint.isActive = true
        }else{
            edgeConstraint.isActive = false
            zeroHeightConstraint.isActive = true
        }
    }

需要特别注意的是,在激活一个约束前务必先禁用另外一个约束。在这些简单的切换禁用和激活代码,遵守这一点让我们避免了冲突,如果约束中一旦存在冲突,控制台就会提醒我们:嘿,我检测到这些约束是互相冲突的😂。例如,我们激活了zeroHeightConstraint约束,而底部约束edgeConstraint还未被禁用,这个时候我们就会看到控制台打印出冲突信息。

1.4 加入动画

加入这些代码重新运行后你会发现我们的界面正确显示和隐藏了,但是我还想为这个过程加上动画,让用户可以看到视图切换过程能够提高用户体验。在这里我们使用UIView animation block来实现动画,UIView animation将捕捉并且动画化整个过程。

UIView.animate(withDuration: 0.25) {
    self.view.layoutIfNeeded()
}

这里得到的动画效果,也并不是我想要的最终效果,我们还需要做最后一点调整,但是这不需要修改我们的代码,我们只需要将底部边缘的约束:edgeConstraint属性更换成连接到顶部边缘的约束,改成一个底部对齐的效果。整个动画效果发生改变,我确认这就是我需要的最终效果。

具体效果可以查看我们的Demo(非苹果官方),通过上面这些内容我们可以知道,怎样通过运行时改变约束来动态调整我们 app 中的布局。

2. 跟踪触摸手势(Tracking touch)

现在我们来看看改变布局的另一种方法,我保证它既简单又炫酷。我们将用它来跟踪触摸手势。我们在我们下图的 app 的中央区域有一张卡片,我们希望卡片能随着触摸手势移动,随着靠近边缘的时候,会有一些旋转,一旦你的手离开屏幕,卡片就会弹回屏幕中间。

2.1 frame 饮水知源

通常一个控件在屏幕上的位置由它的 frame 决定,而 frame 又源起何处呢?

  • Layout engine owns frame。当我们使用Autolayout并使用约束控制此视图时,布局引擎将会持有此视图的 frame 。
    • Value derived from constraints。frame 的值是从这些约束中计算出来的。
  • transform property offsets from frame。还有另一个属性会影响视图在屏幕上的位置,那就是 transform ,在 transform 属性源起于 frame 。
  • CGAffineTransform = translation + rotation + scale。通过CGAffineTransform,它可以帮助我们为视图加入平移 ,旋转和缩放等变换,在从约束中计算出 frame 之后,将其应用在 transform 中。

2.2 加入监听手势

再回到需求上,如果我们想要中间的卡片随着我的手势移动,那我们就要加入一个手势识别器,并且拖线连接到代码中,添加监听手势的方法,在该方法中我们可以访问手势识别器的各种属性。此外还将我们要移动的卡片也通过拖线创建了属性。

@IBOutlet weak var cardView: UIImageView!
@IBAction func panCard(_ sender: UIPanGestureRecognizer) {}

2.3 加入位移和旋转

接下来我们要通过手势识别器监听用户手势移动,得到位移结果后转换成 transform 应用在cardView上。在这里有transform函数帮我们进行了位移和轻微的旋转,这个时候,卡片将会随着你的手指移动伴随着轻微的旋转。

func transform(for translation: CGPoint) -> CGAffineTransform {
    let moveBy = CGAffineTransform(translationX:translation.x, y: translation.y)
    let rotation = -sin(translation.x/(cardView.frame.width * 4.0))
    return moveBy.rotated(by: rotation)
}

2.4 位置还原

但是当我放开手指时,卡片停留在原位,没有回到屏幕中央,因为我们并没有去重置卡片的 transform 属性。当我再次触摸并移动,我们会看到它会回到原来的位置,这是因为我们开始了一个新的位移,新的位移关联的原来的 frame 。总之这不是我想要的效果,我希望在用户手指离开屏幕后,卡片能够立即回到屏幕中间的位置。我们可以通过手势识别器的状态来做到这一点。我们在stateend的时候,将重置 transform 并且加入弹簧动画。加入这部分代码运行 app ,在我松开卡片后它会弹回中间的位置。

@IBAction func panCard(_ sender: UIPanGestureRecognizer) {
    switch sender.state {
    case .changed:
        let translation = sender.translation(in: view)
        cardView.transform = transform(for: translation)
    case .ended:
        UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.4,
initialSpringVelocity: 1.0, options: [], animations: {
            self.cardView.transform = .identity
        }, completion: nil)
    default:
        break;
    }
}

简单的几行代码,实现了一个很有意思的交互效果,在这些内容里面,我们可以看到 frame 它不仅是通过约束来计算出,也会受到 transform 的影响,视图的 frame 中蕴含多种属性的组合效果。

3. 动态字体(Dynamic type)

Dynamic type是 iOS 中提供了一组文本样式,文本样式包含了标题、副标题、正文等样式,而且用户可以控制这些样式字体大小的技术。在 iPhone 的短信消息中,如果用户喜欢大一点的字体,通过在设置中进行设置后,我们将看到消息界面会有所变化,字体变大了,消息气泡和输入文本也变大了。日历和其他一些地方也具备类似的功能。

相信这个时候你一定会好奇,如何才能在我们自己的 app 实现这个功能呢?另外在调整字体大小时,如果不相应地调整我们的布局,容易造成视图重叠,对用户体验来说是非常不好的。幸运的是,Autolayout可以很轻松地帮我们搞定这个问题。

3.1 支持 Dynamic Type

所以赶紧让我们来看看是怎么实现的吧,打开 IB 界面,选中你要支持 Dynamic Type 的 label ,查看 label 的属性,勾选automatically adjust font,如果你眼睛够敏锐的话,你能看到上面出现了一个警告,原因是因为automatically adjust font属性生效,该属性要求 label 设置指定的文本样式。这里要将系统默认字体更换为caption one,该样式和默认字体12号大小相对应。

top Created with Sketch.