SwiftUI Drag gesture is not ending - xcode

I was trying to create a custom View with rotation effects in SwiftUI. Instead of using rotation gestures, I was trying to use the Drag Gesture to rotate the Parent by adding a separate view to the parent. Dragging the child (The Blue Dot) would rotate its parent (ZStack). But I've faced difficulty while dragging the blue dot. The drag gesture is not ending. Can you guys help me to find out what I did wrong?
import SwiftUI
struct EditingHolder: View {
/* For Rotation Circle Drag */
#State private var currDragRect : CGSize = .zero
#State private var prevDragRect : CGSize = .zero
var rectRadius : CGFloat {
let sum : CGFloat = 2*pow(150, 2)
return sqrt(sum/4)
}
var dragRadius : CGFloat {
let height = prevDragRect.height + currDragRect.height
let width = prevDragRect.width + currDragRect.width
let sum = pow(height, 2) + pow(width, 2)
return sqrt(sum/4)
}
var rotateAngle : CGFloat{
let angle = asin(dragRadius/rectRadius)
print("🍄 Angle Produced = ", angle)
return angle
}
var body: some View {
/* **** Gestures **** */
let rotateDrag = DragGesture()
.onChanged({ value in
print("🏵 Rotate Circle Drag Started ...")
currDragRect = value.translation
}).onEnded({ _ in
print("🏵 Rotate Circle Drag Ended ✅")
prevDragRect.height += currDragRect.height
prevDragRect.width += currDragRect.width
currDragRect = .zero
})
//************* Views *******************
GeometryReader { geo in
ZStack(alignment: .center) {
Rectangle()
.padding()
.foregroundColor(Color.yellow)
///Rotate Circle `top`
Circle()
.foregroundColor(Color.blue)
.frame(width: 20, height: 20)
.position(x: 150 - 3, y: 3)
.gesture(rotateDrag)
}.frame(width:150, height: 150, alignment: .center)
.border(.green, width: 3)
.position(x: geo.size.width/2, y: geo.size.height/2)
.rotationEffect(.radians(rotateAngle))
}
//************* Views *******************
}
}
struct EditingHolder_Previews: PreviewProvider {
static var previews: some View {
EditingHolder()
}
}

To provide high priority to a specific gesture there is a modifier called .highPriorityGesture(). Have a look here for a better explanation How to use gestures in SwiftUI.
I've updated the angle calculations,
/* **** Gestures **** */
let rotateDrag = DragGesture()
.onChanged({ value in
print("🏵 Rotate Circle Drag Started ...")
let difY = center.y - value.location.y
let difX = center.x - value.location.x
//Initial Angle when the drag started..
if deltaAngle == 0{
deltaAngle = atan2(difY, difX)
}else{
angle = atan2(difY, difX) - deltaAngle
}
}).onEnded({ _ in
print("🏵 Rotate Circle Drag Ended ✅")
withAnimation {
angle = 0
deltaAngle = 0
}
})
//************* Views *******************
Now to add .highPriorityGesture(rotateDrag) to ZStack.
.onTapGesture() is added to get the center for angle calculation. Tap on the view and then rotate by dragging the blue dot.
Here is the final implementation,
struct EditingHolder: View {
/* For Rotation Circle Drag */
#State private var center : CGPoint = .zero
#State private var angle : CGFloat = 0
#State private var deltaAngle : CGFloat = 0
var body: some View {
/* **** Gestures **** */
let rotateDrag = DragGesture()
.onChanged({ value in
print("🏵 Rotate Circle Drag Started ...")
let difY = center.y - value.location.y
let difX = center.x - value.location.x
//Initial Angle when the drag started..
if deltaAngle == 0{
deltaAngle = atan2(difY, difX)
}else{
angle = atan2(difY, difX) - deltaAngle
}
}).onEnded({ _ in
print("🏵 Rotate Circle Drag Ended ✅")
withAnimation {
angle = 0
deltaAngle = 0
}
})
//************* Views *******************
GeometryReader { geo in
ZStack(alignment: .center) {
Rectangle()
.padding()
.foregroundColor(Color.yellow)
///Rotate Circle `top`
Circle()
.foregroundColor(Color.blue)
.frame(width: 20, height: 20)
.position(x: 150 - 3, y: 3)
.gesture(rotateDrag)
}.frame(width:150, height: 150, alignment: .center)
.border(.green, width: 3)
.position(x: geo.size.width/2, y: geo.size.height/2)
.rotationEffect(Angle(radians: angle))
/* You have make the gesture a high priority */
.highPriorityGesture(rotateDrag)
.onTapGesture {
print("☘️ Center assigned..")
center = CGPoint(x: geo.frame(in: .global).size.width/2, y: geo.frame(in: .global).size.height/2)
}
}
//************* Views *******************
}
}
struct EditingHolder_Previews: PreviewProvider {
static var previews: some View {
EditingHolder()
}
}

The sequence of drag gesture and rotation is important, otherwise SwiftUI looses context of the dragged view (which is changing by drag).
Also you don't need GeometryReader. Here is an example that works in regards to the dragging, the angle calculation needs some more work.
struct ContentView: View {
/* For Rotation Circle Drag */
#State private var currDragRect : CGSize = .zero
#State private var prevDragRect : CGSize = .zero
let rectRadius : CGFloat = 75
var dragRadius : CGFloat {
let height = prevDragRect.height + currDragRect.height
let width = prevDragRect.width + currDragRect.width
let sum = pow(height, 2) + pow(width, 2)
return sqrt(sum/4)
}
var rotateAngle : CGFloat{
let x = min(1, max(-1, dragRadius/rectRadius)) // asin only defined in -1...1
let angle = asin(x)
print("🍄 Angle Produced = ", angle)
return angle
}
var body: some View {
/* **** Gestures **** */
let rotateDrag = DragGesture()
.onChanged { value in
print("🏵 Rotate Circle Drag Started ...")
currDragRect = value.translation
}
.onEnded { _ in
print("🏵 Rotate Circle Drag Ended ✅")
prevDragRect.height += currDragRect.height
prevDragRect.width += currDragRect.width
currDragRect = .zero
}
//************* Views *******************
ZStack(alignment: .topTrailing) {
Rectangle()
.padding()
.foregroundColor(Color.yellow)
.border(.green, width: 3)
///Rotate Circle `top`
Circle()
.foregroundColor(Color.blue)
.frame(width: 20, height: 20)
.offset(x: 8, y: -8)
}
.rotationEffect(.radians(rotateAngle)) // rotation here
.gesture(rotateDrag) // drag here
.frame(width:150, height: 150, alignment: .center)
//************* Views *******************
}
}

Related

SwiftUI Animation Circle with Colors

My problem is simple I think but I can't figure how solve it.
I've this :
struct ArcSelectionView: View {
#Binding var isShowing: Bool
#Binding var curColor: Color
#Binding var colorToPress: Color
#Binding var score: Int
#State var colors = [Color.blue, Color.red, Color.green, Color.yellow]
var body: some View {
ZStack {
ForEach(1 ..< 5, id: \.self) { item in
Circle()
.trim(from: self.isShowing ? CGFloat((Double(item) * 0.25) - 0.25) : CGFloat(Double(item) * 0.25),
to: CGFloat(Double(item) * 0.25))
.stroke(self.colors[item - 1], lineWidth: 50)
.frame(width: 300, height: 300)
.onTapGesture {
if colors[item - 1] == colorToPress {
score += 1
}
isShowing.toggle()
colorToPress = colors.randomElement() ?? Color.offWhite
colors.shuffle()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
self.isShowing.toggle()
}
}
}
}
.opacity(self.isShowing ? 1 : 0)
.rotationEffect(.degrees(self.isShowing ? 0 : 180))
.animation(.linear(duration: 0.35))
}
}
If I didn't shuffle colors in the .onTapGesture, everything is ok. But If I do, I've a strange plain Circle that appears in the middle and disappear after. It's ugly. Ugly Circle
Thank you for your help !
The issue is with the animation of the Circles. The better solution is to use arc shapes. Here is a working solution:
struct ArcSelectionView: View {
#Binding var curColor: Color
#Binding var colorToPress: Color
#Binding var score: Int
#State private var colors = [Color.blue, Color.red, Color.green, Color.yellow]
#State private var pct: CGFloat = 0.25
#State private var originalPCT: CGFloat = 0.25
let duration: Double = 0.35
var body: some View {
ZStack {
CircleView(wedge: originalPCT)
// I am not sure why, but at there is a difference of 10 in the sizes of the
// circle and the modifier. This corrects for it so the touch is accurate.
.frame(width: 310, height: 310)
PercentageArc(Color.clear, colors: colors, pct: pct) {
// With this solution you must have the callback sent to
// the main thread. This was unnecessary with AnimatbleModifier.
DispatchQueue.main.async {
pct = originalPCT
}
}
.animation(.linear(duration: duration), value: pct)
.frame(width: 300, height: 300)
// This forces the view to ignore taps.
.allowsHitTesting(false)
}
.onAppear {
pct = 1.0 / CGFloat(colors.count)
originalPCT = pct
}
}
func CircleView(wedge: CGFloat) -> some View {
ZStack {
// Array(zip()) is a cleaner and safe way of using indices AND you
// have the original object to use as well.
ForEach(Array(zip(colors, colors.indices)), id: \.0) { color, index in
Circle()
.trim(from: CGFloat((Double(index) * wedge)),
to: CGFloat(Double(index + 1) * wedge))
// The color of the stroke should match your background color.
// Clear won't work.
.stroke(.white, lineWidth: 50)
.onTapGesture {
if color == colorToPress {
score += 1
print("score!")
}
pct = 0
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
colorToPress = colors.randomElement() ?? .white
colors.shuffle()
}
}
}
}
}
}
struct PercentageArc<Content>: View, Animatable where Content: View {
private var content: Content
private var colors: [Color]
private var pct: CGFloat
private var target: CGFloat
private var onEnded: () -> ()
init(_ content: Content, colors: [Color], pct: CGFloat, onEnded: #escaping () -> () = {}) {
self.content = content
self.colors = colors
self.pct = pct
self.target = pct
self.onEnded = onEnded
}
var animatableData: CGFloat {
get { pct }
set { pct = newValue
// newValue here is interpolating by engine, so changing
// from previous to initially set, so when they got equal
// animation ended
if newValue == target {
onEnded()
}
}
}
var body: some View {
content
.overlay(
ForEach(Array(zip(colors, colors.indices)), id: \.0) { color, index in
ArcPortionShape(pct: pct, startAngle: .degrees(1.0 / CGFloat(colors.count) * CGFloat(index) * 360.0))
.foregroundColor(color)
}
)
}
struct ArcPortionShape: InsettableShape {
let pct: CGFloat
let startAngle: Angle
var insetAmount = 0.0
init(pct: CGFloat, startAngle: Angle) {
self.pct = pct
self.startAngle = startAngle
}
var portion: CGFloat {
pct * 360.0
}
var endAngle: Angle {
.degrees(startAngle.degrees + portion)
}
func path(in rect: CGRect) -> Path {
var p = Path()
p.addArc(center: CGPoint(x: rect.width / 2.0, y:rect.height / 2.0),
radius: rect.height / 2.0 + 5.0,
startAngle: startAngle,
endAngle: endAngle,
clockwise: false)
return p.strokedPath(.init(lineWidth: 50))
}
func inset(by amount: CGFloat) -> some InsettableShape {
var arc = self
arc.insetAmount += amount
return arc
}
}
}
Originally, I made this with an AnimatableModifier, but it is deprecated, and the solution using it fails if it is placed in ANY stack or NavigationView. I can see why AnimatableModifier is deprecated.
This solution draws inspiration from this answer from Asperi, for the callback idea, though the solution will not work in iOS 15.2.

What is the image size limitation for ScrollView in SwiftUI?

I am trying to create a scrollable image for large images that are 30,000 by 1800 in size. When I add the image to Assets and reference it in ContentView nothing shows. Smaller images (640 x 480) show and are scrollable.
Here is the code:
struct ContentView: View {
#State var scale: CGFloat = 1.0
#State var isTapped: Bool = false
#State var pointTapped: CGPoint = CGPoint.zero
#State var draggedSize: CGSize = CGSize.zero
#State var previousDragged: CGSize = CGSize.zero
var body: some View {
GeometryReader {reader in
Image("scroll")
.resizable()
.scaledToFit()
.animation(.default)
.offset(x: self.draggedSize.width, y: 0)
.scaleEffect(self.scale)
.scaleEffect(self.isTapped ? 2 : 1, anchor: UnitPoint(x: self.pointTapped.x / reader.frame(in: .global).maxX, y: self.pointTapped.y / reader.frame(in: .global).maxY))
.gesture(TapGesture(count: 2)
.onEnded({
self.isTapped = !self.isTapped
}).simultaneously(with: DragGesture(minimumDistance: 0, coordinateSpace: .global).onChanged({ (value) in
self.pointTapped = value.startLocation
self.draggedSize = CGSize(width: value.translation.width + self.previousDragged.width, height: value.translation.height + self.previousDragged.height)
// print(value.startLocation)
}).onEnded({ (value) in
// print(value.location)
let offsetWidth = (reader.frame(in: .global).maxX * self.scale - reader.frame(in: .global).maxX) / 2
let newDraggedWidth = self.draggedSize.width * self.scale
if (newDraggedWidth > offsetWidth) {
self.draggedSize = CGSize(width: offsetWidth / self.scale, height: value.translation.height + self.previousDragged.height)
} else if (newDraggedWidth < -offsetWidth) {
self.draggedSize = CGSize(width: -offsetWidth / self.scale, height: value.translation.height + self.previousDragged.height)
} else {
self.draggedSize = CGSize(width: value.translation.width + self.previousDragged.width, height: value.translation.height + self.previousDragged.height)
}
self.previousDragged = self.draggedSize
}))).gesture(MagnificationGesture().onChanged({ (scale) in
self.scale = scale.magnitude
}).onEnded({ (scaleFinal) in
self.scale = scaleFinal.magnitude
}))
}
}
}
try adding to the Image:
.renderingMode(.original)
just before
.resizable()

How to pinch and scroll an image in SwiftUI?

I want to add an image viewer in my application but I have trouble implementing it. I want to display an image and allow the user to pinch and scroll in the image to examine it in details. Based on what I've gathered from multiple Internet posts, I have something kinda working, but not exactly. Here is my code:
import SwiftUI
struct ContentView: View {
#State var currentScale: CGFloat = 1.0
#State var previousScale: CGFloat = 1.0
#State var currentOffset = CGSize.zero
#State var previousOffset = CGSize.zero
var body: some View {
ZStack {
Image("bulldog2")
.resizable()
.edgesIgnoringSafeArea(.all)
.aspectRatio(contentMode: .fit)
.offset(x: self.currentOffset.width, y: self.currentOffset.height)
.scaleEffect(self.currentScale)
.gesture(DragGesture()
.onChanged { value in
let deltaX = value.translation.width - self.previousOffset.width
let deltaY = value.translation.height - self.previousOffset.height
self.previousOffset.width = value.translation.width
self.previousOffset.height = value.translation.height
self.currentOffset.width = self.currentOffset.width + deltaX / self.currentScale
self.currentOffset.height = self.currentOffset.height + deltaY / self.currentScale }
.onEnded { value in self.previousOffset = CGSize.zero })
.gesture(MagnificationGesture()
.onChanged { value in
let delta = value / self.previousScale
self.previousScale = value
self.currentScale = self.currentScale * delta
}
.onEnded { value in self.previousScale = 1.0 })
VStack {
Spacer()
HStack {
Text("Menu 1").padding().background(Color.white).cornerRadius(30).padding()
Spacer()
Text("Menu 2").padding().background(Color.white).cornerRadius(30).padding()
}
}
}
}
}
The initial view looks like this:
The first problem I have is that I can move the image too far in the way that I can see outside of the image. This can cause the image not to be not visible in the application anymore if it moved too far.
The second problem, which is not a big one, is that I can scale down the image but it becomes too small compared to the view. I want to constraint it so that "fit" would be its smallest size. Is there a better way than constraining self.currentScale and self.previousScale?
The third problem is that if I change the image to fill the space, the bottom menu gets larger that the phone's screen.
I'm not an iOS developer and there is probably a much better way to implement this feature. Thanks for you help.
I can answer 2 of 3 questions. I can't repeat the third one.
you can use GeometryReader and use it's frame and size to make some constraints (I'll show it in example below);
maybe the most simplest and better way is just to use max function, like .scaleEffect(max(self.currentScale, 1.0)).
Here is changed example:
struct BulldogImageViewerView: View {
#State var currentScale: CGFloat = 1.0
#State var previousScale: CGFloat = 1.0
#State var currentOffset = CGSize.zero
#State var previousOffset = CGSize.zero
var body: some View {
ZStack {
GeometryReader { geometry in // here you'll have size and frame
Image("bulldog")
.resizable()
.edgesIgnoringSafeArea(.all)
.aspectRatio(contentMode: .fit)
.offset(x: self.currentOffset.width, y: self.currentOffset.height)
.scaleEffect(max(self.currentScale, 1.0)) // the second question
.gesture(DragGesture()
.onChanged { value in
let deltaX = value.translation.width - self.previousOffset.width
let deltaY = value.translation.height - self.previousOffset.height
self.previousOffset.width = value.translation.width
self.previousOffset.height = value.translation.height
let newOffsetWidth = self.currentOffset.width + deltaX / self.currentScale
// question 1: how to add horizontal constraint (but you need to think about scale)
if newOffsetWidth <= geometry.size.width - 150.0 && newOffsetWidth > -150.0 {
self.currentOffset.width = self.currentOffset.width + deltaX / self.currentScale
}
self.currentOffset.height = self.currentOffset.height + deltaY / self.currentScale }
.onEnded { value in self.previousOffset = CGSize.zero })
.gesture(MagnificationGesture()
.onChanged { value in
let delta = value / self.previousScale
self.previousScale = value
self.currentScale = self.currentScale * delta
}
.onEnded { value in self.previousScale = 1.0 })
}
VStack {
Spacer()
HStack {
Text("Menu 1").padding().cornerRadius(30).background(Color.blue).padding()
Spacer()
Text("Menu 2").padding().cornerRadius(30).background(Color.blue).padding()
}
}
}
}
}
with this code I achieved:
user can't move picture away from the screen in horizontal direction;
user can't make picture smaller;

How to update different views using SwiftUI?

I am aiming to make a program in which I am using using SwiftUI buttons to update by SCNView in SceneKit. I have a cylinder as a SCNCylinder in my SCNView inside a frame in SwiftUI. I want my cylinder to rotate about 180° after I press the button. In my current code I have used #State and #Binding to update the view. But somehow the cylinder rotates as soon as I run the code, not waiting for me to touch the button. Not sure why this happens
Here is my code :
struct ContentView: View {
#State var rotationAngle: Float = 180
var body: some View {
VStack{
Button(action: {
// What to perform
self.rotationAngle = 180
}) {
// How the button looks like
Text("180°")
.frame(width: 100, height: 100)
.position(x: 225, y: 500)
}
SceneKitView(angle: self.$rotationAngle)
.frame(width: 300, height: 300)
.position(x: 225, y: 0)
}
}
}
struct SceneKitView: UIViewRepresentable {
#Binding var angle: Float
func degreesToRadians(_ degrees: Float) -> CGFloat {
return CGFloat(degrees * .pi / 180)
}
func makeUIView(context: UIViewRepresentableContext<SceneKitView>) -> SCNView {
let sceneView = SCNView()
sceneView.scene = SCNScene()
sceneView.allowsCameraControl = true
sceneView.autoenablesDefaultLighting = true
sceneView.backgroundColor = UIColor.white
sceneView.frame = CGRect(x: 0, y: 10, width: 0, height: 1)
return sceneView
}
func updateUIView(_ sceneView: SCNView, context: UIViewRepresentableContext<SceneKitView>) {
let cylinder = SCNCylinder(radius: 0.02, height: 2.0)
let cylindernode = SCNNode(geometry: cylinder)
cylindernode.position = SCNVector3(x: 0, y: 0, z: 0)
cylinder.firstMaterial?.diffuse.contents = UIColor.green
cylindernode.pivot = SCNMatrix4MakeTranslation(0, -1, 0)
let rotation = SCNAction.rotate(by: self.degreesToRadians(self.angle), around: SCNVector3(1, 0, 0), duration: 5)
sceneView.scene?.rootNode.addChildNode(cylindernode)
cylindernode.runAction(rotation)
}
typealias UIViewType = SCNView
}
I want the cylinder to rotate after I press the button. Please help me with this problem.
just set startingAngle to 0
#State var rotationAngle: Float = 0

How to update a SceneKit scene with SwiftUI?

I have a cylinder as SCNCylinder in a SCNScene in SceneKit and want to display it in a frame in SwiftUI. My goal is to rotate the cylinder by a angle of 180° or 90° (as the user chooses). To take the input (of the angle of rotation) i have used Text() and onTapGesture{ .... } property in SwiftUI. After I tap the text, the cylinder rotates but now I have two cylinders, one at the original position and one rotating at an desired angle. I am not sure why this happens. I want the same cylinder to rotate, not a identical copy of that doing it. I have connected the SwiftUI view and SceneKit view by using #State and #Binding.
Here is my code :
struct ContentView: View {
#State var rotationAngle = 0
var body: some View {
VStack{
Text("180°").onTapGesture {
self.rotationAngle = 180
}
Spacer()
Text("90°").onTapGesture {
self.rotationAngle = 90
}
SceneKitView(angle: $rotationAngle)
.position(x: 225.0, y: 200)
.frame(width: 300, height: 300, alignment: .center)
}
}
}
struct SceneKitView: UIViewRepresentable {
#Binding var angle: Int
func degreesToRadians(_ degrees: Float) -> CGFloat {
return CGFloat(degrees * .pi / 180)
}
func makeUIView(context: UIViewRepresentableContext<SceneKitView>) -> SCNView {
let sceneView = SCNView()
sceneView.scene = SCNScene()
sceneView.allowsCameraControl = true
sceneView.autoenablesDefaultLighting = true
sceneView.backgroundColor = UIColor.white
sceneView.frame = CGRect(x: 0, y: 10, width: 0, height: 1)
return sceneView
}
func updateUIView(_ sceneView: SCNView, context: UIViewRepresentableContext<SceneKitView>) {
let cylinder = SCNCylinder(radius: 0.02, height: 2.0)
let cylindernode = SCNNode(geometry: cylinder)
cylindernode.position = SCNVector3(x: 0, y: 0, z: 0)
cylinder.firstMaterial?.diffuse.contents = UIColor.green
cylindernode.pivot = SCNMatrix4MakeTranslation(0, -1, 0)
let inttofloat = Float(self.angle)
let rotation = SCNAction.rotate(by: self.degreesToRadians(inttofloat), around: SCNVector3(1, 0, 0), duration: 5)
cylindernode.runAction(rotation)
sceneView.scene?.rootNode.addChildNode(cylindernode)
}
typealias UIViewType = SCNView
}
I want to have a single cylinder rotation at a given angle.
The problem is, that updateUIView will be called several times. You can check this by adding a debug point there. Because of that your cylinder will be added several times. So you can solve this by many ways...one way would be to delete all nodes in your sceneview before starting your animation like so:
func updateUIView(_ sceneView: SCNView, context: UIViewRepresentableContext<SceneKitView>) {
sceneView.scene?.rootNode.enumerateChildNodes { (node, stop) in
node.removeFromParentNode()
}

Resources