- 想到做到:Android开发关键技术与精彩案例
- 詹建飞等编著
- 177字
- 2020-08-27 00:27:38
第4章 图形用户界面
衡量一个平台优秀与否的条件之一就是图形用户界面的设计。优秀的图形用户界面架构应该可以帮助开发者快速设计出专业、友好、易用的界面。Android平台做到了这一点。
本章重点介绍Android平台图形用户界面开发的相关知识。从熟悉Android的图形用户界面的特点入手,包括如何创建OptionMenu、ContextMenu菜单,如何响应用户的输入。随后通过实例介绍Android平台常用的各种Widget组件,以及如何实现自定义的View。
4.1 用户界面概述
↘ 4.1.1 手机软硬件特性的发展
Symbian推出的Series 60平台取得了非常大的成功,那时候的用户习惯于使用左、右软键操作手机,使用的组件也相对简单,比如列表格式。如今,随着互联网的快速发展,手机硬件处理能力的增强,消费者已经不再满足于传统的中小屏幕的手机,而是更倾向于使用屏幕更大、支持触摸的手机,以满足网上冲浪、浏览视频等要求。苹果公司的iPhone手机推出后,受到了广大用户的极大欢迎,这也印证了手机硬件特性和用户界面设计的发展方向。
表4-1列举了从2000年至今有代表性的手机参数变化,从中可以看出手机的屏幕尺寸正在变大,色彩越来越丰富,开放的编程接口越来越强大,操作方式也正在从数字键盘向触摸屏发展。
表4-1 手机硬件特性的发展
↘ 4.1.2 如何影响应用程序开发
手机硬件特性的改变,特别是手机屏幕的尺寸、颜色、操作方式的改变,直接影响了用户界面程序设计。手机支持的色彩越来越丰富,使得在应用程序中使用色彩更饱和、更清晰的图片成为可能;手机屏幕尺寸从原来的100×80(像素),逐渐过渡到240×320(像素),再到如今的800×480(像素),让用户界面设计人员有更多的空间可以使用。浏览HTML页面,播放视频文件,都慢慢成为现实,而且效果越来越好;操作方式从数字键盘向触摸屏方式转换,这些要求用户界面设计人员重新设计一种新的用户交互方式,提高用户体验,同时也要求开发人员适应新的编程接口。
↘ 4.1.3 Android图形引擎
Android平台的2D图形引擎由SGL(Skia Graphics Library)提供。SGL是一个2D矢量图形处理引擎,包括字型、坐标变换、位图处理等内容。之所以选择SGL,是因为手机等手持设备的硬件处理能力相对较差,电源有限,而SGL基于C++实现,设计非常严谨,并且经过了高度的优化,可以满足在受限设备上提供高质量的2D渲染的要求。
Android中提供基于OpenGL ES的高性能3D图形引擎,使您可以开发出更加精彩的2D和3D应用程序用户界面,如图4-1所示。OpenGL ES是桌面版OpenGL的精简子集,专为嵌入式系统设计,它创建了软件与图形加速间灵活强大的底层交互接口。最新的Android平台已经支持OpenGL ES 2.0。对于OpenGL ES与本地视窗系统的交互,Android中提供了强大易用的GLSurfaceView工具类,将OpenGL ES与Android底层平台完美融合,使您可以快速、方便地创建OpenGL ES的Activity。Android中的OpenGL ES API与Java ME中的JSR239非常类似,OpenGL ES本身强壮的跨平台性也确保了将其他平台的OpenGL ES应用程序轻松地移植到Android中。关于OpenGL ES编程,将在本书的第12章中详细介绍。
图4-1 基于Android 2D和3D引擎的用户界面
4.2 用户界面设计
Symbian和iOS均提供了一套图形用户界面的编程接口,但是它们之间的差异往往比较大。Android也不例外,如果希望快速地熟悉Android应用程序开发,那么掌握图形用户界面设计无疑是最重要的一环。本节以chapter3_5项目为基础,通过增加应用程序退出提示、音乐扫描、删除歌曲等功能,介绍Android用户界面设计的基础知识。
↘ 4.2.1 声明布局文件
布局,顾名思义,就是如何布置当前的界面。在Android应用程序开发中,可以选择两种布局文件:一种是编写Java代码,另一种是编写XML文件。无论采用何种方式,目的都是构建Activity的界面。由于使用XML文件控制界面布局可以实现编程和界面设计分离的效果,XML文件可读性好,而且可以从XML文件的内容快速地了解到界面是如何设计的,因此我们推荐使用XML文件。
↘ 4.2.2 编写XML文件
每一个XML文件都必须包含一个根节点,节点可以是View,也可以是ViewGroup。编写好的XML文件存放在res/layout目录下,例如,chapter4_1的布局文件songs.xml内容如下所示:
<?xml version="1.0" encoding="UTF-8"?> <LinearLayout android:id="@+id/widget1" android:layout_width="fill_parent" android:layout_height="fill_parent" xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" > <ListView android:id="@android:id/list" android:layout_width="fill_parent" android:layout_height="fill_parent" /> <TextView android:id="@android:id/empty" android:layout_width="fill_parent" android:layout_height="fill_parent" android:textSize="20sp" android:text="@string/no_songs"/> </LinearLayout>
布局文件songs.xml定义的根节点为LinearLayout,根节点的id为widget1。LinearLayout包含两个子节点,分别是ListView和TextView。事实上,这两个节点是相互替换的,在某一时刻只显示其中一个View。例如,只有当ListView中没有任何元素时,系统才会显示TextView。需要注意的一点是,LinearLayout、ListView和TextView的android:id的写法不同。其中,“@+id/widget1”代表创建一个id来标识此LinearLayout对象,widget1将在R.java中自动创建;而“@android:id/list”和“@ android:id/empty”则代表这两个id是系统内置的。
为了方便编写,通常,XML中定义的属性都与对应类的方法有着某种联系。例如,android:orientation="vertical"表明LinearLayout的子元素按照垂直的顺序依次排列,与调用方法setOrientation(LinearLayout.VERTICAL)具有相同的效果。
↘ 4.2.3 加载XML文件
通常,Activity在onCreate()方法中调用setContentView()方法加载XML文件,代码如下所示:
@Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 设置界面布局 setContentView(R.layout.songs); }
加载成功后,XML中定义的布局将被转换为树状结构,如图4-2所示。当界面更新时,根节点将请求所有的子节点按照顺序绘制自己,如果子节点包含其他子节点,则由此父节点负责调用子节点的相关方法重新绘制。
图4-2 Android界面的View结构图
在运行时,songs.xml中定义的<LinearLayout>、<TextView>和<ListView>将被转换成对应的Java对象LinearLayout、TextView和ListView。如果希望在程序中获得songs.xml中定义的组件,则可以调用findViewById(int id)方法,传入组件的id。
↘ 4.2.4 将数据绑定到AdapterView
AdapterView是ViewGroup的子类,它的子View由Adapter决定,常用的AdapterView有ListView、Spinner、GridView和Gallery。通常,AdapterView用于显示存储的数据,这些数据可能来自数组或者数据库。Adapter是AdapterView和数据之间的桥梁,负责生成表示每个数据单元的View。Android平台提供了SimpleCursorAdapter和ArrayAdapter,它们分别用于桥接存储在数据库和数组中的数据。
下面的代码从Content Provider中读取存储在SD卡上的歌曲列表,通过SimpleCursor-Adapter将列表绑定到ListView。
ContentResolver resolver = getContentResolver(); //从Content Provider中获得SD卡上的音乐列表 cursor = resolver.query(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, null, null, null, MediaStore.Audio.Media.DEFAULT_SORT_ORDER); String[] cols = new String[] { MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.ARTIST, }; int[] ids = new int[] { R.id.track_name, R.id.artist }; if (cursor != null) startManagingCursor(cursor); //创建Adapter并绑定到ListView SimpleCursorAdapter adapter = new SimpleCursorAdapter(this, R.layout.songs_list, cursor, cols, ids); getListView().setOnCreateContextMenuListener(this); setListAdapter(adapter);
SimpleCursorAdapter将Cursor中定义的列映射到XML文件中定义的TextView中。res/layout目录下的songs_list.xml文件定义了列表中每一行数据的布局。在songs_list.xml中的组件使用“@+id/track_name”和“@+id/artist”生成新的id,以便将Cursor中包含的TITLE和ARTIST列映射到上述两个TextView中。
运行chapter4_1,音乐列表界面如图4-3所示。
图4-3 音乐列表界面
↘ 4.2.5 创建菜单
Android平台提供了两种菜单设计:OptionMenu和ContextMenu。菜单的展现使用的是统一的形式,方便用户操作和记忆。OptionMenu是Activity的主菜单,当用户按“MENU”键时,OptionMenu展现在用户面前。在Android的界面设计中,OptionMenu被置于屏幕的底部,依次排列,如果超过了界面的显示范围,会自动增加一个“更多”选项,将其他OptionMenu放在“更多”菜单中。ContextMenu类似桌面电脑的“右键”操作,当长按某个View时,系统会弹出一个漂浮的列表菜单,用户可以选择其中定义的操作,ContextMenu常与ListView一起使用。
本例中为MusicActivity创建了两个OptionMenu,分别是“退出”和“扫描”。当初次创建OptionMenu时,Android系统会调用onCreateOptionsMenu()方法,并传入Menu对象。通常,只需要调用Menu.add()方法即可添加OptionMenu,代码如下所示:
@Override public boolean onCreateOptionsMenu(Menu menu) { //增加“退出”菜单 menu.add(0, OPTION_ITEM_EXIT, 0, R.string.option_exit); //增加“扫描”菜单 menu.add(0,OPTION_ITEM_SCAN,1,R.string.option_scan); return true; }
必须为每个OptionMenu指定一个int类型的id,以便当OptionMenu被选中时,可以根据id处理用户的请求。onOptionsItemSelected()方法如下所示:
@Override public boolean onOptionsItemSelected(MenuItem item) { int itemId = item.getItemId(); switch(itemId){ case OPTION_ITEM_EXIT: showDialog(SHOW_EXIT_DIALOG); break; case OPTION_ITEM_SCAN: showDialog(SHOW_SCAN_DIALOG); break; } return true; }
运行chapter4_1,按“MENU”键,弹出的OptionMenu如图4-4(a)所示。
图4-4 OptionMenu和ContextMenu的样式
如果希望为ListView创建ContextMenu,则需要首先覆盖Activity的onCreateContextMenu()方法,在此方法中构建ContextMenu的布局,然后调用registerForContextMenu (getListView())为ListView注册ContextMenu。除了通过add()方法添加ContextMenu的选项外,还可以定义菜单的XML文件,使用MenuInflater构建菜单。onCreateContextMenu()和context_menu.xml内容如下所示:
@Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { MenuInflater menuInflater = getMenuInflater(); menuInflater.inflate(R.menu.context_menu, menu); } Context_menu.xml <menu xmlns:android="http://schemas.android.com/apk/res/android"> <item android:id="@+id/ctx_delete" android:orderInCategory="1" android:menuCategory="container" android:title="@string/ctx_delete"></item> <item android:id="@+id/ctx_property" android:orderInCategory="2" android:menuCategory="container" android:title="@string/ctx_property"></item> </menu>
当ContentMenu被选中时,onContextItemSelected(MenuItem item)会被调用。可以从MenuItem中获得AdapterContextMenuInfo对象,进而得到当前选中的条目的id或者position信息。运行chapter4_1,长按列表中的某个歌曲,弹出的ContextMenu如图4-4(b)所示。
@Override public boolean onContextItemSelected(MenuItem item) { AdapterContextMenuInfo info = (AdapterContextMenuInfo) item.getMenuInfo(); int id = item.getItemId(); switch(id){ case R.id.ctx_delete: cursor.moveToPosition(info.position); showDialog(SHOW_DELETE_DIALOG); case R.id.ctx_property: break; } return super.onContextItemSelected(item); }
↘ 4.2.6 创建Dialog
应用程序经常使用Dialog提示用户下载的进度,或者询问用户是否要删除某首歌曲。弹出的Dialog会获得焦点,准备接收用户的输入,而底下的Activity则进入到暂停状态。目前,Android平台提供了4种类型的Dialog:AlertDialog、ProgressDialog、DatePickerDialog和TimePickerDialog。
AlertDialog通常包括标题、消息和若干个按钮。如果有需要,还可以在AlertDialog中提供列表选项供用户选择。通常,使用AlertDialog.Builder的create()方法来创建Dialog对象。当然,在此之前需要设置Dialog的上述属性。
事实上,Dialog被认为是Activity的一部分。如果需要创建Dialog,应该覆盖Activity的onCreateDialog(int id)方法并返回Dialog对象。当Dialog初次创建时,Android系统会调用onCreateDialog(int id)方法,并认为当前的Activity为此Dialog的所有人,负责管理Dialog的状态。需要显示Dialog时,调用showDialog(int id)方法,id应该是唯一标识Dialog的int型变量,且与传入onCreateDialog(int id)方法的id保持一致。当不再需要Dialog时,可以调用Dialog对象的dismiss()方法或者Activity的dismissDialog(int id)方法。
在chapter4_1中,当用户想删除某首歌曲时,系统会首先弹出Dialog请用户确认,界面如图4-5(a)所示。
图4-5 通知用户
case SHOW_DELETE_DIALOG: return new AlertDialog.Builder(this).setTitle(R.string.delete_message). setNegativeButton(R.string.button_cancel, new DialogInterface.OnClickListener(){ public void onClick(DialogInterface dialog, int which) { dismissDialog(SHOW_DELETE_DIALOG); } }).setPositiveButton(R.string.button_ok, new DialogInterface.OnClickListener(){ public void onClick(DialogInterface dialog, int which) { ContentResolver resolver = getContentResolver(); int songId = cursor.getInt(cursor.getColumnIndexOrThrow (MediaStore.Audio.Media._ID)); String path = cursor.getString(cursor.getColumnIndexOrThrow (MediaStore.Audio.Media.DATA)); //获得指定id歌曲的Uri Uri ringUri = ContentUris.withAppendedId( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, songId); //删除数据库中的记录 resolver.delete(ringUri, null, null); //删除SD卡上的文件 File file = new File(path); if (file.exists()) file.delete(); } //通知用户歌曲已经被删除 Toast.makeText(MusicActivity.this, R.string.file_deleted, Toast.LENGTH_SHORT).show(); }).create();
↘ 4.2.7 通知用户
设计优秀的应用程序应该能够和用户对话,在执行过程中,在不同的时刻提示用户某些重要的信息。Android平台提供了3种通知用户的方式:
Toast——提示用户一小段文本信息,无法和用户交互。
Notification——在状态栏上提示用户,用户可以和Notification交互。例如,当短消息到来时,可以在状态栏上使用Notification提示用户。
Dialog——在当前Activity前面弹出的小窗口,代替Activity接收用户输入。
在chapter4_1中,当成功地从SD卡上删除了用户选定的文件时,应该使用Toast提示用户“歌曲已经从SD卡上删除”,如图4-5(b)所示,代码如下。关于Notification的使用,将在后续章节中进行介绍。
//通知用户歌曲已经被删除 Toast.makeText(MusicActivity.this,R.string.file_deleted, Toast.LENGTH_SHORT).show();
↘ 4.2.8 处理用户输入
Android为处理用户输入提供了多种方式,其核心机制是使用Java的接口回调(call back)机制。当某一事件被触发时,系统会向正在监听此事件的组件发送消息,方式是调用监听器的回调方法。View类中定义了一系列的内嵌监听器类,包括:
View.OnClickListener——当用户点击View时,onClick()方法被调用。
View.OnLongClickListener——当用户长按View时,onLongClick()方法被调用。
View.OnFocusChangeListener——当焦点进入或者离开View时,onFocusChange()方法被调用。
View.OnKeyListener——当View获得焦点,用户点击设备按键时,onKey()方法被调用。
View.OnTouchListener——当用户在屏幕上发出一个触摸事件,包括按下、释放或者移动时,onTouch()方法被调用。
View.OnCreateMenuListener——当ContextMenu被创建时,onCreateContextMenu()方法被调用。
如果希望监听某一类型的事件,则需要首先为View设置监听器,下面的代码为Button设置了OnClickListener。
call = (Button) findViewById(R.id.call); call.setOnClickListener(new View.OnClickListener() { public void onClick(View v) { //直接发起电话呼叫 Intent intent = new Intent(Intent.ACTION_CALL, Uri.parse("tel:10086")); startActivity(intent); } });
对于其他的处理用户输入的方式,本节不一一列举了,在本书后续的介绍中会具体说明。
↘ 4.2.9 样式与主题
Android支持用户自定义样式和主题,以达到统一应用程序风格的目的。通常,样式用来定义某个元素的特性,例如,为TextView设置字体大小和颜色等属性。而主题的范围更宽广一些,可以用来设置应用程序的所有Activity或者单个Activity的样式,例如,设置窗口是否包含标题、窗口的背景等。
1.样式
创建一个自定义的样式,需要首先在res/values目录下创建styles.xml,并在文件中添加一个根节点<resources>。<style>是<resources>的子节点,以name属性标识,同时还可以使用parent属性指定父样式,以便从父样式继承一些属性定义。样式的定义使用<item>标签标识,其中,name属性代表所定义的属性的名称,例如android:textColor。
在chapter4_1项目中,为TextView定义了一个样式chapter4_1_TextView,内容如下所示:
<?xml version="1.0" encoding="utf-8"?> <resources> <style name="chapter4_1_TextView"> <item name="android:textColor">#FF0000</item> </style> </resources>
然后在songs_list.xml中,修改id为track_name的TextView,加入刚才定义的样式。
style="@style/chapter4_1_TextView"
重新运行chapter4_1,发现歌曲列表的字体已经由白色变成了红色,如图4-6(a)所示。
图4-6 样式和主题
2.主题
与定义格式类似,主题也定义在res/values目录下,并且使用的标签也类似。chapter4_1项目中定义了chapter4_1_Theme主题,主要设置了背景图片,并且去掉了窗口中的标题框。Themes.xml文件内容如下所示:
<?xml version="1.0" encoding="utf-8"?> <resources> <style name="chapter4_1_Theme"> <item name="android:textColor">#FF0000</item> <item name="android:windowNoTitle">true</item> <item name="android:windowBackground">@drawable/sea</item> </style> </resources>
将chapter4_1_Theme主题应用到整个应用程序,需要修改AndroidManifest.xml文件中的<application>标签,如下所示。重新运行chapter4_1,如图4-6(b)所示。
android:theme="@style/chapter4_1_Theme"
掌握Android图形用户界面设计,除了上述的知识点之外,还应该了解Layout对象和自定义View实现等内容。如果生硬地把所有知识点揉进chapter4_1,可能会适得其反,因此,chapter4_1中未涉及的知识点,会在后续的章节中一一详细介绍。
4.3 常用Widget
Android内置了丰富的用户界面组件,包括ListView、Spinner、Grid等。此类组件经过了优秀的设计和高强度的测试,封装了大量API控制组件的表现。使用这些组件可以快速地开发出灵活、易用的应用程序。本节重点介绍Android应用程序开发中常用的组件。
↘ 4.3.1 TextView
TextView是View的直接子类。顾名思义,TextView用来向用户展示一段文字,一般用做应用程序的标签或者邮件正文的显示等。在默认情况下,TextView是不允许用户编辑的。如果希望使用可编辑的组件,可以参考EditText。
1.自定义字体颜色和大小
相比View类中定义的XML属性,TextView增加了一些属性用来控制文本的字号、字体颜色等属性,可以在<TextView>标签中直接使用。下面是一段自定义字体颜色和字号的XML内容。
<TextView android:id="@+id/custom" android:layout_width="fill_parent" android:layout_height="fill_parent" android:textColor="#FFBBAA" android:textSize="20px" android:layout_marginBottom="10px" android:text="@string/message" />
2.单行显示
在默认情况下,TextView把所有的文本显示在屏幕上。有时,应用程序可能希望文本单行显示,超出的部分将被自动截取掉,这时可以使用singleLine属性限制TextView单行显示。下面的代码用于限制TextView单行显示。
<TextView android:id="@+id/single" android:layout_width="fill_parent" android:layout_height="fill_parent" android:layout_marginBottom="10px" android:singleLine="true" android:text="@string/message" />
3.自动连接
TextView的文本可能包含电话、网址和邮件地址等内容。应用程序可能希望使用连接的方式显示这些特殊的部分,这样用户可以点击网址打开浏览器,或者点击电话号码发起一个电话呼叫。在默认情况下,TextView是不对这些特殊内容自动连接的,如果需要则可以使用android:autoLink="all"打开这一特性。下面是一段自动连接HTML样式的文本的XML文件内容。
<TextView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/autolink" android:layout_width="fill_parent" android:layout_height="fill_parent" android:autoLink="all" android:text="@string/html" />
如果TextView的内容是动态获取的,比如读取网页内容,这时可以在代码中实现自动连接的特性,示例代码如下所示:
TextView t3 = (TextView) findViewById(R.id.text3); t3.setText( Html.fromHtml( "<b>自动连接:</b> 欢迎访问 " + "<a href=\"http://www.doodev.com\">移动开发网</a> ")); t3.setMovementMethod(LinkMovementMethod.getInstance());
4.响应用户输入
虽然TextView的主要作用是显示文本内容,但是TextView同样可以响应用户的输入事件,比如点击TextView或者长时间按住TextView,这是因为TextView继承自View类。下面的代码演示了如何为TextView注册OnLongClickListener。当TextView接收到长按事件时,使用Toast给用户提示一段文本信息。当Toast组件显示到界面上时,会处于悬浮状态,并获得焦点。
TextView view = (TextView) findViewById(R.id.single); view.setOnLongClickListener(new View.OnLongClickListener() { public boolean onLongClick(View v) { TextView tv = (TextView) v; Toast.makeText(TextViewActivity.this, tv.getText(), Toast.LENGTH_SHORT).show(); return true; } });
运行TextViewActivity,如图4-7所示,展示了刚才介绍的TextView的特性。
图4-7 TextView与CheckedTextView
↘ 4.3.2 CheckedTextView
CheckedTextView扩展了TextView并实现了Checkable接口,当ListView的选择模式设置为单选或者多选的时候,这个类就非常有用,可以与ListView一起使用来标识某种条件。CheckedTextView实现了Checkable接口,也就实现了下面的三个方法:
isChecked()——判断Widget是否处于选中状态。
setChecked()——设置Widget的选中状态。
toggle()——反转Widget的选中状态,即由选中变更为未选中,由未选中变更为选中。
CheckedTextViewActivity演示了,CheckedTextView如何与ListView一起使用。运行CheckedTextViewActivity,界面如图4-7所示。
public class CheckedTextViewActivity extends ListActivity { private String[] peoples = { "Eric", "Monica", "Jim", "John", "Hanks","Tom", "Tiger","Hanks","Jack","Cherry"}; CheckedTextView ctv; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 初始化屏幕的布局 setContentView(R.layout.contacts_list); // 绑定到ArrayAdapter,设置为多选框 setListAdapter(new ArrayAdapter<String>(this, android.R.layout.simple_ list_item_multiple_choice, peoples)); ListView list = (ListView)findViewById(android.R.id.list); list.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); ctv = (CheckedTextView)findViewById(R.id.checkedtv); ctv.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { //反转选中状态 ctv.toggle(); } }); } }
↘ 4.3.3 Button
Button是应用程序中经常使用的组件,一般用来完成用户指定的某项任务,比如将一个表单提交到服务器端,或者完成一个表达式计算。如果不看Android的源代码,你一定想不到Button是继承自TextView。阅读Android的源代码可以了解到,Button只是给TextView定义了buttonStyle样式的结果。下面的代码演示了如何为Button注册一个OnClickListener。
setContentView(R.layout.button); Button button = (Button)findViewById(R.id.button); //设置OnClickListener button.setOnClickListener(new View.OnClickListener(){ public void onClick(View v) { Toast.makeText(ButtonActivity.this, R.string.submit_msg, Toast.LENGTH_SHORT).show(); } });
ToggleButton扩展了Button类,可以显示Button的选中和未选中状态。在默认情况下,ToggleButton使用“ON”表示选中状态,使用“OFF”表示未选中状态。当然,也可以使用ToggleButton新增的android:textOff和android:textOn属性定义显示的文本。下面的XML内容定义了一个ToggleButton对象。
<ToggleButton android:id="@+id/toggle_button" android:textOff="@string/toggle_off" android:textOn="@string/toggle_on" android:layout_width="wrap_content" android:layout_height="wrap_content" />
我们可以为ToggleButton设置一个OnCheckedChangeListener,以便监听ToggleButton状态在ON和OFF之间切换的时间,代码示例如下所示:
ToggleButton toggleButton = (ToggleButton)findViewById(R.id.toggle_button); toggleButton.setOnCheckedChangeListener(new OnCheckedChangeListener() { @Override public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { if(isChecked){ //实现Button处于ON状态的代码 }else{ //实现Button处于OFF状态的代码 } } });
运行ButtonActivity,界面如图4-8所示。
图4-8 Button
↘ 4.3.4 ImageView
ImageView是View的直接子类,用于在屏幕上显示一幅图片,图片可以来自资源文件,也可以从数据库或者文件系统读取。如果有需要,可以使用Bitmap.createScaledBitmap()缩放原始图片。ImageViewActivity显示了两幅图片,后者是前者的缩放版,代码如下所示:
public class ImageViewActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.imageview); ImageView scale = (ImageView)findViewById(R.id.scale); //从资源文件获得Bitmap对象 Bitmap pic = BitmapFactory.decodeResource(getResources(), R.drawable.jadde); //查询Bitmap的高度和宽度 int w = pic.getWidth(); int h = pic.getHeight(); //缩放图片 Bitmap scaled = Bitmap.createScaledBitmap(pic, 100, 100*h/w, false); scale.setImageBitmap(scaled); } }
运行ImageViewActivity,界面如图4-9所示。与TextView类似,ImageView有一个子类ImageButton,既可以显示图片,又可以作为Button使用。
图4-9 缩放图片
↘ 4.3.5 ProgressBar
ProgressBar组件用来提示用户当前任务执行的进度。当应用程序执行一个长时间任务时,提示用户当前执行的百分比,是一个不错的用户体验。但是并非所有任务都可以计算百分比,因此ProgressBar也分为intermediate和indeterminate两种模式。
1.intermediate模式
intermediate模式的ProgressBar包含两个进度显示,分别是主进度和次进度。当应用程序从网络上播放MP3文件时,可以使用主进度表示当前歌曲的播放进度,使用次进度表示歌曲的缓冲进度。调用下面的方法可以用来更新ProgressBar的主进度和次进度。对于indeterminate模式的ProgressBar,此方法不做任何处理。
public synchronized void setProgress(int progress) public synchronized void setSecondaryProgress(int secondaryProgress)
下面的XML内容定义了一个ProgressBar对象,最大值为100,初始进度为0,次进度为50。
<ProgressBar android:id="@+id/progress" style="?android:attr/progressBarStyleHorizontal" android:layout_width="200dip" android:layout_marginBottom="10dip" android:layout_height="wrap_content" android:max="100" android:progress="0" android:secondaryProgress="50" />
通常,任务在后台线程中执行,而只有在任务所在的线程中才能获取任务执行的百分比。此时,如果想直接更新任务的执行百分比,是不可行的。因为根据Android的设计要求,只能在创建界面结构的线程中才可以修改结构中的View对象,否则会出现异常。一般来讲,界面结构是在主线程中通过setContentView()方法装载的,如果要从后台线程中更新主线程中创建的View,则必须使用Handler。在默认情况下,Handler创建时将和所在线程及线程中的消息队列绑定到一起,Handler对象发送的消息或者Runnable对象将被放到所在线程的消息队列之中。因此,Handler是跨线程执行任务的最佳选择。下面的代码在主线程中创建Handler,而在后台线程中借助Handler更新ProgressBar的进度。
private ProgressBar progress; private int progressCurrent; private int progressSecond; private Handler handler = new Handler(); @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); //设置在Title上显示Progressbar requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); setContentView(R.layout.progressbar); //设置bar的可见性 setProgressBarIndeterminateVisibility(visible); progress = (ProgressBar) findViewById(R.id.progress); //启动后台线程更新progress的进度 new Thread(this).start(); } public void run() { progressCurrent = progress.getProgress(); progressSecond = progress.getSecondaryProgress(); while (progressCurrent < 100) { //使用handler在单独线程中更新界面 handler.post(new Runnable() { public void run() { //增加progress的进度 progress.setProgress(++progressCurrent); progress.setSecondaryProgress(++progressSecond); } }); try { Thread.sleep(500); } catch (InterruptedException ex) { ex.printStackTrace(); } } }
2.indeterminate模式
有些任务可能无法计算当前的执行进度,例如,无法从服务器端的HTTP响应获得内容的长度,这时无法计算确切的进度。此时,可以使用indeterminate模式的ProgressBar,只是显示某种动画提示任务正在执行中。下面的代码可以在标题栏区域显示动画形式的ProgressBar。
//设置在Title上显示Progressbar requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); setContentView(R.layout.progressbar); //设置bar的可见性 setProgressBarIndeterminateVisibility(visible);
运行ProgressBarActivity,界面中包含了一个intermediate模式和一个indeterminate模式的ProgressBar,如图4-10所示。
图4-10 可切换成两种模式的ProgressBar
↘ 4.3.6 DatePicker/TimePicker
Android平台提供了DatePicker和TimePicker组件,分别用于设置日期和时间。DatePicker用于设置年、月、日,而TimePicker用于设置小时、分。其中,TimePicker可以设置是否使用24小时制显示。需要注意的是,DatePicker和TimePicker都是View,它们将内嵌在界面中。除此之外,还可以使用DatePickerDialog和TimePickerDialog来设置日期和时间,此时Dialog会悬浮在窗口上。
如果希望监听用户更改日期的事件,则可以为DatePicker设置OnDateChangedListener,代码如下所示:
datePicker = (DatePicker)findViewById(R.id.datepicker); int year = calendar.get(Calendar.YEAR); int month = calendar.get(Calendar.MONTH); int day = calendar.get(Calendar.DAY_OF_MONTH); //设置监听器,当用户修改日期时,onDateChanged()被调用 datePicker.init(year, month, day, new DatePicker.OnDateChangedListener(){ public void onDateChanged(DatePicker view, int year, int monthOfYear, int dayOfMonth) { } });
如 果 希 望 监 听 用 户 更 改 时 间 的 事 件,则 可 以 为TimePicker设 置OnTimeChangedListener,代码如下所示:
timePicker = (TimePicker)findViewById(R.id.timepicker); //设置监听器,当用户修改时间时,onTimeChanged()被调用 timePicker.setOnTimeChangedListener(new TimePicker.OnTimeChangedListener(){ public void onTimeChanged(TimePicker view, int hourOfDay, int minute) { StringBuffer buffer = new StringBuffer(); buffer.append(hourOfDay<10?"0"+hourOfDay:hourOfDay); buffer.append(":").append(minute); Toast.makeText(TimePickerActivity.this, buffer.toString(), Toast.LENGTH_SHORT).show(); } });
运行TimePickerActivity,如图4-11所示。
图4-11 设置日期和时间
↘ 4.3.7 GridView
在4.2节“用户界面设计”中,已经介绍了ListView的基本内容。GridView和ListView非常类似,它们都继承自AbsListView,只是表现形式不同。ListView是以列表的形式表示的,而GridView则是以网格的形式表示的。GridView同样通过ListAdapter访问后台的数据,因此必须调用setAdapter()方法将GridView和数据绑定。
网络上曾经流行一款小游戏,叫“克隆”。玩法是通过点击,从众多图片中找到两个一样的图片,直到所有图片都被翻开,所用时间越短,成绩越高。使用GridView实现克隆游戏的界面非常方便,首先使用XML文件定义一个GridView对象。
<GridView android:id="@+id/myGrid" android:layout_width="fill_parent" android:layout_height="wrap_content" android:padding="10dp" android:verticalSpacing="10dp" android:horizontalSpacing="10dp" android:numColumns="auto_fit" android:columnWidth="60dp" android:stretchMode="columnWidth" android:gravity="center" />
由于GridView和数据通过ListAdapter绑定,因此定义一个ImageAdapter类将资源图片和GridView绑定到一起。ImageAdapter扩展自BaseAdapter,实现了ListAdapter接口。ImageAdapter的getView()方法是绑定数据的关键,根据参数position指定的位置返回一个ImageView。使用convertView之前,应该首先检查其是否为空,如果为空则可以创建一个新的ImageView。
public View getView(int position, View convertView, ViewGroup parent){ ImageView imageView; if (convertView == null) { imageView = new ImageView(mContext); imageView.setLayoutParams(new GridView.LayoutParams(80, 50)); imageView.setAdjustViewBounds(false); imageView.setScaleType(ImageView.ScaleType.CENTER_CROP); imageView.setPadding(8, 8, 8, 8); } else { imageView = (ImageView) convertView; } imageView.setImageResource(target[position]); return imageView; }
GridViewActivity类中定义了两个int类型的数组,target数组用于存储当前图片在R类中的id,初始化时target统一设置为R.drawable.hide;map数组用于存储打乱后的图片的id。游戏开始之前,需要调用initMap()方法。
private void initMap() { //初始化target,默认为R.drawable.hide for (int i = 0; i < target.length; i++) { target[i] = R.drawable.hide; } int[] temp = new int[mThumbIds.length]; System.arraycopy(mThumbIds,0,temp,0,temp.length); int max = temp.length; Random r = new Random(); //随机算法,打乱图片位置 for (int i = 0; i < map.length; i++) { int index = (r.nextInt() >>> 1) % max; map[i] = temp[index]; int t = temp[index]; temp[index] = temp[max -1]; temp[max -1] = t; max--; } }
运行GridViewActivity,界面如图4-12 所示。有兴趣的话,读者可以自行完善这个简单的克隆游戏。
图4-12 克隆游戏界面
↘ 4.3.8 Spinner
Spinner用于显示下拉列表,供用户从列表中选择数据。由于Spinner是AdapterView的子类,因此Spinner中的数据是由Adapter提供的。下面的XML定义了一个Spinner对象。
<Spinner android:id="@+id/spinner" android:layout_width="fill_parent" android:layout_height="wrap_content" android:drawSelectorOnTop="true" android:prompt="@string/city_message" />
在显示Spinner之前,必须构建一个Adapter,用来桥接后台的数据和Spinner的显示。例如,下面的代码创建了一个ArrayAdapter,用户可以从Spinner中选择城市。如果希望监视用户选择列表数据的事件,则可以为Spinner注册一个OnItemSelectedListener。
//从数组创建ArrayAdapter ArrayAdapter<String> adapter = new ArrayAdapter<String>(this, android.R.layout.simple_spinner_item, CITY); adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); spinner.setAdapter(adapter);
运行SpinnerActivity,如图4-13所示。
图4-13 Spinner
↘ 4.3.9 Gallery
Gallery和前面介绍的Spinner均继承自AbsSpinner类,由于同属于AdapterView的范畴,因此Gallery同样通过Adapter和后台的数据绑定在一起。Gallery用于显示水平滚动的列表数据,其中心是固定不动的。例如,展示一组图片数据。下面的XML内容创建了一个Gallery对象。
<Gallery android:id="@+id/gallery" android:layout_width="fill_parent" android:layout_height="wrap_content" android:spacing="5px" android:unselectedAlpha="1.2" />
可以为Gallery设置OnItemClickListener,当Gallery的条目被点击时,可以获得点击事件。也可以为Gallery的条目设置ContextMenu,当用户长按时,可以弹出ContextMenu。这一点和ListView类似,其实Spinner、Gallery、ListView和GridView有很多相似之处,可以看做同一后台数据、不同的前台展示。下面的代码创建了Gallery,并为其设置OnItemClickListener。
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.gallery); gallery = (Gallery)findViewById(R.id.gallery); gallery.setAdapter(new ImageAdapter(this)); //显示被选中的图片 selected = (ImageView)findViewById(R.id.selected); gallery.setOnItemClickListener(new AdapterView.OnItemClickListener(){ public void onItemClick(AdapterView<?> arg0, View arg1, int arg2, long arg3) { selected.setImageResource(IMAGES[arg2]); } }); }
运行GalleryActivity,如图4-14所示。
图4-14 Gallery
↘ 4.3.10 TabHost
Tab格式的窗口视图在手机应用程序中广泛使用,用户只需要点击标签就可以在不同的内容中切换,用户体验非常好。Android中定义了TabHost作为标签式的视图的容器类。TabHost包含两个子元素:一组标签和一个FrameLayout对象,用户可以选择指定的标签,标签对应的内容显示在FrameLayout中。
TabActivity扩展了ActivityGroup,可以在其中包含多个Activity或者View对象。使用TabHost最便捷的办法就是创建一个Activity并扩展TabActivity。由于TabActivity中已经包含了一个TabHost对象,可以通过TabActivity对象的getTabHost()方法获得一个TabHost对象。可以为TabHost对象设置一个OnTabChangedListener,当用户切换标签时,OnTabChangedListener的onTabChanged()方法会被调用。TabSampleActivity的代码如下所示:
public class TabSampleActivity extends TabActivity implements OnTabChangeListener { private TabHost tabHost; private static final String GALLERY = "gallery"; private static final String IMAGE = "image"; private static final String TEXT = "text"; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); //使用系统的layout文件初始化界面结构 setContentView(android.R.layout.tab_content); //获得TabHost对象 tabHost = getTabHost(); //设置OnTabChangeListener,当用户切换Tab的时候被调用 tabHost.setOnTabChangedListener(this); } //切换Tab的时候,此方法被调用 public void onTabChanged(String tabId) { Toast.makeText(this, tabId, Toast.LENGTH_SHORT).show(); } }
获得TabHost对象之后,可以调用addTab(TabHost.TabSpec tabSpec)方法向容器添加TabSpec对象。每个TabSpec由标记、指示器和内容组成。其中标记是一个String对象,用于唯一标识此TabSpec对象;指示器一般由字符串和图标组成;TabSpec的内容可以由三种方式创建,可以指定Intent来启动一个Activity作为内容,也可以使用由id表示的View作为内容,如果内容需要根据需要创建,则可以借助TabContentFactory接口。
TabSampleActivity中包含了三个TabSpec对象,前两个的内容是Intent类型,分别指向GalleryActivity和ImageViewActivity,第三个TabSpec使用TabContentFactory创建一个TextView作为内容。创建TabSpec的代码如下所示:
//创建一个TabSpec,包含Indicator和content两部分 TabHost.TabSpec gallery = tabHost.newTabSpec(GALLERY); gallery.setIndicator(GALLERY, null); //content设置为Intent,则会启动一个Activity作为内容 Intent g_intent = new Intent(this, GalleryActivity.class); gallery.setContent(g_intent); //将gallery加入到TabHost对象中 tabHost.addTab(gallery); TabHost.TabSpec image = tabHost.newTabSpec(IMAGE); image.setIndicator(IMAGE, null); Intent i_intent = new Intent(this, ImageViewActivity.class); image.setContent(i_intent); tabHost.addTab(image); TabHost.TabSpec text = tabHost.newTabSpec(TEXT); text.setIndicator(TEXT, null); //此处根据需要创建TabSpec的content,不是View的id,也不是一个Intent text.setContent(new TabHost.TabContentFactory() { public View createTabContent(String tag) { //返回一个TextView TextView view = new TextView(TabSampleActivity.this); view.setText(tag); return view; } }); tabHost.addTab(text); tabHost.setCurrentTab(0);
运行TabSampleActivity,界面如图4-15所示。
图4-15 TabHost
4.4 高级图形用户界面技术
↘ 4.4.1 图形系统类结构
在Android图形系统中,一切用来显示的组件,包括布局组件LinearLayout、FrameLayout等,都是View的子类,也就是说,所有显示的图形组件类都继承自View类。在Android的图形体系中,图形组件分成两类:一类组件会在界面上显示具体的内容,提供响应事件等,如Button、TextView,这种组件叫做Widget;另一类组件不会显示具体的内容,而是其他组件的集合,主要起到布局的作用,或者将单一的组件组合成一个新的组件,如LinearLayout(线性布局组件)。Layout组件都继承自ViewGroup类。
1.View
首先了解一下View类的结构。
public class View implements Drawable.Callback, KeyEvent.Callback{ //View的构造函数。 public View(Context context, AttributeSet attrs, int defStyle) { //进行组件的初始化操作,主要为属性和风格的读取及设置、布局的调整等 } ... //设置View点击后的监听器,当该View被点击后会执行该监听器实现点击操作 public void setOnClickListener(OnClickListener l) { ... } //该方法规定了本View组件实际显示的宽和高 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec)); } //绘制组件方法,该方法绘制出组件的具体表现样子 public void draw(Canvas canvas){ ... // 调用onDraw方法,该方法用来让View的子类绘制自己的组件图形 onDraw(canvas); // 绘制子View组件 dispatchDraw(canvas); ... } }
View类中关键的方法列表如下:
onFinishInflate()
当该View及其所有子View组件全部从XML文件inflated完毕时调用。
onMeasure(int, int)
该方法用来指定View的宽和高。
onLayout(boolean, int, int, int, int)
该方法用来布局自己的子组件,主要指定各子组件的位置和大小。
onSizeChanged(int, int, int, int)
当组件的尺寸发生变化时调用该方法。
onDraw(Canvas)
当需要绘制组件时调用该方法。
onKeyDown(int, KeyEvent)
当有一个按键按下时调用该方法。
onKeyUp(int, KeyEvent)
当有一个按键抬起时调用该方法。
onTouchEvent(MotionEvent)
当有触摸事件发生时调用该方法。
onFocusChanged(boolean, int, Rect)
当有焦点变化时调用该方法。
onWindowFocusChanged(boolean)
当窗口的焦点发生变化时调用该方法。
onAttachedToWindow()
当组件完全显示于窗口中时调用该方法,主要用来初始化一些组件的属性或者注册IntentReceiver。
onDetachedFromWindow()
当组件从窗口中被删除时调用该方法,主要用来销毁一些属性或取消注册IntentReceiver。
onWindowVisibilityChanged(int)
当窗口的可见性发生变化时调用该方法。
可以看到View类提供了大量钩子方法,其子类可以对这些方法进行覆盖(Override)以实现具体的组件效果。之前讲解的各种图形组件都继承自View类。在实际开发中,会制作很多自定义组件用以显示特殊的效果或执行特殊的功能,后续的章节中会通过制作一个自定义时钟组件的实例来讲解如何实现自定义组件。
2.ViewGroup
之前已经提到,ViewGroup主要提供了对其子组件的管理功能,包括布局、动画等处理,子组件可以是一个View,也可以是一个ViewGroup。图4-16描述了上述概念的继承关系。Android系统提供了一些常用的ViewGroup布局类,下节将会讲述各布局类的使用方法。
图4-16 View与子类的继承关系
↘ 4.4.2 常用布局类
1.FrameLayout
FrameLayout是最简单的布局对象,所有的组件都会固定在屏幕的左上角,不能指定位置。一般要制作一个复合型的新组件都是基于该类来实现的。下面的代码在XML文件中定义了FrameLayout。
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="wrap_content" android:layout_height="wrap_content"> <Button android:text="@string/btn_ok_label" android:id="@+id/button_ok" android:layout_width="wrap_content" android:layout_height="wrap_content" android:width="150px" android:height="150px"></Button> <Button android:text="@string/btn_cancel_label" android:id="@+id/button_cancel" android:layout_width="wrap_content" android:layout_height="wrap_content"></Button> </FrameLayout>
FrameLayout的布局效果如图4-17所示。
图4-17 FrameLayout的布局效果
2.LinearLayout
顾名思义,LinearLayout以单一方向对其中的组件进行线性排列显示。比如以垂直排列显示,则各组件将在垂直方向上排列显示;以水平排列显示,则各组件将在水平方向上排列显示。同时,它还可以对个别的显示对象设置显示比例。
<LinearLayout android:layout_width="fill_parent" android:layout_height="wrap_content"> <Button android:text="@string/btn_ok_label" android:id="@+id/button_ok" android:layout_width="wrap_content" android:layout_height="wrap_content" ></Button> <Button android:text="@string/btn_cancel_label" android:id="@+id/button_cancel" android:layout_width="wrap_content" android:layout_height="wrap_content"></Button> </LinearLayout>
LinearLayout的布局效果如图4-18所示。
图4-18 LinearLayout的布局效果
3.TableLayout
以拥有任意行列的表格对显示对象进行布局,每个显示对象被分配到各自的单元格之中。在TableLayout中,使用TableRow代表一行,每行可以包含一个或多个Cell,或者为空,每个Cell代表一个View组件。与HTML中的Table类似,Cell也可以跨列显示;与HTML的Table不同的是,TableLayout不会绘制表格边框。
TabelLayout的子组件不能设置layout_width属性,该属性会永远为FILL_PARENT。如果子组件为TableRow,其layout_height属性也不能设置,而是永远为WRAP_CONTENT。我们知道各个组件是处于一行及一列中的,每一列的宽度会根据该列中各组件的大小计算得出。TableLayout可以将指定的列设置为shrinkable、stretchable或collapsible(通过TableLayout的属性方法或者在XML中定义)。
设置为stretchable的列会尽可能多地使用可用空间来显示列。例如,将第一列设置为stretchable后,当所有列加在一起也不能填满parent时,第一列就加大自己的宽度以填满parent。
当表格的空间不够时,设置为shrinkable的列会缩小自己的宽度来调整表格总宽度。
设置为collapsible的列为不可见列,这列的空间会被其他列使用。
设置以上属性时,使用从0开始的列编号即可。在XML中定义TableLayout的实例代码如下:
<TableLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="fill_parent" android:shrinkColumns="0" > <TableRow> <TextView android:text="@string/chapter1" android:padding="3dip" /> <TextView android:text="@string/chapter2" android:padding="3dip" /> <TextView android:text="@string/chapter3" android:padding="3dip" /> </TableRow> <TableRow> <TextView android:text="@string/chapter4" android:padding="3dip" /> <TextView android:text="@string/chapter5" android:padding="3dip" /> <TextView android:text="@string/chapter6" android:padding="3dip" /> </TableRow> </TableLayout>
TableLayout的布局效果如图4-19所示。默认情况下,TableLayout是不能滚动的,超出手机屏幕部分无法显示。在TableLayout的外层增加一个ScrollView可以解决此问题,无须修改代码。
图4-19 TableLayout的布局效果
4.AbsoluteLayout
AbsoluteLayout允许以坐标的方式指定显示对象的具体位置。左上角的坐标为(0,0),使用属性layout_x和layout_y来指定组件的具体坐标。这种布局管理器由于显示对象的位置固定了,所以在不同的设备上有可能会出现最终的显示效果不一致。示例代码如下:
<AbsoluteLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="wrap_content" android:layout_height="wrap_content"> <EditText android:id="@+id/edit_msg" android:text="@striing/edit_text_label" android:layout_x="87dip" android:layout_y="49dip" android:layout_width="wrap_content" android:layout_height="wrap_content"/> <Button android:id="@+id/btn_submit" android:text="@string/btn_submit_label" android:layout_x="93dip" android:layout_y="150dip" android:layout_width="wrap_content" android:layout_height="wrap_content" ></Button> </AbsoluteLayout>
AbsoluteLayout的布局效果如图4-20所示。
图4-20 AbsoluteLayout的布局效果
5.RelativeLayout
RelativeLayout允许通过指定显示对象相对于其他显示对象或父级对象的相对位置来布局。比如一个按钮可以放于另一个按钮的右边,或者放在布局管理器的中央。下例详细讲解了如何使用该布局。
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="wrap_content"> <TextView android:id="@+id/label" android:layout_width="fill_parent" android:layout_height="wrap_content" android:text="@striing/hello_label"/> <EditText android:id="@+id/entry" android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_below="@id/label"/> <Button android:id="@+id/ok" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_below="@id/entry" android:layout_alignParentRight="true" android:layout_marginLeft="10dip" android:text="@string/btn_ok_label "/> <Button android:id="@+id/cancel" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_toLeftOf="@id/ok" android:layout_alignTop="@id/ok" android:text="@string/btn_cancel_label "/> </RelativeLayout>
在该例中,文本框label没有设置任何参数,默认显示在左上角;编辑框entry设置了layout_below属性,表示将该组件放置于文本框label的下方,与之对应的属性还有下面这些。
layout_top:置于指定组件之上。
layout_toLeftOf:置于指定组件的左边。
layout_toRightOf:置于指定组件的右边。
“确定”按钮置于编辑框之下,同时layout_alignParentRight表示与父组件右侧对齐。与之对应的属性如下所示。
layout_alignParentTop:与父组件上对齐。
layout_alignParentBottom:与父组件下对齐。
layout_alignParentLeft:与父组件左对齐。
“取消”按钮置于“确定”按钮的左侧,同时与“确定”按钮上对齐。layout_alignTop表示与指定组件上对齐,与之对应的属性如下所示。
layout_alignRight:与指定组件右对齐。
layout_alignBottom:与指定组件下对齐。
layout_alignLeft:与指定组件左对齐。
layout_alignBaseline:与指定组件基线对齐。
RelativeLayout的布局效果如图4-21所示。
图4-21 RelativeLayout的布局效果
↘ 4.4.3 绘制图形
在4.4.1节中,讲解了View及ViewGroup的架构体系。最终图形组件会显示成什么样子,很大程度上取决于View的绘制过程,也就是View中draw(Canvas canvas)方法的实现过程。接下来让我们深入了解一下,如何使用Canvas对象绘制出各种各样的图形。
首先通过一段简单的代码了解一下使用Canvas绘制一个简单图形的过程。
Bitmap bg = Bitmap.createBitmap(200, 200, Config.ARGB_8888); Canvas canvas = new Canvas(bg); Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); String label = "Hello Android"; canvas.drawText(label, 10, 10, paint);
该代码说明了绘制一个图形必需的4个要素:
Bitmap——即一个位图对象,作为图形的“载体”,绘制的图形最终要体现在该位图对象中。
Canvas——实现具体绘制操作的对象。在该实例中可以看到,创建Canvas时,将之前生成好的位图对象作为参数传入Canvas对象中。这样,Canvas的绘制结果会保存到该位图中,生成图像。
Paint——画笔对象定义了绘制过程中的样式,例如,绘制线条时,可以规定使用点画线,或者规定是否使用防抖动等绘制参数和颜色。
绘制内容——即具体要显示的内容。在该实例中绘制了一个文字,即具体的绘制内容为一个文本对象。在绘制其他类型元素时会要求不同的绘制内容,例如,绘制Bitmap时,需要传入Bitmap对象。
1.Bitmap
Bitmap即位图,实际上是一个存储颜色信息的二维数组,二维数组的颜色在屏幕上绘制后会形成图像。可以使用Bitmap.createBitmap()方法生成Bitmap对象,或者使用BitmapFactory从文件或输入流中方便地读取或者创建Bitmap对象。
2.Canvas
Canvas是实现绘制最为重要的对象。可以把Canvas想象成一个绘制窗口,所有想要绘制在Bitmap中的图像都要经过Canvas来实现。Canvas部分主要分成两大块功能:绘制基本图形及Canvas变换。
(1)绘制基本图形
使用Canvas对象可以方便地绘制出点、线、矩形、位图及文本等基本图形,使用这些基本图形可以绘制出各种复杂的图形界面。使用Canvas绘制基本图形的方法如下。
点
点是图形中最基本的元素,使用Canvas对象的drawPoints()方法可以方便地绘制多个点。该方法的具体解释如下:
drawPoints(float[] pts, int offset, int count, Paint paint)
pts为一个float数组,存放所有点的信息。按照 [x0 y0 x1 y1 x2 y2…]格式存放各个点的坐标信息。
offset、count可以指定具体绘制pts序列中的哪些点。
paint为绘制点时使用的画笔对象。
线
绘制线主要是指定线的起始点和结束点,可以使用Canvas对象的3个方法来绘制线。这3个方法的具体解释如下:
void drawLine(float startX, float startY, float stopX, float stopY, Paint paint)
该方法使用startX,startY指定线的起始点坐标,stopX,stopY指定线的结束点坐标。
void drawLines(float[] pts, Paint paint) void drawLines(float[] pts, int offset, int count, Paint paint)
这两个方法类似,可以同时绘制多条线。pts存放了各条线的起始点和结束点的信息。pts数组以4个数组元素为一个单位,表示一条线的信息,存储的格式为[x0, y0, x1, y1],其中(x0, y0)为起始点,(x1, y1)为结束点。如果要绘制多条线,则可以再加入新的坐标信息。
矩形
绘制矩形主要需要指定矩形4条边的坐标,或者使用Rect及RectF对象来表述矩形4条边的信息。Rect存放的是整型数,RectF存放的是浮点数。具体的方法信息如下:
void drawRect(float left, float top, float right, float bottom, Paint paint) void drawRect(Rect r, Paint paint) void drawRect(RectF rect, Paint paint)
位图
使用Canvas可以方便地将一个Bitmap位图绘制到画布上,使用left和top参数可以指定绘制位图的位置。方法的具体信息如下:
void drawBitmap(Bitmap bitmap, float left, float top, Paint paint)
最简单的绘制位图方法是将该位图绘制到(left, top)位置。
void drawBitmap(Bitmap bitmap, Rect src, Rect dest, Paint paint) void drawBitmap(Bitmap bitmap, Rect src, RectF dest, Paint paint)
这两个方法会将一个位图绘制到一个指定的矩形dest中,位图会自动进行平移和缩放等操作。如果src参数不为null,则会裁剪位图的一部分来进行绘制。
文本
在Canvas中有很多种绘制文本的方法,这些方法最终都是指定一个字符及其绘制的位置信息。最典型的绘制文本的方法如下:
void drawText(String text, float x, float y, Paint paint)
其中,text为需要绘制的文本内容;x,y为文本绘制的坐标位置。请注意,这里(x,y)为文本的左下点坐标;paint为绘制文本使用的画笔。
(2)图形变换
在绘制基本图形元素的基础上,Canvas也提供了平移、缩放及旋转等画布的变换能力。下面让我们来了解一下如何使用这些方法。
画布平移
void translate(float dx, float dy);
将画布在x及y方向平移dx及dy(单位为像素)的距离。具体用图形表示,如图4-22所示。
图4-22 平移画布
从图中可以看出,translate()方法可以将画布移动到指定的位置,之后在画布上绘制的图形会产生相应的位移。其实可以把画布当成一个窗口,初始化时窗口位于Bitmap的原点(0,0),当窗口发生位移或其他变换时,从窗口绘制的图形自然会产生相应的变换效果。
画布缩放
void scale(float sx, float sy);
将画布在x轴及y轴上缩放sx及sy倍。当sx或sy大于1时,产生放大效果;小于1时为缩小效果。需要注意的是,缩放的原点为画布的原点(0,0),而不是Bitmap的原点或者View的原点。
具体的图形变换表示如图4-23所示。
图4-23 缩放画布
可以看到,缩放是以画布的顶点(0,0)为缩放原点的。有时希望能够以特定的点为缩放原点,例如,希望位于画布中心的文字能够以x轴的中心点为缩放原点,达到对称缩放的效果,如图4-24所示。
图4-24 对称缩放
我们可以按照以下步骤来实现,首先使用translate()方法将画布移动到目标原点,即想要缩放的原点。
canvas.translate(dx, 0);
示意图如图4-25所示。
图4-25 移动画布
接下来进行缩放操作,代码如下:
canvas.scale(2f, 0);
示意图如图4-26所示。
图4-26 缩放画布
最后一步尤为关键,即应将放大后的图形平移回期望的原点处,即向原点平移2dx。
canvas.translate(-2*dx, 0);
但是实际效果却与期待的大相径庭,图形向回平移过大,以至于超出了画面。为什么会这样呢?因为我们忘记了放大的效果对平移操作也是有影响的!当Canvas放大两倍后,一切针对Canvas的操作也会随之放大,当然平移操作(translate)也会放大相应的倍数。
所以,向回平移的距离不是2*dx,而是dx。
canvas.translate(-dx, 0);
这样,就得到了以(dx,0)为原点的缩放效果。其实Canvas已经封装了这个操作,可以直接使用:
void scale(float sx, float sy, float px, float py){ translate(px, py); scale(sx, sy); translate(-px, -py); }
其中,(px, py)即为缩放的原点。
画布旋转
void rotate(float degrees);
将画布以(0,0)为原点,顺时针旋转degrees角度,如图4-27所示。
图4-27 旋转画布
相信有了上面对scale()方法的讲解,您一定可以猜到如何以一个特定的点为原点进行旋转。在Canvas中已经提供了该方法:
void rotate(float degrees, float px, float py){ translate(px, py); rotate(degrees); translate(-px, -py); }
画布倾斜
void skew(float sx, float sy);
skew()方法实现了画布倾斜,将画布在x及y方向上倾斜相应的角度,sx或sy即为该倾斜角度的tan值。例如,canvas.skew(1, 0); 即为在x方向上倾斜45°(tan(45)=1)。
画布倾斜的效果,如图4-28所示。
图4-28 画布倾斜
至此,我们已经了解了如何使用画布来绘制图形,以及如何变化画布达到不同的效果。Canvas对象本身还有很多实用的方法,如save()、restore(),将在后续章节中一点点深入挖掘。
3.Paint
Paint(画笔)在绘图过程中同样起到了极其重要的作用。画笔主要保存了颜色、样式等绘制信息,指定了如何绘制文本及图形。画笔对象有很多设置方法,这些设置方法大体上可以分为两类:一类与图形绘制相关,另一类与绘制文本相关。
(1)图形绘制
setARGB(int a, int r, int g, int b)
设置绘制的颜色,a代表透明度,r、g、b代表颜色值。
setAlpha(int a)
设置绘制图形的透明度。
setColor(int color)
设置绘制的颜色,使用颜色值来表示,该颜色值包括透明度和RGB颜色。
setAntiAlias(boolean aa)
设置是否使用抗锯齿功能。抗锯齿会让图形的边缘产生模糊效果,使图像边缘看上去更加平滑。使用该效果后图像绘制速度会变慢,所以通常的操作是在动画等需要大量运算时停用该效果。
setDither(boolean dither)
设定是否使用图像抖动处理。抖动处理是图形处理中的一个重要方法,经过抖动处理的图像颜色会显得更为平滑和饱满,图像会更加清晰。抖动处理的算法有很多,但都会耗费很长的处理时间。所以在播放动画等需要大量图形计算时建议停止使用抖动处理,缩短绘制时间。
setFilterBitmap(boolean filter)
如果该项设置为True,则图像在动画进行中会滤掉对Bitmap图像的优化操作,加快显示速度。本设置项依赖于dither和xfermode的设置。
setMaskFilter(MaskFilter maskfilter)
设置MaskFilter,可以用不同的MaskFilter实现滤镜的效果,如虚化、立体等。
setColorFilter(ColorFilter filter)
设置颜色过滤器,可以在绘制颜色时实现不同的颜色变换效果。
setPathEffect(PathEffect effect)
设置绘制路径的效果,如点画线等。
setShader(Shader shader)
设置图像效果,使用Shader可以绘制出各种渐变的效果。
setShadowLayer(float radius, float dx, float dy, int color)
在图形下设置阴影层,产生阴影效果。radius为阴影的角度,dx、dy为阴影在x轴和y轴上的距离,color为阴影颜色。
setStyle(Paint.Style style)
设置画笔样式,为FILL、FILL_OR_STROKE或STROKE。
setStrokeCap(Paint.Cap cap)
当画笔样式为STROKE或FILL_OR_STROKE时,设置笔刷的图形样式,如圆形样式(Cap.ROUND)或方形样式(Cap.SQUARE)。
setStrokeJoin(Paint.Join join)
设置绘制时各图形的结合方式,如平滑结合等。
setStrokeWidth(float width)
当画笔样式为STROKE或FILL_OR_STROKE时,设置笔刷的粗细度。
setXfermode(Xfermode xfermode)
设置图形重叠时的处理方式,如合并、取交集或并集等,经常用来制作橡皮的擦除效果。
(2)文本绘制
setFakeBoldText(boolean fakeBoldText)
模拟实现粗体文字。为什么叫FakeBold?因为文字的粗体本应是字体本身带有的,当使用textStyle为bold时应该显示字体中的粗体。但有时字体本身没有粗体,设置fakeBoldText就可使用图形绘制的效果模式实现“假”的粗体效果。该效果在小字体上效果非常差,请酌情使用。
setSubpixelText(boolean subpixelText)
设置该项为True,将有助于文本在LCD屏幕上的显示效果。
setTextAlign(Paint.Align align)
设置绘制文字的对齐方式。
setTextScaleX(float scaleX)
设置绘制文字x轴的缩放比例,可以实现文字拉伸的效果。
setTextSize(float textSize)
设置文字的字号大小。
setTextSkewX(float skewX)
设置斜体文字,skewX为倾斜弧度。
setTypeface(Typeface typeface)
设置Typeface对象,即字体风格。字体风格包括粗体、斜体以及衬线体、非衬线体等,在视觉设计中字体风格往往起到重要的作用。具体的字体风格解释可以查看相关资料。
setUnderlineText(boolean underlineText)
设置带有下画线的文字效果。
setStrikeThruText(boolean strikeThruText)
设置带有删除线的效果。
在了解了绘制图形的整个细节后,相信读者已经可以随心所欲地绘制出自己想要的图形了。
回到图形组件的问题上来,之前的章节已经讲到,如果想自定义一个图形组件,首先需要创建一个继承自View的组件类,一般需要覆盖View的onDraw(Canvas canvas)方法。请注意,这时Canvas对象已经创建好,也就是说,您不用关心绘制的Bitmap在哪里,View本身已经帮我们实现了。接下来介绍一个完整的自定义组件是如何实现的。
↘ 4.4.4 构建自己的组件
在本小节中,将通过一个实例来展示如何实现一个自定义的组件。该实例会综合使用之前讲解的内容,给读者一个知识的回顾。另外,还会对Canvas等对象进行更深入的展示和讨论。
本实例实现一个自定义的时钟组件,该组件会模拟一个表盘显示系统时间。最终效果如图4-29所示。
图4-29 自定义时钟组件
1.扩展View类
首先,创建一个MyClockView类继承自View,并在构造方法中加载绘制表盘必备的图片资源。
public class MyClockView extends View { private Time mCalendar; //表盘上的各指针图片 private Drawable mHourHand; private Drawable mMinuteHand; private Drawable mDial; //定义表盘的宽和高 private int mDialWidth; private int mDialHeight; private float mMinutes; private float mHour; private boolean mChanged; public MyClockView(Context context) { this(context, null); } public MyClockView (Context context, AttributeSet attrs) { this(context, attrs, 0); } public MyClockView (Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); Resources r = context.getResources(); //通过资源加载各图片,用于绘制时钟 mDial = r.getDrawable(R.drawable.clock_dial); mHourHand = r.getDrawable(R.drawable.clock_hour); mMinuteHand = r.getDrawable(R.drawable.clock_minute); mCalendar = new Time(); mDialWidth = mDial.getIntrinsicWidth(); mDialHeight = mDial.getIntrinsicHeight(); } … }
其中,mDial等对象为Drawable对象。接下来,需要指定该组件的尺寸,覆盖View的onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法可以指定组件的尺寸。
2.onMeasure()方法
下面通过覆盖onMeasure()方法来决定组件的尺寸,具体代码如下:
//需要计算该组件的宽和高时调用该方法 @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //取得组件的宽和高,以及指定模式 int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); float hScale = 1.0f; float vScale = 1.0f; if (widthMode != MeasureSpec.UNSPECIFIED && widthSize < mDialWidth) { hScale = (float) widthSize / (float) mDialWidth; } if (heightMode != MeasureSpec.UNSPECIFIED && heightSize < mDialHeight) { vScale = (float )heightSize / (float) mDialHeight; } //如果表盘图像的宽和高超出其组件的宽和高,即要进行相应的缩放 float scale = Math.min(hScale, vScale); setMeasuredDimension(resolveSize((int) (mDialWidth * scale), widthMeasureSpec), resolveSize((int) (mDialHeight * scale), heightMeasureSpec)); }
首先,根据传入的参数widthMeasureSpec和heightMeasureSpec来取得规定的宽和高及其模式。这里有些复杂,读者不禁会问,这两个参数代表什么?它们又是从哪里来的呢?
在之前的章节中我们已经了解到,任何一个View组件都是包含在一个ViewGroup容器中的。自然,根据各个容器的布局属性不同(如线性布局或表格布局等),其中的组件显示方式也会有一定的限制。这种限制体现在两个方面:尺寸限制和尺寸模式限制。
容器将尺寸和尺寸模式一起传给组件,告诉组件如何规划自己的尺寸。其中尺寸模式为一个整型参数,有以下几种。
UNSPECIFIED:说明容器对组件本身的尺寸没有任何限制,组件可以根据自己的需要随意规划自己的尺寸。在这种情况下,容器提供的尺寸也没有任何意义了。
EXACTLY:说明容器严格要求其组件的尺寸必须为给定尺寸,不能自己决定尺寸大小。
AT_MOST:说明容器提供的尺寸是一个最大值。也就是说,组件可以随意决定自己的尺寸,只要不大于容器指定的尺寸即可。
为了传值方便,Android图形系统将容器提供的尺寸和尺寸模式结合成一个整型表示,并提供了MeasureSpec工具类进行辅助操作,主要方法如下所示。
public static int makeMeasureSpec(int size, int mode)
通过该方法传入尺寸和模式后,会生成一个新的整型,叫做measureSpec。
public static int getSize(int measureSpec)
public static int getMode(int measureSpec)
使用getSize()或getMode()方法,可以将measureSpec中的尺寸和模式提取出来。
至此,我们可以理解onMeasure()方法传入的参数的意义了。在onMeasure()方法中,首先判断容器对组件大小有没有限制,如果尺寸模式不为UNSPECIFIED,即说明容器限定了组件的大小。这时需要判断表盘的大小是否超出了容器限定的尺寸。如果超出,则计算其需要缩小的比例。最后比较宽和高的缩小比例,取其最小值进行缩放。
方法的最后调用setMeasuredDimension()计算出符合要求的宽、高尺寸并返回。需要注意的是,在onMeasure()方法中必须调用setMeasuredDimension()方法返回宽、高值,否则系统会抛出异常。
3.onDraw()方法
组件尺寸决定后,接下来绘制出整个表的具体图形。通过覆盖View的onDraw()方法来绘制表的图形,代码如下:
//绘制组件的方法,使用Canvas对象绘制组件的具体表现 @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); //用changed标识来判断是否需要重新绘制 boolean changed = mChanged; if (changed) { mChanged = false; } //获取组件的位置信息 final int mRight = getRight(); final int mLeft = getLeft(); final int mTop = getTop(); final int mBottom = getBottom(); //计算实际的宽和高 int availableWidth = mRight - mLeft; int availableHeight = mBottom - mTop; //计算时钟的原点 int x = availableWidth / 2; int y = availableHeight / 2; //表盘的宽和高 final Drawable dial = mDial; int w = dial.getIntrinsicWidth(); int h = dial.getIntrinsicHeight(); boolean scaled = false; //利用实际宽、高和表盘的宽、高,判断是否需要缩放画布 if (availableWidth < w || availableHeight < h) { scaled = true; float scale = Math.min((float) availableWidth / (float) w, (float) availableHeight / (float) h); canvas.save(); //进行画布缩放 canvas.scale(scale, scale, x, y); } if (changed) { dial.setBounds(x - (w / 2), y - (h / 2), x + (w / 2), y + (h / 2)); } dial.draw(canvas); //绘制表盘 //绘制时针 canvas.save(); canvas.rotate(mHour / 12.0f * 360.0f, x, y); final Drawable hourHand = mHourHand; if (changed) { w = hourHand.getIntrinsicWidth(); h = hourHand.getIntrinsicHeight(); hourHand.setBounds(x - (w / 2), y - (h / 2), x + (w / 2), y + (h / 2)); } hourHand.draw(canvas); canvas.restore(); //省略绘制分针的代码 … if (scaled) { canvas.restore(); } }
可以发现,之前讲解的Canvas种种变换在这里都得到了充分的使用。首先根据组件的尺寸和表盘图片的尺寸比较、判断是否需要缩放,如果需要,则使用scale()方法进行缩放。
绘制时针则使用了rotate()方法。首先计算出需要转动的角度,然后以表盘中心为原点(x, y)旋转相应角度,然后绘制时针。
值得注意的是,在每次进行画布变换操作之前,都使用了canvas.save()方法,在变换之后又调用了canvas.restore()方法。当对画布进行各种变换之后,往往需要将这些变换清除。例如,为了绘制时针,首先需要将画布旋转。接下来绘制分针时则需要将之前旋转的角度还原,否则会出现错误的效果。canvas.save()方法会保存当前的画布状态。经过画布的各种变换之后,可以调用canvas.restore()方法恢复到之前保存的状态。需要注意的是,save()和restore()必须成对出现,这就好像VB中的IF和ENDIF一样。当然,它们也可以嵌套使用,其实上面的代码实例中就嵌套使用了该方法。
4.View刷新
目前,已经实现了时钟的显示,但是这个时钟并不能随着时间的变化而转动时针。为了实现真正的时钟效果,需要根据时间的变化实时更新View的显示。具体思路如下:
(1)创建BroadcastReceiver监听时间的变化。
(2)如果时间变化,则更新mHour和mMinutes变量。
(3)最后,刷新View显示,重新调用onDraw()方法。
首先,创建BroadcastReceiver,并在View加载完成之后即注册该BroadcastReceiver,实施监听;在View被销毁之前取消监听。代码如下:
//在组件绘制到Window之前调用,这时组件的相关资源已经读取完毕。一般在该 //方法中进行逻辑上的初始化操作 @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); onTimeChanged(); if (!mAttached) { mAttached = true; IntentFilter filter = new IntentFilter(); //注册时间改变的Intent,当时间变动时改变时钟时间 filter.addAction(Intent.ACTION_TIME_TICK); filter.addAction(Intent.ACTION_TIME_CHANGED); getContext().registerReceiver(mIntentReceiver, filter, null, mHandler); } } //当该组件不在Window上显示时调用,一般进行一些BroadcastReceiver的销毁工作 @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); if (mAttached) { getContext().unregisterReceiver(mIntentReceiver); mAttached = false; } }
onAttachedToWindow()方法在View显示在屏幕之前被调用,这个时候是注册Intent监视器的最佳时机。之后,在onDetachedFromWindow()方法中注销监视器,当View从屏幕移除后会调用该方法。mIntentReceiver实例的创建代码如下,主要实现了在时间改变时更新View的时间变量及刷新显示。
private final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { onTimeChanged(); invalidate(); } };
onTimeChanged()方法更新了mHour和mMinutes变量,用于显示表盘时使用,这里就不赘述了。invalidate()方法非常重要,该方法会刷新View的显示。顾名思义,该方法是让View显示的内容无效,无效之后自然就需要重新绘制,即重新调用onDraw()方法。没有参数的invalidate()方法默认会将整个View重新绘制。有时候,View没有必要整个刷新,仅仅需要部分刷新。例如,在游戏中背景部分往往是不需要刷新的,仅需要刷新游戏人物。部分刷新的方法为:
public void invalidate(int l, int t, int r, int b) public void invalidate(Rect dirty)
第一个方法的4个参数为需要刷新区域的4个顶点的坐标;第二个方法的作用与第一个方法相同,只是用Rect来表示需要刷新的区域。
经过对一个自定义View的实例讲解,相信读者已经可以开发一个简单的自定义View组件了。但是在UI的设计中,我们经常需要实现一些动画效果。4.5节就来讲解在Android中如何实现动画效果。
4.5 图形动画
任何动画的实现其实都是一系列静态画面的连续展示。每一个静态画面叫做“帧”。在Android中实现一个简单的动画效果,代码如下:
Animation animation = new TranslateAnimation(0, 20f, 0, 20f); mView.startAnimation(animation);
由此可以看到,实现一个动画效果是多么简单。首先实例化一个animation对象,然后调用View的startAnimation()方法,将之前创建好的animation对象作为参数传入即可实现动画效果。
↘ 4.5.1 Animation
Animation是一个抽象类,TranslateAnimation类是Animation的继承类,实现了简单的位移动画。除了位移动画外,在Android系统中还提供了各种各样的动画效果。
1.渐变动画
渐变动画是较常用的一种动画效果,通过修改View的Alpha值实现从完全显示到透明或是从透明到完全显示的动画效果。在制作渐进渐出的效果时经常使用该动画。使用Java来创建及使用渐变动画的代码如下:
Animation animation = new AlphaAnimation(1.0f, 0.0f); mView.startAnimation(animation);
该代码实现了组件从完全显示到完全透明的动画效果。可以看到,渐变动画首先要创建一个AlphaAnimation的动画实例,该类的构造函数解释如下:
AlphaAnimation (float fromAlpha, float toAlpha)
渐变透明度动画效果的参数如下所示。
fromAlpha:动画开始时的透明度。
toAlpha:动画结束时的透明度。
说明:0.0表示完全透明;1.0表示完全不透明。
除了在代码中直接创建动画实例外,还可以用XML资源来表示动画。在Android系统中字符串、图片等资源是可以通过context获取的,界面Layout也是通过XML文件设置而后通过context获取的,同样动画也可以用XML资源来表示,而后在程序中读取动画实例。
首先,在项目根目录下创建目录res/anim,而后在该目录中创建XML文件。渐变动画的XML实例如下:
<alpha xmlns:android="http://schemas.android.com/apk/res/android" android:fromAlpha="0.0" android:toAlpha="1.0" android:duration="300" />
该XML定义了一个从完全透明到完全显示的动画,动画时间为300毫秒。
假设保存文件名为alpha_anim.xml,而后可以使用AnimationUtil工具类读取动画实例,代码如下:
Animation alphaAnim = AnimationUtils.loadAnimation(context, R.anim.alpha_anim);
2.平移动画
平移动画会将一个组件从一个位置移动到另一个指定位置,这是最简单的动画效果,在之前的例子中已经做了简单演示。平移动画需要指定动画的起始点和结束点位置。在Java中创建平移动画的代码如下:
Animation animation = new TranslateAnimation(0, 20f, 0, 20f); mView.startAnimation(animation);
该实例实现了组件从(0,0)位置移动到(20,20)的动画效果。平移动画TranslateAnimation的构造函数如下:
TranslateAnimation(float fromXDelta, float toXDelta, float fromYDelta, float toYDelta)
画面转换位置移动动画效果的参数如下所示。
fromXDelta:动画起始时x坐标上的移动位置。
toXDelta:动画结束时x坐标上的移动位置。
fromYDelta:动画起始时y坐标上的移动位置。
toYDelta:动画结束时y坐标上的移动位置。
同样,也可以在XML中定义平移动画。在XML资源中定义平移动画的实例如下:
<translate xmlns:android="http://schemas.android.com/apk/res/android" android:fromXDelta="0" android:toXDelta="50" android:fromYDelta="0" android:toYDelta="50" android:duration="1500"/>
该实例实现了从(0,0)到(50,50)的平移动画效果。
3.缩放动画
缩放动画可以实现放大或缩小的动画效果。前面在介绍画布的缩放操作时,特意强调了如何以一个指定点为原点进行缩放操作。缩放动画也一样,同样需要指定动画的缩放原点,其被称为动画的轴心点(Pivot Point)。首先看一下如何实现一个简单的缩放动画。
Animation animation = new ScaleAnimation(1.0f, 2.0f, 1.0f, 2.0f, Animation.ABSOLUTE, 10f, Animation.ABSOLUTE, 10f); mView.startAnimation(animation);
可以发现,缩放动画的构造函数所需要的参数要比平移和渐变动画的多。下面来看一下缩放动画构造函数的定义,以及各个参数的意义。
ScaleAnimation (float fromX, float toX, float fromY, float toY, int pivotXType, float pivotXValue, int pivotYType, float pivotYValue)
渐变尺寸伸缩动画效果的参数如下所示。
fromX:动画起始时x坐标上的伸缩尺寸。
toX:动画结束时x坐标上的伸缩尺寸。
fromY:动画起始时y坐标上的伸缩尺寸。
toY:动画结束时y坐标上的伸缩尺寸。
pivotXType:轴心点在x轴相对于组件的位置类型。
pivotXValue:轴心点在x轴上的位置,该值根据pivotXType的不同代表不同的意义。
pivotYType:轴心点在y轴相对于组件的位置类型。
pivotYValue:轴心点在y轴上的位置,该值根据pivotYType的不同代表不同的意义。
其中,fromX及toX分别为x轴上动画起始时和结束时的缩放倍数,使用浮点数来表示,例如:1.0f表示原大小,2.0f表示两倍大小;fromY和toY同理。后4个参数指定了动画缩放的轴心点位置,我们可以这样理解:动画轴心点,即在动画过程中不会移动的一个固定点。例如,上例中“Animation.ABSOLUTE,10f,Animation.ABSOLUTE,10f”即表示在动画过程中相对组件左上角的(10, 10)为动画轴心点,在动画过程中这一点会固定不变。参数pivotXType的值可以为Animation.ABSOLUTE、Animation.RELATIVE_TO_SELF或Animation.RELATIVE_TO_PARENT。Animation.ABSOLUTE代表轴心点的值,使用绝对坐标值来表示,即轴心点相对组件左上角的距离值;Animation.RELATIVE_TO_SELF在实际应用中非常有用,代表轴心点的值,使用相对组件本身大小的比例值来表示。例如,经常需要实现轴心点为组件中心点的缩放动画,一般情况下,可能需要计算组件的宽和高,然后除以2,得到中心点坐标,但是在定义动画时往往不知道目标组件的实际大小,也就无法计算宽和高。为了解决这个问题,可以将pivotXType设置为Animation.RELATIVE_TO_SELF,然后将pivotXValue设置为0.5f,这样就代表动画轴心点x轴的坐标为目标组件宽的0.5倍,即为组件的中心点。Animation.RELATIVE_TO_PARENT与其类似,只是它以父组件为总长度来计算。
同样,使用XML资源也可以方便地定义缩放动画。
<scale xmlns:android="http://schemas.android.com/apk/res/android" android:fromXScale="2.0" android:toXScale="1.0" android:fromYScale="2.0" android:toYScale="1.0" android:pivotX="50%" android:pivotY="50%" android:duration="300" />
该XML资源定义了以组件中心点为轴心点、从两倍大小还原到正常大小的缩放动画。在XML中,系统会根据pivotX和pivotY的值自动计算其pivotXType和pivotYType类型,当该值为一个浮点数时,pivotXType类型为Animation.ABSOLUTE,为百分数时pivotXType类型为Animation.RELATIVE_TO_SELF。例如,上例中的50%,当在后面加一个p字母时,如50%p,则为Animation.RELATIVE_TO_PARENT。
4.旋转动画
旋转动画会围绕一个轴心点对组件进行旋转。与缩放动画类似,旋转动画同样需要指定一个旋转的轴心点。使用Java实现旋转动画的代码如下:
Animation animation = new RotateAnimation(0f, 350.0f, Animation.ABSOLUTE, 10f, Animation.ABSOLUTE, 10f); mView.startAnimation(animation);
该动画效果会以(10,10)为旋转轴心点,将组件从0°旋转到350°。RotateAnimation的构造函数具体如下:
RotateAnimation (float fromDegrees, float toDegrees, int pivotXType, float pivotXValue, int pivotYType, float pivotYValue)
fromDegrees:动画起始时的旋转角度。
toDegrees:动画旋转到的角度。
pivotXType:轴心点在x轴相对于组件的位置类型。
pivotXValue:轴心点在x轴上的位置,该值根据pivotXType的不同代表不同的意义。
pivotYType:轴心点在y轴相对于组件的位置类型。
pivotYValue:轴心点在y轴上的位置,该值根据pivotYType的不同代表不同的意义。
旋转动画中轴心点的设置与缩放动画一样,这里就不再赘述了。使用XML来定义旋转动画的代码如下:
<rotate xmlns:android="http://schemas.android.com/apk/res/android" android:fromDegrees="0" android:toDegrees="+350" android:pivotX="50%" android:pivotY="50%" android:duration="3000" />
该例以中心为轴心点,顺时针旋转350°。其中pivotX和pivotY的设置规则与缩放动画一致。
5.动画属性
动画有很多属性可以设置,用来实现不同的动画效果。利用这些属性可以控制动画的播放时间、播放次数及播放模式等。列出如下。
setDuration(long durationMillis)
设置动画的播放时间,参数为播放时间,单位为毫秒。注意:参数必须大于或等于0。
setRepeatCount(int repeatCount)
设置动画播放的次数。如果想无限次播放,可传入小于0 的参数,或者使用常量Amination.INFINITE(该常量值为-1)。
setRepeatMode(int repeatMode)
设置动画的重复模式。直观地说,就是动画播放完毕后的行为。重复模式如下所示。
Amination.RESTART:播放完毕后从头开始播放。
Amination.REVERSE:播放完毕后,从后向前反过来播放。
用一个简单的例子来说明以上这些属性如何使用。
Animation animation = new TranslateAnimation(0, 20f, 0, 20f); //每次动画播放20秒 animation.setDuration(20000); //不停止地重复播放 animation.setRepeatCount(Animation.INFINITE); //当动画播放一遍完毕后,反过来重新播放 animation.setRepeatMode(Animation.REVERSE);
上例实现了一个不断重复的动画,并且每次动画完毕后都会反过来重新播放。下面是设置播放起始时间和播放偏移时间的属性。
setStartTime(long startTimeMillis)
设置动画开始播放的时间,该时间是系统的真实时间。若想取得当前系统时间,则可以使用AnimationUtils类提供的currentAnimationTimeMillis()方法。如果想立即播放,则可以将该属性设置为常量Animation.START_ON_FIRST_FRAME(值为-1),动画会判断传入的值是否小于0,如果小于0则会自动转换成当前时间,即实现了立即播放。
setStartOffset(long startOffset)
设置播放偏移时间,即在动画开始之前会延时startOffset毫秒。
在动画播放的过程中,有两段时间是值得关注的,即动画开始播放之前和动画已经播放完毕之后。开始播放之前很好理解,即当前时间还没有到startTime设置的时间或是设置了startOffset时间;动画播放之后如何理解呢?我们知道动画其实就是画面不停地切换,那么每一次切换也是有时间间隔的,也就是每一帧的间隔时间。当动画即将播放结束(即将到达规定的duration时间)时,这时还没有到达播放结束的时间,但当播放下一帧时,由于每帧之间有时间间隔,所以超出了播放时间。这个时候可以看做动画已经播放结束了,这段超出的时间为播放完毕之后的时间。
那么,在播放之前和播放完毕之后还会进行动画操作吗?这个也是可以设置的,具体的属性如下。
setFillBefore(boolean fillBefore)
如果设置为true,那么在动画开始播放之前就会进行动画变换操作。默认为true。
setFillAfter(boolean fillAfter)
如果设置为true,那么在动画结束之后(只有一帧)仍会进行动画变换操作。默认为false。
setFillEnabled(boolean fillEnabled)
只有将其设置为true,上述两个属性才有意义;否则无论开始还是结束时都不会进行动画变换操作。默认为false。
↘ 4.5.2 Interpolator
在实际的动画实现过程中,我们经常遇到这样的情况,即动画的播放不是按时间线性进行的。举例来说,有时需要实现一个具有缓冲效果的平移动画,也就是越到结束时动画的速度越慢。可以使用Interpolator来实现这样的效果。Interpolator是一个接口,主要用来对动画播放的时间进度进行控制。Animation在播放动画的每一帧时,会计算出当前的播放进度,即当前已经播放的百分比。normalizedTime参数是一个小于或等于1 的浮点数,按照常理来说,Animation会根据该参数计算当前应播放的帧,实现线性的动画效果。但实际上Animation会将该值交由Interpolator进行一次变换处理,由Interpolator来控制播放进度。在Animation中相关的代码如下:
final float interpolatedTime = mInterpolator.getInterpolation(normalizedTime);
这样Interpolator就可以方便地控制动画的播放进度,进而达到各种加速或减速的播放效果。系统提供了一些Interpolator的实现类以实现各种不同的动画播放效果,列出如下。
LinearInterpolator:实现动画的线性播放(匀速播放),为默认效果。
AccelerateInterpolator:实现动画的加速播放,即动画会越来越快;该类有一个参数factor,为加速因子。当factor为1时,返回值为normalizedTime的平方。当factor的值越大时加速的效果越明显,即开始时动画较慢,结束时较快。
DecelerateInterpolator:与AccelerateInterpolator相反,实现动画的减速播放。
AccelerateDecelerateInterpolator:开始和结束时较慢,在动画的中间阶段会先加速后减速。
CycleInterpolator:实现循环播放的动画效果。
当创建了Interpolator实例后,可以通过Animation的setInterpolator(Interpolator i)方法将其设置到动画实例中。举例说明如下:
Animation animation = new TranslateAnimation(0, 20f, 0, 20f); Interpolator i = new AccelerateInterpolator(0.8f); //在构造函数中设置factor值 animation.setInterpolator(i);
该实例使用AccelerateInterpolator实现了动画播放的加速效果。
经过以上章节的讲解,相信您已经可以创建出具有各种特性的动画效果了。但是单一的动画效果往往不能满足要求,有时需要将各种动画效果整合在一起形成新的动画效果。4.5.3节就来了解一下如何实现动画的组合。
↘ 4.5.3 AnimationSet
AnimationSet类是Animation的继承类,在实现了Animation的基础上,将各种动画效果合并在一起。例如,在平移的同时让View组件渐渐透明消失,实现一个渐进渐出的效果。AnimationSet使用的实例代码如下:
AnimationSet animationSet = new AnimationSet(true); Animation animation1 = new TranslateAnimation(0, 20f, 0, 20f); Animation animation2 = new AlphaAnimation(1.0f, 0); animationSet.addAnimation(animation1); animationSet.addAnimation(animation2); mView.startAnimation(animationSet);
首先实例化一个AnimationSet对象,在其构造方法中需要传入一个参数,该参数说明整合的各种动画是否会使用同样的Interpolator。
接下来创建想要合并的动画,而后使用AnimationSet的addAnimation方法将其加入animationSet中。最后,就像使用普通的Animation一样,使用View的startAnimation方法播放动画。这样,一个渐出的动画效果就实现了,是不是很简单?
AnimationSet同样可以在XML中定义。利用XML的嵌套特性,set的定义更为简单,代码如下:
<set xmlns:android="http://schemas.android.com/apk/res/android" android:interpolator="@android:anim/accelerate_interpolator"> <alpha android:fromAlpha="0.0" android:toAlpha="1.0" android:duration="300" /> <scale android:fromXScale="2.0" android:toXScale="1.0" android:fromYScale="2.0" android:toYScale="1.0" android:pivotX="50%" android:pivotY="50%" android:duration="300" /> </set>
该AnimationSet实现了Alpha动画和缩放动画同时显示。
↘ 4.5.4 自定义动画
尽管Android已经提供了各种各样的动画,以及动画组合的功能,但仍不能满足所有动画需求。这时可以创建自己的动画,下面将讲解一个自定义动画的实现过程。
首先创建自定义的动画类。所有自定义的动画类都要继承于Animation类,代码如下:
//创建一个自定义动画,实现组件的3D翻转效果 public class My3DAnimation extends Animation { private final float mFromDegrees; private final float mToDegrees; private final float mCenterX; private final float mCenterY; private final float mDepthZ; private final boolean mReverse; private Camera mCamera; public My3DAnimation(float fromDegrees, float toDegrees, float centerX, float centerY, float depthZ, boolean reverse){ mFromDegrees = fromDegrees; mToDegrees = toDegrees; mCenterX = centerX; mCenterY = centerY; mDepthZ = depthZ; mReverse = reverse; } }
在该类的构造函数中,传入必要的属性。这些属性主要有以下几个。
fromDegrees:动画旋转的起始角度。
toDegrees:动画结束时的角度。
centerX,centerY:动画旋转的原点。
depthZ:在动画旋转时,会在z轴上有一个来回的效果。depthZ表示在z轴上平移的最大距离。
reverse:如果为True,则动画反向旋转。
然后需要覆盖applyTransformation()方法,该方法指定了动画每一帧的变换效果。代码如下:
@Override protected void applyTransformation(float interpolatedTime, Transformation t) { final float fromDegrees = mFromDegrees; float degrees = fromDegrees + ((mToDegrees - fromDegrees) * interpolatedTime); final float centerX = mCenterX; final float centerY = mCenterY; final Camera camera = mCamera; final Matrix matrix = t.getMatrix(); camera.save(); if (mReverse) { camera.translate(0.0f, 0.0f, mDepthZ * interpolatedTime); } else { camera.translate(0.0f, 0.0f, mDepthZ * (1.0f - interpolatedTime)); } camera.rotateY(degrees); camera.getMatrix(matrix); camera.restore(); matrix.preTranslate(-centerX, -centerY); matrix.postTranslate(centerX, centerY); }
该方法有两个参数,其中interpolatedTime代表动画执行的进度,即Interpolator计算出的结果,为一个大于或等于0、小于或等于1的浮点数;参数Transformation t为动画变换的载体。
动画实际上是每一帧画面的组合,每一帧产生不同的变换,进而产生动画效果。Transformation即每一帧变换信息的载体,其主要有两个属性:Alpha和Matrix。Alpha为透明度。这个很好理解,表明这一帧时View组件的透明度为多少。AlphaAnimation的渐进渐出效果就是通过改变每一帧的透明度实现的。接下来详细介绍一下Matrix。
1.Matrix介绍
Matrix是变换矩阵,这是图形系统中极为重要的一个概念。在计算机图形学中,图形的基本操作如缩放、旋转、平移等,都可以用一个3×3的矩阵来表示,因此就有了Matrix这个类。Matrix本身的实现机理不在本书的讨论范围内,感兴趣的读者可以参阅计算机图形学的相关资料。下面介绍一下如何使用Matrix。
在之前的章节中,我们学习了如何使用Canvas本身的方法进行各种变换效果(平移、缩放等)。其实这些变换信息都可以由Matrix来表示,而后作用于Canvas上。下面的代码简单地利用Matrix实现旋转加缩放:
Matrix matrix = new Matrix(); matrix.setRotate(45, 50, 50); matrix.setScale(1.5f, 1.5f); canvas.concat(matrix);
Matrix可以方便地设置各种图形变换信息,使用Canvas的concat()方法将Matrix设置的变换信息作用于Canvas上,实现变换效果。Matrix的主要方法如下。
setTranslate(float dx, float dy)
设置平移信息,dx和dy为在x轴和y轴上平移的距离。
setScale(float sx, float sy, float px, float py)
设置缩放信息,sx和sy为在x轴和y轴上的缩放倍数,(px, py)为缩放原点。
setRotate(float degrees, float px, float py)
设置旋转信息,degrees为转动的角度,(px,py)为转动原点。
setSinCos(float sinValue, float cosValue, float px, float py)
利用sin或cos的值来标示转动的角度,(px,py)为转动原点。
setSkew(float kx, float ky, float px, float py)
设置倾斜信息,kx和ky为在x轴和y轴上的倾斜度,(px, py)为倾斜原点。
setConcat(Matrix a, Matrix b)
将两个矩阵信息合并。
以上方法主要用于设置各种变换的信息。另外,每种变换方法还会对应pre和post两种方法。例如,setTranslate()会对应preTranslate()和postTranslate()两个方法。在Matrix中设置各种变换信息是有顺序的,例如,先缩放再平移与先平移后缩放是截然不同的效果(可参见4.4.3 节Canvas缩放部分)。PreTranslate()即将该平移操作放置最开始执行,postTranslate()即将该平移操作放置最后执行。其他以pre和post开头的方法也是如此。
了解了Matrix的使用方法之后,回过头来看看之前的3D变换动画实例。首先使用当前的动画进度计算出当前应转动的角度。
float degrees = fromDegrees + ((mToDegrees - fromDegrees) * interpolatedTime);
而后从Transformation中取出Matrix准备进行变换。但是,在本例中并没有使用Matrix的方法设置变换信息,而是使用了Camera来进行变换操作。
2.Camera介绍
Camera主要实现了三维的平移和旋转,其主要方法如下所示。
translate(float x, float y, float z)
Camera的translate方法与Canvas的translate()方法所不同的是,它可以在z轴上进行平移,也就是使画面相对屏幕前后平移,达到三维的效果。
rotateX(float deg)
以x轴为轴心旋转deg角度。
rotateY(float deg)
以y轴为轴心旋转deg角度。
rotateZ(float deg)
以z轴为轴心旋转deg角度。
同样,Camera也有save和restore方法,用于保存和恢复变换的状态。当Camera变换完毕后,可将其变换值作用于Matrix上,使用Camera.getMatrix()方法。不难看出,Matrix本身的方法都是针对二维平面的变换,而三维的变换则由Camera来帮助实现,最终实现了围绕y轴的旋转效果。
程序的最后,有这样一段代码:
matrix.preTranslate(-centerX, -centerY); matrix.postTranslate(centerX, centerY);
之前已经讲解过,pre和post方法会分别将变换效果置于变换最前和变换最后。结合Canvas变换中缩放原点的实现原理,不难理解以上操作会将(centerX,centerY)作为图形变换的原点,即该动画会以(centerX,centerY)为原点在y轴上产生旋转效果。整个动画的最终效果如图4-30所示。
图4-30 自定义动画的最终效果
本节用一个实例讲解了实现自定义动画的过程。其实,Android系统提供的自定义动画功能远不止如此,结合图形学的知识,相信读者能创造出更酷、更炫的动画。
4.6 Resource介绍
在之前的章节中,多次提到Resource(资源)的概念。我们可以在项目的res目录下定制各种资源文件(XML文件或图片等),而后SDK会自动生成R类,并生成与资源对应的id变量。最后,利用context中的Resource对象便可以方便地获取资源。本节将会对Android系统支持的资源类型做一个简单的汇总。
↘ 4.6.1 资源类型
Android系统支持字符串、位图及其他很多种类型的资源,每一种资源的语法、格式以及存放的位置,都会根据其类型的不同而不同。通常,创建的资源一般来自于3种文件:XML文件、位图文件(图像)以及Raw文件(如声音文件等)。下面列出每种资源的文件类型列表,详细地描述了每种类型的语法、格式及其包含文件的格式。
目录:res/anim/
之前的章节已经进行了详细讲解,其中文件将会被编译成Animation对象。
目录:res/color/
定义一个View在特定状态(如点击、选择等)下的颜色。
目录:res/drawable/
可以有两种类型:一种是图片文件,即png和jpg文件;另一种是XML文件。图片文件中如果使用.9.png结尾,则表明该文件为“点9”图片文件,可以用做背景等特殊用途。XML文件被系统编译为Drawable对象。
目录:res/layout/
被系统编译成Layout对象,或是Layout中的一部分。
目录:res/menu/
在Activity中使用该目录中的文件可以生成菜单。
目录:res/values/
原则上可以存有任意名称的XML文件,之后会被编译到R类,用做系统的变量(如字符串、颜色值等)。虽然可以任意存储XML文件,但是系统有一些约定好的文件如下。
array.xml:定义数组数据。
colors.xml:定义color drawable和颜色的字符串值。使用Resource.getDrawable()和Resources.getColor()分别获得这些资源。
dimens.xml:定义尺寸值(dimension value)。使用Resources.getDimension()获得这些资源。
strings.xml:定义字符串(string)值(使用Resources.getString()或者Resources. getText()获取这些资源。getText()会保留在UI字符串上应用的丰富的文本样式)。
styles.xml:定义样式(style)对象。
目录:res/xml/
任意的XML文件,在运行时可以通过调用Resources.getXML()读取。同样,系统有一些约定好的文件名称或特性的xml节点可以实现特定的功能:在XML文件中使用PreferenceScreen标签可以生成一个应用设置界面,使用searchable标签可以定义应用的搜索属性;另外,定义文件名称为appwidget_provider.xml可以生成在主屏应用中显示的AppWidget。总之,xml目录中的内容往往都有特殊的用途,这里不展开来讲解了,请读者查阅相关的资料。
目录:res/raw/
直接复制到设备中的任意文件。它们无须编译,添加到编译应用程序产生的压缩文件中。要使用这些资源,可以调用Resources.openRawResource(),参数是资源的ID。
可以在项目的res目录下适当的子目录中创建和保存资源文件。Android SDK有专门编译资源的工具,会将资源编译成二进制格式,同时生成R类,对应资源的索引。
↘ 4.6.2 使用资源
资源定义完成后便可以方便地使用了。使用资源一般有两种方法:在代码中使用资源,或者在资源文件中引用其他资源。
1.在代码中使用资源
之前我们已经了解到,在编译时系统会产生一个名为R的类,它包含了程序中所有资源的资源标识符。这个类包含了一些子类,每一个子类针对一种支持的资源类型,或者所提供的一个资源文件。每一个类都包含了已编译资源的一个或多个资源标识符,可以在代码中使用它们来加载资源。下面是一个资源文件实例,包含了字符串、布局和图像等资源。
public final class R { public static final class drawable { public static final int icon=0x7f020003; } public static final class id { public static final int Button01=0x7f070006; public static final int Button02=0x7f070007; } public static final class layout { public static final int alarm_detail=0x7f030000; public static final int main=0x7f030007; } public static final class menu { public static final int detail_menu=0x7f060000; } public static final class string { public static final int alarm_cancel=0x7f040000; public static final int alarm_name_label=0x7f040003; } public static final class style { public static final int CustomTheme=0x7f050001; public static final int FormTitle=0x7f050000; } }
了解了这些标识符后就可以在代码中使用它们了。使用Context.getResource()的Resource对象可以直接取得大部分资源,如Resource.getString()、Resource.getDrawable()等,Resource的方法直观明了,这里就不一一列举了。
一般情况下,可以直接使用这些标识符来代表这些资源的引用而无须Resource对象,对于关键方法,系统已经做了充分的封装,示例代码如下:
msgTextView.setText(R.string.hello_message); //直接使用标识符代表字符串 this.getWindow().setBackgroundDrawableResource(R.drawable.my_background_image); //直接使用标识符代表drawable对象
2.在资源文件中引用资源
在属性(或资源)中提供的值也可以作为资源的引用。这种情况经常出现在布局文件中,用于提供字符串和图像。引用可以是任何资源类型,包括颜色和整数。实例代码如下:
<?xml version="1.0" encoding="utf-8"?> <EditText id="text" xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="fill_parent" android:layout_height="fill_parent" android:textColor="@color/red" android:text="Hello, Android!" />
这里使用“@”前缀引入对一个资源的引用,其形式为@[package:]type/name。其中type是资源类型,name是资源名称。在这种情况下,不需要指定package名,因为引用的是自己包中的资源。
↘ 4.6.3 资源适配
任何手机系统都会面临一个实际的问题——资源适配,也就是根据设备的各种属性匹配相应的资源。在UI设计中经常有这样的需求,例如要实现国际化,或是分辨率适配。
Android系统使用非常简便的方法解决了这个问题。我们知道每一个资源都需要保存于不同的子目录中,在Android系统中,如果想要表示该资源是特定条件下使用的资源(例如,中文环境、HVGA的分辨率下),只需要将子目录的名字后面加上条件的描述即可。规则是:使用“-”分割符将表示条件的字符串加在目录名称后即可。例如,表示中文环境的资源目录为res/values-zh。系统提供了一系列的条件标识符,如表4-2所示。
表4-2 条件标识符
在不同终端上进行用户界面适配工作时,如果布局文件都采用像素(pixel)来表示尺寸大小,则会因为像素密度不一致等原因而不得不使用多套资源。比如,在一个mdpi(像素密度为160dpi)的终端上,如果一个按钮的长度是16 像素,则其实际长度大概是16pixel/160dpi = 0.1 inch,而在一个hdpi的设备中,如果想达到同样的显示效果,则应将该按钮的长度调整为0.1 inch×240dpi =24pixel。
在Android平台中,建议开发者在布局文件中使用与像素密度无关的dp(density-independent pixel)和sp(scale-independent pixels)来分别描述布局尺寸大小和字体大小。
对于mdpi的设备,一个dp相当于一个像素。在mdpi像素密度时,用dp描述的布局文件,可以直接在其他像素密度的终端上使用。通常情况下,Android终端分辨率和像素密度的匹配关系如表4-3所示。
表4-3 Android终端分辨率和像素密度的匹配关系
对于图片资源,Android平台通常将资源目录划分为drawable-hdpi、drawable-mdpi和drawable-ldpi,按照表4-3的匹配关系,分别在其中放置WVGA/FWVGA、HVGA和WQVGA分辨率下的图片资源。如果FWVGA分辨率下的图片资源和WVGA分辨率下的不一致,则可以在工程中再建立一个drawable-hdpi-854×480的资源目录来放置FWVGA分辨率的特殊图片资源。
4.7 App Widgets
Android平台为开发者提供了AppWidget应用程序框架。基于该框架,开发者可以将特定View嵌入到其他应用中。一个最典型的应用场景就是在Android模拟器的主屏上开发外观类似传统桌面Widget的小应用,这些小应用可以在主屏上灵活地添加、拖动和删除。负责将特定View嵌入到其他应用的组件被称为AppWidget providers,能够包含这些特定View的宿主组件被称为AppWidget host。
创建一个App Widget通常需要以下几个对象。本节将以一个简单的时钟App Widget为例具体讲解AppWidget的原理和使用方法。
AppWidgetProvider
继承自BroadcastReceiver类,在App Widget应用update、enable、disable和deleted时接收通知。其中,onUpdate()和onReceive()是最常用到的方法,它们接收更新通知。
AppWidgetProviderInfo
描述AppWidget的元数据,包括更新频率和布局文件等信息。在XML资源中使用<appwidget-provider>标签来定义AppWidgetProviderInfo对象。通常该资源存放在项目的res/xml/目录中。
布局文件
定义App Widget的初始化布局。
配置Activity
开发者可以实现一个用于配置App Widget的Activity,当用户在桌面上新增App Widget的时候,此Activity可以自动启动,允许用户配置App Widget的相关设置。这个Activity是可选的,开发者可以不使用这个特性。
↘ 4.7.1 AppWidgetProvider
如上文所述,AppWidgetProvider继承自BroadcastReceiver类,因此,其本质与BroadcastReceiver类一致,都是接收与处理各类通知Intent。它提供的回调方法如下。
onDeleted(Context context, int[] appWidgetIds)
当每个AppWidget实例从宿主中被删除时,AppWidgetProvider收到Action为android.appwidget.action.APPWIDGET_DELETED的Intent,该方法将被调用。
onDisabled(Context context)
当最后一个AppWidget实例从宿主中被删除时,AppWidgetProvider收到Action为android.appwidget.action.APPWIDGET_DISABLED的Intent,该方法将被调用。开发者可以在该方法中释放使用过的资源,避免内存占用,例如在onEnabled()方法中创建的数据库。
onEnabled(Context context)
当第一个AppWidget实例被创建时,AppWidgetProvider收到Action为android.appwidget. action.APPWIDGET_ENABLED的Intent,该方法将被调用。如果用户为App Widget创建了多个实例,那么后续的App Widget创建的时候,将不会再调用onEnabled()方法。如果只希望为App Widget实例创建一个数据库,那么在此方法中实现再合适不过了。
onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds)
当每个AppWidget实例被添加至宿主或者在进行定时更新时,AppWidgetProvider收到Action为android.appwidget.action.APPWIDGET_UPDATE的Intent,该方法将被调用。需要注意的是如果我们声明了一个Activity用于配置App Widget,那么当用户添加App Widget的时候,这个方法并不被调用,应该由Activity来执行初次更新App Widget的工作。
onReceive(Context context, Intent intent)
当AppWidgetProvider接收到注册的所有类型的Intent时,该方法都会被调用。通过获取Intent中的Action内容,可以分析得到响应事件动作。该方法可用来接收用户自定义执行动作的Intent。通常我们不需要实现此方法。
在AndroidManifest.xml文件中,需要声明AppWidgetProvider类,其方式和声明BroadcastReceiver类一致。在项目chapter4_4中,声明App Widget的XML内容如下所示:
<receiver android:name=".ClockWidgetProvider" > <intent-filter> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> </intent-filter> <meta-data android:name="android.appwidget.provider" android:resource="@xml/appwidget_info" /> </receiver>
<receiver>标签的android:name属性指定了App Widget使用的AppWidgetProvider,<intent- filter>标签中的<action>标签包含了AppWidgetProvider接收的Intent的Action类型,其中,android.appwidget.action.APPWIDGET_UPDATE是必须显式声明的类型。<meta-data>元数据标签中指定了AppWidgetProviderInfo类的资源,android:name属性指定了元数据名称,android:resource属性指定了AppWidgetProviderInfo的资源路径。
↘ 4.7.2 AppWidgetProviderInfo
如本例中,我们将代表AppWidgetProviderInfo对象的appwidget_info.xml放在res/xml目录下,示例如下:
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android" android:minWidth="294dp" android:minHeight="72dp" android:updatePeriodMillis="5000" android:initialLayout="@layout/appwidget_layout" android:configure="com.doodev.chapter4_4.ConfigActivity" > </appwidget-provider>
<appwidget-provider>标签对应AppWidgetProviderInfo对象的内容。其中,android:minWidth和android:minHeight属性分别指定了AppWidget的最小宽度和最小高度。在实际开发中,开发者可以自行指定大小。
android:updatePeriodMillis的属性值指定AppWidgetProvider定时更新的周期,单位是毫秒。如果用户设置了大于零的值T,则AppWidgetProvider每间隔T毫秒都会收到Action为android.appwidget.action.APPWIDGET_UPDATE的Intent来触发定时更新。考虑到终端本身的功耗问题,不建议频繁地触发定时更新。android:initialLayout的属性值指定了AppWidget的实际布局文件。
Configure属性用于定义一个Activity,当用户添加App Widget的时候,Activity将会启动并用于用户来配置App Widget的设置。本例中我们创建了一个ConfigActivity,用于配置时钟字体的颜色。
↘ 4.7.3 App Widget的布局文件
我们还需要为App Widget创建一个布局文件,存放在res/layout目录下,这样系统才知道应该如何渲染App Widget。需要注意的是,App Widget是基于RemoteViews的,并不支持所有的布局和View。目前App Widget支持的布局文件包括FrameLayout、LinearLayout和RelativeLayout。支持的View包括AnalogClock、Button、Chronometer、ImageButton、ImageView、ProgressBar、TextView、ViewFlipper、ListView、GridView、StackView和AdapterViewFlipper。
chapter4_4中用于描述App Widget的布局文件appwidget_layout.xml的内容如下:
<?xml version="1.0" encoding="UTF-8"?> <RelativeLayout android:id="@+id/widget" android:layout_height="wrap_content" android:background= "#CCCCCC" android:padding="@dimen/widget_margin" android:layout_width="fill_parent" xmlns:android="http://schemas.android.com/ apk/res/android"> <TextView android:id="@+id/time" android:layout_height="wrap_content" android:layout_marginTop="5dip" android:layout_width="wrap_content" android:singleLine="true" android:textSize="20sp" android:textColor="#FFFFFF" /> <ImageView android:id="@+id/imageView1" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginLeft="16dp" android:layout_toRightOf="@+id/time" android:src="@drawable/ic_launcher" /> </RelativeLayout>
↘ 4.7.4 RemoteViews
RemoteViews是一种能在其他应用显示的类,其构造方法如下所示:
public RemoteViews (String packageName, int layoutId)
参数packageName表示包含布局资源文件的包名,参数layoutId指定AppWidget的布局文件,和4.7.2节中< appwidget-provider >标签的android:initialLayout属性对应的资源一致。
在chapter4_4中,RemoteViews的布局文件widget_layout.xml包含一个RelativeLayout布局类,它由一个ImageView和两个TextView组成,分别显示应用图标和当前时间。在普通的View中进行设置组件内容操作时,通常先使用findViewById(int id)方法获得组件,然后再设置对应内容。而在RemoteViews中,则是直接利用组件的id来设置对应内容。比如:
setTextViewText(int viewId, CharSequence text)
参数viewId为RemoteViews中某个TextView的id值,text为其对应内容。
setImageViewResource(int viewId, int srcId)
参数viewId为RemoteViews中某个ImageView的id值,srcId为其资源对应的id值。
在RemoteViews中没有OnClickListener方法,取而代之的是setOnClickPendingIntent(int viewId, PendingIntent pendingIntent)方法。与OnClickListener可以自定义任何方式的响应事件相比,setOnClickPendingIntent方法只能为指定的组件设置3种类型的响应事件——启动一个新的Activity、广播Intent或启动Service。
本例中我们为RemoteViews的图标设置了一个PendingIntent,当图标被点击的时候会启动浏览器打开www.doodev.com的主页:
Uri uri = Uri.parse("http://www.doodev.com"); Intent intent = new Intent(Intent.ACTION_VIEW, uri); PendingIntent pIntent = PendingIntent .getActivity(context, 0, intent, 0); RemoteViews rViews = new RemoteViews(context.getPackageName(), R.layout.appwidget_layout); rViews.setOnClickPendingIntent(R.id.imageView1, pIntent);
↘ 4.7.5 配置App Widget的Activity
前面在AppWidgetProviderInfo中已经声明了本例中的配置Activity是ConfigActivity。首先需要在AndroidManifest.xml中声明,具体如下所示。需要注意的是ConfigActivity的<intent-filter>中ACTION应该设置为ACTION_APPWIDGET_CONFIGURE。
<activity android:name="com.doodev.chapter4_4.ConfigActivity" android:label="@string/app_name" > <intent-filter> <action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" /> </intent-filter> </activity>
如果App Widget设置了Activity用于配置其自身的设置,那么当用户首次添加App Widget的时候,Activity将被启动。首先需要从ConfigActivity中获得App Widget的ID,只有获得了这个ID,我们才能使用AppWidgetManager进行更新。
Intent intent = getIntent(); Bundle extras = intent.getExtras(); if (extras != null) { appWidgetId = extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); }
本例中我们允许用户设置时钟时间的字体颜色,当用户设置完成之后,我们获得AppWidgetManager的实例并调用updateAppWidget()方法更新App Widget的界面。
int pos = spinner.getSelectedItemPosition(); int _color = COLORS_VALUE[pos]; SharedPreferences.Editor editor = pref.edit(); editor.putInt("color", _color); editor.commit(); Context context = getApplicationContext(); ClockWidgetProvider.updateAppWidget(context, manager, appWidgetId, _color);
最后,我们设置Intent并结束ConfigActivity,告诉宿主创建App Widget,代码如下所示。我们可以在ConfigActivity刚创建的时候设置Activity的结果为RESULT_CANCELED。这样,如果用户没有完成配置就退出了,那么宿主就会得到通知,不会创建App Widget。
Intent resultValue = new Intent(); resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); setResult(RESULT_OK, resultValue); finish();
运行chapter4_4,从主屏添加Clock小插件,会首先进入到配置界面,配置完成后在主屏显示时钟,如图4-31所示。
图4-31 显示时钟