一、项目概述
1.1 背景与意义
在许多应用场景中,我们需要展示一系列按时间先后排列的事件,比如:
-
社交应用中的动态时间线
-
订单状态跟踪界面的步骤展示
-
项目日志或版本发布记录
一个直观的“时间轴”控件能够帮助用户快速了解事件的先后顺序及状态。Android 原生并不提供专门的时间轴控件,因此我们通常使用 RecyclerView
+ 自定义 ItemDecoration
或者在每个 item 中绘制线条和节点来实现这一效果。
1.2 功能需求
-
垂直方向时间轴:左侧垂直一直线,线上若干节点(圆点);每条 item 水平分为“时间”与“内容”两部分。
-
节点样式:第一条和最后一条可以自定义不同样式(如大圆、突出色);中间节点统一样式。
-
item 间隔:时间链自动延伸,首尾断点处理平滑;
-
数据驱动:可动态加载任意长度的事件列表;
-
样式可定制:线宽、圆点大小、颜色、左右间距、时间文字样式等可在代码或属性中配置。
二、相关技术知识
-
RecyclerView
-
支持动态列表、可复用
ViewHolder
。 -
RecyclerView.ItemDecoration
可用于在 item 绘制前后插入自定义绘制(如时间轴的线段和节点)。
-
-
Canvas 绘图
-
Canvas.drawLine()
、Canvas.drawCircle()
实现线段与节点; -
Paint
设置颜色、线宽、抗锯齿等属性。
-
-
自定义属性
-
可在
res/values/attrs.xml
中声明自定义属性,如timelineLineColor
、timelineDotRadius
,在布局中通过app:
前缀配置。
-
-
数据模型
-
自定义
TimelineItem
包含time
和content
字段;可扩展status
字段以控制节点样式。
-
三、实现思路
-
数据准备
构造一个List<TimelineItem>
,每个元素包含时间字符和内容描述。 -
布局设计
-
activity_main.xml
:包含一个全屏RecyclerView
; -
item_timeline.xml
:左右两列布局,左侧TextView
显示时间,右侧TextView
显示内容,并留出左侧绘制时间轴的区域。
-
-
Adapter 实现
-
TimelineAdapter
继承自RecyclerView.Adapter
,使用单一ViewType
,在onBindViewHolder()
中填充时间与内容。
-
-
ItemDecoration 实现
-
在
onDrawOver()
中:-
计算每个 item 左侧中点的 Y 坐标;
-
绘制上半段线(如果不是第一条)和下半段线(如果不是最后一条);
-
在中点绘制圆点;
-
-
线段和圆点的样式(颜色、宽度、半径)可在构造时通过参数传递。
-
-
集成与刷新
-
在
MainActivity
中初始化数据、适配器和ItemDecoration
; -
数据更新后调用
adapter.notifyDataSetChanged()
即可刷新。
-
四、整合代码
4.1 Java 代码(MainActivity.java,所有类集中)
package com.example.timeline;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.util.DisplayMetrics;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.List;
/**
* MainActivity —— 时间轴示例
*/
public class MainActivity extends AppCompatActivity {
private RecyclerView recyclerView;
private TimelineAdapter adapter;
private List<TimelineItem> dataList;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// 1. 初始化数据
dataList = new ArrayList<>();
dataList.add(new TimelineItem("2023-01-01", "项目立项"));
dataList.add(new TimelineItem("2023-02-15", "完成需求分析"));
dataList.add(new TimelineItem("2023-04-10", "完成第一版设计"));
dataList.add(new TimelineItem("2023-06-01", "开发与测试"));
dataList.add(new TimelineItem("2023-07-20", "上线发布"));
// 2. 初始化 RecyclerView 和 Adapter
recyclerView = findViewById(R.id.recycler);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
adapter = new TimelineAdapter(this, dataList);
recyclerView.setAdapter(adapter);
// 3. 添加自定义 ItemDecoration 来绘制时间轴
recyclerView.addItemDecoration(new TimelineItemDecoration(this, dataList.size()));
}
// ========================================================================
// TimelineItem —— 数据模型
// ========================================================================
static class TimelineItem {
String time, content;
TimelineItem(String t, String c) { time = t; content = c; }
}
// ========================================================================
// TimelineAdapter —— RecyclerView.Adapter
// ========================================================================
static class TimelineAdapter extends RecyclerView.Adapter<TimelineAdapter.TimelineVH> {
private Context ctx;
private List<TimelineItem> items;
TimelineAdapter(Context c, List<TimelineItem> list) {
ctx = c; items = list;
}
@NonNull @Override
public TimelineVH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(ctx)
.inflate(R.layout.item_timeline, parent, false);
return new TimelineVH(v);
}
@Override public void onBindViewHolder(@NonNull TimelineVH vh, int pos) {
TimelineItem it = items.get(pos);
vh.tvTime.setText(it.time);
vh.tvContent.setText(it.content);
}
@Override public int getItemCount() { return items.size(); }
static class TimelineVH extends RecyclerView.ViewHolder {
TextView tvTime, tvContent;
TimelineVH(View v) {
super(v);
tvTime = v.findViewById(R.id.tv_time);
tvContent = v.findViewById(R.id.tv_content);
}
}
}
// ========================================================================
// TimelineItemDecoration —— 自定义 ItemDecoration 绘制时间轴
// ========================================================================
static class TimelineItemDecoration extends RecyclerView.ItemDecoration {
private Paint linePaint, dotPaint;
private int lineWidthPx, dotRadiusPx;
private int itemCount;
private int offset;
TimelineItemDecoration(Context ctx, int totalCount) {
itemCount = totalCount;
DisplayMetrics dm = ctx.getResources().getDisplayMetrics();
lineWidthPx = (int)(2 * dm.density);
dotRadiusPx = (int)(4 * dm.density);
offset = (int)(40 * dm.density); // 左侧留出 40dp 绘制区域
linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
linePaint.setColor(0xFFCCCCCC);
linePaint.setStrokeWidth(lineWidthPx);
dotPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
dotPaint.setColor(0xFF3F51B5);
}
@Override
public void onDrawOver(@NonNull Canvas canvas,
@NonNull RecyclerView parent,
@NonNull RecyclerView.State state) {
int left = parent.getPaddingLeft() + offset / 2;
int right = parent.getWidth() - parent.getPaddingRight();
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
View child = parent.getChildAt(i);
int pos = parent.getChildAdapterPosition(child);
// 计算圆心纵坐标
float cx = left;
float cy = (child.getTop() + child.getBottom()) / 2f;
// 上半段线:若不是第0个,则绘制从上一个节点到当前节点的连线
if (pos > 0) {
// 找到上一个节点的 cy
View prev = parent.getLayoutManager().findViewByPosition(pos - 1);
float prevCy = (prev != null)
? (prev.getTop() + prev.getBottom()) / 2f
: child.getTop() - child.getHeight() / 2f;
canvas.drawLine(cx, prevCy, cx, cy - dotRadiusPx, linePaint);
}
// 下半段线:若不是最后一个,则绘制从当前节点到底下一个节点的连线
if (pos < itemCount - 1) {
View next = parent.getLayoutManager().findViewByPosition(pos + 1);
float nextCy = (next != null)
? (next.getTop() + next.getBottom()) / 2f
: child.getBottom() + child.getHeight() / 2f;
canvas.drawLine(cx, cy + dotRadiusPx, cx, nextCy, linePaint);
}
// 绘制节点圆点
canvas.drawCircle(cx, cy, dotRadiusPx, dotPaint);
}
}
}
}
4.2 XML 布局与资源
<!-- ===================================================================
AndroidManifest.xml —— 应用入口声明
=================================================================== -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.timeline">
<application
android:allowBackup="true"
android:label="Timeline Demo"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
<!-- ===================================================================
activity_main.xml —— 主界面布局:一个 RecyclerView
=================================================================== -->
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingVertical="16dp"/>
<!-- ===================================================================
item_timeline.xml —— 列表项布局:左侧时间,右侧内容
=================================================================== -->
<?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:orientation="horizontal"
android:paddingVertical="12dp"
android:paddingEnd="16dp">
<!-- 时间文本-->
<TextView
android:id="@+id/tv_time"
android:layout_width="80dp"
android:layout_height="wrap_content"
android:gravity="end"
android:textColor="#666666"
android:textSize="14sp"/>
<!-- 右侧内容区-->
<TextView
android:id="@+id/tv_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="16dp"
android:textColor="#333333"
android:textSize="16sp"/>
</LinearLayout>
五、代码解读
-
数据模型
TimelineItem
-
包含
time
(时间字符串)和content
(事件描述),Adapter 根据它填充界面。
-
-
TimelineAdapter
-
单一 ViewType,通过
item_timeline.xml
显示左侧时间和右侧内容; -
onBindViewHolder
简单填充文本。
-
-
TimelineItemDecoration
-
在
onDrawOver()
中为每个可见 item 绘制时间轴:-
计算圆点的 X (
left + offset
)、Y(item 垂直中心)坐标; -
若不是首尾,分别从圆点上方/下方延伸线段至相邻节点位置;
-
使用单例
Paint
对象提高性能; -
offset
控制线段绘制的水平位置,与 item 左侧时间列对齐。
-
-
-
MainActivity
-
初始化数据并设置
RecyclerView
:LinearLayoutManager
+adapter
+ItemDecoration
。 -
数据变更后重新
notifyDataSetChanged()
即可自动重绘时间轴。
-
六、项目总结
-
本文基于
RecyclerView.ItemDecoration
实现了一个简洁、高效的垂直时间轴控件。 -
核心思路是:将“绘制时间轴”与“显示内容”分离,Adapter 只负责内容,Decoration 统一管理线条与节点的绘制。
-
该方案无需额外第三方库,易于定制和扩展,适用于各种分步、日志、版本发布和社交时间线场景。
七、实践建议与未来展望
-
样式定制化
-
将线条颜色、节点半径、时间文本宽度等抽象为自定义属性(
attrs.xml
),通过布局 XML 配置。
-
-
水平时间轴
-
可参考同样思路在横向
RecyclerView
中实现水平时间轴。
-
-
动态交互
-
节点点击支持展开更多详情,结合
RecyclerView
的点击监听或ItemTouchHelper
。
-
-
Jetpack Compose
-
在 Compose 中可用
LazyColumn
+Modifier.drawBehind{}
实现相同效果,且代码更加简洁。
-
-
动画
-
为节点或线条添加入场动画(Alpha/Scale),提升视觉效果。
-
八、知识拓展与参考资料
-
开源实现参考 “StickyTimeline”、“RecyclerViewTimelineDecoration” 等 GitHub 项目。