Reputation: 1041
I want to be able to resize and move an image in SwiftUI (like if it were a map) with pinch to zoom and drag it around.
With UIKit I embedded the image into a UIScrollView
and it took care of it, but I don't know how to do it in SwiftUI.
I tried using MagnificationGesture
but I cannot get it to work smoothly.
I've been searching about this for a while, does anyone know if there's an easier way?
Upvotes: 90
Views: 54104
Reputation: 158
I used the following code to create an Instagram like zooming effect:
import SwiftUI
extension View {
@ViewBuilder func pinchZoom(_ dimsBackground: Bool = true) -> some View {
PinchZoomHelper(dimsBackground: dimsBackground) {
self
}
}
}
struct ZoomContainer<Content: View>: View {
@ViewBuilder var content: Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content()
}
@ObservedObject private var containerData = ZoomContainerData()
var body: some View {
GeometryReader { _ in
content
.environmentObject(containerData)
ZStack(alignment: .topLeading) {
if let view = containerData.zoomingView {
Group {
if containerData.dimsBackground {
Rectangle()
.fill(.black.opacity(0.25))
.opacity(containerData.zoom - 1)
}
view
.scaleEffect(containerData.zoom, anchor: containerData.zoomAnchor)
.offset(containerData.dragOffset)
.offset(x: containerData.viewRect.minX, y: containerData.viewRect.minY)
}
}
}
}
}
}
fileprivate class ZoomContainerData: ObservableObject {
@Published var zoomingView: AnyView?
@Published var viewRect: CGRect = .zero
@Published var dimsBackground: Bool = false
@Published var zoom: CGFloat = 1
@Published var zoomAnchor: UnitPoint = .center
@Published var dragOffset: CGSize = .zero
@Published var isResetting: Bool = false
}
fileprivate struct PinchZoomHelper<Content: View>: View {
var dimsBackground: Bool
@ViewBuilder var content: Content
@EnvironmentObject private var containerData: ZoomContainerData
@State private var config: Config = .init()
var body: some View {
content
.opacity(config.hidesSourceView ? 0 : 1)
.overlay(GestureOverlay(config: $config))
.overlay {
GeometryReader {
let rect = $0.frame(in: .global)
Color.clear
.onChange(of: config.isGestureActive) { newValue in
if newValue {
guard !containerData.isResetting else {return}
containerData.viewRect = rect
containerData.zoomAnchor = config.zoomAnchor
containerData.dimsBackground = dimsBackground
containerData.zoomingView = AnyView(erasing: content)
config.hidesSourceView = true
} else {
containerData.isResetting = true
if #available(iOS 17.0, *) {
withAnimation(.spring(duration: 0.3),
completionCriteria: .logicallyComplete) {
containerData.dragOffset = .zero
containerData.zoom = 1
} completion: {
withAnimation(.spring(duration: 0.3),
completionCriteria: .logicallyComplete) {
config = .init()
containerData.zoomingView = nil
containerData.isResetting = false
} completion: {}
}
} else {
withAnimation(.snappy(duration: 0.3, extraBounce: 0)) {
containerData.dragOffset = .zero
containerData.zoom = 1
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
withAnimation(.snappy(duration: 0.3, extraBounce: 0)) {
config = .init()
containerData.zoomingView = nil
containerData.isResetting = false
}
}
}
}
}
.onChange(of: config) { newValue in
if config.isGestureActive && !containerData.isResetting {
containerData.zoom = config.zoom
containerData.dragOffset = config.dragOffset
}
}
}
}
}
}
fileprivate struct GestureOverlay: UIViewRepresentable {
@Binding var config: Config
func makeCoordinator() -> Coordinator {
Coordinator(config: $config)
}
func makeUIView(context: Context) -> some UIView {
let view = UIView(frame: .zero)
view.backgroundColor = .clear
let panGesture = UIPanGestureRecognizer()
panGesture.name = "PINCHPANGESTURE"
panGesture.minimumNumberOfTouches = 2
panGesture.addTarget(context.coordinator, action: #selector(Coordinator.panGesture(gesture:)))
panGesture.delegate = context.coordinator
view.addGestureRecognizer(panGesture)
let pinchGesture = UIPinchGestureRecognizer()
pinchGesture.name = "PINCHZOOMGESTURE"
pinchGesture.addTarget(context.coordinator, action: #selector(Coordinator.pinchGesture(gesture:)))
pinchGesture.delegate = context.coordinator
view.addGestureRecognizer(pinchGesture)
return view
}
func updateUIView(_ uiView: UIViewType, context: Context) {}
class Coordinator: NSObject, UIGestureRecognizerDelegate {
@Binding var config: Config
init(config: Binding<Config>) {
self._config = config
}
@objc func panGesture(gesture: UIPanGestureRecognizer) {
if gesture.state == .began || gesture.state == .changed {
let translation = gesture.translation(in: gesture.view)
config.dragOffset = .init(width: translation.x, height: translation.y)
config.isGestureActive = true
} else {
config.isGestureActive = false
}
}
@objc func pinchGesture(gesture: UIPinchGestureRecognizer) {
if gesture.state == .began {
let location = gesture.location(in: gesture.view)
if let bounds = gesture.view?.bounds {
config.zoomAnchor = .init(x: location.x / bounds.width, y: location.y / bounds.height)
}
}
if gesture.state == .began || gesture.state == .changed {
let scale = max(gesture.scale, 1)
config.zoom = scale
config.isGestureActive = true
} else {
config.isGestureActive = false
}
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer.name == "PINCHPANGESTURE" && otherGestureRecognizer.name == "PINCHZOOMGESTURE" {
return true
}
return false
}
}
}
fileprivate struct Config: Equatable {
var isGestureActive: Bool = false
var zoom: CGFloat = 1
var zoomAnchor: UnitPoint = .center
var dragOffset: CGSize = .zero
var hidesSourceView: Bool = false
}
You can then wrap your parent view with this ZoomContainer
and add the modifier pinchToZoom()
to your image.
Upvotes: 0
Reputation: 1
I used both solutions Solution1 and Solution2, but I faced an issue in Solution1 where func updateUIView(_ uiView: UIScrollView, context: Context)
went into an infinite loop. Also, .onTapGesture(count: 2, perform: doubleTapAction)
with location is only supported by iOS 17. Therefore, I updated the code to use UITapGestureRecognizer. Here is my updated version:
fileprivate let maxAllowedScale = 4.0
struct ZoomableScrollView<Content: View>: UIViewRepresentable {
private var content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
// Setup the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator // for viewForZooming(in:)
scrollView.maximumZoomScale = maxAllowedScale
scrollView.minimumZoomScale = 1
scrollView.bouncesZoom = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
scrollView.clipsToBounds = false
// 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)
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.doubleTapAction))
tap.numberOfTapsRequired = 2
scrollView.addGestureRecognizer(tap)
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator(hostingController: UIHostingController(rootView: content))
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
context.coordinator.hostingController.rootView = 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
}
//MARK: - Actions
@objc func doubleTapAction(_ recognizer: UITapGestureRecognizer) {
guard let scrollView = recognizer.view as? UIScrollView else { return }
if (scrollView.zoomScale > scrollView.minimumZoomScale) {
scrollView.setZoomScale(scrollView.minimumZoomScale, animated: true)
} else {
let zoomRect = zoomRectForScale(scrollView: scrollView, scale: scrollView.maximumZoomScale, center: recognizer.location(in: recognizer.view))
scrollView.zoom(to: zoomRect, animated: true)
}
}
//MARK: - ScrollView delegate
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
//MARK: - Private
private func zoomRectForScale(scrollView: UIScrollView, scale : CGFloat, center : CGPoint) -> CGRect {
var zoomRect = CGRect.zero
if let viewForZooming = self.hostingController.view {
zoomRect.size.height = viewForZooming.frame.size.height / scale;
zoomRect.size.width = viewForZooming.frame.size.width / scale;
let newCenter = viewForZooming.convert(center, from: scrollView)
zoomRect.origin.x = newCenter.x - ((zoomRect.size.width / 2.0));
zoomRect.origin.y = newCenter.y - ((zoomRect.size.height / 2.0));
}
return zoomRect;
}
}
}
Upvotes: 0
Reputation: 318
I went with this, it initially scales the image to fit the view. Then you can pan and zoom.
struct PreviewView : View {
var body: some View {
let image = UIImage(named: "cones.jpg")!
GeometryReader { proxy in
PanZoomView(size: proxy.size, image: image)
}
.frame(maxHeight: .infinity)
}
}
class PanZoomViewUIScrollView: UIScrollView, UIScrollViewDelegate {
var imageView: UIImageView!
var image: UIImage!
var size :CGSize
init(size :CGSize, image: UIImage) {
self.image = image
self.size = size
super.init(frame: .zero)
imageView = UIImageView(image: image)
imageView.contentMode = .scaleAspectFill
addSubview(imageView)
delegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// Tell the scroll view delegate which view to use for zooming and scrolling
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
}
struct PanZoomView: UIViewRepresentable {
let size: CGSize
let image: UIImage
func makeUIView(context: Context) -> PanZoomViewUIScrollView {
let view = PanZoomViewUIScrollView(size: size, image: image)
return view
}
func updateUIView(_ pageControl: PanZoomViewUIScrollView, context: Context) {
let fitScale = self.size.width / self.image.size.width
pageControl.minimumZoomScale = fitScale
pageControl.maximumZoomScale = 2.0
pageControl.zoomScale = fitScale
}
}
Upvotes: 0
Reputation: 17054
Taking hints from several comments, other answers and my own UIKit version of a UIScrollView that centers content and sets the minimum zoom scale to fit the image, I created a UIViewRepresentable
that handles zooming, panning of a uiImage. As long as ScrollView
doesn't really support all this, I think this is the way to go. Hope it helps someone.
struct ZoomableUIImageView: UIViewRepresentable {
var image: UIImage
let scrollViewDelegate = ScrollViewDelegate()
func makeUIView(context: Context) -> UIViewType {
let imageView = UIImageView(image: image)
let scrollView = UIViewType(imageView: imageView)
scrollView.delegate = scrollViewDelegate
scrollView.maximumZoomScale = 8
scrollView.minimumZoomScale = 0.1
scrollView.bouncesZoom = true
scrollView.bounces = true
scrollView.alwaysBounceVertical = true
scrollView.alwaysBounceHorizontal = true
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
scrollView.contentInsetAdjustmentBehavior = .never
imageView.contentMode = .scaleAspectFill
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.setNeedsLayout()
imageView.layoutIfNeeded()
scrollView.addSubview(imageView)
return scrollView
}
func updateUIView(_ uiView: CenteredImageScrollView, context: Context) {
uiView.imageView?.image = image
}
// MARK: - ScrollViewDelegate
class ScrollViewDelegate: NSObject, UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
(scrollView as? UIViewType)?.imageView
}
}
// MARK: - CenteredImageScrollView
class CenteredImageScrollView: UIScrollView {
weak var imageView: UIImageView?
init(imageView: UIImageView) {
self.imageView = imageView
super.init(frame: .zero)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var minZoomScaleIsSet: Bool = false
override var bounds: CGRect {
didSet {
centerContent()
}
}
override var contentSize: CGSize {
didSet {
centerContent()
}
}
override var zoomScale: CGFloat {
didSet {
centerContent()
}
}
override func layoutSubviews() {
super.layoutSubviews()
centerContent()
guard bounds.size != .zero,
minZoomScaleIsSet == false,
let imageView,
let image = imageView.image
else {
return
}
let imageViewSize = image.size
let scrollViewSize = bounds.size
let widthScale = scrollViewSize.width / imageViewSize.width
let heightScale = scrollViewSize.height / imageViewSize.height
let minZoomScale = min(widthScale, heightScale)
contentSize = imageViewSize
zoomScale = minZoomScale
minimumZoomScale = minZoomScale
minZoomScaleIsSet = true
}
func centerContent() {
guard contentSize != .zero,
bounds.size != .zero
else { return }
var top: CGFloat = 0
var left: CGFloat = 0
if contentSize.width < bounds.size.width {
left = (bounds.size.width - contentSize.width) * 0.5
}
if contentSize.height < bounds.size.height {
top = (bounds.size.height - contentSize.height) * 0.5
}
let newContentInset = UIEdgeInsets(top: top, left: left, bottom: top, right: left)
if contentInset != newContentInset {
contentInset = newContentInset
}
}
override func didAddSubview(_ subview: UIView) {
super.didAddSubview(subview)
centerContent()
}
override var frame: CGRect {
get {
super.frame
}
set {
super.frame = newValue
centerContent()
}
}
}
}
Upvotes: 5
Reputation: 2270
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
@State private var scale: 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
self.scale = 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.
Upvotes: 47
Reputation: 246
The simple solution works for me
@State var lastZoomFactor: CGFloat = 1.0
Image("AnyImageName")
.gesture(MagnificationGesture()
.onChanged { value in
self.lastZoomFactor += value - 1.0 // Calculate the zoom factor change
self.currentZoomFactor = min(max(self.lastZoomFactor, 0.5), 10).
self.viewModel.zoom(with: lastZoomFactor)
}
)
Upvotes: -1
Reputation: 697
My experience with the MagnificationGesture
was pretty bad, it was very laggy and consumed A LOT of CPU and RAM (like many other solutions). The best solution was to use a basic UIScrollView
.
Based on another solution, I've implemented more dynamic struct that allows you to:
View
you'd likeAnd most important thing - it has been tested to guarantee you that it won't eat up your CPU & RAM!
// ContentView.swift
import SwiftUI
struct ContentView: View {
var body: some View {
ZoomableContainer{
// Put here any `View` you'd like (e.g. `Image`, `Text`)
}
}
}
// ZoomableContainer.swift
import SwiftUI
fileprivate let maxAllowedScale = 4.0
struct ZoomableContainer<Content: View>: View {
let content: Content
@State private var currentScale: CGFloat = 1.0
@State private var tapLocation: CGPoint = .zero
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func doubleTapAction(location: CGPoint) {
tapLocation = location
currentScale = currentScale == 1.0 ? maxAllowedScale : 1.0
}
var body: some View {
ZoomableScrollView(scale: $currentScale, tapLocation: $tapLocation) {
content
}
.onTapGesture(count: 2, perform: doubleTapAction)
}
fileprivate struct ZoomableScrollView<Content: View>: UIViewRepresentable {
private var content: Content
@Binding private var currentScale: CGFloat
@Binding private var tapLocation: CGPoint
init(scale: Binding<CGFloat>, tapLocation: Binding<CGPoint>, @ViewBuilder content: () -> Content) {
_currentScale = scale
_tapLocation = tapLocation
self.content = content()
}
func makeUIView(context: Context) -> UIScrollView {
// Setup the UIScrollView
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator // for viewForZooming(in:)
scrollView.maximumZoomScale = maxAllowedScale
scrollView.minimumZoomScale = 1
scrollView.bouncesZoom = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
scrollView.clipsToBounds = false
// 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: content), scale: $currentScale)
}
func updateUIView(_ uiView: UIScrollView, context: Context) {
// Update the hosting controller's SwiftUI content
context.coordinator.hostingController.rootView = content
if uiView.zoomScale > uiView.minimumZoomScale { // Scale out
uiView.setZoomScale(currentScale, animated: true)
} else if tapLocation != .zero { // Scale in to a specific point
uiView.zoom(to: zoomRect(for: uiView, scale: uiView.maximumZoomScale, center: tapLocation), animated: true)
// Reset the location to prevent scaling to it in case of a negative scale (manual pinch)
// Use the main thread to prevent unexpected behavior
DispatchQueue.main.async { tapLocation = .zero }
}
assert(context.coordinator.hostingController.view.superview == uiView)
}
// MARK: - Utils
func zoomRect(for scrollView: UIScrollView, scale: CGFloat, center: CGPoint) -> CGRect {
let scrollViewSize = scrollView.bounds.size
let width = scrollViewSize.width / scale
let height = scrollViewSize.height / scale
let x = center.x - (width / 2.0)
let y = center.y - (height / 2.0)
return CGRect(x: x, y: y, width: width, height: height)
}
// MARK: - Coordinator
class Coordinator: NSObject, UIScrollViewDelegate {
var hostingController: UIHostingController<Content>
@Binding var currentScale: CGFloat
init(hostingController: UIHostingController<Content>, scale: Binding<CGFloat>) {
self.hostingController = hostingController
_currentScale = scale
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return hostingController.view
}
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
currentScale = scale
}
}
}
}
Upvotes: 14
Reputation: 14361
Using the pan gesture here caused some wonky snapping behavior (similar to what is pointed out here) and it doesn't have a nice springy/bounce effect that panning around a ScrollView does.
I wanted to use a SwiftUI ScrollView and just support the zoom gesture...
struct ExampleView: View {
@State private var lastScale: CGFloat = 1.0
@State private var scale: CGFloat = 1.0
var body: some View {
let contentSize: CGFloat = 1500 //testing on iPad
ScrollView(
[.horizontal, .vertical]
) {
Text("My Content")
.font(.system(size: 300))
.frame(
width: contentSize,
height: contentSize
)
.scaleEffect(scale)
.frame(
width: contentSize * scale,
height: contentSize * scale
)
.background(.red)
.gesture(
MagnificationGesture()
.onChanged { val in
let delta = val / lastScale
lastScale = val
let newScale = scale * delta
if newScale <= 3 && newScale >= 1 {
scale = newScale
}
}.onEnded { val in
lastScale = 1
}
)
}
}
}
It works "fine", but the main problem is that zooming shifts content towards the center, instead of zooming in where you make your gesture. This isn't a ScrollView specific issue, even without the ScrollView I had the same experience.
Example showing zooming shifting away from zoom area
However, to solve this... SwiftUI ScrollViews are not very flexible. If I want to track content offset and programmatically adjust offset while I scale it is a pretty huge effort, since there's no direct support for this in SwiftUI.
The workaround I found for this is to actually zoom the whole scrollview instead, not the content.
Example showing zooming remains centered on the zoom area
struct ExampleView: View {
@State private var lastScale: CGFloat = 1.0
@State private var scale: CGFloat = 1.0
var body: some View {
let contentSize: CGFloat = 1500 //testing on iPad
ScrollView(
[.horizontal, .vertical]
) {
Text("My Content")
.font(.system(size: 300))
.frame(
width: contentSize,
height: contentSize
)
.background(.red)
.padding(contentSize / 2)
}
.scaleEffect(scale)
.gesture(
MagnificationGesture()
.onChanged { val in
let delta = val / lastScale
lastScale = val
let newScale = scale * delta
if newScale <= 3 && newScale >= 1 {
scale = newScale
}
}.onEnded { val in
lastScale = 1
}
)
}
}
Obviously, this is a bit of hack but works well when the ScrollView content covers a whole screen in a ZStack. You just have to be sure you have enough content padding to handle the zoom threshold and prevent shrinking below 1.0 scale.
This wont work for all scenarios but it worked great for mine (moving around a game board), just wanted to post just in case someone else is in the same boat.
Upvotes: 1
Reputation: 118751
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
}
}
}
Upvotes: 104
Reputation: 343
Here is my solution with pinch-zooming in image exactly like Apple's photo app.
import SwiftUI
public struct SwiftUIImageViewer: View {
let image: Image
@State private var scale: CGFloat = 1
@State private var lastScale: CGFloat = 1
@State private var offset: CGPoint = .zero
@State private var lastTranslation: CGSize = .zero
public init(image: Image) {
self.image = image
}
public var body: some View {
GeometryReader { proxy in
ZStack {
image
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(scale)
.offset(x: offset.x, y: offset.y)
.gesture(makeDragGesture(size: proxy.size))
.gesture(makeMagnificationGesture(size: proxy.size))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.edgesIgnoringSafeArea(.all)
}
}
private func makeMagnificationGesture(size: CGSize) -> some Gesture {
MagnificationGesture()
.onChanged { value in
let delta = value / lastScale
lastScale = value
// To minimize jittering
if abs(1 - delta) > 0.01 {
scale *= delta
}
}
.onEnded { _ in
lastScale = 1
if scale < 1 {
withAnimation {
scale = 1
}
}
adjustMaxOffset(size: size)
}
}
private func makeDragGesture(size: CGSize) -> some Gesture {
DragGesture()
.onChanged { value in
let diff = CGPoint(
x: value.translation.width - lastTranslation.width,
y: value.translation.height - lastTranslation.height
)
offset = .init(x: offset.x + diff.x, y: offset.y + diff.y)
lastTranslation = value.translation
}
.onEnded { _ in
adjustMaxOffset(size: size)
}
}
private func adjustMaxOffset(size: CGSize) {
let maxOffsetX = (size.width * (scale - 1)) / 2
let maxOffsetY = (size.height * (scale - 1)) / 2
var newOffsetX = offset.x
var newOffsetY = offset.y
if abs(newOffsetX) > maxOffsetX {
newOffsetX = maxOffsetX * (abs(newOffsetX) / newOffsetX)
}
if abs(newOffsetY) > maxOffsetY {
newOffsetY = maxOffsetY * (abs(newOffsetY) / newOffsetY)
}
let newOffset = CGPoint(x: newOffsetX, y: newOffsetY)
if newOffset != offset {
withAnimation {
offset = newOffset
}
}
self.lastTranslation = .zero
}
}
Also, I have this solution as Swift Package in my GitHub here.
Upvotes: 29
Reputation: 11666
my two cents. I did search and find a solution from: iOSCretor repo(https://github.com/ioscreator/ioscreator, thanks to Arthur Knopper!)
I did slightly modify and copied here, for convenience, adding reset method.
technically we:
add image with scale and state.
add 2 gestures that work simultaneously
add also a "reset" via double tap
import SwiftUI
struct ContentView: View {
@GestureState private var scaleState: CGFloat = 1
@GestureState private var offsetState = CGSize.zero
@State private var offset = CGSize.zero
@State private var scale: CGFloat = 1
func resetStatus(){
self.offset = CGSize.zero
self.scale = 1
}
init(){
resetStatus()
}
var zoomGesture: some Gesture {
MagnificationGesture()
.updating($scaleState) { currentState, gestureState, _ in
gestureState = currentState
}
.onEnded { value in
scale *= value
}
}
var dragGesture: some Gesture {
DragGesture()
.updating($offsetState) { currentState, gestureState, _ in
gestureState = currentState.translation
}.onEnded { value in
offset.height += value.translation.height
offset.width += value.translation.width
}
}
var doubleTapGesture : some Gesture {
TapGesture(count: 2).onEnded { value in
resetStatus()
}
}
var body: some View {
Image(systemName: "paperplane")
.renderingMode(.template)
.resizable()
.foregroundColor(.red)
.scaledToFit()
.scaleEffect(self.scale * scaleState)
.offset(x: offset.width + offsetState.width, y: offset.height + offsetState.height)
.gesture(SimultaneousGesture(zoomGesture, dragGesture))
.gesture(doubleTapGesture)
}
}
for Your convenience here is a GIST: https://gist.github.com/ingconti/124d549e2671fd91d86144bc222d171a
Upvotes: 9
Reputation: 711
Implementation of Zoom and Drag of an image in SwiftUI
struct PhotoViewer: View {
@State private var uiimage = UIImage(named: "leaf.png")
@GestureState private var scaleState: CGFloat = 1
@GestureState private var offsetState = CGSize.zero
@State private var offset = CGSize.zero
@State private var scale: CGFloat = 1
var magnification: some Gesture {
MagnificationGesture()
.updating($scaleState) { currentState, gestureState, _ in
gestureState = currentState
}
.onEnded { value in
scale *= value
}
}
var dragGesture: some Gesture {
DragGesture()
.updating($offsetState) { currentState, gestureState, _ in
gestureState = currentState.translation
}.onEnded { value in
offset.height += value.translation.height
offset.width += value.translation.width
}
}
var body: some View {
Image(uiImage: uiimage!)
.resizable()
.scaledToFit()
.scaleEffect(self.scale * scaleState)
.offset(x: offset.width + offsetState.width, y: offset.height + offsetState.height)
.gesture(SimultaneousGesture(magnification, dragGesture))
}
}
Upvotes: 5
Reputation: 1549
A extremely simple approach that I think deserves mention - use Apple's PDFKit
.
import SwiftUI
import PDFKit
struct PhotoDetailView: UIViewRepresentable {
let image: UIImage
func makeUIView(context: Context) -> PDFView {
let view = PDFView()
view.document = PDFDocument()
guard let page = PDFPage(image: image) else { return view }
view.document?.insert(page, at: 0)
view.autoScales = true
return view
}
func updateUIView(_ uiView: PDFView, context: Context) {
// empty
}
}
Pros:
If you're just presenting the image for viewing, this method might be perfect for you. But if you want to add image annotation, etc, I'd follow one of the other answers.
Edited to add view.autoScales = true
at maka's suggestion.
Upvotes: 39
Reputation: 4117
This is another solution, based on jtbandes' answer. It still wraps a UIScrollView
in a UIViewRepresentable
but with a few changes:
UIImage
, rather than generic SwiftUI content: it works for this case and it doesn't require to wrap the underlying UIImage
into a SwiftUI Image
Use:
struct EncompassingView: View {
let uiImage: UIImage
var body: some View {
GeometryReader { geometry in
ZoomableView(uiImage: uiImage, viewSize: geometry.size)
}
}
}
Definition:
struct ZoomableView: UIViewRepresentable {
let uiImage: UIImage
let viewSize: CGSize
private enum Constraint: String {
case top
case leading
}
private var minimumZoomScale: CGFloat {
let widthScale = viewSize.width / uiImage.size.width
let heightScale = viewSize.height / uiImage.size.height
return min(widthScale, heightScale)
}
func makeUIView(context: Context) -> UIScrollView {
let scrollView = UIScrollView()
scrollView.delegate = context.coordinator
scrollView.maximumZoomScale = minimumZoomScale * 50
scrollView.minimumZoomScale = minimumZoomScale
scrollView.bouncesZoom = true
let imageView = UIImageView(image: uiImage)
scrollView.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
let topConstraint = imageView.topAnchor.constraint(equalTo: scrollView.topAnchor)
topConstraint.identifier = Constraint.top.rawValue
topConstraint.isActive = true
let leadingConstraint = imageView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
leadingConstraint.identifier = Constraint.leading.rawValue
leadingConstraint.isActive = true
imageView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor).isActive = true
imageView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor).isActive = true
return scrollView
}
func makeCoordinator() -> Coordinator {
return Coordinator()
}
func updateUIView(_ scrollView: UIScrollView, context: Context) {
guard let imageView = scrollView.subviews.first as? UIImageView else {
return
}
// Inject dependencies into coordinator
context.coordinator.zoomableView = imageView
context.coordinator.imageSize = uiImage.size
context.coordinator.viewSize = viewSize
let topConstraint = scrollView.constraints.first { $0.identifier == Constraint.top.rawValue }
let leadingConstraint = scrollView.constraints.first { $0.identifier == Constraint.leading.rawValue }
context.coordinator.topConstraint = topConstraint
context.coordinator.leadingConstraint = leadingConstraint
// Set initial zoom scale
scrollView.zoomScale = minimumZoomScale
}
}
// MARK: - Coordinator
extension ZoomableView {
class Coordinator: NSObject, UIScrollViewDelegate {
var zoomableView: UIView?
var imageSize: CGSize?
var viewSize: CGSize?
var topConstraint: NSLayoutConstraint?
var leadingConstraint: NSLayoutConstraint?
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
zoomableView
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
let zoomScale = scrollView.zoomScale
print("zoomScale = \(zoomScale)")
guard
let topConstraint = topConstraint,
let leadingConstraint = leadingConstraint,
let imageSize = imageSize,
let viewSize = viewSize
else {
return
}
topConstraint.constant = max((viewSize.height - (imageSize.height * zoomScale)) / 2.0, 0.0)
leadingConstraint.constant = max((viewSize.width - (imageSize.width * zoomScale)) / 2.0, 0.0)
}
}
}
Upvotes: 5
Reputation: 1437
Here is a complete example of @James accepted response, which also features rudimentary support for scrolling around the newly zoomed image via adjusting a hidden rectangle that resizes the content of the scrollview in proportion with the image scale:
import SwiftUI
struct EnlargedImage: View {
var image = UIImage(named: "YourImageName")
@State var scale: CGFloat = 1.0
@State var lastScaleValue: CGFloat = 1.0
var body: some View {
ScrollView([.vertical, .horizontal], showsIndicators: false){
ZStack{
Rectangle().foregroundColor(.clear).frame(width: image!.size.width * scale, height: image!.size.height * scale, alignment: .center)
Image(uiImage: image!).scaleEffect(scale)
.gesture(MagnificationGesture().onChanged { val in
let delta = val / self.lastScaleValue
self.lastScaleValue = val
var newScale = self.scale * delta
if newScale < 1.0
{
newScale = 1.0
}
scale = newScale
}.onEnded{val in
lastScaleValue = 1
})
}
}.background(Color(.systemBackground).edgesIgnoringSafeArea(.all))
}
}
I have a better version of this in my GitHub.
Upvotes: 5
Reputation: 6436
Other answers are fine, here is an additional tip: if you are using a SwiftUI gesture you can use a @GestureState
instead of a @State
for storing gesture state. It will automatically reset the state to its initial value after the gesture ended, thus you can simplify this kind of code:
@State private var scale: CGFloat = 1.0
.gesture(MagnificationGesture().onChanged { value in
// Anything with value
scale = value
}.onEnded { value in
scale = 1.0
})
with:
@GestureState private var scale: CGFloat = 1.0
.gesture(MagnificationGesture().updating($scale) { (newValue, scale, _) in
// Anything with value
scale = newValue
})
Upvotes: 14
Reputation: 1445
Here's an alternative approach to @James and @ethoooo 's. The final zoom state and the transient gesture state are kept separate (the transient will always return 1), so it's a state you can set from a button or stepper for example in addition to the gesture itself.
@State var scrollContentZoom: CGFloat = 1
@GestureState var scrollContentGestureZoom: CGFloat = 1
var contentZoom: CGFloat { scrollContentZoom*scrollContentGestureZoom }
var magnification: some Gesture {
MagnificationGesture()
.updating($scrollContentGestureZoom) { state, gestureState, transaction in
print("Magnifed: \(state)")
gestureState = state
}
.onEnded { (state) in
scrollContentZoom = contentZoom*state
}
}
Upvotes: 3
Reputation: 25
struct DetailView: View {
var item: MenuItem
@State private var zoomed:Bool = false
@State var scale: CGFloat = 1.0
@State var isTapped: Bool = false
@State var pointTaped: CGPoint = CGPoint.zero
@State var draggedSize: CGSize = CGSize.zero
@State var previousDraged: CGSize = CGSize.zero
var width = UIScreen.main.bounds.size.width
var height = UIScreen.main.bounds.size.height
var body: some View {
GeometryReader { reader in
VStack(alignment: .center) {
ScrollView(){
HStack {
ScrollView(.vertical){
Image(self.item.mainImage)
.resizable()
.scaledToFill()
.animation(.default).offset(x: self.draggedSize.width, y: 0)
.scaleEffect(self.scale).scaleEffect(self.isTapped ? 2 : 1, anchor: UnitPoint(x : (self.pointTaped.x) / (reader.frame(in : .global).maxX),y: (self.pointTaped.y) / (reader.frame(in : .global).maxY )))
.gesture(TapGesture(count: 2)
.onEnded({ value in
self.isTapped = !self.isTapped
})
.simultaneously(with: DragGesture(minimumDistance: 0, coordinateSpace: .global) .onChanged { (value) in
self.pointTaped = value.startLocation
self.draggedSize = CGSize(width: value.translation.width + self.previousDraged.width, height: value.translation.height + self.previousDraged.height)
}
.onEnded({ (value) in
let offSetWidth = (reader.frame(in :.global).maxX * self.scale) - (reader.frame(in :.global).maxX) / 2
let newDraggedWidth = self.previousDraged.width * self.scale
if (newDraggedWidth > offSetWidth){
self.draggedSize = CGSize(width: offSetWidth / self.scale, height: value.translation.height + self.previousDraged.height)
}
else if (newDraggedWidth < -offSetWidth){
self.draggedSize = CGSize(width: -offSetWidth / self.scale, height: value.translation.height + self.previousDraged.height)
}
else{
self.draggedSize = CGSize(width: value.translation.width + self.previousDraged.width, height: value.translation.height + self.previousDraged.height)
}
self.previousDraged = self.draggedSize
})))
.gesture(MagnificationGesture()
.onChanged { (value) in
self.scale = value.magnitude
}.onEnded { (val) in
//self.scale = 1.0
self.scale = val.magnitude
}
)
}
}
HStack {
Text(self.item.description)
.foregroundColor(Color.black)
.multilineTextAlignment(.leading)
.padding(4)
}
}
}.navigationBarTitle("Menu Detail")
}
}
}
Upvotes: 0
Reputation: 4711
I am also struggle with this issue. But some working sample is made with the this video-(https://www.youtube.com/watch?v=p0SwXJYJp2U)
This is not completed. It's difficult to scale with anchor point. Hope this is hint to someone else.
struct ContentView: View {
let maxScale: CGFloat = 3.0
let minScale: CGFloat = 1.0
@State var lastValue: CGFloat = 1.0
@State var scale: CGFloat = 1.0
@State var draged: CGSize = .zero
@State var prevDraged: CGSize = .zero
@State var tapPoint: CGPoint = .zero
@State var isTapped: Bool = false
var body: some View {
let magnify = MagnificationGesture(minimumScaleDelta: 0.2)
.onChanged { value in
let resolvedDelta = value / self.lastValue
self.lastValue = value
let newScale = self.scale * resolvedDelta
self.scale = min(self.maxScale, max(self.minScale, newScale))
print("delta=\(value) resolvedDelta=\(resolvedDelta) newScale=\(newScale)")
}
let gestureDrag = DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { (value) in
self.tapPoint = value.startLocation
self.draged = CGSize(width: value.translation.width + self.prevDraged.width,
height: value.translation.height + self.prevDraged.height)
}
return GeometryReader { geo in
Image("dooli")
.resizable().scaledToFit().animation(.default)
.offset(self.draged)
.scaleEffect(self.scale)
// .scaleEffect(self.isTapped ? 2 : 1,
// anchor: UnitPoint(x: self.tapPoint.x / geo.frame(in: .local).maxX,
// y: self.tapPoint.y / geo.frame(in: .local).maxY))
.gesture(
TapGesture(count: 2).onEnded({
self.isTapped.toggle()
if self.scale > 1 {
self.scale = 1
} else {
self.scale = 2
}
let parent = geo.frame(in: .local)
self.postArranging(translation: CGSize.zero, in: parent)
})
.simultaneously(with: gestureDrag.onEnded({ (value) in
let parent = geo.frame(in: .local)
self.postArranging(translation: value.translation, in: parent)
})
))
.gesture(magnify.onEnded { value in
// without this the next gesture will be broken
self.lastValue = 1.0
let parent = geo.frame(in: .local)
self.postArranging(translation: CGSize.zero, in: parent)
})
}
.frame(height: 300)
.clipped()
.background(Color.gray)
}
private func postArranging(translation: CGSize, in parent: CGRect) {
let scaled = self.scale
let parentWidth = parent.maxX
let parentHeight = parent.maxY
let offset = CGSize(width: (parentWidth * scaled - parentWidth) / 2,
height: (parentHeight * scaled - parentHeight) / 2)
print(offset)
var resolved = CGSize()
let newDraged = CGSize(width: self.draged.width * scaled,
height: self.draged.height * scaled)
if newDraged.width > offset.width {
resolved.width = offset.width / scaled
} else if newDraged.width < -offset.width {
resolved.width = -offset.width / scaled
} else {
resolved.width = translation.width + self.prevDraged.width
}
if newDraged.height > offset.height {
resolved.height = offset.height / scaled
} else if newDraged.height < -offset.height {
resolved.height = -offset.height / scaled
} else {
resolved.height = translation.height + self.prevDraged.height
}
self.draged = resolved
self.prevDraged = resolved
}
}
Upvotes: 6
Reputation: 5155
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())
}
}
Upvotes: 40
Reputation: 555
Looks like there isn't native support in SwiftUI's ScrollView, however, there's still a pretty simple way to do it.
Create a MagnificationGesture
like you were going for, but be sure to multiply your current scale by the value you get in the gesture's .onChanged
closure. This closure is giving you the change in zoom rather than the current scale value.
When you're zoomed out and begin to zoom in it won't increase from the current scale (0.5 to 0.6 as an arbitrary example), it will increase from 1 to 1.1. That's why you were seeing weird behavior.
This answer will work if the MagnificationGesture
is on the same view that has the .scaleEffect
. Otherwise, James' answer will work better.
struct ContentView: View {
@State var scale: CGFloat
var body: some View {
let gesture = MagnificationGesture(minimumScaleDelta: 0.1)
.onChanged { scaleDelta in
self.scale *= scaleDelta
}
return ScrollView {
// Your ScrollView content here :)
}
.gesture(gesture)
.scaleEffect(scale)
}
}
P.S. You may find that using a ScrollView
for this purpose is clunky and you aren't able to drag and zoom simultaneously. If this is the case & you aren't happy with it I would look into adding multiple gestures and adjusting your content's offset manually rather than using a ScrollView
.
Upvotes: 6