原創文章,轉載請聯絡作者。個人部落格
梧桐落,又還秋色,又還寂寞。
效果圖,檔案比較大,稍稍等一下 (●゚ω゚●):
前言
首先,首先!Demo
只是對FliBoard
的立體感直板翻頁式互動效果作了模仿,只是效果只是效果。那種翻頁元件挺麻煩的,以後可能會抽時間做一下( ̄▽ ̄)"
立體感
是一種模仿,在二維平面上,合理地利用光影、透視(遠小近大)等方式,塑造一種近似現實三維世界的感jio。為什麼會產生立體感? 是因為人的視網膜接受到的,全是三維世界的投影。是你的大腦以及經驗,腦補出了三維世界。
舉栗子,下面這張圖片,你會把它看作一個彎曲三角嗎
同理,動畫也無非是利用了人眼的視覺暫留而已。某種程度,它和魔術擁有相同的本質————欺騙。
效果解析
解析效果前,先提一下會用到的知識點
1、用到的知識點
- graphics.Camera,圖形包下用來處理3D旋轉的類
- canvas、Matrix
2、效果拆解
直板式的翻頁,效果其實並不複雜。手機螢幕之後,是一個三維座標系。想象一下有張板子(Bitmap)放在XY座標系,要達到翻頁效果,讓其繞著X軸旋轉即可。正常情況下,板子(Bitmap)是作為整體旋轉。我們將板子中心點移到X軸上,那麼繞著Z軸旋轉時,上下兩部分運動的方向肯定是相反的。就像這樣:
上圖為繞著X軸旋轉45度,縮放0.5f效果
如上圖所示,為達到效果,必須將上下兩部分分開繪製。你可以採用將Bitmap
分割的方式,也可以分割Canvas
。Demo裡,我採取的是分割Canvas
。使用方法canvas.clipRect(left, top, right, bottom)
。
3、手勢拆解
翻頁共有三種狀態,靜態、下翻以及上翻。靜態不必贅述,下面會分析一下上翻和下翻繪製。
3.1 向下翻頁繪製解析
向下翻頁,就是翻過當前頁回到上一頁。在效果拆解那部分,我們已經知道,45度時,上半部分會偏向螢幕後。所以要讓上半部分向下翻轉。旋轉角度得是負數。也就是,在一個完整的下翻週期內,角度的變化為0到-180度
。
其中0到-90度
內,當前頁正在下翻,頁面變動在上半區域,此時可以看到的介面有:下翻ing的當前頁上半部分、當前頁產生的陰影、上一頁的上半部分(保持不動)。而在-90到-180度
階段,此時下翻的動作接近完成,頁面變動在下半區域,此時可以看到的介面有:即將翻過的上一頁的下半部分、上一頁翻轉產生的陰影、當前頁的下半部分。
3.2 向上翻頁繪製解析
向上翻頁,就是翻過當前頁去下一頁。和下翻邏輯相反,這是一個0到180度
的週期活動。0到90度
為正在上翻,頁面變動在下半區域。而90到180度
,上翻動作接近完成,頁面變動在上班區域,很快會看到完整的下一頁。
具體實現
用自定義View來實現,這裡只貼出主要程式碼,部分邏輯會用虛擬碼表述,完整程式碼文末提供。
1、繪製
因為只是仿寫效果,所以全部邏輯放在了一個自定義View內部。先看一些主要的成員變數。
//向下翻旋轉角度,0~-180f
private var rotateF
//向上翻旋轉角度,0~180f
private var rotateS
//翻動狀態,0為鬆手,1為向下翻,-1為向上翻
private var statusFlip = 0
//當前頁
private var curPage
//用於3D旋轉的Camera類
private val camera
//繪製Bitmap的Matrix
private val drawMatrix
//中心點X座標
private val centerX
//中心點Y座標
private val centerY
//當前Bitmap
private var curBitmap: Bitmap
//上一張Bitmap
private var lastBitmap: Bitmap
//下一張Bitmap
private var nextBitmap: Bitmap
複製程式碼
我維護了兩個變數用來分別控制下翻和上翻的角度變化。與此同時,也分了兩個方法,來分別繪製上半部分和下半部分。
//上半部分繪製
fun drawFirstHalf(canvas: Canvas?, bitmap: Bitmap?, rotate: Float) {
canvas?.save()
//將canvas上半部分切割
canvas?.clipRect(0, 0, width, height / 2)
camera.save()
//camera繞著X軸旋轉
camera.rotateX(角度變化小於-90度,不再處理)
camera.getMatrix(drawMatrix)
camera.restore()
//隨著旋轉角度變化的縮放值,只縮放Y軸
drawMatrix.preScale(1.0f, 縮放比)
//將圖片移到中心點
drawMatrix.preTranslate(-centerX, -centerY)
drawMatrix.postTranslate(centerX, centerY)
canvas?.drawBitmap(this, drawMatrix, null)
canvas?.restore()
}
複製程式碼
fun drawSecondHalf(canvas: Canvas?, bitmap: Bitmap?, rotate: Float) {
canvas?.save()
camera.save()
//切割下半部分canvas
canvas?.clipRect(0, height / 2, width, height)
camera.rotateX(繞著X軸旋轉角度,大於90度後只不再處理變化)
camera.getMatrix(drawMatrix)
camera.restore()
drawMatrix.preScale(1.0f, 縮放比隨著角度變化)
drawMatrix.preTranslate(-centerX, -centerY)
drawMatrix.postTranslate(centerX, centerY)
canvas?.drawBitmap(this, drawMatrix, null)
canvas?.restore()
}
複製程式碼
2、 手勢處理
手勢處理較為簡單,只需要在MOVE的時候,判斷此時的狀態是上翻還是下翻。然後在抬手UP的時候,根據此時的距離,來判斷是否下翻成功或是上翻成功。倘若距離不夠標準閾值,那麼一切歸於原位。
- 其中startX、startY為手指落點
MotionEvent.ACTION_MOVE -> {
val x = this.x
val y = this.y
//當y運動距離大於x的1.5倍時,才判斷為垂直翻動
val disY = y - startY
if (Math.abs(disY) > 1f && Math.abs(disY) >= Math.abs(x - startX) * 1.5f) {
if (statusFlip == 0) {
//滑動間距為正並且不是第一頁判斷為向下翻,滑動間距為負並且不是最後一頁判斷為向上翻
statusFlip = if (disY > 0 && curPage != 0) DOWN_FLIP
else if (disY < 0 && curPage != girls.lastIndex) UP_FLIP else 0
}
val ratio = Math.abs(disY) / centerY
if (statusFlip == DOWN_FLIP) {
//向下翻並且當前頁不等於0
rotateF = ratio * -180f
Log.d("cece", ": rotateF : " + rotateF);
invalidate()
} else if (statusFlip == UP_FLIP) {
//向上翻,並且不是最後一頁
if (curPage != girls.lastIndex) {
rotateS = ratio * 180f
Log.d("cece", ": rotateS : " + rotateS);
invalidate()
}
}
}
}
複製程式碼
- 當手指抬起時,首先判斷此時的狀態,然後再判斷移動過的距離是否滿足閾值。不滿足的迴歸當前頁,滿足閾值的,繼續執行未完成的狀態。
if (statusFlip != 0) {
drawMatrix.reset()
//放手的時候,有動畫發生
if (Math.abs(event.y - startY) <= centerY / 2) {
//滑動距離小於1/4螢幕高,判定仍停留在當前頁
rotateF = 0f
rotateS = 0f
statusFlip = 0
invalidate()
} else {
//滑動距離超過臨界值,判定為跳過當前頁
if (statusFlip == DOWN_FLIP) {
//自動執行完下翻到上一頁的動作
for (i in rotateF.toInt() downTo -180 step 6) {
invalidate()
}
curPage--
} else {
//自動執行完上翻到下一頁的動作
for (i in rotateS.toInt() until 180 step 6) {
invalidate()
}
curPage++
}
rotateF = 0f
rotateS = 0f
statusFlip = 0
}
}
複製程式碼
當距離達到閾值時,就需要程式碼來繼續完成下翻或者上翻的邏輯。這裡我使用迴圈的方式。譬如上翻超過90度了,就迴圈到180度,繼續完成上翻的動作。
3、 陰影部分和繪製順序
在onDraw(...)
方法內繪製時,一定要注意程式碼順序。因為在這個方法內,順序代表著層次。譬如陰影繪製一定要寫在頁面繪製之前。
陰影部分的繪製也分為上下兩部分。
fun drawFirstShadow(canvas: Canvas?, rotate: Float) {
canvas切割上半部分,繪製color即可
}
fun drawSecondShadow(canvas: Canvas?, rotate: Float) {
canvas切割下半部分,繪製color即可
}
複製程式碼
在onDraw(...)
方法內的繪製順序一定要分明
//繪製當前頁底下的一層,翻頁進行中
if (statusFlip == DOWN_FLIP) {
//向下翻,滑到上一頁
drawFirstHalf(canvas, lastBitmap, 0f)
drawFirstShadow(canvas, rotateF)
} else if (statusFlip == UP_FLIP) {
drawSecondHalf(canvas, nextBitmap, 0f)
drawSecondShadow(canvas, rotateS)
}
//繪製當前頁
drawFirstHalf(canvas, curBitmap, rotateF)
drawSecondHalf(canvas, curBitmap, rotateS)
//繪製當前頁之上的一層,翻頁完成後
if (statusFlip == DOWN_FLIP) {
if (rotateF <= -90f) {
//先繪製陰影
drawSecondShadow(canvas, rotateF + 180f)
drawSecondHalf(canvas, lastBitmap, rotateF + 180f)
}
//繪製覆蓋在翻頁Bitmap之上淡淡透明層,透明度固定
drawFirstColor(canvas, 20)
} else if (statusFlip == UP_FLIP) {
if (rotateS >= 90f) {
drawFirstShadow(canvas, rotateS - 180f)
drawFirstHalf(canvas, nextBitmap, rotateS - 180f)
}
//淡淡透明度的陰影層
drawSecondColor(canvas, 20)
}
複製程式碼
還是得區分一下狀態,當下翻時,我們得先繪製上一頁的上半部分,而且是靜態的。然後再繪製當前頁下翻產生的陰影。再繪製當前頁,然後在當前頁頂上再繪製一層固定淡淡透明度的陰影層,讓頁面層次更加明顯。
4、效果修正
到這裡主要的邏輯業已完成,但我注意到還是有一些小瑕疵。就是旋轉角度和縮放比,變化不明顯。通常要角度變化到超過45度,才會有很明顯的縮放效果展現出來。
最開始我以為是縮放比的演算法問題,後來才發現是camera
的機位問題,camera
預設的拍攝角度是[0,0,-8]
,當距離螢幕很近時,變化自然不是很明顯。
當然,camera
提供了設定機位的方法setLocation(x, y, z)
。最後我調整到[0,0,-20]
才滿意這個效果。
下圖,我給出了,預設機位和[0,0,-20]機位的效果區別。
結語
Demo裡的實現方式並非是唯一,分享出來是為了提供一種思路。路有很多條,選擇即是正確。
以上
專案程式碼在此,大家要是喜歡的話不妨點個贊吧
有一個公眾號,會記錄一些開發的經驗,也會發一些自己的學習日記。歡迎大家關注。