目录
前言
在开发中,我们经常会遇到点击图片查看大图的需求。在 Apple 的推动下,iOS 开发必定会从 UIKit 慢慢向 SwiftUI 转变。为了更好地适应这一趋势,今天我们用 SwiftUI 实现一个可缩放的图片预览器。
实现过程
程序的初步构想
要做一个程序,首先肯定是给它起个名字。既然是图片预览器(Image Previewer),再加上我自己习惯用的前缀 LBJ,就把它命名为 LBJImagePreviewer 吧。
既然是图片预览器,所以需要外部提供图片给我们;然后是可缩放,所以需要一个最大的缩放倍数。有了这些思考,可以把 LBJImagePreviewer 简单定义为:
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 定义如下:
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:
imageContent .frame( width: imageSize.width, height: imageSize.height )
我们通过用 imageSize(fits: geometry) 的返回值乘以一个倍数,就可以改变 frame 的大小。这个倍数就是放大的倍数。因此我们定义一个变量记录倍数,然后通过双击手势改变它,就能把图片放大缩小,有变动的代码如下:
// 当前的放大倍数 @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。代码变动如下:
// 稳定的放大倍数,放大手势以此为基准来改变 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() } }
到目前为止,我们的图片预览器就实现了。是不是很简单?🤣🤣🤣
但是仔细回顾一下代码,目前这个图片预览器只支持 UIImage 的预览。如果预览器的用户查看的图片是 Image 呢?又或者是其他任何通过 View 来显示的图片呢?所以我们还得进一步增强预览器的可用性。
预览任意 View
既然是任意 View,很容易想到泛型。我们可以将 LBJImagePreviewer 定义为泛型。代码变动如下:
public struct LBJImagePreviewer<Content: View>: View { private let uiImage: UIImage? private let contentInfo: (content: Content, aspectRatio: CGFloat)? private let maxScale: CGFloat public init( uiImage: UIImage, maxScale: CGFloat = LBJImagePreviewerConstants.defaultMaxScale ) { self.uiImage = uiImage self.contentInfo = nil self.maxScale = maxScale } public init( content: Content, aspectRatio: CGFloat, maxScale: CGFloat = LBJImagePreviewerConstants.defaultMaxScale ) { self.uiImage = nil self.contentInfo = (content, aspectRatio) self.maxScale = maxScale } @ViewBuilder var imageContent: some View { if let uiImage = uiImage { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fit) } else if let content = contentInfo?.content { if let image = content as? Image { image.resizable() } else { content } } } func imageSize(fits geometry: GeometryProxy) -> CGSize { if let uiImage = uiImage { let hZoom = geometry.size.width / uiImage.size.width let vZoom = geometry.size.height / uiImage.size.height return uiImage.size * min(hZoom, vZoom) } else if let contentInfo = contentInfo { let geoRatio = geometry.size.width / geometry.size.height let imageRatio = contentInfo.aspectRatio let width: CGFloat let height: CGFloat if imageRatio < geoRatio { height = geometry.size.height width = height * imageRatio } else { width = geometry.size.width height = width / imageRatio } return .init(width: width, height: height) } return .zero } }
从代码中可以看到,如果是用 content 来初始化预览器,还需要传入 aspectRatio (宽高比),因为不能从传入的 content 得到它的比例,所以需要外部告诉我们。
通过修改,目前的图片预览器就可以支持任意 View 的缩放了。但如果我们就是要预览 UIImage,在初始化预览器的时候,它还要求指定泛型的具体类型。例如:
// EmptyView 可以换成其他任意遵循 `View` 协议的类型 LBJImagePreviewer<EmptyView>(uiImage: UIImage(named: "IMG_0001")!)
如果不加上 <EmptyView> 就会报错,这显然是不合理的设计。我们还得进一步优化。
将 UIImage 从 LBJImagePreviewer 剥离
在预览 UIImage 时,不需要用到任何与泛型有关的代码,所以只能将 UIImage 从 LBJImagePreviewer 剥离出来。
从复用代码的角度出发,我们可以想到新定义一个 LBJUIImagePreviewer 专门用于预览 UIImage,内部实现直接调用 LBJImagePreviewer 即可。
LBJUIImagePreviewer 的代码如下:
public struct LBJUIImagePreviewer: 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 { // LBJImagePreviewer 重命名为 LBJViewZoomer LBJViewZoomer( content: Image(uiImage: uiImage), aspectRatio: uiImage.size.width / uiImage.size.height, maxScale: maxScale ) } }
将 UIImage 从 LBJImagePreviewer 剥离后,LBJImagePreviewer 的职责只负责缩放 View,所以应该给它重命名,我将它改为 LBJViewZoomer。完整代码如下:
public struct LBJViewZoomer<Content: View>: View { private let contentInfo: (content: Content, aspectRatio: CGFloat) private let maxScale: CGFloat public init( content: Content, aspectRatio: CGFloat, maxScale: CGFloat = LBJImagePreviewerConstants.defaultMaxScale ) { self.contentInfo = (content, aspectRatio) self.maxScale = maxScale } @State private var steadyStateZoomScale: CGFloat = 1 @GestureState private var gestureZoomScale: CGFloat = 1 public var body: some View { GeometryReader { geometry in let zoomedImageSize = zoomedImageSize(in: geometry) ScrollView([.vertical, .horizontal]) { imageContent .gesture(doubleTapGesture()) .gesture(zoomGesture()) .frame( width: zoomedImageSize.width, height: zoomedImageSize.height ) .padding(.vertical, (max(0, geometry.size.height - zoomedImageSize.height) / 2)) } .background(Color.black) } .ignoresSafeArea() } } // MARK: - Subviews private extension LBJViewZoomer { @ViewBuilder var imageContent: some View { if let image = contentInfo.content as? Image { image .resizable() .aspectRatio(contentMode: .fit) } else { contentInfo.content } } } // MARK: - Gestures private extension LBJViewZoomer { // MARK: Tap func doubleTapGesture() -> some Gesture { TapGesture(count: 2) .onEnded { withAnimation { if zoomScale > 1 { steadyStateZoomScale = 1 } else { steadyStateZoomScale = maxScale } } } } // MARK: Zoom var zoomScale: CGFloat { steadyStateZoomScale * gestureZoomScale } func zoomGesture() -> some Gesture { MagnificationGesture() .updating($gestureZoomScale) { latestGestureScale, gestureZoomScale, _ in gestureZoomScale = latestGestureScale } .onEnded { gestureScaleAtEnd in steadyStateZoomScale *= gestureScaleAtEnd makeSureZoomScaleInBounds() } } func makeSureZoomScaleInBounds() { withAnimation { if steadyStateZoomScale < 1 { steadyStateZoomScale = 1 Haptics.impact(.light) } else if steadyStateZoomScale > maxScale { steadyStateZoomScale = maxScale Haptics.impact(.light) } } } } // MARK: - Helper Methods private extension LBJViewZoomer { func imageSize(fits geometry: GeometryProxy) -> CGSize { let geoRatio = geometry.size.width / geometry.size.height let imageRatio = contentInfo.aspectRatio let width: CGFloat let height: CGFloat if imageRatio < geoRatio { height = geometry.size.height width = height * imageRatio } else { width = geometry.size.width height = width / imageRatio } return .init(width: width, height: height) } func zoomedImageSize(in geometry: GeometryProxy) -> CGSize { imageSize(fits: geometry) * zoomScale } }
另外,为了方便预览 Image 类型的图片,我们可以定义一个类型:
public typealias LBJImagePreviewer = LBJViewZoomer<Image>
至此,我们的图片预览器就真正完成了。我们一共给外部暴露了三个类型:
LBJUIImagePreviewer LBJImagePreviewer LBJViewZoomer
源码
我已经将图片预览器制作成一个 Swift Package,大家可以点击查看。LBJImagePreviewer
在源码中,我在 LBJViewZoomer 多添加了一个属性 doubleTapScale,表示双击放大时的倍数,进一步优化用户使用体验。
总结
这个图片预览器的实现难度并不高,关键点在于对 ScrollView 和放大手势的理解。
存在问题
双击放大时,图片只能从中间位置放大,无法在点击位置放大。(目前 ScrollView 无法手动设置 contentOffset,等待 ScrollView 更新以解决这个问题。)