最终效果
环境
AndroidStudio 3.1.2
compileSdkVersion 27
原理分析
1 从效果图可以看出,每条物流信息基本都是重复的布局(左侧稍有区别),因此我们只需要自定义物流条目即可,无需把整个效果作为自定义View.
2 最容易想到的方式就是直接利用 RecyclerView 实现.我们只要控制 Item 改变左侧样式即可.
3 本文利用自定义 View 继承 RelativeLayout 实现,暂时叫做 LogisticsLayout
4 上图是一个自定义 LogisticsLayout , 因为继承自 RelativeLayout 所以右边的两行展示可以直接写在布局的 xml 中,然后距离左侧留出一定的距离,这样 LogisticsLayout 在 onLayout()
阶段会自动完成子孩子的绘制,我们只需要在 onDraw()
阶段绘制左侧圆点和虚线即可.这里需要注意的是 Item 之间是没有 divier 的,上图下方的空白是 Item 自身的 padding 或者 margin 属性. 这两个属性占据的高度是 Item 的高度的一部分.
5 绘制左侧效果时,区分三种情况,首部,普通,尾部.
首部 | 普通 | 尾部 |
---|---|---|
颜色红色 | 颜色灰色 | 颜色灰色 |
虚线从圆点到bottom | 虚线占据整个高度 | 虚线从top至圆点 |
6 条目中的电话高亮以及点击后自动拨号,我们直接使用 TextView 的 android:autoLink="phone"
属性
效果实现
attr属性
定义一个可在xml使用的属性,用于限定左侧宽度. 这样做是为了方便维护.当然你也可以直接写死在 View 中.
在 values 文件夹下创建 attrs.xml 并写入下面代码
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="LogisticsLayout">
<attr name="left_margin" format="dimension" />
</declare-styleable>
</resources>
这里只定义一个 left_margin 即可
LogisticsLayout 实现
public class LogisticsLayout extends RelativeLayout {
Paint linePaint;
Paint circlePaint;
// 左侧绘制范围的宽度,此值作为基准,
float totalWidth;
float totalHeight;
float radius;
float centerX;
float marginTop;
float dottedLen;
public LogisticsLayout(Context context) {
this(context, null);
}
public LogisticsLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public LogisticsLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.LogisticsLayout);
try {
totalWidth = array.getDimension(R.styleable.LogisticsLayout_left_margin, 60);
centerX = totalWidth * 0.5f;
radius = totalWidth * 0.1f;
marginTop = totalWidth * 0.3f;
dottedLen = totalWidth / 15;
} finally {
array.recycle();
}
init();
}
private void init() {
linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
linePaint.setStyle(Paint.Style.FILL);
linePaint.setStrokeWidth(3);
linePaint.setColor(Color.GRAY);
circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
circlePaint.setStyle(Paint.Style.FILL);
circlePaint.setColor(Color.GRAY);
// 清除标记,因为 ViewGroup 作为容器,默认不会触发 onDraw() 方法
setWillNotDraw(false);
//关闭硬件加速 这里非常重要, 不然虚线等没有效果
setLayerType(View.LAYER_TYPE_SOFTWARE, null);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
totalHeight = getHeight();
System.out.println(totalWidth + " " + totalHeight + "hp");
// 画虚线
drawLine(canvas);
// 画圆点
drawCircle(canvas);
}
private void drawLine(Canvas canvas) {
linePaint.setPathEffect(new DashPathEffect(new float[]{dottedLen, dottedLen}, 2));
canvas.drawLine(centerX, 0, centerX, totalHeight, linePaint);
}
private void drawCircle(Canvas canvas) {
canvas.drawCircle(centerX, marginTop, radius, circlePaint);
}
}
-
注意几点
- 初始化时定义关键参数,其中以左侧宽度为基准,根据比例定义出 centerX, radius 等.这样做有助于不同屏幕的适配
- 画虚线和圆点,这部分比较简单,只需要第一步定义好位置参数,作图代码只需三行.
-
init() 方法中最后两行需要注意. 其中为了实现 ViewGroup 的
onDraw()
方法调用也可以采用在 xml 中设置背景的方式. - 关闭硬件加速是为了实现某些特殊效果,例如虚线,阴影等
初步效果
以 LogisticsLayout 为布局跟节点,配合 RecyclerView 展示初步效果
Item布局:
<?xml version="1.0" encoding="utf-8"?>
<com.example.hepan.logistics.LogisticsLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:left_margin="50dp">
<TextView
android:id="@+id/tvTime"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="50dp"
android:autoLink="phone"
android:text="时间" />
<TextView
android:id="@+id/tvDesc"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/tvTime"
android:layout_marginBottom="20dp"
android:layout_marginLeft="50dp"
android:autoLink="phone"
android:text="描述" />
</com.example.hepan.logistics.LogisticsLayout>
结合 RecyclerView 效果
最终实现
上面的效果距离目标还有一定的距离.我们需要给 LogisticsLayout 添加判定类型类型的方法,以便在绘图时有所区别
1 在末尾添加 state 属性,并需改 drawLine
drawCircle
方法
private void drawLine(Canvas canvas) {
linePaint.setPathEffect(new DashPathEffect(new float[]{dottedLen, dottedLen}, 2));
if (state == State.STATE_HEADER) {
canvas.drawLine(centerX, marginTop, centerX, totalHeight, linePaint);
} else if (state == State.STATE_FOOTER) {
canvas.drawLine(centerX, 0, centerX, marginTop, linePaint);
} else {
canvas.drawLine(centerX, 0, centerX, totalHeight, linePaint);
}
}
private void drawCircle(Canvas canvas) {
if (state == State.STATE_HEADER) {
circlePaint.setColor(Color.RED);
canvas.drawCircle(centerX, marginTop, radius, circlePaint);
} else {
canvas.drawCircle(centerX, marginTop, radius, circlePaint);
}
}
@State
private int state = State.STATE_NORMAL;
@Retention(RetentionPolicy.SOURCE)
@IntDef({State.STATE_HEADER, State.STATE_NORMAL, State.STATE_FOOTER})
public @interface State {
int STATE_HEADER = 0;
int STATE_NORMAL = 1;
int STATE_FOOTER = 2;
}
public int getState() {
return state;
}
public void setState(int state) {
this.state = state;
}
2 在适配器中设置属性
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
when (position) {
0 -> {
//设置属性
holder.itemView.root.state = LogisticsLayout.State.STATE_HEADER
//字体颜色
holder.itemView.tvTime.setTextColor(Color.RED)
holder.itemView.tvDesc.setTextColor(Color.RED)
}
data.size - 1 -> holder.itemView.root.state = LogisticsLayout.State.STATE_FOOTER
else -> holder.itemView.root.state = LogisticsLayout.State.STATE_NORMAL
}
holder.itemView.tvDesc.text = data[position].split("\n")[0]
holder.itemView.tvTime.text = data[position].split("\n")[1]
}
-
修改后效果
注意几点
- 我这里用的 kotlin, when() 其实就是switch() 语句, .() 就是 set**()
- onBindViewHolder 周期中 View 还未真正绘制,此处设置属性时不用内部调用 postInvalidate(); 等方法
- 用字符串模拟了条目的物流信息,其中用\n隔开
-
电话的颜色显示是以 colors.xml 文件中
<color name="colorAccent">****</color>
为默认值的.也就是app的强调色,如果想改变的话可以自定义 TextView 样式设置对应属性值.
-
备注
-
感谢何天鹏组长,本文思路是参考其代码完成的
- 项目地址 希望能有所帮助