<정리> 사과마켓 앱 구현 1
이번주 개인 과제로 나온 사과마켓 앱
처음 들을 때 당근마켓의 느낌이 물씬났는데 맞았다.
지금은 필수구현과제까지 마무리한 내용을 적어놓으려고 한다.
최대한 예제와 비슷하게 하려다 보니 기능 구현보다 레이아웃에 시간을 더 많이 사용했던 거 같다.
기능 자체는 이번주차 강의를 수강했다면 약간의 구글링으로 해결할 수 있었던 거 같다.
1. 레이아웃 관련
요즘은 ConstrainLayout에 푹 빠져있다.
아주 편하고 좋다.
이 과제를 하면서 Layout은 다 ConstraintLayout을 썼다.
레이아웃을 짜면서 가장 어려웠던 건 id를 붙이는 거였다.
'어떻게 하면 id 딱 보고 어떤 건지 알 수 있을까?'라는 고민이 많이 들었었다.
그러다보니 좀 길어지고 그러는데 이건 나중에 피드백을 받아보겠다.
메인 페이지 레이아웃 짤 때 기억나는 건 RecyclerView의 높이를 0dp로 한 것이다.
이렇게 하면 다른 컴포넌트가 차지한 부분은 제외하고 다 차지하기 때문이다.
물론 제약이 그 다른 컴포넌트와 걸려있어야한다.
또한 TextView의 기본 텍스트 색이 맘에 들지 않았던 것도 기억에 남는다.
이건 이전 팀과제 때 스타일을 지정했던 걸 떠올려서 가져와 적용했다.
styles.xml에 코드를 작성하고 themes.xml에 가서 적용시켰다.
<!-- styles.xml -->
<resources>
<style name="customTextViewFontStyle" parent="@android:style/Widget.DeviceDefault.TextView">
<item name="android:textColor">@color/text_color</item>
</style>
</resources>
<!-- themes.xml -->
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Base.Theme.AppleMarket" parent="Theme.Material3.DayNight.NoActionBar">
...
<item name="android:textViewStyle">@style/customTextViewFontStyle</item>
</style>
<style name="Theme.AppleMarket" parent="Base.Theme.AppleMarket" />
</resources>
RecyclerView에 들어갈 아이템 레이아웃 짤 때 생각나는 건 View로 만든 수평선 정도밖에 없다.
여기에서는 ConstraintLayout 하나 안에 다 요소들을 넣고 마무리했다.
디테일 페이지 레이아웃에서는 ScrollView의 높이를 0dp로 한 것이 기억에 남는다.
이유는 메인 페이지의 RecyclerView와 동일하다.
다른 거는 Button 문제였다.
drawable 폴더에서 shape 파일을 만들어서 모서리와 크기, 배경색을 지정한 다음 버튼 backgound에 적용시키려고 했는데 배경색이 적용이 되지를 않았다...
현재는 backgroundTint 속성으로 배경색을 지정했고 크기나 모서리도 Button 안에서 속성값을 지정하는 것으로 해결했다.
좀 찾아보니까 Material3 테마에서 이런 이슈가 있다고 하더라
해결방법은 backgroundTint를 @null로 주고 background에 이전 방식대로 shape 파일을 넣는거라고 하는데...
2. 메인 페이지 관련
1) 뷰바인딩
이제부터는 뷰바인딩을 사용해도 되기 때문에 오랜만에 사용했다.
확실히 코드가 간결해지고 보기 좋다.
대신 xml 레이아웃 파일에서 id를 잘 지어줘야하지만...
2) 리사이클러뷰 연결
메인 페이지에서는 리사이클러뷰를 연결시키는 가장 큰 과제였다.
이를 위해서 제공해준 더미 데이터의 카테고리를 data class에 동일하게 적용시켜 만들고 리스트 형태로 (수동) 저장했다.
private val dataList = mutableListOf<MarketItem>()
private fun setMarketItem() {
dataList.add(
MarketItem(
1,
R.drawable.img_sample1,
"산진 한달된 선풍기 팝니다",
"이사가서 필요가 없어졌어요 급하게 내놓습니다",
"대현동",
1000,
"서울 서대문구 창천동",
13,
25
)
)
}
그리고 RecyclerView.Adapter를 상속받고 리스트를 파라미터로 받는 어댑터 파일을 만들었다.
class ItemAdapter(private val items: MutableList<MarketItem>): RecyclerView.Adapter<ItemAdapter.Holder>() {
...
}
그 안에 기본적으로 있어야 하는 onCreateViewHolder(), onBindViewHolder(), getItemCount() 함수를 오버라이딩했다.
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
...
}
override fun onBindViewHolder(holder: Holder, position: Int) {
...
}
override fun getItemCount(): Int {
...
}
또한 RecyclerView.ViewHolder를 상속받고 리사이클러뷰 아이템 레이아웃 뷰 바인딩 값을 파라미터로 받는 Holder 클래스를 inner class로 선언했다.
inner class Holder(private val binding: ItemRecyclerviewBinding): RecyclerView.ViewHolder(binding.root) {
...
}
아이템 클릭 처리를 위해 인터페이스도 만들었다.
interface ItemClick {
fun onClick(view: View, position: Int)
}
var itemClick: ItemClick? = null
그리고나서 어댑터를 xml 레이아웃 파일의 리사이클러뷰의 어댑터로 연결하고 layoutManager를 설정하는 것으로 아이템들이 리사이클러뷰에 적용되도록 하는건 완료됐다.
val adapter = ItemAdapter(dataList)
binding.recyclerViewMain.adapter = adapter
binding.recyclerViewMain.layoutManager = LinearLayoutManager(this)
그리고 아까 어댑터에서 만들어 놓은 인터페이스를 이용하여 아이템 클릭 이벤트를 처리하도록 했다.
adapter.itemClick = object : ItemAdapter.ItemClick {
override fun onClick(view: View, position: Int) {
startActivity(
DetailActivity.newIntent(
context = this@MainActivity,
marketItem = dataList[position]
)
)
}
}
3) 다이얼로그
이번 과제에서 다이얼로그는 뒤로가기 버튼을 클릭했을 때 종료 확인하는 용도로 사용되었다.
그래서 개인적으로 하던 사이드 프로젝트나 이전 팀과제에서 사용했던 방법을 가지고 적용했다.
바로 콜백함수를 이용하는 것이다.
먼저 OnBackPressedCallback() 추상 클래스를 받아서 handleOnBackPressed()를 오버라이딩한다.
오버라이딩하는 함수 안에서 다이얼로그를 만들고 확인 버튼을 눌렀을 때 액티비티를 종료시키는 메소드를 실행하는 리스너를 만들어서 setPositiveButton에 넣었다.
p0에 들어가는 값이 버튼에 대한 값이다.
private val callback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val builder = AlertDialog.Builder(this@MainActivity)
builder.setTitle("종료")
builder.setIcon(R.drawable.img_recycler_message)
builder.setMessage("정말 종료하시겠습니까?")
val listener = DialogInterface.OnClickListener { _, p0 ->
if (p0 == DialogInterface.BUTTON_POSITIVE) {
finish()
}
}
// 작업 버튼 설정
builder.setPositiveButton("확인", listener)
builder.setNegativeButton("취소", null)
builder.show()
}
}
마지막으로 이걸 onBackPressedDispatcher에 등록해주면 뒤로가기 버튼을 눌렀을 때 다이얼로그가 뜨고 확인 버튼을 누르면 액티비티가 종료된다.
this.onBackPressedDispatcher.addCallback(this, callback)
4) 알림
이전에 졸업 프로젝트나 토이 프로젝트를 할 때 나를 많이 괴롭혔던 알림, Notification
이번에도 만나게 됐지만 이제는 배운 걸 반복만 잘 한다면 문제 없이 잘 헤쳐나갈 수 있을 거 같다.
가장 먼저 한 것은 내가 쓰는 버전이 API 33이상이기 때문에 AndroidManifest.xml에서 알림을 보내는 것에 대한 권한을 추가해줬다.
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
이것으로 사용자의 프라이버시를 강화하고, 앱이 사용자의 동의 없이 알림을 보내는 것을 방지할 수 있다.
현재 알림 권한이 있는지 확인하는 함수를 만드는 것이었다.
나는 앱이 처음 실행될 때 확인하도록 했고 권한이 없다면 암시적 인텐트를 이용하여 권한 설정 화면으로 이동하게 했다.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (NotificationManagerCompat.from(this).areNotificationsEnabled().not()) {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
startActivity(intent)
}
}
다음으로는 알림 채널을 만들었다.
Android 8.0 이상부터 해야되는 과정인데 매우 귀찮다.
Android 8.0 이상인지 확인하고 알림 채널을 만들고 알림 채널을 NotificationManger에 등록한 다음, 채널을 이용해 알림을 만드는 builder를 초기화한다.
Android 8.0 보다 이전이라면 다른 과정 필요없이 builder를 초기화해주면 된다.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelID,
"keyword channel",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "description text of this channel."
setShowBadge(true)
}
notificationManager.createNotificationChannel(channel)
builder = NotificationCompat.Builder(this, channelID)
} else {
builder = NotificationCompat.Builder(this)
}
그리고 알림에 들어갈 내용들을 세팅하면 된다.
나는 기본적인 내용에 긴 텍스트 확장만 했지만 이미지, 버튼, 프로그레스바도 추가가 가능하다.
마지막에 notificationManager.notify까지 해줘야지 알림이 정상적으로 나온다.
builder.run {
setSmallIcon(R.mipmap.ic_launcher)
setContentTitle("제목")
setContentText("내용")
setPriority(NotificationCompat.PRIORITY_DEFAULT)
setStyle(NotificationCompat.BigTextStyle().bigText("긴 내용"))
}
notificationManager.notify(notificationID, builder.build())
3. 디테일 페이지 관련
1) 인텐트로 data class 객체 받아오기
참고 : https://retry-thinksubox.tistory.com/140
data class 객체로 넘겨받지 않고 일일히 넘겨받는다면 개수가 너무 많았기 때문에 과제를 하면서 알게 된 방법으로 진행했다.
먼저 모듈 수준의 build.gradle에 Parcelable 관련 코드를 추가했다.
plugins {
...
id("kotlin-parcelize")
}
그리고 data class를 Parcelable을 상속받게 하고 @Parcelize 어노테이션을 붙여줬다.
@Parcelize
data class MarketItem(
val id: Int,
val thumbnail: Int,
val title: String,
val description: String,
val seller: String,
val price: Int,
val sellerAddress: String,
var favoriteCount: Int,
var chatCount: Int
) : Parcelable
이제 디테일 페이지 코드 상에서 할 차례다.
companion object 안에 newIntent 함수를 만들어 인텐트를 받아오는 함수를 만들었다.
companion object {
private const val EXTRA_MARKET_ITEM = "extra_market_item"
fun newIntent(
context: Context,
marketItem: MarketItem
): Intent = Intent(
context,
DetailActivity::class.java
).apply {
putExtra(EXTRA_MARKET_ITEM, marketItem)
}
}
그 함수를 통해 data class 객체가 넘어오게 되고 getParcelableExtra 함수를 통해 가져올 수 있다.(버전 체크 필요)
private val item: MarketItem? by lazy {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra(EXTRA_MARKET_ITEM, MarketItem::class.java)
} else {
intent?.getParcelableExtra(EXTRA_MARKET_ITEM)
}
}
2) 천의 자리마다 ,(쉼표) 붙이기
리사이클러뷰 아이템 레이아웃 짤 때도 쓰긴 했는데 여기서 정리한다.
다른 건 없고 DecimalFormat을 사용해서 했다.
DecimalFormat은 자바에서 가져온 클래스다.
소수점, 단위 쉼표, 음수, 양수, 지수, 퍼센트 등등의 포맷으로 변경이 필요할 때 쓰기 좋다.
내가 쓴 패턴은 "#,###"으로 천의 자리마다 쉼표를 붙이고 빈공간은 채우지 않는 10진수다.
그리고나서 format에 원하는 값을 집어넣으면 된다.
DecimalFormat("#,###").format(item?.price)