iOS 9 by Tutorials 笔记(十一)

Chapter 11: UIKit Dynamics

iOS 9 更新了他的物理引擎箱,如增加了重力、磁性领域、非矩形碰撞,和一些额外的附属连接行为。本章的主要着眼点在这些新特性上:

Getting started

打开 playground,添加下面内容:

import UIKit  
import XCPlayground  
let view = UIView(frame: CGRect(x: 0, y: 0,  
  width: 600, height: 600))
view.backgroundColor = UIColor.lightTextColor()  
XCPShowView("Main View", view: view)  
let whiteSquare = UIView(frame: CGRect(x: 100, y: 100,  
  width: 100, height: 100))
whiteSquare.backgroundColor = UIColor.whiteColor()  
view.addSubview(whiteSquare)  
let orangeSquare = UIView(frame: CGRect(x: 400, y: 100,  
  width: 100, height: 100))
orangeSquare.backgroundColor = UIColor.orangeColor()  
view.addSubview(orangeSquare)  

添加了两个矩形,一白、一橙

接下来创建 UIDynamicAnimator,该类负责管理所有的物理特性。也可以看做是一种媒介用来协调 dynamic itemssubviewsdynamic behaviorsiOS 物理引擎。他提供了一个上下文用来计算将要渲染的动画。

let animator = UIDynamicAnimator(referenceView: view)  

Dynamic behaviors 封装了某些特定的物理效果,如重力,引力,弹跳效果。Dynamic animators 在动画过程中负责追踪这些 items,你之前传递进去的 referenceView 可以看做是一张施展动画的画布,因此所有要动画的 views 必须是 referenceView 的子类

给橙色矩形加一个自由落体效果

animator.addBehavior(UIGravityBehavior(items: [orangeSquare]))  

一运行橙色矩形就掉下去了(出了屏幕),现在来加个底,让他落在底边上

let boundaryCollision = UICollisionBehavior(items:  
  [whiteSquare, orangeSquare])
boundaryCollision.translatesReferenceBoundsIntoBoundary = true  
animator.addBehavior(boundaryCollision)  

默认的所有 dynamic items 都会有一组行为集合来描述他的重量、下落速度、如何应对碰撞和其他一些物理特性。而这些都是由 UIDynamicItemBehavior 来负责描述

我们来设置一下橙色矩形的碰撞效果

let bounce = UIDynamicItemBehavior(items: [orangeSquare])  
bounce.elasticity = 0.6  
bounce.density = 200  
bounce.resistance = 2  
animator.addBehavior(bounce)  

一个 dynamic item 的密度(density)和尺寸决定了他的重量,弹力(Elasticity)决定了碰撞后的弹跳效果(默认为 0 ),阻力(Resistance)也就是摩擦力,可以让 dynamic item 在线性运动时停下来

为了更好的观察,开启 debug 模式,现在你能在橙色矩形外看到一个蓝色矩形框(表示碰撞时的边界)

animator.setValue(true, forKey: "debugEnabled")  

Behaviors

下面来学习一下 UIDynamicBehavior 的七个子类

  • UIAttachmentBehavior: 两个 dynamic items 连接起来或一个 dynamic items 和一个锚点连接在一起
  • UICollisionBehavior: 描述两个 item 接触时产生的效果,可以用来开启 item 边界: translatesReferenceBoundsIntoBoundary 设为 ture
  • UIDynamicItemBehaviordynamic items 的一些物理特性
  • UIFieldBehavior:这个是 iOS 9 新加的,添加了很多物理场行为,包含电场(electric)、磁场(magnetic)、拖拽(dragging)、漩涡(vortex)、辐射(radial)、线性重力(linear gravity)、速率(velocity)、噪声(noise)、涡流(turbulence)、弹簧场(SpringField)
  • UIGravityBehavior:模拟重力效果
  • UIPushBehavior:应用在 dynamic items 上的一种力
  • UISnapBehavior:将 dynamic items 移动到指定的位置伴随着弹性效果

最后你可以将上面七种行为混合组合使用,简单来说就是先创建一个 parentBehaviorUIDynamicBehavior),然后创建上面七个子类中的几个,在通过父类的 addChildBehavior 方法添加到 parentBehavior 中去

来实际例子中玩一下,先给白色矩形加点物理属性:

let parentBehavior = UIDynamicBehavior()  
let viewBehavior = UIDynamicItemBehavior(items: [whiteSquare])  
viewBehavior.density = 0.01  
viewBehavior.resistance = 10  
viewBehavior.friction = 0.0  
viewBehavior.allowsRotation = false  
parentBehavior.addChildBehavior(viewBehavior)  

再定义一个弹簧场范围,白色矩形刚好位于其中:

let fieldBehavior = UIFieldBehavior.springField()  
fieldBehavior.addItem(whiteSquare)  
fieldBehavior.position = CGPoint(x: 150, y: 350)  
fieldBehavior.region = UIRegion(size: CGSizeMake(500, 500))  
parentBehavior.addChildBehavior(fieldBehavior)  

运行

animator.addBehavior(parentBehavior)  

开启了 debug 模式,就是下面展示的效果:

Spring fields 弹簧场可以这么理解,你确定当中某个物体的位置,然后对该物体施加一个力,物体也许会偏离一点点,不过最终会稳定在那一点上,就像被栓了跟弹簧。看得不明显?施加一个向上的力来看看:

let delayTime = dispatch_time(DISPATCH_TIME_NOW,  
  Int64(2 * Double(NSEC_PER_SEC)))
dispatch_after(delayTime, dispatch_get_main_queue()) {  
  let pushBehavior = UIPushBehavior(items: [whiteSquare],
    mode: .Instantaneous)
  pushBehavior.pushDirection = CGVector(dx: 0, dy: -1)
  pushBehavior.magnitude = 0.3
  animator.addBehavior(pushBehavior)
}

Applying dynamics to a real app

之前我们都在 playground 里玩,现在我们应用在一个 Real App 上,下面是一个照片浏览应用,主界面 photos 是简单的 collectionView,点进去是一张大图和关于图片的细节信息

我们首先来将目光焦聚在右边大图照片中显示图片细节信息的部分,即照片拍摄的时间、尺寸、和名称。这些信息都显示在一个灰黑色半透明的圆角矩形中。我们来对这个圆角矩形应用一个自定义的 Sticky behavior,即使其变成可拖动的,但无论放置在屏幕的哪个位置,该圆角矩形都会自动慢慢停靠在最上边或最下边的边界上。

Sticky behavior

import UIKit

class StickyEdgesBehavior: UIDynamicBehavior {  
  private var edgeInset: CGFloat
  private let itemBehavior: UIDynamicItemBehavior
  private let collisionBehavior: UICollisionBehavior
  private let item: UIDynamicItem
  private let fieldBehaviors = [
    UIFieldBehavior.springField(),
    UIFieldBehavior.springField()
  ]

  init(item: UIDynamicItem, edgeInset: CGFloat) {
    self.item = item
    self.edgeInset = edgeInset
    collisionBehavior = UICollisionBehavior(items: [item])
    collisionBehavior.translatesReferenceBoundsIntoBoundary = true
    itemBehavior = UIDynamicItemBehavior(items: [item])
    itemBehavior.density = 0.01
    itemBehavior.resistance = 20
    itemBehavior.friction = 0.0
    itemBehavior.allowsRotation = false
    super.init()
    addChildBehavior(collisionBehavior)
    addChildBehavior(itemBehavior)
    for fieldBehavior in fieldBehaviors {
      fieldBehavior.addItem(item)
      addChildBehavior(fieldBehavior)
    }
  }
}

StickyEdgesBehaviorUIDynamicBehavior 的子类,在这里的作用相当于之前提到的 parentBehavior,我们在该类中设置了 UIDynamicItemBehaviorUICollisionBehaviorUIDynamicItem 以及两个弹簧场 UIFieldBehavior.springField()

初始化的时候,我们传入了一个 item(通常是遵循 UIDynamicItem 协议的 View)和一个距边界尺寸(edge inset)

func updateFieldsInBounds(bounds: CGRect) {  
//1 确保 bounds 尺寸非零,且提取了长和宽
  guard bounds != CGRect.zero else { return }
  let h = bounds.height
  let w = bounds.width
  let itemHeight = item.bounds.height
//2 更新 field 的中心位置和区域(size)
  func updateRegionForField(field: UIFieldBehavior,
    _ point: CGPoint) {
    let size = CGSize(width: w - 2 * edgeInset,
      height: h - 2 * edgeInset - itemHeight)
    field.position = point
    field.region = UIRegion(size: size)
  }
//3 找出 bounds 上半部分和下半部分的中心点
  let top = CGPoint(x: w / 2, y: edgeInset + itemHeight / 2)
  let bottom = CGPoint(x: w / 2,
    y: h - edgeInset - itemHeight / 2)
//4 更新 fieldBehaviors 中的 UIFieldBehavior.springField()
  updateRegionForField(fieldBehaviors[StickyEdge.Top.rawValue],
    top)
  updateRegionForField(
    fieldBehaviors[StickyEdge.Bottom.rawValue], bottom)
  }
}

上面这个方法传入一个 bounds 作为参数,描述了整个弹簧场(UIFieldBehavior.springField())的范围。在方法内部,又将 bounds 一分为二:划分了上半部分和下半部分两个弹簧场(UIFieldBehavior.springField())

接下来添加一个计算属性 isEnabled,用来在动画过程中关闭 behavior

var isEnabled = true {  
  didSet {
    if isEnabled {
      for fieldBehavior in fieldBehaviors {
        fieldBehavior.addItem(item)
      }
      collisionBehavior.addItem(item)
      itemBehavior.addItem(item)
    } else {
      for fieldBehavior in fieldBehaviors {
        fieldBehavior.removeItem(item)
      }
      collisionBehavior.removeItem(item)
      itemBehavior.removeItem(item)
    }
  } 
}

最后为 item 增加一个线性速度

func addLinearVelocity(velocity: CGPoint) {  
  itemBehavior.addLinearVelocity(velocity, forItem: item)
}

现在 StickyEdgesBehavior 已经定义完毕,我们来使用他。具体方法:回到 FullPhotoViewController.swift,即展示大图的 VC 为 照片细节部分 tagView(黑色矩形框)添加一个手势

添加下面的一些属性:

private var animator: UIDynamicAnimator!  
var stickyBehavior: StickyEdgesBehavior!  
private var offset = CGPoint.zero  

viewDidload() 中做些初始配置:

let gestureRecognizer = UIPanGestureRecognizer(target: self,  
  action: "pan:")
tagView.addGestureRecognizer(gestureRecognizer)  
animator = UIDynamicAnimator(referenceView: containerView)  
stickyBehavior = StickyEdgesBehavior(item: tagView,  
  edgeInset: 8)
animator.addBehavior(stickyBehavior)  

为 tagView 上面添加了一个手势,stickyBehavior 也加在了 tagView 上。接着添加 layoutSubviews 方法,当 main view 的 layout 发生改变时,sticky behavior 会自动调整 bounds(例如旋转发生时)

override func viewDidLayoutSubviews() {  
  super.viewDidLayoutSubviews()
  stickyBehavior.isEnabled = false
  stickyBehavior.updateFieldsInBounds(containerView.bounds)
}

最后来实现 pan: 手势

func pan(pan:UIPanGestureRecognizer) {  
  var location = pan.locationInView(containerView)
  switch pan.state {
  case .Began:
    let center = tagView.center
    offset.x = location.x - center.x
    offset.y = location.y - center.y
    stickyBehavior.isEnabled = false
  case .Changed:
    let referenceBounds = containerView.bounds
    let referenceWidth = referenceBounds.width
    let referenceHeight = referenceBounds.height
    let itemBounds = tagView.bounds
    let itemHalfWidth = itemBounds.width / 2.0
    let itemHalfHeight = itemBounds.height / 2.0
    location.x -= offset.x
    location.y -= offset.y
    location.x = max(itemHalfWidth, location.x)
    location.x = min(referenceWidth - itemHalfWidth, location.x)
    location.y = max(itemHalfHeight, location.y)
    location.y = min(referenceHeight - itemHalfHeight, location.y)
    tagView.center = location
  case .Cancelled, .Ended:
    let velocity = pan.velocityInView(containerView)
    stickyBehavior.isEnabled = true
    stickyBehavior.addLinearVelocity(velocity)
  default: ()
  } 
}

pan gesture 开始时,sticky behavior 将会被关掉,接着他会记录用户手势移动的偏移量 offset,在 .Changed case 中根据 offset 来实时更新 metadata view 的位置,并保证 metadata view 不会超出 container view 的范围。在手势结束或取消时,再开启 sticky behavior,此时你会发现 metadata view (tagView)会先判断在上下半场哪个半场,然后再根据上下弹簧场的各自特性(上面的向上运动,下面的向下运动)自行移动到相应位置。

最后在 viewDidload 里开启 debug 模式运行看一下:

animator.setValue(true, forKey: "debugEnabled")  

Full photo with a thud

现在回到 collectionView 上的照片集合界面,点击其中任意一张照片,一张大图从天匀速而降,让我们来加入点自由落体和弹性效果。

实现起来也很简单,在 PhotosCollectionViewController.swift 中增加一个 UIDynamicAnimator,然后创建一些 UIDynamicBehavior 加进去

创建 UIDynamicAnimator

var animator: UIDynamicAnimator!  

随后在 viewDidLoad() 中初始化

animator = UIDynamicAnimator(referenceView: self.view)  

创建 UIGravityBehavior、UICollisionBehavior,以及为 item 增加点物理特性(UIDynamicItemBehavior)。最后将这些 behavior 统统添加到 animator 中来

func showFullImageView(index: Int) {  
  //1 将 fullPhotoView 向上移出屏幕
  fullPhotoViewController.photoPair = photoData[index]
  fullPhotoView.center = CGPoint(x: fullPhotoView.center.x,
    y: fullPhotoView.frame.height / -2)
  fullPhotoView.hidden = false

  //2 先清空 animator 再添加 behaviors
  animator.removeAllBehaviors()

  let dynamicItemBehavior = UIDynamicItemBehavior(items:
    [fullPhotoView])
  dynamicItemBehavior.elasticity = 0.2
  dynamicItemBehavior.density = 400
  animator.addBehavior(dynamicItemBehavior)

  let gravityBehavior = UIGravityBehavior(items:
    [fullPhotoView])
  gravityBehavior.magnitude = 5.0
  animator.addBehavior(gravityBehavior)

  let collisionBehavior = UICollisionBehavior(items:
    [fullPhotoView])
  let left = CGPoint(x: 0, y: fullPhotoView.frame.height + 1.5)
  let right = CGPoint(x: fullPhotoView.frame.width,
    y: fullPhotoView.frame.height + 1.5)
  collisionBehavior.addBoundaryWithIdentifier("bottom",
    fromPoint: left, toPoint: right)
  animator.addBehavior(collisionBehavior)

  //3 执行动画,动画完成时添加 barButton(Done)
  UIView.animateWithDuration(0.5, animations:
      { () -> Void in
        self.fullPhotoView.center = self.view.center
      }, completion: {
        (completed: Bool) -> Void in
        let doneButton = UIBarButtonItem(barButtonSystemItem: UIBarButtonSystemItem.Done, 
          target: self, action: "dismissFullPhoto:")
        self.navigationItem.rightBarButtonItem = doneButton
      })
}

注意我们在底部从 left 到 right 创建了一条线 collisionBehavior.addBoundaryWithIdentifier("bottom", fromPoint: left, toPoint: right) 这条线稍微比 view 的下边界还要偏下一点


-EOF-

如果感觉此文对你有帮助,请随意打赏支持作者 😘

chengway

认清生活真相之后依然热爱它!

Subscribe to Talk is cheap, Show me the world!

Get the latest posts delivered right to your inbox.

or subscribe via RSS with Feedly!