7.4 自定义控件
在实际开发中,不同的产品有不同的界面样式、交互方式和业务逻辑,单单使用Android提供的基本控件并不能很好地满足各式各样交互界面需求。因此,开发者往往需要对现有基本控件的样式、交互行为、事件处理逻辑等进行定制和变更,构建符合产品需求的自定义控件,来更高效地开发所需的交互界面。
本节将从实际项目入手,看看在大家最熟悉的系统原生应用中,开发者是如何构建和运用自定义控件、高效地实现交互界面的开发的。
7.4.1 控件的定制
所谓自定义控件,就是从已有的基本控件对象中继承派生出隶属于应用自己的控件对象,通过重载等手段,将控件的样式、功能和事件处理进行变更,使其能够满足产品的需求,更高效地构建交互界面。
在控件的定制中,往往需要改变控件呈现的基本式样。
❑改变控制控件中内容的排布
每个控件都会有特定的测算和排列其中内容的方式,这些预设的方式大多时候不能完全满足产品需求。比如,在Android提供的文本控件中,字符串并不会按照特定的段落特征进行分行,有可能会导致中文的标点符号位于句首。如果开发者期望在产品中能够重新对字符串进行分行,更符合中文段落特征,就可以继承基本的文本控件,并修改原有的分行策略。
❑重新绘制控件效果
Android虽然支持丰富的控件效果,但依然不能满足各式各样的产品需求。比如在前面介绍过的图像控件,并不能支持图片的圆角显示,如果需要这样的呈现效果,就应该重载基本图像控件的绘制函数。
❑改变控件的操作行为
Android的控件都有既定的对事件进行处理的流程,这样的流程同样不能完全契合产品需求。这同样可以通过自定义控件的方式进行修改。
以Android原生的计算器应用为例,来看看实际开发中需要如何定制所需的控件。Android原生的计算器应用大量使用了自定义控件来构建交互界面,如图7-14所示,计算器上有多个样式统一的按钮,每个按钮中的文本都重新排布过,处于整个按钮的最中央。为了增强用户点击按钮时的反馈效果,计算器中的每个按钮在按下时都会产生一个特殊的强烈渐变动画。为了能够快速地构造这样的交互界面,计算器应用将这些按钮封装成了一个自定义的“彩色按钮”控件ColorButton(com.android.calculator2.ColorButton)。ColorButton派生自基础的按钮控件android.widget.Button:
class ColorButton extends Button
implements OnClickListener{
public ColorButton(Context context, AttributeSet attrs){
super(context, attrs);
init(calc);
setOnClickListener(this);
}
private void init(Calculator calc){
//从资源文件中读取需要的参数信息
Resources res=getResources();
mFeedbackColor=res.getColor(R.color.magic_flame);
mFeedbackPaint=new Paint();
mFeedbackPaint.setStyle(Style.STROKE);
mFeedbackPaint.setStrokeWidth(2);
getPaint().setColor(res.getColor(R.color.button_text));
//初始时,未开始绘制按钮按下的动画
mAnimStart=-1;
…
}
};
派生的自定义控件,通常至少会实现接受Context和AttributeSet参数的构造函数,这样该控件就可以通过资源文件来进行构造并设置样式。
在自定义控件的构造函数中,往往会有一个类似于ColorButton中init的函数,在该函数中对自定义控件的样式参数进行初始化。在ColorButton.init函数中,会从应用的资源对象android.content.res.Resources中读取界面相关的颜色、尺寸、间隔等常量信息。在界面开发中,一般来说不应该在代码中直接定义与界面相关的常量信息,而是把这样的常量信息放到资源文件中。这样可以利用Android资源文件的可配置性,在不同尺寸的屏幕、不同界面风格下使用不同的界面参数,提升控件的适配性。
图 7-14 Android的计算器应用的自定义控件
为了实现所有文本内容居中的效果,ColorButton控件会在文本内容和控件尺寸发生变化时,重新计算文本所处的位置:
//计算文本位置的函数,使得按钮中的文本始终居中显示
//其中,mTextX和mTextY用来记录文本内容左上角位置
private void measureText(){
Paint paint=getPaint();
mTextX=(getWidth()-paint.measureText(getText().toString()))/2;
mTextY=(getHeight()-paint.ascent()-paint.descent())/2;
}
//重载并监控控件尺寸变化,当控件大小发生变化时,重新计算文字的位置
@Override
public void onSizeChanged(int w, int h, int oldW, int oldH){
measureText();
}
//重载并监控控件中文本变化,当文本内容发生变化时,重新计算文字的位置
@Override
protected void onTextChanged(CharSequence text, int start, int before, int after){
measureText();
}
有了文本内容的位置信息,就可以通过重载View.onDraw函数重新对控件界面进行绘制,将文本内容居中显示,并在按钮按下时绘制特殊的渐变动画:
public void onDraw(Canvas canvas){
if(mAnimStart!=-1){
//如果正在播放按钮按下的动画,则通过当前时间计算并绘制特定效果
int animDuration=(int)(System.currentTimeMillis()-mAnimStart);
if(animDuration>=CLICK_FEEDBACK_DURATION){
mAnimStart=-1;
}else{
drawMagicFlame(animDuration, canvas);
postInvalidateDelayed(CLICK_FEEDBACK_INTERVAL);
}else if(isPressed()){
//如果没有播放动画,则简单地绘制边框
drawMagicFlame(0,canvas);
}
}
//在按钮上将文本居中绘制出来
CharSequence text=getText();
canvas.drawText(text,0,text.length(),mTextX, mTextY, getPaint());
}
可以看到,在界面进行绘制时,是依据mAnimStart参数记录的按钮被按下的状态和时间来进行动画效果绘制的。为了能够知道用户什么时候点击了按钮,什么时候释放了按钮,系统就需要派生View.onTouchEvent,截获点击事件,修改存放在mAnimStart中的信息:
@Override
public boolean onTouchEvent(MotionEvent event){
boolean result=super.onTouchEvent(event);
switch(event.getAction()){
case MotionEvent.ACTION_UP:
if(isPressed()){
//如果用户刚刚按下按钮,记录按下时间,重新绘制界面,播放动画
mAnimStart=System.currentTimeMillis();
invalidate();
}else{
//如果用户停留在按下状态,不断地重绘界面,刷新动画效果
invalidate();
}
break;
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_CANCEL:
//如果用户触摸或者停止触摸按钮,重新绘制
invalidate();
break;
}
//返回基类的处理结果,不阻止点击事件的传播
return result;
}
可以看到,ColorButton通过重载View.onSizeChanged等函数,重新对控件中的内容分布进行了计算;通过重载View.onDraw函数,对控件中的内容进行了重新绘制;通过重载View.onTouchEvent捕获了用户与按钮交互的事件,改变了原有的事件传播方式。这些工作,使得基础的Android按钮控件能够成为符合产品需求的“彩色控件”,被用来更快速地构建Android原生计算器的交互界面。
小贴士 在实际开发中,如果只需要为控件设置统一的样式,那不必使用自定义控件,只要设置同一样式资源即可。只有需要改变控件事件行为或者深度定制控件效果时,才需要考虑自定义控件。
这是因为自定义控件是基于继承的实现方式,在实现定制的过程中,其灵活性受到了不小的约束。开发者在设计中应当优先考虑使用组合的方式来取代基于继承的实现。
在Android的计算器应用中,除了ColorButton之外,计算器上方的结果文本框也是自定义控件CalculatorEditText(com.android.calculator2.CalculatorEditText)。与ColorButton不同,自定义结果框CalculatorEditText并没有改变原有的样式,而是绑定了与计算器相关的业务逻辑。当结果框控件被长按时,会弹出与之相符合的相关菜单,便于用户操作:
//计算器结果控件重载于Android的编辑控件
public class CalculatorEditText extends EditText{
//定义内容菜单的处理对象
private class MenuHandler implements
MenuItem.OnMenuItemClickListener{
public boolean onMenuItemClick(MenuItem item){
//当用户点击菜单项时,触发对应的处理逻辑
return onTextContextMenuItem(item.getTitle());
}
}
@Override
public void onCreateContextMenu(ContextMenu menu){
//初始化菜单项,相关操作包括剪切、复制和粘贴
if(mMenuItemsStrings==null){
Resources resources=getResources();
mMenuItemsStrings=new String[3];
mMenuItemsStrings[CUT]=
resources.getString(android.R.string.cut);
mMenuItemsStrings[COPY]=
resources.getString(android.R.string.copy);
mMenuItemsStrings[PASTE]=
resources.getString(android.R.string.paste);
}
//为各个菜单项绑定处理函数
MenuHandler handler=new MenuHandler();
for(int i=0;i<mMenuItemsStrings.length;i++){
menu.add(Menu.NONE, i,i, mMenuItemsStrings[i]).setOnMenuItemClickListener(
handler);
}
//根据显示的内容控制菜单项的显示逻辑
if(getText().length()==0){
menu.getItem(CUT).setVisible(false);
menu.getItem(COPY).setVisible(false);
}
if(hasTextInPrimaryClip()){
menu.getItem(PASTE).setVisible(false);
}
}
}
在开发中经常会有类似的需求,需要在特定的控件上绑定一系列的业务逻辑,比如弹出特定的菜单、提供特殊的接口、存储额外的数据,等等。这样的需求,可以通过继承方式定制自定义控件来满足,也可以通过组合的方式将控件对象与特定的业务逻辑处理函数或对象绑定来进行处理。如果这个控件对象仅需要在一个地方使用,那会倾向于使用组合的方式来实现;而如果类似的控件对象需要在多处使用(在本例中,CalculatorEditText会被构造多个实例,以便实现切换动画的效果),则可以使用自定义控件的方式,提升复用度,也使得逻辑更为清晰。