티스토리 뷰

챌린지반 4주차 세션 정리

 

RecyclerView의 패턴

리사이클러뷰는 뷰홀더 패턴(ViewHolder pattern)을 강제한다.

 

뷰홀더 패턴은 뷰홀더를 선언해 각 항목 뷰에 대한 참조를 저장하여,

뷰를 재활용할 때 매번 findViewById를 호출하지 않아도 되도록 한다.

 

이로 인해 리사이클러뷰의 성능이 크게 향상된다.

 

 

RecyclerView 구성요소의 역할

어댑터: 뷰와 데이터를 분리하는 역할을 한다.
뷰홀더: 각각의 아이템뷰를 캡슐화하여 해당 아이템의 데이터를 바인딩한다.

 

RecyclerView의 데이터 위치

액티비티에서는 어댑터에 데이터를 넣어주고, 어댑터에서는 받은 데이터를 관리한다.

결론적으로 리싸이클러뷰는 어댑터의 데이터를 가지고 있게 된다.

 

 

RecyclerView의 OOP적 특징

액티비티는 어댑터를 통해서만 데이터를 관리하게 되고,

리사이클러뷰는 어댑터로부터 데이터 변경 사항을 통지받는다.

 

따라서 데이터 로직과 UI 로직을 분리하여 관리할 있다.

 

 

데이터 변경 과정

 

어댑터에 데이터가 들어왔을 때 데이터를 뷰홀더에 세팅한다음

뷰홀더의 데이터를 리싸이클러뷰에 통지 해준다.

 

뷰모델에서 데이터를 관리하게 되면, 뷰모델의 옵저버를 통해

데이터를 변경하고 이를 어댑터에 전달하면 됩니다.

 

이를 위해 setData 또는 submitData 메소드를 호출해

어댑터에 새로운 데이터를 설정할 있다.

 

 

여러개의 뷰홀더

 

리싸이클러뷰는 여러 개의 뷰홀더를 사용해 다양한 형태의 아이템을 출력할 수 있다.

 

 

프래그먼트 용도

 

프래그먼트를 이용해 다양한 화면 사이즈에 대응할 있다.

 


 

챌린지반 4주차 세션 첫번째 과제 

 

연락처 리스트 앱 만들기

 

구현사항

- 메인 화면에는 RecyclerView 하나만 표시합니다.
- 연락처 아이템 레이아웃에는 사진, 이름, 전화번호를 표시하는 뷰가 포함되어야 합니다.
- 어댑터 클래스는 ListAdapter를 확장합니다.
- 데이터는 adapter.submitList()를 통해서 집어넣습니다
- 연락처 데이터를 바인딩하는 로직이 포함되어야 합니다.
- 데이터모델의 즐겨찾기 여부에 따라 viewType을 나눠 표시해보세요 
- ViewType은 총 두개입니다. (ViewHolder도 두개)
- MainActivity에서 RecyclerView를 어댑터와 연결하고, 더미 데이터를 로드하여 표시합니다.
- 전화를 걸어 봅시다 (선택과제)

 

 

레이아웃

 

viewType을 나누기 위해 즐겨찾기 여부에 따른 아이템 레이아웃을 작성한다.

 

 

데이터 클래스 설계

data class ContactItem(
    val img: Int,
    val name: String,
    val contact: String,
    var isFavorite: Boolean
)

연락처 아이템에 사진, 이름, 전화번호, 즐겨찾기 여부 프로퍼티를 추가한다.

 

 

ListAdapter란?

RecyclerView 어댑터의 인터페이스로 RecylclerView.Adapter를 상속받는다.

 

ListAdpater는 DiffUtill 클래스와 결합해 데이터 변경사항을 비교하고

최소한의 업데이트만을 수행하여 앱의 성능을 향상시킨다.

 

어댑터 내의 데이터가 변경될 때마다 RecylerView에 대한 데이터를 

다시 설정하지 않고도 업데이트를 진행할 수 있다는 장점이 있다.

 

 

DiffUtil

companion object {
    val diffUtil = object : DiffUtil.ItemCallback<ContactItem>() {
        override fun areItemsTheSame(oldItem: ContactItem, newItem: ContactItem): Boolean {
            return oldItem.contact == newItem.contact
        }

        override fun areContentsTheSame(oldItem: ContactItem, newItem: ContactItem): Boolean {
            return oldItem == newItem
        }
    }
}

DiffUtil은 두 개의 아이템 간의 차이를 계산하고, 변경된 항목을 식별하는 역할을 한다.

어댑터 클래스 내의 companion object에서 diffUtil을 선언해 정적변수로써 사용한다. 

 

 

ListAdapter 선언

class ContactAdpater() :
    ListAdapter<ContactItem, RecyclerView.ViewHolder>(diffUtil) {
    // ListAdapter 메소드 오버라이딩
}

ListAdapter 클래스를 확장해 어댑터를 정의하려면 

ListAdpater를 상속받아 생성자에 diffUtil을 전달하면 된다.

 

 

클릭 이벤트 인터페이스 정의

interface IconClick {
    fun onClick(position: Int)
}

interface ItemLongClick {
    fun onLongClick(view : View, position : Int)
}

var iconClick : IconClick? = null
var itemLongClick : ItemLongClick? = null

어댑터 클래스 내에 인터페이스를 정의하여 클릭 이벤트를 처리하도록 한다.

인터페이스를 상속하는 변수들은 메인 액티비티에서 구현된다.

 

 

다중 ViewHolder선언

inner class FavoriteViewHolder(var binding: ItemFavoriteContactBinding) :
    RecyclerView.ViewHolder(binding.root) {
    fun bind(item: ContactItem) {
        binding.apply {
            ivPerson.setImageResource(item.img)
            tvName.text = item.name
            tvContact.text = item.contact
            ivFavorite.setOnClickListener {
                iconClick?.onClick(adapterPosition)
            }
        }
    }
}

inner class NormalViewHolder(var binding: ItemNormalContactBinding) :
    RecyclerView.ViewHolder(binding.root) {
    fun bind(item: ContactItem) {
        binding.apply {
            ivPerson.setImageResource(item.img)
            tvName.text = item.name
            tvContact.text = item.contact
            ivFavorite.setOnClickListener {
                iconClick?.onClick(adapterPosition)
            }
        }
    }
}

 어댑터는 여러 개의 뷰홀더를 선언할 수 있다.

각각의 뷰홀더는 해당하는 레이아웃의 바인딩을 인자로 받아 구현된다. 

 

 

즐겨찾기 여부에 따른 ViewType 나누기

companion object {
    const val FAVORITE = 1
    const val NORMAL = 2
	...
}

어댑터 클래스 내에 viewType 상수를 정적 변수로 선언한다.

 

override fun getItemViewType(position: Int): Int {
    return if (currentList[position].isFavorite) FAVORITE else NORMAL
}

뷰홀더가 2개 이상이라면 필수적으로 구현해야되는 함수이다.

아이템의 즐겨찾기 여부에 따라 viewType이 초기화된다.

 

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
    return when (viewType) {
        FAVORITE -> {
            val binding = ItemFavoriteContactBinding.inflate(
                LayoutInflater.from(parent.context), parent, false
            )
            FavoriteViewHolder(binding)
        }
        NORMAL -> {
            val binding = ItemNormalContactBinding.inflate(
                LayoutInflater.from(parent.context), parent, false
            )
            NormalViewHolder(binding)
        }
        else -> throw IllegalArgumentException("Invalid view type")
    }
}

viewType값에 따라 해당하는 뷰홀더를 생성한다.

 

override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
    // 연락처를 길게 눌렀을 때의 동작 정의
    holder.itemView.setOnLongClickListener() OnLongClickListener@{
        itemLongClick?.onLongClick(it, position)
        return@OnLongClickListener true
    }

    val item = currentList[position]
    when (holder) {
        is FavoriteViewHolder -> holder.bind(item)
        is NormalViewHolder -> holder.bind(item)
    }
}

해당 함수는 각 뷰홀더에 데이터를 바인딩 하는 함수로 when 구문을

사용해 뷰홀더의 타입을 확인 후 bind 메서드를 호출한다.

 

이러한 구조를 통해 다양한 타입의 뷰홀더에 대응할 수 있으며,

각 뷰홀더가 자신의 타입에 맞는 데이터를 처리할 수 있도록 합니다.

 

 

바인딩 사용할 때 주의할점

// setContentView(R.layout.activity_contact)
setContentView(binding.root)

어댑터 설정을 완료했는데도 화면에 아무것도 안뜨는 문제를 겪은 적이 있는데

알고보니까 setContentView의 인자값을 binding.root로 주는 걸 까먹어서였다.

 

따로 설정안해줘도 정상적으로 빌드가 되기에 찾기 어려운 오류였다.

 

 

어댑터 연결 및 데이터 로드

// 어댑터 연결
val adapter = ContactAdpater()
binding.recyclerView.layoutManager = LinearLayoutManager(this)
binding.recyclerView.adapter = adapter

// 더미 데이터 생성
val contacts = listOf(
    ContactItem(R.drawable.ic_person, "person1", "010-1234-1234", false),
    ContactItem(R.drawable.ic_person, "person2", "010-1111-2222", false)
)

// 데이터 로드
adapter.submitList(contacts)

더미 데이터 초기화 로직은 따로 함수로 빼줘도 좋다.

 

 

즐겨찾기 아이콘 클릭 이벤트 구현

adapter.iconClick = object : ContactAdpater.IconClick {
    override fun onClick(position: Int) {
        val contactItem = contacts[position]
        contactItem.isFavorite = !contactItem.isFavorite
        adapter.notifyItemChanged(position)
    }
}

즐겨찾기 아이콘을 클릭하면 isFavorite(Bool)값을 반전시키고 어댑터에 알린다.

 

어댑터의 뷰타입 초기화 메소드에서 아이템의 isFavorite값에 따라 반환할

뷰타입 상수값이 정해지기에 아이콘을 누르게 되면 뷰홀더가 변하게 된다.

 

 

아이템 연락처로 전화걸기

<uses-feature
    android:name="android.hardware.telephony"
    android:required="false" />

<uses-permission android:name="android.permission.CALL_PHONE" />

매니패스트에서 전화를 걸기 위한 permission을 설정한다.

 

private val CALL_PERMISSION_CODE = 1
private var contact: String = ""

액티비티 클래스 내에 전화 권한 요청에 코드와 연락처 문자열 변수를 선언한다.

 

private fun makePhoneCall(contact: String) {
    if (ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE)
        != PackageManager.PERMISSION_GRANTED
    ) { ActivityCompat.requestPermissions(this,
            arrayOf(Manifest.permission.CALL_PHONE), CALL_PERMISSION_CODE)
    } else {
        val call = Intent(Intent.ACTION_CALL, Uri.parse("tel:${contact}"));
        startActivity(call);
    }
}

현재 애플리케이션의 전화 권한 여부를 확인하고 그에 따른 동작을 하는 함수이다.

전화 권한이 없다면 권한 요청을 하고 있다면 아이템의 전화번호로 전화를 건다.

 

override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray,
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    if (requestCode == CALL_PERMISSION_CODE) {
        if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            makePhoneCall(contact)
        } else {
            showPermissionSettingDialog()
        }
    }
}

권한 요청 결과를 처리하는 함수로 권한을 허용하지 않았을 때의 동작을 정의할 수 있다.

전화 권한이 허용되지 않으면 권한 설정 여부를 물어보는 다이얼로그를 띄우도록 했다.

 

private fun showPermissionSettingDialog() {
    val ad = AlertDialog.Builder(this)

    ad.setTitle("권한 필요")
    ad.setMessage("전화 걸기 기능을 사용하려면 전화 권한이 필요합니다.")

    val listener = object : DialogInterface.OnClickListener {
        override fun onClick(p0: DialogInterface?, p1: Int) {
            when (p1) {
                DialogInterface.BUTTON_POSITIVE -> {
                    val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
                    intent.data = Uri.fromParts("package", packageName, null)
                    startActivity(intent)
                }
            }
        }
    }

    ad.setPositiveButton("설정", listener)
    ad.setNegativeButton("취소", listener)

    ad.show()
}

해당 다이얼로그에서 "설정"버튼을 누르게 되면 

애플리케이션의 상세 설정 화면으로 이동하도록 했다.

 

전화 권한 페이지로 바로 이동하게 하고 싶었는데 그런 건 없다 하더라...

 

private fun showPermissionSettingDialog() {
    AlertDialog.Builder(this)
        .setTitle("권한 필요")
        .setMessage("전화 걸기 기능을 사용하려면 전화 권한이 필요합니다.")
        .setPositiveButton("허용") { _, _ ->
            val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
            intent.data = Uri.fromParts("package", packageName, null)
            startActivity(intent)
        }
        .setNegativeButton("취소", null)
        .show()
}

강의에서 배운대로 다이얼로그를 구현해 봤는데

짧게 쓸 수 있을 거 같아 찾아보니까 이런식으로 줄여쓰는게 가능했다.

 

adapter.itemLongClick = object : ContactAdpater.ItemLongClick {
    override fun onLongClick(view: View, position: Int) {
        contact = contacts[position].contact
        makePhoneCall(contact)
    }
}

아이템을 길게 누르면 아이템의 전화번호로 전화를 걸도록 했다.

 

 

번외

두번째 과제도 있는데 글이 너무 길어진 관계로 다음주 월요일에 진행하겠다.

4주차 과제 너무 많은 거 아니냐 근데...

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함