안드로이드/이론

[ 안드로이드 ] RecylcerView + Filterable을 이용하여 실시간 검색 기능 구현하기

dongx._.2 2023. 4. 7. 02:43

 

 

졸업 작품을 진행하면서 위와 같은 화면을 구현해야했다. 

약품 리스트를 받아와서, 검색한 단어와 일치한 모든 약품들을 보여줘야했다. 추가로 일치하는 단어에는 하이라이팅까지 해줘야했다.

 

위의 화면에서 지켜져야 할 조건들은 아래와 같다.

  1. 전체 약품 리스트를 미리 갖고 있어야한다.
    • 입력을 감지해서 실시간으로 결과를 출력할 것이기 때문에, 속도나 서버 부하 측면에서 매번 서버로 요청을 보낼 수 없다.
    • 미리 전체 약품 리스트를 받아와서 변수로 저장해둔 후 사용해야한다.
  2. 일치하는 단어는 하이라이팅으로 표시해줘야 한다.
    • Spannable을 이용해서 TextView의 색상을 조절해주면 된다.

 

 

약품 검색 화면 xml 작성

먼저 화면을 작성해준다.

나같은 경우에는 약품을 선택했을 때 해시태그처럼 상단에 추가되게 할 예정이었기 때문에 RecyclerView를 하나 더 넣어줬다.

 

그냥 검색 기능만 구현할 예정이라면 RecyclerView는 하나만 있어도 된다.

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".medicine.MedicineSearchFragment">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/background"
        android:orientation="vertical">

        <!-- Layout 상단바 레이아웃 -->
        <RelativeLayout
            android:id="@+id/title_bar_layout"
            android:layout_width="match_parent"
            android:layout_height="56dp"
            android:orientation="horizontal"
            android:background="@color/white">

            <ImageView
                android:id="@+id/back_btn"
                android:layout_width="24dp"
                android:layout_height="24dp"
                android:layout_gravity="left|center_vertical"
                android:layout_marginStart="20dp"
                android:background="@drawable/ic_left_arrow"

                android:layout_centerVertical="true"

                android:src="@drawable/ripple_unbounded"
                android:clickable="true"/>

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:text="의약품 검색"
                android:textSize="@dimen/H4_M500_18_auto"
                android:textColor="@color/black1_20"
                android:fontFamily="@font/pretendard_medium"
                android:lineSpacingExtra="3sp"
                android:includeFontPadding="false"
                android:layout_centerInParent="true"/>
        </RelativeLayout>

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:background="@color/white"
            android:paddingTop="10dp"
            android:orientation="vertical">

            <EditText
                android:id="@+id/search_edit_text"
                android:layout_width="match_parent"
                android:layout_height="45dp"
                android:layout_marginTop="10dp"
                android:layout_marginBottom="14dp"
                android:layout_marginStart="@dimen/side_margin"
                android:layout_marginEnd="@dimen/side_margin"
                android:paddingStart="15dp"
                android:paddingEnd="15dp"

                android:background="@drawable/selector_primary_edittext"
                android:drawableEnd="@drawable/ic_search"
                android:drawableTint="@color/gray1_55"

                android:hint="@string/meidcine_search_hint"
                android:textColorHint="@color/gray4_CB"
                android:textSize="14sp"
                android:textColor="@color/black1_20"
                android:fontFamily="@font/pretendard_medium"
                android:maxLines="1"
                android:inputType="text"/>

            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/selected_view"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginBottom="14dp"
                android:paddingStart="@dimen/side_margin"
                android:clipToPadding="false"
                android:orientation="horizontal"/>

        </LinearLayout>
        <!-- Layout End 상단바 레이아웃 -->

        <LinearLayout
            android:id="@+id/search_result_layout"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginTop="12dp"
            android:paddingStart="@dimen/side_margin"
            android:paddingEnd="@dimen/side_margin"
            android:clipToPadding="false"
            android:background="@color/white"
            android:orientation="vertical">

            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/search_result_view"
                android:layout_width="match_parent"
                android:layout_height="0dp"
                android:layout_weight="1"
                />

            <!-- Layout 다음 단계 버튼 -->
            <Button
                android:id="@+id/select_btn"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_gravity="bottom"
                android:layout_marginTop="16dp"
                android:paddingTop="15dp"
                android:paddingBottom="15dp"
                android:layout_marginBottom="@dimen/btn_bottom_margin"
                app:layout_constraintBottom_toBottomOf="parent"
                android:visibility="gone"
                style="?attr/borderlessButtonStyle"
                android:background="@drawable/selector_btn"
                android:foreground="?attr/selectableItemBackground"

                android:clickable="true"
                android:orientation="horizontal"
                android:gravity="center"

                android:text="선택 완료"
                android:textSize="18sp"
                android:textColor="@color/white"
                android:fontFamily="@font/pretendard_bold"/>
        </LinearLayout>
    </LinearLayout>
</layout>

 


MedicineFilterAdapter 작성

검색 결과 화면의 RecyclerView와 연결되는 Adapter이다. 해당 어댑터가 사용자의 입력 값을 포함하는 약품 이름들을 RecyclerView에 출력해주는 역할을 한다.

 

코드가 좀 길어서 중요한 부분만 작성하고 전체 코드는 아래에 첨부하겠다.

 

Filterable 인터페이스 구현

사용자의 입력 값을 받아서 필터링을 해줄 핵심 인터페이스이다.

 

먼저 Adapter에서 해당 인터페이스를 Implements 한다.

class MedicineFilterAdapter(
    medicineList: MedicineList
) : RecyclerView.Adapter<MedicineFilterAdapter.ViewHolder>(), Filterable {
	/*...*/
}

 

그 후, 해당 인터페이스의 getFilter() 메소드구현해주면 된다.

 

    override fun getFilter(): Filter {
        return object : Filter() {
            override fun performFiltering(p0: CharSequence?): FilterResults {
                userInput = "$p0" // 사용자가 입력한 값
				
                // 필터링 된 결과 값을 담을 리스트
                filteredList =
                    if (userInput.isEmpty()) arrayListOf()
                    else {
                        val filteringList = ArrayList<Medicine>()
                        for (item in unFilteredList) { // unFilteredList : 전체 약품가 담긴 리스트
                        	// 사용자의 입력 값이 약품 이름에 포함되어 있으면 filterlingList에 추가한다.
                            if (item.itemName.contains(userInput)) filteringList.add(item)
                        }
                        filteringList
                    }
				
                // 필터링 결과를 FilterResult()에 저장한다.
                val filterResults = FilterResults() 
                filterResults.values = filteredList

                return filterResults
            }

            @SuppressLint("NotifyDataSetChanged")
            override fun publishResults(p0: CharSequence?, results: FilterResults?) {
                filteredList = results?.values as List<Medicine>
                notifyDataSetChanged() // 필터링 된 결과가 갱신 되었음을 알려준다.
            }
        }
    }

 

전체 코드

더보기
class MedicineFilterAdapter(
    medicineList: MedicineList
) : RecyclerView.Adapter<MedicineFilterAdapter.ViewHolder>(), Filterable {
    interface OnItemClickListener {
        fun onItemClicked(position: Int, data:String)
    }

    fun setOnItemClickListener(listner:OnItemClickListener){
        this.itemClickListener = listner
    }

    private lateinit var itemClickListener: OnItemClickListener
    private var filteredList    : List<Medicine> = emptyList()
    private val unFilteredList  : List<Medicine> = medicineList.medicines
    private var userInput:String = ""

    /**
     * ViewHolder를 생성하는 메소드
     */
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.sub_search_result_item, parent, false)

        return ViewHolder(view)
    }

    /**
     * ViewHolder에 데이터를 바인딩해주는 메소드
     * 생성자에서 전달 받은 medicineList의 데이터를 Position에 맞게 ViewHolder에 할당
     */
    @SuppressLint("SetTextI18n")
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.medicineTitle.apply {
            // 약품 이름 앞에 전문/일반 약품 표시
            val medicine = filteredList[position]
            when (medicine.type) {
                "전문의약품" -> {
                    text = "[전문] " + medicine.itemName
                    setForegroundColor(resources.getColor(R.color.detail_point), 0, 4)
                }
                "일반의약품" -> {
                    text = "[일반] " + medicine.itemName
                    setForegroundColor(resources.getColor(R.color.primary_normal), 0, 4)
                }
                else -> {
                    text = "[기타] " + medicine.itemName
                    setForegroundColor(resources.getColor(R.color.gray4_CB), 0, 4)
                }
            }

            // 검색어와 일치하는 단어 하이라이팅
            val matchedIdx = text.indexOf(userInput, 0, true)
            if(matchedIdx >= 0){
                setForegroundColor(resources.getColor(R.color.primary_normal), matchedIdx, matchedIdx + userInput.length)
            }
        }

        holder.layout.setOnClickListener {
            if(!::itemClickListener.isInitialized) return@setOnClickListener

            itemClickListener.onItemClicked(position, "${holder.medicineTitle.text}")
        }
    }

    override fun getItemCount() = filteredList.size

    override fun getFilter(): Filter {
        return object : Filter() {
            override fun performFiltering(p0: CharSequence?): FilterResults {
                userInput = "$p0"

                filteredList =
                    if (userInput.isEmpty()) arrayListOf()
                    else {
                        val filteringList = ArrayList<Medicine>()
                        for (item in unFilteredList) {
                            if (item.itemName.contains(userInput)) filteringList.add(item)
                        }
                        filteringList
                    }

                val filterResults = FilterResults()
                filterResults.values = filteredList

                return filterResults
            }

            @SuppressLint("NotifyDataSetChanged")
            override fun publishResults(p0: CharSequence?, results: FilterResults?) {
                filteredList = results?.values as List<Medicine>
                notifyDataSetChanged()
            }
        }
    }



    inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val layout:LinearLayout = itemView.findViewById(R.id.search_medicine_layout)
        val medicineTitle: TextView = itemView.findViewById(R.id.search_result_item)
    }
}

 


RecyclerView 초기화

어댑터 작성이 완료 되었으니, Fragment (또는 Activity) 에서 RecyclerView를 초기화 해준다.

 

먼저 어댑터생성한다.

 

Adapter의 매개변수로 넘겨주는 medicineList에 전체 약품 리스트가 들어있어야 한다

 

리스트는 서버에서 미리 요청을 해오거나 내부 DB에 저장된 값을 불러오거나 하면 된다.

medicineFilterAdapter = MedicineFilterAdapter(medicineList).apply {
	setOnItemClickListener(medicineItemClickListener)
}

 

RecyclerView를 초기화하면서 어댑터할당해준다.

bind.searchResultView.apply {
	adapter = medicineFilterAdapter
	setHasFixedSize(true)
	layoutManager = LinearLayoutManager(requireContext())
}
  • setHasFixedSize는 RecyclerView의 크기를 고정하겠다는 뜻이다.
  • RecyclerView 특성상 뷰 안의 아이템 개수가 매번 바뀌므로 크기가 달라질 수 있는데, 이때마다 크기 측정을 하기 때문에 성능 저하가 발생할 수 있다.
  • 특별한 경우가 아니라면 크기는 고정하는 게 좋을 듯하다.

 


TextWatcher 작성

Fragment (또는 Activity) 에서 사용자가 입력한 값을 가져올 TextWatcher를 생성한다.

 

사용자의 입력을 위에서 구현했던 Adapter의 filter 메소드로 전달해준다.

    /**
     * 검색어 입력 체크 TextWatcher
     */
    private val filterTextWatcher = object : TextWatcher{
        override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {}
        override fun afterTextChanged(p0: Editable?) {}

        override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
            if(!::medicineFilterAdapter.isInitialized) return
            medicineFilterAdapter.filter.filter(p0)
        }
    }

 


 

실행 결과