在阅读本文前,你需要一定的 SwiftUI 基础。
在开发中,我们经常会遇到点击图片查看大图的需求。在 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
呢? 点击这里可以找到答案 >>
显示 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
更新以解决这个问题。)