새로 업데이트된 gtx 노선때매 반갈죽 당해버린ww
NaverMap Compose 를 통해 클러스터링을 구현하는 방식에 대해, github repository 내에 샘플 예제를 제외하면 마땅한 레퍼런스가 존재하지 않아, 이를 구현하는 방법과 구현하면서 겪었던 문제를 해결하는 방법을 공유해보고자 한다.
NaverMap 의 경우 공식 문서가 아직 XML 기반으로 서술되어있고, Compose 버전의 NaverMap 을 정식으로 지원하지 않는다.
fornewid 님께서 NaverMap 을 Compose 환경에서도 사용할 수 있도록 오픈소스 라이브러리로 개발하여 운영중이시다.
KakaoMap 도 마찬가지로 Compose 버전을 정식으로 지원하지 않는다.
NaverMap 에서 클러스터링을 정식으로 지원하지 않았을 때는, Ted Park 님께서 개발하신 TedNaverClustering 를 사용하면 되었는데, NaverMap 에서 정식으로 지원하는 클러스터링이 어느정도 안정화가 되었으므로, 이제는 정식버전을 사용하면 될듯하다.
초기 설정 및 구현 자체는 NaverMap 공식 문서의 클러스터링 파트 내용을 한번 숙지한 후에, github repository 내에 클러스터링 예제 코드들을 참고하여 구현(요구사항에 맞춰 설정 값들을 변경)하면 되기에 큰 어려움은 없었다.
기존 XML 기반의 NaverMap github repository 에 있던 예제 코드들을 전부 Compose 로 포팅 해주셔서, 구현하는데 정말 큰 도움이 되었다.
감사합니다. fornewid 님(_ _)
직접 구현한 코드는 다음과 같고, 각각의 람다함수와 개별 속성들에 대해 주석을 달아보았다.
@OptIn(ExperimentalNaverMapApi::class)
@Composable
internal fun MapContent(
uiState: MapUiState, // 지도 UI 상태를 관리하는 객체
cameraPositionState: CameraPositionState, // 카메라 위치 상태를 관리
rotationState: Float, // 지도 회전 상태
onMapUiAction: (MapUiAction) -> Unit, // 지도 UI 액션 처리를 위한 콜백
isClusteringEnabled: Boolean, // 클러스터링 활성화 여부 (마커 그룹화)
// ...
) {
Box {
NaverMap(
// 카메라 위치 설정
cameraPositionState = cameraPositionState,
properties = MapProperties(
// 위치 추적 모드 설정 (위치를 자동으로 따라가지 않음)
locationTrackingMode = LocationTrackingMode.NoFollow,
// 시스템 다크 모드에 따라 지도 테마 설정
isNightModeEnabled = isSystemInDarkTheme(),
),
uiSettings = MapUiSettings(
// 줌 컨트롤 버튼 활성화
isZoomControlEnabled = true,
// 축척 표시 비활성화
isScaleBarEnabled = false,
// Naver 로고 클릭 비활성화
isLogoClickEnabled = false,
// 현재 위치 버튼 활성화
isLocationButtonEnabled = true,
),
// 위치 정보 소스 설정
locationSource = rememberFusedLocationSource(),
) {
// 학교 경계선 표시 - 다각형 오버레이로 구현
PolygonOverlay(
// 외부 경계선 좌표
coords = uiState.outerPolygon,
// 반투명 회색으로 채우기
color = Color.Gray.copy(alpha = 0.3f),
// 경계선 색상
outlineColor = Color.Gray,
// 경계선 두께
outlineWidth = 1.dp,
// 내부 PolyLines 정의
holes = uiState.innerPolyLines,
)
// 클러스터링 활성화 여부에 따른 마커 표시 방식 분기
if (isClusteringEnabled) {
// 클러스터러 생성 및 상태 관리
var clusterManager by remember { mutableStateOf<Clusterer<BoothMapModel>?>(null) }
DisposableMapEffect(uiState.filteredBoothList) { map ->
if (clusterManager == null) {
clusterManager = Clusterer.ComplexBuilder<BoothMapModel>()
// 최소 클러스터링 줌 레벨 설정 (9 이하에서는 모두 클러스터링)
.minClusteringZoom(9)
// 최대 클러스터링 줌 레벨 설정 (16 이상에서는 클러스터링 해제)
.maxClusteringZoom(16)
// 클러스터링할 마커간 최대 화면 거리
.maxScreenDistance(200.0)
.thresholdStrategy { zoom ->
if (zoom <= 11) {
0.0 // 줌 레벨 11 이하에서는 더 공격적으로 클러스터링 (모든 마커를 클러스터링)
} else {
70.0 // 줌 레벨 11 초과에서는 70 픽셀 거리 내에서만 클러스터링
}
}
// 거리별 클러스터링 전략 설정
.distanceStrategy(
object : DistanceStrategy {
// 기본 거리 전략 사용
private val defaultDistanceStrategy = DefaultDistanceStrategy()
override fun getDistance(zoom: Int, node1: Node, node2: Node): Double {
return if (zoom <= 9) {
-1.0 // 줌 레벨 9 이하에서는 무조건 클러스터링 (-1은 항상 클러스터링)
} else if ((node1.tag as ItemData).category == (node2.tag as ItemData).category) {
if (zoom <= 11) {
-1.0 // 줌 레벨 11 이하에서 같은 카테고리면 무조건 클러스터링
} else {
defaultDistanceStrategy.getDistance(zoom, node1, node2) // 기본 거리 전략 사용
}
} else {
10000.0 // 다른 카테고리는 클러스터링하지 않음 (큰 값으로 설정)
}
}
},
)
// 마커 tag 병합 전략 설정
.tagMergeStrategy { cluster ->
if (cluster.maxZoom <= 9) {
null // 줌 레벨 9 이하에서는 태그 정보 없음
} else {
ItemData("", (cluster.children.first().tag as ItemData).category) // 첫 번째 자식의 카테고리 사용
}
}
// 마커 옵션 설정
.markerManager(
object : DefaultMarkerManager() {
override fun createMarker() = super.createMarker().apply {
// 부제목 텍스트 크기
subCaptionTextSize = 10f
// 부제목 색상
subCaptionColor = android.graphics.Color.WHITE
// 부제목 테두리 색상
subCaptionHaloColor = android.graphics.Color.TRANSPARENT
}
},
)
// 클러스터링 마커 설정
.clusterMarkerUpdater { info, marker ->
marker.apply {
// 클러스터 아이콘 설정
icon = OverlayImage.fromResource(designR.drawable.ic_cluster)
// 클러스터 내 마커 개수 표시
captionText = info.size.toString()
// 캡션 가운데 정렬
setCaptionAligns(Align.Center)
// 캡션 색상
captionColor = android.graphics.Color.WHITE
// 캡션 테두리 색상
captionHaloColor = android.graphics.Color.TRANSPARENT
// 클러스터 클릭 리스너 설정
onClickListener = DefaultClusterOnClickListener(info)
}
}
// 리프 마커(각각의 마커) 설정
.leafMarkerUpdater { info, marker ->
marker.apply {
// 카테고리와 선택 상태에 따른 아이콘
icon = MarkerCategory.fromString((info.key as BoothMapModel).category)
.getMarkerIcon((info.key as BoothMapModel).isSelected)
// 캡션 텍스트 빈 문자열로 설정(초기화, 이후 언급)
// captionText = ""
// 부제목 텍스트 빈 문자열로 설정(초기화, 이후 언급)
// subCaptionText = ""
// 마커 클릭 이벤트 처리
onClickListener = Overlay.OnClickListener {
onMapUiAction(MapUiAction.OnBoothMarkerClick(listOf(info.key as BoothMapModel)))
true
}
}
}
.build()
// 클러스터러를 현재 지도에 적용
.apply { this.map = map }
}
// 부스 리스트를 ItemData와 매핑
val boothListMap = uiState.filteredBoothList.associateWith { booth -> ItemData(booth.name, booth.category) }
// 클러스터러에 모든 부스 추가
clusterManager?.addAll(boothListMap)
onDispose {
// 컴포저블이 제거될 때 클러스터러 초기화
clusterManager?.clear()
}
}
} else {
// 클러스터링이 비활성화된 경우 개별 마커로 표시
uiState.filteredBoothList.forEach { booth ->
ComposeMarker(
state = rememberMarkerState(position = LatLng(booth.latitude, booth.longitude)), // 마커 위치 설정
icon = MarkerCategory.fromString(booth.category).getMarkerIcon(booth.isSelected), // 카테고리와 선택 상태에 맞는 아이콘
onClick = {
onMapUiAction(MapUiAction.OnSingleBoothMarkerClick(booth)) // 단일 마커 클릭 이벤트 처리
true
},
)
}
}
}
// 그밖에 MapScreen 구성요소들...
}
}
// Marker 에 대한 UiModel
data class BoothMapModel(
val id: Long = 0L,
val name: String = "",
val category: String = "",
val description: String = "",
val thumbnail: String = "",
val location: String = "",
val latitude: Double = 0.toDouble(),
val longitude: Double = 0.toDouble(),
val isSelected: Boolean = false,
) : ClusteringKey {
override fun getPosition(): LatLng {
return LatLng(latitude, longitude)
}
}
학교라는 좁은 면적내에 위치하는 축제 부스 마커들이 전부 하나로 병합되지 않도록, 클러스터링 조건을 상당히 타이트하게 설정하였고, 설정화면 내에서 클러스터링 활성화 여부를 토글로 관리할 수 있도록 지원 하였다.
zoom 을 확대하거나 클러스터링 마커를 클릭하는 경우에, 각 클러스터링 마커는 리프(leaf) 마커들로 분리가 되는데, 이때 기존 클러스터링 마커 자리에 위치하는 리프 마커에 captionText(병합된 리프 마커의 개수)가 표기 되는 문제가 발생하였다.
https://navermaps.github.io/android-map-sdk/guide-ko/5-8.html
NaverMap 에서 Marker 객체가 클러스터링이 해제되고, 다시 적용될 때마다 추가, 삭제가 반복된다면 리소스 낭비가 발생할 수 있다. 따라서 NaverMap 측에서는 마커가 재사용되도록 내부적으로 구현하고 있다.
때문에 문서에 언급된 것 처럼, leafMarkerUpdater 람다 함수내에서 명시적으로 마커내에 captionText 를 빈 문자열로 덮어씌우는(초기화 하는) 코드를 추가해줘야 문제를 해결할 수 있다.
.leafMarkerUpdater { info, marker ->
marker.icon = MarkerCategory.fromString((info.key as BoothMapModel).category)
.getMarkerIcon((info.key as BoothMapModel).isSelected)
// captionText 명시적 초기화
marker.captionText = ""
marker.subCaptionText = ""
marker.onClickListener = Overlay.OnClickListener {
onMapUiAction(MapUiAction.OnBoothMarkerClick(listOf(info.key as BoothMapModel)))
true
}
}
ClursterMarker 에 대한 Click Event 지원
기존의 기획은 클러스터링된 부스를 클릭시, 선택된 부스 목록을 지도 화면 하단에 horizontalPager 로 띄워주는 방식이었다.
하지만, clusterMarkerUpdater 람다 함수의 매개변수인 info, marker 의 경우
.clusterMarkerUpdater { info, marker ->
marker.apply {
// ...
// 클러스터 내 마커 개수 표시
captionText = info.size.toString()
//...
// 클러스터 클릭 리스너 설정
onClickListener = DefaultClusterOnClickListener(info)
}
}
ClusterMarkerInfo.class
@b
@AnyThread
public class ClusterMarkerInfo extends MarkerInfo {
private final int a;
ClusterMarkerInfo(long id, @Nullable Object tag, @NonNull WebMercatorCoord coord, @NonNull LatLng position, int minZoom, int maxZoom, int size) {
super(id, tag, coord, position, minZoom, maxZoom);
this.a = size;
}
@Nullable
public Object getTag() {
return super.getTag();
}
@NonNull
public LatLng getPosition() {
return super.getPosition();
}
public int getSize() {
return this.a;
}
public boolean equals(Object o) {
if (this == o) {
return true;
} else if (o != null && this.getClass() == o.getClass()) {
if (!super.equals(o)) {
return false;
} else {
ClusterMarkerInfo var2 = (ClusterMarkerInfo)o;
return this.a == var2.a;
}
} else {
return false;
}
}
public int hashCode() {
int var1 = super.hashCode();
var1 = 31 * var1 + this.a;
return var1;
}
}
Marker.class
public final class Marker extends Overlay {
//...
@b
public void setMap(@Nullable NaverMap map) {
super.setMap(map);
}
@b
public int getGlobalZIndex() {
return super.getGlobalZIndex();
}
@b
public void setGlobalZIndex(int globalZIndex) {
super.setGlobalZIndex(globalZIndex);
}
@Keep
@NonNull
@b
@UiThread
public LatLng getPosition() {
this.e();
return this.nativeGetPosition();
}
@Keep
@b
@UiThread
public void setPosition(@NonNull LatLng position) {
this.e();
a("position", position);
this.nativeSetPosition(position.latitude, position.longitude);
}
@Keep
@NonNull
@b
@UiThread
public OverlayImage getIcon() {
this.e();
return this.a;
}
@Keep
@b
@UiThread
public void setIcon(@NonNull OverlayImage icon) {
this.e();
if (!ObjectsCompat.equals(this.a, icon)) {
this.a = icon;
if (this.isAdded()) {
this.nativeSetIcon(icon);
}
}
}
@ColorInt
@Keep
@b
@UiThread
public int getIconTintColor() {
this.e();
return this.nativeGetIconTintColor();
}
@Keep
@b
@UiThread
public void setIconTintColor(@ColorInt int color) {
this.e();
this.nativeSetIconTintColor(color);
}
// ... 너무 길어서 생략
}
요약하자면 매개변수인 info 내에서 현재 병합된 리프 마커들의 객체 목록을 가져올 수 없기 때문에, 구현이 불가능하였다.
따라서, 클러스터링 마커의 기본 클릭 이벤트를 지원하는 DefaultCusterOnClickListener 를 적용하여, 클릭시 클러스터링 마커가 각각의 리프 마커로 분리되도록 구현하였다.
네이버 클라우드에 문의한 결과, tagMergeStrategy 를 이용해 자식 리프 노드의 데이터를 리스트로 병합하여 클러스터의 tag 로 지정해 접근하면 된다고 하는데,
tagMergyStategy 람다함수 내에 return 값을 clusterMarkerUpdater 람다 함수에서 사용할 방법을 찾지 못하였다...
문의 답변 처럼, tagMergeStrategy 람다함수를 통해 클러스터링 마커에 병합된 리프 노드들의 tag 를 List 로 묶어 반환하면, 이를 clusterMarkerUpdater 람다 함수내에 매개변수인 info 를 통해 info.tag 로 접근할 수 있었다!
따라서 이 info.tag 를 원하는 타입으로 형변환 후(info.tag 의 기본 타입은 Object) 클릭 이벤트내에 전달해주면 된다.
.tagMergeStrategy { cluster ->
cluster.children.flatMap { child ->
when (val tag = child.tag) {
is BoothMapModel -> listOf(tag)
is List<*> -> tag.filterIsInstance<BoothMapModel>()
else -> emptyList()
}
}
}
// ...다른 람다 함수
.clusterMarkerUpdater { info, marker ->
marker.apply {
icon = OverlayImage.fromResource(designR.drawable.ic_cluster)
captionText = info.size.toString()
//... 다른 속성들
val booths = (info.tag as? List<BoothMapModel>) ?: emptyList()
onClickListener = Overlay.OnClickListener {
onMapUiAction(MapUiAction.OnBoothMarkerClick(booths))
true
}
}
}
Clustering Marker 에 대한 Icon Custom Design 지원
이슈 링크를 타고 들어가면 각각의 한계에 대한 자세한 설명을 확인 할 수 있다.
기존의 앱의 기획은 위의 이미지 처럼, 같은 카테고리의 마커들만을 클러스터링으로 묶고, 몇개의 마커가 병합되었는지에 대한 개수를 마커에 오른쪽 상단에 표기하도록 구현하려고 하였다.
하지만, 마커의 icon 과 captionText 등은 어떤 값을 넣을지 설정만 가능하지(타입도 제한됨), 기획에서 처럼 captionText 의 위치와 나타내는 방식 자체를 커스텀할 순 없었다.내가 못 찾은 것일 수도 있다.
코드를 보면 알 수 있듯이, 빌더 패턴을 통한 명령형 프로그램의 방식으로 각각의 개별 속성들을 정의하는 스타일로 클러스터링을 구현하게 되는데, 이는 NaverMap Compose 가 기존의 XML 기반의 Android NaverMap 을 Compose 로 Wrapping 한 라이브러리이기 때문이다.
구현할 방법이 아예 없진 않는데, 모든 경우에 수(ex. 13개의 마커가 병합된 카테고리가 Bar 인 마커, 25개의 마커가 병합된 카테고리가 Food 인 마커 등등...)에 대한 리소스 파일을 앱에 넣어놓은 뒤에, 이를 각 상황에 맞게 분기처리하여 모두 적용해주면 된다.
하지만 이는 너무나 많은 리소스를 앱에 추가해야 하는 관계로, 앱의 용량이 많이 늘어날 수 있기 때문에 현명한 방식이 아니라고 판단하여, 절충하여 현재 썸네일의 형태로 구현하게 되었다.
위의 문제 2의 해결방식을 응용해서, clusterMarkerUpdater 내에 클러스터링 마커내에 병합된 마커 객체들을 모두 가져오는 방식으로 필요한 정보를 모두 가져올 순 있는 것을 확인할 수 있었다.
이후 클러스터링 전략 변경(거리로만 판단하는 것이 아닌, 같은 카테고리의 마커들만 병합)과 아이콘 변경 및 텍스트 추가를 위한 Custom AndroidView 구현을 통해 어떻게든 구현은 할 수 있을 것 같은데, 조금 더 고민을 해봐야할 것 같다.
각각의 설정을 위한 람다함수 내부에서는 Composable 함수를 사용할 수 없기 때문에, 마커에 대한 커스텀 디자인은 아직 Custom AndroidView 로 구현해야하는 점이 개인적으로 아쉬운 부분이다.
그건 아니다. 클러스터링 마커가 아닌, 기본 마커의 경우 Composable 함수의 형태로 구현할 수 있도록 지원하고 있다.
지원을 부탁드렸더니 직접 만들어주셨다 ㅎㅎ (_ _)
내부 구현 코드는 여기서 확인할 수 있다.
// 사용 예시
uiState.filteredBoothList.forEach { booth ->
ComposeMarker(
state = rememberMarkerState(position = LatLng(booth.latitude, booth.longitude)),
icon = MarkerCategory.fromString(booth.category).getMarkerIcon(booth.isSelected),
onClick = {
onMapUiAction(MapUiAction.OnSingleBoothMarkerClick(booth))
true
},
)
}
// MarkerCategory.kt
import com.naver.maps.map.overlay.OverlayImage
enum class MarkerCategory(val value: String) {
BAR("BAR"),
FOOD("FOOD"),
EVENT("EVENT"),
NORMAL("NORMAL"),
MEDICAL("MEDICAL"),
TOILET("TOILET"),
;
companion object {
fun fromString(value: String): MarkerCategory {
return entries.find { it.value == value } ?: NORMAL
}
}
fun getMarkerIcon(isSelected: Boolean): OverlayImage {
if (isSelected) {
return when (this) {
BAR -> OverlayImage.fromResource(R.drawable.ic_marker_bar_selected)
FOOD -> OverlayImage.fromResource(R.drawable.ic_marker_food_selected)
EVENT -> OverlayImage.fromResource(R.drawable.ic_marker_event_selected)
NORMAL -> OverlayImage.fromResource(R.drawable.ic_marker_normal_selected)
MEDICAL -> OverlayImage.fromResource(R.drawable.ic_marker_medical_selected)
TOILET -> OverlayImage.fromResource(R.drawable.ic_marker_toilet_selected)
}
} else {
return when (this) {
BAR -> OverlayImage.fromResource(R.drawable.ic_marker_bar)
FOOD -> OverlayImage.fromResource(R.drawable.ic_marker_food)
EVENT -> OverlayImage.fromResource(R.drawable.ic_marker_event)
NORMAL -> OverlayImage.fromResource(R.drawable.ic_marker_normal)
MEDICAL -> OverlayImage.fromResource(R.drawable.ic_marker_medical)
TOILET -> OverlayImage.fromResource(R.drawable.ic_marker_toilet)
}
}
}
}
덕분에 원하는 리소스 파일을 통해 Composable 함수의 선언형의 방식으로 마커를 디자인 할 수 있었다.
NaverMap Compose 를 통해 클러스터링을 구현하는 방식과, 직접 구현하면서 발생했던 문제에 대한 해결 방법, 그밖에 아직은 지원하지 않는 부분들을 알아볼 수 있었다.
전체 코드는 하단 링크를 통해 확인할 수 있습니다.
https://github.com/Project-Unifest/unifest-android
reference)
https://github.com/fornewid/naver-map-compose/tree/main/app/src/main/java/com/naver/maps/map/compose/demo/clustering
https://navermaps.github.io/android-map-sdk/guide-ko/5-8.html
헉 이렇게 좋은 것이 있었다니...다음에 또 저희 학교 축제 해주시면 홍보해드리겠습니다!