如何优雅地在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;}
红色框为视图的区域,蓝色框为内部图片的区域,帧计算触发时机使用
View
的computeScroll
方法,这里会牵扯到停止判定,之后会讲述
状态1 :其中一边有越界
Alt text分析一下上图中的位置,蓝色部分为内部图片,它被拖动越界了,此时的合力应该为 tension * dx - friction * v
, v
为图片在 y 轴方向上的速度,(dx
和 v
都是矢量,我暂且设置向右和向下为正),之后就直接调用invalidate();
,就可以播放动画了。
状态2:两边都没越界
Alt text此时因为两边都没有越界,所以应该不存在拉力,可以认为此时dx
为0,摩擦力需要注意下,因为可以支持滑动(Fling
),所以此时的摩擦力要比之前越界回弹时候的摩擦力小,至于具体数值,文末会给出
状态3:两边都超出
Alt text此时两边都超出边界,蓝色区域应该和红色区域中心绑定,所以此时的 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){ // 此时表示不需要自动适应,把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;}
缩放的方法和移动一致,设定 tension
和 friction
,边界设定为外面红色的框框,蓝色区域无法某一边充满红色区域的时候,有拉力,否则没拉力,摩擦力一直存在,至于双击放大和放小,只需要在双击的时候给缩放状态设置一个初速度,然后invalidate();
,搞定!是不是很简单啊
触发的时间间隔 (dt)
时间这一个参数在计算中是非常重要的,这关系到当前微分状态的数值变化,假如用欧拉方法模拟速度和位置的变化,x' = x + v * dt
,v' = 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 * dt
和 v' = 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;// 最大的计算时间间隔 dtprivate static final double MAX_DELTA_TIME_SEC = 0.064;// reset 位置 0.05 dp/s 0.05dpprivate 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,具体细节请看源码
源码敬上
更多相关文章
- android自定义动画的一点感悟
- Android小项目:计算器
- Android异步加载全解析之大图处理
- 关于Android(安卓)Matrix pre post 的理解
- Android处理大图,如一张30M的大图,如何预防OOM
- Android通过多点触控的方式对图片进行缩放的实例代码
- Android图片裁剪实现(EnjoyCrop)
- Android9.0万年历毕业设计H5小应用webview应用源码分析已运行通
- android屏幕适配计算方式及适配values文件生成