造輪子之圖片預覽元件(preview)

清夜發表於2019-01-08

圖片放大預覽是種很常見的場景和功能,一般移動網站首頁的輪播 banner,商品詳情頁的商品圖片等位置都會用到此功能

像這種常用的場景功能肯定是有人早就寫好外掛了的,所以遇到這種場景,一般都遵循以下三步:

  • 開啟冰箱 啟動 Github
  • 搜尋 photopreviewcarouselphotoSwipe等關鍵字
  • 找到想要的庫,npm install

這種做法沒毛病,有現成的輪子可用當然拿來主義,因為專案用的是 vue,所以我在網上找了一圈 基於 vue的放大預覽元件庫,結果令我有點意外,圖片放大預覽的庫的數量明顯比不上輪播元件庫,並且更令人 智熄 的是,這些少得可憐的元件庫中,其中一大半都是基於 PhotoSwipe 這個開源庫進行的二次封裝,除此之外,能用於實際生產的預覽元件庫(image gallery)……好像沒有(也可能是我見識短淺),這種情況不僅體現在 vue庫上,其他框架乃至是原生的相關庫都是如此

雖說不提倡重複造輪子,但輪子太少沒有選擇的餘地也有點說不過去, PhotoSwipe 用起來很順手,功能也很齊全,足以應對實際生產環境中的絕大部分場景

但與此同時,也就代表它程式碼體積會比較大,引入的冗餘程式碼會比較多,於是,抱著精簡程式碼以及順便豐富放大預覽外掛家族的想法,決定自己造個輪子

先看下最終實現效果:

造輪子之圖片預覽元件(preview)

或者你想自己體驗一下,這裡也有個寫好的 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個單位

造輪子之圖片預覽元件(preview)

因為考慮到後面圖片的位置座標還有其他地方需要用到,而且直接設定 transform-origin的方式更簡單方便,所以這裡我選擇了第一種方法

but,很快我就發現,我還是想得太簡單了

假設現在手指離開螢幕後,圖片以 (10, 56)為縮放中心放大了 2倍,然後雙指再次放在螢幕上,這個時候雙指的中心座標為 (70, 88),這個時候按照上面說的,就需要動態地將 transform-origin 由之前的 (10, 56) 改為 (70, 88),但是如果真的改了,你就會發現,圖片立刻產生了跳動

這是因為在第二次雙指觸控螢幕之前,圖片放大兩倍的狀態是基於 transform-origin(10, 56),現在改變了 transform-origin,那就相當於是改變了圖片的放大基點,圖片的狀態必然會改變

難道要換成第二種方法?但是總感覺頻繁地修改 left/top的值有點不太對勁,而且這種方法的計算方式也比較複雜,擔心影響效能

仔細想了下,也是可以解決的

之所以第二次縮放會產生跳動,就在於是改變了第一次結束後的狀態,因為這個狀態並不是固定的,此時圖片的 scaletransform-origin都是被動態修改過的,只要能把這個狀態給固定下來,固定為預設狀態的值,那不就行了嗎?

至於如何固定這個狀態,其實也是很簡單的

對於一個尺寸為 100*100的圖片,以 (10, 10)transform-origin放大 2倍,則放大後的圖片尺寸為 200*200,左上角偏移量為 (-10, -10) SO 在第一次縮放結束後,立即將圖片的寬高設定為 200*200,並且給個 left: -10; top: -10的偏移量,然後就可以將 scaletransform-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的疼

原始碼已經放到 github上了,程式碼註釋得也算是比較詳細,感興趣的可以參考下,如果有什麼問題,歡迎提 issues

相關文章