android商品詳情頁開發

像商品詳情這樣的頁面,功能多,頁面繁雜,特別是對頁面邏輯也不少,所以我覺得有必要記錄一下開發商品詳情頁面踩過的坑。

一.別人家的view

如果是仿淘寶或京東的詳情頁那還好說

image.png

它的導航欄是在上邊,這樣的結構很好,基本不會有什么大問題,可以自定義一個布局去當標題欄。

關鍵是有些頁面不是導航欄在上邊,而是在中間(比如我自己要做的),這種情況其實不是很好,即使是能實現效果,但是體驗還是不如JD那樣的導航欄放上邊的好。

image.png

比如這個taptap的詳情頁,導航欄就是放中間。

我這里只想說說這種導航欄在中間的情況

二.開發需求

如果是上邊的導航欄在中間的情況,肯定會要求我們當滑動時,導航欄會一直頂在布局頂部。

1.用CoordinatorLayout實現布局

我們一看這樣的布局,二話不說就馬上能想到用CoordinatorLayout去實現這樣的效果。沒錯,這樣的布局講道理應該是用CoordinatorLayout去實現,谷歌也是這樣推薦的。

但是,我之前寫過一篇文章說CoordinatorLayout有問題,當你折疊的部分高度不高時還不容易看出有什么問題,但是當可折疊部分高度高時,就會出現嚴重的滑動卡頓的問題,記住,是嚴重的卡頓。

可能有些大佬能夠自定義Behavior來解決卡頓的問題。我也覺得這樣的做法是官方的做法,但是我是新手嘛,自定義Behavior我反正試了沒用,那只能走其它的路。

2.用Nestedscrollview實現布局

那我就用CoordinatorLayout的內部實現Nestedscrollview來解決這個問題,而Nestedscrollview官方定義本來就能解決滑動的沖突。

(1)自定義NestedScrollingParent和NestedScrollingChild

用Nestedscrollview的原理,我先自己寫個NestedScrollingParent和NestedScrollingChild兩個viewgroup來顯示嵌套滑動的效果。

做法其實不難,就是要分別實現這兩個接口的方法。

image.png
image.png

然后你很容易在網上找到這兩個接口中方法的使用流程。然后在自定義的viewgroup中完成事件監聽onTouchEvent監聽點擊滑動放開。

我覺得沒必要貼代碼,就自定義NestedScrollingParent和NestedScrollingChild,網上有很多demo。主要做這些事:

實現接口中的方法
監聽事件onTouchEvent

這樣就能簡單的實現上面說的效果(嵌套滑動并且導航欄會頂在布局頂部)。但是僅僅這樣做會發現個問題,沒有慣性。如果你僅僅只需要滑動流暢,那不做慣性也是一個不錯的選擇,但是沒有慣性的滑動體驗效果真的不是很好,也許是我們習慣了有慣性的滑動效果。

我看了下代碼,慣性的實現和這兩個接口關系不大,是要自己去實現。要做慣性就要用VelocityTracker這個類

image.png

意思就是這貨能追蹤觸摸事件的速度,我之前沒用過這個類,百度了一下資料,效果不是很理想,我嘗試實現這個效果但是實際是沒能實現的,畢竟沒時間研究,以后肯定會寫一篇關于這個的,畢竟它這么牛逼的效果。本來想去看看RecyclerView源碼試試能不能看懂些什么,但是內聚性比較高加上一大堆靜態變量,我還真看不出個所以然。

那么對于我來說用自定義NestedScrollingParent和NestedScrollingChild也失敗了,因為我不會做慣性。那我就打算直接自定義NestedScrollingView,因為它內部已經有了慣性的機制。

(2)自定義NestedScrollingView充當NestedScrollingParent

首先我想說這個方法絕對可行,但是我做不到。我沒辦法讓導航欄在滑動的時候停在頂部。

原因很簡單,我做不到一件事:當父布局滑動到一定的位置時,子布局通知父布局不要滑動,而子布局來繼續滑動,如果是自定義NestedScrollingView,我做不到子布局通知父布局不要滑動而自己滑動。也許是我對這個控件的了解不足,反正我試了很多個方法都不行,但是我覺得這個方法可行。

3.視覺效果實現布局

用CoordinatorLayout有官方的卡頓效果,用Nestedscrollview自己又不熟悉所以做不好,那怎么辦,總不能不做吧。所以我就想出了第三種方法,這種方法能夠實現那樣的效果,只不過是投機取巧去實現。

(1)原理
總的來說還是使用Nestedscrollview嵌套,因為Nestedscrollview可以解決嵌套滑動的問題。那么怎么讓圖中的導航欄一直停在頂部呢?很簡單,我只要做一個一模一樣的布局一直放在頂部隱藏著,我監聽滑動,當滑動的距離大于等于導航欄距頂部的距離,我就讓隱藏的導航欄顯示,這樣就能產生視覺上的當導航欄滑到頂部時會一直在頂部的效果。

15099406446461509940638249.gif

這個效果就是這樣做出來的視覺差。

(2)實現

我們先來實現導航欄tabView吧。導航欄可以使用系統自帶的tablayout,但是要注意,這個頁面是用兩個tablayout的,而且他們是聯動的,就是說有一個tablayout切換到tab2的話,其它的tablayout都要切換到tab2。所以我們可以寫一個幫助類來做TabLayout之間聯動的操作。

我就暫時簡單寫一個,封裝得不是很好。

public class ProductDetailsTabGroup {

    private Context context;
    private List<TabLayout> tabLayoutList;

    public ProductDetailsTabGroup(Context context){
        this.context = context;
        tabLayoutList = new ArrayList<>();
    }

    public void addTabLayout(TabLayout tabLayout){
        tabLayoutList.add(tabLayout);
    }

    public void addTitiles(String[] titles){

        if (tabLayoutList == null || tabLayoutList.size() < 1){
            return;
        }

        for (int i = 0; i < tabLayoutList.size(); i++) {
            for (int j = 0; j < titles.length; j++) {
                tabLayoutList.get(i).addTab(tabLayoutList.get(i).newTab().setText(titles[j]));
            }
        }

    }

    public void tabGroupListener(){

        if (tabLayoutList == null || tabLayoutList.size() < 1){
            return;
        }

        tabLayoutList.get(0).setOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
            @Override
            public void onTabSelected(TabLayout.Tab tab) {
                tabLayoutList.get(1).getTabAt(tab.getPosition()).select();
                ((TestProductDetails)context).showFragment(tab.getPosition());
            }

            @Override
            public void onTabUnselected(TabLayout.Tab tab) {

            }

            @Override
            public void onTabReselected(TabLayout.Tab tab) {

            }
        });

        tabLayoutList.get(1).setOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
            @Override
            public void onTabSelected(TabLayout.Tab tab) {
                tabLayoutList.get(0).getTabAt(tab.getPosition()).select();
            }

            @Override
            public void onTabUnselected(TabLayout.Tab tab) {

            }

            @Override
            public void onTabReselected(TabLayout.Tab tab) {

            }
        });

    }

}

addTitiles方法是所有tablayout設置相同的標題。tabGroupListener()方法是聯動,我這里寫死兩個tab的聯動,只用在其中一個加切換fragment的方法就行((TestProductDetails)context).showFragment(tab.getPosition())。

多個的時候用嵌套for循環來聯動,我這里寫死兩個確實擴展性不好。

聯動成功之后,監聽滑動來判斷頂部的tablayout的顯示和隱藏。

scrollview.setOnScrollChangeListener(new NestedScrollView.OnScrollChangeListener() {
            @Override
            public void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
                if (scrollY >= tabLayout.getTop()+contentView.getTop()+emptyViewGroup.getContentView().getTop()){
                    topTabLayout.setVisibility(View.VISIBLE);
                }else {
                    topTabLayout.setVisibility(View.GONE);
                }
            }
        });

(3)嵌套布局的Viewgroup

我想說說嵌套布局的viewgroup,用FragmentManager來做而不用viewpager來做,是因為會出現以下的原因:

如果使用viewpager的話,會出現布局高度不固定的情況。你可以設死一個固定的高度,但是這樣的話,兩個滾動會不兼容,就是會出現子布局的滾動會優先于父布局的滾動,而不是配合滾動。

但是這里有個技巧,你可以設置Viewpager的高度為根據子view的高度進行設置,這樣的話就需要自定義viewpager重寫onMeasure方法

@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 (h > height)
                    height = h;
            }

            mHight = height;
            heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY);
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

雖然這樣能夠解決高度的問題,但是這樣做的話,或出現一個顯現,假如有兩個fragment,那viewpager的高度會取最后測量的那個,也就是說所有的fragment的高度會相同,如果偏低的頁面就會補空白,偏高就會滾動。
這樣就不行,我們需要的是每個fragment的高度都是自適應的。當然你也可以動態去改變viewpager的高度。

動態改變布局高度的方法是用setLayoutParams()

但是你要獲取到布局的高度,需要用多線程來監聽繪制后獲取viewgroup的高度。

 ViewTreeObserver vto = viewgroup.getViewTreeObserver();
        vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                rlParent.getViewTreeObserver().removeGlobalOnLayoutListener(this);
               // todo 獲取viewgroup高度
            }
        });

雖然能實現,但是總的來說非常的麻煩,可能你不明白我說的是什么,但是如果你用viewpager來嵌套的話,就會出現很多問題,所以我建議用FragmentManager來做嵌套,而且你這樣的頁面中講真也不應該給它左右滑動,不然會很亂。

三.總結

總的來說,實現第二張圖那樣的導航欄在中間的情況,真的會有很多坑,而且體驗的效果還不如第一張圖京東那樣好。我也貼些代碼吧,由于功能多,我只貼頁面邏輯的代碼。

1.布局

(1)最外層布局

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

    <com.xxxxx.xxxxx.components.widget.view.MyPullRefreshScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/scrollview"
        android:layout_above="@+id/ll_bottom"
        >
    </com.xxxxx.xxxxx.components.widget.view.MyPullRefreshScrollView>

    <android.support.design.widget.TabLayout
        android:layout_alignParentTop="true"
        android:id="@+id/tl_top_tab"
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:background="@color/white"
        android:visibility="gone"
        app:tabMode="fixed"
        app:tabGravity="fill"
        app:tabTextColor="@color/app_black"
        app:tabSelectedTextColor="@color/login_red"
        app:tabIndicatorColor="@color/login_red"
        app:tabIndicatorHeight="2dp"
      app:tabTextAppearance="@style/MyTabLayoutTextAppearanceInverse"
        />

</RelativeLayout>

MyPullRefreshScrollView是一個自定義的可下拉刷新的基于PullToRefreshBase的view,然后TabLayout就是上面說的要一直在頂部的導航欄,默認是隱藏。

MyPullRefreshScrollView:

public class MyPullRefreshScrollView extends PullToRefreshBase <NestedScrollView>{

    private NestedScrollView berScrollView;
    private FrameLayout flContent;

    public PullRefreshBerScrollView(Context context) {
        super(context);
    }

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

    public PullRefreshBerScrollView(Context context, Mode mode) {
        super(context, mode);
    }

    @Override
    public Orientation getPullToRefreshScrollDirection() {
        return Orientation.VERTICAL;
    }

    @Override
    protected NestedScrollView createRefreshableView(Context context, AttributeSet attrs) {
        berScrollView = (NestedScrollView) LayoutInflater.from(context).inflate(R.layout.layout_berscrollview,null);
        flContent = (FrameLayout) berScrollView.findViewById(R.id.fl_content);
        return berScrollView;
    }

    public void addView(View view){
        flContent.addView(view);
    }

    public NestedScrollView getBerScrollView() {
        return berScrollView;
    }

    @Override
    protected boolean isReadyForPullEnd() {
        return false;
    }

    @Override
    protected boolean isReadyForPullStart() {
        return berScrollView.getScrollY() <= 0;
    }
}

下拉控件中,控制能否下拉的條件就是.getScrollY() <= 0(滑動距離是否小于等于0)

主要布局

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:background="@color/white"
        android:id="@+id/ll_scroll_content"
        ></LinearLayout>


    <android.support.design.widget.TabLayout
        android:id="@+id/tl_tab"
        android:layout_width="match_parent"
        android:layout_height="50dp"
        android:background="@color/white"
        app:tabMode="fixed"
        app:tabGravity="fill"
        app:tabTextColor="@color/app_black"
        app:tabSelectedTextColor="@color/login_red"
        app:tabIndicatorColor="@color/login_red"
        app:tabIndicatorHeight="2dp"
        app:tabTextAppearance="@style/MyTabLayoutTextAppearanceInverse"
        />

    <View
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:background="@color/divider_grey"/>


    <!--<com.xxx.xxx.ui.activity.test.MyTestViewPager-->
        <!--android:layout_width="match_parent"-->
        <!--android:layout_height="wrap_content"-->
        <!--android:id="@+id/vp"-->
        <!--></com.xxx.xxx.ui.activity.test.MyTestViewPager>-->

    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/fl_child_content"
        ></FrameLayout>

</LinearLayout>

我用了mvvm模式,最上邊的LinearLayout是用來動態添加View(本人不喜歡寫死xml布局,這樣擴展性差),TabLayout就是導航欄,下面我注釋viewpager是因為我之前用viewpager,太麻煩了所以改用FragmentManager,所以這里用FrameLayout

2.初始化tablayout

我上面也說了,寫一個幫助類來做tablayout間聯動的操作,所以我這里就貼調用這歌輔助類的代碼。

private void initTab(){
        tabGroup = new ProductDetailsTabGroup(this);
        tabGroup.addTabLayout(tabLayout);
        tabGroup.addTabLayout(topTabLayout);
        tabGroup.addTitiles(titles);
    }

監聽滑動

scrollview.setOnScrollChangeListener(new NestedScrollView.OnScrollChangeListener() {
            @Override
            public void onScrollChange(NestedScrollView v, int scrollX, int scrollY, int oldScrollX, int oldScrollY) {
                if (scrollY >= tabLayout.getTop()+contentView.getTop()+emptyViewGroup.getContentView().getTop()){
                    topTabLayout.setVisibility(View.VISIBLE);
                }else {
                    topTabLayout.setVisibility(View.GONE);
                }
            }
        });
3.設置fragmentManger
public void showFragment(int position){
        for (int i = 0; i < fragments.length; i++) {
            if (i == position){
                if (fragments[i] == null){
                    addFragment(position);
                    fragmentManager.beginTransaction().add(R.id.fl_child_content, fragments[i]).commit();
                }else {
                    fragmentManager.beginTransaction().attach(fragments[i]).commit();
                }
            }else {
                if (fragments[i] != null){
                    fragmentManager.beginTransaction().detach(fragments[i]).commit();
                }
            }
        }
    }
4.子view布局
<android.support.v4.widget.NestedScrollView
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <FrameLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:id="@+id/framelayout"
            >
        </FrameLayout>

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

記得子view要嵌套NestedScrollView。

注意一下,如果你用RecyclerView做子View的話會產生滑動無慣性,這時候你需要給RecyclerView設一個屬性recyclerview.setNestedScrollingEnabled(false);在xml中設也行,這樣就正常了。

這樣就能實現那個效果了,代碼也不是很難,就是要多注意一些細節,而且使用FragmentManager的話連懶加載都不用做了,簡直方便了很多。

5.總結

按照我這樣的做法,你肯定能實現文章里gif圖的那種效果,但是,這種方法是投機取巧的方法,也行不會有什么問題,但是和理論對不上,理論上實現這樣的效果就是一種解決嵌套滑動的思路(NestedScrollView的那種思路才是正常解決這個方法的正確思路),我這樣做雖然能實現,但是容易出BUG,擴展性不好。

再有,這樣的情況,真的不使用viewpager,這里用viewpager只會把一個簡單的問題給復雜化。

最后,我之前寫過一篇關于NestedScrollView嵌套解決滑動沖突,這是我目前發現的能解決滑動沖突最好的方法,至于要實現折疊的特效,還是需要用CoordinatorLayout,而這個東西的卡頓BUG我估計這輩子谷歌是不會去解決它了,所以想做特效,我覺得要理解CoordinatorLayout封裝的思想和自定義Behavior,或者直接自定義CoordinatorLayout進行擴展。


2017.11.13 更新

更新內容:添加demo
項目地址 : https://github.com/994866755/handsomeYe.productdetails

最近一直沒怎么又時間更新,而且也發現github很久沒維護了,然后也抽出點時間也寫一個簡單的demo實現這個商品詳情頁面的功能。希望有Bug的話可以提出,有寫得不好的地方也能指出來,謝謝。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容