欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

Android Scroller完全解析

程序员文章站 2024-03-04 21:06:12
在android中,任何一个控件都是可以滚动的,因为在view类当中有scrollto()和scrollby()这两个方法,如下图所示: 这两个方法的主要作用是...

在android中,任何一个控件都是可以滚动的,因为在view类当中有scrollto()和scrollby()这两个方法,如下图所示:

Android Scroller完全解析

这两个方法的主要作用是将view/viewgroup移至指定的坐标中,并且将偏移量保存起来。另外:

mscrollx 代表x轴方向的偏移坐标
mscrolly 代表y轴方向的偏移坐标

这两个方法都是用于对view进行滚动的,那么它们之间有什么区别呢?简单点讲,scrollby()方法是让view相对于当前的位置滚动某段距离,而scrollto()方法则是让view相对于初始的位置滚动某段距离。

关于偏移量的设置我们可以参看下源码:

public class view { 
  .... 
  protected int mscrollx; //该视图内容相当于视图起始坐标的偏移量,x轴方向   
  protected int mscrolly; //该视图内容相当于视图起始坐标的偏移量,y轴方向 
  //返回值 
  public final int getscrollx() { 
    return mscrollx; 
  } 
  public final int getscrolly() { 
    return mscrolly; 
  } 
  public void scrollto(int x, int y) { 
    //偏移位置发生了改变 
    if (mscrollx != x || mscrolly != y) { 
      int oldx = mscrollx; 
      int oldy = mscrolly; 
      mscrollx = x; //赋新值,保存当前便宜量 
      mscrolly = y; 
      //回调onscrollchanged方法 
      onscrollchanged(mscrollx, mscrolly, oldx, oldy); 
      if (!awakenscrollbars()) { 
        invalidate(); //一般都引起重绘 
      } 
    } 
  } 
  // 看出区别了吧 。 mscrollx 与 mscrolly 代表我们当前偏移的位置 , 在当前位置继续偏移(x ,y)个单位 
  public void scrollby(int x, int y) { 
    scrollto(mscrollx + x, mscrolly + y); 
  } 
  //... 
} 

于是,在任何时刻我们都可以获取该view/viewgroup的偏移位置了,即调用getscrollx()方法和getscrolly()方法。

下面我们写个例子看下它们的区别吧:

<?xml version="1.0" encoding="utf-8"?>
<linearlayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:tools="http://schemas.android.com/tools"
  android:id="@+id/layout"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:orientation="vertical">

  <button
    android:id="@+id/scroll_to_btn"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="scrollto"/>

  <button
    android:id="@+id/scroll_by_btn"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_margintop="10dp"
    android:text="scrollby"/>
</linearlayout>

外层使用了一个linearlayout,在里面包含了两个按钮,一个用于触发scrollto逻辑,一个用于触发scrollby逻辑。

public class mainactivity extends appcompatactivity {

  private linearlayout layout;
  private button scrolltobtn;
  private button scrollbybtn;

  @override
  protected void oncreate(bundle savedinstancestate) {
    super.oncreate(savedinstancestate);
    setcontentview(r.layout.activity_main);
    layout = (linearlayout) findviewbyid(r.id.layout);
    scrolltobtn = (button) findviewbyid(r.id.scroll_to_btn);
    scrollbybtn = (button) findviewbyid(r.id.scroll_by_btn);
    scrolltobtn.setonclicklistener(new view.onclicklistener() {
      @override
      public void onclick(view v) {
        layout.scrollto(getresources().getdimensionpixeloffset(r.dimen.horizontal_scroll),
            getresources().getdimensionpixeloffset(r.dimen.horizontal_scroll));
      }
    });
    scrollbybtn.setonclicklistener(new view.onclicklistener() {
      @override
      public void onclick(view v) {
        layout.scrollby(getresources().getdimensionpixeloffset(r.dimen.horizontal_scroll),
            getresources().getdimensionpixeloffset(r.dimen.horizontal_scroll));
      }
    });
  }
}



<resources>
  <dimen name="horizontal_scroll">-20dp</dimen>
  <dimen name="vertical_scroll">-30dp</dimen>
</resources>

当点击了scrollto按钮时,我们调用了linearlayout的scrollto()方法,当点击了scrollby按钮时,调用了linearlayout的scrollby()方法。那有的朋友可能会问了,为什么都是调用的linearlayout中的scroll方法?这里一定要注意,不管是scrollto()还是scrollby()方法,滚动的都是该view内部的内容,而linearlayout中的内容就是我们的两个button,如果你直接调用button的scroll方法的话,那结果一定不是你想看到的。

另外还有一点需要注意,就是两个scroll方法中传入的参数,第一个参数x表示相对于当前位置横向移动的距离,正值向左移动,负值向右移动。第二个参数y表示相对于当前位置纵向移动的距离,正值向上移动,负值向下移动。
运行一下程序:

Android Scroller完全解析

当我们点击scrollto按钮时,两个按钮会一起向右下方滚动,之后再点击scrollto按钮就没有任何作用了,界面不会再继续滚动,只有点击scrollby按钮界面才会继续滚动,并且不停点击scrollby按钮界面会一起滚动下去。

scroller类

从上面例子运行结果可以看出,利用scrollto()/scrollby()方法把一个view偏移至指定坐标(x,y)处,整个过程是直接跳跃的,没有对这个偏移过程有任何控制,对用户而言不太友好。于是,基于这种偏移控制,scroller类被设计出来了,该类的主要作用是为偏移过程制定一定的控制流程,从而使偏移更流畅,更完美。
我们分析下源码里去看看scroller类的相关方法,其源代码(部分)如下: 路径位于 \frameworks\base\core\java\android\widget\scroller.java

public class scroller { 

  private int mstartx;  //起始坐标点 , x轴方向 
  private int mstarty;  //起始坐标点 , y轴方向 
  private int mcurrx;   //当前坐标点 x轴, 即调用startscroll函数后,经过一定时间所达到的值 
  private int mcurry;   //当前坐标点 y轴, 即调用startscroll函数后,经过一定时间所达到的值 

  private float mdeltax; //应该继续滑动的距离, x轴方向 
  private float mdeltay; //应该继续滑动的距离, y轴方向 
  private boolean mfinished; //是否已经完成本次滑动操作, 如果完成则为 true 

  //构造函数 
  public scroller(context context) { 
    this(context, null); 
  } 
  public final boolean isfinished() { 
    return mfinished; 
  } 
  //强制结束本次滑屏操作 
  public final void forcefinished(boolean finished) { 
    mfinished = finished; 
  } 
  public final int getcurrx() { 
    return mcurrx; 
  } 
   /* call this when you want to know the new location. if it returns true, 
   * the animation is not yet finished. loc will be altered to provide the 
   * new location. */  
  //根据当前已经消逝的时间计算当前的坐标点,保存在mcurrx和mcurry值中 
  public boolean computescrolloffset() { 
    if (mfinished) { //已经完成了本次动画控制,直接返回为false 
      return false; 
    } 
    int timepassed = (int)(animationutils.currentanimationtimemillis() - mstarttime); 
    if (timepassed < mduration) { 
      switch (mmode) { 
      case scroll_mode: 
        float x = (float)timepassed * mdurationreciprocal; 
        ... 
        mcurrx = mstartx + math.round(x * mdeltax); 
        mcurry = mstarty + math.round(x * mdeltay); 
        break; 
      ... 
    } 
    else { 
      mcurrx = mfinalx; 
      mcurry = mfinaly; 
      mfinished = true; 
    } 
    return true; 
  } 
  //开始一个动画控制,由(startx , starty)在duration时间内前进(dx,dy)个单位,即到达坐标为(startx+dx , starty+dy)出 
  public void startscroll(int startx, int starty, int dx, int dy, int duration) { 
    mfinished = false; 
    mduration = duration; 
    mstarttime = animationutils.currentanimationtimemillis(); 
    mstartx = startx;    mstarty = starty; 
    mfinalx = startx + dx; mfinaly = starty + dy; 
    mdeltax = dx;      mdeltay = dy; 
    ... 
  } 
} 

其中比较重要的两个方法为:

public boolean computescrolloffset()
函数功能说明:根据当前已经消逝的时间计算当前的坐标点,保存在mcurrx和mcurry值中。

public void startscroll(int startx, int starty, int dx, int dy, int duration)
函数功能说明:开始一个动画控制,由(startx , starty)在duration时间内前进(dx,dy)个单位,到达坐标为(startx+dx , starty+dy)处。

computescroll()方法介绍:
为了易于控制滑屏控制,android框架提供了 computescroll()方法去控制这个流程。在绘制view时,会在draw()过程调用该方法。因此, 再配合使用scroller实例,我们就可以获得当前应该的偏移坐标,手动使view/viewgroup偏移至该处。
computescroll()方法原型如下,该方法位于viewgroup.java类中

/** 
   * called by a parent to request that a child update its values for mscrollx and mscrolly if necessary. this will typically be done if the child is animating a scroll using a {@link android.widget.scroller scroller} 
   * object. 
   * 由父视图调用用来请求子视图根据偏移值 mscrollx,mscrolly重新绘制 */
  public void computescroll() { //空方法 ,自定义viewgroup必须实现方法体     
  } 

为了实现偏移控制,一般自定义view/viewgroup都需要重载该方法 。其调用过程位于view绘制流程draw()过程中,如下:

@override 
protected void dispatchdraw(canvas canvas){ 
  ... 

  for (int i = 0; i < count; i++) { 
    final view child = children[getchilddrawingorder(count, i)]; 
    if ((child.mviewflags & visibility_mask) == visible || child.getanimation() != null) { 
      more |= drawchild(canvas, child, drawingtime); 
    } 
  } 
} 
protected boolean drawchild(canvas canvas, view child, long drawingtime) { 
  ... 
  child.computescroll(); 
  ... 
} 

实例演示

viewpager相信每个人都再熟悉不过了,因此它实在是太常用了,我们可以借助viewpager来轻松完成页面之间的滑动切换效果,但是如果问到它是如何实现的话,我感觉大部分人还是比较陌生的。其实说到viewpager最基本的实现原理主要就是两部分内容,一个是事件分发,一个是scroller。对于事件分发,不了解的同学可以参考我这篇博客android事件的分发、拦截和执行
接下来我将结合事件分发和scroller来实现一个简易版的viewpager。首先自定义一个viewgroup,不了解的可以参考android自定义viewgroup(一)之customgridlayout这篇文章。平滑偏移的主要做法如下:

第一、调用scroller实例去产生一个偏移控制(对应于startscroll()方法)
第二、手动调用invalid()方法去重新绘制,剩下的就是在computescroll()里根据当前已经逝去的时间,获取当前应该偏移的坐标(由scroller实例对应的computescrolloffset()计算而得)
第三、当前应该偏移的坐标,调用scrollby()方法去缓慢移动至该坐标处。

新建一个scrollerlayout并让它继承自viewgroup来作为我们的简易viewpager布局,代码如下所示:

public class scrollerlayout extends viewgroup {

  private scroller mscroller; //用于完成滚动操作的实例
  private velocitytracker mvelocitytracker = null ; //处理触摸的速率
  public static int snap_velocity = 600 ; //最小的滑动速率
  private int mtouchslop = 0 ;      //最小滑动距离,超过了,才认为开始滑动
  private float mlastionmotionx = 0 ;  //上次触发action_move事件时的屏幕坐标
  private int curscreen = 0 ; //当前屏幕
  private int leftborder;  //界面可滚动的左边界
  private int rightborder; //界面可滚动的右边界

  //两种状态: 是否处于滑屏状态
  private static final int touch_state_rest = 0; //什么都没做的状态
  private static final int touch_state_scrolling = 1; //开始滑屏的状态
  private int mtouchstate = touch_state_rest; //默认是什么都没做的状态

  public scrollerlayout(context context, attributeset attrs) {
    super(context, attrs);
    // 创建scroller的实例
    mscroller = new scroller(context);
    //初始化一个最小滑动距离
    mtouchslop = viewconfiguration.get(context).getscaledtouchslop();
  }

  @override
  protected void onmeasure(int widthmeasurespec, int heightmeasurespec) {
    super.onmeasure(widthmeasurespec, heightmeasurespec);
    int childcount = getchildcount();
    for (int i = 0; i < childcount; i++) {
      view childview = getchildat(i);
      // 为scrollerlayout中的每一个子控件测量大小
      measurechild(childview, widthmeasurespec, heightmeasurespec);
    }
  }

  @override
  protected void onlayout(boolean changed, int l, int t, int r, int b) {
    if (changed) {
      int childcount = getchildcount();
      for (int i = 0; i < childcount; i++) {
        view childview = getchildat(i);
        // 为scrollerlayout中的每一个子控件在水平方向上进行布局
        childview.layout(i * childview.getmeasuredwidth(), 0, (i + 1) * childview.getmeasuredwidth(), childview.getmeasuredheight());
      }
    }
    // 初始化左右边界值
    leftborder = getchildat(0).getleft();
    rightborder = getchildat(getchildcount() - 1).getright();
  }

  @override
  public boolean onintercepttouchevent(motionevent ev) {
    final int action = ev.getaction();
    //表示已经开始滑动了,不需要走该action_move方法了(第一次时可能调用)。
    //该方法主要用于用户快速松开手指,又快速按下的行为。此时认为是处于滑屏状态的。
    if ((action == motionevent.action_move) && (mtouchstate != touch_state_rest)) {
      return true;
    }
    final float x = ev.getx();
    switch (action) {
      case motionevent.action_move:
        final int xdiff = (int) math.abs(mlastionmotionx - x);
        //超过了最小滑动距离,就可以认为开始滑动了
        if (xdiff > mtouchslop) {
          mtouchstate = touch_state_scrolling;
        }
        break;
      case motionevent.action_down:
        mlastionmotionx = x;
        mtouchstate = mscroller.isfinished() ? touch_state_rest : touch_state_scrolling;
        break;
      case motionevent.action_cancel:
      case motionevent.action_up:
        mtouchstate = touch_state_rest;
        break;
    }
    return mtouchstate != touch_state_rest;
  }

  public boolean ontouchevent(motionevent event){
    super.ontouchevent(event);
    //获得velocitytracker对象,并且添加滑动对象
    if (mvelocitytracker == null) {
      mvelocitytracker = velocitytracker.obtain();
    }
    mvelocitytracker.addmovement(event);
    //触摸点
    float x = event.getx();
    switch(event.getaction()){
      case motionevent.action_down:
        //如果屏幕的动画还没结束,你就按下了,我们就结束上一次动画,即开始这次新action_down的动画
        if(mscroller != null){
          if(!mscroller.isfinished()){
            mscroller.abortanimation();
          }
        }
        mlastionmotionx = x ; //记住开始落下的屏幕点
        break ;
      case motionevent.action_move:
        int detax = (int)(mlastionmotionx - x ); //每次滑动屏幕,屏幕应该移动的距离
        if (getscrollx() + detax < leftborder) {  //防止用户拖出边界这里还专门做了边界保护,当拖出边界时就调用scrollto()方法来回到边界位置
          scrollto(leftborder, 0);
          return true;
        } else if (getscrollx() + getwidth() + detax > rightborder) {
          scrollto(rightborder - getwidth(), 0);
          return true;
        }
        scrollby(detax, 0);//开始缓慢滑屏咯。 detax > 0 向右滑动 , detax < 0 向左滑动
        mlastionmotionx = x ;
        break ;
      case motionevent.action_up:
        final velocitytracker velocitytracker = mvelocitytracker ;
        velocitytracker.computecurrentvelocity(1000);
        //计算速率
        int velocityx = (int) velocitytracker.getxvelocity() ;
        //滑动速率达到了一个标准(快速向右滑屏,返回上一个屏幕) 马上进行切屏处理
        if (velocityx > snap_velocity && curscreen > 0) {
          // fling enough to move left
          snaptoscreen(curscreen - 1);
        }
        //快速向左滑屏,返回下一个屏幕
        else if(velocityx < -snap_velocity && curscreen < (getchildcount()-1)){
          snaptoscreen(curscreen + 1);
        }
        //以上为快速移动的 ,强制切换屏幕
        else{
          //我们是缓慢移动的,因此先判断是保留在本屏幕还是到下一屏幕
          snaptodestination();
        }
        //回收velocitytracker对象
        if (mvelocitytracker != null) {
          mvelocitytracker.recycle();
          mvelocitytracker = null;
        }
        //修正mtouchstate值
        mtouchstate = touch_state_rest ;
        break;
      case motionevent.action_cancel:
        mtouchstate = touch_state_rest ;
        break;
    }
    return true ;
  }

  //我们是缓慢移动的,因此需要根据偏移值判断目标屏是哪个
  private void snaptodestination(){
    //判断是否超过下一屏的中间位置,如果达到就抵达下一屏,否则保持在原屏幕
    //公式意思是:假设当前滑屏偏移值即 scrollcurx 加上每个屏幕一半的宽度,除以每个屏幕的宽度就是我们目标屏所在位置了。
    int destscreen = (getscrollx() + getwidth() / 2 ) / getwidth() ;
    snaptoscreen(destscreen);
  }

  //真正的实现跳转屏幕的方法
  private void snaptoscreen(int whichscreen){
    //简单的移到目标屏幕,可能是当前屏或者下一屏幕,直接跳转过去,不太友好,为了友好性,我们在增加一个动画效果
    curscreen = whichscreen ;
    //防止屏幕越界,即超过屏幕数
    if(curscreen > getchildcount() - 1)
      curscreen = getchildcount() - 1 ;
    //为了达到下一屏幕或者当前屏幕,我们需要继续滑动的距离.根据dx值,可能向左滑动,也可能向右滑动
    int dx = curscreen * getwidth() - getscrollx() ;
    mscroller.startscroll(getscrollx(), 0, dx, 0, math.abs(dx) * 2);
    //由于触摸事件不会重新绘制view,所以此时需要手动刷新view 否则没效果
    invalidate();
  }

  @override
  public void computescroll() {
    //重写computescroll()方法,并在其内部完成平滑滚动的逻辑
    if (mscroller.computescrolloffset()) {
      scrollto(mscroller.getcurrx(), mscroller.getcurry());
      invalidate();
    }
  }
}

代码比较长,但思路比较清晰。
(1)首先在scrollerlayout的构造函数里面我们创建scroller的实例,由于scroller的实例只需创建一次,因此我们把它放到构造函数里面执行。另外在构建函数中我们还初始化的touchslop的值,这个值在后面将用于判断当前用户的操作是否是拖动。
(2)接着重写onmeasure()方法和onlayout()方法,在onmeasure()方法中测量scrollerlayout里的每一个子控件的大小,在onlayout()方法中为scrollerlayout里的每一个子控件在水平方向上进行布局,布局类似于方向为horizontal的linearlayout。
(3) 接着重写onintercepttouchevent()方法, 在这个方法中我们记录了用户手指按下时的x坐标位置,以及用户手指在屏幕上拖动时的x坐标位置,当两者之间的距离大于touchslop值时,就认为用户正在拖动布局,置状态为touch_state_scrolling,当用户手指抬起,重置状态为touch_state_rest。这里当状态值为touch_state_scrolling时返回true,将事件在这里拦截掉,阻止事件传递到子控件当中。
(4)那么当我们把事件拦截掉之后,就会将事件交给scrollerlayout的ontouchevent()方法来处理。
如果当前事件是action_move,说明用户正在拖动布局,那么我们就应该对布局内容进行滚动从而影响拖动事件,实现的方式就是使用我们刚刚所学的scrollby()方法,用户拖动了多少这里就scrollby多少。另外为了防止用户拖出边界这里还专门做了边界保护,当拖出边界时就调用scrollto()方法来回到边界位置。
如果当前事件是action_up时,说明用户手指抬起来了,但是目前很有可能用户只是将布局拖动到了中间,我们不可能让布局就这么停留在中间的位置,因此接下来就需要借助scroller来完成后续的滚动操作。首先计算滚动速率,判断当前动作是scroll还是fling。如果是fling,再根据fling的方向跳转到上一页或者下一页,调用函数snaptoscreen。如果是scroll,就调用函数snaptodestination,函数中首先根据当前的滚动位置来计算布局应该继续滚动到哪一页,滚动到哪一页同样调用snaptoscreen。再来看看snaptoscreen写法吧,其实是调用startscroll()方法来滚动数据,紧接着调用invalidate()方法来刷新界面。
(5)重写computescroll()方法,并在其内部完成平滑滚动的逻辑 。在整个后续的平滑滚动过程中,computescroll()方法是会一直被调用的,因此我们需要不断调用scroller的computescrolloffset()方法来进行判断滚动操作是否已经完成了,如果还没完成的话,那就继续调用scrollto()方法,并把scroller的curx和cury坐标传入,然后刷新界面从而完成平滑滚动的操作。

现在scrollerlayout已经准备好了,接下来我们修改activity_main.xml布局中的内容,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<com.hx.scroller.scrollerlayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent">

  <imageview
    android:layout_width="match_parent"
    android:layout_height="200dp"
    android:background="@drawable/crazy_1" />

  <imageview
    android:layout_width="match_parent"
    android:layout_height="200dp"
    android:background="@drawable/crazy_2" />

  <imageview
    android:layout_width="match_parent"
    android:layout_height="200dp"
    android:background="@drawable/crazy_3" />

</com.hx.scroller.scrollerlayout>

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持。