阅读完本文约需 10 分钟。
废不说,有图
逼真模拟便利贴撕页效果,在一些需要分步操作的场景非常适合。
用户的每一步操作都非常清晰,撕页的效果可以提醒用户上一步操作已经圆满完成了。
比如,现在用户在填写人口普查的信息,必填项繁多复杂,如果全部塞在一个页面就会显得臃肿,用户很容易漏填错填。
总不能一直说,用户是 撅着屁股看天----有眼无珠 吧?
在职场,甩锅给用户可能是个好策略,如同iphone12没有充电器一样环保。
但人总不能骗自己,甩完锅之后的还是得反省思考。
撕页效果就是值得参考的交互优化方案。
最后的效果也非常nice,真是牛逼他妈说牛逼----牛逼plus。
一、设计思路
怎样让用户的注意力集中在当前的操作步骤上?怎样让用户操作完当前步骤后获得一种干净利落的感觉,从而持续集中精力操作下一步骤?
现实生活中的打工人如何处理自己的待办事项?有很多打工人就习惯于使用便利贴,小小的便利贴上写满各种任务,完成后就撕页进行下一项。
将这一设计用于app中,可以给用户带来同样的体验。
二、实现方案
1、UI拆解
1.1 形状分析
初看可能会觉得无从下手,首先按照纸张状态进行分析,分为“正面内容全部显示”、“正面内容部分显示”、“反面内容全部显示”三种状态。
用辣眼睛的色块进行区分,红色代表正面,蓝色代表反面,如下图:
1.2 模型设计
按照以上三种状态,延伸定义卷角、内容显示区域以及它们的合集区域,对应蓝色区域、红色区域以及它们的合集
/*** 内容路径*/var contentPath: Path = Path()/*** 纸张卷角*/var dogEaredPath: Path = Path()/*** 组合路径 = 内容路径+纸张卷角*/var unionPath: Path = Path()//卷角大小var crimpSize: Float = 0F
在翻页时,本质上就是各个区域路径的变化。在绘制时,只有两种情况,要么需要绘制正面,要么不绘制正面,临界点为下图
在调用者看来,控件状态分为三种情况:正面正常显示状态、正在撕的状态、已撕完状态
1.3 方案参考
模型设计完毕,组合各个区域路径,定义关键点,采用贝塞尔曲线进行弯曲部分的绘制,以内容显示区域为例
2、UI绘制
2.1 状态定义
按之前定义,为方便区分和控制,绘制状态有如下两种,代码如下
/*** 卷角在内状态*/val DRAW_STATE_INNER = 0x01/*** 卷角在外状态*/val DRAW_STATE_OUTER = 0x02var drawState = DRAW_STATE_INNER
2.2 形状绘制
2.2.1 内容区域形状绘制
定义A~G各个点。需要注意的是,在_DRAW_STATE_OUTER _的绘制状态时,D点为右上角顶点。如此多的点不可能一一进行控制,不是时间管理大师就不要撩拨这么多个点了,否则非常复杂。
以D点为关键点,用D点的坐标推算出其它各点的坐标,从而控制好D点即可控制所有点位。
/*** 坐标点*/var pointA: Coordinate = Coordinate()var pointB: Coordinate = Coordinate()var pointC: Coordinate = Coordinate()var pointD: Coordinate = Coordinate()var pointE: Coordinate = Coordinate()var pointF: Coordinate = Coordinate()var pointG: Coordinate = Coordinate()//outer状态下的顶点坐标var pointH: Coordinate = Coordinate()var pointI: Coordinate = Coordinate()
有了各个坐标点,即可组合成各个区域路径,以内容区域路径为例
contentPath.reset()contentPath.moveTo(0F, 0F)contentPath.lineTo(pointA.x, pointA.y)contentPath.quadTo(pointB.x, pointB.y, pointC.x, pointC.y)contentPath.lineTo(pointD.x, pointD.y)contentPath.lineTo(pointE.x, pointE.y)contentPath.quadTo(pointF.x, pointF.y, pointG.x, pointG.y)contentPath.lineTo(paperWidth, paperHeight)contentPath.lineTo(paperWidth, 0F)contentPath.close()
2.2.2 卷角区域绘制
卷角区域采用与内容区域的差集获得。这里涉及到某一段贝塞尔曲线中的某一点的计算,计算出卷角贝塞尔曲线中中线的坐标,相连闭合后与内容区域作差集操作。
val pointb = BazierUtils.getBezierPoint(pointA, pointB, pointC, 0.5F)val pointf = BazierUtils.getBezierPoint(pointE, pointF, pointG, 0.5F)dogEaredPath.reset()dogEaredPath.moveTo(pointb.x, pointb.y)dogEaredPath.lineTo(pointD.x, pointD.y)dogEaredPath.lineTo(pointf.x, pointf.y)dogEaredPath.lineTo(pointb.x, pointb.y)dogEaredPath.close()dogEaredPath.op(contentPath, Path.Op.DIFFERENCE)
2.3 渐变&阴影绘制
为突出立体效果,需要绘制卷角区域往内容区域上的投影和弯曲处的渐变
代码如下:
mPaint.setShadowLayer(20F, 10F, -10F, shadowColor)drawPath(dogEaredPath, mPaint)mPaint.shader = LinearGradient(pointD.x / 2F, pointD.y + pointD.x / 2F, pointD.x * 3 / 4F, pointD.y + pointD.x / 4F, shadowColor, Color.WHITE, Shader.TileMode.CLAMP)drawPath(dogEaredPath, mPaint)
2.4 ViewGroup的onDraw方法
在使用ViewGroup时,onDraw方法的调用受限于mViewFlags中的WILL_NOT_DRAW标识,当ViewGroup中无子View时,不会执行onDraw方法中的逻辑,初始化时将其配置
setWillNotDraw(false)
2.5 裁切子View
ViewGroup中通过drawChild方法判断是否绘制子view,此方法可以讲处理过的Canvas传递给子View。在_DRAW_STATE_OUTER_ 的绘制状态时,无需再绘制子View。
override fun drawChild(canvas: Canvas?, child: View?, drawingTime: Long): Boolean { if (drawState == DRAW_STATE_INNER) { canvas?.save() canvas?.clipPath(childContentPath) val flag = super.drawChild(canvas, child, drawingTime) canvas?.restore() return flag } else { return true }}
3、交互
3.1 手动控制
用户可以手动撕页
3.2 动画
控制D点坐标即可控制其它所有的点的坐标,动画时长设置成888毫秒,不求别的,就图个吉利,代码如下
/*** 撕页动画*/var tearAnim: ValueAnimator? = null/*** 开始便利贴撕页动画*/fun startTearAnim() { tearAnim?.cancel() tearAnim = ValueAnimator.ofFloat(0F, paperWidth * 2.5F) with(tearAnim!!) { // 图个吉利 duration = 888L interpolator = AccelerateInterpolator() dStartX = pointD.x dStartY = pointD.y addUpdateListener { offset = it.animatedValue as Float pointD.x = dStartX + offset pointD.y = dStartY - offset //边界控制 if (pointD.x <= 0F) { pointD.x = 0F } if (pointD.y >= paperHeight) { pointD.y = paperHeight } executeCrimpSizeFunc(dStartX + offset) configPoint(pointD.x, pointD.y) combinePath() postInvalidate() } addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator?) { super.onAnimationEnd(animation) state = STATE_TEARED onTearStateChangeListener?.onTearStateChanged(state) } }) start() }}
3.4 边界控制
当用户手指移动超出边界时,必须强制赋值,否则效果拉胯
//边界控制if (pointD.x <= 0F) { pointD.x = 0F}if (pointD.y >= paperHeight) { pointD.y = paperHeight}
3.5 细节设计
注意,在撕页过程中,卷角的弯曲大小是有变化的,因此在配置各个坐标点的时候,将卷角大小考虑进去。
4、扩展
通用方案参考
在这个控件中,便利贴是正方形的,如果想要方案更加通用,适用于所有翻页效果,则可以按照下图的思路进行设计,原则也是同样的,控制其中的一个点从而控制所有的点。
模型设计是这样的,任选abcd矩形区域内的一点D,那么点D对应的纸张内容显示的绘制区域为蓝色部分,线段BC为点D、点d的中垂线,△BCd与△DBC相同,点A为线段Dd的中点,点J、K、L、M为线段DA的中垂线与各个线段的交点。
已知点D、点d的坐标即可推算出其它所有的点的坐标,由于d点为顶点,无需变动,因此控制D点即可控制所有的点。
5、优化
优化思路有很多,针对低端机器的形状绘制,可采用SurfaceView,对一些线段进行抽点绘制,避免路径的相交、合并操作。
当然,这里仅作抛砖引玉。
三、后记
此篇为《隔壁产品馋哭》系列文章的完结篇,一路坚持下来,很不容易。
但我就算写再多的怎样艰难奋进的话语,如:
苦苦构思各个交互设计;
深夜还在想控件的实现方案;
文章推广困难重重;
写文章被同事嘲讽…...
可是这些和读者又有什么关系呢?只能让读者发挥同理心,感受到系列文章创作的不易,从而点赞转发评论……甚至打赏!
你,感受到了么?
后会有期。
控件放在了gitee上
啊~ 现在开不了打赏。