Android launcher 的平滑和立体翻页效果

我们这里把 Android launcher 程序的 Workspace 相关的代码抽取出来,以一个比较简单的代码来展示 launcher 程序是如何实现多页以及不同页面之间的切换效果。本示例代码在 SDK 2.1 中运行,设置的是 WVGA 的屏幕大小。

首先我们来看一下程序运行的效果来一些感性的认识。

图 1:平滑移动效果

 


图 2:立体翻页效果

 

 

窗口页面的布局

接着我们来看一下程序 UI(即 View 和 ViewGroup)的布局,Activity 的 ContentView 是 layout 中的 main.xml。它的内容如下:

清单 1.

 

其中 FlatWorkspace 的基类是 Workspace,它继承自 ViewGroup,是一个容器类,其中包含三个子 View,子 View 是 ImageView。三个 ImageView 就是三个页面。这三个 ImageView 的创建是在 WorkspaceActivity 的 onCreate 函数中调用 Workspace 的 initScreens 函数完成的,代码如下:

清单 2

ViewGroup.LayoutParams p = new iewGroup.LayoutParams(ViewGroup.LayoutParams.FILL_PARENT,ViewGroup.LayoutParams.FILL_PARENT); 	
        	 for (int i = 0; i < 3; i++) { 
			 this.addView(new ImageView(this.getContext()), i, p); 
		 } 
		 ((ImageView)this.getChildAt(0)).setImageResource(R.drawable.image_search); 
		 ((ImageView)this.getChildAt(1)).setImageResource(R.drawable.image_system); 
		 ((ImageView)this.getChildAt(2)).setImageResource(R.drawable.image_top);

图 3:Workspace 和页面布局图

 

为了让三个页面达到上图的窗口布局,我们对 Workspace 的 onMeasure 和 onLayout 函数进行了重载,重点在 onLayout 代码中。onLayout 函数调用 layoutScreens 函数完成布局,FlatWorkspace 中的 layoutScreens 实现如下:

清单 3

protected void layoutScreens() { 
        int childLeft = 0; 
        final int count = getChildCount(); 
        for (int i = 0; i < count; i++) { 
            final View child = getChildAt(i); 
            if (child.getVisibility() != View.GONE) { 
                final int childWidth = child.getMeasuredWidth(); 
                child.layout(childLeft, 0, childLeft + childWidth, child.getMeasuredHeight()); 
                childLeft += childWidth; 
            } 
        } 
 }

上面 child.layout 部分的代码把三个页面分别布局到了 X 和 Y 坐标系中的((0,0)-(ScreenWidth,ScreenHeight))和((ScreenWidth,0)-(2*ScreenWidth,ScreenHeight))以及((2*ScreenWidth,0)-(3*ScreenWidth,ScreenHeight))三个矩形区域中,这里用矩形区域的左上角顶点坐标和右下角的顶点坐标来表示矩阵。

至此我们已经完成了整个窗口页面的布局,窗口页面的布局大小是实际可视屏幕宽度的三倍,所以要显示所有页面需要让页面滚动。

 

页面的平滑移动的实现

下面来看用户 touch move 的时候程序如何让页面进行滑动,并且绘制他们。

页面的滑动可以调用 View 的 scrollBy 或 ScrollTo 函数,在 Workspace 的 onTouchEvent 函数中取得用户的手指移动的距离,然后调用 scrollBy(它的参数就是 X 和 Y 轴上需要移动的距离)来让 Workspace 这个 View(也是 ViewGroup)移动用户手指移动的距离,当然 View 移动之前得判断一下用户手指移动的距离和速度是否足够才进行移动,以此减少用户的误操作。这部分代码简单就不进行深入分析了,请大家自己看看代码。

当 Workspace 这个 View 调用 scrollBy 进行 View 的滚动时,必然导致这个 View 无效,从而被系统重新绘制,所以它的 dispatchDraw 函数会被调用来进行子 View(ImageView)的绘制,它本身没有什么东西要绘制,所以就不用关心 Workspace 的 onDraw 函数了。dispatchDraw 函数会调用 drawScreens(canvas) 来对子 View 进行绘制。我们来看一下 FlatWorkspace 的实现:

清单 4

protected void drawScreens(Canvas canvas) { 
        final long drawingTime = getDrawingTime(); 
        final int count = getChildCount(); 
        for (int i = 0; i < count; i++) { 
            drawChild(canvas, getChildAt(i), drawingTime); 
        } 
    }

这里的 canvas 宽高就是屏幕可视范围的大小(如 HVGA 屏幕的 320 × 480 大小),而三个子 ImageView 的布局要超出屏幕的范围,不在屏幕可视范围之内的部分是不会被绘制的。这个绘制三个子 ImageView 的函数很重要,是制作立方体翻页等特效的关键地方,FlatWorkspace 实现的是平滑滑动效果,所以我们直接绘制三个子 ImageView。如果要实现立方体的效果,在绘制三个子 ImageView 的时候就要让它们被绘制的时候有立体感,这个在 android 中我们可以通过上文提到的 Camera 类沿 Y 轴旋转一定的角度实现。

程序让用户进行 touch move 操作的目的是让用户选择一个页面,如果按照上面的实现,当用户最后抬起手指时,页面切换不会很彻底,而是象图 1 一样停留在两个页面之间。所以当用户抬起手指时程序需判断一下移动到下一个完整的页面还有多大距离,然后让 Workspace 这个 View 再移动这个距离一遍完整的切换到下一页。在这个移动的过程中,为了给用户一个平滑的感觉,不能一下就移动这个距离,而是需要给一定的时间间隔,在这个时间段里逐渐的移动到位,所以这里我们使用 Scroller 类的方法实现逐渐的移动。具体过程是在 Workspace 的 onTouchEvent 函数中检测到用户 touch up(抬起手指)时进行应该调整到哪个页面的判断,然后调用 snapToScreen(targetScreen) 跳转到需要目的页面,然后它调用 scrollToScreen(screen) 让 Workspace 这个 View 进行需要的滚动,这个函数在 FlatWorkspace 中的实现如下:

清单 5

public void scrollToScreen(int screen) { 
        final int newX = screen * getWidth(); 
        final int deltaX = newX - getScrollX(); 
        Log.e("FlatWorkspace","scrollToScreen call mScroller.startScroll"); 
        mScroller.startScroll(getScrollX(), getScrollY(), deltaX, getScrollY(), Math.abs(deltaX) * 2); 
        invalidate(); 
  }

这里的重点是 mScroler.startScroll 部分的代码,它让 Workspace view 在时间段 Math.abs(deltaX) * 2 里移动下一个目标页面可视化需要移动的距离 deltaX(及目的页面的坐标减去目前已经移动的距离),大家请好好看一下这个 deltaX 的计算,这里不细说了。这个 mScroller.startScroll 并不会导致 Workspace 立即进行移动,它只会导致当前 View 无效,从而重新绘制,在 Workspace 被它的父亲 View 调用绘制的时候,它的 computeScroll 函数会被调用,所以会在这个函数中让 Workspace 调用 scrollTo 函数进行实际的移动。代码如下:

清单 6

public void computeScroll() { 
		 if (mScroller.computeScrollOffset()) { 	
			 scrollTo(mScroller.getCurrX(), mScroller.getCurrY()); 
			 //postInvalidate(); 
		 } else if (mNextScreen != INVALID_SCREEN) { 
			 mCurrentScreen = mNextScreen; 
			 mNextScreen = INVALID_SCREEN; 

		 } 
	 }

至此,我们对 Workspace 的整个运行机制和平滑移动的效果是如何实现的已经介绍完成了。下面我们来具体谈谈立体翻页效果是如何实现的。

 

立体翻页效果的实现

通过前面的分析可知,立体翻页效果可以在平滑翻页效果的基础上通过改写三个子 ImageView 的绘制来完成。同时可知,翻页时用户操作过程分为三步:放下手指触摸屏幕,移动手指,抬起手指。手指触摸屏幕表示页面之间的滑动要开始了;移动手指的时候页面应该跟着用户手指的移动距离进行对应距离的移动,同时系统会根据页面的移动位置对 Workspace 里面的三个子 View(即页面)进行绘制;抬起手指的时候判断应该移动到哪个页面,还需要移动多少距离,然后平滑的移动需要的距离来跳转到目的页面上。

为了显示立体效果,对每个子 ImageView 的绘制时得想办法让它沿 Y 轴旋转一定的角度,前面已经提到 android 通过 Camera 这个类提供了这个功能,不需要使用 opengl ES 的东西,当然如果要做出更好的 3D 效果,我们就需要 opengl ES 的强大功能了。既然要旋转一定的角度,那这个角度怎么计算呢?我们把这个角度和用户手指移动的距离关联起来。因为这个立方体只会沿着 Y 轴旋转,我们只看这三个面的立方体的顶部就够了,它的顶部沿着 Y 轴的往其箭头指示的方向看是一个等边三角形,每个面相对于手机屏幕的沿着 Y 轴旋转的角度的计算方法如下图所示:

图 4:初始屏幕位置示意图

 

下图为屏幕 1 沿 Y 轴旋转 45 读后其他两个屏幕需要沿 Y 轴旋转的角度。

图 5:旋转 45 度后屏幕位置示意图

这个变换的部分请看代码 CubeWorkspace 中函数 drawScreen 的代码,如下:

清单 7

protected void drawScreen(Canvas canvas, int screen, long drawingTime) { 
        final int width = getWidth(); 
        final int scrollWidth = screen * width; 
        final int scrollX = this.getScrollX();  
        if(scrollWidth > scrollX + width || scrollWidth + width < scrollX) { 
            return; 
        } 
        final View child = getChildAt(screen); 
        final int faceIndex = screen; 
        final float faceDegree = currentDegree - faceIndex * preFaceDegree; 
        if(faceDegree > 90  faceDegree < -90) { 
            return; 
        } 
        final float centerX = (scrollWidth < scrollX)?scrollWidth + width:scrollWidth; 
        final float centerY = getHeight()/2; 
        final Camera camera = mCamera; 
        final Matrix matrix = mMatrix; 
        canvas.save(); 
        camera.save(); 
        camera.rotateY(-faceDegree); 
        camera.getMatrix(matrix); 
        camera.restore(); 
        matrix.preTranslate(-centerX, -centerY); 
        matrix.postTranslate(centerX, centerY); 
        canvas.concat(matrix); 
        drawChild(canvas, child, drawingTime); 
        child.setBackgroundColor(Color.TRANSPARENT); 
        canvas.restore(); 
    }

上面函数中的 currentDegree 变量是变化的,不是一个固定的值,改变这个变量值的方法比较隐蔽,在 AngelBaseWorkspace 的 scrollTo 函数中。AngelBaseWorkspace 中的 scrollTo 函数把 View 类中的函数重载了,这个函数会被 View 中的 scrollBy 函数调用,所以每次 touch 屏幕并且 move 的时候 AngelBaseWorkspace 中的 scrollTo 函数会被调用(onTouchEvent 调用 scrollBy,scrollBy 调用 scrollTo),它会根据用户 touch move 移动的距离来更改当前页面的角度,即变量 currentDegree 的值。具体请看如下代码:

清单 8

public void scrollTo(int x, int y) { 
        if (getScrollX() != x || getScrollY() != y) { 
            int oldX = getScrollX(); 
            int oldY = getScrollY(); 

            super.scrollTo(x, y); 
            //x is the touch action X direction move distance 
            currentDegree = x * degreeOffset;            
            onScrollChanged(x, y, oldX, oldY); 
            invalidate(); 
        } 
    }

这个立方体特效部分的代码介绍到这里。

 

结束语

本文介绍了 Android launcher 的平滑和立体翻页效果实现,可以帮助开发者深入理解 Android 的动画框架原理,从而能够充分利用 android 现有框架来做出够眩、够酷的动画效果。