SwiftUI.Stepper bug from `onIncrement` and `onDecrement`?

Ok… I'm baffled here… this is very strange.

Here is a SwiftUI app:

import SwiftUI

@main struct StepperDemoApp: App {
  func onIncrement() {
    print(#function)
  }
  
  func onDecrement() {
    print(#function)
  }
  
  var body: some Scene {
    WindowGroup {
      Stepper {
        Text("Stepper")
      } onIncrement: {
        self.onIncrement()
      } onDecrement: {
        self.onDecrement()
      }
    }
  }
}

When I run in the app in macOS (Xcode 16.0 (16A242) and macOS 14.6.1 (23G93)), I see some weird behavior from these buttons. My experiment is tapping + + + - - -. Here is what I see printed:

onIncrement()
onIncrement()
onIncrement()
onIncrement()
onDecrement()

What I expected was:

onIncrement()
onIncrement()
onIncrement()
onDecrement()
onDecrement()
onDecrement()

Why is an extra onIncrement being called? And why is one onDecrement dropping on the floor?

Deploying the app to iPhone Simulator does not repro this behavior (I see the six "correct" logs from iPhone Simulator).

Resetting the view id seems to lead to the correct values being printed on macOS:

import SwiftUI

@main struct StepperDemoApp: App {
  @State var id = UUID()
  
  func onIncrement() {
    print(#function)
    self.id = UUID()
  }
  
  func onDecrement() {
    print(#function)
    self.id = UUID()
  }
  
  var body: some Scene {
    WindowGroup {
      Stepper {
        Text("Stepper")
      } onIncrement: {
        self.onIncrement()
      } onDecrement: {
        self.onDecrement()
      }
      .id(self.id)
    }
  }
}

Tapping + + + - - -:

onIncrement()
onIncrement()
onIncrement()
onDecrement()
onDecrement()
onDecrement()

This is a hack? Or a legit workaround for a known issue? Is some internal state in Stepper bad for some reason without this id hack?

This one also works:

  var body: some Scene {
    WindowGroup {
      Stepper {
        Text(self.id.uuidString)
      } onIncrement: {
        self.onIncrement()
      } onDecrement: {
        self.onDecrement()
      }
    }
  }

hi @vanvoorden ,

I believe what's going on here is that anything that re-renders the view is causing it to start working as expected. I'm not certain what's causing it to not print the function names when there's no UI re-rendering, so I advise filing a bug report at https://feedbackassistant.apple.com and mentioning it. You'll notice that if you use this code:

struct ContentView: View {
  func onIncrement() {
      number += 1
    print(#function)
  }
  
  func onDecrement() {
      number -= 1
    print(#function)
  }
    @State private var number: Int = 0
    var body: some View {
    
      Stepper {
        Text("Stepper \(number)")
      } onIncrement: {
        self.onIncrement()
      } onDecrement: {
        self.onDecrement()
      }
    
  }
}

Where the UI is rendered each time increment or decrement is pressed because the number is changing, the code will work as expected.

I advise filing a bug report at https://feedbackassistant.apple.com and mentioning it.

I actually haven't had very quick results from feedback assistant… the last bug I filed was Feb 12 and I still see no comments or any information from an Apple Engineer. This bug has been fixed but I did not see any comments or notifications for me to follow along with the progress in feedback assistant.

I'm actually blocked on this Stepper. :( If I used one of my DTS service request support tickets would you be available to help investigate to confirm if this is a legit bug in the framework? My last DTS request was Oct 27 (last year) and I still have heard no response back from anyone at Apple. Could I file a DTS and ask for you to be the engineer if you have time? Thanks!

Hi @vanvoorden ,

You can submit a request here: https://developer.apple.com/support/technical/ but before you do, could you explain what you're using this for that's not updating any UI? we might be able to solve this here.

If you do submit a request, please put my name (Sydney) in the referral field and I will take a look.

could you explain what you're using this for that's not updating any UI

Thanks! The post here is an attempt at a MRE test case to show I am unable to see these two functions called how I expect on macOS (but iPhone simulator behaves as expected).

My production app does more complex work in the onIncrement and onDecrement functions and I am not seeing that work being called correctly. I seem to be running into the same problem in my production app I have here in the MRE test case… without an explicit state or id refreshing the stepper then the macOS stepper seems to get in some weird situation where the + and - buttons do not dispatch correctly until the next manual refresh computes a new body.

@vanvoorden thanks!

I've submitted a bug report myself and will see if I can find a workaround.

I've submitted a bug report myself and will see if I can find a workaround.

Thanks! I am actually kind of "unblocked" in a different way by building my own custom component:

@main struct StepperDemoApp: App {
  func onIncrement() {
    print(#function)
  }
  
  func onDecrement() {
    print(#function)
  }
  
  var body: some Scene {
    WindowGroup {
      VStack {
        Text("Stepper")
        Button("Increment") {
          self.onIncrement()
        }
        Button("Decrement") {
          self.onDecrement()
        }
      }
      .padding()
    }
  }
}

This unblocks my use case for my app… but I am still blocked from using the system Stepper component. If I did ship this custom component as a workaround it would be helpful for my repo to be able to explain to readers why this workaround is needed (because the production stepper component is potentially causing a bug on macOS).

This is a more detailed example that shows some extra work from my production app:

@main struct StepperDemoApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

@Observable final class Number {
  var value: Int = 0
}

struct ContentView: View {
  @State private var number = Number()
  
  func onIncrement() {
    number.value += 1
    print(#function)
  }
  
  func onDecrement() {
    number.value -= 1
    print(#function)
  }
  
  var body: some View {
    Stepper {
      Text("Stepper \(number.value)")
    } onIncrement: {
      self.onIncrement()
    } onDecrement: {
      self.onDecrement()
    }
    .padding()
  }
}

#Preview {
  ContentView()
}

In this example, the Stepper text label value depends on state (the number class), but I see the same unexpected behavior (potential bug) from my original example. My Stepper text label is correctly updating when the Observable value changes… but the onIncrement and onDecrement closures are in some kind of "bad" state.

My original example showed how the Stepper behaves unexpectedly when no state is triggering a redraw. This new example behaves unexpectedly when state is triggering a redraw through an Observable object. Our previous example showed how Stepper behaves as expected when we trigger a redraw directly on state (without the indirection of an Observable object).

All these repros are from macOS 14.6.1… deploying to iPhone simulator behaves as expected on all examples (no unexpected behavior).

SwiftUI.Stepper bug from `onIncrement` and `onDecrement`?
 
 
Q