主要流程
前端emoji組件一般用在聊天輸入界面,點擊表情,整個輸入框被彈起,顯示emoji界面,點擊其它位置,emoji界面自動關閉,這其中有一些注意點:
1、點擊emoji,emoji界面從底部彈起,輸入框也要同步顯示
2、二次點擊emoji圖標,emoji界面關閉(你也可以切換鍵盤輸入)
3、點擊輸入框,鍵盤彈起,emoji界面關閉
4、點擊emoji界面的其它位置(不包含輸入框、emoji圖標),emoji界面自動關閉
我們先從佈局着手,一般情況下,聊天界面默認填充空間,輸入框按正常文檔流排列,當表情彈起時,由於輸入框是正常文檔流排列會佔位,聊天界面自適應
.container {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
.chat-box {
flex: 1;
overflow: auto;
}
.footer-box {
// 不設置高度,根據內部的footer-input-box高度而來
}
}
初始的時候,emoji的高度為0,一旦點擊表情按鈕後,恢復emoji界面的高度,我們可以通過動態切換類名實現,當然,方法有很多,你可以根據實際情況調整。
<div class="footer-box">
<!-- 輸入框盒子 -->
<div class="footer-input-box"></div>
<!-- emoji盒子 -->
<div ref="footerToolsRef" class="footer-tools" :class="[emojiOpen && 'footer-tools-show']">
<!-- 裏面是你的emoji組件 -->
<Emoji :open="emojiOpen" @select="onEmoji" @delete="onDelete" />
</div>
</div>
.footer-box {
// 省略其它...
.footer-tools {
border-top: 1px solid #cccccc;
height: 0px;
background-color: #f7f8fa;
}
.footer-tools-show {
height: 300px;
}
}
到這裏界面就寫好了,我們開始着手邏輯的編寫
這裏需要監聽兩個事件:
1、監聽視口高度,判斷鍵盤是否彈出
2、監聽全局點擊事件,判斷當前點擊是否在footer-tools內部
監聽視口高度的函數的主要功能:
● 輸入框獲取焦點,鍵盤彈起,關閉footer-tools界面
● 輸入框失焦,鍵盤收起,如果點擊的是emoji按鈕,彈出emoji界面
監聽全局點擊事件的主要功能:
● 判斷點擊的位置是否在footer-tools內部,如果在外部則關閉emoji的彈出
● 如果點擊的位置在輸入框,鍵盤彈起,此時也要關閉emoji的彈出
● 其它情況下,點擊emoji按鈕的彈出狀態互斥
export function useEmoji() {
// 默認視口高度,用於判斷鍵盤是否彈出
let initialViewportHeight = 0
// 鍵盤彈出狀態
let isKeyboardOpen = false
// 當前點擊的元素名
let targetName = ''
// emoji是否顯示,響應式變量
const emojiOpen = ref(false)
// 底部工具欄的dom,響應式變量
const footerToolsRef = ref(null)
/**
* 監聽視口變化,判斷鍵盤是否彈出
* 當視口高度變化時,判斷當前高度是否小於初始高度 - 150 , 如果小於,則鍵盤彈出,則隱藏表情彈窗
*
*/
const onViewportResize = () => {
const currentHeight = window.visualViewport?.height || window.innerHeight
// 當前高度小於初始高度 - 150,則鍵盤彈出
if (initialViewportHeight > 0 && currentHeight < initialViewportHeight - 150) {
// 鍵盤彈起時隱藏emoji界面
emojiOpen.value = false
// 開啓鍵盤彈起標識
isKeyboardOpen = true
}
else {
// 鍵盤未彈出,關閉標識
isKeyboardOpen = false
// 如果是點擊的emoji按鈕,則顯示emoji界面
// 因為鍵盤跟emoji的彈出是互斥的,如果鍵盤彈出,emoji必然是true,這裏直接取反即可
if (targetName === 'emoji-btn') {
emojiOpen.value = !emojiOpen.value
}
}
}
/**
* 全局點擊事件,判斷點擊的元素是否在底部工具欄內
* 如果點擊的元素不在底部工具欄內,則隱藏emoji工具欄
* 如果點擊的元素在底部工具欄內,則不做任何操作
* @param event 點擊事件
*/
const onClickOutside = (event: any) => {
// 存儲當前點擊的元素名
targetName = event.target.className
// 如果點擊emoji按鈕
if (event.target.className === 'emoji-btn') {
// 鍵盤未彈出,則彈出emoji界面
if (!isKeyboardOpen) {
return emojiOpen.value = !emojiOpen.value
}
return
}
// 如果點擊輸入框,則不做任何操作,因為鍵盤會彈出
if (event.target.className === 'van-field__control') {
return
}
// 通過dom方法contains判斷,如果當前點擊的元素不在 footer-tools 內,則關閉emoji界面
if (footerToolsRef.value && !footerToolsRef.value.contains(event.target)) {
// 執行相應邏輯,比如隱藏emoji工具欄,鍵盤無需關注,失焦會自動隱藏
emojiOpen.value = false
}
}
/**
* 初始化emoji工具欄
* 內部會監聽視口變化可全局點擊事件
* 內部監聽的觸發時機:
* 1、點擊屏幕,立即觸發onClickOutside
* 2、視口尺寸發生變化時,觸發onViewportResize
* 3、順序:onClickOutside > onViewportResize
*/
const initEmoji = () => {
// 初始化視口高度
initialViewportHeight = window.visualViewport?.height || window.innerHeight
// 監聽視口變化
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', onViewportResize)
}
// 通過 document 監聽到所有點擊事件
document.addEventListener('click', onClickOutside)
}
/**
* 組件卸載時取消監聽視口變化和全局點擊事件
*/
const unmountEmoji = () => {
document.removeEventListener('click', onClickOutside)
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', onViewportResize)
}
}
return {
initEmoji,
unmountEmoji,
emojiOpen,
footerToolsRef,
}
}
使用hook
<template>
<div>
<!-- 省略其它代碼 -->
<div class="footer-box">
<!-- 輸入框盒子 -->
<div class="footer-input-box"></div>
<!-- emoji盒子 -->
<div ref="footerToolsRef" class="footer-tools" :class="[emojiOpen && 'footer-tools-show']">
<!-- 裏面是你的emoji組件,這個組件其實就是emoji數組遍歷出的界面,返回點擊事件和刪除事件 -->
<Emoji :open="emojiOpen" @select="onEmoji" @delete="onDelete" />
</div>
</div>
</div>
</template>
<script setup lang='ts'>
import { useEmoji } from '@/hook/useEmoji'
const { emojiOpen, footerToolsRef, initEmoji, unmountEmoji } = useEmoji()
// 初始化emoji-hook
onMounted(() => {
initEmoji()
})
// 卸載時移除事件監聽
onBeforeUnmount(() => {
unmountEmoji()
})
</script>
完整示例
emoji數據
export const emojiData = [
{ name: 'smile', emoji: '😀', category: 'faces' },
{ name: 'smiley', emoji: '😄', category: 'faces' },
]
emoji組件
<script setup lang="ts">
import { emojiData } from '@/utils/emojiData'
const props = withDefaults(defineProps<Props>(), {
open: false,
columns: 8, // 默認每行 8 個
gap: 6, // 默認行列間距
size: 20, // 默認字體大小
})
const emit = defineEmits(['select', 'delete'])
interface Props {
open?: boolean
columns?: number
gap?: number
size?: number
}
const gap = computed(() => `${props.gap}px`)
const size = computed(() => `${props.size}px`)
// 是否鼠標移入
const mouseInside = ref<boolean>(false)
function onMouseEnter() {
mouseInside.value = true
}
function onMouseLeave() {
mouseInside.value = false
}
function onEmoji(emoji: any) {
emit('select', emoji)
}
function onDelete() {
emit('delete')
}
</script>
<template>
<div class="container" :class="[mouseInside && 'container-hover']" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave">
<div class="emoji-title">
所有表情
</div>
<div class="emoji-remove" @click="onDelete">
<van-icon name="close" />
</div>
<div class="emoji-list">
<div v-for="item in emojiData" :key="item.name" class="emoji-item" @click="onEmoji(item)">
{{ item.emoji }}
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.container {
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 4px;
overflow: hidden auto;
// 禁止鼠標拖拽選中
user-select: none;
border: 1px solid #f2f3f5;
border-radius: 4px;
// 默認隱藏滾動條背景色
&::-webkit-scrollbar-thumb {
background: transparent;
}
&::-webkit-scrollbar-thumb:hover {
background: transparent;
}
.emoji-remove {
position: fixed;
display: v-bind('props.open ? "block" : "none"');
right: 0;
top: calc(100vh - 250px);
padding: 4px 14px 4px 8px;
border-radius: 50% 0px 0px 50%;
background-color: rgba(0, 0, 0, 0.2);
z-index: 10;
}
.emoji-title {
margin: 8px 4px;
font-size: 14px;
color: #4d4d4d;
}
.emoji-list {
display: grid;
grid-template-columns: repeat(v-bind(columns), 1fr);
gap: v-bind(gap);
.emoji-item {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
font-size: v-bind(size);
cursor: pointer;
border-radius: 4px;
&:hover {
background: #f2f2f2;
}
}
}
}
.container-hover {
// 鼠標移入,顯示背景色
&::-webkit-scrollbar-thumb {
background: rgb(0 0 0 / 20%);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgb(0 0 0 / 40%);
}
}
</style>
使用頁面
<script setup lang='ts'>
import { useEmoji } from '@/hook/useEmoji'
// hook文件在前面有介紹
const { emojiOpen, footerToolsRef, initEmoji, unmountEmoji } = useEmoji()
const text = ref("");
// 點擊emoji,拼接字符串
function onEmoji(emoji: any) {
text.value = text.value + emoji.emoji
}
// emoji是多個字符,刪除需要特殊處理
function onDelete() {
if (!text.value)
return
const charArray = [...text.value]
charArray.pop()
text.value = charArray.join('')
}
onMounted(() => {
initEmoji()
})
// 卸載時移除事件監聽
onBeforeUnmount(() => {
unmountEmoji()
})
</script>
<template>
<div class="container">
<!-- 聊天界面 -->
<div></div>
<!-- 輸入框界面 -->
<div class="footer-box">
<!-- 輸入框盒子 -->
<div class="footer-input-box">
<!-- 輸入框 -->
<input/>
<!-- 工具欄 -->
<div class="input-box-right">
<div class="send">發送</div>
<div class="emoji-btn">emoji</div>
</div>
</div>
<!-- 底部工具欄的彈出盒子 -->
<div ref="footerToolsRef" class="footer-tools" :class="[emojiOpen && 'footer-tools-show']">
<Emoji :open="emojiOpen" @select="onEmoji" @delete="onDelete" />
</div>
</div>
</div>
</template>
參考資料:
VisualViewport
Document
Node.contains()
Event:target 屬性
emoji-regex