效果链接
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
拿走,拿走!!!