위의 사진은 네이버 메일의 한 화면입니다. 네이버 메일에는 내 메일함을 트리 구조로 구성할 수 있는 기능이 있습니다. 메일을 분류하기 위해선 꼭 필요한 기능입니다. 위와 같이 리스트 형태를 트리 구조로 구성하려면 어떻게 하면 될까요? 이번 포스팅에서는 트리 구조를 가진 리스트를 구현해보도록 하겠습니다.
트리 구조란?
트리 구조는 그래프의 일종으로, 여러 노드가 한 노드를 가리킬 수 없는 구조를 뜻합니다. 간단하게는 회로가 없고, 서로 다른 두 노드를 잇는 길이 하나뿐인 그래프를 트리라고 합니다. 이외의 트리 구조에 대한 자세한 설명은 이 글을 참고해주세요.
흔히 사용되는 폴더, 파일 구조 또한 트리의 종류입니다. 필요한 기능은 다음과 같습니다.
- 하나의 폴더에는 폴더 혹은 파일이 들어갈 수 있다.
- 폴더를 열면 하위 파일들이 보이고, 반대로 폴더를 닫으면 하위 파일들은 숨겨진다.
많이 사용되는 기능이라 개념적으로 어려울 건 없었지만, 막상 구현하려고 하니 어떻게 구현해야 할지 감이 잘 오지 않았습니다. 학부생 때 트리 구조를 알고리즘으로 구현하던 것을 바탕으로 데이터 형태를 만든 뒤 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
}
}
트리는 여러 개의 노드로 구성되어 있습니다. 하나의 노드는 여러 개의 자식 노드를 가질 수 있고, 이는 반복적으로 정의됩니다. 노드의 부모, 자식을 정의해주기 위해 parent와 children 필드를 사용합니다. 부모 노드는 없을 수도 있기 때문에 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
'Android' 카테고리의 다른 글
[Android] Clean Architecture in Android (12) | 2021.07.03 |
---|---|
[Android] 오픈소스 라이선스 목록 보여주기 - OssLicensesMenuActivity (4) | 2021.07.03 |
[Android] Paging 3.0 Library 알아보기 - 1 (0) | 2021.06.27 |
[Android] Multi Module로 Android project 구성하기 (0) | 2021.06.20 |
[Android] Event Wrapper를 사용한 단일 이벤트 처리 (1) | 2021.06.20 |