話不多說 先看效果:
老規(guī)矩 不想聽俺叨逼叨的請(qǐng)移步: GitHub - SuperBanner
首先總結(jié)下需求:
1:支持手指循環(huán)滑動(dòng)
2:支持定時(shí)輪播
3:支持手指觸摸/滑動(dòng)輪播區(qū)域時(shí)停止輪播,手指離開重新輪播
4:支持輪播圖片簡(jiǎn)述及導(dǎo)航(指示器)標(biāo)識(shí)
5:支持圖片點(diǎn)擊事件回調(diào)
6:支持自定義item切換速度
7:支持item圓角圖片展示
8:支持item切換動(dòng)畫(兩種)
關(guān)于ViewPager2
2018 年 9 月 21 日谷歌發(fā)布了首個(gè)AndroidX
穩(wěn)定版本 ----AndroidX 1.0.0
。后續(xù)版本中,谷歌意圖用AndroidX
逐步替代android.support.xxx
包 那么自然,隸屬于AndroidX
下的ViewPager2
也將會(huì)替代ViewPager
然而就在前幾天(2019年11月20日)
ViewPager2
也更新了一個(gè)正式穩(wěn)定版ViewPager2 1.0.0
不過,考慮到
AndroidX
的適配問題和現(xiàn)階段的普適程度,此banner效果依然使用ViewPager
實(shí)現(xiàn),所以也不打算展開來講ViewPager2
,后續(xù)我會(huì)單寫一篇文章詳細(xì)的介紹和使用ViewPager2
并實(shí)現(xiàn)此效果,總之,無論用哪種控件實(shí)現(xiàn),思路才最重要。
Google GitHub的ViewPager2
Demo 各位有興趣的可以跑起來先耍耍:
https://github.com/googlesamples/android-viewpager2
進(jìn)入正題
首先我們需要在調(diào)用層(Activity)布局文件中定義出我們自定義的ViewPager
相關(guān)布局并設(shè)置一些基本的屬性:
<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 這個(gè)時(shí)候還需要一個(gè)Adapter
來設(shè)置數(shù)據(jù):
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 裝有圖片路徑的數(shù)據(jù)源
* @param onLoadImageListener 加載圖片的回調(diào)接口 讓調(diào)用層處理加載圖片的邏輯
*/
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;
}
}
這些個(gè)方法,用過的都知道不多說。接下來主要是在getCount()
和instantiateItem()
中搞事情。
構(gòu)造方法中的BannerBean
是我請(qǐng)求服務(wù)器后通過Gson
解析后生成的實(shí)體bean
,你也可以把圖片組裝到List
集合或者arr
數(shù)組中 具體還要看你們的業(yè)務(wù)邏輯。
OnLoadImageListener
主要是一個(gè)callback接口 主要用于將加載圖片的邏輯回調(diào)給調(diào)用層去處理 這個(gè)后續(xù)會(huì)講到,OnLoadImageListener
接口內(nèi)容如下:
package com.banner.superbanner;
import android.content.Context;
import android.widget.View;
public interface OnLoadImageListener {
//最后一個(gè)參數(shù)類型為View而不是ImageView,主要為了適應(yīng)item布局的多樣性 使用時(shí)強(qiáng)轉(zhuǎn)一下就行了
void loadImage(Context context, BannerBean bannerBean, int position, View imageView);
}
參數(shù)就不用我多說了吧,看一下基本就明白了,就是加載圖片時(shí)需要的一些信息。
在Activity
中請(qǐng)求服務(wù)器先把圖片路徑地址拿到:
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);
}
}
});
可以看到 請(qǐng)求的host是我的本機(jī)內(nèi)網(wǎng)ip 為了測(cè)試方便 我直接在Tomcat上放了幾張圖片 并且寫了一個(gè)簡(jiǎn)單的json文件模擬服務(wù)器返回的數(shù)據(jù)
請(qǐng)求到的圖片地址如下:
"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"
拿到數(shù)據(jù)源后 在PagerAdapter
的instantiateItem()
中創(chuàng)建ImageView
對(duì)象:
@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){
//設(shè)置回調(diào),傳入數(shù)據(jù) 讓調(diào)用層(Activity)去處理加載圖片的邏輯
mOnLoadImageListener.loadImage(container.getContext(),mBannerBean,position,iv);
}
//把每一個(gè)item(ImageView)添加到ViewPager容器中
container.addView(iv);
return iv;
}
適配器設(shè)置完畢后 在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);
}
}));
此時(shí)運(yùn)行項(xiàng)目:
可以看到 總共6張圖片 在我滑動(dòng)到最后一張的時(shí)候 我們需要讓它繼續(xù)從頭開始循環(huán)滑動(dòng)。
手指滑動(dòng)“無限循環(huán)”
這里就要說到ViewPager
的Adpater
中的getCount()
函數(shù),這個(gè)函數(shù)的返回值就是當(dāng)前ViewPager
的總頁數(shù)(item),當(dāng)ViewPager
滑動(dòng)到最后一頁 也就是當(dāng)前item的position為getCount()-1
的時(shí)候 就會(huì)認(rèn)為已經(jīng)滑動(dòng)到了末尾。
所以,我們這里所說的無限循環(huán)滑動(dòng)其實(shí)是一個(gè)偽概念 因?yàn)槲覀償?shù)據(jù)源的總大小也才6張圖片 等我們滑到第5個(gè)item的時(shí)候理論上已經(jīng)滑不動(dòng)了 但為了做出無限循環(huán)效果,我們可以給getCount()
返回一個(gè)非常大的數(shù) 讓它很難滑動(dòng)到盡頭。
比較主流的做法是直接返回Interger的最大值:
@Override
public int getCount() {
//return mBannerBean.getData().size();
return Integer.MAX_VALUE; //返回Integer的最大值,實(shí)現(xiàn)“手指滑動(dòng)無限循環(huán)”
}
如圖 ,MAX_VALUE
的值為: 2的31次方減1 得出的一個(gè)常量值:2147483647,換句話說 理論上你需要滑動(dòng)二十一億四千七百四十八萬三千六百四十七次才能滑動(dòng)到盡頭....
想必世界上應(yīng)該還沒有如此耿直的人非要滑那么多次的吧 那么 它就是“無限循環(huán)”
或者你還可以這樣寫:
@Override
public int getCount() {
//return mBannerBean.getData().size();
//返回?cái)?shù)據(jù)源大小的整數(shù)倍
return (mBannerBean.getData().size() * 10000 * 100);
}
這種是直接返回?cái)?shù)據(jù)源的整數(shù)倍的方式,個(gè)人推薦這種寫法 原因后續(xù)會(huì)講到。反正不管怎么寫 核心就是返回一個(gè)非常大的數(shù) 在相當(dāng)長(zhǎng)的時(shí)間內(nèi)滑不到盡頭。
ps: 關(guān)于無限循環(huán) 市面上還有一些其他做法 比如重寫 OnPageChangeListener 接口中的onPageSelected 方法或者我看有些人通過動(dòng)態(tài)添加/復(fù)用頭尾item的方式做到所謂“真正意義上的無線循環(huán)”,有興趣請(qǐng)自行瀏覽器了解
ok 我們?cè)O(shè)置完getCount()
返回值后,此時(shí)我們?nèi)绻苯舆\(yùn)行項(xiàng)目 會(huì)報(bào)出IndexOutOfBoundsException
異常,其原因在于:我們?cè)O(shè)置了ViewPager的item的總大小但并沒有對(duì)position進(jìn)行處理,當(dāng)postion的值超出了數(shù)據(jù)源(list集合)的大小 就會(huì)拋出索引越界異常
所以當(dāng)前的position如果超出我們數(shù)據(jù)源的最大值(最大值為6) 我們需要把這個(gè)position處理成數(shù)據(jù)源范圍內(nèi)的值:
@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 通過取余數(shù)的方式來限定position的取值范圍
position = position % mBannerBean.getData().size();
Log.i("TEST_POSITION","處理之后的position:"+position);
if (null!=mOnLoadImageListener){
//設(shè)置回調(diào),傳入數(shù)據(jù) 讓調(diào)用層(Activity)去處理加載圖片的邏輯
mOnLoadImageListener.loadImage(container.getContext(),mBannerBean,position,iv);
}
//把每一個(gè)item(ImageView)添加到ViewPager容器中
container.addView(iv);
return iv;
}
剛好 我們可以通過取余數(shù)的特性 限定position的取值范圍: 從0到數(shù)據(jù)源大小-1之間
此時(shí)運(yùn)行項(xiàng)目并打印position日志:
可以看到 已經(jīng)可以無限的向右滑動(dòng)了,我向右滑動(dòng)了兩輪 此時(shí)Log打印出position值為:
看到了8~ 如果沒處理position 當(dāng)position為6的時(shí)候 就已經(jīng)索引越界了。我們通過取余處理后 position值就能按順序控制在0-6之間以此類推
ok 看似已經(jīng)實(shí)現(xiàn)了手指滑動(dòng)無限循環(huán) 但有一個(gè)小問題 我向右滑動(dòng)沒問題 但我向左邊滑動(dòng)到position值為0的item的時(shí)候就滑不動(dòng)了,ViewPager
就會(huì)認(rèn)為我左邊已經(jīng)沒有item了。
解決這個(gè)問題 只需要讓ViewPager左滑時(shí) 在相當(dāng)長(zhǎng)的時(shí)間內(nèi)滑不到0的位置
很簡(jiǎn)單,ViewPager中有一個(gè)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.
設(shè)置當(dāng)前選擇的頁面。如果ViewPager已經(jīng)使用當(dāng)前適配器完成了它的第一個(gè)布局,那么當(dāng)前項(xiàng)和指定項(xiàng)之間將有一個(gè)平滑的動(dòng)畫過渡。
一般情況下ViewPager
初始化時(shí)默認(rèn)的item位置為0。
但我們可以使用這個(gè)API給ViewPager
一個(gè)初始位置:
//ViewPager初始化時(shí) 滑動(dòng)到一半的距離
mViewPager.setCurrentItem((mViewPager.getAdapter().getCount()) / 2);
在初始化的時(shí)候 給ViewPager
設(shè)置初始位置為:總條目數(shù)的一半
這樣一來 不論是左滑還是右滑都不會(huì)滑到"盡頭"
但問題來了 還記得剛剛提到的實(shí)現(xiàn)無限循環(huán)在getcount()
中的兩種返回方式的寫法嗎? 一個(gè)是返回Integer
最大值 一種是返回?cái)?shù)據(jù)源的整數(shù)倍,并且我還推薦使用整數(shù)倍的寫法。
如果你使用的是返回Integer
最大值的方式:
你會(huì)發(fā)現(xiàn)當(dāng)你冷啟動(dòng)App時(shí) ViewPager
顯示的item位置經(jīng)過取余處理后 仍然不會(huì)在第一位,一般情況下 我們正常需求肯定都是初始化顯示第一張圖片(position = 0) 為什么會(huì)出現(xiàn)這種情況呢?
原因就在于:這個(gè)數(shù)不能被整除
所以你還要對(duì)它的余數(shù)進(jìn)行拼差處理, 太麻煩了 而且這個(gè)數(shù)也太大 我們沒必要設(shè)置這么大的數(shù)。
所以個(gè)人推薦使用整數(shù)倍的方式 。
定時(shí)輪播
在Android中 想要周期性執(zhí)行任務(wù)基本有以下幾種方式:
- Timer+TimerTask
- 延時(shí)Handler(postDelay)
- 周期性執(zhí)行任務(wù)的線程池
首先pass掉第三種 不解釋 第一種和第二種用哪個(gè)都可以 很多人在用postDelay
的方式 那咱們就用Timer+TimerTask吧:
在Acitivity
類中:
private Timer mTimer;
private TimerTask mTimerTask;
/**
* 開啟一個(gè)延時(shí)任務(wù)并執(zhí)行
*/
private void executeDelayedTask() {
//在創(chuàng)建任務(wù)之前 一定要檢查清理未回收的任務(wù),保證只有一組Timer+TimerTask
killDelayedTask();
mTimer = new Timer();
mTimerTask = new TimerTask() {
@Override
public void run() {
runOnUiThread(new Runnable() {
@Override
public void run() {
//顯示下一頁
showNextPage();
}
});
}
};
//設(shè)置delay參數(shù)為3000毫秒表示用戶調(diào)用schedule() 方法后,要等待3秒才可以第一次執(zhí)行run()方法
//設(shè)置period參數(shù)為4000 表示第一次調(diào)用之后,從第二次開始每隔4秒調(diào)用一次run()方法
mTimer.schedule(mTimerTask, 3000, 4000);
}
/**
* @Description 取消(清理)延時(shí)任務(wù)
*/
private void killDelayedTask() {
if (mTimer != null) {
mTimer.cancel();
mTimer = null;
}
if (mTimerTask != null) {
mTimerTask.cancel();
mTimerTask = null;
}
}
/**
* @Description 顯示下一頁
*/
private void showNextPage() {
//獲取到當(dāng)前頁面的位置
int currentPageLocation = mViewPager.getCurrentItem();
//設(shè)置item位置為: 當(dāng)前頁面的位置+1
mViewPager.setCurrentItem(currentPageLocation + 1);
}
如上 使用Timer
+TimerTask
執(zhí)行定時(shí)任務(wù) 這個(gè)任務(wù)就是: showNextPage()
顯示下一頁。
手指觸摸/滑動(dòng)輪播區(qū)域時(shí)停止輪播,手指離開重新輪播
這個(gè)也很簡(jiǎn)單 只需要用到ViewPager
的一個(gè)API
依舊是在Acivity
類中:
/**
* @Description 在手指按下和移動(dòng)時(shí) 清除延時(shí)任務(wù),待手指松開重新創(chuàng)建任務(wù)
*/
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()
中初始化一下:
//設(shè)置3秒鐘后開始執(zhí)行任務(wù) 每個(gè)任務(wù)之間隔4秒執(zhí)行一次
superBanner.executeDelayedTask();
//初始化touch事件
superBanner.setViewPagerTouchListener();
此時(shí)運(yùn)行項(xiàng)目:
注意看 我手指觸摸滑動(dòng)的時(shí)候 此時(shí)會(huì)停止輪播 當(dāng)手指松開后 又會(huì)繼續(xù)輪播。
底部指示器:
定時(shí)輪播完成后 我們想在ViewPager
底部顯示一排"指示器",可以隨頁面的滑動(dòng)更改狀態(tài)
依然實(shí)在Activity
類中:
/**
* @Description 初始化ViewPager底部指示器
* @param indicatorLayout 指示器的父布局 由調(diào)用者提供
*/
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]為指示器的背景資源 相關(guān)樣式可替換
ivIndicator.setBackgroundResource(R.drawable.indicator_select);
ivIndicator.setLayoutParams(lp);
//將一個(gè)個(gè)指示器(ImageView)添加到父布局中
indicatorLayout.addView(ivIndicator);
}
}
上述代碼段中的dpToPx()
作用是將dp值轉(zhuǎn)換為像素值 想必大多人項(xiàng)目的Util中應(yīng)該有該方法, 還是貼出來吧:
/**
* @Description 將dp轉(zhuǎn)為px
*/
private int dpToPx(int dp) {
//獲取手機(jī)屏幕像素密度
float phoneDensity = getResources().getDisplayMetrics().density;
//加0.5f是為了四舍五入 避免丟失精度
return (int) (dp * phoneDensity + 0.5f);
}
指示器創(chuàng)建完畢后,需要將指示器中的每個(gè)view與頁面切換/選中狀態(tài)捆綁:
/**
* @Description 隨著ViewPager頁面滑動(dòng) 更新指示器選中狀態(tài)
* @param position ViewPager中的item的position
*/
public void updateIndicatorSelectState(int position) {
//此時(shí)傳入的position還未經(jīng)過處理 同樣的需要對(duì)position進(jìn)行取余數(shù)處理
position = position % mIndicatorLayout.getChildCount();
//循環(huán)獲取指示器父布局中所有的子View
for (int i = 0; i < mIndicatorLayout.getChildCount(); i++) {
//給每個(gè)子view設(shè)置選中狀態(tài)
//當(dāng)i == position為True的時(shí)候觸發(fā)選中狀態(tài)反之則設(shè)置成未選中
mIndicatorLayout.getChildAt(i).setSelected(i == position);
}
}
如上述代碼段,updateIndicatorSelectState()
需要接受一個(gè)position
, 那么這個(gè)position
從哪里來?換句話說,該在何時(shí)調(diào)用此方法?
沒錯(cuò) 那就是需要在ViewPager
頁面狀態(tài)發(fā)生改變時(shí)調(diào)用。所以還要給ViewPager
添加一個(gè)頁面狀態(tài)事件監(jiān)聽:
/**
*@Description 添加ViewPager頁面改變事件的監(jiān)聽
*/
public void initPageChangeListener(){
mViewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float v, int position1) {
}
@Override
public void onPageSelected(int position) {
//更新指示器選中狀態(tài)
updateIndicatorSelectState(position);
}
@Override
public void onPageScrollStateChanged(int position) {
}
});
}
三個(gè)回調(diào)方法想必大家都很熟悉了吧,不解釋,初始化后 然后運(yùn)行項(xiàng)目:
//初始化指示器
initIndicatorView();
//在初始化的時(shí)候 讓指示器選中第一個(gè)位置
updateIndicatorSelectState(0);
//初始化ViewPager頁面選擇狀態(tài)監(jiān)聽
initPageChangeListener();
至此,我們的基礎(chǔ)的業(yè)務(wù)功能已經(jīng)實(shí)現(xiàn)。
但是, 這UI樣式有些過時(shí)而且頁面切換的時(shí)候交互略顯生硬不夠 優(yōu)雅。
那好 接下來咱們就著手讓它盡可能好看一點(diǎn)
動(dòng)畫效果及UI美化
想要好看 肯定是要改變UI樣式或者添加動(dòng)畫。
首先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 通過繪出一個(gè)圓角矩形的路徑,然后用ClipPath裁剪畫布的方式對(duì)ImageView的邊角進(jìn)行剪裁實(shí)現(xiàn)圓角
*/
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及之前的手機(jī)中,因?yàn)橛布铀俚仍颍谑褂胏lipPath時(shí)很有可能 會(huì)發(fā)生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) {
//主要為了防止屏幕寬高小于圓角半徑值這種詭異的現(xiàn)象出現(xiàn)
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);
}
}
項(xiàng)目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()
中創(chuàng)建的,改一下:
都是日常操作,不解釋。不過俗話講:空白留有余韻,所以唯一要注意的是 我給item布局設(shè)置了一個(gè)padding
值 這樣我們的item就可以距離父控件上下左右有些距離 這樣視覺上會(huì)更好看
不信看下效果:
這個(gè)稍微岔個(gè)話,關(guān)于IamgeView
圓角的實(shí)現(xiàn)方式有很多 關(guān)于ViewPager
item圓角的方式也有很多,比如你們?nèi)绻?code>Glide圖片加載框架 就可以通過重寫Glide
自帶的加載器直接給ImageView
加載圓角,這樣就不用再單給item寫一套布局了(其他圖片框架基本也都支持)。
舉個(gè)栗子?:
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;
/**
*構(gòu)造中接受圓角半徑參數(shù)
*/
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了, 就是這么簡(jiǎn)單。
回到正題, UI樣式是修改了 但是item自動(dòng)切換的時(shí)候 依舊感覺很生硬...
其實(shí)感覺到“生硬”是因?yàn)榍袚Q的時(shí)候速度太快 一瞬而過 不夠平滑,這個(gè)問題可以通過修改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 {
//切換動(dòng)畫時(shí)長(zhǎng)(單位:毫秒)
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;
/**
*此方法主要讓調(diào)用層控制是否延時(shí)
*/
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 {
//默認(rèn)延時(shí)
super.startScroll(startX, startY, dx, dy, mScrollDuration);
}
}
}
重寫完之后 只需要在初始化ViewPager
的時(shí)候 反射到具體的參數(shù) 然后替換一下:
/**
* @Description 通過反射的方式拿到ViewPager的mScroller,然后替換成自己設(shè)置的值
*/
private void updateViewPagerScroller() {
mSuperBannerScroller = new SuperBannerScroller(this);
Class<ViewPager> cl = ViewPager.class;
try {
Field field = cl.getDeclaredField("mScroller");
field.setAccessible(true);
//利用反射設(shè)置mScroller域?yàn)樽约憾x的MScroller
field.set(mViewPager, mSuperBannerScroller);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
初始化調(diào)用一下這個(gè)方法,然后運(yùn)行看下效果:
我為了讓效果稍微直觀一點(diǎn),我設(shè)置切換速度為2秒,其實(shí)一秒鐘就已經(jīng)ok了,看起來有木有舒服些?
ps:目前大多數(shù)主流App, 包括但不限于:淘寶、網(wǎng)易云音樂、掌上生活(招行)、華為應(yīng)用市場(chǎng)、優(yōu)酷、京東..等等的banner基本上都是item圓角或者內(nèi)邊距的形式顯示。此Demo保留了圖片加載由調(diào)用層處理的回調(diào) 你們可以自由加載。
UI樣式告一段落,下面開始加動(dòng)畫。
ViewPager
有個(gè)API:
API功能翻譯:設(shè)置viewpage。當(dāng)滾動(dòng)位置改變時(shí),將為每個(gè)附加頁調(diào)用PageTransformer。這允許應(yīng)用程序?qū)γ總€(gè)頁面應(yīng)用自定義屬性轉(zhuǎn)換,覆蓋默認(rèn)的滑動(dòng)行為。
API參數(shù)翻譯:
reverseDrawingOrder:------ 布爾值:如果提供的PageTransformer要求從最后到第一而不是從第一到最后繪制頁面視圖,則為真。
transformer------ PageTransformer將修改每個(gè)頁面的動(dòng)畫屬性
pageLayerType ------ 應(yīng)用于ViewPager頁面的視圖層類型。它應(yīng)該是LAYER_TYPE_HARDWARE、LAYER_TYPE_SOFTWARE或LAYER_TYPE_NONE。
說白了就是可以利用這個(gè)API給ViewPager
添加頁面切換動(dòng)畫效果。看下它的源碼:
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();
}
}
這是個(gè)重載方法 文檔結(jié)合源碼 首先這個(gè)方法接收三個(gè)參數(shù),第一個(gè)參數(shù)和最后一個(gè)參數(shù)不是重點(diǎn),自行理解,關(guān)鍵是PageTransformer
這個(gè)參數(shù)。
PageTransforme是個(gè)啥玩意兒呢:
注意圖中標(biāo)注區(qū)域,一定要搞清楚這些解釋的真正含義 才能自定義各種動(dòng)畫,如果還沒用過
PageTransforme
自行瀏覽器了解 這里就不深入講了......
為啥不講了? 因?yàn)槲遥?/p>
哈哈哈開玩笑啦 其實(shí)是因?yàn)?code>PageTransforme這個(gè)東西細(xì)節(jié)太多 如果想完全講清楚 需要占用大量篇幅,完全可以單寫一篇文章詳細(xì)講解了。網(wǎng)上有大量相關(guān)PageTransforme
的文章講解 如果不太清楚PageTransforme
的你們就自行了解吧
但本文會(huì)使用谷歌開發(fā)指南中ViewPager
的兩個(gè)動(dòng)畫例子 來給我們的ViewPager
加上動(dòng)畫效果。
第一個(gè):
DepthPageTransformer官方示例效果:
怎么實(shí)現(xiàn)?很簡(jiǎn)單啊 谷歌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);
}
}
}
把這個(gè)類copy到你項(xiàng)目中,然后在初始化ViewPager
的時(shí)候調(diào)用:
//第一個(gè)參數(shù)為true表示頁面是按正序添加 反之則為倒序。(一般只有在幀布局的時(shí)候才有視覺效果)
//第二個(gè)參數(shù)為具體的動(dòng)畫樣式的實(shí)例,此方法一定要在setAdapter之前調(diào)用!!!!
mViewPager.setPageTransformer(true, new DepthPageTransformer());
看下效果:
第二個(gè):
ZoomOutPageTransformer官方示例效果:
同樣的 這個(gè)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());
看下效果:
這塊還可以更美化一點(diǎn) 比如說我們經(jīng)常見到的3D畫廊效果,一屏可以顯示多頁 然后縮放漸入。縮放漸入我們實(shí)現(xiàn)了 怎么能一屏顯示多頁呢?
別想那么復(fù)雜, 一個(gè)屬性就能搞定:
定義子對(duì)象是否被限制在其界限內(nèi)繪制。這對(duì)于將孩子的大小縮放到100%以上的動(dòng)畫非常有用。在這種情況下,應(yīng)該將此屬性設(shè)置為false,以允許子元素繪制超出其邊界的內(nèi)容。此屬性的默認(rèn)值為true。
可以是一個(gè)布爾值,如“true”或“false”。
這是ViewGroup
中的一個(gè)特有屬性 它可以允許子控件越界顯示。
在布局的根布局中設(shè)置一下:
如圖,在根布局加上android:clipChildren="false"
然后我把ViewPager
的寬度由原來的match_parent
改成指定寬度 這樣做是為了讓其不要填滿窗體 這樣其它頁面才能展示到當(dāng)前屏幕上(實(shí)際使用時(shí) 這個(gè)值不要隨便給,最好是通過機(jī)型屏幕寬度計(jì)算出的一個(gè)寬度值)。
直接運(yùn)行項(xiàng)目看下效果:
當(dāng)然 你們也可以自定義動(dòng)畫效果,包括動(dòng)畫的具體參數(shù) 比如透明度 縮放比 樣式等等參數(shù) 都可以自己調(diào)整。
奧 差點(diǎn)忘了,我們還沒有給`ViewPager添加item點(diǎn)擊事件。
設(shè)置item點(diǎn)擊事件
由于ViewPager
并沒有直接提供點(diǎn)擊事件的API 所以目前有很多種方式給ViewPager
添加點(diǎn)擊事件 比如在touch中通過對(duì)手勢(shì)事件的攔截和偏移量的計(jì)算,還有直接給item的View
添加點(diǎn)擊事件 然后再回調(diào)給ViewPager
。那我們就采用第二種方案。
首先定義出一個(gè)回調(diào)接口:
package com.banner.superbanner.callback;
/**
*@Description ViewPager item的點(diǎn)擊事件
*
*/
public interface OnItemClickAdapterListener {
void onItemAdapterClick(int position);
}
這個(gè)callback主要用于BannerAdapter
類中,在BannerAdapter
的構(gòu)造方法中接受回調(diào)的實(shí)例,然后在instantiateItem()
中觸發(fā)回調(diào):
/**
*@Description item的View的點(diǎn)擊事件
* @param cb_iv 點(diǎn)擊事件的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);
}
}
});
}
}
我把這塊邏輯抽了出來,單獨(dú)寫了個(gè)方法 這個(gè)方法只需要在instantiateItem()
中調(diào)用一下就可以了,然后在Acitivity
中給ViewPager
設(shè)置Adapter
時(shí) 在BannerAdapter
的構(gòu)造中實(shí)現(xiàn)OnItemClickAdapterListener
回調(diào) 重寫onItemAdapterClick()
就OK了。
代碼整合與封裝
代碼寫到這里 我們想要的需求也全部實(shí)現(xiàn) 但代碼結(jié)構(gòu)亂的一批。
一方面為了演示方便和自己方便 我直接把ViewPager
相關(guān)代碼全部寫在了調(diào)用層Activity
中,可讀性不強(qiáng)。
第二方面 ViewPager
業(yè)務(wù)邏輯的具體參數(shù)直接寫死了 沒有提供讓外部賦值的方法,不利于擴(kuò)展。
第三方面 沒有對(duì)實(shí)例進(jìn)行非空校驗(yàn) 沒有對(duì)代碼進(jìn)行容錯(cuò)考慮。
這三個(gè)方面 就造成了一個(gè)問題: 耦合嚴(yán)重,健壯性差。
良好的代碼結(jié)構(gòu)應(yīng)該是: 高內(nèi)聚 低耦合 。調(diào)用層和實(shí)現(xiàn)層要盡量解耦
下面要做一些封裝和抽取 目的就是: 要把所有和ViewPager的相關(guān)的業(yè)務(wù)邏輯內(nèi)聚到一個(gè)類中并對(duì)外暴露API 讓調(diào)用層決定banner業(yè)務(wù)邏輯中的具體參數(shù) 并封裝成一個(gè)簡(jiǎn)易框架。
需求是,調(diào)用者可以決定banner:
- 是否可以手指滑動(dòng)無限循環(huán)
- 是否可以定時(shí)輪播
- 是否擁有底部指示器
- 是否擁有動(dòng)畫效果
- 是否需要item點(diǎn)擊事件
- 是否需要調(diào)用者去處理加載圖片的邏輯
- 是否需要item圓角展示(glide)
- Banner頁面切換的速度
- 定時(shí)輪播的間隔時(shí)間
- 底部指示器View的寬高和間距(相對(duì)于父布局)
- item的內(nèi)邊距
新建一個(gè) SuperBanner
類 將之前寫在Activity
中和ViewPager
相關(guān)的代碼全部移植到此類中,然后將上述需求整理成具體函數(shù),以方法鏈的形式暴露出去,最終調(diào)用層對(duì)Banner的設(shè)置只需要以下API:
這樣一來,調(diào)用者只需要確定上述的一些參數(shù) banner的實(shí)現(xiàn)就和調(diào)用層無關(guān)了
由于 SuperBanner
類的代碼太多,貼出來太影響閱讀體驗(yàn) 如果感興趣請(qǐng)自行下載Demo了解 注釋都很詳細(xì) 。
使用
private void showTest(){
//簡(jiǎn)單用法
mSuperBanner.setDataOrigin(imageList).start();
}
你可以直接設(shè)置數(shù)據(jù)源 然后start 但是這樣沒有底部指示器 也沒有其他的一些效果 但默認(rèn)會(huì)有自動(dòng)輪播和手指滑動(dòng)無限輪播效果。
全部API:
mSuperBanner.
//設(shè)置數(shù)據(jù)源
setDataOrigin(imageList)
//重載方法,設(shè)置指示器布局及指示器樣式,不需要就無需調(diào)用 后三個(gè)參數(shù)代表指示器的寬高和間距(可選設(shè)置 有默認(rèn)效果)
.setIndicatorLayoutParam(mIndicatorLayout, R.drawable.indicator_select, 6, 6, 10)
//設(shè)置ViewPager的item切換速度,不需要更改速度就無需調(diào)用
.setViewPagerScroller(1000)
//設(shè)置自動(dòng)輪播間隔時(shí)間,重載方法 默認(rèn)開始執(zhí)行定時(shí)任務(wù)時(shí)間為2秒
.setAutoIntervalTime(3000, 2000)
//.closeAutoBanner(true) 關(guān)閉自動(dòng)輪播
//.closeInfiniteSlide(true) 關(guān)閉手指滑動(dòng)無限循環(huán)
//設(shè)置item的padding值(上下左右)
.setItemPadding(14)
//設(shè)置圓角半徑 一旦設(shè)置值(大于0) 就代表item使用圓角樣式
.setRoundRadius(10)
//.setSwitchAnimation() 設(shè)置ViewPager切換動(dòng)畫
//可選實(shí)現(xiàn)。實(shí)現(xiàn)圖片加載回調(diào)(一定要在start()之前執(zhí)行) 一但實(shí)現(xiàn)回調(diào)就表示圖片加載交由調(diào)用層處理 否則由適配器內(nèi)部加載
.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);
}
})
// 可選實(shí)現(xiàn)。實(shí)現(xiàn)item點(diǎn)擊事件回調(diào)(一定要在start()之前執(zhí)行)
.setOnItemClickListener(new SuperBanner.OnItemClickListener() {
@Override
public void onItemClick(int position) {
Log.i("BannerItemPosition: ", position + "");
}
})
// 此函數(shù)要最后執(zhí)行
.start();
如果有其他需求 直接改源碼就ok 注釋真的很詳細(xì)奧~
奧,最后 你可以在Activity
/Fragment
不可見的時(shí)候 關(guān)掉輪播定時(shí)任務(wù)以盡可能的減少內(nèi)存壓力和內(nèi)存泄漏發(fā)生:
@Override
protected void onPause() {
super.onPause();
//取消輪播
if (mSuperBanner!=null){
mSuperBanner.killDelayedTask();
}
}
然后在頁面可見時(shí)開啟:
@Override
protected void onStart() {
super.onStart();
//開始輪播
if (mSuperBanner!=null){
mSuperBanner.executeDelayedTask();
}
}
很希望能幫到你 不足之處還請(qǐng)見諒 懇請(qǐng)斧正 !。