自定义控件
Android给我们提供了丰富的组件库来创建丰富的UI效果,同时也提供了非常方便的拓展方法,有如下三种方式:
- 对现有控件进行拓展
- 通过组合控件来实现新控件
- 重写View来实现新的控件
除此之外,Android系统还提供给我们很多非常方便的回调方法,具体方法如下表所示:
方法 | 描述 |
---|---|
onFinishInflate() | 从XML加载组件后回调。 |
onSizeChanged() | 组件大小改变时回调。 |
onMeasure() | 回调该方法进行测量。 |
onLayout() | 回调该方法来确定显示的位置。 |
onTouchEvent() | 监听到触摸事件时回调。 |
其中的View的回调顺序如下:
onFinishInflate -> onMeasure() -> onMeasure() -> onSizeChange() -> onLayout() -> onMeasure() -> onMeasure() -> onLayout() -> onDraw()
当执行到onDraw()时,会一直调用onDraw()方法进行绘制。
原生控件拓展
修改原有控件我们只需要创建一个类继承系统存在的组件,然后在原有的逻辑上添加自己的实现即可。
比如,我们想让一个TextView的背景更加丰富,可以给其多增加几层背景:
package com.legend.demo;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.widget.TextView;
public class MyTextView extends TextView {
private Paint mPaint;
private Paint mPaint1;
private int padding = 10;
public MyTextView(Context context) {
super(context);
initPath();
}
public MyTextView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initPath();
}
private void initPath() {
// 创建外层矩形画笔
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setColor(getResources().getColor(android.R.color.holo_blue_light));
mPaint.setStyle(Paint.Style.FILL);
// 创建内层矩形画笔画笔
mPaint1 = new Paint();
mPaint1.setAntiAlias(true);
mPaint1.setColor(Color.MAGENTA);
mPaint1.setStyle(Paint.Style.FILL);
}
@Override
protected void onDraw(Canvas canvas) {
/*在回调父方法前,实现自己的逻辑*/
// 绘制外层矩形
canvas.drawRect(0, 0, getMeasuredWidth(), getMeasuredHeight(), mPaint);
// 绘制内层矩形
canvas.drawRect(padding, padding, getMeasuredWidth() - padding, getMeasuredHeight() - padding, mPaint1);
// 绘制文字前平移10像素
canvas.translate(padding, 0);
canvas.restore();
super.onDraw(canvas);
}
}
注:绘制的时候,实现的绘制逻辑必须在回调父方法之前,如果在回调父方法之后的话,那么实现的绘制逻辑将在绘制文本内容后。
复合控件
创建复合控件可以很好地创建出具有重用功能的控件集合,这种方式需要继承一个合适的ViewGroup,再添加指定功能的控件而组合成新的控件。
a) 我们首先制定好需要组合的控件,用它来达到我们想要的效果:
<RelativeLayout
android:id="@+id/rl_viewgroup"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/tv_content"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginTop="5dp"
android:textSize="20sp"
android:text="我是标题"/>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="10dp"
android:layout_marginTop="5dp"
android:layout_below="@id/tv_content"
android:textColor="@android:color/darker_gray"
android:text="我是未被选中的描述"/>
<CheckBox
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:layout_marginRight="10dp"/>
</RelativeLayout>
b) 在values目录下创建attrs.xml文件,并定义好属性:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name = "combinationView">
<attr name = "title" format = "string"/>
<attr name = "content" format = "string"/>
<attr name = "focusable" format = "boolean"/>
</declare-styleable>
</resources>
c) 创建自定义控件类继承自ViewGroup,并实现带attrs的构造函数,再使用TypeArray来获取属性:
public class TextViewCheckBox extends RelativeLayout {
private TextView mTvTitle,mTvContent;
private CheckBox mCbClick;
private String mTitle,mContentOn,mContentOff;
public TextViewCheckBox(Context context) {
this(context,null);
}
public TextViewCheckBox(Context context, AttributeSet attrs) {
super(context, attrs);
// 初始化布局和控件
View view = View.inflate(context, R.layout.ui_text_checkbox, this);
mTvTitle = (TextView) view.findViewById(R.id.tv_title);
mTvContent = (TextView) view.findViewById(R.id.tv_content);
mCbClick = (CheckBox) view.findViewById(R.id.cb_click);
// 将attrs.xml中定义的所有属性的值存储到TypeArray中
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.combinationView);
mTitle = array.getString(R.styleable.combinationView_title);
mContentOn = array.getString(R.styleable.combinationView_content_on);
mContentOff = array.getString(R.styleable.combinationView_content_off);
array.recycle();
// 初始化子控件描述和状态
if(mTitle != null){
mTvTitle.setText(mTitle);
}
if(mContentOff != null){
mTvContent.setText(mContentOff);
}
}
}
d) 暴露方法给调用者来设置描述和状态:
/**判断是否被选中*/
public boolean isChecked(){
return mCbClick.isChecked();
}
/**设置选中的状态*/
public void setChecked(boolean isChecked){
mCbClick.setChecked(isChecked);
if(isChecked){
mTvContent.setText(mContentOn);
}else{
mTvContent.setText(mContentOff);
}
}
e) 在布局中引用该控件,引入名称空间,并设置自定义的属性。
<cn.legend.review.TextViewCheckBox
android:id="@+id/tvc_textchecked"
android:layout_width="match_parent"
android:layout_height="wrap_content"
review:title="我是标题"
review:content_on = "控件被选中"
review:content_off = "控件没有选中"/>
注意:在使用自定义控件时需要引入名称空间:
xmlns:review="http://schemas.android.com/apk/res/cn.legend.review"
如果想让控件响应事件的话,则直接重写事件即可,如果用到了wrap_content或match_parent则需要进行测量等操作。
定义属性
为View自定义属性非常简单,只需要在res资源目录的values目录下创建attrs.xml的属性定义文件即可。
a)在res/values文件下定义一个attrs.xml文件,代码如下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 声明自定义属性集 -->
<declare-styleable name="ToolBar">
<!-- 通过name属性确定引用的名称 -->
<attr name="buttonNum" format="integer"/>
<attr name="itemBackground" format="reference|color"/>
</declare-styleable>
</resources>
其中format的取值如下表所示:其中不包括 位或运算 和 枚举。
属性 | 描述 |
---|---|
reference | 资源id的形式 |
color | 颜色值 |
boolean | 布尔值 |
dimension | 尺寸值 |
float | 浮点值 |
integer | 整型值 |
string | 字符串 |
fraction | 百分数 |
b)系统提供了TypeArray这样的数据结构来获取自定义属性集合,通过该对象的getString()、getColor()等方法来获取属性值。
// 第一种方式
TypedArray array = context.obtainStyledAttributes(attrs,R.styleable.ToolBar);
int buttonNum = array.getInt(R.styleable.ToolBar_buttonNum, 5);
int itemBg = array.getResourceId(R.styleable.ToolBar_itemBackground, -1);
array.recycle();
// 第二种方式
TypedArray array = context.obtainStyledAttributes(attrs,R.styleable.ToolBar);
int count = array.getIndexCount();
for (int i = 0; i < count; i++) {
int attr = array.getIndex(i);
switch (attr) {
case R.styleable.ToolBar_buttonNum:
int buttonNum = array.getInt(attr, 5);
break;
case R.styleable.ToolBar_itemBackground:
int itemBg = array.getResourceId(attr, -1);
break;
}
}
注:获取属性后记得调用recycle()方法释放资源,然后就是在Android Studio中控件引用自定义属性需要添加名称空间:
xmlns:xx="http://schemas.android.com/apk/res-auto"
完全重写
当Android系统原生控件无法满足我们的需求时,可以通过继承View或ViewGroup的方式来实现需要的功能。
重写View
假如我们要实现静态音频条形图,该类因为没有子控件,所以我们创建一个类来继承View
public class MediaView extends View {
private int mRectCount = 12;
private int mRectWidth;
private int mRectHeight;
private int mWidth;
private double padding = 1;
private double mRandom;
private Paint mPaint;
private LinearGradient mLinearGradient;
public MediaView(Context context) {
super(context, null);
}
public MediaView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.BLUE);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setAntiAlias(true);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mWidth = getWidth();
// 计算矩形的宽度和高度
mRectWidth = (int)(mWidth * 0.6 / mRectCount);
mRectHeight = getHeight();
// 渐变效果
mLinearGradient = new LinearGradient(
0, 0, mRectWidth, mRectHeight, Color.YELLOW, Color.BLUE, Shader.TileMode.CLAMP);
mPaint.setShader(mLinearGradient);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 由于需要绘制矩形条,每个矩形都有空距
for (int i = 0; i < mRectCount; i++) {
canvas.drawRect(
(float)(mWidth * 0.2 + mRectWidth * i + padding), getCurrentHeight(),
(float)(mWidth * 0.2 + mRectWidth * (i + 1)), mRectHeight, mPaint);
}
// 每隔1秒重绘,显示动态效果
postInvalidateDelayed(1000);
}
// 生成随机距离top的高度
private float getCurrentHeight() {
mRandom = Math.random();
return (float)(mRectHeight * mRandom);
}
}
重写ViewGroup
自定义ViewGroup通常需要重写onMeasure() 和 onLayout()方法来对子控件进行测量和确定子控件的位置,重写onTouchEvent()方法增加响应事件。
SlidingMenu是一个ViewGroup,它由左侧菜单和右侧内容区域组成。
![img](file:///C:/Users/Legend/Documents/My Knowledge/temp/9adc03c7-cd33-4eec-ade0-c42e3fe92083/128/index_files/ec819056-0745-43ce-afd6-db93b51571ef.png)
我们首先实现左侧和右侧布局,然后通过include标签将左右侧布局放入到SlidingMenu中:
<com.legend.menu.SlidingMenu
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/left_menu"/>
<include layout="@layout/right_content"/>
</com.legend.menu.SlidingMenu>
编写ViewGrop
a) 首先创建类SlidingMenu继承ViewGroup,然后测量子View的大小,再指定子View的位置。
public class SlidingMenu extends ViewGroup {
private int mLeftWidth;
private View mLeftMenu;
private View mRightContent;
private int mDownx;
private int mDownY;
public SlidingMenu(Context context) {
super(context);
}
public SlidingMenu(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onFinishInflate() {
super.onFinishInflate();
// 获取子控件实例
mLeftMenu = getChildAt(0);
mRightContent = getChildAt(1);
mLeftWidth = mLeftMenu.getLayoutParams().width;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 测量左侧孩子
int leftWidthMeasureSpec = MeasureSpec.makeMeasureSpec(mLeftWidth, MeasureSpec.EXACTLY);
int rightWidthMeasureSpec = heightMeasureSpec;
mLeftMenu.measure(leftWidthMeasureSpec, rightWidthMeasureSpec);
// 测量内容View和父容器等宽高。
mRightContent.measure(widthMeasureSpec, heightMeasureSpec);
// 针对自己,表示测量结束
int measuredWidth = MeasureSpec.getSize(widthMeasureSpec);
int measuredHeight = MeasureSpec.getSize(heightMeasureSpec);
setMeasuredDimension(measuredWidth, measuredHeight);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
// 指定子View的位置
mLeftMenu.layout(-(mLeftMenu.getMeasuredWidth()), 0, 0, mLeftMenu.getMeasuredHeight());
mRightContent.layout(0, 0, mRightContent.getMeasuredWidth(), mRightContent.getMeasuredHeight());
}
@Override
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
mDownx = (int) event.getX();
mDownY = (int) event.getY();
break;
case MotionEvent.ACTION_MOVE:
int moveX = (int) event.getX();
int moveY = (int) event.getY();
//当从左往右滑动,窗体向左移动,坐标在减少,使用downX - moveX则刚好是负数
int diffX = mDownx - moveX;
// 获取窗体左上角坐标
int scrollX = getScrollX();
if(scrollX + diffX < -mLeftMenu.getMeasuredWidth()){
scrollTo(-mLeftMenu.getMeasuredWidth(), 0);
}else if(scrollX + diffX > 0){
scrollTo(0, 0);
}else{
scrollBy(diffX, 0);
}
// 重新记录坐标
mDownx = moveX;
mDownY = moveY;
break;
case MotionEvent.ACTION_UP:
break;
}
return true;
}
}
此时,我们已经完成SlidingMenu的基本操作,接下来讲解Android中的滑动事件。
滑动实现:
从左向右滑动屏幕,此时x坐标在不断变小,在up的时候,我们可以考虑两种情况:
- 当左上角坐标小于左侧菜单的宽度,此时左侧菜单已经出来一大半,我们就显示左侧菜单。
- 当左上角坐标大于左侧菜单的宽度,此时左侧菜单已经出来一小半,我们就显示内容区域。
case MotionEvent.ACTION_UP:
if(getScrollX() < -leftMenuView.getMeasuredWidth() / 2){
// 显示左侧部分
scrollTo(-leftMenuView.getMeasuredWidth(), 0);
}else{
// 显示右侧部分
scrollTo(0, 0);
}
break;
现在,已经大致实现了需求,但是滑动感觉非常的僵硬,我们需要让它实现缓慢滚动的效果,实现一个过渡(模拟滑动)。
我们在构造函数初始化的时候实例化Scroller对象
mScoller = new Scroller(context);
然后模拟数据变化
case MotionEvent.ACTION_UP:
if(getScrollX() < -leftMenuView.getMeasuredWidth() / 2){
// 显示左侧部分
//scrollTo(-leftMenuView.getMeasuredWidth(), 0);
int startX = getScrollX();
int startY = getScrollY();
int endX = -leftMenuView.getMeasuredWidth();
int endY = 0;
int dx = endX - startX;
int dy = endY - startY;
int duration = 500;
mScoller.startScroll(startX, startY, dx, dy, duration);
}else{
// 显示右侧部分
//scrollTo(0, 0);
int startX = getScrollX();
int startY = getScrollY();
int endX = 0;
int endY = 0;
int dx = endX - startX;
int dy = endY - startY;
int duration = 500;
mScoller.startScroll(startX, startY, dx, dy, duration);
}
invalidate();
break;
此时,发现运行起来并没有效果,因为此时只是模拟数据变化,我们还需要实现一个方法:
@Override
public void computeScroll() {
if(mScoller.computeScrollOffset()){ // 正在滚动中
scrollTo(mScoller.getCurrX(), 0);
invalidate();
}
}
事件分发:
当手指按下某个点时,最外侧最先获取到事件,然后一路往下传递给子View,最内侧如果响应,表示事件消费掉了。如果最内侧不响应,会以此向外侧进行传递。
此时,当焦点在左侧菜单的时候,水平滑动是无效的,因为此时左侧菜单获取到了焦点。我们希望焦点在左侧菜单时,水平滑动是有效的,则可以让SlidingMenu去
响应事件即可。所以在SlidingMenu的 方法中去判断是否是水平滑动,是则拦截掉事件。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action = ev.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
mDownX = (int) ev.getX();
mDownY = (int) ev.getY();
break;
case MotionEvent.ACTION_MOVE:
int moveX = (int) ev.getX();
int moveY = (int) ev.getY();
// 水平滑动
if(Math.abs(moveX - mDownX) > Math.abs(moveY - mDownY)){
return true;
}
break;
case MotionEvent.ACTION_UP:
break;
}
return super.onInterceptTouchEvent(ev);
}