注册

从阅读仿真页看贝塞尔曲线

前言


一直觉得阅读器里面的仿真页很有意思,最近在看阅读器相关代码的时候发现仿真页是基于贝塞尔曲线去实现的,所以就有了此篇文章。


仿真页一般有两种实现方式:



  1. 将内容绘制在Bitmap上,基于Canvas去处理仿真页
  2. OpenGl es

本篇文章我会向大家介绍如何使用Canvas绘制贝塞尔曲线,以及详细的像大家介绍仿真页的实现思路。


后续有机会的话,希望可以再向大家介绍方案二(OpenGL es 学习中...)。


一、贝塞尔曲线介绍


贝塞尔曲线是应用于二维图形应用程序的数学曲线,最初是用在汽车设计的。我们在绘图工具上也常常见到曲线,比如钢笔工具。


为了绘制出更加平滑的曲线,在 Android 中我们也可以使用 Path 去绘制贝塞尔曲线,比如这类曲线图或者描述声波的图:


dribbble-bezier-graphs


我们先简单的了解一下基础知识,可以在这个网站先体验一把如何控制贝塞尔曲线:



http://www.jasondavies.com/animated-be…



一阶到四阶都有。


1. 一阶贝塞尔曲线


给定点 P0 和 P1,一阶贝塞尔曲线是两点之间的直线,这条线的公式如下:


image-20221203172517917


图片表示如下:


一阶贝塞尔动画


2. 二阶贝塞尔曲线


从二阶开始,就变得复杂起来,对于给定的 P0、P1 和 P2,都对应的曲线:


二阶贝塞尔曲线


图片表示如下:


贝塞尔二阶动画


二阶的公式是如何得出来的?我们可以假设 P0 到 P1 点是 P3,P1 - P2 的点是P4,二阶贝塞尔也只是 P3 - P4 之间的动态点,则有:



P3 = (1-t) P0 + tP1


P4 = (1-t) P1 + tP2


二阶贝塞尔曲线 B(t) = (1-t)P3 + tP4 = (1-t)((1-t)P0 + tP1) + t((1-t)P1 + tP2) = (1-t)(1-t)P0 + 2t(1-t)P1 + ttP2



与最终的公式对应。


3. 三阶贝塞尔曲线


三阶贝塞尔曲线由四个点控制,对于给定的 P0、P1、P2 和 P3,有对应的曲线:


三阶公式


对应的图片:


三阶动画


同样的,三阶贝塞尔可以由二阶贝塞尔得出,从上面的知识我们可以得处,下图中的点 R0 和 R1 的路径其实是二阶的贝塞尔曲线:


三阶计算图片


对于给定的点 B,有如下的公式,将二阶贝塞尔曲线带入:



R0 = (1-t)(1-t)P0 + 2t(1-t)P1 + ttP2


R1 = (1-t)(1-t)P1 + 2t(1-t)P2 + ttP3


B(t) = (1-t)R0 + tR1 = (1-t)((1-t)(1-t)P0 + 2t(1-t)P1 + ttP2) + t((1-t)(1-t)P1 + 2t(1-t)P2 + ttP3)



最终的结果就是三阶贝塞尔曲线的最终公式。


4. 多阶贝塞尔曲线


多阶贝塞尔曲线我们就不细讲了,可以知道的是,每一阶都可以由它的上一阶贝塞尔曲线推导而出。就像我们之前由一阶推导二阶,由二阶推导出三阶。


二、Android对应的API


Android提供了 Path 供我们去绘制贝塞尔曲线。一阶贝塞尔是一条直线,所以不用处理了。


看一下 Path 对应的 API:



  • Path#quadTo(float x1, float y1, float x2, float y2):二阶
  • Path#cubicTo(float x1, float y1, float x2, float y2,float x3, float y3):三阶

对于一段贝塞尔曲线来说,由三部分组成:



  1. 一个开始点
  2. 一到多个控制点
  3. 一个结束点

使用的方法也很简单,先挪到开始点,然后将控制点和结束点统统加进来:


class BezierView @JvmOverloads constructor(
   context: Context,
   attributeSet: AttributeSet? = null,
   defStyle: Int = 0
) : View(context, attributeSet, defStyle) {

   private val path = Path()
   private val paint = Paint()

   override fun onDraw(canvas: Canvas?) {
       super.onDraw(canvas)

       paint.style = Paint.Style.STROKE
       paint.strokeWidth = 3f
       
       path.moveTo(0f, 200f)
       path.quadTo(200f, 0f, 400f, 200f)
       paint.color = Color.BLUE
       canvas?.drawPath(path, paint)

       path.rewind()
       path.moveTo(0f, 600f)
       path.cubicTo(100f, 400f, 200f, 800f, 300f, 600f)
       paint.color = Color.RED
       canvas?.drawPath(path, paint);
  }
}

最后的结果:


WechatIMG132


上面是二阶贝塞尔,下面是三阶贝塞尔,可以发现,控制点越多,就能设计出越复杂的曲线。如果想使用二阶贝塞尔实现三阶的效果,就得使用两个二阶贝塞尔曲线。


三、简单案例


既然刚刚画了两个曲线,我们可以利用这个方式简单模拟一个动态声波的曲线,像这样:


Screenshot_2022_1204_173610


这个动画只需要在刚刚的代码的基础上稍微改动一点:


class BezierView @JvmOverloads constructor(
   context: Context,
   attributeSet: AttributeSet? = null,
   defStyle: Int = 0
) : View(context, attributeSet, defStyle) {

   private val path = Path()
   private val paint = Paint()

   private var width = 0f
   private var height = 0f
   private var quadY = 0f
   private var cubicY = 0f

   private var per = 1.0f
   private var quadHeight = 100f
   private var cubicHeight = 200f

   private var bezierAnim: ValueAnimator? = null

   init {
       paint.style = Paint.Style.STROKE
       paint.strokeWidth = 3f
       paint.isDither = true
       paint.isAntiAlias = true
  }

   override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
       super.onSizeChanged(w, h, oldw, oldh)

       width = w.toFloat()
       height = h.toFloat()

       quadY = height / 4
       cubicY = height - height / 4
  }


   fun startBezierAnim() {
       bezierAnim?.cancel()
       bezierAnim = ValueAnimator.ofFloat(1.0f, 0f, 1.0f).apply {
           addUpdateListener {
               val value = it.animatedValue as Float
               per = value
               invalidate()
          }
           addListener(object :AnimatorListener{
               override fun onAnimationStart(animation: Animator?) {

              }

               override fun onAnimationEnd(animation: Animator?) {

              }

               override fun onAnimationCancel(animation: Animator?) {

              }

               override fun onAnimationRepeat(animation: Animator?) {
                   val random = Random(System.currentTimeMillis())
                   val one = random.nextInt(400).toFloat()
                   val two = random.nextInt(800).toFloat()

                   quadHeight = one
                   cubicHeight = two
              }

          })
           duration = 300
           repeatCount = -1
           start()
      }
  }


   override fun onDraw(canvas: Canvas?) {
       super.onDraw(canvas)

       var quadStart = 0f
       path.reset()
       path.moveTo(quadStart, quadY)
       while (quadStart <= width){
           path.quadTo(quadStart + 75f, quadY - quadHeight * per, quadStart + 150f, quadY)
           path.quadTo(quadStart + 225f, quadY + quadHeight * per, quadStart + 300f, quadY)
           quadStart += 300f
      }
       paint.color = Color.BLUE
       canvas?.drawPath(path, paint)

       path.reset()
       var cubicStart = 0f
       path.moveTo(cubicStart, cubicY)
       while (cubicStart <= width){
           path.cubicTo(cubicStart + 100f, cubicY - cubicHeight * per, cubicStart + 200f, cubicY + cubicHeight * per, cubicStart + 300f, cubicY)
           cubicStart += 300f
      }
       paint.color = Color.RED
       canvas?.drawPath(path, paint);
  }
}

上面基于二阶贝塞尔曲线,下面基于三阶贝塞尔曲线,加了一层属性动画。


四、仿真页的拆分


我们在本篇文章不会涉及到仿真页的代码,主要做一下仿真页的拆分。


下面的这套方案也是总结自何明桂大佬的方案。


Android图形架构


从图中的仿真页中我们可以看出,上下一共两页,我们需要处理:



  1. 第一页的内容
  2. 第一页的背面
  3. 第二页露出来的内容

这三部分中,除了 GE 和 FH 是两段曲线,其他都是直线,直线是比较好计算的,先看两段曲线。


通过观察发现,这里的 GE 和 FH 都是对称的,只有一个平滑的弯,用一个控制点就能应付,所以选择二阶贝塞尔曲线就够了。GE 这段二阶段贝塞尔曲线,对应的控制点是 C,FH 对应的控制点是 D。


1. 第一页正面


再看图片,路径 A - F - H - B - G - E - A 之外的就是第一页正面,将内容页和这个路径的 Path 取反即可。


具体的过程:



  1. 已知 A 是触摸点,B 是内容页的底角点,可以求出中点 M 的坐标
  2. AB 和 CD 相互垂直,所以可得 CD 的斜率,从 M 点坐标推出 CD 两点坐标
  3. E 是 AC 中点,F 是 AD 中点,那么 E 和 F 的点位置很容易推导出来

2. 第二页内容


第二页的重点 KLB 这个三角形,M 是 AB 的中点,J 是 AM 的中点,N 是 JM 的重点,通过斜率很容易推导出与边界相交的KL 两点,之后从内容页上裁出 KLB 这个Path,第二页的内容绘制在这个 Path 即可。


3. 第一页的背面


背面这一块儿绘制的区域是三角形 AOP,AC、AD 和 KL 都已知,求出相交的 KL 点即可。


但是我们还得将第一页底部的内容做一个旋转和偏移,再加上一层蒙层,就可以得到我们想要的背面内容。


总结


可以看出,学会了贝塞尔曲线以后,仿真页其实并不算特别复杂,但是整个数学计算还是很麻烦的。


让人头秃


下篇文章再和大家讨论具体的代码,如果觉得本文有什么问题,评论区见!


参考文章:



blog.csdn.net/hmg25


作者:九心
链接:https://juejin.cn/post/7173850844977168392
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册