[Android] Tree 구조를 RecyclerView로 만들어보자 - TreeAdapter
Android

[Android] Tree 구조를 RecyclerView로 만들어보자 - TreeAdapter

728x90

 

 

네이버 메일

 

 위의 사진은 네이버 메일의 한 화면입니다. 네이버 메일에는 내 메일함을 트리 구조로 구성할 수 있는 기능이 있습니다. 메일을 분류하기 위해선 꼭 필요한 기능입니다. 위와 같이 리스트 형태를 트리 구조로 구성하려면 어떻게 하면 될까요? 이번 포스팅에서는 트리 구조를 가진 리스트를 구현해보도록 하겠습니다.

 

 

 

트리 구조란?

 트리 구조는 그래프의 일종으로, 여러 노드가 한 노드를 가리킬 수 없는 구조를 뜻합니다. 간단하게는 회로가 없고, 서로 다른 두 노드를 잇는 길이 하나뿐인 그래프를 트리라고 합니다. 이외의 트리 구조에 대한 자세한 설명은 이 글을 참고해주세요.

 

 흔히 사용되는 폴더, 파일 구조 또한 트리의 종류입니다. 필요한 기능은 다음과 같습니다.

  • 하나의 폴더에는 폴더 혹은 파일이 들어갈 수 있다.
  • 폴더를 열면 하위 파일들이 보이고, 반대로 폴더를 닫으면 하위 파일들은 숨겨진다.

 

 많이 사용되는 기능이라 개념적으로 어려울 건 없었지만, 막상 구현하려고 하니 어떻게 구현해야 할지 감이 잘 오지 않았습니다. 학부생 때 트리 구조를 알고리즘으로 구현하던 것을 바탕으로 데이터 형태를 만든 뒤 RecyclerView로 구현해보았습니다.

 

 

 

 

TreeAdapter 구현

Node

class Node<T>(val content: T) {

    var parent: Node<T>? = null

    private val _children = mutableListOf<Node<T>>()
    val children: List<Node<T>>
        get() = _children

    var isExpand = true
        private set

    val isRoot: Boolean
        get() = parent == null

    val isLeaf: Boolean
        get() = children.isEmpty()

    private var _depth = UNDEFINE
    val depth: Int
        get() {
            if (isRoot)
                _depth = 0
            else if (_depth == UNDEFINE)
                _depth = parent!!.depth + 1
            return _depth
        }

    fun toggle() {
        isExpand = !isExpand
    }

    fun expand() {
        if (!isExpand) isExpand = true
    }

    fun expandAll() {
        expand()

        if (isLeaf) return
        children.forEach { it.expandAll() }
    }

    fun collapse() {
        if (isExpand) isExpand = false
    }

    fun collapseAll() {
        collapse()

        if (isLeaf) return
        children.forEach { it.collapseAll() }
    }

    fun addChild(child: Node<T>): Node<T> {
        _children.add(child)
        child.parent = this

        return this
    }

    companion object {
        private const val UNDEFINE = -1
    }
}

 

 

 트리는 여러 개의 노드로 구성되어 있습니다. 하나의 노드는 여러 개의 자식 노드를 가질 수 있고, 이는 반복적으로 정의됩니다. 노드의 부모, 자식을 정의해주기 위해 parentchildren 필드를 사용합니다. 부모 노드는 없을 수도 있기 때문에 nullable로 정의해줍니다.

 노드는 트리 구조를 만들기 위한 Wrapper 클래스이기 때문에 화면에 표시하기 위해 사용되는 데이터는 content 필드를 사용합니다.

 그 외의 기본적인 노드 정보들을 정의해주고, 자식 노드를 가진 노드는 접었다 펼쳤다 하는 기능이 있기 때문에 기능을 구현하기 위한 함수들을 정의해줍니다.

 

 

 

 

TreeAdapter

abstract class TreeAdapter<T, VH: TreeViewHolder<T>>(
    nodes: List<Node<T>> = emptyList()
) : RecyclerView.Adapter<VH>() {

    protected val displayNodes = mutableListOf<Node<T>>()

    init {
        setDisplayNodes(nodes)
    }

    override fun getItemCount() = displayNodes.size

    private fun setDisplayNodes(nodes: List<Node<T>>) {
        nodes.forEach { node ->
            displayNodes.add(node)
            if (!node.isLeaf && node.isExpand) {
                setDisplayNodes(node.children)
            }
        }
    }

    open fun toggle(node: Node<T>) {
        if (node.isLeaf) return

        val isExpand = node.isExpand
        val startPosition = displayNodes.indexOf(node) + 1

        if (isExpand)
            notifyItemRangeRemoved(startPosition, removeChildNodes(node, true))
        else
            notifyItemRangeInserted(startPosition, addChildNodes(node, startPosition))
    }

    fun replaceAll(nodes: List<Node<T>>) {
        displayNodes.clear()
        setDisplayNodes(nodes)
        notifyDataSetChanged()
    }

    private fun addChildNodes(parent: Node<T>, startIndex: Int): Int {
        val childList = parent.children
        var addChildCount = 0

        childList.forEach { child ->
            displayNodes.add(startIndex + addChildCount++, child)
            if (child.isExpand) {
                addChildCount += addChildNodes(child, startIndex + addChildCount)
            }
        }

        if (!parent.isExpand) parent.toggle()

        return addChildCount
    }

    private fun removeChildNodes(parent: Node<T>, shouldToggle: Boolean = true): Int {
        if (parent.isLeaf) return 0

        val childList = parent.children
        var removeChildCount = childList.size
        displayNodes.removeAll(childList)

        childList.forEach { child ->
            if (child.isExpand) {
                child.toggle()
                removeChildCount += removeChildNodes(child, false)
            }
        }

        if (shouldToggle) parent.toggle()
        return removeChildCount
    }
}

 생성자 혹은 replaceAll() 함수로 전달되는 nodes는 트리의 구조를 가진 List입니다. nodes를 바로 화면에 보여주기는 힘들기 때문에 화면에 보여줄 List 형태인 displayNodes로 변환해줍니다. TreeAdapter를 상속받는 어댑터들은 displayNodes를 바라보게 됩니다.

 그 외의 노드를 접었다 펼쳤다 하는 기능은 toggle() 함수를 사용합니다.

 

 

 

 

TreeViewHolder

abstract class TreeViewHolder<T>(itemView: View) : RecyclerView.ViewHolder(itemView) {

    protected val context = itemView.context

    protected open val padding = DEFAULT_PADDING

    abstract fun bind(data: Node<T>)

    open fun setPaddingStart(data: Node<T>) = with(itemView) {
        val depth = data.depth
        itemView.setPadding(padding * depth, paddingTop, paddingRight, paddingBottom)
    }

    companion object {
        private const val DEFAULT_PADDING = 100
    }
}

 TreeAdapter에 사용되는 TreeViewHolder입니다. 별다른 기능은 없고, 트리 구조를 보여줄 때 좌측에 padding을 넣어주기 위해 setPaddingStart() 함수만 구현해보았습니다.

 

 

 

 

FileAdapter

class FileAdapter(nodes: List<Node<Data>>) : TreeAdapter<Data, TreeViewHolder<Data>>(nodes) {

    override fun getItemViewType(position: Int): Int {
        val data = displayNodes[position]

        return if (data.content is Data.File) FILE else DIRECTORY
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TreeViewHolder<Data> {
        val layoutInflater = LayoutInflater.from(parent.context)

        return if (viewType == FILE)
            FileViewHolder(
                ItemFileBinding.inflate(layoutInflater, null, false)
            )
        else
            DirectoryViewHolder(
                ItemDirectoryBinding.inflate(layoutInflater, null, false)
            )
    }

    override fun onBindViewHolder(holder: TreeViewHolder<Data>, position: Int) {
        val data = displayNodes[position]
        holder.bind(data)

        holder.itemView.setOnSingleClickListener {
            toggle(data)
        }
    }

    companion object {
        private const val FILE = 0
        private const val DIRECTORY = 1
    }
}

class FileViewHolder(
    private val binding: ItemFileBinding
) : TreeViewHolder<Data>(binding.root) {

    override fun bind(data: Node<Data>) {
        if (data.content !is Data.File) return

        setPaddingStart(data)

        binding.tvName.text = data.content.name
    }
}

class DirectoryViewHolder(
    private val binding: ItemDirectoryBinding
) : TreeViewHolder<Data>(binding.root) {

    override fun bind(data: Node<Data>) {
        if (data.content !is Data.Directory) return

        setPaddingStart(data)

        binding.tvName.text = data.content.name
    }
}

 

 

 

 

동작 화면

 

 

 

 

 

마치며

 회사에서 프로젝트를 진행하며 위와 같은 구조를 지닌 리스트를 여러 군데에서 구현했어야 했습니다. 그때마다 정형화되지 않은 방식으로 구현했었는데, 이제야 구조를 만들게 되었습니다. 위와 같은 뷰를 만드는데 필요하신 분께 도움이 됐으면 좋겠습니다. :)

 

 

 

예제 소스

https://github.com/tkdgusl94/blog-source/tree/master/TreeAdapter

 

tkdgusl94/blog-source

https://leveloper.tistory.com/ 에서 제공하는 예제 source. Contribute to tkdgusl94/blog-source development by creating an account on GitHub.

github.com

 

728x90