DragGesture on Path in ScrollView doesn't work - swiftui

Im working on a macOS app. It should be possible to move Shapes and Lines around in a ScrollView. Moving Shapes like Circle is working. But when I try to move or tap a Path (for drawing a Line), the drag gesture is not working at all.
When the ScrollView is removed, it's possible to move the Path around. But I need it in a ScrollView.
See code below
struct ContentView: View
{
#State private var offset = CGSize.zero
#State private var x: CGFloat = 0
#State private var y: CGFloat = 0
var body: some View
{
ScrollView( [.vertical, .horizontal])
{
ZStack
{
Path
{ path in
path.move(to: CGPoint(x: 100, y: 100))
path.addLine(to: CGPoint(x: 300, y: 300))
}
.stroke(.blue ,style: StrokeStyle(lineWidth: 5, lineCap: .round, dash: []))
.offset(x: offset.width+x, y: offset.height+y)
.simultaneousGesture( DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged
{ gesture in
offset = gesture.translation
}
.onEnded
{ _ in
x += offset.width
y += offset.height
offset = CGSize.zero
}
)
}
}
.frame(width: 800, height: 800)
}
}
When I replace the Path with a circle in the code, everything works fine.
Circle()
.foregroundColor(.blue)
.frame(width: 50, height: 50)
Replacing simultaneousGesture with gesture or highPriorityGesture doesn't change anything.
Is there a solution to drag a Path within a ScrollView? Or is there another approach to create Lines and drag them around in a ScrollView.

Related

SwiftUI TabView with simultaneousGesture

I have a TabView with some views that have DragGestures inside. I want to be able to drag the views along with the TabView paging. I am trying to use simultaneousGesture for the drag gesture. This works with a ScrollView but not with a TabView. As you can see in the example the green square on the second page can be dragged around but this doesn't happen simultaneously with the TabViews horizontal scrolling.
Here is a simplified version of the code:
struct ContentView: View {
let colors:[Color] = [.red, .green, .blue]
#State private var location: CGPoint = CGPoint(x: 100, y: 100);
var simpleDrag: some Gesture {
DragGesture()
.onChanged { value in
self.location = value.location
}
.onEnded {_ in }
}
var body: some View {
TabView{
ForEach(colors, id: \.self) { color in
Group {
if color == .green {
VStack {
color
.frame(width:100, height: 100)
.position(location)
.simultaneousGesture(simpleDrag)
}
} else {
color
}
}
.frame(width: 200, height: 200)
}
}
.frame(width: 400, height: 400)
.tabViewStyle(.page(indexDisplayMode: .never))
}
}
And here is a version with a ScrollView that works really well, it even scrolls the scroll view when moving side to side and moves the green box when dragging it up and down.
struct ContentView: View {
let colors:[Color] = [.red, .green, .blue]
#State private var location: CGPoint = CGPoint(x: 100, y: 100);
var simpleDrag: some Gesture {
DragGesture()
.onChanged { value in
self.location = value.location
}
.onEnded {_ in }
}
var body: some View {
ScrollView(.horizontal) {
HStack {
ForEach(colors, id: \.self) { color in
Group {
if color == .green {
VStack {
color
.frame(width:100, height: 100)
.position(location)
.simultaneousGesture(simpleDrag)
}
} else {
color
}
}
.frame(width: 200, height: 200)
}
}
}
.frame(width: 400, height: 400)
}
}
I've been searching for a similar answer as well. The closest I could find to a solution is to set the minimumDistance parameter of DragGesture. I found that DragGesture(minimumDistance:20) works fairly well to allow the TabView paging to happen before the DragGesture kicks in. It does delay the other gesture by 20 pixels, but it seems like a fair compromise to allow for both kind of gestures to function

How to center a graph using ScrollView - Scrolling by Pixel

I am using a ScrollView to show a Graph in order to horizontally scroll it. Problem is that I need to center it when the view initially loads.
struct ContentView1: View {
var body: some View {
VStack {
GeometryReader { geometry in
ScrollView(.horizontal) {
VStack {
Graph()
.frame(width: geometry.size.width * 2, height: geometry.size.height, alignment: .center)
}
}
}
}
.frame(minWidth: 400, minHeight: 300, alignment: .center)
}
}
fileprivate struct Graph: View {
var body: some View {
GeometryReader { geometry in
let rect = geometry.size
Path { path in
path.move(to: CGPoint(x: 0, y: 0))
path.addLine(to: CGPoint(x: rect.width, y: rect.height))
}
.stroke(Color.black)
Path { path in
path.move(to: CGPoint(x: rect.width, y: 0))
path.addLine(to: CGPoint(x: 0, y: rect.height))
}
.stroke(Color.red)
}
}
}
ScrollViewReader provides the scrollTo function but it works if the ScrollView contains more views with their own identifier so it's not an option in this case.
How can I scroll the graph by pixel in order to center it programmatically?
(I think) Using ScrollView is unnecessary since you have only one View. You may use DragGesture to move Graph() on the x-axis. It also helps you center Graph() on the x-axis when it appears.
1.
Offset Graph() by half of the screen's width. In order to do so, define a new #State property, set it to -(geometry.size.width / 2), and use .offset() view-modifier.
2.
Use DragGesture() to move on the x-axis. You need to keep track of the last offset too.
struct ContentView: View {
#State var offset: CGFloat = .zero
#State var lastOffset: CGFloat = .zero
var body: some View {
VStack {
GeometryReader { geometry in
VStack {
Graph()
.frame(width: geometry.size.width * 2, height: geometry.size.height, alignment: .center)
.offset(x: offset)
}
.onAppear {
offset = -(geometry.size.width / 2)
lastOffset = offset
}
.gesture(
DragGesture().onChanged { value in
offset = lastOffset + value.translation.width
}
.onEnded { value in
lastOffset = offset
}
)
}
}
.frame(minWidth: 400, minHeight: 300, alignment: .center)
}
}

SwiftUI - How to get notified when dragging and releasing on an view

I have two circles on my screen (top and bottom). I want the user to press down on the top circle, and drag to the bottom one.
I'm not dragging and dropping (I don't want the UI to change).
I just want to know that the user started on the top one, and released their finger on the bottom circle. When the user releases their finger on the bottom one.
I haven't been able to find my answer from other questions.
Current code
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Circle()
.fill()
.foregroundColor(.blue)
.frame(width: 100, height: 100)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .named("mySpace"))
.onChanged { value in
}
.onEnded { value in
// if value.location == endPoint {
// print("user started press on blue and ended on green")
// }
}
)
Spacer()
Circle()
.fill()
.foregroundColor(.green)
.frame(width: 100, height: 100)
}.coordinateSpace(name: "mySpace")
}
}
Screenshot
Would appreciate any help!
Here I could find a way for get notified wether this 2 Circles are in some part inside each other:
import SwiftUI
struct ContentView: View {
typealias OffsetType = (offset: CGSize, lastOffset: CGSize)
#State private var objects: [OffsetType] = [(offset: CGSize(width: 0.0, height: -200.0), lastOffset: CGSize(width: 0.0, height: -200.0)),
(offset: CGSize(width: 0.0, height: 200.0), lastOffset: CGSize(width: 0.0, height: 200.0))]
var body: some View {
ZStack {
CircleView(color: Color.blue)
.offset(objects[0].offset)
.gesture(dragGesture(indexOfObject: 0))
CircleView(color: Color.green)
.offset(objects[1].offset)
.gesture(dragGesture(indexOfObject: 1))
}
.animation(Animation.easeInOut(duration: 0.1))
}
func dragGesture(indexOfObject: Int) -> some Gesture {
DragGesture(minimumDistance: 0.0, coordinateSpace: .global)
.onChanged() { value in
objects[indexOfObject].offset = CGSize(width: objects[indexOfObject].lastOffset.width + value.translation.width,
height: objects[indexOfObject].lastOffset.height + value.translation.height)
}
.onEnded() { value in
objects[indexOfObject].lastOffset = CGSize(width: objects[indexOfObject].lastOffset.width + value.translation.width,
height: objects[indexOfObject].lastOffset.height + value.translation.height)
objects[indexOfObject].offset = objects[indexOfObject].lastOffset
distance()
}
}
func distance() {
if pow(pow((objects[1].offset.width - objects[0].offset.width), 2.0) + pow((objects[1].offset.height - objects[0].offset.height), 2.0), 0.5) <= 100 { print("same place!") }
}
}
struct CircleView: View {
let color: Color
var body: some View {
Circle()
.fill(color)
.frame(width: 100, height: 100)
}
}

SwiftUI shape fill body

I'm trying to construct a view in SwiftUI, where the user can keep zooming in and out, and show elements across the view. But the rectangle keeps the size of the window, and scales down when zooming out instead of filling the body. The body (black) correctly fills the window.
How do you make the white rectangle fill the body when zooming out?
(Must be run in an app instead of preview)
import SwiftUI
func rgb (_ count: Int) -> [Color]{
let colors = [Color.red, Color.green, Color.blue]
var arr: [Color] = []
for i in 0..<count {
arr.append(colors[i%3])
}
return arr
}
struct ContentView: View {
#State var scale: CGFloat = 1.0
var body: some View {
let colors = rgb(20)
ZStack {
Rectangle()
.fill(Color.white)
.frame(minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: .center)
ForEach(colors.indices.reversed(), id: \.self) { i in
Circle()
.size(width: 100, height: 100)
.fill(colors[i])
.offset(x: 100.0*CGFloat(i), y: 100.0*CGFloat(i))
}
}
.drawingGroup()
.scaleEffect(scale)
.gesture(MagnificationGesture()
.onChanged {self.scale = $0})
.background(Color.black)
.frame(minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: .center)
}
}
I put this an an answer to show a screenshot. The second bit of code behaves very inconsistently. I never see 20 circles. It will zoom, but seems to then be caught in some other view. It is very strange behavior and tough to explain. While the screenshot is here, I could run it 20 times and get 20 different screenshots if I zoom and/or resize the window. I am not on Apple silicon, so your first post may be a bug in implementation on Apple silicon. Wouldn't be the first.
Functioning example for this use case, with rectangle removed from ZStack:
import SwiftUI
func rgb (_ count: Int) -> [Color]{
let colors = [Color.red, Color.green, Color.blue]
var arr: [Color] = []
for i in 0..<count {
arr.append(colors[i%3])
}
return arr
}
struct ContentView: View {
#State var scale: CGFloat = 1.0
#State var colorIndex = 0
var bgColor: Color { rgb(3)[colorIndex%3] }
var body: some View {
let colors = rgb(20)
ZStack {
ForEach(colors.indices.reversed(), id: \.self) { i in
Circle()
.size(width: 100, height: 100)
.fill(colors[i])
.offset(x: 100.0*CGFloat(i), y: 100.0*CGFloat(i))
}
}
.drawingGroup()
.scaleEffect(scale)
.background(bgColor)
.gesture(MagnificationGesture()
.onChanged {scale = $0})
.gesture(TapGesture().onEnded({colorIndex+=1}))
}
}
However it does not fix the problem of the shape not scaling to the body size.

Positioning View using anchor point

I have several dozen Texts that I would like to position such that their leading baseline (lastTextBaseline) is at a specific coordinate. position can only set the center. For example:
import SwiftUI
import PlaygroundSupport
struct Location: Identifiable {
let id = UUID()
let point: CGPoint
let angle: Double
let string: String
}
let locations = [
Location(point: CGPoint(x: 54.48386479999999, y: 296.4645408), angle: -0.6605166885682314, string: "Y"),
Location(point: CGPoint(x: 74.99159120000002, y: 281.6336352), angle: -0.589411952788817, string: "o"),
]
struct ContentView: View {
var body: some View {
ZStack {
ForEach(locations) { run in
Text(verbatim: run.string)
.font(.system(size: 48))
.border(Color.green)
.rotationEffect(.radians(run.angle))
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
}
PlaygroundPage.current.setLiveView(ContentView())
This locates the strings such that their center is at the desired point (marked as a red circle):
I would like to adjust this so that the leading baseline is at this red dot. In this example, a correct layout would move the glyphs up and to the right.
I have tried adding .topLeading alignment to the ZStack, and then using offset rather than position. This will let me align based on the top-leading corner, but that's not the corner I want to layout. For example:
ZStack(alignment: .topLeading) { // add alignment
Rectangle().foregroundColor(.clear) // to force ZStack to full size
ForEach(locations) { run in
Text(verbatim: run.string)
.font(.system(size: 48))
.border(Color.green)
.rotationEffect(.radians(run.angle), anchor: .topLeading) // rotate on top-leading
.offset(x: run.point.x, y: run.point.y)
}
}
I've also tried changing the "top" alignment guide for the Texts:
.alignmentGuide(.top) { d in d[.lastTextBaseline]}
This moves the red dots rather than the text, so I don't believe this is on the right path.
I am considering trying to adjust the locations themselves to take into account the size of the Text (which I can predict using Core Text), but I am hoping to avoid calculating a lot of extra bounding boxes.
So, as far as I can tell, alignment guides can't be used in this way – yet. Hopefully this will be coming soon, but in the meantime we can do a little padding and overlay trickery to get the desired effect.
Caveats
You will need to have some way of retrieving the font metrics – I'm using CTFont to initialise my Font instances and retrieving metrics that way.
As far as I can tell, Playgrounds aren't always representative of how a SwiftUI layout will be laid out on the device, and certain inconsistencies arise. One that I've identified is that the displayScale environment value (and the derived pixelLength value) is not set correctly by default in playgrounds and even previews. Therefore, you have to set this manually in these environments if you want a representative layout (FB7280058).
Overview
We're going to combine a number of SwiftUI features to get the outcome we want here. Specifically, transforms, overlays and the GeometryReader view.
First, we'll align the baseline of our glyph to the baseline of our view. If we have the font's metrics we can use the font's 'descent' to shift our glyph down a little so it sits flush with the baseline – we can use the padding view modifier to help us with this.
Next, we're going to overlay our glyph view with a duplicate view. Why? Because within an overlay we're able to grab the exact metrics of the view underneath. In fact, our overlay will be the only view the user sees, the original view will only be utilised for its metrics.
A couple of simple transforms will position our overlay where we want it, and we'll then hide the view that sits underneath to complete the effect.
Step 1: Set up
First, we're going to need some additional properties to help with our calculations. In a proper project you could organise this into a view modifier or similar, but for conciseness we'll add them to our existing view.
#Environment(\.pixelLength) var pixelLength: CGFloat
#Environment(\.displayScale) var displayScale: CGFloat
We'll also need a our font initialised as a CTFont so we can grab its metrics:
let baseFont: CTFont = {
let desc = CTFontDescriptorCreateWithNameAndSize("SFProDisplay-Medium" as CFString, 0)
return CTFontCreateWithFontDescriptor(desc, 48, nil)
}()
Then some calculations. This calculates some EdgeInsets for a text view that will have the effect of moving the text view's baseline to the bottom edge of the enclosing padding view:
var textPadding: EdgeInsets {
let baselineShift = (displayScale * baseFont.descent).rounded(.down) / displayScale
let baselineOffsetInsets = EdgeInsets(top: baselineShift, leading: 0, bottom: -baselineShift, trailing: 0)
return baselineOffsetInsets
}
We'll also add a couple of helper properties to CTFont:
extension CTFont {
var ascent: CGFloat { CTFontGetAscent(self) }
var descent: CGFloat { CTFontGetDescent(self) }
}
And finally we create a new helper function to generate our Text views that uses the CTFont we defined above:
private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
}
Step 2: Adopt our glyphView(_:) in our main body call
This step is simple and has us adopt the glyphView(_:) helper function we define above:
var body: some View {
ZStack {
ForEach(locations) { run in
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
This gets us here:
Step 3: Baseline shift
Next we shift the baseline of our text view so that it sits flush with the bottom of our enclosing padding view. This is just a case of adding a padding modifier to our new glyphView(_:)function that utilises the padding calculation we define above.
private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
.padding(textPadding) // Added padding modifier
}
Notice how the glyphs are now sitting flush with the bottom of their enclosing views.
Step 4: Add an overlay
We need to get the metrics of our glyph so that we are able to accurately place it. However, we can't get those metrics until we've laid out our view. One way around this is to duplicate our view and use one view as a source of metrics that is otherwise hidden, and then present a duplicate view that we position using the metrics we've gathered.
We can do this with the overlay modifier together with a GeometryReader view. And we'll also add a purple border and make our overlay text blue to differentiate it from the previous step.
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
})
.position(run.point)
Step 5: Translate
Making use of the metrics we now have available for us to use, we can shift our overlay up and to the right so that the bottom left corner of the glyph view sits on our red positioning spot.
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
})
.position(run.point)
Step 6: Rotate
Now we have our view in position we can finally rotate.
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.blue)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)
Step 7: Hide our workings out
Last step is to hide our source view and set our overlay glyph to its proper colour:
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.hidden()
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.black)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)
The final code
//: A Cocoa based Playground to present user interface
import SwiftUI
import PlaygroundSupport
struct Location: Identifiable {
let id = UUID()
let point: CGPoint
let angle: Double
let string: String
}
let locations = [
Location(point: CGPoint(x: 54.48386479999999, y: 296.4645408), angle: -0.6605166885682314, string: "Y"),
Location(point: CGPoint(x: 74.99159120000002, y: 281.6336352), angle: -0.589411952788817, string: "o"),
]
struct ContentView: View {
#Environment(\.pixelLength) var pixelLength: CGFloat
#Environment(\.displayScale) var displayScale: CGFloat
let baseFont: CTFont = {
let desc = CTFontDescriptorCreateWithNameAndSize("SFProDisplay-Medium" as CFString, 0)
return CTFontCreateWithFontDescriptor(desc, 48, nil)
}()
var textPadding: EdgeInsets {
let baselineShift = (displayScale * baseFont.descent).rounded(.down) / displayScale
let baselineOffsetInsets = EdgeInsets(top: baselineShift, leading: 0, bottom: -baselineShift, trailing: 0)
return baselineOffsetInsets
}
var body: some View {
ZStack {
ForEach(locations) { run in
self.glyphView(for: run.string)
.border(Color.green, width: self.pixelLength)
.hidden()
.overlay(GeometryReader { geometry in
self.glyphView(for: run.string)
.foregroundColor(.black)
.border(Color.purple, width: self.pixelLength)
.transformEffect(.init(translationX: geometry.size.width / 2, y: -geometry.size.height / 2))
.rotationEffect(.radians(run.angle))
})
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
private func glyphView(for text: String) -> some View {
Text(verbatim: text)
.font(Font(baseFont))
.padding(textPadding)
}
}
private extension CTFont {
var ascent: CGFloat { CTFontGetAscent(self) }
var descent: CGFloat { CTFontGetDescent(self) }
}
PlaygroundPage.current.setLiveView(
ContentView()
.environment(\.displayScale, NSScreen.main?.backingScaleFactor ?? 1.0)
.frame(width: 640, height: 480)
.background(Color.white)
)
And that's it. It's not perfect, but until SwiftUI gives us an API that allows us to use alignment anchors to anchor our transforms, it might get us by!
this code takes care of the font metrics, and position text as you asked
(If I properly understood your requirements :-))
import SwiftUI
import PlaygroundSupport
struct BaseLine: ViewModifier {
let alignment: HorizontalAlignment
#State private var ref = CGSize.zero
private var align: CGFloat {
switch alignment {
case .leading:
return 1
case .center:
return 0
case .trailing:
return -1
default:
return 0
}
}
func body(content: Content) -> some View {
ZStack {
Circle().frame(width: 0, height: 0, alignment: .center)
content.alignmentGuide(VerticalAlignment.center) { (d) -> CGFloat in
DispatchQueue.main.async {
self.ref.height = d[VerticalAlignment.center] - d[.lastTextBaseline]
self.ref.width = d.width / 2
}
return d[VerticalAlignment.center]
}
.offset(x: align * ref.width, y: ref.height)
}
}
}
struct ContentView: View {
var body: some View {
ZStack {
Cross(size: 20, color: Color.red).position(x: 200, y: 200)
Cross(size: 20, color: Color.red).position(x: 200, y: 250)
Cross(size: 20, color: Color.red).position(x: 200, y: 300)
Cross(size: 20, color: Color.red).position(x: 200, y: 350)
Text("WORLD").font(.title).border(Color.gray).modifier(BaseLine(alignment: .trailing))
.rotationEffect(.degrees(45))
.position(x: 200, y: 200)
Text("Y").font(.system(size: 150)).border(Color.gray).modifier(BaseLine(alignment: .center))
.rotationEffect(.degrees(45))
.position(x: 200, y: 250)
Text("Y").font(.system(size: 150)).border(Color.gray).modifier(BaseLine(alignment: .leading))
.rotationEffect(.degrees(45))
.position(x: 200, y: 350)
Text("WORLD").font(.title).border(Color.gray).modifier(BaseLine(alignment: .leading))
.rotationEffect(.degrees(225))
.position(x: 200, y: 300)
}
}
}
struct Cross: View {
let size: CGFloat
var color = Color.clear
var body: some View {
Path { p in
p.move(to: CGPoint(x: size / 2, y: 0))
p.addLine(to: CGPoint(x: size / 2, y: size))
p.move(to: CGPoint(x: 0, y: size / 2))
p.addLine(to: CGPoint(x: size, y: size / 2))
}
.stroke().foregroundColor(color)
.frame(width: size, height: size, alignment: .center)
}
}
PlaygroundPage.current.setLiveView(ContentView())
Updated: you could try the following variants
let font = UIFont.systemFont(ofSize: 48)
var body: some View {
ZStack {
ForEach(locations) { run in
Text(verbatim: run.string)
.font(Font(self.font))
.border(Color.green)
.offset(x: 0, y: -self.font.lineHeight / 2.0)
.rotationEffect(.radians(run.angle))
.position(run.point)
Circle() // Added to show where `position` is
.frame(maxWidth: 5)
.foregroundColor(.red)
.position(run.point)
}
}
}
there is also next interesting variant, use ascender instead of above lineHeight
.offset(x: 0, y: -self.font.ascender / 2.0)