You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android平台无需第三方库实现WhatsApp风格表情布局问询

嘿,这个需求完全可以用Android原生组件搞定,不用依赖任何第三方库!我帮不少开发者实现过类似WhatsApp的表情布局,咱们一步步来拆解实现:

一、先准备表情的数据结构和资源

首先得把表情的信息结构化,方便后续分类和展示。我们可以定义一个数据类存储表情的核心信息,同时按分类区分(比如默认、开心、爱心这些):

// 表情数据类
data class Emoji(
    val resId: Int, // 本地表情资源ID
    val unicode: String, // 对应Unicode字符串,用于插入输入框
    val category: EmojiCategory // 所属分类
)

// 表情分类枚举
enum class EmojiCategory {
    DEFAULT, LAUGH, LOVE, ANGRY, SAD // 可根据需求扩展更多分类
}

接下来把你准备好的表情资源(比如drawable里的表情图片)按分类整理成列表,比如:

private fun getEmojisByCategory(category: EmojiCategory): List<Emoji> {
    return when(category) {
        EmojiCategory.DEFAULT -> listOf(
            Emoji(R.drawable.emoji_smile, "😀", EmojiCategory.DEFAULT),
            Emoji(R.drawable.emoji_laugh, "😂", EmojiCategory.DEFAULT),
            // 更多默认表情...
        )
        EmojiCategory.LOVE -> listOf(
            Emoji(R.drawable.emoji_heart, "❤️", EmojiCategory.LOVE),
            // 更多爱心类表情...
        )
        // 其他分类的表情列表...
    }
}

二、构建表情面板的整体布局

WhatsApp的表情面板是底部弹出的,包含分类标签栏、分页表情区域和页面指示器。我们用AndroidX的BottomSheetBehavior实现底部弹出效果,搭配ViewPager2做分页,RecyclerView做网格展示:

<androidx.coordinatorlayout.widget.CoordinatorLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <!-- 主内容区域:聊天记录+输入框 -->
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">

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

        <!-- 输入操作栏 -->
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            android:padding="8dp">

            <ImageView
                android:id="@+id/emoji_btn"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:src="@drawable/ic_emoji"/>

            <EditText
                android:id="@+id/input_edit_text"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:hint="输入消息"/>

            <Button
                android:id="@+id/send_btn"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="发送"/>
        </LinearLayout>
    </LinearLayout>

    <!-- 表情面板(底部弹出) -->
    <LinearLayout
        android:id="@+id/emoji_panel"
        android:layout_width="match_parent"
        android:layout_height="250dp"
        android:orientation="vertical"
        app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">

        <!-- 分类标签栏 -->
        <HorizontalScrollView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:scrollbars="none">

            <LinearLayout
                android:id="@+id/emoji_category_container"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                android:padding="8dp"/>
        </HorizontalScrollView>

        <!-- 表情分页区域 -->
        <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/emoji_view_pager"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"/>

        <!-- 页面指示器 -->
        <LinearLayout
            android:id="@+id/indicator_container"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            android:gravity="center"
            android:padding="4dp"/>
    </LinearLayout>
</LinearLayout>

三、实现分类标签的切换逻辑

分类标签需要动态生成,点击时切换对应的表情页面,同时更新选中状态(比如文字变色、下划线):

private fun initCategoryTabs() {
    val categories = EmojiCategory.values()
    categories.forEach { category ->
        val tab = TextView(this)
        tab.text = when(category) {
            EmojiCategory.DEFAULT -> "默认"
            EmojiCategory.LOVE -> "爱心"
            // 给分类设置友好名称
            else -> category.name
        }
        tab.textSize = 14f
        tab.setPadding(16, 8, 16, 8)
        tab.setOnClickListener {
            val targetPosition = categories.indexOf(category)
            emoji_view_pager.currentItem = targetPosition
            updateTabSelectedState(tab)
            updateIndicatorState(targetPosition)
        }
        emoji_category_container.addView(tab)
    }
    // 默认选中第一个标签
    updateTabSelectedState(emoji_category_container.getChildAt(0) as TextView)
}

// 更新标签选中状态
private fun updateTabSelectedState(selectedTab: TextView) {
    for (i in 0 until emoji_category_container.childCount) {
        val tab = emoji_category_container.getChildAt(i) as TextView
        tab.setTextColor(
            if (tab == selectedTab) ContextCompat.getColor(this, R.color.blue)
            else ContextCompat.getColor(this, R.color.gray)
        )
        tab.paint.isUnderlineText = tab == selectedTab
    }
}

四、实现表情网格的分页展示

每个分类对应一个Fragment,里面用RecyclerView展示网格表情。我们用FragmentStateAdapter给ViewPager2提供页面:

1. 表情页面Fragment

class EmojiPageFragment : Fragment() {
    private lateinit var emojiList: List<Emoji>
    private var onEmojiClick: ((Emoji) -> Unit)? = null

    companion object {
        private const val ARG_EMOJIS = "emojis"
        fun newInstance(emojis: List<Emoji>) = EmojiPageFragment().apply {
            arguments = Bundle().apply {
                putParcelableArrayList(ARG_EMOJIS, ArrayList(emojis))
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        emojiList = arguments?.getParcelableArrayList(ARG_EMOJIS) ?: emptyList()
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val view = inflater.inflate(R.layout.fragment_emoji_page, container, false)
        val recyclerView = view.findViewById<RecyclerView>(R.id.emoji_recycler)
        // 设置网格布局,每行显示8个表情(和WhatsApp一致)
        recyclerView.layoutManager = GridLayoutManager(context, 8)
        recyclerView.adapter = EmojiAdapter(emojiList) { emoji ->
            onEmojiClick?.invoke(emoji)
        }
        return view
    }

    fun setOnEmojiClickListener(listener: (Emoji) -> Unit) {
        onEmojiClick = listener
    }
}

对应的fragment_emoji_page.xml就是一个简单的RecyclerView:

<androidx.recyclerview.widget.RecyclerView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/emoji_recycler"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="8dp"/>

2. 表情列表Adapter

class EmojiAdapter(
    private val emojiList: List<Emoji>,
    private val onItemClick: (Emoji) -> Unit
) : RecyclerView.Adapter<EmojiAdapter.EmojiViewHolder>() {

    inner class EmojiViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        val emojiIv: ImageView = itemView.findViewById(R.id.emoji_image)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EmojiViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_emoji, parent, false)
        return EmojiViewHolder(view)
    }

    override fun onBindViewHolder(holder: EmojiViewHolder, position: Int) {
        val emoji = emojiList[position]
        holder.emojiIv.setImageResource(emoji.resId)
        holder.itemView.setOnClickListener { onItemClick(emoji) }
    }

    override fun getItemCount() = emojiList.size
}

对应的item_emoji.xml:

<ImageView
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/emoji_image"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:padding="4dp"
    android:scaleType="centerInside"/>

3. 关联ViewPager2和Adapter

在主Activity中设置ViewPager2的适配器,并监听页面切换:

private fun initEmojiPager() {
    val categories = EmojiCategory.values()
    emoji_view_pager.adapter = object : FragmentStateAdapter(this) {
        override fun getItemCount() = categories.size

        override fun createFragment(position: Int): Fragment {
            val category = categories[position]
            val emojis = getEmojisByCategory(category)
            return EmojiPageFragment.newInstance(emojis).apply {
                setOnEmojiClickListener { emoji ->
                    insertEmojiIntoInput(emoji)
                }
            }
        }
    }

    // 页面切换时同步更新标签和指示器
    emoji_view_pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
        override fun onPageSelected(position: Int) {
            super.onPageSelected(position)
            val selectedTab = emoji_category_container.getChildAt(position) as TextView
            updateTabSelectedState(selectedTab)
            updateIndicatorState(position)
        }
    })
}

五、处理表情点击插入输入框的逻辑

当用户点击表情时,把对应的Unicode字符插入到输入框的光标位置:

private fun insertEmojiIntoInput(emoji: Emoji) {
    val inputEt = findViewById<EditText>(R.id.input_edit_text)
    val cursorPos = inputEt.selectionStart
    val originalText = inputEt.text.toString()
    // 拼接新文本
    val newText = originalText.substring(0, cursorPos) + emoji.unicode + originalText.substring(cursorPos)
    inputEt.setText(newText)
    // 光标移动到插入后的位置
    inputEt.setSelection(cursorPos + emoji.unicode.length)
}

六、添加页面指示器

为了更贴近WhatsApp的体验,我们给ViewPager2添加底部圆点指示器:

private fun initIndicator() {
    val categories = EmojiCategory.values()
    categories.forEach {
        val dot = View(this)
        val params = LinearLayout.LayoutParams(8, 8)
        params.setMargins(4, 0, 4, 0)
        dot.layoutParams = params
        dot.setBackgroundResource(R.drawable.indicator_dot_unselected)
        indicator_container.addView(dot)
    }
    // 默认选中第一个圆点
    updateIndicatorState(0)
}

// 更新指示器选中状态
private fun updateIndicatorState(selectedPosition: Int) {
    for (i in 0 until indicator_container.childCount) {
        val dot = indicator_container.getChildAt(i)
        dot.setBackgroundResource(
            if (i == selectedPosition) R.drawable.indicator_dot_selected
            else R.drawable.indicator_dot_unselected
        )
    }
}

需要两个drawable文件:

  • indicator_dot_selected.xml(选中状态):
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="@color/blue"/>
</shape>
  • indicator_dot_unselected.xml(未选中状态):
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="oval">
    <solid android:color="@color/light_gray"/>
</shape>

七、优化交互体验

最后加上表情面板的显示隐藏逻辑,点击表情按钮切换状态,点击输入框自动隐藏面板:

private fun initBottomSheet() {
    val bottomSheetBehavior = BottomSheetBehavior.from(emoji_panel)
    // 默认隐藏面板
    bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN

    // 表情按钮切换面板显示
    emoji_btn.setOnClickListener {
        bottomSheetBehavior.state = if (bottomSheetBehavior.state == BottomSheetBehavior.STATE_HIDDEN) {
            BottomSheetBehavior.STATE_EXPANDED
        } else {
            BottomSheetBehavior.STATE_HIDDEN
        }
    }

    // 点击输入框隐藏面板
    input_edit_text.setOnClickListener {
        bottomSheetBehavior.state = BottomSheetBehavior.STATE_HIDDEN
    }
}

这样一套实现下来,完全用Android原生组件完成,没有任何第三方库依赖,效果和WhatsApp的表情布局基本一致。你可以根据自己的需求调整分类数量、每行表情个数、样式配色这些细节~

内容的提问来源于stack exchange,提问作者Tim

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.26 09:06:48