引言:Scroller类是一个比较有趣的Android原生类,可译为“滚动器”。我们经常会在App中使用到滚动的功能,因此在智能设备屏幕尺寸比较小的情况下我们也能查看到更多的信息。同时我们或许还会有这样的体验:打开联系人界面,手指向上滑动,联系人列表也会跟着一起滑动,但是,当我们松手之后,滑动并不会因此而停止,而是伴随着一段惯性继续滑动,最后才慢慢停止。还有桌面启动器,当你用较快的速度滑动桌面时,你会发现即使你手指的移动范围很小,但也能切换到其他页面。这样的用户体验完全照顾了人的习惯和对事物的感知,是一种非常舒服自然的操作。要实现这样的功能,需要Scroller及其相关类的支持。
Scroller的基础知识
Scroller类其实并不负责“滚动”这个动作,“滚动”的动作[1]是由基类View的scrollTo(x,y)
和scrollBy(dx,dy)
的这两个方法完成的,Scroller类只是根据要滚动的起始位置和结束位置生成中间的过渡位置,从而形成一个滚动的动画。这一过程有点像下图展示的翻页动画书的制作过程(哈哈,在我的博文不仅能学到知识,还能学到把妹技巧,是不是很值啊,快!下面的表白利器赶快收了)。
同时我们还需明白Scroller类还需与容器类配合才能产生滚动的这个过程。因为一个View在容器(比如ViewGroup)中的滚动不是自身发起的动作,而是由父容器驱动容器内的子控件来完成,换句话说就是发生滚动效果的是组件的内容。例如在ViewGroup中使用Scroller,移动的是所有子View。但如果在TextView
中使用,那么移动的将是TextView
中的文本。
探讨scrollTo()与scrollBy()
这两个方法都是对View进行滑动,只是scrollTo(int x,int y)
滑动到终点位置(x,y),而scrollBy(int dx,int dy)
则是使View进行相对滑动,横向滑动距离为dx,竖向滑动距离为dy。但去查看scrollBy()
的源码你会发现其实质也是回调了scrollTo()
方法。
接下来我会用一个Demo来举例说明scrollTo
和scrollBy
的使用。
这是一个自定义的Layout,为MyScrollToByLayout
MyScrollToByLayout.java
public class MyScrollToByLayout extends ViewGroup {
private Button btn1,btn2;
public MyScrollToByLayout(Context context) {
super(context);
}
public MyScrollToByLayout(Context context, AttributeSet attrs) {
this(context,attrs,0);
}
public MyScrollToByLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
btn1=new Button(context);
btn2=new Button(context); //新建两个Button实例btn1,btn2
btn1.setText("Button1");
btn2.setText("Button2");
addView(btn1);
addView(btn2); //将两个Button添加到当前layout中
btn2.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
scrollTo((int)btn2.getX(),(int)btn2.getY()); //使Button2移动到layout左上角
}
});
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
measureChildren(widthMeasureSpec,heightMeasureSpec);
int width=measureWidth(widthMeasureSpec);
int height=measureHeight(heightMeasureSpec);
setMeasuredDimension(width,height);
}
private int measureHeight(int spec) {
int mode=MeasureSpec.getMode(spec);
int size=MeasureSpec.getSize(spec);
int height=0;
if(mode==MeasureSpec.AT_MOST){
throw new IllegalStateException("Must not be" +
"MeasureSpec.AT_MOST"); //layout中的控件高度不能指定为wrap_content
}else {
height=size;
}
return height;
}
private int measureWidth(int spec) {
int mode=MeasureSpec.getMode(spec);
int size=MeasureSpec.getSize(spec);
int width=0;
if(mode==MeasureSpec.AT_MOST){
throw new IllegalStateException("Must not be" +
"MeasureSpec.AT_MOST"); //layout中的控件宽度不能指定为wrap_content
}else {
width=size;
}
return width;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
View tn1=(View)getChildAt(0);
int x2=400;
int x1=tn1.getMeasuredWidth()+x2;
tn1.layout(-x1,500,-x2,tn1.getMeasuredHeight()+500); //将第一个button放置在指定位置
View tn2=(View)getChildAt(1);
tn2.layout(x2,500,x1,tn2.getMeasuredHeight()+500); //将第二个button放置在指定位置
}
}
MyScrollToByLayout在MyScrollToByLayoutDemo这个Activity中使用
MyScrollToByLayoutDemo.java
public class MyScrollToByLayoutDemo extends AppCompatActivity {
private MyScrollToByLayout mLayout;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.my_scroll_to_by_layout_demo);
mLayout=(MyScrollToByLayout) findViewById(R.id.myLayout);
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.main,menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int x=(int)mLayout.getChildAt(0).getX();
int y=(int)mLayout.getChildAt(0).getY();
if(item.getItemId()==R.id.another){
//将原来不可见的Button1通过移动的方式让他显现出来
mLayout.scrollTo(x-mLayout.getWidth()/2,y-mLayout.getHeight()/2);
}
return true;
}
}
上面Activity的布局文件
my_scroll_to_by_layout_demo.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/my_scroll_to_by_layout_demo"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.kobe.myview_test_demo.MyScrollToByLayoutDemo">
<com.kobe.myview_test_demo.MyScrollToByLayout
android:id="@+id/myLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
</com.kobe.myview_test_demo.MyScrollToByLayout>
</RelativeLayout>
效果如下:
Button2滑到屏幕左上角Button1本来是不可见的,但是使用scrollTo()
使Button1滑到屏幕内
由上面的效果图已经验证了“滚动”的动作是由scrollTo()
与scrollBy()
方法产生的,但是他们都是瞬时完成“滚动”这一动作,而Scroller类就是在这一过程中添加中间过程,从而产生一个自然的动画。
同时我们还需要再次强调,scrollTo()
与scrollBy()
方法产生的滑动效果是发生在控件的内容上,而之所以会这样,下面我们可以通过以ViewGroup为例来理解:如果ViewGroup使用scrollTo
或scrollBy
方法,那么可以看做移动的是ViewGroup本身,而ViewGroup的内容一般就是里面的子控件,我们可以认为里面的子控件是画在画布上的。而ViewGroup或者手机屏幕可以看成是一个中空的盖板,盖板下面就是我们刚才说的画布,画布上有许多ViewGroup的子控件。当盖板在画布上方的某一位置时,我们只有透过中间空的矩形看见画布上的子控件,也就是我们在手机屏幕上能够看得见的视图。而有些我们看不见的子控件,并不代表它不存在,只是它在中空的矩形以外,被盖板盖着,也可以说是在手机屏幕之外。实际上,在Android中我刚才说的“画布”就是Canvas类,而Canvas类就是没有边界的,他可以比手机屏幕更大。所以我们可以把View的内容(ViewGroup指的是内部的子控件,普通View例如TextView指的就是它的文本)认为是是画在Cnavas这一画布上的,但我们能不能看见取决于手机屏幕的位置(或者这里说的盖板中空的矩形)。而使用scrollTo()
与scrollBy()
方法会使这里说的盖板发生移动,从而画布上的内容在手机屏幕的位置也发生了改变,从而产生了移动效果。类似于我们物理所说的相对移动。所以说scrollTo()
与scrollBy()
方法的参数为正时,盖板(手机屏幕)往下移动,画布上的内容(组件的内容)在手机屏幕上显现的却是往上运动,这就是画布与盖板之间的相对运动引起的。下面用图帮助大家理解,注意图上“content”指的就是画布(同时这图也是上面实例代码效果的示意图)。
最后,大家还需知道有两个方法getScrollX()
和getScrollY()
就,这两个方法就是获取滑动后屏幕相对于未滑动之前初始位置的相对位移。
Scroller的基本使用
相关方法的介绍
Scroller类虽然对滑动的作用非同小可,但是它定义的方法并不多,各位童鞋可以去看看Scroller的源码。这里我们只介绍常用而且比较重要的几个方法:
public void startScroll(int startX,int startY,int dx,int dy)
public void startScroll(int startX,int startY,int dx,int dy,int duration)
启动滚动行为,startX和startY表示起始位置,dx、dy表示要滚动的x、y方向的距离,duration表示持续时间,默认时间为250毫秒
public final boolean isFinished()
判断滚动是否已经结束,返回true表示已经结束
public void abortAnimation()
停止滚动,currX、currY设置为终点坐标
public final void forceFinished(boolean finished)
强制结束滚动,currX,currY即为当前坐标
public final int getCurrX()
public final int getCurrY()
返回滚动过程中的x(y)坐标值,滚动时会提供startX(startY)即起始值和finalX(finalY)即终点值这两个值计算而来
public boolean computeScrollOffset()
计算滚动偏移量,必掉方法之一。主要负责计算currX和currY两个值,其返回值true表示滚动尚未完成,为false表示滚动已结束
上面的方法都是较为常用的几个Scroller方法,下面我会继续将上面演示scrollTo
方法的实例再拿出来作为演示Scroller类方法的使用案例,这样讲解的思路就能一脉相承,方便大家理解。
相关方法的实际运用
上面已经说明过Scroller类只是产生一个滚动的动画,而“滚动”这一动作还是由scrollTo()
或者scrollBy()
这两个方法完成的。现在把Scroller类的用法添加到上面演示scrollTo()
方法的实例中去(只显示要改动代码的那部分)。如下:
MyScrollToByLayout.java
public class MyScrollToByLayout extends ViewGroup {
private Button btn1,btn2;
private Scroller mScroller;
......
public MyScrollToByLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mScroller=new Scroller(context);
btn1=new Button(context);
btn2=new Button(context); //新建两个Button实例btn1,btn2
btn1.setText("Button1");
btn2.setText("Button2");
addView(btn1);
addView(btn2); //将两个Button添加到当前layout中
btn2.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
mScroller.startScroll(getLayout().getScrollX(),getLayout().getScrollY(),
(int)btn2.getX(),(int)btn2.getY(),10000);
postInvalidate();
}
});
}
.......
@Override
public void computeScroll(){
if(mScroller.computeScrollOffset()){
this.scrollTo(mScroller.getCurrX(),mScoller.getCurrY());
postInvalidate();
}
}
public MyScrollToByLayout getLayout(){
return this;
}
public Scroller getScroller(){
return this.mScroller;
}
}
MyScrollToByLayoutDemo.java
public class MyScrollToByLayoutDemo extends AppCompatActivity {
......
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int x=(int)mLayout.getChildAt(0).getX();
int y=(int)mLayout.getChildAt(0).getY();
if(item.getItemId()==R.id.another){
//将原来不可见的Button1通过移动的方式让他显现出来
int dx=x-mLayout.getWidth()/2;
int dy=y-mLayout.getHeight()/2;
mLayout.getScroller.startScroller(mLayout.getScrollX(),mLayout.getScrollY(),
dx,dy,10000);
mLayout.postInvalidate();
}
return true;
}
}
我们来看看效果[2],是不是很兴奋
Scroller的示范使用 Scroller的示范使用1Scroller的高级应用——仿Launcher
大多数智能手机与桌面系统比如Window一个最明显的标识就是进入桌面后可以左右滑屏查看App应用图标,这给有限的屏幕大小带来了无限的空间。手指在屏幕上滑动时,如果快速滑动,则切换到上一屏或下一屏,如果速度比较慢或者滑动距离小于一个屏幕的1/2,则会自动缩回去。点击“上一屏”和“下一屏”按钮,也可以实现同样的效果。效果[2]图如下:
MultiLauncher演示触摸滑屏可以分为两个过程:一是手指在屏幕上滑动时屏幕跟随一起滑动,滑动速度与手指速度相同。二是手指松开后,根据手指的速度、已滑动距离判断屏幕是回滚还是滑动到下一屏。同时我们应该考虑到Android触摸事件的分发机制,当屏幕正处于滑动状态时,容器内的子组件便不再接受任何事件,onInterceptTouchEvent()
方法必须返回true
。所以我们还需要考虑怎么判断用户手指的状态是不是滑动状态。触摸滑动的操作是在onTouchEvent
方法中完成,手指按下时,需要判断是否正在滑屏中,如果是,则马上停止,同时记下手指的初始坐标。手指移动过程中,获取手指移动的距离,并让容器内容以相同的方向移动相同的距离。手指松开后,根据手指移动速度和已移动的距离判断是要回滚还是移动到下一屏。下面我们给出源码:
MultiLauncher.java
public class MultiLauncher extends ViewGroup {
private static final int SNAP_VELOCITY =500;
private Scroller mScroller;
private int touchSlop; //最小滑动距离,超过了,才认为开时滑动
private static final String TAG = "MultiLauncher";
private static final int TOUCH_STATE_STOP=0x001; //停止状态
private static final int TOUCH_STATE_FLING=0x002; //滑动状态
private int touchState=TOUCH_STATE_STOP;
private float lastionMotionX=0; //上次触摸屏的x位置
private int curScreen; //当前屏
private VelocityTracker velocityTracker; //速率跟踪器
public MultiLauncher(Context context) {
super(context);
}
public MultiLauncher(Context context, AttributeSet attrs) {
this(context,attrs,0);
}
public MultiLauncher(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mScroller=new Scroller(context);
touchSlop= ViewConfiguration.get(context).getScaledTouchSlop();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
measureChildren(widthMeasureSpec,heightMeasureSpec);
int width=measureWidth(widthMeasureSpec);
int height=measureHeight(heightMeasureSpec);
setMeasuredDimension(width,height);
}
private int measureHeight(int heightMeasureSpec) {
int mode=MeasureSpec.getMode(heightMeasureSpec);
int size=MeasureSpec.getSize(heightMeasureSpec);
int height=0;
if (mode==MeasureSpec.AT_MOST){
throw new IllegalStateException("Must not be" +
" MeasureSpec.AT_MOST.");
}else {
height=size;
}
return height;
}
private int measureWidth(int widthMeasureSpec) {
int mode=MeasureSpec.getMode(widthMeasureSpec);
int size=MeasureSpec.getSize(widthMeasureSpec);
int width=0;
if(mode==MeasureSpec.AT_MOST){
throw new IllegalStateException("Must not be" +
" MeasureSpec.AT_MOST.");
}else {
width=size;
}
return width*this.getChildCount();
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int n=this.getChildCount();
int w=(r-l)/n;
int h=b-t;
for (int i = 0; i <n ; i++) {
View child=getChildAt(i);
int left=i*w;
int right=(i+1)*w;
int top=0;
int bottom=h;
child.layout(left,top,right,bottom);
}
}
public void moveToScreen(int whichScreen){
curScreen=whichScreen;
if(curScreen>getChildCount()-1){
curScreen=getChildCount()-1;
}
if(curScreen<0){
curScreen=0;
}
int scrollX=getScrollX();
int splitWidth=getWidth()/getChildCount(); //每一屏的宽度
int dx=curScreen*splitWidth-scrollX; //要移动的距离
mScroller.startScroll(scrollX,0,dx,0,Math.abs(dx));//开始移动
invalidate();
}
public void moveToDestination(){
int splitWidth=getWidth()/getChildCount(); //每一屏的宽度
int toScreen=(getScrollX()+splitWidth/2)/splitWidth;//判断是回滚还是进入下一屏
moveToScreen(toScreen); //移动到目标分屏
}
public void moveToNext(){
moveToScreen(curScreen+1);
}
public void moveToPrevious(){
moveToScreen(curScreen-1);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
int action=ev.getAction();
final int x=(int)ev.getX();
if(action==MotionEvent.ACTION_MOVE&&
touchState==TOUCH_STATE_STOP){
return true;
}
switch (action){
case MotionEvent.ACTION_DOWN:
lastionMotionX=x;
touchState=mScroller.isFinished()?TOUCH_STATE_STOP
:TOUCH_STATE_FLING;
break;
case MotionEvent.ACTION_MOVE:
//滑动距离过小不算滑动
final int dx=(int)Math.abs(x-lastionMotionX);
if (dx>touchSlop){
touchState=TOUCH_STATE_FLING;
}
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
touchState=TOUCH_STATE_STOP;
break;
default:
break;
}
return touchState!=TOUCH_STATE_STOP;
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (velocityTracker == null) {
velocityTracker=VelocityTracker.obtain();
}
velocityTracker.addMovement(event);
super.onTouchEvent(event);
int action=event.getAction();
final int x=(int)event.getX();
switch (action){
case MotionEvent.ACTION_DOWN:
//手指按下时,如果正在滚动,则立即停止
if (mScroller != null && !mScroller.isFinished()) {
mScroller.abortAnimation();
}
lastionMotionX=x;
break;
case MotionEvent.ACTION_MOVE:
//随手指滑动
int dx=(int)(lastionMotionX-x);
scrollBy(dx,0);
lastionMotionX=x;
break;
case MotionEvent.ACTION_UP:
final VelocityTracker velocityTracker=this.velocityTracker;
velocityTracker.computeCurrentVelocity(1000);
int velocityX=(int)velocityTracker.getXVelocity();
//通过velocityX的正负值可以判断滑动方向
if(velocityX>SNAP_VELOCITY&&curScreen>0){
moveToPrevious();
}else if (velocityX<-SNAP_VELOCITY&&curScreen<(getChildCount()-1)){
moveToNext();
}else {
moveToDestination();
}
if (velocityTracker != null) {
this.velocityTracker.clear();
this.velocityTracker.recycle();
this.velocityTracker=null;
}
touchState=TOUCH_STATE_STOP;
break;
case MotionEvent.ACTION_CANCEL:
touchState=TOUCH_STATE_STOP;
break;
}
return true;
}
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()){
this.scrollTo(mScroller.getCurrX(),mScroller.getCurrY());
postInvalidate();
}
}
}
看到上面那么长的代码是不是有点怕了。不用怕,我来教你怎么看。首先我们在MultiLauncher
的构造方法中初始化相关的值:平滑滚动需要使用Scroller对象,通过ViewConfiguration.get(context).getScaledTouchSlop()
可以获取到当前手机上默认的最小滑动距离。然后onMeasure
、onLayout
、measureHeight
、measureWidth
方法都是自定义容器的测量和确定子组件的位置。这些不属于本博文终点内容,所以不细讲。我们重点来看看onInterceptTouchEvent
这一方法,在该方法中如果手指刚触摸屏幕瞬间即ACTION_DOWN
首先判断Scroller对象滚动是否完毕,如果完毕代表手指状态就是非滑动,而如果没有完毕代表手指在滑动。当手指在屏幕上滑动即ACTION_MOVE
时,判断滑动距离有没有超过手机默认的最小滑动距离,如果有就认为手指状态是滑动,如果没有就直接让onInterceptTouchEvent
返回true从而拦截事件。当手指从屏幕上抬起瞬间即ACTION_UP
,手指状态为非滑动状态。然后根据手指状态来决定onInterceptTouchEvent()
返回值。如果手指为滑动状态则拦截事件。
拦截后触摸事件交给了onTouchEvent
处理。其中VelocityTracker主要用于跟踪触摸屏事件的速率,其具体用法可自行百度or谷歌(哈哈,相信我,谷歌是个好东西)。之后获取事件内容,如果手指按下即ACTION_DOWN
时,如果正在滚动,则立刻停止。当手指在屏幕上滑动时即ACTION_MOVE
,应使屏幕跟着手指同步滑动。当手指抬起屏幕瞬间即ACTION_UP
根据手指在屏幕上横向滑动速率和速率的正负值综合考虑决定切换到上一屏还是下一屏。同时记得回收VelocityTracker对象以及设置手指状态为非滑动。最后onTouchEvent
返回true表明事件已被消化。
而moveToScreen
则是决定切换到目标屏幕位置时要移动的距离,moveToDestination
则是决定目标屏幕位置。其方法体内容各位可以慢慢理解,只要认真细心一点我相信各位是可以看懂的。
multi_launcher_demo.xml
<?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/multi_launcher_demo"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context="com.kobe.myview_test_demo.MultiLauncherDemo">
<com.kobe.myview_test_demo.MultiLauncher
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:id="@+id/ml">
<com.kobe.myview_test_demo.LinearLayoutTest
android:layout_width="0dp"
android:layout_height="0dp"
android:background="#FF0000"
android:orientation="vertical"></com.kobe.myview_test_demo.LinearLayoutTest>
<LinearLayout
android:layout_width="0dp"
android:layout_height="0dp"
android:background="#FFFF00"
android:orientation="vertical"></LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="0dp"
android:background="#00FF00"
android:orientation="vertical"></LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="0dp"
android:background="#0000FF"
android:orientation="vertical"></LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="0dp"
android:background="#00FFFF"
android:orientation="vertical"></LinearLayout>
</com.kobe.myview_test_demo.MultiLauncher>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:onClick="pre"
android:text="上一屏"/>
<Button
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:onClick="next"
android:text="下一屏"
android:background="@android:color/holo_blue_bright"/>
</LinearLayout>
</LinearLayout>
MultiLauncherDemo.java
public class MultiLauncherDemo extends AppCompatActivity {
private MultiLauncher mLauncher;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.multi_launcher_demo);
mLauncher=(MultiLauncher) findViewById(R.id.ml);
}
public void pre(View view){
mLauncher.moveToPrevious();
}
public void next(View view){
mLauncher.moveToNext();
}
}
定义继承自AppCompatActivity
的MultiLauncherDemo类,加载multi_launcher_demo.xnl
布局,并响应Button
的单击事件。
总结
平滑滚动的基本工作流程我们可以总结为:
-
调用Scroller对象的
startScroll()
方法定义滚动的起始位置和滚动的距离 -
通过
invalidate()
或postInvalidate()
方法刷新,调用draw(Canvas canvas)
方法重绘组件 -
调用
computeScroll()
计算下一个位置的坐标 -
再次调用
invalidate()
或postInvalidate()
方法刷新重绘 -
判断
computeScroll()
方法的返回值,如果为false表示结束滚动,为true表示继续滚动上面的步骤其实构建了一个方法调用循环:
1-->2-->3-->4-->5-->3-->4-->5-->......
,而3-->4-->5
就是一个循环,该循环用于不断计算下一个位置,并通过重绘移动到该位置,这样就产生了动画效果。同时我们应该注意到computeScroll()
是个空方法来的,我们必须要重写该方法才能实现平滑滚动。
参考资料
《Android自定义组件开发详解》--李赞红
《Android自定义组件开发详解》是Android进阶学习自定义组件开发的一本好教材,强烈推荐有需要的同学查阅
最后是广告时间,我的博文将同步更新在三大平台上,欢迎大家点击阅读!谢谢
-
Android中实现一个View的坐标发生改变的方式有多种,详情可以参照Android中实现滑动的七种方式 ↩
-
效果图是由录屏视频转码成gif,因为gif图的压缩问题导致图示看着移动不是很顺畅,但实际效果是很流畅的,欢迎大家亲自尝试效果 ↩ ↩
网友评论