如何创建新控件? “复合控件”“定制控件”
扩展已存在的视图、组建复合的控件以及创建独特的新视图(定制控件),可以创建出最适合自己的应用程序工作流的优美的用户界面。Android运行从已有的视图工具箱派生子类或实现自己的视图控件,从而可以自由调整用户界面。
创建新视图的最佳方法与希望达到的目标有关:
1. 如果现有控件已经可以满足希望实现的基本功能,那么就只需要对现有控件的外观和行为进行修改扩展即可。通过重写事件处理程序和onDraw方法,但是仍然回调超类的方法,可以对视图进行定制,而不必重现实现它的功能。
2. 可以通过组合多个视图来创建不可分割的、可重用的控件,从而使它可以综合使用多个相互关联的视图的功能。
3. 当需要一个全新的界面,而通过修改或组合现有控件不能实现该目标时,就可以创建一个全新的控件。
1 修改现有视图(继承Android SDK中的基本控件)
Android小组件工具箱包含的视图提供了很多创建UI必需的控件,但这些控件通常都是很通用的。要在一个已有控件的基础上创建一个新的视图,就需要创建一个扩展了原控件的新类。
要修改新视图的外观或者行为,只要重写和扩展与希望修改的行为相关的事件处理程序即可。
继承现有Android SDK中的控件,实现自定义功能。
一般需要覆写onDraw(),用以实现不一样的布局。
package com.demo.view; import android.content.Context; import android.content.res.Resources; import android.graphics.Canvas; import android.graphics.Paint; import android.util.AttributeSet; import android.view.KeyEvent; import android.widget.TextView; import com.demo.R; public class TextViewDesign extends TextView { private Paint mMarginPaint; private Paint mLinePaint; private int mPaperColor; private float mMargin; public TextViewDesign(Context context) { super(context); } public TextViewDesign(Context context, AttributeSet attrs) { super(context, attrs); // XML文件中引入时,调用2参数构造方法 initData(); } public TextViewDesign(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onDraw(Canvas canvas) { // 设置绘制页面的颜色 canvas.drawColor(mPaperColor); // 画item之前的垂直线 canvas.drawLine(0, 0, 0, getMeasuredHeight(), mLinePaint); // 画item之间的分割线 canvas.drawLine(0, getMeasuredHeight(), getMeasuredWidth(), getMeasuredHeight(), mLinePaint); // 画文字前的垂直线 canvas.drawLine(mMargin, 0, mMargin, getMeasuredHeight(), mMarginPaint); canvas.save(); // 画布平移 canvas.translate(mMargin, 0); // 使用TextView基类渲染文本 super.onDraw(canvas); canvas.restore(); } @Override public boolean onKeyDown(int keyCode, KeyEvent event) { // 使用TextView基类所实现的功能来响应键盘事件 return super.onKeyDown(keyCode, event); } /** * <功能描述> 初始化自定义控件数据 * * @return void [返回类型说明] */ private void initData() { // 获得对资源表的引用 Resources contentResources = getResources(); mMarginPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mMarginPaint .setColor(contentResources.getColor(R.color.notepad_margin)); mLinePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mLinePaint.setColor(contentResources.getColor(R.color.notepad_lines)); mPaperColor = contentResources.getColor(R.color.notepad_paper); mMargin = contentResources.getDimension(R.dimen.notepad_margin); } }
实现更改后的视图如下:
对比之前的实现UI:
自定义之后的UI视图,有了很大的区别。
2 创建复合控件(继承Android SDK中的布局)
复合控件是指不可分割的、自包含的视图组,其中包含了多个排列和连接在一起的子视图。
创建复合控件时,必须对它包含的视图的布局、外观和交互进行定义。复合控件是通过扩展一个ViewGroup(通常是一个布局)来创建的。因此,要创建一个新的复合控件,首先需要选择一个适合放置子控件的布局类,然后扩展该类。
package com.demo.view; import android.content.Context; import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.View; import android.widget.Button; import android.widget.EditText; import android.widget.LinearLayout; import com.demo.R; public class ClearableEditText extends LinearLayout { private EditText mEditText; private Button mBtnClear; public ClearableEditText(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } public ClearableEditText(Context context, AttributeSet attrs) { super(context, attrs); } public ClearableEditText(Context context) { super(context); initView(); } private void initView() { String infService = Context.LAYOUT_INFLATER_SERVICE; LayoutInflater li; li = (LayoutInflater) getContext().getSystemService(infService); li.inflate(R.layout.clearable_edit, this, true); mEditText = (EditText) findViewById(R.id.et_content); mBtnClear = (Button) findViewById(R.id.btn_clear); hookupButton(); } /** * <功能描述> 清除EditText内容 * * @return void [返回类型说明] */ private void hookupButton() { mBtnClear.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { mEditText.setText(""); } }); } }
上述的ViewGroup一般是一个布局:Layout子类,同时接受一个.xml布局文件。
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" > <EditText android:id="@+id/et_content" android:layout_width="match_parent" android:layout_height="wrap_content" /> <Button android:id="@+id/btn_clear" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Clear" /> </LinearLayout>
3 创建定制的视图
创建全新的视图将从根本上决定应用程序的样式以及观感的能力。通过创建自己的控件,可以创建出满足自己需求的独特的UI。
要在一个空画布上创建新的控件,就需要对View类或者SurfaceView类进行扩展。View类提供了一个Canvas对象和一系列绘制方法以及Paint类。之后,可以重写像屏幕触摸或者按键按下这样的用户事件以提供交互。
在那些不要求3D图像和极快地重新回执界面的情况下,View基类提供了一个强大的轻量级解决方案。
SurfaceView提供了一个支持从后台线程绘制并且可以使用OpenGL来绘制图形的Surface对象。对于那些对图形要求很高的控件,特别是游戏和3D可视化来说是一个很好的解决方案。
View基类呈现出第一个清晰的100*100像素的空白正方形。要改变控件的大小并呈现出一个更加吸引人的可视界面,就需要分别对onMeasure和onDraw方法进行重写。onMeasure方法中,新的视图将会计算出一系列给定的边界条件下占据的高度和宽度;onDraw用于在画布上绘图。
3.1. 创建新的可视视图
创建继承View的类,并在其中覆写相关的方法,比如:onMeasure()、onDraw()等
3.2. 绘制控件
onDraw()是绘制控件的地方。如果想要创建一个全新的可视界面,那么可以尝试从头创建一个新的小组件。onDraw方法中的Canvas参数就是用来进行绘制的表面(画布)。
可以使用绘制的工具类包括:Canvas、Paint、Drawable,控件可悲渲染的复杂度和细节会受到屏幕的大小和渲染它的处理器的能力的限制。
在Android中编写高效代码的最重要的技术之一是避免重复地创建和销毁对象。在onDraw方法中创建的任何对象都会在屏幕刷新的时候被创建和销毁。可以通过将尽可能多的这样的对象的作用域限定为类作用域,并将它们的创建过程交给构造函数来完成,以此来提高效率。
3.3. 调整控件大小
除非所要求的控件总是恰好占据100*100像素,否则将需要重写onMeasure()。
当控件的父容器布局它的子控件的时候,就会调用onMeasure()。提出“你需要使用多大的空间?”,同时传入两个参数:widthMeasureSpec和heightMeasureSpec。这两个参数指定了控件可用的控件以及一些描述这些空间的元数据。
最后把视图的高度和宽度传递给setMeasuredDimension()中。
package com.demo.view; import android.content.Context; import android.graphics.Canvas; import android.util.AttributeSet; import android.view.View; public class DesignView extends View { public DesignView(Context context, AttributeSet attrs, int defStyleAttr) { // 使用资源文件进行填充时必需的构造函数 super(context, attrs, defStyleAttr); } public DesignView(Context context, AttributeSet attrs) { // 使用资源文件进行填充时必需的构造函数 super(context, attrs); } public DesignView(Context context) { // 使用代码进行创建时必需的构造函数 super(context); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int measuredHeight = measureHeight(widthMeasureSpec); int measuredWidth = measureWidth(heightMeasureSpec); // 必需调用该方法 setMeasuredDimension(measuredWidth, measuredHeight); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); } /** * <功能描述> 解码参数值,并计算View高度 * * @param measureSpec * @return [参数说明] * @return int [返回类型说明] */ private int measureHeight(int measureSpec) { int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); /** * 计算View的高度 */ return specSize; } /** * <功能描述> 解码参数值,并计算View宽度 * * @param measureSpec * @return [参数说明] * @return int [返回类型说明] */ private int measureWidth(int measureSpec) { int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); /** * 计算View的高度 */ return specSize; } }
从效率的角度来考虑,边界参数widthMeasureSpec和heightMeasureSpec是作为整数传入的。
AT_MOST表示的是:控件可用的最大空间;说明父控件正在询问视图在给定上界的情况下希望占据的空间的大小。
EXACTLY:控件占据的确切大小;说明视图被放置到了指定确切大小的空间中。
UNSPECIFIED:控件没有得到任何关于size所代表的引用。
3.4. 处理用户交互事件
要使新视图是可交互的,就需要让View能够对用户事件作出反应,例如按下按键、触摸屏幕等等。Android提供了多个虚拟事件处理程序,可以对用户输入作出反应。
@Override public boolean onKeyDown(int keyCode, KeyEvent event) { // 如果事件得到处理,则返回true return super.onKeyDown(keyCode, event); } @Override public boolean onKeyUp(int keyCode, KeyEvent event) { // 如果事件得到处理,则返回true return super.onKeyUp(keyCode, event); } @Override public boolean onTrackballEvent(MotionEvent event) { // 获得事件所代表的类型 int actionPerformed = event.getAction(); // 如果事件得到处理,则返回true return super.onTrackballEvent(event); } @Override public boolean onTouchEvent(MotionEvent event) { // 获得事件所代表的类型 int actionPerformed = event.getAction(); // 如果事件得到处理,则返回true return super.onTouchEvent(event); }
实例分析:创建一个罗盘View
Android SDK提供的原生控件中没有该View,需要自定义或是定制View。
package com.demo.view; import android.content.Context; import android.content.res.Resources; import android.graphics.Canvas; import android.graphics.Paint; import android.util.AttributeSet; import android.view.View; import com.demo.LogUtil; import com.demo.R; public class CompassView extends View { private static final String TAG = CompassView.class.getSimpleName(); private float mBearing; private Paint mMarkerPaint; private Paint mTextPaint; private Paint mCirclePaint; private String mNorthString; private String mEastString; private String mSouthString; private String mWestString; private int mTextHeight; public CompassView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); initCompassView(); } public CompassView(Context context, AttributeSet attrs) { super(context, attrs); initCompassView(); } public CompassView(Context context) { super(context); initCompassView(); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // Compass需要占据尽可能多的空间,通过设置最短的边界、高度或者宽度来设置测量的尺寸 int measuredWidth = measure(widthMeasureSpec); int measuredHeight = measure(heightMeasureSpec); // 设置最短的边界 int d = Math.min(measuredWidth, measuredHeight); setMeasuredDimension(d, d); LogUtil.d(TAG, "onMeasure::measuredWidth=" + measuredWidth + "; measuredHeight=" + measuredHeight + "; d=" + d); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int width = getMeasuredWidth(); int height = getMeasuredHeight(); LogUtil.d(TAG, "onDraw::width=" + width + "; height=" + height); int px = width / 2; int py = height / 2; int radius = Math.min(px, py); // 绘制罗盘边界,为背景着色 canvas.drawCircle(px, py, radius, mCirclePaint); // 绘制旋转,让当前方向总是指向设备顶部 canvas.rotate(-mBearing, px, py); // 返回字符串的宽度 int textWidth = (int) mTextPaint.measureText("W"); LogUtil.d(TAG, "textWidth=" + textWidth); // 11 int cardinalX = px - textWidth / 2; // 265 int cardinalY = py - radius + mTextHeight; // 13 LogUtil.d(TAG, "onDraw::cardinalX=" + cardinalX + "; cardinalY=" + cardinalY); for (int i = 0; i < 24; i++) { // 将360等份平均分为24份,旋转24次(每份15°) // 画标尺线 canvas.drawLine(px, py - radius, px, py - radius + 10, mMarkerPaint); canvas.save(); // Y方向平移 canvas.translate(0, mTextHeight); if (i % 6 == 0) { // 每旋转90° String dirString = ""; switch (i) { case 0: { // 正北 dirString = mNorthString; int arrowY = 2 * mTextHeight; // 画箭头 canvas.drawLine(px, arrowY, px - 5, 3 * mTextHeight, mMarkerPaint); canvas.drawLine(px, arrowY, px + 5, 3 * mTextHeight, mMarkerPaint); } break; case 6: { // 正东 dirString = mEastString; } break; case 12: { // 正南 dirString = mSouthString; } break; case 18: { // 正西 dirString = mWestString; } break; default: break; } // 写方向标识 canvas.drawText(dirString, cardinalX, cardinalY, mTextPaint); } else if (i % 3 == 0) { // 每旋转45° String angle = String.valueOf(i * 15); float angleTextWidth = mTextPaint.measureText(angle); int angleTextX = (int) (px - angleTextWidth / 2); int angleTextY = py - radius + mTextHeight; // 写角度标识 canvas.drawText(angle, angleTextX, angleTextY, mTextPaint); } canvas.restore(); canvas.rotate(15, px, py); } canvas.restore(); } public void setBearing(float _bearing) { mBearing = _bearing; } public float getBearing() { return mBearing; } /** * <功能描述> 计算尺寸 * * @param measureSpec * @return [参数说明] * @return int [返回类型说明] */ private int measure(int measureSpec) { int result = 0; // 解析参数 int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); if (specMode == MeasureSpec.UNSPECIFIED) { // 没有指定界限,返回默认值200 result = 200; } else { // 希望填充可用控件,返回整个可用空间 result = specSize; } LogUtil.d(TAG, "measure::result=" + result); return result; } /** * <功能描述> 初始化View * * @return void [返回类型说明] */ private void initCompassView() { setFocusable(true); Resources res = this.getResources(); mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG); mCirclePaint.setColor(res.getColor(R.color.background_color)); mCirclePaint.setStrokeWidth(1); mCirclePaint.setStyle(Paint.Style.FILL_AND_STROKE); mNorthString = res.getString(R.string.cardinal_north); mEastString = res.getString(R.string.cardinal_east); mWestString = res.getString(R.string.cardinal_west); mSouthString = res.getString(R.string.cardinal_south); mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mTextPaint.setColor(res.getColor(R.color.text_color)); mTextHeight = (int) mTextPaint.measureText("yY"); LogUtil.d(TAG, "initCompassView::mTextHeight=" + mTextHeight); // 13 mMarkerPaint = new Paint(Paint.ANTI_ALIAS_FLAG); mMarkerPaint.setColor(res.getColor(R.color.marker_color)); } }
定制View的使用:
<FrameLayout 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" tools:context="com.demo.CompassActivity" > <com.demo.view.CompassView android:id="@+id/view_compass" android:layout_width="match_parent" android:layout_height="match_parent" /> </FrameLayout>
package com.demo; import android.app.Activity; import android.os.Bundle; import com.demo.view.CompassView; public class CompassActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.main); CompassView compassView = (CompassView) findViewById(R.id.view_compass); compassView.setBearing(45); } }
当前指向的方向为:N-->E偏角为45°
总结:View的定制,是在Canvas中一步步绘制出来的。
疑问:rotate()和drawLine()的冲突问题。