Android、View视图与坐标系
View的滑动和属性动画
从源码解析View的事件分发机制
View的工作流程
Android自定义view
这种自定义View在系统控件的基础上进行拓展,一般是添加新的功能或者修改显示的效果,一般情况下我们在onDraw()方法中进行处理。这里举一个简单的例子:
public class InvalidTextView extends TextView {
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
public InvalidTextView(Context context) {
super(context);
initDraw();
}
public InvalidTextView(Context context, AttributeSet attrs) {
super(context, attrs);
initDraw();
}
public InvalidTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initDraw();
}
private void initDraw() {
mPaint.setColor(Color.RED);
mPaint.setStrokeWidth((float) 1.5);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
canvas.drawLine(0, height / 2, width, height / 2, mPaint);
}
}
这个自定义View继承TextView,并且在onDraw()方法中画了一条红色的横线,接下来在布局中引用这个InvalidTextView:
<com.InvalidTextView
android:id="@+id/iv_text"
android:layout_width="200dp"
android:layout_height="100dp"
android:background="@android:color/holo_blue_light"
android:gravity="center"
android:textSize="16sp"
android:layout_centerHorizontal="true" />
与上面的继承系统控件的自定义View不同,继承View的自定义View实现起来要稍微复杂一些,不只是要实现onDraw()方法,而且在实现过程中还要考虑到wrap_content属性以及padding属性的设置;为了方便配置自己的自定义View还会对外提供自定义的属性,另外如果要改变触控的逻辑,还要重写onTouchEvent()等触控事件的方法。
按照上面的例子我们再写一个RectView类继承View来画一个正方形:
public class RectView extends View {
private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private int mColor=Color.RED;
public RectView(Context context) {
super(context);
initDraw();
}
public RectView(Context context, AttributeSet attrs) {
super(context, attrs);
initDraw();
}
public RectView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initDraw();
}
private void initDraw() {
mPaint.setColor(mColor);
mPaint.setStrokeWidth((float) 1.5);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
canvas.drawRect(0, 0, width, height, mPaint);
}
}
在布局中引用RectView:
<com.RectView
android:id="@+id/rv_rect"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_below="@id/iv_text"
android:layout_marginTop="50dp"
android:layout_centerHorizontal="true"/>
如果我在布局文件中设置pading属性,发现没有任何的作用,看来还得对padding属性进行处理,只需要在onDraw()方法中稍加修改就可以了,在绘制正方形的时候考虑到padding属性就可以了:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int paddingLeft=getPaddingLeft();
int paddingRight=getPaddingRight();
int paddingTop=getPaddingTop();
int paddingBottom=getPaddingBottom();
int width = getWidth()-paddingLeft-paddingRight;
int height = getHeight()-paddingTop-paddingBottom;
canvas.drawRect(0+paddingLeft, 0+paddingTop, width+paddingRight, height+paddingBottom, mPaint);
}
修改布局文件加入padding属性:
<com.RectView
android:id="@+id/rv_rect"
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_below="@id/iv_text"
android:layout_marginTop="50dp"
android:layout_centerHorizontal="true"
android:padding="10dp"/>
修改布局文件,发现RectView的宽度分别为wrap_content和match_parent效果都是一样的。从View的源码上分析:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
对于这种情况需要我们在onMeasure()方法中指定一个默认的宽和高,在设置wrap_content属性时设置此默认的宽和高就可以了:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int widthSpecSize=MeasureSpec.getSize(widthMeasureSpec);
int heightSpecSize=MeasureSpec.getSize(heightMeasureSpec);
if(widthSpecMode==MeasureSpec.AT_MOST&&heightSpecMode==MeasureSpec.AT_MOST){
setMeasuredDimension(400,400);
}else if(widthSpecMode==MeasureSpec.AT_MOST){
setMeasuredDimension(400,heightSpecSize);
}else if(heightSpecMode==MeasureSpec.AT_MOST){
setMeasuredDimension(widthSpecSize,400);
}
}
android系统的控件以android开头的比如android:layout_width,这些都是系统自带的属性,为了方便配置RectView的属性,我们也可以自定义属性,首先在values目录下创建 attrs.xml:
这个配置文件定义了名为RectView的自定义属性组合,我们定义了rect_color属性,它的格式为color,接下来在RectView的构造函数中解析自定义属性的值:
public RectView(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray mTypedArray=context.obtainStyledAttributes(attrs,R.styleable.RectView);
//提取RectView属性集合的rect_color属性,如果没设置默认值为Color.RED
mColor=mTypedArray.getColor(R.styleable.RectView_rect_color,Color.RED);
//获取资源后要及时回收
mTypedArray.recycle();
initDraw();
}
在布局里使用
使用自定义属性需要添加schemas: xmlns:app=”http://schemas.android.com/apk/res-auto",其中app是 我们自定义的名字,最后我们配置新定义的app:rect_color属性为android:color/holo_blue_light
自定义组合控件就是多个控件组合起来成为一个新的控件,主要用来解决多次重复的使用同一类型的布局。
我们现在就自定义一个顶部的标题栏,当然实现标题栏有很多的方法,我们来看看自定义组合控件如何去实现。首先我们先定义我们组合控件的布局(view_customtitle.xml):
很简单的布局,左右边各一个图标,中间是标题文字。
接下来我们写java代码,因为我们的组合控件整体布局是RelativeLayout,所以我们的组合控件要继承RelativeLayout:
public class TitleBar extends RelativeLayout {
private ImageView iv_titlebar_left;
private ImageView iv_titlebar_right;
private TextView tv_titlebar_title;
private RelativeLayout layout_titlebar_rootlayout;
private int mColor= Color.BLUE;
private int mTextColor= Color.WHITE;
public TitleBar(Context context) {
super(context);
initView(context);
}
public TitleBar(Context context, AttributeSet attrs) {
super(context, attrs);
initView(context);
}
public TitleBar(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initView(context);
}
public void initView(Context context){
LayoutInflater.from(context).inflate(R.layout.view_customtitle, this, true);
iv_titlebar_left= (ImageView) findViewById(R.id.iv_titlebar_left);
iv_titlebar_right= (ImageView) findViewById(R.id.iv_titlebar_right);
tv_titlebar_title= (TextView) findViewById(R.id.tv_titlebar_title);
layout_titlebar_rootlayout= (RelativeLayout) findViewById(R.id.layout_titlebar_rootlayout);
//设置背景颜色
layout_titlebar_rootlayout.setBackgroundColor(mColor);
//设置标题文字颜色
tv_titlebar_title.setTextColor(mTextColor);
}
public void setTitle(String titlename){
if(!TextUtils.isEmpty(titlename)) {
tv_titlebar_title.setText(titlename);
}
}
public void setLeftListener(OnClickListener onClickListener){
iv_titlebar_left.setOnClickListener(onClickListener);
}
public void setRightListener(OnClickListener onClickListener){
iv_titlebar_right.setOnClickListener(onClickListener);
}
}
重写了三个构造方法并在构造方法中加载布局文件,对外提供了三个方法,分别用来设置标题的名字,和左右按钮的点击事件。
我们定义了三个属性,分别用来设置顶部标题栏的背景颜色、标题文字颜色和标题文字。为了引入自定义属性我们需要在TitleBar的构造函数中解析自定义属性的值:
public TitleBar(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray mTypedArray=context.obtainStyledAttributes(attrs,R.styleable.TitleBar);
mColor=mTypedArray.getColor(R.styleable.TitleBar_title_bg,Color.BLUE);
mTextColor=mTypedArray.getColor(R.styleable.TitleBar_title_text_color, Color.WHITE);
titlename=mTypedArray.getString(R.styleable.TitleBar_title_text);
//获取资源后要及时回收
mTypedArray.recycle();
initView(context);
}
在主界面调用我们自定义的TitleBar,并设置了左右两遍按钮的点击事件:
public class MainActivity extends Activity {
private TitleBar mTitleBar;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mTitleBar= (TitleBar) this.findViewById(R.id.title);
// mTitleBar.setTitle("自定义组合控件");
mTitleBar.setLeftListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(MainActivity.this, "点击左键", Toast.LENGTH_SHORT).show();
}
});
mTitleBar.setRightListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(MainActivity.this, "点击右键", Toast.LENGTH_SHORT).show();
}
});
}
}
以下的例子是一个自定义的ViewGroup,左右滑动切换不同的页面,类似一个特别简化的ViewPager,这篇文章会涉及到这个系列的很多文章的内容比如View的measure、layout和draw流程,view的滑动等等。
public class HorizontalView extends ViewGroup {
public HorizontalView(Context context) {
super(context);
}
public HorizontalView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public HorizontalView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
measureChildren(widthMeasureSpec, heightMeasureSpec);
//如果没有子元素,就设置宽高都为0(简化处理)
if (getChildCount() == 0) {
setMeasuredDimension(0, 0);
}
//宽和高都是AT_MOST,则设置宽度所有子元素的宽度的和;高度设置为第一个元素的高度;
else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
View childOne = getChildAt(0);
int childWidth = childOne.getMeasuredWidth();
int childHeight = childOne.getMeasuredHeight();
setMeasuredDimension(childWidth * getChildCount(), childHeight);
}
//如果宽度是wrap_content,则宽度为所有子元素的宽度的和
else if (widthMode == MeasureSpec.AT_MOST) {
int childWidth = getChildAt(0).getMeasuredWidth();
setMeasuredDimension(childWidth * getChildCount(), heightSize);
}
//如果高度是wrap_content,则高度为第一个子元素的高度
else if (heightMode == MeasureSpec.AT_MOST) {
int childHeight = getChildAt(0).getMeasuredHeight();
setMeasuredDimension(widthSize, childHeight);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
}
}
这里如果没有子元素时采用了简化的写法直接将宽和高直接设置为0,正常的话我们应该根据LayoutParams中的宽和高来做相应的处理,另外我们在测量时没有考虑它的padding和子元素的margin。
接下来我们实现onLayout,来布局子元素,因为每一种布局方式子View的布局都是不同的,所以这个是ViewGroup唯一一个抽象方法,需要我们自己去实现:
public class HorizontalView extends ViewGroup {
//... 省略构造方法代码和onMeasure的代码
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int left = 0;
View child;
for (int i = 0; i < childCount; i++) {
child = getChildAt(i);
if (child.getVisibility() != View.GONE) {
int width = child.getMeasuredWidth();
childWidth = width;
child.layout(left, 0, left + width, child.getMeasuredHeight());
left += width;
}
}
}
遍历所有的子元素,如果子元素不是GONE,则调用子元素的layout方法将其放置到合适的位置上,相当于默认第一个子元素占满了屏幕,后面的子元素就是在第一个屏幕后面紧挨着和屏幕一样大小的后续元素,所以left是一直累加的,top保持0,bottom保持第一个元素的高度,right就是left+元素的宽度,同样这里没有处理自身的pading以及子元素的margin。
这个自定义ViewGroup是水平滑动,如果里面是ListView,则ListView是垂直滑动,如果我们检测到的滑动方向是水平的话,就让父View拦截用来进行View的滑动切换 :
public class HorizontalView extends ViewGroup {
private int lastInterceptX;
private int lastInterceptY;
private int lastX;
private int lastY;
//... 省略了构造函数的代码
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercept = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - lastInterceptX;
int deltaY = y - lastInterceptY;
//用户想水平滑动的,所以拦截
if (Math.abs(deltaX) - Math.abs(deltaY) > 0) {
intercept = true;
}
break;
case MotionEvent.ACTION_UP:
break;
}
lastX = x;
lastY = y;
lastInterceptX = x;
lastInterceptY = y;
return intercept;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
return super.onTouchEvent(event);
}
//... 省略了onMeasure和onLayout的代码
}
这里就会进入onTouchEvent事件,然后我们需要进行滑动切换页面,这里需要用到Scroller。
public class HorizontalView extends ViewGroup {
//... 省略构造函数,init方法,onInterceptTouchEvent
int lastInterceptX;
int lastInterceptY;
int lastX;
int lastY;
int currentIndex = 0; //当前子元素
int childWidth = 0;
private Scroller scroller;
@Override
public boolean onTouchEvent(MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - lastX; //跟随手指滑动
scrollBy(-deltaX, 0);
break;
case MotionEvent.ACTION_UP:
//相对于当前View滑动的距离,正为向左,负为向右
int distance = getScrollX() - currentIndex * childWidth;
//滑动的距离要大于1/2个宽度,否则不会切换到其他页面
if (Math.abs(distance) > childWidth / 2) {
if (distance > 0) {
currentIndex++;
} else {
currentIndex--;
}
}
smoothScrollTo(currentIndex * childWidth, 0);
break;
}
lastX = x;
lastY = y;
return super.onTouchEvent(event);
}
//...省略onMeasure方法
@Override
public void computeScroll() {
super.computeScroll();
if (scroller.computeScrollOffset()) {
scrollTo(scroller.getCurrX(), scroller.getCurrY());
postInvalidate();
}
}
//弹性滑动到指定位置
public void smoothScrollTo(int destX, int destY) {
scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(),
destY - getScrollY(), 1000);
invalidate();
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int left = 0;
View child;
//遍历布局子元素
for (int i = 0; i < childCount; i++) {
child = getChildAt(i);
int width = child.getMeasuredWidth();
//赋值为子元素的宽度
childWidth = width;
child.layout(left, 0, left + width, child.getMeasuredHeight());
left += width;
}
}
}
我们不只滑动超过一半才切换到上/下一个页面,如果滑动速度很快的话,我们也可以判定为用户想要滑动到其他页面,这样的体验也是好的。 这部分也是在onTouchEvent中的ACTION_UP部分:
这里又需要用到VelocityTracker,它用来测试滑动速度的。使用方法也很简单,首先在构造函数中进行初始化,也就是前面的init方法中增加一条语句:
...
private VelocityTracker tracker;
...
public void init() {
scroller = new Scroller(getContext());
tracker=VelocityTracker.obtain();
}
...
接着改写onTouchEvent部分:
@Override
public boolean onTouchEvent(MotionEvent event) {
...
case MotionEvent.ACTION_UP:
//相对于当前View滑动的距离,正为向左,负为向右
int distance = getScrollX() - currentIndex * childWidth;
//必须滑动的距离要大于1/2个宽度,否则不会切换到其他页面
if (Math.abs(distance) > childWidth / 2) {
if (distance > 0) {
currentIndex++;
} else {
currentIndex--;
}
}
else {
//调用该方法计算1000ms内滑动的平均速度
tracker.computeCurrentVelocity(1000);
float xV = tracker.getXVelocity(); //获取到水平方向上的速度
//如果速度的绝对值大于50的话,就认为是快速滑动,就执行切换页面
if (Math.abs(xV) > 50) {
//大于0切换上一个页面
if (xV > 0) {
currentIndex--;
//小于0切换到下一个页面
} else {
currentIndex++;
}
}
}
currentIndex = currentIndex < 0 ? 0 : currentIndex > getChildCount() - 1 ?
getChildCount() - 1 : currentIndex;
smoothScrollTo(currentIndex * childWidth, 0);
//重置速度计算器
tracker.clear();
break;
}
当我们快速向左滑动切换到下一个页面的情况,在手指释放以后,页面会弹性滑动到下一个页面,可能需要一秒才完成滑动,这个时间内,我们再次触摸屏幕,希望能拦截这次滑动,然后再次去操作页面。
要实现在弹性滑动过程中再次触摸拦截,肯定要在onInterceptTouchEvent中的ACTION_DOWN中去判断,如果在ACTION_DOWN的时候,scroller还没有完成,说明上一次的滑动还正在进行中,则直接中断scroller:
...
@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
boolean intercept = false;
int x = (int) event.getX();
int y = (int) event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
intercept = false;
//如果动画还没有执行完成,则打断
if (!scroller.isFinished()) {
scroller.abortAnimation();
}
break;
case MotionEvent.ACTION_MOVE:
int deltaX = x - lastInterceptX;
int deltaY = y - lastInterceptY;
if (Math.abs(deltaX) - Math.abs(deltaY) > 0) {
intercept = true;
} else {
intercept = false;
}
break;
case MotionEvent.ACTION_UP:
intercept = false;
break;
}
//因为DOWN返回false,所以onTouchEvent中无法获取DOWN事件,所以这里要负责设置lastX,lastY
lastX = x;
lastY = y;
lastInterceptX = x;
lastInterceptY = y;
return intercept;
}
...
首先我们在主布局中引用HorizontalView,它作为父容器,里面有两个ListView:
以上内容摘自《Android进阶之光》