在列表滚动的时候显示或者隐藏Toolbar(第一部分)

导读:这个系列包含两篇文章,都是关于列表滚动时Toolbar(以及FAB)的显示与隐藏的,但是分为两种一种是Google+中的效果,一种是play store中的效果,本文是第一种。原文翻译如下:

本文将讲解如何实现类似于Google+应用中,当列表滚动的时候,ToolBar(以及悬浮操作按钮)的显示与隐藏(向下滚动隐藏,向上滚动显示),这种效果在Material Design 清单中有提到:

“在合适的地方,当列表向下滚动,app bar可以退出屏幕,以便为内容区域留下更多的空间;而当列表向上滚动回来的时候,app bar又重新显示出来”。

注:这里的向下滚动是指滚动到下面查看更多内容,相对应的手势操作其实是往上。同理向上滚动是指查看前面的内容,而手势其实是向下。

下面是我们应该实现的效果图:

demo_gif.gif

虽然此文我们将使用RecyclerView作为列表,但是这种实现方式适用于任何可以滚动的容器(某些情况下也许要稍微多做点工作,比如listview)。我想到了两种实现的方式:

  1. 在列表的上面加个padding。

  2. 为列表加个header。

我打算只写出第二种实现方式,因为有很多人询问关于如何给RecyclerView加上header的问题,因此借着这个机会就一起讲了。但是我也会非常简单的描述一下第一种实现方法。

开始

首先添加必要的库

dependencies {
    compile fileTree(dir: 'libs', include: \['*.jar'\])
    compile 'com.android.support:appcompat-v7:21.0.3'
    compile "com.android.support:recyclerview-v7:21.0.0"
    compile 'com.android.support:cardview-v7:21.0.3'
}

定义style,使用不带actionbar的Material主题(因为要用Toolbar).

<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
    <item name="colorPrimary">@color/color_primary</item>
    <item name="colorPrimaryDark">@color/color_primary_dark</item>
</style>

创建activity的布局

<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.support.v7.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        android:background="?attr/colorPrimary"/>
    <ImageButton
        android:id="@+id/fabButton"
        android:layout_width="56dp"
        android:layout_height="56dp"
        android:layout_gravity="bottom|right"
        android:layout_marginBottom="16dp"
        android:layout_marginRight="16dp"
        android:background="@drawable/fab_background"
        android:src="@drawable/ic_favorite_outline_white_24dp"
        android:contentDescription="@null"/>
</FrameLayout>

包含了RecyclerViewToolbar以及作为FAB(悬浮操作按钮)的ImageButton。我们将这三个控件放在FrameLayout中是因为Toolbar需要上浮在RecyclerView之上。如果我们不这样做,当Toolbar隐藏的时候列表的上方会有一个空白的区域。

下面转向MainActivity的代码:

public class MainActivity extends ActionBarActivity {
    private Toolbar mToolbar;
    private ImageButton mFabButton;
     
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initToolbar();
        mFabButton = (ImageButton) findViewById(R.id.fabButton);
        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() {
        RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
        recyclerView.setLayoutManager(new LinearLayoutManager(this));
        RecyclerAdapter recyclerAdapter = new RecyclerAdapter(createItemList());
        recyclerView.setAdapter(recyclerAdapter);
    }
 
}

如你所见,这是一个很小的类,只实现了onCreate,做了如下几件事情:

1.初始化Toolbar

2.获得FAB的引用

3.初始化`RecyclerView`

``现在来创建`RecyclerView`的adapter,首先需要为item创建布局:``

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:card_view="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center"
    android:layout_margin="8dp"
    card_view:cardCornerRadius="4dp">
    <TextView
        android:id="@+id/itemTextView"
        android:layout_width="match_parent"
        android:layout_height="?attr/listPreferredItemHeight"
        android:gravity="center_vertical"
        android:padding="8dp"
        style="@style/Base.TextAppearance.AppCompat.Body2"/>
</android.support.v7.widget.CardView>

以及和布局对应的ViewHolder

public class RecyclerItemViewHolder extends RecyclerView.ViewHolder {
    private final TextView mItemTextView;
     
    public RecyclerItemViewHolder(final View parent, TextView itemTextView) {
        super(parent);
        mItemTextView = itemTextView;
    }
    public static RecyclerItemViewHolder newInstance(View parent) {
        TextView itemTextView = (TextView) parent.findViewById(R.id.itemTextView);
        return new RecyclerItemViewHolder(parent, itemTextView);
    }
    public void setItemText(CharSequence text) {
        mItemTextView.setText(text);
    }
     
}

RecyclerAdapter的代码

public class RecyclerAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
    private List<String> mItemList;
     
    public RecyclerAdapter(List<String> itemList) {
        mItemList = itemList;
    }
    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        final View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.recycler_item, parent, false);
        return RecyclerItemViewHolder.newInstance(view);
    }
    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
        RecyclerItemViewHolder holder = (RecyclerItemViewHolder) viewHolder;
        String itemText = mItemList.get(position);
        holder.setItemText(itemText);
    }
    @Override
    public int getItemCount() {
        return mItemList == null ? 0 : mItemList.size();
    }
 
}

这是一个基本的RecyclerView.Adapter的实现,如果你想了解关于RecyclerView的更多东西,推荐阅读Mark Allison的系列文章

代码结构准备就绪,先运行来看看!

Clipped screenshot

很明显列表的最上面有部分内容被Toolbar挡住了,你应该知道是因为FrameLayout的缘故,上面提到了两种解决办法,一种是为`` `RecyclerVie`w ``添加和``````` `` `Toolbar相同高度的` ``paddingTo,但是需要注意`````` `````RecyclerView默认clipToPadding是true的,我们需要关掉,关于```` ``` `` `clipToPadding` `` ``` ````,请看这篇文章 [http://jcodecraeer.com/a/anzhuokaifa/androidkaifa/2015/0317/2613.html](https://upload-images.jcodecraeer.com/upload-images-old/a/anzhuokaifa/androidkaifa/2015/0317/2613.html) 。下面是布局代码:````` `````` ```````

<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"/>

这种实现方法没有问题,但是上面也说了,我们将使用第二种方法-可能还要复杂些(读者还是采用第一种吧,不过对于给```` ``` `` `RecyclerView` `` ``` ````添加header感兴趣可以用第二种)。

为RecyclerView添加header

首先我们需要修改一下Adapter

public class RecyclerAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
    //added view types
    private static final int TYPE_HEADER = 2;
    private static final int TYPE_ITEM = 1;
    private List<String> mItemList;
    public RecyclerAdapter(List<String> itemList) {
        mItemList = itemList;
    }
    //modified creating viewholder, so it creates appropriate holder for a given viewType
    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        Context context = parent.getContext();
        if (viewType == TYPE_ITEM) {
            final View view = LayoutInflater.from(context).inflate(R.layout.recycler_item, parent, false);
            return RecyclerItemViewHolder.newInstance(view);
        } else if (viewType == TYPE_HEADER) {
            final View view = LayoutInflater.from(context).inflate(R.layout.recycler_header, parent, false);
            return new RecyclerHeaderViewHolder(view);
        }
        throw new RuntimeException("There is no type that matches the type " + viewType + " + make sure your using types correctly");
    }
    //modifed ViewHolder binding so it binds a correct View for the Adapter
    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder viewHolder, int position) {
        if (!isPositionHeader(position)) {
            RecyclerItemViewHolder holder = (RecyclerItemViewHolder) viewHolder;
            String itemText = mItemList.get(position - 1); // we are taking header in to account so all of our items are correctly positioned
            holder.setItemText(itemText);
        }
    }
    //our old getItemCount()
    public int getBasicItemCount() {
        return mItemList == null ? 0 : mItemList.size();
    }
    //our new getItemCount() that includes header View
    @Override
    public int getItemCount() {
        return getBasicItemCount() + 1; // header
    }
    //added a method that returns viewType for a given position
    @Override
    public int getItemViewType(int position) {
        if (isPositionHeader(position)) {
            return TYPE_HEADER;
        }
        return TYPE_ITEM;
    }
    //added a method to check if given position is a header
    private boolean isPositionHeader(int position) {
        return position == 0;
    }
 
}

下面是关于上面代码的解释:

1.需要定义Recycler显示的item的类型。RecyclerView是一个非常灵活的控件,当某些item的布局和其他item有区别的时候,我们一般要用到item类型。这也正是我们这里需要的-第一个item是header,不同于其他item(代码9-4行)。

2.我们需要告诉Recycler,item想要显示的类型(49-54行)。getItemViewType方法将根据position返回一个item的类型(int类型,具体值由你自己定义)。

3.需要修改onCreateViewHolder()onBindViewHolder()方法,在item类型为TYPE_ITEM的时候绑定或者返回一个普通item,在item类型为TYPE_HEADER的时候返回或绑定一个header item(14-34行)

4.需要修改getItemCount()-在原有的基数上+1因为多了个header(43-45行)。

现在,我们为header view 创建一个布局和ViewHolder。

<View xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize"/>

布局很简单,只需注意其高度要和Toolbar一致,它的ViewHolder也很简单:

public class RecyclerHeaderViewHolder extends RecyclerView.ViewHolder {
    public RecyclerHeaderViewHolder(View itemView) {
        super(itemView);
    }
}

ok,运行结果:

Fixed clipping screenshot

顺眼多了是吧?最后我们来实现滚动时候的显示与隐藏。

只需要再多为RecyclerView创建一个类OnScrollListener

public abstract class HidingScrollListener extends RecyclerView.OnScrollListener {
    private static final int HIDE_THRESHOLD = 20;
    private int scrolledDistance = 0;
    private boolean controlsVisible = true;
    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        super.onScrolled(recyclerView, dx, dy);
        if (scrolledDistance > HIDE_THRESHOLD && controlsVisible) {
            onHide();
            controlsVisible = false;
            scrolledDistance = 0;
        } else if (scrolledDistance < -HIDE_THRESHOLD && !controlsVisible) {
            onShow();
            controlsVisible = true;
            scrolledDistance = 0;
        }
        if((controlsVisible && dy>0) || (!controlsVisible && dy<0)) {
            scrolledDistance += dy;
        }
    }
    public abstract void onHide();
    public abstract void onShow();
 
}

正如你所看到的,所有关键代码都在一个onScrolled()方法中。其dx, dy参数分别是横向和纵向的滚动距离,准确是的是两个滚动事件之间的偏移量,而不是总的滚动距离。

基本的思路如下:

1.计算出滚动的总距离(deltas相加),但是只在Toolbar隐藏且上滚或者Toolbar未隐藏且下滚的时候,因为我们只关心这两种情况。

if((controlsVisible && dy>0) || (!controlsVisible && dy<0)) {
    scrolledDistance += dy;
}

 2.如果总的滚动距离超多了一定值(这个值取决于你自己的设定,越大,需要滑动的距离越长才能显示或者隐藏),我们就根据其方向显示或者隐藏Toolbar(dy>0意味着下滚,dy<0意味着上滚)。

if (scrolledDistance > HIDE_THRESHOLD && controlsVisible) {
    onHide();
    controlsVisible = false;
    scrolledDistance = 0;
} else if (scrolledDistance < -HIDE_THRESHOLD && !controlsVisible) {
    onShow();
    controlsVisible = true;
    scrolledDistance = 0;
}

3.实际显示和隐藏的操作我们并没有定义在scroll listener类中,而是定义了两个抽象方法。

现在我们为RecyclerView添加listener:

private void initRecyclerView() {
    RecyclerView recyclerView = (RecyclerView) findViewById(R.id.recyclerView);
    recyclerView.setLayoutManager(new LinearLayoutManager(this));
    RecyclerAdapter recyclerAdapter = new RecyclerAdapter(createItemList());
    recyclerView.setAdapter(recyclerAdapter);
    //setting up our OnScrollListener
    recyclerView.setOnScrollListener(new HidingScrollListener() {
        @Override
        public void onHide() {
            hideViews();
        }
        @Override
            public void onShow() {
        showViews();
        }
    });
}

动画显示隐藏的代码如下

private void hideViews() {
    mToolbar.animate().translationY(-mToolbar.getHeight()).setInterpolator(new AccelerateInterpolator(2));
    FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) mFabButton.getLayoutParams();
    int fabBottomMargin = lp.bottomMargin;
    mFabButton.animate().translationY(mFabButton.getHeight()+fabBottomMargin).setInterpolator(new AccelerateInterpolator(2)).start();
}
 
private void showViews() {
    mToolbar.animate().translationY(0).setInterpolator(new DecelerateInterpolator(2));
    mFabButton.animate().translationY(0).setInterpolator(new DecelerateInterpolator(2)).start();
}

我们需要将margin也计算进去,不然fab不能完全隐藏。

看看效果!

Broken scrolling screenshot

基本上是正确的,但是还有点bug-如果你的滑动距离的触发值太小,在隐藏Toolbar的时候会在列表的顶部留下一段空白区域(最开始,随着滚动空白区域会消失),幸好解决起来也很简单。只需检测第一个item是否可见,只有当不可见的时候才执行上面的逻辑。

@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
    super.onScrolled(recyclerView, dx, dy);
    int firstVisibleItem = ((LinearLayoutManager) recyclerView.getLayoutManager()).findFirstVisibleItemPosition();
    //show views if first item is first visible position and views are hidden
    if (firstVisibleItem == 0) {
        if(!controlsVisible) {
            onShow();
            controlsVisible = true;
        }
    } else {
        if (scrolledDistance > HIDE_THRESHOLD && controlsVisible) {
            onHide();
            controlsVisible = false;
            scrolledDistance = 0;
        } else if (scrolledDistance < -HIDE_THRESHOLD && !controlsVisible) {
            onShow();
            controlsVisible = true;
            scrolledDistance = 0;
        }
    }
    if((controlsVisible && dy>0) || (!controlsVisible && dy<0)) {
        scrolledDistance += dy;
    }
}

再次运行

Working example screenshot

嗦嘎,貌似很完美了。

这是我第一次发布博客,如果内容显得枯燥或者有误还请原谅。如果你不想使用添加header的方式,可以试试padding的方法。在下篇文章中我将讲解如何使其和Google Play Store的效果一样。

代码

这篇文章的代码在GitHub repo.

英文原文: How to hide/show Toolbar when list is scroling (part 1)

来自:UI实验室