PaperView:像纸一样折叠
这个效果我是从 这里 看到的,感觉挺酷的,所以就自己试着做了一个,他们也有Android版本的实现,你们可以对比着看一下
我不知道该叫什么名字,像是纸片,但又不全是纸片,但是又没有其他的好的想法,所以还是遵从一如既往的随性,就叫 PaperView 吧
效果图
普通布局中效果 | RecyclerView中效果 |
---|---|
如何使用
1.在布局中声明
<com.goyourfly.library.paper_view.PaperView
android:id="@+id/paperView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
app:paper_bg_color="#fff"
app:paper_duration="1000">
<!--展开的布局-->
<include layout="@layout/item_large" />
<!--收起的布局-->
<include layout="@layout/item_small" />
</com.goyourfly.library.paper_view.PaperView>
参数 | 类型 | 说明 | 默认值 |
---|---|---|---|
paper_duration | integer | 动画总时长 | 2000 |
paper_bg_color | color | 纸片背景色 | #FFF |
2.在代码设置
2.1 展开和折叠
// 折叠卡片
paperView.fold(animator:Boolean,changed:Boolean)
// 展开卡片
paperView.unfold(animator:Boolean,changed:Boolean)
2.2 监听状态变化
paperView.setStateChangedListener(object:PaperView.OnFoldStateChangeListener{
// 折叠
override fun onFold() {}
// 展开
override fun onUnfold() {}
})
实现细节
使用说明写完了,那我们来简单的谈一谈如何实现这一个优美的自定义 View 吧
我是个有代码洁癖的人,我甚至觉得加注释都会影响代码的美观性,所以呢,如果代码里面注释少的话,大家不要怪我哈哈哈
我们知道,自定义 View 的过程无非就是以下两点:
- 继承 View 或者 View 的子类们(这里我们继承了 FrameLayout )
- 重写 onMeasure()、onLayout()、onDraw() 等方法,添加自己的逻辑
下面我就从这两个方面讨论一下我是如何做的
为什么继承 FrameLayout
我们知道 View 类是 Android 的基石,几乎所有的控件都继承自
View,那如果是这样,为什么我们的 PaperView 不直接继承 View,而是继承的是 FrameLayout 呢?
原因有三
- 1.PaperView 需要包含子 View,所以需要继承 ViewGroup
- 2.PaperView 不需要太关注 Layout 的过程,只需处理测量(measure)和绘制(draw),我们没必要重复发明轮子,所以把这个过程交给Android已经给我们提供好的 XXXXLayout
- 3.PaperView 有两个子类,他们相互之间是上下层的关系,相互之间没有影响,所以我们选择 FrameLayout 来处理 Layout 的过程
class PaperView : FrameLayout {
...
}
测量的过程
我们先抛开动画不谈,那么现在 PaperView 应该只有两种状态:展开、关闭
private var status = STATUS_SMALL | STATUS_LARGE
那么,在 measure 的过程中,应该是这样的:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val myWidth = MeasureSpec.getSize(widthMeasureSpec)
for (index in 0 until childCount) {
val child = getChildAt(index)
child.visibility = VISIBLE
// 测量每一个Child的高度
measureChild(child, widthMeasureSpec, heightMeasureSpec)
}
val smallChild = this.smallChild!!
val largeChild = this.largeChild!!
// 根据不同的状态计算需要的尺寸
when (status) {
STATUS_SMALL -> {
smallChild.visibility = VISIBLE
largeChild.visibility = GONE
// 根据不同的状态设置PaperView不同的高度
setMeasuredDimension(myWidth, smallChild.measuredHeight + paddingTop + paddingBottom)
}
STATUS_LARGE -> {
smallChild.visibility = GONE
largeChild.visibility = View.VISIBLE
// 根据不同的状态设置PaperView不同的高度
setMeasuredDimension(myWidth, largeChild.measuredHeight + paddingTop + paddingBottom)
}
}
}
这种情况下,我们就可以通过修改 status
的状态并调用
requestLayout()
来切换展开和关闭效果了。
如果我们不加任何动画的情况下,只需要写到这里就OK了,核心代码就这么多,文章写到这里就可以结束了。
...
可是,我们要加那个好看的动画,美是要付出代价的,so,事情还远远没有结束...
如果你还有耐心,请接着听我哔哔...哔哔哔...
在加动画的情况下,PaperView 的互斥状态就变成了4种:
- 1.关闭
- 2.从关闭到打开的过程
- 3.打开
- 4.从打开到关闭的过程
所以我们定义首先定义了以下4种状态:
private val STATUS_SMALL = 1
private val STATUS_LARGE = 2
private val STATUS_S_TO_L = 3
private val STATUS_L_TO_S = 4
前两种状态我就不解释了,很简单,看一看 STATUS_S_TO_L
这个状态的时候,发生了什么事情。
首先,如果要展开 PaperView 需要调用下面这个方法:
/**
* 展开
* @param animator 是否执行动画
* @param changed 内容是否发生变化
*/
fun unfold(animator: Boolean = true, changed: Boolean = false) {
// 简单的把状态改为 STATUS_S_TO_L
status = if (animator) STATUS_S_TO_L else STATUS_LARGE
contentChanged = changed
// 刷新View
requestLayout()
}
那现在 status = STATUS_S_TO_L
紧接着,onMeasure()
方法会被执行
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
...
when (status) {
...
STATUS_S_TO_L -> {
// 在S_TO_L的过程中,PaperView是展开的过程
// 所以PaperView的初始高度应该是PaperView是
// SMALL状态的高度,既smallChild的高度
setMeasuredDimension(myWidth, smallChild.measuredHeight + paddingTop + paddingBottom)
// 触发动画的执行
animateLargeImpl()
smallChild.visibility = GONE
largeChild.visibility = GONE
}
...
}
...
}
上面的 onMeasure
最终会调用 animateLargeImpl()
这个方法来触发展开的动画
/**
* 展开动画
*/
private fun animateLargeImpl() {
// 动画前的准备工作,在下面介绍
preAnimate()
// 重置一些变量
largeReset()
...
}
我下面会用纸片和纸条这两个词做说明,所以简单解释一下:
- 纸片:完整的一张纸
- 纸条:纸片横向裁剪后的某一个窄条
preAnimate()
主要是为动画的执行提供素材,PaperView 的动画是纸片的折叠和展开的效果,那在展开的过程中,因为我们要模拟类似的过程,所以要将完全展开的纸片裁剪成几条较小的纸条,然后对这几个纸条做旋转和位移的动画
private fun preAnimate() {
// 如果当前正在做动画,则停止动画
if (animating
&& animatorSet != null
&& animatorSet!!.isRunning) {
animatorSet?.end()
animatorSet = null
}
animating = false
if (paperList == null
|| paperList!!.isEmpty()
|| contentChanged) {
contentChanged = false
paperList?.clear()
// 做最后的裁剪
paperList = getDividedBitmap(getSmallBitmap().reverseY(), getLargeBitmap())
}
}
getDividedBitmap
这个方法按照一定的规则将纸片裁剪成几个小纸条,为了方便存储纸条的一些扩展信息,我将裁剪后的纸条封装在
PaperInfo
这个类中,然后在将这几个纸条打包到 List 中返回
private data class PaperInfo(var visible: Boolean,
val x: Float,
var y: Float,
var angle: Float,
val fg: Bitmap,
val bg: Bitmap,
var prev: PaperInfo?,
var next: PaperInfo?)
/**
* smallBitmap是折叠以后的View
* largeBitmap是展开以后的View
*/
private fun getDividedBitmap(smallBitmap: Bitmap, largeBitmap: Bitmap): MutableList<PaperInfo> {
val desireWidth = largeBitmap.width
val desireHeight = largeBitmap.height
val list = ArrayList<PaperInfo>()
val x = 0
val divideItemWidth = smallBitmap.width
val divideItemHeight = smallBitmap.height
var nextDividerItemHeight = divideItemHeight.toFloat()
var divideYOffset = 0F
val count = desireHeight / divideItemHeight + if (desireHeight % divideItemHeight == 0) 0 else 1
var prevStore: PaperInfo? = null
for (i in 0..count - 1) {
if (divideYOffset + nextDividerItemHeight > desireHeight) {
nextDividerItemHeight = desireHeight - divideYOffset
}
val fg = Bitmap.createBitmap(largeBitmap, x, divideYOffset.toInt(), divideItemWidth, nextDividerItemHeight.toInt())
val bg = if (i == 1) smallBitmap else generateBackgroundBitmap(fg.width, fg.height)
val store = PaperInfo(false, x.toFloat(), divideYOffset, 180F, fg, bg, prevStore, null)
list.add(store)
prevStore?.next = store
prevStore = store
divideYOffset += divideItemHeight
}
return list
}
至此,纸片已经被裁剪好了,动画之前的工作都准备好了,开始执行动画
private fun animateLargeImpl() {
...
// 由于折叠的过程是多个动画的组合,所以我们用Set的方式
val set = AnimatorSet()
val list = ArrayList<Animator>()
val eachDuration = duration / paperList!!.size
// 遍历所有的纸片,对每一个纸片生成一个相应的动画
// 然后按照顺序播放
paperList?.forEachIndexed {
index, it ->
// 第一个纸片不做动画
if (index != 0)
list.add(animate(it, angleStart, angleEnd, true, eachDuration))
}
// 所有动画结束以后,修改状态,刷新UI
set.addListener(object : SimpleAnimatorListener() {
override fun onAnimationEnd(animation: Animator?) {
// 所有动画结束时,将状态置为展开
status = STATUS_LARGE
// 是否正在动画中置为 false
animating = false
requestLayout()
listener?.onUnfold()
}
})
set.playSequentially(list)
// 启动动画
startAnimator(set)
}
private fun animate(store: PaperInfo,
from: Float,
to: Float,
visibleOnEnd: Boolean,
duration: Long): Animator {
val animator = ValueAnimator.ofFloat(from, to)
animator.duration = duration
animator.addUpdateListener {
value ->
// 如果当前纸条处于动画期间,UpdateListener
// 就会被执行,我们根据动画的进度计算这个
// 纸条应该旋转的角度并把它存储到PaperInfo中
store.angle = value.animatedValue as Float
// 执行invalidate()触发onDraw()方法
invalidate()
}
animator.addListener(object : SimpleAnimatorListener() {
override fun onAnimationStart(animation: Animator?) {
// 动画开始前,纸条是隐藏的
store.visible = true
}
override fun onAnimationEnd(animation: Animator?) {
// 如果是展开,动画结束时候是显示的
// 如果是收起,则在动画结束时隐藏
store.visible = visibleOnEnd
}
})
return animator
}
读到这里有没有发现,其实 animate()
什么动画也没有执行呀,它只是改变了一下纸片对象中 angle
的值,然后触发 onDraw()
方法,所以,接下来,我们去看看 onDraw()
方法如何绘制动画的
绘制的过程
override fun onDraw(canvas: Canvas) {
// 判断一下状态,如果不属于动画状态就不执行了
if (status == STATUS_SMALL || status == STATUS_LARGE) {
return
}
if (paperList == null)
return
canvas.save()
// 如果有左和上的padding,挪动一下canvas
canvas.translate(paddingLeft.toFloat(), paddingTop.toFloat())
// 在动画中要实事的计算child的高度
childRequireHeight = 0F
// 遍历所有的纸条,根据他们的位置和旋转角度进行绘制
paperList?.forEach {
val itemHeight = flipBitmap(canvas, it)
if (itemHeight > 0) {
childRequireHeight += itemHeight
}
}
canvas.restore()
// 触发onMeasure,为什么要在onDraw里面又触发onMeasure呢?
// 其实主要是为了实时调整PaperView的高度
requestLayout()
}
onDraw()
方法中,遍历所有的纸条,然后对他们分别执行 flipBitmap()
这个方法名好像取得不是特别贴切
下面用到了矩阵的运算,所以如果你不太了解的话,我建议最好上网查查资料,了解一下矩阵运算的本质
private fun flipBitmap(canvas: Canvas, store: PaperInfo?): Float {
if (store == null || !store.visible)
return 0F
val angle = store.angle
val x = store.x
val y = store.y
val centerX = store.fg.width / 2.0F
val centerY = store.fg.height / 2.0F
divideMatrix.reset()
divideCamera.save()
divideCamera.rotate(angle, 0.0F, 0.0F)
divideCamera.getMatrix(divideMatrix)
divideCamera.restore()
// 修正旋转时的透视 MPERSP_0
divideMatrix.getValues(divideTempFloat)
divideTempFloat[6] = divideTempFloat[6] * flipScale
divideTempFloat[7] = divideTempFloat[7] * flipScale
divideMatrix.setValues(divideTempFloat)
// 将锚点调整到 (-centerX,0) 的位置
divideMatrix.preTranslate(-centerX, 0.0F)
// 旋转完之后再回到原来的位置
divideMatrix.postTranslate(centerX, 0.0F)
// 移动到指定位置
divideMatrix.postTranslate(x, y)
// 获取正确的Bitmap,正面/反面
val bitmap = getProperBitmap(store)
// 在旋转的时候调整亮度
val amount = (Math.sin((Math.toRadians(angle.toDouble())))).toFloat() * (-255F / 4)
// 调整亮度,这里是为了模拟纸片在发转的过程中亮度的变化
adjustBrightness(amount)
canvas.drawBitmap(bitmap, divideMatrix, paint)
// 根据旋转角度计算纸片的实际高度
return (bitmap.height * Math.cos(Math.toRadians(angle.toDouble()))).toFloat()
}
getProperBitmap()
根据纸条的旋转角度以及它在整个纸片中的位置来确定使用当前纸片正面、反面、上个纸条的背面、下个纸条的背面等
private fun getProperBitmap(store: PaperInfo): Bitmap {
val angle = store.angle
if (isForeground(angle)) {
// 根据角度计算要显示前面,但是由于前面有遮挡物
// 这个遮挡物就是下一个折叠的背面
if (store.next != null
&& store.next!!.angle == angleStart) {
if (store.next!!.bg.height < store.bg.height) {
return store.bg
} else {
return store.next!!.bg
}
} else {
return store.fg
}
} else {
// 背部同理,可能有前一个折叠的背面遮挡
if (store.prev != null
&& store.prev!!.bg.height > store.bg.height) {
return store.prev!!.bg
} else {
return store.bg
}
}
}
等上面的绘制完成后,childRequireHeight
的大小也已经计算出来了,这个时候执行 requestLayout()
,触发 onMeasure()
的执行调整 PaperView 的尺寸
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
if (childCount != 2) {
throw IndexOutOfBoundsException("PaperView should only have two children")
}
val myWidth = MeasureSpec.getSize(widthMeasureSpec)
if (animating) {
// 如果在动画中,则依据childRequireHeight设置PaperView的高度
setMeasuredDimension(myWidth, (paddingTop + paddingBottom + childRequireHeight).toInt())
return
}
...
}
我在上面讲过,在做动画的时候会给 AnimatorSet 添加一个结束的监听器,等所有动画结束的时候,修改状态为 status = STATUS_LARGE
/**
* 展开动画
*/
private fun animateLargeImpl() {
...
set.addListener(object : SimpleAnimatorListener() {
override fun onAnimationEnd(animation: Animator?) {
// 修改status
status = STATUS_LARGE
// 修改动画状态
animating = false
// 触发onMeasure
requestLayout()
listener?.onUnfold()
}
})
...
}
紧接着,执行 requestLayout()
,触发 onMeasure()
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
...
val smallChild = this.smallChild!!
val largeChild = this.largeChild!!
// 根据不同的状态计算需要的尺寸
when (status) {
...
STATUS_LARGE -> {
smallChild.visibility = GONE
largeChild.visibility = View.VISIBLE
setMeasuredDimension(myWidth, largeChild.measuredHeight + paddingTop + paddingBottom)
}
...
}
}
最后
走到这里,展开的动画过程完全结束,PaperView 现在处于打开状态,那收起其实就是这个的逆过程,我不想讲了,估计您也没耐心看,稍微总结一下上面讲的:
- PaperView 继承自 FrameLayout
- PaperView 主要重写了 onMeasure 和 onDraw 两个方法
- onMeasure 根据状态实时调整 PaperView 大小和判断是否需要触发动画
- onDraw 绘制动画的过程
- PaperView 获取两个子 View 的 Bitmap,将大的 Bitmap 按照小
Bitmap 的尺寸裁剪