UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Speed of movement based on time

Forums > SwiftUI

@00jim  

Swiping the shape that takes up the entire view triggers its descention to the bottom of the screen. The view is divided into eleven bands, from ten to zero (the last being the buffer to allow the shape to remain in view so it may be swiped back up to reset), each one representing a second on the timer:

struct ContentView: View {
    @State private var offsetY: CGFloat = 0
    @State private var countdownTimer: Timer? = nil
    @State private var elapsedTimer: Timer? = nil
    @State private var remainingSeconds: Int = 0
    @State private var secondsSince: Int = 0

    let numberOfSections = 11

    var body: some View {
        GeometryReader { geometry in
            ZStack {
                Rectangle()
                    .frame(width: geometry.size.width, height: geometry.size.height)
                    .foregroundColor(.blue)
                    .offset(y: self.offsetY)
                    .gesture(
                        DragGesture()
                            .onChanged { gesture in
                                let newY = gesture.location.y
                                self.offsetY = min(max(newY, 0), geometry.size.height - 50)
                            }
                            .onEnded { gesture in
                                self.startTimer(height: geometry.size.height)
                                self.moveShapeToBottom(of: geometry.size.height, gestureLocationY: gesture.location.y)
                            }
                        )

                ForEach(0..<11) { index in
                    Text("\(10 - index)")
                        .position(x: 10, y: CGFloat(index) * geometry.size.height / 11 + 25)
                        .foregroundColor(.white)
                }

                VStack {
                    Text("\(self.remainingSeconds) SECONDS LEFT")
                        .padding()
                        .foregroundColor(.white)
                        .background(Color.blue)
                        .cornerRadius(10)

                    Text("\(self.secondsSince) SECONDS ELAPSED")
                        .padding()
                        .foregroundColor(.white)
                        .background(Color.blue)
                        .cornerRadius(10)
                    }
                }
        }
        .edgesIgnoringSafeArea(.all)
    }

    private func startTimer(height: CGFloat) {
        guard countdownTimer == nil else { return }

        remainingSeconds = Int(height)

        countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
            if remainingSeconds >= 1 {
                remainingSeconds -= 1
            } else if remainingSeconds <= 0 {
                stopTimer()
            }
        }
    }

    private func stopTimer() {
        countdownTimer?.invalidate()
        countdownTimer = nil
    }

    private func moveShapeToBottom(of height: CGFloat, gestureLocationY: CGFloat) {

        // WORKAROUND : PREVENT MINUS NUMBERS IF DRAGGING INTO THE "ZERO" BUFFER AREA
        var position: CGFloat {
            let minimum = ((height / CGFloat(numberOfSections)) * CGFloat(numberOfSections - 1)) - 1
            return gestureLocationY >= minimum ? minimum : gestureLocationY
        }

        let band = Int((position / height) * CGFloat(self.numberOfSections))

        // CALCULATE TIME REMAINING FROM BAND DATA
        let mappedTime = CGFloat(self.numberOfSections - 1 - band)

        remainingSeconds = Int(mappedTime)

        // UPDATE TIMER
        startTimer(height: position)

        let threshold = ((height / CGFloat(numberOfSections)) * CGFloat(numberOfSections - 1)) - 1

        print("TT0 : \(threshold)")
        print("TT1 : \(height) : \(height / CGFloat(numberOfSections)) : \(((height / CGFloat(numberOfSections)) * CGFloat(numberOfSections - 1))) : \(gestureLocationY)")
        print("TT2 : \(position)")

//        // FIXME: SPEED OF DESCENT
//        let distanceToBottom = threshold - position
//        let remainingTime = TimeInterval(remainingSeconds)
//        let speed = distanceToBottom / remainingTime

        if position <= threshold {
            countdownTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
                withAnimation {
                    self.offsetY += 15 * 0.05
                    //self.offsetY += 0.05
                }
                if self.offsetY >= threshold {
                    self.stopTimer()
                }
            }
        }
    }
}

My issue relates to the speed of the shape moves (speed) which plays havock with the timer as it will not stop.

   

@00jim  

I managed to get a system to allow for the smooth action of increasing and decreasing a permanent shape from the bottom of the view, but it always starts from top, when it should be a tab (50px) at the bottom:

struct ContentView: View {
    var body: some View {
        VStack {
            Spacer()
            DraggableView()
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(Color.yellow)
        .edgesIgnoringSafeArea(.all)
    }
}

struct DraggableView: View {

    enum TimerDirection { case up, down }

    @State var position : CGFloat   = UIScreen.main.bounds.height - (UIScreen.main.bounds.height / 7)
    @State var height   : CGFloat   = 50     // SET SPACE FROM BOTTOM
    @State var offset   : CGFloat   = 0      // OFFSET VALUE
    @State var countDown: Int       = 0
    @State var timer    : Timer?    = nil

    var body: some View {

        counterBlock    // DRAGGABLE TIMER BLOCK
    }

    var counterBlock: some View {
        Text("\(countDown) sec") // COUNTER BEGINS TO COUNT UP (ELAPSED TIME) AFTER REACHING ZERO
            .frame(width: UIScreen.main.bounds.width / 2, height: height)  // DYNAMIC HEIGHT CALCULATED ON CHANGE EVENT
            .frame(maxWidth: .infinity)
            .position(x: UIScreen.main.bounds.width / 2, y: height / 2)     // ENSURES TEXT (TIMER) ALIGNS TO TOP CENTRE OF FRAME
            .background(.blue)
            .foregroundColor(.yellow)
            .offset(y: max(offset, 0))  // CALCULATE THE AMOUNT TO MOVE BY
            .gesture(
                DragGesture()
                    .onChanged({ value in
                        // MINIMUM OF 50 RESPECTED, WITH NEW HEIGHT ACCRUED FROM GESTURE
                        height = max(50, height - value.location.y)
                        offset = value.location.y   // STORE NEW OFFSET

                        let distance = Int(UIScreen.main.bounds.height - offset)
                        countDown = distance / 100  // 100px : 1 sec
                        invalidateTimer()           // RESET TIMER FOR NEW GESTURE POSITIONING
                    })

                    .onEnded { value in
                        // ANIMATE DECLINE TO WITNESS TIMER COUNTING DOWN
                        withAnimation(Animation.easeInOut(duration: Double(countDown))) {
                            offset = UIScreen.main.bounds.height - height   // DECLINE WILL RESPECT BOTTOM SPACING (DISPLAYING TIMER)
                        }

                        // TIMER COUNTS DOWN SECONDS (CALCULATED FROM 100px VALUES)
                        startTimer(.down)

                        // IF TIMER FINISHED THEN ELAPSED SECONDS BEGIN
                        if countDown <= 0 {
                            startTimer(.up)
                        }
                    })
    }

    // TIMER (UP/DOWN)
    func startTimer(_ set: TimerDirection) {
        invalidateTimer()
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            if set == .up {
                countDown += 1
            } else {
                countDown -= 1
                // WORKAROUND : ENSURE COUNTER DOES NOT STRAY INTO NEGATIVE COUNTING BY INVALIDATING TIMER SEPARATELY
                if countDown <= 0 {
                    invalidateTimer()
                    startTimer(.up)
                }
            }
        }
    }

    func invalidateTimer() {
        timer?.invalidate()
        timer = nil
    }
}

The issue is with positioning, which forces the text (blue block) to take up the entire view, when I just want it to be a small 50px high shape at the bottom of the view.

.position(x: UIScreen.main.bounds.width / 2, y: height / 2)

   

@00jim  

This system was more reliable as it could be dragged a lot faster, withouth the shape set after the onEnded event (which means that it has to count to zero before it can be reshaped i.e. dragged up once more):

struct DraggableView: View {
    @State var rectangleHeight: CGFloat = 50    // SET SPACE FROM BOTTOM
    @State var offset: CGFloat = 0              // OFFSET VALUE
    @State var countDown: Int = 0
    @State var timer: Timer? = nil

    var body: some View {
        Text("\(countDown) sec")
            .frame(width: UIScreen.main.bounds.width, height: rectangleHeight)  // DYNAMIC HEIGHT CALCULATED ON CHANGE EVENT
            .frame(maxWidth: .infinity)
            .background(.blue)
            .foregroundColor(.yellow)
            .offset(y: max(offset, 0))
            .gesture(
                DragGesture()
                    .onChanged({ value in
                        rectangleHeight = max(50, rectangleHeight - value.location.y) // MINIMUM OF 50 RESPECTED, WITH NEW HEIGHT ACCRUED FROM GESTURE
                        offset = value.location.y   // STORE NEW OFFSET

                        let distance = Int(UIScreen.main.bounds.height - offset)
                        countDown = distance / 100  // 100px : 1 sec
                        timer?.invalidate()
                        timer = nil
                    })

            )
    }
}

   

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Click to save your free spot now

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.