android 二級聯動列表,仿eleme點餐頁面
最近手感不錯,老想寫點輪子。正好週末外賣點得多,就仿一仿“餓了麼”好了。先上圖吧,這樣的訂單頁面是不是很眼熟:
右邊的listview分好組以後,在左邊的Tab頁建立索引。可以直接導航,是不是很方便。關鍵在於右邊滑動,左邊也會跟著滑;而點選左邊呢,也能定位右邊的項。它們存在這樣一種特殊的互動。像這種聯動的效果,還有些常見的例子呢,比如知乎採用了常見的tabLayout+viewPager的聯動,只不過是上下佈局:
再看看點評,它的城市選擇頁面也有這種聯動的影子,只是稍微弱一點。側邊欄可以對listview進行索引,這最早是在微信好友列表裡出現的把:
趁著週末,我也擼一個。就拓展性而言,應該可以適配以上所有情況吧。我稱其為LinkedLayout,看下效果圖:
我把右邊按5個一組,可以看到,左邊的索引 = 右邊/5
特點
- 右邊滑動,左邊跟著動
- 左邊滑動到邊界,右邊跟著動
- 點選左邊tab項,右邊滑動定位到相應的group
原始碼
知識點
做之前先羅列一下知識點,或者說我們能從這個demo裡收穫到什麼。
- 面向抽象/介面程式設計
- 自定義 view
- 代理模式
- UML類圖
- 複習 listview && recyclerview 的細節
感覺做完以後收穫最大的還是第一點,面向介面程式設計。事實上,完成功能的時間只佔了一半,後邊的時間一直在抽象和重構;哎,一步到位太難了,還是老老實實寫具體類,再抽取基類把。
構思
UI部分
LinkedLayout
要做的呢是兩個相互關聯的列表,在左邊的作為tab
content
頁。先不考慮互動,我們來打個介面:搞一個叫做LinkedLayout
的類,用來盛放tab
和content
:public class LinkedLayout extends LinearLayout {
private Context mContext;
private BaseScrollableContainer mTabContainer;
private BaseScrollableContainer mContentContainer;
private SectionIndexer mSectionIndexer; // 代理
...
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
我們讓它繼承了LinearLayout
,同時持有兩個Container
的東東,還有一個上帝物件mContext
,以及一個分組用的SectionIndexer
。
BaseScrollableContainer
先別管這些,主要看兩個Container
,從名字上看一個是tab頁,一個是content頁,嘿嘿。因為它們都能scroll嘛,乾脆搞一個BaseScrollableContainer
把。取名為Container
呢,當然是致敬Fragment
啦。我們來定義一下這個類:
初步一想,無非有一個 mContext, 一個 viewGroup, 還有一些 Listener 嘛:
public abstract class BaseScrollableContainer<VG extends ViewGroup> {
protected Context mContext;
public VG mViewGroup;
protected RealOnScrollListener mRealOnScrollListener;
private EventDispatcher mEventDispatcher;
...
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
和我們預想的差不多嘛,mContext
上下文,mViewGroup
基本就是指代我們的兩個listview
了吧。當然,我之後可是要做tablayout
+viewpager
的,肯定得依賴抽象,不能直接寫listview
啦。餘下兩個是Listener
,等我們介面搭好,寫互動的時候在看把。
看來UML圖還是有好處的,繼承和依賴關係一目瞭然。
自定義View && 動態佈局
好了到了自定義view地環節了。我們已經有了一個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>
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
擦,就沒了嘛?剩下的得靠java
程式碼來搞啦。回到LinkedLayout
咱們來佈局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);
// 設定 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);
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
搞了個setContainers
用來注入我們的Container
,裡邊有一些像layout_height
,layout_width
,layout_weight
,orientation
之類的,很眼熟吧,和xml沒差。順便一提的是,我們用了weight
屬性來控制這個比例1:3
,一直感覺這個屬性比較神奇。。。
注入ViewGroup, 使用自定義的LinkedLayout
到這裡為止,LinkedLayout
已經佈局好了,我們分別注入ViewGroup
就可以用了。我這裡分別用listview
作tab
,recyclerview
作content
。想像力有限,用來用去好像也就這麼幾個控制元件。。。這部分程式碼很簡單,在MainActivity
裡,就不貼了。
子類化 BaseScrollableContainer
按照常理,下邊應該實現基類了吧。前面的MainActivity
中,我們是這樣例項化的:
mTabContainer = new ListViewTabContainer(this, mListView);
mContentContainer = new RecyclerViewContentContainer(this, mRecyclerView);
看名字一個是listview
填充的tab
,一個是recyclerview
填充的content
。就先實現這兩個類吧,從圖中可以看到,它們分別繼承於BaseScrollableContainer
,並被LinkedLayout
所持有:
互動部分
與使用者的互動:OnScrollListener 與 代理模式
終於到了互動部分,既然是滑動,那少不了定義監聽器啦。然而,麻煩在於listview
和recyclerview
各自的OnScrollListener
還不一樣,這個時候如果各自實現的話,既麻煩,又有冗餘。像這樣子:
// 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) { // 停止滑動
1.停止時的邏輯...
}else if(newState == RecyclerView.SCROLL_STATE_DRAGGING){ // 按下拖動
2.剛剛拖動時的邏輯...
}
}
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) { // 滑動
3.滑動時的邏輯...
}
}
}
// 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) { // 停止滑動
1.停止時的邏輯...
}else if(scrollState == SCROLL_STATE_TOUCH_SCROLL) // 按下拖動
2.剛剛拖動時的邏輯...
}
@Override
public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
3.滑動時的邏輯... // 滑動
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
那該怎麼辦呢,雖然各自的OnScrollListener
差異挺大,但是仔細觀察可以發現其實很多邏輯都是類似的,可以共用的。這時恰恰可以用代理模式來做重構。我抽取了1、2、3處的邏輯,由於在抽象意義上是一致的,可以整理成介面:
public interface OnScrollListener {
// tab 點選事件
void onClick(int position);
// 1.滑動開始
void onScrollStart();
// 2.滑動結束
void onScrollStop();
// 3.觸發 onScrolled()
void onScrolled();
// 使用者手動滑, 觸發的 onScrolled()
void onScrolledByUser();
// 程式呼叫 scrollTo(), 觸發的 onScrolled()
void onScrolledByInvoked();
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
與此同時,RecyclerView
和ListView
各自的監聽器便分別作為代理類,把1、2、3的邏輯都委託給某個接盤俠,不必自己去實現,倒也落的輕鬆自在。如圖所示:
然後,讓我們來看看這個接盤俠:RealOnScrollListener
。。。
不愧是一個老實類,它老實地接盤了OnScrollListener
的所有介面,並被兩個代理類Proxy…所持有(圖中並未畫出。。)。
具體實現就不貼了,大家可以下原始碼來看。這裡大致分析一下,它有三個成員:
public class RealOnScrollListener implements OnScrollListener {
public boolean isTouching = false; // 處於觸控狀態
private int mCurPosition = 0; // 當前選中項
private BaseViewGroupUtil<VG> mViewUtil; // ViewGroup 工具類
...
}
- 1
- 2
- 3
- 4
- 5
- 6
isTouching
:
為啥要維護這個觸控狀態呢?這是由於我們的效果是聯動的。這就比較討厭了,當onScrolled()被呼叫,我們分不清是使用者的滑動,還是來自另一個列表滑動時的聯動效果。那我們記錄一下isTouching
狀態呢,就能區分開這兩種情況了。
更改isTouching
的邏輯在onScrollStart()
和onScrollStop()
裡邊。mCurPosition
:
這個很好解釋,我們每次滑動需要記錄當前位置,然後通知另一個列表進行聯動。
這段邏輯在onScrolled()
裡邊。mViewUtil
:
一個工具庫,用於簡化邏輯。大概有scrollTo(),setViewSelected(),UpdatePosOnScrolled()等方法,如圖:
兩個Container之間的互動
之前都是對使用者的互動,終於到聯動部分了。不急著實現,先回答我一個問題:假設我一個Activity
裡持有兩個Fragment
,問它們之間如何通訊?
- A同學大聲道:用廣播
- B同學:EventBus !!!
- C同學:看我 RxBus 。。。
別鬧好嗎。。。給我老老實實用Listener。顯然,我們這裡面臨的是同樣的場景。LinkedLayout
=Activity
,Container
=Fragment
。
動手前先定義Listener
吧,要取箇中二點的名字:
/*
* 事件分發者
*/
public interface EventDispatcher {
/**
* 分發事件: fromView 中的 pos 被選中
* @param pos
* @param fromView
*/
void dispatchItemSelectedEvent(int pos, View fromView);
}
/*
* 事件接受者
*/
public interface EventReceiver {
/**
* 收到事件: 立即選中 newPos
* @param newPos
*/
void selectItem(int newPos);
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
然後LinkedLayout
作為父級元素,肯定是分發者的角色,應當實現EventDispatcher
;而BaseScrollableContainer
作為子元素,接受該事件,應當實現EventReceiver
。看下類圖:
看下相應的實現(EventReceiver):
public abstract class BaseScrollableContainer<VG extends ViewGroup>
implements EventReceiver {
protected RealOnScrollListener mRealOnScrollListener;
private EventDispatcher mEventDispatcher; // 持有分發者
...
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 + "");
// 來自另一邊的聯動事件
mViewUtil.smoothScrollTo(position);
// if(mViewUtil.isVisiblePos(position)) // curSection 可見時, 不滾動
mViewUtil.setViewSelected(position);
}
@Override
public void onClick(int position) {
isTouching = true;
mViewUtil.setViewSelected(mCurPosition = position);
dispatchItemSelectedEvent(position); // 點選tab,分發事件
isTouching = false;
}
...
@Override
public void onScrolled() {
mCurPosition = mViewUtil.updatePosOnScrolled(mCurPosition);
if(isTouching) // 來自使用者, 通知 對方 聯動
onScrolledByUser();
else // 來自對方, 被動滑動不響應
onScrolledByInvoked();
}
@Override
public void onScrolledByUser() {
dispatchItemSelectedEvent(mCurPosition); // 來自使用者, 通知 對方 聯動
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
再看(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) { // 來自 content, 轉發給 tab
int convertPos = mSectionIndexer.getSectionForPosition(pos);
mTabContainer.selectItem(convertPos);
} else { // 來自 tab, 轉發給 content
int convertPos = mSectionIndexer.getPositionForSection(pos);
mContentContainer.selectItem(convertPos);
}
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
總結
到此為止,有沒有一種酣暢淋漓的感覺?不管怎麼說,面向物件是信仰,定義好介面以後,實現起來怎麼寫怎麼舒服。
// TODO: 之前說了,這個聯動是通用的。之後有時間會繼續實現一個tablayout+viewPager的聯動…
彩蛋
高清無碼類圖:(完整)