Android仿eleme點(diǎn)餐頁(yè)面二級(jí)聯(lián)動(dòng)列表
本周末外賣(mài)點(diǎn)得多,就仿一仿“餓了么”好了。先上圖吧,這樣的訂單頁(yè)面是不是很眼熟:

右邊的listview分好組以后,在左邊的Tab頁(yè)建立索引??梢灾苯訉?dǎo)航,是不是很方便。關(guān)鍵在于右邊滑動(dòng),左邊也會(huì)跟著滑;而點(diǎn)擊左邊呢,也能定位右邊的項(xiàng)。它們存在這樣一種特殊的交互。像這種聯(lián)動(dòng)的效果,還有些常見(jiàn)的例子呢,比如知乎采用了常見(jiàn)的toolbar+viewPager的聯(lián)動(dòng),只不過(guò)是上下布局:

再看看點(diǎn)評(píng),它的城市選擇頁(yè)面也有這種聯(lián)動(dòng)的影子,只是稍微弱一點(diǎn)。側(cè)邊欄可以對(duì)listview進(jìn)行索引,這最早是在微信好友列表里出現(xiàn)的把:

趁著周末,我也擼一個(gè)。就拓展性而言,應(yīng)該可以適配以上所有情況吧。我稱(chēng)其為L(zhǎng)inkedLayout,看下效果圖:

我把右邊按5個(gè)一組,可以看到,左邊的索引 = 右邊/5
特點(diǎn)
右邊滑動(dòng),左邊跟著動(dòng)
左邊滑動(dòng)到邊界,右邊跟著動(dòng)
點(diǎn)擊左邊tab項(xiàng),右邊滑動(dòng)定位到相應(yīng)的group
源碼
github 傳送門(mén): https://github.com/fashare2015/LinkedScrollDemo
知識(shí)點(diǎn)
做之前先羅列一下知識(shí)點(diǎn),或者說(shuō)我們能從這個(gè)demo里收獲到什么。
面向抽象/接口編程
自定義 view
代理模式
UML類(lèi)圖
復(fù)習(xí) listview && recyclerview 的細(xì)節(jié)
感覺(jué)做完以后收獲最大的還是第一點(diǎn),面向接口編程。事實(shí)上,完成功能的時(shí)間只占了一半,后邊的時(shí)間一直在抽象和重構(gòu);哎,一步到位太難了,還是老老實(shí)實(shí)寫(xiě)具體類(lèi),再抽取基類(lèi)把。
構(gòu)思
UI部分
LinkedLayout
要做的呢是兩個(gè)相互關(guān)聯(lián)的列表,在左邊的作為tab頁(yè),右邊的作為content頁(yè)。先不考慮交互,我們來(lái)打個(gè)界面:搞一個(gè)叫做LinkedLayout的類(lèi),用來(lái)盛放tab和content:

public class LinkedLayout extends LinearLayout {
private Context mContext;
private BaseScrollableContainer mTabContainer;
private BaseScrollableContainer mContentContainer;
private SectionIndexer mSectionIndexer; // 代理
...
}
我們讓它繼承了LinearLayout,同時(shí)持有兩個(gè)Container的東東,還有一個(gè)上帝對(duì)象mContext,以及一個(gè)分組用的SectionIndexer。
BaseScrollableContainer
先別管這些,主要看兩個(gè)Container,從名字上看一個(gè)是tab頁(yè),一個(gè)是content頁(yè),嘿嘿。因?yàn)樗鼈兌寄躶croll嘛,干脆搞一個(gè)BaseScrollableContainer把。取名為Container呢,當(dāng)然是致敬Fragment啦。我們來(lái)定義一下這個(gè)類(lèi):
初步一想,無(wú)非有一個(gè) mContext, 一個(gè) viewGroup, 還有一些 Listener 嘛:

public abstract class BaseScrollableContainer<VG extends ViewGroup> {
protected Context mContext;
public VG mViewGroup;
protected RealOnScrollListener mRealOnScrollListener;
private EventDispatcher mEventDispatcher;
...
}
和我們預(yù)想的差不多嘛,mContext上下文,mViewGroup基本就是指代我們的兩個(gè)listview了吧。當(dāng)然,我之后可是要做toolbar+viewpager的,肯定得依賴(lài)抽象,不能直接寫(xiě)listview啦。余下兩個(gè)是Listener,等我們界面搭好,寫(xiě)交互的時(shí)候在看把。
看來(lái)UML圖還是有好處的,繼承和依賴(lài)關(guān)系一目了然。
自定義View && 動(dòng)態(tài)布局
好了到了自定義view地環(huán)節(jié)了。我們已經(jīng)有了一個(gè)LinkedLayout,這是我們的activity_main.xml布局代碼:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.fashare.linkedscrolldemo.ui.LinkedLayout
android:id="@+id/linked_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"/>
</RelativeLayout>
擦,就沒(méi)了嘛?剩下的得靠Java代碼來(lái)搞啦?;氐絃inkedLayout咱們來(lái)布局UI~:
public class LinkedLayout extends LinearLayout {
...
private static final int MEASURE_BY_WEIGHT = 0;
private static final float WEIGHT_TAB = 1;
private static final float WEIGHT_CONTENT = 3;
public void setContainers(BaseScrollableContainer tabContainer, BaseScrollableContainer contentContainer) {
mTabContainer = tabContainer;
mContentContainer = contentContainer;
mTabContainer.setEventDispatcher(this);
mContentContainer.setEventDispatcher(this);
// 設(shè)置 LayoutParams
mTabContainer.mViewGroup.setLayoutParams(new LinearLayout.LayoutParams(
MEASURE_BY_WEIGHT,
ViewGroup.LayoutParams.WRAP_CONTENT,
WEIGHT_TAB
));
mContentContainer.mViewGroup.setLayoutParams(new LinearLayout.LayoutParams(
MEASURE_BY_WEIGHT,
ViewGroup.LayoutParams.MATCH_PARENT,
WEIGHT_CONTENT
));
this.addView(mTabContainer.mViewGroup);
this.addView(mContentContainer.mViewGroup);
this.setOrientation(HORIZONTAL);
}
}
搞了個(gè)setContainers用來(lái)注入我們的Container,里邊有一些像layout_height,layout_width,layout_weight,orientation之類(lèi)的,很眼熟吧,和xml沒(méi)差。順便一提的是,我們用了weight屬性來(lái)控制這個(gè)比例1:3,一直感覺(jué)這個(gè)屬性比較神奇。。。
注入ViewGroup, 使用自定義的LinkedLayout
到這里為止,LinkedLayout已經(jīng)布局好了,我們分別注入ViewGroup就可以用了。我這里分別用listview作tab,recyclerview作content。想像力有限,用來(lái)用去好像也就這么幾個(gè)控件。。。這部分代碼很簡(jiǎn)單,在MainActivity里,就不貼了。
子類(lèi)化 BaseScrollableContainer
按照常理,下邊應(yīng)該實(shí)現(xiàn)基類(lèi)了吧。前面的MainActivity中,我們是這樣實(shí)例化的:
mTabContainer = new ListViewTabContainer(this, mListView); mContentContainer = new RecyclerViewContentContainer(this, mRecyclerView);
看名字一個(gè)是listview填充的tab,一個(gè)是recyclerview填充的content。就先實(shí)現(xiàn)這兩個(gè)類(lèi)吧,從圖中可以看到,它們分別繼承于BaseScrollableContainer,并被LinkedLayout所持有:
交互部分
與用戶(hù)的交互:OnScrollListener 與 代理模式
終于到了交互部分,既然是滑動(dòng),那少不了定義監(jiān)聽(tīng)器啦。然而,麻煩在于listview和recyclerview各自的OnScrollListener還不一樣,這個(gè)時(shí)候如果各自實(shí)現(xiàn)的話(huà),既麻煩,又有冗余。像這樣子:
// RecyclerView
public class RecyclerViewContentContainer extends BaseScrollableContainer<RecyclerView> {
...
@Override
protected void setOnScrollListener() {
mViewGroup.addOnScrollListener(new ProxyOnScrollListener());
}
private class ProxyOnScrollListener extends RecyclerView.OnScrollListener {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
if(newState == RecyclerView.SCROLL_STATE_IDLE) { // 停止滑動(dòng)
1.停止時(shí)的邏輯...
}else if(newState == RecyclerView.SCROLL_STATE_DRAGGING){ // 按下拖動(dòng)
2.剛剛拖動(dòng)時(shí)的邏輯...
}
}
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) { // 滑動(dòng)
3.滑動(dòng)時(shí)的邏輯...
}
}
}
// ListView
public class ListViewTabContainer extends BaseScrollableContainer<ListView> {
...
@Override
protected void setOnScrollListener() {
mViewGroup.setOnScrollListener(new ProxyOnScrollListener());
...
}
public class ProxyOnScrollListener implements AbsListView.OnScrollListener{
@Override
public void onScrollStateChanged(AbsListView view, int scrollState) {
if(scrollState == SCROLL_STATE_IDLE) { // 停止滑動(dòng)
1.停止時(shí)的邏輯...
}else if(scrollState == SCROLL_STATE_TOUCH_SCROLL) // 按下拖動(dòng)
2.剛剛拖動(dòng)時(shí)的邏輯...
}
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
3.滑動(dòng)時(shí)的邏輯... // 滑動(dòng)
}
}
}
那該怎么辦呢,雖然各自的OnScrollListener差異挺大,但是仔細(xì)觀察可以發(fā)現(xiàn)其實(shí)很多邏輯都是類(lèi)似的,可以共用的。這時(shí)恰恰可以用代理模式來(lái)做重構(gòu)。我抽取了1、2、3處的邏輯,由于在抽象意義上是一致的,可以整理成接口:
public interface OnScrollListener {
// tab 點(diǎn)擊事件
void onClick(int position);
// 1.滑動(dòng)開(kāi)始
void onScrollStart();
// 2.滑動(dòng)結(jié)束
void onScrollStop();
// 3.觸發(fā) onScrolled()
void onScrolled();
// 用戶(hù)手動(dòng)滑, 觸發(fā)的 onScrolled()
void onScrolledByUser();
// 程序調(diào)用 scrollTo(), 觸發(fā)的 onScrolled()
void onScrolledByInvoked();
}
與此同時(shí),RecyclerView和ListView各自的監(jiān)聽(tīng)器便分別作為代理類(lèi),把1、2、3的邏輯都委托給某個(gè)接盤(pán)俠,不必自己去實(shí)現(xiàn),倒也落的輕松自在。如圖所示:這里寫(xiě)圖片描述
然后,讓我們來(lái)看看這個(gè)接盤(pán)俠:RealOnScrollListener。。。
不愧是一個(gè)老實(shí)類(lèi),它老實(shí)地接盤(pán)了OnScrollListener的所有接口,并被兩個(gè)代理類(lèi)Proxy…所持有(圖中并未畫(huà)出。。)。
具體實(shí)現(xiàn)就不貼了,大家可以下源碼來(lái)看。這里大致分析一下,它有三個(gè)成員:
public class RealOnScrollListener implements OnScrollListener {
public boolean isTouching = false; // 處于觸摸狀態(tài)
private int mCurPosition = 0; // 當(dāng)前選中項(xiàng)
private BaseViewGroupUtil<VG> mViewUtil; // ViewGroup 工具類(lèi)
...
}
isTouching:
為啥要維護(hù)這個(gè)觸摸狀態(tài)呢?這是由于我們的效果是聯(lián)動(dòng)的。這就比較討厭了,當(dāng)onScrolled()被調(diào)用,我們分不清是用戶(hù)的滑動(dòng),還是來(lái)自另一個(gè)列表滑動(dòng)時(shí)的聯(lián)動(dòng)效果。那我們記錄一下isTouching狀態(tài)呢,就能區(qū)分開(kāi)這兩種情況了。
更改isTouching的邏輯在onScrollStart()和onScrollStop()里邊。
mCurPosition:
這個(gè)很好解釋?zhuān)覀兠看位瑒?dòng)需要記錄當(dāng)前位置,然后通知另一個(gè)列表進(jìn)行聯(lián)動(dòng)。
這段邏輯在onScrolled()里邊。
mViewUtil:
一個(gè)工具庫(kù),用于簡(jiǎn)化邏輯。大概有scrollTo(),setViewSelected(),UpdatePosOnScrolled()等方法,如圖:
兩個(gè)Container之間的交互
之前都是對(duì)用戶(hù)的交互,終于到聯(lián)動(dòng)部分了。不急著實(shí)現(xiàn),先回答我一個(gè)問(wèn)題:假設(shè)我一個(gè)Activity里持有兩個(gè)Fragment,問(wèn)它們之間如何通信?
A同學(xué)大聲道:用廣播
B同學(xué):EventBus !!!
C同學(xué):看我 RxBus 。。。
別鬧好嗎。。。給我老老實(shí)實(shí)用Listener。顯然,我們這里面臨的是同樣的場(chǎng)景。LinkedLayout=Activity,Container=Fragment。
動(dòng)手前先定義Listener吧,要取個(gè)中二點(diǎn)的名字:
/*
* 事件分發(fā)者
*/
public interface EventDispatcher {
/**
* 分發(fā)事件: fromView 中的 pos 被選中
* @param pos
* @param fromView
*/
void dispatchItemSelectedEvent(int pos, View fromView);
}
/*
* 事件接受者
*/
public interface EventReceiver {
/**
* 收到事件: 立即選中 newPos
* @param newPos
*/
void selectItem(int newPos);
}
然后LinkedLayout作為父級(jí)元素,肯定是分發(fā)者的角色,應(yīng)當(dāng)實(shí)現(xiàn)EventDispatcher;而B(niǎo)aseScrollableContainer作為子元素,接受該事件,應(yīng)當(dāng)實(shí)現(xiàn)EventReceiver??聪骂?lèi)圖:

看下相應(yīng)的實(shí)現(xiàn)(EventReceiver):
public abstract class BaseScrollableContainer<VG extends ViewGroup>
implements EventReceiver {
protected RealOnScrollListener mRealOnScrollListener;
private EventDispatcher mEventDispatcher; // 持有分發(fā)者
...
public void setEventDispatcher(EventDispatcher eventDispatcher) {
mEventDispatcher = eventDispatcher;
}
// 掉用 mEventDispatcher,也就是 LinkedLayout
protected void dispatchItemSelectedEvent(int curPosition){
if(mEventDispatcher != null)
mEventDispatcher.dispatchItemSelectedEvent(curPosition, mViewGroup);
}
@Override
public void selectItem(int newPos) {
mRealOnScrollListener.selectItem(newPos);
}
// OnScrollListener: 代理模式
public class RealOnScrollListener implements OnScrollListener {
...
public void selectItem(int position){
mCurPosition = position;
Log.d("setitem", position + "");
// 來(lái)自另一邊的聯(lián)動(dòng)事件
mViewUtil.smoothScrollTo(position);
// if(mViewUtil.isVisiblePos(position)) // curSection 可見(jiàn)時(shí), 不滾動(dòng)
mViewUtil.setViewSelected(position);
}
@Override
public void onClick(int position) {
isTouching = true;
mViewUtil.setViewSelected(mCurPosition = position);
dispatchItemSelectedEvent(position); // 點(diǎn)擊tab,分發(fā)事件
isTouching = false;
}
...
@Override
public void onScrolled() {
mCurPosition = mViewUtil.updatePosOnScrolled(mCurPosition);
if(isTouching) // 來(lái)自用戶(hù), 通知 對(duì)方 聯(lián)動(dòng)
onScrolledByUser();
else // 來(lái)自對(duì)方, 被動(dòng)滑動(dòng)不響應(yīng)
onScrolledByInvoked();
}
@Override
public void onScrolledByUser() {
dispatchItemSelectedEvent(mCurPosition); // 來(lái)自用戶(hù), 通知 對(duì)方 聯(lián)動(dòng)
}
}
}
再看(EventDispatcher):
public class LinkedLayout extends LinearLayout implements EventDispatcher {
private BaseScrollableContainer mTabContainer;
private BaseScrollableContainer mContentContainer;
private SectionIndexer mSectionIndexer; // 分組接口
...
@Override
public void dispatchItemSelectedEvent(int pos, View fromView) {
if (fromView == mContentContainer.mViewGroup) { // 來(lái)自 content, 轉(zhuǎn)發(fā)給 tab
int convertPos = mSectionIndexer.getSectionForPosition(pos);
mTabContainer.selectItem(convertPos);
} else { // 來(lái)自 tab, 轉(zhuǎn)發(fā)給 content
int convertPos = mSectionIndexer.getPositionForSection(pos);
mContentContainer.selectItem(convertPos);
}
}
}
總結(jié)
到此為止,有沒(méi)有一種酣暢淋漓的感覺(jué)?不管怎么說(shuō),面向?qū)ο笫切叛?,定義好接口以后,實(shí)現(xiàn)起來(lái)怎么寫(xiě)怎么舒服。
// TODO: 之前說(shuō)了,這個(gè)聯(lián)動(dòng)是通用的。之后有時(shí)間會(huì)繼續(xù)實(shí)現(xiàn)一個(gè)toolbar+viewPager的聯(lián)動(dòng)…
彩蛋
高清無(wú)碼類(lèi)圖:(完整)

相關(guān)文章
android實(shí)現(xiàn)條目倒計(jì)時(shí)功能
這篇文章主要為大家詳細(xì)介紹了android實(shí)現(xiàn)條目倒計(jì)時(shí)功能,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2019-09-09
android第三方分享方式的簡(jiǎn)單實(shí)現(xiàn)
這篇文章主要為大家詳細(xì)介紹了android第三方分享方式的簡(jiǎn)單實(shí)現(xiàn),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-10-10
詳解Android冷啟動(dòng)實(shí)現(xiàn)APP秒開(kāi)的方法
這篇文章給大家介紹的是Android冷啟動(dòng)實(shí)現(xiàn)APP秒開(kāi)的方法,對(duì)大家日常開(kāi)發(fā)APP還是很實(shí)用的,有需要的可以參考借鑒。2016-08-08
Android編程中的5種數(shù)據(jù)存儲(chǔ)方式
這篇文章主要介紹了Android編程中的5種數(shù)據(jù)存儲(chǔ)方式,結(jié)合實(shí)例形式詳細(xì)分析了Android實(shí)現(xiàn)數(shù)據(jù)存儲(chǔ)的5中實(shí)現(xiàn)技巧,具有一定參考借鑒價(jià)值,需要的朋友可以參考下2015-12-12
Android中EditText的drawableRight屬性設(shè)置點(diǎn)擊事件
這篇文章主要介紹了Android中EditText的drawableRight屬性的圖片設(shè)置點(diǎn)擊事件,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2016-10-10
非常實(shí)用的側(cè)滑刪除控件SwipeLayout
這篇文章主要為大家詳細(xì)介紹了非常實(shí)用的側(cè)滑刪除控件SwipeLayout,類(lèi)似于QQ側(cè)滑點(diǎn)擊刪除效果,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-08-08
Android自定義View繪制貝塞爾曲線中小紅點(diǎn)的方法
貝塞爾曲線的本質(zhì)是通過(guò)數(shù)學(xué)計(jì)算的公式來(lái)繪制平滑的曲線,分為一階,二階,三階及多階。但是這里不講數(shù)學(xué)公式和驗(yàn)證,那些偉大的數(shù)學(xué)家已經(jīng)證明過(guò)了,所以就只講講Android開(kāi)發(fā)中的運(yùn)用吧2023-02-02
Android跳轉(zhuǎn)系統(tǒng)設(shè)置Settings的各個(gè)界面詳解
系統(tǒng)設(shè)置Settings中定義的一些常用的各界面ACTION常量,下面這篇文章主要給大家介紹了關(guān)于Android跳轉(zhuǎn)系統(tǒng)設(shè)置Settings的各個(gè)界面,文中介紹非常詳細(xì),需要的朋友可以參考下2023-01-01
Android MarkTipsView文字標(biāo)識(shí)控件使用方法
這篇文章主要為大家詳細(xì)介紹了Android MarkTipsView文字標(biāo)識(shí)控件的使用方法,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-04-04

