PaperView:像紙一樣摺疊

大飛機發表於2017-12-09

GitHub:PaperView

這個效果我是從這裡看到的,感覺挺酷的,所以就自己試著做了一個,他們也有Android版本的實現,你們可以對比著看一下

我不知道該叫什麼名字,像是紙片,但又不全是紙片,但是又沒有其他的好的想法,所以還是遵從一如既往的隨性,就叫PaperView吧

效果圖

普通佈局中效果 RecyclerView中效果
PaperView:像紙一樣摺疊
PaperView:像紙一樣摺疊

如何使用

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的過程無非就是以下兩點:

  1. 繼承View或者View的子類們(這裡我們繼承了FrameLayout)
  2. 重寫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的尺寸裁剪

相關文章