In Part 1 of this series, I introduced custom renderers in Xamarin Forms and presented a pair of custom renderers – one for Android, and one for Windows Phone – that extended the Xamarin Forms Button control to honor the BorderRadius, BorderWidth, and BorderColor properties on all platforms. In Part 2, we’ll use what we learned to add some flair to the Xamarin Forms calculator app I presented a few weeks ago.
Here’s how the calculator looked in its original incarnation:
Now suppose you were handed UI requirements that called for the buttons to have rounded ends. Even if BorderRadius was supported on Windows Phone and Android, you’d have to write code to dynamically set the BorderRadius each time a button’s size changed since the buttons aren’t explicitly sized (they expand to fill the Grid cells that contain them) and they undergo a size change when the device orientation changes. Furthermore, assume that the calculator buttons need to have rounded ends, but that there might be other buttons in the app that need to have the traditional square corners. How would you go about conforming to these requirements?
The answer, of course, is by writing custom renderers: this time for Windows Phone, Android, and iOS. Here’s the finished product:
And here is what it took to get there.
The RoundedButton Class
To differentiate between rounded buttons and ordinary buttons, I added a new class to the solution’s PCL project:
public class RoundedButton : Button { }
RoundedButton derives from Xamarin.Forms.Button and adds nothing to it. But the fact that RoundedButton now exists as a separate control type allowed me to modify the Button declarations in CalculatorPage.xaml to declare RoundedButtons rather than Buttons:
<local:RoundedButton Text="±" Grid.Row="1" Grid.Column="2" Command="{Binding CalculatorCommand}" CommandParameter="±" /> <local:RoundedButton Text="EXP" Grid.Row="1" Grid.Column="3" Command="{Binding CalculatorCommand}" CommandParameter="EXP" /> <local:RoundedButton Text="STO" Grid.Row="1" Grid.Column="4" Command="{Binding CalculatorCommand}" CommandParameter="STO" /> <local:RoundedButton Text="RCL" Grid.Row="1" Grid.Column="5" Command="{Binding CalculatorCommand}" CommandParameter="RCL" /> <local:RoundedButton Text="ENTER" Grid.Row="2" Grid.ColumnSpan="2" Grid.Column="2" Command="{Binding CalculatorCommand}" CommandParameter="ENTER" /> <local:RoundedButton Text="FIX" Grid.Row="2" Grid.Column="4" Command="{Binding CalculatorCommand}" CommandParameter="FIX" /> <local:RoundedButton Text="CLX" Grid.Row="2" Grid.Column="5" Command="{Binding CalculatorCommand}" CommandParameter="CLX" /> <local:RoundedButton Text="-" Grid.Row="3" Grid.Column="2" Command="{Binding CalculatorCommand}" CommandParameter="-" /> <local:RoundedButton Text="7" Grid.Row="3" Grid.Column="3" Command="{Binding CalculatorCommand}" CommandParameter="7" /> <local:RoundedButton Text="8" Grid.Row="3" Grid.Column="4" Command="{Binding CalculatorCommand}" CommandParameter="8" /> <local:RoundedButton Text="9" Grid.Row="3" Grid.Column="5" Command="{Binding CalculatorCommand}" CommandParameter="9" /> <local:RoundedButton Text="+" Grid.Row="4" Grid.Column="2" Command="{Binding CalculatorCommand}" CommandParameter="+" /> <local:RoundedButton Text="4" Grid.Row="4" Grid.Column="3" Command="{Binding CalculatorCommand}" CommandParameter="4" /> <local:RoundedButton Text="5" Grid.Row="4" Grid.Column="4" Command="{Binding CalculatorCommand}" CommandParameter="5" /> <local:RoundedButton Text="6" Grid.Row="4" Grid.Column="5" Command="{Binding CalculatorCommand}" CommandParameter="6" /> <local:RoundedButton Text="x" Grid.Row="5" Grid.Column="2" Command="{Binding CalculatorCommand}" CommandParameter="x" /> <local:RoundedButton Text="1" Grid.Row="5" Grid.Column="3" Command="{Binding CalculatorCommand}" CommandParameter="1" /> <local:RoundedButton Text="2" Grid.Row="5" Grid.Column="4" Command="{Binding CalculatorCommand}" CommandParameter="2" /> <local:RoundedButton Text="3" Grid.Row="5" Grid.Column="5" Command="{Binding CalculatorCommand}" CommandParameter="3" /> <local:RoundedButton Text="÷" Grid.Row="6" Grid.Column="2" Command="{Binding CalculatorCommand}" CommandParameter="÷" /> <local:RoundedButton Text="0" Grid.Row="6" Grid.Column="3" Command="{Binding CalculatorCommand}" CommandParameter="0" /> <local:RoundedButton Text="." Grid.Row="6" Grid.Column="4" Command="{Binding CalculatorCommand}" CommandParameter="." /> <local:RoundedButton Text="DEL" Grid.Row="6" Grid.Column="5" Command="{Binding CalculatorCommand}" CommandParameter="DEL" /> <!-- Buttons visible only in landscape mode --> <local:RoundedButton Text="sin" Grid.Row="1" Grid.Column="0" Command="{Binding CalculatorCommand}" CommandParameter="sin" IsVisible="False" /> <local:RoundedButton Text="cos" Grid.Row="2" Grid.Column="0" Command="{Binding CalculatorCommand}" CommandParameter="cos" IsVisible="False" /> <local:RoundedButton Text="tan" Grid.Row="3" Grid.Column="0" Command="{Binding CalculatorCommand}" CommandParameter="tan" IsVisible="False" /> <local:RoundedButton Text="asin" Grid.Row="4" Grid.Column="0" Command="{Binding CalculatorCommand}" CommandParameter="asin" IsVisible="False" /> <local:RoundedButton Text="acos" Grid.Row="5" Grid.Column="0" Command="{Binding CalculatorCommand}" CommandParameter="acos" IsVisible="False" /> <local:RoundedButton Text="atan" Grid.Row="6" Grid.Column="0" Command="{Binding CalculatorCommand}" CommandParameter="atan" IsVisible="False" /> <local:RoundedButton Text="1/x" Grid.Row="1" Grid.Column="1" Command="{Binding CalculatorCommand}" CommandParameter="1/x" IsVisible="False" /> <local:RoundedButton Text="sqrt" Grid.Row="2" Grid.Column="1" Command="{Binding CalculatorCommand}" CommandParameter="sqrt" IsVisible="False" /> <local:RoundedButton Text="x²" Grid.Row="3" Grid.Column="1" Command="{Binding CalculatorCommand}" CommandParameter="x²" IsVisible="False" /> <local:RoundedButton Text="log" Grid.Row="4" Grid.Column="1" Command="{Binding CalculatorCommand}" CommandParameter="log" IsVisible="False" /> <local:RoundedButton Text="ln" Grid.Row="5" Grid.Column="1" Command="{Binding CalculatorCommand}" CommandParameter="ln" IsVisible="False" /> <local:RoundedButton Text="p" Grid.Row="6" Grid.Column="1" Command="{Binding CalculatorCommand}" CommandParameter="p" IsVisible="False" />
What’s the “local” prefix in each RoundedButton declaration? It’s a custom XML namespace prefix added to the ContentPage element at the top of the page:
xmlns:local="clr-namespace:XFormsRPNCalculator;assembly=XFormsRPNCalculator"
It provides the information the parser needs to emit code that creates instances of the RoundedButton class at run-time. It’s the same technique you use to resolve any custom type referenced in XAML. And in this case, it refers to the PCL assembly containing the code and resources that are shared by the platform-specific projects.
RoundedButtonRenderer (Windows Phone)
In the absence of custom renderers, RoundedButton would work – sort of. You’d be able to declare instances of RoundedButton, but they’d have the same square corners as ordinary Button controls. Radiusing the corners to create rounded ends is the job of a custom renderer. Here’s the custom renderer I added to the Windows Phone project:
[assembly: ExportRenderer(typeof(RoundedButton), typeof(RoundedButtonRenderer))] namespace XFormsRPNCalculator.WinPhone { public class RoundedButtonRenderer : ButtonRenderer { protected override void OnElementChanged(ElementChangedEventArgs<Xamarin.Forms.Button> e) { base.OnElementChanged(e); if (Control != null) { var button = (RoundedButton)e.NewElement; button.SizeChanged += (s, args) => { Control.ApplyTemplate(); var borders = Control.GetVisuals<Border>(); var radius = Math.Min(button.Width, button.Height) / 2.0; foreach (var border in borders) { border.CornerRadius = new CornerRadius(radius); } }; } } } static class DependencyObjectExtensions { public static IEnumerable<T> GetVisuals<T>(this DependencyObject root) where T : DependencyObject { int count = VisualTreeHelper.GetChildrenCount(root); for (int i = 0; i < count; i++) { var child = VisualTreeHelper.GetChild(root, i); if (child is T) yield return child as T; foreach (var descendants in child.GetVisuals<T>()) { yield return descendants; } } } } }
It’s not unlike the custom button renderer introduced in Part 1. It waits for the button to fire a SizeChanged event so it can get the button’s assigned width and height, but instead of writing the button’s BorderRadius property to the CornerRadius property of the Border element that comprises the button border, it calculates the radius from the button’s width or height. Now, no matter what the button’s width or height, it will always have corner radii that equal half the least of the two. And if the button is resized at run-time (it will be if you rotate the device), the SizeChanged handler fires again and recomputes the corner radii so the button always has a rounded end.
RoundedButtonRenderer (Android)
The custom renderer I added to the Android project is similar to the Android renderer presented in Part 1. It creates a pair of GradientDrawables representing the button in the normal state and the pressed state, adds the GradientDrawables to a StateListDrawable, and assigns the StateListDrawable to the button. But unlike the Android renderer in the previous article, it assigns each GradientDrawable a computed radius based on the width and height of the button:
[assembly: ExportRenderer(typeof(RoundedButton), typeof(RoundedButtonRenderer))] namespace XFormsRPNCalculator.Droid { public class RoundedButtonRenderer : ButtonRenderer { private GradientDrawable _normal, _pressed; protected override void OnElementChanged(ElementChangedEventArgs<Xamarin.Forms.Button> e) { base.OnElementChanged(e); if (Control != null) { var button = (RoundedButton)e.NewElement; button.SizeChanged += (s, args) => { var radius = (float)Math.Min(button.Width, button.Height); // Create a drawable for the button's normal state _normal = new Android.Graphics.Drawables.GradientDrawable(); if (button.BackgroundColor.R == -1.0 && button.BackgroundColor.G == -1.0 && button.BackgroundColor.B == -1.0) _normal.SetColor(Android.Graphics.Color.ParseColor("#ff2c2e2f")); else _normal.SetColor(button.BackgroundColor.ToAndroid()); _normal.SetCornerRadius(radius); // Create a drawable for the button's pressed state _pressed = new Android.Graphics.Drawables.GradientDrawable(); var highlight = Context.ObtainStyledAttributes(new int[] { Android.Resource.Attribute.ColorActivatedHighlight }).GetColor(0, Android.Graphics.Color.Gray); _pressed.SetColor(highlight); _pressed.SetCornerRadius(radius); // Add the drawables to a state list and assign the state list to the button var sld = new StateListDrawable(); sld.AddState(new int[] { Android.Resource.Attribute.StatePressed }, _pressed); sld.AddState(new int[] { }, _normal); Control.SetBackgroundDrawable(sld); }; } } } }
As in the Windows Phone renderer, I compute the corner radius in a SizeChanged event handler to ensure that the button remains rounded, even if its size changes.
RoundedButtonRenderer (iOS)
The renderer for rounded buttons in iOS is the simplest of the three. It does little more than hook SizeChanged events from RoundedButton controls and set BorderRadius to half the width or height of the button, whichever is less:
[assembly: ExportRenderer(typeof(RoundedButton), typeof(RoundedButtonRenderer))] namespace XFormsRPNCalculator.iOS { public class RoundedButtonRenderer : ButtonRenderer { protected override void OnElementChanged(ElementChangedEventArgs<Button> e) { base.OnElementChanged(e); if (Control != null) { var button = (RoundedButton)e.NewElement; button.SizeChanged += (s, args) => { var radius = Math.Min(button.Width, button.Height) / 2.0; button.BorderRadius = (int)(radius); }; } } } }
This leverages the fact that Xamarin.Forms.Button.BorderRadius is honored for iOS buttons. Had the same been true for Windows Phone and Android, their renderers would have been considerably simpler.
Orientation Changes Revisited
I made one other change to the app while modifying it to use custom renderers: I changed the way it responds to orientation changes.
In Responding to Orientation Changes in Xamarin Forms, I overrode the page’s OnSizeAllocated method to detect orientation changes. That worked, but it also required me to write code to ignore consecutive calls to OnSizeAllocated with the same width and height parameters. In the latest version of the calculator, I removed the OnSizeAllocated override and registered a handler for SizeChanged events in the page constructor:
public CalculatorPage() { InitializeComponent(); this.BindingContext = ((App)Application.Current).GetCalculatorViewModel(); this.SizeChanged += (s, e) => { if (this.Width != this.Height) ShowExtraButtons(this.Width > this.Height); }; }
This simplified the logic somewhat since SizeChanged fires just once per orientation change. On Windows Phone, you do get an initial SizeChanged event with Width and Height equal to 0.0, hence the if statement that skips the call to ShowExtraButtons if Width equals Height.
Get the Source!
As usual, I posted a zip file containing the modified Visual Studio solution so you can download it and try it for yourself. You’ll need Xamarin to run it, of course: a Xamarin.iOS license to run it on iOS, and a Xamarin.Android license to run it on Android. Happy rendering!
Need Xamarin Help?
Xamarin Consulting Xamarin Training