View的滑动实现方式

转载请以链接形式标明出处:
本文出自:103style的博客

《Android开发艺术探索》 学习记录

base on Android-29


可以带着以下问题来看本文:

  • scrollTo 和 scrollBy 改变是 View 的什么属性?
  • 补间动画和属性动画的使用?
  • 如何改变 View 的LayoutParams ?
  • Scroller实现平滑滑动的原理?

目录

  • scrollTo 和 scrollBy
  • 使用动画
  • 改变布局参数
  • 弹性滑动Scroller
  • 问题的解答

scrollTo 和 scrollBy

我们先来看看这两个方法的源码:

public void scrollTo(int x, int y) {
    if (mScrollX != x || mScrollY != y) {
        int oldX = mScrollX;
        int oldY = mScrollY;
        mScrollX = x;
        mScrollY = y;
        invalidateParentCaches();
        onScrollChanged(mScrollX, mScrollY, oldX, oldY);
        if (!awakenScrollBars()) {
            postInvalidateOnAnimation();
        }
    }
}
public void scrollBy(int x, int y) {
    scrollTo(mScrollX + x, mScrollY + y);
}

我们可以看到 scrollBy 也是调用了 scrollTo 方法。
首先我们先看下 mScrollX 和 mScrollY 的示例图:

mScrollX,mScrollY 示例图

通过上图,我们可以很明显的看出:
当View的内容往左往上时,mScrollX 和 mScrollY 为正。
当View的内容往右往下时,mScrollX 和 mScrollY 为负。
也就是说在View的坐标系中, mScrollX、mScrollY 分别为View的边缘减去对应内容边缘的大小
并且 scrollTo 和 scrollBy 改变的是其内容的位置,而不是其在布局中的位置!

我们来看个示例:

//activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <Button
        android:id="@+id/bt_anim"
        android:layout_width="160dp"
        android:layout_height="160dp"
        android:text="Anim"
        android:textAllCaps="false" />
</LinearLayout>
//MainActivity.java
public class MainActivity extends AppCompatActivity {
    private Button btAnim;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        btAnim = findViewById(R.id.bt_anim);
        btAnim.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                btAnim.scrollTo(100, 100);
            }
        });
    }
}

点击可以明显看到,内容往左上方移动了。

scrollTo(100, 100)

我们把 btAnim.scrollTo(100, 100); 改成 btAnim.scrollTo(-100, -100); 看看,可以看到内容往右下方移动了。
scrollTo(-100, -100)


使用动画

动画这块我们后面会单独具体介绍,这里先简单介绍下怎么使用动画来实现滑动。

还记得我们在 View的基础知识介绍 中说到的View的位置参数中的 translationXtranslationY 吗?动画实现滑动就是改变这个属性的值。

下面通过补间动画和属性动画来实现View的滑动:

// res/anim/translation.xml
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="2000"
    android:fromXDelta="0"
    android:fromYDelta="0"
    android:toXDelta="400"
    android:toYDelta="400" />

这个补间动画的意思是 将 View 从 (0,0) 在 2s 内移动到 (400,400)

属性动画的用法则为:
ObjectAnimator.ofFloat(view, "translationX", 0, 400).setDuration(2000).start();
意思是 将 view 的 translationX 属性在两秒内从 0 移动到 400.

//MainActivity.java
public class MainActivity extends AppCompatActivity {
    private Button btAnim;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        btAnim = findViewById(R.id.bt_anim);
        btAnim.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Animation animation = AnimationUtils.loadAnimation(MainActivity.this, R.anim.translation);
                btAnim.startAnimation(animation);
            }
        });
        btAnim.setOnLongClickListener(new View.OnLongClickListener() {
            @Override
            public boolean onLongClick(View v) {
                ObjectAnimator
                        .ofFloat(btAnim, "translationX", 0, 400)
                        .setDuration(2000)
                        .start();
                return true;
            }
        });
    }
}

上面代码中我们通过监听 btAnim 的点击事件来触发 补间动画,监听长按事件来触发 属性动画。

效果图

这里我们需要注意的是 补间动画 实现的平移 实际上只是对View的影像做操作,并不会真正改变View的位置参数。
如果我们添加 android:fillAfter="true" 的话,当动画结束后,则会停在最后的位置。
此时你会发现一个问题,当我们再次点击View时,并不会触发动画效果,但是点击之前的位置则会触发

我们修改例子来看看。

// res/anim/translation.xml
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="2000"
    android:fillAfter="true"
    android:fromXDelta="0"
    android:fromYDelta="0"
    android:toXDelta="400"
    android:toYDelta="400" />
//MainActivity.java
public class MainActivity extends AppCompatActivity {
    private Button btAnim;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        btAnim = findViewById(R.id.bt_anim);
        btAnim.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                show();
                Animation animation = AnimationUtils.loadAnimation(MainActivity.this, R.anim.translation);
                btAnim.startAnimation(animation);
            }
        });
        btAnim.setOnLongClickListener(new View.OnLongClickListener() {
            @Override
            public boolean onLongClick(View v) {
                show();
                ObjectAnimator
                        .ofFloat(btAnim, "translationX", 0, 400)
                        .setDuration(2000)
                        .start();
                return true;
            }
        });
    }
    private void show(){
        Toast.makeText(this,"start anim", Toast.LENGTH_SHORT).show();
    }
}
补间动画
属性动画

我们可以看到 补间动画 完了之后,只有点击之前所在位置才能触发点击事件, 而 属性动画 则只有点到View所在的位置才会触发长按事件。
是什么原因我们后面在将动画的时候再介绍吧。


改变布局参数

改变布局参数很简单,就是改变其 LayoutParams,我们可以通过 View.getLayoutParams() 来获取这个布局参数,然后修改属性, 再通过 setLayoutParams() 或者 requestLayout() 来重新布局。

具体我们来下面这个示例:

//MainActivity.java
public class MainActivity extends AppCompatActivity {
    private Button btAnim;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        btAnim = findViewById(R.id.bt_anim);
        btAnim.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                changeLayoutParams();
            }
        });
    }
    private void changeLayoutParams() {
        LinearLayout.LayoutParams llp = (LinearLayout.LayoutParams) btAnim.getLayoutParams();
        llp.width += 100;
        llp.height += 100;
        llp.setMarginStart(llp.getMarginStart() + 50);
        llp.topMargin += 50;
        btAnim.setLayoutParams(llp);
//        btAnim.requestLayout();
    }
}

我们通过每次点击使其 宽高 增加 100px, 左边距 和 上边距 增加 50px,效果图如下:

改变位置参数的效果图


这里我们先来总结下上面三种实现滑动的方法:

  1. scollTo/scollBy : 操作简单,只能移动View的内容。
  2. 动画:操作简单,主要用于没有交互的View 和 复杂的动画效果
  3. 改变布局参数:操作稍微复杂,适用于有交互的View.

通过效果图,我们可以很明显的看到 scollTo/scollBy 和 改变布局参数 这两种实现滑动的方法 效果比较生硬,用户体验不太好。 动画 方式实现的效果则会体验好很多。

下面我们来介绍通过 Scroller 来实先动画那样用户体验相对较好的 的滑动效果。


弹性滑动Scroller

我们在 View的基础知识介绍 中有介绍 Scroller 的用法,再重新回顾下:

  • 创建一个Scroller;
  • 重写 viewcomputeScroll 方法;
  • 然后通过 mScroller.startScroll()来实现滑动。
//TestScroller.java
public class TestScroller extends TextView {
    Scroller mScroller;
    public TestScroller(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        mScroller = new Scroller(context);
    }
    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            postInvalidate();
        }
    }
    public void smoothScrollTo(int destX, int destY) {
        int scrollX = getScrollX();
        int scrollY = getScrollY();
        int deltaX = destX - scrollX;
        int deltaY = destY - scrollY;
        mScroller.startScroll(scrollX, scrollY, deltaX, deltaY, 1000);
        invalidate();
    }
}
//activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">
    <com.lxk.viewdemo.TestScroller
        android:id="@+id/tv"
        android:layout_width="320dp"
        android:layout_height="320dp"
        android:layout_margin="8dp"
        android:background="@color/colorAccent"
        android:gravity="center"
        android:padding="8dp"
        android:text="Hello World!" />
</LinearLayout>
//MainActivity.java
public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        final TestScroller scroller = findViewById(R.id.tv);
        scroller.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                scroller.smoothScrollTo(200, 200);
            }
        });
}

运行,可以看到点击之后,内容在 1s 内往左上方各平移了 200px并且改变的也是View的内容

Scroll

首先我们来看看 smoothScrollTo 中调用的 ScrollerstartScroll 方法,我们可以看到它其实只是保存我们传入的参数。

public void startScroll(int startX, int startY, int dx, int dy, int duration) {
    mMode = SCROLL_MODE;
    mFinished = false;
    mDuration = duration;//滑动的事件
    mStartTime = AnimationUtils.currentAnimationTimeMillis();
    mStartX = startX;//滑动的起点
    mStartY = startY;//滑动的起点
    mFinalX = startX + dx;
    mFinalY = startY + dy;
    mDeltaX = dx;//滑动的距离
    mDeltaY = dy;//滑动的距离
    mDurationReciprocal = 1.0f / (float) mDuration;
}

然后通过在 smoothScrollTo 调用 invalidate() 方法,通过 invalidate() 触发重绘,来调用 computeScroll 方法,
然后通过Scroller.computeScrollOffset()判断状态,
满足则通过 mScroller.getCurrX()mScroller.getCurrY() 获取当前的位置,
然后通过 scrollTo 实现滑动,
然后通过 postInvalidate() 来继续触发重绘。

我们来看看 ScrollercomputeScrollOffset()方法:

//Scroller.java
public boolean computeScrollOffset() {
    if (mFinished) {
        return false;
    }
    int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
    if (timePassed < mDuration) {
        switch (mMode) {
            case SCROLL_MODE:
                //通过插值器来计算对应时间对应的值
                final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
                mCurrX = mStartX + Math.round(x * mDeltaX);
                mCurrY = mStartY + Math.round(x * mDeltaY);
                break;
            ...
        }
    } else {
        mCurrX = mFinalX;
        mCurrY = mFinalY;
        mFinished = true;
    }
    return true;
}

我们可以看到里面通过插值器来计算对应时间对应的 mCurrXmCurrY
mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
然后通过重绘来达到平滑滑动的效果。
插值器 属于 动画那块的内容,我们在将动画的时候在具体介绍,暂时当它是一个数学函数就可以了。

至此,我们大致知道了 Scroller实现滑动的原理为:
我们通过 Scroller 的 startScroll() 来设置要滑动的位置,
然后通过 invalidate() 触发重绘 来调用 View 的 computeScroll() 方法,
然后在 Scroller 的computeScrollOffset()中 通过插值器计算 这个滑动时间中 每个时间点对应的 目标距离,
然后再通过 scrollTo() 滑动这个时间点对应的距离,
然后继续重绘到对应时间点来实现滑动。

所以实际上 Scroller 本身并不能实现View的滑动,他需要配合View的 computeScroll() 方法才能达到平滑滑动的效果。


问题解答

  • scrollTo 和 scrollBy 改变是 View 的什么属性?
    A:mScrollX 和 mScrollY,内容往上往左滑 这两个值为正, 反之为负。

  • 补间动画和属性动画的使用?
    A: 补间动画: 通过 AnimationUtils.loadAnimation(context, R.anim.translation) 来获取补间动画,然后通过 view.startAnimation(animation) 来执行动画,补间动画进改变View的影像,并不改变其实际位置,所以点击事件只有点击原位置才会响应。
    属性动画:通过 ObjectAnimator.ofFloat(view, 对应属性, 起始值, 结束值).setDuration(时间).start() 来实现。

  • 如何改变 View 的LayoutParams ?
    A:通过 View.getLayoutParams() 获取LayoutParams,然后修改宽高、边距等,再通过 setLayoutParams() 或者 requestLayout() 来重新布局。

  • Scroller实现平滑滑动的原理?
    A:问题解答标题 上面就有,就不再赘述了。


如果觉得不错的话,请帮忙点个赞呗。

以上


扫描下面的二维码,关注我的公众号 103Tech, 点关注,不迷路。

103Tech

`