Have you ever thought of creating a map on Android?

In this article, I will tell you how I created my Mercator projection map from scratch. I’ll describe the basic functionality and methods I used to change camera positions. Finally, I’ll talk about the algorithms I used for multiple touches and, of course, share a link to my repository in Github.

For the record: this is not a magic pill that will solve all your problems but a tutorial, which may be useful for developers to understand the way maps work on Android.

The map is developed from the Mercator projection.
* The Mercator projection is one of the basic map projections used to represent the surface of the Earth or another celestial body on a plane. The projection was developed by the 16th-century geographer Gerardus Mercator and is still used by cartographers, navigators, and geographers.

Image.

The map functionality is quite simple: there are basic functionality (e.g., displaying and converting geodata) and secondary functionality (e.g., changing camera positions and adding markers). All the features add up to a complete map.

The basic functionality is divided into two parts: converting the geodata (latitude and longitude) into x/y coordinates and displaying the data on the screen.

Basic functionality

All objects on the map are geodata that we convert to x/y coordinates and then display on the screen.
Mercator projection geodatabase conversion code:

private const val A = 6378137
private const val B = 6356752.3142
private var ZOOM = 10000
fun converterLatitudeToX(latitude: Double, zoom: Int = ZOOM): Double {
    val rLat = latitude * PI / 180
    val f = (A - B) / A
    val e = sqrt(2 * f - f.pow(2))    
return (A * Math.log(tan(PI / 4 + rLat / 2) * ((1 - e 
* sin(rLat)) / (1 + e * sin(rLat))).pow(e / 2))) / zoom
}
fun converterLongitudeToY(longitude: 
Double, zoom: Int = ZOOM): Double {
    val rLong= longitude * PI / 180
    return (A * rLong) / zoom
}
fun converterGeoData(latitude: Double, longitude: Double, zoom: Int = ZOOM): 
Point {
    return Point(converterLatitudeToX(latitude, zoom), converterLongitudeToY(longitude, zoom))
}

To display, we need to create a custom View class, in which we have to describe the logic of displaying objects on the screen in the onDraw method with the help of Canvas.

In the custom View class, write the following code:

private lateinit var presenter: MapPresenter
private lateinit var bitmap: Bitmap

private var drawCanvas: Canvas? = null

constructor(context: Context) : super(context, null) {
    initMap(context)
}

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

fun initMap(context: Context) {
    presenter = MapPresenter(context)    post {
        initMapCanvas()
    }
}private fun initMapCanvas() {
    bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
    drawCanvas = Canvas(bitmap)    
invalidate()
}

@SuppressLint("DrawAllocation")
override fun onDraw(canvas: Canvas) {
    if (drawCanvas != null) {
        canvas.save()

        drawCanvas!!.drawColor(backgroundColor)

        presenter.getShapes()?.forEach {
            val shape = Path()

            for ((index, shapeItem) in it.shapeList.withIndex()) {
                if (index == 0) {
                    shape.moveTo(shapeItem)
                } else {
                    shape.lineTo(shapeItem)
                }
            }
            drawCanvas!!.save()

            paintShape.color = Color.parseColor(it.color)
            drawCanvas!!.drawPath(shape, paintShape)

            drawCanvas!!.restore()
        }
    }
}

We use Path to display shapes on the screen. The Path class allows you to create straight lines, curves, patterns, and other lines.

object PathUtil {
    fun Path.moveTo(point: Point) {
        this.moveTo(point.x.toFloat(), point.y.toFloat())
    }

    fun Path.lineTo(point: Point) {
        this.lineTo(point.x.toFloat(), point.y.toFloat())
    }
}

In the .xml file Activity, write:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
 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"
    tools:context=".MainActivity">

    <com.maddevs.madmap.map.view.MapView <- our custom View
        android:id="@+id/map"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

The MapPresenter class stores GeoPoint data. The GeoPoint class itself converts latitude and longitude into x/y points during initialization to display in the custom View.

open class GeoPoint(var latitude: Double, var longitude: Double) : 
Point(converterGeoData(latitude, longitude)) {

    private val angleRotate = 270.0
    private val zoom = 10000

    private var angleRotateXY = 0.0
    private var dx: Float = 0f
    private var dy: Float = 0f

    init {
        rotate(angleRotate)
    }
    open fun updateCoordinateZoom(zoomLevel: Int) {
        updateData(converterGeoData(latitude, longitude, zoomLevel))

        rotate(angleRotate)
    }    protected fun updateCoordinate(latitude: Double, longitude:
 Double, changeZoom: Boolean) {
        updateData(converterGeoData(latitude, longitude, zoom))
        rotate(angleRotate + angleRotateXY)

        if (changeZoom) {
            x += dx
            y += dy
        }
    }
}

The GeoPoint class inherits the x/y parameters from the Point class.

open class Point {

    var x: Double
    var y: Double

    constructor(x: Double, y: Double) {
        this.x = x
        this.y = y
    }

    constructor(point: Point) {
        this.x = point.x
        this.y = point.y
    }

    constructor() {
        this.x = Double.NaN
        this.y = Double.NaN
    }

    fun updateData(point: Point) {
        this.x = point.x
        this.y = point.y
    }

    fun updateData(x: Double, y: Double) {
        this.x = x
        this.y = y
    }

    fun rotate(angle: Double) {
        val rad: Double = angle * PI / 180

        val rx = x
        val ry = y

        x = rx * cos(rad) - ry * sin(rad)
        y = ry * cos(rad) + rx * sin(rad)
    }
}

It has the rotate method and a formula for rotating a point in two-dimensional space.
We store the data in a JSON file. And we parse it in the getShapes method.

override fun getShapes(): List<ShapeObject>? {
    val shapes = JSONArray(getAssetsFileString("shapes.json"))

    val result = ArrayList<ShapeObject>()

    for (item in shapes.iterator()) {
        val shapes = ArrayList<ShapeItemObject>()
        val color = item.getString("color")
        val type = item.getString("type")

        for (shape in item.getJSONArray("shape").iterator()) {
            shapes.add(
                ShapeItemObject(
                    shape.getDouble("latitude"),
                    shape.getDouble("longitude")
                )
            )
        }        result.add(ShapeObject(shapes, type, color))

    }    return result
}

In the ShapeItemObject class, parameters are inherited from GeoPoint, where they are already located.

class ShapeItemObject(latitude: Double, longitude: Double) : 
GeoPoint(latitude, longitude)

And in ShapeObject, I added the type and color of the shape to the sheet from ShapeItemObject.

class ShapeObject(val shapeList: List<ShapeItemObject>, 
type: String, val color: String)

And… after all the added logic, we run the application and get the result.

We now have the shapes displayed on the screen. Initially, we have only the island of Madagascar and a part of Africa displayed.
Now we need to add secondary functionality to move the camera around the map.

Secondary functionality

Secondary functionality includes moving the camera by data and by touching the screen with your finger.

The first thing we need to implement is the movement of the camera by the data. For this, the project already has conversion tools and classes for working with geopoints.

In the custom class, we write the onChangeCameraPosition method, and latitude and longitude will be written in the parameters.

fun onChangeCameraPosition(latitude: Double, longitude: Double) {
    post {
        presenter.changeCameraPosition(latitude, longitude)
        invalidate()
    }
}

In the presenter, we write the changeCameraPosition method.

class MapPresenter(context: Context, repository: 
MapRepository = MapRepository(context)) {

    private val shapesRendering: List<ShapeObject>? =
 repository.getShapes()
    private val bordersRendering: List<BorderObject>? =
 repository.getBorders()
    private val bordersLineRendering: List<BorderLineObject>? =
 repository.getBordersLine()
    private val shapesStringRendering: List<StringObject>? =
 repository.getShapesString()

    private lateinit var cameraPosition: CameraPosition

    override fun getShapes(): List<ShapeObject>? {
        return shapesRendering
    }

    override fun getShapesString(): List<StringObject>? {
        return shapesStringRendering
    }

    override fun getBorders(): List<BorderObject>? {
        return bordersRendering
    }

    override fun getBordersLine(): List<BorderLineObject>? {
        return bordersLineRendering
    }

    override fun initCamera(width: Int, height: Int) {
        this.heightY = height / 2
        this.widthC = width / 2

        cameraPosition = CameraPosition(width, height)

        moveCoordiante(
            ((cameraPosition.x * -1) + 
cameraPosition.centerX).toFloat(),
            ((cameraPosition.y * -1) + 
cameraPosition.centerY).toFloat()
        )
    }

    override fun changeCameraPosition(latitude: Double, longitude:
 Double) {
        cameraPosition.updatePosition(latitude, longitude)

        moveCoordiante(
            ((cameraPosition.x * -1) + 
cameraPosition.centerX).toFloat(),
            ((cameraPosition.y * -1) + 
cameraPosition.centerY).toFloat()
        )
    }

    fun estimationData(estimation: Estimation<GeoPoint>) {
        estimation.counting(cameraPosition)

        shapesRendering?.forEach {
            it.shapeList.forEach { item ->
                estimation.counting(item)
            }
        }
    }

fun moveCoordiante(dx: Float, dy: Float) {
        estimationData(object : Estimation<GeoPoint>{
            override fun counting(item: GeoPoint) {
                item.move(dx, dy)
            }
        })
    }
}

We need the Estimation interface to enumerate all the points on the screen.

interface Estimation<T> {
    fun counting(item: T)
}

Now, we need to write the CameraPosition class in GeoPoint; in its constructor, we write the default zero coordinates.

class CameraPosition(width: Int, height: Int) : GeoPoint(0.0, 0.0) {

    var centerX: Int = width / 2
    var centerY: Int = height / 2

    fun updatePosition(latitude: Double, longitude: Double) {
        updateCoordinate(latitude, longitude, true)
    }
}

And then in GeoPoint we write the move method.

open class GeoPoint(var latitude: Double, var longitude: Double) : 
Point(converterGeoData(latitude, longitude)) {

    private val angleRotate = 270.0
    private val zoom = 10000

    private var angleRotateXY = 0.0
    private var dx: Float = 0f
    private var dy: Float = 0f

    init {
        rotate(angleRotate)
    }

fun move(dx: Float, dy: Float) {
        x += dx
        y += dy
    }    open fun updateCoordinateZoom(zoomLevel: Int) {
        updateData(converterGeoData(latitude, longitude, zoomLevel))

        rotate(angleRotate)
    }

    protected fun updateCoordinate(latitude: Double, longitude: 
Double, changeZoom: Boolean) {
        updateData(converterGeoData(latitude, longitude, zoom))
        rotate(angleRotate + angleRotateXY)

        if (changeZoom) {
            x += dx
            y += dy
        }
    }
}
Mad Map.

Now the entire camera moves at the specified coordinates.

How it works: During the initialization of the camera default data is assigned as zero (latitude 0.0 longitude 0.0); after initialization and data conversion, all points on the screen move to the camera point multiplied by -1, and after moving all the points move to the width and height of the custom View divided by 2 (in the middle of the screen).

To check, in Activity we have two buttons that will move the camera according to the entered coordinates:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        one.setOnClickListener {
            map.onChangeCameraPosition(18.902389796969448, 
14.877051673829557)
        }

        two.setOnClickListener {
            map.onChangeCameraPosition(-20.182886472696126, 
46.45624604076147)
        }
    }
}
Result of moveCoordiante.

Now, we need to implement the movement of the map by having the screen touched with your finger. To do this, we need to implement the onTouchEvent method in the custom View.

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
    val x = event.x
    val y = event.y

    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            presenter.touchStart(x, y)
        }
        MotionEvent.ACTION_MOVE -> {
            presenter.touchMove(x, y)
        }
    }
    
    invalidate()
    return true
}

And implement two methods, touchStart and touchMove, in the presenter. They will contain the code for finding the difference between the touches.

private var mX = 0f
private var mY = 0f

fun touchStart(x: Float, y: Float) {
    mX = x
    mY = y
}

fun touchMove(x: Float, y: Float) {
    val dx = x - mX
    val dy = y - mY

    if (Point(x.toDouble(), 
y.toDouble()).distanceTo(Point(mX.toDouble(), mY.toDouble())) < 300) 
{
        moveCoordiante(dx, dy)
    }

    mX = x
    mY = y
}

Also, in the Point class, you must add a method for finding the distance between points distanceTo.

fun distanceTo(point: Point) : Double {
    return sqrt((x - point.x).pow(2) + (y - point.y).pow(2))
}
Result onTouchEvent.

We already have geodata converted into coordinates, movement by touch, and movement by geodata. For full functionality, we need to add a change of map scale.

I added a class for scale types.

class CameraZoom : Point() {
    enum class Type { PLUS, MINUS }
}

To do this, you need to write a method for changing the size of shapes in GeoPoint.

open fun changeZoom(addedZoom: Double, type: CameraZoom.Type) {
    when (type) {
        CameraZoom.Type.PLUS -> {
            x /= addedZoom
            y /= addedZoom
        }
        CameraZoom.Type.MINUS -> {
            x *= addedZoom
            y *= addedZoom
        }
    }
}

Also, add in the presenter the enumeration and call the changeZoom method.

fun changeCameraPosition(zoom: Double, type: CameraZoom.Type) {
    zoomCoordiante(zoom, type)
}

private fun zoomCoordiante(zoom: Double, type: CameraZoom.Type) {
    moveCoordiante((widthC * -1).toFloat(), (heightY * -1).toFloat())

    estimationData(object : Estimation<GeoPoint> {
        override fun counting(item: GeoPoint) {
            item.changeZoom(zoom, type)
        }
    })

    moveCoordiante((widthC).toFloat(), (heightY).toFloat())
}

In the custom View, let’s add the onChangeCameraPosition method.

fun onChangeCameraPosition(zoom: Double, type: CameraZoom.Type) {
    post {
        presenter.changeCameraPosition(zoom, type)
        invalidate()
    }
}

Everything is ready. Now all that’s left is to test. Let’s add 2 buttons and call methods onChangeCameraPosition in Activity.

plus.setOnClickListener {
    map.onChangeCameraPosition(2.0, CameraZoom.Type.PLUS)
}

minus.setOnClickListener {
    map.onChangeCameraPosition(2.0, CameraZoom.Type.MINUS)
}
Result of zoomCoordiante.

Result of the work: the code on the map scale change. Everything works by dividing and shifting the camera to the center of the map. From the code in the presenter, you can see that the moveCoordinate (move the map) method goes to the center first, multiplied by -1. This moves the center to the upper left corner and zooms the map, and then returns everything to the original center by moving everything to the middle.

The last thing left to implement is map rotation. For this, we need to add a class to make the map work and display after rotation, just like in the other implementations.

For this we already have a method for rotation in the Point class.

fun rotate(angle: Double) {
    val rad: Double = angle * PI / 180

    val rx = x
    val ry = y

    x = rx * cos(rad) - ry * sin(rad)
    y = ry * cos(rad) + rx * sin(rad)
}

It is also necessary to implement the CameraRotate class for working with angles and saving the center of the camera position. In it, you need to write the logic of saving the current angle.

class CameraRotate(width: Int, height: Int) : Point((width / 
2).toDouble(), (height / 2).toDouble()) {

    var regulatoryСenterX: Int = width / 2
    var regulatoryСenterY: Int = height / 2

    private var angleRotateXY = 0.0

    fun changeRotate(rotate: Double) {
        rotate(rotate - angleRotateXY)

        angleRotateXY = rotate
    }

    fun move(dx: Float, dy: Float) {
        x += dx
        y += dy
    }
}

Now we need a method to call rotate for all points on the screen in the presenter.

private fun rotateCoordinate(rotate: Double) {
    cameraRotate.changeRotate(rotate)

    estimationData(object : Estimation<GeoPoint>{
        override fun counting(item: GeoPoint) {
            item.changeRotate(rotate)
        }
    })
}

Let’s also add the main method for calling and returning the map to its original state (to the center).

override fun changeCameraPosition(rotate: Double) {
    rotateCoordinate(rotate)

    moveCoordiante(
        ((cameraRotate.x * -1) + 
cameraRotate.regulatoryСenterX).toFloat(),
        ((cameraRotate.y * -1) + 
cameraRotate.regulatoryСenterY).toFloat(),
        true
    )
}

To make the camera work, you need to extend the moveCoordiante method and put a mode in it to call the cameraRotate method move.

private fun moveCoordiante(dx: Float, dy: Float, rotateMode: Boolean
 = false) {
    if (rotateMode) {
        cameraRotate.move(dx, dy)
    }

    estimationData(object : Estimation<GeoPoint>{
        override fun counting(item: GeoPoint) {
            item.move(dx, dy)
        }
    })
}

And call the changeCameraPosition method in the View.

override fun onChangeCameraPosition(rotate: Double) {
    post {
        presenter.changeCameraPosition(rotate)
        invalidate()
    }
}

For testing, we need to add two buttons to change the rotation in the Activity.

var rotateAngel = 0.0

rotate_one.setOnClickListener {
    rotateAngel += 5.0 

   map.onChangeCameraPosition(rotateAngel)
}

rotate_two.setOnClickListener {
    rotateAngel -= 5.0

    map.onChangeCameraPosition(rotateAngel)
}
Result of rotateCoordinate.

Result of rotateCoordinate

All rotation functionality works using the rotate method, only when the rotate method is called, rotation occurs at all points except CameraRotate, where data from the camera class is used to return to the center of the map.

The last things we’ll add to the map are motion, zooming, and rotating the map by multiple screen taps. To do this, we need to add a class to the project for handling touchManager, which will use all the methods we developed above.

But first, we need to slightly modify the rotation functionality (add saving the previous state and the map position relative to this state). Add the rotateNotSaveCoordinate method to the presenter.

private fun rotateNotSaveCoordinate(rotate: Double) {
    cameraRotate.rotate(rotate)

    estimationData(object : Estimation<GeoPoint>{
        override fun counting(item: GeoPoint) {
            item.rotate(rotate)
        }
    })
}

Also add a new method and a point on which to rotate the map.

override fun changeCameraPosition(rotate: Double, regulatoryPoint: 
Point) {
    rotateNotSaveCoordinate(rotate)

    moveCoordiante(
        ((cameraRotate.x * -1) + regulatoryPoint.x).toFloat(),
        ((cameraRotate.y * -1) + regulatoryPoint.y).toFloat(),
        true
    )
}

In the class constructor, let’s specify the interface for calling methods in the presenter.

class TouchManager(var presenter: MapContract.Presenter, width: Int,
 height: Int) {

    private val centerPoint = Point((width / 2).toDouble(), (height 
/ 2).toDouble())

    private var checkBearing = 0.0
    private var checkTepm = 0
    private var checkStartBearing = 0.0
    private var checkEndBearing = 0.0

    private var startBearing = 0.0
    private var distancePoint = 0.0

    fun touch(event: MotionEvent) {
        if (event.pointerCount > 1) {
            val firstPoint = Point(event.getX(0).toDouble(), 
event.getY(0).toDouble())
            val secondPoint = Point(event.getX(1).toDouble(), 
event.getY(1).toDouble()) 

           val middlePoint = firstPoint.middleTo(secondPoint)

           val distance = centerPoint.distanceTo(middlePoint)

            when (event.action) {
                MotionEvent.ACTION_MOVE -> {
                    if (distancePoint == 0.0) {
                        distancePoint = 
firstPoint.distanceTo(secondPoint)
                    }

presenter.touchDoubleMove(middlePoint.x.toFloat(), 
middlePoint.y.toFloat())

                    val checkDistance = distancePoint - 
firstPoint.distanceTo(secondPoint)

                    if (checkDistance > 10) {
                        distancePoint = 0.0

                        presenter.changeCameraPosition(1.02, 
CameraZoom.Type.PLUS)

presenter.changeCameraPosition(centerPoint.pointTo(centerPoint.bearingTo(middlePoint), 
(distance / 100) * 5))
                    } else if (checkDistance < -10) {
                        distancePoint = 0.0 

                       presenter.changeCameraPosition(1.02, 
CameraZoom.Type.MINUS)

presenter.changeCameraPosition(centerPoint.pointTo(middlePoint.bearingTo(centerPoint), 
(distance / 100) * 5))
                    }

                    if (checkStartBearing != 0.0) {
                        checkBearing = 
middlePoint.bearingTo(firstPoint) - checkStartBearing

                        if (checkEndBearing == 0.0) {
                            checkEndBearing = checkBearing
                        }

                        if (abs(checkBearing) > 
abs(checkEndBearing)) {
                            checkEndBearing = checkBearing
                            checkTepm++
                        }

                        if (checkTepm > 6) {
                            if (startBearing != 0.0) {

presenter.changeCameraPosition((middlePoint.bearingTo(firstPoint) - 
startBearing), middlePoint)
                            }
                            startBearing = 
middlePoint.bearingTo(firstPoint)
                        }
                    }

                    checkStartBearing = 
middlePoint.bearingTo(firstPoint)
                }
                MotionEvent.ACTION_POINTER_UP -> {
                    distancePoint = 0.0
                    startBearing = 0.0
                    checkBearing = 0.0
                    checkEndBearing = 0.0
                    checkStartBearing = 0.0
                    checkTepm = 0
                    presenter.touchDoubleEnd()
                }
            }
        } else {
            val x = event.x
            val y = event.y

            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    presenter.touchStart(x, y)
                }
                MotionEvent.ACTION_MOVE -> {
                    presenter.touchMove(x, y)
                }
            }
        }
    }
}

TouchManager works based on the number of touches: one will make only moveCoordinate call work, and for multiple touches, there are parameters in the class based on which the functionality will work.

To use it, you need to write it in the View.

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
    touchManager.touch(event)
    invalidate()
    return true
}
Result of TouchManager.

At this point, we have added the basic toolkit for the map. The next article will deal with the logic of displaying cities (buildings, roads, and other objects) and dynamically adding markers to the map.

Map code: https://github.com/maddevsio

Custom Software and Mobile App Development.

Latest articles here

Modularizing an iOS Application.

Modularizing an iOS Application

Modularization is the process of splitting a codebase into separate modules in order to reuse code and/or separate work across different teams.When...

PWA vs. Native App: Which One to Choose?

PWA vs. Native App: Which One to Choose?

The current pace of life is not comparable to twenty years ago. And current technologies do not stand still. Every day people are seeking ways to...

React Native vs Flutter: Which One to Choose in 2024?

React Native vs Flutter: Which One to Choose in 2024?

Cross-platform technologies are becoming more and more popular. And if not actively replacing native solutions, they are confidently living in...

Go to blog