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

拿走,拿走!!!

相关推荐
c++之路17 分钟前
C++20概述
java·开发语言·c++20
儿歌八万首18 分钟前
Jetpack Compose 实战:实现一个动态平滑折线图
android·折线图·compose
Championship.23.2421 分钟前
Linux Top 命令族深度解析与实战指南
java·linux·服务器·top·linux调试
橘子海全栈攻城狮36 分钟前
【最新源码】养老院系统管理A013
java·spring boot·后端·web安全·微信小程序
逻辑驱动的ken42 分钟前
Java高频面试考点18
java·开发语言·数据库·算法·面试·职场和发展·哈希算法
冷雨夜中漫步1 小时前
Claude Code源码分析——Claude Code Agent Loop 详细设计文档
java·开发语言·人工智能·ai
直奔標竿1 小时前
Java开发者AI转型第二十六课!Spring AI 个人知识库实战(五)——联网搜索增强实战
java·开发语言·人工智能·spring boot·后端·spring
one_love_zfl2 小时前
java面试-微服务组件篇
java·微服务·面试
一只大袋鼠2 小时前
Java进阶:CGLIB动态代理解析
java·开发语言
环流_2 小时前
HTTP 协议的基本格式
java·网络协议·http