Isn't there an easy way to pinch to zoom in an image in Swiftui?
The SwiftUI API is pretty unhelpful here: the onChanged gives number relative to start of current zoom gesture and no obvious way within a callback to get the initial value. And there is an onEnded callback but easy to miss/forget.
A work around, add:
@State var lastScaleValue: CGFloat = 1.0
Then in the callback:
.gesture(MagnificationGesture().onChanged { val in
let delta = val / self.lastScaleValue
self.lastScaleValue = val
let newScale = self.scale * delta
//... anything else e.g. clamping the newScale
}.onEnded { val in
// without this the next gesture will be broken
self.lastScaleValue = 1.0
}
where newScale is your own tracking of scale (perhaps state or a binding). If you set your scale directly it will get messed up as on each tick the amount will be relative to previous amount.
Here's one way of adding pinch zooming to a SwiftUI view. It overlays a UIView
with a UIPinchGestureRecognizer
in a UIViewRepresentable
, and forwards the relevant values back to SwiftUI with bindings.
You can add the behaviour like this:
Image("Zoom")
.pinchToZoom()
This adds behaviour similar to zooming photos in the Instagram feed. Here's the full code:
import UIKit
import SwiftUI
class PinchZoomView: UIView {
weak var delegate: PinchZoomViewDelgate?
private(set) var scale: CGFloat = 0 {
didSet {
delegate?.pinchZoomView(self, didChangeScale: scale)
}
}
private(set) var anchor: UnitPoint = .center {
didSet {
delegate?.pinchZoomView(self, didChangeAnchor: anchor)
}
}
private(set) var offset: CGSize = .zero {
didSet {
delegate?.pinchZoomView(self, didChangeOffset: offset)
}
}
private(set) var isPinching: Bool = false {
didSet {
delegate?.pinchZoomView(self, didChangePinching: isPinching)
}
}
private var startLocation: CGPoint = .zero
private var location: CGPoint = .zero
private var numberOfTouches: Int = 0
init() {
super.init(frame: .zero)
let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(pinch(gesture:)))
pinchGesture.cancelsTouchesInView = false
addGestureRecognizer(pinchGesture)
}
required init?(coder: NSCoder) {
fatalError()
}
@objc private func pinch(gesture: UIPinchGestureRecognizer) {
switch gesture.state {
case .began:
isPinching = true
startLocation = gesture.location(in: self)
anchor = UnitPoint(x: startLocation.x / bounds.width, y: startLocation.y / bounds.height)
numberOfTouches = gesture.numberOfTouches
case .changed:
if gesture.numberOfTouches != numberOfTouches {
// If the number of fingers being used changes, the start location needs to be adjusted to avoid jumping.
let newLocation = gesture.location(in: self)
let jumpDifference = CGSize(width: newLocation.x - location.x, height: newLocation.y - location.y)
startLocation = CGPoint(x: startLocation.x + jumpDifference.width, y: startLocation.y + jumpDifference.height)
numberOfTouches = gesture.numberOfTouches
}
scale = gesture.scale
location = gesture.location(in: self)
offset = CGSize(width: location.x - startLocation.x, height: location.y - startLocation.y)
case .ended, .cancelled, .failed:
isPinching = false
scale = 1.0
anchor = .center
offset = .zero
default:
break
}
}
}
protocol PinchZoomViewDelgate: AnyObject {
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool)
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat)
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint)
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize)
}
struct PinchZoom: UIViewRepresentable {
@Binding var scale: CGFloat
@Binding var anchor: UnitPoint
@Binding var offset: CGSize
@Binding var isPinching: Bool
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIView(context: Context) -> PinchZoomView {
let pinchZoomView = PinchZoomView()
pinchZoomView.delegate = context.coordinator
return pinchZoomView
}
func updateUIView(_ pageControl: PinchZoomView, context: Context) { }
class Coordinator: NSObject, PinchZoomViewDelgate {
var pinchZoom: PinchZoom
init(_ pinchZoom: PinchZoom) {
self.pinchZoom = pinchZoom
}
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangePinching isPinching: Bool) {
pinchZoom.isPinching = isPinching
}
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeScale scale: CGFloat) {
pinchZoom.scale = scale
}
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeAnchor anchor: UnitPoint) {
pinchZoom.anchor = anchor
}
func pinchZoomView(_ pinchZoomView: PinchZoomView, didChangeOffset offset: CGSize) {
pinchZoom.offset = offset
}
}
}
struct PinchToZoom: ViewModifier {
@State var scale: CGFloat = 1.0
@State var anchor: UnitPoint = .center
@State var offset: CGSize = .zero
@State var isPinching: Bool = false
func body(content: Content) -> some View {
content
.scaleEffect(scale, anchor: anchor)
.offset(offset)
.animation(isPinching ? .none : .spring())
.overlay(PinchZoom(scale: $scale, anchor: $anchor, offset: $offset, isPinching: $isPinching))
}
}
extension View {
func pinchToZoom() -> some View {
self.modifier(PinchToZoom())
}
}
The other answers here are overly complicated with custom zooming logic. If you want the standard, battle-tested UIScrollView zooming behavior you can just use a UIScrollView!
SwiftUI allows you to put any UIView inside an otherwise SwiftUI view hierarchy using UIViewRepresentable
or UIViewControllerRepresentable
. Then to put more SwiftUI content inside that view, you can use UIHostingController
. Read more about SwiftUI–UIKit interop in Interfacing with UIKit and the API docs.
You can find a more complete example where I'm using this in a real app at: https://github.com/jtbandes/SpacePOD/blob/main/SpacePOD/ZoomableScrollView.swift (That example also includes more tricks for centering the image.)
var body: some View {
ZoomableScrollView {
Image("Your image here")
}
}
struct ZoomableScrollView<Content: View>: UIViewRepresentable {
private var content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
// set up the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator // for viewForZooming(in:)
scrollView.maximumZoomScale = 20
scrollView.minimumZoomScale = 1
scrollView.bouncesZoom = true
// create a UIHostingController to hold our SwiftUI content
let hostedView = context.coordinator.hostingController.view!
hostedView.translatesAutoresizingMaskIntoConstraints = true
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
hostedView.frame = scrollView.bounds
scrollView.addSubview(hostedView)
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: self.content))
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
// update the hosting controller's SwiftUI content
context.coordinator.hostingController.rootView = self.content
assert(context.coordinator.hostingController.view.superview == uiView)
}
// MARK: - Coordinator
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
init(hostingController: UIHostingController<Content>) {
self.hostingController = hostingController
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
}
}