Cannot pan image in zoomed ScrollView? - swiftui

I would like to build a simple view that allows me to show an image in a scroll view and let the user pinch to zoom on the image, and pan.
I've looked around and started with thisScrollView:
struct TestView: View {
var body: some View {
ScrollView {
Image("test")
.border(Color(.yellow))
}
.border(Color(.red))
}
}
That would not handle zooming.
I then did this:
struct TestView: View {
/// https://wp.usatodaysports.com/wp-content/uploads/sites/88/2014/03/sunset-in-the-dolos-mikadun.jpg
var image = UIImage(named: "test")!
#State var scale: CGFloat = 1.0
#State var lastScaleValue: CGFloat = 1.0
var body: some View {
GeometryReader { geo in
ScrollView([.vertical, .horizontal], showsIndicators: false){
ZStack{
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: geo.size.width, height: geo.size.width)
.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
})
}
}
.frame(width: geo.size.width, height: geo.size.width)
.border(Color(.red))
.background(Color(.systemBackground).edgesIgnoringSafeArea(.all))
}
}
}
This allows me to zoom in and out, however, I cannot pan the image:
How can I code up things so I can support zoom and panning?

in order to get the panning functionality you will have to change the size of your Image container, in this case the ZStack.
So first we need a variable to save the current latest scale value.
#State var scaledFrame: CGFloat = 1.0
Then just change the size of the container each time the gesture ends.
ZStack{
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: geo.size.width, height: geo.size.width )
.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
scaledFrame = scale//Update the value once the gesture is over
lastScaleValue = 1
})
.draggable()
}
.frame(width: geo.size.width * scaledFrame, height: geo.size.width * scaledFrame)
//change the size of the frame once the drag is complete
This is due to the way ScrollView works, when you were zooming in, the real size of the container was not changing, therefore the ScrollView was only moving accordingly.

Related

SwiftUI stop drag gesture amplification from scale

The drag gesture works super great in swiftui when the scale of the object is 1.0, but when that scale is larger or smaller than the drag gesture it no longer follows the natural movement of the users finger, but rather is amplified. In the below example I'm attempting to stop the amplification by dividing by the scale without any luck.
struct TestView: View {
#State var location1: CGPoint = CGPoint(x: UIScreen.main.bounds.width/2, y: UIScreen.main.bounds.height/2)
#State var newScale1: CGFloat = 5.0
var body: some View {
ZStack{
ColorSquare(color: .mint)
.frame(width: 100, height: 100)
.position(location1)
.scaleEffect(newScale1)
.gesture(
DragGesture()
.onChanged { value in
self.location1 = value.location
}
)
.gesture (
MagnificationGesture()
.onChanged { gesture in
var scale = gesture/newScale1
self.newScale1 = scale
}
)
}
Thanks for any help!!
You can use the translation, which can be divided by your scale factor. But, you'll have to keep track of the start position:
struct TestView: View {
#State private var location: CGPoint = CGPoint(x: 200, y: 200)
#GestureState private var startLocation: CGPoint? = nil
#State private var newScale1: CGFloat = 2.0
var body: some View {
ZStack{
Rectangle()
.fill(Color.green)
.frame(width: 100, height: 100)
.position(location)
.scaleEffect(newScale1)
.gesture(
DragGesture()
.onChanged { value in
var newLocation = startLocation ?? location
newLocation.x += value.translation.width / newScale1
newLocation.y += value.translation.height / newScale1
self.location = newLocation
}
.updating($startLocation) { (value, startLocation, transaction) in
startLocation = startLocation ?? location
}
)
.gesture (
MagnificationGesture()
.onChanged { gesture in
self.newScale1 = gesture / newScale1
}
)
}
}
}
Adapted from: https://sarunw.com/posts/move-view-around-with-drag-gesture-in-swiftui/

Strange magnification behavior: scale starts at 1

I want to pinch zoom an image on a view.
I have this code:
#State var progressingScale: CGFloat = 1
var body: some View {
Image(imageName)
.resizable()
.aspectRatio(contentMode: .fit)
.padding([.leading, .trailing], 20)
.scaleEffect(progressingScale)
.gesture(MagnificationGesture()
.onChanged { value in
progressingScale = value.magnitude
}
.onEnded {value in
progressingScale = value.magnitude
}
)
}
This code works relatively well but suppose I scale the image up and lift the fingers. Now the image is huge.
Then, I try to pinch and scale it up more. The image goes back to scale = 1 and starts scaling up again. I want it to start at the scale it was when the fingers were released the first time.
Cannot figure why... any ideas?
In order for the magnification to stick, you need to keep track of two values: the image's permanent scale (imageScale) and the current magnification value (magnifyBy). The scale applied to the image is the product of these two values (imageScale * magnifyBy).
While the MagnificationGesture is in progress, just change the magnifyBy property. When the gesture ends, permanently apply the magnification to the imageScale and set magnifyBy back to 1.
struct ContentView: View {
#State private var imageScale: CGFloat = 1
#State private var magnifyBy: CGFloat = 1
var body: some View {
Image(systemName: "globe")
.resizable()
.aspectRatio(contentMode: .fit)
.padding([.leading, .trailing], 20)
.scaleEffect(imageScale * magnifyBy)
.gesture(MagnificationGesture()
.onChanged { value in
magnifyBy = value.magnitude
}
.onEnded {value in
imageScale *= value.magnitude
magnifyBy = 1
}
)
}
}

SwiftUI Image Gallery don't pan out of visible area

What I'm trying to do is basically having some images in a TabView and being able to zoom and pan on those images.
Right now I got this quite nicely implemented but I struggle to find a solution so that it is not possible to pan outside the bounds of the image.
This is what I currently have:
I want to try having the image clip to the side of the screen so you don't pan the image out of the visible area.
So far this it what my code looks like:
struct FinalImageSwipeView: View {
#ObservedObject var viewModel: ImageChatViewModel
#State var currentImage: UUID
#State var fullPreview: Bool = false
#GestureState var draggingOffset: CGSize = .zero
var body: some View {
TabView(selection: $currentImage) {
ForEach(viewModel.images) { image in
GeometryReader{ proxy in
let size = proxy.size
PinchAndPanImage(image: UIImage(named: image.imageName)!,
fullPreview: $fullPreview)
.frame(width: size.width, height: size.height)
.contentShape(Rectangle())
}
.tag(image.id)
.ignoresSafeArea()
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .always))
.ignoresSafeArea()
// upper navigation bar
.overlay(
ImageSwipeViewNavigationBar(fullPreview: $fullPreview, hideSwipeView: viewModel.hideSwipeView),
alignment: .top
)
// bottom image scrollview
.overlay(
ImageSwipeViewImageSelection(viewModel: viewModel,
currentImage: $currentImage,
fullPreview: $fullPreview),
alignment: .bottom
)
.gesture(DragGesture().updating($draggingOffset, body: { (value, outValue, _) in
if viewModel.imageScale == 0 {
outValue = value.translation
viewModel.onChangeDragGesture(value: draggingOffset)
}}).onEnded({ (value) in
if viewModel.imageScale == 0 {
viewModel.onEnd(value: value)
}
}))
.transition(.offset(y: UIScreen.main.bounds.size.height + 100))
}
}
struct ImageSwipeViewImageSelection: View {
#ObservedObject var viewModel: ImageChatViewModel
#Binding var currentImage: UUID
#Binding var fullPreview: Bool
var body: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: true) {
HStack(spacing: 15) {
ForEach(viewModel.images) { image in
Image(image.imageName)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 70, height: 60)
.cornerRadius(12)
.id(image.id)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(image.id == currentImage ? Color.white : Color.clear, lineWidth: 2)
}
.onTapGesture {
currentImage = image.id
}
}
}
.padding()
}
.frame(height: 80)
.background(BlurView(style: .systemUltraThinMaterialDark).ignoresSafeArea(edges: .bottom))
// While current post changing center current image in scrollview
.onAppear(perform: {
proxy.scrollTo(currentImage, anchor: .bottom)
})
.onChange(of: currentImage) { _ in
viewModel.imageScale = 1
withAnimation {
proxy.scrollTo(currentImage, anchor: .bottom)
}
}
}
.offset(y: fullPreview ? 150 : 0)
}
}
struct PinchAndPanImage: View {
let image: UIImage
#Binding var fullPreview: Bool
// Stuff for Pinch and Pan
#State var imageScale: CGFloat = 1
#State var imageCurrentScale: CGFloat = 0
#State var imagePanOffset: CGSize = .zero
#State var currentImagePanOffset: CGSize = .zero
var usedImageScale: CGFloat {
max(1, min(imageScale + imageCurrentScale, 10))
}
var usedImagePan: CGSize {
let width = imagePanOffset.width + currentImagePanOffset.width
let height = imagePanOffset.height + currentImagePanOffset.height
return CGSize(width: width, height: height)
}
var body: some View {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.cornerRadius(0)
.offset(usedImagePan)
.scaleEffect(usedImageScale > 1 ? usedImageScale : 1)
.gesture(
// Magnifying Gesture
MagnificationGesture()
.onChanged({ value in
imageCurrentScale = value - 1
})
.onEnded({ value in
imageCurrentScale = 0
imageScale = imageScale + value - 1
withAnimation(.easeInOut) {
if imageScale > 5 {
imageScale = 5
}
}
})
)
.simultaneousGesture(createPanGesture())
.onTapGesture(count: 2) {
withAnimation {
imageScale = 1
imagePanOffset = .zero
}
}
.onTapGesture(count: 1) {
withAnimation {
fullPreview.toggle()
}
}
}
private func createPanGesture() -> _EndedGesture<_ChangedGesture<DragGesture>>? {
let gesture = DragGesture()
.onChanged { value in
let width = value.translation.width / usedImageScale
let height = value.translation.height / usedImageScale
currentImagePanOffset = CGSize(width: width, height: height)
}
.onEnded { value in
currentImagePanOffset = .zero
let scaledWidth = value.translation.width / usedImageScale
let scaledHeight = value.translation.height / usedImageScale
let width = imagePanOffset.width + scaledWidth
let height = imagePanOffset.height + scaledHeight
imagePanOffset = CGSize(width: width, height: height)
}
return imageScale > 1 ? gesture : nil
}
}

How can i rotate an image in SwiftUI with one finger only?

I have an image in a view and i want to be able to rotate it with one finger only, how can i do that in SwiftUI?
I checked the RotationGesture but it works only with two fingers...
Thanks
Ok, i got it with this code :
https://gist.github.com/ts95/9f8e05380824c6ca999ab3bc1ff8541f
Ok fixed it with this code :
struct RotationGesture: View {
#State var totalRotation = CGSize.zero
#State var currentRotation = CGSize.zero
var body: some View {
Text("Hello, World!")
.frame(width: 150, height: 60)
.padding()
.background(Color.orange)
.cornerRadius(15)
.rotationEffect(Angle(degrees: Double(-totalRotation.width)))
.gesture(
DragGesture()
.onChanged { value in
totalRotation.width = value.translation.width + currentRotation.width
}
.onEnded { value in
currentRotation = totalRotation
}
)
}
}
But now we have to fixed the vertical movement because this solution is only working when you move around the X axis...
I want to solution to work when you make circle movement around the view you want to rotate...
one finger rotation
struct RotationGesture: View {
#State var gestureValue = CGSize.zero
var body: some View {
Text("Hello, World!")
.frame(width: 150, height: 60)
.padding()
.background(Color.orange)
.cornerRadius(15)
.rotationEffect(Angle(degrees: Double(-gestureValue.width)))
.gesture(
DragGesture().onChanged({ (value) in
self.gestureValue = value.translation
}).onEnded({ (value) in
self.gestureValue = .zero
})
)
.animation(.spring(response: 0.5, dampingFraction: 0.6, blendDuration: 0))
}
}
Using the solutions from above this is what I have for rotating with one finger. This will keep the position when you finish rotating as well as starting the next rotation where the last one left off.
struct SwiftUIView: View {
#State private var rotation: Angle = Angle(degrees: 0)
#State private var previousRotation: Angle?
var body: some View {
Circle()
.fill(AngularGradient(gradient: Gradient(colors: [Color.red, Color.blue]), center: /*#START_MENU_TOKEN#*/.center/*#END_MENU_TOKEN#*/, angle: .degrees(90)))
.frame(width: 760, height: 760)
.rotationEffect(rotation, anchor: .center)
.gesture(DragGesture()
.onChanged{ value in
if let previousRotation = self.previousRotation {
let deltaY = value.location.y - (760 / 2)
let deltaX = value.location.x - (760 / 2)
let fingerAngle = Angle(radians: Double(atan2(deltaY, deltaX)))
let angle = fingerAngle - previousRotation
rotation += angle
self.previousRotation = fingerAngle
} else {
let deltaY = value.location.y - (760 / 2)
let deltaX = value.location.x - (760 / 2)
let fingerAngle = Angle(radians: Double(atan2(deltaY, deltaX)))
previousRotation = fingerAngle
}
}
.onEnded{ _ in
previousRotation = nil
})
}
}
A dragGesture will give us the value of a location where user has dragged on screen (in our case, the circle). Here, the circle frame will be equal height and equal width.
Capturing the location point where the user tapped and subtracting height/2 from y point and width/2 from x point results in deltaX and deltaY. Once we have deltaX and deltaY, we can convert it into radians using the atan2 function (which is provided by the Swift Standard Library).
struct SwiftUIView: View {
#State var angle: Angle = .zero
var circleFrame: CGRect = CGRect(x: 0, y: 0, width: 300, height: 300)
var body: some View {
Circle()
.fill(AngularGradient(gradient: Gradient(colors: [Color.red, Color.blue]), center: /*#START_MENU_TOKEN#*/.center/*#END_MENU_TOKEN#*/, angle: .degrees(90)))
.padding()
.rotationEffect(angle)
.gesture(
DragGesture()
.onChanged { value in
let deltaY = value.location.y - (circleFrame.height / 2)
let deltaX = value.location.x - (circleFrame.width / 2)
angle = Angle(radians: Double(atan2(deltaY, deltaX)))
}
)
}
}

View is re-initialized after DragGesture onChanged

The following code shows an orange screen with a green circle in the lower right. The circle can be dragged.
import SwiftUI
struct DraggableCircle: View {
#Binding var offset: CGSize
#State private var previousOffset: CGSize
var body: some View {
Circle().fill(Color.green)
.frame(width: 100)
.offset(self.offset)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged { event in
print("\nDragGesture onChanged")
self.offset = CGSize(width: event.location.x - (event.startLocation.x - self.previousOffset.width),
height: event.location.y - (event.startLocation.y - self.previousOffset.height))
}
)
}
init(offset: Binding<CGSize>) {
print("Init with offset \(offset.wrappedValue)")
self._offset = offset
self._previousOffset = State(initialValue: offset.wrappedValue)
print("offset = \(self.offset), previousOffset=\(self.previousOffset)")
}
}
struct ContentView: View {
#State var circleOffset = CGSize()
var body: some View {
GeometryReader { reader in
Rectangle().fill(Color.orange)
.overlay(
DraggableCircle(offset: self.$circleOffset)
)
.onAppear {
self.circleOffset = CGSize(width: reader.size.width / 2,
height: reader.size.height / 2)
print("size: \(reader)\n")
}
}
}
}
If you run and tap the green circle (to begin a drag gesture), the following appears in the console:
Init with offset (0.0, 0.0)
offset = (0.0, 0.0), previousOffset=(0.0, 0.0)
size: GeometryProxy(base: SwiftUI._PositionAwareLayoutContext(base: SwiftUI.LayoutTraitsContext(context: AttributeGraph.AttributeContext<SwiftUI.VoidAttribute>(graph: __C.AGGraphRef(p: 0x00007f84b6a05ff0), attribute: AttributeGraph.Attribute<()>(identifier: __C.AGAttribute(id: 42))), environmentIndex: 4), dimensionsIndex: 1, transformIndex: 3, positionIndex: 2), seed: 1, viewGraph: Optional(SwiftUI.ViewGraph))
Init with offset (187.5, 323.5)
offset = (187.5, 323.5), previousOffset=(187.5, 323.5)
DragGesture onChanged
Init with offset (0.0, 0.0)
offset = (0.0, 0.0), previousOffset=(0.0, 0.0)
What I expected to happen, is that when you drag the circle, it can be smoothly dragged somewhere else on the screen.
What actually happens, is when dragging starts, DraggableCircle.init is called again, which resets the offset and places the circle right in the middle. Why is this?
Note: when you move the #State previousOffset into the ContentView, the issue disappears. But I don't understand why.
I changed my code based on your comment. In my code this drag gesture workes fine. The only bug right now I see is in the first time drag gesture is activated. Maybe this will lead you to your desired solution.
struct DraggableCircle: View {
#Binding var circleOffset: CGSize
#State private var previousOffset: CGSize
var body: some View {
Circle().fill(Color.green)
.frame(width: 100)
.offset(self.circleOffset)
.gesture(
DragGesture()
.onChanged { event in
print("\nDragGesture onChanged")
self.circleOffset = CGSize(width: event.location.x - (event.startLocation.x - self.previousOffset.width),
height: event.location.y - (event.startLocation.y - self.previousOffset.height))
}
.onEnded { event in
self.circleOffset = CGSize(width: event.location.x - (event.startLocation.x - self.previousOffset.width),
height: event.location.y - (event.startLocation.y - self.previousOffset.height))
self.previousOffset = self.circleOffset
}
)
}
init(offset: Binding<CGSize>) {
print("Init with offset \(offset.wrappedValue)")
self._circleOffset = offset
self._previousOffset = State(initialValue: offset.wrappedValue)
print("offset = \(self.offset), previousOffset=\(self.previousOffset)")
}
}
struct ContentView: View {
#State var circleOffset = CGSize()
var body: some View {
GeometryReader { reader in
Rectangle().fill(Color.orange)
.overlay(
DraggableCircle(offset: self.$circleOffset)
)
.onAppear {
self.circleOffset = CGSize(width: reader.size.width / 2,
height: reader.size.height / 2)
print("size: \(reader)\n")
}
}
}
}