Strange magnification behavior: scale starts at 1 - swiftui

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
}
)
}
}

Related

Cannot pan image in zoomed ScrollView?

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.

SwiftUI View Jumps on Initial Drag

I am looking to have a process where a card starts at the bottom of the screen and can be dragged up and down (vertical axis only). I have an issue where on initial drag the card jumps from the bottom of the screen to the top. I have simplified the below to its most minimum to demonstrate the issue. I was thinking it was some sort of coordinate space issue, but changing between local and global doesn't help.
struct ContentView: View {
#State private var offset = CGSize(width: 0, height: UIScreen.main.bounds.height * 0.9)
var body: some View {
GeometryReader { geo in
Color(.red).edgesIgnoringSafeArea(.all)
Color(.orange)
.clipShape(RoundedRectangle(cornerRadius: 20))
.offset(offset)
.animation(.spring())
.gesture(DragGesture()
.onChanged { gesture in
offset.height = gesture.translation.height
}
)
}
}
}
Again when the finger is first place at the top of the bottom orange view and the drag gesture begins upwards, the orange view jumps to the top of the red view.
You can separate the two different offsets to two different modifiers. First add .offset() with your starting amount. Then add another .offset() that will offset the view from the starting offset.
I also added an .onEnded gesture so that it doesn't lag when starting to drag a 2nd time. You'll probably want to change the .onEnded so that at the end of the gesture the view animates toward a locked position.
struct DragView: View {
#State private var offsetY: CGFloat = 0
#State private var lastOffsetY: CGFloat = 0
var body: some View {
GeometryReader { geo in
Color(.red)
.ignoresSafeArea()
Color(.orange)
.clipShape(RoundedRectangle(cornerRadius: 20))
.offset(y: UIScreen.main.bounds.height * 0.8)
.offset(y: offsetY)
.animation(.spring())
.gesture(DragGesture()
.onChanged { gesture in
withAnimation(.spring()) {
offsetY = lastOffsetY + gesture.translation.height
}
}
.onEnded { _ in
lastOffsetY = offsetY
}
)
}
}
}

How do I animate an image to continuously move across the screen?

This code shows the general behavior I'm trying to achieve, but how can I make this continuous so there's no apparent end to the image, with no gaps of white space and a smooth transition? AND - isn't there a better way to do this???
Thanks!
import SwiftUI
struct ContentView: View {
#State private var xVal: CGFloat = 0.0
#State private var timer = Timer.publish(every: 0.05, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Image("game_background_2") //image attached
.aspectRatio(contentMode: .fit)
.offset(x: xVal, y: 0)
.transition(.slide)
.padding()
.onReceive(timer) {_ in
xVal += 2
if xVal == 800 { xVal = 0 }
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Here's a simple approach that could work. First use a GeometryReader to set the frame of the screen. Then add an HStack with 2 images inside. The first image is full size and the 2nd image is limited to the width of the screen. Finally, animate the HStack to move from the .trailing to the .leading edge of the screen frame. We set the animation .repeatForever so that it continues loops and autoReverses: false so that it restarts. When it restarts, however, it should be seamless because the restart position is identical to the 2nd image.
struct ImageBackgroundView: View {
#State var animate: Bool = false
let animation: Animation = Animation.linear(duration: 10.0).repeatForever(autoreverses: false)
var body: some View {
GeometryReader { geo in
HStack(spacing: -1) {
Image("game_background_2")
.aspectRatio(contentMode: .fit)
Image("game_background_2")
.aspectRatio(contentMode: .fit)
.frame(width: geo.size.width, alignment: .leading)
}
.frame(width: geo.size.width, height: geo.size.height,
alignment: animate ? .trailing : .leading)
}
.ignoresSafeArea()
.onAppear {
withAnimation(animation) {
animate.toggle()
}
}
}
}

SwiftUI position and frame animation broken in iOS 14

I have a progress view whose progress / width can be animated. This animation works by itself. However, in some cases, the parent view changes its layout and a new view is added above the progress bar. Then, the progress view moves down to make space for the new view. This is also animated. When both animations happen in iOS 13, the progress view moves down and the width of the blue progress bar is animated at the same time. In iOS 14, the progress view moves down but the blue progress bar also uses its animation to animate the position change. In iOS 13 it was only used for animating the width. This makes the animation look as if the progress bar flies into the progress view and it is wrong and looks weird.
I reproduced the code I have in my app with the following code. This code was also used to record the video.
struct MainView: View {
#State var toggle = false
#State var trim: CGFloat = 0.2
var body: some View {
VStack {
Rectangle().frame(minHeight: 0, maxHeight: self.toggle ? 200 : 0)
ProgressBarView(trim: self.$trim)
Button(
action: {
withAnimation {
self.toggle.toggle()
self.trim = self.toggle ? 0.8 : 0.2
}
},
label: {
Text("Toggle")
})
}
}
}
struct ProgressBarView: View {
#State var grow: Bool = false
#Binding var trim: CGFloat
var body: some View {
ZStack(alignment: .leading) {
GeometryReader { geo in
Rectangle()
.opacity(0.1)
.zIndex(0)
Rectangle()
.frame(
minWidth: 0,
maxWidth: self.grow
? geo.frame(in: .global).width * self.trim
: 0
)
.animation(Animation.easeOut(duration: 1.2).delay(0.5))
.foregroundColor(Color(UIColor.systemBlue))
}
}
.cornerRadius(10)
.frame(height: 20)
.onAppear(perform: {
self.grow = true
})
}
}
Update
This is the original and working animation with the same code on my iPad that is still running iOS 13.7. I just removed the delay and increased the time to make the different animations more obvious.
Update 2
There was some confusion when answering the question so I copied some frames and put them into a screenshot. I hope this helps to understand my question. On the left is the animation on iOS 14 with the unwanted behavior. As you can see the blue progress bar doesn't appear in some images. In other images, it is only partly visible. On the right is the iOS 13 animation. The frames on the image (and all other frames) show the blue progress bar fully visible. It is always completely visible and on top of the background.
I don't understand what change in iOS 14 (or maybe Swift 5.3 or whatever else) caused the animation to be different and I cannot find a workaround to this problem.
Hey there! I made few changes in the ProgressBarView struct. Let's get to the less important changes first.
Removed the GeometryReader and replaced it with screen.width. If you are using the bar inside a container in order to get the width of the container, use GeometryReader.
Changed the Rectangle to a capsule.
To the important one
I target the animation modifier of the bar to the Boolean variable that trigged the change of the progress bar, i.e. grow
struct ProgressBarView: View {
#State var grow: Bool = false
#Binding var trim: CGFloat
let screen = UIScreen.main.bounds
var body: some View {
VStack {
ZStack(alignment: .leading) {
Capsule()
.foregroundColor(Color.black.opacity(0.1))
Capsule()
.frame(width: self.grow ? screen.width * trim : 0)
.foregroundColor(.blue)
.animation(Animation.easeOut(duration: 1).delay(0.5), value: self.grow)
}
.frame(width: screen.width, height: 20)
.onAppear {
self.grow = true
}
}
}
}
UPDATE 1.1
I added an animation to the ZStack for the initial increase of bar from 0 to 0.2
Changed the DispatchQueue delay to 0.18 sec.
The reason for the change in delay is because, since the expanding of the rectangle is changing the position of the bar in the y-direction, the animation effect with duration of 1.2 sec is being applied to the change in position which we don't want. Thus the delay of 0.18 starts the bar animation a bit after the expanding of the rectangle. This is a bit of a hack, but gets the work done. If I find a better solution, I'll update this answer with 'Update 2.0'. If you find a better solution, let me know.
Code
struct MainView: View {
#State var toggle = false
#State var trim: CGFloat = 0.2
var body: some View {
VStack {
Rectangle().frame(minHeight: 0, maxHeight: self.toggle ? 200 : 0)
ProgressBarView(trim: self.$trim)
Button(
action: {
withAnimation {
self.toggle.toggle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
self.trim = self.toggle ? 0.8 : 0.2
}
}
},
label: {
Text("Toggle")
})
}
}
}
struct ProgressBarView: View {
#State var grow: Bool = false
#Binding var trim: CGFloat
let screen = UIScreen.main.bounds
var body: some View {
ZStack(alignment: .leading) {
Capsule()
.opacity(0.1)
Capsule()
.frame(width: self.grow ? screen.width * trim : 0)
.foregroundColor(.blue)
.animation(Animation.easeOut(duration: 1.2), value: self.trim)
}
.frame(width: screen.width, height: 20)
.onAppear {
self.grow = true
}
.animation(.easeOut(duration: 0.5), value: grow)
}
}
I hope this helps you.

How to stop SwiftUI DragGesture from dying when View content changes

In my app, I drag a View horizontally to set a position of a model object. I can also drag it downward and release it to delete the model object. When it has been dragged downward far enough, I indicate the potential for deletion by changing its appearance. The problem is that this change interrupts the DragGesture. I don't understand why this happens.
The example code below demonstrates the problem. You can drag the light blue box side to side. If you pull down, it and it turns to the "rays" system image, but the drag dies.
The DragGesture is applied to the ZStack with a size of 50x50. The ZStack should continue to exist across that state change, no? Why is the drag gesture dying?
struct ContentView: View {
var body: some View {
ZStack {
DraggableThing()
}.frame(width: 300, height: 300)
.border(Color.black, width: 1)
}
}
struct DraggableThing: View {
#State private var willDeleteIfDropped = false
#State private var xPosition: CGFloat = 150
var body: some View {
//Rectangle()
// .fill(willDeleteIfDropped ? Color.red : Color.blue.opacity(0.3))
ZStack {
if willDeleteIfDropped {
Image(systemName: "rays")
} else {
Rectangle().fill(Color.blue.opacity(0.3))
}
}
.frame(width: 50, height: 50)
.position(x: xPosition, y: 150)
.gesture(DragGesture()
.onChanged { val in
print("drag changed \(val.translation)")
self.xPosition = 150 + val.translation.width
self.willDeleteIfDropped = (val.translation.height > 25)
}
.onEnded { val in
print("drag ended")
self.xPosition = 150
}
)
}
}
You need to keep content, which originally captured gesture. So your goal can be achieved with the following changes:
ZStack {
Rectangle().fill(Color.blue.opacity(willDeleteIfDropped ? 0.0 : 0.3))
if willDeleteIfDropped {
Image(systemName: "rays")
}
}