美团,百度外卖的左右二级联动效果如下:

具体的效果建议打开手机软件玩玩。
分析
首先我们一起分析一下这个界面给我们要怎么去实现。
1.最上面的ToolBar不用多解释,比较简单。
2.下面三个界面切换,可以使用TabLayout+ViewPager,切换3个Fragment。
3.我们要处理的主要是最左边(也就是“点菜”)这个Fragment中的内容,另外的两个Fragment不用管。
4.这个Fragment分为左右两侧,左边就是一个简单的ListView。
5.右侧的就稍微复杂一点。首先,右侧的列表需要分组,我们使用StickyListHeaders。
这是一个粘列表标题的三方控件。类似于Android联系人的列表效果。
配置它:
compile 'se.emilsjolander:stickylistheaders:2.7.0'
使用StickyListHeaders的主要代码如下:
public class GoodsFragmentGoodsAdapter extends BaseAdapter implements StickyListHeadersAdapter { //处理分类条目头 public View getHeaderView(int position, View convertView, ViewGroup parent) { return null; } //获取条目数据对应的分类 public long getHeaderId(int position) { return 0; } }
具体实现
首先,主页面的布局如下:
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/seller_detail_container" android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@color/colorPrimaryDark" /> <android.support.design.widget.TabLayout android:id="@+id/tabs" android:layout_width="match_parent" android:layout_height="wrap_content"> </android.support.design.widget.TabLayout> <android.support.v4.view.ViewPager android:id="@+id/vp" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" /> </LinearLayout> </FrameLayout>
ToolBar+Tablayout+ViewPager,基本的组合,很简单 。
然后看它对应的Activity的代码:
public class SellerDetailActivity extends BaseActivity { private Toolbar toolBar; private String[] titles = {"商品", "评论", "商家"}; private TabLayout tabLayout; private ViewPager viewPager; private MyAdapter adapter; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_seller_detail); toolBar = ((Toolbar) findViewById(R.id.toolbar)); toolBar.setTitle("南京大排档(德基店)"); setSupportActionBar(toolBar); //替换toolbar的相关配置需要在这个方法前完成 getSupportActionBar().setDisplayHomeAsUpEnabled(true); //显示返回键 tabLayout = ((TabLayout) findViewById(R.id.tabs)); // tabLayout.addTab();//添加 viewPager = ((ViewPager) findViewById(R.id.vp)); adapter = new MyAdapter(getSupportFragmentManager()); viewPager.setAdapter(adapter); tabLayout.setupWithViewPager(viewPager); } //ViewPager的适配器 private class MyAdapter extends FragmentPagerAdapter { public MyAdapter(FragmentManager fm) { super(fm); } @Override public Fragment getItem(int position) { Fragment fragment = null; switch (position) { case 0: fragment = new GoodsFragment(); break; case 1: fragment = new RecommendFragment(); break; case 2: fragment = new SellerFragment(); break; } return fragment; } @Override public int getCount() { return titles.length; } //ViewPager和Tablayout结合使用时候需要复写 @Override public CharSequence getPageTitle(int position) { return titles[position]; } } @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == android.R.id.home) { finish(); } return super.onOptionsItemSelected(item); } }
这样就简单的实现了三个Fragment的切换,然后,我们着重要考虑的就是GoodsFragment的设计了。另外两个Fragment放个text展示即可。
下载效果如下:

如果你喜欢透明状态栏,可以配置下,那样顶部融为一体比较好看,这里我们要着重处理“商品”这个Fragment,就不再处理这些细节了。
GoodsFragment布局如下:
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal"> <ListView android:id="@+id/lv" android:layout_width="80dp" android:layout_height="match_parent" android:divider="@android:color/transparent" android:dividerHeight="0dp" android:scrollbars="none"> </ListView> <se.emilsjolander.stickylistheaders.StickyListHeadersListView android:id="@+id/shl" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1.0"> </se.emilsjolander.stickylistheaders.StickyListHeadersListView> </LinearLayout> //这就是一个浮动的购物车,可以暂时不要 <RelativeLayout android:id="@+id/cart" android:layout_width="50dp" android:layout_height="50dp" android:layout_gravity="bottom|left" android:layout_marginBottom="30dp" android:layout_marginLeft="55dp" android:background="@drawable/circle"> <ImageView android:id="@+id/iv_cart" android:layout_width="35dp" android:layout_height="35dp" android:layout_centerInParent="true" android:src="@mipmap/seller_detail_cart" /> <TextView android:id="@+id/fragment_goods_tv_count" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_alignParentRight="true" android:background="@drawable/bubble" android:gravity="center" android:text="1" android:textColor="#fff" android:textSize="12sp" android:visibility="invisible" /> </RelativeLayout> </FrameLayout>
水平的LinearLayout,左侧放ListView,右侧放StickyListHeadersListView。
GoodsFragment代码如下:
public class GoodsFragment extends BaseFragment implements AdapterView.OnItemClickListener, AbsListView.OnScrollListener { @InjectView(R.id.shl) StickyListHeadersListView shl; @InjectView(R.id.lv) ListView lv; private GroupAdapter groupAdapter; private HeadAdapter headAdapter; @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View view = inflater.inflate(R.layout.fragment_goods, null); ButterKnife.inject(this, view); return view; } @Override public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); testData(); headAdapter = new HeadAdapter(); lv.setAdapter(headAdapter); groupAdapter = new GroupAdapter(); shl.setAdapter(groupAdapter); lv.setOnItemClickListener(this); shl.setOnScrollListener(this); } /////////////////////////////左侧--头信息的点击事件///////////////////////////////// @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { headAdapter.setSelectPosition(position); Head head = heads.get(position); shl.setSelection(head.groupFirstIndex); isScroll = false; } /////////////////////////////右侧--分组信息的滚动事件//////////////////////////////// boolean isScroll = false; @Override public void onScrollStateChanged(AbsListView view, int scrollState) { //这个方法触发才代表用户的滚动 isScroll = true; } @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { //右侧分组信息滚动,左侧对应的头信息高亮处理 if (isScroll) { Data data = datas.get(firstVisibleItem); headAdapter.setSelectPosition(data.headIndex); //判断头容器是否处于可见状态 //获取到第一个和最后一个可见的,比第一个小或比最后一个大的均为不可见 int firstVisiblePosition = lv.getFirstVisiblePosition(); int lastVisiblePosition = lv.getLastVisiblePosition(); if (data.headIndex >= lastVisiblePosition || data.headIndex <= firstVisiblePosition) { lv.setSelection(data.headIndex);//可见处理 } } } private ArrayList<Head> heads = new ArrayList<>(); class Head { String info; int groupFirstIndex; } private ArrayList<Data> datas = new ArrayList<>(); class Data { public String info; int headId; int headIndex; } private void testData() { for (int i = 0; i < 10; i++) { Head head = new Head(); head.info = "头" + i; heads.add(head); for (int j = 0; j < 10; j++) { Data data = new Data(); data.info = "普通条目" + j; data.headId = i; data.headIndex = i; if (j == 0) { head.groupFirstIndex = datas.size(); } datas.add(data); } } } /////////////////////////////右侧--分组信息的适配器///////////////////////////////// private class GroupAdapter extends BaseAdapter implements StickyListHeadersAdapter { @Override public View getHeaderView(int position, View convertView, ViewGroup parent) { int headIndex = datas.get(position).headIndex; Head head = heads.get(headIndex); TextView textView = new TextView(MyApplication.getContext()); textView.setText(head.info); textView.setTextColor(Color.BLACK); textView.setBackgroundColor(Color.parseColor("#BFBFBF")); return textView; } @Override public long getHeaderId(int position) { return datas.get(position).headId; } @Override public int getCount() { return datas.size(); } @Override public Object getItem(int position) { return datas.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { TextView textView = new TextView(MyApplication.getContext()); textView.setText(datas.get(position).info); textView.setTextColor(Color.BLACK); return textView; } } //////////////左侧头信息--ListView的adapter/////////////// private class HeadAdapter extends BaseAdapter { private int selectPosition; @Override public int getCount() { return heads.size(); } @Override public Object getItem(int position) { return heads.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { TextView textView = new TextView(MyApplication.getContext()); textView.setText(heads.get(position).info); textView.setLayoutParams(new ListView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 100)); textView.setGravity(Gravity.CENTER); textView.setTextSize(16); textView.setTextColor(Color.BLACK); if (position == selectPosition) { textView.setBackgroundColor(Color.WHITE); } else { textView.setBackgroundColor(Color.parseColor("#BFBFBF")); } return textView; } public void setSelectPosition(int selectPosition) { if (this.selectPosition == selectPosition) { return; } this.selectPosition = selectPosition; notifyDataSetChanged(); } } @Override public void onDestroyView() { super.onDestroyView(); ButterKnife.reset(this); } }
下面我会把上面的代码分开来按照具体的实现过程一一讲解,也是实现二级联动个的核心:
1.首先我们通过ButterKnife找到了控件。
2.我们需要为ListView和StickyListHeadersListView构建一些模拟数据,而这里有很明显的一点,就是ListView的
数据和StickyListHeadersListView的头信息的数据是完全一样的。
所以我们需要两组数据,一组是头数据(既用于ListView,又用于StickyListHeadersListView的分组头数据),一组是
普通条目的数据。这点理解了就可以分析下面的内容了。下面我们会把左侧的listView或右侧的分组头统称为头信
息,或头数据。因为他们本身也是一致的。
3.于是我们构建了两个实体类
/** * 头信息---用于左侧的listView和右侧分组信息的头 */ private ArrayList<Head> heads = new ArrayList<>(); class Head { String info; int groupFirstIndex; //点击头任意角标的时候,需要知道其对应组的第一条元素下标,用于点击头,将对应组信息置顶。 } /** * StickyListHeadersListView的普通条目信息 */ private ArrayList<Data> datas = new ArrayList<>(); class Data { public String info; int headId; //进行分组操作,同组数据该字段值相同,可以是任意值 int headIndex; //当前普通条目对应的头数据所在集合的下标---也就是说,把头信息的position保存到分组数据中去。 }
刚开始的时候,我们不会想到要在Head里面添加groupFirstIndex字段,也不会想到在Data数据中添加headId和headIndex字段。只会都写上info字段,也就是头数据和普通条目最基本的数据。
然后我们模拟出测试数据:
private void testData() { for (int i = 0; i < 10; i++) { //左侧--头信息 Head head = new Head(); head.info = "头" + i; heads.add(head); for (int j = 0; j < 10; j++) { //右侧--分组中的条目 Data data = new Data(); data.info = "普通条目" + j; data.headId = i; data.headIndex = i; if (j == 0) { //在每个分组的第一条数据的时候,给头信息添加它的第一个元素的角标 head.groupFirstIndex = datas.size(); //0--10--20.... } datas.add(data); } } }
代码写到这里,刚才的几个字段理论上是还没有出现的理由的。
但是当我们给StickyListHeadersListView设置适配器的时候:
/////////////////////////////右侧--分组信息的适配器///////////////////////////////// private class GroupAdapter extends BaseAdapter implements StickyListHeadersAdapter { //注意:这里面所有的position都是普通条目的position,这个position跟头数据无关 ////////////////////////////////分组头信息的处理/////////////////////////////////// @Override public View getHeaderView(int position, View convertView, ViewGroup parent) { //获取头信息的position int headIndex = datas.get(position).headIndex; Head head = heads.get(headIndex); TextView textView = new TextView(MyApplication.getContext()); textView.setText(head.info); textView.setTextColor(Color.BLACK); textView.setBackgroundColor(Color.parseColor("#BFBFBF")); return textView; } @Override public long getHeaderId(int position) { //依据position获取普通条目,普通条目中存放了headId。这样就获取到了条目对应的分类 return datas.get(position).headId; } ////////////////////////////////普通条目的处理//////////////////////////////////// @Override public int getCount() { return datas.size(); } @Override public Object getItem(int position) { return datas.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { TextView textView = new TextView(MyApplication.getContext()); textView.setText(datas.get(position).info); textView.setTextColor(Color.BLACK); return textView; } }
首先我们继承了BaseAdapter,因为这面有普通的条目信息,需要继承BaseAdapter ,然后因为我们使用的第三方的控件来处理分组的头信息,需要实现它的StickyListHeadersAdapter ,这样就需要复写2组方法,第一组就是对头信息的处理,第二组就是baseAdapter中的对普通条目进行处理的四个方法,如图。
在getView方法中处理普通的条目,构建一个TextView返回即可。
重点在于,在getHeaderId方法中我们要获取到头数据的id。看到这里,你应该清楚了,前面的headId 字段就是这个作用,我们在for循环的内层为每个Head数据设置了它的headId,那么在getHeaderId方法中就可以从普通条目中拿到它所对应的头数据的headId.
你可能会问,为什么不把headId储存在Head里面,而是储存在它的分组数据里面?
因为GroupAdapter 里面所有复写方法中的的position都是普通条目的position,这个position跟头数据无关。
所以我们把headId储存到普通条目中,通过datas.get(position).headId就可以拿到了。
然后在getHeaderView方法中要处理头信息。就需要拿到头数据的角标了。
//获取头信息的position int headIndex = datas.get(position).headIndex; Head head = heads.get(headIndex);
同样的,我们只能根据普通条目的position拿到普通条目,所以头数据的index还是要储存到普通条目中去。
再回头看看这两个实体类是不是豁然开朗了。实体来中还有一个字段groupFirstIndex后面解释。
然后我们处理了左侧的listView的适配器,这就很简单了。看代码HeadAdapter。
现在左右两侧的数据已经能够展示出来了:

怎么样。效果不错吧,不要介意界面美观的问题,UI的事情我们不管,主要是功能嘛。
别高兴得太早,下面的问题还多着呢?
如何实现联动
左侧我们叫头容器,右侧叫条目容器。
1.在头容器中点击某个条目的时候,该条目背景高亮处理,同时让对应的该组信息在条目容器中置顶
2.条目容器滚动时,头容器跟着进行调整,包括背景高亮处理。
3.性能优化:避免频繁的刷新头信息。
首先我们给listView设置点击事件,看代码:
/////////////////////////////左侧--头信息的点击事件///////////////////////////////// @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { // 1.高亮点击条目 headAdapter.setSelectPosition(position); //2.点击头容器,分组容器对应分组信息进行置顶 Head head = heads.get(position); shl.setSelection(head.groupFirstIndex); } public void setSelectPosition(int selectPosition) { this.selectPosition = selectPosition; notifyDataSetChanged();//刷新适配器 }
在listView中通过如下代码设置了高亮的判断
if (position == selectPosition) { textView.setBackgroundColor(Color.WHITE); } else { textView.setBackgroundColor(Color.parseColor("#BFBFBF")); }
然后条目容器中的置顶就要用到前面剩余没讲的那个字段了:groupFirstIndex。
shl.setSelection(head.groupFirstIndex); class Head { String info; int groupFirstIndex; //点击头任意角标的时候,需要知道其对应组的第一条元素下标,用于点击头,将对应组信息置顶。 }
然后是StickyListHeadersListView的滚动事件的处理。
boolean isScroll = false; @Override public void onScrollStateChanged(AbsListView view, int scrollState) { //这个方法触发才代表用户的滚动 isScroll = true; } @Override public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) { //右侧分组信息滚动,左侧对应的头信息高亮处理 if (isScroll) { Data data = datas.get(firstVisibleItem); headAdapter.setSelectPosition(data.headIndex); //判断头容器是否处于可见状态 //获取到第一个和最后一个可见的,比第一个小或比最后一个大的均为不可见 int firstVisiblePosition = lv.getFirstVisiblePosition(); int lastVisiblePosition = lv.getLastVisiblePosition(); if (data.headIndex >= lastVisiblePosition || data.headIndex <= firstVisiblePosition) { lv.setSelection(data.headIndex);//可见处理 } } }
其中:
Data data = datas.get(firstVisibleItem); headAdapter.setSelectPosition(data.headIndex);
右侧滚动时,对应左侧头的高亮处理。
最后讲一讲性能优化的问题。
1.点击左侧头条目,会引起右侧StickyListHeadersListView的滚动,滚动就会触发onScroll方法。
又会重复触发headAdapter.setSelectPosition(data.headIndex)来设置头容器的定位(显然这里已经没必要了)
会重复调用setSelectPosition中的notifyDataSetChanged。也就会造成重复刷新了。
为了避免这个问题,我们可以通过打印日志来确定,但我们点击左侧的时候,会调用shl.setSelection(head.groupFirstIndex)来给条目容器分组进行置顶,而这个方法只会触发onScroll,不会触发onScrollStateChanged。也就是说,只有onScrollStateChanged方法触发才能代表用户的滚动。
所以我们添加了这个变量
boolean isScroll = false;
来记录是否是用户的滚动。然后在onScroll方法对这个变量进行判断,只有是用户滚动的时候才做相应处理。而单纯的点击左侧listview是不会触发滚动事件的回调的。
2.当右侧分组信息虽然在滑动,但仍然处在同一个分组时,没必要刷新界面。
这个我们在setSelectPosition的方法中添加了这个判断:
if (this.selectPosition == selectPosition) { return; }
如果是同一个分组,直接return,怎么样,这个处理是不是很细节。
以上,就实现了这种二级联动的效果了!并且对性能进行了很大的优化处理!