[Huawei Developers] Freemind R:
More articles like this, you can visit HUAWEI Developer Forum and Medium.​

Previously on All About Maps: Episode 1:

The principles of clean architecture

The importance of eliminating map provider dependencies with abstraction

Drawing polylines and markers on Mapbox Maps, Google Maps (GMS), and Huawei Maps (HMS)

Episode 2: Bounded Regions

Welcome to the second episode of AllAboutMaps. In order to understand this blog post better, I would first suggest reading the Episode 1. Otherwise, it will be difficult to follow the context.

In this episode we will talk about bounded regions:

The GPX parser datasource will parse the the file to get the list of attraction points (waypoints in this case).

The datasource module will emit the bounded region information in every 3 seconds

A rectangular bounded area from the centered attraction points with a given radius using a utility method (No dependency to any Map Provider!)

We will move the map camera to the bounded region each time a new bounded region is emitted.

[IMG="https://communityfile-dre.op.hicloud.com/FileServer/getFile/cmtybbs/023/288/729/5190358000023288729.20200604115020.25455650527605784686785421340424:50510623111715:2800:820615D20C1B0EAC08048B99CF593D3E15A30661ACAA9E9621BBD8323328E87F.gif"]https://communityfile-dre.op.hicloud.com/FileServer/getFile/cmtybbs/023/288/729/5190358000023288729.20200604115020.25455650527605784686785421340424:50510623111715:2800:820615D20C1B0EAC08048B99CF593D3E15A30661ACAA9E9621BBD8323328E87F.gif[/IMG]

ChangeLog since Episode 1

As we all know, software development is continous process. It helps a lot when you have reviewers who can comment on your code and point out issues or come up with suggestions. Since this project is one person task, it is not always easy to spot the flows in the code duirng implementation. The software gets better and evolves hopefully in a good way when we add new features. Once again. I would like to add the disclaimer that my suggestions here are not silver bullets. There are always better approaches. I am more than happy to hear your suggestions in the comments!

You can see the full code change between episode 1 and 2 here:

https://github.com/ulusoyca/AllAboutMaps/compare/episode_1-parse-gpx...episode_2-bounded-region

Here are the main changes I would like to mention:

1- Added MapLifecycleHandlerFragment.kt base class

In episode 1, I had one feature: show the polyline and markers on the map. The base class of all 3 fragments (RouteInfoMapboxFragment, RouteInfoGoogleFragment and RouteInfoHuaweiFragment) called these lifecycle methods. When I added another feature (showing bounded regions) I realized that the new base class of this feature again implemented the same lifecycle methods. This is against the DRY rule (Dont Repeat Yourself)! Here is the base class I introduced so that each feature's base class will extend this one:

/**
* The base fragment handles map lifecycle. To use it, the mapview classes should implement
* [AllAboutMapView] interface.
*/
abstract class MapLifecycleHandlerFragment : DaggerFragment() {

protected lateinit var mapView: AllAboutMapView

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
mapView.onMapViewCreate(savedInstanceState)
}

override fun onResume() {
super.onResume()
mapView.onMapViewResume()
}

override fun onPause() {
super.onPause()
mapView.onMapViewPause()
}

override fun onStart() {
super.onStart()
mapView.onMapViewStart()
}

override fun onStop() {
super.onStop()
mapView.onMapViewStop()
}

override fun onDestroyView() {
super.onDestroyView()
mapView.onMapViewDestroy()
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mapView.onMapViewSaveInstanceState(outState)
}
}

Let's see the big picture now:

[IMG="https://communityfile-dre.op.hicloud.com/FileServer/getFile/cmtybbs/023/288/729/5190358000023288729.20200604083547.91595299529252499968967337800294:50510623111715:2800:6E0B92FAC1FC47A29510DFAFE8FDA55ABBF8EAF131C09E6D5698692040972332.png"]https://communityfile-dre.op.hicloud.com/FileServer/getFile/cmtybbs/023/288/729/5190358000023288729.20200604083547.91595299529252499968967337800294:50510623111715:2800:6E0B92FAC1FC47A29510DFAFE8FDA55ABBF8EAF131C09E6D5698692040972332.png[/IMG]

2- Refactored the abstraction for styles, marker options, and line options.

In the first episode, we encapsulated a dark map style inside each custom MapView. When I intended to use outdoor map style for the second episode, I realized that my first approach was a mistake. A specific style should not be encapsulated inside MapView. Each feature should be able to select different style. I took the responsibility to load the style from MapViews to fragments. Once the style is loaded, the style object is passed to MapView.

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
mapView = binding.mapView
super.onViewCreated(view, savedInstanceState)
binding.mapView.getMapAsync { mapboxMap ->
binding.mapView.onMapReady(mapboxMap)
mapboxMap.setStyle(Style.OUTDOORS) {
binding.mapView.onStyleLoaded(it)
onMapStyleLoaded()
}
}
}

I also realized the need for MarkerOptions and LineOptions entities in our domain module:

data class MarkerOptions(
var latLng: LatLng,
var text: String? = null,
@DrawableRes var iconResId: Int,
var iconMapStyleId: String,
@ColorRes var iconColor: Int,
@ColorRes var textColor: Int
)

data class LineOptions(
var latLngs: List<LatLng>,
@DimenRes var lineWidth: Int,
@ColorRes var lineColor: Int
)

Above entities has properties based on the needs of my project. I only care about the color, text, location, and icon properties of the marker. For polyline, I will customize width, color and text properties. If your project needs to customize the marker offset, opacity, line join type, and other properties, then feel free to add them in your case.

These entities are mapped to corresponding map provider classes:

LineOptions:

private fun LineOptions.toGoogleLineOptions(context: Context) = PolylineOptions()
.color(ContextCompat.getColor(context, lineColor))
.width(resources.getDimension(lineWidth))
.addAll(latLngs.map { it.toGoogleLatLng() })

private fun LineOptions.toHuaweiLineOptions(context: Context) = PolylineOptions()
.color(ContextCompat.getColor(context, lineColor))
.width(resources.getDimension(lineWidth))
.addAll(latLngs.map { it.toHuaweiLatLng() })

private fun LineOptions.toMapboxLineOptions(context: Context): MapboxLineOptions {
val color = ColorUtils.colorToRgbaString(ContextCompat.getColor(context, lineColor))
return MapboxLineOptions()
.withLineColor(color)
.withLineWidth(resources.getDimension(lineWidth))
.withLatLngs(latLngs.map { it.toMapboxLatLng() })
}

MarkerOptions

private fun DomainMarkerOptions.toGoogleMarkerOptions(): GoogleMarkerOptions {
var markerOptions = GoogleMarkerOptions()
.icon(BitmapDescriptorFactory.fromResource(iconResId))
.position(latLng.toGoogleLatLng())
markerOptions = text?.let { markerOptions.title(it) } ?: markerOptions
return markerOptions
}

private fun DomainMarkerOptions.toHuaweiMarkerOptions(context: Context): HuaweiMarkerOptions {
BitmapDescriptorFactory.setContext(context)
var markerOptions = HuaweiMarkerOptions()
.icon(BitmapDescriptorFactory.fromResource(iconResId))
.position(latLng.toHuaweiLatLng())
markerOptions = text?.let { markerOptions.title(it) } ?: markerOptions
return markerOptions
}

private fun DomainMarkerOptions.toMapboxSymbolOptions(context: Context, style: Style): SymbolOptions {
val drawable = ContextCompat.getDrawable(context, iconResId)
val bitmap = BitmapUtils.getBitmapFromDrawable(drawable)!!
style.addImage(iconMapStyleId, bitmap)
val iconColor = ColorUtils.colorToRgbaString(ContextCompat.getColor(context, iconColor))
val textColor = ColorUtils.colorToRgbaString(ContextCompat.getColor(context, textColor))
var symbolOptions = SymbolOptions()
.withIconImage(iconMapStyleId)
.withLatLng(latLng.toMapboxLatLng())
.withIconColor(iconColor)
.withTextColor(textColor)
symbolOptions = text?.let { symbolOptions.withTextField(it) } ?: symbolOptions
return symbolOptions
}

There are minor technical details to handle the differences between map provider APIs but it is out of this blog post's scope.

Earlier our methods for drawing polyline and marker looked like this:

fun drawPolyline(latLngs: List<LatLng>, @ColorRes mapLineColor: Int)
fun drawMarker(latLng: LatLng, icon: Bitmap, name: String?)

After this refactor they look like this:

fun drawPolyline(lineOptions: LineOptions)
fun drawMarker(markerOptions: MarkerOptions)

It is a code smell when the number of the arguments in a method increases when you add a new feature. That's why we created data holders to pass around.

3- A secondary constructor method for LatLng

While working on this feature, I realized that a secondary method that constructs the LatLng entity from double values would also be useful when mapping the entities with different map providers. I mentioned the reason why I use inline classes for Latitude and Longitude in the first episode.

inline class Latitude(val value: Float)
inline class Longitude(val value: Float)

data class LatLng(
val latitude: Latitude,
val longitude: Longitude
) {
constructor(latitude: Double, longitude: Double) : this(
Latitude(latitude.toFloat()),
Longitude(longitude.toFloat())
)
val latDoubleValue: Double
get() = latitude.value.toDouble()

val lngDoubleValue: Double
get() = longitude.value.toDouble()
}

Bounded Region

A bounded region is used to describe a particular area (in many cases it is rectangular) on a map. We usually need two coordinate pairs to describe a region: Soutwest and Northeast. In this stackoverflow answer (https://stackoverflow.com/a/31029389), it is well described:

[IMG="https://communityfile-dre.op.hicloud.com/FileServer/getFile/cmtybbs/023/288/729/5190358000023288729.20200604091652.84473217715360771388196838422269:50510623111715:2800:55D14837F67C9B235119541B2DF607A6CD5AB9359E8A19B091B11FCB7A55642E.png"]https://communityfile-dre.op.hicloud.com/FileServer/getFile/cmtybbs/023/288/729/5190358000023288729.20200604091652.84473217715360771388196838422269:50510623111715:2800:55D14837F67C9B235119541B2DF607A6CD5AB9359E8A19B091B11FCB7A55642E.png[/IMG]

As expected Mapbox, GMS and HMS maps provide LatLngBounds classes. However, they require a pair of coordinates to construct the bound. In our case we only have one location for each attraction point. We want to show the region with a radius from center on map. We need to do a little bit extra work to calculate the location pair but first let's add LatLngBound entity to our domain module:

data class LatLngBounds(
val southwestCorner: LatLng,
val northeastCorner: LatLng
)

Implementation

First, let's see the big (literally!) picture:

[IMG="https://communityfile-dre.op.hicloud.com/FileServer/getFile/cmtybbs/023/288/729/5190358000023288729.20200605063056.04875101404222702625433695543571:50510623111715:2800:612AF53C97DF50E2D230509E139D7AD45B4331DEB2BE3C09576F6EE876EA9D6F.png"]https://communityfile-dre.op.hicloud.com/FileServer/getFile/cmtybbs/023/288/729/5190358000023288729.20200605063056.04875101404222702625433695543571:50510623111715:2800:612AF53C97DF50E2D230509E139D7AD45B4331DEB2BE3C09576F6EE876EA9D6F.png[/IMG]

Thanks to our clean architecture, it is very easy to add a new feature with a new use case. Let's start with the domain module as always:

/**
* Emits the list of waypoints with a given update interval
*/
class StartWaypointPlaybackUseCase
@Inject constructor(
private val routeInfoRepository: RouteInfoRepository
) {
suspend operator fun invoke(
points: List<Point>,
updateInterval: Long
): Flow<Point> {
return routeInfoRepository.startWaypointPlayback(points, updateInterval)
}
}

The user interacts with the app to start the playback of waypoints. I call this playback because playback is "the reproduction of previously recorded sounds or moving images." We have a list of points to be listened in a given time. We will move map camera periodically from one bounded region to another. The waypoints are emitted from datasource with a given update interval. Domain module doesn't know the implementation details. It sends the request to our datasource module.

Let's see our datasource module. We added a new method in RouteInfoDataRepository:

override suspend fun startWaypointPlayback(
points: List<Point>,
updateInterval: Long
): Flow<Point> = flow {
val routeInfo = gpxFileDatasource.parseGpxFile()
routeInfo.wayPoints.forEachIndexed { index, waypoint ->
if (index != 0) {
delay(updateInterval)
}
emit(waypoint)
}
}.flowOn(Dispatchers.Default)

Thanks to Kotlin Coroutines, it is very simple to emit the points with a delay. Roman Elizarov describes the flow api in very neat diagram below. If you are interested to learn more about it, his talks are the best to start with.

[IMG="https://communityfile-dre.op.hicloud.com/FileServer/getFile/cmtybbs/023/288/729/5190358000023288729.20200604093041.81635580917044844448378069463040:50510623111715:2800:973FD9A562C21573EC7D7B58E7738D638EC62F06C413787CE08C770EA037A555.png"]https://communityfile-dre.op.hicloud.com/FileServer/getFile/cmtybbs/023/288/729/5190358000023288729.20200604093041.81635580917044844448378069463040:50510623111715:2800:973FD9A562C21573EC7D7B58E7738D638EC62F06C413787CE08C770EA037A555.png[/IMG]

Long story short, our app module invokes the use case from domain module, domain module forwards the request to datasource module. The corresponding repository class inside datasource module gets the data from GPX datasource and the datasource module orchestrates the data flow.

For full content, you can visit HUAWEI Developer Forum.