在列表滚动的时候显示或者隐藏Toolbar

转自:http://www.jcodecraeer.com/a/anzhuokaifa/androidkaifa/2015/0319/2618.html

编辑推荐:稀土掘金,这是一个针对技术开发者的一个应用,你可以在掘金上获取最新最优质的技术干货,不仅仅是Android知识、前端、后端以至于产品和设计都有涉猎,想成为全栈工程师的朋友不要错过!

这是系列文章点击打开链接的第二部分(也是最后一部分),建议你先阅读 第一部分 ,在上一部分中,我们学会了如何实现Google+应用中隐藏Toolbar的效果,今天我们来实现Play Store中的效果。

在开始之前,我先讲讲这一部分对 项目  结构的一点改动。原有的activity被分割成了两个:PartOneActivity和PartTwoActivity,他们都是被MainActivity所调用。

下面是本篇文章要实现的Toolbar效果与Play Store的对比:

goal.gif   

译者注:在阅读本文的同时,最好先实际操作一下play store应用,即便你大致知道效果是怎样也建议操作一下,不然下面的计算有点不好理解。其实这些都是很细微的东西,要一眼带过,估计什么也看不出来。

开始

build.gradle文件和第一部分是一样的,不再赘述,我们从创建Activity的布局开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<FrameLayout xmlns:android= "http://schemas.android.com/apk/res/android"
     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= "match_parent"
         android:paddingTop= "?attr/actionBarSize"
         android:clipToPadding= "false" />
     <android.support.v7.widget.Toolbar
         android:id= "@+id/toolbar"
         android:layout_width= "match_parent"
         android:layout_height= "?attr/actionBarSize"
         android:background= "?attr/colorPrimary" />
  
</FrameLayout>

只有一个RecyclerView和一个Toolbar(后面我们还会添加Tabs)。注意这次我们使用的是 上篇文章  中提到的第二种方法(添加padding到RecyclerView)。list item的布局文件和上次一样,直接跳过,RecyclerAdapter同样如此(这里   是其代码-一个不带header的adapter),跳过 ,我们直接进入PartTwoActivity的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class PartTwoActivity extends ActionBarActivity {
  
     private Toolbar mToolbar;
      
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         setTheme(R.style.AppThemeGreen);
         super .onCreate(savedInstanceState);
         setContentView(R.layout.activity_part_two);
          
         initToolbar();
         initRecyclerView();
     }
      
     private void initToolbar() {
         mToolbar = (Toolbar) findViewById(R.id.toolbar);
         setSupportActionBar(mToolbar);
         setTitle(getString(R.string.app_name));
         mToolbar.setTitleTextColor(getResources().getColor(android.R.color.white));
     }
      
     private void initRecyclerView() {
         final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
         recyclerView.setLayoutManager( new  LinearLayoutManager( this ));
         RecyclerAdapter recyclerAdapter =  new  RecyclerAdapter(createItemList());
         recyclerView.setAdapter(recyclerAdapter);
         recyclerView.setOnScrollListener( new  HidingScrollListener( this ));
     }
      
     private List<String> createItemList() {
         List<String> itemList =  new  ArrayList<>();
         for (int i=0;i<20;i++) {
             itemList.add( "Item " +i);
         }
         return  itemList;
     }
}

只是RecyclerViewToolbar基本的初始化操作,注意第27行OnScrollListener的设置。

最有趣的部分是HidingScrollListener,让我们创建一个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public abstract class HidingScrollListener extends RecyclerView.OnScrollListener {
  
     private int mToolbarOffset = 0;
     private int mToolbarHeight;
      
     public HidingScrollListener(Context context) {
         mToolbarHeight = Utils.getToolbarHeight(context);
     }
      
     @Override
     public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
         super .onScrolled(recyclerView, dx, dy);
          
         clipToolbarOffset();
         onMoved(mToolbarOffset);
          
         if ((mToolbarOffset <mToolbarHeight && dy>0) || (mToolbarOffset >0 && dy<0)) {
             mToolbarOffset += dy;
         }
     }
      
     private void clipToolbarOffset() {
         if (mToolbarOffset > mToolbarHeight) {
             mToolbarOffset = mToolbarHeight;
         else  if (mToolbarOffset < 0) {
             mToolbarOffset = 0;
         }
     }
      
     public abstract void onMoved(int distance);
}

如果你读了前面一篇文章,这段代码应该很眼熟(实际上这次还更简单了)。这里只有一个比较重要的变量-mToolbarOffset,它表示相对于Toolbar高度的滚动偏移量。为了简便起见,我们只追踪0到Toolbar高度之间的值:

1
2
3
if ((mToolbarOffset <mToolbarHeight && dy>0) || (mToolbarOffset >0 && dy<0)) {
     mToolbarOffset += dy;
}

当向上滚动的时候(注意在第一篇文章中我们对于向上滚动的解释)这个值将增加(但是我们并不希望这个值大于Toolbar的高度),而向下滚动的时候这个值将减小(同样,我们也不希望减小到小于0),你很快会知道为什么我们要作此限制的原因。虽然上面的代码已经有了限制,但是在很短的时间内(比如fling的时候),还是有可能超过这个区间,因此需要调用clipToolbarOffset()方法来做二次限制,确保mToolbarOffset在0到Toolbar高度的范围内,否则会出现抖动闪烁的情况。我们还定义了一个在scroll期间被调用的抽象方法onMoved()。 

让我们回到PartTwoActivity,同时实现scroll listener中的onMoved()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
private void initRecyclerView() {
     final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
     recyclerView.setLayoutManager( new  LinearLayoutManager( this ));
     RecyclerAdapter recyclerAdapter =  new  RecyclerAdapter(createItemList());
     recyclerView.setAdapter(recyclerAdapter);
      
     recyclerView.setOnScrollListener( new  HidingScrollListener( this ) {
         @Override
         public void onMoved(int distance) {
             mToolbarContainer.setTranslationY(-distance);
         }
     });
}

好了,我们看看现在是什么效果:

nosnap.gif


非常不错,Toolbar随着列表的滚动而滚动,并且能在消失之后再次随着反向的滚动而滚回来,这和我们的预期是一致的。这要归功于我们对mToolbarOffset的限制。如果我们省略检查mToolbarOffset是否大于0且小于mToolbarHeight,那么当我们向上滚动(这里指手指向上,也许是作者疏忽吧,前后的意思不一致)时,Toolbar将会远远超出屏幕的范围,想再次看到Toolbar需要等列表滚回到0的位置才行。而现在最多才滚动mToolbarHeight的距离,因此Toolbar始终紧挨着列表的最上面,因此向下滚动(这里也是指手指向下)的时候,能立即看到Toolbar

虽然目前看起来还不错,但并非我想要的。如果在滚动一半的时候突然停止,Toolbar将是部分可见的,这看起来很奇怪。实际上Google Play Games就是这种效果,但我觉得这是个bug。


让Toolbar自动滚动到正确位置

正确是效果是Toolbar应该像Google Play store中那样自动平滑的过度到该有的位置。


下面来看看新的HidingScrollListener:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public abstract class HidingScrollListener extends RecyclerView.OnScrollListener {
      
     private static final float HIDE_THRESHOLD = 10;
     private static final float SHOW_THRESHOLD = 70;
      
     private int mToolbarOffset = 0;
     private boolean mControlsVisible =  true ;
     private int mToolbarHeight;
      
     public HidingScrollListener(Context context) {
         mToolbarHeight = Utils.getToolbarHeight(context);
     }
      
     @Override
     public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
         super .onScrollStateChanged(recyclerView, newState);
          
         if (newState == RecyclerView.SCROLL_STATE_IDLE) {
             if  (mControlsVisible) {
                 if  (mToolbarOffset > HIDE_THRESHOLD) {
                     setInvisible();
                 else  {
                     setVisible();
                 }
             else  {
                 if  ((mToolbarHeight - mToolbarOffset) > SHOW_THRESHOLD) {
                     setVisible();
                 else  {
                     setInvisible();
                 }
             }
         }
     }
      
     @Override
     public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
         super .onScrolled(recyclerView, dx, dy);
          
         clipToolbarOffset();
         onMoved(mToolbarOffset);
          
         if ((mToolbarOffset <mToolbarHeight && dy>0) || (mToolbarOffset >0 && dy<0)) {
             mToolbarOffset += dy;
         }
          
     }
      
     private void clipToolbarOffset() {
         if (mToolbarOffset > mToolbarHeight) {
             mToolbarOffset = mToolbarHeight;
         else  if (mToolbarOffset < 0) {
             mToolbarOffset = 0;
         }
     }
      
     private void setVisible() {
         if (mToolbarOffset > 0) {
             onShow();
             mToolbarOffset = 0;
         }
         mControlsVisible =  true ;
     }
      
     private void setInvisible() {
         if (mToolbarOffset < mToolbarHeight) {
             onHide();
             mToolbarOffset = mToolbarHeight;
         }
         mControlsVisible =  false ;
     }
      
     public abstract void onMoved(int distance);
     public abstract void onShow();
     public abstract void onHide();
}

比以前复杂了点,但是也不用怕,我们只是重写了RecyclerView.OnScrollListener的第二个方法onScrollStateChanged(),下面是onScrollStateChanged中所做的事情:

1.检查列表是否处于RecyclerView.SCROLL_STATE_IDLE状态,这个状态下列表没有滚动(因为如果在滚动,我们是像以前一样主动移动Toolbar的Y值)。

2.如果我们放开了手指并且列表停止滚动(这是就是RecyclerView.SCROLL_STATE_IDLE状态),我们需要检查当前Toolbar是否可见,如果是可见的,意味着在mToolbarOffset大于HIDE_THRESHOLD的时候隐藏它,而在mToolbarOffset小于SHOW_THRESHOLD的时候显示它。

1
2
3
4
5
6
7
if  (mControlsVisible) {
     if  (mToolbarOffset > HIDE_THRESHOLD) {
         setInvisible();
     else  {
         setVisible();
     }
}

如果Toolbar是不可见的,我们要做相反的事情-当mToolbarOffset(现在是从顶部计算所以是mToolbarHeight - mToolbarOffset)大于SHOW_THRESHOLD显示,当小于IDE_THRESHOLD再次隐藏:

1
2
3
4
5
6
7
else  // it's not visible
     if  ((mToolbarHeight - mToolbarOffset) > SHOW_THRESHOLD) {
         setVisible();
     else  {
         setInvisible();
     }
}

ps:实话是说我没看懂。。。

onScrolled()方法和之前保持一致,最后剩下的事情是在PartTwoActivity中实现两个新的抽象方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private void initRecyclerView() {
     final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
     recyclerView.setLayoutManager( new  LinearLayoutManager( this ));
     RecyclerAdapter recyclerAdapter =  new  RecyclerAdapter(createItemList());
     recyclerView.setAdapter(recyclerAdapter);
      
     recyclerView.setOnScrollListener( new  HidingScrollListener( this ) {
      
         @Override
         public void onMoved(int distance) {
             mToolbarContainer.setTranslationY(-distance);
         }
          
         @Override
         public void onShow() {
             mToolbarContainer.animate().translationY(0).setInterpolator( new  DecelerateInterpolator(2)).start();
         }
          
         @Override
         public void onHide() {
             mToolbarContainer.animate().translationY(-mToolbarHeight).setInterpolator( new  AccelerateInterpolator(2)).start();
         }
          
     });
}


现在来看看编译运行的结果:

Snap no tabs gif

看起来还比较顺利!

等等,不是说要做成play store的效果吗,还差了个tab吧,你可能会觉得添加tab会让事情变得复杂很多,让我来告诉你。其实不是那么回事。

添加tab

需要修改Activity的布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<FrameLayout xmlns:android= "http://schemas.android.com/apk/res/android"
     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= "match_parent"
         android:clipToPadding= "false" />
  
     <LinearLayout
         android:id= "@+id/toolbarContainer"
         android:layout_width= "match_parent"
         android:layout_height= "wrap_content"
         android:orientation= "vertical" >
      
         <android.support.v7.widget.Toolbar
             android:id= "@+id/toolbar"
             android:layout_width= "match_parent"
             android:layout_height= "?attr/actionBarSize"
             android:background= "?attr/colorPrimary" />
      
         <include layout= "@layout/tabs" />
     </LinearLayout>
  
</FrameLayout>

其中tabs.xml代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<LinearLayout xmlns:android= "http://schemas.android.com/apk/res/android"
     android:layout_width= "match_parent"
     android:layout_height= "@dimen/tabsHeight"
     android:background= "?attr/colorPrimary" >
     <FrameLayout
         android:layout_width= "0dp"
         android:layout_height= "match_parent"
         android:layout_weight= "1"  >
         <TextView
             android:layout_width= "match_parent"
             android:layout_height= "match_parent"
             android:text= "@string/tab_1"
             android:gravity= "center"
             style= "@style/Base.TextAppearance.AppCompat.Body2"
             android:textColor= "@android:color/white"
             android:background= "@android:color/transparent"  />
         <View
             android:layout_width= "match_parent"
             android:layout_height= "6dp"
             android:layout_gravity= "bottom"
             android:background= "@android:color/white"  />
     </FrameLayout>
     <TextView
         android:layout_width= "0dp"
         android:layout_height= "match_parent"
         android:layout_weight= "1"
         android:text= "@string/tab_2"
         android:gravity= "center"
         style= "@style/Base.TextAppearance.AppCompat.Body2"
         android:textColor= "@android:color/white"
         android:background= "@android:color/transparent"  />
</LinearLayout>

可以看到,我并没有添加一个真正意义上的Tab,而是一个长得像tab的布局。但这并不会改变什么,这里可以是任意的view,原理都是一样的,至于材料设计风格的tab,github上有一些实现,你可以用来替换。

添加Tab意味着列表再次被挡住一部分空间,因此需要增加padding的值。考虑到灵活性,这次我们不再使用xml来设置padding,而是在代码中设置:

1
2
3
4
5
6
7
8
9
private void initRecyclerView() {
     final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
      
     int paddingTop = Utils.getToolbarHeight(PartTwoActivity. this ) + Utils.getTabsHeight(PartTwoActivity. this );
     recyclerView.setPadding(recyclerView.getPaddingLeft(), paddingTop, recyclerView.getPaddingRight(), recyclerView.getPaddingBottom());
      
     recyclerView.setLayoutManager( new  LinearLayoutManager( this ));
     // ...
}

很简单,我们将padding设置成ToolbarTab高度之和,运行看看正确与否:

Screen with tabs

看来是正确的列表的第一个item可以完全显示,那么我们继续,实际上,HidingScrollListener类中的代码完全不变,只需呀变更下PartTwoActivity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public class PartTwoActivity extends ActionBarActivity {
      
     private LinearLayout mToolbarContainer;
     private int mToolbarHeight;
      
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         setTheme(R.style.AppThemeGreen);
         super .onCreate(savedInstanceState);
         setContentView(R.layout.activity_part_two);
          
         mToolbarContainer = (LinearLayout) findViewById(R.id.toolbarContainer);
         initToolbar();
         initRecyclerView();
     }
      
     private void initToolbar() {
         Toolbar mToolbar = (Toolbar) findViewById(R.id.toolbar);
         setSupportActionBar(mToolbar);
         setTitle(getString(R.string.app_name));
         mToolbar.setTitleTextColor(getResources().getColor(android.R.color.white));
         mToolbarHeight = Utils.getToolbarHeight( this );
     }
      
     private void initRecyclerView() {
         final RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
          
         int paddingTop = Utils.getToolbarHeight(PartTwoActivity. this ) + Utils.getTabsHeight(PartTwoActivity. this );
         recyclerView.setPadding(recyclerView.getPaddingLeft(), paddingTop, recyclerView.getPaddingRight(), recyclerView.getPaddingBottom());
          
         recyclerView.setLayoutManager( new  LinearLayoutManager( this ));
         RecyclerAdapter recyclerAdapter =  new  RecyclerAdapter(createItemList());
         recyclerView.setAdapter(recyclerAdapter);
          
         recyclerView.setOnScrollListener( new  HidingScrollListener( this ) {
          
             @Override
             public void onMoved(int distance) {
                 mToolbarContainer.setTranslationY(-distance);
             }
              
             @Override
             public void onShow() {
                 mToolbarContainer.animate().translationY(0).setInterpolator( new  DecelerateInterpolator(2)).start();
             }
              
             @Override
             public void onHide() {
                 mToolbarContainer.animate().translationY(-mToolbarHeight).setInterpolator( new  AccelerateInterpolator(2)).start();
             }
          
         });
     }
      
     private List<String> createItemList() {
         List<String> itemList =  new  ArrayList<>();
         for (int i=0;i<20;i++) {
             itemList.add( "Item " +i);
         }
         return  itemList;
     }
}

不同之处在于,我们将Toolbar和Tab视为一个整体,被包含在LinearLayout中,mToolbarContainer变量即这个mToolbarContainer的引用,在onMove(), onHide()和onShow()方法中,我们动画变换的不是Toolbar而是mToolbarContainer。这将让整个LinearLayout中的内容一起动。

如果运行你会发现似乎很完美,但仔细观察可以发现一个bug,有时候Toolbar和Tab之间会有一条白线,这很可能是因为两者在动画的时候不同步造成的。幸好修改起来也很简单,在Toolbar和Tab的父布局上添加一个同种颜色的背景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<FrameLayout xmlns:android= "http://schemas.android.com/apk/res/android"
     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= "match_parent"
         android:clipToPadding= "false" />
  
     <LinearLayout
         android:id= "@+id/toolbarContainer"
         android:layout_width= "match_parent"
         android:layout_height= "wrap_content"
         android:orientation= "vertical"
         android:background= "?attr/colorPrimary" > <!-- added here -->
      
         <android.support.v7.widget.Toolbar
             android:id= "@+id/toolbar"
             android:layout_width= "match_parent"
             android:layout_height= "?attr/actionBarSize" /> <!-- removed from here and tabs.xml -->
          
         <include layout= "@layout/tabs" />
     </LinearLayout>
  
</FrameLayout>

现在即使动画不同步的问题仍然没有解决,但是你也看不到这条白线。还有一个bug,这个bug在第一部分也存在,如果我们列表在顶部,我们向上滑动一点点,当HIDE_THRESHOLD足够小的时候,Toolbar隐藏的同时列表的顶部还会有一个空白的区域,这个bug也好修复:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public abstract class HidingScrollListener extends RecyclerView.OnScrollListener {
      
     private static final float HIDE_THRESHOLD = 10;
     private static final float SHOW_THRESHOLD = 70;
      
     private int mToolbarOffset = 0;
     private boolean mControlsVisible =  true ;
     private int mToolbarHeight;
     private int mTotalScrolledDistance;
      
     public HidingScrollListener(Context context) {
         mToolbarHeight = Utils.getToolbarHeight(context);
     }
      
     @Override
     public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
         super .onScrollStateChanged(recyclerView, newState);
          
         if (newState == RecyclerView.SCROLL_STATE_IDLE) {
             if (mTotalScrolledDistance < mToolbarHeight) {
                 setVisible();
             else  {
                 if  (mControlsVisible) {