Camera与Matrix的那些事儿

1、引子

笔者刚开始工作时,做的第一个模块是手机中的launcher,launcher可自由选择滑屏效果,甚至还有三D效果,酷炫的动画让人震惊同时也感到非常迷惑,这动画效果怎么实现的呢?帧动画?补间动画还是属性动画,都不能实现以上效果。它们都与本文中提到的Camera相关

ps:自定义view是android应用开发工程师的必备技能,除了需要了解view原理、touch事件分发等等,还需要了解绘制相关的东西,例如canvas、matrix、camera等等

2、camera

android底层所有的绘制都是通过opengl进行的,为了方便用户使用,android封装了一些常用接口,用户可使用camera进行旋转、移动等等

A camera instance can be used to compute 3D transformations and generate a matrix that can be applied, for instance, on aCanvas.
一个照相机实例可以被用于计算3D变换,生成一个可以被使用的Matrix矩阵,一个实例,用在画布上。

camera的常用方法为:

Camera() 创建一个没有任何转换效果的新的Camera实例
applyToCanvas(Canvas canvas) 根据当前的变换计算出相应的矩阵,然后应用到制定的画布上
getLocationX() 获取Camera的x坐标
getLocationY() 获取Camera的y坐标
getLocationZ() 获取Camera的z坐标
getMatrix(Matrixmatrix) 获取转换效果后的Matrix对象
restore() 恢复保存的状态
rotate(float x, float y, float z) 沿X、Y、Z坐标进行旋转
rotateX(float deg)
rotateY(float deg)
rotateZ(float deg)
save() 保存状态
setLocation(float x, float y, float z)
translate(float x, float y, float z)沿X、Y、Z轴进行平移

可将camera想象成一个处于坐标原点的相机,而view在空间中的某一点,通过操作相机,view在相机中看到的就不一样了,可实现旋转、移动等等,如果沿Z轴移动,view还能放大缩小

上文中提到旋转,其实canvas本身也可以旋转,也可以平衡。但camera比canvas的强大之处在于,camera的坐标系为空间坐标系,它有Z轴,它是立体的。而canvas的坐标系是平面的。

camera的坐标系为左手坐标系,伸出左手,大姆指朝x轴正向,食指朝Y轴方向,中指垂直于view平面,指向Z轴。

左手坐标系.png

3、matrix

matrix代表着一个矩阵,view的平移、旋转等等,系统都会封装成矩阵运算,将结果保存在矩阵中,根据矩阵绘制view

matrix类中封装了一些接口,开发者不需要直接写矩阵的值,就可以实现平移、旋转、缩放等。

setTranslate(floatdx,floatdy):控制Matrix进行平移
setSkew(floatkx,floatky,floatpx,floatpy):控制Matrix以px,py为轴心进行倾斜,kx,ky为X,Y方向上的倾斜距离
setRotate(floatdegress):控制Matrix进行旋转,degress控制旋转的角度
setRorate(floatdegress,floatpx,floatpy):设置以px,py为轴心进行旋转,degress控制旋转角度
setScale(floatsx,floatsy):设置Matrix进行缩放,sx,sy控制X,Y方向上的缩放比例
setScale(floatsx,floatsy,floatpx,floatpy):设置Matrix以px,py为轴心进行缩放,sx,sy控制X,Y方向上的缩放比例

matrix还提供了pre和post两种类型操作。pre代表前乘,post代表后乘,因为矩阵是不满足交换律的,所以pre和post操作完全不一样

4、旋转中心

view常用操作有三种,平移、缩放、旋转,其中平移最简单,缩放和旋转略复杂,下面以旋转为例说明。
view的旋转中心默认是坐标原点,如果view在旋转前不作任何操作,往往达不到用户想要的效果。往往需要在旋转前将view的中心点移到原点处,再旋转,再将中心点移回原位,这样view将按照用户标明的中心点旋转

    matrix.preTranslate(-centerX, -centerY);
    matrix.postTranslate(centerX, centerY);

上述代码中也正好解释了matrix的pre和post操作。

5、使用Camera与Matrix实现三D容器

先看效果


效果.png

容器中有四个子view,子view大小和父view一样,且是竖直布局,在绘制时,根据容器的滚动距离计算子view的旋转角度。

  • 测量过程

    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
      super.onMeasure(widthMeasureSpec, heightMeasureSpec);
      int w = MeasureSpec.getSize(widthMeasureSpec);
      int h = MeasureSpec.getSize(heightMeasureSpec);
      setMeasuredDimension(w, h);
      mWidth = w;
      mHeight = h;
      
      int childW = w - getPaddingLeft() - getPaddingRight();
      int childH = h - getPaddingTop() - getPaddingBottom();
      
      int childWSpec = MeasureSpec.makeMeasureSpec(childW, MeasureSpec.EXACTLY);
      int childHSpec = MeasureSpec.makeMeasureSpec(childH, MeasureSpec.EXACTLY);
      measureChildren(childWSpec, childHSpec);
      //默认此容器滚动到第二个view处
      scrollTo(0, mStartScreen*mHeight);
    }
    

测量过程较简单,但在最后时,让view滚动到第二屏。

  • 布局过程

    protected void onLayout(boolean changed, int l, int t, int r, int b) {
      for (int i = 0; i < getChildCount(); i++) {
          View child = getChildAt(i);
          int left = (getPaddingLeft() + getPaddingRight())/2;
          int top = (getPaddingTop() + getPaddingBottom())/2;
          //子view是竖直排列的,通过camera方式,旋转才看到三D效果
          child.layout(left, top + i*mHeight, left + mWidth, top + (i+1)*mHeight);
      }
    }
    

布局过程也比较简单,竖直排列

  • 绘制过程

    protected void dispatchDraw(Canvas canvas) {
      for (int i = 0; i < getChildCount(); i++) {
          drawScreen(canvas, i, getDrawingTime());
      }
    }
    
    private void drawScreen(Canvas canvas, int index, long time){
      int scrollHeight = mHeight * index;
      int scrollY = getScrollY();
      //view的位置明显看不到,则不需要绘制。比如滚动距离+view高度,还小于view的起始top值,则此view不绘制
      if (scrollHeight > scrollY + mHeight || scrollHeight < scrollY - mHeight) {
          return;
      }
      View child = getChildAt(index);
      //旋转中心点是旋转中的关键。view旋转的中心点都是0,0点,
      //所以需要先将中心点移到0,0点,旋转,再移动回来,看起来像view是在中心点旋转一样
      //如果是滚动距离大于view的top点,那么则y中心点则是view的bottom位置,否则则是top位置
      float centerX = mWidth/2;
      float centerY = (getScrollY() > scrollHeight) ? scrollHeight + mHeight : scrollHeight;
      //计算旋转角度
      float degree = mAngle * (getScrollY() - scrollHeight)/mHeight;
      if (degree > 90 || degree < -90) {
          return;
      }
      canvas.save();
      mCamera.save();
      matrix.reset();
      mCamera.rotateX(degree);
      mCamera.getMatrix(matrix);
      mCamera.restore();
      //移动到旋转中心点
      matrix.preTranslate(-centerX, -centerY);
      matrix.postTranslate(centerX, centerY);
      canvas.concat(matrix);
      drawChild(canvas, child, time);
      canvas.restore();
    }
    

绘制过程有两个难点:

  1. 角度计算
    在measure阶段,容器向下滚动mHeight距离(子view高度),用户看到的是第二个子view,第一个子view应该旋转角度为90度,第二个子view旋转角度为0度,第三个子view旋转角度为-90度。由此得出以下公式

float degree = mAngle * (getScrollY() - scrollHeight)/mHeight;

  1. 旋转中心点计算
    每个子view的旋转中心点是不一样的,为了达到协同的效果,旋转中心y值按如下公式计算

float centerY = (getScrollY() > scrollHeight) ? scrollHeight + mHeight : scrollHeight;

如果是滚动距离大于view的top点,那么则y中心点则是view的bottom位置,否则则是top位置。第一个子view以(w/2,h)为中心点旋转,而第二个子view也是以(w/2,h)为中心点旋转,符合上述公式。旋转中心点的问题需要认真思考,在纸上绘制示意图会帮助理解。

找到旋转中心点以及角度计算,则非常简单了,使用camera绕X轴旋转一定角度,得到对应的matrix,将矩阵应用到canvas上,同时进行旋转时的中心点平移工作,注意相关对象的状态保存及恢复,整个绘制程序则完成。

  • Touch事件处理
    需要view容器完成跟手操作,当手松开时,容器需要回到正确的状态(或到上一页、下一页等)。

子view的旋转角度与容器的滚动距离有关系,因此处理move事件时,滚动容器即可。当手松开时,需要计算容器的下一个状态是什么,容器是显示上一个子view还是显示下一个子view,还是停留在本页内,根据下一个状态让容器滚动适当的距离即可。

  case MotionEvent.ACTION_MOVE:
        mVelocityTracker.addMovement(event);
        float y = event.getY();
        float detal = y - mDownY;
        mDownY = y;
        //当scroller结束滚动时再响应move事件。
        if (mScroller.isFinished()) {
            moveScroll((int)detal);
        }
        break;
  case MotionEvent.ACTION_UP:
        mVelocityTracker.addMovement(event);
        mVelocityTracker.computeCurrentVelocity(1000);
        float vel = mVelocityTracker.getYVelocity();
  //            Log.i(TAG, "vel = " + vel);
        //y速度值为正则是往下滑动,为负则是往上滑动,以500为界定
        if (vel >= 500) {
            moveToNext();
            //滑动到下一屏
        }else if (vel <= -500) {
            //滑动到上一屏
            moveToPre();
        }else {
            //依然在当前屏
            moveNormal();
        }
        mVelocityTracker.clear();
        mVelocityTracker.recycle();
        mVelocityTracker = null;
        break;

当容器显示第一个子view时,用户还在向上翻动容器,为完成循环显示效果,需要将容器的第四个子view在原位置删除,添加到容器的0位置。同理,另一种情况下需要将第一个子view删除,将它添加到容器的3位置,这样用户将看到正方体旋转效果。

  private void moveToPre(){
    addPreView();
    int scrolly = getScrollY();
    //以从第二个view回到第一个view为例,第二个view的滚动距离为scrolly,而第一个view的正常位置则是滚动距离为0
    //所以从第二个view滚动回第一个view的真正距离就是scrolly,因为是向上,所以为负值
    int curY = scrolly + mHeight;
    setScrollY(curY);
    int detal = -(curY - mHeight);
    mScroller.startScroll(0, curY, 0, detal,500);
}

private void moveToNext(){
    addNextView();
    int scrolly = getScrollY();
    int curY = scrolly - mHeight;
    setScrollY(curY);
    int detal = mHeight - (curY);
    mScroller.startScroll(0, curY, 0, detal,500);
}

private void moveNormal(){
    int scrolly = getScrollY();
    int curY = scrolly;
    int detal = mHeight - curY;
    //Log.i(TAG, "cury = " + curY + "  detal = " + detal + "  mheight = " + mHeight);
    mScroller.startScroll(0, curY, 0, detal,500);
    //此处必须刷新,否则computeScroll不会执行
    invalidate();
}

6、后记

关于camera与matrix相关的文章,有不少大神已经写文说明了,本例也差不多,且多有借鉴,例如,http://www.jianshu.com/p/34e0fe5f9e31 ,非为抄袭,只为总结知识,自成知识体系。

代码均已上传到本人的github:https://github.com/okunu/DemoApp ,欢迎访问。

推荐阅读更多精彩内容