Android实现RecyclerView粘性头部效果,模拟微信账单列表的月份标题平移

效果链接

https://live.csdn.net/v/494980

1、在res/values/colors.xml中添加:

java 复制代码
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="green">#07C160</color>
    <color name="red">#FF3B30</color>
</resources>

2、月份标题布局 (item_header.xml)

java 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="48dp"
    android:background="#F5F5F5"
    android:orientation="vertical"
    android:paddingLeft="16dp">

    <TextView
        android:id="@+id/tv_month"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center_vertical"
        android:textColor="#000000"
        android:textSize="16sp" />
</LinearLayout>

3、主布局 (activity_main.xml)

java 复制代码
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipToPadding="false" />

</RelativeLayout>

4、账单项布局 (item_bill.xml)

java 复制代码
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="#FFFFFF"
    android:orientation="vertical"
    android:padding="16dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/tv_date"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:textColor="#888888"
            android:textSize="14sp" />

        <TextView
            android:id="@+id/tv_amount"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="16sp" />
    </LinearLayout>

    <TextView
        android:id="@+id/tv_description"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:textColor="#000000"
        android:textSize="16sp" />
</LinearLayout>

5、自定义StickyHeaderDecoration 类

java 复制代码
/**
 * @author: 魏
 * @date: 2025/9/29
 * 自定义StickyHeaderDecoration类
 */
public class StickyHeaderDecoration extends RecyclerView.ItemDecoration {
    private StickyHeaderListener listener;
    private View currentStickyView;
    private int currentStickyPosition = RecyclerView.NO_POSITION;
    private int lastTranslateY = 0;

    public StickyHeaderDecoration(StickyHeaderListener listener) {
        this.listener = listener;
    }

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

        // 查找当前需要置顶的月份标题位置
        int topChildPosition = findTopVisibleItemPosition(parent);
        if (topChildPosition == RecyclerView.NO_POSITION) {
            return;
        }

        // 获取当前月份标题位置
        int headerPosition = listener.getHeaderPositionForItem(topChildPosition);
        if (headerPosition == RecyclerView.NO_POSITION) {
            return;
        }

        // 获取或创建月份标题视图
        if (currentStickyPosition != headerPosition || currentStickyView == null) {
            currentStickyView = listener.getHeaderView(parent, headerPosition);
            measureHeaderView(currentStickyView, parent);
            currentStickyPosition = headerPosition;
        }

        // 计算标题绘制位置
        int nextHeaderPosition = findNextHeaderPosition(parent, topChildPosition);
        int translateY = 0;

        if (nextHeaderPosition != RecyclerView.NO_POSITION) {
            View nextHeader = parent.getChildAt(nextHeaderPosition - topChildPosition);
            if (nextHeader != null) {
                int bottom = currentStickyView.getBottom();
                int nextHeaderTop = nextHeader.getTop();
                // 当两个标题相遇时的动画效果
                if (nextHeaderTop < bottom) {
                    translateY = nextHeaderTop - bottom;
                }
            }
        }

        // 平滑过渡动画
        if (Math.abs(translateY - lastTranslateY) > 1) {
            ValueAnimator animator = ValueAnimator.ofInt(lastTranslateY, translateY);
            animator.setDuration(200);
            animator.setInterpolator(new DecelerateInterpolator());
            animator.addUpdateListener(animation -> {
                parent.invalidate();
            });
            animator.start();
            lastTranslateY = translateY;
        }

        // 绘制月份标题
        c.save();
        c.translate(0, translateY);
        currentStickyView.draw(c);
        c.restore();
    }

    private int findTopVisibleItemPosition(RecyclerView parent) {
        LinearLayoutManager layoutManager = (LinearLayoutManager) parent.getLayoutManager();
        return layoutManager.findFirstVisibleItemPosition();
    }

    private int findNextHeaderPosition(RecyclerView parent, int position) {
        int itemCount = parent.getAdapter().getItemCount();
        for (int i = position + 1; i < itemCount; i++) {
            if (listener.isHeader(i)) {
                return i;
            }
        }
        return RecyclerView.NO_POSITION;
    }

    private void measureHeaderView(View headerView, 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(), headerView.getLayoutParams().width);
        int childHeight = ViewGroup.getChildMeasureSpec(heightSpec,
                parent.getPaddingTop() + parent.getPaddingBottom(), headerView.getLayoutParams().height);

        headerView.measure(childWidth, childHeight);
        headerView.layout(0, 0, headerView.getMeasuredWidth(), headerView.getMeasuredHeight());
    }
}

6、定义粘性头部监听接口

java 复制代码
/**
 * @author: 魏
 * @date: 2025/9/29
 * 定义粘性头部监听接口
 */
public interface StickyHeaderListener {
    // 判断是否是头部项
    boolean isHeader(int position);

    // 获取指定位置对应的头部位置
    int getHeaderPositionForItem(int itemPosition);

    // 获取头部视图
    View getHeaderView(RecyclerView parent, int headerPosition);
}

7、账单数据模型

java 复制代码
/**
 * @author: 魏
 * @date: 2025/9/29
 * 数据模型与适配器
 * 账单数据模型
 */
public class BillItem {
    public static final int TYPE_HEADER = 0;
    public static final int TYPE_ITEM = 1;

    private int type;
    private String month; // 用于标题
    private String date;  // 用于账单项
    private String description;
    private double amount;
    private boolean isIncome; // 收入或支出

    public BillItem(int type, String month, String date, String description, double amount, boolean isIncome) {
        this.type = type;
        this.month = month;
        this.date = date;
        this.description = description;
        this.amount = amount;
        this.isIncome = isIncome;
    }

    // Getter方法
    public int getType() { return type; }
    public String getMonth() { return month; }
    public String getDate() { return date; }
    public String getDescription() { return description; }
    public double getAmount() { return amount; }
    public boolean isIncome() { return isIncome; }
}

8、账单适配器实现

java 复制代码
/**
 * @author: 魏
 * @date: 2025/9/29
 * 账单适配器实现
 */
public class BillAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder>
        implements StickyHeaderListener {

    private List<BillItem> billItems;
    private Context context;

    public BillAdapter(Context context, List<BillItem> billItems) {
        this.context = context;
        this.billItems = billItems;
    }

    @Override
    public int getItemViewType(int position) {
        return billItems.get(position).getType();
    }

    @NonNull
    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        if (viewType == BillItem.TYPE_HEADER) {
            View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_header, parent, false);
            return new HeaderViewHolder(view);
        } else {
            View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_bill, parent, false);
            return new BillViewHolder(view);
        }
    }

    @Override
    public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
        BillItem item = billItems.get(position);
        if (holder instanceof HeaderViewHolder) {
            ((HeaderViewHolder) holder).tvMonth.setText(item.getMonth());
        } else if (holder instanceof BillViewHolder) {
            BillViewHolder billHolder = (BillViewHolder) holder;
            billHolder.tvDate.setText(item.getDate());
            billHolder.tvDescription.setText(item.getDescription());

            // 设置金额颜色(收入绿色,支出红色)
            if (item.isIncome()) {
                billHolder.tvAmount.setTextColor(ContextCompat.getColor(context, R.color.green));
                billHolder.tvAmount.setText(String.format("+¥%.2f", item.getAmount()));
            } else {
                billHolder.tvAmount.setTextColor(ContextCompat.getColor(context, R.color.red));
                billHolder.tvAmount.setText(String.format("-¥%.2f", item.getAmount()));
            }
        }
    }

    @Override
    public int getItemCount() {
        return billItems.size();
    }

    // StickyHeaderListener接口实现
    @Override
    public boolean isHeader(int position) {
        return billItems.get(position).getType() == BillItem.TYPE_HEADER;
    }

    @Override
    public int getHeaderPositionForItem(int itemPosition) {
        for (int i = itemPosition; i >= 0; i--) {
            if (isHeader(i)) {
                return i;
            }
        }
        return RecyclerView.NO_POSITION;
    }

    @Override
    public View getHeaderView(RecyclerView parent, int headerPosition) {
        HeaderViewHolder holder = new HeaderViewHolder(
                LayoutInflater.from(parent.getContext()).inflate(R.layout.item_header, parent, false));
        holder.tvMonth.setText(billItems.get(headerPosition).getMonth());
        return holder.itemView;
    }

    public static class HeaderViewHolder extends RecyclerView.ViewHolder {
        TextView tvMonth;

        public HeaderViewHolder(@NonNull View itemView) {
            super(itemView);
            tvMonth = itemView.findViewById(R.id.tv_month);
        }
    }

    public static class BillViewHolder extends RecyclerView.ViewHolder {
        TextView tvDate;
        TextView tvDescription;
        TextView tvAmount;

        public BillViewHolder(@NonNull View itemView) {
            super(itemView);
            tvDate = itemView.findViewById(R.id.tv_date);
            tvDescription = itemView.findViewById(R.id.tv_description);
            tvAmount = itemView.findViewById(R.id.tv_amount);
        }
    }
}

9、Activity集成

java 复制代码
/**
 * @param
 * @return
 * @author 魏
 * @time 2025/9/29 14:40
 */
public class MainActivity extends AppCompatActivity {
    private RecyclerView recyclerView;
    private BillAdapter adapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        recyclerView = findViewById(R.id.recycler_view);
        recyclerView.setLayoutManager(new LinearLayoutManager(this));

        // 生成测试数据
        List<BillItem> billItems = generateTestData();
        adapter = new BillAdapter(this, billItems);
        recyclerView.setAdapter(adapter);

        // 添加粘性头部装饰器
        recyclerView.addItemDecoration(new StickyHeaderDecoration(adapter));
    }

    private List<BillItem> generateTestData() {
        List<BillItem> items = new ArrayList<>();

        // 添加测试数据
        items.add(new BillItem(BillItem.TYPE_HEADER, "2025年9月", null, null, 0, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "9月29日", "微信支付-超市购物", 128.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "9月28日", "微信支付-工资", 20000.00, true));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "9月27日", "微信支付-餐饮", 56.80, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "9月26日", "微信支付-超市购物", 128.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "9月25日", "微信支付-工资", 20000.00, true));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "9月24日", "微信支付-餐饮", 56.80, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "9月23日", "微信支付-超市购物", 128.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "9月22日", "微信支付-工资", 20000.00, true));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "9月21日", "微信支付-餐饮", 56.80, false));


        items.add(new BillItem(BillItem.TYPE_HEADER, "2025年8月", null, null, 0, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月31日", "水电费", 320.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月30日", "房租", 2500.00, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月29日", "水电费", 320.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月28日", "房租", 2500.00, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月27日", "水电费", 320.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月26日", "房租", 2500.00, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月25日", "水电费", 320.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月24日", "房租", 2500.00, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月23日", "水电费", 320.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月22日", "房租", 2500.00, false));

        items.add(new BillItem(BillItem.TYPE_HEADER, "2025年7月", null, null, 0, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "7月29日", "旅游消费", 1200.00, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "7月28日", "电影票", 80.00, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月27日", "旅游消费", 320.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月26日", "房租", 2500.00, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月25日", "旅游消费", 320.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月24日", "房租", 2500.00, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月23日", "旅游消费", 320.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月22日", "房租", 2500.00, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月21日", "旅游消费", 320.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月20日", "房租", 2500.00, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月19日", "旅游消费", 320.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月18日", "房租", 2500.00, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月17日", "旅游消费", 320.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月16日", "房租", 2500.00, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月15日", "旅游消费", 320.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月14日", "房租", 2500.00, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月13日", "旅游消费", 320.50, false));
        items.add(new BillItem(BillItem.TYPE_ITEM, null, "8月12日", "房租", 2500.00, false));

        return items;
    }
}

完整的示例地址:
https://gitee.com/weicongxiang/imitation-we-chat-bill-list.git

拿走,拿走!!!

相关推荐
LB21122 小时前
苍穹外卖-菜品新增、删除
java·服务器·windows
寻星探路2 小时前
Java EE初阶启程记04---线程的状态
java·开发语言·jvm·java-ee
努力也学不会java3 小时前
【Java并发】揭秘Lock体系 -- 深入理解ReentrantLock
java·开发语言·人工智能·python·机器学习·reentrantlock
haokan_Jia3 小时前
【MyBatis-Plus 动态数据源的默认行为】
java·开发语言·mybatis
_院长大人_3 小时前
IDEA 实现SpringBoot热部署(HotSwap和DevTools混用)
java·spring boot·intellij-idea
小信丶4 小时前
Spring 6 的 @HttpExchange 注解:声明式 HTTP 客户端的现代化利器
java·spring·http
野犬寒鸦6 小时前
多级缓存架构:性能与数据一致性的平衡处理(原理及优势详解+项目实战)
java·服务器·redis·后端·缓存
帧栈8 小时前
开发避坑指南(58):Java Stream 按List元素属性分组实战指南
java
Da Da 泓8 小时前
LinkedList模拟实现
java·开发语言·数据结构·学习·算法