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

Posted

技术标签:

【中文标题】如何在 RecyclerView 中制作粘性标题? (没有外部库)【英文标题】:How can I make sticky headers in RecyclerView? (Without external lib) 【发布时间】:2015-10-05 13:35:05 【问题描述】:

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

就我而言,我不想按字母顺序进行。我有两种不同类型的视图(标题和普通)。我只想修复到顶部,最后一个标题。

【问题讨论】:

问题是关于 RecyclerView,这个 ^ lib 是基于 ListView 【参考方案1】:

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

首先,让我感谢@tim.paetz,他的帖子启发了我开始使用ItemDecorations 实现我自己的粘性标题的旅程。我在实现中借用了他的部分代码。

正如您可能已经体验过的那样,如果您尝试自己做,很难找到一个很好的解释如何 来实际使用ItemDecoration 技术来做。我的意思是,步骤是什么?其背后的逻辑是什么?我如何使标题保持在列表顶部?不知道这些问题的答案是其他人使用外部库的原因,而使用ItemDecoration 自己做却很容易。

初始条件

    您的数据集应该是不同类型项目的list(不是“Java 类型”意义上的,而是“标题/项目”类型意义上的)。 您的列表应该已经排序。 列表中的每个项目都应该属于特定类型 - 应该有一个与之相关的标题项目。 list 中的第一项必须是标题项。

在这里,我提供了我的RecyclerView.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);
 

业务逻辑

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

你没有。您不能让RecyclerView 的项目成为您选择的项目,只是停下来并坚持在上面,除非您是自定义布局的专家,并且您知道RecyclerView 的12,000 多行代码。所以,就像 UI 设计一样,如果你不能做某事,那就假装它。您只需在所有内容之上绘制标题,使用Canvas。您还应该知道用户目前可以看到哪些项目。碰巧的是,ItemDecoration 可以为您提供Canvas 和有关可见项目的信息。有了这个,以下是基本步骤:

    RecyclerView.ItemDecorationonDrawOver 方法中,获取用户可见的第一个(顶部)项目。

        View topChild = parent.getChildAt(0);
    

    确定哪个标头代表它。

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

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

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

“在一切之上绘图”的技术同样适用于此。

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

            View childInContact = getChildInContact(parent, contactPoint);
    

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

            int contactPoint = currentHeader.getBottom();
    

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

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

其余部分由我提供的一段代码中的 cmets 和详尽注释进行解释。

用法很简单:

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;

Slightly different implementation in Kotlin

【讨论】:

@Sevastyan 太棒了!我真的很喜欢你解决这个挑战的方式。没什么可说的,除了一个问题:有没有办法在“粘性标题”上设置一个 OnClickListener,或者至少消耗点击阻止用户点击它? 如果你把这个实现的适配器示例放在会很棒 if (childInContact == null) // Check if there is another child some px after the divider if(getChildInContact(parent, contactPoint + 15) != null) drawHeader(c, currentHeader); return; return; 谢谢。有趣的解决方案,但在每个滚动事件上膨胀标题视图有点昂贵。我只是更改了逻辑并使用 ViewHolder 并将它们保存在 WeakReferences 的 HashMap 中以重用已经膨胀的视图。 @Sevastyan,干得好。我有一个建议。避免每次都创建新的标题。只需保存标题并仅在更改时进行更改。 private View getHeaderViewForItem(int itemPosition, RecyclerView parent) int headerPosition = mListener.getHeaderPositionForItem(itemPosition); if(headerPosition != mCurrentHeaderIndex) mCurrentHeader = mListener.createHeaderView(headerPosition, parent); mCurrentHeaderIndex = headerPosition; return mCurrentHeader; 【参考方案2】:

最简单的方法是为您的 RecyclerView 创建一个项目装饰。

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);


recycler_section_header.xml 中您的标头的 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_
    android:layout_
    android:background="@android:color/black"
    android:paddingLeft="10dp"
    android:paddingRight="10dp"
    android:textColor="@android:color/white"
    android:textSize="14sp"
/>

最后将项目装饰添加到您的 RecyclerView:

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

【讨论】:

谢谢。这对我有用,但是这个标题与 recyclerview 重叠。你能帮忙吗? 我不确定你所说的重叠 RecyclerView 是什么意思。对于“粘性”布尔值,如果将其设置为 false,它会将项目装饰放在行之间,并且不会停留在 RecyclerView 的顶部。 将其设置为“粘性”以 false 将标题放在行之间,但这不会停留在顶部(我不想要)。将其设置为 true 时,它​​停留在顶部,但与 recyclerview 中的第一行重叠 我可以看到潜在的两个问题,一个是部分回调,您没有将 isSection 的第一项(0 位置)设置为 true。另一个是你通过错误的高度。文本视图的 xml 高度必须与您传递给节项装饰的构造函数的高度相同。 我要补充的一点是,如果您的标题布局具有动态调整大小的标题文本视图(例如wrap_content),您还需要在设置标题文本后运行fixLayoutSize .【参考方案3】:

我对上面的 Sevastyan 的解决方案做了我自己的变体

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
    ... */

因此,在这种情况下,标题不仅仅是在画布上绘制,而是带有选择器或波纹、点击侦听器等的视图。

【讨论】:

感谢分享!为什么你最终将 RecyclerView 包装在一个新的 RelativeLayout 中? 因为我的sticky header版本是View,我把它放到RecyclerView上面这个RelativeLayout中(在headerContainer字段中) 你能在类文件中展示你的实现吗?您如何传递在适配器中实现的侦听器对象。 recyclerView.addItemDecoration(HeaderItemDecoration(recyclerView, adapter))。抱歉,找不到我使用的实现示例。我已经编辑了答案 - 向 cmets 添加了一些文本【参考方案4】:

当您已经拥有DividerItemDecoration 时,任何寻求解决闪烁/闪烁问题的人。我似乎已经这样解决了:

override fun onDrawOver(...)
    
        //code from before

       //do NOT return on null
        val childInContact = getChildInContact(recyclerView, currentHeader.bottom)
        //add null check
        if (childInContact != null && mHeaderListener.isHeader(recyclerView.getChildAdapterPosition(childInContact)))
        
            moveHeader(...)
            return
        
    drawHeader(...)

这似乎有效,但谁能确认我没有破坏其他任何东西?

【讨论】:

谢谢,它也为我解决了闪烁的问题。【参考方案5】:

哟,

如果您只需要一种类型的支架棒,当它开始离开屏幕时,您就是这样做的(我们不关心任何部分)。只有一种方法不会破坏回收项目的内部 RecyclerView 逻辑,那就是在 recyclerView 的标题项目之上扩展额外的视图并将数据传递给它。我让代码说话。

import android.graphics.Canvas
import android.graphics.Rect
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.LayoutRes
import androidx.recyclerview.widget.RecyclerView

class StickyHeaderItemDecoration(@LayoutRes private val headerId: Int, private val HEADER_TYPE: Int) : RecyclerView.ItemDecoration() 

private lateinit var stickyHeaderView: View
private lateinit var headerView: View

private var sticked = false

// executes on each bind and sets the stickyHeaderView
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) 
    super.getItemOffsets(outRect, view, parent, state)

    val position = parent.getChildAdapterPosition(view)

    val adapter = parent.adapter ?: return
    val viewType = adapter.getItemViewType(position)

    if (viewType == HEADER_TYPE) 
        headerView = view
    


override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) 
    super.onDrawOver(c, parent, state)
    if (::headerView.isInitialized) 

        if (headerView.y <= 0 && !sticked) 
            stickyHeaderView = createHeaderView(parent)
            fixLayoutSize(parent, stickyHeaderView)
            sticked = true
        

        if (headerView.y > 0 && sticked) 
            sticked = false
        

        if (sticked) 
            drawStickedHeader(c)
        
    


private fun createHeaderView(parent: RecyclerView) = LayoutInflater.from(parent.context).inflate(headerId, parent, false)

private fun drawStickedHeader(c: Canvas) 
    c.save()
    c.translate(0f, Math.max(0f, stickyHeaderView.top.toFloat() - stickyHeaderView.height.toFloat()))
    headerView.draw(c)
    c.restore()


private fun fixLayoutSize(parent: ViewGroup, view: View) 

    // Specs for parent (RecyclerView)
    val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
    val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)

    // Specs for children (headers)
    val childWidthSpec = ViewGroup.getChildMeasureSpec(widthSpec, parent.paddingLeft + parent.paddingRight, view.getLayoutParams().width)
    val childHeightSpec = ViewGroup.getChildMeasureSpec(heightSpec, parent.paddingTop + parent.paddingBottom, view.getLayoutParams().height)

    view.measure(childWidthSpec, childHeightSpec)

    view.layout(0, 0, view.measuredWidth, view.measuredHeight)



然后您只需在适配器中执行此操作:

override fun onAttachedToRecyclerView(recyclerView: RecyclerView) 
    super.onAttachedToRecyclerView(recyclerView)
    recyclerView.addItemDecoration(StickyHeaderItemDecoration(R.layout.item_time_filter, YOUR_STICKY_VIEW_HOLDER_TYPE))

YOUR_STICKY_VIEW_HOLDER_TYPE 是您应该是粘性持有者的 viewType。

【讨论】:

【参考方案6】:

您可以在我的FlexibleAdapter 项目中检查并获取类StickyHeaderHelper 的实现,并根据您的用例进行调整。

但是,我建议使用该库,因为它简化并重新组织了您通常为 RecyclerView 实现适配器的方式:不要重新发明***。

我还要说,不要使用装饰器或已弃用的库,也不要使用只做 1 或 3 件事情的库,您必须自己合并其他库的实现。

【讨论】:

我花了 2 天时间阅读 wiki 和示例,但仍然不知道如何使用您的 lib 创建可折叠列表。该示例对于新手来说相当复杂 你为什么反对使用Decorators? @Sevastyan,因为我们将到达需要点击监听器的点,以及子视图。我们装饰你根本不能定义。 @Davidea,你的意思是你以后想在标题上设置点击监听器吗?如果是这样,那是有道理的。但是,如果您将标题作为数据集项提供,则不会有任何问题。甚至 Yigit Boyar 也推荐使用装饰器。 @Sevastyan,是的,在我的库中,标题与列表中的其他项一样,因此用户可以对其进行操作。在遥远的将来,自定义布局管理器将取代当前的帮助器。【参考方案7】:

另一种解决方案,基于滚动侦听器。初始条件与Sevastyan answer中相同

RecyclerView recyclerView;
TextView tvTitle; //sticky header view

//... onCreate, initialize, etc...

public void bindList(List<Item> items)  //All data in adapter. Item - just interface for different item types
    adapter = new YourAdapter(items);
    recyclerView.setAdapter(adapter);
    StickyHeaderViewManager<HeaderItem> stickyHeaderViewManager = new StickyHeaderViewManager<>(
            tvTitle,
            recyclerView,
            HeaderItem.class, //HeaderItem - subclass of Item, used to detect headers in list
            data ->  // bind function for sticky header view
                tvTitle.setText(data.getTitle());
            );
    stickyHeaderViewManager.attach(items);

ViewHolder 和粘性标题的布局。

item_header.xml

<TextView xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/tv_title"
    android:layout_
    android:layout_/>

RecyclerView 的布局

<FrameLayout
    android:layout_
    android:layout_>

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_
        android:layout_/>

    <!--it can be any view, but order important, draw over recyclerView-->
    <include
        layout="@layout/item_header"/>

</FrameLayout>

HeaderItem 的类。

public class HeaderItem implements Item 

    private String title;

    public HeaderItem(String title) 
        this.title = title;
    

    public String getTitle() 
        return title;
    


都是有用的。适配器、ViewHolder 和其他东西的实现对我们来说并不有趣。

public class StickyHeaderViewManager<T> 

    @Nonnull
    private View headerView;

    @Nonnull
    private RecyclerView recyclerView;

    @Nonnull
    private StickyHeaderViewWrapper<T> viewWrapper;

    @Nonnull
    private Class<T> headerDataClass;

    private List<?> items;

    public StickyHeaderViewManager(@Nonnull View headerView,
                                   @Nonnull RecyclerView recyclerView,
                                   @Nonnull Class<T> headerDataClass,
                                   @Nonnull StickyHeaderViewWrapper<T> viewWrapper) 
        this.headerView = headerView;
        this.viewWrapper = viewWrapper;
        this.recyclerView = recyclerView;
        this.headerDataClass = headerDataClass;
    

    public void attach(@Nonnull List<?> items) 
        this.items = items;
        if (ViewCompat.isLaidOut(headerView)) 
            bindHeader(recyclerView);
         else 
            headerView.post(() -> bindHeader(recyclerView));
        

        recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() 

            @Override
            public void onScrolled(RecyclerView recyclerView, int dx, int dy) 
                super.onScrolled(recyclerView, dx, dy);
                bindHeader(recyclerView);
            
        );
    

    private void bindHeader(RecyclerView recyclerView) 
        if (items.isEmpty()) 
            headerView.setVisibility(View.GONE);
            return;
         else 
            headerView.setVisibility(View.VISIBLE);
        

        View topView = recyclerView.getChildAt(0);
        if (topView == null) 
            return;
        
        int topPosition = recyclerView.getChildAdapterPosition(topView);
        if (!isValidPosition(topPosition)) 
            return;
        
        if (topPosition == 0 && topView.getTop() == recyclerView.getTop()) 
            headerView.setVisibility(View.GONE);
            return;
         else 
            headerView.setVisibility(View.VISIBLE);
        

        T stickyItem;
        Object firstItem = items.get(topPosition);
        if (headerDataClass.isInstance(firstItem)) 
            stickyItem = headerDataClass.cast(firstItem);
            headerView.setTranslationY(0);
         else 
            stickyItem = findNearestHeader(topPosition);
            int secondPosition = topPosition + 1;
            if (isValidPosition(secondPosition)) 
                Object secondItem = items.get(secondPosition);
                if (headerDataClass.isInstance(secondItem)) 
                    View secondView = recyclerView.getChildAt(1);
                    if (secondView != null) 
                        moveViewFor(secondView);
                    
                 else 
                    headerView.setTranslationY(0);
                
            
        

        if (stickyItem != null) 
            viewWrapper.bindView(stickyItem);
        
    

    private void moveViewFor(View secondView) 
        if (secondView.getTop() <= headerView.getBottom()) 
            headerView.setTranslationY(secondView.getTop() - headerView.getHeight());
         else 
            headerView.setTranslationY(0);
        
    

    private T findNearestHeader(int position) 
        for (int i = position; position >= 0; i--) 
            Object item = items.get(i);
            if (headerDataClass.isInstance(item)) 
                return headerDataClass.cast(item);
            
        
        return null;
    

    private boolean isValidPosition(int position) 
        return !(position == RecyclerView.NO_POSITION || position >= items.size());
    

绑定标题视图的接口。

public interface StickyHeaderViewWrapper<T> 

    void bindView(T data);

【讨论】:

我喜欢这个解决方案。 findNearestHeader 中的小错字:for (int i = position; position &gt;= 0; i--) //should be i &gt;= 0【参考方案8】:

对于那些可能关心的人。根据 Sevastyan 的回答,您是否想让它水平滚动。 只需将所有getBottom() 更改为getRight() 并将getTop() 更改为getLeft()

【讨论】:

【参考方案9】:

您可以通过copying these 2 files into your project 获得粘性标题功能。我对这个实现没有任何问题:

可以与 sticy 标题交互(点击/长按/滑动) 粘性标题隐藏并正确显示...即使每个视图持有者具有不同的高度(这里的一些其他答案没有正确处理,导致显示错误的标题,或者标题上下跳跃)

see an example of the 2 files being used in this small github project i whipped up

【讨论】:

【参考方案10】:

如果您希望标题位于您的 recyclerview 项目旁边,像这样 然后使用相同的代码here 并将这两行添加到onDrawOver

//hide the image and the name, and draw only the alphabet
        val headerView = getHeaderViewForItem(topChildPosition, parent) ?: return
        headerView.findViewById<ShapeableImageView>(R.id.contactImageView).isVisible = false
        headerView.findViewById<TextView>(R.id.nameTextView).isVisible = false

在这里,您基本上是在重新绘制 recyclerview 项目,但隐藏了右侧的所有元素。 如果你想知道如何创建这样的 recyclerview 项目,那么这里是如何: 然后您将创建如下数据列表:

class ContactRecyclerDataItem(val contact: SimpleContact, val alphabet: String? = null)

这样当您收到数据列表时,您可以构建 ContactRecyclerDataItem 列表

这边

list?.let 
            val adapterDataList = mutableListOf<ContactRecyclerDataItem>()
            if (it.isNotEmpty()) 
                var prevChar = (it[0].name[0].code + 1).toChar()
                it.forEach  contact ->
                    if (contact.name[0] != prevChar) 
                        prevChar = contact.name[0]
                        adapterDataList.add(ContactRecyclerDataItem(contact, prevChar.toString()))
                     else 
                        adapterDataList.add(ContactRecyclerDataItem(contact))
                    
                
            
            contactsAdapter.data = adapterDataList
        

然后在viewHolder 内的回收器适配器中检查字母表是否为空,

        if (itemRecycler.alphabet != null) 
            alphabetTextView.text = itemRecycler.alphabet
         else 
            alphabetTextView.text = ""
        

最后你用左边的字母构建这个rec​​yclerview,但是为了让它们变得粘稠,你膨胀并移动第一个元素,它是标题一直到下一个标题,上面提到的技巧是隐藏所有除了字母表之外,您的 recyclerview 项目中的其他元素。 要使第一个元素可点击,您应该在 itemDecorat 中返回 false 在init block 里面parent.addOnItemTouchListene 返回 false 时,您将单击侦听器传递给波纹管视图,在这种情况下,这是您可见的 recyclerview 项目。

【讨论】:

【参考方案11】:

答案已经在这里了。如果您不想使用任何库,可以按照以下步骤操作:

    按名称对数据进行排序 使用数据通过列表迭代,并在当前项目的第一个字母!= 下一个项目的第一个字母时,插入“特殊”类型的对象。 当项目为“特殊”时,在适配器中放置特殊视图。

解释:

onCreateViewHolder 方法中,我们可以检查viewType 并根据值(我们的“特殊”类型)膨胀特殊布局。

例如:

public static final int TITLE = 0;
public static final int ITEM = 1;

@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) 
    if (context == null) 
        context = parent.getContext();
    
    if (viewType == TITLE) 
        view = LayoutInflater.from(context).inflate(R.layout.recycler_adapter_title, parent,false);
        return new TitleElement(view);
     else if (viewType == ITEM) 
        view = LayoutInflater.from(context).inflate(R.layout.recycler_adapter_item, parent,false);
        return new ItemElement(view);
    
    return null;

class ItemElementclass TitleElement 看起来像普通的ViewHolder

public class ItemElement extends RecyclerView.ViewHolder 
//TextView text;

public ItemElement(View view) 
    super(view);
   //text = (TextView) view.findViewById(R.id.text);


所以所有这些的想法都很有趣。但我很感兴趣它是否有效,因为我们需要对数据列表进行排序。我认为这会降低速度。如果有任何想法,请写信给我:)

还有一个开放的问题:如何在物品回收的同时将“特殊”布局保持在顶部。也许将所有这些与CoordinatorLayout 结合起来。

【讨论】:

可以用cursoradapter制作吗 这个解决方案没有说明任何关于 STICKY 标头的内容,这是本文的重点

以上是关于如何在 RecyclerView 中制作粘性标题? (没有外部库)的主要内容,如果未能解决你的问题,请参考以下文章

如何在反应中制作粘性页脚?

如何在 IE11 中使用 flexbox 制作粘性页脚?

如何在顶部 UICollectionView 上制作粘性标题

如何制作一个只在android屏幕上浮动的粘性列表项?

如何使用 CSS 制作粘性页脚?

如何从 Assets 文件夹中解析 StickyHeader RecyclerView 中的 json 数据