上图
仿日历翻页效果。
起因
产品有了新想法,要不我们做一个卡片翻页的效果,就像日历翻页那样。 ok,比较产品才是技术探索的动力。
思路
其实第一时间,我想到的就是掘金,看看有没有现成的方案,结果还真让我找到了 花里胡哨的3D翻页卡片,隔壁产品都馋哭了:给我也整一个这个效果! (gitee的地址)
作者的方案是把整个view分成三部分,翻页的过程中分别绘制上页片,下页片和活动页片。 其中还涉及到Matrix和Camera,不理解的同学合理参考扔物线-自定义View 大神朱凯把自定义View讲解得够易于听懂。
方法
原理已经在3d翻页卡片里了,这里就不陈词滥调了。经过自己的修改,开放出来几个方法,嫌麻烦的可以直接调用方法使用
startPageUp(num: Int) //卡片上翻,num为下一个显示的数字,0~9
startPageDown(num: Int) //卡片下翻,num为下一个显示的数字
setDuration(time: Long) //设置翻页时间
setScaleSize(scaleSize: Float) //设置内容缩放(0.5,9)
setTextSize(size: Float) //设置字号
setStrokeWidth(width: Float) //设置字体粗细
实现
下面的代码直接复制便可用
/**
* 3D摄像头
*/
private var mCamera: Camera = Camera()
private val mPaint by lazy { Paint() }
private val mTextPaint by lazy { Paint() }
private var mMatrix: Matrix = Matrix()
/**
* 3D位置参数
*/
private var depthZ: Float = 0F
private var rotateX: Float = 0F
private var rotateY: Float = 0F
/**
* 当前显示数字
*/
@IntRange(from = 0, to = 9)
private var curShowNum: Int = 0
private var nextShowNum: Int = 0
/**
* 每片card的宽高
*/
private var cardWidth: Float = 0F
private var cardHeight: Float = 0F
/**
* 是否需要绘制上中下卡片
*/
private var isNeedDrawUpCard = true
private var isNeedDrawMidCard = true
private var isNeedDrawDownCard = true
/**
* Card翻转函数
*/
private var cardRotateFunc: CardRotateFunc? = null
/**
* 控件状态定义
*/
//上翻中
private val STATE_UP_ING = 0x02
//下翻中
private val STATE_DOWN_ING = 0x03
//常规显示状态
private val STATE_NORMAL = 0x04
//完整的内边框
private val rectInnerF by lazy {
RectF(
cardWidth * (1f - scaleSize),
cardHeight * (1 - scaleSize) * 2,
cardWidth * scaleSize,
cardHeight * scaleSize * 2
)
}
private val outRectUpF by lazy {
RectF(
0f,
0f,
cardWidth,
cardHeight
)
}
private val outRectDownF by lazy {
RectF(
0f,
cardHeight,
cardWidth,
cardHeight * 2
)
}
private var curState: Int = STATE_NORMAL
private var objectAnimator: ValueAnimator? = null
private var scaleSize = 0.9f//value (0.5,1)
private var durationTime = 800L
private var textSize = 100f
private var strokeWidth = 16f
constructor(context: Context?) : this(context, null)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
initView(context, attrs)
}
private fun initView(context: Context?, attrs: AttributeSet?) {
mPaint.style = Paint.Style.FILL
mPaint.color = Color.WHITE
mPaint.isAntiAlias = true
mPaint.isFilterBitmap = true
mTextPaint.isAntiAlias = true
mTextPaint.style = Paint.Style.FILL
mTextPaint.strokeWidth = strokeWidth
mTextPaint.textSize = textSize
mTextPaint.textAlign = Paint.Align.CENTER
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
//设置card 大小
cardWidth = width.toFloat()
cardHeight = (height / 2).toFloat()
configFunc()
}
/**
* 配置各个函数
*/
private fun configFunc() {
cardRotateFunc = CardRotateFunc()
with(cardRotateFunc!!) {
inParamMin = 0F
inParamMax = cardHeight * 2
outParamMin = 0F
outParamMax = 180F
initValue = 45F
}
}
/**
* 重置各个初始值
*/
private fun resetInitValue() {
cardRotateFunc?.let {
it.initValue = rotateX
}
}
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
//不开启硬件加速
setLayerType(LAYER_TYPE_SOFTWARE, null)
//判断状态,不同状态绘制不同内容
judgeState(curState)
canvas?.let {
drawUpCard(it)
drawDownCard(it)
// 中间活动card最后绘制
drawMidCard(it)
}
}
private fun judgeState(state: Int) {
when (state) {
STATE_NORMAL -> {
isNeedDrawMidCard = false
isNeedDrawUpCard = true
isNeedDrawDownCard = true
}
STATE_UP_ING -> {
isNeedDrawMidCard = true
}
STATE_DOWN_ING -> {
isNeedDrawMidCard = true
}
}
}
/**
* 上页片
*/
private fun drawUpCard(canvas: Canvas) {
if (!isNeedDrawUpCard) return
with(canvas) {
mPaint.isAntiAlias = true
mPaint.isFilterBitmap = true
//数字大小
//绘制内框
val rectF = rectInnerF
//绘制外框,锁死大小
val outRect = outRectUpF
drawRoundRect(
0f, 0f, cardWidth, cardHeight,
5F,
5f,
mPaint
)
//绘制数字
// 根据状态绘制不同的数字
val fontMetrics = mTextPaint.fontMetrics
val distance = (fontMetrics.bottom - fontMetrics.top) / 2 - fontMetrics.bottom
val baseline = rectF.centerY() + distance
//if state_down_ing 往下翻,显示前一个数字 else 绘制当前数字
val tvBitmap = Bitmap.createBitmap(
(cardWidth).toInt(),
(cardHeight * 2).toInt(),
Bitmap.Config.ARGB_8888
)
val newCanvas = Canvas(tvBitmap)
newCanvas.drawText(
if (curState == STATE_DOWN_ING) "$nextShowNum" else "$curShowNum",
rectF.centerX(),
baseline,
mTextPaint
)
drawBitmap(
tvBitmap, Rect(
0,
0,
cardWidth.toInt(),
cardHeight.toInt()
), outRect, mPaint
)
}
}
/**
* 中页片(活动页片)
*/
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
val rectF = RectF(
cardWidth * (1f - scaleSize),
cardHeight,
cardWidth * scaleSize,
cardHeight * scaleSize * 2
)
drawRoundRect(
rectF,
10F,
10F,
mPaint
)
//rotateX : 180 -> 0
val fontMetrics = mTextPaint.fontMetrics
val distance = (fontMetrics.bottom - fontMetrics.top) / 2 - fontMetrics.bottom
val baseline = rectInnerF.centerY() + distance
if (rotateX >= 90F) {
//first
val newMatrix = Matrix()
newMatrix.postRotate(180f)
newMatrix.postScale(-1f, 1f)
val num: Int
if (curState == STATE_UP_ING) {
num = nextShowNum
} else if (curState == STATE_DOWN_ING) {
if (Math.abs(cardRotateFunc!!.initValue - rotateX) >= 90) {
num = nextShowNum
} else {
num = curShowNum
}
} else {
num = nextShowNum
}
val tvBitmap = Bitmap.createBitmap(
(cardWidth).toInt(),
(cardHeight * 2).toInt(),
Bitmap.Config.ARGB_8888
)
val newCanvas = Canvas(tvBitmap)
newCanvas.drawText(
"$num",
rectF.centerX(),
baseline,
mTextPaint
)
val temp = Bitmap.createBitmap(
tvBitmap,
0,
0,
tvBitmap.width,
tvBitmap.height,
newMatrix,
false
)
//这里是相反的
drawBitmap(
temp,
Rect(
0, temp.height / 2, temp.width, temp.height
),
Rect(0, temp.height / 2, temp.width, temp.height),
mPaint
)
} else {
//last
val tvBitmap = Bitmap.createBitmap(
(cardWidth).toInt(),
(cardHeight * 2).toInt(),
Bitmap.Config.ARGB_8888
)
val newCanvas = Canvas(tvBitmap)
newCanvas.drawText(
if (Math.abs(cardRotateFunc!!.initValue - rotateX) >= 90F) "$nextShowNum" else "$curShowNum",
rectF.centerX(),
baseline,
mTextPaint
)
drawBitmap(
tvBitmap,
Rect(0, tvBitmap.height / 2, tvBitmap.width, tvBitmap.height),
Rect(0, tvBitmap.height / 2, tvBitmap.width, tvBitmap.height),
mPaint
)
}
restore()
}
}
/**
* 下页片
*/
private fun drawDownCard(canvas: Canvas) {
if (!isNeedDrawDownCard) return
with(canvas) {
mPaint.isAntiAlias = true
mPaint.isFilterBitmap = true
mPaint.setShadowLayer(10F, 0F, 0F, Color.WHITE)
mPaint.color = Color.WHITE
val rectF = rectInnerF
val outRect = outRectDownF
drawRoundRect(
outRect,
5F,
5F,
mPaint
)
//绘制数字
mPaint.clearShadowLayer()
val fontMetrics = mTextPaint.fontMetrics
val distance = (fontMetrics.bottom - fontMetrics.top) / 2 - fontMetrics.bottom
val baseline = rectF.centerY() + distance
//if 往上翻,显示下一个数字 else 显示当前
val tvBitmap = Bitmap.createBitmap(
(cardWidth).toInt(),
(cardHeight * 2).toInt(),
Bitmap.Config.ARGB_8888
)
val newCanvas = Canvas(tvBitmap)
newCanvas.drawText(
if (curState == STATE_UP_ING) "$nextShowNum" else "$curShowNum",
rectF.centerX(),
baseline,
mTextPaint
)
drawBitmap(
tvBitmap, Rect(
0,
cardHeight.toInt(),
cardWidth.toInt(),
(cardHeight * 2).toInt()
), outRect, mPaint
)
}
}
/**
* 卡片翻转动画
*/
private var cardRotateAnim: ValueAnimator? = null
/**
* 卡片上翻动画
*/
private fun startCardUpAnim(curNum: Int) {
cardRotateAnim?.cancel()
cardRotateAnim = ValueAnimator.ofFloat(rotateX, 180F)
with(cardRotateAnim!!) {
duration = durationTime
addUpdateListener {
rotateX = it.animatedValue as Float
postInvalidate()
}
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
super.onAnimationEnd(animation)
resetInitValue()
curState = STATE_NORMAL
curShowNum = curNum
}
})
start()
}
}
/**
* 卡片下翻动画
*/
private fun startCardDownAnim(curNum: Int) {
cardRotateAnim?.cancel()
cardRotateAnim = ValueAnimator.ofFloat(rotateX, 0F)
with(cardRotateAnim!!) {
duration = durationTime
addUpdateListener {
rotateX = it.animatedValue as Float
postInvalidate()
}
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator?) {
super.onAnimationEnd(animation)
resetInitValue()
curState = STATE_NORMAL
curShowNum = curNum
}
override fun onAnimationCancel(animation: Animator?) {
super.onAnimationCancel(animation)
resetInitValue()
curState = STATE_NORMAL
curShowNum = curNum
postInvalidate()
}
})
start()
}
}
fun startPageDown(num: Int) {
if (num == curShowNum) return
rotateX = 180F
curState = STATE_DOWN_ING
nextShowNum = num
resetInitValue()
postInvalidate()
startCardDownAnim(num)
objectAnimator?.cancel()
objectAnimator = ValueAnimator.ofFloat(0f, 180f)
objectAnimator?.addUpdateListener {
rotateX = 180f - (it.animatedValue as Float)
postInvalidate()
}
objectAnimator?.duration = durationTime
objectAnimator?.start()
}
fun startPageUp(num: Int) {
if (num == curShowNum) return
rotateX = 180F
curState = STATE_UP_ING
nextShowNum = num
resetInitValue()
postInvalidate()
startCardUpAnim(num)
objectAnimator?.cancel()
objectAnimator = ValueAnimator.ofFloat(0f, 180f)
objectAnimator?.addUpdateListener {
rotateX = it.animatedValue as Float
postInvalidate()
}
objectAnimator?.duration = durationTime
objectAnimator?.start()
}
fun setDuration(time: Long) {
this.durationTime = time
}
fun setScaleSize(scaleSize: Float) {
if (scaleSize > 0.5f && scaleSize < 1f) {
this.scaleSize = scaleSize
}
}
fun setTextSize(size: Float) {
this.textSize = size
mTextPaint.textSize = textSize
}
fun setStrokeWidth(width: Float) {
this.strokeWidth = width
mTextPaint.strokeWidth = strokeWidth
}
//卡片计算rotate角度
inner class CardRotateFunc {
/**
* 初始值
*/
var initValue: Float = 0f
/**
* 入参的阈值
*/
var inParamMax: Float = 0f
/**
* 入参的阈值
*/
var inParamMin: Float = 0f
/**
* 出参的阈值
*/
var outParamMax: Float = 0f
/**
* 出参的阈值
*/
var outParamMin: Float = 0f
fun execute(inParam: Float): Float {
if (inParam > inParamMax) {
return outParamMin
} else if (inParam < inParamMin) {
return outParamMax
} else {
//斜率 // 过大会导致rotate的值有不到的地方
// val rate = (outParamMin - outParamMax) / (inParamMax - inParamMin)
// return outParamMax + inParam * rate
return 180 - inParam
}
}
override fun toString(): String {
return "BaseFuncImpl(initValue=$initValue, inParamMax=$inParamMax, inParamMin=$inParamMin)"
}
}
补充
其实刚开始的时候,想着用属性动画就可以实现。
像这般,放三个textview,然后通过改变tv的旋转角度就可以实现翻页效果。
val animator = ObjectAnimator.ofFloat(tv, "rotateY", 0f, 180f)
思路是对的,但可能是学艺不精吧,上层的tv会绕着Y轴旋转,但中间层和下层的tv会被上层挡住,方案失败。