轉(zhuǎn)自 Cocoa開發(fā)者社區(qū) 微信公眾號(hào)
https://mp.weixin.qq.com/s/qWLava8mv4HJFpepSGQBww
在WWDC2018上鸡捐,蘋果設(shè)計(jì)師提出了一個(gè)關(guān)于“流暢的交互設(shè)計(jì)”的話題像吻,解釋了iPhone X手勢(shì)交互(gestural interface)背后的設(shè)計(jì)理念
蘋果WWDC2018“流暢的交互設(shè)計(jì)”
這個(gè)話題提供了一些技術(shù)引導(dǎo)合呐,作為一個(gè)想法,這些發(fā)布的內(nèi)容有點(diǎn)讓人意外蝇裤。但只發(fā)布了偽代碼,還留下很多謎團(tuán)。
演講中一些類似Swift代碼
如果你想要嘗試這些想法拣凹,你也許就會(huì)意識(shí)到理想與現(xiàn)實(shí)的差距
而我的目標(biāo)就是為這些想法提供一些代碼示例,幫助跨過這個(gè)差距恨豁。
我們將創(chuàng)建8個(gè)交互
什么是流暢的交互
流暢的交互要做到:快速嚣镜,平滑,自然橘蜜。給人一種很流暢的體驗(yàn)菊匿。
WWDC演講把流暢的交互稱作“用戶意識(shí)的擴(kuò)展”和“自然世界的擴(kuò)展”。只有當(dāng)一個(gè)交互表現(xiàn)得符合人類感官计福,而不是機(jī)器理念時(shí)才能算是流暢跌捆。
如何使他們顯得流暢?
流暢的交互是可響應(yīng)象颖,可中斷佩厚,可反向的。下面是一個(gè)iPhone X的”滑動(dòng)返回”手勢(shì)
App可以在動(dòng)畫階段被關(guān)閉
這個(gè)交互能夠立即對(duì)用戶的輸入做出反應(yīng)力麸,可以在其過程中任意時(shí)刻停止可款,還可以在中途反向育韩。
我們?yōu)槭裁搓P(guān)注流暢的交互
流暢的交互提高了用戶體驗(yàn),讓每一個(gè)響應(yīng)更快捷闺鲸,輕量筋讨,意思明確。
它們給用戶一種便于掌控的感覺摸恍,從而會(huì)更加信任你的App悉罕。
但它們并不易創(chuàng)建,一個(gè)流暢的交互很難復(fù)制立镶。
交互
在本文下面部分壁袄,我會(huì)展示如何創(chuàng)建8種交互,它們涉及到了演講中的所有主要部分媚媒。
8個(gè)圖標(biāo)代表我們要?jiǎng)?chuàng)建的8個(gè)交互
交互1:計(jì)算器按鈕
該按鈕模仿iOS計(jì)算器的按鈕動(dòng)作
主要特性
點(diǎn)擊后馬上高亮
即使在動(dòng)畫中也可以快速點(diǎn)擊
用戶可以在按下后嗜逻,手指移動(dòng)出按鈕區(qū)來取消點(diǎn)擊
用戶可以在按下后,手指移動(dòng)出按鈕區(qū)缭召,再移入栈顷,此時(shí)點(diǎn)擊有效。
設(shè)計(jì)理念
我們希望按鈕有良好響應(yīng)性嵌巷,讓用戶感到它們都在好好工作萄凤。另外,我們希望如果用戶在按下之后想取消動(dòng)作的話搪哪,能夠取消靡努。這會(huì)讓用戶更快操作,因?yàn)樗麄兙涂梢赃呄脒呅袆?dòng)了晓折。
WWDC的幻燈片展示了邊動(dòng)手邊思考惑朦,可以讓行動(dòng)更迅速。
關(guān)鍵代碼
創(chuàng)建這個(gè)按鍵第一步要使用UIControl子類已维,而不是UIButton子類行嗤。UIButton也許也可以用,但我們要定義互動(dòng)垛耳,所以這里不需要它。
CalculatorButton: UIControl {
public var value: Int = 0 {
didSet {
label.text = “\(value)”
}
}
private lazy var label: UILabel = { ... }()
}
之后飘千,我們會(huì)用UIControlEvents來為各種接觸反應(yīng)設(shè)計(jì)函數(shù)堂鲜。
addTarget(self, action: #selector(touchDown), for: [.touchDown, .touchDragEnter])
addTarget(self, action: #selector(touchUp), for: [.touchUpInside, .touchDragExit, .touchCancel])
我們把touchDown和touchDragEnter事件分組到一個(gè)事件中,取名為touchDown护奈,并把touchUpInside缔莲,touchDragExit,touchCancel事件分組到一個(gè)事件中霉旗,取名為touchUp
這樣我們可以用2個(gè)函數(shù)來處理動(dòng)畫
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中痴奏,我們會(huì)取消播放中的動(dòng)畫(如果有的話)蛀骇,并立即把按鍵設(shè)為高亮 (本例中設(shè)為亮灰色)
在touchUp中,我們會(huì)創(chuàng)建并播放一個(gè)新動(dòng)畫读拆,使用UIViewPropertyAnimator來更方便的取消高亮動(dòng)畫
(備注:這和iOS計(jì)算器的按鍵表現(xiàn)不完全一樣擅憔,但大致上已經(jīng)很類似)
交互2:彈性動(dòng)畫(Spring Animations)
這個(gè)交互展示如何創(chuàng)建一個(gè)彈性動(dòng)畫,其中需要指定阻尼(反彈)與響應(yīng)(速度)參數(shù)
主要特性:
使用“設(shè)計(jì)友好”的參數(shù)
不設(shè)置動(dòng)畫持續(xù)時(shí)間
易于中斷
設(shè)計(jì)理念
因?yàn)樗鼈兊乃俣扰c自然表現(xiàn)檐晕,彈性能讓動(dòng)畫模型變得好看暑诸。一個(gè)彈性動(dòng)畫啟動(dòng)時(shí)非常快速辟灰,并漸漸接近最終狀態(tài)个榕。這非常適合創(chuàng)建一個(gè)給人響應(yīng)感的交互,讓人感到生動(dòng)芥喇!
創(chuàng)建彈性動(dòng)畫時(shí)的一些注意事項(xiàng):
彈性不一定要有反彈西采。把阻尼值設(shè)為1會(huì)讓動(dòng)畫慢慢接近終點(diǎn),而沒有反彈继控。大部分動(dòng)畫都需要把阻尼設(shè)為1苛让。
不要去想著持續(xù)時(shí)間。理論上一個(gè)彈性模型永遠(yuǎn)無法走完全程湿诊,如果強(qiáng)制設(shè)置一個(gè)持續(xù)時(shí)間會(huì)給人不自然的感覺狱杰。取而代之,通過設(shè)置阻尼與響應(yīng)來調(diào)整好彈性模型厅须。
中斷很關(guān)鍵仿畸,因?yàn)閺椥阅P蜁?huì)花費(fèi)很多時(shí)間來接近最終狀態(tài),用戶也許會(huì)覺得動(dòng)畫已經(jīng)完成朗和,并開始操作错沽。
關(guān)鍵代碼
在UIKit中,我們可以用UIViewPropertyAnimator和UISpringTimingParameters對(duì)象創(chuàng)建彈性動(dòng)畫眶拉∏О#可惜的是我們找不到一個(gè)帶有阻尼和響應(yīng)的初始化。最接近的是UISpringTimingParameters初始化忆植,它帶有質(zhì)量放可,剛度,阻尼和初速度朝刊。
UISpringTimingParameters(mass: CGFloat, stiffness: CGFloat, damping: CGFloat, initialVelocity: CGVector)
我們想要?jiǎng)?chuàng)建一個(gè)帶有阻尼和響應(yīng)的初始化耀里,并把它映射到所需的質(zhì)量,剛度與阻尼拾氓。
通過一些物理推導(dǎo)冯挎,我們可以得到所需的公式
求解彈性常數(shù)和阻尼系數(shù)
根據(jù)結(jié)果,我們可以根據(jù)所需的參數(shù)創(chuàng)建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)
}
}
這就是我們?yōu)樗衅渌换ブ贫◤椥詣?dòng)畫的方法咙鞍。
交互3:閃光按鈕
它的表現(xiàn)很不一樣房官,模仿了iPhone X鎖屏上的閃光按鈕趾徽。
主要特性:
需要3D touch的特別手勢(shì)
對(duì)所需手勢(shì)有反響提示
有觸覺反饋確認(rèn)激活
設(shè)計(jì)理念
蘋果希望設(shè)計(jì)一個(gè)能簡(jiǎn)單快捷點(diǎn)擊,但又不會(huì)意外觸發(fā)的按鈕翰守。那么需要一定壓力來激活閃光就是個(gè)好辦法孵奶,但缺少功能可見性,也缺乏反饋潦俺。
為了解決這些問題拒课,這個(gè)按鈕要有彈力,并隨著用戶手指壓力而擴(kuò)大事示,對(duì)所需手勢(shì)能給出提示早像。此外,有2個(gè)獨(dú)立的震動(dòng)反饋肖爵,一個(gè)是當(dāng)施加壓力達(dá)到所需壓力時(shí)卢鹦,另一個(gè)是當(dāng)按鈕激活壓力減小時(shí)。這些觸覺是模仿一個(gè)實(shí)際按鈕的特征劝堪。
關(guān)鍵代碼
要測(cè)量施加到按鈕的壓力大小冀自,我們可以用UITouch對(duì)象提供一個(gè)觸摸事件。
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)
}
我們根據(jù)當(dāng)前壓力秒啦,計(jì)算外形變化熬粗,當(dāng)施加壓力時(shí)按鈕會(huì)變大。
因?yàn)榘粹o被輕輕按壓時(shí)不會(huì)觸發(fā)余境,我們需要一直追蹤按鈕的狀態(tài)驻呐。
enum ForceState {
case reset, activated, confirmed
}
private let resetForce: CGFloat = 0.4private
let activationForce: CGFloat = 0.5private
let confirmationForce: CGFloat = 0.49
通過讓confirmationForce略低于activationForce,防止用戶在跨越壓力閾值時(shí)快速反復(fù)觸發(fā)芳来。
我們用UIKit的反饋生成器來產(chǎn)生觸摸反饋
private let activationFeedbackGenerator = UIImpactFeedbackGenerator(style: .light)
private let confirmationFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
最后含末,我們用UIViewPropertyAnimator和前面創(chuàng)建的UISpringTimingParameters初始化,來制作彈性的動(dòng)畫即舌。
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:橡皮筋
當(dāng)一個(gè)視圖對(duì)抗運(yùn)動(dòng)時(shí)佣盒,就產(chǎn)生了橡皮筋動(dòng)畫。一個(gè)例子就是一個(gè)滑動(dòng)視圖滑到了它的末尾顽聂。
主要特性:
即使一個(gè)動(dòng)作無效肥惭,交互也始終可響應(yīng)
通過不同步的接觸追蹤表示邊界
通過一些運(yùn)動(dòng)來遠(yuǎn)離邊界
設(shè)計(jì)理念
橡皮筋可以告知一個(gè)無效運(yùn)動(dòng),同時(shí)依舊讓用戶有一種自己可控的感覺芜飘。它展示出邊界务豺,并把視圖拖回到有效狀態(tài)。
關(guān)鍵代碼
橡皮筋可以直接實(shí)現(xiàn)
offset = pow(offset, 0.7)
使用一個(gè)0-1之間的參數(shù)嗦明,這樣視圖會(huì)偏移其靜止位置,反向移動(dòng)一些蚪燕。參數(shù)越大移動(dòng)距離越小娶牌,參數(shù)小則移動(dòng)距離大奔浅。
進(jìn)一步講,當(dāng)拖動(dòng)時(shí)诗良,代碼經(jīng)常包含一個(gè)UIPanGestureRecognizer回叫信號(hào)汹桦。偏移量會(huì)根據(jù)初始和當(dāng)前接觸位置的差來計(jì)算,這個(gè)偏移量可以被轉(zhuǎn)換鉴裹。
var offset = touchPoint.y - originalTouchPoint.yoffset = offset > 0 ? pow(offset, 0.7) : -pow(-offset, 0.7)
view.transform = CGAffineTransform(translationX: 0, y: offset)
注意:這并不是蘋果在滑動(dòng)視圖時(shí)候的橡皮筋動(dòng)作舞骆。這個(gè)方法更加簡(jiǎn)易,但要實(shí)現(xiàn)不同的動(dòng)作會(huì)需要更多的函數(shù)径荔。
交互5:加速停頓
來看iPhone X上的app切換督禽,用戶從屏幕底部上滑,在中間停頓总处。這個(gè)交互就重現(xiàn)了這個(gè)動(dòng)作狈惫。
主要特性:
根據(jù)手勢(shì)加速來計(jì)算停頓
更快的停止代表更快的響應(yīng)
不用計(jì)時(shí)器
設(shè)計(jì)理念
流暢交互需要迅速,計(jì)時(shí)器帶來的延遲鹦马,即使很短胧谈,也會(huì)使交互顯得遲鈍。
這個(gè)交互之所以很酷荸频,就是因?yàn)樗姆磻?yīng)時(shí)間是根據(jù)用戶動(dòng)作菱肖。如果他們快速停頓,交互就快速響應(yīng)旭从,慢速停頓則慢速響應(yīng)稳强。
關(guān)鍵代碼
為了測(cè)量加速度,我們可以追蹤手勢(shì)速度遇绞。
private var velocities = [CGFloat]()
private func track(velocity: CGFloat) {
if velocities.count < numberOfVelocities {
velocities.append(velocity)
} else {
velocities = Array(velocities.dropFirst())
velocities.append(velocity)
}
}
這個(gè)代碼更新velocities數(shù)組键袱,隨時(shí)獲得最新的7個(gè)速度,用于計(jì)算加速度摹闽。
為了確定加速度是否足夠大蹄咖,我們可以測(cè)量數(shù)組中第一個(gè)速度與當(dāng)前速度的差值。
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
}
我們同樣要檢驗(yàn)該移動(dòng)有一個(gè)最小距離與速度付鹿。如果一個(gè)手勢(shì)降低了90%的速度澜汤,我們就認(rèn)為它停頓了。
我的實(shí)現(xiàn)并不理想舵匾,測(cè)試中它運(yùn)行的很好俊抵,但應(yīng)該有更好的測(cè)量加速的方法。
交互6:條件動(dòng)量(Rewarding Momentum)
一個(gè)包含開關(guān)狀態(tài)的滑動(dòng)頁(drawer)坐梯,根據(jù)手勢(shì)的速度決定是否有反彈徽诲,
主要特性:
點(diǎn)擊滑動(dòng)頁打開,但不啟動(dòng)反彈
拖動(dòng)滑動(dòng)頁打開,啟動(dòng)反彈
可交互谎替,可中斷偷溺,可反向
設(shè)計(jì)理念
滑動(dòng)頁展示了條件動(dòng)量的概念。當(dāng)用戶用一定速度拖動(dòng)滑動(dòng)頁時(shí)候钱贯,會(huì)更希望看到反彈效果挫掏。這使得交互更生動(dòng)有趣。
當(dāng)點(diǎn)擊滑動(dòng)頁時(shí)秩命,不會(huì)有反彈尉共,因?yàn)辄c(diǎn)擊不帶有某個(gè)方向的動(dòng)量,這樣表現(xiàn)更合適弃锐。
當(dāng)設(shè)計(jì)自定交互時(shí)袄友,要記住針對(duì)不同的交互應(yīng)該有不同的動(dòng)畫。
關(guān)鍵代碼
為了簡(jiǎn)化點(diǎn)擊的邏輯拿愧,我們使用一個(gè)自定的手勢(shì)識(shí)別器子類杠河,在按下時(shí)立即進(jìn)入began狀態(tài)。
class InstantPanGestureRecognizer: UIPanGestureRecognizer {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
super.touchesBegan(touches, with: event)
self.state = .began
}
}
他同樣允許用戶在滑條運(yùn)動(dòng)時(shí)點(diǎn)擊停止浇辜,就像點(diǎn)擊滾動(dòng)中的滾動(dòng)視圖一樣券敌。要處理點(diǎn)擊,我們要檢查手勢(shì)結(jié)束時(shí)速度是否為0柳洋,并繼續(xù)動(dòng)畫待诅。
if yVelocity == 0 {
animator.continueAnimation(withTimingParameters: nil, durationFactor: 0)
}
要處理一個(gè)帶速度的手勢(shì),我們首先要計(jì)算其速度相對(duì)整個(gè)剩余位移量的大小熊镣。
let fractionRemaining = 1 - animator.fractionCompletelet distanceRemaining = fractionRemaining * closedTransform.tyif distanceRemaining == 0 {
animator.continueAnimation(withTimingParameters: nil, durationFactor: 0) break
}
let relativeVelocity = abs(yVelocity) / distanceRemaining
我們使用相對(duì)速度和包含反彈的時(shí)間參數(shù)來讓動(dòng)畫繼續(xù)進(jìn)行卑雁。
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)
這里我們創(chuàng)建一個(gè)新的UIViewPropertyAnimator來計(jì)算動(dòng)畫花費(fèi)的時(shí)間,這樣當(dāng)動(dòng)畫繼續(xù)時(shí)我們可以提供正確的durationFactor參數(shù)绪囱。
交互7:FaceTime畫中畫(PiP)
重建iOS FaceTime中的畫中畫UI
主要特性
輕量测蹲,空中交互(airy interaction)
根據(jù)UIScrollView的減速度(deceleration rate)規(guī)劃位置
根據(jù)手勢(shì)初速度進(jìn)行持續(xù)動(dòng)畫
關(guān)鍵代碼
我們最終目標(biāo)是寫下面這樣的代碼
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()
我們想要?jiǎng)?chuàng)建一個(gè)帶有初速度的動(dòng)畫,它能匹配拖動(dòng)手勢(shì)(pan gesture)的速度鬼吵,并把畫中畫運(yùn)動(dòng)向最近的角
首先計(jì)算初始速度
我們要根據(jù)當(dāng)前速度扣甲,當(dāng)前位置,和目標(biāo)位置計(jì)算出相對(duì)速度齿椅。
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方向琉挖,分別確定每個(gè)的大小
之后計(jì)算畫中畫應(yīng)該運(yùn)動(dòng)到的角落
為了讓我們的交互看起來自然輕量,我們會(huì)根據(jù)畫中畫的當(dāng)前運(yùn)動(dòng)情況規(guī)劃最終位置涣脚。
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的減速度來計(jì)算他的靜止位置示辈。這個(gè)很重要,它會(huì)參考用戶的滑動(dòng)動(dòng)作遣蚀。如果一個(gè)用戶知道視圖能滑動(dòng)多遠(yuǎn)矾麻,他就可以根據(jù)這個(gè)來直觀估計(jì)需要多少力量才能把畫中畫移動(dòng)到想要的位置纱耻。
減速度可以讓交互變得輕量——只需要一個(gè)拖動(dòng)就可以把畫中畫移動(dòng)到屏幕各個(gè)地方。
我們可以使用前面提供的規(guī)劃函數(shù)來計(jì)算最終規(guī)劃位置
/// 勻減速到0時(shí)射富,運(yùn)動(dòng)的距離
func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat {
return (initialVelocity / 1000) * decelerationRate / (1 - decelerationRate)
}
現(xiàn)在所需的最后一步就是根據(jù)規(guī)劃位置計(jì)算出最近的角膝迎。我們可以遍歷所有的角粥帚,找到距離最近的一個(gè)胰耗。
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
}
總結(jié):我們使用UIScrollView的減速度來規(guī)劃畫中畫運(yùn)動(dòng)到其靜止位置,并使用計(jì)算出相對(duì)速度芒涡,來放入U(xiǎn)ISpringTimingParameters中
交互8:旋轉(zhuǎn)
把畫中畫交互的概念應(yīng)用到旋轉(zhuǎn)動(dòng)畫中
主要特性
使用規(guī)劃反映出手勢(shì)速度
總是在一個(gè)有效的方向結(jié)束
關(guān)鍵代碼
這里的代碼和前面的畫中畫交互很像柴灯,我們使用同樣的構(gòu)建區(qū)塊,只是把nearestCorner函數(shù)換成了closestAngle函數(shù)
func project(...) {
...
}
func relativeVelocity(...) {
...
}
func closestAngle(...) {
...
}
當(dāng)要最終創(chuàng)建UISpringTimingParameters時(shí)费尽,即使我們的轉(zhuǎn)動(dòng)是一維的赠群,也需要使用CGVector賦予初速度。在一維動(dòng)畫的情況下旱幼,把dx參數(shù)設(shè)為所需速度查描,而把dy設(shè)為0.
let timingParameters = UISpringTimingParameters(
damping: 0.8,
response: 0.4,
initialVelocity: CGVector(dx: relativeInitialVelocity, dy: 0)
)
動(dòng)畫會(huì)忽視dy,使用dx創(chuàng)建時(shí)間曲線
親手嘗試柏卤!
在實(shí)機(jī)上這些交互會(huì)更有趣冬三。Demo app可在GitHub上找到。