- 原文地址:Building Fluid Interfaces: How to create natural gestures and animations on iOS
- 原文作者:Nathan Gitter
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:RydenSun
- 校對者:atuooo
如何在 iOS 上建立自然的互動手勢及動畫
在 WWDC 2018 上,蘋果設計師進行了一次題為 “設計流暢的互動介面” 的演講,解釋了 iPhone X 手勢互動體系背後的設計理念。
蘋果 WWDC18 演講 “設計流暢的互動介面”
這是我最喜歡的 WWDC 分享 —— 我十分推薦它
這次分享提供了一些技術性指導,這對一個設計演講來說是很特殊的,但它只是一些虛擬碼,留下了太多的未知。
演講中一些看起來像 Swift 的程式碼。
如果你想嘗試實現這些想法,你可能會發現想法和實現是有差距的。
我的目的就是通過提供每個主要話題的可行的程式碼例子,來減少差距。
我們會建立 8 個介面。 按鈕,彈簧動畫,自定義介面和更多!
這是我們今天會講到的內容概覽:
- “設計流暢的互動介面”演講的概要。
- 8 個流暢的互動介面,背後的設計理念和構建的程式碼。
- 設計師和開發者的實際應用
什麼是流暢的互動介面?
一個流暢互動介面也可以被描述為“快”,“順滑”,“自然”或是“奇妙”。它是一種光滑的,無摩擦的體驗,讓你只會感覺到它是對的。
WWDC 演講認為流暢的互動介面是“你思想的延伸”或是“自然世界的延伸”。當一個介面是按照人們的想法做事,而不是按照機器的想法時,他就是流暢的。
是什麼讓它們流暢?
流暢的互動介面是響應式的,可中斷的,並且是可重定向的。這是一個 iPhone X 滑動返回首頁的手勢案例:
應用在啟動動畫中是可以被關閉的。
互動介面即時響應使用者的輸入,可以在任何程式中停止,甚至可以中途改變動畫方向。
我們為什麼關注流暢的互動介面?
- 流暢的互動介面提升了使用者體驗,讓使用者感覺每一個互動都是快的,輕量和有意義的。
- 它們給予使用者一種掌控感,這為你的應用與品牌建立了信任感。
- 它們很難被構建。一個流暢的互動介面是很難被仿造,這是一個有力的競爭優勢。
互動介面
這篇文章剩下的部分,我會為你們展示怎樣來構建 WWDC 演講中提到的 8 個主要的介面。
圖示代表了我們要構建的 8 個互動介面。
互動介面 #1:計算器按鈕
這個按鈕模仿了 iOS 計算器應用中按鈕的表現行為。
核心功能
- 被點選時馬上高亮。
- 即便處於動畫中也可以被立即點選。
- 使用者可以在按住手勢結束時或手指脫離按鈕時取消點選。
- 使用者可以在按住手勢結束時,手指脫離按鈕和手指重回按鈕來確認點選。
設計理念
我們希望按鈕感覺是即時響應的,讓使用者知道它們是有功能的。 另外,我們希望操作是可以被取消的,如果使用者在按下按鈕時決定撤銷操作。這允許使用者更快的做決定,因為他們可以在考慮的同時進行操作。
WWDC 演講上的幻燈片,展示了手勢是如何與想法同時進行的,以此讓操作更迅速。
關鍵程式碼
第一步是建立一個按鈕,繼承自 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
。
(檢視 這個文件 來獲取所有可用的 UIControlEvents
的描述。)
這讓我們有兩個方法來處理動畫。
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
,我們建立了一個新的 animator 並且將動畫啟動。使用 UIViewPropertyAnimator
,可以輕鬆地取消高亮動畫。
(幻燈片筆記:這不是嚴謹的 iOS 計算器應用中按鈕的表現,它允許手勢從別的按鈕移動到這個按鈕來啟動點選事件。大多數情況下,我在這裡建立的按鈕就是 iOS 按鈕的預期行為)
互動介面 #2:彈簧動畫
這個互動展示了彈簧動畫是如何可以通過指定一個“阻尼”(反彈)和“響應”(速度)來建立的。
核心功能
- 使用“對設計友好”的引數。
- 對動畫持續時間無概念。
- 可輕易中斷。
設計理念
彈簧是一個很好的動畫模型,因為它的速度和自然的外觀表現。一個彈簧動畫可以及其迅速的開始,用其大多數的時間來慢慢接近最終狀態。 這對建立一個響應式的互動介面來說是完美的。
設計彈簧動畫時的幾個額外的提醒:
- 彈簧動畫不需要有彈性。使用數值為 1 的阻尼會構建一個動畫,它慢慢的向剩下部分靠近,但沒有任何反彈。大多數動畫應該使用值為 1 的阻尼。
- 嘗試著避免考慮時長。理論上,一個彈簧動畫從來不會完全靠近其餘的部分,如果強加上時長限制,會造成動畫的不自然。相反,要不斷調整阻尼和響應值,直到它感覺對。
- 可中斷性是很關鍵的。因為彈簧動畫消耗了它們絕大部分的時間來接近最終值,使用者可能會認為動畫已經完成並且會嘗試再與它互動。
關鍵程式碼
在 UIKit 中,我們可以用 UIViewPropertyAnimator
和一個 UISpringTimingParameters
物件來構建一個彈簧動畫。不幸的是,它沒有一個只接受“阻尼”和“響應”的初始化構造器。我們能得到的最接近的初始化構造器是 UISpringTimingParameters
,它需要質量,硬度,阻尼和初始加速度這幾個引數。
UISpringTimingParameters(mass: CGFloat, stiffness: CGFloat, damping: CGFloat, initialVelocity: CGVector)
複製程式碼
我們希望建立一個簡便的初始化構造器,只使用阻尼和響應這兩個引數,並且將它們對映至需要的質量,硬度和阻尼。
使用一點物理知識,我們可以匯出我們需要的公示:
彈簧動畫的常量和阻尼係數的解決方案。
有了這個結果,我們正好可以使用我們想要的引數來建立我們自己的 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)
}
}
複製程式碼
這就是我們如何可以指定彈簧動畫到所有其他的互動介面。
彈簧動畫背後的物理學
想深入研究彈簧動畫?看看 Christian Schnorr 發的這篇極好的文章:Demystifying UIKit Spring Animations。
讀了他的文章之後,我最終理解了彈簧動畫。對 Christian 大大的致敬,因為它幫助我理解了這些動畫背後的數學理論,而且教我如何解二階微分方程。
互動介面 #3:手電筒按鈕
又是一個按鈕,但又不同的表現形式。它模仿了 iPhone X 鎖屏上的手電筒按鈕。
核心功能
- 需要一個使用 3D Touch 的強力手勢。
- 對手勢有反彈提示。
- 對確認啟動有震動反饋。
設計理念
蘋果希望建立一個按鈕,它可以輕易地並且快速地被接觸到,但是並不會被不小心觸發。需要強壓來啟動手電筒是一個很棒的選擇,但是缺少了功能的可見性和反饋性。
為了解決這個問題,這個按鈕是有彈性的,並且會隨著使用者按壓的力度來變大。除此之外,有兩個單獨的觸覺震動反饋:一個是在達到要求的力度按壓時,另一個是按壓結束按鈕被觸發時。這些觸覺模擬了物理按鈕的表現形式。
關鍵程式碼
為了衡量按壓按鈕的力度,我們可以使用 touch 事件提供的 UITouch
物件。
override func touchesMoved(_ touches: Set<UITouch>, 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.4
private let activationForce: CGFloat = 0.5
private let confirmationForce: CGFloat = 0.49
複製程式碼
通過將確認壓力設定到稍小於啟動壓力,防止使用者通過快速的超過壓力閾值來頻繁的啟動和取消啟動按鈕。
對於觸覺反饋,我們可以使用 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:橡皮筋動畫
橡皮筋動畫發生在檢視抗拒移動時。一個例子就是當滾動檢視滑到最底部時。
核心功能
- 互動介面永遠是可響應的,即使當操作是無效的。
- 不同步的觸控追蹤,代表了邊界。
- 隨著遠離邊界,移動距離變小。
設計理念
橡皮筋動畫是一種很好的方式來溝通無效的操作,它仍然會給使用者一種掌控感。它溫柔的告訴你這是一個邊界,將它們拉回到有效的狀態。
關鍵程式碼
幸運的是,橡皮筋動畫實現起來很直接。
offset = pow(offset, 0.7)
複製程式碼
通過使用 0 到 1 之間的一個指數,檢視會隨著遠離原始位置,移動越來越少。要移動的少就用一個大的指數,移動的多就使用一個小的指數。
再詳細一點,這段程式碼一般是在觸控移動時,在 UIPanGestureRecognizer
回撥中實現的。
var offset = touchPoint.y - originalTouchPoint.y
offset = offset > 0 ? pow(offset, 0.7) : -pow(-offset, 0.7)
view.transform = CGAffineTransform(translationX: 0, y: offset)
複製程式碼
注意:這並不是蘋果如何使用像 scroll view 這些元素來實現橡皮筋動畫。我喜歡這個方法,是因為它簡單,但對不同的表現,還有很多更復雜的方法。
互動介面 #5:加速中止
為了看 iPhone X 上的應用切換,使用者需要從螢幕底部向上滑,並且在中途停止。這個互動介面就是為了建立這個表現形式。
核心功能
- 中止是基於手勢加速度來計算的。
- 越快的停止導致越快的響應。
- 沒有計時器。
設計理念
流暢的互動介面應該是快速的。計時器產生的延遲,即便很短,也會讓介面感到卡頓。
這個互動十分酷,因為它的反應時間是根據使用者手勢運動的。如果他們很快停止,介面會很快響應。如果他們慢慢停止,介面就慢慢響應。
關鍵程式碼
為了衡量加速度,我們可以追蹤最新的拖拽手勢的速度值。
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:獎勵有自我動量的動畫一些反彈效果
一個抽屜動畫,有開啟和關閉狀態,他們會根據手勢的速度有一些反彈。
核心功能
- 點選抽屜動畫,沒有反彈。
- 輕彈出抽屜,有反彈。
- 可互動,可中斷並且可逆。
設計理念
抽屜動畫展示了這個互動介面的理念。當使用者有一定速度的滑動某個檢視,將動畫附帶一些反彈會更令人滿意。這樣互動介面感覺像活得,也更有趣。
當抽屜被點選時,它的動畫是沒有反彈的,這感覺起來是對的,因為點選時沒有任何明確方向動量的。
當設計自定義的互動介面時,要謹記介面對於不同的互動是有不同的動畫的。
關鍵程式碼
為了簡化點選與拖拽手勢的邏輯,我們可以使用一個自定義的手勢識別器的子類,在點選的一瞬間進入 began
狀態。
class InstantPanGestureRecognizer: UIPanGestureRecognizer {
override func touchesBegan(_ touches: Set<UITouch>, 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.fractionComplete
let distanceRemaining = fractionRemaining * closedTransform.ty
if 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).duration
let durationFactor = CGFloat(newDuration / animator.duration)
animator.continueAnimation(withTimingParameters: timingParameters, durationFactor: durationFactor)
複製程式碼
這裡我們建立有一個新的 UIViewPropertyAnimator
來計算動畫需要的時間,這樣我們可以在繼續動畫時提供正確的 durationFactor
。
關於動畫的迴轉,會更復雜,我這裡就不介紹了。如果你想知道的哦更多,我寫了一個關於這部分的完整的教程:構建更好的 iOS APP 動畫。
互動動畫 #7: FaceTime PiP
重新創造 iOS FaceTime 應用中的 picture-in-picture(下文中簡稱 Pip)UI。
核心功能
- 輕量,輕快的互動
- 投影位置是基於
UIScrollView
的減速速率。 - 有遵循手勢最初速度的持續動畫。
關鍵程式碼
我們最終的目的是寫一些這樣的程式碼。
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()
複製程式碼
我們希望建立一個帶有初始速度的動畫,並且與拖拽手勢的速度相匹配。並且進行 pip 動畫到最近的角落。
首先,我們需要計算初始速度。
為了能做到這個,我們需要計算基於目前速度,目前為止和目標為止的相對速度。
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 兩部分,並且決定它們各自的相對速度。
下一步,我們為 PiP 動畫計算各個角落。
為了讓我們的互動介面感覺自然並且輕量,我們要基於它現在的移動來投影 PiP 的最終位置。如果 PiP 可以滑動並且停止,它最終停在哪裡?
let decelerationRate = UIScrollView.DecelerationRate.normal.rawValue
let 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
的減速速率來計算剩下的位置。這很重要,因為它與使用者滑動的肌肉記憶相關。如果一個使用者知道一個檢視需要滾動多遠,他們可以使用之前的知識直覺地猜測 PiP 到最終目標需要多大力。
這個減速速率也是很寬泛的,讓互動感到輕量——只需要一個小小的推動就可以送 PiP 飛到螢幕的另一端。
我們可以使用“設計流暢的互動介面”演講中的投影方法來計算最終的投影位置。
/// Distance traveled after decelerating to zero velocity at a constant rate.
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
的減速速率來投影 pip 的運動到它最終的位置,並且計算了相對速度傳入了 UISpringTimingParameters
。
互動介面 #8: 旋轉
將 PiP 的原理應用到一個旋轉動畫。
核心功能
- 使用投影來遵循手勢的速度。
- 永遠停在一個有效的方向。
關鍵程式碼
這裡的程式碼和前面的 PiP 很像。 我們會使用同樣的構造回撥,除了將 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 應用,可以在 GitHub 上獲取到。
流暢的互動介面 demo 應用,可以在 GitHub 上獲取!
實際應用
對於設計師
- 把互動介面考慮成流程的表達中介,而不是一些固定元素的組合。
- 在設計流程早期就考慮動畫和手勢。Sketch 這些排版工具是很好用的,但是並不會像裝置一樣提供完整的表現。
- 跟開發工程師進行原型展示。讓有設計思維的開發工程師幫助你開發原型的動畫,手勢和觸覺反饋。
對於開發工程師
- 將這些建議應用到你自己開發的自定義互動元件上。考慮如何更有趣的將它們結合到一起。
- 告訴你的設計師關於這些新的可能。許多設計師沒有意識到 3D touch,觸覺反饋,手勢和彈簧動畫的真正力量。
- 與設計師一起演示原型。幫助他們在真機上檢視自己的設計,並且建立一些工具幫助他們,來讓設計更加的有效率。
如果你喜歡這篇文章,請留下一些鼓掌。 ???
你可以點選鼓掌 50 次! 所以趕快點啊! ?
請將這篇文章在社交媒體上分享給你的 iOS 設計師/iOS 開發工程師朋友。
如果你喜歡這種內容,你應該在 Twitter 上關注我。我只發高質量的內容。twitter.com/nathangitte…
感謝 David Okun 校對。
感謝 Christian Schnorr 和 David Okun。
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。