- 原文地址:Building Fluid Interfaces: How to create natural gestures and animations on iOS
- 原文做者:Nathan Gitter
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:RydenSun
- 校對者:atuooo
在 WWDC 2018 上,蘋果設計師進行了一次題爲 「設計流暢的交互界面」 的演講,解釋了 iPhone X 手勢交互體系背後的設計理念。前端
蘋果 WWDC18 演講 「設計流暢的交互界面」android
這是我最喜歡的 WWDC 分享 —— 我十分推薦它ios
此次分享提供了一些技術性指導,這對一個設計演講來講是很特殊的,但它只是一些僞代碼,留下了太多的未知。git
演講中一些看起來像 Swift 的代碼。github
若是你想嘗試實現這些想法,你可能會發現想法和實現是有差距的。spring
個人目的就是經過提供每一個主要話題的可行的代碼例子,來減小差距。swift
咱們會建立 8 個界面。 按鈕,彈簧動畫,自定義界面和更多!後端
這是咱們今天會講到的內容概覽:數組
一個流暢交互界面也能夠被描述爲「快」,「順滑」,「天然」或是「奇妙」。它是一種光滑的,無摩擦的體驗,讓你只會感受到它是對的。bash
WWDC 演講認爲流暢的交互界面是「你思想的延伸」或是「天然世界的延伸」。當一個界面是按照人們的想法作事,而不是按照機器的想法時,他就是流暢的。
流暢的交互界面是響應式的,可中斷的,而且是可重定向的。這是一個 iPhone X 滑動返回首頁的手勢案例:
應用在啓動動畫中是能夠被關閉的。
交互界面即時響應用戶的輸入,能夠在任何進程中中止,甚至能夠中途改變更畫方向。
這篇文章剩下的部分,我會爲大家展現怎樣來構建 WWDC 演講中提到的 8 個主要的界面。
圖標表明瞭咱們要構建的 8 個交互界面。
這個按鈕模仿了 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 按鈕的預期行爲)
這個交互展現了彈簧動畫是如何能夠經過指定一個「阻尼」(反彈)和「響應」(速度)來建立的。
彈簧是一個很好的動畫模型,由於它的速度和天然的外觀表現。一個彈簧動畫能夠及其迅速的開始,用其大多數的時間來慢慢接近最終狀態。 這對建立一個響應式的交互界面來講是完美的。
設計彈簧動畫時的幾個額外的提醒:
在 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 大大的致敬,由於它幫助我理解了這些動畫背後的數學理論,並且教我如何解二階微分方程。
又是一個按鈕,但又不一樣的表現形式。它模仿了 iPhone X 鎖屏上的手電筒按鈕。
蘋果但願建立一個按鈕,它能夠輕易地而且快速地被接觸到,可是並不會被不當心觸發。須要強壓來啓動手電筒是一個很棒的選擇,可是缺乏了功能的可見性和反饋性。
爲了解決這個問題,這個按鈕是有彈性的,而且會隨着用戶按壓的力度來變大。除此以外,有兩個單獨的觸覺震動反饋:一個是在達到要求的力度按壓時,另外一個是按壓結束按鈕被觸發時。這些觸覺模擬了物理按鈕的表現形式。
爲了衡量按壓按鈕的力度,咱們可使用 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()
複製代碼
橡皮筋動畫發生在視圖抗拒移動時。一個例子就是當滾動視圖滑到最底部時。
橡皮筋動畫是一種很好的方式來溝通無效的操做,它仍然會給用戶一種掌控感。它溫柔的告訴你這是一個邊界,將它們拉回到有效的狀態。
幸運的是,橡皮筋動畫實現起來很直接。
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 這些元素來實現橡皮筋動畫。我喜歡這個方法,是由於它簡單,但對不一樣的表現,還有不少更復雜的方法。
爲了看 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%,咱們會考慮將它中止。
個人實現並不完美。在個人測試裏,它看起來工做的不錯,但還有機會深刻探索加速度的計算方法。
一個抽屜動畫,有打開和關閉狀態,他們會根據手勢的速度有一些反彈。
抽屜動畫展現了這個交互界面的理念。當用戶有必定速度的滑動某個視圖,將動畫附帶一些反彈會更使人滿意。這樣交互界面感受像活得,也更有趣。
當抽屜被點擊時,它的動畫是沒有反彈的,這感受起來是對的,由於點擊時沒有任何明確方向動量的。
當設計自定義的交互界面時,要謹記界面對於不一樣的交互是有不一樣的動畫的。
爲了簡化點擊與拖拽手勢的邏輯,咱們可使用一個自定義的手勢識別器的子類,在點擊的一瞬間進入 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 動畫。
從新創造 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
。
將 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 上獲取!
若是你喜歡這篇文章,請留下一些鼓掌。 👏👏👏
你能夠點擊鼓掌 50 次! 因此趕快點啊! 😉
請將這篇文章在社交媒體上分享給你的 iOS 設計師/iOS 開發工程師朋友。
若是你喜歡這種內容,你應該在 Twitter 上關注我。我只發高質量的內容。twitter.com/nathangitte…
感謝 David Okun 校對。
感謝 Christian Schnorr 和 David Okun。
若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。