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주차 과제 Step 2 #47

Open
wants to merge 18 commits into
base: njiyeon
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,16 @@
# android-map-keyword
## 카카오맵 클론 코딩을 위한 시작

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

### step 2
- 검색어를 입력하면 검색 결과 목록이 표시된다.
- 검색 결과 목록은 세로 스크롤이 된다.
- 입력한 검색어는 X를 눌러서 삭제할 수 있다.
- 검색 결과 목록에서 하나의 항목을 선택할 수 있다.
- 선택된 항목은 검색어 저장 목록에 추가된다.
- 저장된 검색어 목록은 가로 스크롤이 된다.
- 저장된 검색어는 X를 눌러서 삭제할 수 있다.
- 저장된 검색어는 앱을 재실행하여도 유지된다
8 changes: 7 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,23 @@ android {

buildFeatures {
viewBinding = true
dataBinding = true
}
}

dependencies {

implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.datastore:datastore-preferences:1.0.0")

// 추가된 종속성
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1")
implementation("androidx.activity:activity-ktx:1.7.2")

testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
Expand Down
45 changes: 45 additions & 0 deletions app/src/main/java/campus/tech/kakao/map/HistoryAdapter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package campus.tech.kakao.map

import android.database.Cursor
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import campus.tech.kakao.map.databinding.ItemHistoryBinding

class HistoryAdapter(private val onDelete: (String) -> Unit) : RecyclerView.Adapter<HistoryAdapter.ViewHolder>() {

private var cursor: Cursor? = null
Copy link

Choose a reason for hiding this comment

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

Cursor를 Adapter까지 끌고 들어오는건 좋지 않아요. DB와 연결되는 객체이기 때문에 Adapter의 역할을 한참 벗어난것 같네요. ViewModel 단에서 Item을 load 하고 Adapter는 단순히 불러온 아이템을 표현하는 용도로만 사용해주세요

Copy link

Choose a reason for hiding this comment

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

쉽게 말하자면 신뢰할 수 있는 데이터의 집합인 LocalDB 에서 Adapter 까지 내려와야 합니다. LiveData 등을 활용해서
LocalDB -> Repository(Optional) -> ViewModel -> View -> Adpater 로 데이터가 흐르게끔 작성해보세요

Copy link
Author

Choose a reason for hiding this comment

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

제가 이해한 것이 맞다면, Cursor를 Adapter에서 직접 사용하는 게 아니라 ViewModel에서 데이터를 불러와 Adapter에 전달하도록 바꾸면 될까요?

Copy link

Choose a reason for hiding this comment

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

네 맞습니다 :)


inner class ViewHolder(private val binding: ItemHistoryBinding) : RecyclerView.ViewHolder(binding.root) {

fun bind(name: String) {
binding.tvName.text = name
binding.ibDelete.setOnClickListener {
onDelete(name)
}
}
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemHistoryBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
cursor?.apply {
moveToPosition(position)
val name = getString(getColumnIndexOrThrow(HistoryContract.COLUMN_NAME))
holder.bind(name)
Copy link

Choose a reason for hiding this comment

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

제가 잘 안읽히는 이유 중 하나도 apply의 역할때문입니다. apply는 객체의 속성을 정할 때 많이 사용되어서 getString, getColumnIndexOrThrow가 어디에 속한 메서드인지 명확하게 파악하기 힘드네요.
이 경우에는 apply 말고 그냥 plain 하게 작성하시는게 이해하기 편할 것 같습니다

}
Copy link

Choose a reason for hiding this comment

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

어떤 로직을 원하신 건가요?

}

override fun getItemCount(): Int {
return cursor?.count ?: 0
}

fun submitCursor(cursor: Cursor?) {
this.cursor = cursor
notifyDataSetChanged()
}
}

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

object HistoryContract {
const val TABLE_NAME = "History"
const val COLUMN_NAME = "name"
}
24 changes: 24 additions & 0 deletions app/src/main/java/campus/tech/kakao/map/HistoryDBHelper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package campus.tech.kakao.map

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

class HistoryDBHelper(
context: Context?,
name: String?,
factory: SQLiteDatabase.CursorFactory?,
version: Int
) : SQLiteOpenHelper(context, name, factory, version) {

override fun onCreate(db: SQLiteDatabase?) {
val createTableQuery = "CREATE TABLE ${HistoryContract.TABLE_NAME} (" +
"${HistoryContract.COLUMN_NAME} TEXT)"
db?.execSQL(createTableQuery)
}

override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
db?.execSQL("DROP TABLE IF EXISTS ${HistoryContract.TABLE_NAME}")
onCreate(db)
}
}
72 changes: 71 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,81 @@
package campus.tech.kakao.map

import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import campus.tech.kakao.map.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {

private lateinit var binding: ActivityMainBinding
private val viewModel: PlaceViewModel by viewModels()
private lateinit var placeAdapter: PlaceAdapter
private lateinit var historyAdapter: HistoryAdapter

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

binding.btnDelete.setOnClickListener {
binding.etSearch.setText(null)
}

setupView()

viewModel.places.observe(this, Observer { cursor ->
placeAdapter.submitCursor(cursor)
})
Copy link

Choose a reason for hiding this comment

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

Data Observing 하는 로직도 분리하면 좋겠네요.


viewModel.history.observe(this, Observer { cursor ->
historyAdapter.submitCursor(cursor)
})

binding.etSearch.addTextChangedListener(object : TextWatcher {
Copy link

Choose a reason for hiding this comment

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

addTestChangedListener 확장함수 잘 찾아보시면 원하는 함수만 재정의할 수 있습니다.
그리고 이렇게 만든 TextWatcher는 View가 Destroy될 때 해제해주세요

override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// Do nothing
}

override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
val searchText = s.toString().trim()
if (searchText.isEmpty()) {
binding.tvNoResults.visibility = View.VISIBLE
binding.recyclerViewPlaces.visibility = View.INVISIBLE
} else {
binding.tvNoResults.visibility = View.INVISIBLE
binding.recyclerViewPlaces.visibility = View.VISIBLE
viewModel.searchPlaces(searchText)
}
}

override fun afterTextChanged(s: Editable?) {
// Do nothing
}
})
}

private fun setupView() {
placeAdapter = PlaceAdapter { name ->
viewModel.addHistory(name)
}

historyAdapter = HistoryAdapter { name ->
viewModel.removeHistory(name) // 삭제 버튼 클릭 시 ViewModel의 removeHistory 메서드 호출
}

binding.recyclerViewPlaces.apply {
adapter = placeAdapter
layoutManager = LinearLayoutManager(this@MainActivity)
}

binding.recyclerViewHistory.apply {
adapter = historyAdapter
layoutManager = LinearLayoutManager(this@MainActivity, LinearLayoutManager.HORIZONTAL, false)
}
}
}
48 changes: 48 additions & 0 deletions app/src/main/java/campus/tech/kakao/map/PlaceAdapter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package campus.tech.kakao.map

import android.database.Cursor
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import campus.tech.kakao.map.databinding.ItemPlaceBinding

class PlaceAdapter(private val onClick: (String) -> Unit) : RecyclerView.Adapter<PlaceAdapter.ViewHolder>() {

private var cursor: Cursor? = null

inner class ViewHolder(private val binding: ItemPlaceBinding) : RecyclerView.ViewHolder(binding.root) {

fun bind(name: String, address: String, category: String) {
binding.tvName.text = name
binding.tvAddress.text = address
binding.tvCategory.text = category
binding.root.setOnClickListener {
onClick(name)
}
Copy link

Choose a reason for hiding this comment

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

클릭 리스너를 bind 때에 작성하는건 Anti-pattern 입니다. 왜 그런지 생각해보시고 리팩토링 해주세요!

}
}

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemPlaceBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
cursor?.apply {
moveToPosition(position)
val name = getString(getColumnIndexOrThrow(PlaceContract.COLUMN_TITLE))
val address = getString(getColumnIndexOrThrow(PlaceContract.COLUMN_LOCATION))
val category = getString(getColumnIndexOrThrow(PlaceContract.COLUMN_CATEGORY))
holder.bind(name, address, category)
}
}

override fun getItemCount(): Int {
return cursor?.count ?: 0
}

fun submitCursor(cursor: Cursor?) {
this.cursor = cursor
notifyDataSetChanged()
}
}
8 changes: 8 additions & 0 deletions app/src/main/java/campus/tech/kakao/map/PlaceContract.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package campus.tech.kakao.map

object PlaceContract {
const val TABLE_NAME = "places"
const val COLUMN_CATEGORY = "category"
const val COLUMN_TITLE = "title"
const val COLUMN_LOCATION = "location"
}
70 changes: 70 additions & 0 deletions app/src/main/java/campus/tech/kakao/map/PlaceDBHelper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package campus.tech.kakao.map

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

class PlaceDBHelper(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {

override fun onCreate(db: SQLiteDatabase?) {
db?.execSQL(SQL_CREATE_TABLE)
insertInitialRecords(db)
}

override fun onUpgrade(db: SQLiteDatabase?, oldVersion: Int, newVersion: Int) {
db?.execSQL(SQL_DROP_TABLE)
onCreate(db)
}

private fun insertInitialRecords(db: SQLiteDatabase?) {
db?.beginTransaction()
try {
db?.let {
insertCafeRecords(it)
insertPharmacyRecords(it)
}
db?.setTransactionSuccessful()
} finally {
db?.endTransaction()
}
}

private fun insertCafeRecords(db: SQLiteDatabase) {
val category = "카페"
val titleBase = "카페"
val locationBase = "서울 성동구 성수동"

for (i in 1..20) {
val title = "$titleBase$i"
val location = "$locationBase $i"
db.execSQL(
"INSERT INTO ${PlaceContract.TABLE_NAME} (${PlaceContract.COLUMN_CATEGORY}, ${PlaceContract.COLUMN_TITLE}, ${PlaceContract.COLUMN_LOCATION}) VALUES ('$category', '$title', '$location');"
)
}
}

private fun insertPharmacyRecords(db: SQLiteDatabase) {
val category = "약국"
val titleBase = "약국"
val locationBase = "서울 강남구 대치동"

for (i in 1..20) {
val title = "$titleBase$i"
val location = "$locationBase $i"
db.execSQL(
"INSERT INTO ${PlaceContract.TABLE_NAME} (${PlaceContract.COLUMN_CATEGORY}, ${PlaceContract.COLUMN_TITLE}, ${PlaceContract.COLUMN_LOCATION}) VALUES ('$category', '$title', '$location');"
)
}
}

companion object {
private const val DB_VERSION = 1
private const val DB_NAME = "location.db"
private const val SQL_CREATE_TABLE =
"CREATE TABLE ${PlaceContract.TABLE_NAME} (" +
"${PlaceContract.COLUMN_CATEGORY} TEXT NOT NULL, " +
"${PlaceContract.COLUMN_TITLE} TEXT NOT NULL, " +
"${PlaceContract.COLUMN_LOCATION} TEXT NOT NULL);"
private const val SQL_DROP_TABLE = "DROP TABLE IF EXISTS ${PlaceContract.TABLE_NAME}"
}
}
Loading