参考 link.网站写的一篇博客;
View是Android所有控件的基类,同时ViewGroup也是继承自View,下面这张图可以直观了解。
Android中有两种坐标系,分别为Android坐标系和视图坐标系,首先我们先来看看Android坐标系。
在Android中,将屏幕的左上角的顶点作为Android坐标系的原点,这个原点向右是X轴正方向,原点向下是Y轴正方向。
1. View获取自身宽高
getHeight():获取View自身高度
getWidth():获取View自身宽度
2. View自身坐标
通过如下方法可以获得View到其父控件(ViewGroup)的距离:
getTop():获取View自身顶边到其父布局顶边的距离
getLeft():获取View自身左边到其父布局左边的距离
getRight():获取View自身右边到其父布局左边的距离
getBottom():获取View自身底边到其父布局顶边的距离
3. MotionEvent提供的方法
getX():获取点击事件距离控件左边的距离,即视图坐标
getY():获取点击事件距离控件顶边的距离,即视图坐标
getRawX():获取点击事件距离整个屏幕左边距离,即绝对坐标
getRawY():获取点击事件距离整个屏幕顶边的的距离,即绝对坐标
六种滑动的方法,分别是:layout()、offsetLeftAndRight()与offsetTopAndBottom()、LayoutParams、动画、scollTo与scollBy和Scroller;
view进行绘制的时候会调用onLayout()方法来设置显示的位置,因此我们同样也可以通过修改View的left、top、right、bottom这四种属性来控制View的坐标。首先我们要自定义一个View,在onTouchEvent()方法中获取触摸点的坐标:
LayoutParams主要保存了一个View的布局参数,因此我们可以通过LayoutParams来改变View的布局的参数从而达到了改变View的位置的效果。同样的我们将ACTION_MOVE中的代码替换成如下代码:
LinearLayout.LayoutParams layoutParams= (LinearLayout.LayoutParams) getLayoutParams();
layoutParams.leftMargin = getLeft() + offsetX;
layoutParams.topMargin = getTop() + offsetY;
setLayoutParams(layoutParams);
可以采用View动画来移动,在res目录新建anim文件夹并创建translate.xml:
在Java代码中引用:
mCustomView.setAnimation(AnimationUtils.loadAnimation(this, R.anim.translate));
当然使用属性动画移动那就更简单了,我们让CustomView在1000毫秒内沿着X轴像右平移300像素:
ObjectAnimator.ofFloat(mCustomView,"translationX",0,300).setDuration(1000).start();
scollTo(x,y)表示移动到一个具体的坐标点,而scollBy(dx,dy)则表示移动的增量为dx、dy。其中scollBy最终也是要调用scollTo的。scollTo、scollBy移动的是View的内容,如果在ViewGroup中使用则是移动他所有的子View。我们将ACTION_MOVE中的代码替换成如下代码:
((View)getParent()).scrollBy(-offsetX,-offsetY);
这里要实现CustomView随着我们手指移动的效果的话,我们就需要将偏移量设置为负值。
我们用scollTo/scollBy方法来进行滑动时,这个过程是瞬间完成的,所以用户体验不大好。这里我们可以使用Scroller来实现有过度效果的滑动,这个过程不是瞬间完成的,而是在一定的时间间隔完成的。Scroller本身是不能实现View的滑动的,它需要配合View的computeScroll()方法才能弹性滑动的效果。
它提供了AlphaAnimation,RotateAnimation,TranslateAnimation,ScaleAnimation四种动画方式,并提供了AnimationSet动画集合来混合使用多中动画。随着android3.0属性动画的推出,视图动画就不再风光,相比属性动画视图动画非常大的缺陷是不具有交互性,当某个元素发生视图动画后,其响应事件的位置依然在动画前的地方,所以视图动画只能做普通的动画效果,避免交互的发生。但是它的优点也非常明显:效率比较高使用也方便。
由于android3.0之前已有的动画框架Animation存在一些局限性:动画改变的只是显示,并不能响应事件。因此,在android3.0之后,谷歌就推出了新的动画框架,帮助开发者实现更加丰富的动画效果。
在Animator框架中使用最多的就是AnimatorSet和ObjectAnimator配合,使用ObjectAnimator进行更精细化的控制,控制一个对象和一个属性值,而使用多个ObjectAnimator组合到AnimatorSet形成一个动画。属性动画通过调用属性get,set方法来真实地控制了一个View的属性值,因此强大的属性动画框架,基本可以实现所有的动画效果。
ObjectAnimator
ObjectAnimator是属性动画最重要的类,创建一个ObjectAnimator只需通过他的静态工厂类直接返还一个ObjectAnimator对象。参数包括一个对象和对象的属性名字,但这个属性必须有get和set函数,内部会通过java反射机制来调用set函数修改对象的属性值。
ValueAnimator
ValueAnimator不提供任何动画效果,它更像一个数值发生器,用来产生一定规律数字,从而让调用者来控制动画的实现过程。通常情况下,在ValueAnimator的AnimatorUpdateListener中监听数值的变化,从而完成动画的变换
组合动画-AnimatorSet
这个类提供了一个play()方法,如果我们向这个方法中传入一个Animator对象(ValueAnimator或ObjectAnimator)将会返回一个AnimatorSet.Builder的实例,AnimatorSet.Builder中包括以下四个方法:
after(Animator anim) 将现有动画插入到传入的动画之后执行
after(long delay) 将现有动画延迟指定毫秒后执行
before(Animator anim) 将现有动画插入到传入的动画之前执行
with(Animator anim) 将现有动画和传入的动画同时执行
自定义View按照笔者的划分,分为两大类,一种是自定义View,一种是自定义ViewGroup;其中自定义View又分为继承View和继承系统控件两种。这篇文章首先先了解下两大类的其中一种:自定义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_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);
}
}
要实现自定义的ViewGroup,首先要继承ViewGroup并调用父类构造方法,实现抽象方法等。
import android.content.Context;
import android.util.AttributeSet;
import android.view.ViewGroup;
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 onLayout(boolean changed, int l, int t, int r, int b) {
}
}
在Android View体系(九)自定义View这篇文章中我们同样对wrap_content属性进行了处理不明白的可以查看这篇文章或者直接查看Android View体系(七)从源码解析View的measure流程来了解具体的原因,这里就不赘述了。
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
public class HorizontalView extends ViewGroup {
//...省略此前的构造代码
@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唯一一个抽象方法,需要我们自己去实现:
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;
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的滑动切换 :
import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Scroller;
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,具体请查看Android View体系(二)实现View滑动的六种方法这篇文章,而Scroller滑动的原理请查看 Android View体系(四)从源码解析Scroller这篇文章。
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.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;
}