建立流畅的交互(Fluid Interfaces)

转自 Cocoa开发者社区 微信公众号
https://mp.weixin.qq.com/s/qWLava8mv4HJFpepSGQBww

在WWDC2018上,苹果设计师提出了一个关于“流畅的交互设计”的话题,解释了iPhone X手势交互(gestural interface)背后的设计理念

建立流畅的交互(Fluid Interfaces)_第1张图片
image

苹果WWDC2018“流畅的交互设计”

这个话题提供了一些技术引导,作为一个想法,这些发布的内容有点让人意外。但只发布了伪代码,还留下很多谜团。

建立流畅的交互(Fluid Interfaces)_第2张图片
image

演讲中一些类似Swift代码

如果你想要尝试这些想法,你也许就会意识到理想与现实的差距

而我的目标就是为这些想法提供一些代码示例,帮助跨过这个差距。

image

我们将创建8个交互

什么是流畅的交互

流畅的交互要做到:快速,平滑,自然。给人一种很流畅的体验。

WWDC演讲把流畅的交互称作“用户意识的扩展”和“自然世界的扩展”。只有当一个交互表现得符合人类感官,而不是机器理念时才能算是流畅。

如何使他们显得流畅?

流畅的交互是可响应,可中断,可反向的。下面是一个iPhone X的”滑动返回”手势

image

App可以在动画阶段被关闭

这个交互能够立即对用户的输入做出反应,可以在其过程中任意时刻停止,还可以在中途反向。

我们为什么关注流畅的交互

  1. 流畅的交互提高了用户体验,让每一个响应更快捷,轻量,意思明确。

  2. 它们给用户一种便于掌控的感觉,从而会更加信任你的App。

  3. 但它们并不易创建,一个流畅的交互很难复制。

交互

在本文下面部分,我会展示如何创建8种交互,它们涉及到了演讲中的所有主要部分。

建立流畅的交互(Fluid Interfaces)_第3张图片
image.gif

8个图标代表我们要创建的8个交互

交互1:计算器按钮

该按钮模仿iOS计算器的按钮动作

建立流畅的交互(Fluid Interfaces)_第4张图片
image

主要特性

  • 点击后马上高亮

  • 即使在动画中也可以快速点击

  • 用户可以在按下后,手指移动出按钮区来取消点击

  • 用户可以在按下后,手指移动出按钮区,再移入,此时点击有效。

设计理念

我们希望按钮有良好响应性,让用户感到它们都在好好工作。另外,我们希望如果用户在按下之后想取消动作的话,能够取消。这会让用户更快操作,因为他们就可以边想边行动了。

WWDC的幻灯片展示了边动手边思考,可以让行动更迅速。

建立流畅的交互(Fluid Interfaces)_第5张图片
image

关键代码

创建这个按键第一步要使用UIControl子类,而不是UIButton子类。UIButton也许也可以用,但我们要定义互动,所以这里不需要它。

CalculatorButton: UIControl {    
  public var value: Int = 0 {        
    didSet {
       label.text = “\(value)”
    }    
  }    
  private lazy var label: UILabel = { ... }()
}

之后,我们会用UIControlEvents来为各种接触反应设计函数。

addTarget(self, action: #selector(touchDown), for: [.touchDown, .touchDragEnter])
addTarget(self, action: #selector(touchUp), for: [.touchUpInside, .touchDragExit, .touchCancel])

我们把touchDown和touchDragEnter事件分组到一个事件中,取名为touchDown,并把touchUpInside,touchDragExit,touchCancel事件分组到一个事件中,取名为touchUp

这样我们可以用2个函数来处理动画

private var animator = UIViewPropertyAnimator()
@objc private func touchDown() {    
  animator.stopAnimation(true)    
  backgroundColor = highlightedColor
}
@objc private func touchUp() {    
  animator = UIViewPropertyAnimator(duration: 0.5, curve: .easeOut, animations: {        
     self.backgroundColor = self.normalColor    
  })    
  animator.startAnimation()
}

在touchDown中,我们会取消播放中的动画(如果有的话),并立即把按键设为高亮 (本例中设为亮灰色)

在touchUp中,我们会创建并播放一个新动画,使用UIViewPropertyAnimator来更方便的取消高亮动画

(备注:这和iOS计算器的按键表现不完全一样,但大致上已经很类似)

交互2:弹性动画(Spring Animations)

这个交互展示如何创建一个弹性动画,其中需要指定阻尼(反弹)与响应(速度)参数

建立流畅的交互(Fluid Interfaces)_第6张图片
image

主要特性:

  • 使用“设计友好”的参数

  • 不设置动画持续时间

  • 易于中断

设计理念

因为它们的速度与自然表现,弹性能让动画模型变得好看。一个弹性动画启动时非常快速,并渐渐接近最终状态。这非常适合创建一个给人响应感的交互,让人感到生动!

创建弹性动画时的一些注意事项:

  1. 弹性不一定要有反弹。把阻尼值设为1会让动画慢慢接近终点,而没有反弹。大部分动画都需要把阻尼设为1。

  2. 不要去想着持续时间。理论上一个弹性模型永远无法走完全程,如果强制设置一个持续时间会给人不自然的感觉。取而代之,通过设置阻尼与响应来调整好弹性模型。

  3. 中断很关键,因为弹性模型会花费很多时间来接近最终状态,用户也许会觉得动画已经完成,并开始操作。

关键代码

在UIKit中,我们可以用UIViewPropertyAnimator和UISpringTimingParameters对象创建弹性动画。可惜的是我们找不到一个带有阻尼和响应的初始化。最接近的是UISpringTimingParameters初始化,它带有质量,刚度,阻尼和初速度。

UISpringTimingParameters(mass: CGFloat, stiffness: CGFloat, damping: CGFloat, initialVelocity: CGVector)

我们想要创建一个带有阻尼和响应的初始化,并把它映射到所需的质量,刚度与阻尼。

通过一些物理推导,我们可以得到所需的公式

建立流畅的交互(Fluid Interfaces)_第7张图片
image

求解弹性常数和阻尼系数

根据结果,我们可以根据所需的参数创建UISpringTimingParameters了

extension UISpringTimingParameters {    
  convenience init(damping: CGFloat, response: CGFloat, initialVelocity: CGVector = .zero) {        
    let stiffness = pow(2 * .pi / response, 2)        
    let damp = 4 * .pi * damping / response        
    self.init(mass: 1, stiffness: stiffness, damping: damp, initialVelocity: initialVelocity)    
  }
}

这就是我们为所有其他交互制定弹性动画的方法。

交互3:闪光按钮

它的表现很不一样,模仿了iPhone X锁屏上的闪光按钮。

建立流畅的交互(Fluid Interfaces)_第8张图片
image.gif

主要特性:

  • 需要3D touch的特别手势

  • 对所需手势有反响提示

  • 有触觉反馈确认激活

设计理念

苹果希望设计一个能简单快捷点击,但又不会意外触发的按钮。那么需要一定压力来激活闪光就是个好办法,但缺少功能可见性,也缺乏反馈。

为了解决这些问题,这个按钮要有弹力,并随着用户手指压力而扩大,对所需手势能给出提示。此外,有2个独立的震动反馈,一个是当施加压力达到所需压力时,另一个是当按钮激活压力减小时。这些触觉是模仿一个实际按钮的特征。

关键代码

要测量施加到按钮的压力大小,我们可以用UITouch对象提供一个触摸事件。

override func touchesMoved(_ touches: Set, with event: UIEvent?) {    
  super.touchesMoved(touches, with: event)    
  guard let touch = touches.first else { 
    return 
  }    
  let force = touch.force / touch.maximumPossibleForce    
  let scale = 1 + (maxWidth / minWidth - 1) * force    
  transform = CGAffineTransform(scaleX: scale, y: scale)
}

我们根据当前压力,计算外形变化,当施加压力时按钮会变大。

因为按钮被轻轻按压时不会触发,我们需要一直追踪按钮的状态。

enum ForceState {    
  case reset, activated, confirmed
  }
private let resetForce: CGFloat = 0.4private 
let activationForce: CGFloat = 0.5private 
let confirmationForce: CGFloat = 0.49

通过让confirmationForce略低于activationForce,防止用户在跨越压力阈值时快速反复触发。

我们用UIKit的反馈生成器来产生触摸反馈

private let activationFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
private let confirmationFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium)

最后,我们用UIViewPropertyAnimator和前面创建的UISpringTimingParameters初始化,来制作弹性的动画。

let params = UISpringTimingParameters(damping: 0.4, response: 0.2)
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: params)animator.addAnimations {    
  self.transform = CGAffineTransform(scaleX: 1, y: 1)    
  self.backgroundColor = self.isOn ? self.onColor : self.offColor
}animator.startAnimation()

交互4:橡皮筋

当一个视图对抗运动时,就产生了橡皮筋动画。一个例子就是一个滑动视图滑到了它的末尾。

建立流畅的交互(Fluid Interfaces)_第9张图片
image

主要特性:

  • 即使一个动作无效,交互也始终可响应

  • 通过不同步的接触追踪表示边界

  • 通过一些运动来远离边界

设计理念

橡皮筋可以告知一个无效运动,同时依旧让用户有一种自己可控的感觉。它展示出边界,并把视图拖回到有效状态。

关键代码

橡皮筋可以直接实现

offset = pow(offset, 0.7)

使用一个0-1之间的参数,这样视图会偏移其静止位置,反向移动一些。参数越大移动距离越小,参数小则移动距离大。

进一步讲,当拖动时,代码经常包含一个UIPanGestureRecognizer回叫信号。偏移量会根据初始和当前接触位置的差来计算,这个偏移量可以被转换。

var offset = touchPoint.y - originalTouchPoint.yoffset = offset > 0 ? pow(offset, 0.7) : -pow(-offset, 0.7)
view.transform = CGAffineTransform(translationX: 0, y: offset)

注意:这并不是苹果在滑动视图时候的橡皮筋动作。这个方法更加简易,但要实现不同的动作会需要更多的函数。

交互5:加速停顿

来看iPhone X上的app切换,用户从屏幕底部上滑,在中间停顿。这个交互就重现了这个动作。

建立流畅的交互(Fluid Interfaces)_第10张图片
image

主要特性:

  • 根据手势加速来计算停顿

  • 更快的停止代表更快的响应

  • 不用计时器

设计理念

流畅交互需要迅速,计时器带来的延迟,即使很短,也会使交互显得迟钝。

这个交互之所以很酷,就是因为它的反应时间是根据用户动作。如果他们快速停顿,交互就快速响应,慢速停顿则慢速响应。

关键代码

为了测量加速度,我们可以追踪手势速度。

private var velocities = [CGFloat]()
private func track(velocity: CGFloat) {    
  if velocities.count < numberOfVelocities {        
    velocities.append(velocity)    
  } else {        
    velocities = Array(velocities.dropFirst())        
    velocities.append(velocity)    
  }
}

这个代码更新velocities数组,随时获得最新的7个速度,用于计算加速度。

为了确定加速度是否足够大,我们可以测量数组中第一个速度与当前速度的差值。

if abs(velocity) > 100 || abs(offset) < 50 { 
  return 
  }
let ratio = abs(firstRecordedVelocity - velocity) / abs(firstRecordedVelocity)if ratio > 0.9 {    
  pauseLabel.alpha = 1    
  feedbackGenerator.impactOccurred()    
  hasPaused = true
}

我们同样要检验该移动有一个最小距离与速度。如果一个手势降低了90%的速度,我们就认为它停顿了。

我的实现并不理想,测试中它运行的很好,但应该有更好的测量加速的方法。

交互6:条件动量(Rewarding Momentum)

一个包含开关状态的滑动页(drawer),根据手势的速度决定是否有反弹,

建立流畅的交互(Fluid Interfaces)_第11张图片
image

主要特性:

  • 点击滑动页打开,但不启动反弹

  • 拖动滑动页打开,启动反弹

  • 可交互,可中断,可反向

设计理念

滑动页展示了条件动量的概念。当用户用一定速度拖动滑动页时候,会更希望看到反弹效果。这使得交互更生动有趣。

当点击滑动页时,不会有反弹,因为点击不带有某个方向的动量,这样表现更合适。

当设计自定交互时,要记住针对不同的交互应该有不同的动画。

关键代码

为了简化点击的逻辑,我们使用一个自定的手势识别器子类,在按下时立即进入began状态。

class InstantPanGestureRecognizer: UIPanGestureRecognizer {    
  override func touchesBegan(_ touches: Set, with event: UIEvent) {        
    super.touchesBegan(touches, with: event)        
    self.state = .began    
  }
}

他同样允许用户在滑条运动时点击停止,就像点击滚动中的滚动视图一样。要处理点击,我们要检查手势结束时速度是否为0,并继续动画。

if yVelocity == 0 {    
  animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
}

要处理一个带速度的手势,我们首先要计算其速度相对整个剩余位移量的大小。

let fractionRemaining = 1 - animator.fractionCompletelet distanceRemaining = fractionRemaining * closedTransform.tyif distanceRemaining == 0 {    
  animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)    break
}
let relativeVelocity = abs(yVelocity) / distanceRemaining

我们使用相对速度和包含反弹的时间参数来让动画继续进行。

let timingParameters = UISpringTimingParameters(damping: 0.8, response: 0.3, initialVelocity: CGVector(dx: relativeVelocity, dy: relativeVelocity))
let newDuration = UIViewPropertyAnimator(duration: 0, timingParameters: timingParameters).durationlet durationFactor = CGFloat(newDuration / animator.duration)animator.continueAnimation(withTimingParameters: timingParameters, durationFactor: durationFactor)

这里我们创建一个新的UIViewPropertyAnimator来计算动画花费的时间,这样当动画继续时我们可以提供正确的durationFactor参数。

交互7:FaceTime画中画(PiP)

重建iOS FaceTime中的画中画UI

建立流畅的交互(Fluid Interfaces)_第12张图片
image

主要特性

  • 轻量,空中交互(airy interaction)

  • 根据UIScrollView的减速度(deceleration rate)规划位置

  • 根据手势初速度进行持续动画

关键代码

我们最终目标是写下面这样的代码

let params = UISpringTimingParameters(damping: 1, response: 0.4, initialVelocity: relativeInitialVelocity)
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: params)animator.addAnimations {    
  self.pipView.center = nearestCornerPosition
}animator.startAnimation()

我们想要创建一个带有初速度的动画,它能匹配拖动手势(pan gesture)的速度,并把画中画运动向最近的角

首先计算初始速度

我们要根据当前速度,当前位置,和目标位置计算出相对速度。

let relativeInitialVelocity = CGVector(    dx: relativeVelocity(forVelocity: velocity.x, from: pipView.center.x, to: nearestCornerPosition.x),    dy: relativeVelocity(forVelocity: velocity.y, from: pipView.center.y, to: nearestCornerPosition.y))
func relativeVelocity(forVelocity velocity: CGFloat, from currentValue: CGFloat, to targetValue: CGFloat) -> CGFloat {    
  guard currentValue - targetValue != 0 
  else { 
    return 0 
  }    
  return velocity / (targetValue - currentValue)
}

我们可以把速度分为x和y方向,分别确定每个的大小

之后计算画中画应该运动到的角落

为了让我们的交互看起来自然轻量,我们会根据画中画的当前运动情况规划最终位置。

let decelerationRate = UIScrollView.DecelerationRate.normal.rawValuelet velocity = recognizer.velocity(in: view)
let projectedPosition = CGPoint(    x: pipView.center.x + project(initialVelocity: velocity.x, decelerationRate: decelerationRate),    y: pipView.center.y + project(initialVelocity: velocity.y, decelerationRate: decelerationRate))
let nearestCornerPosition = nearestCorner(to: projectedPosition)

我们用UIScrollView的减速度来计算他的静止位置。这个很重要,它会参考用户的滑动动作。如果一个用户知道视图能滑动多远,他就可以根据这个来直观估计需要多少力量才能把画中画移动到想要的位置。

减速度可以让交互变得轻量——只需要一个拖动就可以把画中画移动到屏幕各个地方。

我们可以使用前面提供的规划函数来计算最终规划位置

/// 匀减速到0时,运动的距离
func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat {    
  return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate)
}

现在所需的最后一步就是根据规划位置计算出最近的角。我们可以遍历所有的角,找到距离最近的一个。

func nearestCorner(to point: CGPoint) -> CGPoint {    
  var minDistance = CGFloat.greatestFiniteMagnitude    
  var closestPosition = CGPoint.zero    
  for position in pipPositions {        
    let distance = point.distance(to: position)        
    if distance < minDistance {            
      closestPosition = position            
      minDistance = distance        
    }    
  }    
  return closestPosition
}

总结:我们使用UIScrollView的减速度来规划画中画运动到其静止位置,并使用计算出相对速度,来放入UISpringTimingParameters中

交互8:旋转

把画中画交互的概念应用到旋转动画中

建立流畅的交互(Fluid Interfaces)_第13张图片
image

主要特性

  • 使用规划反映出手势速度

  • 总是在一个有效的方向结束

关键代码

这里的代码和前面的画中画交互很像,我们使用同样的构建区块,只是把nearestCorner函数换成了closestAngle函数

func project(...) {
   ... 
}
func relativeVelocity(...) {
   ... 
}
func closestAngle(...) {
   ... 
}

当要最终创建UISpringTimingParameters时,即使我们的转动是一维的,也需要使用CGVector赋予初速度。在一维动画的情况下,把dx参数设为所需速度,而把dy设为0.

let timingParameters = UISpringTimingParameters(    
  damping: 0.8,    
  response: 0.4,    
  initialVelocity: CGVector(dx: relativeInitialVelocity, dy: 0)
)

动画会忽视dy,使用dx创建时间曲线

亲手尝试!

在实机上这些交互会更有趣。Demo app可在GitHub上找到。

你可能感兴趣的:(建立流畅的交互(Fluid Interfaces))