Cenário de Implementação
Este guia demonstra como criar um aplicativo Android usando Jetpack Compose que incorpora um mapa interativo baseado na biblioteca OpenLayers via WebView. A aplicação permite ao usuário definir dois pontos no mapa, conectá-los com uma linha e gerar um conjunto de linhas paralelas a essa linha-base, com a quantidade e o espaçamento definidos pelo usuário. As coordenadas de todas as linhas são exportadas para um arquivo CSV.
Desafios Técnicos e Solução Proposta
A principla limitação é que o OpenLayers é uma biblioteca JavaScript. Para utilizá-la em um aplicativo nativo Android, adotamos as seguintes estratégias:
- WebView no Compose: Utilizamos o composable
AndroidViewpara hospedar umaWebView, que carrega a aplicação web construída com OpenLayers. - Comunicação Nativa-JavaScript: Implementamos uma interface de ponte (
JavascriptInterface) para permitir que o código Kotlin chame funções JavaScript e vice-versa. Isso é essencial para passar os parâmetros de entrada (quantidade e espaçamento das linhas) do lado nativo para a lógica do mapa, e para enviar os dados das coordenadas geradas de volta para o aplicativo. - Gerenciamento de Estado: O ViewModel do Hilt é utilizado para manter o estado dos parâmetros de entrada do usuário, garantindo que as alterações sejam refletidas de forma reativa no mapa.
- Persistência de Dados: As coordenadas recebidas do JavaScript são processadas e salvas em um arquivo CSV no armazenamento interno do aplicativo.
Implementação do Lado Android (Kotlin & Compose)
1. Configuração do Projeto e Dependências
Criamos um novo projeto Compose e adicionamos as dependências necessárias ao arquivo build.gradle.kts do módulo app:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("kotlin-kapt")
id("com.google.dagger.hilt.android")
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
// Ícones estendidos do Material
implementation("androidx.compose.material:material-icons-extended")
// Injeção de dependência com Hilt
implementation("com.google.dagger:hilt-android:2.51")
kapt("com.google.dagger:hilt-android-compiler:2.51")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
}
kapt {
correctErrorTypes = true
}
No arquivo build.gradle.kts do projeto (raiz), aplicamos o plugin do Hilt:
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
id("com.google.dagger.hilt.android") version "2.51" apply false
}
2. Configuração do Hilt
Criamos a classe de aplicação anotada com @HiltAndroidApp e a registramos no AndroidManifest.xml:
// MapApp.kt
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MapApp : Application()
<application
android:name=".MapApp"
...>
Nossa MainActivity também deve ser anotada:
@AndroidEntryPoint
class MainActivity : ComponentActivity() { ... }
3. ViewModel para o Estado do Mapa
Este ViewModel armazena os parâmetros de configuração das linhas paralelas que o usuário insere na interface nativa.
// MapConfigViewModel.kt
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class MapConfigViewModel @Inject constructor() : ViewModel() {
var parallelLineCount by mutableIntStateOf(10)
var lineSpacingInMeters by mutableIntStateOf(10)
}
4. Estrutura de Navegação
Definimos dois destinos na navegação: a tela principal com o mapa e uma tela para visualizar o arquivo CSV gerado.
// AppNavigation.kt
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
const val MapScreenRoute = "map_display"
const val CsvViewerRoute = "csv_viewer"
@Composable
fun AppNavigationGraph(
navController: NavHostController,
modifier: Modifier = Modifier
) {
NavHost(
navController = navController,
startDestination = MapScreenRoute,
modifier = modifier
) {
composable(MapScreenRoute) {
MapDisplayScreen(navController = navController)
}
composable(CsvViewerRoute) {
CsvPreviewScreen(navController = navController)
}
}
}
5. Tela Principal com WebView e Campos de Entrada
Esta tela compõe a WebView (carregando a aplicação web local) e dois campos de texto na parte inferior para configuração.
// MapDisplayScreen.kt
import android.annotation.SuppressLint
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
// ... outras importações
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun MapDisplayScreen(
navController: NavController,
mapConfigVM: MapConfigViewModel = hiltViewModel()
) {
val context = LocalContext.current
val webViewInstance = remember { WebView(context) }
// Efeito que recarrega a WebView quando os parâmetros mudam
LaunchedEffect(mapConfigVM.parallelLineCount, mapConfigVM.lineSpacingInMeters) {
webViewInstance.loadUrl("file:///android_asset/index.html")
}
Scaffold(
topBar = {
TopAppBar(title = { Text("OpenLayers no Android") },
actions = {
IconButton(onClick = { navController.navigate(CsvViewerRoute) }) {
Icon(Icons.Default.FolderOpen, contentDescription = "Ver CSV")
}
})
}
) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).fillMaxSize()) {
// Área da WebView
AndroidView(
modifier = Modifier.weight(1f).fillMaxWidth(),
factory = {
webViewInstance.apply {
settings.javaScriptEnabled = true
settings.allowFileAccess = true
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
webViewClient = CustomWebViewClient()
addJavascriptInterface(
WebAppBridge(context, mapConfigVM.parallelLineCount, mapConfigVM.lineSpacingInMeters),
"NativeBridge"
)
loadUrl("file:///android_asset/index.html")
}
},
update = { webView ->
// Atualizar a interface JS com novos parâmetros seria feito aqui ou via reload
}
)
// Painel de Configurações (Campos de Entrada)
Surface(tonalElevation = 3.dp) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
NumberInputField(
value = mapConfigVM.parallelLineCount,
onValueChange = { mapConfigVM.parallelLineCount = it },
label = "Quantidade de Linhas Paralelas"
)
NumberInputField(
value = mapConfigVM.lineSpacingInMeters,
onValueChange = { mapConfigVM.lineSpacingInMeters = it },
label = "Espaçamento entre Linhas (metros)"
)
}
}
}
}
}
@Composable
private fun NumberInputField(value: Int, onValueChange: (Int) -> Unit, label: String) {
OutlinedTextField(
value = value.toString(),
onValueChange = { text ->
text.toIntOrNull()?.takeIf { it > 0 }?.let { onValueChange(it) }
},
label = { Text(label) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
6. Implementação da Ponte JavaScript
Esta classe é exposta ao JavaScript da WebView. Ela fornece os parâmetros de configuração e recebe os dados das coordenadas.
// WebAppBridge.kt
import android.content.Context
import android.webkit.JavascriptInterface
import org.json.JSONArray
// ... outras importações
class WebAppBridge(
private val appContext: Context,
private val currentLineCount: Int,
private val currentSpacing: Int
) {
// Métodos chamados pelo JavaScript
@JavascriptInterface
fun getLineCount(): Int = currentLineCount
@JavascriptInterface
fun getSpacing(): Int = currentSpacing
// Método chamado pelo JS para enviar os dados das linhas geradas
@JavascriptInterface
fun exportLineCoordinates(jsonString: String) {
try {
val coordinatesArray = JSONArray(jsonString)
val lineDataList = parseCoordinatesJson(coordinatesArray)
FileStorageHelper.saveLinesToCsv(lineDataList, "parallel_lines.csv", appContext)
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun parseCoordinatesJson(json: JSONArray): List<linecoordinates> {
val lines = mutableListOf<linecoordinates>()
for (i in 0 until json.length()) {
val lineJson = json.getJSONArray(i)
val startPoint = lineJson.getJSONArray(0)
val endPoint = lineJson.getJSONArray(1)
lines.add(
LineCoordinates(
startLon = "%.6f".format(startPoint.getDouble(0)),
startLat = "%.6f".format(startPoint.getDouble(1)),
endLon = "%.6f".format(endPoint.getDouble(0)),
endLat = "%.6f".format(endPoint.getDouble(1))
)
)
}
return lines
}
data class LineCoordinates(val startLon: String, val startLat: String, val endLon: String, val endLat: String)
}</linecoordinates></linecoordinates>
7. Utilitários para Arquivos
// FileStorageHelper.kt
import android.content.Context
import java.io.File
import java.io.FileWriter
object FileStorageHelper {
fun saveLinesToCsv(lines: List<webappbridge.linecoordinates>, fileName: String, context: Context) {
try {
val outputFile = File(context.filesDir, fileName)
FileWriter(outputFile).use { writer ->
writer.append("Longitude_A,Latitude_A,Longitude_B,Latitude_B\n")
lines.forEach { line ->
writer.append("${line.startLon},${line.startLat},${line.endLon},${line.endLat}\n")
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
// ... função de leitura para a tela de preview
}</webappbridge.linecoordinates>
Implementação do Lado Web (JavaScript & OpenLayers)
Estrutura do Projeto Web
Utilizamos o Vite para construir a aplicação web. A estrutura principal inclui:
main.js: Ponto de entrada, configura o mapa.modules/mapSetup.js: Configura camadas e interações.modules/drawingLogic.js: Lógica para desenhar pontos, linhas e calcular paralelas.modules/nativeBridge.js: Funções para comunicação com o aplicativo nativo Android.
Lógica Principal do Mapa e Desenho (Reescrita)
// modules/drawingLogic.js
import { fromLonLat } from 'ol/proj';
import { Feature } from 'ol';
import { Point, LineString } from 'ol/geom';
import { Vector as VectorSource } from 'ol/source';
import { Vector as VectorLayer } from 'ol/layer';
import { Circle as CircleStyle, Fill, Stroke, Style } from 'ol/style';
import * as Bridge from './nativeBridge.js';
let startPointFeature = null;
let endPointFeature = null;
let mainLineLayer = null;
let generatedLinesLayer = null;
let collectedCoordinates = [];
// Estilos pré-definidos
const pointStyle = new Style({
image: new CircleStyle({
radius: 7,
fill: new Fill({ color: '#d9534f' }),
stroke: new Stroke({ color: '#c9302c', width: 2 })
})
});
const lineStyle = new Style({
stroke: new Stroke({ color: '#5bc0de', width: 2 })
});
const parallelLineStyle = new Style({
stroke: new Stroke({ color: '#5cb85c', width: 1.5, lineDash: [5, 5] })
});
export function initMapInteractions(map) {
map.on('click', (event) => handleMapClick(event, map));
}
function handleMapClick(event, map) {
const clickedCoord = event.coordinate;
if (!startPointFeature) {
// Criação do Ponto A
startPointFeature = createPointFeature(clickedCoord, pointStyle);
addFeatureToMap(map, startPointFeature);
collectedCoordinates = []; // Resetar
} else if (!endPointFeature) {
// Criação do Ponto B e Linha AB
endPointFeature = createPointFeature(clickedCoord, pointStyle);
addFeatureToMap(map, endPointFeature);
const startCoord = startPointFeature.getGeometry().getCoordinates();
const endCoord = endPointFeature.getGeometry().getCoordinates();
mainLineLayer = drawLine(map, startCoord, endCoord, lineStyle);
collectedCoordinates.push([startCoord, endCoord]);
// Calcular e desenhar paralelas
const lineCount = Bridge.fetchLineCount();
const spacing = Bridge.fetchSpacing();
generatedLinesLayer = createAndDrawParallelLines(map, startCoord, endCoord, lineCount, spacing, parallelLineStyle);
// Exportar coordenadas para o app nativo
Bridge.sendCollectedCoordinates(collectedCoordinates);
} else {
// Resetar para um novo desenho
clearPreviousDrawing(map);
startPointFeature = createPointFeature(clickedCoord, pointStyle);
addFeatureToMap(map, startPointFeature);
}
}
function createPointFeature(coord, style) {
const feature = new Feature(new Point(coord));
feature.setStyle(style);
return feature;
}
function drawLine(map, coordA, coordB, style) {
const lineFeature = new Feature(new LineString([coordA, coordB]));
lineFeature.setStyle(style);
const layer = new VectorLayer({ source: new VectorSource({ features: [lineFeature] }) });
map.addLayer(layer);
return layer;
}
function createAndDrawParallelLines(map, start, end, count, distanceMeters, style) {
const vectorSource = new VectorSource();
const view = map.getView();
const metersPerUnit = view.getProjection().getMetersPerUnit();
const distanceInMapUnits = distanceMeters / metersPerUnit;
const directionVector = [end[0] - start[0], end[1] - start[1]];
const lineLength = Math.sqrt(directionVector[0]**2 + directionVector[1]**2);
const unitDirection = [directionVector[0]/lineLength, directionVector[1]/lineLength];
const perpUnit = [-unitDirection[1], unitDirection[0]]; // Vetor perpendicular
for (let i = -Math.floor(count / 2); i <= Math.floor(count / 2); i++) {
if (i === 0) continue; // Pula a linha principal
const offset = i * distanceInMapUnits;
const offsetStart = [
start[0] + perpUnit[0] * offset,
start[1] + perpUnit[1] * offset
];
const offsetEnd = [
end[0] + perpUnit[0] * offset,
end[1] + perpUnit[1] * offset
];
const lineFeature = new Feature(new LineString([offsetStart, offsetEnd]));
lineFeature.setStyle(style);
vectorSource.addFeature(lineFeature);
collectedCoordinates.push([offsetStart, offsetEnd]);
}
const layer = new VectorLayer({ source: vectorSource });
map.addLayer(layer);
return layer;
}
// ... outras funções auxiliares (addFeatureToMap, clearPreviousDrawing)
Módulo de Comunicação com o App Nativo (Reescrito)
// modules/nativeBridge.js
// Função para obter a quantidade de linhas do app Android
export function fetchLineCount() {
try {
return NativeBridge.getLineCount(); // Chama o método Kotlin anotado
} catch (e) {
console.error("Falha ao obter contagem de linhas do nativo:", e);
return 10; // Valor padrão
}
}
// Função para obter o espaçamento do app Android
export function fetchSpacing() {
try {
return NativeBridge.getSpacing();
} catch (e) {
console.error("Falha ao obter espaçamento do nativo:", e);
return 10;
}
}
// Função para enviar as coordenadas coletadas de volta para o app
export function sendCollectedCoordinates(coordinatesArray) {
try {
const jsonData = JSON.stringify(coordinatesArray);
NativeBridge.exportLineCoordinates(jsonData); // Chama o método Kotlin para salvar
console.log("Coordenadas enviadas com sucesso para o app nativo.");
} catch (e) {
console.error("Falha ao enviar coordenadas para o nativo:", e);
}
}