圖片放大預覽是種很常見的場景和功能,一般移動網站首頁的輪播 banner
,商品詳情頁的商品圖片等位置都會用到此功能
像這種常用的場景功能肯定是有人早就寫好外掛了的,所以遇到這種場景,一般都遵循以下三步:
開啟冰箱啟動 Github- 搜尋
photo
、preview
、carousel
、photoSwipe
等關鍵字 - 找到想要的庫,
npm install
之
這種做法沒毛病,有現成的輪子可用當然拿來主義,因為專案用的是 vue
,所以我在網上找了一圈 基於 vue
的放大預覽元件庫,結果令我有點意外,圖片放大預覽的庫的數量明顯比不上輪播元件庫,並且更令人 智熄 的是,這些少得可憐的元件庫中,其中一大半都是基於 PhotoSwipe 這個開源庫進行的二次封裝,除此之外,能用於實際生產的預覽元件庫(image gallery
)……好像沒有(也可能是我見識短淺),這種情況不僅體現在 vue
庫上,其他框架乃至是原生的相關庫都是如此
雖說不提倡重複造輪子,但輪子太少沒有選擇的餘地也有點說不過去, PhotoSwipe 用起來很順手,功能也很齊全,足以應對實際生產環境中的絕大部分場景
但與此同時,也就代表它程式碼體積會比較大,引入的冗餘程式碼會比較多,於是,抱著精簡程式碼以及順便豐富放大預覽外掛家族的想法,決定自己造個輪子
先看下最終實現效果:
或者你想自己體驗一下,這裡也有個寫好的 Demo
我已經將此功能打包成了一個
npm package
,可直接下載安裝使用,包括樣式在內的程式碼體積壓縮後不到22KB
,Gzipped之後不到8KB
,原始碼 已上傳
滑動形式
滑動形式的選型與 造輪子之圖片輪播元件(swiper)中的一樣,就不多說了
資料處理
資料處理和 造輪子之圖片輪播元件(swiper) 中的第一種方法一樣,就不多說了:
<VueActivePreview :urlList="urlList" />
複製程式碼
touch事件
此元件的 touch
事件比較複雜,並且涉及到不同 touch
事件之間的互動,所以稍微麻煩點,不過只要條理清晰,考慮清晰,還是可以解決的
單指滑動
單指滑動的主體邏輯與 造輪子之圖片輪播元件(swiper)的相差不多,都是計算手指滑動的距離,通過不斷改變 translate
的值進行位移
雙指縮放
支援對單個圖片的縮放操作,原理其實很簡單,通過計算在起始時與滑動過程中雙指間距離的比例,就可以得到圖片的縮放比例
獲取雙指間距離:
getDistance (p1, p2) {
return Math.sqrt(Math.pow(p2.clientX - p1.clientX, 2) + Math.pow(p2.clientY - p1.clientY, 2))
}
複製程式碼
獲取圖片縮放比例:
this.scaleValue = this.getDistance(targetTouch1, targetTouch2) / doubleTransferInfo.startDistance
複製程式碼
通過改變 transform: scale(scaleValue)
,就可以實現圖片的即時縮放
不過,這個時候有個問題,那就是 CSS3 scale
的縮放中心座標,預設是 50% 50% 0
,也就是元素的中心位置,所以如果在不設定 transform-origin
的情況下,直接設定 scale
,那麼圖片也可以正常縮放,但縮放的結果卻並不一定是所想要的
比如,雙指中心座標是 (10, 56)
,按照正常習慣,當進行放大時,整張圖片應該是以這個點為中心進行放大,而不應該是圖片的中心的位置
有兩種方法可以解決這個問題
- 動態設定
transform-origin
直接將雙指之間的中心座標設定為 transform-origin
,然後進行縮放即可,這是最簡單的方法
所以需要動態設定 transform-origin
const targetTouch1 = e.touches[0]
const targetTouch2 = e.touches[1]
this.transOriginX = (targetTouch1.clientX + targetTouch2.clientX) / 2 - this.left
this.transOriginY = (targetTouch1.clientY + targetTouch2.clientY) / 2 - this.top
複製程式碼
- 動態設定圖片的位置座標
transform-origin
的改變,其實就是改變了圖片的位置狀態,無需關心 transform-origin
到底應該是什麼,直接預設圖片的中心位置就是每次圖片縮放的 transform-origin
,然後在圖片縮放的過程中,動態地修正圖片的位置,抵消 transform-origin
帶來的影響,就可保持視覺上的統一
例如,圖片的預設 transform-origin
是 (100, 100)
,如果以此為中心放大兩倍,那麼結束放大後,圖片的左上角相比於原始狀態向左偏移了 100
個單位,但是現在雙指的起始中心座標是 (0, 0)
(只是個假設,為了方便計算說明),並將此設定為 transform-rogin
的話,放大兩倍後,圖片的左上角相比於原始狀態向左將偏移 0
個單位,也就是沒有任何偏移
所以,當縮放中心是 (0, 0)
時,在不改變 transform-origin
的情況下,要想保持視覺上的統一,必須在圖片放大的過程中,將圖片進行持續地右移,保證在每一幀中移動的距離都能抵消因為 transform-origin
帶來的差距,直到最終移出 100
個單位
因為考慮到後面圖片的位置座標還有其他地方需要用到,而且直接設定 transform-origin
的方式更簡單方便,所以這裡我選擇了第一種方法
but
,很快我就發現,我還是想得太簡單了
假設現在手指離開螢幕後,圖片以 (10, 56)
為縮放中心放大了 2
倍,然後雙指再次放在螢幕上,這個時候雙指的中心座標為 (70, 88)
,這個時候按照上面說的,就需要動態地將 transform-origin
由之前的 (10, 56)
改為 (70, 88)
,但是如果真的改了,你就會發現,圖片立刻產生了跳動
這是因為在第二次雙指觸控螢幕之前,圖片放大兩倍的狀態是基於 transform-origin(10, 56)
,現在改變了 transform-origin
,那就相當於是改變了圖片的放大基點,圖片的狀態必然會改變
難道要換成第二種方法?但是總感覺頻繁地修改 left/top
的值有點不太對勁,而且這種方法的計算方式也比較複雜,擔心影響效能
仔細想了下,也是可以解決的
之所以第二次縮放會產生跳動,就在於是改變了第一次結束後的狀態,因為這個狀態並不是固定的,此時圖片的 scale
和 transform-origin
都是被動態修改過的,只要能把這個狀態給固定下來,固定為預設狀態的值,那不就行了嗎?
至於如何固定這個狀態,其實也是很簡單的
對於一個尺寸為 100*100
的圖片,以 (10, 10)
為 transform-origin
放大 2
倍,則放大後的圖片尺寸為 200*200
,左上角偏移量為 (-10, -10)
SO
在第一次縮放結束後,立即將圖片的寬高設定為 200*200
,並且給個 left: -10; top: -10
的偏移量,然後就可以將 scale
與 transform-origin
恢復到預設狀態了,這個時候的圖片狀態也就相當於是沒有使用任何 transform
屬性
那麼第二次縮放的時候,初始狀態就以當前這個 200*200
的圖片為起始狀態而非是一開始的 100*100
,這樣一來,就無需關心狀態問題了,因為每一次縮放都是一個全新的狀態
直觀示例:
transform: scale(2); => width: 200; height: 200;
transform-origin: 10 10; => left: -10; top: -10;
複製程式碼
程式碼示例:
this.left = left
this.top = top
this.currentW = currentW
this.currentH = currentH
this.scaleValue = 1
複製程式碼
縮放過程中的滑動檢視
單個圖片縮放後,為了允許使用者更自由地檢視圖片的每個細節,允許對縮放後的圖片進行滑動檢視
這個功能的主體邏輯還是比較簡單的,通過監聽 touch
事件,計算得到每一幀間的 move
距離,動態位移圖片位置即可
不過為了更貼近實際的物理互動,達到更好的使用者體驗,新增了一個慣性滑動的能力,即當使用者在滑動圖片的過程中結束觸控時,圖片還會繼續往前滑動一定的距離
這個場景有兩種解決方案
- css 動畫
在觸控結束的瞬間,以當前速度為條件,計算出圖片應該滑動多少距離才停下來,並設定一個速度逐漸降低的 transition
動畫
- js 動態動畫
規定一個速度遞減的係數,每一幀的速度都在前一幀的基礎上,以這個係數為前提進行遞減,直到最後停下來
綜合考慮了一下,第一種的方式可能更加節約效能,但是不太好模擬出那種物理慣性的感覺,數值不太好計算,相比於節約的那一點效能來說,價效比不高
第二種方式更加容易控制,所以選擇了第二種方式
主要是藉助了 requestAnimationFrame
這個 API
(已經對不相容此 API
的裝置做了降級處理):
rafHandler = raf(() => {
speedX *= 0.9
speedY *= 0.9
// ...
if (Math.abs(speedX) < 1) speedX = 0
if (Math.abs(speedY) < 1) speedY = 0
if (speedX !== 0 || speedY !== 0) {
this.frictionMove(speedX, speedY)
} else {
// ...
}
})
複製程式碼
總結
其實這個元件的主體邏輯還是蠻清晰的,沒什麼太多的道道,但是需要考慮的情況太多,而且還有三種不同情況下 touch
事件的互動與判斷,所有的情況綜合在一起還是蠻傷腦筋的,五分之一不到的時間用來寫主體邏輯,剩下的時間全耗在 if...else
上了,等我把這個輪子寫完,我也算是明白為何這個場景的輪子那麼少了,因為真的腦闊疼,不是功能邏輯的疼,功能邏輯寫起來畢竟還有點意思,而是 if...else
的疼