千家信息网

怎么利用SwiftUI实现可缩放的图片预览器

发表于:2025-01-19 作者:千家信息网编辑
千家信息网最后更新 2025年01月19日,这篇文章给大家分享的是有关怎么利用SwiftUI实现可缩放的图片预览器的内容。小编觉得挺实用的,因此分享给大家做个参考,一起跟随小编过来看看吧。实现过程程序的初步构想要做一个程序,首先肯定是给它起个名
千家信息网最后更新 2025年01月19日怎么利用SwiftUI实现可缩放的图片预览器

这篇文章给大家分享的是有关怎么利用SwiftUI实现可缩放的图片预览器的内容。小编觉得挺实用的,因此分享给大家做个参考,一起跟随小编过来看看吧。

    实现过程

    程序的初步构想

    要做一个程序,首先肯定是给它起个名字。既然是图片预览器(Image Previewer),再加上我自己习惯用的前缀 LBJ,就把它命名为 LBJImagePreviewer 吧。

    既然是图片预览器,所以需要外部提供图片给我们;然后是可缩放,所以需要一个最大的缩放倍数。有了这些思考,可以把 LBJImagePreviewer 简单定义为:

    import SwiftUIpublic 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 的大小。这个倍数就是放大的倍数。因此我们定义一个变量记录倍数,然后通过双击手势改变它,就能把图片放大缩小,有变动的代码如下:

    // 当前的放大倍数@Stateprivate var zoomScale: CGFloat = 1public 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 的值@Stateprivate var steadyStateZoomScale: CGFloat = 1// 放大手势缩放过程中产生的倍数变化@GestureStateprivate 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.swiftenum Haptics {  static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) {    let generator = UIImpactFeedbackGenerator(style: style)    generator.impactOccurred()  }}

    到目前为止,我们的图片预览器就实现了。是不是很简单????

    但是仔细回顾一下代码,目前这个图片预览器只支持 UIImage 的预览。如果预览器的用户查看的图片是 Image 呢?又或者是其他任何通过 View 来显示的图片呢?所以我们还得进一步增强预览器的可用性。

    预览任意 View

    既然是任意 View,很容易想到泛型。我们可以将 LBJImagePreviewer 定义为泛型。代码变动如下:

    public struct LBJImagePreviewer: 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(uiImage: UIImage(named: "IMG_0001")!)

    如果不加上 就会报错,这显然是不合理的设计。我们还得进一步优化。

    将 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: 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: - Subviewsprivate extension LBJViewZoomer {  @ViewBuilder  var imageContent: some View {    if let image = contentInfo.content as? Image {      image        .resizable()        .aspectRatio(contentMode: .fit)    } else {      contentInfo.content    }  }}// MARK: - Gesturesprivate 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 Methodsprivate 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

    至此,我们的图片预览器就真正完成了。我们一共给外部暴露了三个类型:

    LBJUIImagePreviewerLBJImagePreviewerLBJViewZoomer

    感谢各位的阅读!关于"怎么利用SwiftUI实现可缩放的图片预览器"这篇文章就分享到这里了,希望以上内容可以对大家有一定的帮助,让大家可以学到更多知识,如果觉得文章不错,可以把它分享出去让更多的人看到吧!

    0