背景
这篇博客会介绍如何自定义用Xamarin开发的Android以及iOS的navigation bar的返回的图标和文字。
之所以会需要以博客的形式记录这个知识点是由于本身如果Xamarin.Forms来做一个最基本的控件的custom renderer,思路会是这样的:首先要了解我们要自定义的控件是Xamarin.Forms中的哪个控件,然后在.NET Standard的项目中定义一个类去继承自Xamarin.Forms下的这个控件的类。之后就是在Xamarin的platform specific的项目中,各自定义两个新的renderer的类,加上一定的属性来表明这两个renderer是用在这个我们新定义的控件上的,然后重写这个renderer的OnElementChanged
方法,这个方法会在改元素被生成的时候调用。最后,在XAML的页面中使用新的自定义的控件就可以达到效果。
具体的官方教程在这里:Xamarin.Forms Custom Renderers
难点
但是我们这次的主题不会是这么简单的一个过程,原因如下:
我们先拿安卓来举例,如果按照常规思路,由于Back按钮是在Toolbar
上面显示的,而Toolbar一定需要在NavigationPage
下才能显示,所以我们的第一反应应该是去重写NavigationPage
这个控件,那么我们的实现应该如下:
1 | [ ] |
这样我们就修改了默认的安卓上的Back按钮的图标和文字。
具体的使用也很方便,打开App.xaml.cs文件,在App的构造函数中改变MainPage的页面为一个NavigationPage,并且加入一个Button click事件:
1 | MainPage = new NavigationPage(new MainPage); |
产生的问题
首先说一下,我这篇文章中会用到的一个实现结构很简单,除了MainPage是第一个页面之外,另外还有2个页面,分别是SecondPage和ThirdPage,在MainPage和SecondPage中,分别有Label控件表示这个页面的名字,并且带有一个Button用来做navigation。第三个页面为最终的一个页面,上面只有一个Label用来标记该页面为第三页面。这样做的原因之后再解释。
好了,回来说这个问题,当开发完毕进行测试的时候,我发现当使用RAM比较小的模拟器,或者比较慢的手机的时候,这个Toolbar上的返回的按钮会在卡顿的时候显示为默认的样子。那这样对于用户来说肯定是不可以接受的。
问题原因
如果去查看Xamarin.Forms关于这部分的源代码,先搜索到它的OnPushAsync方法,会发现它的一步步的实现大致如下:
1 | protected virtual Task<bool> OnPushAsync(Page view, bool animated) |
从上述逻辑可以看出,在每次Navigation的时候,其实Xamarin本身对NavigationPage的实现会强行设置一次这个toolbar的NavigationIcon
。但是custom renderer里势必要调用base.OnElementChanged(e);
,所以会导致这个情况。
解决方案
所以我们大致的解决方案是像Xamarin的源代码一样的做法,不是在生成这个元素的时候去改变它的NavigationIcon,而是重写OnPushAsync
方法来达到同样的目的。
但是这里有会碰到另一个难点,就是安卓和iOS的实现不能完全一样。看到下面的解决方案的代码会发现安卓要简单很多。原因是由于在安卓中,我们只要去判断当前有没有toolbar,有toolbar的话它的NavigationIcon是否为空即可。为空我们可以什么都不做。但是iOS相对复杂很多,具体的请看下面的解决方案。
For安卓
首先我们在.NET Standard的项目中,添加一个新的名为
CustomNavigationPage
的类,该类继承于NavigationPage
,作用是为了让我们的custom renderer可以应用在这个类上。代码如下:
1
2
3
4
5
6
7public class CustomNavigationPage : NavigationPage
{
public CustomNavigationPage (Page startupPage) : base(startupPage)
{
}
}注意到我们这边其实根本不需要实现任何的关于这个类的自定义的代码,原因是它只是我们custom renderer应用上去的一个target罢了。
在安卓的项目中,新建
NavigationPageRenderer
类,并且重写它的OnPushAsync
方法,注意上面提到的原因。代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30using AppCompToolbar = Android.Support.V7.Widget.Toolbar;
// ...
[ ]
namespace NavBarBackImage.Droid.CustomRenderers
{
public class NavigationPageRenderer : Xamarin.Forms.Platform.Android.AppCompat.NavigationPageRenderer
{
public AppCompToolbar toolbar;
public Activity context;
protected override Task<bool> OnPushAsync(Page view, bool animated)
{
var retVal = base.OnPushAsync(view, animated);
context = (Activity)Forms.Context;
toolbar = context.FindViewById<AppCompToolbar>(Droid.Resource.Id.toolbar);
if (toolbar != null)
{
if (toolbar.NavigationIcon != null)
{
toolbar.NavigationIcon = Android.Support.V7.Content.Res.AppCompatResources.GetDrawable(context, Resource.Drawable.back);
toolbar.Title = "返回";
}
}
return retVal;
}
}
}这里对Xamarin.Forms的custom renderer做一些解释。
首先是一个必备条件,也就是第一行代码,
[assembly: ExportRenderer(typeof(CustomNavigationPage), typeof(NavigationPageRenderer))]
,这行代码的意思就是将NavigationPageRenderer
,也就是这个类的实现内容,应用到CustomNavigationPage
上,也就是我们刚才在.NET Standard中建立的那个空类。另外一个比较关键的点是,为什么这个renderer是继承于
Xamarin.Forms.Platform.Android.AppCompat.NavigationPageRenderer
而不是Xamarin.Forms.Platform.Android.NavigationRenderer
, 原因是因为Xamarin需要兼顾老版本,所以AppCompat就是为了那个而设计的。最后就是简单的判断toolbar是否存在,是否有icon,有的话设置icon为自己加入的resource,并且设置toolbar的title为显示文字。
使用方法:
在App.xaml.cs文件中,将MainPage设为用我们自定义的NavigationPage,代码如下:
1
2
3
4
5
6public App()
{
InitializeComponent();
MainPage = new CustomNavigationPage(new MainPage());
}之后就是在MainPage的button的click事件中,调用正常的
PushAsync
方法,代码如下:1
2
3
4private async void Button_Clicked(object sender, EventArgs e)
{
await Navigation.PushAsync(new SecondPage());
}安卓的部分这样就解决了。
For iOS
iOS要比安卓相对复杂一些。原因是因为以下2个难点。
难点1
对于iOS来说,它的返回是一个button,并且是Xamarin封装的时候相当于会自动实现给一个默认的button。如果要自定义这个button,在iOS的部分,其实不是重写,而是给这个页面的TopViewController.NavigationItem.LeftBarButtonItems
赋值。当这个值为null
,则iOS会应用默认的button,但是当我们给它附了值,iOS就会显示这个自定义的button items在Navigation Bar上面。
之所以是LeftBarButtonItems
是因为默认的情况下,我们的界面都是LTR的,也就是Left To Right的,那么iOS的返回button是在Navigataion Bar的左边的。但是其实iOS的nav bar还有RightBarButtonItems
的。
具体的Xamarin支持的3个平台的nav bar的区别图如下:
所以对于iOS的Nav bar,我们可以这样理解,左半边是LeftBarButtonItems
,中间是PageTitle
,然后右边是RightBarButtonItems
。默认情况下,left和right的button items都是为null的。
所以这第一个难点是要理解如何修改这个button的原理,我们不是修改,而是设置。
难点2
第二个难点是,对于安卓的部分来说,我们只要去判断是不是需要修改,能修改则修改,不能修改则保持默认,具体的逻辑其实就是判断navbar是不是为null并且它的icon是不是null,如果都不是,则修改为我们要求的icon和title,问题就解决了。
但是对于iOS来说,我们可以在所有情况下都设置这个LeftBarButtonItems
,但是这样会导致一个问题,就是你会发现第一个页面,也就是我们例子中的MainPage
也会有返回button了。那这样很显然是不对的,因为这个时候你再去触发返回按钮的事件,一定是会有exception的。
所以我们需要额外的去研究和实现Navigation Stack中的一个逻辑,要判断返回的页面是不是需要设置LeftBarButtonItems
,还是设置一个默认的button。
针对上述的逻辑,在iOS中,我们的custom renderer类中,不光要实现OnPushAsync
,同时还要实现OnPopViewAsync
方法。
具体实现
首先我们还是在iOS的平台项目下,新建一个类叫作
NavigationPageRendereriOS
,该类继承自Xamarin.Forms.Platform.iOS.NavigationRenderer
。其中我们要先定义两个私有字段,分别是navigationStack以及CurrentNavigationPage,当然,在我的项目中,CurrentNavigationPage并没有被实际用到。先看一下下面的代码结构:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21[ ]
// Change the namespace to yours
namespace NavBarBackImage.iOS
{
public class NavigationPageRenderer : Xamarin.Forms.Platform.iOS.NavigationRenderer
{
// Private fields
private readonly Stack<NavigationPage> _navigationPageStack = new Stack<NavigationPage>();
private NavigationPage CurrentNavigationPage => _navigationPage.Peek();
// Constructor
public NavigationPageRendereriOS() : base()
{
}
// Below should be the override and custom methods which will be explained later in this blog.
// ...
}
}我们先来实现两个方法,一个是
SetImageTitleBackButton
方法,是用来设置LeftBarButtonItems
的,另一个是与它对应的设置默认Back button的方法,这个方法会有两个效果,如果是在没有可返回的页面的时候,默认的效果是没有Back button,但是如果有些页面你需要是默认的而不是你自定义的,你也可以做一些页面属性的判断,然后再选择是调用设置的方法还是默认的方法。先来看
SetImageTitleBackButton
的代码,如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31void SetImageTitleBackButton(string imageBundleName, string buttonTitle, int horizontalOffset)
{
var topVC = this.TopViewController;
// Create the image back button
var backButtonImage = new UIBarButtonItem(
UIImage.FromBundle(imageBundleName),
UIBarButtonItemStyle.Plain,
(sender, args) =>
{
topVC.NavigationController.PopViewController(true);
});
// Create the Text Back Button
var backButtonText = new UIBarButtonItem(
buttonTitle,
UIBarButtonItemStyle.Plain,
(sender, args) =>
{
topVC.NavigationController.PopViewController(true);
});
backButtonText.SetTitlePositionAdjustment(new UIOffset(horizontalOffset, 0), UIBarMetrics.Default);
// Add buttons to the Top Bar
UIBarButtonItem[] buttons = new UIBarButtonItem[2];
buttons[0] = backButtonImage;
buttons[1] = backButtonText;
topVC.NavigationItem.LeftBarButtonItems = buttons;
}通过这段代码我们可以看到,
TopViewController.NavigationItem.LeftBarButtonItems
其实是一个数组,该数组是只有2个元素的,这两个元素的类型全部都是UIBarButtonItem
。在这里,我是把数组第一个元素放了返回的箭头的图片,第二个元素则是返回的要显示的字符串,两个拼起来,就和默认的效果的顺序是一样的了。我们来看一下这个方法包含的3个参数的含义:
- imageBundleName: 这个是传入的显示返回button的那个箭头图标的bundle name,参数类型为string,这里涉及到一个iOS的设置图片为bundle resource的概念,具体的教程链接请参考:Displaying an image in Xamarin.iOS
- buttonTile: 这个就是一个字符串,用来设置箭头后面显示的文字,我们可以设为任意自己想要的,比如返回,后退,等等。
horizontalOffset:这个就是横向的一个offset,也就是说希望这个buttonTitle和左边图片的一个offset。
现在我们再来看
SetDefaultBackButton
方法的实现,代码如下:1
2
3
4void SetDefaultBackButton()
{
this.TopViewController.NavigationItem.LeftBarButtonItems = null;
}这个就非常简单明了了,就是设置
LeftBarButtonItems
为空。
然后我们定义设置这个Back Button到页面的逻辑,其实非常简单,目前我的demo中,只有当页面是MainPage的时候,我会设置为default,也就是没有back button,其他情况,我都是统一要设置的。
方法的代码如下:
1
2
3
4
5
6
7
8
9
10
11void SetBackButtonOnPage(Page page)
{
if (page.GetType() == typeof(MainPage))
{
SetDefaultBackButton();
}
else
{
SetImageTitleBackButton("Down", "返回", -15);
}
}注意这里的调用当中的第一个参数Down,就是我自己设置的一个向下的箭头的BundleImage,它的BundleImageName为”Down”。
现在来实现相对比较简单的
OnPushAsync
方法,代码如下:1
2
3
4
5
6
7
8protected override Task<bool> OnPushAsync(Page page, bool animated)
{
var retVal = base.OnPushAsync(page, animated);
SetBackButtonOnPage(page, animated);
return retVal;
}没有太多需要解释的,当调用了默认的OnPushAsync之后,将返回的button按照我们要求的设置,仅此而已,因为Push了页面之后一定会需要显示这个back button,所以在那个方法中一定会走到我们自定义设置的那个代码块。
最后看一下
OnPopViewAsync
方法,代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15protected override Task<bool> OnPopViewAsync(Page page, bool animated)
{
var retVal = base.OnPopViewAsync(page, animated);
var stack = page.Navigation.NavigationStack;
var returnPage = stack[stack.Count - 2];
if (returnPage != null)
{
SetBackButtonOnPage(returnPage);
}
return retVal;
}这里最主要的一句代码其实就是
var returnPage = stack[stack.Count - 2];
,这个代码获得了下一个返回的页面,然后把这个页面传入SetBackButtonPage
,判断是不是MainPage并且设定正确的back button。之所以是-2,无非也就是因为数组从0开始,假设我们navigate了两次,到了第三个页面,这个时候
stack.Count
返回的是3,因为NavigationStack里面一共会有3个页面,这时候点了返回button之后,应该到的是第二个页面,也就是stack[1]
这个页面,所以是3-2得到1,这就解释了为什么是-2。
结尾
到此,这个项目就算开发完了。其实这个项目可以做很多的额外的customization的内容,这里因为普通的项目开发中只需要统一的风格,所以我并没有去做过多的customization。大家可以自由发挥。同时我共享了这个项目的GitHub repository,地址在这里。
大家如果有疑惑或者有问题,欢迎随时留言或者通过关于我里面说到的,开case给我,我会帮助大家解决。
谢谢!
评论