话不多说 先看效果:
四种效果Demo老规矩 不想听俺叨逼叨的请移步: GitHub - SuperBanner
首先总结下需求:
1:支持手指循环滑动
2:支持定时轮播
3:支持手指触摸/滑动轮播区域时停止轮播,手指离开重新轮播
4:支持轮播图片简述及导航(指示器)标识
5:支持图片点击事件回调
6:支持自定义item切换速度
7:支持item圆角图片展示
8:支持item切换动画(两种)
关于ViewPager2
2018 年 9 月 21 日谷歌发布了首个AndroidX
稳定版本 ----AndroidX 1.0.0
。后续版本中,谷歌意图用AndroidX
逐步替代android.support.xxx
包 那么自然,隶属于AndroidX
下的ViewPager2
也将会替代ViewPager
然而就在前几天(2019年11月20日)
ViewPager2
也更新了一个正式稳定版ViewPager2 1.0.0
官方文档关于ViewPager2的更新及使用方法
不过,考虑到
AndroidX
的适配问题和现阶段的普适程度,此banner效果依然使用ViewPager
实现,所以也不打算展开来讲ViewPager2
,后续我会单写一篇文章详细的介绍和使用ViewPager2
并实现此效果,总之,无论用哪种控件实现,思路才最重要。
Google GitHub的ViewPager2
Demo 各位有兴趣的可以跑起来先耍耍:
https://github.com/googlesamples/android-viewpager2
进入正题
首先我们需要在调用层(Activity)布局文件中定义出我们自定义的ViewPager
相关布局并设置一些基本的属性:
<android.support.v4.view.ViewPager
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="180dp" />
<!--指示器的布局-->
<LinearLayout
android:id="@+id/indicator_ly"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBottom="@id/view_pager"
android:layout_alignParentRight="true"
android:layout_marginRight="25dp"
android:layout_marginBottom="8dp"
android:orientation="horizontal"></LinearLayout>
ok 这个时候还需要一个Adapter
来设置数据:
package com.banner.superbanner;
import android.support.annotation.NonNull;
import android.support.v4.view.PagerAdapter;
import android.view.View;
import android.view.ViewGroup;
public class BannerAdapter extends PagerAdapter {
private BannerBean mBannerBean;
private OnLoadImageListener mOnLoadImageListener;
/**
* @param bannerBean 装有图片路径的数据源
* @param onLoadImageListener 加载图片的回调接口 让调用层处理加载图片的逻辑
*/
private BannerAdapter(BannerBean bannerBean, OnLoadImageListener onLoadImageListener) {
this.mBannerBean = bannerBean;
this.mOnLoadImageListener = onLoadImageListener;
}
@Override
public int getCount() {
return 0;
}
@NonNull
@Override
public Object instantiateItem(@NonNull ViewGroup container, int position) {
return super.instantiateItem(container, position);
}
@Override
public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
container.removeView((View) object);
}
@Override
public boolean isViewFromObject(@NonNull View view, @NonNull Object o) {
return view == o;
}
}
这些个方法,用过的都知道不多说。接下来主要是在getCount()
和instantiateItem()
中搞事情。
构造方法中的BannerBean
是我请求服务器后通过Gson
解析后生成的实体bean
,你也可以把图片组装到List
集合或者arr
数组中 具体还要看你们的业务逻辑。
OnLoadImageListener
主要是一个callback接口 主要用于将加载图片的逻辑回调给调用层去处理 这个后续会讲到,OnLoadImageListener
接口内容如下:
package com.banner.superbanner;
import android.content.Context;
import android.widget.View;
public interface OnLoadImageListener {
//最后一个参数类型为View而不是ImageView,主要为了适应item布局的多样性 使用时强转一下就行了
void loadImage(Context context, BannerBean bannerBean, int position, View imageView);
}
参数就不用我多说了吧,看一下基本就明白了,就是加载图片时需要的一些信息。
在Activity
中请求服务器先把图片路径地址拿到:
OkHttpClient okHttpClient = new OkHttpClient();
final Request request = new Request.Builder()
.url("http://192.168.0.105:8080/banner/banner_image.json")
.get()
.build();
Call call = okHttpClient.newCall(request);
call.enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
}
@Override
public void onResponse(Call call, Response response) throws IOException {
if (response.code() == 200) {
Gson gson = new Gson();
//Type type = new TypeToken<BannerBean>(){}.getType();
mBannerBean = gson.fromJson(response.body().string(), BannerBean.class);
Message message = new Message();
message.arg1 = OK;
handler.sendMessage(message);
}
}
});
可以看到 请求的host是我的本机内网ip 为了测试方便 我直接在Tomcat上放了几张图片 并且写了一个简单的json文件模拟服务器返回的数据
请求到的图片地址如下:
"http://192.168.0.105:8080/pic/01.png",
"http://192.168.0.105:8080/pic/02.png",
"http://192.168.0.105:8080/pic/03.png",
"http://192.168.0.105:8080/pic/04.png",
"http://192.168.0.105:8080/pic/05.png",
"http://192.168.0.105:8080/pic/06.png"
拿到数据源后 在PagerAdapter
的instantiateItem()
中创建ImageView
对象:
@Override
public int getCount() {
return mBannerBean.getData().size();
}
@NonNull
@Override
public Object instantiateItem(@NonNull ViewGroup container, int position) {
ImageView iv= new ImageView(container.getContext());
//等比例缩放图片,占满容器
iv.setScaleType(ImageView.ScaleType.FIT_XY);
if (null!=mOnLoadImageListener){
//设置回调,传入数据 让调用层(Activity)去处理加载图片的逻辑
mOnLoadImageListener.loadImage(container.getContext(),mBannerBean,position,iv);
}
//把每一个item(ImageView)添加到ViewPager容器中
container.addView(iv);
return iv;
}
适配器设置完毕后 在Activity
中给ViewPager
添加适配器并加载图片:
mViewPager.setAdapter(new BannerAdapter(mBannerBean, new OnLoadImageListener() {
@Override
public void loadImage(Context context, BannerBean bannerBean, int position, View imageView) {
Glide.with(context)
.load(bannerBean.getData().get(position))
.into((ImageView)imageView);
}
}));
此时运行项目:
初步效果演示可以看到 总共6张图片 在我滑动到最后一张的时候 我们需要让它继续从头开始循环滑动。
手指滑动“无限循环”
这里就要说到ViewPager
的Adpater
中的getCount()
函数,这个函数的返回值就是当前ViewPager
的总页数(item),当ViewPager
滑动到最后一页 也就是当前item的position为getCount()-1
的时候 就会认为已经滑动到了末尾。
所以,我们这里所说的无限循环滑动其实是一个伪概念 因为我们数据源的总大小也才6张图片 等我们滑到第5个item的时候理论上已经滑不动了 但为了做出无限循环效果,我们可以给getCount()
返回一个非常大的数 让它很难滑动到尽头。
比较主流的做法是直接返回Interger的最大值:
@Override
public int getCount() {
//return mBannerBean.getData().size();
return Integer.MAX_VALUE; //返回Integer的最大值,实现“手指滑动无限循环”
}
Integer最大值SDK文档解释
如图 ,MAX_VALUE
的值为: 2的31次方减1 得出的一个常量值:2147483647,换句话说 理论上你需要滑动二十一亿四千七百四十八万三千六百四十七次才能滑动到尽头....
想必世界上应该还没有如此耿直的人非要滑那么多次的吧 那么 它就是“无限循环”
或者你还可以这样写:
@Override
public int getCount() {
//return mBannerBean.getData().size();
//返回数据源大小的整数倍
return (mBannerBean.getData().size() * 10000 * 100);
}
这种是直接返回数据源的整数倍的方式,个人推荐这种写法 原因后续会讲到。反正不管怎么写 核心就是返回一个非常大的数 在相当长的时间内滑不到尽头。
ps: 关于无限循环 市面上还有一些其他做法 比如重写 OnPageChangeListener 接口中的onPageSelected 方法或者我看有些人通过动态添加/复用头尾item的方式做到所谓“真正意义上的无线循环”,有兴趣请自行浏览器了解
ok 我们设置完getCount()
返回值后,此时我们如果直接运行项目 会报出IndexOutOfBoundsException
异常,其原因在于:我们设置了ViewPager的item的总大小但并没有对position进行处理,当postion的值超出了数据源(list集合)的大小 就会抛出索引越界异常
所以当前的position如果超出我们数据源的最大值(最大值为6) 我们需要把这个position处理成数据源范围内的值:
@NonNull
@Override
private Object instantiateItem(@NonNull ViewGroup container, int position) {
ImageView iv= new ImageView(container.getContext());
iv.setScaleType(ImageView.ScaleType.FIT_XY);
Log.i("TEST_POSITION","处理之前的position: "+position);
//处理position 通过取余数的方式来限定position的取值范围
position = position % mBannerBean.getData().size();
Log.i("TEST_POSITION","处理之后的position:"+position);
if (null!=mOnLoadImageListener){
//设置回调,传入数据 让调用层(Activity)去处理加载图片的逻辑
mOnLoadImageListener.loadImage(container.getContext(),mBannerBean,position,iv);
}
//把每一个item(ImageView)添加到ViewPager容器中
container.addView(iv);
return iv;
}
刚好 我们可以通过取余数的特性 限定position的取值范围: 从0到数据源大小-1之间
此时运行项目并打印position日志:
可以看到 已经可以无限的向右滑动了,我向右滑动了两轮 此时Log打印出position值为:
position处理前与处理后的值
看到了8~ 如果没处理position 当position为6的时候 就已经索引越界了。我们通过取余处理后 position值就能按顺序控制在0-6之间以此类推
ok 看似已经实现了手指滑动无限循环 但有一个小问题 我向右滑动没问题 但我向左边滑动到position值为0的item的时候就滑不动了,ViewPager
就会认为我左边已经没有item了。
解决这个问题 只需要让ViewPager左滑时 在相当长的时间内滑不到0的位置
很简单,ViewPager中有一个API:
官方文档API解释
Set the currently selected page. If the ViewPager has already been through its first layout with its current adapter there will be a smooth animated transition between the current item and the specified item.
设置当前选择的页面。如果ViewPager已经使用当前适配器完成了它的第一个布局,那么当前项和指定项之间将有一个平滑的动画过渡。
一般情况下ViewPager
初始化时默认的item位置为0。
但我们可以使用这个API给ViewPager
一个初始位置:
//ViewPager初始化时 滑动到一半的距离
mViewPager.setCurrentItem((mViewPager.getAdapter().getCount()) / 2);
在初始化的时候 给ViewPager
设置初始位置为:总条目数的一半
这样一来 不论是左滑还是右滑都不会滑到"尽头"
但问题来了 还记得刚刚提到的实现无限循环在getcount()
中的两种返回方式的写法吗? 一个是返回Integer
最大值 一种是返回数据源的整数倍,并且我还推荐使用整数倍的写法。
如果你使用的是返回Integer
最大值的方式:
你会发现当你冷启动App时 ViewPager
显示的item位置经过取余处理后 仍然不会在第一位,一般情况下 我们正常需求肯定都是初始化显示第一张图片(position = 0) 为什么会出现这种情况呢?
原因就在于:这个数不能被整除
所以你还要对它的余数进行拼差处理, 太麻烦了 而且这个数也太大 我们没必要设置这么大的数。
所以个人推荐使用整数倍的方式 。
定时轮播
在Android中 想要周期性执行任务基本有以下几种方式:
- Timer+TimerTask
- 延时Handler(postDelay)
- 周期性执行任务的线程池
首先pass掉第三种 不解释 第一种和第二种用哪个都可以 很多人在用postDelay
的方式 那咱们就用Timer+TimerTask吧:
在Acitivity
类中:
private Timer mTimer;
private TimerTask mTimerTask;
/**
* 开启一个延时任务并执行
*/
private void executeDelayedTask() {
//在创建任务之前 一定要检查清理未回收的任务,保证只有一组Timer+TimerTask
killDelayedTask();
mTimer = new Timer();
mTimerTask = new TimerTask() {
@Override
public void run() {
runOnUiThread(new Runnable() {
@Override
public void run() {
//显示下一页
showNextPage();
}
});
}
};
//设置delay参数为3000毫秒表示用户调用schedule() 方法后,要等待3秒才可以第一次执行run()方法
//设置period参数为4000 表示第一次调用之后,从第二次开始每隔4秒调用一次run()方法
mTimer.schedule(mTimerTask, 3000, 4000);
}
/**
* @Description 取消(清理)延时任务
*/
private void killDelayedTask() {
if (mTimer != null) {
mTimer.cancel();
mTimer = null;
}
if (mTimerTask != null) {
mTimerTask.cancel();
mTimerTask = null;
}
}
/**
* @Description 显示下一页
*/
private void showNextPage() {
//获取到当前页面的位置
int currentPageLocation = mViewPager.getCurrentItem();
//设置item位置为: 当前页面的位置+1
mViewPager.setCurrentItem(currentPageLocation + 1);
}
如上 使用Timer
+TimerTask
执行定时任务 这个任务就是: showNextPage()
显示下一页。
手指触摸/滑动轮播区域时停止轮播,手指离开重新轮播
这个也很简单 只需要用到ViewPager
的一个API
依旧是在Acivity
类中:
/**
* @Description 在手指按下和移动时 清除延时任务,待手指松开重新创建任务
*/
private void setViewPagerTouchListener() {
mViewPager.setOnTouchListener(new OnTouchListener() {
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
killDelayedTask();
break;
case MotionEvent.ACTION_MOVE:
killDelayedTask();
break;
case MotionEvent.ACTION_UP:
executeDelayedTask(mDelay,mDelay);
break;
}
return false;
}
});
}
在Activity
的onCreate()
中初始化一下:
//设置3秒钟后开始执行任务 每个任务之间隔4秒执行一次
superBanner.executeDelayedTask();
//初始化touch事件
superBanner.setViewPagerTouchListener();
此时运行项目:
定时轮播+手指触停
注意看 我手指触摸滑动的时候 此时会停止轮播 当手指松开后 又会继续轮播。
底部指示器:
定时轮播完成后 我们想在ViewPager
底部显示一排"指示器",可以随页面的滑动更改状态
依然实在Activity
类中:
/**
* @Description 初始化ViewPager底部指示器
* @param indicatorLayout 指示器的父布局 由调用者提供
*/
public void initIndicatorView(Context context, BannerBean bannerBean, ViewGroup indicatorLayout) {
this.mIndicatorLayout = indicatorLayout;
for (int i = 0; i < bannerBean.getData().size(); i++) {
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(dpToPx(6), dpToPx(6));
lp.leftMargin = dpToPx(10);
lp.bottomMargin = dpToPx(6);
View ivIndicator = new View(context);
//[R.drawable.indicator_select]为指示器的背景资源 相关样式可替换
ivIndicator.setBackgroundResource(R.drawable.indicator_select);
ivIndicator.setLayoutParams(lp);
//将一个个指示器(ImageView)添加到父布局中
indicatorLayout.addView(ivIndicator);
}
}
上述代码段中的dpToPx()
作用是将dp值转换为像素值 想必大多人项目的Util中应该有该方法, 还是贴出来吧:
/**
* @Description 将dp转为px
*/
private int dpToPx(int dp) {
//获取手机屏幕像素密度
float phoneDensity = getResources().getDisplayMetrics().density;
//加0.5f是为了四舍五入 避免丢失精度
return (int) (dp * phoneDensity + 0.5f);
}
指示器创建完毕后,需要将指示器中的每个view与页面切换/选中状态捆绑:
/**
* @Description 随着ViewPager页面滑动 更新指示器选中状态
* @param position ViewPager中的item的position
*/
public void updateIndicatorSelectState(int position) {
//此时传入的position还未经过处理 同样的需要对position进行取余数处理
position = position % mIndicatorLayout.getChildCount();
//循环获取指示器父布局中所有的子View
for (int i = 0; i < mIndicatorLayout.getChildCount(); i++) {
//给每个子view设置选中状态
//当i == position为True的时候触发选中状态反之则设置成未选中
mIndicatorLayout.getChildAt(i).setSelected(i == position);
}
}
如上述代码段,updateIndicatorSelectState()
需要接受一个position
, 那么这个position
从哪里来?换句话说,该在何时调用此方法?
没错 那就是需要在ViewPager
页面状态发生改变时调用。所以还要给ViewPager
添加一个页面状态事件监听:
/**
*@Description 添加ViewPager页面改变事件的监听
*/
public void initPageChangeListener(){
mViewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float v, int position1) {
}
@Override
public void onPageSelected(int position) {
//更新指示器选中状态
updateIndicatorSelectState(position);
}
@Override
public void onPageScrollStateChanged(int position) {
}
});
}
三个回调方法想必大家都很熟悉了吧,不解释,初始化后 然后运行项目:
//初始化指示器
initIndicatorView();
//在初始化的时候 让指示器选中第一个位置
updateIndicatorSelectState(0);
//初始化ViewPager页面选择状态监听
initPageChangeListener();
无限循环+自动轮播+触开离停+底部指示器
至此,我们的基础的业务功能已经实现。
但是, 这UI样式有些过时而且页面切换的时候交互略显生硬不够 优雅。
那好 接下来咱们就着手让它尽可能好看一点
动画效果及UI美化
想要好看 肯定是要改变UI样式或者添加动画。
首先ViewPager
中的图片都是直角 太直了不好看 听说流行圆角好多年了 那咋办? 先把ImageView
剪裁成圆角再说:
package com.banner.superbanner;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Path;
import android.os.Build;
import android.support.v7.widget.AppCompatImageView;
import android.util.AttributeSet;
import android.view.View;
/**
*@Description 通过绘出一个圆角矩形的路径,然后用ClipPath裁剪画布的方式对ImageView的边角进行剪裁实现圆角
*/
public class CircularBeadImageView extends AppCompatImageView {
float width,height;
//此值代表圆角的半径
int angle = 30;
public CircularBeadImageView(Context context) {
this(context, null);
}
public CircularBeadImageView(Context context, AttributeSet attrs) {
this(context, attrs, -1);
}
public CircularBeadImageView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
//Android4.0及之前的手机中,因为硬件加速等原因,在使用clipPath时很有可能 会发生UnsupportedOperationException异常
if (Build.VERSION.SDK_INT < 18) {
setLayerType(View.LAYER_TYPE_SOFTWARE, null);
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
width = getWidth();
height = getHeight();
}
@Override
protected void onDraw(Canvas canvas) {
//主要为了防止屏幕宽高小于圆角半径值这种诡异的现象出现
if (width > angle && height > angle) {
Path path = new Path();
path.moveTo(angle, 0);
path.lineTo(width - angle, 0);
path.quadTo(width, 0, width, angle);
path.lineTo(width, height - angle);
path.quadTo(width, height, width - angle, height);
path.lineTo(angle, height);
path.quadTo(0, height, 0, height - angle);
path.lineTo(0, angle);
path.quadTo(0, 0, 40, 0);
canvas.clipPath(path);
}
super.onDraw(canvas);
}
}
项目res/layout文件夹下增加布局: item_ly.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/iv_ly"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<com.banner.superbanner.CircularBeadImageView
android:id="@+id/iv"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitXY"
/>
</RelativeLayout>
ViewPager
的item的布局是在BannerAdapter中
的instantiateItem()
中创建的,改一下:
都是日常操作,不解释。不过俗话讲:空白留有余韵,所以唯一要注意的是 我给item布局设置了一个padding
值 这样我们的item就可以距离父控件上下左右有些距离 这样视觉上会更好看
不信看下效果:
内边距+圆角这个稍微岔个话,关于IamgeView
圆角的实现方式有很多 关于ViewPager
item圆角的方式也有很多,比如你们如果用Glide
图片加载框架 就可以通过重写Glide
自带的加载器直接给ImageView
加载圆角,这样就不用再单给item写一套布局了(其他图片框架基本也都支持)。
举个栗子?:
RequestOptions options = RequestOptions.bitmapTransform(new CenterCropRoundCornerTransform(20));
Glide.with(container.getContext())
.load(mBannerBean.getData().get(position))
.apply(options)
.into(cb_iv);
CenterCropRoundCornerTransform
类是继承并重写了Glide
专门让我们加载圆角图片的CenterCrop
类:
package com.banner.superbanner;
import android.graphics.Bitmap;
import android.graphics.BitmapShader;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool;
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.TransformationUtils;
import java.security.MessageDigest;
public class CenterCropRoundCornerTransform extends CenterCrop {
private static float radius = 0f;
/**
*构造中接受圆角半径参数
*/
public CenterCropRoundCornerTransform(int px) {
this.radius = px;
}
@Override
protected Bitmap transform(BitmapPool pool, Bitmap toTransform, int outWidth, int outHeight) {
Bitmap bitmap = TransformationUtils.centerCrop(pool, toTransform, outWidth, outHeight);
return roundCrop(pool, bitmap);
}
private static Bitmap roundCrop(BitmapPool pool, Bitmap source) {
if (source == null) return null;
Bitmap result = pool.get(source.getWidth(), source.getHeight(), Bitmap.Config.ARGB_4444);
if (result == null) {
result = Bitmap.createBitmap(source.getWidth(), source.getHeight(), Bitmap.Config.ARGB_4444);
}
Canvas canvas = new Canvas(result);
Paint paint = new Paint();
paint.setShader(new BitmapShader(source, BitmapShader.TileMode.CLAMP, BitmapShader.TileMode.CLAMP));
paint.setAntiAlias(true);
RectF rectF = new RectF(0f, 0f, source.getWidth(), source.getHeight());
canvas.drawRoundRect(rectF, radius, radius, paint);
return result;
}
public String getId() {
return getClass().getName() + Math.round(radius);
}
@Override
public void updateDiskCacheKey(MessageDigest messageDigest) {
}
}
OK了, 就是这么简单。
回到正题, UI样式是修改了 但是item自动切换的时候 依旧感觉很生硬...
其实感觉到“生硬”是因为切换的时候速度太快 一瞬而过 不够平滑,这个问题可以通过修改item切换速度来解决。
但是ViewPager
的item切换速度是写死的 并没有暴露出API让我们修改,我们只能通过反射的方式去修改切换速度。
ViewPager
的切换速度是通过Scroll
类来控制的,新建SuperBannerScroller
类重写它:
package com.banner.superbanner;
import android.content.Context;
import android.view.animation.Interpolator;
import android.widget.Scroller;
public class SuperBannerScroller extends Scroller {
//切换动画时长(单位:毫秒)
private int mScrollDuration = 2000;
private static final Interpolator sInterpolator = new Interpolator() {
@Override
public float getInterpolation(float t) {
t -= 1.0f;
return t * t * t * t * t + 1.0f;
}
};
public boolean noDuration;
/**
*此方法主要让调用层控制是否延时
*/
public void setNoDuration(boolean noDuration) {
this.noDuration = noDuration;
}
public SuperBannerScroller(Context context) {
this(context, sInterpolator);
}
public SuperBannerScroller(Context context, Interpolator interpolator) {
super(context, interpolator);
}
@Override
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
if (noDuration) {
super.startScroll(startX, startY, dx, dy, 0);
} else {
//默认延时
super.startScroll(startX, startY, dx, dy, mScrollDuration);
}
}
}
重写完之后 只需要在初始化ViewPager
的时候 反射到具体的参数 然后替换一下:
/**
* @Description 通过反射的方式拿到ViewPager的mScroller,然后替换成自己设置的值
*/
private void updateViewPagerScroller() {
mSuperBannerScroller = new SuperBannerScroller(this);
Class<ViewPager> cl = ViewPager.class;
try {
Field field = cl.getDeclaredField("mScroller");
field.setAccessible(true);
//利用反射设置mScroller域为自己定义的MScroller
field.set(mViewPager, mSuperBannerScroller);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
初始化调用一下这个方法,然后运行看下效果:
item切换延时两秒+内边距我为了让效果稍微直观一点,我设置切换速度为2秒,其实一秒钟就已经ok了,看起来有木有舒服些?
ps:目前大多数主流App, 包括但不限于:淘宝、网易云音乐、掌上生活(招行)、华为应用市场、优酷、京东..等等的banner基本上都是item圆角或者内边距的形式显示。此Demo保留了图片加载由调用层处理的回调 你们可以自由加载。
UI样式告一段落,下面开始加动画。
ViewPager
有个API:
API功能翻译:设置viewpage。当滚动位置改变时,将为每个附加页调用PageTransformer。这允许应用程序对每个页面应用自定义属性转换,覆盖默认的滑动行为。
API参数翻译:
reverseDrawingOrder:------ 布尔值:如果提供的PageTransformer要求从最后到第一而不是从第一到最后绘制页面视图,则为真。
transformer------ PageTransformer将修改每个页面的动画属性
pageLayerType ------ 应用于ViewPager页面的视图层类型。它应该是LAYER_TYPE_HARDWARE、LAYER_TYPE_SOFTWARE或LAYER_TYPE_NONE。
说白了就是可以利用这个API给ViewPager
添加页面切换动画效果。看下它的源码:
public void setPageTransformer(boolean reverseDrawingOrder, @Nullable ViewPager.PageTransformer transformer, int pageLayerType) {
boolean hasTransformer = transformer != null;
boolean needsPopulate = hasTransformer != (this.mPageTransformer != null);
this.mPageTransformer = transformer;
this.setChildrenDrawingOrderEnabled(hasTransformer);
if (hasTransformer) {
this.mDrawingOrder = reverseDrawingOrder ? 2 : 1;
this.mPageTransformerLayerType = pageLayerType;
} else {
this.mDrawingOrder = 0;
}
if (needsPopulate) {
this.populate();
}
}
这是个重载方法 文档结合源码 首先这个方法接收三个参数,第一个参数和最后一个参数不是重点,自行理解,关键是PageTransformer
这个参数。
PageTransforme是个啥玩意儿呢:
注意图中标注区域,一定要搞清楚这些解释的真正含义 才能自定义各种动画,如果还没用过
PageTransforme
自行浏览器了解 这里就不深入讲了......
为啥不讲了? 因为我:
哈哈哈开玩笑啦 其实是因为PageTransforme
这个东西细节太多 如果想完全讲清楚 需要占用大量篇幅,完全可以单写一篇文章详细讲解了。网上有大量相关PageTransforme
的文章讲解 如果不太清楚PageTransforme
的你们就自行了解吧
但本文会使用谷歌开发指南中ViewPager
的两个动画例子 来给我们的ViewPager
加上动画效果。
第一个:
DepthPageTransformer页面深度线性淡出效果
DepthPageTransformer官方示例效果:
DepthPageTransformer效果
怎么实现?很简单啊 谷歌demo示例代码都给咱写好了:
public class DepthPageTransformer implements ViewPager.PageTransformer {
private static final float MIN_SCALE = 0.75f;
public void transformPage(View view, float position) {
int pageWidth = view.getWidth();
if (position < -1) { // [-Infinity,-1)
// This page is way off-screen to the left.
view.setAlpha(0f);
} else if (position <= 0) { // [-1,0]
// Use the default slide transition when moving to the left page
view.setAlpha(1f);
view.setTranslationX(0f);
view.setScaleX(1f);
view.setScaleY(1f);
} else if (position <= 1) { // (0,1]
// Fade the page out.
view.setAlpha(1 - position);
// Counteract the default slide transition
view.setTranslationX(pageWidth * -position);
// Scale the page down (between MIN_SCALE and 1)
float scaleFactor = MIN_SCALE
+ (1 - MIN_SCALE) * (1 - Math.abs(position));
view.setScaleX(scaleFactor);
view.setScaleY(scaleFactor);
} else { // (1,+Infinity]
// This page is way off-screen to the right.
view.setAlpha(0f);
}
}
}
把这个类copy到你项目中,然后在初始化ViewPager
的时候调用:
//第一个参数为true表示页面是按正序添加 反之则为倒序。(一般只有在帧布局的时候才有视觉效果)
//第二个参数为具体的动画样式的实例,此方法一定要在setAdapter之前调用!!!!
mViewPager.setPageTransformer(true, new DepthPageTransformer());
看下效果:
DepthPageTransformer实例效果
第二个:
ZoomOutPageTransformer收缩淡入效果
ZoomOutPageTransformer官方示例效果:
ZoomOutPageTransformer效果
同样的 这个Demo的示例代码谷歌也给了我们:
public class ZoomOutPageTransformer implements ViewPager.PageTransformer {
private static final float MIN_SCALE = 0.85f;
private static final float MIN_ALPHA = 0.5f;
public void transformPage(View view, float position) {
int pageWidth = view.getWidth();
int pageHeight = view.getHeight();
if (position < -1) { // [-Infinity,-1)
// This page is way off-screen to the left.
view.setAlpha(0f);
} else if (position <= 1) { // [-1,1]
// Modify the default slide transition to shrink the page as well
float scaleFactor = Math.max(MIN_SCALE, 1 - Math.abs(position));
float vertMargin = pageHeight * (1 - scaleFactor) / 2;
float horzMargin = pageWidth * (1 - scaleFactor) / 2;
if (position < 0) {
view.setTranslationX(horzMargin - vertMargin / 2);
} else {
view.setTranslationX(-horzMargin + vertMargin / 2);
}
// Scale the page down (between MIN_SCALE and 1)
view.setScaleX(scaleFactor);
view.setScaleY(scaleFactor);
// Fade the page relative to its size.
view.setAlpha(MIN_ALPHA +
(scaleFactor - MIN_SCALE) /
(1 - MIN_SCALE) * (1 - MIN_ALPHA));
} else { // (1,+Infinity]
// This page is way off-screen to the right.
view.setAlpha(0f);
}
}
}
不多说 直接初始化:
mViewPager.setPageTransformer(true, new ZoomOutPageTransformer());
看下效果:
ZoomOutPageTransformer效果实现
这块还可以更美化一点 比如说我们经常见到的3D画廊效果,一屏可以显示多页 然后缩放渐入。缩放渐入我们实现了 怎么能一屏显示多页呢?
别想那么复杂, 一个属性就能搞定:
clipChildren属性文档解释定义子对象是否被限制在其界限内绘制。这对于将孩子的大小缩放到100%以上的动画非常有用。在这种情况下,应该将此属性设置为false,以允许子元素绘制超出其边界的内容。此属性的默认值为true。
可以是一个布尔值,如“true”或“false”。
这是ViewGroup
中的一个特有属性 它可以允许子控件越界显示。
在布局的根布局中设置一下:
一屏显示多页布局设置
如图,在根布局加上android:clipChildren="false"
然后我把ViewPager
的宽度由原来的match_parent
改成指定宽度 这样做是为了让其不要填满窗体 这样其它页面才能展示到当前屏幕上(实际使用时 这个值不要随便给,最好是通过机型屏幕宽度计算出的一个宽度值)。
直接运行项目看下效果:
3D画廊效果
当然 你们也可以自定义动画效果,包括动画的具体参数 比如透明度 缩放比 样式等等参数 都可以自己调整。
奥 差点忘了,我们还没有给`ViewPager添加item点击事件。
设置item点击事件
由于ViewPager
并没有直接提供点击事件的API 所以目前有很多种方式给ViewPager
添加点击事件 比如在touch中通过对手势事件的拦截和偏移量的计算,还有直接给item的View
添加点击事件 然后再回调给ViewPager
。那我们就采用第二种方案。
首先定义出一个回调接口:
package com.banner.superbanner.callback;
/**
*@Description ViewPager item的点击事件
*
*/
public interface OnItemClickAdapterListener {
void onItemAdapterClick(int position);
}
这个callback主要用于BannerAdapter
类中,在BannerAdapter
的构造方法中接受回调的实例,然后在instantiateItem()
中触发回调:
/**
*@Description item的View的点击事件
* @param cb_iv 点击事件的view
* @param position ietm的索引(取余过后的)
*/
private void onItemClick(CircularBeadImageView cb_iv, final int position) {
if (cb_iv != null) {
cb_iv.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
if (mOnItemClickAdapterListener != null) {
mOnItemClickAdapterListener.onItemAdapterClick(position);
}
}
});
}
}
我把这块逻辑抽了出来,单独写了个方法 这个方法只需要在instantiateItem()
中调用一下就可以了,然后在Acitivity
中给ViewPager
设置Adapter
时 在BannerAdapter
的构造中实现OnItemClickAdapterListener
回调 重写onItemAdapterClick()
就OK了。
代码整合与封装
代码写到这里 我们想要的需求也全部实现 但代码结构乱的一批。
一方面为了演示方便和自己方便 我直接把ViewPager
相关代码全部写在了调用层Activity
中,可读性不强。
第二方面 ViewPager
业务逻辑的具体参数直接写死了 没有提供让外部赋值的方法,不利于扩展。
第三方面 没有对实例进行非空校验 没有对代码进行容错考虑。
这三个方面 就造成了一个问题: 耦合严重,健壮性差。
良好的代码结构应该是: 高内聚 低耦合 。调用层和实现层要尽量解耦
下面要做一些封装和抽取 目的就是: 要把所有和ViewPager的相关的业务逻辑内聚到一个类中并对外暴露API 让调用层决定banner业务逻辑中的具体参数 并封装成一个简易框架。
需求是,调用者可以决定banner:
- 是否可以手指滑动无限循环
- 是否可以定时轮播
- 是否拥有底部指示器
- 是否拥有动画效果
- 是否需要item点击事件
- 是否需要调用者去处理加载图片的逻辑
- 是否需要item圆角展示(glide)
- Banner页面切换的速度
- 定时轮播的间隔时间
- 底部指示器View的宽高和间距(相对于父布局)
- item的内边距
新建一个 SuperBanner
类 将之前写在Activity
中和ViewPager
相关的代码全部移植到此类中,然后将上述需求整理成具体函数,以方法链的形式暴露出去,最终调用层对Banner的设置只需要以下API:
这样一来,调用者只需要确定上述的一些参数 banner的实现就和调用层无关了
由于 SuperBanner
类的代码太多,贴出来太影响阅读体验 如果感兴趣请自行下载Demo了解 注释都很详细 。
使用
private void showTest(){
//简单用法
mSuperBanner.setDataOrigin(imageList).start();
}
你可以直接设置数据源 然后start 但是这样没有底部指示器 也没有其他的一些效果 但默认会有自动轮播和手指滑动无限轮播效果。
全部API:
mSuperBanner.
//设置数据源
setDataOrigin(imageList)
//重载方法,设置指示器布局及指示器样式,不需要就无需调用 后三个参数代表指示器的宽高和间距(可选设置 有默认效果)
.setIndicatorLayoutParam(mIndicatorLayout, R.drawable.indicator_select, 6, 6, 10)
//设置ViewPager的item切换速度,不需要更改速度就无需调用
.setViewPagerScroller(1000)
//设置自动轮播间隔时间,重载方法 默认开始执行定时任务时间为2秒
.setAutoIntervalTime(3000, 2000)
//.closeAutoBanner(true) 关闭自动轮播
//.closeInfiniteSlide(true) 关闭手指滑动无限循环
//设置item的padding值(上下左右)
.setItemPadding(14)
//设置圆角半径 一旦设置值(大于0) 就代表item使用圆角样式
.setRoundRadius(10)
//.setSwitchAnimation() 设置ViewPager切换动画
//可选实现。实现图片加载回调(一定要在start()之前执行) 一但实现回调就表示图片加载交由调用层处理 否则由适配器内部加载
.setOnLoadImageListener(new SuperBanner.OnLoadImageListener() {
@Override
public void onLoadImage(List imageData, int position, View imageView) {
if (mOptions == null) {
mOptions = RequestOptions.bitmapTransform(new CenterCropRoundCornerTransform(20));
}
int resourceId = (int) imageData.get(position);
Glide.with(MainActivity.this)
.load(resourceId)
.apply(mOptions)
.into((ImageView) imageView);
}
})
// 可选实现。实现item点击事件回调(一定要在start()之前执行)
.setOnItemClickListener(new SuperBanner.OnItemClickListener() {
@Override
public void onItemClick(int position) {
Log.i("BannerItemPosition: ", position + "");
}
})
// 此函数要最后执行
.start();
如果有其他需求 直接改源码就ok 注释真的很详细奥~
奥,最后 你可以在Activity
/Fragment
不可见的时候 关掉轮播定时任务以尽可能的减少内存压力和内存泄漏发生:
@Override
protected void onPause() {
super.onPause();
//取消轮播
if (mSuperBanner!=null){
mSuperBanner.killDelayedTask();
}
}
然后在页面可见时开启:
@Override
protected void onStart() {
super.onStart();
//开始轮播
if (mSuperBanner!=null){
mSuperBanner.executeDelayedTask();
}
}
很希望能帮到你 不足之处还请见谅 恳请斧正 !。
网友评论