개발일지

Android in A..Z - RecyclerView (Selection Tracker) 본문

Android (안드로이드)/RecyclerView

Android in A..Z - RecyclerView (Selection Tracker)

강태종 2021. 1. 15. 04:19

Selection Tracker

RecyclerView에서 Item을 선택하는 기능을 제공한다.

 

ex) 갤러리에서 사진 여러개 선택


Dependency

dependencies {
    implementation "androidx.recyclerview:recyclerview:1.1.0"
    // For control over item selection of both touch and mouse driven selection
    implementation "androidx.recyclerview:recyclerview-selection:1.1.0-rc03"
}

기본구조

RecyclerView에서 Select이 발생하면 Select된 ViewHolder의 Id를 Selection에 기록하고, onBindViewHolder를 호출하여 업데이트하는 구조이다.

=> ViewHolder의 Id를 가져오는 코드가 필요함 -> ItemKeyProvider

=> Select된 ViewHolder의 정보를 가져오는 코드가 필요함 -> ItemDetailsLookup

구조

  • SelectionTracker : RecyclerView를 감시하여 Select를 감지하고 Select된 ViewHolder의 Id를 저장한다.
  • ItemKeyProvider : Select된 ViewHolder의 Id값을 가져온다.
  • ItemDetailsLookup : Select된 ViewHolder의 정보를 가져온다.
  • StorageStrategy : Id의 Save, Restore등을 담당한다. => 생명주기의 변화같은 Data의 저장 복원이 필요한 경우

Key (Id) 정하기

Selection 라이브러리는 3가지의 Key Type을 제공한다.

  • Long
  • String
  • Parcelable

Adapter 설정

class SelectionAdapter : BaseAdapter<Selection>(SelectionItemCallback()) {
    var tracker: SelectionTracker<Long>? = null

    init {
        setHasStableIds(true)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseHolder<out ViewDataBinding, Selection> {
        return SelectionHolder(DataBindingUtil.inflate(LayoutInflater.from(parent.context), viewType, parent, false))
    }

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

    override fun getItemViewType(position: Int): Int {
        return R.layout.holder_selection
    }

    inner class SelectionHolder(binding: HolderSelectionBinding) : BaseHolder<HolderSelectionBinding, Selection>(binding) {
        override fun bind(element: Selection) {
            super.bind(element)
            binding.selection = element

            itemView.isActivated = tracker?.isSelected(itemId) ?: false
        }
    }

    class SelectionKeyProvider(private val recyclerView: RecyclerView) : ItemKeyProvider<Long>(SCOPE_MAPPED) {
        override fun getKey(position: Int): Long {
            val holder = recyclerView.findViewHolderForAdapterPosition(position)
            return holder?.itemId ?: throw IllegalStateException("No Holder")
        }

        override fun getPosition(key: Long): Int {
            val holder = recyclerView.findViewHolderForItemId(key)
            return if (holder is SelectionAdapter.SelectionHolder) {
                holder.adapterPosition
            } else {
                RecyclerView.NO_POSITION
            }
        }
    }

    class SelectionDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup<Long>() {
        override fun getItemDetails(e: MotionEvent): ItemDetails<Long>? {
            val view = recyclerView.findChildViewUnder(e.x, e.y) ?: return null

            val holder = recyclerView.getChildViewHolder(view)
            return if (holder is SelectionHolder) {
                object : ItemDetails<Long>() {
                    override fun getPosition(): Int {
                        return holder.adapterPosition
                    }

                    override fun getSelectionKey(): Long {
                        return holder.itemId
                    }
                }
            } else {
                null
            }
        }
    }

    class SelectionPredicate(private val recyclerView: RecyclerView) : SelectionTracker.SelectionPredicate<Long>() {
        override fun canSetStateForKey(key: Long, nextState: Boolean): Boolean {
            val holder = recyclerView.findViewHolderForItemId(key)
            return if (holder is SelectionAdapter.SelectionHolder) {
                holder.element.text == "YES"
            } else {
                false
            }
        }

        override fun canSetStateAtPosition(position: Int, nextState: Boolean): Boolean {
            return true
        }

        override fun canSelectMultiple(): Boolean {
            return true
        }
    }

    class SelectionItemCallback : DiffUtil.ItemCallback<Selection>() {
        override fun areItemsTheSame(oldItem: Selection, newItem: Selection): Boolean {
            return oldItem.id == newItem.id
        }

        override fun areContentsTheSame(oldItem: Selection, newItem: Selection): Boolean {
            return oldItem.text == newItem.text
        }
    }
}
  • setHasStableIds(true) : Adapter에 고유한 ID값을 가진다고 설정한다.
  • getItemId(position) : 각 Item의 Id값을 설정한다.

ItemKeyProvider

SelectionTracker에서 기본적으로 제공하는 StableIdKeyProvider을 사용하거나 ItemKeyProvider를 상속받아서 구현한다.

class SelectionKeyProvider(private val recyclerView: RecyclerView) : ItemKeyProvider<Long>(SCOPE_MAPPED) {
    override fun getKey(position: Int): Long {
        val holder = recyclerView.findViewHolderForAdapterPosition(position)
        return holder?.itemId ?: throw IllegalStateException("No Holder")
    }

    override fun getPosition(key: Long): Int {
        val holder = recyclerView.findViewHolderForItemId(key)
        return if (holder is SelectionAdapter.SelectionHolder) {
            holder.adapterPosition
        } else {
            RecyclerView.NO_POSITION
        }
    }
}

ItemDetailsLookup

ItemDetailsLookup을 상속받아서 구현한다.

class SelectionDetailsLookup(private val recyclerView: RecyclerView) : ItemDetailsLookup<Long>() {
    override fun getItemDetails(e: MotionEvent): ItemDetails<Long>? {
        val view = recyclerView.findChildViewUnder(e.x, e.y) ?: return null

        val holder = recyclerView.getChildViewHolder(view)
        return if (holder is SelectionHolder) {
            object : ItemDetails<Long>() {
                override fun getPosition(): Int {
                    return holder.adapterPosition
                }

                override fun getSelectionKey(): Long {
                    return holder.itemId
                }
            }
        } else {
            null
        }
    }
}

StorageStrategy

StorageStrategy에서 기본적으로 제공하는 함수를 사용한다.

  • StorageStrategy.createLongStorage() : Key가 Long인 경우
  • StorageStrategy.createStringStorage() : Key가 String인 경우
  • StorageStrategy.createParcelableStorage() : Key가 Parcelable인 경우

Selection Tracker

SelectionTracker.Builder로 생성한다.

private val tracker by lazy {
    with(binding) {
        SelectionTracker.Builder(
                "Selection",
                recyclerView,
                //StableIdKeyProvider()
                SelectionAdapter.SelectionKeyProvider(recyclerView),
                SelectionAdapter.SelectionDetailsLookup(recyclerView),
                StorageStrategy.createLongStorage()
        ).withSelectionPredicate(
                SelectionAdapter.SelectionPredicate(recyclerView)
        ).build().apply {
            addObserver(object : SelectionTracker.SelectionObserver<Long>() {
                override fun onSelectionChanged() {
                    super.onSelectionChanged()
                    val tracker = this@apply
                    if (tracker.hasSelection() && menu.findItem(MENU_DELETE) == null) {
                        menu.add(Menu.NONE, MENU_DELETE, Menu.NONE, "Delete")
                                .setIcon(R.drawable.ic_delete)
                                .setOnMenuItemClickListener {
                                    tracker.selection.forEach {
                                        val holder = recyclerView.findViewHolderForItemId(it)
                                        if (holder is SelectionAdapter.SelectionHolder) {
                                            list.remove(holder.element)
                                        }
                                    }

                                    tracker.clearSelection()
                                    adapter.notifyDataSetChanged()
                                    true
                                }
                                .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
                    } else if (!tracker.hasSelection() && menu.findItem(MENU_DELETE) != null){
                        menu.removeItem(MENU_DELETE)
                    }
                }
            })
        }
    }
}

SelectionHolder

inner class SelectionHolder(binding: HolderSelectionBinding) : BaseHolder<HolderSelectionBinding, Selection>(binding) {
    override fun bind(element: Selection) {
        super.bind(element)
        binding.selection = element

        itemView.isActivated = tracker?.isSelected(itemId) ?: false
    }
}

holder_selection

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <data>
        <variable
            name="selection"
            type="com.taetae98.recyclerview.data.Selection" />
    </data>

    <LinearLayout
        android:background="@drawable/selection_background"
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:gravity="center"
            android:minHeight="100dp"
            android:text="@{selection.text}"
            android:textColor="@color/black"
            android:textSize="18sp"
            android:textStyle="bold" />
    </LinearLayout>
</layout>

selection_background

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:state_activated="true"
        android:drawable="@android:color/holo_blue_light" />

    <item
        android:drawable="@color/white"/>
</selector>

SelectionFragment

class SelectionFragment : BaseFragment<FragmentSelectionBinding>(R.layout.fragment_selection) {
    lateinit var menu: Menu

    init {
        setHasOptionsMenu(true)
    }

    companion object {
        const val MENU_DELETE = 0
    }

    private val adapter by lazy { SelectionAdapter().apply { submitList(list) }}
    private val list by lazy {
        mutableListOf(
            Selection(0, "YES"), Selection(1, "NO"), Selection(2, "YES"),
            Selection(3, "NO"), Selection(4, "YES"), Selection(5, "YES"),
            Selection(6, "YES"), Selection(7, "YES"), Selection(8, "YES")
    ) }
    private val tracker by lazy {
        with(binding) {
            SelectionTracker.Builder(
                    "Selection",
                    recyclerView,
                    //StableIdKeyProvider()
                    SelectionAdapter.SelectionKeyProvider(recyclerView),
                    SelectionAdapter.SelectionDetailsLookup(recyclerView),
                    StorageStrategy.createLongStorage()
            ).withSelectionPredicate(
                    SelectionAdapter.SelectionPredicate(recyclerView)
            ).build().apply {
                addObserver(object : SelectionTracker.SelectionObserver<Long>() {
                    override fun onSelectionChanged() {
                        super.onSelectionChanged()
                        val tracker = this@apply
                        if (tracker.hasSelection() && menu.findItem(MENU_DELETE) == null) {
                            menu.add(Menu.NONE, MENU_DELETE, Menu.NONE, "Delete")
                                    .setIcon(R.drawable.ic_delete)
                                    .setOnMenuItemClickListener {
                                        tracker.selection.forEach {
                                            val holder = recyclerView.findViewHolderForItemId(it)
                                            if (holder is SelectionAdapter.SelectionHolder) {
                                                list.remove(holder.element)
                                            }
                                        }

                                        tracker.clearSelection()
                                        adapter.notifyDataSetChanged()
                                        true
                                    }
                                    .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM)
                        } else if (!tracker.hasSelection() && menu.findItem(MENU_DELETE) != null){
                            menu.removeItem(MENU_DELETE)
                        }
                    }
                })
            }
        }
    }

    override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
        super.onCreateOptionsMenu(menu, inflater)
        this.menu = menu
    }

    override fun init() {
        super.init()
        initRecyclerView()
        initTracker()
    }

    private fun initRecyclerView() {
        binding.recyclerView.addItemDecoration(GridSpacingItemDecoration(3, 10.toDp()))
        binding.recyclerView.adapter = adapter
    }

    private fun initTracker() {
        adapter.tracker = tracker
    }
}

주의할 점

SelectionTracker에서 Select를 감지한 경우 onBindViewHolder를 호출하기 때문에 onBindViewHolder에서 Select에 대한 업데이트를 처리한다.

SelectionTracker를 구현하기 전에 Adapter가 RecyclerView에 attach되어있어야 한다.


Git (예제코드)

github.com/KangTaeJong98/Example/tree/main/Android/RecyclerView

 

KangTaeJong98/Example

My Example Code. Contribute to KangTaeJong98/Example development by creating an account on GitHub.

github.com

 

Comments