[Android/Kotlin 06-1] 당근마켓 클론코딩 사과마켓 1탄

이다을·2023년 8월 24일
1

개인과제의 시간이 돌아오면서 이번에는 리사이클러뷰를 이용한 당근마켓 클론코딩을 진행한다. 심화과정으로 넘어오면서 상당부분의 사용 빈도를 차지하는 👀뷰바인딩, 어댑터뷰, 프래그먼트, 다이얼로그, 알림👀 등에 대한 내용을 학습한다.

🔗 사과마켓 github
🔗 사과마켓 최종

느므나도 귀여운 사과마켓(🍎)

  • (✔) RecyclerViewer를 이용해 리스트 화면을 만들어준다.
  • (✔) 상품 이미지는 모서리를 라운드 처리한다.
  • (✔) 이름은 최대 두 줄이고, 넘어가는 부분은 ...으로 처리한다.
  • (✔) 뒤로가기(BACK)버튼 클릭시 다이얼로그 창을 띄운다.
  • (✔) Spinner로 동이름을 변경한다.
  • (✔) 상단 종모양 아이콘을 누르면 Notification을 생성한다.
  • (✔) 상품 가격은 1000단위로 콤마(,) 처리한다.
  • (✔) 아이템들 사이에 회색 라인으로 구분해준다.
  • (✔) 플로팅 버튼을 클릭하면 스크롤 최상단으로 이동한다.
  • (✔) 상품 선택시 해당 상품의 상세 페이지로 이동한다.
  • 아이템을 롱클릭하면 다이얼로그를 띄워 상품을 삭제할 수 있도록 한다.
  • (✔) 상품 상세페이지 이동시 intent로 객체를 전달한다.(Parcelize)
  • (✔) 메인에서 전달받은 데이터를 상품 상세페이지에 표시한다.
  • (✔) 상세페이지에서 하단 가격을 제외하고 전체화면을 스크롤되게한다.
  • 상품 상세 화면에서 좋아요 선택시 아이콘 변경 및 Snackbar 메세지를 표시한다.(+좋아요 카운트, 해제시 이전 상태로 되돌림)
  • 상단 뒤로가기(<)버튼을 누르면 상세 화면은 종료되고 메인화면으로 돌아간다.

⚡사과마켓 UI

나는 UI 화면을 구상할때 레이아웃이 제일 어려웠다. html이나 flutter처럼 부모가 자식을 감싸서 위젯 형식으로 만들어 주는데 익숙해져있어 이 또한 비슷한 개념으로 생각했다. 하지만 알고보니 앱은 달랐다. 레이아웃이 중첩되면 메모리에 많은 영향을 끼치기 때문에 최소한으로 사용하는 것이 좋다고 한다. 처음에는 constraint레이아웃안에 Linear레이아웃으로 텍스트들을 쪼갰는데 알고보니 constraint레이아웃 하나로 끝낼 수 있는게 많았다. 지금은 페이지가 적어서 잘 티가 안나지만 나중에 많은 양의 페이지를 만들게되면 유지보수와 수정사항에 용이할 수 있도록 constraint레이아웃 사용을 권장한다.

⚡data class

객체를 다른 액티비티나 프래그먼트 등 간에 전달할 때 사용되는 인터페이스로 객체를 주고 받을 때 복잡한 작업을 할 필요 없이 간단하게 정보를 주고 받을 수 있습니다.

  • Parcelable을 구현할 데이터 클래스에 @Parcelize 애노테이션을 추가합니다.
@Parcelize
data class MyItem(
    val listImage: Int,
    val listTitle: String,
    val listAddress: String,
    val listPrice: String,
    val chatCount: Int,
    var likeCount: Int,
    val nickname: String,
    val detailContent: String
) : Parcelable

⚡Spinner : 스피너

다이얼로그 형식의 선택 목록을 표시하는 UI로, 터치하면 선택할 수 있는 항목들이 리스트로 표시되며 이 중 하나의 항목을 선택할 수 있습니다. 당근동에서 사과동으로 동네 설정을 변경했습니다.

  • XML 레이아웃 파일에서 스피너를 추가해야 합니다.
<Spinner
    android:id="@+id/spinner"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content" />
  • 뷰 바인딩을 사용하여 스피너를 초기화하고 어댑터를 통해 데이터를 연결합니다.
  • 스피너가 생성되고 어댑터에 의해 제공된 아이템이 표시됩니다.
val adList =  resources.getStringArray(R.array.spinnerArray)
val adAdapter = ArrayAdapter<String>(this, android.R.layout.simple_spinner_dropdown_item, adList)
binding.spinner.adapter = adAdapter

⚡floatingactionbutton : 플로팅 버튼

작업이나 동작을 강조하는 데 사용되며, 화면의 오른쪽 또는 왼쪽 하단에 부착되어 사용자가 쉽게 접근할 수 있습니다. 버튼을 누르면 화면 최상단으로 이동합니다.

  • 기본 값으로 화면에서 보이지 않도록 invisible 처리 해주었습니다.
<com.google.android.material.floatingactionbutton.FloatingActionButton
	android:id="@+id/floatingButton"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginEnd="@dimen/layout_margin"
    android:layout_marginBottom="@dimen/layout_margin"
    android:clickable="true"
    android:visibility="invisible"
    android:src="@drawable/up_arrow"
    style="@style/CustomFloatingButton"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="@+id/recyclerView" />
  • AlphaAnimation : 페이드 인, 아웃 애니메이션으로 투명도를 조절합니다.
  • isTop = true : 화면 맨 위에 있는지 여부를 확인합니다.
  • addOnScrollListener : 스크롤 상태가 변경될 때마다 호출되며, 가시성을 조절합니다.
  • binding.recyclerView.canScrollVertically(-1) : 스크롤 방향을 위로 지정하고, 스크롤이 가능한지 확인합니다.
  • smoothScrollToPosition : 맨 위로 부드럽게 스크롤합니다.
val fadeIn = AlphaAnimation(0f, 1f).apply { duration = 700 }
val fadeOut = AlphaAnimation(1f, 0f).apply { duration = 700 }
var isTop = true

binding.recyclerView.addOnScrollListener(object: RecyclerView.OnScrollListener() {
	override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
		super.onScrollStateChanged(recyclerView, newState)
			if (!binding.recyclerView.canScrollVertically(-1)
				&& newState == RecyclerView.SCROLL_STATE_IDLE) {
                binding.floatingButton.startAnimation(fadeOut)
                binding.floatingButton.visibility = View.INVISIBLE
                isTop = true
			} else {
                if(isTop) {
                binding.floatingButton.visibility = View.VISIBLE
                binding.floatingButton.startAnimation(fadeIn)
                isTop = false
				}
			}
		}
	})
    binding.floatingButton.setOnClickListener {
		binding.recyclerView.smoothScrollToPosition(0)
	}

⚡onBackPressed() : 하드웨어 뒤로가기 버튼

기본적으로 화면에서 뒤로 가기 버튼을 누르면 onBackPressed() 메서드가 호출되어 현재 화면을 종료하고 이전 화면으로 돌아가게 됩니다. 이 앱에서는 종료 다이얼로그 메세지와 함께 앱 종료 여부를 묻습니다.

  • setPositiveButton() : 확인 버튼을 추가하고, 클릭 시에 finish() 메서드를 호출하여 앱을 종료합니다.
  • setNegativeButton() : 취소 버튼을 추가하고, 클릭 시 아무 동작도 하지 않습니다.
  • create() : 설정한 내용으로 다이얼로그 객체를 생성합니다.
override fun onBackPressed() {
        val alertDialog = AlertDialog.Builder(this)
            .setIcon(R.drawable.chat)
            .setTitle("종료")
            .setMessage("정말로 종료하시겠습니까?")
            .setPositiveButton("확인") { dialog, which ->
                finish()
            }
            .setNegativeButton("취소", null)
            .create()
        alertDialog.show()
    }

⚡Notification : 시스템 알림

앱에서 사용자에게 알림을 보여주는 기능입니다. 종 모양 아이콘을 누르면 상태바에 설정해놓은 아이콘 표시가 뜨며, 알림을 확인할 수 있습니다.

  • showNotification : 알림을 생성하고 표시하는 부분입니다.
  • NotificationCompat.Builder : 알림을 구성합니다.
  • NotificationManagerCompat : notify 메서드를 사용하여 알림을 표시하는 역할을 수행합니다.
  • showNotification : 버튼을 클릭하면 함수가 호출되어 알림을 생성하고, 사용자 인터페이스와 동작이 연동되어 알림을 사용자에게 보여줄 수 있습니다.
private val myNotificationID = 1
private val channelID = "default"
    
val notiButton = findViewById<ImageButton>(R.id.notiButton)
notiButton.setOnClickListener {
showNotification()
}
createNotificationChannel()

private fun createNotificationChannel() {
	if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
		val channel = NotificationChannel(channelID, "default channel",
			NotificationManager.IMPORTANCE_DEFAULT)
        channel.description = "description text of this channel."
        val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
        notificationManager.createNotificationChannel(channel)
        }
    }
private fun showNotification() {
	val builder = NotificationCompat.Builder(this, channelID)
		.setSmallIcon(R.drawable.apple)
		.setContentTitle("키워드 알림")
		.setContentText("설정한 키워드에 대한 알림이 도착했습니다!!")
		.setPriority(NotificationCompat.PRIORITY_DEFAULT)
	NotificationManagerCompat.from(this).notify(myNotificationID, builder.build())
}

⚡트러블 슈팅

문제점:
1) Spinner로 셀렉트박스 구현시 원하는 위치에 맞게 조절할 수 없던 점
2) floating 버튼 커스텀이 내맘같지 않았던 점
3) 각 아이템 리스트 하단에 stroke가 말썽피운 점

원인:
1) spinner모드 중에서는 '드롭다운'옵션이 있는데, 각각의 끝에서부터 시작되는데 border라던지 background등의 구분해줄만한 부분이 없어 영역을 벗어난 것처럼 보였다.

2) 기존의 background drawable 속성으로 버튼을 커스텀 하려고 했는데 전혀 먹질 않았다. 찾아보니 플로팅 버튼과 같이 특이사항이 있는 버튼은 되지 않을 수 있어 style로 적용해야 한다는 것을 알게되었다.

3) 레이아웃간 관계성을 지정해 주지 않아서 레이아웃 영역 밖에서는 뭍혀있었다..?라는 표현이 맞는지 모르겠지만 감춰져있었다. 그래서 2dp를 줬을땐 아주 약간 빼꼼 보여서 마치 1dp가 정상적으로 적용된것처럼 보였던 것

해결책:
1) dropdwon이 아닌 dialog 속성을 선택하여 깔끔하게 가운데 뜰 수 있도록 변경해 주었다.

// xml 속성
<Spinner
	android:spinnerMode="dialog"/>

2) vlaues의 styles.xml을 만들어서 resources로 커스텀 해주었다.
3) 레이아웃 간의 관계성을 재정의하고, background drawable 속성이 아닌 컬러만 넣어주니 거짓말같이 1dp의 선이 나왔다. ㅠㅠㅠㅠㅠㅠㅠ 진짜 이번에 한 것 중에 성취도 최고다.

느낀점:
1) 다음에는 다이얼로그 속성으로 타협하는게 아닌 드롭다운 모드에서 어떻게 예쁘게 꾸밀까 고민해 봐야겠다.
2) 원래는 버튼 테두리 색상과 아이콘 색상을 통일하고 배경은 흰색으로 채워진 선으로된 버튼을 만들고 싶었는데 style에서 옵션은 오류가 나지 않는데 적용이 되지 않았다. 다시한번 적용할 수 있는 방법을 알아봐야겠다.
3) 관계성의 중요성을 다시한번 느끼게되었다.

😥

블로그 다 정리하고 갑자기 알게된 사실인데요. 하단 영역 선을 xml의 view로 만들면 성능에 좋지않다고 하네요? RecyclerView ItemDecoration 이라는 키워드를 알게되었는데 아직 구현하지 못한 나머지 기능들과 함께 자세히 알아보도록 하겠습니다.

profile
나도 개발 할래

1개의 댓글

comment-user-thumbnail
2023년 8월 24일

오 깔끔하게 구현 잘하셨네요 ! ItemDecoration도 빨리 구현하셔서 블로그에 정리해주세요 !!

답글 달기