[移动开发] Android自定义控件系列六:自定义ViewGroup(一)实现ViewPager效果
今天我们开始新的Android自定组件旅程,下面一个内容是如何自定义一个ViewGoup,之前我们已经通过几篇博文已经了解了自定义view的基本写法,如果有不了解的同学,可以参看下面专栏中的文章:Android自定义控件。
这次同样也是通过一个例子来说明要如何自定义一个ViewGroup,最终目标就是要实现一个类ViewPager功能的ViewGroup。
我们先来看看最终效果:
对于系统的ViewGroup我们已经是十分熟悉了,最常用的LinearLayout和RelativeLayout几乎是天天要打交道,下面我们就来看看,如何一步一步将其实现:
一、首先当然也是最通常的新建一个Android工程,然后在里面新建一个类继承自ViewGroup,这里我们把这个类叫做MyViewPager。
实现继承ViewGroup之后,会发现,需要我们实现其中未实现的方法:第一个当然是两参的构造函数,用于xml布局文件生成对应的组件对象;第二个则是onLayout,这个方法我们应该也不陌生,在前几篇自定义view的博文中有提到过,onLayout方法实际作用是指定组件位置,在这里实际上是给ViewGroup的子View来确定位置。下面我们就需要来仔细考虑一下,我们的这个ViewGroup想要实现一个什么样的位置关系了:
加入说我们有6张图片想要显示,如果要实现viewpager效果的话,就需要一次显示一张图片,然后左右滑动的时候,可以显示前后的其他图片(如果前后还有图片的话);要实现这么一种效果,我们在onLayout方法里应该如何排列我们的子view呢(对于图片来说就是ImageView对象),看看下面这张图就会明白了:
如上图所示,在onLayout方法里,Android系统的坐标原点是位于手机屏幕的左上角顶点位置,x和y的正方向分别是向右和向下,对于ViewPager效果需要水平滑动的情况来说,我们的子View(ImageView)就应该要像上面图中所示的效果一样,在x-y坐标系中水平排列,这样我们手指水平滑动的时候才可以将左右的图片显示到手机屏幕上来。所以在这里,我们需要做的工作就是:
1、获取所有的子view。
2、将这些子view按照想显示的顺序,依次水平排列,每个子view的左上角与前后的子view的左上角的水平距离都相差一个手机屏幕的宽度,垂直方向上所有子view平齐。
用代码实现的话,如下:
@Override /** * 对子view进行布局排列,确定子view的位置 * @param changed 代表调用onLayout方法时,判断当前布局有没有发生改变,如果发生改变了就为true,否则false * @param int l, int t, int r, int b代表当前view也就是我们的myviewpager,相对于它的父view的位置,在这里我们在排列自己的子view的时候 * 这几个参数基本没什么用 */ protected void onLayout(boolean changed, int l, int t, int r, int b) { // ①首先拿到所有的子view for (int i = 0; i < getChildCount(); i++) { View view = getChildAt(i);// 取得下表为i的子view // ② 对子view进行排列,实际上就是需要调用子view的layout方法,四个参数分别对应 左、上、右、下 view.layout(i * getWidth(), 0, getWidth() * (i + 1), getHeight()); } }
上面代码中view.layout方法中所对应的左、上、右、下四个参数分别对应于当前子view的左、上、右、下方向上的四条边,对应于父ViewGroup也就是我们自定义的ViewGroup的距离,所以排列好之后,效果并不是上图的效果,而是0号ImageView在手机屏幕显示的位置,而其他的ImageView依次排列在右边。
二、定义触摸事件,让我们可以使用手势来滑动我们之前使onLayout方法排列好的子view显示出来。
这里我们必不可少的要使用到onTouchEvent方法了,这个方法是触摸事件经由dispatchTouchEvent方法分发和onInterceptTouchEvent方法处理之后会首先将触摸事件传递到onTouchEvent方法中, 关于触摸事件的传递机制,将会在下一篇文章中进行介绍。按照我们之前的做法,就是在onTouchEvent中直接使用switch语句进行判断,在ACTION_DOWN,ACTION_MOVE和ACTION_UP三个状态中进行不同处理;在这里我们先使用Android提供的的一个类来处理:GestureDetector。GestureDetector可以用来接收一个事件,然后对事件的不同类型进行相应的处理:我们只要简单的使用gestureDetector.onTouchEvent(event);下面关键的地方在于创建这个GestureDetector对象的时候要实现的方法:
detector = new GestureDetector(context, new OnGestureListener() { @Override /** * 有一个手指抬起的时候回调 */ public boolean onSingleTapUp(MotionEvent e) { // TODO Auto-generated method stub return false; } @Override /** * 当有手指点击屏幕的时候 */ public void onShowPress(MotionEvent e) { // TODO Auto-generated method stub } @Override /** * 当有手指在屏幕上滑动的时候回调 */ public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { return false; } @Override /** * 当有手指在屏幕上长按的时候回调 */ public void onLongPress(MotionEvent e) { // TODO Auto-generated method stub } @Override /** * 快速滑动的时候回调 */ public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { // TODO Auto-generated method stub return false; } @Override /** * 按下的时候回调的方法 */ public boolean onDown(MotionEvent e) { // TODO Auto-generated method stub return false; } });
不难看出GestureDetector对于事件的分类解析的方法有很多个。。。这里我们只关心滑动,所以只需要关注一下onScroll方法即可:public boolean onScroll(MotionEvent e1, MotionEvent e2,float distanceX, float distanceY) ,四个参数,e1表示最开始触发本次这一系列Event事件的那个ACTION_DOWN事件,e2表示触发本次Event事件的那个ACTION_MOVE事件,distanceX、distanceY分别表示从上一次调用onScroll调用到这一次onScroll调用在x和y方向上滑动的距离。这里需要稍微留意的是,distanceX、distanceY的正负并不是像之前ViewGroup里坐标显示的正负那样,而是向左滑动值distanceX为正,向右滑动值为distanceX负。
搞清楚参数的意思之后,下面要做的就是在onScroll让子View滑动起来~这里隆重介绍一个方法:public void scrollBy(int x, int y) ,在这里我们调用这个方法,将distanceX传进来,而第二个参数设为0(因为y方向上不需要滑动),即可达到我们的滑动目的了:scrollBy((int) distanceX, 0);在scrollBy方法内部,实际上是重写了scrollTo方法,scrollTo方法作用是让调用他的控件当前视图的基准点移动到某个坐标点,对于这里就是我们的ViewGroup,试想,如果基准点x坐标移动到负的屏幕宽度,那么现实的子view不就变成了1号了吗:
@Override /** * 当有手指在屏幕上滑动的时候回调 */ public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { // System.out.println("" + distanceX); // distanceX为正时,向左移动,为负时,向右移动 // 移动屏幕的方法scrollBy,很重要,这个方法会调用onScrollChanged方法,并刷新视图 /** * dx表示x方向上移动的距离,dy表示y方向上移动的距离。往坐标轴正方向上移动的话,值就是正值;反之为负 */ // scrollBy内部实际上是重写了scrollTo方法,scrollTo是将当前视图的基准点移动到某个坐标点 scrollBy((int) distanceX, 0); return false; }
<com.example.myviewpager.ui.MyViewPager android:id="@+id/my_view_pager" android:layout_width="fill_parent" android:layout_height="fill_parent" />
然后在MainActivity中,将这个MyViewPager的对象创建出来myViewPager,有资源文件的图片,都放到drawable目录中,然后循环调用myViewPager.addview方法,给我们的ViewGroup添加子view:
package com.example.myviewpager; import android.app.Activity; import android.os.Bundle; import android.view.Window; import android.widget.ImageView; import com.example.myviewpager.ui.MyViewPager; public class MainActivity extends Activity { private MyViewPager myViewPager; // 图片资源id数组 private int[] ids = new int[] { R.drawable.a1, R.drawable.a2, R.drawable.a3, R.drawable.a4, R.drawable.a5, R.drawable.a6 }; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 实现无标题效果 requestWindowFeature(Window.FEATURE_NO_TITLE); setContentView(R.layout.activity_main); myViewPager = (MyViewPager) findViewById(R.id.my_view_pager); for (int i = 0; i < ids.length; i++) { ImageView imageView = new ImageView(this); imageView.setBackgroundResource(ids[i]); // 添加ImageView到自定义的viewgroup myViewPager.addView(imageView); } } }
好了,至此,我们就可以先来看一看阶段效果啦~^_^:
看起来好像还不错,基本的滑动功能已经实现了,但是还是有一些问题存在:
1、在第一张图片和最后一张 图片显示的时候继续往外滑动,会显示空白的内容,并且停留
2、在抬起手指的时候,不会自动恢复到显示一个完整的图片状态
三、解决出现的两个问题:空白页面显示、抬起手指自动恢复选择显示完整图片
1、空白页面显示的问题
解决的方案就是使用一个整型值currentId来代表当前滑动到哪个位置了,最开始打开的时候位置为0,如果页面往右滑动,并且达到了滑动到下一个页面的条件,那么currentId就+1,如果是将要滑动到前一个页面,currentId就-1,当然currentId的值也是有范围的,那就是[0,getChildCount()-1],如果currentId<0了,我们就让其等于0,如果currentId>=getChildCount了,我们就让其等于getChildCount-1,然后滑动到对应currentId位置停下,这样就解决了
2、抬起手指的时候,让图片显示自动归位的问题
解决思路就是对手指抬起的时候距离最开始手指按下的时候滑动的距离,如果是往右或者往左距离大于屏幕距离的1/2,那么就往右或者往左滑动显示一个图片,如果不足1/2,则滑回之前显示的页面。这里涉及到按下和抬起两个动作,我们可以在MyViewPager的onTouchEvent方法中继续添加switch判断条件,来获取对应的值从而来判断滑动条件,主要就是要在onTouchEvent方法增加内容,我们来看看:
/** * down事件时的x坐标 */ private int firstX; private int currentId;//当前显示的图片的id,从0开始,最大值为getChildCount()-1 @Override public boolean onTouchEvent(MotionEvent event) { super.onTouchEvent(event); // 使用工具来解析触摸事件 detector.onTouchEvent(event); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: firstX = (int) event.getX(); break; case MotionEvent.ACTION_MOVE: break; case MotionEvent.ACTION_UP: int tmpId = 0; System.out.println("currentId=" + currentId); // 手指向右滑动超过屏幕的1/2,当前的id应该-1 if (event.getX() - firstX > getWidth() / 2) { tmpId = currentId - 1; } else if (firstX - event.getX() > getWidth() / 2) { tmpId = currentId + 1; } else { tmpId = currentId; } System.out.println("currentId=" + currentId); // 三目运算符的效率比if else效率要高很多 int childCount = getChildCount(); currentId = tmpId < 0 ? 0 : ((tmpId > childCount - 1) ? childCount - 1 : tmpId); scrollTo(currentId*getWidth(), 0); break; default: break; } // 消费掉本次事件 return true; }
经过上面的改动,发现之前的两个问题都解决了,但是看着还是有点不爽是不是。。。松手之后一下就弹回去了,用户体验不太好啊。所以我们这里得添加一个松手之后的滑动延时机制,让其慢慢的多次滑动,最后滑动到我们要的位置。
四、松手滑动延时效果的实现:
对于这一块,Android实际上也是有API可以使用的,这里我们先来自己动手实现一下看看。
这里的核心思想,还是要调用scrollTo方法,只是要在手指抬起之后,先拿到要滑动的距离,然后在指定的时间内匀速滑动完这一段距离即可,所以我们需要多次调用scrollTo方法来在不同的时间点里面移动到不同的位置。
我们自己写一个类DistanceProvider,来实现上面的功能。我们先拿到要移动的距离,然后将距离参数传入到DistanceProvider的开始滑动方法startScroll中。
先将之前的scrollTo(currentId * getWidth(), 0);那一句注释掉,写一个方法:moveToDest();
/** * 移动到合理的位置的方法 */ private void moveToDest() { /** * 移动起点到终点的距离 */ int distance = currentId * getWidth() - getScrollX(); /** * 在一段时间里面,平滑移动这段距离 */ // 开启计算偏移量 distanceProvider.startScroll(getScrollX(), 0, distance, 0); invalidate();// invalidate会导致ondraw和computeScroll方法的执行 }
下面重点来关注一下DistanceProvider这个类要如何写,这里会涉及到一个知识点,那就是在invalidate方法中,除了会调用onDraw方法之外还会调用一个方法computeScroll,这个方法的父类里面没有实现,实际上就是提供给开发者来使用的,我们可以重写computeScroll方法,在里面判断自动滑动是否结束,如果没结束,拿到最新的滑动位置,并且再次刷新视图调用invalidate方法,那么这一次调用的invalidate方法又会调用computeScroll方法,直到自动滑动完成。
对于DistanceProviderl类,有如下实现:
package com.example.myviewpager.utils; import android.content.Context; import android.os.SystemClock; /** * 计算位移距离的工具类 * * @author Alex * */ public class DistanceProvider { private int startX; private int startY; private int distanceX; private int distanceY; /** * 开始执行动画的时间 */ private long startTime; /** * 判断是否还在执行动画,true为已经停止,false表示还在运行 */ private boolean isFinish; /** * 默认的运行时间,毫秒值,300毫秒 */ private long duration; /** * 当前的x值 */ private long currentX; private long currentY; public long getCurrentX() { return currentX; } public void setCurrentX(long currentX) { this.currentX = currentX; } public DistanceProvider(Context context) { isFinish = false; } /** * 开始移动 * * @param startX * x的起始坐标 * @param startY * y的起始坐标 * @param distanceX * x方向要移动的距离 * @param distanceY * y方向要移动的距离 */ public void startScroll(int startX, int startY, int distanceX, int distanceY) { this.startX = startX; this.startY = startY; this.distanceX = distanceX; this.distanceY = distanceY; this.startTime = SystemClock.uptimeMillis(); //我们这里将滑动的持续时间设为300毫秒 this.duration = 300; this.isFinish = false; } /** * 计算一下当前的运行状态 * * @return true:表示运行结束 false:表示还在运行 */ public boolean computeScrollOffset() { if (isFinish) { return isFinish; } // 计算一下滑动运行了多久时间 long passTime = SystemClock.uptimeMillis() - startTime; if (passTime < duration) { currentX = startX + distanceX * passTime / duration; currentY = startY + distanceX * passTime / duration; } else { currentX = startX + distanceX; currentY = startY + distanceY; isFinish = true; } return false; } }
对于自定义viewgroup中的computeScroll方法:
@Override public void computeScroll() { // super.computeScroll(); // 计算滑动偏移量 if (!distanceProvider.computeScrollOffset()) { int newX = (int) distanceProvider.getCurrentX(); scrollTo(newX, 0); // 再次刷新 invalidate(); } }
下一篇将在本文的基础上,研究一下Android触摸事件的传递机制,敬请关注!谢谢!
郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。