像商品詳情這樣的頁面,功能多,頁面繁雜,特別是對頁面邏輯也不少,所以我覺得有必要記錄一下開發商品詳情頁面踩過的坑。
一.別人家的view
如果是仿淘寶或京東的詳情頁那還好說
它的導航欄是在上邊,這樣的結構很好,基本不會有什么大問題,可以自定義一個布局去當標題欄。
關鍵是有些頁面不是導航欄在上邊,而是在中間(比如我自己要做的),這種情況其實不是很好,即使是能實現效果,但是體驗還是不如JD那樣的導航欄放上邊的好。
比如這個taptap的詳情頁,導航欄就是放中間。
我這里只想說說這種導航欄在中間的情況
二.開發需求
如果是上邊的導航欄在中間的情況,肯定會要求我們當滑動時,導航欄會一直頂在布局頂部。
1.用CoordinatorLayout實現布局
我們一看這樣的布局,二話不說就馬上能想到用CoordinatorLayout去實現這樣的效果。沒錯,這樣的布局講道理應該是用CoordinatorLayout去實現,谷歌也是這樣推薦的。
但是,我之前寫過一篇文章說CoordinatorLayout有問題,當你折疊的部分高度不高時還不容易看出有什么問題,但是當可折疊部分高度高時,就會出現嚴重的滑動卡頓的問題,記住,是嚴重的卡頓。
可能有些大佬能夠自定義Behavior來解決卡頓的問題。我也覺得這樣的做法是官方的做法,但是我是新手嘛,自定義Behavior我反正試了沒用,那只能走其它的路。
2.用Nestedscrollview實現布局
那我就用CoordinatorLayout的內部實現Nestedscrollview來解決這個問題,而Nestedscrollview官方定義本來就能解決滑動的沖突。
(1)自定義NestedScrollingParent和NestedScrollingChild
用Nestedscrollview的原理,我先自己寫個NestedScrollingParent和NestedScrollingChild兩個viewgroup來顯示嵌套滑動的效果。
做法其實不難,就是要分別實現這兩個接口的方法。
然后你很容易在網上找到這兩個接口中方法的使用流程。然后在自定義的viewgroup中完成事件監聽onTouchEvent監聽點擊滑動放開。
我覺得沒必要貼代碼,就自定義NestedScrollingParent和NestedScrollingChild,網上有很多demo。主要做這些事:
實現接口中的方法
監聽事件onTouchEvent
這樣就能簡單的實現上面說的效果(嵌套滑動并且導航欄會頂在布局頂部)。但是僅僅這樣做會發現個問題,沒有慣性。如果你僅僅只需要滑動流暢,那不做慣性也是一個不錯的選擇,但是沒有慣性的滑動體驗效果真的不是很好,也許是我們習慣了有慣性的滑動效果。
我看了下代碼,慣性的實現和這兩個接口關系不大,是要自己去實現。要做慣性就要用VelocityTracker這個類
意思就是這貨能追蹤觸摸事件的速度,我之前沒用過這個類,百度了一下資料,效果不是很理想,我嘗試實現這個效果但是實際是沒能實現的,畢竟沒時間研究,以后肯定會寫一篇關于這個的,畢竟它這么牛逼的效果。本來想去看看RecyclerView源碼試試能不能看懂些什么,但是內聚性比較高加上一大堆靜態變量,我還真看不出個所以然。
那么對于我來說用自定義NestedScrollingParent和NestedScrollingChild也失敗了,因為我不會做慣性。那我就打算直接自定義NestedScrollingView,因為它內部已經有了慣性的機制。
(2)自定義NestedScrollingView充當NestedScrollingParent
首先我想說這個方法絕對可行,但是我做不到。我沒辦法讓導航欄在滑動的時候停在頂部。
原因很簡單,我做不到一件事:當父布局滑動到一定的位置時,子布局通知父布局不要滑動,而子布局來繼續滑動,如果是自定義NestedScrollingView,我做不到子布局通知父布局不要滑動而自己滑動。也許是我對這個控件的了解不足,反正我試了很多個方法都不行,但是我覺得這個方法可行。
3.視覺效果實現布局
用CoordinatorLayout有官方的卡頓效果,用Nestedscrollview自己又不熟悉所以做不好,那怎么辦,總不能不做吧。所以我就想出了第三種方法,這種方法能夠實現那樣的效果,只不過是投機取巧去實現。
(1)原理
總的來說還是使用Nestedscrollview嵌套,因為Nestedscrollview可以解決嵌套滑動的問題。那么怎么讓圖中的導航欄一直停在頂部呢?很簡單,我只要做一個一模一樣的布局一直放在頂部隱藏著,我監聽滑動,當滑動的距離大于等于導航欄距頂部的距離,我就讓隱藏的導航欄顯示,這樣就能產生視覺上的當導航欄滑到頂部時會一直在頂部的效果。
這個效果就是這樣做出來的視覺差。
(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的話可以提出,有寫得不好的地方也能指出來,謝謝。