매우매우 삽질했던 이야기...... 카카오맵 자체에서는 기본적으로 지원하지 않는 기능인 거 같아 많이 헤맸었다.
🌟 루트 표시 🌟 | (참고) 활동 목록 |
---|
디자인을 보면서 지도에 나타낼 것을 생각해보자.
지도에는 아래 정보를 표시해야 한다.
- 지나온 루트를 선으로 연결해주어야 한다.
- 활동을 순서대로 마커로 표시해주어야 한다. (우측 활동 목록과 비교)
1번은 쉽게 구현할 수 있었지만, 2번은 매우 많은 고민을 했었다.
카카오맵이 24년 6월 30일부터 v2로 바뀌어서.. 관련 자료도 많이 없다는 게 참 마힘이었다.
위의 두 개 문서를 많이 참고했다. 카카오에서 제공해 주는 공식 문서이다.
위의 공식 문서의 LabelStyle를 보면 나에게 필요한 건 Icon과 Text라는 것을 알 수 있었다.
그래서 첫 번째로 떠올렸던 방법은 LabelStyles에서 IconStyle과 LabelTextStyle를 지정한 뒤, "이 Icon과 Text를 겹치는 방법"이었다.
kakaoMap?.labelManager?.layer?.addLabel(LabelOptions.from(latLng)
.setStyles(setPinStyle(this, category))
.setTexts(
LabelTextBuilder().setTexts(activityNumber)
)
)
companion object {
fun setPinStyle(context: Context, category: Category): LabelStyles {
return LabelStyles.from(
LabelStyle.from(category.categoryMarkerIcon)
.setTextStyles(LabelTextStyle.from(35, ContextCompat.getColor(context, R.color.black))
)
)
}
}
그러나.. LabelStyle에서 setPadding
, setTextGravity
, setAnchorPoint
등을 모두 써보고 숫자도 엄청 조절해 봤지만 내가 원하는 '아이콘과 텍스트가 완전히 겹쳐지는 결과'는 나타나지 않았다.
.setPadding(0.5f) | .setAnchorPoint(0f, -1f) | .setTextGravity(1) |
---|
아무리 숫자를 바꿔봐도...... 원하는 결과는 나오지 않았다.
그래서 생각한 방법은 두 번째!
IconLabel과 TextLabel을 나눠서 텍스트가 아이콘 안에 위치하게끔 하는 것이었다.
이 방법이 정답이었다...
companion object {
const val DEFAULT_ZOOM_LEVEL = 10 // 루트를 표시하는 기본 줌 레벨
// IconLabel
private fun setMapIconLabelStyles(category: Category): LabelStyles {
return LabelStyles.from(
LabelStyle.from(category.categoryMarkerIcon)
)
}
fun getMapActivityIconLabelOptions(latLng: LatLng, category: Category, activityNumber: Int): LabelOptions {
return LabelOptions.from(latLng)
.setStyles(setMapIconLabelStyles(category))
}
// TextLabel
private fun setMapTextLabelStyle(): LabelStyles {
return LabelStyles.from(
LabelStyle.from(LabelTextStyle.from(28, Color.BLACK))
)
}
fun getMapActivityNumberLabelOptions(latLng: LatLng, activityNumber: Int): LabelOptions {
return LabelOptions.from(latLng)
.setStyles(setMapTextLabelStyle())
.setTexts(LabelTextBuilder().setTexts(activityNumber.toString()))
}
}
TextLabel의 사이즈는 마커 아이콘에 맞춰 28로 해주었다. 이건 디자인에 따라 달라질 수 있다.
private fun setActivityMarker() {
if (!viewModel.hasActivity()) return // 활동이 하나도 없다면 return
// 활동 마커 추가하기
viewModel.route.value?.routeActivities!!.forEachIndexed { index, activity ->
// 지도에 마커 표시
addMarker(
LatLng.from(activity.latitude.toDouble(), activity.longitude.toDouble()),
Category.getCategoryByName(activity.category),
index.plus(1) // 장소 번호는 0번부터 시작
)
}
}
// 마커 띄우기
private fun addMarker(latLng: LatLng, category: Category, activityNumber: Int) {
val layer = kakaoMap?.labelManager?.layer
// IconLabel 추가
val iconLabel = layer?.addLabel(
getMapActivityIconLabelOptions(latLng, category, activityNumber)
)
// TextLabel 추가
val textLabel = layer?.addLabel(
getMapActivityNumberLabelOptions(latLng, activityNumber)
)
// TextLabel의 위치를 IconLabel 내부로 조정
if (iconLabel != null && textLabel != null) {
// IconLabel의 크기를 가정 (예: 60x60 픽셀)
val iconSize = 60f
// 텍스트를 아이콘 중심에서 약간 위로 이동
val offsetY = - iconSize / (2.3)
// changePixelOffset 메서드를 사용하여 텍스트 라벨의 위치 조정
textLabel.changePixelOffset(0f, offsetY.toFloat())
}
}
iconLabel, textLabel를 만든 뒤 changePixelOffset()
를 통해 textLabel의 위치를 iconLabel 안으로 옮겨준다. iconSize와 offsetY 또한 여러 번 시도해서 조정한 값이다.
1️⃣, 2️⃣ 과정 이후 빌드를 시켜보자.
일단 텍스트를 이미지 안에 위치하는 건 성공했다!
하지만 문제가 하나 생겼다.
위 사진을 보면 텍스트가 아이콘 위에 위치하는 경우도, 아래에 위치해서 안 보이는 경우도 생긴다. 기대했던 것과는 조금 다른 결과다.
해결을 위해서는 아이콘/텍스트 라벨 간의 우선 순위를 정해주어야 한다.
텍스트는 무조건 아이콘 위에 위치시켜주어야 한다.
이를 위해서 LabelOptions
에 setRank()
를 붙여줄 수 있다.
private const val RANK_OFFSET = 1 // 아이콘-텍스트 간 rank 차이 (기본적으로 텍스트는 아이콘 위에 표시)
// IconLabel
fun getMapActivityIconLabelOptions(latLng: LatLng, category: Category, activityNumber: Int): LabelOptions {
return LabelOptions.from(latLng)
.setStyles(setMapIconLabelStyles(category))
.setRank(100) // activityNumber가 클수록 높은 rank를 가짐
}
// TextLabel
fun getMapActivityNumberLabelOptions(latLng: LatLng, activityNumber: Int): LabelOptions {
return LabelOptions.from(latLng)
.setStyles(setMapTextLabelStyle())
.setTexts(LabelTextBuilder().setTexts(activityNumber.toString()))
.setRank(200) // 텍스트는 아이콘보다 높은 rank를 가짐
}
텍스트의 setRank에 아이콘의 setRank보다 더 큰 값을 넣어주면 텍스트는 아이콘 위에 표시되게 된다.
그렇지만~ 여기서 끝날 순 없죠. 빌드를 한 번 더 시켜 봅시다.
6, 7, 8번 핀을 보면 굉장히 가까운 좌표에 위치해서 마커가 서로 겹치는 걸 확인할 수 있다.
여기서 6번 마커의 텍스트가 7, 8번 마커의 위로 표시되는 걸 볼 수 있는데,
이는 앞서 TextLabel의 rank를 무조건 200으로, IconLabel은 100으로 설정해 놓아서 'activityNumber에 따른 rank 차이는 무시했기 때문에' 발생한 일이다.
그렇다면 해결을 위해서는 어떻게 수정해야 할까?
해결 방법은 activityNumber가 같은 아이콘/텍스트 끼리 그룹화를 해주는 것이다.
private const val RANK_INTERVAL = 10 // activity 번호에 따른 rank 차이 (더 높은 activityNumber를 가졌다면 핀을 더 위에 표시)
private const val RANK_OFFSET = 1 // 아이콘-텍스트 간 rank 차이 (기본적으로 텍스트는 아이콘 위에 표시)
// IconLabel
fun getMapActivityIconLabelOptions(latLng: LatLng, category: Category, activityNumber: Int): LabelOptions {
return LabelOptions.from(latLng)
.setStyles(setMapIconLabelStyles(category))
.setRank((activityNumber * RANK_INTERVAL).toLong()) // activityNumber가 클수록 높은 rank를 가짐
}
// TextLabel
fun getMapActivityNumberLabelOptions(latLng: LatLng, activityNumber: Int): LabelOptions {
return LabelOptions.from(latLng)
.setStyles(setMapTextLabelStyle())
.setTexts(LabelTextBuilder().setTexts(activityNumber.toString()))
.setRank((activityNumber * RANK_INTERVAL + RANK_OFFSET).toLong()) // 텍스트는 아이콘보다 높은 rank를 가짐
}
RANK_INTERVAL
은 activityNumber간 차이를 나타내기 위해 곱할 값이고,
RANK_OFFSET
은 같은 activityNumber일 때 Text를 Label 위에 표시해 주기 위해 더할 값이다.
이렇게까지 하면 더 높은 activityNumber를 가진 마커는 지도에서 더 위에! 잘 표시되는 걸 확인할 수 있다.
코드를 조금 더 깔끔하게 관리하기 위해 MapUtil 클래스를 만들어 지도 관련 코드를 정리해 주었다.
(지도에 마커를 표시하는 게 한, 두 화면에서 쓰이는 게 아니다.)
object MapUtil {
const val DEFAULT_ZOOM_LEVEL = 10 // 루트를 표시하는 기본 줌 레벨
private const val RANK_INTERVAL = 10 // activity 번호에 따른 rank 차이 (더 높은 activityNumber를 가졌다면 핀을 더 위에 표시)
private const val RANK_OFFSET = 1 // 아이콘-텍스트 간 rank 차이 (기본적으로 텍스트는 아이콘 위에 표시)
private const val ICON_SIZE = 60f // IconLabel의 크기를 가정
const val TEXT_OFFSET_Y = - (ICON_SIZE / (2.3)).toFloat() // 텍스트를 이동할 offset (아이콘 중심에서 약간 위로 이동)
/** 스타일 관련 */
// IconLabel
private fun setMapIconLabelStyles(category: Category): LabelStyles {
return LabelStyles.from(
LabelStyle.from(category.categoryMarkerIcon)
)
}
fun getMapActivityIconLabelOptions(latLng: LatLng, category: Category, activityNumber: Int): LabelOptions {
return LabelOptions.from(latLng)
.setStyles(setMapIconLabelStyles(category))
.setRank((activityNumber * RANK_INTERVAL).toLong()) // activityNumber가 클수록 높은 rank를 가짐
}
// TextLabel
private fun setMapTextLabelStyle(): LabelStyles {
return LabelStyles.from(
LabelStyle.from(LabelTextStyle.from(28, Color.WHITE))
)
}
fun getMapActivityNumberLabelOptions(latLng: LatLng, activityNumber: Int): LabelOptions {
return LabelOptions.from(latLng)
.setStyles(setMapTextLabelStyle())
.setTexts(LabelTextBuilder().setTexts(activityNumber.toString()))
.setRank((activityNumber * RANK_INTERVAL + RANK_OFFSET).toLong()) // 텍스트는 아이콘보다 높은 rank를 가짐
}
}
텍스트의 색상은 디자인대로 흰색으로 바꾸어주었다.
지도에 마커를 표시하려면, 지도를 사용하는 액티비티/프래그먼트에 아래 코드만 추가해 주면 된다.
private fun setActivityMarker() {
if (!viewModel.hasActivity()) return
// 활동 마커 추가하기
viewModel.route.value?.routeActivities!!.forEachIndexed { index, activity ->
// 지도에 마커 표시
addMarker(
LatLng.from(activity.latitude.toDouble(), activity.longitude.toDouble()),
Category.getCategoryByName(activity.category),
index.plus(1) // 장소 번호는 0번부터 시작
)
}
}
// 마커 띄우기
private fun addMarker(latLng: LatLng, category: Category, activityNumber: Int) {
val layer = kakaoMap?.labelManager?.layer
// IconLabel 추가
val iconLabel = layer?.addLabel(
getMapActivityIconLabelOptions(latLng, category, activityNumber)
)
// TextLabel 추가
val textLabel = layer?.addLabel(
getMapActivityNumberLabelOptions(latLng, activityNumber)
)
// TextLabel의 위치를 IconLabel 내부로 조정
if (iconLabel != null && textLabel != null) {
// changePixelOffset 메서드를 사용하여 텍스트 라벨의 위치 조정
textLabel.changePixelOffset(0f, MapUtil.TEXT_OFFSET_Y)
}
}
다양한 화면에서 쓰이는 모습! MapUtil로 코드를 분리한 보람이 있다.
영상으로도 확인해 보자.
활동과 지도의 마커가 잘 매치되는 걸 확인할 수 있다.
오늘은 꽤나 구현에 어려움을 겪었던 마커 안에 텍스트를 표시하는 방법에 대해 알아보았다!
이것저것 실험해보고, 가능한 아이디어를 찾아내는 과정이 나름은 재미있었다.
디자인을 보면 활동들이 선으로 연결되어 있는 걸 알 수 있는데, 다음 편에는 카카오맵에 선형의 선을 표시하는 방법을 다뤄보겠다.