最近在使用小米天气的时候发现,应用里的24小时天气滚动显示控件挺有意思的。
正如上面所表现的,控件滑动时,不光能够动态表现当前时点的温度,还能显示出当前时点的天气状况,而且相同的天气之间使用区间来表现,而这个表现图标,正好显示在区间的中间点位置。于是就想要尝试自定义一个类似的控件。
一、控件的大小
自定义控件首先的需要注意的一步,就是控件大小的设定。关于控件大小的设定,我们只需要重写onMeasure()
方法,就可以完成对控件大小的控制。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 获取高度和宽度的指定模式
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
// 判断指定控件宽度的模式
int widgetWidth;
if (widthMode == MeasureSpec.EXACTLY) {
// 以精确值作为宽
widgetWidth = widthSize;
} else {
// 以250dp作为默认宽
widgetWidth = dp2Px(250);
}
// 判断指定控件高度的模式
if (heightMode == MeasureSpec.EXACTLY) {
// 以精确值作为高
widgetHeight = heightSize;
} else {
// 以200dp作为默认高
widgetHeight = dp2Px(200);
}
// 构建高宽指定的控件
setMeasuredDimension(widgetWidth, widgetHeight);
}
其中对于在xml中指定控件的大小模式,这里不再赘述,不清楚的可以自行查阅onMeasure()
方法的设定。当然,这里需要在确定了大小之后,需要调用setMeasuredDimension()
方法通知控件,告诉它你的大小已经设定完成了,就按照这个大小给自己布局。
二、控件的滚动
关于控件的滚动,我们可以重写onTouchEvent()
事件,在事件中,调用scrollTo()
完成控件的滚动。当然,类似于ScrollView
控件,我们也需要设定滑动的左右边界。左边界的设定其实就是让控件向左滑动的距离不小于0,而 右边界的距离就是要控件向左滑动的距离不大于所有数据源加起来的宽度。
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
// 按下事件
case MotionEvent.ACTION_DOWN:
lastEventX = event.getX();
return true;
// 移动事件
case MotionEvent.ACTION_MOVE:
offsetX = offsetX + (int) (lastEventX - event.getX());
// 设置左边边界
if (offsetX < 0)
offsetX = 0;
// 设置右边边界
if (offsetX > maxOffsetX)
offsetX = maxOffsetX;
scrollTo(offsetX, 0);
lastEventX = event.getX();
return true;
}
return super.onTouchEvent(event);
}
注:
offsetX
就是上述是控件向左滑动的距离。
scrollTo()
方法需要一个总的滑动距离,所以需要在这里进行不停的修正。另外,scrollBy()
也是可以实现同样的效果的,不过如果使用scrollBy()
,这里就不能使用offsetX
值去作为参数。鉴于以后offsetX
有判断分割线状态的作用,所以这里就采用了scrollTo()
方法。
三、控件的数据
控件的布局以及滑动的问题解决了,接下来又产生新的问题了,我们如何解决数据问题呢?因为天气不可能一成不变,我们不能直接在控件中给它定义死啊。
为了能够比较简单的使用控件,以及能够有个统一的数据规范,我们可以在这边加一个适配器,使用起来就和ListView
一样,给控件设置一个实例化的适配器类就可以完成数据的创建工作。
public abstract class WeatherAdapter {
abstract int getCount();
abstract List<WeatherModel> getData();
/**
* 返回最大最小温度
*
* @return 最大最小温度数组([0]最大温度, [1]最小温度)
*/
public int[] getMaxMinTemperature() {
if (maxTemp == -1 || minTemp == -1) {
maxTemp = 0;
minTemp = getData().get(0).getTemperature();
for (WeatherModel model : getData()) {
if (maxTemp < model.getTemperature())
maxTemp = model.getTemperature();
if (minTemp > model.getTemperature())
minTemp = model.getTemperature();
}
}
return new int[]{maxTemp, minTemp};
}
}
如上所示,这个适配器类,我并没有定义的很复杂,无非就是返回数据源以及数据长度的抽象方法,这个需要我们自己去在使用的时候实现它。然后剩下的那个getMaxMinTemperature()
函数,也就是为了在使用的时候能够更方便,更高效。
其中的WeatherModel
类,就是我们需要的单个数据源类:
class WeatherModel {
// 当前时间
private String time;
// 当前温度
private int temperature;
// 当前天气
private WeatherType weatherType;
public WeatherModel(String time, int temperature, WeatherType weatherType) {
this.time = time;
this.temperature = temperature;
this.weatherType = weatherType;
}
public String getTime() {
return time;
}
public int getTemperature() {
return temperature;
}
public WeatherType getWeatherType() {
return weatherType;
}
/**
* 天气类型
*/
enum WeatherType {
// 晴
Sunny,
// 多云
Cloudy,
// 阴天
Overcast,
// 雨
Rain,
// 雷阵雨
Thunderstorm,
// 雪
Snow
}
}
注: 由于在本例中不需要对数据进行修改,这里就只是简单地添加了
get()
方法,而没有添加set()
方法。
天气类型只定义了简单的几种,因为这里只是讲述一个控件的绘制,而不是一个上架的应用,所以只采用了简单的几种天气类型作为展示用,如果需要其他复杂的天气类型,各位自行补充。
四、控件的绘制
数据的问题解决了,下面我们可以正式开始我们的控件绘制工作了。
就这个控件而言,我把它分成两步去绘制:
- 第一步,先绘制除去气象图标跟随滑动而移动之外的其他部分,因为这部分只需要控制横向滚动就可以完成了,无论怎样滚动,各区域之间总是相对静止的。
- 第二步,绘制气象图标的动态变化。这部分绘制思路,我下面再进行说明。
好了,有了大体的绘制思路,我们先进行第一部分的绘制工作。
第一步的绘制工作,因为是各部分相对静止的状态,所以这部分主要就是基本的绘制控件问题了。
首先,根据数据,我们先计算出每一个单位摄氏度的高度,有了这个单位高度,我们就可以先绘制出每一个时间点的温度点。当然,我们可以在遍历数据源的时候,加入温度点连线的构建,使用Path类的lineTo()
函数创建温度点之间的连线。
其次,关于不同天气之间的分割线,只需要判断相邻两个时间点的天气是否相同,不相同则绘制一条分割线,这里可以将分割线的X坐标保存到数组之中,方便后面绘制图标的时候使用。
最后,静态气象图标的绘制,主要就是计算两条相邻分割线之间的中心点位置问题,如下所示:
// X坐标:两条分割线的中心点
final float bitmapX = (splits.get(i + 1) - splits.get(i) - bWidth) / 2 + splits.get(i);
// Y坐标:从60%的高度为底开始到高度基的中心点
final float bitmapY = (float) widgetHeight * 0.6f + ((float) widgetHeight * 0.2f - bHeight) / 2;
注:
i
是循环的计数变量。bWidth
和bHeight
是图标Bitmap对象的高度和宽度。
将之前记录的分割线数组遍历一次,就可以计算出绘制在中心点的所有图标的坐标,然后调用canvas.drawBitmap(bitmap, bitmapX, bitmapY, paint)
绘制图标。
关于图标的Bitmap
对象,因为安卓中Bitmap
不会自动释放,而且内存占用比较大,所以我选择在setAdapter()
的时候,遍历数据源,然后创建控件中需要的Bitmap
对象,这样就不需要在初始化的时候,将所有的图标创建出来,一定程度上减少内存的开销。当然,有了Bitmap
对象,我们也需要在控件销毁的时候,释放这个资源。
释放资源,我们可以在onDetachedFromWindow()
函数中进行,举个例子:
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (bSunny != null) {
bSunny.recycle();
bSunny = null;
}
......
}
注: 关于
onDetachedFromWindow()
方法,不清楚的各位可以点击方法链接查阅
其他部分代码这边也不上了,篇幅比较长,太占空间,也没啥可说的,需要的各位可以在文章最后找到下载链接。
好,大致控件绘制完了,现在我们配置一下数据,用法其实很简单。
JZMiWeather miWeather = (JZMiWeather) findViewById(R.id.miweather);
Adapter adapter = new Adapter();
miWeather.setAdapter(adapter);
至于这个Adapter
类,就是我们继承之前抽象类的实例化产物。
private class Adapter extends WeatherAdapter {
private List<WeatherModel> data;
private Adapter() {
data = new ArrayList<>();
data.add(new WeatherModel("01时", 20, WeatherModel.WeatherType.Sunny));
data.add(new WeatherModel("02时", 21, WeatherModel.WeatherType.Cloudy));
......
}
@Override
int getCount() {
return data.size();
}
@Override
List<WeatherModel> getData() {
return data;
}
}
这里,数据的创建我就不写太多了。好了,数据弄好了,现在我们来运行一下效果。
这和预想的效果已经近了一步了。
基础绘制工作完成了之后,接下来,我们就需要将天气图标动起来了。
原本的图标是固定在两条分割线中心位置的,而理想的效果中,我们是需要把屏幕边界考虑进去,也就是说,在滑动的时候,如果图标相邻的分割线滑出了屏幕,需要用屏幕边界来代替原本的分割线位置。
因此,在计算Bitmap
对象X坐标的时候,需要将上面绘制出来的图标修改一下:
// 计算左侧分割线X坐标(如果理想左分割线超出屏幕左边界,以屏幕左边界为实际分割线)
final float leftX = splits.get(i) < offsetX ? offsetX : splits.get(i);
// 计算右侧分割线X坐标(如果理想右分割线超出屏幕右边界,以屏幕右边界为实际分割线)
final float splitOffsetX = offsetX + screenWidth;
final float rightX = splits.get(i + 1) > splitOffsetX ? splitOffsetX : splits.get(i + 1);
// X坐标:两条分割线的中心点
final float bitmapX = (rightX - leftX - bWidth) / 2 + leftX;
通过判断分割线与对应边界的关系,修改理想分割线的位置,然后计算图标的坐标。
好,我们再来运行一下效果。
控件已经大体差不多了,但总是感觉还是少了什么。相信细心的各位已经发现了,这个天气图标在实际分割线和屏幕之间距离不足以绘制图标的时候,会覆盖分割线,这和我们理想的控件还是不一样的。造成这个现象的原因,就是我们只考虑了屏幕边界对分割线造成的影响,而忽略了实际分割线和屏幕之间距离不足以绘制图标的情况。
其实这个问题并不复杂,距离不够无非就是三种情况:
- 左实际分割线滑出左屏幕,而右实际分割线与左屏幕之间的距离不足一个图标的宽度;
- 右实际分割线滑出右屏幕,而左实际分割线与右屏幕之间的距离不足一个图标的宽度;
- 左右实际分割线都在屏幕之中,但是两条分割线之间的距离不足一个图标的宽度。
以上三种情况中,1和2的情况可以归为一类。而第三种情况,我们可以不需要考虑,因为这种情况首先不是很多,一般不大会有两个很小的单位时段内出现三种天气情况。而且,就算有人说,我们需要考虑这种情况,那也可以在设计的时候就避免这个问题。首先,我们采用的图片是资源图片,也就是说,我们可以在控件初始化的时候就计算出图标的宽度,而单位时间点的宽度是我们人为定义的,所以,我们完全可以使得单位时间点宽度大于图标的宽度。这里我就不在本例中考虑了。
好了,我们现在解决1和2的两种情况。这两种情况可以概括为空间不足。那,我们完全可以把控件不足的状况筛选出来,上面计算出来的实际分割线坐标(leftX,rightX)相减,我们就得到了相邻实际分割线之间的空间了,如下所示:
float deltaX = rightX - leftX;
有了这个delta,我们拿去和图标宽度比较,如果小于图标宽度,那就是上面这两种情况之一了,这就变成了if else的问题了。好,直接上代码:
// 当两条分割线间距小于图标宽度时,修正图标位置
if (deltaX < bWidth)
// 当左侧理想分割线已经超出屏幕左侧,强制图标紧贴右侧分割线;右侧同理
bitmapX = splits.get(i) < offsetX ? rightX - bWidth : leftX;
现在,我们再来运行一下效果。
已经和预期的控件相差无几了,剩下来的就是控件美化的问题了,这个不在此赘述,需要的各位可以自行美化。
源码下载,请移步我的Github