当前位置 博文首页 > 文章内容

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

    作者:21344 栏目:未分类 时间:2021-09-13 14:44:17

    前言

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