Android開發之複雜佈局嵌套(ScrollView+TabLayout+ViewPager+RecyclerView)導致衝突的解決辦法

前言

最近在做一些項目和畢業設計,所以自從上次梳理完數據結構之後,一直想寫些什麼,但是又沒有比較好的內容,所以博客從過完年之後就停更了很長很長很長一段時間,不過這次在做項目的時候,正好遇到一個我本以爲很簡單,結果折騰了好久的一個問題,其實這個問題對於做Android開發的同學來說,並不陌生,那就是滑動佈局的互相嵌套。

當然並不僅限於標題中寫的這種嵌套,只要是可滑動的佈局,例如GridView,各種自定義的可滑動控件等等,都會遇到這個問題,如果嵌套相對簡單的話,例如ScrollView嵌套RecyclerView,解決滑動衝突的辦法就簡單點,可以使用NestedScrollView替代ScrollView或者重寫RecyclerViewonMeasure方法,都可以達到解決衝突的效果,但是一但嵌套複雜起來,像ScrollView+TabLayout+ViewPager+RecyclerView這種,情況就不一樣了,那麼下面分享下我這次遇到的問題,以及整個探索過程,包括我最後的解決辦法。

目錄

1、需要實現的效果
2、探索過程
3、解決方案

正文

1、需要實現的效果

在項目中,我需要實現的效果其實在很多APP裏都可以找到,由於項目需求明確寫明是仿京東發現頁面,所以是需要實現一個類似京東發現頁面的效果,我們來到京東的發現頁面,效果如下
在這裏插入圖片描述
這裏把圖中的效果再總結一下:
1、整體佈局分爲頂部標題欄,頂部導航欄,下方可左右滑動的內容區,導航欄和內容區是聯動的
2、整體頁面任意位置往上滑動時,若標題欄在屏幕可見區域內,則標題欄會滑出屏幕,導航欄會懸浮在頂部
3、整體頁面任意位置往下滑動時,若標題欄之前被滑出了屏幕,則標題欄隨下滑而出現在導航欄上方(圖中沒有演示出來,實際是有這個效果的)
4、若內容區滑到最頂部,再往下拉時,會產生下拉刷新

2、探索過程

2.1 我最初的實現思路

首先看到這個佈局,我的思路很清晰,當然也想當然的認爲這樣實現沒有問題,因爲之前也遇到過類似的佈局,雖然沒這個複雜,但是最後都解決了。

首先整個頁面肯定是一個Activity的佈局,下方的底部導航欄就暫且忽略,不是本文的重點,我們需要關注的就是中間這個頁面(就是一個Fragment)的佈局,由於整個頁面是可滑動的,所以佈局最外層肯定是一個ScrollView,佈局最上面是一個標題欄,這個沒啥好說的,標題欄下面是一個導航欄,導航欄的實現也不陌生了,就是一個TabLayout,導航欄下面是內容區,內容區可左右滑動,很明顯,內容區可使用ViewPager實現,在內容區裏,是一條條的文章數據,這個我的實現思路是使用RecyclerView,雖然你會發現有些佈局項和其它不同,但是沒關係,我們可以通過適配器中的viewType來控制,然後導航欄和內容區的聯動效果可使用TabLayoutsetupWithViewPager方法直接實現聯動效果。

上面的思路實現了整體的佈局效果,但是一些小細節還沒有實現:

細節一TabLayout滑到頂部時,懸浮在頂部,而標題欄直接滑出屏幕,這個效果要實現,主要是實現一個滑動監聽, 我們可以監聽最外層ScrollView的滑動,根據參數來判斷TabLayout是否滑到了屏幕頂部,如果滑到了頂部,則在佈局中移除原TabLayout,然後可以在屏幕頂部位置寫一個空的頂部佈局,再將TabLayout添加到這個空的佈局中。反之,如果ScrollView滑動到了頂部,此時應該將TabLayout從頂部佈局中移除,將TabLayout迴歸原位,涉及到的方法主要有addViewremoveView
細節二:整體佈局任意位置下滑時,如果標題欄之前被滑出了屏幕,那麼會隨下滑而逐漸出現在屏幕頂部,這個效果,我最初沒有去實現,因爲需求中沒有這個,但是我後來發現了這個細節效果,現在的思路也是和上面的一樣,監聽ScrollView的滑動,根據滑動參數判斷上滑還是下滑,如果是下滑的話,那麼直接將標題欄佈局添加到頂部佈局,並根據下滑的距離慢慢將標題欄佈局滑出來

2.2 嘗試寫代碼實現

首先根據上面的整體佈局思路來一步步實現,至於下面的兩個細節問題,先放一放,待會再來實現,ok,有了整體思路,我們不難寫出如下代碼:

<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <LinearLayout
        android:id="@+id/container_normal"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <RelativeLayout
            android:id="@+id/top_info"
            android:layout_width="match_parent"
            android:layout_height="60dp">

            <TextView
                android:id="@+id/tv"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_centerInParent="true"
                android:text="我是標題欄"
                android:textSize="18sp" />
        </RelativeLayout>

        <android.support.design.widget.TabLayout
            android:id="@+id/tabLayout"
            android:layout_width="match_parent"
            android:layout_height="50dp"
            android:background="@android:color/white"
            app:tabSelectedTextColor="@android:color/black"
            app:tabMode="fixed"/>

        <android.support.v4.view.ViewPager
            android:id="@+id/viewPager"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"/>

    </LinearLayout>

</ScrollView>

現在我們嘗試往這個佈局中添加簡單的內容,看有沒有什麼問題出現,爲了簡化,我就給ViewPager弄三個簡易的Fragment,每個Fragment佈局如下

<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android" 
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:text="第一個頁面"
        android:textSize="20sp" />
</RelativeLayout>

對應的java代碼如下

public class OneFragment extends Fragment {
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, 
    									@Nullable Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_one,container,false);
    }
}

我們創建三個這樣的Fragment用於內容填充,接下來就是給tabLayoutviewPager設置內容,由於不是重點,我就不廢話,直接放上Activity中的相關代碼,如下

public class MainActivity extends AppCompatActivity {

    private TabLayout tabLayout;
    private ViewPager viewPager;
    private FragmentPagerAdapter mPageAdapter;
    private ArrayList<String> titleList = new ArrayList<>();
    private ArrayList<Fragment> fragmentList=new ArrayList<>();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        tabLayout=findViewById(R.id.tabLayout);
        viewPager=findViewById(R.id.viewPager);

        titleList.clear();
        titleList.add("標籤一");
        titleList.add("標籤二");
        titleList.add("標籤三");

        fragmentList.clear();
        fragmentList.add(new OneFragment());
        fragmentList.add(new TwoFragment());
        fragmentList.add(new ThreeFragment());

        mPageAdapter=new FragmentPagerAdapter(getSupportFragmentManager()) {
            @Override
            public Fragment getItem(int i) {
                return fragmentList.get(i);
            }

            @Nullable
            @Override
            public CharSequence getPageTitle(int position) {
                return titleList.get(position);
            }

            @Override
            public long getItemId(int position) {
                return position;
            }

            @Override
            public int getCount() {
                return titleList.size();
            }
        };

        viewPager.setAdapter(mPageAdapter);
        tabLayout.setupWithViewPager(viewPager);
    }
}

最終運行出來的效果如下
在這裏插入圖片描述
ok,可以明顯的看到,我們明明設置了ViewPager而且設置了內容,但是卻發現ViewPager沒有顯示出來,第一個問題出現了,究其原因就是我們在ScrollView中嵌套了ViewPager,導致ViewPager的高度計算不正確,所以我們可以通過重寫ViewPageronMeasure方法來重新計算高度,除此之外,還有一個簡單的解決辦法,就是給ScrollView設置fillViewport屬性爲true,這個屬性的作用就是讓子佈局中的內容鋪滿全屏,ok,設置了該屬性之後,我們運行看效果
在這裏插入圖片描述
接下來我們繼續豐富其中的內容, 將Fragment中的內容更改爲RecyclerView的列表,看看有沒有什麼問題,Fragment佈局很簡單,就一個RecyclerView,我們看Fragment對應的java代碼,如下

public class OneFragment extends Fragment {

    private RecyclerView mRecyclerView;
    private RecyclerView.LayoutManager mLayoutManager;
    private RecyclerViewAdapter mAdapter;
    private View mainView;

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        mainView=inflater.inflate(R.layout.fragment_one,container,false);
        initView();
        return mainView;
    }

    private void initView(){
        mRecyclerView=mainView.findViewById(R.id.recyclerview);
        mLayoutManager=new LinearLayoutManager(getActivity(), LinearLayoutManager.VERTICAL, false);
        ArrayList<String> data=new ArrayList<>();
        for(int i=0;i<20;i++){
            data.add("列表項"+i);
        }
        mAdapter=new RecyclerViewAdapter(getActivity(),data);
        mRecyclerView.setLayoutManager(mLayoutManager);
        mRecyclerView.setAdapter(mAdapter);
    }
}

適配器代碼如下

public class RecyclerViewAdapter extends RecyclerView.Adapter<RecyclerViewAdapter.ViewHolder> {

    private Context context;
    private ArrayList<String> data;

    public RecyclerViewAdapter(final Context context, ArrayList<String> data) {
        this.context = context;
        this.data = data;
    }

    @NonNull
    @Override
    public ViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
        View view = LayoutInflater.from(context).inflate(R.layout.item_rv, viewGroup, false);
        return new ViewHolder(view);
    }

    @Override
    public long getItemId(int position) {
        return position;
    }

    @Override
    public void onBindViewHolder(@NonNull ViewHolder viewHolder, int position) {
        viewHolder.tv.setText(data.get(position));
    }

    @Override
    public int getItemCount() {
        return data.size();
    }


    class ViewHolder extends RecyclerView.ViewHolder {
        private TextView tv;

        ViewHolder(View itemView) {
            super(itemView);
            tv = itemView.findViewById(R.id.tv);
        }
    }
}

運行效果如下:
在這裏插入圖片描述
ok,到這裏爲止,我們基本上算是實現了一個整體佈局的大致效果,現在我們再來實現第一個細節,TabLayout滑到頂部懸浮的效果,要實現這個效果,我們首先需要監聽到最外層的ScrollView的滑動,這樣才能根據參數來控制TabLayout的位置,也就是說我們需要將RecyclerView的滑動事件交給ScrollView來執行,換句話說,就是ScrollView需要攔截RecyclerView的滑動事件,ok,我們只需要重寫ScrollViewonInterceptTouchEvent方法即可對滑動事件進行攔截,自定義ScrollView代碼如下:

public class InterceptScrollView extends ScrollView {
    public InterceptScrollView(Context context) {
        super(context);
    }

    public InterceptScrollView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public InterceptScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        super.onInterceptTouchEvent(ev);
        return true;
    }
}

然後在佈局中,使用我們的自定義ScrollView,而不是系統原生的,ok,我們嘗試運行一下,看會不會和預期的一樣,運行後效果
在這裏插入圖片描述
很遺憾,結果並不如預期,整個頁面現在什麼也做不了,無法滑動,無法點擊,我們先解決無法滑動的問題.

爲什麼這個時候整個頁面無法上下滑動了呢?前面說到了給ScrollView設置fillViewport屬性爲true來解決ViewPager內容顯示不全的問題,但是這個屬性在這個時候卻正好幫了倒忙,當我們設置ScrovllView攔截滑動事件的時候,只有當包裹內容超出屏幕範圍的時候,ScrollView纔可以滑動(也就是整個頁面纔可以滑動),但是由於fillViewport屬性的設置,導致ViewPager的佈局高度始終是充滿整個屏幕的,也就是說此時ScrollView包裹的內容正好填滿整個屏幕,並沒有超出,自然不能滑動了。

ok,既然fillViewport屬性幫了倒忙,那麼我們就去掉這個屬性,但是一旦去掉這個屬性,又會發生ScrollView嵌套ViewPager導致ViewPager內容不顯示(高度爲0)的問題。

所以到這裏,我們陷入了一個兩難的境地。到這裏我們只能回到上個問題,既然使用fillViewport屬性行不通,那我們就重寫ViewPageronmeasure方法來解決高度顯示爲0的問題。

重寫ViewPager,代碼如下:

public class AutofitViewPager extends ViewPager {

    public AutofitViewPager(@NonNull Context context) {
        this(context,null);
    }

    public AutofitViewPager(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        addOnPageChangeListener(new OnPageChangeListener() {
            @Override
            public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
            }

            @Override
            public void onPageSelected(int position) {
                requestLayout();//保證每次選中當前頁時,計算高度,達到高度自適應效果
            }

            @Override
            public void onPageScrollStateChanged(int state) {
            }
        });
    }

    //重寫onMeasure,解決高度顯示爲0,同時高度動態顯示爲當前子項的高度
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int height = 0;
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            child.measure(widthMeasureSpec,
                    MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
            int h = child.getMeasuredHeight();
            if(i==getCurrentItem()){
                height=h;
            }
        }
        heightMeasureSpec = MeasureSpec.makeMeasureSpec(height,
                MeasureSpec.EXACTLY);
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

}

ok,我們現在把這個自定義的ViewPager放入我們的佈局中,最終運行,得到如下效果
在這裏插入圖片描述
ok,現在我們的佈局終於可以既顯示完整,又可以滑動了,但是仔細操作,我們會發現又有兩個新的問題產生了
問題一:界面初次加載的時候,ViewPager的內容會置頂,導致標題欄和```TabLayout的內容被頂出屏幕外面 問題二:界面只能上下滑動,無法響應點擊事件,ViewPager``無法橫向滑動

我們首先來解決問題一,發生問題一的原因主要是,在界面初次加載的時候發生了一個焦點爭奪的問題,從效果可以看出,ViewPager或者ViewPager裏的RecyclerView獲得了焦點,所以我們需要將焦點讓最上面的佈局獲取,也就是標題欄的佈局,Ok,我們通過給標題欄佈局設置如下三個屬性即可解決問題

android:focusable="true"
android:focusableInTouchMode="true"
android:descendantFocusability="beforeDescendants"

簡單的解釋一下第三個屬性descendantFocusability,這個屬性一共有三個值,含義如下

beforeDescendants:viewgroup會優先其子類控件而獲取到焦點
afterDescendants:viewgroup只有當其子類控件不需要獲取焦點時才獲取焦點
blocksDescendants:viewgroup會覆蓋子類控件而直接獲得焦點

根據我們的需求,我們需要設置爲beforeDescendants。
ok,現在我們運行程序,效果如下:
在這裏插入圖片描述
現在我們再來解決第二個問題,只能上下滑動,也不能響應點擊事件,我們簡單分析下,我們之前重寫ScrollViewonInterceptTouchEvent時,是直接返回的true,代表攔截所有事件,而我們現在這個場景中, 可以發現我們只需要攔截垂直方向的滑動時間,水平方向的滑動事件不予處理,ok,按照這個思路,我們再重新寫一下ScrollViewonInterceptTouchEvent方法,如下

private int lastInterceptX;
private int lastInterceptY;

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    boolean intercept = false;
    int x = (int) ev.getX();
    int y = (int) ev.getY();
    switch (ev.getAction()) {
        case MotionEvent.ACTION_DOWN:
            intercept = false;
            break;
        case MotionEvent.ACTION_MOVE:
            int deltaX = x - lastInterceptX;
            int deltaY = y - lastInterceptY;
            //如果是垂直滑動,則攔截
            if (Math.abs(deltaX) - Math.abs(deltaY) < 0) {
                intercept = true;
            } else {
                intercept = false;
            }

            break;
        case MotionEvent.ACTION_UP:
            intercept = false;
            break;
    }
    lastInterceptX = x;
    lastInterceptY = y;
    super.onInterceptTouchEvent(ev);//這一句一定不能漏掉,否則無法攔截事件
    return intercept;
}

ok,現在我們再來運行一下,看是不是如預期的那樣,效果如下
在這裏插入圖片描述
整體上,和我們預期的一樣,上下滑動是ScrollView處理的,橫向滑動是ViewPager處理的,但是我們卻發現了一個極其奇怪的現象,那就是通過點擊tab按鈕來切換到第三個頁面時,對應的佈局內容居然不顯示,而通過滑動操作切換到第三個頁面時,卻可以正常顯示。

下面我們來簡單分析下,雖然這個現象很奇怪,但是如果你接觸ViewPager較多的話,那麼你肯定知道ViewPager是有緩存機制的,也就是默認會預加載左右各一個頁面,而我們的demo中,很顯然,初次進入的時候,加載第一個頁面,同時第二個標籤頁對應的頁面也是會加載的(預加載),而我們的第三個頁面卻沒有預加載,這時候,直接點擊tab來切換,可能導致view還沒有加載就直接測量其高度,這時候自然是測量不到的,所以我們可以通過設置初次加載的時候預加載所有的頁面來解決這個問題(雖然不優雅。

所以我們在代碼中只需要給ViewPager加上如下一行代碼

viewPager.setOffscreenPageLimit(titleList.size());

這樣就可以預加載所有的界面,現在我們再來運行下,看有沒有剛纔那個問題,效果如下
在這裏插入圖片描述
至此,我們已經實現了整個頁面的佈局,接下來實現細節一:TabLayout在滑動到頂部的時候,會懸浮在屏幕上方的效果。思路之前已經講過了,接下來就是修改代碼,首先我們修改Activity的佈局代碼,爲其添加“懸浮在屏幕上方的空佈局”,如下

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.hq.testscrollview.InterceptScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:id="@+id/container_normal"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:descendantFocusability="beforeDescendants"
            android:focusable="true"
            android:focusableInTouchMode="true"
            android:orientation="vertical">

            <RelativeLayout
                android:id="@+id/top_info"
                android:layout_width="match_parent"
                android:layout_height="60dp">

                <TextView
                    android:id="@+id/tv"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_centerInParent="true"
                    android:text="我是標題欄"
                    android:textSize="18sp" />
            </RelativeLayout>

            <android.support.design.widget.TabLayout
                android:id="@+id/tabLayout"
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:background="@android:color/white"
                app:tabMode="fixed"
                app:tabSelectedTextColor="@android:color/black" />

            <com.hq.testscrollview.AutofitViewPager
                android:id="@+id/viewPager"
                android:layout_width="match_parent"
                android:layout_height="match_parent" />

        </LinearLayout>
    </com.hq.testscrollview.InterceptScrollView>
    
	<!-- 懸浮在屏幕上方的頂部空佈局-->
    <LinearLayout
        android:id="@+id/container_top"
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:orientation="vertical">

    </LinearLayout>
</RelativeLayout>

由於頂部佈局不受ScrollView的滑動影響,所以最外層的佈局就不能是ScrollView了,然後我們在監聽滑動的ScrollView,怎麼監聽呢,很簡單,ScrollView默認提供了onScrollChanged方法,我們只需要將該方法的參數暴露出去即可,如下,修改InterceptScrollView的代碼

public class InterceptScrollView extends ScrollView {

    private int lastInterceptX;
    private int lastInterceptY;

    private ScrollChangedListener onScrollChangedListener;
    
    public InterceptScrollView(Context context) {
        super(context);
    }

    public InterceptScrollView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public InterceptScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        boolean intercept = false;
        int x = (int) ev.getX();
        int y = (int) ev.getY();
        switch (ev.getAction()) {
            case MotionEvent.ACTION_DOWN:
                intercept = false;
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - lastInterceptX;
                int deltaY = y - lastInterceptY;
                //如果是垂直滑動,則攔截
                if (Math.abs(deltaX) - Math.abs(deltaY) < 0) {
                    intercept = true;
                } else {
                    intercept = false;
                }

                break;
            case MotionEvent.ACTION_UP:
                intercept = false;
                break;
        }
        lastInterceptX = x;
        lastInterceptY = y;
        super.onInterceptTouchEvent(ev);//這一句一定不能漏掉,否則無法攔截事件
        return intercept;
    }

    @Override
    protected void onScrollChanged(int l, int t, int oldl, int oldt) {
        super.onScrollChanged(l, t, oldl, oldt);
        if(onScrollChangedListener!=null){
            onScrollChangedListener.onScrollChanged(l,t,oldl,oldt);
        }
    }

    public void setScrollChangedListener(ScrollChangedListener listener){
        onScrollChangedListener = listener;
    }

    public interface ScrollChangedListener{
        void onScrollChanged(int scrollX, int scrollY, int oldScrollX, int oldScrollY);
    }
}

然後,在Activity的代碼中,給InterceptScrollView添加監聽,如下

mScrollView.setScrollChangedListener(new InterceptScrollView.ScrollChangedListener() {
    @Override
    public void onScrollChanged(int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
        //如果Y軸方向滑動距離超過60dp,那麼就將tabLayout添加在頂部的空佈局中,達到“懸浮效果”
        //爲什麼是60dp呢,因爲標題欄佈局高度爲60dp
        if (scrollY >= DpUtils.dp2px(MainActivity.this,
         60)&&tabLayout.getParent()==container_normal) {
            container_normal.removeView(tabLayout);
            container_top.addView(tabLayout);
        } else if(scrollY < DpUtils.dp2px(MainActivity.this,
         60)&&tabLayout.getParent()==container_top){//沒超過60dp,則將tabLayout迴歸正常的佈局位置
            container_top.removeView(tabLayout);
            //參數 1 代表佈局中子佈局的第1個位置處(0爲第一個子佈局)
            container_normal.addView(tabLayout, 1);
        }
    }
});

運行代碼,效果如下:
在這裏插入圖片描述
整體效果上還是不錯的,但是,仔細觀察,會發現還是存在如下這兩個問題:

問題一:在TabLayout處於即將要懸浮的臨界狀態時,因爲TabLayout被從原佈局中突然移除,導致列表項的部分佈局突然頂上去,而看不到這些頂上去的佈局(比如上圖中 列表項0 就會在臨界狀態突然頂上去,導致看不到)
問題二:其中一個頁面滑動時,會聯動其它的頁面也滑動相同的距離(比如第一個頁面向下拉了3個列表項高度的距離,那麼此時向右滑動到第二個界面,會發現第二個頁面也向下滑動了3個列表項高度的距離)

首先針對問題一,既然TabLayout被從原佈局中移除時,會導致下方佈局被頂上去的問題,那麼我可以簡單直接的在TabLayout的地方添加一個同等高度的“佔位佈局”,當TabLayout移除時,佔位佈局來暫時佔位,ok,我們現在修改佈局代碼如下

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <com.hq.testscrollview.InterceptScrollView
        android:id="@+id/interceptScrollView"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:id="@+id/container_normal"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:descendantFocusability="beforeDescendants"
            android:focusable="true"
            android:focusableInTouchMode="true"
            android:orientation="vertical">

            <RelativeLayout
                android:id="@+id/top_info"
                android:layout_width="match_parent"
                android:layout_height="60dp">

                <TextView
                    android:id="@+id/tv"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_centerInParent="true"
                    android:text="我是標題欄"
                    android:textSize="18sp" />
            </RelativeLayout>

            <android.support.design.widget.TabLayout
                android:id="@+id/tabLayout"
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:background="@android:color/white"
                app:tabMode="fixed"
                app:tabSelectedTextColor="@android:color/black" />
			<!-- 佔位佈局,默認是爲gone狀態,當TabLayout移除時,才更改其狀態爲可見,達到“佔位”效果-->
            <View
                android:id="@+id/view_place"
                android:layout_width="match_parent"
                android:layout_height="50dp"
                android:visibility="gone" />

            <com.hq.testscrollview.AutofitViewPager
                android:id="@+id/viewPager"
                android:layout_width="match_parent"
                android:layout_height="match_parent" />

        </LinearLayout>
    </com.hq.testscrollview.InterceptScrollView>

    <!-- 懸浮在屏幕上方的頂部空佈局-->
    <LinearLayout
        android:id="@+id/container_top"
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:orientation="vertical">

    </LinearLayout>
</RelativeLayout>

相應的ScrollView的監聽方法更改如下

mScrollView.setScrollChangedListener(new InterceptScrollView.ScrollChangedListener() {
    @Override
    public void onScrollChanged(int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
        if (scrollY >= DpUtils.dp2px(MainActivity.this,
         60)&&tabLayout.getParent()==container_normal) {
            container_normal.removeView(tabLayout);
            container_top.addView(tabLayout);
            //更改 佔位佈局爲  顯示
            viewPlace.setVisibility(View.INVISIBLE);
        } else if(scrollY < DpUtils.dp2px(MainActivity.this,
         60)&&tabLayout.getParent()==container_top){
            container_top.removeView(tabLayout);
            container_normal.addView(tabLayout, 1);
            //隱藏佔位佈局
            viewPlace.setVisibility(View.GONE);
        }
    }
});

現在運行一下代碼,看看效果
在這裏插入圖片描述
ok,現在我們解決了第一個問題,現在來看看第二個問題,各頁面滑動的時候會有聯動效果。

我們簡單分析下,不難找到爲什麼會發生這樣的問題,首先我們滑動ViewPager中的內容時,是通過ScrollView來攔截的滑動事件,也就是說,雖然內容頁看上去分爲了三個分頁,但是實際上滑動它們的,都是同一個ScrollView,既然是滑動的同一個ScrollView,那麼自然其中一個頁面滑動了多少距離,其它頁面也會跟着滑動多少距離。

既然清楚了問題產生的原因,我們可以從根本入手,我這裏想到的解決方案是設置一個ArrayMap,用於保存每個頁面分別滑動了多少距離,然後當切換到這個頁面的時候,首先從ArrayMap中取出這個滑動距離,再將ScrollView滑到對應距離的位置。

ok,有了思路,我們現在來修改代碼,首先我們需要思考在哪裏存入當前頁面滑動的距離,這個因爲我們是滑動的ViewPager,所以自然想到的是監聽ViewPager的滑動,如果滑動導致頁面切換,那麼存入頁面的滑動距離,同時,在切換到新頁面時,獲取Map中的值,並將ScrollView滑動到這個位置,最終Activity要添加的代碼如下

//存放頁面和滑動距離的Map
private ArrayMap<Integer,Integer> scrollMap=new ArrayMap<>();
//當前頁面
private int currentTab=0;
//當前頁面的滑動距離
private int currentScrollY=0;

.....//省略若干代碼

//監聽viewpager的滑動
viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
    @Override
    public void onPageScrolled(int i, float v, int i1) {

    }

    @Override
    public void onPageSelected(int i) {
        mScrollView.scrollTo(0,scrollMap.get(i));
    }

    @Override
    public void onPageScrollStateChanged(int i) {
        if(i==1){//手指按下時,記錄當前頁面
            currentTab=viewPager.getCurrentItem();
        }else if(i==2){//手指擡起時
            if(currentTab!=viewPager.getCurrentItem()){
                //如果滑動成功,也就是說翻頁成功,那麼保存之前頁面的滑動距離
                scrollMap.put(currentTab,currentScrollY);
            }
        }
    }
});

現在我們來運行看看效果如何:
在這裏插入圖片描述
ok,和我們預期的一樣,現在每個分頁都是能記錄自己的滑動距離,並且各個頁面之間互不干擾。

但是很明顯,我們又有了新的問題產生,從圖中可以很明顯的感受到,主要是一些邏輯問題:如果我們在第一個頁面滑動讓導航欄懸浮在了頂部,然後當我們切換到第二個頁面時,因爲第二個頁面此時滑動距離爲0,所以會導致懸浮的導航欄突然迴歸原位,造成體驗極不友好。

那麼我們怎麼來解決呢?可以增設一個標誌位,用於判斷導航欄是否懸浮,如果懸浮的話,同時當前頁面的滑動距離爲0,那麼我們將ScrollView滑動一個固定的距離,讓導航欄此時正好懸浮在頂部,在我們這個例子中,這個固定的距離就是標題欄的高度,ok,我們現在來修改代碼,看是不是如預期的效果:

//用於判斷,當前頁面的導航欄是否懸浮
private boolean isTabLayoutSuspend;

........//此處省略代碼

mScrollView.setScrollChangedListener(new InterceptScrollView.ScrollChangedListener() {
    @Override
    public void onScrollChanged(int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
        currentScrollY=scrollY;
        if (scrollY >= DpUtils.dp2px(MainActivity.this, 
        60)&&tabLayout.getParent()==container_normal) {
            container_normal.removeView(tabLayout);
            container_top.addView(tabLayout);
            viewPlace.setVisibility(View.INVISIBLE);
            isTabLayoutSuspend=true;//記錄TabLayout的狀態
        } else if(scrollY < DpUtils.dp2px(MainActivity.this, 
        60)&&tabLayout.getParent()==container_top){
            container_top.removeView(tabLayout);
            container_normal.addView(tabLayout, 1);
            viewPlace.setVisibility(View.GONE);
            isTabLayoutSuspend=false;//記錄TabLayout的狀態
        }
    }
});

........//此處省略代碼

viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
      @Override
      public void onPageScrolled(int i, float v, int i1) {

      }

      @Override
      public void onPageSelected(int i) {
          //如果導航欄懸浮
          if(isTabLayoutSuspend){
              //當前頁面的滑動距離爲0或者小於60dp,那麼只需滑動60dp,讓導航欄懸浮即可
              if(scrollMap.get(i)==0||scrollMap.get(i)<DpUtils.dp2px(MainActivity.this,60)){
                  mScrollView.scrollTo(0,DpUtils.dp2px(MainActivity.this,60));
              }else{//如果頁面滑動的距離大於60dp,那麼直接滑動對應的距離即可
                  mScrollView.scrollTo(0,scrollMap.get(i));
              }
          }else{//如果導航欄沒有懸浮
              mScrollView.scrollTo(0,currentScrollY);
          }

      }

      @Override
      public void onPageScrollStateChanged(int i) {
          if(i==1){//手指按下時,記錄當前頁面
              currentTab=viewPager.getCurrentItem();
          }else if(i==2){//手指擡起時
              if(currentTab!=viewPager.getCurrentItem()){
                  //如果滑動成功,也就是說翻頁成功,那麼保存之前頁面的滑動距離
                  scrollMap.put(currentTab,currentScrollY);
              }
          }
      }
  });
}

現在來運行看效果,如下
在這裏插入圖片描述
可以看到,和預期的效果一樣,TabLayout跳動的問題已經解決了。

至此,我們已經實現了整體的佈局效果+細節實現,對比文章開頭京東頁面的效果,還有個下拉刷新,RecyclerView添加下拉刷新,這個很簡單,實現方法也多種多樣,官網也提供了SwipeRefreshLayout這個直接使用的下拉刷新控件,至於具體怎麼實現,就不是本篇文章的重點了。

2.2 存在的問題

按照上面的方式,可以看到確實是實現了佈局效果,但是我們現在修改下每個頁面的佈局高度,例如我把第一個頁面的條目設置爲40個,第二個爲15個,第三個頁面的條爲20個,看看有沒有什麼問題。運行效果如下
在這裏插入圖片描述
可以看到,由於我中間頁面只有15個列表項的高度,當我從中間頁面滑到第一個頁面時,第一個頁面並沒有如預期的那樣滑到我們Map中保存的的滑動距離的位置,這是因爲在第二個頁面的時候,ScrollView的整體最大高度爲頭部高度+15個列表項的高度,但是回到第一個頁面的時候,其列表項數目增多導致超過了這個最大高度,所以ScrollView只能滑動到這個最大高度(也就是中間頁面的高度)。

除此之外,我在使用SwipeRefreshLayoutRecyclerView添加下拉刷新時,又遇到了滑動嵌套的問題,因爲SwipeRefreshLayout需要包裹的內容可滑動,同時SwipeRefreshLayout還會導致RecyclerView高度顯示不全,雖然我們設置了ViewPager的高度自適應,但是由於Fragment的最外層被修改爲了SwipeRefreshLayout,所以導致ViewPager也沒辦法獲取RecyclerView的高度了,爲此,我們可以嘗試去重寫SwipeRefreshLayout或者其它方法,來讓高度正確顯示,或者換種辦法,不使用SwipeRefreshLayout,而直接修改RecyclerView來實現下拉刷新也行。可見,從一開始如果按照我們的思路(ScrollView+TabLayout+ViewPager+RecyclerView)來做的話,後面總是會遇到各種各樣的問題,這裏就不繼續深究了。

另外,雖然按照上面這種方法,可以實現效果,但是還有一個最影響性能的問題就是我們不得已設置了ViewPager一次性緩存所有的頁面,顯然這是違背ViewPager的設計初衷的,要知道在APP上,運行卡頓是一件多麼糟糕的事情。

如果你一口氣跟着我做到這裏,雖然我們費了很大的力氣,解決了一個問題,又冒出來一個問題,但是我們最後還是遇到了各種各樣的問題有待解決,是不是以爲這就結束了呢,是不是有點想放棄了呢,其實不然!

車到山前必有路,這篇文章真正的內容纔剛剛開始!

3、解決方案

3.1 揭開神祕面紗 ,論知識廣度的重要性

在上面,我們費了好大的力氣實現的效果,仍然不完美,那到底上面京東頁面的效果是怎樣實現的呢?

首先我們先拋開之前的思路,也就是使用傳統的佈局,通過各種嵌套來實現效果。那麼我們的問題來了,不使用這些基本的控件和佈局,那使用啥呢?

這時,如果平常關注Android版本更新多一點的話,可能會看到一個相對陌生的詞,叫CoordinatorLayout,這是Google在Android5.0的時候推出的一個新佈局,中文翻譯過來叫“協調者佈局”,在絕大部分的開發工作中,我們可能都不會使用到這個控件,我自己初次看到這個佈局的時候,還是比較好奇的,下了一些demo運行了下,感覺還不錯,之後就沒有再怎麼管這個佈局了,甚至把它忘了。

這不,誰能知道,這次困擾我這麼久的問題,居然讓CoordinatorLayout給一下子解決了,而且代碼及其簡單,不用像我們上面那樣去解決各種高度顯示不全、滑動衝突等等諸多問題。

ok,由於本文的重點不是介紹CoordinatorLayout,所以就不花很大的篇幅來講解,對這個佈局感興趣的可以深入瞭解下,當然在使用這個佈局之前,記得加上support:design的依賴庫,下面直接放上佈局代碼,

<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/coordinatorLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.design.widget.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:elevation="0dp"
        android:background="@android:color/white">

        <RelativeLayout
            android:id="@+id/title_layout"
            android:layout_width="match_parent"
            android:layout_height="50dp"
            app:layout_scrollFlags="scroll">

            <TextView
                android:id="@+id/tv"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_centerInParent="true"
                android:text="我是標題欄"
                android:textSize="18sp" />
        </RelativeLayout>

        <android.support.design.widget.TabLayout
            android:id="@+id/tabLayout"
            android:layout_width="match_parent"
            android:layout_height="50dp"
            android:background="@android:color/white"
            app:tabMode="fixed"
            app:tabSelectedTextColor="@android:color/black" />

    </android.support.design.widget.AppBarLayout>

    <android.support.v4.view.ViewPager
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior" />

</android.support.design.widget.CoordinatorLayout>

OK,然後再在Activity中添加對應的邏輯,基本上和我們之前探索過程中的代碼沒有很大差別,如下

public class MainActivity extends AppCompatActivity {

    private TabLayout tabLayout;
    private ViewPager viewPager;
    private FragmentPagerAdapter mPageAdapter;

    private ArrayList<String> titleList = new ArrayList<>();
    private ArrayList<Fragment> fragmentList=new ArrayList<>();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        initData();
        initView();
    }

    private void initData(){
        titleList.clear();
        titleList.add("標籤一");
        titleList.add("標籤二");
        titleList.add("標籤三");

        fragmentList.clear();
        fragmentList.add(new OneFragment());
        fragmentList.add(new TwoFragment());
        fragmentList.add(new ThreeFragment());

        mPageAdapter=new FragmentPagerAdapter(getSupportFragmentManager()) {
            @Override
            public Fragment getItem(int i) {
                return fragmentList.get(i);
            }

            @Nullable
            @Override
            public CharSequence getPageTitle(int position) {
                return titleList.get(position);
            }

            @Override
            public int getCount() {
                return titleList.size();
            }
        };
    }

    private void initView(){
        tabLayout=findViewById(R.id.tabLayout);
        viewPager=findViewById(R.id.viewPager);
        viewPager.setAdapter(mPageAdapter);
        tabLayout.setupWithViewPager(viewPager);
    }
}

代碼中的適配器和Fragment都是使用的之前的,Fragment的代碼和佈局和之前相比就加了一個SwipeRefreshLayout用於下拉刷新,佈局如下

<android.support.v4.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/swipeRefreshLayout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recyclerview"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true" />

</android.support.v4.widget.SwipeRefreshLayout>

Fragment代碼如下

public class OneFragment extends Fragment {

    private RecyclerView mRecyclerView;
    private RecyclerView.LayoutManager mLayoutManager;
    private RecyclerViewAdapter mAdapter;

    private SwipeRefreshLayout swipeRefreshLayout;
    private View mainView;

    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, 
    @Nullable Bundle savedInstanceState) {
        mainView = inflater.inflate(R.layout.fragment_one, container, false);
        initView();
        return mainView;
    }

    private void initView() {
        mRecyclerView = mainView.findViewById(R.id.recyclerview);
        swipeRefreshLayout=mainView.findViewById(R.id.swipeRefreshLayout);
        mLayoutManager = new LinearLayoutManager(getActivity(), 
        LinearLayoutManager.VERTICAL, false);
        ArrayList<String> data = new ArrayList<>();
        for (int i = 0; i < 40; i++) {
            data.add("列表項" + i);
        }
        mAdapter = new RecyclerViewAdapter(getActivity(), data);
        mRecyclerView.setLayoutManager(mLayoutManager);
        mRecyclerView.setAdapter(mAdapter);
        swipeRefreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
            @Override
            public void onRefresh() {
                new Handler().postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        swipeRefreshLayout.setRefreshing(false);
                        Toast.makeText(getActivity(),"刷新完成",Toast.LENGTH_SHORT).show();
                    }
                },2000);
            }
        });
    }
}

現在我們來運行一下看看效果怎麼樣,如下所示:
在這裏插入圖片描述
怎麼樣,沒有了佈局的嵌套,使用CoordinatorLayout居然實現起來這麼簡單,簡直神奇!而且之前我們實現的方案,最後遺留的問題也都沒有,運行很流暢。

到這裏,我不得不感嘆下,知識的廣度確實挺重要的,見的多了,走的彎路也就少了。

3.2 簡單學習下CoordinatorLayout(協調者佈局)

在最後我們成功使用CoordinatorLayout解決了我們的問題,但是我們難免會好奇,CoordinatorLayout究竟是怎樣實現這麼複雜的效果的,答案就是它的核心Behavior,這個就留作另一篇文章把,今天我們主要學習下CoordinatorLayout相關的概念。

在上面最後實現的佈局中,我們難免會有些疑問。可以看到,在這個佈局中,我們看到了一個相對陌生的屬性layout_scrollFlags,這個屬性設置在了標題欄佈局上,我們首先來學習下這個屬性。從資料中,我們可以知道,這個屬性用於在滑動時,執行怎樣的操作,該屬性一共有五個值,具體的含義如下

  • scroll : 該View伴隨着滾動事件而滾出或滾進屏幕。
  • enterAlways : 快速返回模式,向下滾動時,首先將該View滾動出來,然後再滾動整體佈局。
  • enterAlwaysCollapsed : 摺疊快速返回模式,向下滾動時,該View先向下滾動最小高度值,然後整體佈局開始滾動,到達邊界時,該View再向下滾動,直至顯示完全。
  • exitUntilCollapsed : 向上滾動事件時,該View向上滾動退出直至最小高度,然後整體佈局開始滾動。最後的狀態是該View不會完全退出屏幕。
  • snap : 該View不會存在局部顯示的情況,滾動該View的部分高度,當我們鬆開手指時,該View要麼向上全部滾出屏幕,要麼向下全部滾進屏幕。

我們這裏根據需求,很顯然需要選擇scroll這個值,因爲每次我們滑動時,都需要該View滑出屏幕。

當然,可能還有一個疑問,爲什麼一定要使用AppBarLayout這個控件呢,這是因爲layout_scrollFlags這個屬性是AppBarLayout這個控件纔有的。

然後我們可以看到ViewPager寫的位置非常奇怪,爲什麼要寫在這裏呢,這個,其實是官網推薦的寫法,相當於在CoordinatorLayout佈局中的一種固定用法,可以這麼理解。

然後我們還看到了另外一個陌生的屬性layout_behavior,這個屬性是幹嘛的呢,這個屬性有很多值,其實就是一個字符串,而這個字符串的作用就是把當前View放到AppBarLayout控件的下面。

結語

本篇文章到此就差不多了,本以爲很快就可以寫完的,結果寫了這麼久,寫這篇文章的目的也很簡單,就是如果需要實現類似我這樣的效果,大家就不要像我一樣走彎路了,各種嵌套各種衝突,解決了一個問題又倆一個問題,結果最後還是有問題沒法解決,性能也不好,下次遇到複雜的交互佈局時,就不要想到使用嵌套了,而是CoordinatorLayout

然後文章中關於CoordinatorLayout的內容比較少,我自己對這個佈局也不是很熟,下一篇,再深入瞭解下這個神奇好玩的佈局。

最後放上本篇中兩個例子的源碼供參考,按照我自己最初的思路,使用基本控件嵌套實現的佈局:點此下載;使用CoordinatorLayout實現佈局的源碼:點此下載

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章