当前位置: 首页 > news >正文

uni-app 键盘适配方案

一、核心适配目标

  1. 键盘弹出时,输入框不被键盘遮挡
  2. 页面布局自动上推,聊天消息区域不挤压变形
  3. 输入消息后自动滚动到最新消息
  4. 兼容手机底部安全区,避免输入框被底部横条遮挡

二、你的具体实现方案(核心代码逻辑)

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(),保证输入消息、接收消息时,始终显示最新内容。

三、方案整体原理(一句话)

关闭系统自动上推 → 监听键盘实时高度 → 用占位符动态撑开页面底部 → 配合安全区样式 → 实现输入框不被键盘遮挡

四、你的方案优点

  1. 纯原生实现:无第三方依赖,兼容所有 uni-app 端(微信小程序、App、H5)
  2. 精准适配:根据键盘实际高度动态调整,适配不同手机 / 不同输入法高度
  3. 布局稳定:不会挤压消息列表,页面不会抖动、变形
  4. 安全区兼容:解决了全面屏手机底部遮挡问题
  5. 联动聊天场景:键盘弹出自动滚动到最新消息,体验流畅

五、代码中存在的小问题(可优化点)

  1. 样式语法错误
    transform: height 0.1s ease; // 错误 transition: height 0.3s ease; // 正确(高度过渡动画)
  2. 键盘收起时未重置高度keyboardHeight只在弹出时赋值,收起时未重置为 0,可能导致占位符残留空白。
  3. 防抖缺失:快速弹出 / 收起键盘时,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>
http://www.jsqmd.com/news/773727/

相关文章:

  • 避坑指南:STM32 HAL库定时器配置那些容易踩的坑(基于F103C8T6与CubeMX)
  • 最新英语教学手机APP推荐 零基础轻松入门快速提升英语能力
  • Bridge-Search:基于MCP协议实现WSL2与Windows文件系统高速互通的AI助手搜索桥梁
  • 独立开发者如何低成本推广产品?先从这5步开始
  • 4. 逻辑操作符: , || , !
  • 别再死记硬背了!用TensorFlow 1.x的变量与占位符,手把手带你理解计算图的运作原理
  • 为什么92%的AISMM导入项目失败?——从战略解码到执行断层的4个隐形缺口,今天必须补上
  • 2026年AI面试软件排行榜:从求职到“考公“,谁才是你的最强陪练?
  • 万亿参数震撼发布:DeepSeek V4 MoE架构深度解析——1.6万亿参数、百万上下文与效率革命,国产大模型的范式级突破
  • 2026豆包广告服务商实力横评:全栈技术派VS场景深耕派综合竞争力测评 - GEO优化
  • 基于MCP协议的Subiekt GT AI助手:本地化ERP与AI的深度融合实践
  • 避开华为审核雷区:UniApp权限申请的正确姿势(含拒绝后引导方案)
  • Cursor Free VIP终极指南:三步破解AI编辑器限制,完整解锁Pro功能
  • 基于MCP协议构建AI智能体万能工具箱:wet-mcp部署与实战指南
  • 第四篇:事务隔离级别与MVCC——InnoDB的并发控制
  • MySQL迁移过程中数据校验失败处理_基于Hash值对比差异
  • Retrieval-based-Voice-Conversion-WebUI实战指南:仅需10分钟数据打造专业级AI语音转换系统
  • GEO服务商该如何挑选?2026年五大代表品牌测评分析及选型结论 - GEO优化
  • HBM面临多方挑战,谁能在“后摩尔时代内存形态”路线之争中胜出?
  • Steam成就管理器完全指南:5分钟掌握游戏成就自由掌控权
  • 酷秒神马 9.0 2026 全新版本 内核全面焕新
  • 如何快速掌握Lab Streaming Layer:科研数据同步的终极解决方案
  • Windows下用Anaconda配置TensorFlow GPU环境,一次性避开cudart64_110.dll等所有坑
  • 基于RAG技术构建本地文档问答系统:从原理到实践
  • 如何集成OpenClaw?2026年阿里云及Coding Plan配置保姆级攻略
  • 2026年国内五大GEO公司深度盘点:企业 GEO 布局搭建与实操落地指南 - GEO优化
  • 全维度实测|2026年优质GEO优化服务商 TOP5 排行榜及避坑技巧 - GEO优化
  • 2026年Hermes Agent/OpenClaw怎么部署?阿里云安全部署及Coding Plan配置详解
  • 5--1--1.4端点安全架构(base64编码:U0VDNTExIOaMgee7reebkeaOp+S4juWuieWFqOi/kOe7tA==)
  • axiarch文本分块:高效处理非结构化数据,优化RAG与语义搜索