61

I want to change another unrelated @State variable when a Picker gets changed, but there is no onChanged and it's not possible to put a didSet on the pickers @State. Is there another way to solve this?

Lemon
  • 1,184
  • 11
  • 33
trapper
  • 11,716
  • 7
  • 38
  • 82

9 Answers9

153

Deployment target of iOS 14 or newer

Apple has provided a built in onChange extension to View, which can be used like this:

struct MyPicker: View {
    @State private var favoriteColor = 0

    var body: some View {
        Picker(selection: $favoriteColor, label: Text("Color")) {
            Text("Red").tag(0)
            Text("Green").tag(1)
        }
        .onChange(of: favoriteColor) { tag in print("Color tag: \(tag)") }
    }
}

Deployment target of iOS 13 or older

struct MyPicker: View {
    @State private var favoriteColor = 0

    var body: some View {
        Picker(selection: $favoriteColor.onChange(colorChange), label: Text("Color")) {
            Text("Red").tag(0)
            Text("Green").tag(1)
        }
    }

    func colorChange(_ tag: Int) {
        print("Color tag: \(tag)")
    }
}

Using this helper

extension Binding {
    func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> {
        return Binding(
            get: { self.wrappedValue },
            set: { selection in
                self.wrappedValue = selection
                handler(selection)
        })
    }
}
chasew
  • 8,438
  • 7
  • 41
  • 48
  • 1
    This is a good solution, but as SwiftUI matures, I would expect a native solution. – William Grand May 01 '20 at 18:18
  • This is a brilliant solution! Far better than `.onReceive` since that was called every time the View got rendered in my case. Thank you. – ixany Aug 04 '20 at 19:00
  • @WilliamGrand You were right, iOS 14 introduced [.onChange](https://www.hackingwithswift.com/quick-start/swiftui/how-to-run-some-code-when-state-changes-using-onchange) – Jeremy Aug 05 '20 at 15:17
  • Should have mentioned `tag` is important here otherwise `onChange` modifier won't be invoked in the latter case. I have not tried the first one though – Asesh Mar 01 '21 at 15:24
  • The iOS 14 solution worked perfectly! – dbDev Nov 09 '21 at 15:33
  • Thank you, solved my problem, helped me better understand both .tag() and .onChange(). My problem was, I wanted my picker bound to an Int in my view model. The .tag() and .onChange() helped me update the Int in sync with the Picker selection. Inside .onChange perform { tag in vm.foo = tag }. – Tim Sonner Apr 06 '22 at 03:11
31

First of all, full credit to ccwasden for the best answer. I had to modify it slightly to make it work for me, so I'm answering this question hoping someone else will find it useful as well.

Here's what I ended up with (tested on iOS 14 GM with Xcode 12 GM)

struct SwiftUIView: View {
    @State private var selection = 0

    var body: some View {
        Picker(selection: $selection, label: Text("Some Label")) {
            ForEach(0 ..< 5) {
                Text("Number \($0)") }
        }.onChange(of: selection) { _ in
            print(selection)
        }
        
    }
}

The inclusion of the "_ in" was what I needed. Without it, I got the error "Cannot convert value of type 'Int' to expected argument type '()'"

snowskeleton
  • 644
  • 7
  • 7
15

I think this is simpler solution:

@State private var pickerIndex = 0
var yourData = ["Item 1", "Item 2", "Item 3"]

// USE this if needed to notify parent
@Binding var notifyParentOnChangeIndex: Int    

var body: some View {

   let pi = Binding<Int>(get: {

            return self.pickerIndex

        }, set: {

            self.pickerIndex = $0

            // TODO: DO YOUR STUFF HERE
            // TODO: DO YOUR STUFF HERE
            // TODO: DO YOUR STUFF HERE

            // USE this if needed to notify parent
            self.notifyParentOnChangeIndex = $0

        })

   return VStack{

            Picker(selection: pi, label: Text("Yolo")) {
                ForEach(self.yourData.indices) {
                    Text(self.yourData[$0])
                }
            }
            .pickerStyle(WheelPickerStyle())
            .padding()

   }

}
sabiland
  • 2,526
  • 1
  • 25
  • 24
9

I know this is a year old post, but I thought this solution might help others that stop by for a visit in need of a solution. Hope it helps someone else.

import Foundation
import SwiftUI

struct MeasurementUnitView: View {
    
    @State var selectedIndex = unitTypes.firstIndex(of: UserDefaults.standard.string(forKey: "Unit")!)!
    var userSettings: UserSettings
    
    var body: some View {
        
        VStack {
            Spacer(minLength: 15)
            Form {
                Section {
                    Picker(selection: self.$selectedIndex, label: Text("Current UnitType")) {
                        
                        ForEach(0..<unitTypes.count, id: \.self) {
                            Text(unitTypes[$0])
                        }
                    }.onReceive([self.selectedIndex].publisher.first()) { (value) in
                        self.savePick()
                    }
                    .navigationBarTitle("Change Unit Type", displayMode: .inline)
                }
            }
        }
    }
    
    func savePick() {
        
        if (userSettings.unit != unitTypes[selectedIndex]) {
            userSettings.unit = unitTypes[selectedIndex]
        }
    }
}


Tim
  • 597
  • 8
  • 18
  • This actually solved another problem for me. I find that `onReceive` is a little too sensitive, and got into an infinite loop when it triggered an indirect change to the `@State` variable to itself. By adding `if value == oldValue { return }` it left this alone. – Manngo Oct 16 '20 at 05:37
  • How did you get `oldValue` ? – richy Jun 16 '21 at 10:08
  • It is nice but every time I tap on the picker it gets default first picker status. I am losing the last picker value during the process. How can I modify .publisher.first() to something else to avoid this problem. – Mert Köksal Aug 19 '21 at 08:51
  • .onChange(of: self.selectedStatusIndex) { newValue in self.editItem(account: account) } – Mert Köksal Aug 19 '21 at 09:10
8

I use a segmented picker and had a similar requirement. After trying a few things I just used an object that had both an ObservableObjectPublisher and a PassthroughSubject publisher as the selection. That let me satisfy SwiftUI and with an onReceive() I could do other stuff as well.

// Selector for the base and radix
Picker("Radix", selection: $base.value) {
    Text("Dec").tag(10)
    Text("Hex").tag(16)
    Text("Oct").tag(8)
}
.pickerStyle(SegmentedPickerStyle())
// receiver for changes in base
.onReceive(base.publisher, perform: { self.setRadices(base: $0) })

base has both an objectWillChange and a PassthroughSubject<Int, Never> publisher imaginatively called publisher.

class Observable<T>: ObservableObject, Identifiable {
    let id = UUID()
    let objectWillChange = ObservableObjectPublisher()
    let publisher = PassthroughSubject<T, Never>()
    var value: T {
        willSet { objectWillChange.send() }
        didSet { publisher.send(value) }
    }

    init(_ initValue: T) { self.value = initValue }
}

typealias ObservableInt = Observable<Int>

Defining objectWillChange isn't strictly necessary but when I wrote that I liked to remind myself that it was there.

Michael Salmon
  • 1,056
  • 7
  • 15
6

For people that have to support both iOS 13 and 14, I added an extension which works for both. Don't forget to import Combine.

Extension View {
    @ViewBuilder func onChangeBackwardsCompatible<T: Equatable>(of value: T, perform completion: @escaping (T) -> Void) -> some View {
        if #available(iOS 14.0, *) {
            self.onChange(of: value, perform: completion)
        } else {
            self.onReceive([value].publisher.first()) { (value) in
                completion(value)
            }
        }
    }
}

Usage:

Picker(selection: $selectedIndex, label: Text("Color")) {
    Text("Red").tag(0)
    Text("Blue").tag(1)
}.onChangeBackwardsCompatible(of: selectedIndex) { (newIndex) in
    print("Do something with \(newIndex)")
}

Important note: If you are changing a published property inside an observed object within your completion block, this solution will cause an infinite loop in iOS 13. However, it is easily fixed by adding a check, something like this:

.onChangeBackwardsCompatible(of: showSheet, perform: { (shouldShowSheet) in
   if shouldShowSheet {
      self.router.currentSheet = .chosenSheet
      showSheet = false
   }
})

Geo
  • 61
  • 1
  • 3
4

SwiftUI 1 & 2

Use onReceive and Just:

import Combine
import SwiftUI

struct ContentView: View {
    @State private var selection = 0

    var body: some View {
        Picker("Some Label", selection: $selection) {
            ForEach(0 ..< 5, id: \.self) {
                Text("Number \($0)")
            }
        }
        .onReceive(Just(selection)) {
            print("Selected: \($0)")
        }
    }
}
pawello2222
  • 46,897
  • 22
  • 145
  • 209
3

iOS 14 and CoreData entities with relationships

I ran into this issue while trying to bind to a CoreData entity and found that the following works:

Picker("Level", selection: $contact.level) {
                        ForEach(levels) { (level: Level?) in
                            HStack {
                                Circle().fill(Color.green)
                                    .frame(width: 8, height: 8)
                                Text("\(level?.name ?? "Unassigned")")
                            }
                            .tag(level)
                        }
                    }
.onChange(of: contact.level) { _ in savecontact() }

Where "contact" is an entity with a relationship to "level".

The Contact class is an @ObservedObject var contact: Contact

saveContact is a do-catch function to try viewContext.save()...

Rillieux
  • 587
  • 9
  • 23
2

The very important issue : we must pass something to "tag" modifier of Picker item view (inside ForEach) to let it "identify" items and trigger selection change event. And the value we passed will return to Binding variable with "selection" of Picker.

For example :

Picker(selection: $selected, label: Text("")){
            
            ForEach(data){item in //data's item type must conform Identifiable
                
                HStack{
                    
                    //item view
                    
                
                }
                .tag(item.property)
                
            }
            
        }
        
        .onChange(of: selected, perform: { value in
            
            //handle value of selected here (selected = item.property when user change selection)
            
        })
Ole Pannier
  • 3,208
  • 9
  • 22
  • 33