如何优雅地在Android上实现iOS的图片预览

原文博客链接

用过 iOS 的都知道,拟物理的回弹效果在上面非常普遍,因为这是 iOS 系统支持的一套 UI 框架,但是 Android 就没有了,就拿图片查看器来讲,iOS 的效果就是感觉一张图片被绑定在了弹簧装置上,滑动很自然,Android 没有自带的图片查看器,需要自己实现

市面上主流的图片查看器都没有回弹的效果,一部分原因是没有这个需求,还有一部分是实现麻烦,这里讲述一个个人认为最好的方案

需求

一个图片查看器,要求可以滑动 Fling,触碰到边界的时候回弹,有越界回弹的效果,支持双指缩放,双击缩放

分析

咋一看需求,应该好写,滚动的时候用 Scroller 来解决,回弹效果直接用 ValueAnimator,设置插值器为减速插值器来解决。看似简单,但是因为是仿物理效果,中间牵扯到从滚动到回弹的时候(Scroller动画切换到ValueAnimator动画)的速度衔接问题,要看上去从滚动到开始回弹至结束没有突兀,中间的特判边界处理是很麻烦的,还要牵扯到缩放,所以不考虑这种方案

既然是要模拟现实中的物理效果,为何不在每一帧根据当前的状态得到对用的加速度,然后去计算下一帧的状态位置,这样只要模拟现实中的物理加速度不就可以实现了吗,那些边界特判之类的就可以去见阎王了

方案确定完毕,接下来就是选定加速度的方程,要模拟弹簧的效果,拉力很简单,用胡克定律嘛!F = k * dx,摩擦力呢?Ff = μ*FN ? 这里推荐一个更加好的方案,借鉴自 Rebound 库,这是 Facebook 的一个弹簧动画库,设定一个目的数值,它会根据当前的拉力,摩擦力,速度然后变化到目标值,加速度方程为

a=tension·dx - friction·v

其中 tension 为弹性系数,friction为摩擦力系数,为什么让摩擦力和速度成正比呢?如果摩擦力和速度成正比,那么就不存在静摩擦力,也就是不存在物体静止情况下拉力小于摩擦力的情况(因为速度为0的时候,阻力为0,除非拉力为0),物体肯定会向目标地点靠近,遏制了物体摩擦力过大而无法达到目的地情况

类的设计

为了方便接入各种 View ,设计一个 ZoomableGestureHelper

public static class ZoomableGestureHelper{

    // 因为可以缩放,平移,用矩阵来表示结果最好
    public Matrix getZoomMatrix();
    /**
     * 计算下一帧的位置,dt单位为秒,模拟现实物理
     */
    public boolean compute(double dt);

    /**
     * 获取到外部容器的范围
     * @return
     */
    public abstract Rect getBounds(Rect rect);

    /**
     * 内部滚动视图的范围
     * @return
     */
    public abstract Rect getInnerBounds(Rect rect);
}

设计目的,我只需要知道视图的大小边界 (bounds) 和内部可滚动回弹的边界 (innerBounds),就可以通过计算得到一个新的转换矩阵

对于物理状态,需要一个类 SpringPhysicsState 来做存储,里面包含了速度、拉力系数、摩擦力系数,不保存位置,因为位置是通过 getBounds 动态计算得到的

public class SpringPhysicsState {
    // 速度
    private double velocity;
    // 拉力弹性系数
    private double tension;
    // 摩擦力系数
    private double friction;

    // ---------- 构造函数 ----------

    /**
     * 默认数值 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;
    }

    // ------------------ 私有函数 -------------------

    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;
    // 设置摩擦力系数
    xPhysicsState.setFriction(FRICTION * getDensity());
    yPhysicsState.setFriction(FRICTION * getDensity());

    // 计算x轴方向
    ...

    // 计算y轴方向
    if (getHeight(bounds) > getHeight(innerBounds)){
        // 状态3 (见下面解析)
        yPosition = (innerBounds.bottom + innerBounds.top) / 2.0f;
        yEndPosition = (bounds.top + bounds.bottom) / 2.0f;
    } else {
        if (innerBounds.top > bounds.top){
            // 状态1
            yPosition = innerBounds.top;
            yEndPosition = bounds.top;
        } else if (innerBounds.bottom < bounds.bottom){
            // 状态1
            yPosition = innerBounds.bottom;
            yEndPosition = bounds.bottom;
        } else {
            // 状态2,滑动fling状态,需要更换摩擦力系数
            yPhysicsState.setFriction(FLING_FRICTION * getDensity());
        }
    }

    double newYPosition = yPosition;

    yPhysicsState.computeNextPosition(newYPosition, yEndPosition, SOLVER_TIMESTEP_SEC);

    // 移动
    zooomMatrix.postTranslate((float) (newXPosition - xPosition), (float) (newYPosition - yPosition));

    // 返回false不会表示结束计算,不会有下次计算了
    // sgn 函数 (x) => (x > EPS ? 1 : 0) - (x < -EPS ? 1 : 0)
    // EPS = 1e-4;
    return sgn(newYPosition - yEndPosition) != 0 || sgn(yPhysicsState.getVelocity()) != 0;
}

红色框为视图的区域,蓝色框为内部图片的区域,帧计算触发时机使用 ViewcomputeScroll 方法,这里会牵扯到停止判定,之后会讲述

状态1 :其中一边有越界

Alt text

分析一下上图中的位置,蓝色部分为内部图片,它被拖动越界了,此时的合力应该为 tension * dx - friction * v, v为图片在 y 轴方向上的速度,(dxv 都是矢量,我暂且设置向右和向下为正),之后就直接调用invalidate();,就可以播放动画了。

状态2:两边都没越界

Alt text

此时因为两边都没有越界,所以应该不存在拉力,可以认为此时dx为0,摩擦力需要注意下,因为可以支持滑动(Fling),所以此时的摩擦力要比之前越界回弹时候的摩擦力小,至于具体数值,文末会给出

状态3:两边都超出

Alt text

此时两边都超出边界,蓝色区域应该和红色区域中心绑定,所以此时的 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){
        // 此时表示不需要自动适应,把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;
}

缩放的方法和移动一致,设定 tensionfriction ,边界设定为外面红色的框框,蓝色区域无法某一边充满红色区域的时候,有拉力,否则没拉力,摩擦力一直存在,至于双击放大和放小,只需要在双击的时候给缩放状态设置一个初速度,然后invalidate();,搞定!是不是很简单啊

触发的时间间隔 (dt)

时间这一个参数在计算中是非常重要的,这关系到当前微分状态的数值变化,假如用欧拉方法模拟速度和位置的变化,x' = x + v * dtv' = v + a * dt,公式可以看出时间决定了动画的快慢,为了接近现实物理时间,这里采用的时间单位为秒(计算机中常用的是毫秒)

确定了单位,还需要控制一下时间间隔的数值范围,我们不能让两次computeScroll的时间间隔过于短或者过于长,这里采用的策略为固定每次计算时候的时间间隔,如果两次 computeScroll 的时间间隔小于此时间间隔,那么保存累计时间间隔,等待下一次 computeScroll,直到大于等于固定的时间间隔,再用 while 循环一步一步的计算

public boolean compute(double realDeltaTime){
    double adjustTime = realDeltaTime;
    // 如果大于最大给定的间隔,设置成最大

    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);
    }
}

结束判定

结束判定是唯一的一个坑,因为计算机只是在 dt 时间内模拟速度和位移的变化,不是通过微积分计算的,存在误差,比如欧拉方法 x' = x + v * dtv' = v + a * dt计算得到的 x'v' 都是近似数值,把 dt这段时间内的变化看成了匀变速运动

计算机中欧拉方法误差还是大的,可以选择另一种误差小的计算方法,龙格库塔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;
}

所以结束判定还需要设置一个阈值,当速度和偏移量小于此数值的时候,可以认定为达到了目的地

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);
}

常数系数选择

// 用于 sgn 函数
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;

// 系数常数
// 滑动时候的摩擦力
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,具体细节请看源码

源码敬上

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,761评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,953评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,998评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,248评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,130评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,145评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,550评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,236评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,510评论 1 291
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,601评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,376评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,247评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,613评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,911评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,191评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,532评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,739评论 2 335

推荐阅读更多精彩内容