If you’ve read the previous posts in this series, you’re aware that custom renderers are the keys that unlock the doors to advanced customizations in Xamarin Forms. In Part 1, I presented custom renderers for rounding the corners of and placing borders around Button controls. In Part 2, I used custom renderers to stylize the buttons in my Xamarin Forms RPN calculator. In Part 3, I showed how to use custom renderers to display wrapped, truncated text in Label controls. Now, in Part 4, I’m going to use custom renderers to do something completely different – something to customize a control’s behavior rather than its appearance, and in a way that could be useful to real-world apps.
Assume you’ve built a Xamarin Forms app with a page containing the following XAML:
<Grid Padding="32" RowSpacing="32" ColumnSpacing="32"> <BoxView Grid.Row="0" Grid.Column="0" Color="#FFF25022" /> <BoxView Grid.Row="0" Grid.Column="1" Color="#FF7FBA00" /> <BoxView Grid.Row="1" Grid.Column="0" Color="#FF01A4EF" /> <BoxView Grid.Row="1" Grid.Column="1" Color="#FFFFB901" /> </Grid>
Running the app produces this on Windows Phone, Android, and iOS, in that order:
Now suppose that when one of the colored rectangles is tapped, you want to highlight it by changing its color to, say, bright yellow. So you attach a TapGestureRecognizer to each BoxView like so:
<Grid Padding="32" RowSpacing="32" ColumnSpacing="32"> <BoxView Grid.Row="0" Grid.Column="0" Color="#FFF25022"> <BoxView.GestureRecognizers> <TapGestureRecognizer Tapped="OnBoxViewTapped" /> </BoxView.GestureRecognizers> </BoxView> <BoxView Grid.Row="0" Grid.Column="1" Color="#FF7FBA00"> <BoxView.GestureRecognizers> <TapGestureRecognizer Tapped="OnBoxViewTapped" /> </BoxView.GestureRecognizers> </BoxView> <BoxView Grid.Row="1" Grid.Column="0" Color="#FF01A4EF"> <BoxView.GestureRecognizers> <TapGestureRecognizer Tapped="OnBoxViewTapped" /> </BoxView.GestureRecognizers> </BoxView> <BoxView Grid.Row="1" Grid.Column="1" Color="#FFFFB901"> <BoxView.GestureRecognizers> <TapGestureRecognizer Tapped="OnBoxViewTapped" /> </BoxView.GestureRecognizers> </BoxView> </Grid>
And you write an event handler to respond to single taps:
void OnBoxViewTapped(object sender, EventArgs args) { ((BoxView)sender).Color = Color.Yellow; }
You try it, and it works like a charm. But then the requirements change, and you need to highlight a rectangle when a finger touches down on it, and restore its original color when the finger lifts up. Furthermore, if a finger drags across a rectangle, you need to highlight it when the finger enters and unhighlight it when the finger exits. Imagine the user drawing a big circle on the screen with his or her finger, with rectangles turning on and off as the finger passes over them. How would you go about doing that in a Xamarin Forms app?
It’s a simple question, but without a simple answer. There is no built-in gesture recognizer that will do it. And as of this writing, Xamarin Forms doesn’t support custom gesture recognizers. I feel certain that will change in the future, but for now, writing a Xamarin Forms app with touch logic that does anything more than respond to simple taps requires a degree of customization that only custom renderers afford. I’ll demonstrate with a custom control called SmartBoxView.
The SmartBoxView Control
SmartBoxView derives from BoxView and adds a pair of events named Entered and Exited. An Entered event fires when a finger “enters” the control – that is, when a finger goes down over the control, or goes down elsewhere on the screen and then drags over the control. Exited events fire when a finger “exits” the control – specifically, when it lifts off the screen while over the control, or moves outside the control after dragging across it. Here’s how SmartBoxView is defined:
public class SmartBoxView : BoxView { public event EventHandler Entered; public event EventHandler Exited; public void RaiseEntered() { if (Entered != null) Entered(this, EventArgs.Empty); } public void RaiseExited() { if (Exited != null) Exited(this, EventArgs.Empty); } }
And here’s the XAML presented earlier, modified to replace BoxViews with SmartBoxViews:
<Grid x:Name="SmartGrid" Padding="32" RowSpacing="32" ColumnSpacing="32"> <local:SmartBoxView Grid.Row="0" Grid.Column="0" Color="#FFF25022" /> <local:SmartBoxView Grid.Row="0" Grid.Column="1" Color="#FF7FBA00" /> <local:SmartBoxView Grid.Row="1" Grid.Column="0" Color="#FF01A4EF" /> <local:SmartBoxView Grid.Row="1" Grid.Column="1" Color="#FFFFB901" /> </Grid>
Knowing that SmartBoxView controls fire Entered and Exited events, you could do the following in the page’s code-behind to change colors as a finger moves around the screen:
public partial class MainPage : ContentPage { private Color _color; // Assumes single touch public MainPage() { InitializeComponent(); foreach (var child in SmartGrid.Children) { var box = child as SmartBoxView; if (box != null) { box.Entered += OnEntered; box.Exited += OnExited; } } } private void OnEntered(object sender, EventArgs e) { var box = (SmartBoxView)sender; _color = box.Color; box.Color = Color.Yellow; } private void OnExited(object sender, EventArgs e) { ((SmartBoxView)sender).Color = _color; } }
I took this XAML and code directly from a sample app named SmartBoxViewDemo, which you can download from here. If you run the sample, you’ll discover that as you drag your finger around the screen, a rectangle turns to yellow when it’s entered, and reverts to its original color when it’s exited. Clearly SmartBoxViews fire Entered and Exited events as expected. But they can’t do it on their own; they have to have help. And that help comes from – you guessed it – custom renderers.
Making SmartBoxView Work on Windows Phone
Recall that Label controls, Entry controls, Button controls, and other Xamarin Forms controls are accompanied by per-platform renderers that “render” controls native to the host platform. The Windows Phone renderer for Label controls, for example, creates TextBlock controls, while the iOS renderer for Label controls creates UILabels. By writing custom renderers, you can tap into the control-rendering process and modify the native controls that are rendered out.
To put the Smart in SmartBoxView on Windows Phone, I added the following class to the Windows Phone project:
[assembly: ExportRenderer(typeof(SmartBoxView), typeof(SmartBoxViewRenderer))] namespace SmartBoxViewDemo.WinPhone { public class SmartBoxViewRenderer : BoxViewRenderer { protected override void OnElementChanged(ElementChangedEventArgs<BoxView> e) { base.OnElementChanged(e); if (Control != null) { var box = (SmartBoxView)e.NewElement; Control.MouseEnter += (s, args) => box.RaiseEntered(); Control.MouseLeave += (s, args) => box.RaiseExited(); } } } }
This renderer is simple because Windows Phone’s BoxViewRenderer emits a XAML Rectangle element, and on Windows Phone, Rectangles and other visual elements fire events named MouseEnter and MouseLeave when a finger enters or leaves them. SmartBoxViewRenderer does nothing more than convert MouseEnter and MouseLeave events from the Rectangle into Entered and Exited events from the SmartBoxView. Simple as that. If only it were so simple on iOS and Android.
Making SmartBoxView Work on Android
Neither iOS nor Android fires events signifying that a finger has entered or exited an element. And neither operating system makes it especially easy to write code that detects entries and exits, either.
To illustrate, suppose that in Android, you position two views side by side and register a touch listener for each. If the user puts a finger on one of the views and drags the finger to the other view, you might think that both touch listeners would receive a series of events signifying moves – the first one while the finger moves over the first view, and the second one while the finger moves over the second view. But you’d be wrong. As long as the finger maintains contact with the screen, only the first touch listener receives notifications. And it continues to receive notifications even after the finger leaves the view.
This means that you can’t detect when a finger enters and leaves views in Android by processing events from the views themselves. Instead, you have to process events firing from the views’ parents – or from the page itself – and hit-test each reported touch point to determine if the finger has entered or exited a view. It follows that you can’t detect entries and exits in a SmartBoxView by writing a custom renderer for SmartBoxView. Instead, you write a custom renderer for the page.
Here’s the custom renderer that fires Entered and Exited events from SmartBoxView controls when you run SmartBoxViewDemo on an Android phone. It replaces the default renderer for the app’s one and only page (MainPage):
[assembly: ExportRenderer(typeof(MainPage), typeof(SmartPageRenderer))] namespace SmartBoxViewDemo.Droid { public class SmartPageRenderer : PageRenderer { private SmartBoxView _last; public override bool DispatchTouchEvent(MotionEvent e) { var grid = ((MainPage)this.Element).FindByName<Xamarin.Forms.Grid>("SmartGrid"); if (grid != null) { SmartBoxView target = null; var x = (int)((e.GetX() / Resources.DisplayMetrics.Density) - grid.X); var y = (int)((e.GetY() / Resources.DisplayMetrics.Density) - grid.Y); // Find which SmartBoxView, if any, the pointer is over foreach (var child in grid.Children) { if (child.Bounds.Contains(x, y)) { target = (child is SmartBoxView) ? (SmartBoxView)child : null; break; } } // At this point, target == null means the pointer isn't // over a SmartBoxView; target != null means it is. Fire // Entered or Exited events as needed. switch (e.Action) { case MotionEventActions.Down: if (target != null) target.RaiseEntered(); _last = target; break; case MotionEventActions.Move: if (_last != target) { if (target != null) target.RaiseEntered(); if (_last != null) _last.RaiseExited(); _last = target; } break; case MotionEventActions.Up: if (target != null) target.RaiseExited(); _last = null; break; case MotionEventActions.Cancel: if (target != null) target.RaiseExited(); _last = null; break; } } return true; } } }
In Xamarin, the Xamarin.Forms.Platform.Android.PageRenderer class derives indirectly from Android.Views.View, which has a virtual method named DispatchTouchEvent. SmartPageRenderer derives from PageRenderer and overrides DispatchTouchEvent, which is called when a touch event occurs anywhere on the page. In the override, the renderer grabs the touch coordinates, converts them from raw coordinates relative to the upper-left corner of the page into local coordinates relative to the upper-left corner of the Grid that hosts the SmartBoxViews (notice that I divide the coordinates returned by GetX and GetY by Resources.DisplayMetrics.Density; that transforms the raw touch coordinates provided by Android into the same units used by Xamarin Forms), and determines, which, if any, SmartBoxView the touch point is over. If this is the first touch point reported for a given SmartBoxView, the SmartBoxView’s RaiseEntered method is called to fire an Entered event. If the finger has just left a SmartBoxView or breaks contact with the screen while over a SmartBoxView, RaiseExited is called to fire an Exited event. The result is that SmartBoxViews fire Entered and Exited on Android the same way they do on Windows Phone.
Be aware that this implementation of SmartPageRenderer is coupled rather tightly to the architecture of the page. The renderer assumes that any and all SmartBoxViews in the page are children of a Xamarin.Forms.Grid named “SmartGrid.” SmartBoxViews outside a Grid named “SmartGrid” won’t break anything, but they won’t fire Entered and Exited events, either. If you need to structure your page differently – if, for example, you need to place some SmartBoxViews in a StackLayout, or your page has multiple Grids containing SmartBoxViews – you’ll need to modify the renderer so it knows where on the page to find the SmartBoxView controls.
Making SmartBoxView Work on iOS
When it comes to touch events, iOS resembles Android more than Windows Phone. To detect that a finger has dragged into or out of a view (UIView) in iOS, you need to process events at least one level above that view. Consequently, the custom renderer I wrote for iOS is a page renderer similar to the one I wrote for Android. It derives from Xamarin.Forms.Platform.iOS.PageRenderer, which in turn derives from UIViewController, and it overrides key virtual methods to detect touch events anywhere on the page and translate them into Entered and Exited events for SmartBoxView controls:
[assembly: ExportRenderer(typeof(MainPage), typeof(SmartPageRenderer))] namespace SmartBoxViewDemo.iOS { public class SmartPageRenderer : PageRenderer { private SmartBoxView _last; public override void TouchesBegan(NSSet touches, UIEvent evt) { base.TouchesBegan(touches, evt); var touch = touches.AnyObject as UITouch; if (touch != null) { var target = GetTouchTarget(touch); if (target != null) target.RaiseEntered(); _last = target; } } public override void TouchesMoved(NSSet touches, UIEvent evt) { base.TouchesMoved(touches, evt); var touch = touches.AnyObject as UITouch; if (touch != null) { var target = GetTouchTarget(touch); if (_last != target) { if (target != null) target.RaiseEntered(); if (_last != null) _last.RaiseExited(); _last = target; } } } public override void TouchesCancelled(NSSet touches, UIEvent evt) { base.TouchesCancelled(touches, evt); var touch = touches.AnyObject as UITouch; if (touch != null) { var target = GetTouchTarget(touch); if (target != null) target.RaiseExited(); _last = null; } } public override void TouchesEnded(NSSet touches, UIEvent evt) { base.TouchesEnded(touches, evt); var touch = touches.AnyObject as UITouch; if (touch != null) { var target = GetTouchTarget(touch); if (target != null) target.RaiseExited(); _last = null; } } private SmartBoxView GetTouchTarget(UITouch touch) { SmartBoxView target = null; var grid = ((MainPage)this.Element).FindByName<Xamarin.Forms.Grid>("SmartGrid"); var point = touch.LocationInView(this.View); var x = point.X - grid.X; var y = point.Y - grid.Y; // Find which SmartBoxView, if any, the pointer is over foreach (var child in grid.Children) { if (child.Bounds.Contains(x, y)) { target = (child is SmartBoxView) ? (SmartBoxView)child : null; break; } } // At this point, target == null means the pointer isn't // over a SmartBoxView; target != null means it is return target; } } }
Like the Android version of SmartPageRenderer, the iOS version assumes that all the SmartBoxView controls in the page live in a Grid named “SmartGrid.” And as with the Android version, if that assumption is incorrect, you’ll need to modify the renderer accordingly.
More to Come
At this point, you might be wondering if the custom renderers introduced in this article have any practical use. After all, how often do you need to know when a finger sliding across a touch screen enters and exits elements on that screen?
In my next post, I’ll present an app that does just that. It’s the reason I wrote SmartBoxView and the renderers that go with it in the first place. In addition to finding the app interesting from a technical point of view, I think you’ll find it entertaining, too. Stay tuned
Need Xamarin Help?
Xamarin Consulting Xamarin Training