I am using Xamarin Forms and somewhere in my application, I need the user to be able to enter a time in the format HH:mm:ss. So, basically I need a control like this one:
By using a custom iOS render, I've been able to remove the AM/PM part from the TimePicker to achieve something like this:
Here's my renderer code:
public class TimePickerSecondsRenderer : TimePickerRenderer
{
protected override void OnElementChanged(ElementChangedEventArgs<TimePicker> e)
{
base.OnElementChanged(e);
var timePicker = (UIDatePicker)Control.InputView;
timePicker.Locale = new NSLocale("CA");
}
}
It's a small step in the right direction I suppose, but I'm really at loss when it comes to adding a third column for the seconds, as well as the labels for each column.
I took a look at this post but it doesn't really help me so far.
Did anyone managed to achieve that kind of control with their Xamarin Forms project? Would you mind sharing some pointers?
I finally got a working implementation. Here's the result:
The only downside is that the labels "hr", "min", and "sec" are in their own column. As such, there is a bounce effect if the user tries to interact with them. I'll try to see if I can improve it later.
Suggestions welcome!
And, of course, the code (if it can be of use to someone):
The renderer
[assembly: ExportRendererAttribute(typeof(MyTimePicker), typeof(MyTimePickerRenderer))]
namespace MyProject.iOS.Renderers
{
public class MyTimePickerRenderer : PickerRenderer
{
internal static IDevice Device;
internal const int ComponentCount = 6;
private const int _labelSize = 30;
private MyTimePicker _myTimePicker;
public MyTimePickerRenderer()
{
// This is dependent on XForms (see Update note)
Device = Resolver.Resolve<IDevice>();
}
protected override void OnElementChanged (ElementChangedEventArgs<Picker> e)
{
base.OnElementChanged (e);
if (Control != null)
{
Control.BorderStyle = UITextBorderStyle.None;
_myTimePicker = e.NewElement as MyTimePicker;
var customModelPickerView = new UIPickerView
{
Model = new MyTimePickerView(_myTimePicker)
};
SelectPickerValue(customModelPickerView, _myTimePicker);
CreatePickerLabels(customModelPickerView);
Control.InputView = customModelPickerView;
}
}
private void SelectPickerValue(UIPickerView customModelPickerView, MyTimePicker myTimePicker)
{
customModelPickerView.Select(new nint(myTimePicker.SelectedTime.Hours), 0, false);
customModelPickerView.Select(new nint(myTimePicker.SelectedTime.Minutes), 2, false);
customModelPickerView.Select(new nint(myTimePicker.SelectedTime.Seconds), 4, false);
}
private void CreatePickerLabels(UIPickerView customModelPickerView)
{
nfloat verticalPosition = (customModelPickerView.Frame.Size.Height / 2) - (_labelSize / 2);
nfloat componentWidth = new nfloat(Device.Display.Width / ComponentCount / Device.Display.Scale);
var hoursLabel = new UILabel(new CGRect(componentWidth, verticalPosition, _labelSize, _labelSize));
hoursLabel.Text = "hrs";
var minutesLabel = new UILabel(new CGRect((componentWidth * 3) + (componentWidth / 2), verticalPosition, _labelSize, _labelSize));
minutesLabel.Text = "mins";
var secondsLabel = new UILabel(new CGRect((componentWidth * 5) + (componentWidth / 2), verticalPosition, _labelSize, _labelSize));
secondsLabel.Text = "secs";
customModelPickerView.AddSubview(hoursLabel);
customModelPickerView.AddSubview(minutesLabel);
customModelPickerView.AddSubview(secondsLabel);
}
protected override void OnElementPropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
base.OnElementPropertyChanged(sender, e);
if (Control == null)
{
return;
}
if (e.PropertyName == "SelectedIndex")
{
var customModelPickerView = (UIPickerView)Control.InputView;
SelectPickerValue(customModelPickerView, _myTimePicker);
}
}
public class MyTimePickerView : UIPickerViewModel
{
private readonly MyTimePicker _myTimePicker;
public MyTimePickerView(MyTimePicker picker)
{
_myTimePicker = picker;
}
public override nint GetComponentCount(UIPickerView pickerView)
{
return new nint(MyTimePickerRenderer.ComponentCount);
}
public override nint GetRowsInComponent(UIPickerView pickerView, nint component)
{
if (component == 0)
{
// Hours
return new nint(24);
}
if (component % 2 != 0)
{
// Odd components are labels for hrs, mins and secs
return new nint(1);
}
// Minutes & seconds
return new nint(60);
}
public override string GetTitle(UIPickerView pickerView, nint row, nint component)
{
if (component == 0)
{
return row.ToString();
}
else if (component == 1)
{
return null;
}
else if (component == 3)
{
return null;
}
else if (component == 5)
{
return null;
}
return row.ToString("00");
}
public override void Selected(UIPickerView pickerView, nint row, nint component)
{
var selectedHours = pickerView.SelectedRowInComponent(0);
var selectedMinutes = pickerView.SelectedRowInComponent(2);
var selectedSeconds = pickerView.SelectedRowInComponent(4);
var time = new TimeSpan((int)selectedHours, (int)selectedMinutes, (int)selectedSeconds);
_myTimePicker.SelectedTime = time;
}
public override nfloat GetComponentWidth(UIPickerView pickerView, nint component)
{
var screenWidth = MyTimePickerRenderer.Device.Display.Width;
var componentWidth = screenWidth /
MyTimePickerRenderer.ComponentCount /
MyTimePickerRenderer.Device.Display.Scale;
return new nfloat(componentWidth);
}
}
}
Custom bindable picker class
public class MyTimePicker : Picker
{
public static readonly BindableProperty SelectedTimeProperty =
BindableProperty.Create<MyTimePicker, TimeSpan>(p => p.SelectedTime, TimeSpan.MinValue, BindingMode.TwoWay,propertyChanged: OnSelectedTimePropertyPropertyChanged);
public MyTimePicker()
{
// Ugly hack since Xamarin Forms' Picker uses only one component internally
// This is a list of all possible timespan from 0:00:00 to 23:59:59
for (int hour = 0; hour < 24; hour++)
{
for (int minute = 0; minute < 60; minute ++)
{
for (int second = 0; second < 60; second++)
{
Items.Add(string.Format("{0:D2}:{1:D2}:{2:D2}", hour, minute, second));
}
}
}
base.SelectedIndexChanged += (o, e) =>
{
if (base.SelectedIndex < 0)
{
SelectedTime = TimeSpan.MinValue;
return;
}
int index = 0;
foreach (var item in Items)
{
if (index == SelectedIndex)
{
SelectedTime = TimeSpan.Parse(item);
break;
}
index++;
}
};
}
public TimeSpan SelectedTime
{
get { return (TimeSpan)GetValue(SelectedTimeProperty); }
set { SetValue(SelectedTimeProperty, value); }
}
private static void OnSelectedTimePropertyPropertyChanged(BindableObject bindable, TimeSpan value, TimeSpan newValue)
{
var picker = (MyTimePicker)bindable;
var itemMatch = picker.Items.FirstOrDefault(x => x == newValue.ToString());
var index = picker.Items.IndexOf(itemMatch);
picker.SelectedIndex = index;
}
}
XAML usage
<myControls:MyTimePicker SelectedTime="{Binding SelectedTime}" />
Where SelectedTime is your ViewModel property used for binding. Must be of type TimeSpan.
I'm sure it can be improved, so comment away if you have suggestions.
UPDATE
I've updated the renderer code.
- The "hrs", "mins" and "secs" are now label and no longer columns of their own. That means that the user no longer can interact with them (no more bounce effect)
- I've also fixed a bug where, if the picker value was set from the ViewModel, upon opening the keyboard the selection was still at 00:00:00
- Better support for iPhone Plus resolution
Note that this code is dependent on XForms (https://github.com/XLabs/Xamarin-Forms-Labs) to get the device screen size, because that's the framework that I'm using for my project, but it can easily be modified.