티스토리 뷰

1. 안드로이드 에어비앤비 앱 비슷하게 만들어 보기

네이버 맵 api 를 이용해서 에어비앤비와 비슷한 안드로이드 앱을 만들어보았습니다. 네이버 지도를 메인으로 숙소 목록을 서버(mocky 사용)에서 받아와 지도상에 마커로 나타내주고 하단에 좌우로 스크롤 가능한 페이져를 둬서 숙소를 살필 수 있습니다.

주요 기능

  • 네이버 지도 api 를 사용해서 지도를 보여줌
  • Mock api 를 사용하여 예약가능한 숙소 목록을 지도에 표시
  • 하단 시트뷰를 통한 숙소목록을 인터렉션하게 표현
  • 현재 보고 있는 숙소의 위치를 지도에서 중앙으로 연동
  • 숙소를 눌러 외부로 공유할 수 있음

사용 기술

  • Naver map api
  • mocky
  • ViewPager2
  • CoordinatorLayout
  • BottomSheetBehavior
  • retrofit
  • glide
  • layout include

결과 화면

2. 기본 레이아웃 구성

레이아웃은 기본적으로 가장 상단에 네이버 맵뷰가 위치하며 그 아래 뷰페이져2를 이용하여 지도에 나타난 숙소를 좌우로 스크롤할 수 있는 뷰 그리고 및에는 bottomSheetView 를 둬서 위로 스크롤 해서 숙소목록을 자세히 볼 수 있도록 구성하였습니다.

또한 하단 시트뷰의 경우에는 다른 xml 로 뺀 뒤 메인 레이아웃에 include 하는 방식을 사용하였고 네이버 맵의 현재 위치 기능을 사용하기 위해서 LocationButtonView 를 추가 했습니다. (따로 정의해주지 않아도 코드로 표시할 수 있지만, 다른 뷰에 가려져 임의로 정의 했습니다.)

[activity_main.xml]

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
   ...>

    <com.naver.maps.map.MapView
        android:id="@+id/mapView"
        ... />

    <androidx.viewpager2.widget.ViewPager2
        android:id="@+id/houseViewPager"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:layout_gravity="bottom"
        android:layout_marginBottom="100dp"
        android:orientation="horizontal" />

    <com.naver.maps.map.widget.LocationButtonView
        android:layout_gravity="top|start"
        android:layout_margin="12dp"
        android:id="@+id/currentLocationButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

    <include layout="@layout/bottom_sheet" />


</androidx.coordinatorlayout.widget.CoordinatorLayout>

[bottom_sheet.xml]

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
	...
    android:background="@drawable/top_radius_white_background"
    app:behavior_peekHeight="100dp"
    app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">

    <View
        android:layout_width="30dp"
        ...
        android:background="#cccccc"
        android:layout_marginTop="12dp"
        android:layout_height="3dp" />

    <TextView
        android:id="@+id/bottomSheetTitleTextView"
        android:layout_width="0dp"
        android:layout_height="100dp"
        android:gravity="center"
        android:text="여러개의 숙소"
        android:textColor="@color/black"
        android:textSize="15sp"
        android:textStyle="bold"
        ... />

    <View
        ...
        android:background="#cccccc"
        android:layout_height="1dp" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
       ... />

</androidx.constraintlayout.widget.ConstraintLayout>

bottomSheetview 의 경우에는 위로 당길 수 있을 듯한 느낌을 주기위해 위에 라인을 뷰를 이용해 추가 하였고 몇개의 숙소가 있는지 표시할 텍스트뷰 그리고 리사이클러뷰로 이루어져 있습니다.

메인엑티비티의 베이스 레이아웃을 CoordinatorLayout 으로 설정하고 bottomSheetView 속성으로 layout_behavior, behavior_peekHeight 를 설정해서 아래에 100dp 만큼 튀어나와있고 스크롤해서 위로 당길 수 있도록 설정해주었습니다.

좌 mainActivity 우 bottomSheet

2.1. 네이버 지도 API 사용하기

네이버 지도를 안드로이드에서 사용하기 위해서 네이버 콘솔을 사용할 수 있습니다. 회원가입이 필요하며 결제 수단을 등록해야 네이버 맵을 사용할 수 있는 기능이 표시되기 때문에 꼭 결제수단을 등록하시기 바랍니다. (카드를 가지고 있지 않다가 삽질좀 했습니다..😅)

이후 프로젝트를 추가한 뒤에 안드로이드 스튜디오로 돌아와서 아래와 같이 그레이들을 설정해서 안드로이드용 sdk를 받아와 줍니다. 자신의 프로젝트 패키지명을 잘 등록해주어야 정상적으로 받아올 수 있기 때문에 네이버 콘솔에서 꼭 제대로된 값으로 추가하셔야해요.

프로젝트 수준 gradle

    repositories {
        google()
        mavenCentral()
        maven {
            url 'https://naver.jfrog.io/artifactory/maven/'
        }
    }

앱 수준 gradle

    // 네이버 지도 SDK
    implementation 'com.naver.maps:map-sdk:3.12.0'

    implementation('com.google.android.gms:play-services-location:18.0.0')

    implementation('com.squareup.retrofit2:retrofit:2.9.0')
    implementation('com.squareup.retrofit2:converter-gson:2.9.0')

    implementation 'com.github.bumptech.glide:glide:4.12.0'

앱 수준 그레이들에서 앞으로 사용할 레트로핏이나 글라이드 또한 추가해주었습니다. 현위치 정보를 사용하기 위해 구글 서비스에 해당하는 녀석도 추가해주었습니다.

삽질

위에 gradle 설정 부분은 네이버 참고 문서에도 잘 나와 있는데 그대로 복사 붙여넣기 해주었는데도 계속 sync 에서 에러가 떠서 jar 등으로 직접 Import 해서 사용하려고 삽질하다가 결국 해결을 했는데 해결 방법은 settings.gradle 에 추가해주는것 이었습니다.

아래와 같이 추가해준 뒤 다시 sync 하니 빌드 성공을 하더군요. (허무 😮‍💨)

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        jcenter() // Warning: this repository is going to shut down soon
        maven {
            url 'https://naver.jfrog.io/artifactory/maven/'
        }
    }
}
rootProject.name = "AirBnb"
include ':app'

먼저 xml에 Naver MapView를 추가한 뒤 아래와 같이 초기화를 해줍니다.

    private val mapView: MapView by lazy { findViewById(R.id.mapView) }

엑티비티 생명주기 함수를 오버라이드 해서 맵뷰에도 연결시켜 줍니다. (아래)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        // onCreate 연결
        mapView.onCreate(savedInstanceState)
		...
    }
    
        override fun onStart() {
        super.onStart()
        mapView.onStart()
    }

    override fun onResume() {
        super.onResume()
        mapView.onResume()
    }

    override fun onPause() {
        super.onPause()
        mapView.onPause()
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        mapView.onSaveInstanceState(outState)
    }

    override fun onStop() {
        super.onStop()
        mapView.onStop()
    }

    override fun onDestroy() {
        super.onDestroy()
        mapView.onDestroy()
    }

    override fun onLowMemory() {
        super.onLowMemory()
        mapView.onLowMemory()
    }

맵 불러오기

이제 맵을 불러올건데 getMapAsync로 맵을 불러오며 이때 OnMapReadyCallback 을 넘겨서 맵이 받아와진 경우 처리를 해줄 수 있는데 해당 프로젝트에서는 메인엑티비티에서 이것을 구현해주었기 때문에 this 로 넘겨주었습니다.

// 맵 가져오기 -> onMapReady
mapView.getMapAsync(this)

onMapReady

    // 맵 가져오기(from: getMapAsync)
    override fun onMapReady(map: NaverMap) {
        naverMap = map

        // 줌 범위 설정
        naverMap.maxZoom = 18.0
        naverMap.minZoom = 10.0

        // 지도 위치 이동
        val cameraUpdate = CameraUpdate.scrollTo(LatLng(37.497801, 127.027591))
        naverMap.moveCamera(cameraUpdate)

        // 현위치 버튼 기능
        val uiSetting = naverMap.uiSettings
        uiSetting.isLocationButtonEnabled = false // 뷰 페이져에 가려져 이후 레이아웃에 정의 하였음.

        currentLocationButton.map = naverMap // 이후 정의한 현위치 버튼에 네이버맵 연결

        // -> onRequestPermissionsResult // 위치 권한 요청
        locationSource =
            FusedLocationSource(this@MainActivity, LOCATION_PERMISSION_REQUEST_CODE)
        naverMap.locationSource = locationSource

        // 지도 다 로드 이후에 가져오기
        getHouseListFromAPI()
    }

onMapReady 콜백을 통해서 지도가 불러와진 후 처리를 해줄 수 있습니다.

  • maxZoom, minZoom : 줌 할 수 있는 최대치와 최소치를 설정합니다.
  • CameraUpdate 를 통해서 현재 보여지고 있는 지도의 위치를 변경할 수 있습니다.
  • uiSetting.isLocationButtonEnabled 를 통해 현위치 버튼을 보이도록 할 수 있습니다.
  • locationSource 를 등록하여 현재위치를 사용할 수 있습니다.

현위치로 이동 버튼의 경우 위치 권한이 필요하며 이는 play-services-location 라이브러리를 사용해서 구현해주었습니다. 현위치 버튼의 경우 새로 정의해준 이유는 좌측하단에 위치하면 뷰페이져 등의 뷰로 가려져서 상단으로 이동시켜 주기위해 임의로 레이아웃에 추가했습니다.

3. 현재 위치로 이동하기

위치 정보를 사용하기 위해 메니페스트에 아래 권한을 추가합니다. (인터넷 권한도 필요하니 추가해두었습니다.)

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.INTERNET"/>

이후 FusedLocationSource를 사용하여 현위치를 사용할 수 있도록 할 것인데 이전에 권한을 요청할 때 요청코드를 주어 처리했던 것과 같이 따로 상수를 선언하여 1000 으로 지정해주었습니다.

private lateinit var locationSource: FusedLocationSource

...

locationSource =
	FusedLocationSource(this@MainActivity, LOCATION_PERMISSION_REQUEST_CODE)
naverMap.locationSource = locationSource

...

    companion object {
        private const val LOCATION_PERMISSION_REQUEST_CODE = 1000
    }

이후 onRequestPermissionsResult 에서 해당 코드로 오는 결과를 걸러서 현위치가 activated 되지 않았다면 위치 추적을 사용하지 않도록 설정합니다.

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        if (requestCode != LOCATION_PERMISSION_REQUEST_CODE)
            return

        if (locationSource.onRequestPermissionsResult(requestCode, permissions, grantResults)) {
            if (!locationSource.isActivated) {
                // 권한 설정 거부시 위치 추적을 사용하지 않음
                naverMap.locationTrackingMode = LocationTrackingMode.None
            }
            return
        }
    }

3.1. mocky 사용해서 서버에서 json 받아오기

mocky 를 사용해서 작성해둔 Json을 서버에서 받아올 수 있게 해줄 수 있습니다. 작성해둔 json 을 복사해서 http response body 에 붙여넣은 후 활성화 시키면 나오는 주소로 방문하면 해당 json을 받아올 수 있습니다.

json 형식으로 items 에 리스트로 각 항목은 id, title, price, lat, lng, imgUrl 을 가지고 있습니다. 이미지 url의 경우에도 picsum을 통해 임의의 이미지 주소를 받아와서 작성해주었습니다. 이후 해당 json을 retrofit 을 통해 받아와 처리해주도록 하겠습니다.

3.2. retrofit 사용 하기

레트로 핏을 사용하기 위해 서비스 인터페이스를 정의해둡니다. 베이스url을 제외한 url주소를 get에 설정해주고 받아올 데이터 클래스 형식을 dto로 넘겨 Call 해주는 getHouseList 함수를 정의 합니다.

import retrofit2.Call
import retrofit2.http.GET

// for retrofit
interface HouseService {
    @GET("/v3/6c14ab02-b757-4931-b3ba-2dump5765073") // 생성해둔 mocky 주소
    fun getHouseList(): Call<HouseDto>
}

데이터 클래스는 아래와 같으며 json 내부에 있는 키를 바탕으로 작성하였습니다. items 가 HouseModel 리스트를 들고 있는 구조이지요. lat, lng 로 지도에서의 위치 정보를 들고 있습니다. 이미지의 경우 glide 로 뿌려줄것 입니다.

data class HouseDto(
    val items: List<HouseModel>
)

data class HouseModel(
    val id: Int,
    val title: String,
    val price: String,
    val lat: Double,
    val lng: Double,
    val imgUrl: String
)

이후 retrofit의 빌더로 빌드를 해서 레트로핏을 사용합니다. (아래) 이때 baseurl 은 mocky의 주소를 베이스로 합니다. (이전 프로젝트에서 사용해본 이력이 있으므로 자세한 설명은 생략합니다. 자세한 설명이 필요하신 분은 제 블로그에서 다른 프로젝트를 참고해보세요.)

    private fun getHouseListFromAPI() {
        val retrofit = Retrofit.Builder()
            .baseUrl("https://run.mocky.io")
            .addConverterFactory(retrofit2.converter.gson.GsonConverterFactory.create())
            .build()

        retrofit.create(HouseService::class.java).also {
            it.getHouseList()
                .enqueue(object : Callback<HouseDto> {
                    override fun onResponse(call: Call<HouseDto>, response: Response<HouseDto>) {
                        if (response.isSuccessful.not()) {
                            // fail
                            Log.d("Retrofit", "실패1")

                            return
                        }

                        // 성공한 경우 아래 처리
                        response.body()?.let { dto ->
                            updateMarker(dto.items)
                            viewPagerAdapter.submitList(dto.items)
                            recyclerViewAdapter.submitList(dto.items)
                            bottomSheetTitleTextView.text = "${dto.items.size}개의 숙소"
                        }
                    }

                    override fun onFailure(call: Call<HouseDto>, t: Throwable) {
                        // 실패 처리 구현;
                        Log.d("Retrofit", "실패2")
                        Log.d("Retrofit", t.stackTraceToString())
                    }

                })
        }
    }

응답을 성공적으로 받아온 경우 body를 검사해서 dto로 받아와 처리해주도록 합니다. 하우스 모델 리스트를 받아와서 마커를 추가하고, 뷰페이져, 리사이클러뷰에 각각 뿌려지도록 해주었습니다. 간혹 가다 키 값을 오타낸 경우 실패하므로 오타에 주의하세요.

4. 지도에 마커 표시하기

    private fun updateMarker(houses: List<HouseModel>) {
        houses.forEach { house ->

            val marker = Marker()
            marker.position = LatLng(house.lat, house.lng)
            marker.onClickListener = this // 마커 클릭 시 뷰 페이져 연동 되도록 구현
            marker.map = naverMap
            marker.tag = house.id
            marker.icon = MarkerIcons.BLACK
            marker.iconTintColor = Color.RED

        }
    }

숙소 목록을 받아왔으면 Marker() 를 사용해 지도위에 마커를 표시할 수 있습니다. 우리에게는 위도와 경도 정보가 있으므로 해당 정보를 사용해서 맵에 연결시켜 주면 마커를 표시해줄 수 있습니다. 아이콘이나 색상을 설정할 수도 있습니다. tag는 숙소의 id로 지정해주었습니다.

4.1. 마커 클릭 리스너 구현

메인엑티비티에서 Overlay.OnClickListener를 구현해주어 마커에 클릭 리스너로 this 를 전달해줄 수 있습니다. 마커를 클릭하면 뷰페이저의 리스트에서 해당 아이디를 갖는 숙소를 찾은 뒤에 해당 숙소를 현재의 아이템 포지션으로 지정해주도록 해줍니다.

    // 지도 marker 클릭 시
    override fun onClick(overlay: Overlay): Boolean {
        // overlay : 마커

        val selectedModel = viewPagerAdapter.currentList.firstOrNull {
            it.id == overlay.tag
        }
        selectedModel?.let {
            val position = viewPagerAdapter.currentList.indexOf(it)
            viewPager.currentItem = position
        }
        return true
    }

5. 뷰 페이져 아이템 리스너 : 공유하기

뷰 페이져의 항목이 클릭된 경우 chooser를 통해서 다양한 앱으로 공유할 수 있도록 하는 함수를 하나 정의해 주었습니다. 해당 함수를 클릭리스너로 달아주면 putExtra로 설정된 텍스트가 공유할 수 있도록 작동합니다.

viewPager2, recyclerView 의 어뎁터와 아이템 레이아웃 코드의 경우에는 따로 싣지 않도록 하겠습니다. 참고로 뷰 페이져의 아이템 레이아웃은 match_parent 로 설정해주어야 오류가 나지 않습니다. (뷰페이져 어뎁터 코드, 리사이클러뷰 어뎁터 코드) (뷰페이져 아이템 레이아웃, 리사이클러뷰 아이템 레이아웃)

    private fun onHouseModelClicked(houseModel: HouseModel) {
        // 공유 기능; 인텐트에있는 츄져사용할것임
        val intent = Intent()
            .apply {
                action = Intent.ACTION_SEND
                putExtra(
                    Intent.EXTRA_TEXT,
                    "[지금 이 가격에 예약하세요!!] ${houseModel.title} ${houseModel.price} 사진 보기(${houseModel.imgUrl}",
                )
                type = "text/plain"
            }
        startActivity(Intent.createChooser(intent, null))
    }

해당 프로젝트의 전체 코드 및 파일이 궁금하신 분들은 저의 깃허브 저장소를 참고하시기 바랍니다. (해당 프로젝트는 fast campus 안드로이드 강좌를 수강하면서 학습한 내용입니다.)

2021.06.24 - [Android/App] - [Android] 중고 거래 앱 만들기 (중고 물품 등록, 채팅, 로그인)

2021.06.23 - [Android] - [Android] 커스텀 뷰 에서 엑티비티 종료 시키기 (customView finish)

2021.06.22 - [Android] - [Android] 코루틴으로 url 이미지 불러오기 (String 👉 Bitmap)

댓글
최근에 올라온 글
최근에 달린 댓글
네이버 이웃추가
«   2024/03   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함