Stories

Detail Return Return

移動端emoji輸入組件 - Stories Detail

主要流程

前端emoji組件一般用在聊天輸入界面,點擊表情,整個輸入框被彈起,顯示emoji界面,點擊其它位置,emoji界面自動關閉,這其中有一些注意點:
1、點擊emojiemoji界面從底部彈起,輸入框也要同步顯示
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

Add a new Comments

Some HTML is okay.