使用UIViewPropertyAnimator创建自定义动画

创建动画很棒。 它们是iOS人机界面指南的重要组成部分。 动画有助于将用户的注意力吸引到重要的事物上,或者只是使应用程序变得无聊。

有几种方法可以在iOS中实现动画。 可能最流行的方法是使用UIView.animate(withDuration:animations :) 。 您可以使用CABasicAnimation为图像图层设置动画。 另外,UIKit允许您使用UIViewControllerTransitioningDelegate配置自定义动画以显示控制器。

在本文中,我想讨论另一种激动人心的视图动画方法-UIViewPropertyAnimator 。 此类提供了比其前身UIView.animat e更多的管理功能。 使用它来创建临时的,交互式的和间断的动画。 此外,可以快速更改动画制作器。

介绍UIViewPropertyAnimator


UIViewPropertyAnimatoriOS 10中引入的。 它允许您以面向对象的方式创建动画。 我们来看一个使用UIViewPropertyAnimator创建的动画的示例

图片

使用UIView时就是这样。

UIView.animate(withDuration: 0.3) { view.frame = view.frame.offsetBy(dx: 100, dy: 0) } 

这是使用UIViewPropertyAnimator的方法

 let animator = UIViewPropertyAnimator(duration:0.3, curve: .linear) { view.frame = view.frame.offsetBy(dx:100, dy:0) } animator.startAnimation() 

如果需要检查动画,只需创建一个Playground并运行以下代码即可。 这两个代码片段将导致相同的结果。

图片

您可能会认为在此示例中并没有太大区别。 那么,添加新的动画制作方式有什么意义呢? 当您需要创建交互式动画时, UIViewPropertyAnimator变得更加有用。

互动和中断的动画


您还记得经典的“手指滑动即可解锁设备”手势吗? 或“从下到上在屏幕上移动手指”手势来打开控制中心? 这些是交互式动画的绝佳示例。 您可以开始用手指移动图像,然后释放它,图像将返回其原始位置。 此外,您可以在动画过程中捕获图像,然后继续用手指移动它。

UIView动画不提供控制动画完成百分比的简便方法。 您不能在循环的中间暂停动画,并在中断后继续执行动画。

在这种情况下,我们将讨论UIViewPropertyAnimator 。 接下来,我们将研究如何通过几个步骤轻松地创建完全交互的,中断的动画和反向动画。

准备启动项目


首先,您需要下载入门项目 。 打开档案后,您将找到CityGuide应用程序,该应用程序可帮助用户计划假期。 用户可以滚动浏览城市列表,然后打开包含有关他喜欢的城市的详细信息的详细说明。

在开始创建精美的动画之前,请考虑项目的源代码。 通过在Xcode中打开项目,您可以在项目中找到以下内容:

  1. ViewController.swift :具有UICollectionView的主应用程序控制器,用于显示City对象的数组。
  2. CityCollectionViewCell.swift:显示City的单元格。 实际上,本文中的大多数更改都将应用于此类。 您可能会注意到在类中已经定义了descriptionLabelcloseButton 。 但是,启动应用程序后,这些对象将被隐藏。 不用担心,稍后会看到它们。 此类还具有collectionViewindex属性。 以后,它们将用于动画。
  3. CityCollectionViewFlowLayout.swift:此类负责水平滚动。 我们不会更改它。
  4. City.swift :主要应用程序模型具有ViewController中使用的方法。
  5. Main.storyboard:可以在其中找到ViewControllerCityCollectionViewCell的用户界面。

让我们尝试构建并运行示例应用程序。 结果,我们获得了以下内容。

cityguideapp-iphone8

实施展开和折叠动画


启动应用程序后,将显示城市列表。 但是用户无法与单元形式的对象进行交互。 现在,当用户单击其中一个单元格时,需要显示每个城市的信息。 看一下应用程序的最终版本。 这是实际需要开发的内容:

图片

动画看起来不错,不是吗? 但是这里没有什么特别的,只是UIViewPropertyAnimator的基本逻辑。 让我们看看如何实现这种动画。 创建一个collectionView方法(_:didSelectItemAt) ,将以下代码片段添加到ViewController文件的末尾:

 func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let selectedCell = collectionView.cellForItem(at: indexPath)! as! CityCollectionViewCell selectedCell.toggle() } 

现在我们需要实现toggle方法。 让我们切换到CityCollectionViewCell.swift并实现此方法。

首先,在声明CityCollectionViewCell类之前,将State枚举添加到文件顶部。 此清单使您可以跟踪单元的状态:

 private enum State { case expanded case collapsed var change: State { switch self { case .expanded: return .collapsed case .collapsed: return .expanded } } } 

CityCollectionViewCell类中添加一些属性来控制动画:

 private var initialFrame: CGRect? private var state: State = .collapsed private lazy var animator: UIViewPropertyAnimator = { return UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) }() 

initialFrame变量用于存储单元格帧,直到动画运行state用于跟踪单元格是展开还是折叠。 动画变量用于控制动画。

现在添加toggle方法并从close方法中调用它,例如:

 @IBAction func close(_ sender: Any) { toggle() } func toggle() { switch state { case .expanded: collapse() case .collapsed: expand() } } 

然后我们再添加两个方法: expand()崩溃() 。 我们将继续执行它们。 首先,我们从expanded()方法开始:

 private func expand() { guard let collectionView = self.collectionView, let index = self.index else { return } animator.addAnimations { self.initialFrame = self.frame self.descriptionLabel.alpha = 1 self.closeButton.alpha = 1 self.layer.cornerRadius = 0 self.frame = CGRect(x: collectionView.contentOffset.x, y:0, width: collectionView.frame.width, height: collectionView.frame.height) if let leftCell = collectionView.cellForItem(at: IndexPath(row: index - 1, section: 0)) { leftCell.center.x -= 50 } if let rightCell = collectionView.cellForItem(at: IndexPath(row: index + 1, section: 0)) { rightCell.center.x += 50 } self.layoutIfNeeded() } animator.addCompletion { position in switch position { case .end: self.state = self.state.change collectionView.isScrollEnabled = false collectionView.allowsSelection = false default: () } } animator.startAnimation() } 

多少代码。 让我一步一步地解释正在发生的事情:

  1. 首先,检查collectionViewindex是否不等于零。 否则,我们将无法开始动画。
  2. 接下来,通过调用animator.addAnimations开始创建动画
  3. 接下来,保存当前帧,用于在卷积动画中还原它。
  4. 然后,我们为descriptionLabelcloseButton设置alpha值以使其可见。
  5. 接下来,删除圆角并为单元格设置一个新框架。 该单元将以全屏显示。
  6. 接下来,我们移动相邻的单元格。
  7. 现在调用animator.addComplete()方法以禁用集合图像的交互。 这样可以防止用户在单元扩展过程中滚动它。 还要更改单元格的当前状态。 仅在动画结束之后,更改单元的状态非常重要。

现在添加一个卷积动画。 简而言之,只是我们将单元格还原到了先前的状态:

 private func collapse() { guard let collectionView = self.collectionView, let index = self.index else { return } animator.addAnimations { self.descriptionLabel.alpha = 0 self.closeButton.alpha = 0 self.layer.cornerRadius = self.cornerRadius self.frame = self.initialFrame! if let leftCell = collectionView.cellForItem(at: IndexPath(row: index - 1, section: 0)) { leftCell.center.x += 50 } if let rightCell = collectionView.cellForItem(at: IndexPath(row: index + 1, section: 0)) { rightCell.center.x -= 50 } self.layoutIfNeeded() } animator.addCompletion { position in switch position { case .end: self.state = self.state.change collectionView.isScrollEnabled = true collectionView.allowsSelection = true default: () } } animator.startAnimation() } 

现在该编译并运行该应用程序了。 尝试单击单元格,您将看到动画。 要关闭图像,请单击右上角的十字图标。

添加手势处理


您可以使用UIView.animate声称达到相同的结果。 使用UIViewPropertyAnimator有什么意义

好了,该让动画变得互动了。 添加一个UIPanGestureRecognizer和一个名为popupOffset的新属性,以跟踪可以移动单元格的数量。 让我们在CityCollectionViewCell类中声明这些变量:

 private let popupOffset: CGFloat = (UIScreen.main.bounds.height - cellSize.height)/2.0 private lazy var panRecognizer: UIPanGestureRecognizer = { let recognizer = UIPanGestureRecognizer() recognizer.addTarget(self, action: #selector(popupViewPanned(recognizer:))) return recognizer }() 

然后添加以下方法来注册滑动定义:

 override func awakeFromNib() { self.addGestureRecognizer(panRecognizer) } 

现在,您需要添加popupViewPanned方法来跟踪滑动手势。 将以下代码粘贴到CityCollectionViewCell中

 @objc func popupViewPanned(recognizer: UIPanGestureRecognizer) { switch recognizer.state { case .began: toggle() animator.pauseAnimation() case .changed: let translation = recognizer.translation(in: collectionView) var fraction = -translation.y / popupOffset if state == .expanded { fraction *= -1 } animator.fractionComplete = fraction case .ended: animator.continueAnimation(withTimingParameters: nil, durationFactor: 0) default: () } } 

有三种状态。 在手势开始时,我们使用toggle方法初始化动画器,然后立即将其暂停。 当用户拖动单元格时,我们通过设置fractionComplete乘数的属性来更新动画。 这是动画师的主要魔力,可以让他们控制。 最后,当用户松开手指时,将调用continueAnimation动画师方法以继续动画。 然后,单元格将移动到目标位置。

启动应用程序后,您可以向上拖动单元以将其展开。 然后将展开的单元格向下拖动以使其折叠。

现在,动画看起来不错,但是无法在中间中断动画。 因此,要使动画完全互动,您需要添加另一个功能-中断。 用户可以像往常一样开始展开/折叠动画,但是在动画周期中用户单击单元格后,应立即暂停动画。

为此,请保存动画的进度,然后考虑此值,以便计算动画的完成百分比。

首先,在CityCollectionViewCell中声明一个新属性:

 private var animationProgress: CGFloat = 0 

然后,使用以下代码行更新popupViewPanned方法的.began块以记住进度:

 animationProgress = animator.fractionComplete 

.changed块中, 需要更新以下代码行以正确计算完成百分比:

 animator.fractionComplete = fraction + animationProgress 

现在,该应用程序已准备好进行测试。 运行项目,看看会发生什么。 如果按照我的指示正确执行了所有操作,则动画应如下所示:

图片

动画反转


您可以找到当前实现的缺陷。 如果稍微拖动单元格,然后将其返回到其原始位置,则松开手指时,单元格将继续扩展。 让我们解决此问题以使交互式动画更好。
让我们更新popupViewPanned方法的.end块,如下所述:

 let velocity = recognizer.velocity(in: self) let shouldComplete = velocity.y > 0 if velocity.y == 0 { animator.continueAnimation(withTimingParameters: nil, durationFactor: 0) break } switch state { case .expanded: if !shouldComplete && !animator.isReversed { animator.isReversed = !animator.isReversed } if shouldComplete && animator.isReversed { animator.isReversed = !animator.isReversed } case .collapsed: if shouldComplete && !animator.isReversed { animator.isReversed = !animator.isReversed } if !shouldComplete && animator.isReversed { animator.isReversed = !animator.isReversed } } animator.continueAnimation(withTimingParameters: nil, durationFactor: 0) 

现在,我们考虑手势的速度,以确定是否应该反转动画。

最后,将另一行代码插入.changed块中。 将此代码放在animator.fractionComplete计算的右侧。

 if animator.isReversed { fraction *= -1 } 

让我们再次运行该应用程序。 现在一切都应该正常运行。

图片

修复平移手势


至此 ,我们已经使用UIViewPropertyAnimator完成了动画的实现 。 但是,有一个不愉快的错误。 您可能在测试应用程序时遇到了她。 问题在于无法水平滚动单元格。 让我们尝试在单元格中向左/向右滑动,我们将面临这个问题。

主要原因与我们创建UIPanGestureRecognizer有关。 它还捕获滑动手势,并与内置手势识别器UICollectionView冲突

尽管用户仍然可以滚动浏览单元格的顶部/底部或单元格之间的空间以滚动浏览城市,但我仍然不喜欢这样糟糕的用户界面。 让我们修复它。

要解决冲突,我们需要实现一个名为gestRecognizerShouldBegin(_ :)的委托方法。 此方法控制手势识别器是否应继续解释触摸。 如果在该方法中返回false ,则手势识别器将忽略触摸。 因此,我们要做的就是让我们自己的全景图识别工具能够忽略水平运动。

为此,让我们设置泛识别器的委托 。 将以下代码行插入panRecognizer初始化中(您可以将代码放在返回识别器的前面:

 recognizer.delegate = self 

然后,我们实现gestRecognizerShouldBegin(_ :)方法,如下所示:

 override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { return abs((panRecognizer.velocity(in: panRecognizer.view)).y) > abs((panRecognizer.velocity(in: panRecognizer.view)).x) } 

如果垂直速度大于水平速度,我们将打开/关闭。

哇! 让我们再次测试该应用程序。 现在,您可以通过在单元格之间向左/向右滑动来在城市列表中移动。

图片

奖励:自定义同步功能


在完成本教程之前,让我们谈谈计时功能。 您是否还记得开发人员要求您为所创建的动画实现自定义同步功能的情况?

通常,您应该将UIView.animation更改为CABasicAnimation或将其包装在CATransaction中使用UIViewPropertyAnimator,您可以轻松实现自定义计时功能。

定时功能 (或缓动功能)应理解为动画速度功能,它会影响一个或另一个动画属性的变化率。 当前支持四种类型:easeInOut,easeIn,easeOut,线性。

如下所示,用以下计时函数替换动画师初始化(尝试绘制自己的贝塞尔曲线)。

 private lazy var animator: UIViewPropertyAnimator = { let cubicTiming = UICubicTimingParameters(controlPoint1: CGPoint(x: 0.17, y: 0.67), controlPoint2: CGPoint(x: 0.76, y: 1.0)) return UIViewPropertyAnimator(duration: 0.3, timingParameters: cubicTiming) }() 

另外,除了使用三次同步参数外,还可以使用弹簧同步,例如:

  let springTiming = UISpringTimingParameters(mass: 1.0, stiffness: 2.0, damping: 0.2, initialVelocity: .zero) 

尝试再次启动该项目,看看会发生什么。

结论


通过UIViewPropertyAnimator,您可以通过交互式动画增强静态屏幕和用户交互。

我知道您迫不及待想意识到自己在项目中学到的东西。 如果您在项目中采用这种方法,那将非常酷,请在下面留下评论,让我知道这一点。

作为参考,您可以在此处下载最终草案

其他连结


使用UIKit的专业动画-https : //developer.apple.com/videos/play/wwdc2017/230/

适用于Apple开发人员的UIViewPropertyAnimator文档-https : //developer.apple.com/documentation/uikit/uiviewpropertyanimator

Source: https://habr.com/ru/post/zh-CN458970/


All Articles