要实现省份区域的单独点击,必须要能获取到各个区域在地图上分布的范围。然而,这些区域都是非常不规则的图形,而且或集中或零散,无法通过简单的数据进行描述,而常规的图片文件(如JPG或PNG等)是很难实现的,因此我们需要使用SVG图片来实现想要的效果。
SVG格式的图片主要是用于Web,它是一种矢量图片,无论放大多少倍都不会出现失真,并且相对而言,其体量也比常规的JPG或PNG等图片要小得多,因为SVG相当于一种纯文本(换言之就是代码)文件,它是由画布来加载的,而PNG这种图片则需要图像引擎来加载。
当然,Google为我们提供了Android的SVG支持,并且非常提倡我们在开发中使用SVG图片来替代以往的PNG等格式图片。(SDK23之后,Android默认的图标都是SVG格式的xml文件。)
但是,一般美工小姐姐交给我们的或者网上下载到的SVG都不能直接在Android中使用,而需要做格式转换才能被Android识别,这类工具在GitHub上也有很多,可以直接使用。
可以在这个网站免费获取到世界各国地图SVG文件。
下载好了SVG文件后,可以通过这个工具转换为Android可识别的SVG文件。或者去网上找相应的工具也可以。
转换为Android可用的SVG之后,我们可以很清楚的看到文件的结构。
这里截取了一段转换后的中国地图的SVG文件,在Android中,SVG的根节点不再是< svg />,而是
< vector />,这里的width和height可以设置成合适的大小,由于我们自定义View没有对这个属性做处理,所以设置成多少都无所谓。如果直接将这个SVG设置给ImageView的src,那么ImageView就会读取这个属性值来设置成对应的大小。
我们看到剩下的节点全都是< path />,这个path和我们的Paint类非常相似,而"android:pathData"属性值则和Path类相对应,而我们要做的,就是将pathData的数据转换为对应的Path,我们看到它是一连串的数据集合,通过M、L等关键字符连接,在图中其实就是一个一个的坐标点,而每一个pathData就绘制了一个省份的区域。
接下来进入撸码环节~
我们从最简单的构造函数开始。
MapView.java
public class MapView extends View {
public MapView(Context context) {
this(context, null);
}
public MapView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public MapView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context, attrs, defStyleAttr);
}
private void init(Context context, @Nullable AttributeSet attrs, int defStyleAttr){
// ...
}
}
构造函数中调用了初始化方法,用来初始化画笔、线程池、解析自定义属性。
首先我们需要自定义一个属性,用来设置SVG资源Id。
在res -> values文件夹下新建xml文件:attrs.xml
attrs.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="MapView">
<attr name="map" format="reference"/>
</declare-styleable>
</resources>
设置格式为reference,这样在布局文件中就会有提示啦~
由于解析xml是一个比较费时的操作,理所应当我们要放到子线程中去执行,所以需要用到线程池来管理我们的线程。
MapView.java
public class MapView extends View {
/**
* 线程池,用于加载xml
*/
private ExecutorService mThreadPool;
/**
* 地图画笔
*/
private Paint mPaint;
/**
* SVG地图资源Id
*/
private int mMapResId = -1;
// ...
/**
* 初始化画笔、线程池、解析自定义属性
*/
private void init(Context context, @Nullable AttributeSet attrs, int defStyleAttr){
mPaint = new Paint();
// 设置抗锯齿
mPaint.setAntiAlias(true);
// 初始化线程池
initThreadPool();
// 解析自定义属性
getMapResource(context, attrs, defStyleAttr);
}
/**
* 初始化线程池
*/
private void initThreadPool() {
ThreadFactory threadFactory = new ThreadFactory() {
@Override
public Thread newThread(@NonNull Runnable r) {
Thread thread = new Thread(r);
thread.setPriority(Thread.MAX_PRIORITY);
return thread;
}
};
mThreadPool = new ThreadPoolExecutor(1, 1, 10L, TimeUnit.MINUTES,
new LinkedBlockingQueue<Runnable>(10), threadFactory,
new ThreadPoolExecutor.AbortPolicy());
}
/**
* 解析自定义属性
*/
private void getMapResource(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MapView, defStyleAttr, 0);
int resId = a.getResourceId(R.styleable.MapView_map, -1);
a.recycle();
setMapResId(resId);
}
/**
* 设置地图资源Id
*/
public void setMapResId(int resId) {
mMapResId = resId;
executeLoad();
}
/**
* 执行加载
*/
private void executeLoad() {
// ...
}
//...
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
if (mThreadPool != null) {
// 释放线程池
mThreadPool.shutdown();
}
}
}
我们通过自定义属性获取需要解析的地图SVG的资源id,setMapResId暴露给外部动态设置或者更改地图资源。
我们将使用Document对xml进行解析,获取我们需要的所有path节点上的"android:pathData"节点的数据,将数据转换成Path并封装成ProvinceItem对象。
在这里我们还需要做一件事,由于我们SVG中的节点坐标数据都是固定的,所以导致最后绘制出来的地图在不同分辨率的手机上显示的结果会有很大差异,因此我们需要对整个地图根据屏幕进行缩放,当然,这里只是做最简单的适配。
通过之前对SVG的数据观察可知,每一个path代表着一个省份的区域,绘制的时候是一个省份一个省份进行绘制的,那我们又如何知道整个地图的边界在哪里呢,所以在这里我们就需要通过每一个path来对比确定整个地图的边界。
MapView.java
public class MapView extends View {
// ...
/**
* 解析得到的省份列表
*/
private List<ProvinceItem> mItemList;
/**
* 整个地图的最大矩形边界
*/
private RectF mMaxRect;
/**
* 省份区块颜色列表
*/
private int[] mColorArray = new int[] { 0xFF239BD7, 0xFF30A9E5, 0xFF80CBF1 };
// ...
/**
* 执行加载
*/
private void executeLoad() {
if (mMapResId <= 0) {
return;
}
mThreadPool.execute(new Runnable() {
@Override
public void run() {
// 获取xml文件输入流
InputStream inputStream = getResources().openRawResource(mMapResId);
// 创建解析实例
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder;
try {
builder = factory.newDocumentBuilder();
// 解析输入流,得到Document实例
Document doc = builder.parse(inputStream);
// 获取根节点,即vector节点
Element rootElement = doc.getDocumentElement();
// 获取所有的path节点
NodeList items = rootElement.getElementsByTagName("path");
// 以下四个变量用来保存地图四个边界,用于确定缩放比例(适配屏幕)
float left = -1;
float right = -1;
float top = -1;
float bottom = -1;
// 解析path节点
List<ProvinceItem> list = new ArrayList<>();
for (int i = 0; i < items.getLength(); ++i) {
Element element = (Element) items.item(i);
// 获取pathData内容
String pathData = element.getAttribute("android:pathData");
// 将pathData转换为path
Path path = PathParser.createPathFromPathData(pathData);
// 封装成ProvinceItem对象
ProvinceItem provinceItem = new ProvinceItem(path);
provinceItem.setDrawColor(mColorArray[i % mColorArray.length]);
RectF rectF = new RectF();
// 计算当前path区域的矩形边界
path.computeBounds(rectF, true);
// 判断边界,最终获得的就是整个地图的最大矩形边界
left = left < 0 ? rectF.left : Math.min(left, rectF.left);
right = Math.max(right, rectF.right);
top = top < 0 ? rectF.top : Math.min(top, rectF.top);
bottom = Math.max(bottom, rectF.bottom);
list.add(provinceItem);
}
// 解析完成,保存节点列表和最大边界
mItemList = list;
mMaxRect = new RectF(left, top, right, bottom);
// 通知重新布局和绘制
post(new Runnable() {
@Override
public void run() {
requestLayout();
invalidate();
}
});
} catch (Exception e) {
e.printStackTrace();
}
}
});
}
}
我们并不需要通过一个一个关键字符去解析"android:pathData"中的数据,Google已经为我们封装好了解析pathData的方法,PathParser.createPathFromPathData可以帮我们完成数据的解析,返回一个Path对象。
Path.computeBounds可以计算出path的最大矩形边界,我们将每个path的最大矩形边界的边界最小或最大值组合起来就可以得到整个地图的矩形边界。
封装成ProvinceItem对象时,我们将得到的Path和一个颜色值传入了ProvinceItem,之后,它将完成自己的path的绘制,并通过path的范围判断是否处理点击事件。
解析完成后,调用post方法,通知重新布局和绘制,这里用的到post方法是View本身就封装的方法,该方法会post一个Runnable到UI线程中执行。
接下来看下ProvinceItem的代码。
ProvinceItem.java
public class ProvinceItem {
/**
* 省份路径
*/
private Path mPath;
/**
* 区块颜色
*/
private int mDrawColor;
/**
* Path的有效区域
*/
private Region mRegion;
public ProvinceItem(Path path) {
this.mPath = path;
RectF rectF = new RectF();
// 计算path的边界, exact参数无所谓,该方法不再使用这个参数
mPath.computeBounds(rectF, true);
mRegion = new Region();
// 得到path和其最大矩形范围的交集区域
mRegion.setPath(mPath, new Region((int) rectF.left, (int) rectF.top, (int) rectF.right, (int) rectF.bottom));
}
/**
* 设置区块绘制颜色
*/
public void setDrawColor(int drawColor) {
this.mDrawColor = drawColor;
}
// ...
}
构造方法中,我们通过计算Path的矩形边界,与其自身进行交集,从而得到了该Path的有效区域mRegion。通过这个对象,我们就能判断点击事件的位置是否在当前Path的区域内,从而可以判断是否是点击了该省份区域。
解析完成之后,调用了布局和重绘,我们就可以在onMeasure和onDraw方法中进行测量和绘制的处理。
在onMeasure中,主要是通过解析获取到的地图的最大矩形边界,来完成屏幕宽度的适配,计算出缩放比例,然后在onDraw方法中使用该缩放比例绘制所有的path。
MapView.java
public class MapView extends View {
// ...
/**
* 当前选择的省份Item
*/
private ProvinceItem mSelectItem;
/**
* 地图缩放比例
*/
private float mScale = 1f;
// ...
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
if (mMaxRect != null) {
// 获取缩放比例
double mapWidth = mMaxRect.width();
mScale = (float) (width / mapWidth);
}
// 应用测量数据
setMeasuredDimension(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mItemList != null) {
// 使地图从画布左上角开始绘制(图片本身可能存在边距)
canvas.translate(-mMaxRect.left, -mMaxRect.top);
// 设置画布缩放,以(mMaxRect.left, mMaxRect.top)为基准进行缩放
// 因为当前该点对应屏幕左上(0, 0)点
canvas.scale(mScale, mScale, mMaxRect.left, mMaxRect.top);
// 绘制所有省份区域,并设置是否选中状态
for (ProvinceItem provinceItem : mItemList) {
provinceItem.drawItem(canvas, mPaint, mSelectItem == provinceItem);
}
}
}
// ...
}
onMeasure中只单独对屏幕宽度做了适配,这里可以做更复杂的适配处理。但是要注意的是,如果图片本身存在左或上边距,会导致边距区域也会被缩放(因为我们缩放了画布),最终会超出屏幕范围,所以需要将画布左移mMaxRect.left并上移mMaxRect.top,然后再以点(mMaxRect.left, mMaxRect.top)为基准缩放画布,就能保证缩放后,地图的有效区域(此时边距区域已经绘制在屏幕之外了)的绘制起点仍然在屏幕的(0, 0)点。如果没有边距,相当于没有移动,不影响绘制结果。
在onDraw方法中,遍历集合并逐一调用了ProvinceItem.drawItem方法,第三个参数是用来判断当前Item是否被选中,选中时需要绘制明显的边界。
ProvinceItem.java
public class ProvinceItem {
// ...
/**
* 绘制区块
*/
public void drawItem(Canvas canvas, Paint paint, boolean isSelect) {
if (isSelect) {
// 选中状态
paint.clearShadowLayer();
paint.setStrokeWidth(1);
// 绘制填充
paint.setStyle(Paint.Style.FILL);
paint.setColor(mDrawColor);
canvas.drawPath(mPath, paint);
// 绘制描边
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(2);
paint.setColor(Color.BLACK);
canvas.drawPath(mPath, paint);
} else {
// 普通状态
paint.setStrokeWidth(2);
// 绘制底色+阴影
paint.setColor(Color.BLACK);
paint.setStyle(Paint.Style.FILL);
paint.setShadowLayer(8, 0, 0, 0xffffff);
canvas.drawPath(mPath, paint);
// 绘制填充
paint.clearShadowLayer();
paint.setColor(mDrawColor);
paint.setStyle(Paint.Style.FILL);
canvas.drawPath(mPath, paint);
}
}
// ...
}
接下来需要MapView监听触摸事件,并将事件依次派发给所有的区块(ProvinceItem),由其来判断是否响应和消费事件。
MapView.java
public class MapView extends View {
// ...
@Override
public boolean onTouchEvent(MotionEvent event) {
// 将事件分发给所有的区块,如果事件未被消费,则调用View的onTouchEvent,这里会默认范围false
if (handleTouch((int) (event.getX() / mScale + mMaxRect.left), (int) (event.getY() / mScale + mMaxRect.top), event)) {
return true;
}
return super.onTouchEvent(event);
}
/**
* 派发触摸事件
*/
private boolean handleTouch(int x, int y, MotionEvent event) {
if (mItemList == null) {
return false;
}
boolean isTouch = false;
ProvinceItem selectItem = null;
for (ProvinceItem provinceItem : mItemList) {
// 依次派发事件
if (provinceItem.isTouch(x, y, event)) {
// 选中省份区块
selectItem = provinceItem;
isTouch = true;
break;
}
}
if (selectItem != null && selectItem != mSelectItem) {
mSelectItem = selectItem;
// 通知重绘
postInvalidate();
}
return isTouch;
}
}
这里很好理解,派发,处理,如果有一个区块处理了就退出循环并选中该区块,若选中的区块与先前选中的不同,则要通知重绘。
需要说明的是,将点击事件的位置传入时,需要对位置进行反向缩放,因为绘制地图的path都是被缩放过的,也就表示他们的识别范围也缩放了,所以需要对点击位置进行反向缩放还原;同样的,需要排除左、上边距的干扰,x和y反向缩放后需要分别加上左、上的偏移后,这样得到的点就还原到了未缩放时地图上对应的点。
接下来看下ProvinceItem的isTouch方法,该方法里进行了事件的判断和消费处理。
ProvinceItem.java
public class ProvinceItem {
// ...
public boolean isTouch(int x, int y, MotionEvent event) {
boolean isTouch = mRegion.contains(x, y);
if (isTouch) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
// 按下
break;
case MotionEvent.ACTION_MOVE:
// 滑动
break;
case MotionEvent.ACTION_UP:
// 抬起
break;
default:
break;
}
}
return isTouch;
}
}
通过我们之前求出来的Path的有效区域mRegion的contains方法即可判断点(x,y)是否在区域内,是否需要消费事件。从这里也能明了,为什么事件的点击位置要反向缩放,因为这里的mRegion是通过未被缩放的Path计算出来的,所以它还是最原始的值。
MainActivity.java
public class MainActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
}
什么都不用改。
布局文件如下:
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<com.mkl.svg.MapView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:map="@raw/china"/>
</LinearLayout>
我们只需要在这里传入不同的map即可绘制出不同的区块可点击的地图。
SVG的使用非常的灵活,多使用SVG不仅可以优化安装包大小,保证图片质量不失真,还能实现很多酷炫的效果,比如,我们可以控制一个一个path的绘制,或快或慢,从而实现各种各样的动画,非常的灵活,它的可编辑性和可编程性太高了~