swiftswiftuismoothingcurve

SwiftUI track drawing weird


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

Solution

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