美文网首页简化开发
【Android轮子】自定义轮播图

【Android轮子】自定义轮播图

作者: 感同身受_ | 来源:发表于2020-11-04 10:29 被阅读0次

    前言:
    这是自己实现的一个轮播图控件,下面的文章是记录自己的开发过程。这个项目我已经做成gitHub的开源库了,可供大家方便使用
    gitHub地址

    一、ViewPager源码分析

    1. setAdapter

    我们从viewPager的setAdapter()方法入手

    public void setAdapter(@Nullable PagerAdapter adapter) {
        if (mAdapter != null) {
            //观察者模式
            mAdapter.setViewPagerObserver(null);
            //...
        }
        //我们重点关注这个方法
        //创建和销毁itemView
        populate();
    }
    
    void populate() {
        populate(mCurItem);
    }
    
    //newCurrentItem:表示当前选中的item
    void populate(int newCurrentItem) {
        ItemInfo oldCurInfo = null;
        if (mCurItem != newCurrentItem) {
            oldCurInfo = infoForPosition(mCurItem);
            mCurItem = newCurrentItem;
        }
        
        if (curItem == null && N > 0) {
            //进到里面,里面通过 mAdapter.instantiateItem()方法创建ItemView
            curItem = addNewItem(mCurItem, curIndex);
        }
        
        if (curItem != null) {
            for (int pos = mCurItem - 1; pos >= 0; pos--) {
                mItems.remove(itemIndex);
                //通过for循环,销毁ItemView
                mAdapter.destroyItem(this, pos, ii.object);
            }
        }
    }
    
    ItemInfo addNewItem(int position, int index) {
        ItemInfo ii = new ItemInfo();
        ii.position = position;
        //通过这个方法不断的创建子View
        ii.object = mAdapter.instantiateItem(this, position);
        ii.widthFactor = mAdapter.getPageWidth(position);
        if (index < 0 || index >= mItems.size()) {
            mItems.add(ii);
        } else {
            mItems.add(index, ii);
        }
        return ii;
    }
    
    

    由源码可以看出,ViewPager里面无论放多少页面都不会内存溢出,因为他会不断的去销毁和创建ItemView,通过mAdapter.instantiateItem(this, position);创建和mAdapter.destroyItem(this, pos, ii.object);这和销毁

    image-20201031135012014.png

    上面的图是默认的情况,缓存左右1页,如果要自定义的话,需要调用viewPager.setOffscreenPageLimit(5);这个方法来设置,传进去我们要缓存的页数,这时候左边5页+右边5页+自己 = 11页

    2. setCurrentItem();

    切换页面,执行动画

    //...经过一些列的调用,最终会调到这个方法来
    
    void setCurrentItemInternal(int item, boolean smoothScroll, boolean always, int velocity) {
        //当我们切换itemView的时候,他就会通过这里去创建和销毁itemView
        populate(item);
        //然后滚动到要切换的页面
        scrollToItem(item, smoothScroll, velocity, dispatchSelected);
    }
    

    二、实现无限轮播图

    实现无限录播有两种方法:

    • 自定义ViewPager,继承自ViewPager
    • 自定义ViewGroup,继承自ViewGroup+HorizontalScroll

    为了简便,我们直接使用继承自ViewPager的方式

    1. 创建自定义ViewPager

    public class BannerViewPager extends ViewPager {
         public BannerViewPager(@NonNull Context context) {
            this(context,null);
        }
    
        public BannerViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
            super(context, attrs);
        }   
        
        //为后面的Adapter模式做准备
        public void setAdapter( BannerAdapter adapter) {
            this.mAdapter = adapter;
            //设置父类 ViewPager的adapter
            setAdapter(new BannerPagerAdapter());
            //这里设置Adapter之后,会不断的循环调用instantiateItem()方法,去增加ItemView
        }
    }
    

    BannerViewPager类现在就已经继承自ViewPager了,但是我们得把数据放到BannerViewPager中,这个时候,我们就需要Adapter的加入了,他能把数据变成BannerViewPager需要的数据

    2. 为ViewPager创建一个Adapter

    /**
     * 给ViewPager设置适配器
     */
    public class  BannerPagerAdapter extends PagerAdapter{
    
        @Override
        public int getCount() {
            //为了实现无限循环
            return Integer.MAX_VALUE;
        }
    
        @Override
        public boolean isViewFromObject(@NonNull View view, @NonNull Object object) {
            //官方推荐这里这么写
            //因为在ViewPager的addNewItem方法中,回调新增ItemView的时候ii.object = mAdapter.instantiateItem(this, position);
            //返回的就是object对象,所以这里直接用view == object
            return view == object;
        }
    
        /**
         * 创建ViewPager条目回调的方法
         * ii.object = mAdapter.instantiateItem(this, position);
         * @param container 就是我们的ViewPager,上面的this就是传过来的container,就是ViewPager
         * @param position
         * @return 这里返回Object对象,和上面的isViewFromObject里面的逻辑对应起来了
         */
        @NonNull
        @Override
        public Object instantiateItem(@NonNull ViewGroup container, int position) {
           //Adapter设计模式为了完全让用户自定义
            // position 的变化 0 -> 2^31 会溢出,所以我们对总数据进行求模运算
            View bannerItemView = mAdapter.getView(position%mAdapter.getCount());
            //让用户去添加,实现用户的自定义
            // 添加ViewPager里面
            container.addView(bannerItemView);
            return bannerItemView;
        }
    
        /**
         * 销毁条目回调的方法
         * @param container
         * @param position
         * @param object
         */
        @Override
        public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
            container.removeView((View)object);
            //不造成内存泄漏
            object = null;
        }
    }
    

    上面这个Adapter中,我们复写了getCount()方法,并把他的返回值设置成了Integer.MAX_VALUE,这里就是为了给无限轮播做准备。

    而isViewFromObject()方法里面,我们直接返回view == object,这是官方推荐的写法,这里之所以用object与view直接比较,是因为我们创建ItemView的时候,instantiateItem()方法就是直接返回的Object对象(instantiateItem()方法的作用和回调时机上面已经分析过了)。

    我们还复写了instantiateItem()方法用于创建ItemView,不过这里我们使用了Adapter设计模式,方便用户实现Adapter的自定义(自定义BannerViewPager里面放置什么样的数据)。然后我们把用户自定义的View添加到了container对象中,这个container对象其实就是我们的BannerViewPager。

    destroyItem()方法就是设置如何销毁一个ItemView,我们这里是直接从我们的BannerViewPager中移除,被把object = null,这里是为了不造成内存泄漏

    3. 实现用户自定义View

    在上面的Adapter中,我们使用了Adapter设计模式,目的就是方便去调用用户自定义的View,所以我们这里应该设置一个接口,便于用户去继承,然后去设置自己的View

    public abstract class BannerAdapter{
    
        /**
         * 根据位置获取ViewPager里面的子View
         * @param position
         * @return
         */
        public abstract View getView(int position);
    
    }
    

    用户要使用的时候,只需要继承BannerAdapter,然后实现getView()方法中的逻辑

    mBannerVp.setAdapter(new BannerAdapter() {
        @Override
        public View getView(int position) {
            ImageView imageView = new ImageView(BannerActivity.this);
            String imagePath = mData.get(0).getCoverMiddle();
            Glide.with(BannerActivity.this).load(imagePath)
                    .placeholder(R.drawable.ic_launcher_foreground)//加载占位图(默认图片)
                    .into(imageView);
            return imageView;
        }
    });
    

    而这里调用的Adapter就是我们在BannerViewPager中设置的setaAdapter方法,它里面调用的setAdapter方法就是其父类ViewPager中的setAdapter方法

     public void setAdapter( BannerAdapter adapter) {
         this.mAdapter = adapter;
         //设置父类 ViewPager的adapter
         setAdapter(new BannerPagerAdapter());
         //这里设置Adapter之后,会不断的循环调用instantiateItem()方法,去增加ItemView
     }
    

    4. 实现自动轮播

    实现方式

    • Timer类写一个定时器
    • Handler发送消息
    • Thread().start()开一个子线程

    我们这里采用Handler的方式来实现,不过得注意Handler的内存泄漏问题(Activity的生命周期没有Handler的生命周期长)

    //2.实现自动轮播 -- 发送消息的messageWhat
    private final int SCROLL_MSG = 0X0011;
    
    //2.实现自动轮播 -- 页面切换间隔时间(默认值)
    private int mCutDownTime = 2500;
    
    //这种方式待容易造成内存泄漏
    private Handler mHandler = new Handler(){
        @Override
        public void handleMessage(@NonNull Message msg) {
            //每个多少秒X秒切换到下一页
            //切换到下一页
            setCurrentItem(getCurrentItem() + 1);
            //不断循环执行
            startRoll();
        }
    };
    

    这里创建的Handler一直在执行,没有被销毁,这样下去会造成内存泄漏的情况,因为Handler一直在执行,Activity就不会调用onDestory()方法去销毁自己

    解决Handler内存泄漏方法:

    4.1. 在Activity退出当前页面的时候,把mHandler停止并销毁
    **
     * 销毁Handler停止发送  解决内存泄漏
     */
    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        mHandler.removeMessages(SCROLL_MSG);
        mHandler = null;
    }
    
    4.2. 使用静态内部类:
    private InnerHandler mHandler = new InnerHandler();
    
    private static class InnerHandler extends Handler{
        @Override
        public void handleMessage(@NonNull Message msg) {
            super.handleMessage(msg);
            //doSomething...
        }
    } 
    
    4.3. 使用弱引用解决静态内部类访问外部类
    private InnerHandler mHandler = new InnerHandler(BannerViewPager.this){};
    
    private static class InnerHandler extends Handler{
        private WeakReference<BannerViewPager> mWeakReference;
    
        public InnerHandler(BannerViewPager bannerViewPager){
            mWeakReference = new WeakReference<>(bannerViewPager);
        }
    
        @Override
        public void handleMessage(@NonNull Message msg) {
            super.handleMessage(msg);
            if (mWeakReference.get() != null){
                //doSomething...
            }
        }
    }
    

    但就算如此,在退出MainActivity后,Looper线程的消息队列中还是可能会有待处理的消息,因此建议在Activity销毁时,移除消息队列中的消息。

    4.4. 最后一道关卡:

    其实也就是我们第一个方法的用法,在当前的View退出Window时,把Handler的任务清空,并销毁Handler,这样就能保证不会内存泄漏了

    /**
     * 销毁Handler停止发送  解决内存泄漏
     */
    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        mHandler.removeMessages(SCROLL_MSG);
        mHandler = null;
    }
    

    继续回到我们的Handler的使用

    这里创建并实例化了Handler,然后我们要开始使用他,并提供方法,让用户手动去开启自动轮播

    /**
     * 实现自动轮播
     */
    public void startRoll(){
        //清除消息,防止被多次调用时,间隔时间就没有2500了
        mHandler.removeMessages(SCROLL_MSG);
        //参数: 消息,延迟时间
        //需求:让用户自定义,但也要有个默认值  2500
        mHandler.sendEmptyMessageDelayed(SCROLL_MSG,mCutDownTime);
    }
    

    5. 设置滚动动画的速度

    5.1. 分析源码,寻找办法

    根据我们之前的源码分析,在调用ViewPager的setCurrentItem的时候,这个方法的最后会调用

    void setCurrentItemInternal(int item, boolean smoothScroll, boolean always, int velocity) {
        //创建和销毁itemView
        populate(item);
        scrollToItem(item, smoothScroll, velocity, dispatchSelected);
    }
    
    private void scrollToItem(int item, boolean smoothScroll, int velocity,
                boolean dispatchSelected) {
        if (smoothScroll) {
            smoothScrollTo(destX, 0, velocity);
        } else {
            completeScroll(false);
            scrollTo(destX, 0);
        }
    }
    
    private Scroller mScroller;
    private static final int MAX_SETTLE_DURATION = 600; // ms
    
    void smoothScrollTo(int x, int y, int velocity) {
        int duration;
        //默认600ms
        duration = Math.min(duration, MAX_SETTLE_DURATION);
        
        mScroller.startScroll(sx, sy, dx, dy, duration);
    }
    

    这个滚动最终会调到Scroller的startScroll()方法上来,但是通过源码的查看,我们发现只有改变Scroller这个对象才能改变他切换的时间

    所以现在我们改变ViewPager切换速率的方式有两种

    • duration 持续时间,但他是局部变量
    • 改变mScroller,但是他是private属性,所以通过反射去设置

    duration是局部变量,我们无从下手,但是反射改变mScroller还是可行的

    5.2. 自定义Scroller去替换ViewPager中的mScroller

    我们首先要创建一个Scroller

    /**
     * 改变ViewPager切换的速率
     */
    public class BannerScroller extends Scroller {
    
        //动画持续时间
        private int mScrollerDuration = 950;
    
        /**
         * 设置切换页面持续的时间
         * @param scrollerDuration
         */
        public void setScrollerDuration(int scrollerDuration) {
            mScrollerDuration = scrollerDuration;
        }
    
        public BannerScroller(Context context) {
            super(context);
        }
    
        public BannerScroller(Context context, Interpolator interpolator) {
            super(context, interpolator);
        }
    
        public BannerScroller(Context context, Interpolator interpolator, boolean flywheel) {
            super(context, interpolator, flywheel);
        }
    
        @Override
        public void startScroll(int startX, int startY, int dx, int dy, int duration) {
            super.startScroll(startX, startY, dx, dy, mScrollerDuration);
        }
    }
    

    5.3. 在自定义ViewPager中使用反射,改变mScroller的值

    然后我们在自定义的ViewPager的构造函数中,去使用反射

    public BannerViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    
        //改变ViewPager切换的速率,两种方式
        //1. duration 持续时间,但他是局部变量
        //2. 改变mScroller,但是这个属性是private的,所以通过反射去设置
        try {
            //获取属性
            Field field = ViewPager.class.getDeclaredField("mScroller");
            mScroller = new BannerScroller(context);
            //设置为强制改变private,不然可能提示我们不能修改私有属性
            field.setAccessible(true);
            //设置参数 第一个object当前属性在哪个类  第二个参数代表要设置的值
            field.set(this, mScroller);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }
    

    6. 自定义BannerView: 封装轮播图和指示器

    由于我们一直使用的ViewPager做轮播图,现在如果想要给轮播图加上指示器(文字+点)的话,只能对ViewPager和指示器做再一次的封装,把他们封装到一个BannerView里面,这样我们使用的时候,才能做到简单方便

    6.1. 封装布局

    先用一个布局把ViewPager和存放点的ViewGroup封装到一个布局中

    <?xml version="1.0" encoding="utf-8"?>
    <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=".BannerActivity">
    
        <com.example.recyclerviewanalisys.banner.BannerViewPager
            android:id="@+id/banner_vp"
            android:layout_width="match_parent"
            android:layout_height="match_parent"/>
    
        <RelativeLayout
            android:paddingBottom="5dp"
            android:paddingStart="10dp"
            android:paddingEnd="10dp"
            android:paddingTop="5dp"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/banner_bottom_bar_bg_day"
            android:layout_alignParentBottom="true">
    
            <TextView
                android:id="@+id/banner_desc_tv"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:textSize="12sp"
                android:textColor="@color/white"
                android:text="广告的描述"/>
    
            <LinearLayout
                android:id="@+id/dot_container"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal">
    
            </LinearLayout>
    
        </RelativeLayout>
    
    </RelativeLayout>
    

    6.2. 创建BannerView类

    布局建好之后,我们要把布局和当前的这个类绑定起来

    //把布局加载到View这个里面
    inflate(context, R.layout.ui_banner_layout,this);
    

    然后要把ViewPager的Adapter换到这里来,因为我们现在对外用的是BannerView了,而不是ViewPager了

    public void setAdapter(BannerAdapter adapter) {
        mAdapter = adapter;
        mBannerVp.setAdapter(adapter);
        //初始化点的指示器
        initDotIndicator();
    }
    

    当然,设置了Adapter,也就要开始设置ViewPager的滚动速度了,这个在上面已经分析过了

    public void startRoll() {
        mBannerVp.startRoll();
    }
    

    其实目前为止,我们都是在对ViewPager进行包装,把ViewPager的属性,通过BannerView抛给外界使用

    下面就是轮播图的圆点指示器了

    6.3. 指示器+ 数据描述

    这个圆点指示器我们可以直接用ImageView去反映指示器的状态,也可以自定义一个View类,通过设置View的Drawable来反映指示器的状态,这里采用第二种

    import androidx.annotation.Nullable;
    
    public class DotIndicatorView extends View {
    
        private Drawable mDrawable;
    
        public DotIndicatorView(Context context) {
            this(context,null);
        }
    
        public DotIndicatorView(Context context, @Nullable AttributeSet attrs) {
            this(context, attrs,0);
        }
    
        public DotIndicatorView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
        }
    
        @Override
        protected void onDraw(Canvas canvas) {
            if (mDrawable != null){
                mDrawable.setBounds(0,0,getMeasuredWidth(),getMeasuredHeight());
                mDrawable.draw(canvas);
            }
        }
    
        /**
         * 设置Drawable
         * @param drawable
         */
        public void setDrawable(Drawable drawable) {
            this.mDrawable = drawable;
            //重新绘制View
            invalidate();
        }
    }
    

    创建好类之后,我们开始初始化指示器的圆点,初始化:位置、大小、间距、状态、监听等等,我们设置监听的目的,是为了在ViewPager滑动的时候,让我们根据当前的位置去获取数据

    private void initDotIndicator() {
        int count = mAdapter.getCount();
        //让点的位置在轮播图的右边
        mDotContainer.setGravity(Gravity.END);
    
        for (int i = 0; i < count; i++) {
            //不断的往点的指示器添加圆点
            DotIndicatorView indicatorView = new DotIndicatorView(mContext);
            //设置大小
            LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dip2px(8), dip2px(8));
            indicatorView.setLayoutParams(params);
            //设置左右间距
            params.leftMargin = params.rightMargin = dip2px(2);
            if (i == 0){
                //选中位置
                indicatorView.setDrawable(mIndicatorFocusDrawable);
            }else{
                //未选中的
                indicatorView.setDrawable(mIndicatorNormalDrawable);
            }
            mDotContainer.addView(indicatorView);
        }
    
        //监听
        mBannerVp.addOnPageChangeListener(new ViewPager.SimpleOnPageChangeListener() {
            @Override
            public void onPageSelected(int position) {
                //监听当前选中的位置
                pageSelect(position);
            }
        });
    }
    
    private int dip2px(int dip) {
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,dip,getResources().getDisplayMetrics());
    }
    
    /**
    * 页面切换的回调
    * @param position
    */
    private void pageSelect(int position) {
        //把之前选中状态的点改为正常
        DotIndicatorView oldIndicatorView = (DotIndicatorView) mDotContainer.getChildAt(mCurrentPosition);
        oldIndicatorView.setDrawable(mIndicatorNormalDrawable);
        //更新当前的位置,先更新,再设置
        mCurrentPosition = position%mAdapter.getCount();
        //把当前位置的点,改成选中状态  position:  0 --> 2^31
        DotIndicatorView currentIndicatorView = (DotIndicatorView) mDotContainer.getChildAt(mCurrentPosition);
        currentIndicatorView.setDrawable(mIndicatorFocusDrawable);
    
    
        //设置广告描述
        String bannerDesc = mAdapter.getBannerDesc(mCurrentPosition);
        mBannerDescTv.setText(bannerDesc);
    }
    

    不过这里有一个小bug,就是我们轮播图刚开始的时候,并没有回调监听事件,所以就不会改变广告的描述,所以,我们要在setAdapter的时候,默认设置第一个数据的广告描述

    public void setAdapter(BannerAdapter adapter) {
        mAdapter = adapter;
        mBannerVp.setAdapter(adapter);
        //初始化点的指示器
        initDotIndicator();
        //初始化广告的描述,默认第一条
        String bannerDesc = mAdapter.getBannerDesc(mCurrentPosition);
        mBannerDescTv.setText(bannerDesc);
    }
    

    6.4. 使用圆点指示器

    上面的的指示器是我们直接设置Drawable的的Bounds属性,来确定每个点的绘制区域,然后再在画布上画Drawable。

    @Override
    protected void onDraw(Canvas canvas) {
        if (mDrawable != null){
    //            mDrawable.setBounds(0,0,getMeasuredWidth(),getMeasuredHeight());
    //            mDrawable.draw(canvas);
            //从drawable中得到Bitmap
            Bitmap bitmap = drawableToBitmap(mDrawable);
    
            //把Bitmap变为圆的
            Bitmap circleBitmap = getCircleBitmap(bitmap);
    
            //把圆形的bitmap绘制到画布上
            canvas.drawBitmap(circleBitmap,0,0,null);
        }
    }
    

    所以我们要画圆形的指示器的话,就得改变DotIndicatorView的绘制方式。我们先要把Drawable变成矩形的Bitmap,然后再把矩形的Bitmap变为圆的,最后再把圆的Bitmap画在画布上

    1. 把Drawable变成Bitmap

      private Bitmap drawableToBitmap(Drawable drawable) {
          //如果是BitmapDrawable类型
          if (drawable instanceof BitmapDrawable){
              return ((BitmapDrawable)drawable).getBitmap();
          }
          //如果是其他类型 ColorDrawable
          //创建一个什么也没有的bitmap
          Bitmap outBitmap = Bitmap.createBitmap(getMeasuredWidth(), getHeight(), Bitmap.Config.ARGB_8888);
          //创建一个画布
          Canvas canvas = new Canvas(outBitmap);
          //把Drawable画到Bitmap上
          drawable.setBounds(0,0,getMeasuredWidth(),getMeasuredHeight());
          drawable.draw(canvas);
          return outBitmap;
      }
      
      

      这里先判断传过来的Drawable是不是BitmapDrawable的子类,如果是就直接返回Bitmap了,没必要转换了,如果不是他的子类,就需要先创建一个Bitmap对象,然后再创建一个画布(把Bitmap和画布绑定起来),最后再把Drawable绘制到Bitmap上,由于之前画布和Bitmap已经绑定,所以此时Bitmap上已经有绘制的Drawable,也就达到了我们把Drawable转换成Bitmap的目的

    2. 把Bitmap变为圆的

      private Bitmap getCircleBitmap(Bitmap bitmap) {
          //创建一个圆形的Bitmap
          Bitmap circleBitmap = Bitmap.createBitmap(getMeasuredWidth(),getMeasuredHeight(),Bitmap.Config.ARGB_8888);
          Canvas canvas = new Canvas(circleBitmap);
      
          Paint paint = new Paint();
          //抗锯齿
          paint.setAntiAlias(true);
          paint.setFilterBitmap(true);
          //仿抖动
          paint.setDither(true);
      
          //在画布上面画个圆
          canvas.drawCircle(getMeasuredWidth()/2,getMeasuredHeight()/2,getMeasuredWidth()/2,paint);
      
          //设置model,取圆和bitmap矩阵的交集的模式  ---srcIn
          paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN));
          //再把原来的bitmap绘制到新的圆上面
          canvas.drawBitmap(bitmap,0,0,paint);
          return circleBitmap;
      }
      

      他的思路也是先创建一个新的Bitmap,然后再创建一个Paint对象,再用Paint对象在Canvas上面绘制出一个圆,然后注意,我们需要改变Paint的模式,因为接下来我们要绘制一个矩形(Drawable变成Bitmap时生成的Bitmap矩形),这个时候再用默认的SRC_OVER模式,会让矩形部分把圆的部分遮住

    image-20201102192228598.png

    但这不是我们想要的效果,我们想要两个的交集部分显示出来

    image-20201102192432283.png

    这样,所以我们要设置他的模式为SRC_IN,让矩形和原型的交集部分 = 圆给显示出来,设置完Paint的模式后,我们把之前的矩形Bitmap绘制到canvas上面去,此时两个Bitmap的区域会重叠,Paint绘制的时候,绘制的是两个的交集部分

    1. 最后,把圆形Bitmap绘制到onDraw()方法的Canvas上面去。这样我们的圆形指示器就完工了

    到了这里,其实我们的功能做的差不多了,效果看上去也很ok,但是要考虑一下他的扩展性问题了,因为不可能每次使用都来看一遍源码,然后开始改源码,我们得提供一个高效的办法。所以就有了我们的自定义属性

    7. 自定义属性

    我们要先确定自己要定义的属性有哪些

    • 点的颜色(选中和未选中)
    • 点的大小
    • 点的间距
    • 点的位置
    • 底部颜色

    7.1. 定义属性

    创建attr.xml文件

    <?xml version="1.0" encoding="utf-8"?>
    <resources>
        <declare-styleable name="BannerView">
            <!--点的选中的颜色值-->
            <attr name="dotIndicatorFocus" format="color|reference"/>
            <!--点的默认的颜色值-->
            <attr name="dotIndicatorNormal" format="color|reference"/>
            <!--点的大小-->
            <attr name="dotSize" format="dimension"/>
            <!--点的间距-->
            <attr name="dotDistance" format="dimension"/>
            <!--点的位置-->
            <attr name="dotGravity" format="enum">
                <enum name="center" value="0"/>
                <enum name="right" value="1"/>
                <enum name="left" value="-1"/>
            </attr>
            <!--底部颜色-->
            <attr name="bottomColor" format="color"/>
        </declare-styleable>
    </resources>
    

    7.2. 在布局中使用

    <com.example.recyclerviewanalisys.banner.BannerView
        android:id="@+id/banner_view"
        android:layout_width="match_parent"
        android:layout_height="145dp"
        app:dotSize="3dp"
        app:dotDistance="1dp"
        app:dotGravity="center"
        app:bottomColor="@color/banner_bottom_bar_bg_day"
        app:dotIndicatorFocus="@color/dot_select_color"
        app:dotIndicatorNormal="@color/dot_unselect_color"/>
    

    7.3. 获取自定义属性

    public BannerView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        this.mContext = context;
    
        //把布局加载到View这个里面
        inflate(context, R.layout.ui_banner_layout,this);
        //初始化View
        initView();
        //初始化自定义属性
        initAttribute(attrs);
    }
    
    private void initAttribute(AttributeSet attrs) {
        TypedArray typedArray = mContext.obtainStyledAttributes(attrs, R.styleable.BannerView);
    
        //点的位置
        mDotGravity = typedArray.getInt(R.styleable.BannerView_dotGravity, mDotGravity);
        //点的颜色
        mIndicatorFocusDrawable = typedArray.getDrawable(R.styleable.BannerView_dotIndicatorFocus);
        if (mIndicatorFocusDrawable == null){
            //如果在布局文件中没有配置点的颜色,有一个默认值
            mIndicatorFocusDrawable = new ColorDrawable(Color.RED);
        }
        mIndicatorNormalDrawable = typedArray.getDrawable(R.styleable.BannerView_dotIndicatorNormal);
        if (mIndicatorNormalDrawable == null){
            //如果在布局文件中没有配置点的颜色,有一个默认值
            mIndicatorNormalDrawable = new ColorDrawable(Color.WHITE);
        }
        //获取点的大小和距离
        mDotSize = (int) typedArray.getDimension(R.styleable.BannerView_dotSize, dip2px(mDotSize));
        mDotDistance = typedArray.getDimensionPixelSize(R.styleable.BannerView_dotDistance, dip2px(mDotDistance));
        typedArray.recycle();
    }
    

    记得把初始化指示器的地方改成我们的自定义属性

    //设置大小
    LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(dip2px(mDotSize), dip2px(mDotSize));
    indicatorView.setLayoutParams(params);
    //设置左右间距
    params.leftMargin = params.rightMargin = dip2px(mDotDistance);
    

    8. 自适应高度

    在onMeasure中获取到BannerView的宽度,然后再根据宽度*宽高比,就能得到BannerView的高度,这样就达到了自适应的目的

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //先测量,后面才能获取数据
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //防止后面除数为0
        if (mWidthProportion == 0 || mHeightProportion == 0){
            return;
        }
    
        //动态计算宽高,计算高度
        int width = MeasureSpec.getSize(widthMeasureSpec);
        int height = (int)(width*mHeightProportion/mWidthProportion);
        //指定宽高
        setMeasuredDimension(width,height);
    }
    
    

    但是这么设置之后,我们的BannerView仍然不可见,那为什么呢?

    其实道理很简单,因为我们测量BannerView的时候,虽然设置了他的宽高比,但是这个时候他的数据还没有进来,BannerView的ViewPager没有数据,所以ViewPager的宽度为0,自然,BannerView的宽度、高度也就为0。

    所以解决这个办法也变得简单了,我们在有数据进BannerView的时候,去设置他的高度,这样就能达到适配的效果。所在我们在setAdapter里面去动态设置高度

    public void setAdapter(BannerAdapter adapter) {
        mAdapter = adapter;
        mBannerVp.setAdapter(adapter);
        //初始化点的指示器
        initDotIndicator();
        //初始化广告的描述,默认第一条
        String bannerDesc = mAdapter.getBannerDesc(mCurrentPosition);
        mBannerDescTv.setText(bannerDesc);
    
        //动态指定高度
        //防止后面除数为0
        if (mWidthProportion == 0 || mHeightProportion == 0){
            return;
        }
    
        //动态计算宽高,计算高度
        int width = getMeasuredWidth();
        int height = (int)(width*mHeightProportion/mWidthProportion);
        //指定宽高
        getLayoutParams().height = height;
    }
    

    9. 代码优化

    思考:当我们复用这个控件的时候,

    • 是不是足够方便
    • 可扩展性强不强
    • 内存优化

    9.1. Handler内存泄漏问题

    内存优化,我们在Handler得内存泄漏的时候已经接触过,如果我们退出Activity的时候,不让Handler的Message清除和销毁Handler,那么我们退出Activity后,Handler任然会继续执行

    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        mHandler.removeMessages(SCROLL_MSG);
        mHandler = null;
    }
    

    9.2. 界面复用问题

    当我们ViewPager里面滚动的时候,每次都会调用instantiateItem()去新建一个View,并通过destroyItem()方法去销毁一个View

    public Object instantiateItem(@NonNull ViewGroup container, int position) {
        //Adapter设计模式为了完全让用户自定义
        // position 的变化 0 -> 2^31 会溢出,所以我们对总数据进行求模运算
        View bannerItemView = mAdapter.getView(position%mAdapter.getCount());
        //...
        return bannerItemView;
    }
    
    /**
     * 销毁条目回调的方法
     */
    @Override
    public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
        container.removeView((View)object);
        object = null;
    }
    

    这样每次都创建、销毁,我们就要考虑一下View复用的问题了,我们应该像ListView里面那样,缓存View,然后方便复用

    所以我们要在BannerViewPager里面去设置一个View作为缓存的View

    //复用
    private View mConvertView;
    

    然后修改创建和销毁子View的过程

    public Object instantiateItem(@NonNull ViewGroup container, int position) {
        //Adapter设计模式为了完全让用户自定义
        // position 的变化 0 -> 2^31 会溢出,所以我们对总数据进行求模运算
        View bannerItemView = mAdapter.getView(position%mAdapter.getCount(),mConvertView);
        //...
        return bannerItemView;
    }
    
    /**
     * 销毁条目回调的方法
     */
    @Override
    public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
        container.removeView((View)object);
        //缓存
        mConvertView = (View) object;
    }
    

    我们在创建ItemView的时候,把我们缓存的ItenView传出去,方便用户去复用这个View,而销毁的时候,不再把object销毁,而是缓存起来。然后我们在外面调用的时候,就能使用缓存的View了。

    mBannerView.setAdapter(new BannerAdapter() {
        @Override
        public View getView(int position, View convertView) {
            ImageView imageView = null;
            //缓存复用
            if (convertView == null){
                imageView = new ImageView(BannerActivity.this);
                imageView.setScaleType(ImageView.ScaleType.FIT_XY);
            }else{
                imageView = (ImageView) convertView;
            }
            String imagePath = mData.get(position).getCoverMiddle();
            Glide.with(BannerActivity.this).load(imagePath)
                    .placeholder(R.drawable.ic_launcher_foreground)//加载占位图(默认图片)
                    .into(imageView);
            return imageView;
        }
    
        //...
    });
    

    但是这样还有一个BUG,就是当我们快速滑动ViewPager的时候,会出现一个错误,显示java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.这个,这是因为,我们滑动得太快,导致还没调用销毁ItemView的方法前(销毁方法之后,convertView就不属于任何父View了),就已经把当前在ViewPager中的convertView又拿来赋值了,此时的convertView是ViewPager中的子View,但我们的操作是在让他重新加入一个父View,这个时候两个父View就冲突了

    解决办法,我们应该多缓存几个,以便有足够的View去复用,所以我们的把convertView设置成一个List

    //复用
    private List<View> mConvertView;
    

    然后销毁ItemView的时候,把销毁的object添加到mConvertView里面去

    public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
        container.removeView((View)object);
        mConvertView.add((View) object);
    }
    

    而在创建的时候,我们通过一个方法去判断当前的convertView里面的View是否还有未添加到ViewPager中的,如果有就拿去复用,否则给调用的地方返回一个null,让调用的地方去处理

    public Object instantiateItem(@NonNull ViewGroup container, int position) {
        bannerItemView = mAdapter.getView(position%mAdapter.getCount(),getConvertView());   
    }
    
    /**
    * 获取复用界面
     * @return
    */
    private View getConvertView() {
        for (int i = 0; i < mConvertView.size(); i++) {
            //获取没有添加ViewPager里面的
            if (mConvertView.get(i).getParent() != null){
                return mConvertView.get(i);
            }
        }
        return null;
    }
    

    9.3. bitmap内存优化

    在我们创建指示器的点的时候,有一个步骤就是把两个Bitmap重叠,然后只显示其交集,然后返回一个Bitmap,那么另一个Bitmap就没使用了,所以我们最后要去把不再使用的Bitmap给回收掉

    private Bitmap getCircleBitmap(Bitmap bitmap) {
        //创建一个圆形的Bitmap
        Bitmap circleBitmap = Bitmap.createBitmap(getMeasuredWidth(),getMeasuredHeight(),Bitmap.Config.ARGB_8888);
        //...
    
        //回收Bitmap
        bitmap.recycle();
        bitmap = null;
        return circleBitmap;
    }
    

    9.4. 设置监听回调

    在BannerViewPager中,定义接口,并把设置的方法抛给外界使用

    /**
     * 设置监听
     */
    public void setBannerItemClickListener(BannerItemClickListener listener){
        this.mListener = listener;
    }
    
    /**
     * 监听回调
     */
    public interface BannerItemClickListener{
        void onItemClick(int position);
    }
    

    然后在BannerViewPager中,创建ItemView的地方给ItemView设置监听

    public Object instantiateItem(@NonNull ViewGroup container, final int position) {
        //Adapter设计模式为了完全让用户自定义
        // position 的变化 0 -> 2^31 会溢出,所以我们对总数据进行求模运算
        View bannerItemView;
        bannerItemView = mAdapter.getView(position%mAdapter.getCount(),getConvertView());
        //设置监听
        bannerItemView.setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                mListener.onItemClick(position%mAdapter.getCount());
            }
        });
        //让用户去添加,实现用户的自定义
        // 添加ViewPager里面
        container.addView(bannerItemView);
        return bannerItemView;
    }
    

    和之前得BannerViewPager的属性一样,我们得把BannerViewPager的属性通过BannerView抛给外界使用,所以我们还需要在BannerView中设置

    /**
     * 点击监听
     * @param listener
     */
    public void setBannerItemClickListener(BannerViewPager.BannerItemClickListener listener){
        mBannerVp.setBannerItemClickListener(listener);
    }
    

    这样外界就能监听到BannerView中每个ItemView的点击事件了

    9.5. 管理Activity的生命周期

    我们看一个现象,在我们的BannerView滚动的时候,我们按下Hone键,回到桌面,此时我们的BannerView已经进入后台进程了,但是我们看控制台的打印,发现BannerViewPager还在滚动,这不是我们想要的,所以我们应该让BannerViewPager和Activity的生命周期结合起来

    如果是简单的结合,我们可以在Activity中,去设置BannerViewPager的滚动,但是这样太麻烦,每次都需要我们在Activity去设置,所以,我们要是在BannerViewPager里面监听Activity的生命周期,然后去控制BannerViewPager的滚动,这样就会方便很多。

    首先创建一个类,让他实现ActivityLifecycleCallbacks接口,这样,我们只需要实例化的时候,直接实例化DefaultActivityLifecycleCallbacks这个类,然后选择性的重写我们想要的方法,不用把ActivityLifecycleCallbacks中的所有方法都实现了

    /**
     * 默认实现ActivityLifecycle生命周期的回调
     */
    public class DefaultActivityLifecycleCallbacks implements Application.ActivityLifecycleCallbacks {
        @Override
        public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
    
        }
    
        @Override
        public void onActivityStarted(@NonNull Activity activity) {
    
        }
    
        @Override
        public void onActivityResumed(@NonNull Activity activity) {
    
        }
    
        @Override
        public void onActivityPaused(@NonNull Activity activity) {
    
        }
    
        @Override
        public void onActivityStopped(@NonNull Activity activity) {
    
        }
    
        @Override
        public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {
    
        }
    
        @Override
        public void onActivityDestroyed(@NonNull Activity activity) {
    
        }
    }
    

    然后我们创建一个ActivityLifecycleCallbacks对象,选着性的重写DefaultActivityLifecycleCallbacks里面的onActivityResumed()方法和onActivityPaused()方法

    //管理Activity的生命周期
    Application.ActivityLifecycleCallbacks mActivityLifecycleCallbacks = new DefaultActivityLifecycleCallbacks(){
        @Override
        public void onActivityResumed(@NonNull Activity activity) {
            //注意监听的是不是当前Activity的生命周期,因为我们这里监听的是所有的Activity的生命周期
            Log.d(TAG, "onActivityResumed: current activity --> " + activity);
            Log.d(TAG, "onActivityResumed: current getContext() --> " + getContext());
            if (activity == getContext()){
                //开启轮播
                mHandler.sendEmptyMessageDelayed(mCutDownTime,SCROLL_MSG);
            }
        }
    
        @Override
        public void onActivityPaused(@NonNull Activity activity) {
            if (activity == getContext()){
                //停止轮播
                mHandler.removeMessages(SCROLL_MSG);
            }
        }
    };
    

    通过这个对象我们可以去监听当前Activity的生命周期。在设置Adapter的方法里,我们注册一下这个Callback

    public void setAdapter( BannerAdapter adapter) {
        this.mAdapter = adapter;
        //设置父类 ViewPager的adapter
        //这里设置Adapter之后,会不断的循环调用instantiateItem()方法,去增加ItemView
        setAdapter(new BannerPagerAdapter());
        //管理Activity的生命周期
        //这里的Context就是Activity
        ((Activity)getContext()).getApplication().registerActivityLifecycleCallbacks(mActivityLifecycleCallbacks);
    }
    

    这样,当我们的BannerViewPager运行在后台的时候,就会停止滚动了。当再次进入BannerViewPager的时候,就会继续滚动

    当然,有注册就有注销,在BannerViewPager退出的时候,我们需要将生命周期的监听从BannerViewPager里面注销掉

    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        //解除绑定
        ((Activity)getContext()).unregisterActivityLifecycleCallbacks(mActivityLifecycleCallbacks);
        mHandler.removeMessages(SCROLL_MSG);
        mHandler = null;
    }
    

    10. 总结

    这个轮播图到这里就做的差不多了。

    三、个人仓库的搭建

    1. 发布个人开源库教程:

    搭建个人开源库教程

    2. 开源库依赖

    allprojects {
        repositories {
            ...
            maven { url 'https://jitpack.io' }
        }
    }
    
    dependencies {
        implementation 'com.github.LuoSPro:BannerView:1.0.1'
    }
    

    3. gitHub地址

    gitHub地址

    四、问题:

    java.lang.NoSuchMethodError: No virtual method unregisterActivityLifecycleCallbacks(Landroid/app/Application$ActivityLifecycleCallbacks;)V in class Landroid/app/Activity; or its super classes (declaration of 'android.app.Activity' appears in /system/framework/framework.jar)
    

    解决:

    因为unregisterActivityLifecycleCallbacks()方法时Application里面的,而我们在注销时,直接使用

    mActivity.unregisterActivityLifecycleCallbacks(mActivityLifecycleCallbacks);
    

    这种形式调用的,Activity不能去调用这个方法,会抛异常,所以应该

    mActivity.getApplication().unregisterActivityLifecycleCallbacks(mActivityLifecycleCallbacks);
    

    应该这样

    相关文章

      网友评论

        本文标题:【Android轮子】自定义轮播图

        本文链接:https://www.haomeiwen.com/subject/xgowvktx.html