提问者:小点点

如何在RecyclerView中制作粘性标头?(没有外部库)


我想在屏幕顶部修复我的标题视图,如下图所示,而不使用外部库。

在我的例子中,我不想按字母顺序来做。我有两种不同类型的视图(标题和普通视图)。我只想固定到顶部,最后一个标题。


共3个答案

匿名用户

在这里我将解释如何在没有外部库的情况下做到这一点。这将是一个很长的帖子,所以请做好准备。

首先,让我感谢@timz. paetz,他的帖子激励我开始了使用ItemDecoration实现我自己的粘性标题的旅程。我在我的实现中借用了他的一些代码。

正如你可能已经经历过的那样,如果你试图自己做,很难找到一个很好的解释如何使用ItemDecoration技术实际做这件事。我的意思是,步骤是什么?它背后的逻辑是什么?我如何让标题粘在列表的顶部?不知道这些问题的答案是什么让别人使用外部库,而自己使用ItemDecoration做这件事是相当容易的。

初始条件

  1. 数据集应该是不同类型项的列表(不是Java类型,而是标题/项类型)。
  2. 您的列表应该已经排序。
  3. 列表中的每个项目都应该是特定类型的-应该有一个与之相关的标题项。
  4. 列表中的第一项必须是标题项。

在这里,我为我的RecycliView. ItemDecoration提供了完整的代码,称为HeaderItemDecoration。然后我详细解释了所采取的步骤。

public class HeaderItemDecoration extends RecyclerView.ItemDecoration {

 private StickyHeaderInterface mListener;
 private int mStickyHeaderHeight;

 public HeaderItemDecoration(RecyclerView recyclerView, @NonNull StickyHeaderInterface listener) {
  mListener = listener;

  // On Sticky Header Click
  recyclerView.addOnItemTouchListener(new RecyclerView.OnItemTouchListener() {
   public boolean onInterceptTouchEvent(RecyclerView recyclerView, MotionEvent motionEvent) {
    if (motionEvent.getY() <= mStickyHeaderHeight) {
     // Handle the clicks on the header here ...
     return true;
    }
    return false;
   }

   public void onTouchEvent(RecyclerView recyclerView, MotionEvent motionEvent) {

   }

   public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {

   }
  });
 }

 @Override
 public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
  super.onDrawOver(c, parent, state);

  View topChild = parent.getChildAt(0);
  if (Util.isNull(topChild)) {
   return;
  }

  int topChildPosition = parent.getChildAdapterPosition(topChild);
  if (topChildPosition == RecyclerView.NO_POSITION) {
   return;
  }

  View currentHeader = getHeaderViewForItem(topChildPosition, parent);
  fixLayoutSize(parent, currentHeader);
  int contactPoint = currentHeader.getBottom();
  View childInContact = getChildInContact(parent, contactPoint);
  if (Util.isNull(childInContact)) {
   return;
  }

  if (mListener.isHeader(parent.getChildAdapterPosition(childInContact))) {
   moveHeader(c, currentHeader, childInContact);
   return;
  }

  drawHeader(c, currentHeader);
 }

 private View getHeaderViewForItem(int itemPosition, RecyclerView parent) {
  int headerPosition = mListener.getHeaderPositionForItem(itemPosition);
  int layoutResId = mListener.getHeaderLayout(headerPosition);
  View header = LayoutInflater.from(parent.getContext()).inflate(layoutResId, parent, false);
  mListener.bindHeaderData(header, headerPosition);
  return header;
 }

 private void drawHeader(Canvas c, View header) {
  c.save();
  c.translate(0, 0);
  header.draw(c);
  c.restore();
 }

 private void moveHeader(Canvas c, View currentHeader, View nextHeader) {
  c.save();
  c.translate(0, nextHeader.getTop() - currentHeader.getHeight());
  currentHeader.draw(c);
  c.restore();
 }

 private View getChildInContact(RecyclerView parent, int contactPoint) {
  View childInContact = null;
  for (int i = 0; i < parent.getChildCount(); i++) {
   View child = parent.getChildAt(i);
   if (child.getBottom() > contactPoint) {
    if (child.getTop() <= contactPoint) {
     // This child overlaps the contactPoint
     childInContact = child;
     break;
    }
   }
  }
  return childInContact;
 }

 /**
  * Properly measures and layouts the top sticky header.
  * @param parent ViewGroup: RecyclerView in this case.
  */
 private void fixLayoutSize(ViewGroup parent, View view) {

  // Specs for parent (RecyclerView)
  int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY);
  int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(), View.MeasureSpec.UNSPECIFIED);

  // Specs for children (headers)
  int childWidthSpec = ViewGroup.getChildMeasureSpec(widthSpec, parent.getPaddingLeft() + parent.getPaddingRight(), view.getLayoutParams().width);
  int childHeightSpec = ViewGroup.getChildMeasureSpec(heightSpec, parent.getPaddingTop() + parent.getPaddingBottom(), view.getLayoutParams().height);

  view.measure(childWidthSpec, childHeightSpec);

  view.layout(0, 0, view.getMeasuredWidth(), mStickyHeaderHeight = view.getMeasuredHeight());
 }

 public interface StickyHeaderInterface {

  /**
   * This method gets called by {@link HeaderItemDecoration} to fetch the position of the header item in the adapter
   * that is used for (represents) item at specified position.
   * @param itemPosition int. Adapter's position of the item for which to do the search of the position of the header item.
   * @return int. Position of the header item in the adapter.
   */
  int getHeaderPositionForItem(int itemPosition);

  /**
   * This method gets called by {@link HeaderItemDecoration} to get layout resource id for the header item at specified adapter's position.
   * @param headerPosition int. Position of the header item in the adapter.
   * @return int. Layout resource id.
   */
  int getHeaderLayout(int headerPosition);

  /**
   * This method gets called by {@link HeaderItemDecoration} to setup the header View.
   * @param header View. Header to set the data on.
   * @param headerPosition int. Position of the header item in the adapter.
   */
  void bindHeaderData(View header, int headerPosition);

  /**
   * This method gets called by {@link HeaderItemDecoration} to verify whether the item represents a header.
   * @param itemPosition int.
   * @return true, if item at the specified adapter's position represents a header.
   */
  boolean isHeader(int itemPosition);
 }
}

业务逻辑

那么,我如何让它坚持下去?

你不知道。你不能让一个你选择的回收视图的项目停下来贴在上面,除非你是一个自定义布局的大师,你对一个回收视图的12,000行代码烂熟于心。所以,因为它总是与UI设计相结合,如果你不能做一些东西,伪造它。你只需要用画布在所有东西的顶部画标题。你也应该知道用户现在可以看到哪些项目。碰巧的是,ItemDecoration可以为你提供画布和关于可见项目的信息。有了这个,这里是基本的步骤:

>

  • onDrawover方法中,Recycliview. ItemDecoration获取用户可见的第一个(顶部)项目。

        View topChild = parent.getChildAt(0);
    

    确定哪个标头代表它。

            int topChildPosition = parent.getChildAdapterPosition(topChild);
        View currentHeader = getHeaderViewForItem(topChildPosition, parent);
    

    使用drathHeader()方法在RecyclerView顶部绘制适当的标头。

    我还想实现当新的即将到来的标题与顶部标题相遇时的行为:它应该看起来像即将到来的标题轻轻地将当前顶部标题推出视图并最终取代他的位置。

    同样的“在一切之上绘图”的技巧也适用于这里。

    >

  • 确定顶部“卡住”的标题何时遇到新的即将到来的标题。

            View childInContact = getChildInContact(parent, contactPoint);
    

    获取此接触点(即您绘制的粘性标题的底部和即将到来的标题的顶部)。

            int contactPoint = currentHeader.getBottom();
    

    如果列表中的项目侵入了这个“接触点”,请重新绘制您的粘性标题,使其底部位于侵入项目的顶部。您可以使用Canvas翻译()方法来实现这一点。结果,顶部标题的起点将超出可见区域,它将看起来像“被即将到来的标题推出”。当它完全消失时,在顶部绘制新的标题。

            if (childInContact != null) {
            if (mListener.isHeader(parent.getChildAdapterPosition(childInContact))) {
                moveHeader(c, currentHeader, childInContact);
            } else {
                drawHeader(c, currentHeader);
            }
        }
    

    其余的由我提供的一段代码中的注释和彻底的注释来解释。

    用法很简单:

    mRecyclerView.addItemDecoration(new HeaderItemDecoration((HeaderItemDecoration.StickyHeaderInterface) mAdapter));
    

    您的mAdapter必须实现StickyHeaderInterface才能正常工作。实现取决于您拥有的数据。

    最后,在这里我提供了一个带有半透明标题的gif,这样您就可以掌握这个想法并实际看到幕后发生了什么。

    这是“在一切之上绘制”概念的说明。你可以看到有两个项目“标题1”——一个是我们绘制并停留在顶部的卡住位置,另一个来自数据集并与所有其余项目一起移动。用户不会看到它的内部工作原理,因为你不会有半透明的标题。

    这里是“推出”阶段发生的事情:

    希望有帮助。

    编辑

    这是我在RecyclerView的适配器中对getHeaderPositionForItem()方法的实际实现:

    @Override
    public int getHeaderPositionForItem(int itemPosition) {
        int headerPosition = 0;
        do {
            if (this.isHeader(itemPosition)) {
                headerPosition = itemPosition;
                break;
            }
            itemPosition -= 1;
        } while (itemPosition >= 0);
        return headerPosition;
    }
    

    静态编程语言中略有不同的实现

  • 匿名用户

    最简单的方法是创建一个项目装饰为您的回收视图。

    import android.graphics.Canvas;
    import android.graphics.Rect;
    import android.support.annotation.NonNull;
    import android.support.v7.widget.RecyclerView;
    import android.view.LayoutInflater;
    import android.view.View;
    import android.view.ViewGroup;
    import android.widget.TextView;
    
    public class RecyclerSectionItemDecoration extends RecyclerView.ItemDecoration {
    
    private final int             headerOffset;
    private final boolean         sticky;
    private final SectionCallback sectionCallback;
    
    private View     headerView;
    private TextView header;
    
    public RecyclerSectionItemDecoration(int headerHeight, boolean sticky, @NonNull SectionCallback sectionCallback) {
        headerOffset = headerHeight;
        this.sticky = sticky;
        this.sectionCallback = sectionCallback;
    }
    
    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
    
        int pos = parent.getChildAdapterPosition(view);
        if (sectionCallback.isSection(pos)) {
            outRect.top = headerOffset;
        }
    }
    
    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c,
                         parent,
                         state);
    
        if (headerView == null) {
            headerView = inflateHeaderView(parent);
            header = (TextView) headerView.findViewById(R.id.list_item_section_text);
            fixLayoutSize(headerView,
                          parent);
        }
    
        CharSequence previousHeader = "";
        for (int i = 0; i < parent.getChildCount(); i++) {
            View child = parent.getChildAt(i);
            final int position = parent.getChildAdapterPosition(child);
    
            CharSequence title = sectionCallback.getSectionHeader(position);
            header.setText(title);
            if (!previousHeader.equals(title) || sectionCallback.isSection(position)) {
                drawHeader(c,
                           child,
                           headerView);
                previousHeader = title;
            }
        }
    }
    
    private void drawHeader(Canvas c, View child, View headerView) {
        c.save();
        if (sticky) {
            c.translate(0,
                        Math.max(0,
                                 child.getTop() - headerView.getHeight()));
        } else {
            c.translate(0,
                        child.getTop() - headerView.getHeight());
        }
        headerView.draw(c);
        c.restore();
    }
    
    private View inflateHeaderView(RecyclerView parent) {
        return LayoutInflater.from(parent.getContext())
                             .inflate(R.layout.recycler_section_header,
                                      parent,
                                      false);
    }
    
    /**
     * Measures the header view to make sure its size is greater than 0 and will be drawn
     * https://yoda.entelect.co.za/view/9627/how-to-android-recyclerview-item-decorations
     */
    private void fixLayoutSize(View view, ViewGroup parent) {
        int widthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth(),
                                                         View.MeasureSpec.EXACTLY);
        int heightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight(),
                                                          View.MeasureSpec.UNSPECIFIED);
    
        int childWidth = ViewGroup.getChildMeasureSpec(widthSpec,
                                                       parent.getPaddingLeft() + parent.getPaddingRight(),
                                                       view.getLayoutParams().width);
        int childHeight = ViewGroup.getChildMeasureSpec(heightSpec,
                                                        parent.getPaddingTop() + parent.getPaddingBottom(),
                                                        view.getLayoutParams().height);
    
        view.measure(childWidth,
                     childHeight);
    
        view.layout(0,
                    0,
                    view.getMeasuredWidth(),
                    view.getMeasuredHeight());
    }
    
    public interface SectionCallback {
    
        boolean isSection(int position);
    
        CharSequence getSectionHeader(int position);
    }
    }
    

    XMLrecycler_section_header. xml中的标题:

    <?xml version="1.0" encoding="utf-8"?>
    <TextView xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/list_item_section_text"
        android:layout_width="match_parent"
        android:layout_height="@dimen/recycler_section_header_height"
        android:background="@android:color/black"
        android:paddingLeft="10dp"
        android:paddingRight="10dp"
        android:textColor="@android:color/white"
        android:textSize="14sp"
    />
    

    最后,要将项目装饰添加到您的回收视图:

    RecyclerSectionItemDecoration sectionItemDecoration =
            new RecyclerSectionItemDecoration(getResources().getDimensionPixelSize(R.dimen.recycler_section_header_height),
                                              true, // true for sticky, false for not
                                              new RecyclerSectionItemDecoration.SectionCallback() {
                                                  @Override
                                                  public boolean isSection(int position) {
                                                      return position == 0
                                                          || people.get(position)
                                                                   .getLastName()
                                                                   .charAt(0) != people.get(position - 1)
                                                                                       .getLastName()
                                                                                       .charAt(0);
                                                  }
    
                                                  @Override
                                                  public CharSequence getSectionHeader(int position) {
                                                      return people.get(position)
                                                                   .getLastName()
                                                                   .subSequence(0,
                                                                                1);
                                                  }
                                              });
        recyclerView.addItemDecoration(sectionItemDecoration);
    

    使用此项目装饰,您可以在创建项目装饰时使用布尔值固定/粘贴或不固定标题。

    你可以在github上找到一个完整的工作示例:https://github.com/paetztm/recycler_view_headers

    匿名用户

    我对上面的塞瓦斯蒂安的解决方案做了自己的变体

    class HeaderItemDecoration(recyclerView: RecyclerView, private val listener: StickyHeaderInterface) : RecyclerView.ItemDecoration() {
    
    private val headerContainer = FrameLayout(recyclerView.context)
    private var stickyHeaderHeight: Int = 0
    private var currentHeader: View? = null
    private var currentHeaderPosition = 0
    
    init {
        val layout = RelativeLayout(recyclerView.context)
        val params = recyclerView.layoutParams
        val parent = recyclerView.parent as ViewGroup
        val index = parent.indexOfChild(recyclerView)
        parent.addView(layout, index, params)
        parent.removeView(recyclerView)
        layout.addView(recyclerView, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
        layout.addView(headerContainer, LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
    }
    
    override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
        super.onDrawOver(c, parent, state)
    
        val topChild = parent.getChildAt(0) ?: return
    
        val topChildPosition = parent.getChildAdapterPosition(topChild)
        if (topChildPosition == RecyclerView.NO_POSITION) {
            return
        }
    
        val currentHeader = getHeaderViewForItem(topChildPosition, parent)
        fixLayoutSize(parent, currentHeader)
        val contactPoint = currentHeader.bottom
        val childInContact = getChildInContact(parent, contactPoint) ?: return
    
        val nextPosition = parent.getChildAdapterPosition(childInContact)
        if (listener.isHeader(nextPosition)) {
            moveHeader(currentHeader, childInContact, topChildPosition, nextPosition)
            return
        }
    
        drawHeader(currentHeader, topChildPosition)
    }
    
    private fun getHeaderViewForItem(itemPosition: Int, parent: RecyclerView): View {
        val headerPosition = listener.getHeaderPositionForItem(itemPosition)
        val layoutResId = listener.getHeaderLayout(headerPosition)
        val header = LayoutInflater.from(parent.context).inflate(layoutResId, parent, false)
        listener.bindHeaderData(header, headerPosition)
        return header
    }
    
    private fun drawHeader(header: View, position: Int) {
        headerContainer.layoutParams.height = stickyHeaderHeight
        setCurrentHeader(header, position)
    }
    
    private fun moveHeader(currentHead: View, nextHead: View, currentPos: Int, nextPos: Int) {
        val marginTop = nextHead.top - currentHead.height
        if (currentHeaderPosition == nextPos && currentPos != nextPos) setCurrentHeader(currentHead, currentPos)
    
        val params = currentHeader?.layoutParams as? MarginLayoutParams ?: return
        params.setMargins(0, marginTop, 0, 0)
        currentHeader?.layoutParams = params
    
        headerContainer.layoutParams.height = stickyHeaderHeight + marginTop
    }
    
    private fun setCurrentHeader(header: View, position: Int) {
        currentHeader = header
        currentHeaderPosition = position
        headerContainer.removeAllViews()
        headerContainer.addView(currentHeader)
    }
    
    private fun getChildInContact(parent: RecyclerView, contactPoint: Int): View? =
            (0 until parent.childCount)
                .map { parent.getChildAt(it) }
                .firstOrNull { it.bottom > contactPoint && it.top <= contactPoint }
    
    private fun fixLayoutSize(parent: ViewGroup, view: View) {
    
        val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
        val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)
    
        val childWidthSpec = ViewGroup.getChildMeasureSpec(widthSpec,
                parent.paddingLeft + parent.paddingRight,
                view.layoutParams.width)
        val childHeightSpec = ViewGroup.getChildMeasureSpec(heightSpec,
                parent.paddingTop + parent.paddingBottom,
                view.layoutParams.height)
    
        view.measure(childWidthSpec, childHeightSpec)
    
        stickyHeaderHeight = view.measuredHeight
        view.layout(0, 0, view.measuredWidth, stickyHeaderHeight)
    }
    
    interface StickyHeaderInterface {
    
        fun getHeaderPositionForItem(itemPosition: Int): Int
    
        fun getHeaderLayout(headerPosition: Int): Int
    
        fun bindHeaderData(header: View, headerPosition: Int)
    
        fun isHeader(itemPosition: Int): Boolean
    }
    }
    

    …这是StickyHeaderInterface的实现(我直接在回收器适配器中完成):

    override fun getHeaderPositionForItem(itemPosition: Int): Int =
        (itemPosition downTo 0)
            .map { Pair(isHeader(it), it) }
            .firstOrNull { it.first }?.second ?: RecyclerView.NO_POSITION
    
    override fun getHeaderLayout(headerPosition: Int): Int {
        /* ... 
          return something like R.layout.view_header
          or add conditions if you have different headers on different positions
        ... */
    }
    
    override fun bindHeaderData(header: View, headerPosition: Int) {
        if (headerPosition == RecyclerView.NO_POSITION) header.layoutParams.height = 0
        else /* ...
          here you get your header and can change some data on it
        ... */
    }
    
    override fun isHeader(itemPosition: Int): Boolean {
        /* ...
          here have to be condition for checking - is item on this position header
        ... */
    }
    

    因此,在这种情况下,标题不仅仅是在画布上绘制,而是使用选择器或波纹、clicklistener等查看。