最近在一個交流群里看到有個朋友問兩個RecyclerView左右聯(lián)動效果怎么實現(xiàn)酌伊,看了他的效果圖就感覺很熟悉跑筝,發(fā)現(xiàn)是自己去年9月份寫過的一個界面痰催,于是乎回顧了當(dāng)初所寫的代碼,并寫了一個Demo蜻懦。
拼多多效果圖:
看圖分析需求:
- 左邊item點擊選中時,顯示狀態(tài)改變夕晓,右邊將相應(yīng)的分類內(nèi)容滑動到最頂部顯示宛乃。
- 右邊滑動時,左邊item跟隨著滑動改變選中狀態(tài)蒸辆,具體為右邊所看見第一個item內(nèi)容列表的顯示高度小于自身一半時征炼,左邊自動跳轉(zhuǎn)到下一個item。
實現(xiàn)思路:
- 如上圖所示躬贡,整體界面可用三個RecyclerView實現(xiàn)谆奥,1,2關(guān)聯(lián)滑動拂玻,3為嵌套內(nèi)容顯示酸些。
- 左邊item點擊實現(xiàn)為在數(shù)據(jù)源中添加一個isSelected布爾值,在holder中通過判斷isSelected是否為true來改變背景顏色和字體顏色檐蚜,每次點擊后改變isSelected值魄懂,然后調(diào)用notifyItemChanged(position)方法刷新界面。
- 接下來就考慮左邊點擊時右邊跟隨著滑動熬甚,RecyclerView中滑動到指定position的方法有scrollToPosition(position)和smoothScrollToPosition(position)兩種逢渔,兩者區(qū)別為前者類似于直接跳到position位置,后者有一個滑動過程乡括,故此我們選擇后者來實現(xiàn)滑動效果肃廓。
- 右邊監(jiān)聽滑動,設(shè)置addOnScrollListener在onScrolled()方法中獲取第一個可見item诲泌,判斷item的顯示高度來改變左邊item所選中的position盲赊。
所遇到的問題
- 在點擊后,右邊滑動時敷扫,會再次刷新左邊選中狀態(tài)哀蘑,例:當(dāng)前選中為第一項诚卸,當(dāng)點擊第五項時,第五項設(shè)為選中狀態(tài)绘迁,右邊會將第五個item滑動到頂部顯示合溺,不過在滑動的時候會觸發(fā)onScrolled()方法,這使得左邊依次的刷新1缀台、2棠赛、3、4膛腐、5個item的選中狀態(tài)睛约。
解決辦法:設(shè)置一個點擊狀態(tài)變量clicked,當(dāng)是點擊時將clicked設(shè)為true哲身,然后在onScrolled判斷若是點擊的條件下辩涝,不觸發(fā)左邊的關(guān)聯(lián)滑動,最后在onScrollStateChanged(RecyclerView recyclerView, int newState)方法中勘天,判斷clicked是否為真且newState為停止?fàn)顟B(tài)時將點擊狀態(tài)設(shè)為flase怔揩。
Tips:newState有三種狀態(tài):SCROLL_STATE_IDLE:停止?fàn)顟B(tài);SCROLL_STATE_DRAGGING:手指拖動狀態(tài)误辑;SCROLL_STATE_SETTLING:手指拖動后剛抬起手指的狀態(tài)沧踏; - 調(diào)用smoothScrollToPosition(position)時,item有時會在頂部巾钉,有時會在底部翘狱,而要求是item每次都滑動到頂部。
解決辦法:當(dāng)初處理這個問題時使用了其他方法砰苍,不過我實現(xiàn)后還是不行潦匈,最后在源碼中找到了解決方案。首先赚导,思考問題茬缩,為什么會有時出現(xiàn)到頂部,有時出現(xiàn)在底部呢吼旧,經(jīng)過多次探索凰锡,發(fā)現(xiàn)smoothScrollToPosition(position)的方法的目的是將該position的item顯示在界面上。具體的效果可理解為若該position的item的top和bottom都在界面上圈暗,那么將不會滑動掂为,若只有top在界面上,那么會向上滑動员串,將bottom顯示出來即可勇哗,若只有bottom在界面上,那么會向下滑動寸齐,將top也展示在界面上欲诺,若兩者都不在界面上抄谐,會自動計算上下滑動的距離,選擇較小的值滑動扰法。那么我們可不可以只讓它滑動到頂部呢蛹含,去源碼中找找具體的實現(xiàn)吧。
首先進入smoothScrollToPosition(position)方法中,發(fā)現(xiàn)主要是調(diào)用了mLayout.smoothScrollToPosition(this, mState, position)方法塞颁。
public void smoothScrollToPosition(int position) {
if (mLayoutFrozen) {
return;
}
if (mLayout == null) {
Log.e(TAG, "Cannot smooth scroll without a LayoutManager set. "
+ "Call setLayoutManager with a non-null argument.");
return;
}
mLayout.smoothScrollToPosition(this, mState, position);
}
此處的mLayout是我們?yōu)镽ecyclerView設(shè)置的LayoutManager挣惰,所以具體的就在LinearLayoutManager中查看,在該類中找到重寫的smoothScrollToPosition方法殴边。
public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state,
int position) {
LinearSmoothScroller linearSmoothScroller =
new LinearSmoothScroller(recyclerView.getContext());
linearSmoothScroller.setTargetPosition(position);
startSmoothScroll(linearSmoothScroller);
}
在該方法中new了一個LinearSmoothScroller對象,然后調(diào)用startSmoothScroll方法珍语,startSmoothScroll后續(xù)中并沒有發(fā)現(xiàn)怎么計算滑動的锤岸,所以具體實現(xiàn)就在LinearSmoothScroller類中。
/**
* Align child view's left or top with parent view's left or top
*
* @see #calculateDtToFit(int, int, int, int, int)
* @see #calculateDxToMakeVisible(android.view.View, int)
* @see #calculateDyToMakeVisible(android.view.View, int)
*/
public static final int SNAP_TO_START = -1;
/**
* Align child view's right or bottom with parent view's right or bottom
*
* @see #calculateDtToFit(int, int, int, int, int)
* @see #calculateDxToMakeVisible(android.view.View, int)
* @see #calculateDyToMakeVisible(android.view.View, int)
*/
public static final int SNAP_TO_END = 1;
/**
* <p>Decides if the child should be snapped from start or end, depending on where it
* currently is in relation to its parent.</p>
* <p>For instance, if the view is virtually on the left of RecyclerView, using
* {@code SNAP_TO_ANY} is the same as using {@code SNAP_TO_START}</p>
*
* @see #calculateDtToFit(int, int, int, int, int)
* @see #calculateDxToMakeVisible(android.view.View, int)
* @see #calculateDyToMakeVisible(android.view.View, int)
*/
public static final int SNAP_TO_ANY = 0;
一進入LinearSmoothScroller就被這三個常量所吸引板乙,用我蹩腳的英語翻譯了下是偷,發(fā)現(xiàn)有點意思,首先看SNAP_TO_START:Align child view's left or top with parent view's left or top(將子視圖的左視圖或上視圖與父視圖的左視圖或上視圖對齊)募逞,這不就是我們想要的頂部對齊嗎蛋铆,然后就選中Ctrl+F,找尋它的蹤影放接,下一個刺啦、下一個、下一...等會兒纠脾,好像發(fā)現(xiàn)了什么B耆场!苟蹈!
/**
* When scrolling towards a child view, this method defines whether we should align the left
* or the right edge of the child with the parent RecyclerView.
*
* @return SNAP_TO_START, SNAP_TO_END or SNAP_TO_ANY; depending on the current target vector
* @see #SNAP_TO_START
* @see #SNAP_TO_END
* @see #SNAP_TO_ANY
*/
protected int getHorizontalSnapPreference() {
return mTargetVector == null || mTargetVector.x == 0 ? SNAP_TO_ANY :
mTargetVector.x > 0 ? SNAP_TO_END : SNAP_TO_START;
}
/**
* When scrolling towards a child view, this method defines whether we should align the top
* or the bottom edge of the child with the parent RecyclerView.
*
* @return SNAP_TO_START, SNAP_TO_END or SNAP_TO_ANY; depending on the current target vector
* @see #SNAP_TO_START
* @see #SNAP_TO_END
* @see #SNAP_TO_ANY
*/
protected int getVerticalSnapPreference() {
return mTargetVector == null || mTargetVector.y == 0 ? SNAP_TO_ANY :
mTargetVector.y > 0 ? SNAP_TO_END : SNAP_TO_START;
}
再次用蹩腳的英語和對代碼的理解糊渊,果斷確定了,這就是我們的解決辦法;弁选C烊蕖!重寫getVerticalSnapPreference()方法菱鸥,將返回值設(shè)為SNAP_TO_START即可宗兼。
解決方法是有了,不過還是得看看到底是怎么判斷的吧采缚,mTargetVector為一個PointF類针炉,主要是根據(jù)y坐標(biāo)的大小來返回值,那我們就看看這個mTargetVector是怎么來的吧扳抽。同樣Ctrl+F篡帕,查找mTargetVector的來源殖侵。
protected void updateActionForInterimTarget(Action action) {
PointF scrollVector = computeScrollVectorForPosition(getTargetPosition());
...
mTargetVector = scrollVector;
...
}
scrollVector賦值給mTargetVector,然后進入 computeScrollVectorForPosition方法镰烧。
public PointF computeScrollVectorForPosition(int targetPosition) {
RecyclerView.LayoutManager layoutManager = getLayoutManager();
if (layoutManager instanceof ScrollVectorProvider) {
return ((ScrollVectorProvider) layoutManager)
.computeScrollVectorForPosition(targetPosition);
}
Log.w(TAG, "You should override computeScrollVectorForPosition when the LayoutManager"
+ " does not implement " + ScrollVectorProvider.class.getCanonicalName());
return null;
}
發(fā)現(xiàn)返回的是layoutManager.computeScrollVectorForPosition(targetPosition)拢军,那就只有去LinearLayoutManager中找答案了。
@Override
public PointF computeScrollVectorForPosition(int targetPosition) {
//當(dāng)RecyclerView的item為0時
if (getChildCount() == 0) {
return null;
}
//獲取第一個可見item在Adapter數(shù)據(jù)源中的位置
final int firstChildPos = getPosition(getChildAt(0));
final int direction = targetPosition < firstChildPos != mShouldReverseLayout ? -1 : 1;
if (mOrientation == HORIZONTAL) {
return new PointF(direction, 0);
} else {
return new PointF(0, direction);
}
}
解釋一波:getChildAt(0)獲取第一個可見item怔鳖,getPosition(View view)通過view來獲取該view在Adapter數(shù)據(jù)源中的position茉唉,為什么要這么做,那是因為RecyclerView復(fù)用的原因结执,mShouldReverseLayout為是否反轉(zhuǎn)度陆,最后判斷targetPosition和firstChildPos大小設(shè)置direction,direction的值便是我們上面看到的y值献幔。
OK懂傀,思路和解決問題都已經(jīng)說清楚了,那么就just show the code@小5乓稀!
左邊Recycle人View的實現(xiàn)
左邊主要代碼是在Adapter中寫一個設(shè)置選中狀態(tài)的函數(shù)郑兴,holder中設(shè)置選中狀態(tài)的item背景犀斋,item的布局一個TextView就Ok了。
public class LeftAdapter extends RecyclerArrayAdapter<LeftBean> {
private int prePosition = 0;
public LeftAdapter(Context context) {
super(context);
}
public LeftAdapter(Context context, LeftBean[] objects) {
super(context, objects);
}
public LeftAdapter(Context context, List<LeftBean> objects) {
super(context, objects);
}
@Override
public BaseViewHolder OnCreateViewHolder(ViewGroup parent, int viewType) {
return new LeftHolder(parent);
}
public void setSelectedPosition(int position){
if (prePosition != position){
//Tips:處理右邊滑動時左邊對應(yīng)的item不在屏幕上的情況
mRecyclerView.smoothScrollToPosition(position);
mObjects.get(prePosition).setSelected(false);
notifyItemChanged(prePosition);
prePosition = position;
mObjects.get(prePosition).setSelected(true);
notifyItemChanged(prePosition);
}
}
}
===============Holder代碼=================
public class LeftHolder extends BaseViewHolder<LeftBean> {
private TextView textView;
public LeftHolder(ViewGroup itemView) {
this(itemView, R.layout.item_left);
textView = $(R.id.text_menu);
}
public LeftHolder(ViewGroup parent, int res) {
super(parent, res);
}
@Override
public void setData(LeftBean data) {
super.setData(data);
textView.setText(data.getName());
//設(shè)置選中狀態(tài)的背景
if (data.isSelected()){
textView.setBackgroundColor(Color.WHITE);
}
else {
textView.setBackgroundColor(Color.parseColor("#747373"));
}
}
}
右邊RecyclerView實現(xiàn)
右邊界面實現(xiàn)就是兩個RecyclerView的嵌套情连,很簡單的叽粹,就不粘貼代碼了。不過需要注意的是嵌套的item布局高度寫成wrap_content却舀,如果是match_parent可能會造成內(nèi)容顯示不完整球榆。
RecyclerView聯(lián)動效果實現(xiàn)
在MainActivity中,左邊RecyclerView設(shè)置點擊事件禁筏,右邊RecyclerView添加滑動監(jiān)聽事件持钉。
recyclerLeft.setLayoutManager(new LinearLayoutManager(this));recyclerLeft.setAdapter(leftAdapter);
leftAdapter.setOnItemClickListener(new RecyclerArrayAdapter.OnItemClickListener() {
@Override
public void onItemClick(int position) {
//關(guān)聯(lián)右邊滑動
recyclerRight.smoothScrollToPosition(position);
leftAdapter.setSelectedPosition(position);
clicked = true;
}
});
================右邊滑動監(jiān)聽===================
//設(shè)置能夠平滑到頂部的LayouManager
recyclerRight.setLayoutManager(new ScrollTopLayoutManager(this));
recyclerRight.setAdapter(rightAdapter);
recyclerRight.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
if (clicked && newState == RecyclerView.SCROLL_STATE_IDLE) {
clicked = false;
}
}
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
if (!clicked) {
LinearLayoutManager linearLayoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
int currentItem = linearLayoutManager.findFirstVisibleItemPosition();
View v = linearLayoutManager.findViewByPosition(currentItem);
//右邊滑動超過當(dāng)前item一半時左邊切換到下一個
if (v.getBottom() < v.getHeight() / 2 && currentItem >= 0) {
if (currentItem < linearLayoutManager.getItemCount() - 1){
leftAdapter.setSelectedPosition(currentItem + 1);
}
} else {
leftAdapter.setSelectedPosition(currentItem);
}
}
}
});
ScrollTopLayoutManager的實現(xiàn)
ScrollTopLayoutManager繼承自LinearLayoutManager,重寫了smoothScrollToPosition方法篱昔,并設(shè)置滑動到頂部的SmoothScroller每强。
public class ScrollTopLayoutManager extends LinearLayoutManager {
public ScrollTopLayoutManager(Context context) {
super(context);
}
public ScrollTopLayoutManager(Context context, int orientation, boolean reverseLayout) {
super(context, orientation, reverseLayout);
}
public ScrollTopLayoutManager(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) {
TopLinearSmoothScroller topLinearSmoothScroller = new TopLinearSmoothScroller(recyclerView.getContext());
topLinearSmoothScroller.setTargetPosition(position);
startSmoothScroll(topLinearSmoothScroller);
}
private static class TopLinearSmoothScroller extends LinearSmoothScroller {
public TopLinearSmoothScroller(Context context) {
super(context);
}
@Override
protected int getVerticalSnapPreference() {
//滑動到頂部
return SNAP_TO_START;
}
}
}
最后在加入模擬數(shù)據(jù),渲染在界面上就實現(xiàn)該效果了州刽。
private void initData() {
if (null == leftAdapter){
ArrayList<LeftBean> leftBeans = new ArrayList<>();
for (int i = 0; i < 10; i++){
leftBeans.add(new LeftBean("我是左邊第"+ i + "個"));
//默認(rèn)第一個為選中狀態(tài)
if (i == 0){
leftBeans.get(0).setSelected(true);
}
}
leftAdapter = new LeftAdapter(this,leftBeans);
}
if (null == rightAdapter){
ArrayList<RightBean> rightBeans = new ArrayList<>();
for (int i = 0; i < 10; i++){
ArrayList<InnerBean> innerBeans = new ArrayList<>();
for (int j = 0; j < 5; j++){
innerBeans.add(new InnerBean("我是里邊第"+ j + "個"));
}
rightBeans.add(new RightBean("我是右邊第"+ i + "個",innerBeans));
}
rightAdapter = new RightAdapter(this,rightBeans);
}
}
Demo效果圖:
該Demo的數(shù)據(jù)較為簡單空执,并沒有添加實用數(shù)據(jù),此次Demo中所用的Adapter為EasyRecyclerView中的穗椅,具體可看鏈接https://github.com/Jude95/EasyRecyclerView辨绊。
所有的代碼已經(jīng)上傳到GitHub了,代碼地址:https://github.com/ysls/Demo