2

I'm creating a custom stepper control in SwiftUI, and I'm trying to replicate the accelerating value change behavior of the built-in control. In a SwiftUI Stepper, long pressing on "+" or "-" will keep increasing/decreasing the value with the rate of change getting faster the longer you hold the button.

I can create the visual effect of holding down the button with the following:

struct PressBox: View {
    @GestureState var pressed = false
    @State var value = 0

    var body: some View {
        ZStack {
            Rectangle()
                .fill(pressed ? Color.blue : Color.green)
                .frame(width: 70, height: 50)
                .gesture(LongPressGesture(minimumDuration: .infinity)
                    .updating($pressed) { value, state, transaction in
                        state = value
                    }
                    .onChanged { _ in
                        self.value += 1
                    }
                )
            Text("\(value)")
                .foregroundColor(.white)
        }
    }
}

This only increments the value once. Adding a timer publisher to the onChanged modifier for the gesture like this:

let timer = Timer.publish(every: 0.5, on: .main, in: .common)
@State var cancellable: AnyCancellable? = nil

...

.onChanged { _ in 
    self.cancellable = self.timer.connect() as? AnyCancellable
}

will replicate the changing values, but since the gesture never completes successfully (onEnded will never be called), there's no way to stop the timer. Gestures don't have an onCancelled modifier.

I also tried doing this with a TapGesture which would work for detecting the end of the gesture, but I don't see a way to detect the start of the gesture. This code:

.gesture(TapGesture()
    .updating($pressed) { value, state, transaction in
        state = value
    }
)

generates an error on $pressed:

Cannot convert value of type 'GestureState' to expected argument type 'GestureState<_>'

Is there a way to replicate the behavior without falling back to UIKit?

2 Answers 2

4

You'd need an onTouchDown event on the view to start a timer and an onTouchUp event to stop it. SwiftUI doesn't provide a touch down event at the moment, so I think the best way to get what you want is to use the DragGesture this way:

import SwiftUI

class ViewModel: ObservableObject {
    private static let updateSpeedThresholds = (maxUpdateSpeed: TimeInterval(0.05), minUpdateSpeed: TimeInterval(0.3))
    private static let maxSpeedReachedInNumberOfSeconds = TimeInterval(2.5)

    @Published var val: Int = 0
    @Published var started = false

    private var timer: Timer?
    private var currentUpdateSpeed = ViewModel.updateSpeedThresholds.minUpdateSpeed
    private var lastValueChangingDate: Date?
    private var startDate: Date?

    func start() {
        if !started {
            started = true
            val = 0
            startDate = Date()
            startTimer()
        }
    }

    func stop() {
        timer?.invalidate()
        currentUpdateSpeed = Self.updateSpeedThresholds.minUpdateSpeed
        lastValueChangingDate = nil
        started = false
    }

    private func startTimer() {
        timer = Timer.scheduledTimer(withTimeInterval: Self.updateSpeedThresholds.maxUpdateSpeed, repeats: false) {[unowned self] _ in
            self.updateVal()
            self.updateSpeed()
            self.startTimer()
        }
    }

    private func updateVal() {
        if self.lastValueChangingDate == nil || Date().timeIntervalSince(self.lastValueChangingDate!) >= self.currentUpdateSpeed {
            self.lastValueChangingDate = Date()
            self.val += 1
        }
    }

    private func updateSpeed() {
        if self.currentUpdateSpeed < Self.updateSpeedThresholds.maxUpdateSpeed {
            return
        }
        let timePassed = Date().timeIntervalSince(self.startDate!)
        self.currentUpdateSpeed = timePassed * (Self.updateSpeedThresholds.maxUpdateSpeed - Self.updateSpeedThresholds.minUpdateSpeed)/Self.maxSpeedReachedInNumberOfSeconds + Self.updateSpeedThresholds.minUpdateSpeed
    }
}

struct ContentView: View {
    @ObservedObject var viewModel: ViewModel

    var body: some View {
        ZStack {
            Rectangle()
                .fill(viewModel.started ? Color.blue : Color.green)
                .frame(width: 70, height: 50)
                .gesture(DragGesture(minimumDistance: 0)
                    .onChanged { _ in
                        self.viewModel.start()
                    }
                    .onEnded { _ in
                        self.viewModel.stop()
                    }
            )

            Text("\(viewModel.val)")
                .foregroundColor(.white)
        }
    }
}


#if DEBUG
struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    ContentView(viewModel: ViewModel())
  }
}
#endif

Let me know if I got what you wanted or whether I can improve my answer somehow.

Sign up to request clarification or add additional context in comments.

1 Comment

Hadn't thought to use the drag gesture. Until we get more flexibility in the API, this is a great approach. Thanks!!
1

For anyone attempting something similar, here's a slightly different take on superpuccio's approach. The api for users of the type is a bit more straightforward, and it minimizes the number of timer fires as the speed ramps up.

struct TimerBox: View {
    @Binding var value: Int
    @State private var isRunning = false
    @State private var startDate: Date? = nil
    @State private var timer: Timer? = nil

    private static let thresholds = (slow: TimeInterval(0.3), fast: TimeInterval(0.05))
    private static let timeToMax = TimeInterval(2.5)

    var body: some View {
        ZStack {
            Rectangle()
                .fill(isRunning ? Color.blue : Color.green)
                .frame(width: 70, height: 50)
                .gesture(DragGesture(minimumDistance: 0)
                    .onChanged { _ in
                        self.startRunning()
                    }
                    .onEnded { _ in
                        self.stopRunning()
                    }
            )

            Text("\(value)")
                .foregroundColor(.white)
        }
    }

    private func startRunning() {
        guard isRunning == false else { return }
        isRunning = true
        startDate = Date()
        timer = Timer.scheduledTimer(withTimeInterval: Self.thresholds.slow, repeats: true, block: timerFired)
    }

    private func timerFired(timer: Timer) {
        guard let startDate = self.startDate else { return }
        self.value += 1
        let timePassed = Date().timeIntervalSince(startDate)
        let newSpeed = Self.thresholds.slow - timePassed * (Self.thresholds.slow - Self.thresholds.fast)/Self.timeToMax
        let nextFire = Date().advanced(by: max(newSpeed, Self.thresholds.fast))
        self.timer?.fireDate = nextFire
    }

    private func stopRunning() {
        timer?.invalidate()
        isRunning = false
    }
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.