对于很多开发人员来说,炫酷的ui效果是最吸引他们注意力的,很多人也因为这些炫酷的效果而去学习一些比较知名的ui库。而做出炫酷效果的前提是你必须对自定义view有所理解,作为90的小民自然也不例外。特别对于刚处在开发初期的小民,对于自定义view这件事觉得又神秘又帅气,于是小民决定深入研究自定义view以及相关的知识点。
在此之前我们先来看看洋神的原版效果图:
记得那是2014年的第一场雪,比以往时候来得稍晚一些。小民的同事洋叔是一位资深的研发人员,擅长写ui特效,在开发领域知名度颇高。最近洋叔刚发布了一个效果不错的圆形菜单,这个菜单的每个item环形排布,并且可以转动。小民决定仿照洋叔的效果实现一遍,但是对于小民这个阶段来说只要实现环形布局就不错了,转动部分作为下个版本功能,就当作自定义view的练习了。
在google了自定义view相关的知识点之后,小民就写好了这个圆形菜单布局视图,我们一步一步来讲解,代码如下:
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
|
// 圆形菜单 public class circlemenulayout extends viewgroup { // 圆形直径 private int mradius; // 该容器内child item的默认尺寸 private static final float radio_default_child_dimension = 1 / 4f; // 该容器的内边距,无视padding属性,如需边距请用该变量 private static final float radio_padding_layout = 1 / 12f; // 该容器的内边距,无视padding属性,如需边距请用该变量 private float mpadding; // 布局时的开始角度 private double mstartangle = 0 ; // 菜单项的文本 private string[] mitemtexts; // 菜单项的图标 private int [] mitemimgs; // 菜单的个数 private int mmenuitemcount; // 菜单布局资源id private int mmenuitemlayoutid = r.layout.circle_menu_item; // menuitem的点击事件接口 private onitemclicklistener monmenuitemclicklistener; public circlemenulayout(context context, attributeset attrs) { super (context, attrs); // 无视padding setpadding( 0 , 0 , 0 , 0 ); } // 设置菜单条目的图标和文本 public void setmenuitemiconsandtexts( int [] images, string[] texts) { if (images == null && texts == null ) { throw new illegalargumentexception( "菜单项文本和图片至少设置其一" ); } mitemimgs = images; mitemtexts = texts; // 初始化mmenucount mmenuitemcount = images == null ? texts.length : images.length; if (images != null && texts != null ) { mmenuitemcount = math.min(images.length, texts.length); } // 构建菜单项 buildmenuitems(); } // 构建菜单项 private void buildmenuitems() { // 根据用户设置的参数,初始化menu item for ( int i = 0 ; i < mmenuitemcount; i++) { view itemview = inflatemenuview(i); // 初始化菜单项 initmenuitem(itemview, i); // 添加view到容器中 addview(itemview); } } private view inflatemenuview( final int childindex) { layoutinflater minflater = layoutinflater.from(getcontext()); view itemview = minflater.inflate(mmenuitemlayoutid, this , false ); itemview.setonclicklistener( new onclicklistener() { @override public void onclick(view v) { if (monmenuitemclicklistener != null ) { monmenuitemclicklistener.onclick(v, childindex); } } }); return itemview; } private void initmenuitem(view itemview, int childindex) { imageview iv = (imageview) itemview .findviewbyid(r.id.id_circle_menu_item_image); textview tv = (textview) itemview .findviewbyid(r.id.id_circle_menu_item_text); iv.setvisibility(view.visible); iv.setimageresource(mitemimgs[childindex]); tv.setvisibility(view.visible); tv.settext(mitemtexts[childindex]); } // 设置menuitem的布局文件,必须在setmenuitemiconsandtexts之前调用 public void setmenuitemlayoutid( int mmenuitemlayoutid) { this .mmenuitemlayoutid = mmenuitemlayoutid; } // 设置menuitem的点击事件接口 public void setonitemclicklistener(onitemclicklistener listener) { this .monmenuitemclicklistener = listener; } // 代码省略 } |
小民的思路大致是这样的,首先让用户通过setmenuitemiconsandtexts函数将菜单项的图标和文本传递进来,根据这些图标和文本构建菜单项,菜单项的布局视图由mmenuitemlayoutid存储起来,这个mmenuitemlayoutid默认为circle_menu_item.xml,这个xml布局为一个imageview显示在一个文本控件的上面。为了菜单项的可定制型,小民还添加了一个setmenuitemlayoutid函数让用户可以设置菜单项的布局,希望用户可以定制各种各样的菜单样式。在用户设置了菜单项的相关数据之后,小民会根据用户设置进来的图标和文本数量来构建、初始化相等数量的菜单项,并且将这些菜单项添加到圆形菜单circlemenulayout中。然后添加了一个可以设置用户点击菜单项的处理接口的setonitemclicklistener函数,使得菜单的点击事件可以被用户自定义处理。
在将菜单项添加到circlemenulayout之后就是要对这些菜单项进行尺寸丈量和布局了,我们先来看丈量尺寸的代码,如下 :
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
//设置布局的宽高,并策略menu item宽高 @override protected void onmeasure( int widthmeasurespec, int heightmeasurespec) { // 丈量自身尺寸 measuremyself(widthmeasurespec, heightmeasurespec); // 丈量菜单项尺寸 measurechildviews(); } private void measuremyself( int widthmeasurespec, int heightmeasurespec) { int reswidth = 0 ; int resheight = 0 ; // 根据传入的参数,分别获取测量模式和测量值 int width = measurespec.getsize(widthmeasurespec); int widthmode = measurespec.getmode(widthmeasurespec); int height = measurespec.getsize(heightmeasurespec); int heightmode = measurespec.getmode(heightmeasurespec); // 如果宽或者高的测量模式非精确值 if (widthmode != measurespec.exactly || heightmode != measurespec.exactly) { // 主要设置为背景图的高度 reswidth = getsuggestedminimumwidth(); // 如果未设置背景图片,则设置为屏幕宽高的默认值 reswidth = reswidth == 0 ? getdefaultwidth() : reswidth; resheight = getsuggestedminimumheight(); // 如果未设置背景图片,则设置为屏幕宽高的默认值 resheight = resheight == 0 ? getdefaultwidth() : resheight; } else { // 如果都设置为精确值,则直接取小值; reswidth = resheight = math.min(width, height); } setmeasureddimension(reswidth, resheight); } private void measurechildviews() { // 获得半径 mradius = math.max(getmeasuredwidth(), getmeasuredheight()); // menu item数量 final int count = getchildcount(); // menu item尺寸 int childsize = ( int ) (mradius * radio_default_child_dimension); // menu item测量模式 int childmode = measurespec.exactly; // 迭代测量 for ( int i = 0 ; i < count; i++) { final view child = getchildat(i); if (child.getvisibility() == gone) { continue ; } // 计算menu item的尺寸;以及和设置好的模式,去对item进行测量 int makemeasurespec = - 1 ; makemeasurespec = measurespec.makemeasurespec(childsize, childmode); child.measure(makemeasurespec, makemeasurespec); } mpadding = radio_padding_layout * mradius; } |
代码比较简单,就是先测量circlemenulayout的尺寸,然后测量每个菜单项的尺寸。尺寸获取了之后就到了布局这一步,这也是整个圆形菜单的核心所在。代码如下 :
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
31
32
33
34
35
36
|
// 布局menu item的位置 @override protected void onlayout( boolean changed, int l, int t, int r, int b) { final int childcount = getchildcount(); int left, top; // menu item 的尺寸 int itemwidth = ( int ) (mradius * radio_default_child_dimension); // 根据menu item的个数,计算item的布局占用的角度 float angledelay = 360 / childcount; // 遍历所有菜单项设置它们的位置 for ( int i = 0 ; i < childcount; i++) { final view child = getchildat(i); if (child.getvisibility() == gone) { continue ; } // 菜单项的起始角度 mstartangle %= 360 ; // 计算,中心点到menu item中心的距离 float distancefromcenter = mradius / 2f - itemwidth / 2 - mpadding; // distancefromcenter cosa 即menu item中心点的left坐标 left = mradius / 2 + ( int )math.round(distancefromcenter * math.cos(math.toradians(mstartangle)) * - 1 / 2f * itemwidth); // distancefromcenter sina 即menu item的纵坐标 top = mradius / 2 + ( int ) math.round(distancefromcenter * math.sin( math.toradians(mstartangle) ) * - 1 / 2f * itemwidth); // 布局child view child.layout(left, top, left + itemwidth, top + itemwidth); // 叠加尺寸 mstartangle += angledelay; } } |
onlayout函数看起来稍显复杂,但它的含义就是将所有菜单项按照圆弧的形式布局。整个圆为360度,如果每个菜单项占用的角度为60度,那么第一个菜单项的角度为0~60,那么第二个菜单项的角度就是60~120,以此类推将所有菜单项按照圆形布局。首先要去计算每个菜单项的left 和 top位置 ,计算公式的图形化表示如图所示。
上图右下角那个小圆就是我们的菜单项,那么他的left坐标就是mradius / 2 + tmp * coas , top坐标则是mradius / 2 + tmp * sina 。这里的tmp就是我们代码中的distancefromcenter变量。到了这一步之后小民的第一版圆形菜单算是完成了。
下面我们就来集成一下这个圆形菜单。
创建一个工程之后,首先在布局xml中添加圆形菜单控件,代码如下 :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
<linearlayout xmlns:android= "http://schemas.android.com/apk/res/android" xmlns:tools= "http://schemas.android.com/tools" android:layout_width= "match_parent" android:layout_height= "match_parent" android:background= "@drawable/bg" android:gravity= "center" android:orientation= "horizontal" > <com.dp.widgets.circlemenulayout xmlns:android= "http://schemas.android.com/apk/res/android" android:id= "@+id/id_menulayout" android:layout_width= "wrap_content" android:layout_height= "wrap_content" android:background= "@drawable/circle_bg" /> </linearlayout> |
为了更好的显示效果,在布局xml中我们为圆形菜单的上一层以及圆形菜单本书都添加了一个背景图。然后在mainactivity中设置菜单项数据以及点击事件等。代码如下所示 :
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
31
32
33
34
35
|
public class mainactivity extends activity { private circlemenulayout mcirclemenulayout; // 菜单标题 private string[] mitemtexts = new string[] { "安全中心 " , "特色服务" , "投资理财" , "转账汇款" , "我的账户" , "信用卡" }; // 菜单图标 private int [] mitemimgs = new int [] { r.drawable.home_mbank_1_normal, r.drawable.home_mbank_2_normal, r.drawable.home_mbank_3_normal, r.drawable.home_mbank_4_normal, r.drawable.home_mbank_5_normal, r.drawable.home_mbank_6_normal }; @override protected void oncreate(bundle savedinstancestate) { super .oncreate(savedinstancestate); setcontentview(r.layout.activity_main); // 初始化圆形菜单 mcirclemenulayout = (circlemenulayout) findviewbyid(r.id.id_menulayout); // 设置菜单数据项 mcirclemenulayout.setmenuitemiconsandtexts(mitemimgs, mitemtexts); // 设置菜单项点击事件 mcirclemenulayout.setonitemclicklistener( new onitemclicklistener() { @override public void onclick(view view, int pos) { toast.maketext(mainactivity. this , mitemtexts[pos], toast.length_short).show(); } }); } } |
运行效果如前文的动图所示。
小民得意洋洋的蹦出了一个字:真酷!同时也为自己的学习能力感到骄傲,脸上写满了满足与自豪,感觉自己又朝高级工程师迈近了一步。
“这不是洋叔写的圆形菜单嘛,小民也下载了?”整准备下班的主管看到这个ui效果问道。小民只好把其中的缘由、实现方式一一说给主管听,小民还特地强调了circlemenulayout的可定制型,通过setmenuitemlayoutid函数设置菜单项的布局id,这样菜单项的ui效果就可以被用户定制化了。主管扫视了小民的代码,似乎察觉出了什么。于是转身找来还在埋头研究代码的洋叔,并且把小民的实现简单介绍了一遍,洋叔老师在扫视了一遍代码之后就发现了其中的问题所在。
“小民呐,你刚才说用户通过setmenuitemlayoutid函数可以设定菜单项的ui效果。那么问题来了,在你的circlemenulayout中默认实现的是circle_menu_item.xml的逻辑,比如加载菜单项布局之后会通过findviewbyid找到布局中的各个子视图,并且进行数据绑定。例如设置图标和文字,但这是针对circle_menu_item.xml这个布局的具体实现。如果用户设置菜单项布局为other_menu_item.xml,并且每个菜单项修改为就是一个button,那么此时他必须修改circlemenulayout中初始化菜单项的代码。因为布局变了,菜单项里面的子view类型也变化了,菜单需要的数据也发生了变化。例如菜单项不再需要图标,只需要文字。这样一来,用户每换一种菜单样式就需要修改一次circlemenulayout类一次,并且设置菜单数据的接口也需要改变。这样就没有定制型可言了嘛,而且明显违反了开闭原则。反复对circlemenulayout进行修改不免会引入各种各样的问题……”洋叔老师果然一针见血,深刻啊!小民这才发现了问题所在,于是请教洋叔老师应该如何处理比较合适。
“这种情况你应该使用adapter,就像listview中的adapter一样,让用户来自定义菜单项的布局、解析、数据绑定等工作,你需要知道的仅仅是每个菜单项都是一个view。这样一来就将变化通过adapter层隔离出去,你依赖的只是adapter这个抽象。每个用户可以有不同的实现,你只需要实现圆形菜单的丈量、布局工作即可。这样就可以拥抱变化,可定制性就得到了保证。当然,你可以提供一个默认的adapter,也就是使用你的 circle_menu_item.xml布局实现的菜单,这样没有定制需求的用户就可以使用这个默认的实现了。”小民频频点头,屡屡称是。“这确实是我之前没有考虑好,也是经验确实不足,我再好好重构一下。”小民发现问题之后也承认了自己的不足,两位前辈看小民这么好学就陪着小民一块重构代码。
在两位前辈的指点下,经过不到五分钟重构,小民的circlemenulayout成了下面这样。
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
31
32
33
34
|
// 圆形菜单 public class circlemenulayout extends viewgroup { // 字段省略 // 设置adapter public void setadapter(listadapter madapter) { this .madapter = madapter; } // 构建菜单项 private void buildmenuitems() { // 根据用户设置的参数,初始化menu item for ( int i = 0 ; i < madapter.getcount(); i++) { final view itemview = madapter.getview(i, null , this ); final int position = i; itemview.setonclicklistener( new onclicklistener() { @override public void onclick(view v) { if (monmenuitemclicklistener != null ) { monmenuitemclicklistener.onclick(itemview, position); } } }); // 添加view到容器中 addview(itemview); } } @override protected void onattachedtowindow() { if (madapter != null ) { buildmenuitems(); } super .onattachedtowindow(); } // 丈量、布局代码省略 } |
现在的circlemenulayout把解析xml、初始化菜单项的具体工作移除,添加了一个adapter,在用户设置了adapter之后,在onattachedtowindow函数中调用adapter的getcount函数获取菜单项的数量,然后通过getview函数获取每个view,最后将这些菜单项的view添加到圆形菜单中,圆形菜单布局再将他们布局到特定的位置即可。
我们看现在使用circlemenulayout是怎样的形式。首先定义了一个实体类menuitem来存储菜单项图标和文本的信息,代码如下 :
1
2
3
4
5
6
7
8
|
static class menuitem { public int imageid; public string title; public menuitem(string title, int resid) { this .title = title; imageid = resid; } } |
然后再实现一个adapter,这个adapter的类型就是listadapter。我们需要在getview中加载菜单项xml、绑定数据等,相关代码如下 :
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
|
static class circlemenuadapter extends baseadapter { list<menuitem> mmenuitems; public circlemenuadapter(list<menuitem> menuitems) { mmenuitems = menuitems; } // 加载菜单项布局,并且初始化每个菜单 @override public view getview( final int position, view convertview, viewgroup parent) { layoutinflater minflater = layoutinflater.from(parent.getcontext()); view itemview = minflater.inflate(r.layout.circle_menu_item, parent, false ); initmenuitem(itemview, position); return itemview; } // 初始化菜单项 private void initmenuitem(view itemview, int position) { // 获取数据项 final menuitem item = getitem(position); imageview iv = (imageview) itemview .findviewbyid(r.id.id_circle_menu_item_image); textview tv = (textview) itemview .findviewbyid(r.id.id_circle_menu_item_text); // 数据绑定 iv.setimageresource(item.imageid); tv.settext(item.title); } // 省略获取item count等代码 } |
这与我们在listview中使用adapter是一致的,实现getview、getcount等函数,在getview中加载每一项的布局文件,并且绑定数据等。最终将菜单view返回,然后这个view就会被添加到circlemenulayout中。这一步的操作原来是放在circlemenulayout中的,现在被独立出来,并且通过adapter进行了隔离。这样就将易变的部分通过adapter抽象隔离开来,即使用户有成千上万中菜单项ui效果,那么通过adapter就可以很容易的进行扩展、实现,而不需要每次都修改circlemenulayout中的代码。circlemenulayout布局类相当于提供了一个圆形布局抽象,至于每一个子view是啥样的它并不需要关心。通过adapter隔离变化,拥抱变化,就是这么简单。
“原来listview、recyclerview通过一个adapter是这个原因,通过adapter将易变的部分独立出去交给用户处理。又通过观察者模式将数据和ui解耦合,使得view与数据没有依赖,一份数据可以作用于多个ui,应对ui的易变性。原来如此!”小民最后总结道。
例如,当我们的产品发生变化,需要将圆形菜单修改为普通的listview样式,那么我们要做的事很简单,就是将xml布局中的circlemenulayout修改为listview,然后将adapter设置给listview即可。代码如下 :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
public class mainactivity extends activity { private listview mlistview; list<menuitem> mmenuitems = new arraylist<menuitem>(); @override protected void oncreate(bundle savedinstancestate) { super .oncreate(savedinstancestate); setcontentview(r.layout.activity_main); // 模拟数据 mockmenuitems(); mlistview = (listview) findviewbyid(r.id.id_menulayout); // 设置适配器 mlistview.setadapter( new circlemenuadapter(mmenuitems)); // 设置点击事件 mlistview.setonitemclicklistener( new onitemclicklistener(){ @override public void onitemclick(adapterview<?> parent, view view, int position, long id) { toast.maketext(mainactivity. this , mmenuitems.get(position).title, toast.length_short).show(); } }); } |
这样我们就完成了ui替换,成本很低,也基本不会引发其他错误。这也就是为什么我们在circlemenulayout中要使用listadapter的原因,就是为了与现有的listview、gridview等组件进行兼容,当然我们也没有啥必要重新再定义一个adapter类型,从此我们就可以任意修改我们的菜单item样式了,保证了这个组件的灵活性!! 替换为listview的效果如下所示:
“走,我请两位前辈吃烤鱼去!”小民在重构完circlemenulayout之后深感收获颇多,为了报答主管和洋叔的指点嚷嚷着要请吃饭。“那就走吧!”主管倒是爽快的答应了,洋叔老师也是立马应允,三人收拾好电脑后就朝着楼下的巫山烤鱼店走去。
20.9总结
adapter模式的经典实现在于将原本不兼容的接口融合在一起,使之能够很好的进行合作。但是在实际开发中,adapter模式也有一些灵活的实现。例如listview中的隔离变化,使得整个ui架构变得更灵活,能够拥抱变化。adapter模式在开发中运用非常广泛,因此掌握adapter模式是非常必要的。
关于adapter模式实战之重构鸿洋集团的android圆形菜单建行的相关知识就给大家介绍到这里,希望对大家有所帮助!