https://apis.map.kakao.com/web/sample/keywordList/
키워드로 검색 후 해당 키워드 클릭 시 마커의 위치를 이동하는 기능을 구현해 보고자 한다.
카카오지도의 경우 팀 프로젝트의 핵심이였고 무조건 구현해야 했던 기능이였다.
카카오 지도 불러오는 방법은 넘어가고 내부의 코드만 보기로 한다.
// 마커를 담을 배열
let markers: any[] = [];
const container = document.getElementById("map");
const options = {
center: new window.kakao.maps.LatLng(38.2313466, 128.2139293),
level: 1,
};
// 지도를 생성한다.
const map = new window.kakao.maps.Map(container, options);
container
와 options
를 이용해 window.kakao.maps
에 지도를 추가하여 생성한다.
const markerPosition = new window.kakao.maps.LatLng(
38.2313466,
128.2139293
);
const marker = new window.kakao.maps.Marker({
position: markerPosition,
});
marker.setMap(map);
1) markerPosition
이라는 변수에 마커의 위치를 담습니다.
2) marker
라는 변수를 이용해서 위치를 markerPosition
의 위치로 지정한다음
3) marker.setMap(map)
이라는 메서드를 통해서 map에 생성합니다.
const ps = new window.kakao.maps.services.Places();
const infowindow = new window.kakao.maps.InfoWindow({ zIndex: 1 });
1) ps
를 이용해 장소 검색 객체를 담습니다. 카카오 자체에서 제공되는 메서드를 이용합니다.
2) infowindow
라는 변수에 InfoWindow를 만들어 줍니다.
// kakao 제공
searchPlaces();
// 내가 적용한 것
const searchForm = document.getElementById("submit_btn");
searchForm?.addEventListener("click", function (e) {
e.preventDefault();
searchPlaces();
});
기존의 searchPlaces()
의 html코드는 다음과 같다.
onsubmit
함수에 searchPlaces()
를 담아서 사용하고 button
의 type에는 submit으로 준 것이다.
내가 구현한 html은 다음과 같다
1) button
에 직접 id를 주고 그 button
의 type을 submit으로 해주었다.
2) searchForm
이라는 변수에 submit_btn
이라는 아이디를 가진 태그를 집어넣어줬다. 여기선 button
이 되고 button
은 searchForm
의 변수에 저장되어있다.
3) searchForm
에 addEventListner
를 이용해서 click
이벤트를 달아주고 그 이벤트에 searchPlaces()
를 달아주었다.
function searchPlaces() {
const keyword = document.getElementById("keyword").value;
if (!keyword.replace(/^\s+|\s+$/g, "")) {
alert("키워드를 입력해주세요!");
return false;
}
ps.keywordSearch(keyword, placesSearchCB);
}
1) 라
에서 만든 searchPlaces()
이다.
2) keyword
라는 변수에 tag의 아이디가 keyword
의 값을 할당해준다.
여기서 id keyword
는 input
이다.
3) 키워드에 아무것도 입력이 되지 않을 시 혹은 정규식에 의해서 올바른 값이 입력되지 않으면 함수가 실행이 되지 않는다.
4) ps.keywordSearch(keyword, placesSearchCB)
올바른 키워드가 입력이 되었으면 다
에서 만든 ps
(장소검색 객체)를 통해 keyword
로 장소검색을 요청한다.
function placesSearchCB(data, status, pagination) {
if (status === window.kakao.maps.services.Status.OK) {
displayPlaces(data);
displayPagination(pagination);
const bounds = new window.kakao.maps.LatLngBounds();
for (let i = 0; i < data.length; i++) {
displayMarker(data[i]);
bounds.extend(new window.kakao.maps.LatLng(data[i].y, data[i].x));
}
map.setBounds(bounds);
} else if (status === window.kakao.maps.services.Status.ZERO_RESULT) {
alert("검색 결과가 존재하지 않습니다.");
} else if (status === window.kakao.maps.services.Status.ERROR) {
alert("검색 결과 중 오류가 발생했습니다.");
}
}
1) 장소 검색이 완료되었으면 data
를 displayPlaces()
의 인자pagination
를 displayPagination()
로 넣어준다.
1-1) displayPlaces()
는 검색 결과 목록과 마커를 표출하는 함수이고,
1-2) displayPagination()
는 검색 결과 목록 하단에 페이지번호를 표시하는 함수이며 아래에서 설명함.
2) 카카오 자체 메서드인 LatLngBounds()
를 이용해 영역을 지정해 bounds
라는 변수에 할당해준다.
검색된 장소 위치를 기준으로 지도 범위를 재설정 하기 위함입니다.
3) map.setBounds(bounds)
를 이용해서 검색된 장소 위치를 기준으로 지도 범위를 재설정 합니다.
4) else if문을 통해서 예외처리를 해준다.
function displayMarker(place) {
const marker = new window.kakao.maps.Marker({
map,
position: new window.kakao.maps.LatLng(place.y, place.x),
});
window.kakao.maps.event.addListener(marker, "click", function (mouseEvent) {
props.setAddress(place);
infowindow.setContent(`
<span>
${place.place_name}
</span>
`);
infowindow.open(map, marker);
const moveLatLon = new window.kakao.maps.LatLng(place.y, place.x);
map.panTo(moveLatLon);
}
);
}
1) 마커를 생성해서 표시해주는 함수입니다.
2) 또한 생성된 마커에 addListener
를 이용해 클릭이벤트를 달아주었습니다.
3) 클릭이벤트 발생시 infowindow
를 생성하여 보여주고 마커의 좌표를 지도 정 중앙에 위치시킵니다.
map.panTo()
라는 메서드를 이용해서 지도를 부드럽게 이동시킵니다.
function displayPlaces(places) {
const listEl = document.getElementById("placesList");
const menuEl = document.getElementById("menu_wrap");
const fragment = document.createDocumentFragment();
// const bounds = new window.kakao.maps.LatLngBounds();
removeAllChildNods(listEl);
removeMarker();
for (let i = 0; i < places.length; i++) {
const placePosition = new window.kakao.maps.LatLng(
places[i].y,
places[i].x
);
const marker = addMarker(placePosition, i);
const itemEl = getListItem(i, places[i]);
// bounds.extend(placePosition);
(function (marker, title) {
window.kakao.maps.event.addListener(
marker,
"mouseover",
function () {
displayInfowindow(marker, title);
}
);
window.kakao.maps.event.addListener(
marker,
"mouseout",
function () {
infowindow.close();
}
);
itemEl.addEventListener("click", function (e) {
displayInfowindow(marker, title);
props.setAddress(places[i]);
map.panTo(placePosition);
});
})(marker, places[i].place_name);
fragment.appendChild(itemEl);
}
listEl?.appendChild(fragment);
menuEl.scrollTop = 0;
// map.panTo(bounds);
}
1) listEl
이라는 변수에 placesList
라는 태그를 담습니다.
여기선 ul태그입니다. 검색결과를 목록화 시킵니다.
2) menuEl
이라는 변수에 menu_wrap
라는 태그를 담습니다.
여기선 div 태그이고 지도 좌측에 나오는 사이드 바입니다.
3) fragment
라는 변수에 Fragment
를 만들어 줍니다. <></>
4) bounds
와 관련된 것들은 리팩토링 과정에서 중복이 발견되어 삭제하였습니다.
5) removeAllChildNods(listEl)
을 통해서 검색 결과 목록에 추가된 항목들을 제거합니다.
6) removeMarker()
는 지도에 표시되고 있는 마커를 제거하는 함수입니다.
5),6)은 추가 검색시 전의 검색결과를 제거하는 역할을 합니다.
7) for문을 이용해서 검색결과를 반복해서 보여주게 됩니다.
검색결과는 displayPlaces()
의 인자로 들어온 places
입니다.
8) for문 내에서 placePosition
이라는 변수에 displayPlaces()
함수에 들어온 인자값(places
)의 좌표를 LatLng()
에 넣어줍니다.
9) for문 내에서 marker
라는 변수에 addMarker()
를 이용해서 마커를 생성합니다. 이 때 addMarker()
의 인자값으로 placePosition
이 들어오며 마커의 좌표를 나타냅니다.
10) for문 내에서 itemEl
이라는 변수에 getListItem()
을 이용해서 검색 결과 항목 Element를 생성합니다. 인자인 i
는 getListItem()
의 index
값을 places[i]
는 places
를 의미합니다.
11) 다음의 함수는 각각 마우스를 호버 했을 때, 아웃했을 때 인포윈도우를 생성, 삭제합니다.
window.kakao.maps.event.addListener(
marker,
"mouseover",
function () {
displayInfowindow(marker, title);
}
);
window.kakao.maps.event.addListener(
marker,
"mouseout",
function () {
infowindow.close();
}
);
12) 10)
에서 생성된 itemEl
(검색결과항목)에 addEventListner
를 이용해서 클릭이벤트를 만들어 준뒤 클릭시 중앙으로 이동하도록 만들었습니다.
즉 좌측 리스트의 아이템을 클릭하면 해당 아이템의 마커가 중앙으로 오게 만들었습니다.
13) listEl
을 fragment
태그에 추가합니다.
즉 검색결과 항목들을 목록(fragment
)에 추가하는 것입니다.
function getListItem(index: any, places: any) {
const el = document.createElement("li");
let itemStr =
'<span class="markerbg marker_' + (index + 1) +'"></span>' + '<div class="info">' + "<h5>" + places.place_name + "</h5>";
if (places.road_address_name) {
itemStr +=
" <span>" +
places.road_address_name +
"</span>" +
' <span class="jibun gray">' +
`<img src="https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/places_jibun.png">
</img>` +
places.address_name +
"</span>";
} else {
itemStr += "<span>" + places.address_name + "</span>";
}
itemStr +=
' <span class="tel">' + places.phone + "</span>" + "</div>";
el.innerHTML = itemStr;
el.className = "item";
return el;
}
1) el
이라는 변수에 li
태그를 만들어서 담습니다.
2) itemStr
은 각각의 검색결과의 이름, 도로명주소, 지번주소, 전화번호를 표시하도록 하였고 이는 개발자에 의해 수정된 값입니다.
3) 생성된 itemStr
은 el.innerHTML
에 할당되고 className
을 item
으로 만들어 줍니다.
4) 이 작업은 좌측의 사이드바에 표시되는 내용입니다.
function addMarker(position: any, idx: any) {
const imageSrc =
"https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/marker_number_blue.png";
const imageSize = new window.kakao.maps.Size(36, 37);
const imgOptions = {
spriteSize: new window.kakao.maps.Size(36, 691),
spriteOrigin: new window.kakao.maps.Point(0, idx * 46 + 10),
offset: new window.kakao.maps.Point(13, 37),
};
const markerImage = new window.kakao.maps.MarkerImage(
imageSrc,
imageSize,
imgOptions
);
const marker = new window.kakao.maps.Marker({
position,
image: markerImage,
});
marker.setMap(map);
markers.push(marker);
return marker;
}
1) 마커이미지와 사이즈 옵션을 조정할 수 있습니다.
2) marker
라는 변수에 marker
가 표시될 좌표, 이미지들을 할당합니다.
3) marker.setMap(map)
을 통해 지도 위에 마커를 표출합니다.
4) markers.push(marker)
를 통해 markers
배열에 생성된 마커를 추가합니다. 이는 가
를 참고하시면 됩니다.
function removeMarker() {
for (let i = 0; i < markers.length; i++) {
markers[i].setMap(null);
}
markers = [];
}
function displayPagination(pagination) {
const paginationEl = document.getElementById("pagination");
const fragment = document.createDocumentFragment();
while (paginationEl?.hasChildNodes()) {
paginationEl.removeChild(paginationEl.lastChild);
}
for (let i = 1; i <= pagination.last; i++) {
const el = document.createElement("a");
el.href = "#";
el.innerHTML = String(i);
if (i === pagination.current) {
el.className = "on";
} else {
el.onclick = (function (i) {
return function () {
pagination.gotoPage(i);
};
})(i);
}
fragment.appendChild(el);
}
paginationEl?.appendChild(fragment);
}
1) paginationEl
이라는 변수에 id가 pagination
을 담습니다.
2) 기존의 추가된 페이지 번호를 삭제하는 함수입니다.
while (paginationEl?.hasChildNodes()) {
paginationEl.removeChild(paginationEl.lastChild);
}
3) for문을 이용해서 페이지네이션 작업을 합니다.
4) for문 내에서 el
이라는 변수에 a
태그를 만들어 담습니다.
5) el
에 href
를 #으로 innerHTML
을 통해서 검색결과의 페이지 수로 변경합니다.
6) 만약에 i
가 현재의 페이지네이션이라면 el
의 className
은 on
으로 변경합니다.
if (i === pagination.current) {
el.className = "on";
}
7) 그렇지 않으면 el
의 onclick
에는 gotoPage()
를 이용해서 선택된 페이지로 이동할수 있게 만듭니다.
else {
el.onclick = (function (i) {
return function () {
pagination.gotoPage(i);
};
})(i);
}
파. 인포윈도우를 생성합니다.
function displayInfowindow(marker: any, title: any) {
const content =
'<div style="padding:5px;z-index:1;">' + title + "</div>";
infowindow.setContent(content);
infowindow.open(map, marker);
}
하. 검색결과 목록의 자식 Element를 제거하는 함수입니다.
function removeAllChildNods(el: any) {
while (el.hasChildNodes()) {
el.removeChild(el.lastChild);
}
}
1) 아
에서 검색 결과 목록에 추가된 아이템들을 제거하는 함수입니다.
const [search, setSearch] = useState("");
const [isOpen, setIsOpen] = useState(true);
const onchangeSearch = (event) => {
setSearch(event?.target.value);
};
const onClickSearchBarOpen = () => {
setIsOpen(!isOpen);
};
const [search, setSearch] = useState("");
const [isOpen, setIsOpen] = useState(true);
input
값으로 들어오는 검색어를 처리하는 onChange
함수입니다.const onchangeSearch = (event: any) => {
setSearch(event?.target.value);
};
onClick
함수 입니다.const onClickSearchBarOpen = () => {
setIsOpen(!isOpen);
};
import { useEffect, useState } from "react";
import * as S from "./WriteMap.styled";
declare const window: typeof globalThis & {
kakao: any;
};
export default function WriteMapPage(props: any) {
useEffect(() => {
const script = document.createElement("script");
script.src =
"//dapi.kakao.com/v2/maps/sdk.js?appkey=f487080ea91748abbd2e3df735d5af4c&libraries=services&autoload=false";
document.head.appendChild(script);
script.onload = () => {
window.kakao.maps.load(function () {
let markers: any[] = [];
const container = document.getElementById("map");
const options = {
center: new window.kakao.maps.LatLng(38.2313466, 128.2139293),
level: 1,
};
const map = new window.kakao.maps.Map(container, options);
const markerPosition = new window.kakao.maps.LatLng(
38.2313466,
128.2139293
);
const marker = new window.kakao.maps.Marker({
position: markerPosition,
});
marker.setMap(map);
const ps = new window.kakao.maps.services.Places();
const infowindow = new window.kakao.maps.InfoWindow({ zIndex: 1 });
const searchForm = document.getElementById("submit_btn");
searchForm?.addEventListener("click", function (e) {
e.preventDefault();
searchPlaces();
});
function searchPlaces() {
const keyword = (
document.getElementById("keyword") as HTMLInputElement
).value;
if (!keyword.replace(/^\s+|\s+$/g, "")) {
alert("키워드를 입력해주세요!");
return false;
}
ps.keywordSearch(keyword, placesSearchCB);
}
function placesSearchCB(data: any, status: any, pagination: any) {
if (status === window.kakao.maps.services.Status.OK) {
displayPlaces(data);
displayPagination(pagination);
const bounds = new window.kakao.maps.LatLngBounds();
for (let i = 0; i < data.length; i++) {
displayMarker(data[i]);
bounds.extend(new window.kakao.maps.LatLng(data[i].y, data[i].x));
}
map.setBounds(bounds);
} else if (status === window.kakao.maps.services.Status.ZERO_RESULT) {
alert("검색 결과가 존재하지 않습니다.");
} else if (status === window.kakao.maps.services.Status.ERROR) {
alert("검색 결과 중 오류가 발생했습니다.");
}
}
function displayMarker(place: any) {
const marker = new window.kakao.maps.Marker({
map,
position: new window.kakao.maps.LatLng(place.y, place.x),
});
window.kakao.maps.event.addListener(
marker,
"click",
function (mouseEvent: any) {
props.setAddress(place);
infowindow.setContent(`
<span>
${place.place_name}
</span>
`);
infowindow.open(map, marker);
const moveLatLon = new window.kakao.maps.LatLng(place.y, place.x);
map.panTo(moveLatLon);
}
);
}
function displayPlaces(places: any) {
const listEl = document.getElementById("placesList");
const menuEl = document.getElementById("menu_wrap");
const fragment = document.createDocumentFragment();
// const bounds = new window.kakao.maps.LatLngBounds();
removeAllChildNods(listEl);
removeMarker();
for (let i = 0; i < places.length; i++) {
const placePosition = new window.kakao.maps.LatLng(
places[i].y,
places[i].x
);
const marker = addMarker(placePosition, i);
const itemEl = getListItem(i, places[i]);
// bounds.extend(placePosition);
(function (marker, title) {
window.kakao.maps.event.addListener(
marker,
"mouseover",
function () {
displayInfowindow(marker, title);
}
);
window.kakao.maps.event.addListener(
marker,
"mouseout",
function () {
infowindow.close();
}
);
itemEl.addEventListener("click", function (e) {
displayInfowindow(marker, title);
props.setAddress(places[i]);
map.panTo(placePosition);
});
})(marker, places[i].place_name);
fragment.appendChild(itemEl);
}
listEl?.appendChild(fragment);
menuEl.scrollTop = 0;
// map.panTo(bounds);
}
function getListItem(index: any, places: any) {
const el = document.createElement("li");
let itemStr =
'<span class="markerbg marker_' +
(index + 1) +
'"></span>' +
'<div class="info">' +
" <h5>" +
places.place_name +
"</h5>";
if (places.road_address_name) {
itemStr +=
" <span>" +
places.road_address_name +
"</span>" +
' <span class="jibun gray">' +
`<img src="https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/places_jibun.png">
</img>` +
places.address_name +
"</span>";
} else {
itemStr += " <span>" + places.address_name + "</span>";
}
itemStr +=
' <span class="tel">' + places.phone + "</span>" + "</div>";
el.innerHTML = itemStr;
el.className = "item";
return el;
}
function addMarker(position: any, idx: any) {
const imageSrc =
"https://t1.daumcdn.net/localimg/localimages/07/mapapidoc/marker_number_blue.png";
const imageSize = new window.kakao.maps.Size(36, 37);
const imgOptions = {
spriteSize: new window.kakao.maps.Size(36, 691),
spriteOrigin: new window.kakao.maps.Point(0, idx * 46 + 10),
offset: new window.kakao.maps.Point(13, 37),
};
const markerImage = new window.kakao.maps.MarkerImage(
imageSrc,
imageSize,
imgOptions
);
const marker = new window.kakao.maps.Marker({
position,
image: markerImage,
});
marker.setMap(map);
markers.push(marker);
return marker;
}
function removeMarker() {
for (let i = 0; i < markers.length; i++) {
markers[i].setMap(null);
}
markers = [];
}
function displayPagination(pagination: any) {
const paginationEl = document.getElementById("pagination");
const fragment = document.createDocumentFragment();
while (paginationEl?.hasChildNodes()) {
paginationEl.removeChild(paginationEl.lastChild);
}
for (let i = 1; i <= pagination.last; i++) {
const el = document.createElement("a");
el.href = "#";
el.innerHTML = String(i);
if (i === pagination.current) {
el.className = "on";
} else {
el.onclick = (function (i) {
return function () {
pagination.gotoPage(i);
};
})(i);
}
fragment.appendChild(el);
}
paginationEl?.appendChild(fragment);
}
function displayInfowindow(marker: any, title: any) {
const content =
'<div style="padding:5px;z-index:1;">' + title + "</div>";
infowindow.setContent(content);
infowindow.open(map, marker);
}
function removeAllChildNods(el: any) {
while (el.hasChildNodes()) {
el.removeChild(el.lastChild);
}
}
});
};
}, []);
const [search, setSearch] = useState("");
const [isOpen, setIsOpen] = useState(true);
const onchangeSearch = (event: any) => {
setSearch(event?.target.value);
};
const onClickSearchBarOpen = () => {
setIsOpen(!isOpen);
};
return (
<S.MapSection className="map_wrap" isOpen={isOpen}>
<div id="map"></div>
<div id="menuDiv">
<div id="menu_wrap" className="bg_white">
<div className="option">
<div>
<div id="map_title">
<div>단짠맛집</div>
</div>
<div id="form">
<input
type="text"
value={search}
id="keyword"
onChange={onchangeSearch}
/>
<button id="submit_btn" type="submit">
<S.SearchIcon />
</button>
</div>
</div>
</div>
<ul id="placesList"></ul>
<div id="pagination"></div>
</div>
<div id="btnDiv">
{isOpen ? (
<div id="btnOn">
<button
id="searchBtn"
onClick={onClickSearchBarOpen}
type="button"
>
<S.LeftDisplayButton />
</button>
</div>
) : (
<div id="btnOn">
<button
id="searchBtn"
onClick={onClickSearchBarOpen}
type="button"
>
<S.RightDisplayButton />
</button>
</div>
)}
</div>
</div>
</S.MapSection>
);
}
import styled from "@emotion/styled";
import {
SearchOutlined,
CaretLeftFilled,
CaretRightFilled,
} from "@ant-design/icons";
interface ISearchBarOpen {
isOpen: boolean;
}
export const MapSection = styled.div`
display: flex;
#map {
width: 920px;
height: 600px;
position: absolute;
overflow: hidden;
border-radius: 20px;
}
#menuDiv {
display: flex;
position: relative;
z-index: 2;
font-size: 12px;
}
#menu_wrap {
position: relative;
width: 400px;
height: 600px;
border-radius: 20px;
overflow-y: auto;
background: rgba(255, 255, 255, 0.7);
display: ${(props: ISearchBarOpen) => (props.isOpen ? "" : "none")};
}
#map_title {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 10px;
}
#form {
display: flex;
justify-content: space-between;
padding: 0px 15px 10px 15px;
}
#keyword {
width: 100%;
border: none;
outline: none;
}
#submit_btn {
background-color: #ff6e30;
border: none;
outline: none;
}
#placesList h5 {
color: #ff6e30;
}
#placesList li {
list-style: square;
}
#placesList .item {
border-bottom: 1px solid #888;
overflow: hidden;
cursor: pointer;
}
#placesList .item .info {
padding: 10px 0 10px 5px;
}
#placesList .item span {
display: block;
margin-top: 4px;
}
#placesList .info .gray {
color: #8a8a8a;
}
#placesList .info .tel {
color: #009900;
}
#btnDiv {
display: flex;
flex-direction: column;
justify-content: center;
}
#pagination {
margin: 10px auto;
text-align: center;
}
#pagination a {
display: inline-block;
margin-right: 10px;
color: #7b7b7b;
}
#pagination .on {
font-weight: bold;
cursor: default;
color: #ff6e30;
}
#btnOn {
height: 600px;
display: flex;
flex-direction: column;
justify-content: center;
}
#searchBtn {
width: 20px;
padding: 0px;
height: 70px;
background-color: #ffa230;
border: none;
outline: none;
}
`;
export const SearchIcon = styled(SearchOutlined)`
color: #fff;
cursor: pointer;
`;
export const LeftDisplayButton = styled(CaretLeftFilled)`
color: #fff;
cursor: pointer;
`;
export const RightDisplayButton = styled(CaretRightFilled)`
color: #fff;
`;
팀프로젝트 때 써뒀던 것을 모아둔 포스팅
안녕하세요. 리액트 네이티브로 앱을 개발하고 있는 초보자입니다.
적자생존님의 글처럼 카카오 지도 api를 활용해서 검색하는 기능을 구현하고 싶은데, 이것이 웹 말고 안드로이드 앱에서도 구현이 가능한지 궁금합니다.
이 글은 혹시 리액트 네이티브가 아닌 리액트를 사용해서 하신 건가요??