Android给我们提供了丰富的组件库来创建丰富的UI效果,同时也提供了非常方便的拓展方法,有如下三种方式:
除此之外,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
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通常需要重写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>
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); }