原创文章,转载请联系作者。个人博客
梧桐落,又还秋色,又还寂寞。
效果图,文件比较大,稍稍等一下 (●゚ω゚●):
前言
首先,首先!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里的实现方式并非是唯一,分享出来是为了提供一种思路。路有很多条,选择即是正确。
以上
项目代码在此,大家要是喜欢的话不妨点个赞吧
有一个公众号,会记录一些开发的经验,也会发一些自己的学习日记。欢迎大家关注。