Integrando OpenLayers 10.2.1 com Android Jetpack Compose para Exibição de Mapas

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 AndroidView para hospedar uma WebView, 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);
    }
}

Tags: OpenLayers Android Jetpack Compose WebView JavaScript Bridge Kotlin

Publicado em 6-8 21:49 por Thomas