버튼 수집상

[안드로이드] 검색어 자동완성 커스텀 뷰 만들기 Custom Auto Complete View 본문

TIL - 안드로이드

[안드로이드] 검색어 자동완성 커스텀 뷰 만들기 Custom Auto Complete View

cocokaribou 2024. 3. 28. 13:54

 

편한가계부 클론코딩중

 

위처럼 모서리가 둥글고 살짝 그림자가 지는 자동완성 검색어 UI를 그려주기 위해 커스텀 뷰를 만들었다.

 

뷰바인딩 세팅하기

build.gradle.kts(:app)

plugins {
    id("com.android.application")
    id("org.jetbrains.kotlin.android")
}

android {
    // 생략..
    buildFeatures {
        viewBinding = true
    }
}

자동완성 커스텀뷰의 베이스 뷰 xml

layout_auto_complete.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content">

    <!-- 자동완성 검색어를 나타내는 TextView가 addView 될 것-->
    <LinearLayout
        android:id="@+id/hint_holder"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@drawable/background_auto_complete_shadow"
        android:orientation="vertical"
        tools:layout_height="40dp" />

</androidx.constraintlayout.widget.ConstraintLayout>

 

자동완성 커스텀뷰의 베이스 뷰 배경 drawable

background_auto_complete_shadow.xml

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- Drop Shadow Stack -->
    <item>
        <shape>
            <padding
                android:bottom="1dp"
                android:left="1dp"
                android:right="1dp"
                android:top="1dp"/>
            <solid android:color="#07bbbbbb" />
            <corners android:radius="12dp" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="1dp"
                android:left="1dp"
                android:right="1dp"
                android:top="1dp"/>
            <solid android:color="#0fbbbbbb" />
            <corners android:radius="12dp" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="1dp"
                android:left="1dp"
                android:right="1dp" />
            <solid android:color="#1fbbbbbb" />
            <corners android:radius="12dp" />
        </shape>
    </item>
    <item>
        <shape>
            <padding
                android:bottom="1dp"
                android:left="1dp"
                android:right="1dp" />
            <solid android:color="#2fbbbbbb" />
            <corners android:radius="12dp" />
        </shape>
    </item>

    <!-- Background -->
    <item>
        <shape>
            <solid android:color="@android:color/white" />
            <corners android:radius="12dp" />
        </shape>
    </item>
</layer-list>

 

그림자 레이어를 쌓으면서 위 레이어에서부터 순서대로

넓은 영역 -> 좁은 영역

연한 색 -> 진한 색

으로 그려진다.

 

커스텀뷰 코드

AutoComplete.kt

import android.content.Context
import android.graphics.Color
import android.util.AttributeSet
import android.view.View
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import com.pionnet.accountbook.R
import com.pionnet.accountbook.databinding.LayoutAutoCompleteBinding
import com.pionnet.accountbook.utils.getSpannedColorText

/**
 * 자동완성 홀더
 *
 * TODO
 * 1. AutoCompleteListener 인터페이스 구현하기
 * 2. [setInput] 검색어 입력
 * 3. [setList] 자동완성 결과 리스트 입력
 */
class AutoComplete @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    def: Int = 0,
) : ConstraintLayout(context, attrs, def) {

    interface AutoCompleteListener {
        /* 선택한 자동완성 단어*/
        fun selectAutoComplete(input: String)
    }

    private lateinit var mListener: AutoCompleteListener

    fun setListener(listener: AutoCompleteListener) {
        mListener = listener
    }

    var binding: LayoutAutoCompleteBinding

    private var inputString = ""
    private var autoCompleteList = listOf<String>()

    // 디폴트 검색어 하이라이트 컬러
    private var mColor = Color.parseColor("#FF5046")
    // 디폴트 텍스트 사이즈
    private var mTextSize = 15f

    init {
        val view = inflate(context, R.layout.layout_auto_complete, this)
        binding = LayoutAutoCompleteBinding.bind(view)
    }

    // 검색어 입력이 실시간으로 감지되는 콜백에서 호출 (ex: TextWatcher 콜백)
    fun setInput(input: String) {
        inputString = input
    }

    // 자동완성 단어 리스트
    // 검색어 입력시 동시에 입력
    fun setList(input: List<String>) {
        autoCompleteList = input
        if (autoCompleteList.isEmpty()) {
            clearAutoComplete()
            return
        }
        binding.root.visibility = View.VISIBLE
        val matchingResult: List<String> = autoCompleteList.filter { it.contains(inputString) }
        if (matchingResult.isEmpty()) {
            clearAutoComplete()
            return
        }

        binding.hintHolder.visibility = View.VISIBLE
        matchingResult.forEach { match ->
            val matchingTextView = TextView(context, null, 0, R.style.AutoCompleteText).apply {
                text = match.getSpannedColorText(inputString, mColor, false)
                textSize = mTextSize
                setOnClickListener {
                    mListener.selectAutoComplete(match)
                    clearAutoComplete()
                }
            }
            binding.hintHolder.addView(matchingTextView)
        }

    }

    fun clearAutoComplete() {
        binding.root.visibility = View.GONE
        binding.hintHolder.removeAllViews()
    }

    fun setMatchingTextColor(color: Int) {
        mColor = color
    }

    fun setTextSize(dp: Float) {
        mTextSize = dp
    }
}

사용처

Activity/Fragment xml 예시

<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="match_parent">
    
    <!-- 검색어 입력창 -->
    <com.google.android.material.card.MaterialCardView
        android:id="@+id/search_form"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="10dp"
        android:backgroundTint="@color/background"
        android:elevation="0dp"
        app:cardCornerRadius="12dp"
        app:cardElevation="0dp"
        app:layout_constraintTop_toBottomOf="@id/back"
        app:strokeColor="#bbbbbb"
        app:strokeWidth="0.7dp">

        <!-- childview 생략 -->
    </com.google.android.material.card.MaterialCardView>
    
    <!-- 검색어 입력창 바로 아래에 자동완성 커스텀뷰 -->
    <com.test.android.views.AutoComplete
        android:id="@+id/autoComplete"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:visibility="gone"
        app:layout_constraintEnd_toEndOf="@id/search_form"
        app:layout_constraintStart_toStartOf="@id/search_form"
        app:layout_constraintTop_toBottomOf="@id/search_form"
        tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

검색어 입력창과 똑같은 너비로 잡고 싶어서 layout_width="0dp"로 설정한 후, Constraint로 start와 end를 잡아줬다.

 

사용례

Activity/Fragment 예시

/* 사용례 */
// 검색하는 경우
// 1. EditText에서 엔터 입력
// 2. AutoComplete 커스텀 뷰에서 자동완성 검색어 직접 선택
class SearchFragment : Fragment(R.layout.fragment_search), AutoComplete.AutoCompleteListener {
    // 프래그먼트 뷰바인딩 init
    lateinit var binding: FragmentSearchBinding
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        binding = FragmentSearchBinding.inflate(inflater)
        return binding.root
    }
    
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        initView()
    }
    
    @SuppressLint("ClickableViewAccessibility")
    private fun initView() = with(binding) {
        // AutoComplete 커스텀뷰 리스너 세팅
        autoComplete.setListener(this@SearchFragment)
        
        // EditText 검색어 입력창
        search.apply {
            // 키보드 노출하고 포커스 주는 유틸
            showKeyboard()

            // 검색어 입력값 실시간 검사 유틸
            addTextChangedListener(object : TextInputWatcher() {
                override fun onInputTextChanged(input: String) {
                    clearSearch.isVisible = input.isNotEmpty()
                    
                    if (input.isNotEmpty()) {
                        // 입력값이 있을 때마다 AutoComplete 뷰에 세팅
                        autoComplete.setInput(input)
                        autoComplete.setList(getAutoCompleteList())
                    } else {
                        autoComplete.clearAutoComplete()
                    }
                }
            })
            // EditText 엔터 리스너 유틸
            setOnKeyListener(object : EnterListener() {
                override fun onEnter() {
                    search()
                }
            })
        }
    }

    // 자동완성 검색어 결과 API
    private fun getAutoCompleteList(): List<String> {
        val input = binding.search.text
        
        // TODO API 구현
        
        return autoComplete(input)
    }

    // 자동완성 검색어에서 선택한 단어로 검색
    override fun selectAutoComplete(input: String) {
        binding.search.setText(input)
        search()
    }
    
    // 검색 API
    private fun search() {
    	// TODO API 구현
    }
}

코드에서 사용한 유틸 함수들

Utils.kt

// 전체중 일부 문자열에 텍스트 컬러 적용하기 유틸
// 예) "강조영역".getSpannedColorText("강조", Color.RED)
fun String.getSpannedColorText(changed: String, color: Int, bold: Boolean = false): Spannable {
    val sb = SpannableStringBuilder(this)
    val pair = getChangedIndex(this, changed)

    sb.setSpan(
        ForegroundColorSpan(color),
        pair.first,
        pair.second,
        Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
    )
    if (bold) {
        sb.setSpan(
            StyleSpan(Typeface.BOLD),
            pair.first,
            pair.second,
            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
        )
    }
    return sb
}

fun getChangedIndex(origin: String, changed: String): Pair<Int, Int> {
    if (origin.isEmpty()) return Pair(0, 0)

    var start = origin.indexOf(changed, 0)
    var end = start + changed.length
    if (start == -1) {
        start = 0
        end = origin.length
    }
    return Pair(start, end)
}

// 실시간 텍스트 입력 감지
abstract class TextInputWatcher : TextWatcher {
    override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}

    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}

    override fun afterTextChanged(s: Editable?) {
        onInputTextChanged(s.toString())
    }

    abstract fun onInputTextChanged(input: String)
}

// 엔터 입력감지
abstract class EnterListener : View.OnKeyListener {
    override fun onKey(v: View?, keyCode: Int, event: KeyEvent?): Boolean {
        if (keyCode == KeyEvent.KEYCODE_ENTER && event?.action == KeyEvent.ACTION_DOWN) {
            onEnter()
            return true
        }
        return false
    }

    abstract fun onEnter()
}

// 시스템 키보드 노출 & 포커스
fun EditText.showKeyboard() {
    requestFocus()

    val inputManager = context.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
    inputManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT)
}

보완

안드로이드에서 제공하는 AutoCompleteTextView에서 ArrayAdapter를 세팅하는 부분만 커스텀하게 꾸밀 수도 있을 것 같다.

 

public class CountriesActivity extends Activity {
      protected void onCreate(Bundle icicle) {
          super.onCreate(icicle);
          setContentView(R.layout.countries);
 
          ArrayAdapter<String> adapter = new ArrayAdapter<String>(this,
                  android.R.layout.simple_dropdown_item_1line, COUNTRIES);
          AutoCompleteTextView textView = (AutoCompleteTextView)
                  findViewById(R.id.countries_list);
          textView.setAdapter(adapter);
      }
 
      private static final String[] COUNTRIES = new String[] {
          "Belgium", "France", "Italy", "Germany", "Spain"
      };
  }

안드로이드 공식 문서에서 제공하는 샘플코드 (자바)

728x90