溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點(diǎn)擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

怎么利用SwiftUI實(shí)現(xiàn)可縮放的圖片預(yù)覽器

發(fā)布時(shí)間:2021-09-13 15:16:25 來源:億速云 閱讀:264 作者:小新 欄目:開發(fā)技術(shù)

這篇文章給大家分享的是有關(guān)怎么利用SwiftUI實(shí)現(xiàn)可縮放的圖片預(yù)覽器的內(nèi)容。小編覺得挺實(shí)用的,因此分享給大家做個(gè)參考,一起跟隨小編過來看看吧。

    實(shí)現(xiàn)過程

    程序的初步構(gòu)想

    要做一個(gè)程序,首先肯定是給它起個(gè)名字。既然是圖片預(yù)覽器(Image Previewer),再加上我自己習(xí)慣用的前綴 LBJ,就把它命名為 LBJImagePreviewer 吧。

    既然是圖片預(yù)覽器,所以需要外部提供圖片給我們;然后是可縮放,所以需要一個(gè)最大的縮放倍數(shù)。有了這些思考,可以把 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 設(shè)置了一個(gè)默認(rèn)值。

    另外還可以看到 maxScale 的默認(rèn)值是通過 LBJImagePreviewerConstants.defaultMaxScale 來設(shè)置的,而不是直接寫 16,這樣做的目的是把代碼中用到的數(shù)值和經(jīng)驗(yàn)值等整理到一個(gè)地方,方便后續(xù)的修改。這是一個(gè)好的編程習(xí)慣。

    細(xì)心的讀者可能還會(huì)注意到 LBJImagePreviewerConstants 是一個(gè) enum 類型。為什么不用 struct 或者 class 呢? 點(diǎn)擊這里可以找到答案 >>

    顯示 UIImage

    當(dāng)用戶點(diǎn)開圖片預(yù)覽器,當(dāng)然是希望圖片等比例占據(jù)整個(gè)圖片預(yù)覽器,所以需要知道圖片預(yù)覽器當(dāng)前的尺寸和圖片尺寸,從而通過計(jì)算讓圖片等比例占據(jù)整個(gè)圖片預(yù)覽器。

    圖片預(yù)覽器當(dāng)前的尺寸可以通過 GeometryReader 得到;圖片大小可以直接從 UIImage 得到。所以我們可以把

    LBJImagePreviewer 的 body 定義如下:

    public struct LBJImagePreviewer: View {
      public var body: some View {
        GeometryReader { geometry in                  // 用于獲取圖片預(yù)覽器所占據(jù)的尺寸
          let imageSize = imageSize(fits: geometry)   // 計(jì)算圖片等比例鋪滿整個(gè)預(yù)覽器時(shí)的尺寸
          ScrollView([.vertical, .horizontal]) {
            imageContent
              .frame(
                width: imageSize.width,
                height: imageSize.height
              )
              .padding(.vertical, (max(0, geometry.size.height - imageSize.height) / 2))  // 讓圖片在預(yù)覽器垂直方向上居中
          }
          .background(Color.black)
        }
        .ignoresSafeArea()
      }
    }
    
    private extension LBJImagePreviewer {
      var imageContent: some View {
        Image(uiImage: uiImage)
          .resizable()
          .aspectRatio(contentMode: .fit)
      }
    
      /// 計(jì)算圖片等比例鋪滿整個(gè)預(yù)覽器時(shí)的尺寸
      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 的內(nèi)容可以滾動(dòng)起來,必須要讓它的尺寸大于 ScrollView 的尺寸。沿著這個(gè)思路可以想到,我們可修改 imageContent 的大小來實(shí)現(xiàn)放大縮小,也就是修改下面這個(gè) frame:

    imageContent
      .frame(
        width: imageSize.width,
        height: imageSize.height
      )

    我們通過用 imageSize(fits: geometry) 的返回值乘以一個(gè)倍數(shù),就可以改變 frame 的大小。這個(gè)倍數(shù)就是放大的倍數(shù)。因此我們定義一個(gè)變量記錄倍數(shù),然后通過雙擊手勢改變它,就能把圖片放大縮小,有變動(dòng)的代碼如下:

    // 當(dāng)前的放大倍數(shù)
    @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
            }
          }
        }
    }
    
    // 縮放時(shí)圖片的大小
    func zoomedImageSize(fits geometry: GeometryProxy) -> CGSize {
      imageSize(fits: geometry) * zoomScale
    }

    放大手勢縮放

    放大手勢縮放的原理與雙擊一樣,都是想辦法通過修改 zoomScale 來達(dá)到縮放圖片的目的。SwiftUI 中的放大手勢是 MagnificationGesture。代碼變動(dòng)如下:

    // 穩(wěn)定的放大倍數(shù),放大手勢以此為基準(zhǔn)來改變 zoomScale 的值
    @State
    private var steadyStateZoomScale: CGFloat = 1
    
    // 放大手勢縮放過程中產(chǎn)生的倍數(shù)變化
    @GestureState
    private var gestureZoomScale: CGFloat = 1
    
    // 變成了只讀屬性,當(dāng)前圖片被放大的倍數(shù)
    var zoomScale: CGFloat {
      steadyStateZoomScale * gestureZoomScale
    }
    
    func zoomGesture() -> some Gesture {
      MagnificationGesture()
        .updating($gestureZoomScale) { latestGestureScale, gestureZoomScale, _ in
          // 縮放過程中,不斷地更新 `gestureZoomScale` 的值
          gestureZoomScale = latestGestureScale
        }
        .onEnded { gestureScaleAtEnd in
          // 手勢結(jié)束,更新 steadyStateZoomScale 的值;
          // 此時(shí) gestureZoomScale 的值會(huì)被重置為初始值 1
          steadyStateZoomScale *= gestureScaleAtEnd
          makeSureZoomScaleInBounds()
        }
    }
    
    // 確保放大倍數(shù)在我們設(shè)置的范圍內(nèi);Haptics 是加上震動(dòng)效果
    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()
      }
    }

    到目前為止,我們的圖片預(yù)覽器就實(shí)現(xiàn)了。是不是很簡單????

    但是仔細(xì)回顧一下代碼,目前這個(gè)圖片預(yù)覽器只支持 UIImage 的預(yù)覽。如果預(yù)覽器的用戶查看的圖片是 Image 呢?又或者是其他任何通過 View 來顯示的圖片呢?所以我們還得進(jìn)一步增強(qiáng)預(yù)覽器的可用性。

    預(yù)覽任意 View

    既然是任意 View,很容易想到泛型。我們可以將 LBJImagePreviewer 定義為泛型。代碼變動(dòng)如下:

    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 來初始化預(yù)覽器,還需要傳入 aspectRatio (寬高比),因?yàn)椴荒軓膫魅氲?content 得到它的比例,所以需要外部告訴我們。

    通過修改,目前的圖片預(yù)覽器就可以支持任意 View 的縮放了。但如果我們就是要預(yù)覽 UIImage,在初始化預(yù)覽器的時(shí)候,它還要求指定泛型的具體類型。例如:

    // EmptyView 可以換成其他任意遵循 `View` 協(xié)議的類型
    LBJImagePreviewer<EmptyView>(uiImage: UIImage(named: "IMG_0001")!)

    如果不加上 <EmptyView> 就會(huì)報(bào)錯(cuò),這顯然是不合理的設(shè)計(jì)。我們還得進(jìn)一步優(yōu)化。

    將 UIImage 從 LBJImagePreviewer 剝離

    在預(yù)覽 UIImage 時(shí),不需要用到任何與泛型有關(guān)的代碼,所以只能將 UIImage 從 LBJImagePreviewer 剝離出來。

    從復(fù)用代碼的角度出發(fā),我們可以想到新定義一個(gè) LBJUIImagePreviewer 專門用于預(yù)覽 UIImage,內(nèi)部實(shí)現(xiàn)直接調(diào)用 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 的職責(zé)只負(fù)責(zé)縮放 View,所以應(yīng)該給它重命名,我將它改為 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
      }
    }

    另外,為了方便預(yù)覽 Image 類型的圖片,我們可以定義一個(gè)類型:

    public typealias LBJImagePreviewer = LBJViewZoomer<Image>

    至此,我們的圖片預(yù)覽器就真正完成了。我們一共給外部暴露了三個(gè)類型:

    LBJUIImagePreviewer
    LBJImagePreviewer
    LBJViewZoomer

    感謝各位的閱讀!關(guān)于“怎么利用SwiftUI實(shí)現(xiàn)可縮放的圖片預(yù)覽器”這篇文章就分享到這里了,希望以上內(nèi)容可以對(duì)大家有一定的幫助,讓大家可以學(xué)到更多知識(shí),如果覺得文章不錯(cuò),可以把它分享出去讓更多的人看到吧!

    向AI問一下細(xì)節(jié)

    免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

    AI