SwiftUI 自定义步进按钮

SwiftUI custom stepper button

我正在 SwiftUI 中创建一个自定义步进控件,我正在尝试复制内置控件的加速值更改行为。在 SwiftUI Stepper 中,长按“+”或“-”将保持 increasing/decreasing 值,按住按钮的时间越长变化速度越快。

我可以通过以下方式创建按住按钮的视觉效果:

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

这只会增加一次值。将计时器发布者添加到手势的 onChanged 修饰符,如下所示:

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
}

将复制变化的值,但由于手势永远不会成功完成(永远不会调用 onEnded),因此无法停止计时器。手势没有 onCancelled 修饰符。

我还尝试使用 TapGesture 来执行此操作,它可以检测手势的结束,但我没有找到检测手势开始的方法。此代码:

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

$pressed 上生成错误:

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

有没有办法在不回退到 UIKit 的情况下复制行为?

您需要视图上的 onTouchDown 事件来启动计时器和 onTouchUp 事件来停止它。 SwiftUI 目前不提供触地事件,所以我认为获得你想要的东西的最好方法是以这种方式使用 DragGesture

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

如果我得到了你想要的,或者我是否可以以某种方式改进我的答案,请告诉我。

对于尝试类似操作的任何人,superpuccio 的方法略有不同。 api 对于这种类型的用户来说更直接一些,并且随着速度的提高它会最大限度地减少计时器触发的次数。

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