Flutter 지도 - 카카오지도 키 발급 및 프로젝트 설정, 그리기 API 구현하기

kkensu
조현철의 개발로그
56 min readMay 15, 2022

Flutter 에서 구글지도와 네이버지도 연동하는 방법에 대해서 작성했습니다. 이어서 이번에는 카카오지도를 연동하는 방법에 대해서 공유하려고 합니다. 혹시 구글지도, 네이버지도 연동관련해서 못 보신 분들은 아래 링크에서 확인해 보시면 됩니다.

구글지도는 구글에서 공식적으로 제공하는 라이브러리가 있고, 네이버지도는 네이버에서 제공한 것은 없고 개인개발자가 만들어서 배포한 라이브러리가 있습니다.

카카오지도 또한 네이버지도와 같이 공식 배포는 없고, 개인이 만든 라이브러리들이 있어서 여러가지 확인해보았습니다. kakaomap_webview, kakao_map_flutter, flutter_kakao_map 이런 라이브러리들이 있었는데 Flutter 오픈채팅방에 확인해보니 많이 사용하는 방법이 kakaomap_webview 를 이용하는 방법이라고 했습니다. 그래서 저도 사용을 해봤는데 사용하기가 좀 불편하여 kakao 관련 라이브러리가 아닌 WebView를 이용하여 직접 구현해 보았습니다. 오늘은 그에 대해 포스팅 해보도록 하겠습니다.

1. 요금정책

1일 300,000회 사용가능 이라고 써 있습니다. 구글지도나 네이버지도에 비해 아주 간단하게 적혀 있습니다.

2. 새 프로젝트 생성 및 라이브러리 설치

저는 계속해서 Android Studio 로 개발을 합니다. 새 Flutter 프로젝트 생성할 때 Organization을 설정하고 Project name을 설정해 주었습니다. 이렇게 하면 Android의 패키지명은 kr.co.kkensu.flutter_kakao_map_sample이 되고 iOS의 번들ID는 kr.co.kkensu.fluterKakaoMapSample이 됩니다.

카카오지도 관련 라이브러리를 사용하는 것이 아니고 WebView를 이용할 건데 2가지 라이브러리를 설치하려고 합니다. 왜 2개를 다 설치하는지는 아래에서 설명하도록 하겠습니다.

flutter pub add webview_flutter
flutter pub add flutter_inappwebview

그 후 WebView를 사용하기 위해서 Android, iOS 각각 설정 해 주어야 하는 부분이 있습니다.

2–1. Android

webview_flutter 라이브러리에 minSdkVersion 을 최소 19 또는 20으로 맞추라고 되어 있습니다.

2–2. iOS

flutter_inappwebview 라이브러리에는 Xcode version을 12 이상으로 맞추라고 되어 있습니다.

위와 같이 WebView를 사용하기 위한 Android, iOS 설정만 하면 됩니다.

3. API KEY 발급하기

API KEY 를 발급받기 위해서는 카카오 개발자콘솔에 가입되어 있어야 합니다. 가입 후에 메인화면에 진입합니다.

처음 애플리케이션 추가할 때 앱 이름을 FLUTTER KAKAO MAP SAMPLE 이라고 적었는데 “허용되지 않는 이름입니다.” 라고 되어 있어서 보니 “KAKAO” 라는 이름이 들어가서 그런 듯 합니다.

  1. 애플리케이션 추가하기
  2. 앱 이름, 사업자명 작성 후 저장
  3. 플랫폼 설정하기
  4. Web 플랫폼 등록
  5. http://localhost:8080 적은 후 저장

위와 같이 설정합니다. 그리고 메인화면에 있는 JavaScript 키가 있는데 그게 API KEY 입니다.

만약 Android, iOS 네이티브 앱을 개발하려면 네이티브 앱 키를 사용해야 하지만 저는 WebView를 이용해서 개발할 것이기 때문에 JavaScript 키를 이용합니다.

4. API KEY 추가하기

우선 프로젝트에 /assets/web 디렉토리를 추가하고 kakaomap.html 파일을 추가했습니다.

<!DOCTYPE html>
<html lang="en">

<head>
<title>Kakao</title>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"/>
<script type="text/javascript"
src='https://dapi.kakao.com/v2/maps/sdk.js?autoload=true&appkey=[appKey]'></script>
</head>

<body style="margin: 0;">
<div id="map" style="width: 100vw; height: 100vh;"></div>
<script src="kakaomap.js"></script>
</body>

</html>

위의 script 영역에 보면 src에 “<script type=”text/javascript”
src=’https://dapi.kakao.com/v2/maps/sdk.js?autoload=true&appkey=[appKey]'></script>”를 추가했습니다. appKey 부분은 3번에서 발급 받았던 JavaScript 키를 넣으시면 됩니다. 대괄호([])부분까지 한꺼번에 교체하셔야 합니다.

// 예시
<script type="text/javascript"
src='https://dapi.kakao.com/v2/maps/sdk.js?autoload=true&appkey=123123123'></script>

kakaomap.js 소스 코드는 전체 소스코드를 확인하시면 됩니다. 맨 아래에 전체소스 링크 걸어 놓겠습니다.

5. 로컬서버 띄우기

main.dartimport 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_kakao_map_sample/src/home.dart';

InAppLocalhostServer server = InAppLocalhostServer(port: 8080);

void main() async {
WidgetsFlutterBinding.ensureInitialized();

await server.start();

runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Home(),
);
}
}

위에서 웹뷰 라이브러리를 왜 2개나 사용하는지에 대해 궁금하셨던 분들이 있을수도 있는데 위 소스에서 처럼 로컬서버를 사용하기 위함 이었습니다. 처음에는 webview_flutter 라이브러리만 이용해서 구현하려고 했었는데요. html에서 javascript를 동적으로 로드하는 것, 커스텀 마커를 사용할 때 프로젝트 내부의 이미지를 사용하는것 등 이 안되어서 결국 flutter_inappwebview 라이브러리를 사용해서 로컬서버를 띄웠습니다.

6. 카카오지도 연동을 위한 위젯 만들기

class KakaoMap extends StatefulWidget {
final MapCreateCallback? onMapCreated;
final OnMapTap? onMapTap;
final OnCameraIdle? onCameraIdle;
final OnZoomChanged? onZoomChanged;

final Set<Polyline>? polylines;
final Set<Circle>? circles;
final Set<Polygon>? polygons;
final List<Marker>? markers;

KakaoMap({
Key? key,
this.onMapCreated,
this.onMapTap,
this.onCameraIdle,
this.onZoomChanged,
this.polylines,
this.circles,
this.polygons,
this.markers,
}) : super(key: key);

@override
State<KakaoMap> createState() => _KakaoMapState();
}

class _KakaoMapState extends State<KakaoMap> {
late final KakaoMapController _mapController;

@override
Widget build(BuildContext context) {
return WebView(
initialUrl: 'http://localhost:8080/assets/web/kakaomap.html',
javascriptMode: JavascriptMode.unrestricted,
onWebViewCreated: (WebViewController webViewController) {
_mapController = KakaoMapController(webViewController);
if (widget.onMapCreated != null) widget.onMapCreated!(_mapController);
// _loadHtmlFromAssets();
},
javascriptChannels: _channels,
);
}

_loadHtmlFromAssets() async {
String fileText = await rootBundle.loadString('assets/web/kakaomap.html');
await _mapController.webViewController.loadUrl(Uri.dataFromString(fileText, mimeType: 'text/html', encoding: Encoding.getByName('utf-8')).toString());
}


@override
void didUpdateWidget(KakaoMap oldWidget) {
_mapController.addPolyline(polylines: widget.polylines);
_mapController.addCircle(circles: widget.circles);
_mapController.addPolygon(polygons: widget.polygons);
_mapController.addMarker(markers: widget.markers);
}

Set<JavascriptChannel>? get _channels {
Set<JavascriptChannel>? channels = {};

channels.add(JavascriptChannel(
name: 'onMapTap',
onMessageReceived: (JavascriptMessage result) {
if (widget.onMapTap != null) widget.onMapTap!(LatLng.fromJson(jsonDecode(result.message)));
}));

channels.add(JavascriptChannel(
name: 'zoomChanged',
onMessageReceived: (JavascriptMessage result) {
print("zoomChanged ${result.message}");
if (widget.onZoomChanged != null) widget.onZoomChanged!(jsonDecode(result.message)['zoomLevel']);
}));

channels.add(JavascriptChannel(
name: 'cameraIdle',
onMessageReceived: (JavascriptMessage result) {
print("idle ${result.message}");
if (widget.onCameraIdle != null) widget.onCameraIdle!(LatLng.fromJson(jsonDecode(result.message)), jsonDecode(result.message)['zoomLevel']);
}));

return channels;
}
}

6–1. 페이지로드

처음에는 위의 볼드처리한 부분처럼 onWebViewCreated 부분에서 _loadHtmlFromAssets 을 호출하여 html파일을 로드하는 방식으로 구현했었습니다. 그런데 위에서 언급한 것과 같이 안되는 것들이 발생하여 결국 initialUrl에 로컬서버주소를 이용하여 로드 하였습니다.

혹시 이 부분에 대해서 로컬서버를 띄우지 않고도 해결 가능한 방법을 알고계신분은 꼭 알려주세요!

6–2. didUpdateWidget

이 부분은 polyline, circle, polygon, marker 를 넘겨주는 부모 위젯이 변경되면 위젯을 재 구성해야 하기 때문에 사용했습니다.

6–3. JavascriptChannel

JavascriptChannel은 Flutter 소스에서 Javascript 코드를 실행시키는 방법입니다. Javascript 소스에 function을 만들어 두었고 해당 function을 Flutter에서 호출하기 위해 JavascriptChannel을 사용한 것입니다.

  • onMapTap : 맵을 클릭(터치)했을 때 콜백
  • zoomChanged : zoom level 변경시 콜백
  • cameraIdle : 좌표를 드래그 후 멈췄을 때 콜백

저는 여기서 위 세가지만 콜백을 받았지만 원하는 콜백이 있다면 직접 추가하셔서 만드셔야 합니다.

6–4. KakaoMapController

카카오지도는 WebView를 사용하기 때문에 WebViewController를 받아서 사용해야 하는데 이걸 Wrapping 하여 KakaoMapController를 만들었습니다.

class KakaoMapController {
final WebViewController _webViewController;

WebViewController get webViewController => _webViewController;

KakaoMapController(this._webViewController);

addPolyline({Set<Polyline>? polylines}) async {
if (polylines != null) {
clearPolyline();
for (var polyline in polylines) {
await _webViewController.runJavascriptReturningResult(
"addPolyline('${polyline.polylineId}', '${jsonEncode(polyline.points)}', '${polyline.strokeColor?.toHexColor()}', '${polyline.strokeOpacity}', '${polyline.strokeWidth}');");
}
}
}

addCircle({Set<Circle>? circles}) async {
if (circles != null) {
clearCircle();
for (var circle in circles) {
await _webViewController.runJavascript(
"addCircle('${circle.circleId}', '${jsonEncode(circle.center)}', '${circle.radius}', '${circle.strokeWidth}', '${circle.strokeColor?.toHexColor()}', '${circle.strokeOpacity}');");
}
}
}

addPolygon({Set<Polygon>? polygons}) async {
if (polygons != null) {
clearPolygon();
for (var polygon in polygons) {
await _webViewController.runJavascript(
"addPolygon('${polygon.polygonId}', '${jsonEncode(polygon.points)}', '${jsonEncode(polygon.holes)}', '${polygon.strokeWidth}', '${polygon.strokeColor?.toHexColor()}', '${polygon.strokeOpacity}', '${polygon.strokeStyle}', '${polygon.fillColor?.toHexColor()}', '${polygon.fillOpacity}');");
}
}
}

addMarker({List<Marker>? markers}) async {
if (markers != null) {
clearMarker();
for (var marker in markers) {
await _webViewController.runJavascript("addMarker('${marker.markerId}', '${jsonEncode(marker.latLng)}')");
}
}
}

clear() {
_webViewController.runJavascript('clear();');
}

clearPolyline() {
_webViewController.runJavascript('clearPolyline();');
}

clearCircle() {
_webViewController.runJavascript('clearCircle();');
}

clearPolygon() {
_webViewController.runJavascript('clearPolygon();');
}

clearMarker() {
_webViewController.runJavascript('clearMarker();');
}
}

add, clear function 들을 구현해 놓았습니다.

7. 카카오지도 위젯 사용하기 — 그리기 API

KakaoMapController? _kakaoMapController;

Set<Polyline> polylines = {};
Set<Circle> circles = {};
Set<Polygon> polygons = {};
Set<Marker> markers = {};
--------------------------------------------------------------------KakaoMap(
onMapCreated: (KakaoMapController controller) {
_kakaoMapController = controller;
},
onMapTap: (LatLng latLng) {
print(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
print("${jsonEncode(latLng)}");
print(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
},
onCameraIdle: (LatLng latLng, int zoomLevel) {
print(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
print("${jsonEncode(latLng)}");
print("zoomLevel : $zoomLevel");
print(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
},
onZoomChanged: (int zoomLevel) {
print(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
print("zoomLevel : $zoomLevel");
print(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>");
},
polylines: polylines,
circles: circles,
polygons: polygons,
markers: markers.toList(),
),

제가 위에서 직접 만들었던 KakaoMap 위젯을 사용하여 카카오지도를 불러왔습니다.

8. 직선그리기(Polyline) API

Polyline을 그리기 위해서 polylines 변수를 선언해 주었습니다.

Set<Polyline> polylines = {};class BaseDraw {
int? strokeWidth;
Color? strokeColor;
double? strokeOpacity;
String? strokeStyle;
Color? fillColor;
double? fillOpacity;

BaseDraw({
this.strokeWidth,
this.strokeColor,
this.strokeOpacity,
this.strokeStyle,
this.fillColor,
this.fillOpacity,
});
}
class Polyline extends BaseDraw {
final String polylineId;
final List<LatLng> points;

Polyline({
required this.polylineId,
required this.points,
super.strokeWidth,
super.strokeColor,
super.strokeOpacity,
super.strokeStyle,
super.fillColor,
super.fillOpacity,
});
}

이렇게 선언해주고 직선을 두 개 그리도록 아래와 같이 구현했습니다. 그리고 한 가지 dart 2.17 버전 부터 상속받은 부모 클래스의 생성자 접근이 super 키워드를 통해 쉽게 접근되도록 변경되었습니다. pubspec.yaml 파일의 enviroment → sdk 의 버전을 최소 2.17.0 이상으로 설정해 주어야 합니다.

environment:
sdk: ">=2.17.0 <3.0.0"

만약 dart 버전을 올릴 수 없는 상황이라면 아래와 같이 구현해야 합니다.

Polyline({
required this.polylineId,
required this.points,
int? strokeWidth,
Color? strokeColor,
double? strokeOpacity,
String? strokeStyle,
Color? fillColor,
double? fillOpacity,
}) {
this.strokeWidth = strokeWidth;
this.strokeColor = strokeColor;
this.strokeOpacity = strokeOpacity;
this.strokeStyle = strokeStyle;
this.fillColor = fillColor;
this.fillOpacity = fillOpacity;
}

직선을 그리기 위해서 버튼이 눌려지면 직선을 그리도록 아래와 같이 구현했습니다.

ElevatedButton(
child: Text('직선'),
onPressed: () async {
List<LatLng> list = [LatLng(37.3625806, 126.9248464), LatLng(37.3626138, 126.9264801), LatLng(37.3632727, 126.9280313)];

List<LatLng> list2 = [LatLng(37.3616144, 126.9250364), LatLng(37.3614955, 126.9286686), LatLng(37.3608681, 126.9306506), LatLng(37.3594222, 126.9280014)];
setState(() {
polylines.add(Polyline(polylineId: "polyline1", points: list, strokeColor: Colors.red, strokeOpacity: 0.7, strokeWidth: 8));
polylines.add(Polyline(polylineId: "polyline2", points: list2, strokeColor: Colors.blue, strokeOpacity: 1, strokeWidth: 4));
});
},
),

polylineId 부분은 polyline 각각 마다 ID를 부여해주고 관리하기 위해서 넣어주긴 했는데 이걸 실제로 이용하는 부분은 구현하진 않았습니다. 필요하신 분들은 직접 구현해주시면 될 듯 합니다.

setState를 이용하여 polylines를 add 해 주면 polylines가 변경이 되고 KakaoMap 위젯의 didUpdateWidget 이 호출되게 됩니다. didUpdateWidget 내부에 polyline을 지웠다가 다시 그리는 로직이 구현되어 있습니다.

// kakao_map_controller.dartaddPolyline({Set<Polyline>? polylines}) async {
if (polylines != null) {
clearPolyline();
for (var polyline in polylines) {
await _webViewController.runJavascriptReturningResult(
"addPolyline('${polyline.polylineId}', '${jsonEncode(polyline.points)}', '${polyline.strokeColor?.toHexColor()}', '${polyline.strokeOpacity}', '${polyline.strokeWidth}');");
}
}
}

// kakaomap.js
function addPolyline(callId, points, color, opacity = 1.0, width = 4) {
let list = JSON.parse(points);
let paths = [];
for (let i = 0; i < list.length; i++) {
paths.push(new kakao.maps.LatLng(list[i].latitude, list[i].longitude));
}

// 지도에 표시할 선을 생성합니다
let polyline = new kakao.maps.Polyline({
path: paths, // 선을 구성하는 좌표배열 입니다
strokeWeight: width, // 선의 두께 입니다
strokeColor: color, // 선의 색깔입니다
strokeOpacity: opacity, // 선의 불투명도 입니다 1에서 0 사이의 값이며 0에 가까울수록 투명합니다
strokeStyle: 'solid' // 선의 스타일입니다
});

polylines.push(polyline);

// 지도에 선을 표시합니다
polyline.setMap(map);
}

9. 원 그리기(Circle) API

Circle을 그리기 위해서 circles 변수를 선언해 주었습니다. 위 Polyline과 비슷한 부분들이 많아 코드로 보여드리고 넘어가도록 하겠습니다.

Set<Circle> polylines = {};class Circle extends BaseDraw {
final String circleId;
final LatLng center;
double? radius;

Circle({
required this.circleId,
required this.center,
this.radius,
super.strokeWidth,
super.strokeColor,
super.strokeOpacity,
super.strokeStyle,
super.fillColor,
super.fillOpacity,
});
}

이렇게 선언해주고 직선을 두 개 그리도록 아래와 같이 구현했습니다.

// home.dartElevatedButton(
child: Text('원'),
onPressed: () {
LatLng center = LatLng(37.3616144, 126.9250364);
setState(() {
circles.add(Circle(circleId: '1', center: center, radius: 44, strokeColor: Colors.amber, strokeOpacity: 1, strokeWidth: 4));
});
},
),

// kakao_map_controller.dart
addCircle({Set<Circle>? circles}) async {
if (circles != null) {
clearCircle();
for (var circle in circles) {
await _webViewController.runJavascript(
"addCircle('${circle.circleId}', '${jsonEncode(circle.center)}', '${circle.radius}', '${circle.strokeWidth}', '${circle.strokeColor?.toHexColor()}', '${circle.strokeOpacity}');");
}
}
}

// kakaomap.js
function addCircle(callId, center, radius, strokeWeight, strokeColor, strokeOpacity = 1, strokeStyle = 'solid', fillColor = '#FFFFFF', fillOpacity = 0) {
center = JSON.parse(center);

// 지도에 표시할 원을 생성합니다
let circle = new kakao.maps.Circle({
center: new kakao.maps.LatLng(center.latitude, center.longitude), // 원의 중심좌표 입니다
radius: radius, // 미터 단위의 원의 반지름입니다
strokeWeight: strokeWeight, // 선의 두께입니다
strokeColor: strokeColor, // 선의 색깔입니다
strokeOpacity: strokeOpacity, // 선의 불투명도 입니다 1에서 0 사이의 값이며 0에 가까울수록 투명합니다
strokeStyle: strokeStyle, // 선의 스타일 입니다
fillColor: fillColor, // 채우기 색깔입니다
fillOpacity: fillOpacity // 채우기 불투명도 입니다
});

circles.push(circle);

// 지도에 원을 표시합니다
circle.setMap(map);
}
원 그리기 실행결과

10. 다각형 그리기

이전 구글지도, 네이버지도에서 구현했던 것과 마찬가지로 일반 다각형을 그려보고, 구멍(?)이 뚫려있는 다각형을 그려보도록 하겠습니다. Polyline에서 설명했던 것들과 크게 다르지 않아 소스코드와 실행결과로 보여드리도록 하겠습니다.

10–1. Polygon(Multi Polygon)

// home.dartSet<Polygon> polygons = {};
ElevatedButton(
child: const Text('다각형'),
onPressed: () async {
_clear();

List<LatLng> list = [LatLng(37.3625806, 126.9248464), LatLng(37.3626138, 126.9264801), LatLng(37.3632727, 126.9280313)];
List<LatLng> list2 = [LatLng(37.3616144, 126.9250364), LatLng(37.3614955, 126.9286686), LatLng(37.3608681, 126.9306506), LatLng(37.3594222, 126.9280014)];

setState(() {
polygons.add(Polygon(polygonId: "4", points: list, strokeWidth: 4, strokeColor: Colors.blue, strokeOpacity: 1, fillColor: Colors.transparent, fillOpacity: 0));
polygons.add(Polygon(polygonId: "5", points: list2, strokeWidth: 4, strokeColor: Colors.blue, strokeOpacity: 1, fillColor: Colors.transparent, fillOpacity: 0));

fitBounds([...list, ...list2]);
});
},
),

// kakao_map_controller.dart
addPolygon({Set<Polygon>? polygons}) async {
if (polygons != null) {
clearPolygon();
for (var polygon in polygons) {
await _webViewController.runJavascript(
"addPolygon('${polygon.polygonId}', '${jsonEncode(polygon.points)}', '${jsonEncode(polygon.holes)}', '${polygon.strokeWidth}', '${polygon.strokeColor?.toHexColor()}', '${polygon
.strokeOpacity}', '${polygon.strokeStyle}', '${polygon.fillColor?.toHexColor()}', '${polygon.fillOpacity}');");
}
}
}

// kakaomap.js
function addPolygon(callId, points, holes, strokeWeight, strokeColor, strokeOpacity = 1, strokeStyle = 'solid', fillColor = '#FFFFFF', fillOpacity = 0) {
points = JSON.parse(points);
let paths = [];
for (let i = 0; i < points.length; i++) {
paths.push(new kakao.maps.LatLng(points[i].latitude, points[i].longitude));
}

holes = JSON.parse(holes);
if (!empty(holes)) {
let holePaths = [];

for (let i = 0; i < holes.length; i++) {
let array = [];
for (let j = 0; j < holes[i].length; j++) {
array.push(new kakao.maps.LatLng(holes[i][j].latitude, holes[i][j].longitude));
}
holePaths.push(array);
}

return addPolygonWithHole(callId, paths, holePaths, strokeWeight, strokeColor, strokeOpacity, strokeStyle, fillColor, fillOpacity);
}

return addPolygonWithoutHole(callId, paths, strokeWeight, strokeColor, strokeOpacity, strokeStyle, fillColor, fillOpacity);
}
function addPolygonWithoutHole(callId, points, strokeWeight, strokeColor, strokeOpacity = 1, strokeStyle = 'solid', fillColor = '#FFFFFF', fillOpacity = 0) {
// 지도에 표시할 다각형을 생성합니다
let polygon = new kakao.maps.Polygon({
path: points, // 그려질 다각형의 좌표 배열입니다
strokeWeight: strokeWeight, // 선의 두께입니다
strokeColor: strokeColor, // 선의 색깔입니다
strokeOpacity: strokeOpacity, // 선의 불투명도 입니다 1에서 0 사이의 값이며 0에 가까울수록 투명합니다
strokeStyle: strokeStyle, // 선의 스타일입니다
fillColor: fillColor, // 채우기 색깔입니다
fillOpacity: fillOpacity // 채우기 불투명도 입니다
});

polygons.push(polygon);

// 지도에 다각형을 표시합니다
polygon.setMap(map);
}
다각형 그리기 실행결과

10–2. Polygon(Multi Polygon) Hole 구현

// home.dartSet<Polygon> polygons = {};ElevatedButton(
child: const Text('다각형-반전'),
onPressed: () async {
_clear();

List<LatLng> list = [LatLng(37.3625806, 126.9248464), LatLng(37.3626138, 126.9264801), LatLng(37.3632727, 126.9280313)];
List<LatLng> list2 = [LatLng(37.3616144, 126.9250364), LatLng(37.3614955, 126.9286686), LatLng(37.3608681, 126.9306506), LatLng(37.3594222, 126.9280014)];

setState(() {
polygons.add(Polygon(
polygonId: "6",
points: createOuterBounds(),
holes: [list, list2],
strokeWidth: 4,
strokeColor: Colors.blue,
strokeOpacity: 0.7,
fillColor: Colors.black,
fillOpacity: 0.5,
));

fitBounds([...list, ...list2]);
});
},
),

// kakao_map_controller.dart
addPolygon({Set<Polygon>? polygons}) async {
if (polygons != null) {
clearPolygon();
for (var polygon in polygons) {
await _webViewController.runJavascript(
"addPolygon('${polygon.polygonId}', '${jsonEncode(polygon.points)}', '${jsonEncode(polygon.holes)}', '${polygon.strokeWidth}', '${polygon.strokeColor?.toHexColor()}', '${polygon
.strokeOpacity}', '${polygon.strokeStyle}', '${polygon.fillColor?.toHexColor()}', '${polygon.fillOpacity}');");
}
}
}

// kakaomap.js
function addPolygon(callId, points, holes, strokeWeight, strokeColor, strokeOpacity = 1, strokeStyle = 'solid', fillColor = '#FFFFFF', fillOpacity = 0) {
points = JSON.parse(points);
let paths = [];
for (let i = 0; i < points.length; i++) {
paths.push(new kakao.maps.LatLng(points[i].latitude, points[i].longitude));
}

holes = JSON.parse(holes);
if (!empty(holes)) {
let holePaths = [];

for (let i = 0; i < holes.length; i++) {
let array = [];
for (let j = 0; j < holes[i].length; j++) {
array.push(new kakao.maps.LatLng(holes[i][j].latitude, holes[i][j].longitude));
}
holePaths.push(array);
}

return addPolygonWithHole(callId, paths, holePaths, strokeWeight, strokeColor, strokeOpacity, strokeStyle, fillColor, fillOpacity);
}

return addPolygonWithoutHole(callId, paths, strokeWeight, strokeColor, strokeOpacity, strokeStyle, fillColor, fillOpacity);
}
function addPolygonWithHole(callId, points, holes, strokeWeight, strokeColor, strokeOpacity = 1, strokeStyle = 'solid', fillColor = '#FFFFFF', fillOpacity = 0) {
// 다각형을 생성하고 지도에 표시합니다
let polygon = new kakao.maps.Polygon({
map: map,
path: [points, ...holes], // 좌표 배열의 배열로 하나의 다각형을 표시할 수 있습니다
strokeWeight: strokeWeight, // 선의 두께입니다
strokeColor: strokeColor, // 선의 색깔입니다
strokeOpacity: strokeOpacity, // 선의 불투명도 입니다 1에서 0 사이의 값이며 0에 가까울수록 투명합니다
fillColor: fillColor, // 채우기 색깔입니다
fillOpacity: fillOpacity, // 채우기 불투명도 입니다
});

polygons.push(polygon);
}
ElevatedButton(
child: const Text('다각형-반전'),
onPressed: () async {
_clear();

List<LatLng> list = [LatLng(37.3625806, 126.9248464), LatLng(37.3626138, 126.9264801), LatLng(37.3632727, 126.9280313)];
List<LatLng> list2 = [LatLng(37.3616144, 126.9250364), LatLng(37.3614955, 126.9286686), LatLng(37.3608681, 126.9306506), LatLng(37.3594222, 126.9280014)];

setState(() {
polygons.add(Polygon(
polygonId: "6",
points: createOuterBounds(),
holes: [list, list2],
strokeWidth: 4,
strokeColor: Colors.blue,
strokeOpacity: 0.7,
fillColor: Colors.black,
fillOpacity: 0.5,
));

fitBounds([...list, ...list2]);
});
},
),
다각형-반전 그리기 실행결과

다각형 그리기에 Hole을 추가했습니다. 그런데 위의 gif를 확인해 보시면 아시겠지만 지도를 확대하거나 축소할 때 이상하게 적용되는 현상이 발생되었습니다. 소스 코드 작성 하는데 너무 많은 시간이 들어가다 보니 이 현상 까지는 해결하지는 못했습니다… 혹시 해결하신 분이 있다면 알려주세요.. 😭

11. 마커 그리기(Marker) API

마커 그리는 부분도 구글지도, 네이버지도와 비슷하게 구현했습니다.

// home.dartSet<Marker> markers = {};ElevatedButton(
child: const Text('마커'),
onPressed: () {
_clear();

LatLng latLng = LatLng(37.3625806, 126.9248464);
LatLng latLng2 = LatLng(37.3605008, 126.9252204);

setState(() {
markers.add(Marker(markerId: "7", latLng: latLng, markerImageSrc: '/assets/web/marker.png', infoWindowText: 'TEST1'));
markers.add(Marker(markerId: "8", latLng: latLng2, markerImageSrc: '/assets/web/marker.png', infoWindowText: 'TEST2'));

fitBounds([latLng, latLng2]);
});
},
),

// kakao_map_controller.dart
addMarker({List<Marker>? markers}) async {
if (markers != null) {
clearMarker();
for (var marker in markers) {
await _webViewController.runJavascript(
"addMarker('${marker.markerId}', '${jsonEncode(marker.latLng)}', '${marker.markerImageSrc}', '${marker.width}', '${marker.height}', '${marker.offsetX}', '${marker.offsetY}', '${marker
.infoWindowText}')");
}
}
}

// kakaomap.js
function addMarker(markerId, latLng, imageSrc, width = 24, height = 30, offsetX = 0, offsetY = 0, infoWindowText) {
let imageSize = new kakao.maps.Size(width, height); // 마커이미지의 크기입니다
let imageOption = {offset: new kakao.maps.Point(offsetX, offsetY)}; // 마커이미지의 옵션입니다. 마커의 좌표와 일치시킬 이미지 안에서의 좌표를 설정합니다.

let markerImage = null;
// 마커의 이미지정보를 가지고 있는 마커이미지를 생성합니다
if (!empty(imageSrc)) {
markerImage = new kakao.maps.MarkerImage(imageSrc, imageSize, imageOption);
}
latLng = JSON.parse(latLng);
let markerPosition = new kakao.maps.LatLng(latLng.latitude, latLng.longitude); // 마커가 표시될 위치입니다

// 마커를 생성합니다
let marker = new kakao.maps.Marker({
position: markerPosition,
image: markerImage, // 마커이미지 설정
});

// 마커가 지도 위에 표시되도록 설정합니다
marker.setMap(map);

markers.push(marker);

// 마커에 클릭이벤트를 등록합니다
kakao.maps.event.addListener(marker, 'click', function () {
if (!empty(infoWindowText)) {
// 마커 위에 인포윈도우를 표시합니다
if (infoWindow != null) infoWindow.close();
showInfoWindow(marker, latLng.latitude, latLng.longitude, infoWindowText);
}
});
}

마커가 그려지는 소스 흐름은 Polyline에서 설명했던 부분과 크게 다르지 않습니다. Marker 설정할 때 assets에 있는 이미지를 넣도록 했습니다. 만약 markerImageSrc 를 따로 설정하지 않으면 기본마커를 찍도록 했습니다.

마커 그리기 실행결과

12. 마커 정보창(InfoWindow) API

InfoWindow는 마커를 터치하면 해당 마커의 정보를 상세하게 보여줄 때 사용하게 됩니다.

// home.dartSet<Marker> markers = {};ElevatedButton(
child: const Text('마커'),
onPressed: () {
_clear();

LatLng latLng = LatLng(37.3625806, 126.9248464);
LatLng latLng2 = LatLng(37.3605008, 126.9252204);

setState(() {
markers.add(Marker(markerId: "7", latLng: latLng, markerImageSrc: '/assets/web/marker.png', infoWindowText: 'TEST1'));
markers.add(Marker(markerId: "8", latLng: latLng2, markerImageSrc: '/assets/web/marker.png', infoWindowText: 'TEST2'));

fitBounds([latLng, latLng2]);
});
},
),
// kakao_map_controller.dartaddMarker({List<Marker>? markers}) async {
if (markers != null) {
clearMarker();
for (var marker in markers) {
await _webViewController.runJavascript(
"addMarker('${marker.markerId}', '${jsonEncode(marker.latLng)}', '${marker.markerImageSrc}', '${marker.width}', '${marker.height}', '${marker.offsetX}', '${marker.offsetY}', '${marker
.infoWindowText}')");
}
}
}
// kakaomap.jsfunction addMarker(markerId, latLng, imageSrc, width = 24, height = 30, offsetX = 0, offsetY = 0, infoWindowText) {
let imageSize = new kakao.maps.Size(width, height); // 마커이미지의 크기입니다
let imageOption = {offset: new kakao.maps.Point(offsetX, offsetY)}; // 마커이미지의 옵션입니다. 마커의 좌표와 일치시킬 이미지 안에서의 좌표를 설정합니다.

let markerImage = null;
// 마커의 이미지정보를 가지고 있는 마커이미지를 생성합니다
if (!empty(imageSrc)) {
markerImage = new kakao.maps.MarkerImage(imageSrc, imageSize, imageOption);
}
latLng = JSON.parse(latLng);
let markerPosition = new kakao.maps.LatLng(latLng.latitude, latLng.longitude); // 마커가 표시될 위치입니다

// 마커를 생성합니다
let marker = new kakao.maps.Marker({
position: markerPosition,
image: markerImage, // 마커이미지 설정
});

// 마커가 지도 위에 표시되도록 설정합니다
marker.setMap(map);

markers.push(marker);

// 마커에 클릭이벤트를 등록합니다
kakao.maps.event.addListener(marker, 'click', function () {
if (!empty(infoWindowText)) {
// 마커 위에 인포윈도우를 표시합니다
if (infoWindow != null) infoWindow.close();
showInfoWindow(marker, latLng.latitude, latLng.longitude, infoWindowText);
}
});
}
let infoWindow = null;

function showInfoWindow(marker, latitude, longitude, contents = '') {
let iwContent = '<div style="padding:5px;">' + contents + '</div>', // 인포윈도우에 표출될 내용으로 HTML 문자열이나 document element가 가능합니다
iwPosition = new kakao.maps.LatLng(latitude, longitude), //인포윈도우 표시 위치입니다
iwRemovable = true; // removable 속성을 ture 로 설정하면 인포윈도우를 닫을 수 있는 x버튼이 표시됩니다

// 인포윈도우를 생성하고 지도에 표시합니다
infoWindow = new kakao.maps.InfoWindow({
map: map, // 인포윈도우가 표시될 지도
position: iwPosition,
content: iwContent,
removable: iwRemovable
});

infoWindow.open(map, marker);
}

마커를 그릴 때 클릭이벤트가 발생하면 showInfoWindow() 함수를 실행시키도록 했습니다. removable 속성을 true로 설정하면 자동으로 ‘x’ 버튼이 생성됩니다.

InfoWindow 그리기 실행결과

13. 추가 기능

위 예시들에서 _clear() 함수가 존재하는데 직선, 원, 다각형, 다각형-반전, 마커 버튼을 선택하면 지도에 그려졌던 것을 전부 삭제 하도록 만든 함수 입니다. 아래와 같이 구현했습니다.

// home.dart_clear() {
_kakaoMapController?.clear();
polylines.clear();
circles.clear();
polygons.clear();
markers.clear();
}
// kakao_map_controller.dartclear() {
_webViewController.runJavascript('clear();');
}
// kakaomap.jslet polylines = [];
let circles = [];
let polygons = [];
let markers = [];
function clear() {
clearPolyline();
clearCircle();
clearPolygon();
clearMarker();
}
function clearPolyline() {
for (let i = 0; i < polylines.length; i++) {
polylines[i].setMap(null);
}

polylines = [];
}

function clearCircle() {
for (let i = 0; i < circles.length; i++) {
circles[i].setMap(null);
}

circles = [];
}

function clearPolygon() {
for (let i = 0; i < polygons.length; i++) {
polygons[i].setMap(null);
}

polygons = [];
}

function clearMarker() {
for (let i = 0; i < markers.length; i++) {
markers[i].setMap(null);
}

if (infoWindow != null) infoWindow.close();

markers = [];
}

도형이 그려진 모든 좌표들을 한 화면에 보여주도록 하기 위한 기능(fitBounds)을 구현했습니다.

// home.dartfitBounds(List<LatLng> bounds) async {
_kakaoMapController?.fitBounds(bounds);
}

// kakao_map_controller.dart
fitBounds(List<LatLng> points) async {
await _webViewController.runJavascript("fitBounds('${jsonEncode(points)}');");
}

// kakaomap.js
function fitBounds(points) {
let list = JSON.parse(points);

let bounds = new kakao.maps.LatLngBounds();
for (let i = 0; i < list.length; i++) {
// LatLngBounds 객체에 좌표를 추가합니다
bounds.extend(new kakao.maps.LatLng(list[i].latitude, list[i].longitude));
}

map.setBounds(bounds);
}

그리고 fitBounds 의 실행결과는 아래와 같습니다.

14. 전체소스

위 샘플을 github에 올려두었습니다.

위에서 소스 설명할 때 설명 누락된 부분들도 모두 있으니 kakaomap.html 내부에 appkey 부분만 본인 것으로 변경하시면 구동시켜 보실 수 있습니다.

15. 정리

카카오지도를 WebView로 연동하면서 기본적으로 알아야 할 것은 크게 2가지 입니다.

  • dart 에서 javascript 호출하기 → runJavascript
  • javascript 에서 dart 호출하기 → JavascriptChannel

이 두가지를 알면 카카오지도 연동 뿐 아니라 기존에 웹으로 연동되어 있던 것들을 앱으로 쉽게 연동할 수 있을 것으로 예상됩니다.

// kakao_map.dartSet<JavascriptChannel>? get _channels {
Set<JavascriptChannel>? channels = {};

channels.add(JavascriptChannel(
name: 'onMapTap',
onMessageReceived: (JavascriptMessage result) {
if (widget.onMapTap != null) widget.onMapTap!(LatLng.fromJson(jsonDecode(result.message)));
}));

return channels;
}
// kakaomap.jskakao.maps.event.addListener(map, 'click', function (mouseEvent) {
// 클릭한 위도, 경도 정보를 가져옵니다
let latLng = mouseEvent.latLng;

//map.setCenter(latlng);
map.panTo(latLng);

const clickLatLng = {
latitude: latLng.getLat(),
longitude: latLng.getLng(),
zoomLevel: map.getLevel(),
}

onMapTap.postMessage(JSON.stringify(clickLatLng));
});

kakao_map.dart 파일 내부의 JavascriptChannel 을 선언해 두고 kakaomap.js 에서 Listener를 이용하여 콜백이 발생하면 선언했던 JavascriptChannel을 호출하는 방식만 기억하면 될 것 같습니다.

저는 카카오지도 연동을 위해서 Kakao Maps API 예제문서를 찾아보면서 구현했습니다. 이 부분은 Javascript에 대해 조금 아셔야 연동하실 수 있을겁니다. 제가 연동한 것 말고도 여러가지 제공되는 API 를 연동하고 싶으면 직접 연결하셔야 합니다… 만들어 둔 것을 오픈소스로 pub.dev에 올릴까도 고민했지만 완성되지 않을 라이브러리를 올리는것도 혼란만 줄 수도 있을 것이라는 생각에 github에만 올려두었습니다.

카카오지도 연동 포스팅을 간단하게 생각하고 접근했는데… 처음에 직접 WebView로 만들게 될줄은 생각을 못했습니다. 그러다 보니 시간도 오래 걸렸네요. 카카오 지도에 대해 궁금한 점이 있다면 말씀해 주세요! 제가 잘못 한 점도 있다면 피드백 해주시면 저에게도 큰 도움이 될 것 같습니다!

그럼 이만 마무리 하겠습니다! 감사합니다!👏🏻👏🏻👏🏻

--

--