如何利用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 呢?

在 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 更新以解决这个问题。)

关于如何利用SwiftUI实现可缩放图片预览器的文章就介绍至此,更多相关SwiftUI可缩放图片预览器内容请搜索编程宝库以前的文章,希望大家多多支持编程宝库

拿来即用短时间效率虽然挺高的,但是拿来的东西没有消化一次,就无法得心应手的使用它。这次的探索思路就是,查询官方文档,设置不同的值测试单个方法中参数的变化,之后测试两个方法的执行顺序,处理的思路, ...