I have the following simple SwiftUI Path of an arrow which I wish to rotate around it's center:
#State var heading: Double = 0.0
#State var xOffset = 0.0
#State var yOffset = 0.0
var body: some View {
TabView {
NavigationStack {
Path { path in
path.move(to: CGPoint(x: xOffset, y: yOffset))
path.addLine(to: CGPoint(x: xOffset + 0, y: yOffset + 20))
path.addLine(to: CGPoint(x: xOffset + 50, y: yOffset + 0))
path.addLine(to: CGPoint(x: xOffset + 0, y: yOffset - 20))
path.addLine(to: CGPoint(x: xOffset + 0, y: yOffset + 0))
}
.stroke(lineWidth: 3)
.foregroundColor(.red)
.rotationEffect(Angle(degrees: heading), anchor: .center)
.transformEffect(CGAffineTransform(translationX: 80, y: 80))
Slider(value: $heading, in: 0...360, step: 30)
}
}
}
It does however not rotate around its center:
How can I rotate a Path around its center (or around any other relative point on its bounds)?
The Path view greedily takes up all of the space available to it, so the view is much larger than you think, and its center is far away. So when you rotate the path, it moves off the screen. You can see this by adding .background(Color.yellow) to the Path view.
It's easier to manage the rotation of the arrow if you make it an .overlay of another View (Color.clear) and then rotate that View. You can make the view visible by using Color.yellow while tuning, and then position the arrow relative to its parent view. The nice thing about this is that when you rotate the parent view, the arrow will stick to it and rotate predictably.
struct ContentView: View {
#State var heading: Double = 0.0
var body: some View {
TabView {
NavigationStack {
Color.clear
.frame(width: 60, height: 60)
.overlay (
Path { path in
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: 0, y: 20))
path.addLine(to: CGPoint(x: 50, y: 0))
path.addLine(to: CGPoint(x: 0, y: -20))
path.addLine(to: CGPoint(x: 0, y: 0))
}
.stroke(lineWidth: 3)
.foregroundColor(.red)
.offset(x: 5, y: 30)
)
.rotationEffect(Angle(degrees: heading), anchor: .center)
Slider(value: $heading, in: 0...360, step: 30)
}
}
}
}
How do I make my code work?
You need to give your Path view a reasonable sized frame. Here I added .frame(width: 60, height: 60) which is big enough to hold your arrow path.
Since you have defined xOffset and yOffset, use them to move the path drawing within its view. Temporarily add a background to your path view with .background(Color.yellow) and then adjust xOffset and yOffset until your arrow is drawing inside of that view. You can then remove this .background.
This has the same effect as the overlay method presented above:
struct ContentView: View {
#State var heading: Double = 0.0
#State var xOffset = 5.0 // here
#State var yOffset = 30.0 // here
var body: some View {
TabView {
NavigationStack {
Path { path in
path.move(to: CGPoint(x: xOffset, y: yOffset))
path.addLine(to: CGPoint(x: xOffset + 0, y: yOffset + 20))
path.addLine(to: CGPoint(x: xOffset + 50, y: yOffset + 0))
path.addLine(to: CGPoint(x: xOffset + 0, y: yOffset - 20))
path.addLine(to: CGPoint(x: xOffset + 0, y: yOffset + 0))
}
.stroke(lineWidth: 3)
.foregroundColor(.red)
// .background(Color.yellow)
.frame(width: 60, height: 60) // here
.rotationEffect(Angle(degrees: heading), anchor: .center)
//.transformEffect(CGAffineTransform(translationX: 80, y: 80))
Slider(value: $heading, in: 0...360, step: 30)
}
}
}
}
Related
I am making an app where I am generating points with random position. I want to connect first point with the second one with the line, and that the second with the third, etc.
My issue is that it is connecting second with the third but in the same time the first is being connected with the third one. And I don't want that. Please help.
This is my code:
struct Dot: Hashable {
var x: CGFloat
var y: CGFloat
}
struct TestShit: View {
#State var lastx = 0.0
#State var lasty = 0.0
#State var dots = [Dot]()
var body: some View {
VStack{
ZStack{
ForEach(dots, id: \.self){ dot in
Circle()
.frame(width: 5, height: 5)
.position(x: dot.x, y: dot.y)
Path { path in
path.move(to: CGPoint(x: lastx, y: lasty))
path.addLine(to: CGPoint(x: dot.x, y: dot.y))
}.stroke(.green, lineWidth: 5)
}
}
Spacer()
Button {
let randx = CGFloat.random(in: 100...300)
let randy = CGFloat.random(in: 100...300)
dots.append(Dot(x: randx, y: randy))
lastx = randx
lasty = randy
} label: {
Text("Button")
}
}
}
}
Here I only maintain 1 path, and append to it each time rather than more than 1 path in the ForEach
import Combine
import SwiftUI
struct Dot: Hashable {
var x: CGFloat
var y: CGFloat
}
class Model: ObservableObject {
#Published
var dots: [Dot] = []
#Published
var path: UIBezierPath?
func addDot(x: CGFloat, y: CGFloat) {
dots.append(Dot(x: x, y: y))
let randomPoint = CGPoint(x: x, y: y)
guard let path = path else {
path = UIBezierPath()
path?.move(to: randomPoint)
path?.lineWidth = 2
return
}
path.addLine(to: CGPoint(x: x, y: y))
}
}
struct TestIt: View {
#ObservedObject
var model: Model
var body: some View {
VStack{
ZStack{
if let path = model.path {
Path(path.cgPath)
.stroke(.green, lineWidth: 5)
}
ForEach(model.dots, id: \.self){ dot in
Circle()
.frame(width: 5, height: 5)
.position(x: dot.x, y: dot.y)
}
}
Spacer()
Button {
let randx = CGFloat.random(in: 100...300)
let randy = CGFloat.random(in: 100...300)
model.addDot(x: randx, y: randy)
} label: {
Text("Button")
}
}
}
}
You might want something a little nicer looking like
Path(path.cgPath.copy(strokingWithWidth: 5, lineCap: .round, lineJoin: .round, miterLimit: 0))
.stroke(.green, lineWidth: 5)
The problem is that with this loop:
Path { path in
path.move(to: CGPoint(x: lastx, y: lasty))
path.addLine(to: CGPoint(x: dot.x, y: dot.y))
}.stroke(.green, lineWidth: 5)
you are creating multiple line segments by moving to the same point, and then adding a line to a different point.
What you want to do is:
set a "start" point
move to that point
for each dot, add a line to the next dot
Take a look at the difference here:
import SwiftUI
struct Dot: Hashable {
var x: CGFloat
var y: CGFloat
}
struct DotsView: View {
// initialize start point
var startX = CGFloat.random(in: 100...300)
var startY = CGFloat.random(in: 100...300)
#State var dots = [Dot]()
var body: some View {
VStack{
ZStack{
Path { path in
// move to start point
path.move(to: CGPoint(x: startX, y: startY))
dots.forEach { dot in
// add line to each dot
path.addLine(to: CGPoint(x: dot.x, y: dot.y))
}
}.stroke(.green, lineWidth: 5)
// draw the start point circle in red
Circle()
.fill(Color.red)
.frame(width: 5, height: 5)
.position(x: startX, y: startY)
// draw each dot circle in blue
ForEach(dots, id: \.self){ dot in
Circle()
.fill(Color.blue)
.frame(width: 5, height: 5)
.position(x: dot.x, y: dot.y)
}
}
Spacer()
Button {
let randx = CGFloat.random(in: 100...300)
let randy = CGFloat.random(in: 100...300)
// add a dot point
dots.append(Dot(x: randx, y: randy))
} label: {
Text("Button")
}
}
}
}
How to make the lower right corner sharp? the result is shown in the screenshot
here is my code now:
struct Test123: View {
var body: some View {
Text("Hello, World!")
.foregroundColor(.white)
.frame(width: 200, height: 50)
.background(Color.black)
}
}
The possible solution is by using .clipShape by preparing any custom shape you want.
Here a demo (prepared with Xcode 13 / iOS 15)
struct SharpShape: Shape {
func path(in rect: CGRect) -> Path {
Path { path in
let chunk = rect.height * 0.5
path.move(to: .zero)
path.addLine(to: CGPoint(x: rect.width, y: 0))
path.addLine(to: CGPoint(x: rect.width, y: chunk))
path.addLine(to: CGPoint(x: max(rect.width - chunk, rect.width / 2), y: rect.height))
path.addLine(to: CGPoint(x: 0, y: rect.height))
}
}
}
struct Test123: View {
var body: some View {
Text("Hello, World!")
.foregroundColor(.white)
.frame(width: 200, height: 50)
.background(Color.black)
.clipShape(SharpShape())
}
}
I'm trying to make a view draggable and/or zoomable only within its clipping container view (otherwise it can run into and conflict with other views' gestures), but nothing I've tried so far keeps the gesture from extending outside the visible boundary of the container.
Here's a simplified demo of the behavior I don't want...
When the red Rectangle goes partially outside the green VStack area (clipped), it responds to drag gestures beyond the green area:
import SwiftUI
import PlaygroundSupport
struct ContentView: View {
#State var position: CGPoint = CGPoint(x: 100, y: 150)
#State var lastPosition: CGPoint = CGPoint(x: 100, y: 150)
var body: some View {
let drag = DragGesture()
.onChanged {
self.position = CGPoint(x: $0.translation.width + self.lastPosition.x, y: $0.translation.height + self.lastPosition.y)
}
.onEnded {_ in
self.lastPosition = self.position
}
return VStack {
Rectangle().foregroundColor(.red)
.frame(width: 150, height: 150)
.position(self.position)
.gesture(drag)
.clipped()
}
.background(Color.green)
.frame(width: 200, height: 300)
}
}
PlaygroundPage.current.setLiveView(ContentView())
How would you limit this gesture to only work inside the container (green area in the example above)?
UPDATE:
#Asperi's solution to the above works well, but when I add a second draggable container next to the one above, I get a "dead area" in the first container inside which I can't drag (it appears to be where the second square would cover the first one if it were not clipped). The problem only happens to the original/left side, not to the new one. I think that has to do with it having higher priority since it is drawn second.
Here's an illustration of the new issue:
And here's the updated code:
struct ContentView: View {
#State var position1: CGPoint = CGPoint(x: 100, y: 150)
#State var lastPosition1: CGPoint = CGPoint(x: 100, y: 150)
let dragArea1: CGRect = CGRect(x: 0, y: 0, width: 200, height: 300)
#State var position2: CGPoint = CGPoint(x: 100, y: 150)
#State var lastPosition2: CGPoint = CGPoint(x: 100, y: 150)
let dragArea2: CGRect = CGRect(x: 0, y: 0, width: 200, height: 300)
var body: some View {
let drag1 = DragGesture(coordinateSpace: .named("dragArea1"))
.onChanged {
guard self.dragArea1.contains($0.startLocation) else { return }
self.position1 = CGPoint(x: $0.translation.width + self.lastPosition1.x, y: $0.translation.height + self.lastPosition1.y)
}
.onEnded {_ in
self.lastPosition1 = self.position1
}
let drag2 = DragGesture(coordinateSpace: .named("dragArea2"))
.onChanged {
guard self.dragArea2.contains($0.startLocation) else { return }
self.position2 = CGPoint(x: $0.translation.width + self.lastPosition2.x, y: $0.translation.height + self.lastPosition2.y)
}
.onEnded {_ in
self.lastPosition2 = self.position2
}
return HStack {
VStack {
Rectangle().foregroundColor(.red)
.frame(width: 150, height: 150)
.position(self.position1)
.gesture(drag1)
.clipped()
}
.background(Color.green)
.frame(width: dragArea1.width, height: dragArea1.height)
VStack {
Rectangle().foregroundColor(.blue)
.frame(width: 150, height: 150)
.position(self.position2)
.gesture(drag2)
.clipped()
}
.background(Color.yellow)
.frame(width: dragArea2.width, height: dragArea2.height)
}
}
}
Any ideas of how to keep dragging disabled outside any containers, as already achieved, but also allow dragging within the full bounds of each container regardless of what happens with others?
Here is possible solution. The idea is to have drag coordinates in container coordinate space and ignore drag if start location is out of that named area.
Tested with Xcode 11.4 / iOS 13.4
struct ContentView: View {
#State var position: CGPoint = CGPoint(x: 100, y: 150)
#State var lastPosition: CGPoint = CGPoint(x: 100, y: 150)
var body: some View {
let area = CGRect(x: 0, y: 0, width: 200, height: 300)
let drag = DragGesture(coordinateSpace: .named("area"))
.onChanged {
guard area.contains($0.startLocation) else { return }
self.position = CGPoint(x: $0.translation.width + self.lastPosition.x, y: $0.translation.height + self.lastPosition.y)
}
.onEnded {_ in
self.lastPosition = self.position
}
return VStack {
Rectangle().foregroundColor(.red)
.frame(width: 150, height: 150)
.position(self.position)
.gesture(drag)
.clipped()
}
.background(Color.green)
.frame(width: area.size.width, height: area.size.height)
.coordinateSpace(name: "area")
}
}
Two days I was looking for a solution to a similar problem
#Asperi's solution helps, but it is not universal for 3 or more figures
My solution: I adding
.contentShape(Rectangle())
before
.gesture(DragGesture().onChanged {
this article helped me.
https://www.hackingwithswift.com/quick-start/swiftui/how-to-control-the-tappable-area-of-a-view-using-contentshape
I hope it will be useful to someone.
Sample code:
var body: some View {
VStack {
Image("my image")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(200, 200)
.clipShape(Rectangle())
.contentShape(Rectangle()) // <== this code helped me
.gesture(
DragGesture()
.onChanged {
//
}
.onEnded {_ in
//
}
)
}
}
For the example above, the code could be like this:
struct ContentView: View {
#State var position1: CGPoint = CGPoint(x: 100, y: 150)
#State var lastPosition1: CGPoint = CGPoint(x: 100, y: 150)
let dragArea1: CGSize = CGSize(width: 200, height: 300)
#State var position2: CGPoint = CGPoint(x: 100, y: 150)
#State var lastPosition2: CGPoint = CGPoint(x: 100, y: 150)
let dragArea2: CGSize = CGSize(width: 200, height: 300)
var body: some View {
let drag = DragGesture()
.onChanged {
if $0.startLocation.x <= self.dragArea1.width {
self.position1 = CGPoint(x: $0.translation.width + self.lastPosition1.x, y: $0.translation.height + self.lastPosition1.y)
} else {
self.position2 = CGPoint(x: $0.translation.width + self.lastPosition2.x, y: $0.translation.height + self.lastPosition2.y)
}
}
.onEnded {_ in
self.lastPosition1 = self.position1
self.lastPosition2 = self.position2
}
return HStack {
VStack {
Rectangle().foregroundColor(.red)
.frame(width: 150, height: 150)
.position(self.position1)
.clipped()
}
.background(Color.green)
.frame(width: dragArea1.width, height: dragArea1.height)
VStack {
Rectangle().foregroundColor(.blue)
.frame(width: 150, height: 150)
.position(self.position2)
.clipped()
}
.background(Color.yellow)
.frame(width: dragArea2.width, height: dragArea2.height)
}
.clipShape(Rectangle()) //<=== This
.contentShape(Rectangle()) //<=== and this
.gesture(drag)
} }
For the 2nd part (i.e. update to the original question), here's what I ended up with. Basically, I combined the two separate drag gestures into one gesture that covers the whole HStack, and then directed the gesture to the appropriate #State variable depending on where in the HStack it started.
Demo of the Result:
Code:
struct ContentView: View {
#State var position1: CGPoint = CGPoint(x: 100, y: 150)
#State var lastPosition1: CGPoint = CGPoint(x: 100, y: 150)
let dragArea1: CGSize = CGSize(width: 200, height: 300)
#State var position2: CGPoint = CGPoint(x: 100, y: 150)
#State var lastPosition2: CGPoint = CGPoint(x: 100, y: 150)
let dragArea2: CGSize = CGSize(width: 200, height: 300)
var body: some View {
let drag = DragGesture()
.onChanged {
guard $0.startLocation.y >= 0 && $0.startLocation.y <= self.dragArea1.height else { return }
if $0.startLocation.x <= self.dragArea1.width {
self.position1 = CGPoint(x: $0.translation.width + self.lastPosition1.x, y: $0.translation.height + self.lastPosition1.y)
} else {
self.position2 = CGPoint(x: $0.translation.width + self.lastPosition2.x, y: $0.translation.height + self.lastPosition2.y)
}
}
.onEnded {_ in
self.lastPosition1 = self.position1
self.lastPosition2 = self.position2
}
return HStack {
VStack {
Rectangle().foregroundColor(.red)
.frame(width: 150, height: 150)
.position(self.position1)
.clipped()
}
.background(Color.green)
.frame(width: dragArea1.width, height: dragArea1.height)
VStack {
Rectangle().foregroundColor(.blue)
.frame(width: 150, height: 150)
.position(self.position2)
.clipped()
}
.background(Color.yellow)
.frame(width: dragArea2.width, height: dragArea2.height)
}
.gesture(drag)
}
}
Notes:
As it is now, the gesture works anywhere in each container (i.e. green and yellow areas), even if you don't drag inside the red or blue square.
This could probably be more versatile and/or give a bit more control if you put the whole gesture code into the view and wrapped it in a GeometryReader so you could reference the local bounds in context inside ".onChanged".
I am trying to draw a circle in SwiftUI using a Shape, Geometry reader and Path, I want a shadow to sit right underneath the circle but the shadow is coming out offset and I can't seem to get it to draw where it should:
struct ContentView: View {
var body: some View {
return GeometryReader { geometry in
VStack(alignment: .center) {
BackgroundRing()
.stroke(Color.red, style: StrokeStyle(lineWidth: geometry.size.width < geometry.size.height ? geometry.size.width / 12.0 : geometry.size.height / 12))
.padding()
.shadow(color: .gray, radius: 1.0, x: 0.0, y: 0.0)
}
}
}
}
struct BackgroundRing : Shape {
func path(in rect: CGRect) -> Path {
var path: Path = Path()
let radiusOfRing: CGFloat = (rect.width < rect.height ? rect.width/2 - rect.width / 12 : rect.height/2 - rect.height / 12)
path.addRelativeArc(center: CGPoint(x: rect.width/2, y: rect.height/2),
radius: radiusOfRing,
startAngle: Angle(radians: 0.0),
delta: Angle(radians: Double.pi * 2.0 ))
return path
}
}
OK So I seem to have managed to fix the problem. There is something going on with the width / heigh that interacts with the code to calculate the location of the shadow - the shadow position seems to come from the frame dimensions rather than the Shape.
Adding
.aspectRatio(contentMode: .fit)
fixes the problem
Additionally, it seems that .shadow automatically sets the default offset to the same value as the radius, so to get a real offset of 0.0 you have to set it relative to the radius, like this:
.shadow(radius: 10.0, x: -10.0, y: -10.0)
Looks to me like a bug, but this work around solves it:
import SwiftUI
struct ContentView: View {
var body: some View {
return GeometryReader { geometry in
VStack(alignment: .center) {
BackgroundRing()
.stroke(Color.red,
style: StrokeStyle(lineWidth: geometry.size.width < geometry.size.height ? geometry.size.width / 12.0 : geometry.size.height / 12))
.shadow(radius: 30.0, x: -30.0, y: -30.0)
.aspectRatio(contentMode: .fit)
}
}
}
}
struct BackgroundRing : Shape {
func path(in rect: CGRect) -> Path {
var path: Path = Path()
let radiusOfRing: CGFloat = (rect.width < rect.height ? rect.width/2 - rect.width / 12 : rect.height/2 - rect.height / 12)
path.addRelativeArc(center: CGPoint(x: rect.width/2, y: rect.height/2), // <- this change solved the problem
radius: radiusOfRing,
startAngle: Angle(radians: 0.0),
delta: Angle(radians: Double.pi * 2.0 ))
return path
}
}
So I've got the following code:
import SwiftUI
struct ContentView : View {
#State private var draggingLocation = CGPoint.zero
#State private var startLocation = CGPoint.zero
#State private var dragging = false
var body: some View {
let GR = DragGesture(minimumDistance: 10, coordinateSpace: .global)
.onEnded { value in
self.dragging = false
self.draggingLocation = CGPoint.zero
self.startLocation = CGPoint.zero
}
.onChanged { value in
if !self.dragging {
self.dragging = true
}
if self.startLocation == CGPoint.zero {
self.startLocation = value.startLocation
}
self.draggingLocation = value.location
}
return ZStack {
if self.dragging {
Path { path in
path.move(to: CGPoint(x: self.startLocation.x-5, y: self.startLocation.y-5))
path.addLine(to: CGPoint(x: self.draggingLocation.x-5, y: self.draggingLocation.y+5))
path.addLine(to: CGPoint(x: self.draggingLocation.x+5, y: self.draggingLocation.y-5))
path.addLine(to: CGPoint(x: self.startLocation.x+5, y: self.startLocation.y+5))
}
.fill(Color.black)
}
Circle()
.fill(self.dragging ? Color.blue : Color.red)
.frame(width: 100, height: 100)
.gesture(GR)
.offset(
x: 75,
y: 75
)
Circle()
.fill(self.dragging ? Color.blue : Color.red)
.frame(width: 100, height: 100)
.gesture(GR)
.offset(
x: -75,
y: -75
)
}
.frame(width: 400, height: 400)
.background(Color.gray)
}
}
#if DEBUG
struct ContentView_Previews : PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
Which results in this behavior:
I'd like to be able to drag the edge out from one circle and into the other, the problem of course is that the coordinate space of the Path is relative to the gray box (ContentView) and not global. A Path has a property coordinateSpace in the documentation but there's very little information how to use it, and googling the term with SwiftUI literally returns three results, all of which are really just links to Apple's currently sparse docs. Anyone have an idea on how to best approach this?
Coordinate spaces come in three flavours: .local, .global and .named. The first two are obvious. The third, named coordinate spaces, are extremely useful in cases like yours. They are also useful in combination with GeometryReader. For more details on that, check https://swiftui-lab.com/geometryreader-to-the-rescue/
Named coordinate spaces let you express a coordinate of one view, in the coordinate space of another. For that, SwiftUI let you specify a name for a view's coordinate space. Then, in other places of your code, you can make a reference of it. In your example, naming the coordinate of your ZStack is the way to go.
Here's the refactored code:
Note: I moved the Path below, only so that it draws in front of the circles. And also, pay attention to the first Path, which is only there to prevent what I think is a bug in ZStack.
import SwiftUI
struct ContentView : View {
#State private var draggingLocation = CGPoint.zero
#State private var startLocation = CGPoint.zero
#State private var dragging = false
var body: some View {
let GR = DragGesture(minimumDistance: 10, coordinateSpace: .named("myCoordinateSpace"))
.onEnded { value in
self.dragging = false
self.draggingLocation = CGPoint.zero
self.startLocation = CGPoint.zero
}
.onChanged { value in
if !self.dragging {
self.dragging = true
}
if self.startLocation == CGPoint.zero {
self.startLocation = value.startLocation
}
self.draggingLocation = value.location
}
return ZStack(alignment: .topLeading) {
Circle()
.fill(self.dragging ? Color.blue : Color.red)
.frame(width: 100, height: 100)
.overlay(Text("Circle 1"))
.gesture(GR)
.offset(x: 75, y: 75)
Circle()
.fill(self.dragging ? Color.blue : Color.red)
.frame(width: 100, height: 100)
.overlay(Text("Circle 2"))
.gesture(GR)
.offset(x: 200, y: 200)
if self.dragging {
Path { path in
path.move(to: CGPoint(x: self.startLocation.x-5, y: self.startLocation.y-5))
path.addLine(to: CGPoint(x: self.draggingLocation.x-5, y: self.draggingLocation.y+5))
path.addLine(to: CGPoint(x: self.draggingLocation.x+5, y: self.draggingLocation.y-5))
path.addLine(to: CGPoint(x: self.startLocation.x+5, y: self.startLocation.y+5))
}
.fill(Color.black)
}
}
.coordinateSpace(name: "myCoordinateSpace")
.frame(width: 400, height: 400, alignment: .topLeading)
.background(Color.gray)
}
}