用過 iOS 的都知道钱贯,擬物理的回彈效果在上面非常普遍秩命,因為這是 iOS 系統(tǒng)支持的一套 UI 框架,但是 Android 就沒有了袄友,就拿圖片查看器來講霹菊,iOS 的效果就是感覺一張圖片被綁定在了彈簧裝置上,滑動很自然券敌,Android 沒有自帶的圖片查看器柳洋,需要自己實現(xiàn)
市面上主流的圖片查看器都沒有回彈的效果,一部分原因是沒有這個需求卑雁,還有一部分是實現(xiàn)麻煩,這里講述一個個人認為最好的方案
需求
一個圖片查看器莹捡,要求可以滑動 Fling篮赢,觸碰到邊界的時候回彈琉挖,有越界回彈的效果,支持雙指縮放寥茫,雙擊縮放
分析
咋一看需求矾麻,應(yīng)該好寫,滾動的時候用 Scroller
來解決弄喘,回彈效果直接用 ValueAnimator
胰耗,設(shè)置插值器為減速插值器來解決柴灯。看似簡單羊始,但是因為是仿物理效果查描,中間牽扯到從滾動到回彈的時候(Scroller
動畫切換到ValueAnimator
動畫)的速度銜接問題,要看上去從滾動到開始回彈至結(jié)束沒有突兀匀油,中間的特判邊界處理是很麻煩的勾笆,還要牽扯到縮放窝爪,所以不考慮這種方案
既然是要模擬現(xiàn)實中的物理效果齐媒,為何不在每一幀根據(jù)當前的狀態(tài)得到對用的加速度纷跛,然后去計算下一幀的狀態(tài)位置贫奠,這樣只要模擬現(xiàn)實中的物理加速度不就可以實現(xiàn)了嗎,那些邊界特判之類的就可以去見閻王了
方案確定完畢刁品,接下來就是選定加速度的方程浩姥,要模擬彈簧的效果状您,拉力很簡單膏孟,用胡克定律嘛!F = k * dx
弊决,摩擦力呢魁淳?Ff = μ*FN
? 這里推薦一個更加好的方案,借鑒自 Rebound 庫昆稿,這是 Facebook 的一個彈簧動畫庫息拜,設(shè)定一個目的數(shù)值少欺,它會根據(jù)當前的拉力,摩擦力畏陕,速度然后變化到目標值氯庆,加速度方程為
a=tension·dx - friction·v
其中 tension
為彈性系數(shù),friction
為摩擦力系數(shù)仁讨,為什么讓摩擦力和速度成正比呢洞豁?如果摩擦力和速度成正比,那么就不存在靜摩擦力刁卜,也就是不存在物體靜止情況下拉力小于摩擦力的情況(因為速度為0的時候曙咽,阻力為0例朱,除非拉力為0),物體肯定會向目標地點靠近洒嗤,遏制了物體摩擦力過大而無法達到目的地情況
類的設(shè)計
為了方便接入各種 View
渔隶,設(shè)計一個 ZoomableGestureHelper
類
public static class ZoomableGestureHelper{
// 因為可以縮放,平移绞灼,用矩陣來表示結(jié)果最好
public Matrix getZoomMatrix()终吼;
/**
* 計算下一幀的位置际跪,dt單位為秒,模擬現(xiàn)實物理
*/
public boolean compute(double dt);
/**
* 獲取到外部容器的范圍
* @return
*/
public abstract Rect getBounds(Rect rect);
/**
* 內(nèi)部滾動視圖的范圍
* @return
*/
public abstract Rect getInnerBounds(Rect rect);
}
設(shè)計目的良姆,我只需要知道視圖的大小邊界 (bounds) 和內(nèi)部可滾動回彈的邊界 (innerBounds)幔戏,就可以通過計算得到一個新的轉(zhuǎn)換矩陣
對于物理狀態(tài),需要一個類 SpringPhysicsState
來做存儲韩玩,里面包含了速度陆馁、拉力系數(shù)、摩擦力系數(shù)击狮,不保存位置益老,因為位置是通過 getBounds
動態(tài)計算得到的
public class SpringPhysicsState {
// 速度
private double velocity;
// 拉力彈性系數(shù)
private double tension;
// 摩擦力系數(shù)
private double friction;
// ---------- 構(gòu)造函數(shù) ----------
/**
* 默認數(shù)值 tension = 40, friction = 12;
*/
public SpringPhysicsState(){
init(40, 12);
}
public SpringPhysicsState(double tension, double friction){
init(tension, friction);
}
public double computeNextPosition(double startPosition, double endPosition, double dt){
// 此處省略計算代碼捺萌,后面會補充
}
// ---------- setter and getter -----------
public double getVelocity() {
return velocity;
}
public void setVelocity(double velocity) {
this.velocity = velocity;
}
public double getTension() {
return tension;
}
public void setTension(double tension) {
this.tension = tension;
}
public double getFriction() {
return friction;
}
public void setFriction(double friction) {
this.friction = friction;
}
// ------------------ 私有函數(shù) -------------------
private void init(double tension, double friction){
this.velocity = 0;
this.friction = friction;
this.tension = tension;
}
}
移動的處理
速度分解成水平方向和垂直方向桃纯,因為處理方法一樣慈参,下面只講述垂直方向的計算
public boolean compute(double realDeltaTime){
...
// 更新 bounds 信息, bounds 為 Rect 類型
getBounds(bounds);
getInnerBounds(innerBounds);
/**
* 以下移動的計算
*/
double xPosition = 0, xEndPosition = 0;
double yPosition = 0, yEndPosition = 0;
// 設(shè)置摩擦力系數(shù)
xPhysicsState.setFriction(FRICTION * getDensity());
yPhysicsState.setFriction(FRICTION * getDensity());
// 計算x軸方向
...
// 計算y軸方向
if (getHeight(bounds) > getHeight(innerBounds)){
// 狀態(tài)3 (見下面解析)
yPosition = (innerBounds.bottom + innerBounds.top) / 2.0f;
yEndPosition = (bounds.top + bounds.bottom) / 2.0f;
} else {
if (innerBounds.top > bounds.top){
// 狀態(tài)1
yPosition = innerBounds.top;
yEndPosition = bounds.top;
} else if (innerBounds.bottom < bounds.bottom){
// 狀態(tài)1
yPosition = innerBounds.bottom;
yEndPosition = bounds.bottom;
} else {
// 狀態(tài)2驮配,滑動fling狀態(tài)着茸,需要更換摩擦力系數(shù)
yPhysicsState.setFriction(FLING_FRICTION * getDensity());
}
}
double newYPosition = yPosition;
yPhysicsState.computeNextPosition(newYPosition, yEndPosition, SOLVER_TIMESTEP_SEC);
// 移動
zooomMatrix.postTranslate((float) (newXPosition - xPosition), (float) (newYPosition - yPosition));
// 返回false不會表示結(jié)束計算涮阔,不會有下次計算了
// sgn 函數(shù) (x) => (x > EPS ? 1 : 0) - (x < -EPS ? 1 : 0)
// EPS = 1e-4;
return sgn(newYPosition - yEndPosition) != 0 || sgn(yPhysicsState.getVelocity()) != 0;
}
紅色框為視圖的區(qū)域,藍色框為內(nèi)部圖片的區(qū)域掰邢,幀計算觸發(fā)時機使用
View
的computeScroll
方法伟阔,這里會牽扯到停止判定皱炉,之后會講述
狀態(tài)1 :其中一邊有越界
分析一下上圖中的位置,藍色部分為內(nèi)部圖片多搀,它被拖動越界了康铭,此時的合力應(yīng)該為 tension * dx - friction * v
, v
為圖片在 y 軸方向上的速度,(dx
和 v
都是矢量蒸痹,我暫且設(shè)置向右和向下為正)呛哟,之后就直接調(diào)用invalidate();
,就可以播放動畫了榛鼎。
狀態(tài)2:兩邊都沒越界
此時因為兩邊都沒有越界者娱,所以應(yīng)該不存在拉力苏揣,可以認為此時dx
為0平匈,摩擦力需要注意下,因為可以支持滑動(Fling
)忍燥,所以此時的摩擦力要比之前越界回彈時候的摩擦力小隙姿,至于具體數(shù)值,文末會給出
狀態(tài)3:兩邊都超出
此時兩邊都超出邊界,藍色區(qū)域應(yīng)該和紅色區(qū)域中心綁定饲嗽,所以此時的 dx
為 dxBottom - dxTop
(注意符號貌虾,因為dx
為矢量,所以不能是dxTop - dxBottom
)
縮放的處理
public boolean compute(double realDeltaTime){
/**
* 以下縮放的計算
*/
double scale = Math.max(getWidth(innerBounds) * 1.0 / getWidth(bounds), getHeight(innerBounds) * 1.0 / getHeight(bounds));
double endScale = 1, startScale = scale;
if(scale >= 1){
// 此時表示不需要自動適應(yīng)衔憨,把dx改為0
endScale = scale;
}
// 計算下一幀的縮放值
scale = scalePhysicsState.computeNextPosition(scale, endScale, SOLVER_TIMESTEP_SEC);
if(sgn(scale) != 0) {
// x, y 為縮放中心
double x = autoScale ? autoScaleCenterX : (innerBounds.right + innerBounds.left) / 2.0;
double y = autoScale ? autoScaleCenterY : (innerBounds.bottom + innerBounds.top) / 2.0;
zooomMatrix.postScale((float)(scale / startScale), (float)(scale / startScale),
(float)x, (float)y);
}
return sgn(scale) != 0;
}
縮放的方法和移動一致践图,設(shè)定 tension
和 friction
,邊界設(shè)定為外面紅色的框框德崭,藍色區(qū)域無法某一邊充滿紅色區(qū)域的時候揖盘,有拉力兽狭,否則沒拉力,摩擦力一直存在服球,至于雙擊放大和放小颠焦,只需要在雙擊的時候給縮放狀態(tài)設(shè)置一個初速度伐庭,然后invalidate();
,搞定!是不是很簡單啊
觸發(fā)的時間間隔 (dt)
時間這一個參數(shù)在計算中是非常重要的丈秩,這關(guān)系到當前微分狀態(tài)的數(shù)值變化蘑秽,假如用歐拉方法模擬速度和位置的變化,x' = x + v * dt
幼衰,v' = v + a * dt
缀雳,公式可以看出時間決定了動畫的快慢,為了接近現(xiàn)實物理時間绝葡,這里采用的時間單位為秒(計算機中常用的是毫秒)
確定了單位腹鹉,還需要控制一下時間間隔的數(shù)值范圍,我們不能讓兩次computeScroll
的時間間隔過于短或者過于長愉阎,這里采用的策略為固定每次計算時候的時間間隔榜旦,如果兩次 computeScroll
的時間間隔小于此時間間隔刊侯,那么保存累計時間間隔,等待下一次 computeScroll
藕届,直到大于等于固定的時間間隔休偶,再用 while
循環(huán)一步一步的計算
public boolean compute(double realDeltaTime){
double adjustTime = realDeltaTime;
// 如果大于最大給定的間隔辜羊,設(shè)置成最大
if (adjustTime > MAX_DELTA_TIME_SEC){
adjustTime = MAX_DELTA_TIME_SEC;
}
// 計時
timeAccumulator += adjustTime;
// 分步 while 計算
while(timeAccumulator >= SOLVER_TIMESTEP_SEC){
timeAccumulator -= SOLVER_TIMESTEP_SEC;
newXPosition = xPhysicsState.computeNextPosition(newXPosition, xEndPosition, SOLVER_TIMESTEP_SEC);
newYPosition = yPhysicsState.computeNextPosition(newYPosition, yEndPosition, SOLVER_TIMESTEP_SEC);
}
}
結(jié)束判定
結(jié)束判定是唯一的一個坑八秃,因為計算機只是在 dt
時間內(nèi)模擬速度和位移的變化,不是通過微積分計算的疹尾,存在誤差骤肛,比如歐拉方法 x' = x + v * dt
和 v' = v + a * dt
計算得到的 x'
和 v'
都是近似數(shù)值腋颠,把 dt
這段時間內(nèi)的變化看成了勻變速運動
計算機中歐拉方法誤差還是大的,可以選擇另一種誤差小的計算方法巾腕,龍格庫塔4階,精度很高
// 四階龍格庫塔
public double computeNextPosition(double startPosition, double endPosition, double dt){
double position;
double tempPosition, tempVelocity;
double aVelocity, aAcceleration;
double bVelocity, bAcceleration;
double cVelocity, cAcceleration;
double dVelocity, dAcceleration;
position = startPosition;
tempPosition = startPosition;
// 龍格庫塔 4階
aVelocity = velocity;
aAcceleration = (tension * (endPosition - tempPosition)) - friction * velocity;
tempPosition = position + aVelocity * dt * 0.5f;
tempVelocity = velocity + aAcceleration * dt * 0.5f;
bVelocity = tempVelocity;
bAcceleration = (tension * (endPosition - tempPosition)) - friction * tempVelocity;
tempPosition = position + bVelocity * dt * 0.5f;
tempVelocity = velocity + bAcceleration * dt * 0.5f;
cVelocity = tempVelocity;
cAcceleration = (tension * (endPosition - tempPosition)) - friction * tempVelocity;
tempPosition = position + cVelocity * dt;
tempVelocity = velocity + cAcceleration * dt;
dVelocity = tempVelocity;
dAcceleration = (tension * (endPosition - tempPosition)) - friction * tempVelocity;
// Take the weighted sum of the 4 derivatives as the final output.
double dxdt = 1.0f/6.0f * (aVelocity + 2.0f * (bVelocity + cVelocity) + dVelocity);
double dvdt = 1.0f/6.0f * (aAcceleration + 2.0f * (bAcceleration + cAcceleration) + dAcceleration);
position += dxdt * dt;
velocity += dvdt * dt;
return position;
}
所以結(jié)束判定還需要設(shè)置一個閾值,當速度和偏移量小于此數(shù)值的時候亲茅,可以認定為達到了目的地
private boolean isAtReset(SpringPhysicsState physicsState, double positionDis){
return Math.abs(physicsState.getVelocity()) < getDensity() * MIN_RESET_VELOCITY &&
(Math.abs(positionDis) < getDensity() * MIN_RESET_POSITION || sgn(physicsState.getTension()) == 0);
}
常數(shù)系數(shù)選擇
// 用于 sgn 函數(shù)
private static final double EPS = 1e-4;
// 每一步計算的時間間隔
private static final double SOLVER_TIMESTEP_SEC = 0.001;
// 最大的計算時間間隔 dt
private static final double MAX_DELTA_TIME_SEC = 0.064;
// reset 位置 0.05 dp/s 0.05dp
private static final double MIN_RESET_VELOCITY = 0.05;
private static final double MIN_RESET_POSITION = 0.05;
// 縮放開始速度
private static final double AUTO_SCALE_VELOCITY = 10;
// 系數(shù)常數(shù)
// 滑動時候的摩擦力
private static final double FLING_FRICTION = 1;
private static final double FRICTION = 12;
private static final double TENSION = 80;
一些坑
對于 ViewPager
的適配有些問題克锣,如果在 Down
的時候 requestDisallow true
移動過程中到了左右邊界又 requestDisallow false
袭祟,此時 ViewPager
會有一個突變(突變可恥但有用)捞附,而且多指頭的時候可能會崩潰,這是 ViewPager
的 Bug鸟召,具體細節(jié)請看源碼