SwiftUI View Jumps on Initial Drag - swiftui

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

Related

Drag gesture only if I start dragging on top of the view that I want to be dragged

I want a view to be dragged only if I start dragging on that view. Currently, if I start dragging from anywhere on the screen and then dragged over it, it drags.
import SwiftUI
struct ContentView: View {
#State private var offset = CGSize.zero
var body: some View {
Text("Hello, world!")
.offset(offset)
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
}
.onEnded { _ in
offset = CGSize.zero
}
)
}
}
Here is a possible solution. Place a view behind the view you want draggable, such as a Color. Place a DragGesture on that to capture the drag if you miss the view you actually want draggable. This has to be a view that will be shown on screen, or this doesn't work, i.e. you can't use Color.clear.
struct ContentView: View {
#State private var offset = CGSize.zero
var body: some View {
ZStack {
// Place a Color behind your view, set to the background color.
// It can't be clear as that doesn't work.
Color.white
// Place a DragGesture there to capture the drag if not on the view you want
.gesture(DragGesture())
Text("Hello, world!")
.offset(offset)
.gesture(
DragGesture()
.onChanged { gesture in
offset = gesture.translation
}
.onEnded { _ in
offset = CGSize.zero
}
)
}
}
}

SwiftUI DragGesture() get start location on screen

I want to make a view "swipeable", however this view only covers a small share of the whole screen. Doing:
var body: some View {
ZStack {
Text("ABC")
.gesture(DragGesture().onChanged(onChanged))
}
}
func onChanged(value: DragGesture.Value) {
print(value.startLocation)
print("onChanged", value.location)
}
Gives me relative values where the user has swiped - however, without knowing the exact location of the view, I'm unable to know where the user's touch is on the screen.
In other words, when the Text is in the center of the screen, going all the way to the right edge gives 200px. But let's say the Text moved to the left, then it would be almost 400. I tried putting another gesture on the ZStack to save the initial value, however it appears you can't have multiple gestures within each other.
How can I make the listener translate the values so the left edge is 0 and the right edge is displayWidth in px, regardless of where the Text is?
You can make use of coordinate spaces, and use that with DragGesture.
Code:
struct ContentView: View {
#State private var location: CGPoint = .zero
var body: some View {
VStack(spacing: 30) {
Text("ABC")
.background(Color.red)
.gesture(
DragGesture(coordinateSpace: .named("screen")).onChanged(onChanged)
)
Text("Location: (\(location.x), \(location.y))")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.coordinateSpace(name: "screen")
}
private func onChanged(value: DragGesture.Value) {
location = value.location
}
}

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

In SwiftUI how can I animate a button offset when displayed

In SwiftUI, I want a button to appear from off screen by dropping in from the top into a final position when the view is initially displayed, I'm not asking for animation when the button is pressed.
I have tried:
Button(action: {}) {
Text("Button")
}.offset(x: 0.0, y: 100.0).animation(.basic(duration: 5))
but no joy.
If you would like to play with offset, this can get you started.
struct ContentView : View {
#State private var offset: Length = 0
var body: some View {
Button(action: {}) { Text("Button") }
.offset(x: 0.0, y: offset)
.onAppear {
withAnimation(.basic(duration: 5)) { self.offset = 100.0 }
}
}
}
I first suggested a .transition(.move(.top)), but I am updating my answer. Unless your button is on the border of the screen, it may not be a good fit. The move is limited to the size of the moved view. So you may need to use offset after all!
Note that to make it start way out of the screen, the initial value of offset can be negative.
First of all you need to create a transition. You could create an extension for AnyTransition or just create a variable. Use the move() modifier to tell the transition to move the view in from a specific edge
let transition = AnyTransition.move(edge: .top);
This alone only works if the view is at the edge of the screen. If your view is more towards the center you can use the combined() modifier to combine another transition such as offset() to add additional offset
let transition = AnyTransition
.move(edge: .top)
.combined(with:
.offset(
.init(width: 0, height: 100)
)
);
This transition will be for both showing and removing a view although you can use AnyTransition.asymmetric() to use different transitions for showing and removing a view
Next create a showButton bool (name this whatever) which will handle showing the button. This will use the #State property wrapper so SwiftUI will refresh the UI when changed.
#State var showButton: Bool = false;
Next you need to add the transition to your button and wrap your button within an if statement checking if the showButton bool is true
if (self.showButton == true) {
Button(action: { }) {
Text("Button")
}
.transition(transition);
}
Finally you can update the showButton bool to true or false within an animation block to animate the button transition. toggle() just reverses the state of the bool
withAnimation {
self.showButton.toggle();
}
You can put your code in onAppear() and set the bool to true so the button is shown when the view appears. You can call onAppear() on most things like a VStack
.onAppear {
withAnimation {
self.showButton = true;
}
}
Check the Apple docs to see what is available for AnyTransition https://developer.apple.com/documentation/swiftui/anytransition
Presents a message box on top with animation:
import SwiftUI
struct MessageView: View {
#State private var offset: CGFloat = -200.0
var body: some View {
VStack {
HStack(alignment: .center) {
Spacer()
Text("Some message")
.foregroundColor(Color.white)
.font(Font.system(.headline).bold())
Spacer()
}.frame(height: 100)
.background(Color.gray.opacity(0.3))
.offset(x: 0.0, y: self.offset)
.onAppear {
withAnimation(.easeOut(duration: 1.5)) { self.offset = 000.0
}
}
Spacer()
}
}
}
For those that do want to start from a Button that moves when you tap on it, try this:
import SwiftUI
struct ContentView : View {
#State private var xLoc: CGFloat = 0
var body: some View {
Button("Tap me") {
withAnimation(.linear(duration: 2)) { self.xLoc+=50.0 }
}.offset(x: xLoc, y: 0.0)
}
}
Or alternatively (can replace Text with anything):
Button(action: {
withAnimation(.linear(duration: 2)) { self.xLoc+=50.0 }
} )
{ Text("Tap me") }.offset(x: xLoc, y: 0.0)