前言
在開發中,我們經常會遇到點擊圖片查看大圖的需求。在 Apple 的推動下,iOS 開發必定會從 UIKit 慢慢向 SwiftUI 轉變。為了更好地適應這一趨勢,今天我們用 SwiftUI 實現一個可縮放的圖片預覽器。
實現過程
程序的初步構想
要做一個程序,首先肯定是給它起個名字。既然是圖片預覽器(Image Previewer),再加上我自己習慣用的前綴 LBJ,就把它命名為 LBJImagePreviewer 吧。
既然是圖片預覽器,所以需要外部提供圖片給我們;然后是可縮放,所以需要一個最大的縮放倍數。有了這些思考,可以把 LBJImagePreviewer 簡單定義為:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
import SwiftUI public struct LBJImagePreviewer: View { private let uiImage: UIImage private let maxScale: CGFloat public init(uiImage: UIImage, maxScale: CGFloat = LBJImagePreviewerConstants.defaultMaxScale) { self.uiImage = uiImage self.maxScale = maxScale } public var body: some View { EmptyView() } } public enum LBJImagePreviewerConstants { public static let defaultMaxScale: CGFloat = 16 } |
在上面代碼中,給 maxScale 設置了一個默認值。
另外還可以看到 maxScale 的默認值是通過 LBJImagePreviewerConstants.defaultMaxScale 來設置的,而不是直接寫 16,這樣做的目的是把代碼中用到的數值和經驗值等整理到一個地方,方便后續的修改。這是一個好的編程習慣。
細心的讀者可能還會注意到 LBJImagePreviewerConstants 是一個 enum 類型。為什么不用 struct 或者 class 呢?
在 Swift 中定義靜態方法,class / struct / enum 三者如何選擇?
在開發過程中,我們經常會遇到需要定義一些靜態方法的需求。通常我們會想到用 class 和 struct 去定義,然而卻忽略了 enum 也可以擁有靜態方法。那么問題來了:既然三者都可以定義靜態方法,那么我們應該如何選擇?
下面直接給出答案:
- class:class 是引用類型,支持繼承。如果你需要這兩個特性,那么選擇 class。
- struct:struct 是值類型,不支持繼承。如果你需要值類型,并且某些時候需要這個類型的實例,那么用 struct。
- enum:enum 也是值類型,一般用來定義一組相關的值。如果我們想要的靜態方法是一系列的工具,不需要任何的實例化和繼承,那么用 enum 最合適。
另外,其實這個規則也適用于靜態變量。
顯示 UIImage
當用戶點開圖片預覽器,當然是希望圖片等比例占據整個圖片預覽器,所以需要知道圖片預覽器當前的尺寸和圖片尺寸,從而通過計算讓圖片等比例占據整個圖片預覽器。
圖片預覽器當前的尺寸可以通過 GeometryReader 得到;圖片大小可以直接從 UIImage 得到。所以我們可以把
LBJImagePreviewer 的 body 定義如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
public struct LBJImagePreviewer: View { public var body: some View { GeometryReader { geometry in // 用于獲取圖片預覽器所占據的尺寸 let imageSize = imageSize(fits: geometry) // 計算圖片等比例鋪滿整個預覽器時的尺寸 ScrollView([.vertical, .horizontal]) { imageContent .frame( width: imageSize.width, height: imageSize.height ) .padding(.vertical, (max(0, geometry.size.height - imageSize.height) / 2)) // 讓圖片在預覽器垂直方向上居中 } .background(Color.black) } .ignoresSafeArea() } } private extension LBJImagePreviewer { var imageContent: some View { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fit) } /// 計算圖片等比例鋪滿整個預覽器時的尺寸 func imageSize(fits geometry: GeometryProxy) -> CGSize { let hZoom = geometry.size.width / uiImage.size.width let vZoom = geometry.size.height / uiImage.size.height return uiImage.size * min(hZoom, vZoom) } } extension CGSize { /// CGSize 乘以 CGFloat static func * (lhs: Self, rhs: CGFloat) -> CGSize { CGSize(width: lhs.width * rhs, height: lhs.height * rhs) } } |
這樣我們就把圖片用 ScrollView 顯示出來了。
雙擊縮放
想要 ScrollView 的內容可以滾動起來,必須要讓它的尺寸大于 ScrollView 的尺寸。沿著這個思路可以想到,我們可修改 imageContent 的大小來實現放大縮小,也就是修改下面這個 frame:
1
2
3
4
5
|
imageContent .frame( width: imageSize.width, height: imageSize.height ) |
我們通過用 imageSize(fits: geometry) 的返回值乘以一個倍數,就可以改變 frame 的大小。這個倍數就是放大的倍數。因此我們定義一個變量記錄倍數,然后通過雙擊手勢改變它,就能把圖片放大縮小,有變動的代碼如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
// 當前的放大倍數 @State private var zoomScale: CGFloat = 1 public var body: some View { GeometryReader { geometry in let zoomedImageSize = zoomedImageSize(fits: geometry) ScrollView([.vertical, .horizontal]) { imageContent .gesture(doubleTapGesture()) .frame( width: zoomedImageSize.width, height: zoomedImageSize.height ) .padding(.vertical, (max(0, geometry.size.height - zoomedImageSize.height) / 2)) } .background(Color.black) } .ignoresSafeArea() } // 雙擊手勢 func doubleTapGesture() -> some Gesture { TapGesture(count: 2) .onEnded { withAnimation { if zoomScale > 1 { zoomScale = 1 } else { zoomScale = maxScale } } } } // 縮放時圖片的大小 func zoomedImageSize(fits geometry: GeometryProxy) -> CGSize { imageSize(fits: geometry) * zoomScale } |
放大手勢縮放
放大手勢縮放的原理與雙擊一樣,都是想辦法通過修改 zoomScale 來達到縮放圖片的目的。SwiftUI 中的放大手勢是 MagnificationGesture。代碼變動如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
// 穩定的放大倍數,放大手勢以此為基準來改變 zoomScale 的值 @State private var steadyStateZoomScale: CGFloat = 1 // 放大手勢縮放過程中產生的倍數變化 @GestureState private var gestureZoomScale: CGFloat = 1 // 變成了只讀屬性,當前圖片被放大的倍數 var zoomScale: CGFloat { steadyStateZoomScale * gestureZoomScale } func zoomGesture() -> some Gesture { MagnificationGesture() .updating($gestureZoomScale) { latestGestureScale, gestureZoomScale, _ in // 縮放過程中,不斷地更新 `gestureZoomScale` 的值 gestureZoomScale = latestGestureScale } .onEnded { gestureScaleAtEnd in // 手勢結束,更新 steadyStateZoomScale 的值; // 此時 gestureZoomScale 的值會被重置為初始值 1 steadyStateZoomScale *= gestureScaleAtEnd makeSureZoomScaleInBounds() } } // 確保放大倍數在我們設置的范圍內;Haptics 是加上震動效果 func makeSureZoomScaleInBounds() { withAnimation { if steadyStateZoomScale < 1 { steadyStateZoomScale = 1 Haptics.impact(.light) } else if steadyStateZoomScale > maxScale { steadyStateZoomScale = maxScale Haptics.impact(.light) } } } // Haptics.swift enum Haptics { static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { let generator = UIImpactFeedbackGenerator(style: style) generator.impactOccurred() } } |
到目前為止,我們的圖片預覽器就實現了。是不是很簡單?