Android - RecyclerView

2024. 7. 16. 16:00[Android] Kotlin 활용 앱 개발

# 개념

- 안드로이드 앱에서 리스트 형태의 데이터를 표시하는 데 사용되는 위젯

- 여러 아이템을 스크롤 가능한 리스트로 표시

- 한 화면을 재활용하여 여러 번 사용, 여러 아이템을 효율적으로 관리하고 표시 가능

 

# ListView와의 차이

1) ListView

- 스크롤 시 위에 있던 아이템은 삭제, 아래에 있는 아이템이 생성

- 아이템이 많을수록 삭제와 생성을 반복하여 성능이 저하

2) RecyclerView

- 스크롤 시 위에 있던 아이템이 재활용되어 아래에서 재사용

- 아이템이 100개 정도여도 스크롤에 따라 10개 정도만 반복해서 재사용

 

# RecyclerView 사용 준비물

1) LayoutManager

- RecyclerView 내부의 아이템들이 어떻게 배치될지 결정함

- 기본 제공: LinearLayoutManager, GridLayoutManager, StaggeredLayoutManager 등

recyclerView.layoutManager = LinearLayoutManager(this) // 수직 리스트를 위한 LinearLayoutManager
// 또는
recyclerView.layoutManager = GridLayoutManager(this, 4) // 2열 그리드를 위한 GridLayoutManager

 

2) Adapter(어댑터)

- RecyclerView에 표시될 데이터와 해당 데이터를 보여줄 ViewHolder를 연결

- 데이터셋의 변경 사항을 RecyclerView에 알리고, 데이터 기반 뷰를 생성

// AOS에서 기본으로 제공하는 RecyclerView.Adapter를 상속
class VideoListAdapter(private val dataList: MutableList<String>) : RecyclerView.Adapter<VideoListAdapter.VideoViewHolder>() {

 	// ViewHolder: 재활용되는 단위 하나를 부르는 명칭
	
    inner class VideoViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val textView: TextView = view.findViewById(R.id.textView)
        val thumbnailView: ImageView = view.findViewById(R.id.thumbnail)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyViewHolder {
        val itemView = LayoutInflater.from(parent.context).inflate(R.layout.item_view, parent, false)
        return VideoViewHolder(itemView)
    }

	// Bind = Connect / position값에 맞춰서 ViewHolder에 해당 position의 데이터를 할당
	// ViewHolder를 재활용해서 안의 데이터를 재활용할 데이터로 바꿔주는 역할
	override fun onBindViewHolder(holder: VideoViewHolder, position: Int) { ******
        Log.d("RecycleView", "onBindViewHolder :$position")
        holder.textView.text = dataList[position]
        holder.thumbnailView.setImageUri(dataList[position].thubnailUrl)
    }

    override fun getItemCount() = dataList.size

    // 데이터 추가
    fun addItem(data: String) {
        dataList.add(data)
        // 새 데이터가 들어갔음을 RecyclerView에 알려줌
        notifyItemInserted(dataList.size - 1)
    }

    // 데이터 삭제
    fun removeItem(position: Int) {
        if (position < dataList.size) {
            dataList.removeAt(position)
            // 데이터가 지워졌음을 RecyclerView에 알려줌
            notifyItemRemoved(position)
        }
    }
}

 

3) ViewHolder

RecyclerView의 개별 아이템 뷰를 위한 객체

// inner class 권장
inner class MyViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val thumbnailView: ImageView = view.findViewById(R.id.thumbnailView)
        val titleView: TextView = view.findViewById(R.id.titleView)
        // 여기에 필요한 다른 뷰를 추가할 수 있습니다.
    }

 

# RecyclerView 사용하기

1) Adapter

- 데이터 테이블을 다양한 형식의 리스트로 보여주기 위해 데이터와 RecyclerView 사이에 존재하는 객체

  (통신 매개체)

 

2) ViewHolder

- 화면에 표시될 데이터, 아이템들을 저장하는 객체

- 스크롤하여 위로 올라간 View들을 재사용하기 위해 이 View들을 기억하는 역할을 수행

 

※ View Binding을 사용하려면 gradle.kts 파일에 아래 코드를 복붙하고 나서 해야 함.

android{
	...
    
    // AndroidStudio 3.6 ~ 4.0
    viewBinding{
    	enabled = true
    }
    
    // AndroidStudio 4.0 ~
    buildFeatures{
    	viewBinding = true
    }
}

 

<예시 코드>

// MyAdapter.kt

package com.example.durianmarket

import android.content.Intent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AdapterView.OnItemClickListener
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.RecyclerView
import com.example.durianmarket.databinding.ItemRecyclerviewBinding
import java.text.NumberFormat

// 어댑터는 원본 데이터를 파라미터로 받아 RecyclerView에 배치하는 역할 수행
// 따라서 그 기능을 하는 RecyclerView.Adapter를 상속

class MyAdapter(val mItems: MutableList<MyItem>) : RecyclerView.Adapter<MyAdapter.Holder>() {
    // 인터페이스는 특정 기능을 클래스에 강제하는 계약을 정의함
    // 인터페이스를 구현하는 클래스는 인터페이스에 정의된 메서드와 프로퍼티를 구체적으로 구현해야 함
    // MainActivity, Adapter 사이에 통신할 수 있는 인터페이스
    // 어댑터에서 구체적인 메서드까지 구현하면 안되므로(단일책임원칙 / 메서드 구현하고 어떤 동작하는지는 Activity의 영역)
    // interface 선언만 하고, 구현이 아직 안된 함수 fun onCLick()만 선언해놓음
    interface ItemClick {
        fun onClick(view: View, position: Int)
    }

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

    // 통신을 위한 인터페이스 타입의 변수 선언
    // onBindViewHolder 안에서 itemView 클릭 시 onClick() 메서드가 실행된다는 사실을 정의해놓기 위해
    // 가상의 인터페이스 변수 선언
    var itemClick : ItemClick? = null
    var itemLongClick: ItemLongClick? = null

    // RecyclerView가 새로운 ViewHolder를 필요로 할 때 호출되어 새로운 항목 뷰를 생성하고 초기화
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        // 뷰 바인딩을 통해 parent(RecyclerView의 부모 뷰 그룹)의 context(현재)의 바인딩 객체를 가져옴
        val binding = ItemRecyclerviewBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        // 가져온 binding을 Holder()에 넣어서 ViewHolder를 생성(재활용되는 여러 뷰들 중에 골라서)
        return Holder(binding)
    }

    // Holder가 임시로 갖고 있던 레이아웃들에 실제 원본 데이터의 값을 할당
    override fun onBindViewHolder(holder: Holder, position: Int){
        // itemView(RecyclerView 전체)에 onClickListener를 설정
        // itemView 전체에 클릭이 발생하면, it(View)와 position값을 onClick에 넣어서 itemClick을 호출
        holder.itemView.setOnClickListener{
            itemClick?.onClick(it, position)
        }
        // 롱클릭 발생 시 수행하는 동작
        holder.itemView.setOnLongClickListener{
            itemLongClick?.onLongClick(it, position)
            true // 내가 롱클릭 썼어~(터치 이벤트를 소모했으니 뒤에는 쓰지 마라~)
        }

        holder.icon.setImageResource(mItems[position].icon)
        holder.title.text = mItems[position].title
        holder.price.text = mItems[position].price.toString()
        val priceString = mItems[position].price.toString()
        // price에 천 단위 쉼표 넣기
        if (holder.price.text.length >= 4) {
            try{
                val priceNumber = priceString.toLong()
                holder.price.text = NumberFormat.getInstance().format(priceNumber).toString()+"원"
            } catch (e: NumberFormatException) {
                // priceString이 Long 타입으로 변환되지 않으면 원래 문자열을 반환
                holder.price.text = priceString
            }
        } else {
            holder.price.text = holder.price.text.toString()+"원"
        }

        if (mItems[position].favorite){
            holder.likeIcon.setImageResource(R.drawable.heart_filled)
        } else {
            holder.likeIcon.setImageResource(R.drawable.heart_empty)
        }

        holder.address.text = mItems[position].address
        holder.like.text = mItems[position].like.toString()
        holder.chat.text = mItems[position].chat
    }

    override fun getItemId(position: Int): Long {
        return position.toLong()
    }

    override fun getItemCount(): Int {
      return mItems.size
    }

    // 원본 데이터가 들어갈 레이아웃의 View들을 임시로 저장
    // Binder의 root에서 유래한(?) RecyclerView 타입의 binding 값을 파라미터로 받아 Holder(임시보관소)를 생성
    // Holder는 화면에 표시할 아이템이나 데이터들을 임시저장하는 역할을 하는 RecyclerView.ViewHolder를 상속
    // -> binding을 파라미터로 받아서 binding이 저장하고 있는 레이아웃의 컴포넌트들을 홀더 안에 val로 저장
    inner class Holder(val binding: ItemRecyclerviewBinding): RecyclerView.ViewHolder(binding.root) {
        val icon = binding.ivIcon
        val title = binding.tvTitle
//        val introduction = binding.
//        val seller = binding.
        val price = binding.tvPrice
        val address = binding.tvAddress
        val like = binding.tvLike
        val likeIcon = binding.ivLike
        val chat = binding.tvChat
    }
}
// MainActivity.kt

package com.example.durianmarket

import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.os.Build
import android.os.Bundle
import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.lifecycle.ReportFragment.Companion.reportFragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.durianmarket.databinding.ActivityMainBinding
import android.os.Handler
import android.util.Log
import android.widget.TextView
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.getSystemService
import com.example.durianmarket.databinding.ItemRecyclerviewBinding
import com.google.android.material.snackbar.Snackbar

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    // adapter를 by lazy로 나중에 onCreate() 안에서 사용
    private val adapter by lazy { MyAdapter(ProductManager.products) }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.recyclerView.adapter = adapter
        binding.recyclerView.layoutManager = LinearLayoutManager(this)

        // 데이터 원본 준비
        ProductManager.addProduct("0", R.drawable.sample1, "산 지 한 달 된 선풍기 팝니다", "이사가서 필요가 없어졌어요 급하게 내놓습니다",
            "대현동", 1000, "서울 서대문구 창천동", 13, "25", false)
        ProductManager.addProduct("1", R.drawable.sample2, "김치냉장고", "이사로인해 내놔요",
            "안마담", 20000, "인천 계양구 귤현동", 8, "44", false)
        ProductManager.addProduct("2", R.drawable.sample3, "샤넬 카드지갑", "고퀄지갑이구요\n사용감이 있어서 싸게 내어둡니다",
            "코코유", 10000, "수성구 범어동", 25, "24", false)
        ProductManager.addProduct("3", R.drawable.sample4, "금고", "금고\n떼서 가져가야함\n대우월드마크센텀\n미국이주관계로 싸게 팝니다",
            "Nicole", 10000, "해운대구 우제2동", 4, "11", false)
        ProductManager.addProduct("4", R.drawable.sample5, "갤럭시Z플립3 팝니다", "갤럭시 Z플립3 그린 팝니다\n항시 케이스 씌워서 썻고 필름 한장챙겨드립니다\n화면에 살짝 스크래치난거 말고 크게 이상은없습니다!",
            "절명", 150000, "연제구 연산제8동", 28, "77", false)
        ProductManager.addProduct("5", R.drawable.sample6, "프라다 복조리백", "까임 오염없고 상태 깨끗합니다\n정품여부모름",
            "미니멀하게", 50000, "수원시 영통구 원천동", 83, "14", false)
        ProductManager.addProduct("6", R.drawable.sample7, "울산 동해오션뷰 60평 복층 펜트하우스 1일 숙박권 펜션 힐링 숙소 별장", "울산 동해바다뷰 60평 복층 펜트하우스 1일 숙박권\n\n(에어컨이 없기에 낮은 가격으로 변경했으며 8월 초 가장 더운날 다녀가신 분 경우 시원했다고 잘 지내다 가셨습니다)\n\n1. 인원: 6명 기준입니다. 1인 10,000원 추가요금\n2. 장소: 북구 블루마시티, 32-33층\n3. 취사도구, 침구류, 세면도구, 드라이기 2개, 선풍기 4대 구비\n4. 예약방법: 예약금 50,000원 하시면 저희는 명함을 드리며 입실 오전 잔금 입금하시면 저희는 동.호수를 알려드리며 고객님은 예약자분 신분증 앞면 주민번호 뒷자리 가리시거나 지우시고 문자로 보내주시면 저희는 카드키를 우편함에 놓아 둡니다.\n5. 33층 옥상 야외 테라스 있음, 가스버너 있음\n6. 고기 굽기 가능\n7. 입실 오후 3시, 오전 11시 퇴실, 정리, 정돈 , 밸브 잠금 부탁드립니다.\n8. 층간소음 주의 부탁드립니다.\n9. 방3개, 화장실3개, 비데 3개\n10. 저희 집안이 쓰는 별장입니다.",
            "굿리치", 150000, "남구 옥동", 7, "4", false)
        ProductManager.addProduct("7", R.drawable.sample8, "샤넬 탑핸들 가방", "샤넬 트랜디 CC 탑핸들 스몰 램스킨 블랙 금장 플랩백 !\n + 색상 : 블랙\n + 사이즈 : 25.5cm * 17.5cm * 8cm\n + 구성 : 본품더스트\n + 급하게 돈이 필요해서 팝니다 ㅠ ㅠ",
            "난쉽", 180000, "동래구 온천제2동", 42, "12", false)
        ProductManager.addProduct("8", R.drawable.sample9, "4행정 엔진분무기 판매합니다.", "3년전에 사서 한번 사용하고 그대로 둔 상태입니다. 요즘 사용은 안해봤습니다. 그래서 저렴하게 내 놓습니다. 중고라 반품은 어렵습니다.\n",
            "알뜰한", 30000, "원주시 명륜2동", 4, "1", false)
        ProductManager.addProduct("9", R.drawable.sample10, "셀린느 버킷 가방", "22년 신세계 대전 구매입니당\n + 셀린느 버킷백\n + 구매해서 몇번사용했어요\n + 까짐 스크래치 없습니다.\n + 타지역에서 보내는 거라 택배로 진행합니당!",
            "똑태현", 190000, "중구 동화동", 8, "28", false)

        // 데이터 통신을 위해 만들어놓은 어댑터의 itemClick 변수에 MyAdapter의 Itemclick 타입의 오브젝트를 만들어놓음
        // MyAdapter에서 생성한 itemClick 인터페이스를 상속받아서 구체적인 onClick() 메서드를 구현
        // 어댑터의 onBindViewHolder 안에서 itemView가 클릭되면 onClick() 메서드가 실행된다는 사실까지는 적어놨음
        // 구체적으로 어떤 함수가 실행되는지 정의하는 것은 Activity의 몫이므로 여기에서 정의
        adapter.itemClick = object : MyAdapter.ItemClick {
            // MyAdapter 30번째 줄에서 호출한 itemClick이 여기로 콜백
            override fun onClick(view: View, position: Int) {
                val intent = Intent(this@MainActivity, ProductActivity::class.java)
                intent.putExtra ("position", position.toString())
                startActivity(intent)
            }
        }

        adapter.itemLongClick = object : MyAdapter.ItemLongClick {
            override fun onLongClick(view: View, position: Int) {
                AlertDialog.Builder(this@MainActivity)
                    .setMessage("상품을 삭제하시겠습니까?")
                    .setPositiveButton("확인") { dialog, which ->
                        val index: String = ProductManager.products[position].index
                        ProductManager.deleteProduct(index)
                        adapter.notifyDataSetChanged()
                    }
                    .setNegativeButton("취소") { dialog, which ->
                    }
                    .show()
            }
        }

        binding.btnFinish.setOnClickListener(){
            AlertDialog.Builder(this)
                .setMessage("앱을 종료하시겠습니까?")
                .setPositiveButton("확인") { dialog, which ->
                    System.exit(0)
                }
                .setNegativeButton("취소") { dialog, which ->
                }
                .show()
        }

        val btn_upscrollButton = binding.ivUpscrollButton

        binding.recyclerView.addOnScrollListener (object : RecyclerView.OnScrollListener(){
            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                super.onScrolled(recyclerView, dx, dy)
                if (dy>0) {
                    if (btn_upscrollButton.visibility == View.GONE){
                        btn_upscrollButton.apply{
                            visibility = View.VISIBLE
                            alpha = 0f
                            animate().alpha(1f).setDuration(300).start()
                        }
                    }
                } else {
                    if(btn_upscrollButton.visibility == View.VISIBLE){
                        btn_upscrollButton.animate()
                            .alpha(0f)
                            .setDuration(800)
                            .withEndAction{btn_upscrollButton.visibility = View.GONE}
                            .start()
                    }
                }
            }
        })

        fun notification() {
            val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
            val builder : NotificationCompat.Builder

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                val channelID = "id"
                val channelName = "default channel"
                val channel = NotificationChannel(
                    channelID,
                    channelName,
                    NotificationManager.IMPORTANCE_DEFAULT
                ).apply {
                    description = "알림"
                }
                manager.createNotificationChannel(channel)
                builder = NotificationCompat.Builder(this, channelID)
            } else {
                // 26 버전 이하에서는 channel ID를 사용할 필요가 없음
                builder = NotificationCompat.Builder(this)
            }

            builder.run {
                setSmallIcon(R.drawable.durian)
                setWhen(System.currentTimeMillis())
                setContentTitle("[두리안마켓]")
                setContentText("알림이 생성되었습니다.")
                setStyle(NotificationCompat.BigTextStyle())
            }
            manager.notify(1, builder.build())
        }

        binding.ivNotificationButton.setOnClickListener{
            notification()
        }

        btn_upscrollButton.setOnClickListener{
            binding.recyclerView.smoothScrollToPosition(0)

            binding.ivUpscrollButton2.visibility = ImageView.VISIBLE
            Handler(Looper.getMainLooper()).postDelayed({
                binding.ivUpscrollButton2.visibility = ImageView.GONE
            }, 50) // 100밀리초, 0.1초
        }

    }

    // 1번
    override fun onResume() {
        super.onResume()
        Log.d("MainActivity-lifecycle", "onResume")
        adapter.notifyDataSetChanged() // 전부 다 수정(대규모 데이터 처리 비추)

    }

    // 2번 - ResultCallback
    // 3번 -

    override fun onStart() {
        super.onStart()
        Log.d("MainActivity-lifecycle", "onStart")
    }

    override fun onStop() {
        super.onStop()
        Log.d("MainActivity-lifecycle", "onStop")
    }
}

 

# 꿀팁

- LayoutManager의 spanCount 속성을 활용하면 열 개수를 지정할 수 있음

- 레이아웃에서 tools:listitem(적용시킬 레이아웃)을 활용하면 뷰를 미리보기할 수 있음

 

'[Android] Kotlin 활용 앱 개발' 카테고리의 다른 글

[챌린지반] MVVM  (0) 2024.07.16
뷰 바인딩(View Binding)  (0) 2024.07.09
Android - 액티비티 생명 주기  (0) 2024.06.20
Android - 인텐트(Intent)  (0) 2024.06.20
Android - 액티비티(Activity)  (0) 2024.06.20