Android学习——自定义利用Scroller实现可循环滚动的动画菜单控件(功能完善待改进)

本文介绍了一种简单易懂的Android自定义View实现,即一个可循环滚动的动画菜单控件。通过继承LinearLayout并使用Scroller,作者展示了如何创建横向和纵向滚动的菜单,并详细讲解了关键代码,包括onMeasure、startAnimation、onLayout、onInterceptTouchEvent和computeScroll等方法。尽管存在一些小bug,但作者计划在未来进行改进。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

       都说自定义View是小白和中级开发者的分界线,这也看出来自定义View在Android开发过程中的重要性,所以我的博客初期会以各种各样的自定义View为主。不会讲述太多的原理,主要提供一种简单易懂的实现。

        本期要实现的是可循环拉动的动画菜单。这个控件我在其他博客上看到过不少的实现方法,但是大多数要么就是代码量太大,要么就是结构太复杂,对新手来说阅读和理解起来比较困难,所以我今天花了一些时间来实现一个简单可用的循环滚动菜单控件。

        先看看效果图,这种控件提供了横向和纵向的实现

      

      接下来进行代码讲解

      代码主要是继承LinearLayout来实现的ScrollerLayout,共有21个方法,当其中只有5个是重要的,其他都是些数值设置初始化相关的方法。下面先来看看自定义的变量

/**
     * 公用的变量
     */
    private Context myContext;//上下文变量
    private Scroller mScroller;//用于操控滚动的Scroller变量
    private int mTouchSlop;//用于判断触碰操作是点击还是滚动
    private int itemPadding;//子元素间距
    private int moveDirection;//点击移动的方向
    private int mOrientation;//布局方向
    private int visiableItemNum;//屏幕可见的子元素个数
    private ArrayList<View>  childViewList;//子元素列表
    private int next;//下一个要显示的子元素索引
    private int front;//上一个要显示的子元素索引
    private int totalNum;//全部子元素个数
    /**
     * LinearLayout为横向时使用的变量
     */
    private float mXDownPos;//点击位置的x坐标
    private float mXMovePos;//点击并移动后的x坐标
    private float mXLastMovePos;//上一次触发滑动事件的位置
    private int leftBorder;//左界限
    private int rightBorder;//右界限
    private int width;//屏幕宽度
    private int displayWidth;//显示的子元素的宽度
    /**
     * LinearLayout为纵向时使用的变量
     */
    private int height;//屏幕高度
    private float mYDownPos;//点击位置的y坐标
    private float mYMovePos;//点击并移动后的y坐标
    private float mYLastMovePos;//上一次触发滑动事件的位置
    private int topBorder;//上界限
    private int bottomBorder;//下界限
    private int displayHeight;//显示的子元素的高度
    private int statusBarHeight;//状态栏高度
    private int titleBarHeight;//标题栏高度
    private int navigationBarHeight;//底部导航栏(部分Android手机的按键高度也属于导航栏高度)

      之前说过这个控件提供了横向和纵向的功能,只需要在XML布局文件中将android:orientation进行设置即可。下面的是所有与数值处理有关的方法,共16个

  /**
     * 构造函数
     */
    public ScrollerLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.setGravity(Gravity.CENTER_VERTICAL);
        this.myContext=context;
        init();
    }
    /**
     * 初始化
     */
    public void init(){
        this.mScroller = new Scroller(this.myContext);//创建Scroller实例
        ViewConfiguration configuration = ViewConfiguration.get(this.myContext);//获取TouchSlop值
        this.mTouchSlop = configuration.getScaledTouchSlop();
    }
    /**
     * 设置屏幕上可见的子元素个数
     * @param value
     */
    public void initVisiableItemNum(int value){
        this.visiableItemNum=(value==0)?getChildCount():value;
        initTotalNum();//获取子元素总个数
        initFront();//获取上一个要显示的子元素索引
        initNext();//获取下一个要显示的子元素索引
    }
    /**
     * 获得子元素列表
     */
    private void initChildViewList(){
        this.childViewList=new ArrayList<View>();
        int childCount = getChildCount();//获取包含的子元素的个数
        for (int i = 0; i < childCount; i++) {
            this.childViewList.add(getChildAt(i));
        }
    }
    /**
     * 初始化状态栏高度
     */
    private void initStatusBarHeight(){
        int result = 0;
        int resourceId = this.myContext.getResources().getIdentifier("status_bar_height", "dimen", "android");
        if (resourceId > 0) {
            result = this.myContext.getResources().getDimensionPixelSize(resourceId);
        }
        this.statusBarHeight=result;
    }
    /**
     * 初始化导航栏高度
     */
    private void initNavigationBarHeight(){
        int result=0;
        Resources resources = this.myContext.getResources();
        int resourceId=resources.getIdentifier("navigation_bar_height","dimen","android");
        if(resourceId!=0)
            this.navigationBarHeight = resources.getDimensionPixelSize(resourceId);
    }
    /**
     * 初始化标题栏高度
     */
    private void initTitleBarHeight(){
        TypedValue tv = new TypedValue();
        if (this.myContext.getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true)) {
            this.titleBarHeight = TypedValue.complexToDimensionPixelSize(tv.data, this.myContext.getResources().getDisplayMetrics());
        }
    }
    /**
     * 获取屏幕高和宽的函数
     */
    private void initScreenWH(){
        WindowManager wm1 = (WindowManager) this.myContext.getSystemService(Context.WINDOW_SERVICE);
        DisplayMetrics dm = new DisplayMetrics();
        wm1.getDefaultDisplay().getMetrics(dm);
        this.width = dm.widthPixels;
        this.height = dm.heightPixels;
        initNavigationBarHeight();
        initStatusBarHeight();
        initTitleBarHeight();
        this.height=this.height-this.statusBarHeight-this.titleBarHeight-this.navigationBarHeight;

    }
    /**
     * 获取LinearLayout布局方向的函数
     */
    private void initOrientation(){
        this.mOrientation=this.getOrientation();
    }
    /**
     * 初始化显示的子元素的宽高和padding
     */
    private void initDisplayWHP(){
        if(this.mOrientation==LinearLayout.HORIZONTAL) {
            this.displayWidth = this.width /this.visiableItemNum;
            this.itemPadding=this.displayWidth/(this.visiableItemNum-1);
            setGravity(Gravity.CENTER_VERTICAL);
        }else{
            this.displayHeight=this.height/this.visiableItemNum;
            this.itemPadding=this.displayHeight/(this.visiableItemNum-1);
            setGravity(Gravity.CENTER_HORIZONTAL);
        }

    }
    /**
     * 初始化子元素总数
     */
    private void initTotalNum(){
        this.totalNum=getChildCount();
    }
    /**
     * 初始化下一个显示的元素索引
     */
    private void initNext(){
        this.next=visiableItemNum+1;
    }
    /**
     * 初始化上一个显示的元素索引
     */
    private void initFront(){
        this.front=this.totalNum;
    }
    /**
     * 得到下一个显示的元素索引
     * @return
     */
    private int getNext(){
        if(this.next>this.totalNum);
            this.next=1;
        return this.next++;
    }
    /**
     * 得到上一个要显示的元素索引
     * @return
     */
    private int getFront(){
        if(this.front<1){
            this.front=this.totalNum;
        }
        return  this.front--;
    }

       接着就是自定义这个ViewGroup时比较重要的函数了,首先是onmeasure

@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);
            measureChild(childView, widthMeasureSpec, heightMeasureSpec);
        }
        initScreenWH();//初始化屏幕宽度和高度
        initOrientation();//初始化屏幕方向
        initDisplayWHP();//初始化展示的子元素的宽度和高度以及padding
    }

       然后是处理动画效果的startAnimation函数

 /**
     * 启动每个子元素形状变化的动画函数
     */
    private  void startAnimate(){//根据显示的子元素的个数对透明度和XY缩小的比例进行调整
        float temp0=(this.visiableItemNum-1)/(float)2+1;
        float temp1=1/(float)temp0;
        float rate;
        for(int i=0;i<this.visiableItemNum;i++){
            rate=(float) ((i+1)*temp1);
            if(rate>1){
                rate=2-rate;
            }
            getChildAt(i).animate().alpha(rate).scaleY(rate).scaleY(rate);
        }
    }

    接着是处理布局变化的onLayout函数

/**
     * 布局函数
     */
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
            for (int i = 0; i < this.totalNum; i++) {
                View childView = this.childViewList.get(i);
                if(this.mOrientation==LinearLayout.HORIZONTAL) {//当布局为横向时
                    childView.layout(i * (this.displayWidth + this.itemPadding) - this.displayWidth / 2, 0, (i + 1) * (this.displayWidth) + i * this.itemPadding - this.displayWidth / 2, childView.getMeasuredHeight());
                    this.leftBorder = getChildAt(0).getLeft();
                    this.rightBorder = getChildAt(this.visiableItemNum-1).getRight();
                }
                else{//当布局为纵向时    
                    childView.layout(0,i * (this.displayHeight + this.itemPadding) - this.displayHeight / 2+statusBarHeight, childView.getMeasuredWidth(), (i + 1) * (this.displayHeight) + i * this.itemPadding - this.displayHeight / 2+statusBarHeight);
                    this.topBorder = getChildAt(0).getTop();
                    this.bottomBorder = getChildAt(this.visiableItemNum-1).getBottom();
                }
            }
            startAnimate();//每次布局结束后都开启动画
    }

    接着就是处理动作交互的函数了,首先用onintercepttouchevent方法判断是用户的动作点击还是滑动

  /**
     *  用于判断用户的Touch属于点击还是滚动的函数
     */
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN://点击时获得x,y坐标
                mXDownPos = ev.getRawX();
                mYDownPos=ev.getRawY();
                mXLastMovePos = mXDownPos;
                mYLastMovePos=mYMovePos;
                break;
            case MotionEvent.ACTION_MOVE://判断手指移动的距离有没有达到定性为滑动的最小距离
                float distance;
                if(this.mOrientation==LinearLayout.HORIZONTAL) {
                    mXMovePos = ev.getRawX();
                     distance= Math.abs(mXMovePos - mXDownPos);
                    mXLastMovePos = mXMovePos;
                }else{
                    mYMovePos = ev.getRawY();
                    distance = Math.abs(mYMovePos - mYDownPos);
                    mYLastMovePos = mYMovePos;
                }
                if (distance > mTouchSlop) {
                    return true;
                }
                break;
        }
        return super.onInterceptTouchEvent(ev);
    }

    如果判断为滑动,就交给onTouchEvent处理

/**
     * 响应触碰事件的函数,我们在这里进行滚动操作的处理
     * @param event
     * @return
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                if(this.mOrientation==LinearLayout.HORIZONTAL) {//当为横向布局时
                    mXMovePos = event.getRawX();
                    int scrolledX = (int) (mXLastMovePos - mXMovePos);
                    if (getScrollX() + scrolledX < leftBorder) {//限制活动范围不能超过界限
                        scrollTo(leftBorder, 0);
                        return true;
                    } else if (getScrollX() + getWidth() + scrolledX > rightBorder) {
                        scrollTo(rightBorder - getWidth(), 0);
                        return true;
                    }
                    moveDirection = scrolledX;
                    scrollBy(scrolledX, 0);
                    mXLastMovePos = mXMovePos;
                }else{//当为纵向布局时
                    mYMovePos = event.getRawY();
                    int scrolledY = (int) (mYLastMovePos - mYMovePos);

                    if (getScrollY() + scrolledY < topBorder) {
                        scrollTo(0, topBorder);
                        return true;
                    } else if (getScrollY() + getHeight() + scrolledY > this.bottomBorder) {
                        scrollTo(0, this.bottomBorder-this.height);
                        return true;
                    }
                    moveDirection = scrolledY;
                    scrollBy(0, scrolledY);
                    mYLastMovePos = mYMovePos;
                }
                break;
            case MotionEvent.ACTION_UP://当手指释放后
                int targetIndex;
                if(this.mOrientation==LinearLayout.HORIZONTAL) {
                    targetIndex = (getScrollX() + displayWidth / 2) / (displayWidth + itemPadding);
                    int dx = getScrollX() - targetIndex * (displayWidth + itemPadding);
                    mScroller.startScroll(getScrollX(), 0, -dx, 0);//调用startScroll实现滚动刷新
                }else{
                    targetIndex = (getScrollY() + displayHeight / 2) / (displayHeight + itemPadding);
                    int dx = getScrollY() - targetIndex * (displayHeight + itemPadding);
                    mScroller.startScroll(0, getScrollY(), 0, -dx);//调用startScroll实现滚动刷新
                }
                if(moveDirection<0) {//这个是实现循环的关键,根据滑动的方向添加移除View
                    int temp=getFront();
                    removeView(this.childViewList.get(temp-1));
                    addView(this.childViewList.get(temp-1),0);
                    requestLayout();//请求重写布局
                }
                else if(moveDirection>0) {
                    int temp=getNext();
                    removeView(this.childViewList.get(temp-1));
                    addView(this.childViewList.get(temp-1),this.totalNum-1);
                    requestLayout();
                }
                invalidate();//刷新
                break;
        }
        return super.onTouchEvent(event);
    }

    接着是与Scroller的实现有关的computeScroll函数

 @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            invalidate();
        }
    }

        这就是这个控件基本的代码,注意,这里有两个个小小的bug,就是当布局为纵向时而屏幕为横向时会出现无法滚动的现象,如下


        这里可能是由于我在纵向位置的处理上出现了问题,而且可见的子元素个数为了美观可用必须为大于1的奇数。之后等我手头上的几个项目完工后再对这个控件进行改进

        下面我们看看如何使用这个控件

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.drw.scroller.MainActivity">
    <com.drw.scroller.ScrollerLayout//这里用到了自定义控件
        android:id="@+id/myScrollerLayout"
        android:layout_alignParentLeft="true"
        android:layout_width="match_parent"
        android:orientation="horizontal"
        android:layout_height="match_parent">
        <ImageView//内置的子元素
            android:clickable="true"
            android:layout_width="100dp"//这里将子元素的长和宽都设置为100dp是应为懒得处理图像,实际上ScrollerLayout中不会理会这个设置,之后会和纵向问题一起改进
            android:layout_height="100dp"
            android:src="@mipmap/btn1"/>
        <ImageView
            android:clickable="true"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:src="@mipmap/btn2"/>
        <ImageView
            android:clickable="true"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:src="@mipmap/btn3"/>
        <ImageView
            android:clickable="true"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:src="@mipmap/btn4"/>
        <ImageView
            android:clickable="true"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:src="@mipmap/btn5"/>
    </com.drw.scroller.ScrollerLayout>
</RelativeLayout>

    接着看看主类

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        ScrollerLayout ms=(ScrollerLayout)findViewById(R.id.myScrollerLayout);
        ms.initVisiableItemNum(3);//只需要设置一下可见的子元素个数即可,注意这里的个数要大于1,且为了美观最好为奇数,额,这也算一个Bug吧
    }
}

    好了,这个控件的展示就到这里了,虽然有点小bug,但是之后等我的项目做完后会对这个控件进行改进。当然,大家有什么好的改进方法也可以留言与我交流。让我也学习学习

    啊,还有项目的源码链接:http://download.youkuaiyun.com/download/qq_37656219/10254838

    我是菜鸟,多多指教,DRW

————————————————————————

    我又对代码进行了一些修改,发现了在纵向布局时的解决方法。把initScreenWH中的initNavigationBarHeight函数注释掉
,然后在源代码中把这个函数和有关的this.navigationBarHeight去掉,这个函数和变量就是影响横纵向的关键所在

 /**
     * 获取屏幕高和宽的函数
     */
    private void initScreenWH(){
        WindowManager wm1 = (WindowManager) this.myContext.getSystemService(Context.WINDOW_SERVICE);
        DisplayMetrics dm = new DisplayMetrics();
        wm1.getDefaultDisplay().getMetrics(dm);
        this.width = dm.widthPixels;
        this.height = dm.heightPixels;
        //initNavigationBarHeight();
        initStatusBarHeight();
        initTitleBarHeight();
        this.height=this.height-this.statusBarHeight-this.titleBarHeight;

    }

    接着再对initDisplayWH,onLayout,构造函数代码进行修改,添加layoutTop和layoutLeft变量,以及自定义属性layoutLocation

   private int layoutTop;//控件最上面举例屏幕顶端高度
    private int layoutLeft;//控件最左面距离屏幕左边宽度
private void initDisplayWHP(){
        this.displayWidth = this.width /this.visiableItemNum;
        this.itemPadding=this.displayWidth/(this.visiableItemNum-1);
        this.displayHeight=this.height/this.visiableItemNum;
        this.itemPadding=this.displayHeight/(this.visiableItemNum-1);
        if(this.mOrientation==LinearLayout.HORIZONTAL) {
            setGravity(Gravity.CENTER_VERTICAL);
        }else{
            setGravity(Gravity.CENTER_HORIZONTAL);
        }

    }

自定义的attrs.xml内容如下

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="ScrollerLayout">
        <attr name="layoutLocation" format="string" />
    </declare-styleable>
</resources>

还有构造函数函数

/**
     * 构造函数
     */
    public ScrollerLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.setGravity(Gravity.CENTER_VERTICAL);
        this.myContext=context;
        TypedArray ta = this.myContext.obtainStyledAttributes(attrs, R.styleable.ScrollerLayout);
        layoutLocation = ta.getString(R.styleable.ScrollerLayout_layoutLocation);
        ta.recycle();
        init();
    }
/**
     * 处理控件位置的函数
     */
    private void initLayoutLocation(){
        if(this.layoutLocation.toString().compareTo("Left")==0){
            this.layoutLeft=0;
        }else if(this.layoutLocation.toString().compareTo("Right")==0){
            Log.e("info","lala"+this.width+","+this.displayWidth);
            this.layoutLeft=this.width-displayWidth;
        }else if(this.layoutLocation.toString().compareTo("Top")==0){
            this.layoutTop=0;
        }else{
            this.layoutTop=this.height-this.displayHeight;
        }
    }
注意这里的字符串比较不能用==,否则永远是false

接下来是onLayut函数

/**
     * 布局函数
     */
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
            initLayoutLocation();//在这里调用设置位置
            for (int i = 0; i < this.totalNum; i++) {
                View childView = this.childViewList.get(i);
                if(this.mOrientation==LinearLayout.HORIZONTAL) {
                    childView.layout(i * (this.displayWidth + this.itemPadding) - this.displayWidth / 2, this.layoutTop, (i + 1) * (this.displayWidth) + i * this.itemPadding - this.displayWidth / 2, this.layoutTop+childView.getMeasuredHeight());
                    this.leftBorder = getChildAt(0).getLeft();
                    this.rightBorder = getChildAt(this.visiableItemNum-1).getRight();
                }
                else{
                    childView.layout(this.layoutLeft,i * (this.displayHeight + this.itemPadding) - this.displayHeight / 2, this.layoutLeft+childView.getMeasuredWidth(), (i + 1) * (this.displayHeight) + i * this.itemPadding - this.displayHeight / 2);
                    this.topBorder = getChildAt(0).getTop();
                    this.bottomBorder = getChildAt(this.visiableItemNum-1).getBottom();
                }
            }
            startAnimate();
    }
到这里就把纵向无法拉动的问题解决了





评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值