搜索框自定义

搜索框自定义

我们需要设计一个自定义的AppCompatEditText搜索框,具有以下功能:

  • 可以设置背景
  • 左边设置图标并且有点击事件
  • 默认搜索事件 (意思是? 可能是点击键盘上的搜索按钮触发搜索,或者调用外部搜索回调)
  • 右边可以定义搜索按钮 (可能是视图上的一个按钮,点击执行搜索)
  • 当有文字输入时,文字右边(搜索按钮的左边)出现删除图标,点击清除文字
  • 键盘右下角action设置成搜索按钮 (IME_ACTION_SEARCH)
  • 输入文字过滤表情符号

1. 自定义属性定义 (res/values/attrs.xml)

xml 复制代码
<declare-styleable name="SearchEditText">
    <!-- 左侧图标 -->
    <attr name="leftIcon" format="reference" />
    <attr name="leftIconVisible" format="boolean" />
    <!-- 右侧搜索按钮文字/图标 -->
    <attr name="searchButtonText" format="string" />
    <attr name="searchButtonIcon" format="reference" />
    <attr name="searchButtonBackground" format="reference" />
    <!-- 清除图标 -->
    <attr name="clearIcon" format="reference" />
    <!-- EditText 相关 -->
    <attr name="android:hint" />
    <attr name="android:textSize" />
    <attr name="android:textColor" />
    <attr name="android:textColorHint" />
    <attr name="android:inputType" />
    <attr name="android:maxLines" />
    <!-- 整体背景 -->
    <attr name="android:background" />
</declare-styleable>

2. SearchEditText 组件代码 (Kotlin)

kotlin 复制代码
package com.example.testabcdemo

import android.content.Context
import android.content.res.TypedArray
import android.text.Editable
import android.text.InputFilter
import android.text.Spanned
import android.text.TextWatcher
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.content.ContextCompat
import java.util.regex.Pattern


class SearchEditText @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {

    private val leftIcon: ImageView
    private val editText: AppCompatEditText
    private val clearIcon: ImageView
    private val searchButton: TextView

    private var onSearchClickListener: ((String) -> Unit)? = null
    private var onLeftIconClickListener: (() -> Unit)? = null

    init {
        orientation = HORIZONTAL
        gravity = android.view.Gravity.CENTER_VERTICAL

        // 加载布局
        LayoutInflater.from(context).inflate(R.layout.view_search_edittext, this, true)
        leftIcon = findViewById(R.id.iv_left_icon)
        editText = findViewById(R.id.et_search)
        clearIcon = findViewById(R.id.iv_clear)
        searchButton = findViewById(R.id.btn_search)

        // 解析自定义属性
        val ta = context.obtainStyledAttributes(attrs, R.styleable.SearchEditText)
        setupAttributes(ta)
        ta.recycle()

        // 设置键盘 action 为搜索
        editText.imeOptions = EditorInfo.IME_ACTION_SEARCH
        editText.inputType = EditorInfo.TYPE_CLASS_TEXT
        editText.maxLines = 1
        editText.isSingleLine = true

        // 过滤表情符号
        editText.filters = arrayOf(EmojiInputFilter())

        // 文本变化监听,控制清除按钮显隐
        editText.addTextChangedListener(object : 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?) {
                clearIcon.visibility = if (s.isNullOrEmpty()) View.GONE else View.VISIBLE
            }
        })

        // 清除按钮点击事件
        clearIcon.setOnClickListener {
            editText.text?.clear()
            // 清除后自动获取焦点并弹出键盘(可选)
            editText.requestFocus()
            showSoftInput()
        }

        // 搜索按钮点击事件
        searchButton.setOnClickListener {
            performSearch()
        }

        // 键盘搜索按钮点击事件
        editText.setOnEditorActionListener { _, actionId, _ ->
            if (actionId == EditorInfo.IME_ACTION_SEARCH) {
                performSearch()
                true
            } else false
        }

        // 左侧图标默认点击事件(可通过外部 setter 覆盖)
        leftIcon.setOnClickListener {
            onLeftIconClickListener?.invoke()
        }

        // 初始状态:无文字时清除按钮隐藏
        clearIcon.visibility = if (editText.text.isNullOrEmpty()) View.GONE else View.VISIBLE
    }

    /**
     * 解析自定义属性
     */
    private fun setupAttributes(ta: TypedArray) {
        // 左侧图标
        val leftIconRes = ta.getResourceId(R.styleable.SearchEditText_leftIcon, 0)
        if (leftIconRes != 0) {
            leftIcon.setImageResource(leftIconRes)
        }
        val leftIconVisible = ta.getBoolean(R.styleable.SearchEditText_leftIconVisible, true)
        leftIcon.visibility = if (leftIconVisible) View.VISIBLE else View.GONE

        // 清除图标
        val clearIconRes = ta.getResourceId(R.styleable.SearchEditText_clearIcon, 0)
        if (clearIconRes != 0) {
            clearIcon.setImageResource(clearIconRes)
        } else {
            // 默认清除图标
            clearIcon.setImageDrawable(
                ContextCompat.getDrawable(context, android.R.drawable.ic_menu_close_clear_cancel)
            )
        }

        // 右侧搜索按钮文字/图标
        val searchButtonText = ta.getString(R.styleable.SearchEditText_searchButtonText)
        val searchButtonIcon = ta.getResourceId(R.styleable.SearchEditText_searchButtonIcon, 0)
        if (searchButtonIcon != 0) {
            searchButton.setCompoundDrawablesWithIntrinsicBounds(0, 0, searchButtonIcon, 0)
            searchButton.text = ""
        } else {
            searchButton.text = searchButtonText ?: "搜索"
        }

        val searchButtonBg = ta.getResourceId(R.styleable.SearchEditText_searchButtonBackground, 0)
        if (searchButtonBg != 0) {
            searchButton.setBackgroundResource(searchButtonBg)
        } else {
            // 默认背景
            searchButton.setBackgroundResource(android.R.drawable.editbox_background)
        }

        // EditText 通用属性
        val hint = ta.getString(R.styleable.SearchEditText_android_hint)
        editText.hint = hint

        val textSize = ta.getDimensionPixelSize(R.styleable.SearchEditText_android_textSize, -1)
        if (textSize != -1) {
            editText.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, textSize.toFloat())
        }

        val textColor = ta.getColor(R.styleable.SearchEditText_android_textColor, -1)
        if (textColor != -1) {
            editText.setTextColor(textColor)
        }

        val textColorHint = ta.getColor(R.styleable.SearchEditText_android_textColorHint, -1)
        if (textColorHint != -1) {
            editText.setHintTextColor(textColorHint)
        }

        val inputType = ta.getInt(R.styleable.SearchEditText_android_inputType, -1)
        if (inputType != -1) {
            editText.inputType = inputType
        }

        val maxLines = ta.getInt(R.styleable.SearchEditText_android_maxLines, 1)
        editText.maxLines = maxLines

        // 整体背景
        val background = ta.getDrawable(R.styleable.SearchEditText_android_background)
        if (background != null) {
            this.background = background
        }
    }

    /**
     * 执行搜索:隐藏键盘并回调
     */
    private fun performSearch() {
        val query = editText.text.toString()
        onSearchClickListener?.invoke(query)
        hideSoftInput()
    }

    /**
     * 隐藏软键盘
     */
    private fun hideSoftInput() {
        val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        imm.hideSoftInputFromWindow(windowToken, 0)
    }

    /**
     * 显示软键盘(可选)
     */
    private fun showSoftInput() {
        val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
        imm.showSoftInput(editText, InputMethodManager.SHOW_IMPLICIT)
    }

    // ========== 公开方法 ==========
    fun setOnSearchClickListener(listener: (String) -> Unit) {
        onSearchClickListener = listener
    }

    fun setOnLeftIconClickListener(listener: () -> Unit) {
        onLeftIconClickListener = listener
    }

    fun getText(): String = editText.text.toString()

    fun setText(text: String) {
        editText.setText(text)
        editText.setSelection(text.length)
    }

    fun setHint(hint: String) {
        editText.hint = hint
    }

    fun setLeftIcon(iconRes: Int) {
        leftIcon.setImageResource(iconRes)
        leftIcon.visibility = View.VISIBLE
    }

    fun setLeftIconVisible(visible: Boolean) {
        leftIcon.visibility = if (visible) View.VISIBLE else View.GONE
    }

    fun setSearchButtonText(text: String) {
        searchButton.text = text
    }

    fun setSearchButtonIcon(iconRes: Int) {
        searchButton.setCompoundDrawablesWithIntrinsicBounds(0, 0, iconRes, 0)
        searchButton.text = ""
    }

    /**
     * 过滤表情符号的 InputFilter
     */
    private class EmojiInputFilter : InputFilter {
        // 简单的 Emoji 正则(覆盖大部分常见表情)
        private val emojiPattern = Pattern.compile(
            "[\uD83C-\uDBFF\uDC00-\uDFFF]|" +         // 代理对
                    "[\u2600-\u27BF]|" +                     // 杂项符号
                    "[\uE000-\uF8FF]|" +                     // 私有区域
                    "[\uFE00-\uFE0F]|" +                     // 变体选择器
                    "[\u1F300-\u1F5FF]|" +                   // 杂项符号和象形文字
                    "[\u1F600-\u1F64F]|" +                   // 表情符号
                    "[\u1F680-\u1F6FF]|" +                   // 交通和地图符号
                    "[\u1F700-\u1F77F]|" +                   // 炼金术符号
                    "[\u1F780-\u1F7FF]|" +                   // 几何形状扩展
                    "[\u1F800-\u1F8FF]|" +                   // 箭头补充
                    "[\u1F900-\u1F9FF]|" +                   // 补充符号和象形文字
                    "[\u1FA00-\u1FA6F]|" +                   // 象棋符号
                    "[\u1FA70-\u1FAFF]"                      // 其他补充符号
        )

        override fun filter(
            source: CharSequence,
            start: Int,
            end: Int,
            dest: Spanned?,
            dstart: Int,
            dend: Int
        ): CharSequence? {
            val builder = StringBuilder()
            for (i in start until end) {
                val c = source[i]
                // 检查是否为 Emoji
                if (!isEmoji(c)) {
                    builder.append(c)
                }
            }
            return if (builder.length == end - start) null else builder.toString()
        }

        private fun isEmoji(char: Char): Boolean {
            // 先判断代理对(高位代理或低位代理)
            if (Character.isHighSurrogate(char) || Character.isLowSurrogate(char)) {
                return true
            }
            // 用正则匹配
            return emojiPattern.matcher(char.toString()).matches()
        }
    }
}

3. 布局文件 (res/layout/view_search_edittext.xml)

ini 复制代码
<?xml version="1.0" encoding="utf-8"?>
<merge 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"
    tools:parentTag="android.widget.LinearLayout">

    <!-- 左侧图标 -->
    <ImageView
        android:id="@+id/iv_left_icon"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:paddingStart="12dp"
        android:paddingEnd="8dp"
        android:src="@drawable/baseline_search_24"
        android:visibility="visible" />

    <!-- 输入框 -->
    <androidx.appcompat.widget.AppCompatEditText
        android:id="@+id/et_search"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:background="@null"
        android:paddingVertical="10dp"
        android:paddingStart="0dp"
        android:paddingEnd="8dp"
        android:singleLine="true"
        android:textSize="14sp" />

    <!-- 清除按钮 -->
    <ImageView
        android:id="@+id/iv_clear"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:paddingStart="4dp"
        android:paddingEnd="4dp"
        android:src="@drawable/baseline_clear_24"
        android:visibility="gone"
        android:contentDescription="clear" />

    <!-- 搜索按钮 -->
    <TextView
        android:id="@+id/btn_search"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:background="?selectableItemBackground"
        android:gravity="center"
        android:paddingStart="12dp"
        android:paddingEnd="12dp"
        android:layout_margin="1dp"
        android:text="搜索"
        android:textColor="@android:color/white"
        android:textSize="14sp" />

</merge>

4. 布局文件 (res/drawable/bg_search_button.xml)

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <corners android:radius="4dp" />
    <gradient
        android:angle="180"
        android:endColor="#ffff2424"
        android:startColor="#ffff8298" />

</shape>

5. 布局文件 (res/drawable/bg_rounded_edittext.xml)

ini 复制代码
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <stroke
        android:width="1dp"
        android:color="#ffe0e0e0" />
    <solid android:color="#fffefefe" />
    <corners
        android:bottomLeftRadius="4dp"
        android:bottomRightRadius="4dp"
        android:topLeftRadius="4dp"
        android:topRightRadius="4dp" />
</shape>

6. 在布局中使用

ini 复制代码
<com.example.testabcdemo.SearchEditText
    android:id="@+id/search_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="16dp"
    android:background="@drawable/bg_rounded_edittext"
    android:hint="请输入关键词"
    android:textColor="@android:color/black"
    android:textColorHint="#888888"
    android:textSize="14sp"
    app:clearIcon="@drawable/baseline_clear_24"
    app:layout_constraintTop_toTopOf="parent"
    app:leftIcon="@drawable/baseline_search_24"
    app:leftIconVisible="true"
    app:searchButtonBackground="@drawable/bg_search_button"
    app:searchButtonText="搜索" />

7. 在 Activity/Fragment 中使用

kotlin 复制代码
val searchEditText = findViewById<SearchEditText>(R.id.search_view)
searchEditText.setOnSearchClickListener { query ->
    // 执行搜索逻辑
    Toast.makeText(this, "搜索: $query", Toast.LENGTH_SHORT).show()
}
searchEditText.setOnLeftIconClickListener {
    // 左侧图标点击事件
    Toast.makeText(this, "左侧图标被点击", Toast.LENGTH_SHORT).show()
}

8. 效果展示

相关推荐
私人珍藏库7 分钟前
【Android】Soul v5.86.0 内置模块版
android·app·工具·软件·多功能
千里马学框架30 分钟前
aosp新增窗口层级 Type 完整实现方案(有源码)-wms需求和面试题
android·智能手机·架构·wms·aaos·车机
峥嵘life6 小时前
Android 蓝牙设备连接广播详解-2026
android·python·学习
MusingByte9 小时前
别再裸用 Claude Code 了!安卓开发者必装 13 个官方推荐插件,效率翻 3 倍省 70% token
android
_李小白9 小时前
【android opencv学习笔记】Day 29: 滤波算法之Sobel 边缘检测
android·opencv·学习
Dxy123931021610 小时前
Python 操作 MySQL 事务:从入门到避坑
android·python·mysql
峥嵘life11 小时前
Android getprop 属性限制详解:User 版本属性获取问题分析
android·开发语言·python·学习
一航jason12 小时前
Speed Tools:一套低侵入的 Android 插件化 + 动态换肤 + 字体切换框架
android·插件化·组件化·换肤
李斯维13 小时前
Jetpack 可观察数据容器 LiveData 的入门与基础使用
android·android jetpack
问心无愧051314 小时前
ctf show web入门261
android·前端·笔记