Frank Schmitt
Frank Schmitt

Reputation: 25785

Multi-Component Picker (UIPickerView) in SwiftUI

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.

Upvotes: 17

Views: 24186

Answers (8)

protasm
protasm

Reputation: 1297

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()
    }
}

Upvotes: 22

MacUserT
MacUserT

Reputation: 1953

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.

Upvotes: 6

In iOS15, this solution: https://stackoverflow.com/a/56568715/12847995 is good, but it requires using the modifier ".compositingGroup()" before ".clipped()" modifier.

Upvotes: 1

Maurice
Maurice

Reputation: 111

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))
    }
}

Upvotes: 11

snowskeleton
snowskeleton

Reputation: 815

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())
    }
}

Upvotes: 2

woko
woko

Reputation: 41

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())

Upvotes: 4

Matteo Pacini
Matteo Pacini

Reputation: 22856

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

Upvotes: 27

AngryDuck
AngryDuck

Reputation: 4607

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

Upvotes: 0

Related Questions