Preface
During development, we often encounter the need to click on the image to view large images. Driven by Apple, iOS development will surely slowly transform from UIKit to SwiftUI. To better adapt to this trend, today we use SwiftUI to implement a scalable image previewer.
Implementation process
Preliminary concept of the program
To make a program, the first thing to do is to give it a name. Since it is an image previewer (Image Previewer), plus the prefix LBJ that I am used to, I will name it LBJImagePreviewer.
Since it is an image previewer, we need to provide external images to us; then it is scalable, so we need a maximum scaling multiple. With these thoughts, LBJImagePreviewer can be simply defined as:
import SwiftUI public struct LBJImagePreviewer: View { private let uiImage: UIImage private let maxScale: CGFloat public init(uiImage: UIImage, maxScale: CGFloat = ) { = uiImage = maxScale } public var body: some View { EmptyView() } } public enum LBJImagePreviewerConstants { public static let defaultMaxScale: CGFloat = 16 }
In the above code, a default value is set to maxScale.
In addition, you can also see that the default value of maxScale is set through , rather than writing 16 directly. The purpose of this is to organize the numerical and empirical values used in the code into one place to facilitate subsequent modifications. This is a good programming habit.
Attentive readers may also notice that LBJImagePreviewerConstants is an enum type. Why not use struct or class?
Defining static methods in Swift, how to choose between class/struct/enum?
During the development process, we often encounter the need to define some static methods. Usually we think of using class and struct to define it, but we ignore that enum can also have static methods. So the question is: Since all three can define static methods, how should we choose?
The answer is given directly below:
- class: class is a reference type and supports inheritance. If you need these two features, then choose class.
- struct: struct is a value type and does not support inheritance. If you need a value type and sometimes an instance of this type is needed, use struct.
- enum: enum is also a value type, which is generally used to define a set of related values. If the static method we want is a series of tools that do not require any instantiation and inheritance, then using enum is the most appropriate.
In addition, this rule actually applies to static variables.
Show UIImage
When the user clicks on the image previewer, of course, he hopes that the image will occupy the entire image previewer in proportion, so he needs to know the current size and image size of the image previewer, so that the image will occupy the entire image previewer in proportion through calculation.
The current size of the image previewer can be obtained through GeometryReader; the image size can be obtained directly from UIImage. So we can
The body definition of LBJImagePreviewer is as follows:
public struct LBJImagePreviewer: View { public var body: some View { GeometryReader { geometry in // Used to obtain the size occupied by the image previewer let imageSize = imageSize(fits: geometry) // Calculate the size of the picture when it fills the entire previewer in equal proportions ScrollView([.vertical, .horizontal]) { imageContent .frame( width: , height: ) .padding(.vertical, (max(0, - ) / 2)) // Let the image center in the vertical direction of the previewer } .background() } .ignoresSafeArea() } } private extension LBJImagePreviewer { var imageContent: some View { Image(uiImage: uiImage) .resizable() .aspectRatio(contentMode: .fit) } /// Calculate the size of the picture when it fills the entire previewer in equal proportions func imageSize(fits geometry: GeometryProxy) -> CGSize { let hZoom = / let vZoom = / return * min(hZoom, vZoom) } } extension CGSize { /// CGSize multiplied by CGFloat static func * (lhs: Self, rhs: CGFloat) -> CGSize { CGSize(width: * rhs, height: * rhs) } }
In this way, we display the image with ScrollView.
Double-click to zoom
If you want the content of the ScrollView to be scrolled, you must make its size larger than the size of the ScrollView. Following this idea, we can imagine that we can modify the size of imageContent to achieve zoom in and out, that is, modify the following frame:
imageContent .frame( width: , height: )
We can change the size of the frame by multiplying the return value of imageSize(fits: geometry) by a multiple. This multiple is the magnification multiple. Therefore, we define a variable record multiple, and then change it through the double-click gesture to enlarge the image. The code with changes is as follows:
// Current magnification@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: , height: ) .padding(.vertical, (max(0, - ) / 2)) } .background() } .ignoresSafeArea() } // Double-tap gesturefunc doubleTapGesture() -> some Gesture { TapGesture(count: 2) .onEnded { withAnimation { if zoomScale > 1 { zoomScale = 1 } else { zoomScale = maxScale } } } } // The size of the image when zoomingfunc zoomedImageSize(fits geometry: GeometryProxy) -> CGSize { imageSize(fits: geometry) * zoomScale }
Zoom in gesture
The principle of zooming in gestures is the same as double-clicking, and it is to find ways to zoom the picture by modifying zoomScale. The magnification gesture in SwiftUI is MagnificationGesture. The code changes are as follows:
// Stable magnification, the magnification gesture uses this as a reference to change the value of zoomScale@State private var steadyStateZoomScale: CGFloat = 1 // Multiple changes generated during zooming gesture@GestureState private var gestureZoomScale: CGFloat = 1 // It becomes a read-only attribute, the multiple of the current image being enlargedvar zoomScale: CGFloat { steadyStateZoomScale * gestureZoomScale } func zoomGesture() -> some Gesture { MagnificationGesture() .updating($gestureZoomScale) { latestGestureScale, gestureZoomScale, _ in // During the scaling process, the value of `gestureZoomScale` is constantly updated gestureZoomScale = latestGestureScale } .onEnded { gestureScaleAtEnd in // The gesture ends, update the value of steadyStateZoomScale; // At this time, the value of the gestureZoomScale will be reset to the initial value 1 steadyStateZoomScale *= gestureScaleAtEnd makeSureZoomScaleInBounds() } } // Make sure the magnification is within the range we set; Haptics adds vibration effectfunc makeSureZoomScaleInBounds() { withAnimation { if steadyStateZoomScale < 1 { steadyStateZoomScale = 1 (.light) } else if steadyStateZoomScale > maxScale { steadyStateZoomScale = maxScale (.light) } } } // enum Haptics { static func impact(_ style: ) { let generator = UIImpactFeedbackGenerator(style: style) () } }
So far, our image previewer has been implemented. Isn't it very simple? 🤣🤣🤣
But if you look back carefully, this image previewer currently only supports preview of UIImage. What if the image viewed by the previewer user is Image? Or is it any other picture displayed through a View? So we have to further enhance the usability of the previewer.
Preview any View
Since it is an arbitrary view, it is easy to think of generics. We can define LBJImagePreviewer as a generic. The code changes are as follows:
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 = ) { = uiImage = nil = maxScale } public init( content: Content, aspectRatio: CGFloat, maxScale: CGFloat = ) { = nil = (content, aspectRatio) = 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 { () } else { content } } } func imageSize(fits geometry: GeometryProxy) -> CGSize { if let uiImage = uiImage { let hZoom = / let vZoom = / return * min(hZoom, vZoom) } else if let contentInfo = contentInfo { let geoRatio = / let imageRatio = let width: CGFloat let height: CGFloat if imageRatio < geoRatio { height = width = height * imageRatio } else { width = height = width / imageRatio } return .init(width: width, height: height) } return .zero } }
As you can see from the code, if you use content to initialize the previewer, you also need to pass in aspectRatio (aspect ratio), because you cannot get its proportion from the passed content, so you need to tell us externally.
Through modification, the current image previewer can support scaling of any view. But if we just want to preview the UIImage, when initializing the previewer, it also requires specifying the specific type of the generic. For example:
// EmptyView can be replaced with any other type that follows the `View` protocolLBJImagePreviewer<EmptyView>(uiImage: UIImage(named: "IMG_0001")!)
If <EmptyView> is not added, an error will be reported, which is obviously an unreasonable design. We need to further optimize.
Strip UIImage from LBJImagePreviewer
When previewing UIImage, you don't need to use any generic-related code, so you can only strip the UIImage from the LBJImagePreviewer.
From the perspective of reusing code, we can think of defining a new LBJUIImagePreviewer specifically for previewing UIImages, and the internal implementation can directly call LBJImagePreviewer.
The code of LBJUIImagePreviewer is as follows:
public struct LBJUIImagePreviewer: View { private let uiImage: UIImage private let maxScale: CGFloat public init( uiImage: UIImage, maxScale: CGFloat = ) { = uiImage = maxScale } public var body: some View { // LBJImagePreviewer renamed to LBJViewZoomer LBJViewZoomer( content: Image(uiImage: uiImage), aspectRatio: / , maxScale: maxScale ) } }
After stripping the UIImage from the LBJImagePreviewer, the LBJImagePreviewer's responsibilities are only responsible for scaling the View, so it should be renamed, I changed it to LBJViewZoomer. The complete code is as follows:
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 = ) { = (content, aspectRatio) = 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: , height: ) .padding(.vertical, (max(0, - ) / 2)) } .background() } .ignoresSafeArea() } } // MARK: - Subviews private extension LBJViewZoomer { @ViewBuilder var imageContent: some View { if let image = as? Image { image .resizable() .aspectRatio(contentMode: .fit) } else { } } } // 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 (.light) } else if steadyStateZoomScale > maxScale { steadyStateZoomScale = maxScale (.light) } } } } // MARK: - Helper Methods private extension LBJViewZoomer { func imageSize(fits geometry: GeometryProxy) -> CGSize { let geoRatio = / let imageRatio = let width: CGFloat let height: CGFloat if imageRatio < geoRatio { height = width = height * imageRatio } else { width = height = width / imageRatio } return .init(width: width, height: height) } func zoomedImageSize(in geometry: GeometryProxy) -> CGSize { imageSize(fits: geometry) * zoomScale } }
In addition, in order to facilitate previewing images of Image type, we can define a type:
public typealias LBJImagePreviewer = LBJViewZoomer<Image>
At this point, our image previewer is truly finished. We have exposed three types to the outside world:
LBJUIImagePreviewer LBJImagePreviewer LBJViewZoomer
Source code
I have made the image previewer into a Swift Package, which you can click to view.LBJImagePreviewer
In the source code, I added an additional attribute doubleTapScale to LBJViewZoomer, indicating the multiple when double-clicking to enlarge, further optimizing the user experience.
Summarize
The implementation of this image previewer is not difficult, and the key point is to understand ScrollView and magnifying gestures.
There are problems
When double-click to enlarge, the image can only be enlarged from the middle position and cannot be enlarged at the click position. (At present, ScrollView cannot manually set contentOffset, wait for ScrollView to update to resolve this issue.)
This is the article about how to use SwiftUI to implement a scalable image previewer. For more related contents of SwiftUI scalable image previewer, please search for my previous articles or continue browsing the related articles below. I hope everyone will support me in the future!