如何利用swiftui实现可缩放的图片预览器-kb88凯时官网登录

来自:网络
时间:2023-07-25
阅读:
目录

前言

在开发中,我们经常会遇到点击图片查看大图的需求。在 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: 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: - 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

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

lbjuiimagepreviewer
lbjimagepreviewer
lbjviewzoomer

源码

我已经将图片预览器制作成一个 swift package,大家可以点击查看。

在源码中,我在 lbjviewzoomer 多添加了一个属性 doubletapscale,表示双击放大时的倍数,进一步优化用户使用体验。

总结

这个图片预览器的实现难度并不高,关键点在于对 scrollview 和放大手势的理解。
存在问题

双击放大时,图片只能从中间位置放大,无法在点击位置放大。(目前 scrollview 无法手动设置 contentoffset,等待 scrollview 更新以解决这个问题。)

返回顶部
顶部
网站地图