首页 > 编程技术 > Swift

如何利用SwiftUI实现可缩放的图片预览器

发布时间:2021-9-13 16:00

前言

在开发中,我们经常会遇到点击图片查看大图的需求。在 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 更新以解决这个问题。)

到此这篇关于如何利用SwiftUI实现可缩放图片预览器的文章就介绍到这了,更多相关SwiftUI可缩放图片预览器内容请搜索猪先飞以前的文章或继续浏览下面的相关文章希望大家以后多多支持猪先飞!

标签:[!--infotagslink--]

您可能感兴趣的文章: