如何優(yōu)雅地在Android上實現(xiàn)iOS的圖片預(yù)覽

原文博客鏈接

用過 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ā)時機使用 ViewcomputeScroll 方法伟阔,這里會牽扯到停止判定皱炉,之后會講述

狀態(tài)1 :其中一邊有越界

Alt text

分析一下上圖中的位置,藍色部分為內(nèi)部圖片多搀,它被拖動越界了康铭,此時的合力應(yīng)該為 tension * dx - friction * v, v為圖片在 y 軸方向上的速度,(dxv 都是矢量蒸痹,我暫且設(shè)置向右和向下為正)呛哟,之后就直接調(diào)用invalidate();,就可以播放動畫了榛鼎。

狀態(tài)2:兩邊都沒越界

Alt text

此時因為兩邊都沒有越界者娱,所以應(yīng)該不存在拉力苏揣,可以認為此時dx為0平匈,摩擦力需要注意下,因為可以支持滑動(Fling)忍燥,所以此時的摩擦力要比之前越界回彈時候的摩擦力小隙姿,至于具體數(shù)值,文末會給出

狀態(tài)3:兩邊都超出

Alt text

此時兩邊都超出邊界,藍色區(qū)域應(yīng)該和紅色區(qū)域中心綁定饲嗽,所以此時的 dxdxBottom - 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è)定 tensionfriction ,邊界設(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 * dtv' = 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é)請看源碼

源碼敬上

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末胆绊,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子欧募,更是在濱河造成了極大的恐慌压状,老刑警劉巖,帶你破解...
    沈念sama閱讀 218,941評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件跟继,死亡現(xiàn)場離奇詭異种冬,居然都是意外死亡,警方通過查閱死者的電腦和手機舔糖,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評論 3 395
  • 文/潘曉璐 我一進店門娱两,熙熙樓的掌柜王于貴愁眉苦臉地迎上來金吗,“玉大人谷婆,你說我怎么就攤上這事×闪模” “怎么了?”我有些...
    開封第一講書人閱讀 165,345評論 0 356
  • 文/不壞的土叔 我叫張陵期贫,是天一觀的道長跟匆。 經(jīng)常有香客問我,道長通砍,這世上最難降的妖魔是什么玛臂? 我笑而不...
    開封第一講書人閱讀 58,851評論 1 295
  • 正文 為了忘掉前任烤蜕,我火速辦了婚禮,結(jié)果婚禮上迹冤,老公的妹妹穿的比我還像新娘讽营。我一直安慰自己,他們只是感情好泡徙,可當我...
    茶點故事閱讀 67,868評論 6 392
  • 文/花漫 我一把揭開白布橱鹏。 她就那樣靜靜地躺著,像睡著了一般堪藐。 火紅的嫁衣襯著肌膚如雪莉兰。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,688評論 1 305
  • 那天礁竞,我揣著相機與錄音糖荒,去河邊找鬼。 笑死模捂,一個胖子當著我的面吹牛捶朵,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播狂男,決...
    沈念sama閱讀 40,414評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼综看,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了并淋?” 一聲冷哼從身側(cè)響起寓搬,我...
    開封第一講書人閱讀 39,319評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎县耽,沒想到半個月后句喷,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,775評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡兔毙,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,945評論 3 336
  • 正文 我和宋清朗相戀三年唾琼,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片澎剥。...
    茶點故事閱讀 40,096評論 1 350
  • 序言:一個原本活蹦亂跳的男人離奇死亡礼预,死狀恐怖瞳腌,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤刨疼,帶...
    沈念sama閱讀 35,789評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站眷唉,受9級特大地震影響隘庄,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜绞佩,卻給世界環(huán)境...
    茶點故事閱讀 41,437評論 3 331
  • 文/蒙蒙 一寺鸥、第九天 我趴在偏房一處隱蔽的房頂上張望猪钮。 院中可真熱鬧,春花似錦胆建、人聲如沸烤低。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,993評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽扑馁。三九已至,卻和暖如春宰译,著一層夾襖步出監(jiān)牢的瞬間檐蚜,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,107評論 1 271
  • 我被黑心中介騙來泰國打工沿侈, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留闯第,地道東北人。 一個月前我還...
    沈念sama閱讀 48,308評論 3 372
  • 正文 我出身青樓缀拭,卻偏偏與公主長得像咳短,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子蛛淋,可洞房花燭夜當晚...
    茶點故事閱讀 45,037評論 2 355

推薦閱讀更多精彩內(nèi)容