Reputation: 25785
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
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
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
Reputation: 11
In iOS15, this solution: https://stackoverflow.com/a/56568715/12847995 is good, but it requires using the modifier ".compositingGroup()" before ".clipped()" modifier.
Upvotes: 1
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
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
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
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:
Upvotes: 27
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