졸업 작품을 진행하면서 위와 같은 화면을 구현해야했다.
약품 리스트를 받아와서, 검색한 단어와 일치한 모든 약품들을 보여줘야했다. 추가로 일치하는 단어에는 하이라이팅까지 해줘야했다.
위의 화면에서 지켜져야 할 조건들은 아래와 같다.
- 전체 약품 리스트를 미리 갖고 있어야한다.
- 입력을 감지해서 실시간으로 결과를 출력할 것이기 때문에, 속도나 서버 부하 측면에서 매번 서버로 요청을 보낼 수 없다.
- 미리 전체 약품 리스트를 받아와서 변수로 저장해둔 후 사용해야한다.
- 일치하는 단어는 하이라이팅으로 표시해줘야 한다.
- 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)
}
}
실행 결과
'안드로이드 > 이론' 카테고리의 다른 글
[ 안드로이드 ] Retrofit2를 이용하여 서버와 통신하기 (1) - 안전하게 통신하기 (SafeApiCall) (0) | 2023.05.04 |
---|---|
[ 안드로이드 ] Room DB - Dao 사용법 (0) | 2023.04.08 |
[ 안드로이드 ] BottomSheetDialog 테두리 둥글게 설정하기 (0) | 2023.04.04 |
[ 안드로이드 ] - Selector를 이용하여 버튼, 체크박스 등에 클릭 효과 주기 (0) | 2023.03.29 |
[ 안드로이드 ] Handler를 통한 Viewmodel 변수 상태 체크하기 (0) | 2023.03.21 |