ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Unity UGUI动态输入框实现:微信聊天输入体验与性能优化

Unity UGUI动态输入框实现:微信聊天输入体验与性能优化 1. 项目概述为什么要在Unity里复刻微信输入框做Unity UI开发尤其是做社交类、聊天类应用输入框的处理绝对是一个绕不开的“深水区”。你可能已经用惯了Unity自带的InputField或者TextMeshPro的TMP_InputField觉得它开箱即用但一旦涉及到多行文本、动态高度、与滚动视图ScrollRect的联动特别是要做出类似微信那样流畅的聊天输入体验时原生控件的局限性就暴露无遗。用户输入长文本时输入框要么高度不变导致文本被遮挡要么粗暴地撑开破坏整个UI布局更别提和下方的消息列表ScrollRect平滑滚动的配合了。这个项目的核心目标就是解决这些痛点。它不是一个简单的组件拼装而是一套完整的、生产可用的解决方案。我们将利用InputField本文以UGUI标准InputField为例TMP版本原理相通作为文本输入的基础结合ScrollRect和Content Size Fitter等布局组件实现一个能够根据输入内容动态调整高度、同时完美内嵌在滚动区域内的智能输入框。最终效果是输入框初始为单行高度随着用户输入换行或文本增多输入框向上扩展推动上方的聊天消息列表自然滚动整个过程流畅无卡顿与主流IM应用的体验一致。这不仅仅是UI美观问题更直接影响用户体验和产品质感。对于新手开发者理解这套实现机制能深刻掌握UGUI布局系统的协同工作原理对于有经验的开发者这里面的性能优化和细节处理比如避免布局重建风暴、输入框焦点管理都是宝贵的实战经验。接下来我会从设计思路拆解开始一步步带你实现并优化这个功能。2. 核心设计思路与组件选型解析实现动态输入框听起来好像很简单监听输入事件改改高度不就行了但实际做起来你会发现要协调InputField、ScrollRect、RectTransform以及可能的Layout Group需要考虑的细节非常多。我们先来拆解一下微信这类输入框的核心行为逻辑高度自适应输入框的Content即文本显示区域高度应随文本行数增加而增加但通常有一个最小高度如单行和最大高度限制如最多显示4-5行之后改为内部滚动。向上扩展输入框高度增加时其底部通常是屏幕下边缘或固定位置应保持不动整体向上扩展避免遮挡自身已输入的内容。推动外部布局输入框作为某个父级ScrollRect聊天消息列表下方的固定元素其高度变化应能推动父级ScrollRect的Content向上滚动确保最新的输入行始终可见且不会覆盖最新的消息。交互连贯性在整个高度变化过程中输入框的焦点不能丢失光标定位要准确触摸/点击区域要随之变化。基于这些需求我们否决了单纯通过代码计算文本行数然后硬设置height的方案因为字体、间距、富文本等都会让计算变得复杂且不准确。UGUI自身提供的Content Size Fitter组件正是为此而生。我们的核心设计思路如下InputField作为承载者它负责文本输入、编辑、光标显示等所有交互逻辑。我们需要将其Text组件的对齐方式设置为Top-Left并允许垂直溢出。Content Size Fitter作为驱动器挂在InputField的Text子物体或专门用于显示文本的RectTransform上设置Vertical Fit为Preferred Size。这样Text矩形就会根据文本内容自动调整到合适的高度。ScrollRect作为容器与联动器方案一输入框内嵌滚动将带有Content Size Fitter的InputField整体放入一个ScrollRect中并设置ScrollRect的Movement Type为ClampedVertical Scrollbar根据需要启用。当文本高度超过ScrollRect视图高度时在内部滚动。这适用于需要限制输入框最大显示行数的场景。方案二输入框推动外部滚动输入框本身不在ScrollRect内但它和消息列表同属于一个父级ScrollRect的Content。输入框高度变化时通过改变自己的sizeDelta或借助Layout Element的Preferred Height驱动父级ScrollRect的Content总高度变化从而引发滚动。这是实现“推动消息列表”效果的关键。Layout Group作为布局管理者如果输入框和消息列表在一个垂直布局中如Vertical Layout Group那么输入框高度的Preferred Size变化会自动触发布局重新计算从而改变父级Content的大小实现联动滚动。这是最优雅、性能相对较好的方案。组件选型考量InputField vs TMP_InputField对于新项目强烈推荐使用TextMeshPro的TMP_InputField它在字体渲染、富文本支持、性能方面优势明显。但本文以标准InputField讲解原理完全互通替换组件即可。Content Size Fitter的挂载点务必挂在显示文本的RectTransform上而不是InputField组件本身的GameObject。通常InputField下会有一个叫Text或Placeholder的子物体。ScrollRect的Viewport和Content理解Viewport视口通常带Mask和Content内容区域的区别至关重要。我们的动态高度改变的是Content或其子元素的高度。3. 详细实现步骤与关键脚本剖析理论清晰后我们开始动手搭建。我将以“方案二”输入框推动外部滚动为例因为它更贴近微信聊天界面的典型结构底部是输入框上方是消息列表两者在一个可垂直滚动的区域内。3.1 UI层级结构与组件配置首先在Unity编辑器中搭建如下UI层级ChatCanvas (Canvas) ├── ChatPanel (Panel, Image) │ ├── Viewport (RectTransform, Mask) // ScrollRect的Viewport │ │ └── Content (RectTransform) // ScrollRect的Content 添加Vertical Layout Group │ │ ├── MessageList (Empty GameObject) // 用于存放消息的容器 │ │ │ ├── (动态生成的消息项...) │ │ │ └── (动态生成的消息项...) │ │ └── InputArea (Panel, Image) // 输入框区域 │ │ ├── InputFieldBackground (Image) │ │ └── ChatInputField (InputField) │ │ ├── Placeholder (Text) │ │ └── Text (Text) // **关键对象**添加Content Size Fitter │ └── Scrollbar Vertical (Scrollbar) // 可选 └── (其他UI元素...)为ContentGameObject添加Vertical Layout Group组件。设置Child Controls Size为Height这样其子物体MessageList和InputArea的高度将影响布局。同时添加Content Size Fitter组件设置Vertical Fit为Preferred Size。这样Content的总高度将由其子物体的布局偏好高度决定。为InputAreaGameObject添加Layout Element组件。这个组件用于向父级Vertical Layout Group报告它的尺寸偏好。我们可以在这里设置Min Height如40单行高度并勾选Preferred Height但其值我们稍后通过脚本动态设置。为ChatInputField下的Text子物体添加Content Size Fitter组件设置Vertical Fit为Preferred Size。确保Text组件的Alignment为Upper LeftVertical Overflow为Overflow。为ChatPanel添加ScrollRect组件。将Viewport拖拽到Viewport属性Content拖拽到Content属性。设置Movement Type为Clamped或ElasticScroll Sensitivity调低一些如5避免滚动过快。取消勾选Horizontal因为我们只需要垂直滚动。3.2 核心控制脚本UIChatInputField创建一个名为UIChatInputField.cs的脚本挂载到ChatInputField即InputField组件所在的对象上。using UnityEngine; using UnityEngine.UI; using System.Collections; [RequireComponent(typeof(InputField))] public class UIChatInputField : MonoBehaviour { [SerializeField] private InputField m_InputField; [SerializeField] private RectTransform m_InputTextRect; // 指向InputField的Text子物体 [SerializeField] private LayoutElement m_InputAreaLayoutElement; // 指向InputArea的LayoutElement [SerializeField] private ScrollRect m_ChatScrollRect; // 外部的聊天ScrollRect [SerializeField] private float m_MinHeight 40f; [SerializeField] private float m_MaxHeight 200f; // 输入框最大高度超过后内部滚动或换行策略 [SerializeField] private float m_ScrollDelay 0.1f; // 滚动延迟避免每帧滚动 private ContentSizeFitter m_TextContentSizeFitter; private float m_LastPreferredHeight; private bool m_IsDirty false; private Coroutine m_ScrollCoroutine; private void Awake() { if (m_InputField null) m_InputField GetComponentInputField(); if (m_InputTextRect ! null) m_TextContentSizeFitter m_InputTextRect.GetComponentContentSizeFitter(); m_InputField.onValueChanged.AddListener(OnInputValueChanged); // 初始设置 if (m_InputAreaLayoutElement ! null) { m_InputAreaLayoutElement.preferredHeight m_MinHeight; m_LastPreferredHeight m_MinHeight; } } private void OnInputValueChanged(string newText) { // 标记布局需要更新 m_IsDirty true; } private void LateUpdate() { // 在LateUpdate中处理布局更新确保UI布局计算已完成一轮 if (!m_IsDirty || m_TextContentSizeFitter null || m_InputAreaLayoutElement null) return; // 强制ContentSizeFitter立即计算一次它通常在本帧渲染前计算 LayoutRebuilder.ForceRebuildLayoutImmediate(m_InputTextRect); // 获取Text的偏好高度 float textPreferredHeight m_InputTextRect.rect.height; // 计算输入区域应有的高度通常要加上一些padding float newHeight Mathf.Clamp(textPreferredHeight 10f, m_MinHeight, m_MaxHeight); // 10是上下内边距估算 // 如果高度发生变化 if (Mathf.Abs(newHeight - m_LastPreferredHeight) 0.1f) { m_InputAreaLayoutElement.preferredHeight newHeight; m_LastPreferredHeight newHeight; // 触发外部ScrollRect的滚动 if (m_ChatScrollRect ! null) { // 延迟滚动避免布局计算未完成或过于频繁 if (m_ScrollCoroutine ! null) StopCoroutine(m_ScrollCoroutine); m_ScrollCoroutine StartCoroutine(ScrollToBottomCoroutine()); } } m_IsDirty false; } private IEnumerator ScrollToBottomCoroutine() { yield return new WaitForSeconds(m_ScrollDelay); // 等待布局稳定 yield return null; // 再等一帧 if (m_ChatScrollRect ! null) { m_ChatScrollRect.verticalNormalizedPosition 0f; // 滚动到底部 } m_ScrollCoroutine null; } // 提供一个公开方法用于手动刷新例如在设置初始文本时 public void RefreshInputFieldHeight() { m_IsDirty true; } private void OnDestroy() { if (m_InputField ! null) m_InputField.onValueChanged.RemoveListener(OnInputValueChanged); } }脚本关键点解析延迟更新与性能在LateUpdate中处理高度更新并引入m_IsDirty标记是为了避免在onValueChanged事件中每输入一个字符就立即触发昂贵的布局重建LayoutRebuilder.ForceRebuildLayoutImmediate这会导致输入卡顿。我们积攒一帧内的变化在LateUpdate中统一处理。高度计算m_InputTextRect.rect.height获取的是Text对象经过Content Size Fitter计算后的实际高度。我们需要根据这个高度加上InputField背景图的内边距10f是一个经验值需根据实际UI调整来设置InputArea的preferredHeight。滚动到底部通过设置ScrollRect.verticalNormalizedPosition 0f来滚动到底部。使用协程延迟执行是为了等待Layout Element的高度变化被Vertical Layout Group和Content Size Fitter消化并更新父级Content的总高度。如果不延迟可能会滚到一个中间位置。最大高度限制通过Mathf.Clamp限制输入框的最大高度。当达到m_MaxHeight时输入框将不再增高。此时你可以选择让Text在内部滚动需要将InputArea放入另一个ScrollRect或者让InputField的LineType改为MultiLine Newline并继续换行但视觉高度固定。微信采用的是前者达到一定行数后内部可滚动。3.3 输入框内部滚动可选进阶如果你需要实现输入框自身内容过多时的内部滚动即达到最大高度后文本可上下滚动查看则需要调整结构将InputArea改造成一个ScrollRect。InputField作为这个ScrollRect的Content。移除InputArea上的Layout Element或者根据InputField的Text高度动态启用/禁用ScrollRect的滚动。修改脚本当计算出的newHeight小于m_MaxHeight时禁用内部ScrollRect的滚动使用布局扩展当newHeight达到m_MaxHeight时启用内部ScrollRect的滚动并固定InputArea的高度。这涉及到更复杂的滚动协调避免内外ScrollRect滚动冲突通常需要通过ScrollRect的onValueChanged事件和InputField的Deactivate事件来管理输入焦点和滚动权限是一个常见的UI难题。4. 性能优化与避坑指南实现功能只是第一步要让它在实际项目中流畅运行必须关注性能。UGUI的布局重建Layout Rebuild和网格重建Graphic Rebuild是主要性能瓶颈。4.1 优化布局计算频率防抖处理我们的脚本已经在LateUpdate中做了一次聚合更新这是最基本的防抖。对于高频触发如onValueChanged这是必须的。避免嵌套Content Size Fitter我们的结构是Content (Content Size Fitter)-InputArea (Layout Element)-Text (Content Size Fitter)。这存在嵌套的Preferred Size计算。虽然不可避免但要确保层级不要过深。谨慎使用ForceRebuildLayoutImmediate这是一个同步的、强制立即布局的方法开销很大。我们只在Text对象上使用且一帧只调用一次。如果整个UI树很大可以考虑用CanvasUpdateRegistry在Layout环节注册重建但更复杂。4.2 输入框与滚动视图的焦点冲突这是一个非常经典的坑。当InputField处于激活状态有光标闪烁时如果用户触摸/拖动它上方的ScrollRect消息列表期望的行为应该是滚动消息列表而不是失去输入焦点。解决方案需要处理ScrollRect的拖拽事件判断拖拽起始点是否在InputField区域内并做相应处理。这里提供一个简化思路// 在UIChatInputField脚本中增加 private bool m_IsInputFieldDragging false; // 在ScrollRect的事件处理中需要另一个脚本或事件转发 public void OnBeginDrag(PointerEventData eventData) { // 判断点击起始位置是否在InputField的RectTransform内 if (RectTransformUtility.RectangleContainsScreenPoint(m_InputField.GetComponentRectTransform(), eventData.position, eventData.pressEventCamera)) { // 如果点在输入框内不拦截让输入框正常处理如选择文本 // 但为了允许滚动我们可以暂时不做处理或者设置一个标志 // 更复杂的做法是判断拖拽方向如果主要是垂直向上拖拽则优先滚动。 // 一个常见的折中方案是点击输入框区域激活输入但在输入框内快速上下滑动时触发外部滚动。 // 这通常需要自定义InputField或使用EventSystem进行更精细的事件管理。 } }实际上很多项目会采用一个变通方案将输入框区域的Image组件的Raycast Target关闭并在其上层覆盖一个透明的、仅用于拦截滚动的面板在这个面板上处理复杂的焦点与滚动逻辑。或者直接使用TMP_InputField它对移动端的触摸处理更好一些。4.3 移动端键盘弹出处理在移动设备上键盘弹出会遮挡大部分屏幕。我们需要调整整个ChatPanel或ScrollRect的Content的位置确保输入框在键盘上方可见。Unity 2018 / 使用TouchScreenKeyboard监听InputField的onTouchScreenKeyboardStatusChanged事件如果是TMP_InputField相关事件可能不同。通用方案使用CanvasScaler的Screen Match Mode配合RectTransform的锚点。更可靠的方法是在键盘弹出时获取键盘高度可通过TouchScreenKeyboard.area或第三方插件然后将包含输入框的底部面板的anchoredPosition.y增加一个偏移量。同时要记得在键盘关闭时恢复位置。与动态高度的协调键盘弹出后可视区域变小输入框动态增高可能会立刻顶到键盘。需要在计算高度和滚动时考虑当前的“安全区域”或可视区域底部坐标。5. 常见问题排查与实战技巧即使按照步骤做了你可能还是会遇到一些奇怪的问题。这里记录几个我踩过的坑和解决方法问题1输入框高度变化时整个UI剧烈抖动或闪烁。原因布局重建顺序错乱。可能同时有多个Content Size Fitter或Layout Group在互相影响且计算时机冲突。解决检查UI层级确保没有不必要的Layout Group。例如MessageList如果不需要布局就移除其上的Vertical Layout Group。尝试调整脚本中LayoutRebuilder.ForceRebuildLayoutImmediate的调用对象。有时重建父级Content比重建Text更稳定。在LateUpdate中更新高度后调用Canvas.ForceUpdateCanvases()谨慎使用性能开销大可以强制所有Canvas立即更新有时能解决同一帧内的渲染抖动。问题2滚动到底部不准确有时会差一点。原因ScrollRect.verticalNormalizedPosition 0f的执行时机过早布局计算还未最终完成。解决增加脚本中ScrollToBottomCoroutine的延迟时间m_ScrollDelay比如从0.1f增加到0.15f。不用协程延迟改用UnityEvent或UnityAction。在设置完preferredHeight后通过LayoutGroup的OnRectTransformDimensionsChange事件或自己派发一个“布局完成”事件来触发滚动。使用ScrollRect的ScrollTo方法如果存在或计算Content的最终位置进行精准滚动。问题3在输入框内快速按回车换行时光标会跳到行首。原因这是Unity原生InputField在多行模式下的一个已知问题。TMP_InputField在此方面表现更好。解决换用TMP_InputField。如果坚持用原生InputField可以尝试监听提交事件onEndEdit但这不是真正的解决方案。更 hack 的方法是继承InputField重写键盘输入处理逻辑成本较高。问题4WebGL平台上输入框表现异常如高度不更新。原因WebGL的UI线程和渲染线程与原生平台有差异LayoutRebuilder的即时计算可能不同步。解决将高度更新的逻辑放在Coroutine中并使用yield return new WaitForEndOfFrame();确保在一帧完全结束后再执行滚动操作。对于WebGL考虑降低更新频率比如不是每次onValueChanged都标记dirty而是用一个计时器每0.1秒检查并更新一次高度。个人实战技巧预制体化将调试好的InputArea包含InputField、Layout Element、背景图等做成预制体。以后在任何需要类似输入框的地方直接拖入挂上脚本并引用对应组件即可极大提升开发效率。定义动画曲线如果觉得输入框高度变化太生硬可以尝试在改变preferredHeight时使用DOTween或LeanTween等插件做一个简短的插值动画例如DOTween.To(() layout.preferredHeight, x layout.preferredHeight x, targetHeight, 0.2f)会让体验更加柔和。注意动画过程中可能也需要触发滚动。测试边界情况务必测试以下场景空文本、大量文本、快速删除、粘贴大段文字、在输入框获得焦点时设备旋转移动端。这些往往是bug高发区。实现一个健壮的、体验良好的动态输入框是Unity UI开发能力的一个体现。它要求开发者对UGUI的布局系统、事件传递、性能优化有比较深入的理解。希望这篇详细的解析和实战记录能帮助你彻底掌握这项技能并应用到你的下一个项目中。
返回列表