uni-app 键盘适配方案
一、核心适配目标
- 键盘弹出时,输入框不被键盘遮挡
- 页面布局自动上推,聊天消息区域不挤压变形
- 输入消息后自动滚动到最新消息
- 兼容手机底部安全区,避免输入框被底部横条遮挡
二、你的具体实现方案(核心代码逻辑)
1. 禁用 input 自动上推页面(基础配置)
<input :adjust-position="false" />- 作用:关闭 uni-app 默认的键盘自动上推页面功能,手动控制布局,避免默认上推导致的页面错乱。
2. 监听键盘高度变化(核心监听)
<input @keyboardheightchange="changeHeight" />let keyboardHeight = ref(0) function changeHeight(e) { keyboardHeight.value = e.detail.height // 实时获取键盘高度 if (keyboardHeight.value > 0) scrollToBottom() }- 作用:精准监听键盘弹出 / 收起的实时高度,赋值给响应式变量
keyboardHeight。
3. 占位符撑开页面(防遮挡核心)
<view class="keyboard-placeholder" :style="{ height: keyboardHeight + 'px' }"></view>- 作用:键盘弹出时,占位符高度 = 键盘高度,把页面底部撑开,让输入框自然浮在键盘上方,不被遮挡;
- 键盘收起时,高度归 0,页面恢复原样。
4. 安全区兼容处理
<view class="input-area" :class="{ 'safeArea': keyboardHeight != 0 }">.input-area { margin-bottom: env(safe-area-inset-bottom); // 默认适配底部安全区 } .safeArea { margin-bottom: 0px !important; // 键盘弹出时取消安全区间距,避免双重间距 }- 作用:无键盘时适配手机底部安全区;键盘弹出时关闭安全区边距,防止输入框和键盘之间出现多余空隙。
5. 联动聊天自动滚动到底部
键盘高度变化时自动调用scrollToBottom(),保证输入消息、接收消息时,始终显示最新内容。
三、方案整体原理(一句话)
关闭系统自动上推 → 监听键盘实时高度 → 用占位符动态撑开页面底部 → 配合安全区样式 → 实现输入框不被键盘遮挡。
四、你的方案优点
- 纯原生实现:无第三方依赖,兼容所有 uni-app 端(微信小程序、App、H5)
- 精准适配:根据键盘实际高度动态调整,适配不同手机 / 不同输入法高度
- 布局稳定:不会挤压消息列表,页面不会抖动、变形
- 安全区兼容:解决了全面屏手机底部遮挡问题
- 联动聊天场景:键盘弹出自动滚动到最新消息,体验流畅
五、代码中存在的小问题(可优化点)
- 样式语法错误
transform: height 0.1s ease; // 错误 transition: height 0.3s ease; // 正确(高度过渡动画) - 键盘收起时未重置高度:
keyboardHeight只在弹出时赋值,收起时未重置为 0,可能导致占位符残留空白。 - 防抖缺失:快速弹出 / 收起键盘时,
keyboardheightchange会频繁触发,可能造成页面抖动。
六、完整版代码
<template> <view class="container"> <view class="header"> <text class="title">WebSocket 测试{{ keyboardHeight }}</text> <view class="header-actions"> <button class="connect-btn" v-if="!isConnected" @click="connectWebSocket"> 连接 </button> <button class="disconnect-btn" v-else @click="disconnectWebSocket"> 断开 </button> <view class="status" :class="{ connected: isConnected, disconnected: !isConnected }"> <view class="status-dot"></view> <text>{{ isConnected ? '已连接' : '未连接' }}</text> </view> </view> </view> <scroll-view class="message-list" scroll-y :scroll-into-view="scrollToId" scroll-with-animation> <view v-for="(msg, index) in messages" :key="index" :id="'msg-' + index" class="message-item" :class="{ 'message-sent': msg.type === 'send', 'message-received': msg.type === 'receive' }"> <view class="message-content"> <text>{{ msg.content }}</text> </view> <text class="message-time">{{ msg.time }}</text> </view> </scroll-view> <view class="input-area" :class="{ 'safeArea': keyboardHeight != 0 }"> <input v-model="inputMessage" :adjust-position="false" @keyboardheightchange="changeHeight" class="message-input" placeholder="请输入消息..." @confirm="sendMessage" confirm-hold /> <button class="send-btn" :disabled="!inputMessage.trim() || !isConnected" @click="sendMessage"> 发送 </button> </view> <view class="keyboard-placeholder" :style="{ height: keyboardHeight + 'px' }"></view> </view> </template> <script setup lang="ts"> import { ref, onUnmounted, nextTick } from 'vue' interface Message { type: 'send' | 'receive' content: string time: string } let keyboardHeight = ref(0) function changeHeight(e: { detail: { height: number } }) { keyboardHeight.value = e.detail.height if (keyboardHeight.value > 0) scrollToBottom() } const isConnected = ref(false) const inputMessage = ref('') const messages = ref<Message[]>([]) const scrollToId = ref('') let socketTask: UniApp.SocketTask | null = null const getTime = () => { const now = new Date() return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}` } const scrollToBottom = () => { scrollToId.value = '' nextTick(() => { if (messages.value.length > 0) { scrollToId.value = 'msg-' + (messages.value.length - 1) console.log(scrollToId.value) } }) } const connectWebSocket = () => { socketTask = uni.connectSocket({ url: 'wss://echo.websocket.org', success: () => { console.log('WebSocket连接成功') }, fail: (err) => { console.error('WebSocket连接失败', err) uni.showToast({ title: '连接失败', icon: 'none' }) } }) socketTask.onOpen(() => { isConnected.value = true uni.showToast({ title: '连接成功', icon: 'success' }) messages.value.push({ type: 'receive', content: 'WebSocket 连接已建立', time: getTime() }) scrollToBottom() }) socketTask.onMessage((res) => { messages.value.push({ type: 'receive', content: res.data, time: getTime() }) scrollToBottom() }) socketTask.onClose(() => { isConnected.value = false messages.value.push({ type: 'receive', content: 'WebSocket 连接已断开', time: getTime() }) scrollToBottom() }) socketTask.onError((err) => { console.error('WebSocket错误', err) uni.showToast({ title: '连接错误', icon: 'none' }) }) } const disconnectWebSocket = () => { if (socketTask) { socketTask.close({ success: () => { isConnected.value = false uni.showToast({ title: '已断开连接', icon: 'none' }) } }) socketTask = null } } const sendMessage = () => { if (!inputMessage.value.trim() || !isConnected.value) return const message = inputMessage.value.trim() messages.value.push({ type: 'send', content: message, time: getTime() }) scrollToBottom() socketTask?.send({ data: message, success: () => { inputMessage.value = '' }, fail: (err) => { console.error('发送失败', err) uni.showToast({ title: '发送失败', icon: 'none' }) } }) } onUnmounted(() => { disconnectWebSocket() }) </script> <style lang="scss" scoped> .container { height: 100%; display: flex; flex-direction: column; background-color: #f5f5f5; transform: height 0.1s ease; } .header { padding: 20rpx 30rpx; background-color: #409eff; display: flex; justify-content: space-between; align-items: center; } .title { font-size: 34rpx; color: #fff; font-weight: bold; } .header-actions { display: flex; align-items: center; gap: 15rpx; } .connect-btn, .disconnect-btn { padding: 10rpx 25rpx; border-radius: 20rpx; font-size: 26rpx; color: #fff; border: none; &[disabled] { opacity: 0.7; } } .connect-btn { background-color: #67c23a; } .disconnect-btn { background-color: #f56c6c; } .status { display: flex; align-items: center; gap: 10rpx; padding: 10rpx 20rpx; border-radius: 20rpx; font-size: 24rpx; color: #fff; &.connected { background-color: rgba(72, 187, 120, 0.8); } &.disconnected { background-color: rgba(245, 108, 108, 0.8); } } .status-dot { width: 16rpx; height: 16rpx; border-radius: 50%; background-color: #fff; } .message-list { flex: 1; width: calc(100vw - 40rpx); min-height: 0; overflow-y: auto; padding: 20rpx; } .message-item { display: flex; flex-direction: column; margin-bottom: 30rpx; max-width: 70%; &.message-sent { align-items: flex-end; margin-left: auto; } &.message-received { align-items: flex-start; margin-right: auto; } } .message-content { padding: 20rpx 30rpx; border-radius: 30rpx; font-size: 28rpx; word-break: break-all; .message-sent & { background-color: #409eff; color: #fff; border-bottom-right-radius: 10rpx; } .message-received & { background-color: #fff; color: #333; border-bottom-left-radius: 10rpx; } } .message-time { font-size: 22rpx; color: #999; margin-top: 10rpx; .message-sent & { text-align: right; } .message-received & { text-align: left; } } .input-area { padding: 20rpx; background-color: #be2222; display: flex; gap: 20rpx; margin-bottom: env(safe-area-inset-bottom); } .safeArea { margin-bottom: 0px !important; } .message-input { flex: 1; height: 80rpx; padding: 0 30rpx; border-radius: 40rpx; background-color: #f5f5f5; font-size: 28rpx; } .send-btn { width: 140rpx; height: 80rpx; border-radius: 40rpx; background-color: #409eff; color: #fff; font-size: 28rpx; display: flex; align-items: center; justify-content: center; border: none; &[disabled] { background-color: #ccc; } } .keyboard-placeholder { transform: height 0.3s ease; } </style>