日历翻页效果

1,775 阅读4分钟

上图 5vxa8-fosdx.gif 仿日历翻页效果。

起因

产品有了新想法,要不我们做一个卡片翻页的效果,就像日历翻页那样。 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)"
      }
   }

补充

其实刚开始的时候,想着用属性动画就可以实现。

20220223-175411.png 像这般,放三个textview,然后通过改变tv的旋转角度就可以实现翻页效果。

val animator = ObjectAnimator.ofFloat(tv, "rotateY", 0f, 180f)

思路是对的,但可能是学艺不精吧,上层的tv会绕着Y轴旋转,但中间层和下层的tv会被上层挡住,方案失败。