smr
smr

Reputation: 1173

SwiftUI custom stepper button

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?

Upvotes: 2

Views: 3085

Answers (2)

smr
smr

Reputation: 1173

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

Upvotes: 1

superpuccio
superpuccio

Reputation: 12972

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.

Upvotes: 4

Related Questions