一耽梅、思路
現(xiàn)在很多應(yīng)用都采用 ViewPager 加 Fragment 的結(jié)構(gòu)薛窥,在 github 上隨便一搜也可以找出各種各樣的動(dòng)畫效果的 ViewPagerIndicator。前不久在項(xiàng)目詳情頁(yè)改版的需求中眼姐,需要把原來的 ViewPager 切換的結(jié)構(gòu)修改成垂直滾動(dòng)的結(jié)構(gòu)(如下圖)诅迷。
第一個(gè)反應(yīng)就是把原來的 ViewPagerIndicator 替換成 RadioGroup 和 RadioButton 然后設(shè)置監(jiān)聽,但是又不想放棄原來的 ViewPagerIndicator 的 tab 的切換動(dòng)畫效果众旗。
然后我選擇了第二種方法——在原來的 NestedScrollView 包含的子 ViewGroup 中插入一個(gè)寬為 match_parent罢杉,高為 1px 的 ViewPager,起到輔助動(dòng)畫的功能贡歧,來與 NestedScrollView 聯(lián)動(dòng)達(dá)到上圖的效果滩租。
二赋秀、效果
講完了思路,先來看下最終實(shí)現(xiàn)的效果持际,效果圖就是上邊這張沃琅,這里主要是給大家看下代碼里如何使用,使用是否方便蜘欲。
public class MainActivity extends AppCompatActivity implements NestedScrollView.OnScrollChangeListener{
private NestedScrollView mSv;
private ScrollViewTabIndicator mTab;
private ScrollViewTabIndicator mTab2;
private int[] mTabMiddleLocation = new int[2];
private int[] mTabTopLocation = new int[2];
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
initView();
}
private void initView() {
mSv = (NestedScrollView) findViewById(R.id.sv);
mTab = (ScrollViewTabIndicator) findViewById(R.id.tab);//在TitleBar下方的indicator
mTab2 = (ScrollViewTabIndicator) findViewById(R.id.tab2);//在ScrollView中的indicator
View view1 = findViewById(R.id.tv_1);//詳情View
View view2 = findViewById(R.id.tv_2);//評(píng)論View
View view3 = findViewById(R.id.tv_3);//須知View
List<String> names = new ArrayList<>();
names.add("詳情");
names.add("評(píng)論");
names.add("須知");
List<View> views = new ArrayList<>();
views.add(view1);
views.add(view2);
views.add(view3);
mTab.setScrollView(mSv,this,names,views);
//將mTab本身作為參數(shù)傳入mTab2已達(dá)到同步狀態(tài)
mTab2.setScrollView(mSv,mTab,names,views);
}
@Override
public void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
setVisibleAndGone();
}
private void setVisibleAndGone() {
mTab2.getLocationOnScreen(mTabMiddleLocation);
mTab.getLocationOnScreen(mTabTopLocation);
if (mTabMiddleLocation[1] <= mTabTopLocation[1]) {
mTab.setVisibility(View.VISIBLE);
mTab2.setVisibility(View.INVISIBLE);
} else {
mTab.setVisibility(View.INVISIBLE);
mTab2.setVisibility(View.VISIBLE);
}
}
}
可以看到使用的方法僅僅是找出 ScrollView 中對(duì)應(yīng)的 View益眉,并給出對(duì)應(yīng)的 tab 標(biāo)題,然后調(diào)用 setScrollView 方法設(shè)置到 ScrollViewTabIndicator姥份,其余的事都交給 ScrollViewTabIndicator 來執(zhí)行郭脂,唯一要自己處理的就是監(jiān)聽滾動(dòng)來控制 mTab 和 mTab2 的顯示和隱藏。
三澈歉、封裝
當(dāng)然這里我將很多 ViewPager 和 ScrollView 的邏輯都封裝起來了展鸡,否則你會(huì)發(fā)現(xiàn)的 Activity 或者 Fragment 中你會(huì)發(fā)現(xiàn)要增加很多與業(yè)務(wù)無關(guān)的代碼,而且也不利于后期的復(fù)用埃难。下邊我就介紹下基于 ViewPagerIndicator 的一些修改莹弊。
3.1 設(shè)置邏輯
/**
* 因?yàn)闀?huì)替換 scrollview 上的 listener, 所以要傳進(jìn)來.
* 如果傳進(jìn)來是一個(gè) TabIndicator 對(duì)象, 則兩者狀態(tài)會(huì)同步, 并且自定義的滾動(dòng)監(jiān)聽要設(shè)置到第一個(gè)上邊
* @param scrollView 監(jiān)聽的 NestedScrollView
* @param listener 原先設(shè)置在 NestedScrollView 上的監(jiān)聽
* @param tabs tab 的標(biāo)題
* @param views 各個(gè) tab 對(duì)應(yīng)的需要滾動(dòng)到的 View
*/
public void setScrollView(NestedScrollView scrollView, NestedScrollView.OnScrollChangeListener listener, List<String> tabs, List<View> views) {
if (mScrollView == scrollView) {
return;
}
if (tabs == null || views == null) {
throw new IllegalArgumentException("tabs and views should not be null!");
}
if (tabs.isEmpty() || views.isEmpty()) {
throw new IllegalArgumentException("tabs and views should not be empty!");
}
if (tabs.size() != views.size()) {
throw new IllegalArgumentException("tabs and views should be the same length!");
}
mScrollListener = listener;
mScrollView = scrollView;
mViews = views;
if (mScrollView != null) {
mScrollView.setOnScrollChangeListener(this);
}
initTabs(tabs);
if (listener instanceof ScrollViewTabIndicator) {
ScrollViewTabIndicator synchronize = (ScrollViewTabIndicator) listener;
mAssistViewPager = synchronize.getAssistViewPager();
//接收覆蓋監(jiān)聽,避免走多余的監(jiān)聽流程
mScrollListener = synchronize.mScrollListener;
if (mAssistViewPager != null) {
mAssistViewPager.addOnPageChangeListener(this);
} else {
initAssistViewPager(tabs.size());
}
} else {
initAssistViewPager(tabs.size());
}
}
去除了原來的具備的 setViewPager 方法涡尘,添加了 setScrollView忍弛,這里主要就是進(jìn)行各種判空,接收傳進(jìn)來的參數(shù)(listener)考抄,并調(diào)用 initTabs(tabs) 來生成對(duì)應(yīng)的 tab细疚。之后調(diào)用 initAssistViewPager(tabs.size()) 來創(chuàng)建輔助動(dòng)畫的 ViewPager,可以先不管 if() 里面的代碼川梅。
3.2 輔助ViewPager
private void initAssistViewPager(int size) {
if (mAssistViewPager != null) {
return;
}
mAssistViewPager = new ViewPager(getContext());
ViewGroup.LayoutParams p = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 1);
mAssistViewPager.setLayoutParams(p);
//請(qǐng)注意這段代碼, 因?yàn)闀?huì)在 ScrollView 的子 view 中插入一個(gè) ViewPager
View viewGroup = mScrollView.getChildAt(0);
if (viewGroup == null) {
throw new IllegalStateException(" The child view of the ScrollView must be not null!");
}
if (!(viewGroup instanceof ViewGroup)) {
throw new IllegalStateException(" The child view of the ScrollView must be a ViewGroup!");
}
viewGroup = mScrollView.getChildAt(0);
((ViewGroup) viewGroup).addView(mAssistViewPager);
final List<View> viewList = new ArrayList<>();
for (int i = 0; i < size; i++) {
viewList.add(new Space(getContext()));
}
mAssistViewPager.setAdapter(new PagerAdapter() {
@Override
public int getCount() {
return viewList.size();
}
@Override
public boolean isViewFromObject(View view, Object object) {
return view == object;
}
@Override
public Object instantiateItem(ViewGroup container, int position) {
container.addView(viewList.get(position));
return viewList.get(position);
}
@Override
public void destroyItem(ViewGroup container, int position, Object object) {
container.removeView(viewList.get(position));
}
});
mAssistViewPager.addOnPageChangeListener(this);
}
創(chuàng)建了一個(gè)只有 1px 高度的 ViewPager 但是由于 ScrollViewTabIndicator 本身繼承的是一個(gè)水平方向的 LinearLayout疯兼,而且需要給予 ViewPager 一定寬度以保證 tab 切換有一定動(dòng)畫效果,所以這里只能在 ScrollView 的子 view 中插入 ViewPager贫途。并且這個(gè) ViewPager 僅僅是為了可以在它的 page 切換的時(shí)候在它的 OnPageChangeListener 中實(shí)現(xiàn) tab 切換的動(dòng)畫吧彪。ps:到這里我覺得針對(duì)任何一個(gè) ViewPagerIndicator 都可以采用這個(gè)形式來修改成我所謂的 ScrollViewTabIndicator。
3.3 對(duì)需要定位的 Views 在 onScrollChange 中的處理
@Override
public void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
if (mScrollListener != null) {
mScrollListener.onScrollChange(v, scrollX, scrollY, oldScrollX, oldScrollY);
}
if (isScrolling()) {
return;
}
if (mStatusBarHeight == 0) {
mStatusBarHeight = getBarHeight();
}
int top = mActionBarHeight + getMeasuredHeight() + mStatusBarHeight;//TitleBar 高度 + 控件高度 + StatusBar 高度
List<Integer> locations = new ArrayList<>();
for (int i = 0, size = mViews.size(); i < size; i++) {
locations.add(getViewLocation(i));
}
Collections.sort(locations);
int position = 0;
if (top < locations.get(0)) {
position = 0;
} else {
for (int j = 0, size = locations.size(); j < size; j++) {
if (j + 1 == size) {
position = j;
break;
}
if (top >= locations.get(j) && top < locations.get(j + 1)) {
//如果已經(jīng)不能向下滾動(dòng)了就
if (!v.canScrollVertically(VERTICAL)) {
position = size - 1;
break;
}
position = j;
break;
}
}
}
if (getCurrentIndex() == position) {
return;
}
mAssistViewPager.setCurrentItem(position, true);
}
首先這里的 mScrollListener 就是我們?cè)凇?」中 setScrollView 里面?zhèn)魅氲?listener潮饱;isScrolling()是 ViewPager 是否在滾動(dòng)来氧;然后給個(gè)需要定位的 View 計(jì)算在屏幕上的 y 坐標(biāo),并進(jìn)行從小到大排序。
private int getViewLocation(int position) {
if (mViews != null && mViews.size() > position) {
View view = mViews.get(position);
if (view != null) {
int[] location = new int[2];
view.getLocationOnScreen(location);
return location[1];
}
}
return 0;
}
之后進(jìn)行判斷來確定是否需要切換 tab:
1.如果所有的坐標(biāo)都大于 top (TitleBar 高度 + 控件高度 + StatusBar 高度香拉,因?yàn)槲覀円龅接袘腋≡跇?biāo)題欄下方的視覺效果所以這里要加控件高度啦扬,大家可自行根據(jù)需求修改這里),則 position 為 0凫碌;
2.如果存在坐標(biāo)小于 top:
- 存在 top 介于坐標(biāo) j 和 坐標(biāo) j + 1 之間扑毡,且可以繼續(xù)向下滾,則取 position 為 j盛险;
- 存在 top 介于坐標(biāo) j 和 坐標(biāo) j + 1 之間瞄摊,且不可以向下滾勋又,取 position 為 size - 1;(為了解決最底部 View 過短永遠(yuǎn)也滾不到的情況)
- 如果所有坐標(biāo)都大于 top换帜,則取 position 為 size - 1楔壤;
如果取到的 position 和 之前的不同則讓 ViewPager 滾到新的一頁(yè),并且 tabs 進(jìn)行相應(yīng)的切換惯驼,當(dāng)然之后的動(dòng)畫邏輯其實(shí)是原來 ViewPagerIndicator 的代碼蹲嚣,這里就不進(jìn)行說明,有興趣的可以之后看下完整的代碼祟牲。
3.4 點(diǎn)擊 Tab 實(shí)現(xiàn)切換和滾動(dòng)
@Override
public void onClick(android.view.View v) {
int position = (Integer) v.getTag();
if (mScrollView != null) {
int location;
location = getViewLocation(position);
// 待滑動(dòng)距離 = 當(dāng)前坐標(biāo) - (ActionBar高度) - indicator高度 - 狀態(tài)欄高度
if (mStatusBarHeight == 0) {
mStatusBarHeight = getBarHeight();
}
location += -mActionBarHeight - getMeasuredHeight() - mStatusBarHeight;
mScrollView.smoothScrollBy(0, location);
}
if (mAssistViewPager != null) {
mIsClick = true;
mAssistViewPager.setCurrentItem(position, true);
}
}
這里的點(diǎn)擊事件是在 initTabs(tabs) 的時(shí)候設(shè)置在每個(gè) TabView 上的隙畜,這里 TabView 的僅僅是繼承了 AppCompatRadioButton 做了一些顏色和背景的設(shè)置。點(diǎn)擊事件做了兩件事说贝,一件是計(jì)算 ScrollView 需要滾動(dòng)的距離并進(jìn)行平滑滾動(dòng)议惰,另外一件就是讓 ViewPager 進(jìn)行平滑的滾動(dòng)。
上面在「3」中的 onScrollChange 我們剛才已經(jīng)知道它對(duì) ViewPager 的滾動(dòng)進(jìn)行了判斷乡恕,當(dāng) ViewPager 滾動(dòng)過程中不會(huì)進(jìn)一步進(jìn)行處理言询。但是事實(shí)上這里還是會(huì)有所影響,因?yàn)閮烧叩臐L動(dòng)時(shí)間不一致傲宜!ScrollView 往往會(huì)慢一點(diǎn)倍试,所以常常會(huì)發(fā)生點(diǎn)擊過后 tab 回滾的現(xiàn)象,所以用 mIsClick 進(jìn)行了進(jìn)一步判斷的處理蛋哭。
@Override
public void onPageScrollStateChanged(int state) {
if (state == ViewPager.SCROLL_STATE_IDLE) {
TextView tv = getTabView(mSelectedPosition);
if (tv != null)
switch (mIndicatorMode) {
case MATCH_PARENT:
updateIndicator(tv.getLeft(), tv.getMeasuredWidth());
break;
case WRAP_CONTENT:
int textWidth = getTextWidth(tv);
updateIndicator(tv.getLeft() + tv.getWidth() / 2 - textWidth / 2, textWidth);
break;
}
/*
* 因 ScrollView 的滾動(dòng)可能持續(xù)比ViewPager長(zhǎng),
* 因此此處不設(shè)置延時(shí)將存在{@link #onScrollChange(NestedScrollView, int, int, int, int)} 中調(diào)用的 isScrolling() 不能攔截掉一些多余的處理,
* 導(dǎo)致indicator回滾的現(xiàn)象, 暫時(shí)未考慮到更好的處理方式
*/
if(mIsClick) {
removeCallbacks(mScrollOffRunnable);
postDelayed(mScrollOffRunnable, 200);
}else{
mScrolling = false;
}
mIsClick = false;
} else {
removeCallbacks(mScrollOffRunnable);
mScrolling = true;
}
}
private Runnable mScrollOffRunnable = new Runnable() {
@Override
public void run() {
mScrolling = false;
}
};
可以看到 mIsClick 僅僅是把 mScrolling 延遲 200ms 設(shè)置成 false,為了讓 ScrollView 先滾完涮母,目前還沒想到其他的方法谆趾。到這里這個(gè)控件可以獨(dú)立使用了,但是為了實(shí)現(xiàn)下面的效果叛本,而不至于監(jiān)聽太亂所以進(jìn)一步進(jìn)行優(yōu)化沪蓬。
3.5 ScrollViewTabIndicator 之間的同步
其實(shí)代碼很簡(jiǎn)單,細(xì)心的同學(xué)可能已經(jīng)看見了来候,就是「1」中讓大家跳過的 if() 中的語句跷叉。
if (listener instanceof ScrollViewTabIndicator) {
ScrollViewTabIndicator synchronize = (ScrollViewTabIndicator) listener;
mAssistViewPager = synchronize.getAssistViewPager();
//接收覆蓋監(jiān)聽,避免走多余的監(jiān)聽流程
mScrollListener = synchronize.mScrollListener;
if (mAssistViewPager != null) {
mAssistViewPager.addOnPageChangeListener(this);
} else {
initAssistViewPager(tabs.size());
}
} else {
initAssistViewPager(tabs.size());
}
判斷如果傳入的 listener 如果是 ScrollVIewTabIndicator 對(duì)象則直接共用創(chuàng)建的輔助動(dòng)畫的 ViewPager营搅,并且接收其中的 mScrollListener云挟。最終會(huì)走的 onScrollChange 的只有最后一個(gè)控件實(shí)現(xiàn)的方法,和最初傳進(jìn)來的 listener转质。下邊看看 5 個(gè)控件的同步過程园欣。
mTab.setScrollView(mSv,this,names,views);
mTab3.setScrollView(mSv,mTab,names,views);
mTab4.setScrollView(mSv,mTab3,names,views);
mTab5.setScrollView(mSv,mTab4,names,views);
mTab2.setScrollView(mSv,mTab5,names,views);
這里只走 this 和 mTab2 的 onScrollChange 方法。
四休蟹、重申幾個(gè)注意點(diǎn)
- 該類會(huì)在 ScrollView 的子 View 中插入一個(gè)寬度為 match_parent, 高度為 1px 的 ViewPager (用來輔助動(dòng)畫), 因此確保 ScrollView 中包含的是 ViewGroup沸枯;
- 使用時(shí)調(diào)用 {{@link #setScrollView(NestedScrollView, NestedScrollView.OnScrollChangeListener, List, List)}}與 NestedScrollView 關(guān)聯(lián),并且原來要設(shè)置再 NestedScrollView 上的監(jiān)聽要在此傳入, 否則講被替換日矫;
- TabIndicator 本身也可以作為一個(gè){ NestedScrollView.OnScrollChangeListener} 傳入, 如果這樣, 兩個(gè)控件將會(huì)同步, 共享已經(jīng)創(chuàng)建的 ViewPager;
- 默認(rèn)用48dp的像素值作為 ActionBar 的高度, 計(jì)算滾動(dòng)距離, 如果有需求用{{@link #setActionBarHeight(int)}} 來設(shè)置绑榴;
五哪轿、 5月11日更新
1. 修復(fù)快速滑動(dòng) tab 沒有切換的問題
不再使用 isScrolling() 方法和 mScrolling 攔截 ScrollView 中的監(jiān)聽,改用 mIsClick 判斷翔怎;修改原先 mScrollOffRunnable 中的 run 方法窃诉。
@Override
public void onPageScrollStateChanged(int state) {
if (state == ViewPager.SCROLL_STATE_IDLE) {
mScrolling = false;
TextView tv = getTabView(mSelectedPosition);
if (tv != null)
switch (mIndicatorMode) {
case MATCH_PARENT:
updateIndicator(tv.getLeft(), tv.getMeasuredWidth());
break;
case WRAP_CONTENT:
int textWidth = getTextWidth(tv);
updateIndicator(tv.getLeft() + tv.getWidth() / 2 - textWidth / 2, textWidth);
break;
}
/*
* 因 ScrollView 的滾動(dòng)可能持續(xù)比 ViewPager 長(zhǎng),
* 因此此處不設(shè)置延時(shí)將存在{@link #onScrollChange(NestedScrollView, int, int, int, int)} 中調(diào)用的 mIsClick 不能攔截掉一些多余的處理,
* 導(dǎo)致indicator回滾的現(xiàn)象, 暫時(shí)未考慮到更好的處理方式
*/
if (mIsClick) {
removeCallbacks(mScrollOffRunnable);
postDelayed(mScrollOffRunnable, 220);
}
} else {
mScrolling = true;
}
}
private Runnable mScrollOffRunnable = new Runnable() {
@Override
public void run() {
mIsClick = false;
}
};
修改「3.5」中的同步代碼。
if (listener instanceof ScrollViewTabIndicator) {
mSynchronize = (ScrollViewTabIndicator) listener;
mSynchronize.mNextSynchronize = this;
mAssistViewPager = mSynchronize.getAssistViewPager();
//接收覆蓋監(jiān)聽姓惑,避免走多余的監(jiān)聽流程
mScrollListener = mSynchronize.mScrollListener;
if (mAssistViewPager != null) {
mAssistViewPager.addOnPageChangeListener(this);
} else {
initAssistViewPager(tabs.size());
}
} else {
initAssistViewPager(tabs.size());
}
可以看到這里不僅保持傳進(jìn)來的 ScrollViewTabIndicator 對(duì)象為 mSynchronize褐奴,而且如果本身如果被設(shè)置給其他的 ScrollViewTabIndicator 他的 mNextSynchronize 也會(huì)被賦值。保持這兩個(gè)引用主要是為了保證多個(gè) ScrollViewTabIndicator 同步時(shí)于毙,在點(diǎn)擊不同對(duì)象的 tab 的時(shí)候敦冬,他們的 mIsClick 能保持一致,下邊看一下 onClick(View v) 方法的改變唯沮。
@Override
public void onClick(android.view.View v) {
int position = (Integer) v.getTag();
if (mAssistViewPager != null) {
synchronizeClickStatus();
mAssistViewPager.setCurrentItem(position, true);
}
if (mScrollView != null) {
int location;
location = getViewLocation(position);
// 待滑動(dòng)距離 = 當(dāng)前坐標(biāo) - (ActionBar高度) - indicator高度 - 狀態(tài)欄高度
if (mStatusBarHeight == 0) {
mStatusBarHeight = getBarHeight();
}
location += -mActionBarHeight - getMeasuredHeight() - mStatusBarHeight;
// location -= getViewMarginTop(position);
//因?yàn)檫@里經(jīng)常會(huì)出現(xiàn) scrollView 沒有滾動(dòng)的現(xiàn)象這里才加了 delay
final int finalLocation = position == 0 ? (location > 0 ? location - 1 : location + 1) : location;
mScrollView.postDelayed(new Runnable() {
@Override
public void run() {
mScrollView.smoothScrollBy(0, finalLocation);
}
}, 100);
}
}
private void synchronizeClickStatus() {
mIsClick = true;
if (mSynchronize != null && !mSynchronize.mIsClick) {
mSynchronize.synchronizeClickStatus();
}
if (mNextSynchronize != null && !mNextSynchronize.mIsClick) {
mNextSynchronize.synchronizeClickStatus();
}
}
修改了兩方面脖旱,一是點(diǎn)擊的時(shí)候同時(shí)修改了前一個(gè)和后一個(gè)同步的 ScrollViewTabIndicator 的 mIsClick,二是因?yàn)橹苯诱{(diào)用 ScrollView 的 smoothScrollBy 方法介蛉,如果點(diǎn)擊速度過快 smoothScrollBy 方法中對(duì)點(diǎn)擊的時(shí)間間隔做了判斷萌庆,導(dǎo)致 ScrollView 常常滾動(dòng)不到預(yù)期的位置,所以做了 100 毫秒的延遲處理币旧。
到這里對(duì)于快速滾動(dòng)的處理算是完成了践险,可能還有其他的問題,如果大家有發(fā)現(xiàn)問題或者意見還望提醒我改正吹菱,謝謝巍虫。
六、 最后
感謝 J!nL!n 同學(xué)的 TabIndicator 以及 CF 同學(xué)的啟發(fā)鳍刷。