버튼 수집상

[안드로이드] 스피너 커스텀UI 만들기 (두 줄 리스트 스피너) 본문

TIL - 안드로이드

[안드로이드] 스피너 커스텀UI 만들기 (두 줄 리스트 스피너)

cocokaribou 2023. 5. 2. 14:48

배경

리사이클러뷰 안에서 다른 뷰홀더를 덮으면서 펼쳐지는 두 줄짜리 리스트를 구현해야 했다.

그래서 드롭다운 UI가 액티비티 최상위에 그려지는 스피너 Spinner 를 선택했다. (참고 PopUpWindow)

 

과제1

드롭다운 리스트가 펼쳐지고 닫히는 시점을 알기 위해

AppCompatSpinner를 상속하는 커스텀뷰를 만들고 리스너 인터페이스를 달았다.

 

참고한 스택오버플로우 답변

 

Spinner: get state or get notified when opens

Is it possible to know whether a Spinner is open or closed? It would even be better if there was some sort of onOpenListener for Spinners. I've tried using an OnItemSelectedListener like this:

stackoverflow.com

 

CustomSpinner.kt

class CustomSpinner @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    def: Int = 0,
) : AppCompatSpinner(context, attrs, def) {

    interface OnSpinnerEventListener {
        fun onSpinnerOpened()
        fun onSpinnerClosed()
        fun onSpinnerItemClicked(index: Int) // 어댑터에서 사용
    }

    lateinit var spinnerListener: OnSpinnerEventListener
    private var mOpenInitiated = false

    fun setSpinner(listener: OnSpinnerEventListener, list: List<String>) {
        spinnerListener = listener // 드롭다운 리스너 초기화
        adapter = CustomSpinnerAdapter(list, listener) // 어댑터 초기화 (데이터리스트와 클릭리스너)
    }

    override fun performClick(): Boolean {
        if (this::spinnerListener.isInitialized) {
            mOpenInitiated = true
            spinnerListener.onSpinnerOpened()
        }
        return super.performClick()
    }

    override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
        if (hasWindowFocus && hasBeenOpened()) {
            if (this::spinnerListener.isInitialized) {
                mOpenInitiated = false
                spinnerListener.onSpinnerClosed()
            }
        }
    }

    private fun hasBeenOpened() = mOpenInitiated
}

 

 

스피너가 열리고 닫히는 지점을 잡을 때
클릭 trigger - 열림
열려있었으면서 windowFocus 획득 - 닫힘

xml 파일

<...>
<ImageView
        android:id="@+id/spinner_button"
        android:layout_width="48dp"
        android:layout_height="48dp"
        android:background="@color/white"
        android:padding="12dp"
        android:src="@drawable/selector_spinner_button"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <View
        android:id="@+id/divider"
        android:layout_width="match_parent"
        android:layout_height="0.5dp"
        android:background="#dddddd"
        app:layout_constraintTop_toBottomOf="@id/tab_scroll" />

    <com.example.flexibleapp.customview.CustomSpinner
        android:id="@+id/spinner"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@color/white"
        android:dropDownWidth="match_parent"
        android:spinnerMode="dropdown"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/divider" />
  <...>

스피너가 접혀있을 때 디폴트로 선택된 아이템을 보여주고 싶지 않았다.

그래서 스피너의 width, height를 0dp로 잡고

spinner_button 버튼을 클릭했을 때 spinner.performClick() 을 호출했다.

 

과제2

스피너에 리스트 아이템을 뿌려줄 어댑터를 구현하자.

그동안 써봤던 ArrayAdpater는 TextView가 최상위 뷰인 xml만 그릴 수 있었다.

두 줄짜리 리스트 아이템을 그리기 위해 BaseAdapter를 상속하는 커스텀 어댑터를 구현했다.

 

CustomSpinnerAdapter.kt

class CustomSpinnerAdapter(
    private val items: List<String>,
    private val listener: CustomSpinner.OnSpinnerEventListener? = null,
) : BaseAdapter() {
    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        // viewHolder 패턴으로 그리라고 경고문이 뜬다
        // 스크롤이 없는 단순한 UI이므로 이대로 구현
        val binding = ItemTab001SpinnerBinding.inflate(LayoutInflater.from(parent?.context), parent, false)

        // 1차원 문자열 리스트를 두 개씩 묶음
        val list = items.mapIndexed{ index, item -> Pair(index, item) }.chunked(2)[position]

        binding.tv1.apply {
            text = list[0].second
            setOnClickListener {
                val index = list[0].first
                listener?.onSpinnerItemClicked(index)
            }
        }

        if (list.size == 2) {
            binding.tv2.apply {
                text = list[1].second
                setOnClickListener {
                    val index = list[1].first
                    listener?.onSpinnerItemClicked(index)
                }
            }
        }

        if (list.size == 1 || position == count - 1) {
            binding.bottomPadding.visibility = View.VISIBLE
        }
        if (position == 0) {
            binding.topPadding.visibility = View.VISIBLE
        }

        return binding.root
    }

    override fun getItem(position: Int): Any {
        return items[position]
    }

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

    override fun getCount(): Int {
        return if (items.size % 2 == 0) items.size / 2 else items.size / 2 + 1
    }
}

 

item_tab001_spinner.xml

<?xml version="1.0" encoding="utf-8"?>
<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="wrap_content">

    <View
        android:id="@+id/top_padding"
        android:layout_width="match_parent"
        android:layout_height="10dp"
        android:background="@color/white"
        android:visibility="gone"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tv1"
        android:layout_width="0dp"
        android:layout_height="40dp"
        android:background="@color/white"
        android:gravity="center_vertical"
        android:maxLines="1"
        android:paddingHorizontal="16dp"
        android:textColor="#505050"
        android:textSize="13dp"
        app:layout_constraintEnd_toStartOf="@id/tv2"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/top_padding"
        tools:text="ABOUT US" />

    <TextView
        android:id="@+id/tv2"
        android:layout_width="0dp"
        android:layout_height="40dp"
        android:background="@color/white"
        android:gravity="center_vertical"
        android:maxLines="1"
        android:paddingHorizontal="16dp"
        android:textColor="#505050"
        android:textSize="13dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/tv1"
        app:layout_constraintTop_toBottomOf="@id/top_padding"
        tools:text="ABOUT US" />

    <View
        android:id="@+id/bottom_padding"
        android:layout_width="match_parent"
        android:layout_height="20dp"
        android:background="@color/white"
        android:visibility="gone"
        app:layout_constraintTop_toBottomOf="@id/tv1" />
</androidx.constraintlayout.widget.ConstraintLayout>

 

이제 뷰홀더 파일 안에서 스피너를 초기화해보자

//...
        spinnerButton.setOnClickListener {
            spinner.performClick()
        }

        spinner.setSpinner(
            listener = object : CustomSpinner.OnSpinnerEventListener {
                override fun onSpinnerOpened() {
                    spinnerButton.isSelected = true
                }

                override fun onSpinnerClosed() {
                    spinnerButton.isSelected = false
                }

                override fun onSpinnerItemClicked(index: Int) {
                    Log.v("TAG", "click! ${data[index].tabTitle}")
                }
            },
            list = data.map { it.tabTitle ?: "" })
//...

 

이러면 잘 작동한다.

 

다만, 최상위 액티비티의 UI에 따라서

onWindowFocusChanged 안에서 구현한 커스텀 스피너 닫기 리스너가 제대로 동작하지 않는 경우가 있다.

 

728x90