[내일배움캠프 - 숙련 개인과제]
오늘은 지난주 부터 학습했던 RecyclerView, Dialog, Notify 등을 이용하여 중고 거래 App을 구현한 과정을 리뷰해보려 한다.
각 Chapter의 요구사항은 아래와 같다.
https://github.com/rlaxodud214/NBC-carrot-market/blob/dev/README.md
data/model(아래 접은 글)
@Parcelize
data class SalesPost(
val post: Post,
val user: User,
val product: Product,
): Parcelable
@Parcelize
data class Post(
val id: Int,
val likeCount: Int,
val chatCount: Int,
val isLikeActivate: Boolean = false,
): Parcelable
@Parcelize
data class User(
val seller: String,
val address: String,
): Parcelable
@Parcelize
data class Product(
@DrawableRes val image: Int,
val name: String,
val content: String,
val price: Int,
): Parcelable
// 더미데이터 예시
SalesPost(
post = Post(
id = 1,
likeCount = 13,
chatCount = 25,
),
user = User(
seller = "대현동",
address = "서울 서대문구 창천동",
),
product = Product(
image = R.drawable.img_product1,
name = "산지 한달 된 선풍기 팝니다.",
content = "이사가서 필요가 없어졌어요 \n급하게 내놓습니다",
price = 20_000,
),
),
전체 다 구현한 뒤 리팩토링 시 Notification, Dialog, Snackbar 로직을 Activity에서 분리하려고 시도했지만,
3가지 모두 context의 의존성을 지니고 있어 context에 대해 학습할 수 있는 계기가 됐다.
Dialog는 분리에 성공했지만 기존 목표였던 Extension으로 빼지 않았고(코드 라인의 큰 차이가 없어보여서)
Snackbar는 기존의 코드도 충분히 간결해서 분리하지 않았다.
Notification은 분리를 위해 로직을 학습하고, 뭐로 빼야할지 몰라 개선하지 못했다. 그냥 Activity의 context를 가져가버리면 메모리 누수 관련해서 이슈가 있다는 글을 봐서 정석이 아닌 것 같아 시행하지 않음.
Step 1. MainPage 구현
사용된 기술 : RecyclerView, Notification, Dialog
기능 A. H/W의 Back 버튼이 눌리면, 종료 확인을 위한 Dialog를 띄운다.
구현 이후 리팩토링 과정에서 DialogFragment() 를 발견한 뒤, Activity에서 Dialog를 분리한 코드
class ExitConfirmDialog(
val positiveListener: () -> Unit
) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
AlertDialog.Builder(requireContext())
.setIcon(R.drawable.ic_bubble_chat)
.setTitle("종료")
.setMessage("종료하겠습니까")
.setNegativeButton("취소") { _: DialogInterface, _: Int -> }
.setPositiveButton("확인") { _, _ ->
positiveListener()
}
.create()
companion object {
const val TAG = "ExitConfirmDialog"
}
}
class MainActivity: AppCompatActivity() {
// Dialog 생성
private val exitConfirmDialog by lazy {
ExitConfirmDialog() {
finish()
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
// 그 버튼이 BACK일 때
return if (keyCode == KeyEvent.KEYCODE_BACK) {
exitConfirmDialog.show(supportFragmentManager, ExitConfirmDialog.TAG)
true
} else {
false
}
}
}
이 과정에서 backButton을 감지하는 onKeyDown을 써봤는데 이는 버튼이 눌렸을 때 호출된다.
리턴값인 Boolean은 입력된 event를 이 메소드에서 처리한다면 true, 다음 수신자가 처리하도록 허용할 때는 false라고 한다.
https://developer.android.com/reference/android/view/KeyEvent.Callback
기능 B. 상품 이름의 라인 제한 및 초과 시 ...처리
android:maxLines="2"
android:ellipsize="end"
기능 C. 상품 가격은 1000단위로 콤마(,) 처리
val dec = DecimalFormat("#,###원")
dec.format(product.price)
기능 D. 상품 아이템들 사이에 회색 라인 추가하기
val dividerItemDecoration = DividerItemDecoration(this, VERTICAL)
binding.rvPosts.addItemDecoration(dividerItemDecoration)
기능 E. 구분선 UI, divider
// 말 그대로 선이다.
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="1dp"/>
기능 F. 상품 선택시 아래 상품 상세 페이지로 이동
// Adapter
class SalesPostAdapter(
private val onClick: (Int) -> Unit
) : RecyclerView.Adapter<SalesPostAdapter.ViewHolder>() {
inner class ViewHolder(
val binding: ItemRvPostsBinding
) : RecyclerView.ViewHolder(binding.root) {
init {
with(itemView) {
setOnClickListener { onClick(adapterPosition) }
}
}
...
}
// Activity
// 리스너 설정
val onItemClickListener: (Int) -> Unit = { position ->
runDetailActivity(position)
}
// 어댑터 생성
salesPostAdapter = SalesPostAdapter(onItemClickListener)
기능 G. 상품 이미지는 모서리를 라운드 처리, 프로필 이미지는 둥글게 처리
- round
// bg_image_round.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="4dp"/>
</shape>
// 적용 xml
<ImageView
android:background="@drawable/bg_image_round"
android:clipToOutline="true"/>
- circle
// bg_image_circle1.xml
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="200dp"/>
</shape>
// bg_image_circle2.xml
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
</shape>
// 적용 xml
<ImageView
android:background="@drawable/bg_image_circle2"
android:clipToOutline="true"/>
전에는 shape를 변경하지 않고 circle1 처럼 radius를 200으로 줘서 처리한 적이 있는데 이번엔 제대로 shape 속성을 oval로 지정해서 circleImageView를 만들었다. 까먹지 말자
Step 2. DetailPage 구현
기능 A. 하단 가격표시 레이아웃을 제외하고 전체화면은 스크롤이 되어야 한다.
- 기억할 부분 : <ScrollView> 하위에는 하나의 위젯만 와야한다.
-> 전체를 ConstraintLayout이나, LinearLayout 등으로 감싸야함.
Step 3. Floating Action Button
기능 A. 플로팅 액션 버튼 클릭 시 RecyclerView를 최상단으로 올리기
private fun initScrollToTopButton() {
with(binding) {
fabToTop.setOnClickListener {
rvPosts.smoothScrollToPosition(0)
}
}
}
기능 B. 플로팅 액션 버튼 이미지 설정 및 클릭(pressed)시 색상 변화
- <com.google.android.material.floatingactionbutton.FloatingActionButton>을 사용할 때,
src로 button의 이미지를 지정했지만, 적용되지 않았다.
-> fab에서는 foreground 속성에 drawable값을 줘야 제대로 적용 됐다.
- 클릭 시 생상 변화
기존 버튼과 같다고 생각해서 selector를 이용해 pressed와 아닐 때의 색상을 지정하는 xml을 만들었다.
하지만, backgroundTint 값에 xml을 지정할 수 없었고, 그냥 background 값에 지정하면 색이 제대로 적용되지 않는 오류가 있었다.
-> app:rippleColoe 속성을 통해 클릭 시 색상을 지정할 수 있었음
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_to_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:fabSize="mini"
android:foreground="@drawable/ic_up"
android:backgroundTint="@color/cattot"
app:rippleColor="@color/blue"
android:visibility="gone"/>
기능 C. 플로팅 버튼은 스크롤을 아래로 내릴 때 나타나며, 스크롤이 최상단일때 사라진다.
기능 D. 플로팅 버튼은 on/off 될 때 FadeIn, FadeOut 효과를 보인다.
처음에 canScrollVertically() 메서드와 onScrollStateChanged(recyclerView: RecyclerView, newState: Int) 메서드를 사용해서 newState 흐름을 Log 찍어서 학습하고, 3시간 동안 씨름했는데 완벽하게 제어할 수 없었다.
-> 주요 문제는 활성화 되었음에도 또 활성화 시키는 애니메이션이 돌아서 지워졌다가 다시 생기는 UI가 여러번 도는 이슈가 있었고, 간단하게 Boolean값으로 해결할 수 있을 줄 알았지만 생각보다 로직이 복잡했다..
-> position 값을 가져오기 위해 verticalScrollbarPosition 등 여러 시도를 했지만, 원하는 결과를 가져올 수는 없었다.
-> 그렇게 다시 찾게된 코드는 정말 2분도 안 걸려서 적용할 수 있었다. (이 과정에서 만들어둔 애니메이션은 제거했다 ㅎ,,,) 그냥 단순히 hide(), show()로 활성화 하면 된다.
private fun RecyclerView.setVisibleFloatingButton() = with(this) {
addOnScrollListener(object: RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val currentPosition = layoutManager.findFirstVisibleItemPosition()
if (currentPosition == 0) {
binding.fabToTop.hide()
} else {
binding.fabToTop.show()
}
}
})
}
Step 4. RecyclerView의 Item 삭제 기능
기능 A. RecyclerView에서 Item 삭제하기
- 처음에 그냥 구현한 코드는 adapter의 data를 수정하고 recyclerView에 adapter를 다시 설정해줬다.
하지만, 정석인 코드는 절대 아니라는 느낌이 들어 찾아보니 더 좋은 메서드가 있었다.
// Acitivity
private fun initRecyclerView() {
val onItemClickListener: (Int) -> Unit = { position ->
runDetailActivity(position)
}
val onItemLongClickListener: (Int) -> Boolean = { position ->
showDialogForItemRemove(position) // Item이 롱클릭 일 때 호출
true
}
salesPostAdapter = SalesPostAdapter(
onClick = onItemClickListener,
onLongClick = onItemLongClickListener,
)
}
private fun showDialogForItemRemove(position: Int): Boolean {
RemoveItemDialog() {
PostDataSource.dummyData.removeAt(position)
// 어댑터에게 position 위치의 Item이 제거되었다고 알림 -> 알아서 갱신해줌
salesPostAdapter.notifyItemRemoved(position)
}.show(supportFragmentManager, RemoveItemDialog.TAG)
return true
}
Step 5. 좋아요 기능 구현 및 연동
기능 A. 상품 상세 화면에서 좋아요 선택시 아이콘 변경 및 Snackbar 메세지 표시
- 아이콘 switch 기능의 경우 메인에서도 적용해야 하므로 extension 으로 분리함
fun ImageView.setHeartIcon(likeActivate: Boolean) {
setImageResource(
if (likeActivate) {
R.drawable.ic_heart_fill
} else {
R.drawable.ic_heart_gray
}
)
}
- Snackbar
아래에서 중요한 건 anchorView이다. 이 값을 ivLike로 설정함으로써 스낵바는 ivLike보다 위쪽에 위치하게 된다.
(처음엔 bottomMargin을 주는 등 이 부분도 헛짓을 많이 한 것 같다,,, ㅎㅎㅎ)
// 호출 로직
val actionText = if (currentLikeActivate) "에 추가" else "에서 삭제"
it.showSnackbar(actionText)
// 스낵바 함수
private fun View.showSnackbar(actionText: String) {
Snackbar.make(this, "관심 목록${actionText}되었습니다.", Snackbar.LENGTH_LONG).let {
setSnackBarOption(it)
it.anchorView = binding.ivLike
it.show()
}
}
// 스낵바 옵션 설정
private fun setSnackBarOption(snackBar: Snackbar) = with(snackBar) {
animationMode = BaseTransientBottomBar.ANIMATION_MODE_SLIDE // 애니메이션 설정
setTextColor(Color.WHITE) // 안내 텍스트 색 지정
setBackgroundTint(getColor(R.color.gray_deep_dark)) // 백그라운드 컬러 지정
}
기능 B. 좋아요 처리 RecyclerView에 반영하기
- 전에 Item 삭제와 비슷한 종류의 메서드다.
Detail Activity에서 좋아요 클릭된 후 다시 Main Activity로 오면 onRestart() 부터 시작하므로 여기에 아래 코드를 추가해준다.
override fun onRestart() {
super.onRestart()
// adapter에게 position 위치의 Item이 수정되었다고 알림
salesPostAdapter.notifyItemChanged(itemPositionByDetail)
}
[해설 강의 리뷰]
time table은 문제시 삭제하겠습니다.
1. [03:20] FloatingActionButton에서 지정 가능한 새로운 속성들
android:hapticFeedbackEnabled="true"
app:maxImageSize="40dp"
app:borderWidth="0dp"
app:elevation="0dp"
app:tint="@null"
(추가) 신기한점은 forground가 아닌 src를 통해서 drawable를 지정했다는 점이다.
어떻게 가능했는지 어떤 속성 때문에 반영이 제대로 된건지 찾아보자.
2. [05:30] 외부 라이브러리 없이 Circle Image View 만드는 간단한 방법
<ImageView>를 <CardView>로 감싸면 된다. -> CardView에는 app:cardCornerRadius 속성이 있어 round 처리를 할 수 있었다.
<androidx.cardview.widget.CardView
android:layout_width="120dp"
android:layout_height="120dp"
app:cardCornerRadius="8dp" // radius 처리
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageView
android:id="@+id/iv_product_image"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/img_product1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.cardview.widget.CardView>
- 나는 ImageView의 background 속성에 <shape>를 oval로 준 xml 파일을 지정하고, clipToOutline 속성을 true로 줘서 적용했었음.
- CardView 사용 시 장단점
장점 : 별도의 xml 파일이 필요하지 않다. -> 하지만, 여러 곳에서 공통으로 사용 가능한 리소스이긴 함.
단점 : 한 번 감싸야 하기 때문에 코드가 2배로 길어지고, depth가 높아진다.
-> Circle ImageView를 여러 번 사용한다고 하면, 나는 bg와 clipToOutline을 사용할 것 같다.
-> 이런 방법도 있다는 것만 인지하고 넘어가자.
3. [06:40] xml에서 비슷한 위치의 그룹을 하나의 Layout으로 묶어도 될까?
- xml에서 관리와 가독성 측면에서 특정 그룹을 하나의 Layout으로 묶는 것 정도는 괜찮다. (1depth가 추가되는 정도는 괜찮다) -> 단점 보단 장점이 더 큰 듯
4. [07:22] 구분선 UI 구현
- divider가 아닌 <View> 위젯에 background 속성으로 color 값을 줘서 구현할 수도 있다.
5. [09:22] Layout 컴포넌트
- anroid:clickable, focusable 속성이 있었다.
6. [10:42] 아래 UI 구현
- 꼭 Button 위젯으로 구현할 필요가 없다. TextView로 구현하고 backgroud를 지정해도 된다.
7. [11:02] TextView 위젯에 underline 추가하는 방법
- text 속성의 값을 <u> 태그로 감싸면 제일 간단하게 구현할 수 있음
// strings.xml
<resources>
<string name="user_temp"><u>매너온도</u></string>
</resources>
8. [14:29] Intent로 데이터 전달 시 공통으로 사용되는 Key값 관리
- 나는 데이터 전달을 받는 class의 companion object에 key 값을 두고 서로 사용하도록 했는데 확실히 아래 방법이 더 좋을 것 같다.
// 공통으로 하나의 파일에서 관리하기
object Constants {
const val ITEM_INDEX = "item_index"
const val ITEM_OBJECT = "item_object"
}
9. [19:15] Dialog에서 NegativeButton의 ClickListener
- 나는 아무 것도 실행하지 않도록 했음({ }) -> dismiss() 메서드 호출
dialog.setNegativeButton("취소") { diolog, _ ->
dialog.dismiss()
}
10. [39:20] FAB 애니메이션 처리에서 깜빡이는 증상이 있었던 이유
- canScrollVertically(-1).not() = 더 이상 위로 올릴 수 없는가? -> 현재 최상단인가?
- && RecyclerView.SCROLL_STATE_IDLE = 스크롤이 끝났을 때,
위 코드를 통해 어느 정도 구현할 수는 있었지만, 아래 gif과 같이 최상단에서 위로 스크롤 시 깜빡이는 이슈가 있었다.
val isTop = true
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
with(binding) {
if(!canScrollVertically(-1)
&& newState == RecyclerView.SCROLL_STATE_IDLE) {
fabToTop.startAnimation(fadeOutAnimation)
fabToTop.visibility = View.GONE
isTop = true
} else if(isTop) {
fabToTop.visibility = View.VISIBLE
fabToTop.startAnimation(fadeInAnimation)
isTop = false
}
}
}
이를 해결하기 위해 scroll의 position값을 통해서 명확하게 지정해줄 필요가 있었다.
그리고 hide()와 show()는 자체적으로 중복으로 돌지 않게? 로직으로 처리해주는 기능도 하는 것 같다.
아래 코드에서 hide() 대신 위의 startAnimation, visibility 코드를 넣으면 그냥 내릴 때도 깜빡인다.
private fun RecyclerView.setVisibleFloatingButton() = with(this) {
addOnScrollListener(object: RecyclerView.OnScrollListener() {
@SuppressLint("ResourceType")
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
val currentPosition = layoutManager.findFirstVisibleItemPosition()
if (currentPosition == 0) {
binding.fabToTop.setHideMotionSpecResource(R.anim.fade_out) // 적용 되지 않음...
binding.fabToTop.hide()
} else {
binding.fabToTop.show()
}
}
})
}
'Android' 카테고리의 다른 글
ch 6주차 정리 (1) | 2024.05.01 |
---|---|
ch 5주차 정리 (0) | 2024.04.30 |
Retrofit2 (2) | 2024.04.15 |
Fragment의 정의, 사용법 및 데이터 전달 방식 (1) | 2024.04.12 |
RecyclerView의 Adapter 구현 및 Item Click Event 처리방법 정리 (0) | 2024.04.11 |