Text and Icons in Master/Detail Reveal Button on iOS
There's really no good title to describe what this article is about. (Sometimes naming posts is almost as difficult as naming variables and classes in code.)
But have you noticed when using a MasterDetailPage
in Xamarin.Forms that the upper left navigation button ... the one that controls revealing the Master
page (which I'll call the reveal button from here on out) ... will either display an icon or text - but not both?
99% of the time you can put a hamburger icon there and things will be fine ... but it's that 1% of the time that your boss, or customer, or worse, graphic designer requests that they want to see both an icon and text in the upper left corner.
Initially I thought this would be super easy - just need to implement an Effect
and away I would go. Nope - not so fast.
What to do then?
Well - I hope to have you covered with this article! It will show you how to get the Xamarin.Forms MasterDetailPage
to work on iOS so it displays text and images in the left nav bar.
And it's quite a journey ... we're going to hit up Custom Renderers, Attached Properties, and Bindable Properties - all in order to get this to work.
Right now be aware that this technique is targeted only towards iOS - not Android. Kinda defeats the purpose of Xamarin.Forms, right? Well, Android still works as normal for the MasterDetailPage
there's just a bit of redundancy in setting properties.
All of the same code for this article can be found on GitHub here.
The Custom Renderer
The dreaded words ... custom renderer.
With the advent of effects and bindable native views and platform specifics - I thought the days of having to write a custom renderer was behind me.
But after trying to implement this as an Effect
, I found I had no other choice...
So that's where I started ... I subclassed the MasterDetailPage
in the core project and then went into the iOS project and created a renderer for it.
The first thing that one needs to understand is that the MasterDetailPage
in Xamarin.Forms really is just a construct over 2 distinct "real" concepts.
In other words a MasterDetailPage
holds a Master
page and a Detail
page ... duh, right?
Well, that fact starts to present some issues when you get into rendering it.
Rendering A MasterDetailPage
First off there several classes you can pick from when creating a custom renderer for a MasterDetailPage
on iOS: PhoneMasterDetailRenderer
and TabletMasterDetailRenderer
.
I will be working with PhoneMasterDetailRenderer
as that's the one which displays the button for the reveal.
Normally when you create a custom renderer - you do all the "customization" from the OnElementChanged
function.
But that's not the case here ...
There are 2 properties which can be used to gain access to the "native" portion of the master-detail NativeView
and ViewController
. But I found that neither of those can access the UINavigationBar
which will be the host for the reveal button.
Not a problem I thought - there's an Element
property that will be a subclass of MasterDetailPage
and I can use that to get the Detail
page - grab it's UINavigationBar
and pop the reveal button onto that.
No dice.
The Detail
property is null during the OnElementChanged
function - even when the "NewElement" is being set.
Luckily the PhoneMasterDetailRenderer
inherits from UIViewController
- which means there is a ViewDidLoad
function to override.
Inside that function, I was able to get at the Detail
property and then convert it to its native representation of UINavigationController
. (Because the Detail
will need to be in a navigation controller in order for the navigation bar to be visible.)
if (!(Element is MasterDetailLeftIconTextPage mdp)) return;
if (!(Platform.GetRenderer(mdp.Detail) is UINavigationController nc)) return;
This piece of code is doing the following:
- Taking the
Element
property of the custom renderer and casting it to the Xamarin.FormsMasterDetailPage
subclass I'm using for this renderer. - Then using the
Detail
property - getting the native representation of it via thePlatform.GetRenderer
.
Great - now I have the UINavigationController
that I need to add the reveal button to!
Adding the button to the navigation bar is the standard run of the mill iOS code, and a function that only adds the button (and gets called from ViewDidLoad
) looks like the following:
void SetupLeftButton(string buttonText = "", string buttonIcon = "")
{
if (!(Element is MasterDetailLeftIconTextPage mdp)) return;
if (!(Platform.GetRenderer(mdp.Detail) is UINavigationController nc)) return;
UIButton btn = new UIButton(UIButtonType.Custom);
btn.SetTitleColor(btn.TintColor, UIControlState.Normal);
btn.Frame = new CGRect(0, 0, 100, 44);
var title = string.IsNullOrEmpty(buttonText) ? mdp.Master.Title : buttonText;
var icon = string.IsNullOrEmpty(buttonIcon) ? mdp.Master.Icon.File : buttonIcon;
var img = UIImage.FromFile(icon);
btn.SetTitle(title, UIControlState.Normal);
btn.SetImage(img, UIControlState.Normal);
btn.ImageEdgeInsets = new UIEdgeInsets(0, -15, 0, 0);
btn.TouchUpInside += (sender, e) => mdp.IsPresented = true;
var lbbi = new UIBarButtonItem(btn);
nc.NavigationBar.TopItem.LeftBarButtonItem = lbbi;
}
The big thing to notice is that the function gets passed a string for the button's text and image.
If either of those are null - the code uses the Title
or Icon
property set on the Xamarin.Forms Master
page.
It also makes sure the master either shows itself or disappears on the TouchUpInside
event.
Making the Text and Icon Change
Well, the hard part's over now, right?
I found a way to get at the navigation bar, customize the reveal button to show both the text & icon, now the only thing that's left to do is monitor for changes on either the Master
page's Title
and Icon
properties.
Super easy!
Not so fast.
I had figured I could use the Element_PropertyChanged
function within the custom renderer and I would get notified whenever something on the Xamarin.Forms MasterDetailPage
gets changed. And the Title
and Icon
properties of the Master
property surely are one of those, right? Wrong.
Turns out the Element_PropertyChanged
only responds to changes on properties on the MasterDetailPage
itself - not to any of its children. That makes sense in hindsight, but I had hoped for an easier solution.
What to do?
Attached Properties to the Rescue!!
A while back I wrote a post about attached properties, finally figuring out what they're useful for.
And they sure seem like they would be useful in this case!
So - I put two attached properties into the subclass of the MasterDetailPage
. This will allow the Master
page to bind to those - thus causing the ElementProperty_Changed
function to get fired!
But ... that didn't work either.
Turns out that attached properties will fire the ElementProperty_Changed
of the control's renderer they are attached to - but not the one they are defined to be apart of.
The control they are defined to be apart of does have reference to them though. And you can setup a handler for the PropertyChanged
delegate.
Hooray!! Now I know what I need to do!
Bindable Properties to the Rescue!!
All that needs to be done now is to create some bindable properties on the MasterDetailPage
.
So - the bindable property definitions are super simple - can't get any more basic than this:
public static readonly BindableProperty InternalLeftTitleProperty = BindableProperty.Create("InternalLeftTitle", typeof(string), typeof(MasterDetailLeftIconTextPage),
string.Empty, BindingMode.Default);
string InternalLeftTitle
{
get => (string)GetValue(InternalLeftTitleProperty);
set => SetValue(InternalLeftTitleProperty, value);
}
public static readonly BindableProperty InternalLeftIconProperty = BindableProperty.Create("InternalLeftIcon", typeof(string), typeof(MasterDetailLeftIconTextPage),
string.Empty, BindingMode.Default);
public string InternalLeftIcon
{
get => (string)GetValue(InternalLeftIconProperty);
set => SetValue(InternalLeftIconProperty, value);
}
I'm calling them "Internal" because they shouldn't actually be bound to anything.
The attached properties that do get attached and bound, look like this:
public static readonly BindableProperty LeftTitleProperty = BindableProperty.CreateAttached("LeftTitle", typeof(string), typeof(MasterDetailLeftIconTextPage),
string.Empty, BindingMode.Default, null, LeftTitleChanged);
public static string GetLeftTitle(BindableObject bindable)
{
return (string)bindable.GetValue(LeftTitleProperty);
}
public static void SetLeftTitle(BindableObject bindable, string value)
{
bindable.SetValue(LeftTitleProperty, value);
}
protected static void LeftTitleChanged(BindableObject bindable, object oldValue, object newValue)
{
if (!(((Page)bindable)?.Parent is MasterDetailLeftIconTextPage parent)) return;
parent.InternalLeftTitle = newValue.ToString();
}
public static readonly BindableProperty LeftIconProperty = BindableProperty.CreateAttached("LeftIcon", typeof(string), typeof(MasterDetailLeftIconTextPage),
string.Empty, BindingMode.Default, null, LeftIconChanged);
public static string GetLeftIcon(BindableObject bindable)
{
return (string)bindable.GetValue(LeftIconProperty);
}
public static void SetLeftIcon(BindableObject bindable, string value)
{
bindable.SetValue(LeftIconProperty, value);
}
protected static void LeftIconChanged(BindableObject bindable, object oldValue, object newValue)
{
if (!(((Page)bindable)?.Parent is MasterDetailLeftIconTextPage parent)) return;
parent.InternalLeftIcon = newValue.ToString();
}
Notice how in the PropertyChanged
delegate the "Internal" bindable property gets called.
Back to the Renderer!
Almost done now!
The custom renderer will listen for those properties to change within its ElementProperty_Changed
function - the code in the renderer will look like this:
void Element_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (!(Element is MasterDetailLeftIconTextPage mdp)) return;
if (e.PropertyName == MasterDetailLeftIconTextPage.InternalLeftTitleProperty.PropertyName || e.PropertyName == MasterDetailLeftIconTextPage.InternalLeftIconProperty.PropertyName)
{
var title = (string)mdp.GetValue(MasterDetailLeftIconTextPage.InternalLeftTitleProperty);
var icon = (string)mdp.GetValue(MasterDetailLeftIconTextPage.InternalLeftIconProperty);
SetupLeftButton(title, icon);
mdp.IsPresented = false;
}
}
Just checks to see if the property being changed is the correct one - and then sets the reveal button up properly.
It does suffer from the misfortune of being called two times in a row when the title and icon change at the same time. I still need to fix that.
Consuming This Thing
The very last thing to do is to setup the ContentPage
that serves as the MasterDetail.Master
page.
One needs to use both the existing Title
and Icon
properties as well as the new attached properties. (Duplicates code, I know.)
And this code example is assuming there's a view model bound to the page - with the properties MasterTitle
and MasterIcon
being bound to the Title
, LeftTitle
and Icon
and LeftIcon
respectively.
<ContentPage.Title>
<Binding Path="MasterTitle"></Binding>
</ContentPage.Title>
<ContentPage.Icon>
<Binding Path="MasterIcon"></Binding>
</ContentPage.Icon>
<local:MasterDetailLeftIconTextPage.LeftTitle>
<Binding Path="MasterTitle"></Binding>
</local:MasterDetailLeftIconTextPage.LeftTitle>
<local:MasterDetailLeftIconTextPage.LeftIcon>
<Binding Path="MasterIcon"></Binding>
</local:MasterDetailLeftIconTextPage.LeftIcon>
And there we go!! It works!!
Download the sample code & see for yourself.
What About MessagingCenter?
Yeah, yeah, yeah ... Instead of creating the bindable properties - and then using the ElementProperty_Changed
function - I could have just used the attached properties - and then the MessagingCenter to broadcast a message. If the renderer was subscribed, it could then automatically do the update that way.
I suppose that would work (I didn't test it) ... but for some reason I don't like using the MessagingCenter if I can find a way around it.
However - I may end up using it for the Android version of all this ...
Summary
So it was a big rigamarole to get this all working properly. (And to be honest, I don't know if it's the best way of doing it either.)
The first issue was that the PhoneMasterDetailRenderer
doesn't give access to the Master
or Detail
native controls in the OnElementChanged
function.
Secondly, there's no good way to monitor for changes on any of the child properties from the custom renderer of a MasterDetailPage
.
That necessitated creating an attached property on the MasterDetailPage
's subclass ... but that doesn't fire the ElementProperty_Changed
function in the custom renderer either.
Finally, by creating new bindable properties - and having the attached properties change those when the attached properties' value changes - the custom renderer can be notified of changes on one of the child controls!
Whew!