Gotocon marin todorov cover

通过自动布局来实现 iOS 动画

GOTO Conference CPH 2015 的这次展示当中,Marin 为我们展示了如何在 Swift 中使用自动布局的 UI 来创建 UIKit 动画。他展示了在动画中使用既有的约束,还展示了通过替换约束更好实现动画的方式。此外,他还讲述了动画触发以及静态布局更新的方式及其原因,谈论了这些动画如何并且为什么能如此巧妙。最后还有福利!一个关于 iOS 9 UIStackView 的动画示例。


概述 (0:00)

我是 Marin Todorov,是一名独立开发者和作家。我的工作时间基本都花费在我的 iPhone 应用外包工作室以及名为 raywenderlich.com 的网站上面。2015 年初,我出版了一本名为《iOS Animations by Tutorials》的书,讲述 iOS 上的精妙动画效果。我同样还维护着每月更新的动画订阅号。今天在这里我想和大家谈论一下自动布局(Auto Layout)和堆栈视图(Stack View)。

在 iOS 7、8、9中动画效果变得更简单了,因为我们不再使用诸如树林之类的背景图或者线框架构来丰富我们的用户界面。现在,iOS 界面大多由文本和色块构成。我们必须找到一种新的方式,能够让我们的创意、想法、思路能够很好地展现给用户,并且还能够从其他应用中脱颖而出。比如说,这个截图可以是 Apple 内置应用中的某个界面,也可能是某人花费了数百小时所设计研发出来的效果。

动画是一种让应用脱颖而出的绝佳方式。上面的例子中,在 iOS 的联系人内置应用中就已存在同样的界面,如果不使用一些独特元素的话,是很难将其气氛出来的。如果你写过 iOS 应用的话,那么其实你至少已经使用过了一种动画。

绝大多数动画教程都以一个红色方块开始。这个方块通过 animateWithDuration 中的一行代码就可以实现移动。你只需指明动画持续的时间,以及目标动画的终值即可。比如说,UIKit API 会取出当前红色方块的位置和相关属性,然后创建一个持续 1 秒的动画,将红色方块在 X 轴上移动 200 px 的单位。

UIView.animateWithDuration(1.0, animations: {
  redSquare.center.x = 200.0
})

这个做法在 iOS 3、4、5 中也是有效的,但是当我们引入新的屏幕尺寸的时候,就大有不同了。在 X 轴 上移动红色方块 200 个单位并不意味着能够达到既有目的。我们都知道屏幕尺寸都是从 0 起始的,接着我们向右移动 200 个单位之后。比如说,在 iPhone 6、6+ 或者 5S 上面,我们就不能够知道移动 200 个单位的实际视觉效果。它很有可能靠近屏幕边缘,也可能位于屏幕中间,或者其他地方。

Receive news and updates from Realm straight to your inbox

在我们继续之前,我想要强调的一点是,在出现相同问题的时候,请一定要搞明白出现问题的原因。千万不要前去 Stack Overflow 然后点开第一条回答,想也不想直接用上。

自动布局与预自动布局大有不同 (6:17)

自动布局与预自动布局(pre-Auto Layout)大有不同。我会为大家快速展示一个例子。我期望您在本次演讲之后就会知晓如何借助自动布局来创建动画,至少要了解大概。

这里是一个既有应用中的一个故事板文件。我使用自动布局。这个模拟的应用有一个登录界面,在界面顶部有用户名和密码输入框。当用户打开用户的时候,我希望它们能够自动移到屏幕中央。在界面构造器中我已经搭建好了所有东西,将文本框居中以及建立好了约束。

接着,让我们想象一下我们到 Stack Overflow 中询问如何创建一个动画。Super Awesome Ninja Dev 回答了我的问题,“通过Duration来调用你的动画,在其中改变center属性,这么做应该有效。”我会到我的代码当中,然后在UIView.animationWithDuration 当中进行了操作,设置其持续时间为0.5秒。我此时有用两个输出口(outlet):用户名和密码。我试图对它们的center属性进行改变。由于它们的透明度为0,因此我们还需要改变透明度。

override func viewDidAppear(animated: Bool) {
  super.viewDidAppear(animated)

  UIView.animateWithDuration(0.5, animations: {
    self.fldPassword.center.y += 200
    self.fldUsername.alpha = 1.0
    self.fldPassword.alpha = 1.0
  })

  UIView.animateWithDuration(0.5, delay: 0.1, options: [], animations: {
    self.fldUsername.center.y += 200
    }, completion: nil)
}

当我输入用户名的时候就出现了问题。当我点击文本框的时候,所有的元素都会往上方跳动。这里发生了两件事情。首先,文本框回到了它们的初始位置。然后,当它们回到原始位置之后并没有淡出。自动布局为我们自行生成约束值,但是这里我们却自行修改了这些视图的位置。这不是一个好主意。本次演讲的第一个要点就是不是所有的动画都能够通过自动布局进行实现。对于透明度、背景颜色之类的动画来说,使用旧有方式是没有问题的,但是对于手动改变center之类的动画来说就不起作用了。

问题是为什么我点击文本框的时候它就会往上这样跳呢?当你在界面构造器中搭建按钮、标签、图像之类的元素,并且设置约束的时候,同时还会给这些视图上面添加解释视图之间关系的约束。最后,所有这些元素都将取决于屏幕尺寸。比如说,当键盘弹出或者当电话拨打进来的时候,视图控制器的尺寸就会发生变化。

NSLayoutConstraint继承自NSObject。这不是一种虚类。它当中并没有可怕的逻辑,这只是一个模型而已。它其中包含了决定等式的一些属性,可以用来设置等式规则。比如说,按钮应该始终居中。有一点很重要的是,你在界面构造器中所设计的约束都是模型-数据对象,包含了许多信息。它们并不会通过UIKit 绘制任何东西,也不会移动视图,只有自动布局才会这样做。当元素需要在屏幕上显示或者定位的时候,自动布局将会获取约束列表。包括宽度、高度、X 轴中值,Y轴边缘,宽高比例,垂直距离以及垂直边缘等等。然而,当自动布局配置完你所设置的这些规则之后,就会改变视图的位置和尺寸。这就是它所做的工作。

假设当你加载应用的时候压入了一个视图控制器。首先,应用从故事板加载了视图。自动布局需要解析布局以便知晓屏幕上所应该展示元素的位置。它会解析你所有的约束,并且找到所有视图的位置。然而,当你点击文本框的时候,比如说,键盘会弹出,改变了视图控制器边缘的尺寸。这样会触发布局改变,自动布局就会改变位置。也就是说,自动布局会重新加载约束列表,基于容器尺寸重新计算它们的位置和大小。

那么我们如何正确的使用动画呢?任何在其中发生的改变都会产生动画:

UIView.animateWithDuration(1.0, animations: {
  view.center.x -= 10
})

这里可以使用诸如透明度、背景颜色、位置、尺寸之类可添加动画的属性,在这个闭包中 UIKit 会自行进行动画效果。现在,我们需要获取到自动布局的所有约束值,重新进行计算并且改变可添加动画属性的中心位置和边缘。我们所做的和之前做的基本相似,不同的是我们需要改变约束值,并且在动画闭包中让布局进行改变。让我快速给大家展示如何用代码实现这个功能。

这个是我展示如何用约束执行动画效果的下一个示例。这是一个待办事项清单应用,应用中的这个顶栏我想让它能够动态调整自己的尺寸。为了给大家更好的演示,我将打开这个故事板文件。在场景顶部有一个视图,下方是一个表视图。顶部视图被固定在场景的顶部,表视图则被固定到了场景底部,并且它们之间也添加了约束。顶部视图有一个高度为 60 px 的约束。

我们有两种通过约束执行动画的方式。第一种是在打算改变约束值的时候。比如说,你可以通过属性来编辑其高度。我知道我们要为哪个约束添加动画,因此我会将这个约束添加输出口(outlet)到视图控制器当中。由于约束是在界面构造器中添加的,因此我们可以为之添加输出口,然后通过该输出口来实时访问其约束值。我打算将其放在一个名为 menuHeight 的 IBOutlet 当中,它的类型是 NSLayoutConstraint

@IBOutlet weak var menuHeight: NSLayoutConstraint!

接着,对于其他诸如按钮、文本框之类的视图,我们可以前往故事板,找到其高度约束,然后前往连接检查器(Connections Inspector),将新的引用输出口拖到视图控制器上来建立输出口和约束之间的关联。

  @IBAction func actionToggleMenu(sender: AnyObject) {

    menuHeight.constant = 200

}

我改变了这个约束的值,由于其高度被固定为了200,因此现在还不会有任何情况出现。现在我们完全掌控了这个约束,因此动画现在就变得十分简单了。将其通过 animateWithDuration 或者弹性动画(spring animation)包裹起来。我打算使用弹性动画。这也是默认的 UIKit API 之一。一切都很正常:

UIView.animateWithDuration(1.0, delay: 0.0,
  usingSpringWithDamping: 0.33, initialSpringVelocity: 0,
  options: [], animations: {

    self.view.layoutIfNeeded()

  }, completion: nil)

如我的幻灯片上所说,同时这也是我想强调的一点,就是在代码中任何一处改变约束值,然后在动画闭包当中让自动布局自行处理。在我们的代码当中,我们改变了布局的中心位置和边缘尺寸之类的东西。改变约束值其实并没有实际改变什么,它并不做任何事情。让我们仔细看一下这个。这里我的约束进行了更新,然后在这个动画闭包当中,我们调用了 view.layoutIfNeeded(),这让自动布局功能立即强制重新查看所有的约束,如果有约束值发生了变化,那么就会重新计算并且改变视图的位置和大小。这就是为什么这个方法称之为 layoutIfNeeded(),因为很可能你并没有改变什么东西,这样的话就不会执行任何改变了。当我点击这个按钮之后,已改变的约束值就会被动画显示出来。有一点很重要的就是我并没有对表视图上的任何东西进行动画操作,我只是改变了顶栏的约束而已,我并没有让表视图做操作,但是……由于顶视图和表视图之间建立了约束,因此改变其中之一的高度也会导致另一个视图的高度发生变化。我并没有明显地声明出来,我让自动布局自行解决这些问题。由于我们定下的规则是这两个视图之间有关联,因此它们都会发生变化。并且这两者的变化都会有动画效果,因为它们的边缘尺寸都在动画闭包当中被改变了。这确实很方便,现在通过这个小小的例子你就可以做几乎任何类型的约束动画了。

第二件我想展示的事情就是改变约束的倍数(multiplier)。倍数是只读的,要改变倍数的话,你必须遍历所有的约束,找到你想要的那个约束,将其移除,然后用一个新的约束来���代它。

for c in titleLabel.superview!.constraints {
  print(c)

  if c.identifier == "Center" {
    c.active = false

    let nc = NSLayoutConstraint(
      item: titleLabel,
      attribute: .CenterX,
      relatedBy: .Equal,
      toItem: titleLabel.superview!,
      attribute: .CenterX,
      multiplier: 1.5,
      constant: 0)
    nc.active = true
  }

}

在 Xcode 7 当中,有一个新的编辑约束的方式,那就是“标识符”。标识符是一种指定约束名字的方式。这个属性同样也在 iOS 8 中存在,但是 Xcode 6 并没有对其显示。虽然在 Xcode 6 中你仍然可以使用它,但是这样的话你就需要使用用户定义者(user definer)和时间特性(time attribute)来设置该标识符。在 Cocoa 当中,你必须设置委托,为之建立关系,而不是使用这个 API。通过这个 API,只要你将约束的 active 的属性设置为 false,下次自动布局进行计算的时候,它会发现“这个约束没被激活,我不需要它”。对于添加新约束来说也是一样的。当你将其设置为“激活”的时候,下次自动布局进行计算的时候就会发现“这个约束被激活了”,然后将会将其加入到约束列表当中。这个 API 是 NSLayoutConstraint 的静态方法,你可以同时添加多个约束,它会遍历所有的约束并将其激活。

这两种是使用约束建立动画的简单方式。添加输出口并改变约束值,或者通过移走旧有约束然后添加新约束来改变倍数的方法。最重要的是,需要的时候在动画闭包当中调用 layoutIfNeeded()

堆栈视图 (38:12)

堆栈视图(Stack View)可以被称为“没有约束的自动布局”。这是一种不通过改变约束值就可以实现动画的方式。我用另一个示例项目来演示堆栈视图。假设这个项目是一个商店类应用。它会为用户展示商店所有的书籍清单,也可以允许用户进行购买。这个应用包含了关于书籍的列表,当用户点击详细视图控制器的时候,就会看到带有额外信息的书籍封面。

import UIKit

class DetailViewController: UIViewController {

  var book: MasterViewController.Book!

  @IBOutlet weak var cover: UIImageView!
  @IBOutlet weak var name: UILabel!
  @IBOutlet weak var year: UILabel!

  @IBOutlet weak var topStack: UIStackView!

  override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)
    configureView()

    view.addGestureRecognizer(
      UITapGestureRecognizer(target: self, action: "didTap")
    )
  }

  func didTap () {
    UIView.animateWithDuration(0.4, animations: {
      self.topStack.arrangedSubviews.last!.hidden = !self.topStack.arrangedSubviews.last!.hidden
      })

  }

  func configureView() {
    //视图配置
    if cover != nil {
      cover.image = UIImage(named: "\(book.imageName).jpg")
    }
    if name != nil {
      name.text = book.title
    }
    if year != nil {
      year.text = book.year
    }
  }
}

我想做的就是弄一些用来显示诸如书名、发布日期之类信息的文本。我打算用一个标签来显示这本书的名称。接着,再显示另一个书名。堆栈视图是一种集合大量视图的方式,并且要集合的这些视图的关系基本不会改变。有一个名为axis的属性,用来决定堆栈视图是水平方向还是垂直方向建立堆栈。此外,还有一个alignment属性用来决定堆栈的位置,有顶部、底部和中央。堆栈视图将保留放置在其中视图的引用,并且还会被视图属性所影响。堆栈视图将为其中的视图进行约束值管理,因此我们就不必改变这些视图的约束值。它会自动为我们创建好约束,因此视图将会按照我们所想的方式进行排列。

About the content

This talk was delivered live in October 2015 at goto; Copenhagen. The video was transcribed by Realm and is published here with the permission of the conference organizers.

Marin Todorov

Marin Todorov is an independent iOS consultant and publisher. He’s co-author on the book “RxSwift: Reactive programming with Swift” the author of “iOS Animations by Tutorials”. He’s part of Realm and raywenderlich.com. Besides crafting code, Marin also enjoys blogging, writing books, teaching, and speaking. He sometimes open sources his code. He walked the way to Santiago.

4 design patterns for a RESTless mobile integration »

close