[Android] 카카오 맵 SDK 지도 구현하기 2부 - 상태 관리 및 지도 기능 구현하기

H.Zoon·2024년 9월 27일
0
post-thumbnail

이번 글에서는 2부로서, 애플리케이션의 핵심 서비스인 상태 관리 및 전환에 초점을 맞춰 구현 방법을 공유하려고 한다.

지도 애플리케이션에서는 사용자 상호작용에 따라 UI 상태가 변화한다.
이러한 상태를 효율적으로 관리하기 위해 MVVM 패턴과 ViewModel을 활용하여 구현하였다.

1. ViewModel 구성하기

이번에 구현할 지도 앱은 크게 다음과 같은 4가지 비즈니스 플로우를 가진다.

  1. 메인화면: 초기 상태 또는 대기 상태
  2. 주소검색: 사용자가 주소 검색 중인 상태이며 기존 검색기록 노출
  3. 검색 결과 노출하기: 검색 결과를 리스트로 표시하는 상태
  4. 검색 상세 노출하기: 검색 결과를 리스트에서 선택된 장소의 상세 정보를 표시하는 상태

MapViewModel은 UI 상태와 이벤트를 관리하도록 구현하였다.

UI 상태 관리 (UiState)

sealed class UiState {
    object Idle : UiState() // 초기 상태
    object Searching : UiState() // 검색 중
    data class SearchResult(val items: List<Document>) : UiState() // 검색 결과 표시 중
    data class ShowDetail(val item: Document) : UiState() // 상세 정보 표시 중
}
•	Idle: 초기 상태 또는 대기 상태
•	Searching: 주소 검색 중인 상태
•	SearchResult: 검색 결과를 표시하는 상태
•	ShowDetail: 선택된 장소의 상세 정보를 표시하는 상태

UI 이벤트 관리 (UiEvent)

sealed class UiEvent {
    data class Search(val query: String) : UiEvent()
    data class SelectResult(val document: Document) : UiEvent()
    object ClearResult : UiEvent()
    object ClearDetail : UiEvent()  // 상세 다이얼로그 닫기 이벤트
}
•	Search: 사용자가 검색을 요청하는 이벤트
•	SelectResult: 검색 결과 중 하나를 선택하는 이벤트
•	ClearResult: 검색 결과를 초기화하는 이벤트
•	ClearDetail: 상세 정보를 닫는 이벤트

ViewModel 코드 전체

@HiltViewModel
class MapViewModel @Inject constructor(
    private val repository: AddressRepository
) : ViewModel() {

    private val _uiState = MutableLiveData<UiState>(UiState.Idle)
    val uiState: LiveData<UiState> = _uiState

    private val _isLoading = MutableLiveData<Boolean>()
    val isLoading: LiveData<Boolean> = _isLoading

	// 검색결과에 에러 발생을 관리하는 StateFlow
    private val _errorMessage = MutableLiveData<String?>()
    val errorMessage: LiveData<String?> = _errorMessage

    // 카메라 트래킹 상태를 관리하는 StateFlow
    private val _cameraIsTracking = MutableStateFlow(true)
    val cameraIsTracking: StateFlow<Boolean> = _cameraIsTracking.asStateFlow()

    // 카메라 트래킹 상태 설정 함수
    fun setCameraTracking(value: Boolean) {
        _cameraIsTracking.value = value
    }

    // 검색 결과 초기화 함수
    fun clearResult() {
        _uiState.value = UiState.Idle
    }

    // UI 이벤트 처리 함수
    fun onEvent(event: UiEvent) {
        when (event) {
            is UiEvent.Search -> {
                searchAddress(event.query)
            }
            is UiEvent.SelectResult -> {
                _uiState.value = UiState.ShowDetail(event.document)
            }
            UiEvent.ClearResult -> {
                clearResult()
            }
            UiEvent.ClearDetail -> {
                if (_uiState.value is UiState.ShowDetail) {
                    _uiState.value = UiState.SearchResult(lastSearchResult ?: emptyList())
                }
            }
        }
    }

    private var lastSearchResult: List<Document>? = null

    // 주소 검색 함수
    private fun searchAddress(query: String?) {
        _isLoading.value = true
        _errorMessage.value = null
        _uiState.value = UiState.Searching
        viewModelScope.launch {
            try {
                val apiResult: KakaoAddressResponse? = repository.searchAddress(query, 1, 10)
                apiResult?.let {
                    lastSearchResult = it.documents
                    _uiState.value = UiState.SearchResult(it.documents)
                } ?: run {
                    _errorMessage.value = "결과가 없습니다."
                    _uiState.value = UiState.Idle
                }
            } catch (e: Exception) {
                _errorMessage.value = e.message
                _uiState.value = UiState.Idle
            } finally {
                _isLoading.value = false
            }
        }
    }
}

위 뷰모델은 다음과 같이 동작하도록 설계하였다.

1. 앱 시작 (Idle 상태)
앱이 처음 열리면 UiState.Idle 상태로 설정되어 대기, UI는 초기 화면을 표시.

2. 주소 검색 시작 (Searching 상태로 전환)
사용자가 검색어를 입력하고 검색 버튼을 누르면 UiEvent.Search 이벤트가 발생.
ViewModel은 이를 처리하여 비동기적으로 주소를 검색하고, UiState.Searching 상태로 전환(로딩 프로그래스 노출).

3. 검색 결과 표시 (SearchResult 상태로 전환)
검색이 완료되면 ViewModel은 결과를 받아 UiState.SearchResult로 상태를 업데이트.
UI는 검색된 결과 리스트를 표시.
결과가 없으면 UiState.Error 상태로 전환되어 에러 메시지를 표시.

4. 상세 정보 표시 (ShowDetail 상태로 전환)
사용자가 검색 결과 중 하나를 선택하면 UiEvent.SelectResult 이벤트가 발생.
ViewModel은 선택된 장소의 정보를 받아 UiState.ShowDetail로 상태를 전환하고, UI는 상세 정보 표시.

5. 상세 정보 닫기 (SearchResult 상태로 전환)
사용자가 상세 정보를 닫으면 UiEvent.ClearDetail 이벤트가 발생.
ViewModel은 다시 UiState.SearchResult로 상태를 전환하고, UI는 검색 결과 리스트를 표시.

6. 검색 결과 초기화 (Idle 상태로 전환)
사용자가 검색 결과를 초기화하면 UiEvent.ClearResult 이벤트가 발생.
ViewModel은 이를 처리하여 UiState.Idle 상태로 전환되며, UI는 초기 화면으로 이동.

2. 상태 변화에 따른 UI 구성하기

Compose에서는 위 ViewModel의 상태를 관찰하여 UI를 업데이트 하도록 하였다.

해당 함수의 핵심 구현부 예제이다

SearchHistoryApp 함수

@Composable
fun SearchHistoryApp(viewModel: MapViewModel) {
    val uiState by viewModel.uiState.observeAsState(UiState.Idle)![](https://velog.velcdn.com/images/jun34723/post/36f3caf9-857d-4ab8-92f9-e48e7a7fa90a/image.gif)

    // 기타 필요한 상태들...

    Scaffold(
        topBar = { /* 검색 바 */ },
        content = { paddingValues ->
            Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
                MapScreen()
                when (uiState) {
                    is UiState.Idle -> {
                        // 기본 지도 화면
                    }
                    is UiState.Searching -> {
                    	// 검색 히스토리 노출
                    }
                    is UiState.SearchResult -> {
                        // 검색 결과 리스트 표시 
                    }
                    is UiState.ShowDetail -> {
                        // 상세 정보 화면 표시
                }
            }
        }
    )
}

MapScreen() 함수는 카카오맵 구현 1부에서 작성한 메인지도 화면이다.
지도뷰는 계속해서 재사용되기 때문에 한번 그려진 이후 다시 그려지지 않도록 하였다.

topBar를 통해 검색버튼을 구현하고 uiState 에 따라 각 UI가 분기되도록 구현하였다.

3. 검색창 구현하기

검색 버튼을 누르면 UiEvent.Search 이벤트를 ViewModel에 전달한다.
이후 ViewModel의 searchAddress 함수를 호출하여 주소 검색을 수행하고,
결과 상태를 변경하도록 처리하였다.

이때 위와같이 사용자가 검색을 위해 텍스트필드에 포커싱하는 경우 히스토리와 검색 버튼을 AnimatedVisibility 을 통해 자연스럽게 나타나도록 구현할 수 있다.

AnimatedVisibility(
	visible = isSearchHistoryVisible,
	enter = fadeIn(animationSpec = tween(300)) + slideInVertically(),
	exit = fadeOut(animationSpec = tween(300)) + slideOutVertically()
    ) {
SearchHistoryList(searchHistory = listOf("기록 1", "기록 2"),
	onHistoryItemClick = { selectedItem ->
	searchText = TextFieldValue(selectedItem)       
	viewModel.onEvent(MapViewModel.UiEvent.Search(selectedItem)) // 선택한 기록으로 검색
	isSearchHistoryVisible = false
	focusManager.clearFocus()
                                    }
                                )
                            }

검색 결과 선택 처리

AddressList(
    items = searchResult,
    onAddressItemClick = { selectedDocument ->
        viewModel.onEvent(UiEvent.SelectResult(selectedDocument))
    }
)
•	검색 결과 리스트에서 아이템을 선택하면 UiEvent.SelectResult 이벤트를 전달합니다.
•	ViewModel은 상태를 UiState.ShowDetail로 변경하여 상세 정보를 표시합니다.

상세 정보 닫기 처리

BackHandler {
    when (uiState) {
        is UiState.ShowDetail -> {
            viewModel.onEvent(UiEvent.ClearDetail)
        }
        else -> {
            // 기타 처리
        }
    }
}
•	상세 정보 화면에서 뒤로 가기 버튼을 누르면 UiEvent.ClearDetail 이벤트를 전달합니다.
•	ViewModel은 이전 상태인 UiState.SearchResult로 돌아갑니다.

3. 검색된 결과에 마커 노출하기

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LocationDetailBottomSheetScaffold(document: Document) {
    // 카메라 업데이트: 검색된 위치의 좌표를 받아 지도 중심으로 이동.
    val cameraUpdate = CameraUpdateFactory.newCenterPosition(
        LatLng.from(
            document.y.toDouble(),  // y 좌표 (위도).
            document.x.toDouble()   // x 좌표 (경도).
        )
    )
    // 카메라를 해당 좌표로 이동.
    kakaoMaps?.moveCamera(cameraUpdate)

    // 선택된 위치에 라벨(마커) 추가.
    detailLabel = kakaoMaps?.labelManager?.layer?.addLabel(
        LabelOptions.from(
            "dotLabel2", LatLng.from(
                document.y.toDouble(),  // y 좌표 (위도).
                document.x.toDouble()   // x 좌표 (경도).
            )
        )
            .setStyles(
                // 오렌지 색상의 핀 아이콘을 라벨 스타일로 설정.
                LabelStyle.from(R.drawable.icon_pin_orange).setAnchorPoint(0.5f, 1f)
            )
            .setRank(1)  // 라벨 우선 순위를 설정 (다른 라벨보다 우선 노출).
    )
}
  1. 카메라 이동하기

CameraUpdateFactory.newCenterPosition: document 객체에 담긴 장소의 위도(y)와 경도(x)를 사용하여 지도의 중심 좌표를 생성합니다. 이를 통해 지도에서 해당 장소가 화면의 중앙에 위치하게 됩니다.

kakaoMaps?.moveCamera(cameraUpdate): 만들어진 cameraUpdate 객체를 사용해 지도 카메라를 해당 좌표로 이동시킵니다. 이로써 사용자는 선택된 장소를 지도의 중심에서 볼 수 있습니다.

  1. 라벨(마커) 추가

kakaoMaps?.labelManager?.layer?.addLabel: LabelOptions.from()을 사용해 라벨의 좌표를 설정합니다. 여기서도 document의 위도(y)와 경도(x)를 사용하여 마커를 추가할 위치를 지정합니다.

setStyles: 추가된 라벨의 스타일을 설정하는 부분입니다. 오렌지 색상의 핀 아이콘(R.drawable.icon_pin_orange)을 마커로 사용하고, setAnchorPoint(0.5f, 1f)는 마커의 앵커 포인트를 설정하여 마커가 정확히 위치에 맞게 표시되도록 조정합니다.

setRank(1): 라벨의 우선 순위를 지정합니다. 우선 순위가 높은 라벨은 다른 라벨들보다 앞서서 표시됩니다.

4개의 댓글

comment-user-thumbnail
2025년 2월 10일

컴포저블 함수에서 kakaoMaps 를 어떻게 사용하셨는지 알 수 있을까요?

2개의 답글