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

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

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

How to animate both offset axes

How can i animate both .offset() axes independently? This looks like a bug right? The blue should keep bouncing up and down but the y animation stops once I try to animate the x offset.
#State var offsetX = 0.0
#State var offsetY = 0.0
var body: some View {
VStack {
Color.blue
.frame(width: 50, height: 50)
.offset(x: offsetX, y: offsetY)
.onAppear {
withAnimation(.easeInOut.repeatForever(autoreverses: true)) {
offsetY = 300
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation {
offsetX = 100
}
}
}
}
}

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.

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