Flutter - 绘制疫情信息地图

用跨平台方案 Flutter 实现疫情地图

Posted by Yuriy on April 14, 2020

Flutter - 绘制疫情信息地图

背景

开发中有时候需要绘制地图,但是Android无法像Html那样使用SVG图片并且实现可点击,可重绘色彩等功能。因此我们需要自己手动去实现这些效果和功能,由于这段时间时间相对充裕,因此下手去研究了一番。

地图组件均已提供了Kotlin和Dart的实现。 示例图中,我们实现了省份可点击效果,上色,描边等。

具体实现 1、解析SVG图片,这里我们使用的是AndroidStudio转换后的Vector图片。

解析SVG-XML文件,

a. PathParser.createPathFromPathData可以根据android:pathData中的数据得到Path, canvas就可以通过Path来绘制图像了!

b. 这里使用的是XmlPullParser来解析XML文件的,由于文件较小,这里没有做多线程处理。

/**
  * 通过地图资源的RawId获取地图信息
  * @param mapRawId 地图资源ID
  */
 fun Context.getChinaMapInfoByMapRawId(@RawRes mapRawId: Int): ChinaMapInfo {
     val xmlPullParser = Xml.newPullParser().apply {
         setInput(StringReader(BufferedInputStream(resources.openRawResource(mapRawId)).bufferedReader().readText()))
     }
     return ChinaMapInfo(provinceInfoList = mutableListOf()).apply {
         var eventType = xmlPullParser.eventType
         while (eventType != XmlPullParser.END_DOCUMENT) {
             try {
                 when (eventType) {
                     XmlPullParser.START_TAG -> when (xmlPullParser.name) {
                         "vector" -> {
                             viewPortWidth = xmlPullParser.getAttributeValue(null, "viewportWidth").toFloat()
                             viewPortHeight = xmlPullParser.getAttributeValue(null, "viewportHeight").toFloat()
                         }
                         "path" -> provinceInfoList?.add(ChinaProvinceInfo(ProvinceLayerPathInfo(
                                 xmlPullParser.getAttributeValue(null, "name"),
                                 xmlPullParser.getAttributeValue(null, "strokeWidth").toFloat(),
                                 Color.parseColor(xmlPullParser.getAttributeValue(null, "strokeColor")),
                                 Color.parseColor(xmlPullParser.getAttributeValue(null, "fillColor")),
                                 PathParser.createPathFromPathData(xmlPullParser.getAttributeValue(null, "pathData"))
                         )))
                     }
                 }
                 eventType = xmlPullParser.next()
             } catch (e: Exception) {
           e.printStackTrace()
             }
         }
     }
 }

2、构造相关的实体类,

地图信息(ChinaMapInfo)

省份图层信息(ProvinceLayerPathInfo)

省份信息(ChinaProvinceInfo): 提供图形绘制、点击区域检测等方法

data class ChinaMapInfo(
        var viewPortWidth: Float = 0f,
        var viewPortHeight: Float = 0f,
        var provinceInfoList: MutableList<ChinaProvinceInfo>? = null
)

data class ProvinceLayerPathInfo(
        var name: String,
        var strokeWidth: Float,
        var strokeColor: Int,
        var backgroundColor: Int,
        var drawPathInfo: Path
)

/**
 * 中国省份信息
 * @param provinceLayerPathInfo 省份图层信息
 */
class ChinaProvinceInfo(private val provinceLayerPathInfo: ProvinceLayerPathInfo) {

    /** 图形路径 **/
    private var path: Path = provinceLayerPathInfo.drawPathInfo

    /** 描边宽度 **/
    private var _borderWidth: Float = provinceLayerPathInfo.strokeWidth

    /** 描边颜色 **/
    private var _borderColor: Int = provinceLayerPathInfo.strokeColor

    /** 背景色 **/
    private var _bgColor: Int = provinceLayerPathInfo.backgroundColor

    /** 文本颜色 **/
    private var _textColor: Int = Color.BLACK

    /** 文本字体大小 **/
    private var _textSize: Float = 9f

    /** 图形所在的Region **/
    private var region: Region = buildRegion(path)

    /** 设置或获取边框宽度 **/
    var borderWidth: Float
        get() = _borderWidth
        set(value) {
            _borderWidth = value
        }

    /** 设置或获取描边颜色 **/
    var borderColor: Int
        get() = _borderColor
        set(value) {
            _borderColor = value
        }

    /** 设置或获取背景颜色 **/
    var backgroundColor: Int
        get() = _bgColor
        set(value) {
            _bgColor = value
        }

    /** 文本颜色 **/
    var textColor: Int
        get() = _textColor
        set(value) {
            _textColor = value
        }

    /** 文本大小 **/
    var textSize: Float
        get() = _textSize
        set(value) {
            _textSize = value
        }

    private fun buildRegion(path: Path): Region {
        val pathBoundsRect = RectF()
        path.computeBounds(pathBoundsRect, false)
        return Region().apply {
            setPath(path, Region(pathBoundsRect.left.toInt(),
                    pathBoundsRect.top.toInt(),
                    pathBoundsRect.right.toInt(),
                    pathBoundsRect.bottom.toInt()))
        }
    }

    /** 是否被点击 **/
    fun isTouched(x: Float, y: Float) = region.contains(x.toInt(), y.toInt())

    /**
     * 绘制省份路径
     * @param canvas 画布
     * @param isFill 是填充还是描边, 默认为TRUE
     * @param pathColor 颜色,如果不能存在该值时使用对象内置的颜色
     */
    fun drawPath(canvas: Canvas?, isFill: Boolean = true, pathColor: Int? = null) {
        val paint = Paint().apply {
            isAntiAlias = true
            if (isFill) {
                style = Paint.Style.FILL
                color = pathColor ?: _bgColor
            } else {
                style = Paint.Style.STROKE
                color = pathColor ?: _borderColor
                strokeWidth = _borderWidth
            }
        }
        canvas?.drawPath(path, paint)
    }

    /**
     * 绘制省份名称
     * @param context 上下文对象
     * @param canvas 画布
     */
    fun drawName(context: Context?, canvas: Canvas?) {
        val provinceName = provinceLayerPathInfo.name
        val paint = Paint().apply {
            isAntiAlias = true
            style = Paint.Style.FILL
            color = _textColor
            textSize = (context?.resources?.displayMetrics?.scaledDensity ?: 0f) * _textSize + 0.5f
        }
        val drawPoint = getNameDrawOffset(provinceName, paint)
        canvas?.drawText(provinceName, drawPoint.x, drawPoint.y, paint)
    }

    /**
     * 获取省份名称的绘制位置
     * @param provinceName 身份名称
     * @param paint 画笔
     */
    private fun getNameDrawOffset(provinceName: String, paint: Paint): PointF {
        val textBounds = Rect()
        paint.getTextBounds(provinceName, 0, provinceName.length, textBounds)
        val regionWidth = region.bounds.width()
        val regionHeight = region.bounds.height()
        val textWidth = textBounds.width()
        val textHeight = textBounds.height()
        var offsetX: Float = (regionWidth - textWidth) / 2f
        var offsetY: Float = (regionHeight - textHeight) * 2f / 3f
        when (provinceName) {
            "重庆" -> offsetY = regionHeight * 0.7f
            "天津" -> {
                offsetX = regionWidth * 0.7f
                offsetY = regionHeight * 1.0f
            }
            "内蒙古" -> offsetY = regionHeight * 4 / 5f
            "河北" -> {
                offsetX = regionWidth * 0.1f
                offsetY = regionHeight * 0.7f
            }
            "甘肃" -> {
                offsetX = regionWidth * 0.15f
                offsetY = regionHeight * 0.23f
            }
            "陕西" -> offsetY = regionHeight * 0.73f
            "江西" -> offsetX = regionWidth * 0.2f
            "江苏" -> offsetX = regionWidth * 0.55f
            "上海" -> {
                offsetX = regionWidth * 0.8f
                offsetY = regionHeight * 0.8f
            }
            "海南" -> offsetY = regionHeight * 0.7f
            "广东" -> offsetY = regionWidth * 0.3f
            "香港" -> {
                offsetX = regionWidth * 1.0f
                offsetY = regionWidth * 1.0f
            }
            "澳门" -> offsetY = regionWidth * 1.0f + textHeight
        }
        return PointF(region.bounds.left + offsetX, region.bounds.top + offsetY)
    }

}

3、中国行政区域绘制

/** 中国省份视图 **/
class ChinaProvinceView : View, View.OnTouchListener {

    private var data: ChinaMapInfo? = null

    private var mapScale: Float = 1.0f

    private var _selectedProvinceInfo: ChinaProvinceInfo? = null

    /** 当前选择的省份 **/
    var selectedProvinceInfo: ChinaProvinceInfo?
        get() = _selectedProvinceInfo
        set(value) {
            _selectedProvinceInfo = value
        }

    /** 省份选择事件 **/
    var onProvinceSelectedChanged: ((ChinaProvinceInfo) -> Unit)? = null

    constructor(context: Context?) : super(context) {
        init(context)
    }

    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
        init(context)
    }

    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
        init(context)
    }

    @SuppressLint("NewApi")
    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
        init(context)
    }

    private fun init(context: Context?) {
        setOnTouchListener(this)
        data = context?.getChinaMapInfoByMapRawId(R.raw.ic_map_china)
    }

    // 处理点击事件
    override fun onTouch(v: View?, event: MotionEvent?): Boolean {
        if (event?.action == MotionEvent.ACTION_DOWN) {
            val selectedProvinceInfo = data?.provinceInfoList?.firstOrNull { it.isTouched(event.x / mapScale, event.y / mapScale) }
            if (selectedProvinceInfo != null && selectedProvinceInfo != _selectedProvinceInfo) {
                _selectedProvinceInfo?.backgroundColor = Color.TRANSPARENT
                _selectedProvinceInfo = selectedProvinceInfo
                _selectedProvinceInfo?.backgroundColor = Color.RED
                onProvinceSelectedChanged?.invoke(selectedProvinceInfo)
                invalidate()
            }
            return true
        }
        return false
    }
   
    // 处理View大小
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val width = MeasureSpec.getSize(widthMeasureSpec)
        var height = MeasureSpec.getSize(heightMeasureSpec)
        if (data != null) {
            mapScale = (width.toFloat() / data!!.viewPortWidth)
            height = (data!!.viewPortHeight * mapScale).toInt()
        }
        setMeasuredDimension(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY))
    }

    //绘制图形函数
    @SuppressLint("DrawAllocation")
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        canvas?.scale(mapScale, mapScale)
        data?.provinceInfoList?.forEach { provinceInfo ->
            provinceInfo.drawPath(canvas, true)
            provinceInfo.drawPath(canvas, false)
        }
        data?.provinceInfoList?.forEach { provinceInfo ->
            provinceInfo.drawName(context, canvas)
        }
    }

}

4、图形绘制新增纯Flutter绘制地图,新增相关的Path路径绘制工具类:PathParser,该类通过Java源码移植到Dart语言。具体代码可查看Git库中的Dev分支即可,保持时常更新。

了解更多,欢迎关注: