Multi-Component Picker (UIPickerView) in SwiftUI
Asked Answered
B

8

16

I'm trying to add a three-component Picker (UIPickerView) to a SwiftUI app (in a traditional UIKit app, the data source would return 3 from the numberOfComponents method), but I can't find an example of this anywhere.

I've tried adding an HStack of three single-component Pickers, but the perspective is off from what it would be if they were all part of a single Picker.

Bellybutton answered 12/6, 2019 at 17:28 Comment(0)
W
22

Here's an adaptation of the solutions above, using the UIKit picker:

import SwiftUI

struct PickerView: UIViewRepresentable {
    var data: [[String]]
    @Binding var selections: [Int]
    
    //makeCoordinator()
    func makeCoordinator() -> PickerView.Coordinator {
        Coordinator(self)
    }

    //makeUIView(context:)
    func makeUIView(context: UIViewRepresentableContext<PickerView>) -> UIPickerView {
        let picker = UIPickerView(frame: .zero)
        
        picker.dataSource = context.coordinator
        picker.delegate = context.coordinator

        return picker
    }

    //updateUIView(_:context:)
    func updateUIView(_ view: UIPickerView, context: UIViewRepresentableContext<PickerView>) {
        for i in 0...(self.selections.count - 1) {
            view.selectRow(self.selections[i], inComponent: i, animated: false)
        }
        context.coordinator.parent = self // fix
    }
    
    class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
        var parent: PickerView
        
        //init(_:)
        init(_ pickerView: PickerView) {
            self.parent = pickerView
        }
        
        //numberOfComponents(in:)
        func numberOfComponents(in pickerView: UIPickerView) -> Int {
            return self.parent.data.count
        }
        
        //pickerView(_:numberOfRowsInComponent:)
        func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
            return self.parent.data[component].count
        }
        
        //pickerView(_:titleForRow:forComponent:)
        func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
            return self.parent.data[component][row]
        }
        
        //pickerView(_:didSelectRow:inComponent:)
        func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
            self.parent.selections[component] = row
        }
    }
}

import SwiftUI

struct ContentView: View {
    private let data: [[String]] = [
        Array(0...10).map { "\($0)" },
        Array(20...40).map { "\($0)" },
        Array(100...200).map { "\($0)" }
    ]
    
    @State private var selections: [Int] = [5, 10, 50]

    var body: some View {
        VStack {
            PickerView(data: self.data, selections: self.$selections)

            Text("\(self.data[0][self.selections[0]]) \(self.data[1][self.selections[1]]) \(self.data[2][self.selections[2]])")
        } //VStack
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Winzler answered 1/11, 2019 at 18:58 Comment(3)
Weirdly, SwiftUI still doesn't have a native multi-component picker wheel. This solution still works great, though.Primordium
Seems to be the only working solution (of the examples on this page) with iOS 15. Works great, responds well. Too bad SwiftUI doesn't have this native yet... :/Sendoff
Is there any way to add any text near each picker row selection? Something like in the apple built in Timer app where it says "x hours y min z sec".Pedometer
C
26

Updated answer in pure SwiftUI- in this example the data is of type String.

Tested on Xcode 11.1 - may not work on previous versions.

struct MultiPicker: View  {

    typealias Label = String
    typealias Entry = String

    let data: [ (Label, [Entry]) ]
    @Binding var selection: [Entry]

    var body: some View {
        GeometryReader { geometry in
            HStack {
                ForEach(0..<self.data.count) { column in
                    Picker(self.data[column].0, selection: self.$selection[column]) {
                        ForEach(0..<self.data[column].1.count) { row in
                            Text(verbatim: self.data[column].1[row])
                            .tag(self.data[column].1[row])
                        }
                    }
                    .pickerStyle(WheelPickerStyle())
                    .frame(width: geometry.size.width / CGFloat(self.data.count), height: geometry.size.height)
                    .clipped()
                }
            }
        }
    }
}

Demo:

struct ContentView: View {

    @State var data: [(String, [String])] = [
        ("One", Array(0...10).map { "\($0)" }),
        ("Two", Array(20...40).map { "\($0)" }),
        ("Three", Array(100...200).map { "\($0)" })
    ]
    @State var selection: [String] = [0, 20, 100].map { "\($0)" }

    var body: some View {
        VStack(alignment: .center) {
            Text(verbatim: "Selection: \(selection)")
            MultiPicker(data: data, selection: $selection).frame(height: 300)
        }
    }

}

Result:

enter image description here

Caracul answered 12/6, 2019 at 18:56 Comment(14)
I wonder if you could tell me where makeCoordinator gets called?Squamation
I believe it's called by the SwiftUI framework. I'm not 100% sure, but I believe it's called only once and then cached even if the view is updated.Sayce
I get a "Cannot assign to property: '$data' is immutable" error on $data and $selection in the init() method.Bumbledom
@patterson7019 answer updated, please check the pure SwiftUI versionCaracul
Thanks worked great! How would we assign a label to each column based on the data.label?Gault
For those blindly copying this piece: It is vital to use the .clipped() modifier on each picker, as several pickers next to each other horizontally distribute overlapping gesture space, resulting up in taking in the wrong coordinates for where you tap on an iOS device. ".clipped()" will fix this! It took me three days to figure this out as the source of the error.Tipple
This has issues with smooth scrolling in iOS 14 / Swift 5.3. Check out how the iOS Timer scrolls smoothly and compare it to this. This snaps back to the original value. Will need to find a different solution.Jugoslavia
This started failing for me in iOS 15. The touch areas seem to go beyond the View width, so trying to spin the first Picker causes the second one to move. (I only have two on my screen)Hipbone
Same - all of the proposed SwiftUI solutions on this page don't work on iOS 15. Touch areas are overlaid by pickers to the right... so, left-most is covered and cannot be a used, etc.Sendoff
All the other SO articles about the issues with touch controls and overlapping pickers seem to point to this solution, so just to reiterate (so you don't spend as much time as I did implementing it anyways)- as of February 2022 and iOS 15.2, this still doesn't work any better than just the regular old "pickers side-by-side and clipped" method (which doesn't work). Submit bug reports, people! Apple will only bother getting their shit together if enough people complain about it.Marauding
This was great, basically plug and playCamus
Solution for iOS 15 touch area issue: https://mcmap.net/q/655408/-swiftui-placing-two-pickers-side-by-side-in-hstack-does-not-resize-pickersDrachma
Just to confirm that the solution at https://mcmap.net/q/655408/-swiftui-placing-two-pickers-side-by-side-in-hstack-does-not-resize-pickers works great on iOS15. Add that!Jaret
@MatteoPacini I noticed that the third wheel was overflowing to the right on the screen because the width calculation with the geometryReader is not taking into consideration the padding the HStack adds by default. I would suggest to modify the HStack adding the (spacing: 0) so the wheel displays properly.Lighthearted
W
22

Here's an adaptation of the solutions above, using the UIKit picker:

import SwiftUI

struct PickerView: UIViewRepresentable {
    var data: [[String]]
    @Binding var selections: [Int]
    
    //makeCoordinator()
    func makeCoordinator() -> PickerView.Coordinator {
        Coordinator(self)
    }

    //makeUIView(context:)
    func makeUIView(context: UIViewRepresentableContext<PickerView>) -> UIPickerView {
        let picker = UIPickerView(frame: .zero)
        
        picker.dataSource = context.coordinator
        picker.delegate = context.coordinator

        return picker
    }

    //updateUIView(_:context:)
    func updateUIView(_ view: UIPickerView, context: UIViewRepresentableContext<PickerView>) {
        for i in 0...(self.selections.count - 1) {
            view.selectRow(self.selections[i], inComponent: i, animated: false)
        }
        context.coordinator.parent = self // fix
    }
    
    class Coordinator: NSObject, UIPickerViewDataSource, UIPickerViewDelegate {
        var parent: PickerView
        
        //init(_:)
        init(_ pickerView: PickerView) {
            self.parent = pickerView
        }
        
        //numberOfComponents(in:)
        func numberOfComponents(in pickerView: UIPickerView) -> Int {
            return self.parent.data.count
        }
        
        //pickerView(_:numberOfRowsInComponent:)
        func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
            return self.parent.data[component].count
        }
        
        //pickerView(_:titleForRow:forComponent:)
        func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
            return self.parent.data[component][row]
        }
        
        //pickerView(_:didSelectRow:inComponent:)
        func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
            self.parent.selections[component] = row
        }
    }
}

import SwiftUI

struct ContentView: View {
    private let data: [[String]] = [
        Array(0...10).map { "\($0)" },
        Array(20...40).map { "\($0)" },
        Array(100...200).map { "\($0)" }
    ]
    
    @State private var selections: [Int] = [5, 10, 50]

    var body: some View {
        VStack {
            PickerView(data: self.data, selections: self.$selections)

            Text("\(self.data[0][self.selections[0]]) \(self.data[1][self.selections[1]]) \(self.data[2][self.selections[2]])")
        } //VStack
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Winzler answered 1/11, 2019 at 18:58 Comment(3)
Weirdly, SwiftUI still doesn't have a native multi-component picker wheel. This solution still works great, though.Primordium
Seems to be the only working solution (of the examples on this page) with iOS 15. Works great, responds well. Too bad SwiftUI doesn't have this native yet... :/Sendoff
Is there any way to add any text near each picker row selection? Something like in the apple built in Timer app where it says "x hours y min z sec".Pedometer
S
8

The easiest way zto do this is creating a wrapped UI View using a UIDatePicker with datePickerMode set to .countDownTimer.

Paste the code below into a new SwiftUI view file called "TimeDurationPicker". The picker updates duration with the value of countDownDuration in DatePicker.

You can preview the picker on the Canvas.

struct TimeDurationPicker: UIViewRepresentable {
    typealias UIViewType = UIDatePicker
    
    @Binding var duration: TimeInterval
   
    func makeUIView(context: Context) -> UIDatePicker {
        let timeDurationPicker = UIDatePicker()
        timeDurationPicker.datePickerMode = .countDownTimer
        timeDurationPicker.addTarget(context.coordinator, action: #selector(Coordinator.changed(_:)), for: .valueChanged)
        return timeDurationPicker
    }

    func updateUIView(_ uiView: UIDatePicker, context: Context) {
        uiView.countDownDuration = duration
    }

    func makeCoordinator() -> TimeDurationPicker.Coordinator {
        Coordinator(duration: $duration)
    }

    class Coordinator: NSObject {
        private var duration: Binding<TimeInterval>

        init(duration: Binding<TimeInterval>) {
            self.duration = duration
        }

        @objc func changed(_ sender: UIDatePicker) {
            self.duration.wrappedValue = sender.countDownDuration
        }
    }
}

struct TimeDurationPicker_Previews: PreviewProvider {
    static var previews: some View {
        TimeDurationPicker(duration: .constant(60.0 * 30.0))
    }
}
Swagman answered 2/10, 2021 at 18:18 Comment(0)
J
5

Just like several other developers in this forum, I found out that the pure SwiftUI solution doesn't work that well in iOS 15 or above. By the way, the UIPickerView solution with UIRepresentableView doesn't work that well either. The height overlaps with other views, that require user input. On the Apple Developer Forum TommyL presented a very elegant and simple solution. Basically, you have to extend UIPickerView with this code:

extension UIPickerView {
    open override var intrinsicContentSize: CGSize {
        return CGSize(width: UIView.noIntrinsicMetric, height: 60)
    }
}

You should fill in a value for height to prevent the Picker to extend too much in the vertical direction. This will limit both the view and the touch area of the picker. It works for me in Xcode 13 and 14 and iOS 15 and above.

Joviality answered 21/7, 2022 at 15:3 Comment(1)
This works and gives you nice rounded corners and padding on the selected row markers. Interactions don"t overlap if you have multiple pickers in an HStack. If you want to maintain original height, use super.intrinsicContentSize.height instead of a constant.Frogfish
E
4

Even with .clipped(), the underlying pickers don't shrink and tend to overlap other pickers. The only way I've managed to clip even the underlying picker views is by adding .mask(Rectangle()) to the parent container. Don't ask why, I have no idea.

A working example with 2 pickers (hours & minues):

GeometryReader { geometry in
    HStack(spacing: 0) {
        Picker("", selection: self.$hoursIndex) {
            ForEach(0..<13) {
                Text(String($0)).tag($0)
            }
        }
        .labelsHidden()
        .fixedSize(horizontal: true, vertical: true)
        .frame(width: geometry.size.width / 2, height: 160)
        .clipped()
        
        Picker("", selection: self.$minutesIndex) {
            ForEach(0..<12) {
                Text(String($0*5)).tag($0*5)
            }
        }
        .labelsHidden()
        .fixedSize(horizontal: true, vertical: true)
        .frame(width: geometry.size.width / 2, height: 160)
        .clipped()
    }
}
.frame(height: 160)
.mask(Rectangle())
Enculturation answered 31/8, 2020 at 20:6 Comment(0)
C
2

I liked woko's answer a lot, but the end result left a little to be desired visually speaking. The elements felt a tad spaced out, so I changed the geometry.size.width multiplier from 2 to 5 and added spacers on either side of the pickers. (I also included the hoursIndex and mintuesIndex variables that were missing from woko's answer.)

The following is testing on iOS 14 using Xcode 12 on the iPhone 12 Pro Max simulator.

struct TimerView: View {
    @State private var hours = Calendar.current.component(.hour, from: Date())
    @State private var minutes = Calendar.current.component(.minute, from: Date())

    var body: some View {
        TimeEditPicker(selectedHour: $hours, selectedMinute: $minutes)
    }
}

struct TimeEditPicker: View {
    @Binding var selectedHour: Int
    @Binding var selectedMinute: Int

    var body: some View {
        GeometryReader { geometry in
            HStack(spacing: 0) {
                Spacer()
                Picker("", selection: self.$selectedHour) {
                    ForEach(0..<24) {
                        Text(String($0)).tag($0)
                    }
                }
                .labelsHidden()
                .fixedSize(horizontal: true, vertical: true)
                .frame(width: geometry.size.width / 5, height: 160)
                .clipped()

                Picker("", selection: self.$selectedMinute) {
                    ForEach(0..<60) {
                        Text(String($0)).tag($0)
                    }
                }
                .labelsHidden()
                .fixedSize(horizontal: true, vertical: true)
                .frame(width: geometry.size.width / 5, height: 160)
                .clipped()

                Spacer()
            }
        }
        .frame(height: 160)
        .mask(Rectangle())
    }
}
Condyle answered 11/10, 2020 at 19:10 Comment(0)
S
1

In iOS15, this solution: https://mcmap.net/q/712125/-multi-component-picker-uipickerview-in-swiftui is good, but it requires using the modifier ".compositingGroup()" before ".clipped()" modifier.

Scheme answered 4/11, 2021 at 12:37 Comment(0)
B
0

This isn't quite as elegant but it doesn't involve porting over any UIKit stuff. I know you mentioned perspective was off in your answer but perhaps the geometry here fixes that

GeometryReader { geometry in

    HStack
    {
         Picker(selection: self.$selection, label: Text(""))
         {
              ForEach(0 ..< self.data1.count)
              {
                  Text(self.data1[$0])
                     .color(Color.white)
                     .tag($0)
              }
          }
          .pickerStyle(.wheel)
          .fixedSize(horizontal: true, vertical: true)
          .frame(width: geometry.size.width / 2, height: geometry.size.height, alignment: .center)


          Picker(selection: self.$selection2, label: Text(""))
          {
               ForEach(0 ..< self.data2.count)
               {
                   Text(self.data2[$0])
                       .color(Color.white)
                       .tag($0)
               }
          }
          .pickerStyle(.wheel)
          .fixedSize(horizontal: true, vertical: true)
          .frame(width: geometry.size.width / 2, height: geometry.size.height, alignment: .center)

    }
}

Using the geometry and fixing the size like this shows the two pickers neatly taking up half the width of the screen in each half. Now you just need to handle selection from two different state variables instead of one but I prefer this way as it keeps everything in swift UI

Bennington answered 12/7, 2019 at 10:43 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.