macosswiftuiios17

Button continues to autoRepeat after being hidden, need to cancel


I have a stepper button which is in a higher level view that's hidden based on the adjusted value hitting a threshold.

The bug is that hiding the control doesn't cancel the repeat behaviour even though the gesture which triggers it has (obviously) ended.

Hitting the Reset Count button makes the vanished content reappear and you can see the count decrease until it vanishes again.

The nested button specifies buttonRepeatBehavior(.enabled)

Note that the problem occurs on both iOS 17+ and macOS 14+.


fileprivate func stepperLabel(isDown: Bool) -> some View {
    if isDown {
        Text("-")
        .font(.title2)
        .foregroundColor(.primary)
        .frame(maxWidth: .infinity, minHeight: 40, maxHeight: .infinity,alignment: .leading)
        .padding(.leading, 16)

    } else {
        Text("+")
        .font(.title2)
        .foregroundColor(.primary)
        .frame(maxWidth: .infinity, minHeight: 40, maxHeight: .infinity,alignment: .trailing)
        .padding(.trailing, 16)

    }
}

struct StepperButtonView: View {
    let isDown: Bool
    let action: ()->Void
    
    var body: some View {
        Button(action: action) {
            stepperLabel(isDown: isDown)
        }
        .buttonStyle(.plain)
        .buttonRepeatBehavior(.enabled)
    }
}

This is grouped into a higher-level control, eg:

struct StepperNumView<T: Numeric & Comparable>: View {
    @Binding var value: T
    let step: T    
    let zeroBased = T.self == UInt.self

    var body: some View {
        VStack {
            HStack(spacing: 0) {
                StepperButtonView(isDown: true) {
                    if zeroBased {
                        if value == 0 {
// message will keep appearing in console & if make visible again, keeps repeating until vanishes
                            print("skipping adjustment from repeating button")
                        } else {
                            value = max(0, value-step) 
                        }
                    } else {
                        value -= step
                    }
                }
                .disabled(zeroBased && value == 0)
...

The parent view controls visibility of a larger section based on the count that's adjusted by the repeating button.


struct ContentView: View {
    @State var count: UInt = 10

    var body: some View {
        VStack {
            Button("Reset Count") {
                count = 10
            }
            .buttonStyle(.borderedProminent)
            Spacer()
            if count > 0 {
                Text("Button vanishes when hits zero, holding down on\n+/- to repeat should trigger weird things")
                Spacer()
                StepperNumView<Int>(title: "Count, tap centre to edit", value: $count, step: 1)
                    .padding(.horizontal)
            } else {
                Text("Button vanished with zero count")
            }

Full code on GitHub

Lodged with Apple as FB15477204 and as rdar://FB15477204


Solution

  • Whilst the original behaviour remains an outstanding Apple bug, I had an insight whilst writing this question.

    Use Animation to delay removal of the view, so the button.disabled clause is evaluated - my theory being that the right way to cancel auto-repeat is for the button to become disabled.

    Simple changes:

    1. Add a property stepperShouldAppear which depends on count
    2. Use stepperShouldAppear in the conditional view (more for neatness)
    3. Add an .animation(... value: stepperShouldAppear) at the end of the ContentView
    struct StepperNumView<T: Numeric & Comparable>: View {
    ...
                    StepperButtonView(isDown: true) {
                        if zeroBased {
                            if value == 0 {
                                print("skipping adjustment from repeating button")
                            } else {
                                value = max(0, value-step) 
                            }
                        } else {
                            value -= step
                        }
                    }
                    .disabled(zeroBased && value == 0)
    ..
    
    
    struct ContentView: View {
        @State var count: UInt = 10
        var stepperShouldAppear: Bool {get{ // 1
            count > 0
        }}
        var body: some View {
            VStack {
                Button("Reset Count") {
                    count = 10
                }
                .buttonStyle(.borderedProminent)
                Spacer()
                if stepperShouldAppear { // 2
                    Text("Button vanishes when hits zero, holding down on\n+/- to repeat should trigger weird things")
                    Spacer()
                    StepperNumView<Int>(title: "Count, tap centre to edit", value: $count, step: 1)
                        .padding(.horizontal)
                } else {
                    Text("Button vanished with zero count")
                }
    ...
            }
            .animation(.easeOut(duration: 1.0), value: stepperShouldAppear)  // 3
    
    
    

    Branch AnimatingFixes shows it, now merged into main.