【Android轮子】自定义轮播图

前言:
这是自己实现的一个轮播图控件,下面的文章是记录自己的开发过程。这个项目我已经做成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 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封装到一个布局中




    

    

        

        

        

    


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文件



    
        
        
        
        
        
        
        
        
        
        
            
            
            
        
        
        
    

7.2. 在布局中使用


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 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轮子】自定义轮播图)