花里胡哨的3D翻页卡片,隔壁产品都馋哭了

11,468 阅读6分钟
阅读完本文约需8分钟。

废不说,看图,有图有**

带有立体纵深的卡片翻页效果,稍加组合和颜色变化就可以搭配出多种不同的风格,如:
比赛比分牌

卡片翻页时钟

一、设计思路

如何使得数字的变化更为灵动?3D纵深效果是个值得思考的方案。在现实生活中的一些特定场景,数字的变化就是通过翻页实现的,比如日历、比分牌、数学教具等等。

灵活替换翻页卡片的颜色和数字图片,即可展现多种不同风格

二、实现方案

2.1 3D纵深效果实现方案参考

为了在Android上实现3D纵深效果,可以考虑使用OpenGL进行,这样实现的效果是细节最丰富、最拟物的,但成本也巨大,为了一个控件使用OpenGL实在是用核弹打苍蝇——可以但没必要。
这里考虑采用Matrix+Camera结合的方案。

2.2 Matrix简介

android中的matrix是一个3*3的矩阵,如下图

scale控制缩放,skew控制错切,trans控制位移,persp控制透视,值得注意的是,旋转操作是通过scale和skew共同作用完成的。
android中matix的封装非常完善,使用者无需再去重拾体育老师教授的数学知识,直接调用。

2.3 Camera简介

这里指的是android.graphics包下的Camera,用于计算3D变换。封装得同样十分完善,使用者无需再用屁股思考控件坐标计算过程,直接调用,同时搭配好对应的matrix。
需要注意的是,Camera中摄像头的位置是对准画布左上角的。
坐标系如下,是左手坐标系:

常用方法如下:

2.4 UI拆解

**2.4.1 形状分析**
从形状上观察并不复杂,算上正在翻转的卡片,3个圆角矩形即可搞定。数字部分采用图片绘制,可以灵活替换,最后在指定卡片区域绘制图片即可。
**2.4.2 模型设计**
卡片翻转过程中,最多同一时刻出现3个卡片,故只需要按位置关系定义上中下三个卡片,中间的卡片负责翻转。

2.5 纵深效果实现

**2.5.1 卡片绘制**
绘制上中下卡片,中间卡片为活动页,需要最后绘制
override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    setLayerType(LAYER_TYPE_SOFTWARE, null)
    //判断状态,不同状态绘制不同内容
    judgeState(curState)
    canvas?.let {
        drawUpCard(it)
        drawDownCard(it)
        // 中间活动card最后绘制
        drawMidCard(it)
    }
}
**2.5.2 实现卡片翻页**
中间卡片翻页效果通过Camera的roate方法实现,绕x轴进行旋转
private fun drawMidCard(canvas: Canvas) {
    if (!isNeedDrawMidCard) return
    with(canvas) {
        save()
        mMatrix.reset()
        mCamera.save()
        mCamera.translate(0F, 0F, depthZ)
        mCamera.rotateX(rotateX)
        mCamera.rotateY(rotateY)
        mCamera.getMatrix(mMatrix)
        mCamera.restore()
        val scale = resources.displayMetrics.density
        val mValues = FloatArray(9)
        mMatrix.getValues(mValues)
        mValues[6] = mValues[6] / scale
        mValues[7] = mValues[7] / scale
        mMatrix.setValues(mValues)
        mMatrix.preTranslate(-width / 2F, -height / 2F)
        mMatrix.postTranslate(width / 2F, height / 2F)
        concat(mMatrix)
        mPaint.color = Color.WHITE
        mPaint.setShadowLayer(cardShadowSize, 0F, cardShadowDistance, Color.GRAY)
        val rectF = RectF(paddingSize, paddingSize + cardHeight, paddingSize + cardWidth, paddingSize + cardHeight * 2)
        drawRoundRect(
                rectF,
                20F,
                20F,
                mPaint
        )
        //todo: 绘制数字图片
        restore()
    }
}
结合我在[《今日头条loading控件,隔壁产品都馋哭了》](https://zhuanlan.zhihu.com/p/228837516)文章中提到过的坐标计算框架,用户手指在屏幕上移动的距离与中间卡片旋转角度存在某种函数关系,通过IFunc进行保存和计算。
代码如下:
/**
* Card翻转函数
*/
var cardRotateFunc: IFunc? = null
/**
* 阴影大小变化函数
*/
var cardShadowSizeFunc: IFunc? = null
/**
* 阴影距离变化函数
*/
var cardShadowDistanceFunc: IFunc? = null
/**
* 配置各个函数
*/
private fun configFunc() {
    cardRotateFunc = CardRotateFunc()
    with(cardRotateFunc!!) {
        inParamMin = 0F
        inParamMax = cardHeight * 2
        outParamMin = 0F
        outParamMax = 180F
        initValue = 45F
    }
    cardShadowSizeFunc = CardShadowSizeFunc()
    with(cardShadowSizeFunc!!) {
        inParamMin = 0F
        inParamMax = 180F
        outParamMax = 50F
        outParamMin = 0F
        initValue = 10F
    }
    cardShadowDistanceFunc = CardShadowDistanceFunc()
    with(cardShadowDistanceFunc!!) {
        inParamMin = 0F
        inParamMax = 180F
        outParamMax = 50F
        outParamMin = 0F
        initValue = 10F
    }
}
**2.5.3 阴影变化**
为了更好地模拟3D效果,卡片阴影也存在微小的变化
/**
* 根据旋转角度计算阴影大小、距离
*/
private fun executeShadowFunc(rotate: Float) {
    cardShadowSizeFunc?.let {
        cardShadowSize = it.execute(rotate)
    }
    cardShadowDistanceFunc?.let {
        cardShadowDistance = it.execute(rotate)
    }
}
**2.5.4 数字图片绘制**
数字的绘制就是图片的绘制,需要注意的是中间活动卡片在上翻或下翻转超过90度时,绘制的数字需要改变,涉及到图片的水平镜像翻转,调用matrix.postScale(-1F, 1F)实现。以下翻为例,代码如下:
if (curState == STATE_DOWN_ING) {
    //往下翻
    if (abs(cardRotateFunc!!.initValue - rotateX) >= 90F) {
        //绘制前一个数字
        if (curShowNum - 1 >= 0) {
            tempBm = Bitmap.createBitmap(numBms[curShowNum - 1], 0, 0, curNumBm.width, curNumBm.height, matrix, false)
        } else {
            tempBm = Bitmap.createBitmap(numBms[0], 0, 0, curNumBm.width, curNumBm.height, matrix, false)
        }
    } else {
        tempBm = Bitmap.createBitmap(numBms[curShowNum], 0, 0, curNumBm.width, curNumBm.height, matrix, false)
    }
}
tempBm?.let {
    drawBitmap(it, Rect(0, it.height / 2, it.width, it.height), rectF, mPaint)
}

2.6 实现交互

**2.6.1 上下翻转**
逻辑主要在onTouchEvent方法中,通过中间卡片初始角度和当前的翻转角度判断是上翻还是下翻
/**
* 手指按下的初始坐标
*/
private var downX: Float = 0F
private var downY: Float = 0F
private var offsetY: Float = 0F
override fun onTouchEvent(event: MotionEvent?): Boolean {
    when (event?.action) {
        MotionEvent.ACTION_DOWN -> {
            downX = event.x
            downY = event.y
            if (downY >= height / 2) {
                //绘制下方的mid card
                rotateX = 0F
                curState = STATE_UP_ING
            } else {
                rotateX = 180F
                curState = STATE_DOWN_ING
            }
            resetInitValue()
            postInvalidate()
        }
        MotionEvent.ACTION_MOVE -> {
            offsetY = event.y - downY
            executeFunc(offsetY)
            postInvalidate()
        }
        MotionEvent.ACTION_UP -> {
            //判断是上翻还是下翻
            if (rotateX >= 90F) {
                if (abs(cardRotateFunc!!.initValue - rotateX) >= 90F) {
                    if (curShowNum + 1 <= 9) {
                        startCardUpAnim(curShowNum + 1)
                    } else {
                        curShowNum = 9
                        startCardDownAnim(9)
                    }
                } else {
                    startCardUpAnim(curShowNum)
                }
            } else {
                if (abs(cardRotateFunc!!.initValue - rotateX) >= 90F) {
                    if (curShowNum - 1 >= 0) {
                        startCardDownAnim(curShowNum - 1)
                    } else {
                        curShowNum = 0
                        startCardUpAnim(0)
                    }
                } else {
                    startCardDownAnim(curShowNum)
                }
            }
            downX = 0F
            downY = 0F
        }
        else -> {
        }
    }
    return true
}
**2.6.2 翻页动画、阴影动画**
由于采用了坐标计算框架,动画的实现就变得非常简单了,控制好offset变量,即可控制中间卡片的翻转角度、阴影距离、阴影大小。以上翻动画为例:
/**
* 卡片上翻动画
*/
private fun startCardUpAnim(curNum: Int) {
    cardRotateAnim?.cancel()
    cardRotateAnim = ValueAnimator.ofFloat(rotateX, 180F)
    with(cardRotateAnim!!) {
        duration = 400L
        addUpdateListener {
            rotateX = it.animatedValue as Float
            executeShadowFunc(rotateX)
            postInvalidate()
        }
        addListener(object : AnimatorListenerAdapter() {
            override fun onAnimationEnd(animation: Animator?) {
                super.onAnimationEnd(animation)
                resetInitValue()
                curState = STATE_NORMAL
                curShowNum = curNum
            }
        })
        start()
    }
}
**2.6.3 显示数字的变化**
当动画结束时,当前显示数字才是真正地发生了改变,对curShowNum进行赋值即可。也可以方便添加数字变化时的监听暴露给调用者。

2.7 加点细节

**2.7.1 边界控制**

当前数字为0时,无法再向下翻转,这时无需绘制上卡片;同样的,当前数字为9时,无需绘制下卡片
private fun judgeState(state: Int) {
    when (state) {
        STATE_NORMAL -> {
            isNeedDrawMidCard = false
            isNeedDrawUpCard = true
            isNeedDrawDownCard = true
        }
        STATE_UP_ING -> {
            isNeedDrawMidCard = true
            if (curShowNum + 1 > 9) {
                isNeedDrawDownCard = false
            }
        }
        STATE_DOWN_ING -> {
            isNeedDrawMidCard = true
            if (curShowNum - 1 < 0) {
                isNeedDrawUpCard = false
            }
        }
    }
}
**2.7.2 翻页回弹**

处于边界数字时,用户继续进行翻转操作,需要进行回弹至原位。
if (curShowNum + 1 <= 9) {
    startCardUpAnim(curShowNum + 1)
} else {
    curShowNum = 9
    startCardDownAnim(9)
}

、后记

Android中的Camera+Matrix很好地模拟了3D效果,在很多控件设计场景中,相较于OpenGL更为轻量易用。控件中还存在很多细节问题的处理,限于文章篇幅不再展开详说。
控件放在了gitee上

点我点我点我

啊,既然看到了这里,是时候亮出我的公号和个人微信了......才怪!

往期精彩:

无极旋钮控件

液体流动控件

电子数显控件

做个锤子的开关

妖媚刻度尺控件