[Kotlin] RecyclerView의 모든것 (기본 사용법, Diffutil로 데이터 변화 시 UI 반영,Drag&Drop으로 아이템 순서 변경, Swipe 후 아이템 삭제)

park_sujeong·2022년 9월 13일
2

Android

목록 보기
6/13
post-thumbnail

RecyclerView는 이름처럼 리소스를 재활용한다. ListView의 확장판으로 성능 개선과 기능을 추가한것이다. ReCylerView는 ListView의 getView 함수 대신 ViewHolder를 의무적으로 사용해야하는 점이 다르다. 좀 더 자세한 개념은 구글링하면 많이 나온다.

RecyclerView의 기능을 쓸때마다 구글링하면서 찾는게 귀찮아서 이 포스팅을 작성한다. 미래의 나는 이걸 보고 쉽게 했으면 좋겠다.


우선 간단한 앱을 만들면서 RecyclerView의 기능을 써볼것이다. 아래는 내가 생각한 UI와 기능이다.

  • 아이템 추가 시 RecyclerView UI에 반영
  • 아이템 좌우로 드래그 가능
  • 아이템 스와이프로 위치 변경
  • 아이템 삭제 기능




해당 글에서 나는 에러 및 오류 해결 방법





RecyclerView 기본 사용법

리스트를 RecyclerView로 띄우기 && RecyclerView에 넣을 data class 생성

결과 화면


구현 방법


  1. build.gradle(:app)에 recyclerview 의존성 추가

    dependencies {
        ...
        implementation 'androidx.recyclerview:recyclerview:1.2.1'
    }

  2. 레이아웃에 RecyclerView 추가

    app/res/layout/activity_main.xml

    <?xml version="1.0" encoding="utf-8"?>
    <layout>
    	<androidx.constraintlayout.widget.ConstraintLayout 	
        	xmlns:android="http://schemas.android.com/apk/res/android"
        	xmlns:app="http://schemas.android.com/apk/res-auto"
        	xmlns:tools="http://schemas.android.com/tools"
        	android:layout_width="match_parent"
        	android:layout_height="match_parent"
        	tools:context=".MainActivity">
    
        	<androidx.recyclerview.widget.RecyclerView
            	android:id="@+id/recyclerview"
            	android:layout_width="0dp"
            	android:layout_height="0dp"
            	android:layout_margin="10dp"
            	app:layout_constraintBottom_toTopOf="@+id/add_button"
        	    app:layout_constraintEnd_toEndOf="parent"
          	  	app:layout_constraintStart_toStartOf="parent"
            	app:layout_constraintTop_toTopOf="parent" />
    
        	<Button
            	android:id="@+id/add_button"
            	android:layout_width="0dp"
            	android:layout_height="wrap_content"
            	android:layout_margin="10dp"
            	android:text="@string/add_item"
            	app:layout_constraintBottom_toBottomOf="parent"
            	app:layout_constraintEnd_toEndOf="parent"
            	app:layout_constraintStart_toStartOf="parent" />
    
    	</androidx.constraintlayout.widget.ConstraintLayout>
    </layout>

  1. RecyclerView의 아이템이 될 Data Class 생성

    app/java/패키지/datas/User.kt

    data class User(
    	val id: Int,
    	val name: String) {
    }

  1. 리스트에 그려줄 아이템 레이아웃 파일 추가

    app/res/layout/user_list_item.xml.xml

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    	android:layout_width="match_parent"
    	android:layout_margin="10dp"
    	android:layout_height="wrap_content">
    
    	<ImageView
        	android:id="@+id/user_image"
        	android:layout_width="50dp"
        	android:src="@mipmap/ic_launcher"
        	android:layout_height="50dp" />
    
    	<TextView
        	android:id="@+id/user_name_text"
        	android:layout_width="wrap_content"
        	android:textSize="20sp"
        	android:gravity="center"
        	android:layout_marginStart="10dp"
        	android:layout_height="match_parent" />
    
    </LinearLayout>

  1. Adapter 클래스 생성
    app/java/패키지/adapters/UserAdapter

    class UserAdapter(
    	private val mContext: Context,
    	private val mList: MutableList<User>
    ): RecyclerView.Adapter<UserAdapter.ViewHolder>() {
    
    	inner class ViewHolder(view: View): RecyclerView.ViewHolder(view) {
    
        	private val userImage: ImageView = 	itemView.findViewById(R.id.user_image)
        	private val userNameText: TextView = itemView.findViewById(R.id.user_name_text)
    
        	fun bind(user: User) {
    
            	userNameText.text = user.name
    
        	}
    	}
    
    	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        	val view = LayoutInflater.from(mContext).inflate(R.layout.user_list_item, parent, false)
        	return ViewHolder(view)
    	}
    
    	override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        	val user = mList[position]
        	holder.bind(user)
    	}
    
    	override fun getItemCount() = mList.size
    
    }

  1. 코틀린단에서 RecyclerView에 어댑터 연결
    app/java/패키지/MainActivity

    
    class MainActivity : AppCompatActivity() {
    	private lateinit var binding: ActivityMainBinding
    	private lateinit var adapter: UserAdapter
    	private var userList = mutableListOf<User>()
    
    	override fun onCreate(savedInstanceState: Bundle?) {
        	super.onCreate(savedInstanceState)
        	binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
    
        	// RecyclerView에 사용할 데이터 리스트 초기화
        	for (i in 1 until 11) {
            	val mUser = User(i, "user$i")
            	userList.add(mUser)
        	}
    	}
    
    	override fun onResume() {
        	super.onResume()
    
        	// RecyclerView에 리스트 추가 및 어댑터 연결
        	adapter = UserAdapter(this, userList)
        	binding.recyclerview.layoutManager = LinearLayoutManager(this)
        	binding.recyclerview.adapter = adapter
    
    	}
    }





아이템 추가 후 RecyclerView UI 반영

diffUtil을 이용한 RecyclerView UI 반영

DiffUtil은 RecyclerView의 데이터가 변할 때 UI 반영을 효율적으로 해주는 것이다. 기존에 변한 데이터를 UI에 반영할때는 notifyDataSetChanged(), notifyItemChanged()...등등을 사용했을 것이다.

notifyDataSetChanged()는 전체 항목을 변경시켜 RecyclerView에 아이템이 많을수록 효율성이 떨어진다. 그 밖에 단일 항목 변경(notifyItemChanged(), notifyItemInserted(), notifyItemRemoved(), notifyItemMoved())범위 변경(notifyItemRangeChanged(), notifyItemRangeInserted(), notifyItemRangeRemoved())이 있다.

DiffUtil은 정말 똑똑하게도 리스트를 하나씩 비교해서 Boolean값을 반환한다. 반환값이 true이면 두 리스트의 동일한 포지션에는 같은 아이템이 존재하는 것이고, false이면 두 리스트의 동일한 포지션에 다른 아이템이거나, 같은 아이템이지만 내용물이 변경된것이다.

이 클래스를 사용하여 효율적인 UI 반영을 처리해보자.


결과 화면

추가된 아이템이 잘 반영되는 걸 볼 수 있다.


구현 방법

  1. RecyclerViewAdpater에 DiffUtil의 ItemCallback을 추가하고, onBindViewHolder에 val user = mList[position]val user = differ.currentList[position]으로 변경한다.

    app/java/패키지/adapters/UserAdapter.kt

    class UserAdapter(
        private val mContext: Context
    ): RecyclerView.Adapter<UserAdapter.ViewHolder>() {
    
        // 추가
    	private val differCallback = object : DiffUtil.ItemCallback<User>() {
    	    override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
            // User의 id를 비교해서 같으면 areContentsTheSame으로 이동(id 대신 data 클래스에 식별할 수 있는 변수 사용)
                return oldItem.id == newItem.id
    	    }
    
        	override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
            // User의 내용을 비교해서 같으면 true -> UI 변경 없음
            // User의 내용을 비교해서 다르면 false -> UI 변경
      	      return oldItem == newItem
        	}
    	}
    
    	// 리스트가 많으면 백그라운드에서 실행하는 게 좋은데 AsyncListDiffer은 자동으로 백그라운드에서 실행
    	val differ = AsyncListDiffer(this, differCallback)
    
        inner class ViewHolder(view: View): RecyclerView.ViewHolder(view) {
    
            private val userImage: ImageView = itemView.findViewById(R.id.user_image)
            private val userNameText: TextView = itemView.findViewById(R.id.user_name_text)
    
            fun bind(user: User) {
    
                userNameText.text = user.name
    
    	    }
    	}
    
    	override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        	val view = LayoutInflater.from(mContext).inflate(R.layout.user_list_item, parent, false)
        return ViewHolder(view)
    	}
    
    	override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        	// 수정 전
        	//val user = mList[position]
    
        	// 수정 후
        	val user = differ.currentList[position]
        	holder.bind(user)
    	}
    	// 수정 전
    	// override fun getItemCount() = mList.size
        
        // 수정 후
    	override fun getItemCount() =  differ.currentList.size
    	
	}

  1. MainActivity에서 differUtil.submitList()을 사용하여 adapter에 데이터를 추가한다. 또한 아이템 추가 버튼에 클릭 이벤트를 추가하여 아이템이 추가될 때 differUtil을 이용하여 Adapter의 UI를 반영한다.

    class MainActivity : AppCompatActivity() {
    
        private lateinit var binding: ActivityMainBinding
        private lateinit var adapter: UserAdapter
        private var userList = mutableListOf<User>()
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
    
            // RecyclerView에 사용할 리스트 제공
            for (i in 1 until 11) {
                val mUser = User(i, "user$i")
                userList.add(mUser)
            }
    
        }
    
        override fun onResume() {
            super.onResume()
    
            // RecyclerView에 리스트 추가 및 어댑터 연결
            adapter = UserAdapter(this)
            binding.recyclerview.layoutManager = LinearLayoutManager(this)
            binding.recyclerview.adapter = adapter
    
            // DiffUtil 적용 후 데이터 추가
            adapter.differ.submitList(userList)
    
            // 아이템 추가 버튼 클릭 시 이벤트(userList에 아이템 추가)
            binding.addButton.setOnClickListener {
    
                // 추가할 데이터 생성
                val mUser = User(userList.size+1, "added user ${userList.size+1}")
    
                // differ의 현재 리스트를 받아와서 newList에 넣기
                val newList = adapter.differ.currentList.toMutableList()
    
                // newList에 생성한 유저 추가
                newList.add(mUser)
    
                // adapter의 differ.submitList()로 newList 제출
                // submitList()로 제출하면 기존에 있는 oldList와 새로 들어온 newList를 비교하여 UI 반영
                adapter.differ.submitList(newList)
    
                // userList에도 추가 (꼭 안해줘도 되지만 userList가 필요할때가 있을 수도 있다.)
                // userList = adapter.differ.currentList 이렇게 사용하면 안됨
                userList.add(mUser)
    
                // 추가 메시지 출력
                Toast.makeText(this, "${mUser.name}이 추가되었습니다.", Toast.LENGTH_SHORT).show()
    
                // 추가된 포지션으로 스크롤 이동
                binding.recyclerview.scrollToPosition(userList.indexOf(mUser))
            }
        }
        
    }
    





Drag하여 아이템 순서 바꾸기

itemTouchHelper를 사용하여 아이템 순서 바꾸기

여기서부터는 헷갈릴 수 있으니 필요한 부분은 코드를 잘라서 먼저 보여주고 전체 코드를 보여주겠다. 우리는 itemTouchHelper를 이용해서 drag and drop 기능을 추가할것이다.

결과 화면


구현 방법

  1. ItemTouchHelper.SimpleCallback을 상속받은 클래스를 만든다. (경로는 어디든 상관없음)

    app/java/패키지/ItemTouchSimpleCallback

     class ItemTouchSimpleCallback : ItemTouchHelper.SimpleCallback(
            ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) {
            
            interface OnItemMoveListener {
                fun onItemMove(from: Int, to: Int)
            }
    
            private var listener: OnItemMoveListener? = null
    
            fun setOnItemMoveListener(listener: OnItemMoveListener) {
                this.listener = listener
            }
    
            override fun onMove(
                recyclerView: RecyclerView,
                viewHolder: RecyclerView.ViewHolder,
                target: RecyclerView.ViewHolder
            ): Boolean {
    
                // 어댑터 획득
                val adapter = recyclerView.adapter as UserAdapter
    
                // 현재 포지션 획득
                val fromPosition = viewHolder.absoluteAdapterPosition
    
                // 옮길 포지션 획득
                val toPosition = target.absoluteAdapterPosition
    
    			// adapter 리스트를 담기위한 변수 생성
                val list = arrayListOf<User>()
    
                // adapter가 가지고 있는 현재 리스트 획득
                list.addAll(adapter.differ.currentList)
    
                // 리스트 순서 바꿈
                Collections.swap(list, fromPosition, toPosition)
    
                // adapter.notifyItemMoved(fromPosition, toPosition)와 같은 역할
    			// list를 adapter.differ.submitList()로 데이터 변경 사항 알림
                adapter.differ.submitList(list)
    
                // 추가적인 조치가 필요할 경우 인터페이스를 통해 해결
                listener?.onItemMove(fromPosition, toPosition)
    
                return true
            }
    
            override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
            }
            
    		// 드래그 완료 후 UI 
            override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
                super.clearView(recyclerView, viewHolder)
    
                // 순서 조정 완료 후 투명도 다시 1f로 변경
                viewHolder.itemView.alpha = 1.0f
            }
            
    		// 드래그 중 UI 변화
            override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
    
                if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
                    // 순서 변경 시 alpha를 0.5f
                    viewHolder?.itemView?.alpha = 0.5f
                }
                super.onSelectedChanged(viewHolder, actionState)
            }
        }

  1. ItemTouchHelper콜백을 작성한 후 Activity에서 RecycleirView와 연결시켜준다.

    app/java/MainActivity

    • ItemTouchHelper Callback과 ItemTouchHelper 변수 선언
    	private val itemTouchSimpleCallback = ItemTouchSimpleCallback()
    	private val itemTouchHelper = ItemTouchHelper(itemTouchSimpleCallback)
    • ItemTouchHelper와 RecyclerView 연결
                // itemTouchSimpleCallback 인터페이스로 추가 작업
            itemTouchSimpleCallback.setOnItemMoveListener(object : ItemTouchSimpleCallback.OnItemMoveListener {
                override fun onItemMove(from: Int, to: Int) {
                    Log.d("MainActivity", "from Position : $from, to Position : $to")				
                    
                    // userList에도 값이 변하는 걸 원한다면 Collections.swap으로 변경
                    //Collections.swap(userList, from, to)
    
                    // userList != adapter.differ.currentList
                    // adapter.differ.currentList는 계속 값을 변경했지만 userList는 변경 전 값(왜냐면 우리는 변경한적이 없다.)
                    Log.d("MainActivity", "userList: $userList")
                    Log.d("MainActivity", "differ currentList: ${adapter.differ.currentList}")
                }
            })
    
            // itemTouchHelper와 recyclerview 연결
            itemTouchHelper.attachToRecyclerView(binding.recyclerview)
    • MainActivity 전체 코드
    class MainActivity : AppCompatActivity() {
    
        private lateinit var binding: ActivityMainBinding
        private lateinit var adapter: UserAdapter
        private var userList = mutableListOf<User>()
        private val itemTouchSimpleCallback = ItemTouchSimpleCallback()
        private val itemTouchHelper = ItemTouchHelper(itemTouchSimpleCallback)
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
    
            // RecyclerView에 사용할 리스트 제공
            for (i in 1 until 11) {
                val mUser = User(i, "user$i")
                userList.add(mUser)
            }
    
        }
    
        override fun onResume() {
            super.onResume()
    
            // RecyclerView에 리스트 추가 및 어댑터 연결
            adapter = UserAdapter(this)
            binding.recyclerview.layoutManager = LinearLayoutManager(this)
            binding.recyclerview.adapter = adapter
    
            // DiffUtil 적용 후 데이터 추가
            adapter.differ.submitList(userList)
    
            // itemTouchSimpleCallback 인터페이스로 추가 작업
            itemTouchSimpleCallback.setOnItemMoveListener(object : ItemTouchSimpleCallback.OnItemMoveListener {
                override fun onItemMove(from: Int, to: Int) {
                    Log.d("MainActivity", "from Position : $from, to Position : $to")
                    //Collections.swap(userList, from, to)
    
                    // userList != adapter.differ.currentList
                    // adapter.differ.currentList는 계속 값을 변경했지만 userList는 변경 전 값(왜냐면 우리는 변경한적이 없다.)
                    Log.d("MainActivity", "userList: $userList")
                    Log.d("MainActivity", "differ currentList: ${adapter.differ.currentList}")
                }
            })
    
            // itemTouchHelper와 recyclerview 연결
            itemTouchHelper.attachToRecyclerView(binding.recyclerview)
    
            // 아이템 추가 버튼 클릭 시 이벤트(userList에 아이템 추가)
            binding.addButton.setOnClickListener {
    
                // 추가할 데이터 생성
                val mUser = User(userList.size+1, "added user ${userList.size+1}")
    
                // differ의 현재 리스트를 받아와서 newList에 넣기
                val newList = adapter.differ.currentList.toMutableList()
    
                // newList에 생성한 유저 추가
                newList.add(mUser)
    
                // adapter의 differ.submitList()로 newList 제출
                // submitList()로 제출하면 기존에 있는 oldList와 새로 들어온 newList를 비교하여 UI 반영
                adapter.differ.submitList(newList)
    
                // userList에도 추가 (꼭 안해줘도 되지만 userList가 필요할때가 있을 수도 있다.)
                // userList = adapter.differ.currentList 이렇게 사용하면 안됨
                userList.add(mUser)
    
                // 추가 메시지 출력
                Toast.makeText(this, "${mUser.name}이 추가되었습니다.", Toast.LENGTH_SHORT).show()
    
                // 추가된 포지션으로 스크롤 이동
                binding.recyclerview.scrollToPosition(userList.indexOf(mUser))
            }
        }
    }





Swipe 후 View 고정 시 나타나는 Button 클릭으로 아이템 제거

Swipe 후 View를 고정하게 되면 뒤에 숨겨져있던 삭제 버튼을 눌러 아이템 제거

작업 순서는 간단하다.

  1. swipe 되고 난 후의 삭제 버튼을 누르기 위해 필요한 view 추가 (user_list_item.xml)
  2. 삭제 버튼 클릭 시 일어날 이벤트 추가 (UserAdapter.kt)
  3. swipe 동작 추가 (ItemTouchSimpleCallback.kt)
  4. ItemTouchSimpleCallback의 추가 이벤트리스너를 RecyclerView와 연결 (MainActivity.kt)

쓰고보니 별로 안간단하다. 설명은 주석을 참고하면 된다!. 수정된 부분을 표시해주고 싶은데 이미 한번 작성해 놓은 글을 날려버려서 의욕이 안난다!


결과 화면


구현 방법

  1. 삭제 버튼(나는 TextView로 했음) 추가 및 전체적인 속성 변경 (app/res/layout/user_list_item.xml)

    app/res/layout/user_list_item.xml

    <?xml version="1.0" encoding="utf-8"?>
    <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="70dp"
        xmlns:app="http://schemas.android.com/apk/res-auto">
    
        <TextView
            android:id="@+id/remove_text_view"
            android:layout_width="100dp"
            android:layout_height="0dp"
            android:background="@android:color/holo_red_light"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            android:text="삭제"
            android:textSize="22sp"
            android:gravity="center" />
    
        <LinearLayout
            android:id="@+id/swipe_view"
            android:layout_width="match_parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            android:background="@color/white"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            android:padding="10dp"
            android:layout_height="0dp">
    
            <ImageView
                android:id="@+id/user_image"
                android:layout_width="50dp"
                android:src="@mipmap/ic_launcher"
                android:layout_height="50dp" />
    
            <TextView
                android:id="@+id/user_name_text"
                android:layout_width="wrap_content"
                android:textSize="20sp"
                android:gravity="center"
                android:layout_marginStart="10dp"
                android:layout_height="match_parent" />
    
        </LinearLayout>
    </androidx.constraintlayout.widget.ConstraintLayout>

  1. 이제 우리는 삭제버튼을 눌렀을 때 일어날 이벤트를 RecyclerView의 Adapter에 써주자(app/java/패키지/adapters/UserAdapter.kt)

    app/java/패키지/adapters/UserAdapter.kt

    class UserAdapter(
        private val mContext: Context)
        : RecyclerView.Adapter<UserAdapter.ViewHolder>() {
    
        inner class ViewHolder(view: View): RecyclerView.ViewHolder(view) {
    
        	private val swipeView: LinearLayout = itemView.findViewById(R.id.swipe_view)
            private val userImage: ImageView = itemView.findViewById(R.id.user_image)
            private val userNameText: TextView = itemView.findViewById(R.id.user_name_text)
            private val removeTextView: TextView = itemView.findViewById(R.id.remove_text_view)
    
            fun bind(user: User) {
    
            	// 재사용 시 Swipe가 되어있다면 Swipe 원상복구
            	swipeView.translationX = 0f
    
                userNameText.text = user.name
    
                removeTextView.setOnClickListener {
                    val list = arrayListOf<User>()
                    list.addAll(differ.currentList)
                    list.remove(user)
    
                    // 해당 아이템 삭제 adapter에 알리기기
                differ.submitList(list)
                }
    
            }
        }
    
        override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
            val view = LayoutInflater.from(mContext).inflate(R.layout.user_list_item, parent, false)
            return ViewHolder(view)
        }
    
        override fun onBindViewHolder(holder: ViewHolder, position: Int) {
            // 수정 전
            //val user = mList[position]
    
            // 수정 후
            val user = differ.currentList[position]
            holder.bind(user)
        }
    
        override fun getItemCount() =  differ.currentList.size
    
        // 추가
        private val differCallback = object : DiffUtil.ItemCallback<User>() {
            override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
                return oldItem.id == newItem.id
            }
    
            override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
                return oldItem == newItem
            }
        }
    
        val differ = AsyncListDiffer(this, differCallback)
    
    }

  1. 천천히 ItemTouchSimpleCallback.kt 전반적으로 수정한다. (app/java/패키지/ItemTouchSimpleCallback.kt)

    app/java/패키지/ItemTouchSimpleCallback.kt

    class ItemTouchSimpleCallback : ItemTouchHelper.SimpleCallback(
        ItemTouchHelper.UP or ItemTouchHelper.DOWN,
        ItemTouchHelper.RIGHT or ItemTouchHelper.LEFT
    ) {
    
        private var currentPosition: Int? = null
        private var previousPosition: Int? = null
        private var currentDx = 0f
    
        // 삭제 버튼 width를 넣을 값
        private var clamp = 0f
    
        interface OnItemMoveListener {
            fun onItemMove(from: Int, to: Int)
        }
    
        private var listener: OnItemMoveListener? = null
    
        fun setOnItemMoveListener(listener: OnItemMoveListener) {
            this.listener = listener
        }
    
        override fun onMove(
            recyclerView: RecyclerView,
            viewHolder: RecyclerView.ViewHolder,
            target: RecyclerView.ViewHolder
        ): Boolean {
    
            // 어댑터 획득
            val adapter = recyclerView.adapter as UserAdapter
    
            // 현재 포지션 획득
            val fromPosition = viewHolder.absoluteAdapterPosition
    
            // 옮길 포지션 획득
            val toPosition = target.absoluteAdapterPosition
    
            // adapter가 가지고 있는 현재 리스트 획득
            val list = arrayListOf<User>()
            list.addAll(adapter.differ.currentList)
    
            // 리스트 순서 바꿈
            Collections.swap(list, fromPosition, toPosition)
    
            // adapter.notifyItemMoved(fromPosition, toPosition)
            adapter.differ.submitList(list)
    
            // 추가적인 조치가 필요할 경우 인터페이스를 통해 해결
            listener?.onItemMove(fromPosition, toPosition)
    
            return true
        }
    
        override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
        }
    
        override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
            super.clearView(recyclerView, viewHolder)
    
            // 순서 조정 완료 후 투명도 다시 1f로 변경
            viewHolder.itemView.alpha = 1.0f
            getDefaultUIUtil().clearView(getView(viewHolder))
            previousPosition = viewHolder.absoluteAdapterPosition
        }
    
        override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
    
            if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
                // 순서 변경 시 alpha를 0.5f
                viewHolder?.itemView?.alpha = 0.5f
            }
    
            if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
                viewHolder?.let {
                    // 삭제 버튼 width 획득
                    clamp = getViewWidth(viewHolder)
                    // 현재 뷰홀더
                    currentPosition = viewHolder.bindingAdapterPosition
                    getDefaultUIUtil().onSelected(getView(it))
                }
            }
    
            super.onSelectedChanged(viewHolder, actionState)
        }
    
        override fun onChildDraw(
            c: Canvas,
            recyclerView: RecyclerView,
            viewHolder: RecyclerView.ViewHolder,
            dX: Float,
            dY: Float,
            actionState: Int,
            isCurrentlyActive: Boolean
        ) {
            if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
                val view = getView(viewHolder)
                val isClamped = getTag(viewHolder)
    
                val x = clampViewPositionHorizontal(view, dX, isClamped, isCurrentlyActive)
    
                currentDx = x
    
                getDefaultUIUtil().onDraw(
                    c, recyclerView, view, x, dY, actionState, isCurrentlyActive
                )
            }
            
            if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
            	super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
        	}
        }
    
        // 삭제버튼 width 구하는 함수
        private fun getViewWidth(viewHolder: RecyclerView.ViewHolder): Float{
            val viewWidth = (viewHolder as UserAdapter.ViewHolder).itemView.findViewById<TextView>(R.id.remove_text_view).width
            return viewWidth.toFloat()
        }
    
        // swipe될 뷰 (우리가 스와이프할 시 움직일 화면)
        private fun getView(viewHolder: RecyclerView.ViewHolder): View {
            return (viewHolder as UserAdapter.ViewHolder).itemView.findViewById(R.id.swipe_view)
        }
    
        // view의 tag로 스와이프 고정됐는지 안됐는지 확인 (고정 == true)
        private fun getTag(viewHolder: RecyclerView.ViewHolder): Boolean {
            return viewHolder.itemView.tag as? Boolean ?: false
        }
    
        // view의 tag에 스와이프 고정됐으면 true, 안됐으면 false 값 넣기
        private fun setTag(viewHolder: RecyclerView.ViewHolder, isClamped: Boolean) {
            viewHolder.itemView.tag = isClamped
        }
    
        // 스와이프 될 가로(수평평) 길이
    private fun clampViewPositionHorizontal(
            view: View,
            dX: Float,  //
            isClamped: Boolean,
            isCurrentlyActive: Boolean
        ): Float {
            val maxSwipe: Float = -clamp * 1.5f
    
            val right = 0f
    
            val x = if (isClamped) {
                if (isCurrentlyActive) dX - clamp else -clamp
            } else dX
    
            return min(
                max(maxSwipe, x),
                right
            )
        }
    
        // 사용자가 Swipe 동작으로 간주할 최소 속도
        override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
            return defaultValue * 10
        }
    
        // 사용자가 스와이프한 것으로 간주할 view 이동 비율
        override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
            setTag(viewHolder, currentDx <= -clamp)
            return 2f
        }
    
        // 다른 아이템 클릭 시 기존 swipe되어있던 아이템 원상복구
        fun removePreviousClamp(recyclerView: RecyclerView) {
            if (currentPosition == previousPosition)
                return
            previousPosition?.let {
                val viewHolder = recyclerView.findViewHolderForAdapterPosition(it) ?: return
                getView(viewHolder).translationX = 0f
                setTag(viewHolder, false)
                previousPosition = null
            }
        }
    
    }

  1. removePreviousClamp를 적용하기 위해 MainActivity.kt를 수정한다. (app/java/패키지/MainActivity.kt)

    app/java/패키지/MainActivity.kt)

    class MainActivity : AppCompatActivity() {
    
        private lateinit var binding: ActivityMainBinding
        private lateinit var adapter: UserAdapter
        private var userList = mutableListOf<User>()
        private val itemTouchSimpleCallback = ItemTouchSimpleCallback()
        private val itemTouchHelper = ItemTouchHelper(itemTouchSimpleCallback)
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
    
            // RecyclerView에 사용할 리스트 제공
            for (i in 1 until 11) {
                val mUser = User(i, "user$i")
                userList.add(mUser)
            }
    
        }
    
        override fun onResume() {
            super.onResume()
    
            // RecyclerView에 리스트 추가 및 어댑터 연결
            adapter = UserAdapter(this)
            binding.recyclerview.layoutManager = LinearLayoutManager(this)
            binding.recyclerview.adapter = adapter
    
            // DiffUtil 적용 후 데이터 추가
            adapter.differ.submitList(userList)
    
            // itemTouchSimpleCallback 인터페이스로 추가 작업
            itemTouchSimpleCallback.setOnItemMoveListener(object : ItemTouchSimpleCallback.OnItemMoveListener {
                override fun onItemMove(from: Int, to: Int) {
                    Log.d("MainActivity", "from Position : $from, to Position : $to")
                    //Collections.swap(userList, from, to)
    
                    // userList != adapter.differ.currentList
                    // adapter.differ.currentList는 계속 값을 변경했지만 userList는 변경 전 값(왜냐면 우리는 변경한적이 없다.)
                    Log.d("MainActivity", "userList: $userList")
                    Log.d("MainActivity", "differ currentList: ${adapter.differ.currentList}")
                }
            })
    
            // itemTouchHelper와 recyclerview 연결
            itemTouchHelper.attachToRecyclerView(binding.recyclerview)
    
            // RecyclerView의 다른 곳을 터치하거나 Swipe 시 기존에 Swipe된 것은 제자리로 변경
            // 아래 코드가 경고 표시를 주는데 이것은 Annotation @SuppressLint("ClickableViewAccessibility")을 함수에 추가하면 됨
            // 또는, performClick 사용
            binding.recyclerview.setOnTouchListener { _, _ ->
                itemTouchSimpleCallback.removePreviousClamp(binding.recyclerview)
                false
            }
    
            // 아이템 추가 버튼 클릭 시 이벤트(userList에 아이템 추가)
            binding.addButton.setOnClickListener {
    
                // 추가할 데이터 생성
                val mUser = User(userList.size+1, "added user ${userList.size+1}")
    
                // differ의 현재 리스트를 받아와서 newList에 넣기
                val newList = adapter.differ.currentList.toMutableList()
    
                // newList에 생성한 유저 추가
                newList.add(mUser)
    
                // adapter의 differ.submitList()로 newList 제출
                // submitList()로 제출하면 기존에 있는 oldList와 새로 들어온 newList를 비교하여 UI 반영
                adapter.differ.submitList(newList)
    
                // userList에도 추가 (꼭 안해줘도 되지만 userList가 필요할때가 있을 수도 있다.)
                // userList = adapter.differ.currentList 이렇게 사용하면 안됨
                userList.add(mUser)
    
                // 추가 메시지 출력
                Toast.makeText(this, "${mUser.name}이 추가되었습니다.", Toast.LENGTH_SHORT).show()
    
                // 추가된 포지션으로 스크롤 이동
                binding.recyclerview.scrollToPosition(userList.indexOf(mUser))
            }
        }
    
    }





MainActivity 코드 정리

지저분한 MainActivity의 소스코드를 좀 정리해보자

onResume()에 있던걸 용도별 함수에 나누어서 onResume()에서는 함수호출로 정리했다.

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private lateinit var adapter: UserAdapter
    private var userList = mutableListOf<User>()
    private val itemTouchSimpleCallback = ItemTouchSimpleCallback()
    private val itemTouchHelper = ItemTouchHelper(itemTouchSimpleCallback)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.activity_main)

        // RecyclerView에 사용할 리스트 제공
        for (i in 1 until 11) {
            val mUser = User(i, "user$i")
            userList.add(mUser)
        }

    }

    override fun onResume() {
        super.onResume()

        initRecyclerView()
        setupEvents()

    }

    private fun initRecyclerView() {
        // RecyclerView에 리스트 추가 및 어댑터 연결
        adapter = UserAdapter(this)
        binding.recyclerview.layoutManager = LinearLayoutManager(this)
        binding.recyclerview.adapter = adapter

        // DiffUtil 적용 후 데이터 추가
        adapter.differ.submitList(userList)

        // itemTouchSimpleCallback 인터페이스로 추가 작업
        itemTouchSimpleCallback.setOnItemMoveListener(object : ItemTouchSimpleCallback.OnItemMoveListener {
            override fun onItemMove(from: Int, to: Int) {
                // Collections.swap(userList, from, to) 처럼 from, to가 필요하다면 사용
                Log.d("MainActivity", "from Position : $from, to Position : $to")
            }
        })

        // itemTouchHelper와 recyclerview 연결
        itemTouchHelper.attachToRecyclerView(binding.recyclerview)

        // RecyclerView의 다른 곳을 터치하거나 Swipe 시 기존에 Swipe된 것은 제자리로 변경
        binding.recyclerview.setOnTouchListener { _, _ ->
            itemTouchSimpleCallback.removePreviousClamp(binding.recyclerview)
            false
        }
    }

    private fun setupEvents() {
        // 아이템 추가 버튼 클릭 시 이벤트(userList에 아이템 추가)
        binding.addButton.setOnClickListener {

            // 추가할 데이터 생성
            val mUser = User(userList.size+1, "added user ${userList.size+1}")

            // differ의 현재 리스트를 받아와서 newList에 넣기
            val newList = adapter.differ.currentList.toMutableList()

            // newList에 생성한 유저 추가
            newList.add(mUser)

            // adapter의 differ.submitList()로 newList 제출
            // submitList()로 제출하면 기존에 있는 oldList와 새로 들어온 newList를 비교하여 UI 반영
            adapter.differ.submitList(newList)

            // userList에도 추가 (꼭 안해줘도 되지만 userList가 필요할때가 있을 수도 있다.)
            // userList = adapter.differ.currentList 이렇게 사용하면 안됨
            userList.add(mUser)

            // 추가 메시지 출력
            Toast.makeText(this, "${mUser.name}이 추가되었습니다.", Toast.LENGTH_SHORT).show()

            // 추가된 포지션으로 스크롤 이동
            binding.recyclerview.scrollToPosition(newList.indexOf(mUser))
        }
    }


}





DiffUtil 사용 시 UI 반영이 안되는 경우

혹시 DiffUtil.submitList()를 사용했는 데 UI에 업데이트가 안된다면 이 글이 유용할 것이다.

DiffUtil.submitList()는 입력받은 리스트를 참조하는 것이지 그 리스트를 따로 가지고 있는 것이 아니다. 이게 무슨 말이냐면 처음에 DiffUtil.submitList()에 listA를 넣는다. 그렇다면 현재 Diffutil은 listA를 바라보고 있다.

우리는 이제 Item을 추가하기 위해 listA에 add.(아이템)을 하고 그것을 DiffUtil.submitList()에 넣을 가능성이 크다(내가 그랬다!나는 멍청했다). 그렇다면 DiffUtil의 areItemsTheSame()과 areContentsTheSame()은 반환값을 true로 준다. 반환값이 true면 이전 리스트와 새로운 리스트의 동일한 포지션에 있는 아이템이 같다는 의미로 adapter에 UI 반영을 해주지않는다.

왜 그럴까?

바로 DifferUtil이 참조하고 있는 listA와 새로 들어온 listA는 같은 것이기 때문이다!

DifferUtil은 submitList()로 데이터가 들어오면 참조하고 있던 oldList와 비교하는데 이 oldList는 listA다. 하지만 listA는 이미 우리가 listA.add(아이템)으로 변경해주었기 때문에 oldList와 newList가 같은 상황인것이다.

해결방법은 새로운 변수에 differ.currentList()를 넣어서 데이터를 추가하고 submitList()에 넣는것이다.

나중에 이것과 관련된 내용을 자세히 공부하는 포스팅을 하게되면 링크를 추가하겠다!





참고





마치며

위의 단계별 내용을 Github에 commit했다. 혹시 필요하다면 아래 주소로 가면 된다.

https://github.com/park-chris/RecyclerView





profile
Android Developer

0개의 댓글