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.
The best solution I found - so far - is to port the original UIPickerView
in SwiftUI, via UIViewRepresentable
and a coordinator.
Porting UIKit components to SwiftUI is discussed in this amazing WWDC 2019 video:
Integrating SwiftUI
Here's the result (see demo code at the bottom):
Implementation:
There are two bindings, data
and selection
, that are passed down from the superview.
data is an array of array of Data
, e.g.
[
Array(1...100),
Array(1...100),
Array(1...100)
]
Each array represent a picker component.
Each value in an array represent a row in a picker component.
selection is an array of Data
, e.g. [10, 20, 30]
It represents the picker selection (across all components).
If one of the bindings changes, it triggers a redraw of all components.
Also, the selection is restored.
struct CustomPicker<Data>: UIViewRepresentable where Data: Equatable {
@Binding var data: [[Data]]
@Binding var selection: [Data]
class Coordinator: NSObject, UIPickerViewDelegate, UIPickerViewDataSource {
@Binding var data: [[Data]]
@Binding var selection: [Data]
init(data: Binding<[[Data]]>, selection: Binding<[Data]>) {
$data = data
$selection = selection
}
func pickerView(_ pickerView: UIPickerView,
numberOfRowsInComponent component: Int) -> Int {
data[component].count
}
func numberOfComponents(in pickerView: UIPickerView) -> Int {
data.count
}
func pickerView(_ pickerView: UIPickerView,
widthForComponent component: Int) -> CGFloat {
return (pickerView.superview?.bounds.width ?? 0) * 0.33
}
func pickerView(_ pickerView: UIPickerView,
rowHeightForComponent component: Int) -> CGFloat {
return 30
}
func pickerView(_ pickerView: UIPickerView,
viewForRow row: Int,
forComponent component: Int,
reusing view: UIView?) -> UIView {
guard let reusableView = view as? UILabel else {
let label = UILabel(frame: .zero)
label.backgroundColor = UIColor.red.withAlphaComponent(0.15)
label.text = "\(data[component][row])"
return label
}
reusableView.text = "\(data[component][row])"
return reusableView
}
func pickerView(_ pickerView: UIPickerView,
didSelectRow row: Int,
inComponent component: Int) {
let value = data[component][row]
selection[component] = value
}
}
func makeCoordinator() -> CustomPicker.Coordinator {
return Coordinator(data: $data,
selection: $selection)
}
func makeUIView(context: UIViewRepresentableContext<CustomPicker>) -> UIPickerView {
let picker = UIPickerView()
picker.delegate = context.coordinator
return picker
}
func updateUIView(_ uiView: UIPickerView,
context: UIViewRepresentableContext<CustomPicker>) {
uiView.reloadAllComponents()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.selection.enumerated().forEach { tuple in
let (offset, value) = tuple
let row = self.data[offset].firstIndex { $0 == value } ?? 0
uiView.selectRow(row, inComponent: offset, animated: false)
}
}
}
}
Demo:
struct ContentView: View {
@State var data: [[Int]] = [
Array(0...10),
Array(20...40),
Array(100...200)
]
@State var selection: [Int] = [0, 20, 100]
var body: some View {
NavigationView {
VStack {
CustomPicker(data: self.$data,
selection: self.$selection)
Text("Selection: \(String(describing: selection))")
}
}
}
}
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