Android一种翻板式交互效果

4,252 阅读8分钟

原创文章,转载请联系作者。个人博客

梧桐落,又还秋色,又还寂寞。

效果图,文件比较大,稍稍等一下 (●゚ω゚●):

前言

首先,首先!Demo只是对FliBoard的立体感直板翻页式交互效果作了模仿,只是效果只是效果。那种翻页组件挺麻烦的,以后可能会抽时间做一下( ̄▽ ̄)"
立体感是一种模仿,在二维平面上,合理地利用光影、透视(远小近大)等方式,塑造一种近似现实三维世界的感jio。为什么会产生立体感? 是因为人的视网膜接受到的,全是三维世界的投影。是你的大脑以及经验,脑补出了三维世界
举栗子,下面这张图片,你会把它看作一个弯曲三角吗


同理,动画也无非是利用了人眼的视觉暂留而已。某种程度,它和魔术拥有相同的本质————欺骗

效果解析

解析效果前,先提一下会用到的知识点

1、用到的知识点

  • graphics.Camera,图形包下用来处理3D旋转的类
  • canvas、Matrix

2、效果拆解

直板式的翻页,效果其实并不复杂。手机屏幕之后,是一个三维坐标系。想象一下有张板子(Bitmap)放在XY坐标系,要达到翻页效果,让其绕着X轴旋转即可。正常情况下,板子(Bitmap)是作为整体旋转。我们将板子中心点移到X轴上,那么绕着Z轴旋转时,上下两部分运动的方向肯定是相反的。就像这样:

上图为绕着X轴旋转45度,缩放0.5f效果

如上图所示,为达到效果,必须将上下两部分分开绘制。你可以采用将Bitmap分割的方式,也可以分割Canvas。Demo里,我采取的是分割Canvas。使用方法canvas.clipRect(left, top, right, bottom)

3、手势拆解

翻页共有三种状态,静态、下翻以及上翻。静态不必赘述,下面会分析一下上翻和下翻绘制。

3.1 向下翻页绘制解析

向下翻页,就是翻过当前页回到上一页。在效果拆解那部分,我们已经知道,45度时,上半部分会偏向屏幕后。所以要让上半部分向下翻转。旋转角度得是负数。也就是,在一个完整的下翻周期内,角度的变化为0到-180度
其中0到-90度内,当前页正在下翻,页面变动在上半区域,此时可以看到的界面有:下翻ing的当前页上半部分当前页产生的阴影上一页的上半部分(保持不动)。而在-90到-180度阶段,此时下翻的动作接近完成,页面变动在下半区域,此时可以看到的界面有:即将翻过的上一页的下半部分上一页翻转产生的阴影当前页的下半部分

3.2 向上翻页绘制解析

向上翻页,就是翻过当前页去下一页。和下翻逻辑相反,这是一个0到180度的周期活动。0到90度为正在上翻,页面变动在下半区域。而90到180度,上翻动作接近完成,页面变动在上班区域,很快会看到完整的下一页。

具体实现

用自定义View来实现,这里只贴出主要代码,部分逻辑会用伪代码表述,完整代码文末提供。

1、绘制

因为只是仿写效果,所以全部逻辑放在了一个自定义View内部。先看一些主要的成员变量。

    //向下翻旋转角度,0~-180f
    private var rotateF 

    //向上翻旋转角度,0~180f
    private var rotateS

    //翻动状态,0为松手,1为向下翻,-1为向上翻
    private var statusFlip = 0

    //当前页
    private var curPage

	 //用于3D旋转的Camera类
    private val camera

	 //绘制Bitmap的Matrix
    private val drawMatrix

	 //中心点X坐标
    private val centerX

	 //中心点Y坐标
    private val centerY

    //当前Bitmap
    private var curBitmap: Bitmap

    //上一张Bitmap
    private var lastBitmap: Bitmap
    
    //下一张Bitmap
    private var nextBitmap: Bitmap

我维护了两个变量用来分别控制下翻和上翻的角度变化。与此同时,也分了两个方法,来分别绘制上半部分和下半部分。

//上半部分绘制
fun drawFirstHalf(canvas: Canvas?, bitmap: Bitmap?, rotate: Float) {
    canvas?.save()
    //将canvas上半部分切割
    canvas?.clipRect(0, 0, width, height / 2)
    camera.save()
    //camera绕着X轴旋转
    camera.rotateX(角度变化小于-90度,不再处理)
    camera.getMatrix(drawMatrix)
    camera.restore()
    //随着旋转角度变化的缩放值,只缩放Y轴
    drawMatrix.preScale(1.0f, 缩放比)
    //将图片移到中心点
    drawMatrix.preTranslate(-centerX, -centerY)
    drawMatrix.postTranslate(centerX, centerY)
    canvas?.drawBitmap(this, drawMatrix, null)
    canvas?.restore()
    }
fun drawSecondHalf(canvas: Canvas?, bitmap: Bitmap?, rotate: Float) {  
    canvas?.save()
    camera.save()
    //切割下半部分canvas
    canvas?.clipRect(0, height / 2, width, height)
    camera.rotateX(绕着X轴旋转角度,大于90度后只不再处理变化)
    camera.getMatrix(drawMatrix)
    camera.restore()
    drawMatrix.preScale(1.0f, 缩放比随着角度变化)
    drawMatrix.preTranslate(-centerX, -centerY)
    drawMatrix.postTranslate(centerX, centerY)
    canvas?.drawBitmap(this, drawMatrix, null)
    canvas?.restore()     
    }

2、 手势处理

手势处理较为简单,只需要在MOVE的时候,判断此时的状态是上翻还是下翻。然后在抬手UP的时候,根据此时的距离,来判断是否下翻成功或是上翻成功。倘若距离不够标准阈值,那么一切归于原位。

  • 其中startX、startY为手指落点
MotionEvent.ACTION_MOVE -> {
                    val x = this.x
                    val y = this.y
                    //当y运动距离大于x的1.5倍时,才判断为垂直翻动
                    val disY = y - startY
                    if (Math.abs(disY) > 1f && Math.abs(disY) >= Math.abs(x - startX) * 1.5f) {
                        if (statusFlip == 0) {
                            //滑动间距为正并且不是第一页判断为向下翻,滑动间距为负并且不是最后一页判断为向上翻
                            statusFlip = if (disY > 0 && curPage != 0) DOWN_FLIP
                            else if (disY < 0 && curPage != girls.lastIndex) UP_FLIP else 0
                        }
                        val ratio = Math.abs(disY) / centerY
                        if (statusFlip == DOWN_FLIP) {
                            //向下翻并且当前页不等于0
                            rotateF = ratio * -180f
                            Log.d("cece", ": rotateF : " + rotateF);
                            invalidate()
                        } else if (statusFlip == UP_FLIP) {
                            //向上翻,并且不是最后一页
                            if (curPage != girls.lastIndex) {
                                rotateS = ratio * 180f
                                Log.d("cece", ": rotateS : " + rotateS);
                                invalidate()
                            }
                        }
                    }
                }
  • 当手指抬起时,首先判断此时的状态,然后再判断移动过的距离是否满足阈值。不满足的回归当前页,满足阈值的,继续执行未完成的状态。
if (statusFlip != 0) {
            drawMatrix.reset()
            //放手的时候,有动画发生
            if (Math.abs(event.y - startY) <= centerY / 2) {
                //滑动距离小于1/4屏幕高,判定仍停留在当前页
                rotateF = 0f
                rotateS = 0f
                statusFlip = 0
                invalidate()
            } else {
                //滑动距离超过临界值,判定为跳过当前页
                if (statusFlip == DOWN_FLIP) {
                    //自动执行完下翻到上一页的动作
                    for (i in rotateF.toInt() downTo -180 step 6) {
                        invalidate()
                    }
                    curPage--
                } else {
                    //自动执行完上翻到下一页的动作
                    for (i in rotateS.toInt() until 180 step 6) {
                        invalidate()
                    }
                    curPage++
                }
                rotateF = 0f
                rotateS = 0f
                statusFlip = 0
            }
        }

当距离达到阈值时,就需要代码来继续完成下翻或者上翻的逻辑。这里我使用循环的方式。譬如上翻超过90度了,就循环到180度,继续完成上翻的动作。

3、 阴影部分和绘制顺序

onDraw(...)方法内绘制时,一定要注意代码顺序。因为在这个方法内,顺序代表着层次。譬如阴影绘制一定要写在页面绘制之前。
阴影部分的绘制也分为上下两部分。

fun drawFirstShadow(canvas: Canvas?, rotate: Float) {
    canvas切割上半部分,绘制color即可    
}

fun drawSecondShadow(canvas: Canvas?, rotate: Float) {
    canvas切割下半部分,绘制color即可 
}

onDraw(...)方法内的绘制顺序一定要分明

//绘制当前页底下的一层,翻页进行中
        if (statusFlip == DOWN_FLIP) {
            //向下翻,滑到上一页
            drawFirstHalf(canvas, lastBitmap, 0f)
            drawFirstShadow(canvas, rotateF)
        } else if (statusFlip == UP_FLIP) {
            drawSecondHalf(canvas, nextBitmap, 0f)
            drawSecondShadow(canvas, rotateS)
        }

        //绘制当前页
        drawFirstHalf(canvas, curBitmap, rotateF)
        drawSecondHalf(canvas, curBitmap, rotateS)

        //绘制当前页之上的一层,翻页完成后
        if (statusFlip == DOWN_FLIP) {
            if (rotateF <= -90f) {
                //先绘制阴影
                drawSecondShadow(canvas, rotateF + 180f)
                drawSecondHalf(canvas, lastBitmap, rotateF + 180f)
            }
            //绘制覆盖在翻页Bitmap之上淡淡透明层,透明度固定
            drawFirstColor(canvas, 20)
        } else if (statusFlip == UP_FLIP) {
            if (rotateS >= 90f) {
                drawFirstShadow(canvas, rotateS - 180f)
                drawFirstHalf(canvas, nextBitmap, rotateS - 180f)
            }
            //淡淡透明度的阴影层
            drawSecondColor(canvas, 20)
        }

还是得区分一下状态,当下翻时,我们得先绘制上一页的上半部分,而且是静态的。然后再绘制当前页下翻产生的阴影。再绘制当前页,然后在当前页顶上再绘制一层固定淡淡透明度的阴影层,让页面层次更加明显。

4、效果修正

到这里主要的逻辑业已完成,但我注意到还是有一些小瑕疵。就是旋转角度和缩放比,变化不明显。通常要角度变化到超过45度,才会有很明显的缩放效果展现出来。
最开始我以为是缩放比的算法问题,后来才发现是camera的机位问题,camera默认的拍摄角度是[0,0,-8],当距离屏幕很近时,变化自然不是很明显。
当然,camera提供了设置机位的方法setLocation(x, y, z)。最后我调整到[0,0,-20]才满意这个效果。

下图,我给出了,默认机位和[0,0,-20]机位的效果区别。

结语

Demo里的实现方式并非是唯一,分享出来是为了提供一种思路。路有很多条,选择即是正确。
以上
项目代码在此,大家要是喜欢的话不妨点个赞吧

有一个公众号,会记录一些开发的经验,也会发一些自己的学习日记。欢迎大家关注。