Skip to content

Commit 81de0a4

Browse files
authored
[Feat] 마커 클러스터링 도입 (#392)
* chore: naver-map-compose PR 버전으로 변경 * fix: 종속성 충돌 해결 * feat: 마커 클러스터링 구현 * fix: kapt 사용 삭제 및 EAT-SSU 포크로 수정 * refactor: animateCameraPositionTo 분리 및 showToast 활용, 기존 코드 정리 * chore: 클러스터링 기준값에 주석 추가 * refactor: 토스트 관련 리팩토링
1 parent 0859a7b commit 81de0a4

5 files changed

Lines changed: 142 additions & 66 deletions

File tree

app/build.gradle.kts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ dependencies {
250250
implementation(libs.androidx.security.crypto)
251251

252252
// naver maps
253-
implementation (libs.map.sdk)
253+
implementation(libs.map.sdk)
254254

255255
// naver maps to compose
256256
implementation(libs.naver.map.compose)
@@ -265,5 +265,8 @@ dependencies {
265265
// Paging3
266266
implementation(libs.androidx.paging.runtime)
267267
implementation(libs.androidx.paging.compose)
268+
}
268269

270+
configurations.all {
271+
exclude(group = "io.github.fornewid", module = "naver-map-location")
269272
}

app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt

Lines changed: 130 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package com.eatssu.android.presentation.map
44

55
import android.Manifest
6+
import android.R.id.message
67
import android.app.Activity
78
import android.content.Context
89
import android.content.ContextWrapper
@@ -11,11 +12,19 @@ import android.content.pm.PackageManager
1112
import android.widget.Toast
1213
import androidx.activity.compose.rememberLauncherForActivityResult
1314
import androidx.activity.result.contract.ActivityResultContracts
15+
import androidx.compose.foundation.Image
16+
import androidx.compose.foundation.background
17+
import androidx.compose.foundation.border
18+
import androidx.compose.foundation.layout.Arrangement.spacedBy
1419
import androidx.compose.foundation.layout.Box
1520
import androidx.compose.foundation.layout.PaddingValues
21+
import androidx.compose.foundation.layout.Row
1622
import androidx.compose.foundation.layout.fillMaxSize
1723
import androidx.compose.foundation.layout.fillMaxWidth
1824
import androidx.compose.foundation.layout.padding
25+
import androidx.compose.foundation.layout.size
26+
import androidx.compose.foundation.shape.CircleShape
27+
import androidx.compose.foundation.shape.RoundedCornerShape
1928
import androidx.compose.material3.CenterAlignedTopAppBar
2029
import androidx.compose.material3.ExperimentalMaterial3Api
2130
import androidx.compose.material3.Scaffold
@@ -31,8 +40,10 @@ import androidx.compose.runtime.remember
3140
import androidx.compose.runtime.rememberCoroutineScope
3241
import androidx.compose.ui.Alignment
3342
import androidx.compose.ui.Modifier
43+
import androidx.compose.ui.graphics.Color
3444
import androidx.compose.ui.platform.LocalContext
3545
import androidx.compose.ui.res.dimensionResource
46+
import androidx.compose.ui.res.painterResource
3647
import androidx.compose.ui.res.stringResource
3748
import androidx.compose.ui.tooling.preview.Preview
3849
import androidx.compose.ui.unit.dp
@@ -44,6 +55,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
4455
import androidx.lifecycle.compose.collectAsStateWithLifecycle
4556
import androidx.lifecycle.viewmodel.compose.viewModel
4657
import com.eatssu.android.R
58+
import com.eatssu.android.domain.model.Partnership
4759
import com.eatssu.android.domain.model.RestaurantType
4860
import com.eatssu.android.presentation.MainState
4961
import com.eatssu.android.presentation.MainViewModel
@@ -53,25 +65,28 @@ import com.eatssu.android.presentation.map.component.MapRestaurantBottomSheet
5365
import com.eatssu.android.presentation.map.component.PartnershipFilterToggle
5466
import com.eatssu.android.presentation.mypage.userinfo.UserInfoActivity
5567
import com.eatssu.android.presentation.util.TrackScreenViewEvent
68+
import com.eatssu.android.presentation.util.showToast
5669
import com.eatssu.common.EventLogger
5770
import com.eatssu.common.UiEvent
5871
import com.eatssu.common.UiState
72+
import com.eatssu.common.UiText
5973
import com.eatssu.common.enums.ScreenId
60-
import com.eatssu.design_system.theme.Black
74+
import com.eatssu.common.enums.ToastType
6175
import com.eatssu.design_system.theme.EatssuTheme
76+
import com.eatssu.design_system.theme.Gray300
77+
import com.eatssu.design_system.theme.Primary
6278
import com.naver.maps.geometry.LatLng
6379
import com.naver.maps.map.CameraPosition
64-
import com.naver.maps.map.compose.Align
80+
import com.naver.maps.map.CameraUpdate
81+
import com.naver.maps.map.clustering.ClusteringKey
6582
import com.naver.maps.map.compose.CameraPositionState
83+
import com.naver.maps.map.compose.Clustering
6684
import com.naver.maps.map.compose.ExperimentalNaverMapApi
6785
import com.naver.maps.map.compose.LocationTrackingMode
6886
import com.naver.maps.map.compose.MapProperties
6987
import com.naver.maps.map.compose.MapUiSettings
70-
import com.naver.maps.map.compose.Marker
7188
import com.naver.maps.map.compose.NaverMap
7289
import com.naver.maps.map.compose.rememberCameraPositionState
73-
import com.naver.maps.map.compose.rememberMarkerState
74-
import com.naver.maps.map.overlay.OverlayImage
7590
import com.naver.maps.map.util.FusedLocationSource
7691
import kotlinx.coroutines.flow.collectLatest
7792
import kotlinx.coroutines.launch
@@ -124,7 +139,10 @@ fun MapRoute(
124139
) { permissions ->
125140
val granted = permissions.values.all { it }
126141
if (!granted) {
127-
Toast.makeText(context, context.getString(R.string.dialog_location_permission_description), Toast.LENGTH_SHORT).show()
142+
context.showToast(
143+
UiText.StringResource(R.string.dialog_location_permission_description),
144+
ToastType.INFO
145+
)
128146
}
129147
}
130148

@@ -143,15 +161,17 @@ fun MapRoute(
143161
LaunchedEffect(Unit) {
144162
viewModel.uiEvent.collectLatest { event ->
145163
when (event) {
146-
is UiEvent.ShowToast -> Toast.makeText(context, event.message.asString(context), Toast.LENGTH_SHORT).show()
164+
is UiEvent.ShowToast -> context.showToast(event)
147165
}
148166
}
149167
}
150168

151169
// 최초 실행 시 위치 권한 요청
152170
LaunchedEffect(Unit) {
153-
val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
154-
val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
171+
val fine =
172+
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
173+
val coarse =
174+
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
155175

156176
if (fine != PackageManager.PERMISSION_GRANTED || coarse != PackageManager.PERMISSION_GRANTED) {
157177
permissionLauncher.launch(
@@ -214,9 +234,9 @@ fun MapRoute(
214234
locationSource = locationSource,
215235
departmentSheetState = departmentSheetState,
216236
partnershipSheetState = partnershipSheetState,
217-
showToast = { message ->
237+
showToast = { uiText, info ->
218238
scope.launch {
219-
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
239+
context.showToast(uiText, info)
220240
}
221241
},
222242
navigateToUserInfo = {
@@ -229,6 +249,18 @@ fun MapRoute(
229249
onHidePartnershipSheet = {
230250
scope.launch { partnershipSheetState.hide() }
231251
},
252+
animateCameraPositionTo = { position, currentZoom ->
253+
scope.launch {
254+
cameraPositionState.animate(
255+
CameraUpdate.toCameraPosition(
256+
CameraPosition(
257+
position,
258+
currentZoom + 2.0
259+
)
260+
)
261+
)
262+
}
263+
},
232264
onSelectedFilterChange = { filter ->
233265
viewModel.setFilter(filter)
234266
},
@@ -247,17 +279,17 @@ internal fun MapScreen(
247279
locationSource: FusedLocationSource,
248280
departmentSheetState: SheetState,
249281
partnershipSheetState: SheetState,
250-
showToast: (String) -> Unit,
282+
showToast: (UiText, ToastType) -> Unit,
251283
navigateToUserInfo: () -> Unit,
252284
onHideDepartmentSheet: () -> Unit = {},
253285
onHidePartnershipSheet: () -> Unit = {},
286+
animateCameraPositionTo: (LatLng, Double) -> Unit,
254287
onSelectedFilterChange: (FilterType) -> Unit,
255288
departmentId: Long,
256289
collegeId: Long,
257290
departmentName: String?,
258291
selectedFilter: FilterType,
259292
) {
260-
val context = LocalContext.current
261293
Scaffold(
262294
topBar = {
263295
CenterAlignedTopAppBar(
@@ -331,46 +363,96 @@ internal fun MapScreen(
331363
locationSource = locationSource,
332364
contentPadding = PaddingValues(bottom = dimensionResource(R.dimen.bottom_nav_height)),
333365
properties = MapProperties(
334-
locationTrackingMode = LocationTrackingMode.Follow,
366+
// 현재 다른 위치에 있는 경우에도 숭실대입구를 보여주어야 함
367+
locationTrackingMode = LocationTrackingMode.NoFollow,
335368
),
336369
) {
337-
mapState.partnerships.forEach { partnership ->
338-
val markerState = rememberMarkerState(
339-
position = LatLng(
340-
partnership.latitude,
341-
partnership.longitude
342-
)
370+
val clusterItems = mapState.partnerships.associateBy {
371+
ItemKey(
372+
it.storeName,
373+
LatLng(it.latitude, it.longitude)
343374
)
375+
}
344376

345-
Marker(
346-
icon = OverlayImage.fromResource(
347-
when (partnership.restaurantType) {
377+
Clustering(
378+
items = clusterItems,
379+
thresholdStrategy = {
380+
// 줌 레벨에 상관 없이 임의의 값 사용
381+
25.0
382+
},
383+
384+
clusterContent = {
385+
Box(
386+
modifier = Modifier
387+
.size(32.dp)
388+
.background(Primary, CircleShape),
389+
contentAlignment = Alignment.Center
390+
) {
391+
Text(
392+
text = "${it.size}",
393+
color = Color.White,
394+
style = EatssuTheme.typography.body2
395+
)
396+
}
397+
},
398+
leafContent = { info ->
399+
val partnership = info.tag as? Partnership ?: return@Clustering
400+
401+
Row(
402+
modifier = Modifier
403+
.background(Color.White, RoundedCornerShape(13.dp))
404+
.border(1.dp, Gray300, RoundedCornerShape(13.dp))
405+
.padding(
406+
start = 3.dp,
407+
end = 7.dp,
408+
top = 2.5.dp,
409+
bottom = 2.5.dp
410+
),
411+
verticalAlignment = Alignment.CenterVertically,
412+
horizontalArrangement = spacedBy(
413+
3.dp
414+
)
415+
) {
416+
val iconRes = when (partnership.restaurantType) {
348417
RestaurantType.CAFE -> R.drawable.ic_map_marker_cafe
349-
RestaurantType.RESTAURANT -> R.drawable.ic_map_marker_restaurant
350418
RestaurantType.PUB -> R.drawable.ic_map_marker_pub
419+
else -> R.drawable.ic_map_marker_restaurant
351420
}
352-
),
353-
width = 20.dp,
354-
height = 20.dp,
355-
captionAligns = arrayOf(Align.Bottom),
356-
state = markerState,
357-
captionText = partnership.storeName,
358-
captionColor = Black,
359-
captionTextSize = 10.sp,
360-
onClick = {
361-
if (partnership.partnershipInfos.isEmpty()) {
362-
showToast(context.getString(R.string.toast_partnership_info_not_found))
363-
true
364-
} else {
365-
// 제휴 정보가 있을 때만 바텀시트 띄움
366-
// LaunchedEffect에서 자동으로 표시됨
367-
viewModel.selectPartnershipByStoreName(partnership.storeName)
368-
true
369-
}
421+
422+
Image(
423+
painter = painterResource(id = iconRes),
424+
contentDescription = null,
425+
modifier = Modifier.size(20.dp)
426+
)
427+
428+
Text(
429+
text = partnership.storeName,
430+
style = EatssuTheme.typography.caption3,
431+
color = Color.Black
432+
)
370433
}
371-
)
434+
},
435+
onClickCluster = { info, _ ->
436+
animateCameraPositionTo(info.position, cameraPositionState.position.zoom)
437+
true
438+
},
439+
onClickLeaf = { info, _ ->
440+
val partnership = info.tag as? Partnership ?: return@Clustering true
441+
442+
if (partnership.partnershipInfos.isEmpty()) {
443+
// 제휴 정보가 없을 때는 토스트만 띄우고 바텀시트는 안 띄움
444+
showToast(
445+
UiText.StringResource(R.string.toast_partnership_info_not_found),
446+
ToastType.INFO
447+
)
448+
} else {
449+
// 제휴 정보가 있을 때만 바텀시트 띄움
450+
viewModel.selectPartnershipByStoreName(partnership.storeName)
451+
}
452+
true
453+
}
372454

373-
}
455+
)
374456
}
375457

376458
// 학과 정보를 입력하지 않은 상태에서 제휴 필터를 변경하려고 할 때 BottomSheet 표시
@@ -384,25 +466,6 @@ internal fun MapScreen(
384466
modifier = Modifier.padding(top = 12.dp),
385467
departmentName = departmentName.toString()
386468
)
387-
388-
// 찜 기능
389-
// FloatingActionButton(
390-
// onClick = { /* TODO */ },
391-
// containerColor = White,
392-
// elevation = FloatingActionButtonDefaults.elevation(4.dp),
393-
// shape = CircleShape,
394-
// modifier = Modifier
395-
// .padding(top = 12.dp, end = 16.dp)
396-
// .border(width = 1.dp, color = Gray300, shape = CircleShape)
397-
// .size(40.dp)
398-
// .align(Alignment.TopEnd)
399-
// ) {
400-
// Image(
401-
// painter = painterResource(id = R.drawable.ic_like),
402-
// contentDescription = "좋아요",
403-
// modifier = Modifier.size(20.dp)
404-
// )
405-
// }
406469
}
407470
}
408471
}
@@ -421,3 +484,7 @@ fun MapFragmentComposeViewPreview() {
421484
MapRoute()
422485
}
423486
}
487+
488+
data class ItemKey(val id: String, private val latLng: LatLng) : ClusteringKey {
489+
override fun getPosition() = latLng
490+
}

app/src/main/java/com/eatssu/android/presentation/util/ToastUtil.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import androidx.fragment.app.Fragment
1212
import com.eatssu.android.databinding.ToastLayoutBinding
1313
import com.eatssu.android.presentation.map.findActivityOrNull
1414
import com.eatssu.common.UiEvent
15+
import com.eatssu.common.UiText
1516
import com.eatssu.common.enums.ToastType
1617
import com.google.android.material.snackbar.Snackbar
1718
import timber.log.Timber
@@ -61,6 +62,9 @@ fun Context.showToast(
6162
fun Context.showToast(@StringRes messageId: Int, type: ToastType) =
6263
showToast(getString(messageId), type)
6364

65+
fun Context.showToast(uiText: UiText, type: ToastType) =
66+
showToast(uiText.asString(this), type)
67+
6468
fun Context.showToast(event: UiEvent.ShowToast) =
6569
showToast(event.message.asString(this), event.type)
6670

gradle/libs.versions.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ lifecycleRuntimeKtx = "2.5.2"
1313
lifecycleViewmodelCompose = "2.8.7"
1414
mapSdk = "3.21.0"
1515
constraintlayout = "2.1.4"
16-
naverMapCompose = "1.8.2"
16+
naverMapCompose = "fc~cluster-SNAPSHOT"
1717
naverMapLocation = "21.0.2"
18+
naverMapClustering = "1.0.2"
1819
navigationUi = "2.8.9"
1920
playServicesLocation = "21.3.0"
2021
navigationFragment = "2.9.3"
@@ -155,7 +156,7 @@ timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "tim
155156
oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "ossLicenses" }
156157
oss-licenses-plugin = { group = "com.google.android.gms", name = "oss-licenses-plugin", version.ref = "ossLicensesPlugin" }
157158
map-sdk = { module = "com.naver.maps:map-sdk", version.ref = "mapSdk" }
158-
naver-map-compose = { module = "io.github.fornewid:naver-map-compose", version.ref = "naverMapCompose" }
159+
naver-map-compose = { module = "com.github.EAT-SSU:naver-map-compose", version.ref = "naverMapCompose" }
159160
naver-map-location = { module = "io.github.fornewid:naver-map-location", version.ref = "naverMapLocation" }
160161
play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" }
161162
posthog-android = { group = "com.posthog", name = "posthog-android", version.ref = "posthog" }

settings.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencyResolutionManagement {
1313
mavenCentral()
1414
maven { url 'https://devrepo.kakao.com/nexus/content/groups/public/' }
1515
maven { url 'https://repository.map.naver.com/archive/maven' }
16+
maven { url 'https://jitpack.io' }
1617
}
1718
}
1819
rootProject.name = "EatSSU-Android"

0 commit comments

Comments
 (0)