View is re-initialized after DragGesture onChanged - swiftui

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

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/

How to animate a view in a circular motion using its real-time position coordinates?

I'm currently working on a SwiftUI project, and in order to detect intersections/collisions, I need real-time coordinates, which SwiftUI animations cannot offer. After doing some research, I came across a wonderful question by Kike regarding how to get the real-time coordinates of a view when it is moving/transitioning. And Pylyp Dukhov's answer to that topic recommended utilizing CADisplayLink to calculate the position for each frame and provided a workable solution that did return the real time values when transitioning.
But I'm so unfamiliar with CADisplayLink and creating custom animations that I'm not sure I'll be able to bend it to function the way I want it to.
So this is the animation I want to achieve using CADisplayLink that animates the orange circle view in a circular motion using its position coordinates and repeats forever:
Here is the SwiftUI code:
struct CircleView: View {
#Binding var moveClockwise: Bool
#Binding var duration: Double // Works as speed, since it repeats forever
let geo: GeometryProxy
var body: some View {
ZStack {
Circle()
.stroke()
.frame(width: geo.size.width, height: geo.size.width, alignment: .center)
//MARK: - What I have with SwiftUI animation
Circle()
.fill(.orange)
.frame(width: 35, height: 35, alignment: .center)
.offset(x: -CGFloat(geo.size.width / 2))
.rotationEffect(.degrees(moveClockwise ? 360 : 0))
.animation(
.linear(duration: duration)
.repeatForever(autoreverses: false), value: moveClockwise
)
//MARK: - What I need with CADisplayLink
// Circle()
// .fill(.orange)
// .frame(width: 35, height: 35, alignment: .center)
// .position(CGPoint(x: pos.realTimeX, y: realTimeY))
Button("Start Clockwise") {
moveClockwise = true
// pos.startMovement
}.foregroundColor(.orange)
}.fixedSize()
}
}
struct ContentView: View {
#State private var moveClockwise = false
#State private var duration = 2.0 // Works as speed, since it repeats forever
var body: some View {
VStack {
GeometryReader { geo in
CircleView(moveClockwise: $moveClockwise, duration: $duration, geo: geo)
}
}.padding(20)
}
}
This is what I have currently with CADisplayLink, I added the coordinates to make a circle and that’s about it & it doesn’t repeat forever like the gif does:
Here is the CADisplayLink + real-time coordinate version that I’ve tackled and got lost:
struct Point: View {
var body: some View {
Circle()
.fill(.orange)
.frame(width: 35, height: 35, alignment: .center)
}
}
struct ContentView: View {
#StateObject var P: Position = Position()
var body: some View {
VStack {
ZStack {
Circle()
.stroke()
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width, alignment: .center)
Point()
.position(x: P.realtimePosition.x, y: P.realtimePosition.y)
}
Text("X: \(P.realtimePosition.x), Y: \(P.realtimePosition.y)")
}.onAppear() {
P.startMovement()
}
}
}
class Position: ObservableObject, Equatable {
struct AnimationInfo {
let startDate: Date
let duration: TimeInterval
let startPoint: CGPoint
let endPoint: CGPoint
func point(at date: Date) -> (point: CGPoint, finished: Bool) {
let progress = CGFloat(max(0, min(1, date.timeIntervalSince(startDate) / duration)))
return (
point: CGPoint(
x: startPoint.x + (endPoint.x - startPoint.x) * progress,
y: startPoint.y + (endPoint.y - startPoint.y) * progress
),
finished: progress == 1
)
}
}
#Published var realtimePosition = CGPoint.zero
private var mainTimer: Timer = Timer()
private var executedTimes: Int = 0
private lazy var displayLink: CADisplayLink = {
let displayLink = CADisplayLink(target: self, selector: #selector(displayLinkAction))
displayLink.add(to: .main, forMode: .default)
return displayLink
}()
private let animationDuration: TimeInterval = 0.1
private var animationInfo: AnimationInfo?
private var coordinatesPoints: [CGPoint] {
let screenWidth = UIScreen.main.bounds.width
let screenHeight = UIScreen.main.bounds.height
// great progress haha
let radius: Double = Double(screenWidth / 2)
let center = CGPoint(x: screenWidth / 2, y: screenHeight / 2)
var coordinates: [CGPoint] = []
for i in stride(from: 1, to: 360, by: 10) {
let radians = Double(i) * Double.pi / 180 // raiments = degrees * pI / 180
let x = Double(center.x) + radius * cos(radians)
let y = Double(center.y) + radius * sin(radians)
coordinates.append(CGPoint(x: x, y: y))
}
return coordinates
}
// Conform to Equatable protocol
static func ==(lhs: Position, rhs: Position) -> Bool {
// not sure why would you need Equatable for an observable object?
// this is not how it determines changes to update the view
if lhs.realtimePosition == rhs.realtimePosition {
return true
}
return false
}
func startMovement() {
mainTimer = Timer.scheduledTimer(
timeInterval: 0.1,
target: self,
selector: #selector(movePoint),
userInfo: nil,
repeats: true
)
}
#objc func movePoint() {
if (executedTimes == coordinatesPoints.count) {
mainTimer.invalidate()
return
}
animationInfo = AnimationInfo(
startDate: Date(),
duration: animationDuration,
startPoint: realtimePosition,
endPoint: coordinatesPoints[executedTimes]
)
displayLink.isPaused = false
executedTimes += 1
}
#objc func displayLinkAction() {
guard
let (point, finished) = animationInfo?.point(at: Date())
else {
displayLink.isPaused = true
return
}
realtimePosition = point
if finished {
displayLink.isPaused = true
animationInfo = nil
}
}
}
Inside Position you're calculating position related to whole screen. But .position modifier requires value related to the parent view size.
You need to make your calculations based on the parent size, you can use such sizeReader for this purpose:
extension View {
func sizeReader(_ block: #escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometry in
Color.clear
.onAppear {
block(geometry.size)
}
.onChange(of: geometry.size, perform: block)
}
)
}
}
Usage:
ZStack {
Circle()
.stroke()
.frame(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width)
Point()
.position(x: P.realtimePosition.x, y: P.realtimePosition.y)
}
.sizeReader { size in
P.containerSize = size
}
Also CADisplayLink is not used in the right way. The whole point of this tool is that it's already called on each frame, so you can calculate real time position, so your animation is gonna be really smooth, and you don't need a timer or pre-calculated values for only 180(or any other number) positions.
In the linked answer timer was used because a delay was needed between animations, but in your case the code can be greatly simplified:
class Position: ObservableObject {
#Published var realtimePosition = CGPoint.zero
var containerSize: CGSize?
private lazy var displayLink: CADisplayLink = {
let displayLink = CADisplayLink(target: self, selector: #selector(displayLinkAction))
displayLink.add(to: .main, forMode: .default)
displayLink.isPaused = true
return displayLink
}()
private var startDate: Date?
func startMovement() {
startDate = Date()
displayLink.isPaused = false
}
let animationDuration: TimeInterval = 5
#objc func displayLinkAction() {
guard
let containerSize = containerSize,
let timePassed = startDate?.timeIntervalSinceNow,
case let progress = -timePassed / animationDuration,
progress <= 1
else {
displayLink.isPaused = true
startDate = nil
return
}
let frame = CGRect(origin: .zero, size: containerSize)
let radius = frame.midX
let radians = CGFloat(progress) * 2 * .pi
realtimePosition = CGPoint(
x: frame.midX + radius * cos(radians),
y: frame.midY + radius * sin(radians)
)
}
}
I've tried to make more simplified the implementation, here is the SwiftUI code,
struct RotatingDotAnimation: View {
#State private var moveClockwise = false
#State private var duration = 1.0 // Works as speed, since it repeats forever
var body: some View {
ZStack {
Circle()
.stroke(lineWidth: 4)
.foregroundColor(.white.opacity(0.5))
.frame(width: 150, height: 150, alignment: .center)
Circle()
.fill(.white)
.frame(width: 18, height: 18, alignment: .center)
.offset(x: -63)
.rotationEffect(.degrees(moveClockwise ? 360 : 0))
.animation(.easeInOut(duration: duration).repeatForever(autoreverses: false),
value: moveClockwise
)
}
.onAppear {
self.moveClockwise.toggle()
}
}
}
It'll basically create animation like this,
enter image description here

SwiftUI DragGesture sets offset in infinite loop [duplicate]

This question already has an answer here:
SwiftUI: Error in dragging logic or is this a bug?
(1 answer)
Closed 1 year ago.
I want to drag one view within a ZStack. In this example code the Toolbar has a blue draggable area. It should move Toolbar around the screen, by setting the offset. Instead, it goes into an infinite loop. How can it be fixed to drag correctly?
It prints the following eternally:
changed (36.5, 27.0)
changed (0.0, 0.0)
extension CGPoint {
func toSize() -> CGSize { .init(width:x, height:y) }
}
struct ContentView: View {
#State var offset: CGSize = .zero
var body: some View {
ZStack(alignment: .topLeading) {
Toolbar(dragOffset: $offset)
.offset(offset)
Text(verbatim: "Offset: \(offset)")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
struct Toolbar: View {
#Binding var dragOffset: CGSize
var body: some View {
HStack {
Color.blue.frame(width: 40, height: 40)
.gesture(drag)
Color.green.frame(width: 40, height: 40)
Color.red.frame(width: 40, height: 40)
}
}
var drag: some Gesture {
DragGesture()
.onChanged { value in
print("changed \(value.location)")
dragOffset = value.location.toSize()
}
.onEnded { value in
print("ended")
}
}
}
I needed to specify a CoordinateSpace, because the default is .local. Using .global works. A named space should also work.
It can also be helpful to record the initial offset in a #State variable and then set the new values using that starting value plus the drag's translation.
#State private var dragStartOffset: CGSize? = nil
...
DragGesture(coordinateSpace: .global)
.onChanged { value in
if dragStartOffset == nil {
dragStartOffset = dragOffset
}
dragOffset = dragStartOffset! + value.translation
}
.onEnded { _ in
dragStartOffset = nil
}
You should apply the offset before the gesture.
This ensures you don't create an infinite loop where you drag on the screen, the offset changes, which then changes the position of the view again. This means your gesture offset has now changed, hence creating an infinite loop.
Changes:
struct ContentView: View {
#State var offset: CGSize = .zero
var body: some View {
ZStack(alignment: .topLeading) {
Toolbar(dragOffset: $offset)
// .offset(offset) // <- REMOVE THIS
Text(verbatim: "Offset: \(offset)")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
struct Toolbar: View {
#Binding var dragOffset: CGSize
var body: some View {
HStack {
Color.blue.frame(width: 40, height: 40)
.offset(dragOffset) // <- ADD THIS
.gesture(drag)
Color.green.frame(width: 40, height: 40)
.offset(dragOffset) // <- ADD THIS
Color.red.frame(width: 40, height: 40)
.offset(dragOffset) // <- ADD THIS
}
}
/* ... */
}

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

How to disable hit test on views that are outside the frame due to offset in SwiftUI?

I am using a Paged Stack that scrolls horizontally and snaps to the selected index. The functionality works fine unless I have it alongside another view. When I scroll, the offset is blocking the view.
I have omitted .clipped() just so it can be visualized. If I add it, it looks visually fine but it is still interact-able, blocking gestures intended for the view below it.
struct PagedStack<Content: View>: View {
let pageCount: Int
let spacing: CGFloat
#Binding var currentIndex: Int
let content: Content
#GestureState private var translation: CGFloat = 0
init(pageCount: Int, spacing: CGFloat, currentIndex: Binding<Int>, #ViewBuilder content: () -> Content) {
self.pageCount = pageCount
self.spacing = spacing
self._currentIndex = currentIndex
self.content = content()
}
var body: some View {
GeometryReader { geometry in
HStack(spacing: self.spacing) {
self.content.frame(width: geometry.size.width)
}
.frame(width: geometry.size.width, alignment: .leading)
.offset(x: -CGFloat(self.currentIndex) * (geometry.size.width + self.spacing))
.offset(x: self.translation)
.animation(.interactiveSpring())
.gesture(
DragGesture().updating(self.$translation) { value, state, _ in
state = value.translation.width
}.onEnded { value in
let offset = value.translation.width / geometry.size.width
let newIndex = (CGFloat(self.currentIndex) - offset).rounded()
self.currentIndex = min(max(Int(newIndex), 0), self.pageCount - 1)
}
)
}
}
}
Here is the visual:
I fixed it. I added .contentShape(Rectangle()) to make the hit test area limited to the frame.