自定义 View 结合 RecyclerView 实现时光轴效果
介绍如何在 Android 中通过自定义 View 与 RecyclerView 配合实现时光轴效果。主要步骤包括定义自定义属性、重写 View 的测量与绘制方法处理线条与圆点布局、在 RecyclerView 的 Adapter 中根据位置状态(首尾中间)动态控制线条显示。该方法适用于快递跟踪、时间线展示等场景,代码结构清晰,便于扩展。

介绍如何在 Android 中通过自定义 View 与 RecyclerView 配合实现时光轴效果。主要步骤包括定义自定义属性、重写 View 的测量与绘制方法处理线条与圆点布局、在 RecyclerView 的 Adapter 中根据位置状态(首尾中间)动态控制线条显示。该方法适用于快递跟踪、时间线展示等场景,代码结构清晰,便于扩展。


微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
时光轴效果在很多 App 中都有出现,例如淘宝中的快递跟踪、用户成长记录等。本文将使用 RecyclerView 配合自定义 View 来实现时光轴效果。
首先,我们需要在 res/values/attrs.xml 中声明自定义属性,以便在 XML 布局中配置时光轴的样式。
<declare-styleable name="TimeLine">
<attr name="beginLine" format="reference" />
<attr name="endLine" format="reference" />
<attr name="lineWidth" format="dimension" />
<attr name="timeLineImage" format="reference" />
<attr name="timeLineImageSize" format="dimension" />
</declare-styleable>
各属性说明:
beginLine: 上方线条的 Drawable 资源。endLine: 下方线条的 Drawable 资源。lineWidth: 线条的宽度。timeLineImage: 中间圆形节点的 Drawable 资源。timeLineImageSize: 中间圆形节点的大小(宽高一致)。创建一个继承自 View 的类 TimeLine。在构造方法中解析自定义属性。
public class TimeLine extends View {
private int lineWidth;
private Drawable mBeginLine;
private Drawable mEndLine;
private Drawable mTimeLineImage;
private int mTimeLineImageSize;
public TimeLine(Context context) {
this(context, null);
}
public TimeLine(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public TimeLine(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.TimeLine);
lineWidth = a.getDimensionPixelOffset(R.styleable.TimeLine_lineWidth, 15);
mBeginLine = a.getDrawable(R.styleable.TimeLine_beginLine);
mEndLine = a.getDrawable(R.styleable.TimeLine_endLine);
mTimeLineImage = a.getDrawable(R.styleable.TimeLine_timeLineImage);
mTimeLineImageSize = a.getDimensionPixelSize(R.styleable.TimeLine_timeLineImageSize, 25);
a.recycle();
}
}
自定义控件通常需要重写 onMeasure 方法。这里需要对 wrap_content 的情况进行特殊处理,确保控件能根据内容正确测量自身大小。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// 计算默认宽高,基于圆形节点大小和 Padding
int w = mTimeLineImageSize + getPaddingLeft() + getPaddingRight();
int h = mTimeLineImageSize + getPaddingTop() + getPaddingBottom();
int widthSize = resolveSizeAndState(w, widthMeasureSpec, 0);
int heightSize = resolveSizeAndState(h, heightMeasureSpec, 0);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
// 处理宽高都为 wrap_content 的情况
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(DEFAULT_WIDTH, DEFAULT_HEIGHT);
}
// 处理宽为 wrap_content 的情况
else if (widthSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(DEFAULT_WIDTH, widthSize);
}
// 处理高为 wrap_content 的情况
else if (heightSpecMode == MeasureSpec.AT_MOST) {
setMeasuredDimension(heightSize, DEFAULT_HEIGHT);
}
}
在 onDraw 中绘制线条和圆形节点。注意先设置好 Drawable 的 Bounds。
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mBeginLine != null) {
mBeginLine.draw(canvas);
}
if (mEndLine != null) {
mEndLine.draw(canvas);
}
if (mTimeLineImage != null) {
mTimeLineImage.draw(canvas);
}
}
当视图尺寸改变时,需要重新计算各个 Drawable 的位置和范围(Bounds),以确保绘制位置准确。
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
int paddingLeft = getPaddingLeft();
int paddingRight = getPaddingRight();
int paddingTop = getPaddingTop();
int paddingBottom = getPaddingBottom();
int childWidth = w - paddingLeft - paddingRight;
int childHeight = h - paddingTop - paddingBottom;
// 限制圆形节点最大不超过父容器可用区域
mTimeLineImageSize = Math.min(mTimeLineImageSize, Math.min(childHeight, childWidth));
Rect bounds = new Rect();
if (mTimeLineImage != null) {
mTimeLineImage.setBounds(paddingLeft, paddingTop, paddingLeft + mTimeLineImageSize, paddingTop + mTimeLineImageSize);
bounds = mTimeLineImage.getBounds();
} else {
bounds = new Rect(paddingLeft, paddingTop, paddingLeft + childWidth, paddingTop + childHeight);
}
// 设置上线条范围
if (mBeginLine != null) {
int lineLeft = bounds.centerX() - (lineWidth >> 1);
mBeginLine.setBounds(lineLeft, 0, lineLeft + lineWidth, bounds.top);
}
// 设置下线条范围
if (mEndLine != null) {
int lineLeft = bounds.centerX() - (lineWidth >> 1);
mEndLine.setBounds(lineLeft, bounds.bottom, lineLeft + lineWidth, h);
}
}
注意:mBeginLine 的长度实际上对应 paddingTop 的高度,mEndLine 对应 paddingBottom 的高度。因此在使用该控件时,通常建议设置 paddingTop 和 paddingBottom。
在 RecyclerView 的 Item 布局中使用该控件。多个 Item 拼接起来就是一条时光轴。
<com.example.timelinedemo.TimeLine
android:id="@+id/timeLineView"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:clickable="true"
android:focusable="true"
android:focusableInTouchMode="true"
android:paddingBottom="8dp"
android:paddingLeft="4dp"
android:paddingRight="4dp"
android:paddingTop="34dp"
app:beginLine="#ff0000"
app:endLine="#ff0000"
app:lineWidth="3dp"
app:timeLineImage="@drawable/timeline_marker"
app:timeLineImageSize="24dp" />
注意事项:父布局建议使用 LinearLayout 且高度模式设为 wrap_content。如果 TextView 设置了较大的 paddingTop,需确保父容器高度足够包裹 TimeLine 控件,否则可能导致 TimeLine 不可见。
RecyclerView 的 Adapter 需要处理四种情况:第一条、最后一条、中间项、以及只有一条数据的情况。根据这些状态动态控制线条的显示与隐藏。
public class TimeLineAdapter extends RecyclerView.Adapter<TimeLineAdapter.ViewHolder> {
private List<TimeLineItem> datas;
public TimeLineAdapter(List<TimeLineItem> datas) {
this.datas = datas;
}
@Override
public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
View view = layoutInflater.inflate(R.layout.item_timeline, null);
return new ViewHolder(view, parent.getContext(), viewType);
}
@Override
public void onBindViewHolder(ViewHolder holder, int position) {
TimeLineItem timeLineItem = datas.get(position);
holder.tv_name.setText(timeLineItem.getTimeLineName());
}
@Override
public int getItemCount() {
return datas.size();
}
@Override
public int getItemViewType(int position) {
int size = datas.size() - 1;
if (size == 0) {
return TimeLineItemType.ATOM; // 只有一条数据
} else if (position == 0) {
return TimeLineItemType.START; // 第一条
} else if (position == size) {
return TimeLineItemType.END; // 最后一条
} else {
return TimeLineItemType.NORMAL; // 中间
}
}
static class ViewHolder extends RecyclerView.ViewHolder {
private TextView tv_name;
private TimeLine timeLine;
public ViewHolder(View itemView, Context context, int viewType) {
super(itemView);
tv_name = itemView.findViewById(R.id.name);
timeLine = itemView.findViewById(R.id.timeLineView);
// 随机设置圆形图标
Drawable drawable = context.getResources().getDrawable(R.drawable.timeline_marker);
Drawable drawable2 = context.getResources().getDrawable(R.drawable.timeline_marker2);
Drawable drawable3 = context.getResources().getDrawable(R.drawable.timeline_marker3);
Drawable drawable4 = context.getResources().getDrawable(R.drawable.timeline_marker4);
Drawable drawable5 = context.getResources().getDrawable(R.drawable.timeline_marker5);
Random random = new Random();
final int i = random.nextInt(5);
final Drawable[] drawables = {drawable, drawable2, drawable3, drawable4, drawable5};
timeLine.setTimeLineImage(drawables[i]);
// 根据类型设置线条
if (viewType == TimeLineItemType.START) {
timeLine.setBeginLine(null);
} else if (viewType == TimeLineItemType.END) {
timeLine.setEndLine(null);
} else if (viewType == TimeLineItemType.ATOM) {
timeLine.setBeginLine(null);
timeLine.setEndLine(null);
}
}
}
static class TimeLineItemType {
public final static int NORMAL = 0;
public final static int START = 1;
public final static int END = 2;
public final static int ATOM = 3;
}
}
public class MainActivity extends AppCompatActivity {
private List<TimeLineItem> mDatas;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
initData();
RecyclerView recyclerView = findViewById(R.id.recyclerview);
LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
linearLayoutManager.setOrientation(LinearLayoutManager.VERTICAL);
recyclerView.setLayoutManager(linearLayoutManager);
TimeLineAdapter adapter = new TimeLineAdapter(mDatas);
recyclerView.setAdapter(adapter);
}
private void initData() {
mDatas = new ArrayList<>();
mDatas.add(new TimeLineItem("爸爸生日"));
mDatas.add(new TimeLineItem("妈妈生日"));
mDatas.add(new TimeLineItem("姐姐生日"));
mDatas.add(new TimeLineItem("女神生日"));
mDatas.add(new TimeLineItem("前任生日"));
}
}
通过上述步骤,我们成功实现了基于 RecyclerView 的时光轴效果。核心在于自定义 View 对线条和圆点的精确绘制,以及在 Adapter 中根据不同位置状态动态调整 UI 表现。这种方案灵活性强,易于扩展其他样式,适用于各种时间线展示场景。
在实际开发中,建议将自定义 View 封装为库组件,方便在不同项目中复用。同时注意性能优化,避免在 onDraw 中进行过多的对象创建操作。