Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

부산대 Android_김정희_2주차 과제_2단계 #52

Open
wants to merge 19 commits into
base: lovelhee
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,37 @@
# android-map-keyword

## Step1

### 📜 Description

카카오맵 클론 코딩 _ 로컬 데이터


### 🎯 Tasks

- Main 기능 : **검색 기능**

- 검색어 입력 및 검색 결과를 표시할 기본 레이아웃을 구현
- 검색에 사용될 데이터를 로컬 데이터베이스에 생성

---

## Step2

### 📜 Description

카카오맵 클론 코딩 _ 검색 및 삭제


### 🎯 Tasks

- Main 기능 : **검색 및 삭제 기능**

- 검색어를 입력하면 검색 결과 목록 표시
- 검색 결과 목록은 세로 스크롤이 됨
- 입력한 검색어는 X를 눌러서 삭제
- 검색 결과 목록에서 하나의 항목 선택
- 선택된 항목은 검색어 저장 목록에 추가
- 저장된 검색어 목록은 가로 스크롤
- 저장된 검색어는 X를 눌러서 삭제
- 저장된 검색어는 앱을 재실행하여도 유지
118 changes: 118 additions & 0 deletions app/src/main/java/campus/tech/kakao/map/DbHelper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package campus.tech.kakao.map

import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper

class DbHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {

companion object {
private const val DATABASE_NAME = "location.db"
private const val DATABASE_VERSION = 1
}

override fun onCreate(db: SQLiteDatabase) {

val createCafeTable = """
CREATE TABLE ${MapContract.TABLE_CAFE} (
${MapContract.COLUMN_ID} INTEGER PRIMARY KEY AUTOINCREMENT,
${MapContract.COLUMN_NAME} TEXT,
${MapContract.COLUMN_ADDRESS} TEXT,
${MapContract.COLUMN_CATEGORY} TEXT
)
""".trimIndent()

val createPharmacyTable = """
CREATE TABLE ${MapContract.TABLE_PHARMACY} (
${MapContract.COLUMN_ID} INTEGER PRIMARY KEY AUTOINCREMENT,
${MapContract.COLUMN_NAME} TEXT,
${MapContract.COLUMN_ADDRESS} TEXT,
${MapContract.COLUMN_CATEGORY} TEXT
)
""".trimIndent()

db.execSQL(createCafeTable)
db.execSQL(createPharmacyTable)
}

override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("DROP TABLE IF EXISTS ${MapContract.TABLE_CAFE}")
db.execSQL("DROP TABLE IF EXISTS ${MapContract.TABLE_PHARMACY}")
onCreate(db)
}

// step1 피드백 수정 부분
fun insertData() {
val db = writableDatabase

for (i in 1..9) {
val name = "카페$i"
// 데이터가 있는지 체크 후 데이터 삽입 처리
if (!dataExists(MapContract.TABLE_CAFE, name)) {
val insertCafe = """
INSERT INTO ${MapContract.TABLE_CAFE} (${MapContract.COLUMN_NAME}, ${MapContract.COLUMN_ADDRESS}, ${MapContract.COLUMN_CATEGORY})
VALUES ('$name', '서울 성동구 성수동 $i', '카페')
""".trimIndent()
db.execSQL(insertCafe)
}
}

for (i in 1..9) {
val name = "약국$i"
if (!dataExists(MapContract.TABLE_PHARMACY, name)) {
val insertPharmacy = """
INSERT INTO ${MapContract.TABLE_PHARMACY} (${MapContract.COLUMN_NAME}, ${MapContract.COLUMN_ADDRESS}, ${MapContract.COLUMN_CATEGORY})
VALUES ('$name', '서울 강남구 대치동 $i', '약국')
""".trimIndent()
db.execSQL(insertPharmacy)
}
}

db.close()
}

// 데이터 존재 여부 체킹 메소드
private fun dataExists(tableName: String, name: String): Boolean {
val db = readableDatabase
val query = "SELECT 1 FROM $tableName WHERE ${MapContract.COLUMN_NAME} = ?"
val cursor = db.rawQuery(query, arrayOf(name))
val exists = cursor.moveToFirst()
cursor.close()
return exists
}
Comment on lines +76 to +83

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 쿼리로 데이터 존재여부를 알수있다면 괜찮습니다.
SELECT EXISTS ... 문법도 있었던것 같네요. 차이점이 있는지 조사해보시고, EXISTS를 명시적으로 사용하는것도 괜찮을것 같습니다.


fun searchPlaces(keyword: String): List<MapItem> {
val db = readableDatabase
val query = """
SELECT ${MapContract.COLUMN_NAME}, ${MapContract.COLUMN_ADDRESS}, ${MapContract.COLUMN_CATEGORY}
FROM ${MapContract.TABLE_CAFE}
WHERE ${MapContract.COLUMN_NAME} LIKE ?
UNION
SELECT ${MapContract.COLUMN_NAME}, ${MapContract.COLUMN_ADDRESS}, ${MapContract.COLUMN_CATEGORY}
FROM ${MapContract.TABLE_PHARMACY}
WHERE ${MapContract.COLUMN_NAME} LIKE ?
""".trimIndent()
val cursor = db.rawQuery(query, arrayOf("%$keyword%", "%$keyword%"))
val results = mutableListOf<MapItem>()

cursor.use {
val nameIndex = cursor.getColumnIndex(MapContract.COLUMN_NAME)
val addressIndex = cursor.getColumnIndex(MapContract.COLUMN_ADDRESS)
val categoryIndex = cursor.getColumnIndex(MapContract.COLUMN_CATEGORY)

if (nameIndex != -1 && addressIndex != -1 && categoryIndex != -1) {
if (cursor.moveToFirst()) {
do {
val name = cursor.getString(nameIndex)
val address = cursor.getString(addressIndex)
val category = cursor.getString(categoryIndex)
results.add(MapItem(name, address, category))
} while (cursor.moveToNext())
}
}
}

return results
}
}
66 changes: 66 additions & 0 deletions app/src/main/java/campus/tech/kakao/map/KeywordAdapter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package campus.tech.kakao.map

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView

class KeywordAdapter(private val listener: OnKeywordRemoveListener) : RecyclerView.Adapter<KeywordAdapter.ViewHolder>() {

interface OnKeywordRemoveListener {
fun onKeywordRemove(keyword: String)
}

private val keywords = mutableListOf<String>()

val currentKeywords: List<String>
get() = keywords

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_keyword, parent, false)
return ViewHolder(view)
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val keyword = keywords[position]
holder.bind(keyword)
}

override fun getItemCount(): Int {
return keywords.size
}

fun submitList(newKeywords: List<String>) {
val oldSize = keywords.size
keywords.clear()
notifyItemRangeRemoved(0, oldSize)
keywords.addAll(newKeywords)
notifyItemRangeInserted(0, newKeywords.size)
}

fun addKeyword(keyword: String) {
if (!keywords.contains(keyword)) {
keywords.add(keyword)
notifyItemInserted(keywords.size - 1)
}
}

inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val tvKeyword: TextView = itemView.findViewById(R.id.tvKeyword)
private val ivRemove: ImageView = itemView.findViewById(R.id.imageView)

fun bind(keyword: String) {
tvKeyword.text = keyword
ivRemove.setOnClickListener {
val position = bindingAdapterPosition
if (position != RecyclerView.NO_POSITION) {
keywords.removeAt(position)
notifyItemRemoved(position)
listener.onKeywordRemove(keyword)
}
}
}
}
}
91 changes: 90 additions & 1 deletion app/src/main/java/campus/tech/kakao/map/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,100 @@
package campus.tech.kakao.map

import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView

class MainActivity : AppCompatActivity(), SearchResultAdapter.OnItemClickListener, KeywordAdapter.OnKeywordRemoveListener {

private lateinit var mapViewModel: MapViewModel
private lateinit var etKeywords: EditText
private lateinit var rvSearchResult: RecyclerView
private lateinit var rvKeywords: RecyclerView
private lateinit var tvNoResults: TextView
private lateinit var ivClear: ImageView

private val searchResultAdapter = SearchResultAdapter(this)
private val keywordAdapter = KeywordAdapter(this)

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

etKeywords = findViewById(R.id.etKeywords)
rvSearchResult = findViewById(R.id.rvSearchResult)
rvKeywords = findViewById(R.id.rvKeywords)
tvNoResults = findViewById(R.id.tvNoResults)
ivClear = findViewById(R.id.ivClear)

rvSearchResult.layoutManager = LinearLayoutManager(this)
rvSearchResult.adapter = searchResultAdapter

rvKeywords.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
rvKeywords.adapter = keywordAdapter

mapViewModel = ViewModelProvider(this).get(MapViewModel::class.java)
mapViewModel.insertData()

etKeywords.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
val keyword = s.toString()
if (keyword.isNotEmpty()) {
mapViewModel.searchPlaces(keyword)
} else {
searchResultAdapter.submitList(emptyList())
tvNoResults.visibility = TextView.VISIBLE
rvSearchResult.visibility = RecyclerView.GONE
}
}
Comment on lines +48 to +57

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

검색어를 실시간으로 입력하다보면 앱이 약간 지연되는 현상이 발생하곤 하던데, 이런 경우 어떤 식으로 문제를 해결하는 것이 좋을까요? (혹시.. 저의 안드로이드스튜디오에서만 생기는 문제일 수도 있습니다..)

지금은 앞서가는 내용이지만, UI 클릭/이벤트를 다룰때 debounce or throttle 라는 개념이 있습니다.
쉽게말해 몇초이내 동일한 이벤트가 재호출되면, 마지막 호출만 유효하게 처리한다. 라는것인데요. 검색어 입력시 호출되는 메소드에 적용해보면 좋을것 같습니다.

지금은 검색어를 입력할때마다 DB를 조회하게 되는데요.
짧은 시간에 과도한 호출이 발생하면 버벅이는 현상이 생길 수 있을것 같습니다.

또 다르게는 쓰레드 / 코루틴 같이 비동기로 처리할 수 있는 방법들을 사용하는것도 도움이 될것 같습니다.
코루틴은 지금 바로 사용하기엔 커리큘럼상 적합하지 않을수있으니 키워드만 알고 자세한 사용법이나 개념은 따로 한번 찾아보세요!

override fun afterTextChanged(s: Editable?) {}
})

mapViewModel.searchResults.observe(this, Observer { results ->
if (results.isEmpty()) {
tvNoResults.visibility = TextView.VISIBLE
rvSearchResult.visibility = RecyclerView.GONE
} else {
tvNoResults.visibility = TextView.GONE
rvSearchResult.visibility = RecyclerView.VISIBLE
searchResultAdapter.submitList(results)
}
})

ivClear.setOnClickListener {
etKeywords.text.clear()
}

loadKeywords()
}

override fun onItemClick(item: MapItem) {
keywordAdapter.addKeyword(item.name)
saveKeywords()
}

override fun onKeywordRemove(keyword: String) {
saveKeywords()
}

private fun loadKeywords() {
val sharedPreferences = getSharedPreferences("keywords", MODE_PRIVATE)
val keywords = sharedPreferences.getStringSet("keywords", setOf())?.toMutableList() ?: mutableListOf()
keywordAdapter.submitList(keywords)
}

private fun saveKeywords() {
val sharedPreferences = getSharedPreferences("keywords", MODE_PRIVATE)
val editor = sharedPreferences.edit()
editor.putStringSet("keywords", keywordAdapter.currentKeywords.toSet())
editor.apply()
}
}
12 changes: 12 additions & 0 deletions app/src/main/java/campus/tech/kakao/map/MapContract.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package campus.tech.kakao.map

object MapContract
{
const val TABLE_CAFE = "Cafe"
const val TABLE_PHARMACY = "Pharmacy"

const val COLUMN_ID = "id"
const val COLUMN_NAME = "name"
const val COLUMN_ADDRESS = "address"
const val COLUMN_CATEGORY = "category"
}
29 changes: 29 additions & 0 deletions app/src/main/java/campus/tech/kakao/map/MapViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package campus.tech.kakao.map

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData

class MapViewModel(application: Application) : AndroidViewModel(application) {

private val dbHelper = DbHelper(application)

private val _searchResults = MutableLiveData<List<MapItem>>()
val searchResults: LiveData<List<MapItem>> get() = _searchResults

fun insertData() {
dbHelper.insertData()
}

fun searchPlaces(keyword: String) {
val results = dbHelper.searchPlaces(keyword)
_searchResults.postValue(results)
}
Comment on lines +19 to +22

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RecyclerView와 ViewModel 간의 데이터 연동을 하는 부분을 구현하기가 어려웠습니다. 해당 부분의 문제점이나 개선할 점을 봐주시면 감사하겠습니다.

ViewModel 에서 조회한 데이터를 -> Activity로 넘기는 처리가 필요한데 LiveData 사용하신것은 좋았습니다. 흔히 사용하는 방법중 하나입니다.

다만, postValue(...) , setValue(...) 2가지 함수를 지원하는데, 각각의 차이를 알고 사용해보면 좋을것 같습니다.

}

data class MapItem(
val name: String,
val address: String,
val category: String
)
Loading