I want to write a simple game in SwiftUI where the user can drag a circle around to dodge trees and it should draw a track of where it was however the track is really weird and rectangular instead of a smooth curve. Can anyone help me?
Here’s my code:
import SwiftUI
struct ContentView: View {
@State private var track = [CGFloat]()
@State private var offset: CGFloat = 0
let timer = Timer.publish(every: 0.001, on: .current, in: .common).autoconnect()
var body: some View {
GeometryReader { geo in
ZStack {
Color.black
Path { path in
let height = (geo.size.height / 2) / CGFloat(track.count)
for i in 0..<track.count {
path.addLine(to: CGPoint(x: geo.size.width * track[i], y: height * CGFloat(i)))
path.move(to: CGPoint(x: geo.size.width * track[i], y: height * CGFloat(i)))
}
} .stroke(lineWidth: 1)
.foregroundStyle(.yellow)
Circle()
.frame(width: 25)
.foregroundStyle(.red)
.position(x: offset, y: geo.size.height / 2)
.gesture (
DragGesture()
.onChanged { value in
offset = min(geo.size.width - 15, max(15 ,value.location.x))
}
)
.onAppear {
offset = geo.size.width / 2
}
.onReceive(timer, perform: { _ in
track.append((offset / geo.size.width))
if track.count >= 100 {
track.removeFirst()
}
})
}
}
}
}
The timer publishes too fast, faster than how fast the Gesture
updates its value.
Every 0.001 seconds, you take a sample of the circle's offset. Many of these samples would likely be the same value, which makes the track look "rectangular".
Note that the rate of the timer also represents the speed of the circle, you only display 100 samples of them on the top half of the screen. This means every 0.1 seconds, the circle would "move vertically" by geo.size.height / 2
. I'm not sure if you want a circle that fast.
I would recommend a timer interval around the rate at which the gesture updates. A value like 1 / 30
works fine in my experiments.
Also, your drawing code is a bit incorrect. I would calculate all the points first, and use addLines
. You should draw tracks
in the reverse order, and from bottom to top.
Path { path in
let height = (geo.size.height / 2) / 100
let points = track.reversed().enumerated().map { (i, value) in
CGPoint(x: geo.size.width * value, y: geo.size.height / 2 - height * CGFloat(i))
}
path.addLines(points)
}
// a round line join might look better than the default miter
.stroke(style: StrokeStyle(lineJoin: .round))
.foregroundStyle(.yellow)
With your current control, you cannot freely control the vertical speed of the circle.
A more flexible approach would be to record the time as well as the offset in the gesture's onChanged
. Instead of storing a constant 100 of these, store everything with a time within the past X seconds.
When drawing the line, you would use the difference between timestamps to figure out how much the circle would have moved vertically in that period (according to a speed that you choose). This gives you the difference in y coordinates between the points.
Here is a rough sketch:
struct Sample: Hashable {
let timestamp: Date
let offset: CGFloat
}
struct ContentView: View {
@State private var track = [Sample]()
let timer = Timer.publish(every: 1 / 30, on: .current, in: .common).autoconnect()
let speed: CGFloat = 100 // points per second
var body: some View {
GeometryReader { geo in
ZStack {
Color.black
Path { path in
var points = [CGPoint]()
var currentY = geo.size.height / 2
let reversed = track.sorted(using: KeyPathComparator(\.timestamp, order: .reverse))
for (sample, nextSample) in zip(reversed, reversed.dropFirst()) {
points.append(CGPoint(x: sample.offset, y: currentY))
let elapsedTime = sample.timestamp.timeIntervalSince(nextSample.timestamp)
currentY -= speed * elapsedTime
}
if let last = reversed.last {
points.append(CGPoint(x: last.offset, y: currentY))
}
path.addLines(points)
}
.stroke(style: StrokeStyle(lineJoin: .round))
.foregroundStyle(.yellow)
Circle()
.frame(width: 25)
.foregroundStyle(.red)
.position(x: track.last?.offset ?? geo.size.width / 2, y: geo.size.height / 2)
.gesture (
DragGesture()
.onChanged { value in
let offset = min(geo.size.width - 15, max(15 ,value.location.x))
track.append(Sample(timestamp: Date(), offset: offset))
}
)
.onAppear {
track = [Sample(timestamp: Date(), offset: geo.size.width / 2)]
}
.onReceive(timer, perform: { time in
if let lastSample = track.last,
time.timeIntervalSince(lastSample.timestamp) >= 1 / 30 {
track.append(Sample(timestamp: time, offset: lastSample.offset))
}
let timeThreshold = geo.size.height / 2 / speed + 0.1 // add 0.1 seconds just to be safe
track.removeAll(where: { time.timeIntervalSince($0.timestamp) > timeThreshold })
})
}
}
}
}