Mike Haslam
Mike Haslam

Reputation: 171

How to use increment & decrement functions in stepper while also using onChange modifier

I have my audioPlayer setup already

In addition to the current functionality of the stepper, I want to also play separate sounds for onIncrement & onDecrement.

This project uses Core Data to persist. $estimatorData.qty listens to published var my View Model when the qty changes the new qty is saved in my view model estimatorData.save()

Here is a link to docs Stepper

I am trying to wrap my head around if one of the initializers would fit with what I am trying to accomplish

Stepper("", value: $estimatorData.qty.onChange { qty in
    estimatorData.save()
}, in: 0...10000)
    .frame(width: 100, height: 35)
    .offset(x: -4)
    .background(colorScheme == .dark ? Color.blue : Color.blue)
    .cornerRadius(8)

Here are my players

func incrementTaped() {
    playSound(sound: "plus", type: "mp3")
}
 
func decrementTaped() {
    playSound(sound: "minus", type: "m4a")
}

Upvotes: 2

Views: 2036

Answers (2)

pawello2222
pawello2222

Reputation: 54566

Problem

Currently there is no initialiser which combines both onIncrement / onDecrement functions and value / bounds / step parameters.

You can either have onIncrement and onDecrement:

public init(onIncrement: (() -> Void)?, onDecrement: (() -> Void)?, onEditingChanged: @escaping (Bool) -> Void = { _ in }, @ViewBuilder label: () -> Label)

or value, bounds and step:

public init<V>(value: Binding<V>, in bounds: ClosedRange<V>, step: V.Stride = 1, onEditingChanged: @escaping (Bool) -> Void = { _ in }, @ViewBuilder label: () -> Label) where V : Strideable

Solution

Instead, you can create a custom Stepper to combine both initialisers:

struct CustomStepper<Label, Value>: View where Label: View, Value: Strideable {
    @Binding private var value: Value
    private let bounds: ClosedRange<Value>
    private let step: Value.Stride
    private let onIncrement: (() -> Void)?
    private let onDecrement: (() -> Void)?
    private let label: () -> Label

    @State private var previousValue: Value

    public init(
        value: Binding<Value>,
        in bounds: ClosedRange<Value>,
        step: Value.Stride = 1,
        onIncrement: (() -> Void)? = nil,
        onDecrement: (() -> Void)? = nil,
        @ViewBuilder label: @escaping () -> Label
    ) {
        self._value = value
        self.bounds = bounds
        self.step = step
        self.onIncrement = onIncrement
        self.onDecrement = onDecrement
        self.label = label
        self._previousValue = .init(initialValue: value.wrappedValue)
    }

    var body: some View {
        Stepper(
            value: $value,
            in: bounds,
            step: step,
            onEditingChanged: onEditingChanged,
            label: label
        )
    }

    func onEditingChanged(isEditing: Bool) {
        guard !isEditing else {
            previousValue = value
            return
        }
        if previousValue < value {
            onIncrement?()
        } else if previousValue > value {
            onDecrement?()
        }
    }
}

Demo

struct ContentView: View {
    @State private var value = 1

    var body: some View {
        CustomStepper(value: $value, in: 1...10, onIncrement: onIncrement, onDecrement: onDecrement) {
            Text(String(value))
        }
        .onChange(of: value) {
            print("onChange value: \($0)")
        }
    }
    
    func onIncrement() {
        print("onIncrement")
    }

    func onDecrement() {
        print("onDecrement")
    }
}

Upvotes: 6

Josh Homann
Josh Homann

Reputation: 16327

Generally speaking the business logic should not go in your view since views are values that get created and destroyed all the time. Move it into a ViewModel. You can watch any published property by using its projectedValue to get a publisher. For example:

import SwiftUI
import PlaygroundSupport
import Combine

final class ViewModel: ObservableObject {
  @Published var steps: Int = 0
  @Published var title: String = ""
  init() {
    $steps
      .handleEvents(receiveOutput: { value in
        // Perform your audio side effect for this value here
      })
      .map {"The current value is \($0)" }
      .assign(to: &$title)
  }
}

struct ContentView: View {
  @StateObject var viewModel = ViewModel()
  var body: some View {
    Stepper(viewModel.title, value: $viewModel.steps)
  }
}

PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())

Upvotes: 1

Related Questions