[Android/Kotlin] 카카오맵 마커(라벨) 위에 텍스트 표시하기

코코아의 개발일지·2024년 10월 20일
0

Android-Kotlin

목록 보기
34/36
post-thumbnail

매우매우 삽질했던 이야기...... 카카오맵 자체에서는 기본적으로 지원하지 않는 기능인 거 같아 많이 헤맸었다.

✍🏻 요구사항 분석

🌟 루트 표시 🌟 (참고) 활동 목록

디자인을 보면서 지도에 나타낼 것을 생각해보자.
지도에는 아래 정보를 표시해야 한다.

  1. 지나온 루트를 선으로 연결해주어야 한다.
  2. 활동을 순서대로 마커로 표시해주어야 한다. (우측 활동 목록과 비교)

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을 나눠서 텍스트가 아이콘 안에 위치하게끔 하는 것이었다.
이 방법이 정답이었다...


💻 코드 작성

1️⃣ LabelStyle 지정

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로 해주었다. 이건 디자인에 따라 달라질 수 있다.

2️⃣ 지도 위에 마커 표시

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

1️⃣, 2️⃣ 과정 이후 빌드를 시켜보자.

일단 텍스트를 이미지 안에 위치하는 건 성공했다!

하지만 문제가 하나 생겼다.
위 사진을 보면 텍스트가 아이콘 위에 위치하는 경우도, 아래에 위치해서 안 보이는 경우도 생긴다. 기대했던 것과는 조금 다른 결과다.

해결을 위해서는 아이콘/텍스트 라벨 간의 우선 순위를 정해주어야 한다.

3️⃣ setRank로 아이콘/텍스트 간 우선 순위 정해주기

텍스트는 무조건 아이콘 위에 위치시켜주어야 한다.
이를 위해서 LabelOptionssetRank()를 붙여줄 수 있다.

 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보다 더 큰 값을 넣어주면 텍스트는 아이콘 위에 표시되게 된다.
그렇지만~ 여기서 끝날 순 없죠. 빌드를 한 번 더 시켜 봅시다.

📱 중간 점검 - 2

6, 7, 8번 핀을 보면 굉장히 가까운 좌표에 위치해서 마커가 서로 겹치는 걸 확인할 수 있다.

여기서 6번 마커의 텍스트가 7, 8번 마커의 위로 표시되는 걸 볼 수 있는데,
이는 앞서 TextLabel의 rank를 무조건 200으로, IconLabel은 100으로 설정해 놓아서 'activityNumber에 따른 rank 차이는 무시했기 때문에' 발생한 일이다.

그렇다면 해결을 위해서는 어떻게 수정해야 할까?

해결 방법은 activityNumber가 같은 아이콘/텍스트 끼리 그룹화를 해주는 것이다.

4️⃣ 같은 활동 순서의 아이콘/텍스트 그룹화

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.kt

코드를 조금 더 깔끔하게 관리하기 위해 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를 가짐
    }
}

텍스트의 색상은 디자인대로 흰색으로 바꾸어주었다.

Activity/Fragment

지도에 마커를 표시하려면, 지도를 사용하는 액티비티/프래그먼트에 아래 코드만 추가해 주면 된다.

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로 코드를 분리한 보람이 있다.

영상으로도 확인해 보자.

활동과 지도의 마커가 잘 매치되는 걸 확인할 수 있다.

💬 마치며

오늘은 꽤나 구현에 어려움을 겪었던 마커 안에 텍스트를 표시하는 방법에 대해 알아보았다!
이것저것 실험해보고, 가능한 아이디어를 찾아내는 과정이 나름은 재미있었다.
디자인을 보면 활동들이 선으로 연결되어 있는 걸 알 수 있는데, 다음 편에는 카카오맵에 선형의 선을 표시하는 방법을 다뤄보겠다.

📚 참고 자료

profile
안드로이드 개발자를 꿈꾸는 학생입니다

0개의 댓글