原创文章,转载请联系作者
梧桐落,又还秋色,又还寂寞。
效果图,文件比较大,稍稍等一下 (●゚ω゚●):
前言 首先,首先!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内部。先看一些主要的成员变量。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 //向下翻旋转角度,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
我维护了两个变量用来分别控制下翻和上翻的角度变化。与此同时,也分了两个方法,来分别绘制上半部分和下半部分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 //上半部分绘制 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() }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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的时候,根据此时的距离,来判断是否下翻成功或是上翻成功。倘若距离不够标准阈值,那么一切归于原位。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 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() } } } }
当手指抬起时,首先判断此时的状态,然后再判断移动过的距离是否满足阈值。不满足的回归当前页,满足阈值的,继续执行未完成的状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 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(...)
方法内绘制时,一定要注意代码顺序。因为在这个方法内,顺序代表着层次。譬如阴影绘制一定要写在页面绘制之前。 阴影部分的绘制也分为上下两部分。
1 2 3 4 5 6 7 fun drawFirstShadow(canvas: Canvas?, rotate: Float) { canvas切割上半部分,绘制color即可 } fun drawSecondShadow(canvas: Canvas?, rotate: Float) { canvas切割下半部分,绘制color即可 }
在onDraw(...)
方法内的绘制顺序一定要分明
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 //绘制当前页底下的一层,翻页进行中 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里的实现方式并非是唯一,分享出来是为了提供一种思路。路有很多条,选择即是正确。以上 项目代码 在此,大家要是喜欢的话不妨点个赞吧