프로젝트 소개
SSAFY 12기 공통 프로젝트에서 개발한 모여(MOYEO)는 AI 기반 여행 플래너, 공동 여행 일정 편집, 그리고 추억 공유 사진첩 기능을 제공하여 여행 계획의 번거로움을 줄인 맞춤형 여행 일정 관리 서비스이다.
- 그 중에서 작성자는 사진첩 기능을 개발하였고, 네이버 지도 API에서 사용 가능한 마커 클러스터링 기능을 적용해보았다.
사용 의도
- 여행을 하면서 촬영했던 사진들을 지역별로 묶어줘서 지도에 보여주고자 클러스터링을 할 방법을 조사하게 되었다.
- 네이버 지도 API에서 2024년부터 마커 클러스터링 라이브러리를 추가하였기 때문에 이를 활용하기로 하였다.
- 대신 거기서 끝나는 것이 아니고 ViewPager2와 TabLayout으로 지역별로 묶은 사진들을 볼 수 있도록 UI를 구성하였다.
적용 방법
먼저 화면에 네이버 지도를 띄워 줄 FragmentContainerView와, ViewPager2와 TabLayout을 배치한다.
<androidx.fragment.app.FragmentContainerView
android:id="@+id/map_photo_classification"
android:name="com.naver.maps.map.MapFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar_photo_classification" />
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/neutral_white"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar_photo_classification" />
<TextView
android:id="@+id/tv_description_photo_classification"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/dp_medium"
android:text="@string/text_description_update_place_name"
android:textAlignment="viewStart"
android:textColor="@color/neutral_100"
android:textSize="@dimen/font_size_large"
app:layout_constraintBottom_toTopOf="@id/tl_photo_classification"
app:layout_constraintEnd_toStartOf="@id/gl_end_photo_classification"
app:layout_constraintStart_toEndOf="@id/gl_start_photo_classification"
app:layout_constraintTop_toBottomOf="@id/toolbar_photo_classification" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tl_photo_classification"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_marginTop="@dimen/dp_medium"
android:background="@android:color/transparent"
app:layout_constraintEnd_toStartOf="@id/gl_end_photo_classification"
app:layout_constraintStart_toEndOf="@id/gl_start_photo_classification"
app:layout_constraintTop_toBottomOf="@id/tv_description_photo_classification"
app:tabBackground="@drawable/shape_tab"
app:tabGravity="fill"
app:tabIndicatorHeight="0dp"
app:tabMaxWidth="100dp"
app:tabMode="scrollable"
app:tabPaddingEnd="@dimen/dp_smaller"
app:tabPaddingStart="@dimen/dp_smaller"
app:tabRippleColor="@null"
app:tabSelectedTextColor="@color/neutral_white"
app:tabTextColor="@color/neutral_90" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/vp_photo_classification"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="@dimen/dp_medium"
app:layout_constraintBottom_toTopOf="@id/gl_bottom_photo_classification"
app:layout_constraintEnd_toStartOf="@id/gl_end_photo_classification"
app:layout_constraintStart_toEndOf="@id/gl_start_photo_classification"
app:layout_constraintTop_toBottomOf="@id/tl_photo_classification" />
- 여기서 FragmentContainerView 위에 View를 겹쳐서 배치시킨 이유는, 사실 네이버 지도를 가리기 위해서이다.
- 여러 가지를 실험해본 결과, 반드시 화면에 네이버 지도가 보이도록 배치해야 클러스터링이 이루어진다. 줌 레벨에 따라서 클러스터링 결과를 도출하는데, 네이버 지도가 보이지 않는다면 줌 레벨을 확인할 수 없어 클러스터링이 이루어지지 않는 것으로 보인다.
- 대신 아무 기능도 하지 않는 View를 네이버 지도 위에 겹쳐서 배치시킴으로써 네이버 지도를 가리도록 하였다...
getMapAsync() 함수로 FragmentContainerView에 네이버 지도를 띄워준다.
class PhotoClassificationFragment :
BaseFragment<FragmentPhotoClassificationBinding>(R.layout.fragment_photo_classification),
OnMapReadyCallback {
private val viewModel: AlbumViewModel by activityViewModels()
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
initNaverMap()
} else {
showToastMessage(resources.getString(R.string.message_location_permission))
}
}
private lateinit var naverMap: NaverMap
private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var locationSource: FusedLocationSource
private lateinit var clusterer: Clusterer<MarkerData>
private lateinit var markerBuilder: Clusterer.ComplexBuilder<MarkerData>
private val tags = mutableListOf<String>()
...
private fun initNaverMap() {
if (!hasPermission()) {
requestLocationPermission()
return
}
locationSource = FusedLocationSource(this, LOCATION_PERMISSION_REQUEST_CODE)
val mapFragment =
childFragmentManager.findFragmentById(R.id.map_photo_classification) as MapFragment?
?: MapFragment.newInstance().also {
childFragmentManager.beginTransaction().add(R.id.map_photo_classification, it)
.commit()
}
mapFragment.getMapAsync(this)
}
override fun onMapReady(naverMap: NaverMap) {
this.naverMap = naverMap
naverMap.locationSource = locationSource
naverMap.locationTrackingMode = LocationTrackingMode.Follow
naverMap.uiSettings.isLocationButtonEnabled = true
naverMap.maxZoom = 18.0
if (ActivityCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_COARSE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
return
}
fusedLocationClient.lastLocation
.addOnSuccessListener { location: Location? ->
location?.let {
naverMap.locationOverlay.run {
isVisible = true
position = LatLng(it.latitude, it.longitude)
}
val cameraUpdate = CameraUpdate.scrollTo(LatLng(it.latitude, it.longitude))
naverMap.moveCamera(cameraUpdate)
setMapToFitAllMarkers(viewModel.tempPhotos.value)
initClusterer()
}
}
}
...
}
- 위치 권한을 허용하면, onMapReady() 함수가 호출되고 NaverMap 객체를 초기화시킨다.
private fun setMapToFitAllMarkers(markers: List<MarkerData>) {
if (markers.isEmpty()) return
val boundsBuilder = LatLngBounds.Builder()
markers.forEach { boundsBuilder.include(it.position) }
val bounds = boundsBuilder.build()
val cameraUpdate = CameraUpdate.fitBounds(bounds, 100)
naverMap.moveCamera(cameraUpdate)
}
- 이 함수로 모든 마커가 보이도록 줌 레벨을 조절할 수 있다.
private fun initClusterer() {
markerBuilder = Clusterer.ComplexBuilder<MarkerData>()
lifecycleScope.launch {
viewModel.tempPhotos.collectLatest { markers ->
tags.clear()
val newClusterer = makeMarker(
markers,
markerBuilder
) {
val newMarkers = mutableListOf<List<MarkerData>>()
var photoIndex = initPlaceNumber(viewModel.photoPlaces.value.map { it.name })
Timber.d("Clusters Size: ${tags.size}")
tags.forEachIndexed { index, tag ->
val newLocations = mutableListOf<MarkerData>()
tag.split(",").forEach { id ->
newLocations.add(markers[id.toInt() - 1])
}
val placeName = newLocations.filterNot { it.photo.photoPlace == "" }
.groupBy { it.photo.photoPlace }.entries
.sortedWith(compareByDescending<Map.Entry<String, List<MarkerData>>> { it.value.size }
.thenBy { it.key }).firstOrNull()?.key ?: "장소 ${photoIndex++}"
tags[index] = placeName
calculateRepresentativeCoordinate(newLocations)
newMarkers.add(newLocations.filter { it.isNewPhoto })
}
viewModel.initNewMarkers(tags, newMarkers)
initTabLayout()
clusterer.map = null
}
clusterer = newClusterer
clusterer.map = naverMap
}
}
}
- 이미지 파일에 포함된 위도 경도 정보를 바탕으로 마커를 생성하고, 줌 레벨에 따라 마커들을 클러스터링한다.
- 이 때, 클러스터링된 마커의 tag는 포함된 마커들의 id값들을 콤마(,)로 구분한 문ㅌ자열이다.(예를 들면 1, 2, 3번 마커가 클러스터링되면 클러스터링된 마커의 tag는 1,2,3이 되는 식이다.)
private suspend fun makeMarker(
markers: List<MarkerData>,
builder: Clusterer.ComplexBuilder<MarkerData>,
onClusterComplete: () -> Unit
): Clusterer<MarkerData> {
val totalMarkers = markers.size
var processedMarkers = 0
val cluster: Clusterer<MarkerData> = builder.tagMergeStrategy { cluster ->
cluster.children.map { it.tag }.joinToString(",")
}
.apply {
clusterMarkerUpdater(object : DefaultClusterMarkerUpdater() {
override fun updateClusterMarker(info: ClusterMarkerInfo, marker: Marker) {
tags.add(info.tag.toString())
markerManager.releaseMarker(info, marker)
processedMarkers += info.size
if (processedMarkers == totalMarkers) {
onClusterComplete()
}
}
}).leafMarkerUpdater(object : DefaultLeafMarkerUpdater() {
override fun updateLeafMarker(info: LeafMarkerInfo, marker: Marker) {
tags.add(info.tag.toString())
markerManager.releaseMarker(info, marker)
processedMarkers++
if (processedMarkers == totalMarkers) {
onClusterComplete()
}
}
})
}
.build()
withContext(Dispatchers.Default) {
markers.forEach { item ->
cluster.add(item, "${item.id}")
}
}
return cluster
}
- 이 함수는 마커들을 추가하고 클러스터링하는 역할을 한다.
- DefaultClustererMarkerUpdater 객체에서 클러스터링된 마커의 tag를 결정한다.
- DefaultLeafMarkerUpdater 객체에서 클러스터링되지 않은 단일 마커들의 tag를 결정한다.
- 결과적으로 클러스터링된 사진들끼리 구분을 하기 위해 tag를 결정하는 것이다.
느낀 점
- 클러스터링을 위해 네이버 지도를 사용하고 이를 보이지 않게 가리는 방법은 다소 초보적이면서도 메모리 관리 측면에서도 좋지 않지만 다른 방법이 생각이 나지 않았다...
- 또한 Kotlin 코드가 굉장히 길어져서 이해하기 힘들 것 같다.
결과
사진들의 위치 정보를 바탕으로 클러스터링을 수행하였고, 지역별로 이름을 변경하거나 특정 사진의 지역 분류를 변경할 수도 있다.
코드
GitHub - littlesam95/SmartTrip: 삼성 청년 SW 아카데미 12기 공통 프로젝트 구미 D210 능이버섯
삼성 청년 SW 아카데미 12기 공통 프로젝트 구미 D210 능이버섯. Contribute to littlesam95/SmartTrip development by creating an account on GitHub.
github.com
'개발 > 안드로이드' 카테고리의 다른 글
[메이플 캘린더] ViewPager2와 TabLayout으로 하이퍼스탯, 어빌리티 프리셋을 좌우 스와이프로 확인해보자 (2) | 2024.05.18 |
---|---|
[메이플 캘린더] Custom View로 장비 세부정보 UI에 스타포스를 달아보자 (0) | 2024.05.10 |
[메이플 캘린더] Hilt와 Clean Architecture (0) | 2024.05.03 |
[메이플 캘린더] ViewPager2와 Custom View로 캐릭터 정보 조회 날짜를 선택하는 달력을 만들어보자 (0) | 2024.04.26 |
[메이플 캘린더] 위로 스와이프 시 새로고침 기능을 구현해보자 (0) | 2024.01.24 |