The RadioButton control is one of the most used controls in almost any application, however Xamarin Forms does not ship with one, so what do you do?
Yes, create
it; it’s a good opportunity to have some fun creating a missing control.
Let’s
address the problem in detail.
What do you
need?
I need a RadioButtonsGroup control not just a single control; that can render nth of radio buttons equal to the items bound to it from its ItemsSource property. We also need to know which item from the ItemsSource collection has been selected by data binding, for that, the control will have SelectedItem Property for the whole selected object, but you may be interested in only one property from that object like ID, so we’ll implement SelectedValue as well, and also SelectedIndex. And Orientation property for the Horizontal & Vertical orientation.
When you bind the control to a collection of items, the control need to know what property is used for the displayed text and what property used for returning the SelectedValue, so it will contain DisplayMemberPath and SelectedValuePath properties, that we will use reflection to manipulate.
I need a RadioButtonsGroup control not just a single control; that can render nth of radio buttons equal to the items bound to it from its ItemsSource property. We also need to know which item from the ItemsSource collection has been selected by data binding, for that, the control will have SelectedItem Property for the whole selected object, but you may be interested in only one property from that object like ID, so we’ll implement SelectedValue as well, and also SelectedIndex. And Orientation property for the Horizontal & Vertical orientation.
When you bind the control to a collection of items, the control need to know what property is used for the displayed text and what property used for returning the SelectedValue, so it will contain DisplayMemberPath and SelectedValuePath properties, that we will use reflection to manipulate.
So far so
good, we did not touch on how we would draw these little circles of the control
itself. In a previous version, I used two Unicode characters: ◉ and ◯ . However,
in this version I will draw them using Frame (look at the gif above to see the differences).
Let’s write
the control!
Add a new class,
give it the name RadioButtonsGroup, add two fields;
parentStack of type StackLayout that will host the radio buttons , and the
other field is lbRadios of type List<SelectionFrame> that we’ll create it
later.
In the constructor:
public RadioButtonsGroup()
{
parentStack = new StackLayout();
Content = parentStack;
}
The
ItemsSource is a bindable property that encapsulate a CLR property of type IEnumerable<object>, in the propertyChanged delegate parameter we are going to make most of
the work our control need:
public static readonly BindableProperty
ItemsSourceProperty = BindableProperty.Create(nameof(ItemsSource), typeof(IEnumerable<object>), typeof(RadioButtonsGroup),
defaultBindingMode: BindingMode.TwoWay, propertyChanged: OnItemsSourceChanged);
public IEnumerable<object> ItemsSource
{
get { return
(IEnumerable<object>)GetValue(ItemsSourceProperty);
}
set { SetValue(ItemsSourceProperty, value); }
}
The rest of the
properties:
public static readonly BindableProperty
SelectedIndexProperty = BindableProperty.Create(nameof(SelectedIndex), typeof(int), typeof(RadioButtonsGroup),
defaultValue: -1, defaultBindingMode: BindingMode.TwoWay);
public static readonly BindableProperty
SelectedItemProperty = BindableProperty.Create(nameof(SelectedItem), typeof(object), typeof(RadioButtonsGroup),
defaultBindingMode: BindingMode.TwoWay);
public static readonly BindableProperty OrientationProperty =
BindableProperty.Create(nameof(Orientation), typeof(StackOrientation),
typeof(RadioButtonsGroup), defaultValue: StackOrientation.Vertical);
public static readonly BindableProperty
DisplayMemberPathProperty = BindableProperty.Create(nameof(DisplayMemberPath), typeof(string), typeof(RadioButtonsGroup));
public static readonly BindableProperty
SelectedValuePathProperty = BindableProperty.Create(nameof(SelectedValuePath), typeof(string), typeof(RadioButtonsGroup));
public static readonly BindableProperty SelectedValueProperty =
BindableProperty.Create(nameof(SelectedValue), typeof(object), typeof(RadioButtonsGroup));
public int
SelectedIndex
{
get { return (int)GetValue(SelectedIndexProperty); }
set { SetValue(SelectedIndexProperty, value); }
}
public object
SelectedItem
{
get { return
GetValue(SelectedItemProperty); }
set { SetValue(SelectedItemProperty, value); }
}
public StackOrientation Orientation
{
get { return
(StackOrientation)GetValue(OrientationProperty); }
set { SetValue(OrientationProperty, value); }
}
public string
DisplayMemberPath
{
get { return (string)GetValue(DisplayMemberPathProperty);
}
set { SetValue(DisplayMemberPathProperty, value); }
}
public string
SelectedValuePath
{
get { return (string)GetValue(SelectedValuePathProperty);
}
set { SetValue(SelectedValuePathProperty, value); }
}
public object
SelectedValue
{
get { return
GetValue(SelectedValueProperty); }
set { SetValue(SelectedValueProperty, value); }
}
Now let’s
implement OnItemsSourceChanged method which is called once the
ItemsSource collection has changed, that is when it’s bound to the collection
after first constructed:
private static void OnItemsSourceChanged(BindableObject
bindable, object oldValue, object newValue)
{
if (newValue == null)
return;
var @this = bindable as RadioButtonsGroup; //catch the current instance
set the parentStack’s Orientation to Orientation property of the
RadioButtonsGroup:
parentStack.Orientation
= Orientation;
look at this picture to get an insight of how we are building the control:
Now let’s add the radio buttons, remember: for every item in the
ItemsSource collection there’s a corresponding radio button:
foreach (var item in items)
{
StackLayout radioStack = new StackLayout
{
Orientation =
StackOrientation.Horizontal,
BindingContext = item,
HorizontalOptions =
Orientation == StackOrientation.Horizontal ? LayoutOptions.CenterAndExpand :
LayoutOptions.Fill,
};
TapGestureRecognizer tap = new TapGestureRecognizer();
tap.Tapped += RadioChecked;
radioStack.GestureRecognizers.Add(tap);
var displayText = DisplayMemberPath == null ? item.ToString() :
item.GetType().GetProperty(DisplayMemberPath).GetValue(item, null).ToString();
Label radioText = new Label { Text = displayText,
VerticalOptions = LayoutOptions.Center };
SelectionFrame circle = new SelectionFrame { ClassId = "r", BorderColor = FrontColor,
VerticalOptions = LayoutOptions.Center };
circle.HorizontalOptions =
LayoutOptions.EndAndExpand;
radioStack.Children.Add(radioText);
radioStack.Children.Add(circle);
parentStack.Children.Add(radioStack);
}
The radioStack will host a single radio button, setting the
BindingContext is important, as the selectedItem will live in it. The checked
event of the radio button is no more than the tapped event of the radioStack of
current item, so whenever the radioStack is tapped, we set the selected value
and selected index and draw the inner filled circle to mark it as selected. The
only way we get the displayed text of the radio button from DisplayMemberPath is
by reflection. We first check if the DisplayMemberPath is null, then the displayed
text will be just the ToString of the item, it’s useful if the ItemsSource is
just of type IEnumerable<string>. Otherwise we get the corresponding
value of the property having the name specified by DisplayMemberPath.
After we get the text to display, we create the label radioText.
The first half of the control is complete; now create the other half,
the circle:
SelectionFrame circle = new SelectionFrame { ClassId = "r",
VerticalOptions = LayoutOptions.Center }; we will come to implementing SelectionFrame
shortly.
Now let’s combine the circle with the text inside the radioStack to
make a single radio button. After we’ve added all radio buttons to the
parentStack, let’s mark one of them as selected if the user provided a valid
SelectedIdex. We apparently need a list of all SelectionFrames to change the
style of the selected one, so let’s get them:
lbRadios =
parentStack.Children.Where(x => x is StackLayout).SelectMany(x => ((StackLayout)x).Children.Where(l
=> l.ClassId == "r").Cast<SelectionFrame>()).ToList();
and then we decide if we mark one of them as selected or not:
if (SelectedIndex >= 0)
{
try
{
lbRadios[SelectedIndex].IsSelected = true;
}
catch (ArgumentOutOfRangeException) //if the user provided invalid
value we set it to 0 as default
{
SetValue(SelectedIndexProperty, 0);
lbRadios[SelectedIndex].IsSelected = true;
}
}
Now let’ implement the RadioChecked method, that’s called when a radio
button is tapped:
private void
RadioChecked(object sender,
EventArgs e)
{
StackLayout stRadio =
(StackLayout)sender;
var lb = stRadio.Children.First(x => x.ClassId == "r") as SelectionFrame;
if (lb is null)
return;
if (!lb.IsSelected)
{
if (SelectedIndex >= 0)
lbRadios.Single(x =>
x.IsSelected).IsSelected = false;
lb.IsSelected = true;
SelectedItem =
stRadio.BindingContext;
SelectedValue =
SelectedValuePath == null ? null :
SelectedItem.GetType().GetProperty(SelectedValuePath).GetValue(SelectedItem, null);
SelectedIndex =
ItemsSource.ToList().IndexOf(SelectedItem);
OnSelectionChanged?.Invoke(this, new SelectionChangedEventArgs(SelectedItem, SelectedValue,
SelectedIndex));
}
}
The sender object is radioStack, we need to change the style of the
SelectionFrame from unselected to selected and clear the selected one, so let’s
get the selection frame:
var lb = stRadio.Children.First(x
=> x.ClassId == "r") as SelectionFrame;
Then we see if the item is already selected, if so, we do nothing,
otherwise we check it, and if there’s already a checked item
(SelectedIndex > 0) then we unselect
it,
after that we set the SelectedItem, SelectedValue and SelectedIndex,
and fire the OnSelectionChanged event, you can easily guess what it does by
parameters passed to its delegate
if (!lb.IsSelected)
{
if (SelectedIndex >= 0)
lbRadios.Single(x =>
x.IsSelected).IsSelected = false;
lb.IsSelected = true;
SelectedItem =
stRadio.BindingContext;
SelectedValue =
SelectedValuePath == null ? null :
SelectedItem.GetType().GetProperty(SelectedValuePath).GetValue(SelectedItem, null);
SelectedIndex =
ItemsSource.ToList().IndexOf(SelectedItem);
OnSelectionChanged?.Invoke(this, new SelectionChangedEventArgs(SelectedItem, SelectedValue,
SelectedIndex));
}
Now, let’s create the SelectionFrame.
Drawing these little circle is not simple as you might expect. So let’s
discuss how frames work in Xamarin:
The frame is a plain full-colored area, so you cannot just draw circle,
frame is a nice workaround. To draw an unselected radio button (empty circle)
you have two frames; the outer with greater radius and with solid color, and
the inner circle with smaller radius and white color, this way we’ll have a
circular frame (off course after setting the corner radius of the frames)
To draw a checked circle we will center a smaller circular frame inside
these two frames with the same color of the outer frame, actually it’s always
there, but in the unselected mode it has the same color as the middle circle.
Now let’s create them:
Frame f1;
Frame f2;
Frame f3;
readonly double f3r;
public SelectionFrame()
{
f3r = 4;
const double f2r = 6;
const double f1r = 7;
f3 = new Frame
{
VerticalOptions =
LayoutOptions.Center,
HorizontalOptions =
LayoutOptions.Center,
BackgroundColor = Color.White,
Padding = 0,
HasShadow = false,
OutlineColor =
Color.Transparent,
Margin = 0,
CornerRadius = (float)f3r,
HeightRequest = f3r * 2,
WidthRequest = f3r * 2
};
f2 = new Frame
{
VerticalOptions =
LayoutOptions.Center,
HorizontalOptions =
LayoutOptions.Center,
BackgroundColor = Color.White,
HasShadow = false,
Padding = 0,
Margin = 0,
CornerRadius = (float)f2r,
HeightRequest = f2r * 2,
WidthRequest = f2r * 2,
Content = f3
};
f1 = new Frame
{
VerticalOptions =
LayoutOptions.Center,
HorizontalOptions =
LayoutOptions.Center,
BackgroundColor = BorderColor,
HasShadow = false,
Padding = new Thickness(0),
CornerRadius = (float)f1r,
HeightRequest = f1r * 2,
WidthRequest = f1r * 2,
Content = f2
};
Frame f0 = new Frame
{
BackgroundColor =
Color.Transparent,
HasShadow = false,
Content = f1
};
Content = f1;
}
This is the IsSelected property you’ve seen in RadioButtonsGroup class.
bool isSelected;
public bool
IsSelected
{
get { return
isSelected; }
set
{
isSelected = value;
SelectionChanged();
}
}
We need to change the color of the inner frame (smallest
frame) to black when selected, or white when unselected:
private void
SelectionChanged()
{
if (IsSelected)
f3.BackgroundColor =
BorderColor;
else
f3.BackgroundColor =
Color.White;
}
That is it; you’ve created the RadioButtonsGroup control.
In the GitHub repo you will find some additional work like animating the inner circle of the frame and some other properties like FrontColor and other properties for the font.
GitHub repo:
https://github.com/mshwf/CustomRadioButtonsGroup
In the GitHub repo you will find some additional work like animating the inner circle of the frame and some other properties like FrontColor and other properties for the font.
GitHub repo:
https://github.com/mshwf/CustomRadioButtonsGroup
No comments:
Post a Comment
Note: Only a member of this blog may post a comment.