前言

输入框 InputField 也是 UGUI 中较为常见的组件,经常被用于设置用户名、密码以及聊天框等。由于需要处理各种输入情况,因此 InputField 的代码量巨大,足足达到了 3000 行。这其中的大部分代码都用于处理输入时的细节,如对不同类型输入的限制、光标的显示、拖拽选中区域等。作为研究 UGUI 机制的系列文章,我们大可不必深入研究这些细节,只需要了解 InputField 对于不同的事件是如何处理的即可。

image.png

InputField 的属性

image.png

InputField 有非常多的可配置属性,对上述各属性的解释如下:

  • Text Component :文本组件,也就是显示输入所用到的 Text 组件

  • Text :文本内容

  • Character Limit :字符长度限制

  • Content Type :内容类型(数字、邮箱、字母、密码共十种类型选择)

    1. Standard:标准,允许所有字符
    2. Autocorrected:自动纠正,允许所有字符,但会自动纠正(针对某些平台)
    3. Integer Number:整数,只允许输入数字
    4. Decimal Number:小数,只允许输入数字和小数点
    5. Alphanumeric:字母及数字,允许输入 A-Z、a-z、0-9
    6. Name:姓名,强制首字母大写
    7. Email Address:邮箱,允许输入邮箱格式
    8. Password:密码,在显示时会使用星号代替密码内容
    9. Pin:PIN码,只允许输入数字,同样会使用星号代替
    10. Custom:自定义,可以自定义输入规则

    InputField 的复杂程度由 ContentType 就可见一斑,处理上述的多种不同情况本身就已经非常复杂。

  • Line Type :行类型(单行、多行)

  • Placeholder :占位Text组件,当无内容时显示该组件(如“请输入内容”)

  • Caret Blink Rate :光标闪烁频率

  • Caret Width :光标宽度

  • CustomCaretColor :自定义光标颜色开关

  • Selection Color :选中区域颜色

  • Hide Mobile Input:隐藏输入框开关

  • On Value Changed:文本发生变化的事件监听

  • On Submit:提交文本的事件监听

  • On End Edit :完成编辑的事件监听

InputField 的生命周期函数

1
2
3
4
5
6
7
8
9
10
11
public class InputField
: Selectable,
IUpdateSelectedHandler,
IBeginDragHandler,
IDragHandler,
IEndDragHandler,
IPointerClickHandler,
ISubmitHandler,
ICanvasElement,
ILayoutElement
{}

由定义来看,InputField 继承自 Selectable,并且实现了诸多事件处理接口。

OnEnable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected override void OnEnable()
{
base.OnEnable();
if (m_Text == null)
m_Text = string.Empty;
m_DrawStart = 0;
m_DrawEnd = m_Text.Length;

// If we have a cached renderer then we had OnDisable called so just restore the material.
if (m_CachedInputRenderer != null)
m_CachedInputRenderer.SetMaterial(m_TextComponent.GetModifiedMaterial(Graphic.defaultGraphicMaterial), Texture2D.whiteTexture);

if (m_TextComponent != null)
{
m_TextComponent.RegisterDirtyVerticesCallback(MarkGeometryAsDirty);
m_TextComponent.RegisterDirtyVerticesCallback(UpdateLabel);
m_TextComponent.RegisterDirtyMaterialCallback(UpdateCaretMaterial);
UpdateLabel();
}
}

InputField 的 Enable 阶段主要是初始化一些变量以及向 Text 组件中注册一些顶点以及材质更新时的回调函数。

这里调用了 base.OnEnable(),不知道各位是否还记得 Selectable.OnEnable() 中做了哪些事情?其实就是将自身注册到静态的 s_Selectables 列表中,以用于导航事件。可以参考 Selectable

其次就是三个被注册的函数了:

MarkGeometryAsDirty & UpdateCaretMaterial

MarkGeometryAsDirty()Text 组件在几何信息更新时的回调。大家可以思考一下,一个输入框应该负责哪些内容的几何信息更新?我们已知文本内容本身的几何信息更新由 Text 组件负责,背景等图片的几何信息更新由 Image 组件负责,那么 InputField 负责的是什么呢?

答案就是 ———— 光标(Caret)。

没错,InputField 本身所负责的就只有光标的更新。因此无论是在几何信息更新的回调中还是在材质更新的回调中,我们都只会看到光标的相关操作。(当然还需要处理输入的文字)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void MarkGeometryAsDirty()
{
CanvasUpdateRegistry.RegisterCanvasElementForGraphicRebuild(this);
}

public virtual void Rebuild(CanvasUpdate update)
{
switch (update)
{
case CanvasUpdate.LatePreRender:
UpdateGeometry();
break;
}
}

可以看到,在 MarkGeometryAsDirty() 中,仅仅是将自身重新注册进 CanvasUpdateRegistry 中,以便在下一帧的 LatePreRender 阶段更新几何信息。而 Rebuild() 中则调用了 UpdateGeometry() 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private void UpdateGeometry()
{
// No need to draw a cursor on mobile as its handled by the devices keyboard.
if (!shouldHideMobileInput)
return;

if (m_CachedInputRenderer == null && m_TextComponent != null)
{
GameObject go = new GameObject(transform.name + " Input Caret", typeof(RectTransform), typeof(CanvasRenderer));
go.hideFlags = HideFlags.DontSave;
go.transform.SetParent(m_TextComponent.transform.parent);
go.transform.SetAsFirstSibling();
go.layer = gameObject.layer;

caretRectTrans = go.GetComponent<RectTransform>();
m_CachedInputRenderer = go.GetComponent<CanvasRenderer>();
m_CachedInputRenderer.SetMaterial(m_TextComponent.GetModifiedMaterial(Graphic.defaultGraphicMaterial), Texture2D.whiteTexture);

// Needed as if any layout is present we want the caret to always be the same as the text area.
go.AddComponent<LayoutElement>().ignoreLayout = true;

AssignPositioningIfNeeded();
}

if (m_CachedInputRenderer == null)
return;

OnFillVBO(mesh);
m_CachedInputRenderer.SetMesh(mesh);
}

UpdateGeometry() 中,确实只针对光标做了处理,这里可以看到代表光标的 gameObject 是动态生成的,并且在不需要显示的情况下不会生成。光标生成的具体逻辑在 OnFillVBO() 中。

1
2
3
4
5
private void UpdateCaretMaterial()
{
if (m_TextComponent != null && m_CachedInputRenderer != null)
m_CachedInputRenderer.SetMaterial(m_TextComponent.GetModifiedMaterial(Graphic.defaultGraphicMaterial), Texture2D.whiteTexture);
}

UpdateCaretMaterial() 也是类似的,只是在材质更新时重新设置了光标的材质。

UpdateLabel

UpdateLabel() 也是在 Text 组件的几何信息更新时注册的回调,主要作用是将用户输入的内容赋给 m_TextComponent.Text

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
protected void UpdateLabel()
{
if (m_TextComponent != null && m_TextComponent.font != null && !m_PreventFontCallback)
{

m_PreventFontCallback = true;

string fullText;
if (compositionString.Length > 0)
fullText = text.Substring(0, m_CaretPosition) + compositionString + text.Substring(m_CaretPosition);
else
fullText = text;

string processed;
if (inputType == InputType.Password)
processed = new string(asteriskChar, fullText.Length);
else
processed = fullText;

bool isEmpty = string.IsNullOrEmpty(fullText);

if (m_Placeholder != null)
m_Placeholder.enabled = isEmpty;

if (!m_AllowInput)
{
m_DrawStart = 0;
m_DrawEnd = m_Text.Length;
}

if (!isEmpty)
{
Vector2 extents = m_TextComponent.rectTransform.rect.size;

var settings = m_TextComponent.GetGenerationSettings(extents);
settings.generateOutOfBounds = true;

cachedInputTextGenerator.PopulateWithErrors(processed, settings, gameObject);

SetDrawRangeToContainCaretPosition(caretSelectPositionInternal);

processed = processed.Substring(m_DrawStart, Mathf.Min(m_DrawEnd, processed.Length) - m_DrawStart);

SetCaretVisible();
}
m_TextComponent.text = processed;
MarkGeometryAsDirty();
m_PreventFontCallback = false;
}
}

该函数的主要逻辑就是处理各种输入情况,得到 processed 字符串后赋给 m_TextComponent.text。文本内容本身的重建操作则由 Text 组件负责。

有一点需要注意的地方,在 UpdateLabel() 函数体内部将 Text.m_PreventFontCallback 设置为 true,从而阻止递归地调用,避免死循环。在处理完成后会再将其设置为 false

OnDisable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protected override void OnDisable()
{
// the coroutine will be terminated, so this will ensure it restarts when we are next activated
m_BlinkCoroutine = null;

DeactivateInputField();
if (m_TextComponent != null)
{
m_TextComponent.UnregisterDirtyVerticesCallback(MarkGeometryAsDirty);
m_TextComponent.UnregisterDirtyVerticesCallback(UpdateLabel);
m_TextComponent.UnregisterDirtyMaterialCallback(UpdateCaretMaterial);
}
CanvasUpdateRegistry.UnRegisterCanvasElementForRebuild(this);

// Clear needs to be called otherwise sync never happens as the object is disabled.
if (m_CachedInputRenderer != null)
m_CachedInputRenderer.Clear();

if (m_Mesh != null)
DestroyImmediate(m_Mesh);
m_Mesh = null;

base.OnDisable();
}

OnDisable() 几乎就是 OnEnable() 相反的操作,主要是注销了一些回调函数以及清理了一些资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public void DeactivateInputField()
{
// Not activated do nothing.
if (!m_AllowInput)
return;

m_HasDoneFocusTransition = false;
m_AllowInput = false;

if (m_Placeholder != null)
m_Placeholder.enabled = string.IsNullOrEmpty(m_Text);

if (m_TextComponent != null && IsInteractable())
{
if (m_WasCanceled)
text = m_OriginalText;

SendOnSubmit();

if (m_Keyboard != null)
{
m_Keyboard.active = false;
m_Keyboard = null;
}

m_CaretPosition = m_CaretSelectPosition = 0;

input.imeCompositionMode = IMECompositionMode.Auto;
}

MarkGeometryAsDirty();
}

DeactivateInputField() 主要是用于取消激活状态,清理一些状态以及触发相应的回调。

1
2
3
4
5
6
protected void SendOnSubmit()
{
UISystemProfilerApi.AddMarker("InputField.onSubmit", this);
if (onEndEdit != null)
onEndEdit.Invoke(m_Text);
}

即用户注册进 onEndEdit 的回调函数。

LateUpdate

InputFieldLateUpdate() 中处理用户的输入逻辑,即每一帧都会检查用户的输入情况,验证输入的有效性,并更新文本内容等;当用户停止输入时也会触发相应的回调。

点击展开代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
protected virtual void LateUpdate()
{
// Only activate if we are not already activated.
if (m_ShouldActivateNextUpdate)
{
if (!isFocused)
{
ActivateInputFieldInternal();
m_ShouldActivateNextUpdate = false;
return;
}

// Reset as we are already activated.
m_ShouldActivateNextUpdate = false;
}

AssignPositioningIfNeeded();

if (!isFocused || InPlaceEditing())
return;

if (m_Keyboard == null || m_Keyboard.status != TouchScreenKeyboard.Status.Visible)
{
if (m_Keyboard != null)
{
if (!m_ReadOnly)
text = m_Keyboard.text;

if (m_Keyboard.status == TouchScreenKeyboard.Status.Canceled)
m_WasCanceled = true;
}

OnDeselect(null);
return;
}

string val = m_Keyboard.text;

if (m_Text != val)
{
if (m_ReadOnly)
{
m_Keyboard.text = m_Text;
}
else
{
m_Text = "";

for (int i = 0; i < val.Length; ++i)
{
char c = val[i];

if (c == '\r' || (int)c == 3)
c = '\n';

if (onValidateInput != null)
c = onValidateInput(m_Text, m_Text.Length, c);
else if (characterValidation != CharacterValidation.None)
c = Validate(m_Text, m_Text.Length, c);

if (lineType == LineType.MultiLineSubmit && c == '\n')
{
m_Keyboard.text = m_Text;

OnDeselect(null);
return;
}

if (c != 0)
m_Text += c;
}

if (characterLimit > 0 && m_Text.Length > characterLimit)
m_Text = m_Text.Substring(0, characterLimit);

if (m_Keyboard.canGetSelection)
{
UpdateCaretFromKeyboard();
}
else
{
caretPositionInternal = caretSelectPositionInternal = m_Text.Length;
}

// Set keyboard text before updating label, as we might have changed it with validation
// and update label will take the old value from keyboard if we don't change it here
if (m_Text != val)
m_Keyboard.text = m_Text;

SendOnValueChangedAndUpdateLabel();
}
}
else if (m_HideMobileInput && m_Keyboard.canSetSelection)
{
m_Keyboard.selection = new RangeInt(caretPositionInternal, caretSelectPositionInternal - caretPositionInternal);
}
else if (m_Keyboard.canGetSelection && !m_HideMobileInput)
{
UpdateCaretFromKeyboard();
}


if (m_Keyboard.status != TouchScreenKeyboard.Status.Visible)
{
if (m_Keyboard.status == TouchScreenKeyboard.Status.Canceled)
m_WasCanceled = true;

OnDeselect(null);
}
}

函数的内容也比较繁琐,但是与事件处理相关的就只有两处:

SendOnValueChangedAndUpdateLabel

LateUpdate() 在更新了 m_Text 后调用了 SendOnValueChangedAndUpdateLabel() 以更新文本内容及触发相应的回调。

1
2
3
4
5
6
7
8
9
10
11
12
private void SendOnValueChangedAndUpdateLabel()
{
SendOnValueChanged();
UpdateLabel();
}

private void SendOnValueChanged()
{
UISystemProfilerApi.AddMarker("InputField.value", this);
if (onValueChanged != null)
onValueChanged.Invoke(text);
}

可以看到在 SendOnValueChangedAndUpdateLabel() 中,会调用 SendOnValueChanged() 函数以触发回调,同时调用 UpdateLabel() 函数以更新文本内容。

1
2
3
4
5
public class OnChangeEvent : UnityEvent<string> {}

private OnChangeEvent m_OnValueChanged = new OnChangeEvent();

public OnChangeEvent onValueChanged { get { return m_OnValueChanged; } set { SetPropertyUtility.SetClass(ref m_OnValueChanged, value); } }

onValueChanged 中注册回调函数就能够实现在文本内容发生变化时触发相应的功能了。

OnDeselect

该函数在用户停止输入时被调用,负责在用户停止输入时触发相应的回调。

1
2
3
4
5
public override void OnDeselect(BaseEventData eventData)
{
DeactivateInputField();
base.OnDeselect(eventData);
}

DeactivateInputField() 上文已经分析过,会触发 onEndEdit 中的函数。

InputField 的事件处理

InputField 本身实现了诸多事件处理接口,这些接口的实现也是 InputField 的核心逻辑之一。

OnPointerClick

OnPointerClick()IPointerClickHandler 接口的实现,用于处理鼠标点击事件。对于 InputField 来说,当鼠标左键点击时,会激活输入框。

1
2
3
4
5
6
7
public virtual void OnPointerClick(PointerEventData eventData)
{
if (eventData.button != PointerEventData.InputButton.Left)
return;

ActivateInputField();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void ActivateInputField()
{
if (m_TextComponent == null || m_TextComponent.font == null || !IsActive() || !IsInteractable())
return;

if (isFocused)
{
if (m_Keyboard != null && !m_Keyboard.active)
{
// 激活键盘接口,并将当前输入框设置进键盘接口中
m_Keyboard.active = true;
m_Keyboard.text = m_Text;
}
}

m_ShouldActivateNextUpdate = true;
}

InputField 中使用了Unity提供的键盘接口类型(TouchScreenKeyboard),该接口记录了我们的输入内容。到此,激活操作就结束了。主要内容是激活了TouchScreenKeyboard,来保存我们的输入。

OnSubmit

1
2
3
4
5
6
7
8
public virtual void OnSubmit(BaseEventData eventData)
{
if (!IsActive() || !IsInteractable())
return;

if (!isFocused)
m_ShouldActivateNextUpdate = true;
}

可以看到,由于在 LateUpdate() 中已经处理了用户的输入逻辑,因此 OnSubmit() 中只是将 m_ShouldActivateNextUpdate 设置为 true,以在下一帧激活输入框。并没有做额外的回调函数等的处理。

OnBeginDrag & OnDrag & OnEndDrag

这三个函数是 IBeginDragHandlerIDragHandlerIEndDragHandler 接口的实现,用于处理拖拽事件。对于 InputField 来说,拖拽事件主要是用于选中文本。

InputField.gif

选中文本会进行高亮显示,并且随着拖动会有一个移动的动画效果,这些都是由 InputField 的拖拽事件处理函数实现的。

1
2
3
4
5
6
7
public virtual void OnBeginDrag(PointerEventData eventData)
{
if (!MayDrag(eventData))
return;

m_UpdateDrag = true;
}

OnBeginDrag() 中,首先验证了时间状态,然后将 m_UpdateDrag 设置为 true,表示正处于拖拽状态中。

1
2
3
4
5
6
7
public virtual void OnEndDrag(PointerEventData eventData)
{
if (!MayDrag(eventData))
return;

m_UpdateDrag = false;
}

OnEndDrag() 中则是相反的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public virtual void OnDrag(PointerEventData eventData)
{
if (!MayDrag(eventData))
return;

Vector2 localMousePos;
RectTransformUtility.ScreenPointToLocalPointInRectangle(textComponent.rectTransform, eventData.position, eventData.pressEventCamera, out localMousePos);
caretSelectPositionInternal = GetCharacterIndexFromPosition(localMousePos) + m_DrawStart;

MarkGeometryAsDirty();

m_DragPositionOutOfBounds = !RectTransformUtility.RectangleContainsScreenPoint(textComponent.rectTransform, eventData.position, eventData.pressEventCamera);
if (m_DragPositionOutOfBounds && m_DragCoroutine == null)
m_DragCoroutine = StartCoroutine(MouseDragOutsideRect(eventData));

eventData.Use();
}

OnDrag() 中,会根据鼠标所在位置更新 caretSelectPositionInternal,代表拖拽到的位置。同时在 OnPointerDown() 函数中会更新 caretPositionInternal 的值代表拖拽的起点位置。有了这两个变量,就可以计算出选中的文本区域了。

除了计算拖拽区域外,OnDrag() 还负责一个动画效果的协程 MouseDragOutsideRect(),当拖拽区域超出显示范围就会移动文本,实现上图中的移动效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
IEnumerator MouseDragOutsideRect(PointerEventData eventData)
{
while (m_UpdateDrag && m_DragPositionOutOfBounds)
{
Vector2 localMousePos;
RectTransformUtility.ScreenPointToLocalPointInRectangle(textComponent.rectTransform, eventData.position, eventData.pressEventCamera, out localMousePos);

Rect rect = textComponent.rectTransform.rect;

if (multiLine)
{
if (localMousePos.y > rect.yMax)
MoveUp(true, true);
else if (localMousePos.y < rect.yMin)
MoveDown(true, true);
}
else
{
if (localMousePos.x < rect.xMin)
MoveLeft(true, false);
else if (localMousePos.x > rect.xMax)
MoveRight(true, false);
}
UpdateLabel();
float delay = multiLine ? kVScrollSpeed : kHScrollSpeed;
if (m_WaitForSecondsRealtime == null)
m_WaitForSecondsRealtime = new WaitForSecondsRealtime(delay);
else
m_WaitForSecondsRealtime.waitTime = delay;
yield return m_WaitForSecondsRealtime;
}
m_DragCoroutine = null;
}

该协程函数的逻辑就是根据鼠标位置的变化来移动文本,实现拖拽时文本的移动效果。包含了水平和垂直两种情况。具体的逻辑我们不再深究。

现在我们计算出了拖拽区域,又实现了动画效果,那么高亮显示的逻辑是在哪里实现的呢?

实际上就在 GenerateHighlight() 函数中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private void GenerateHighlight(VertexHelper vbo, Vector2 roundingOffset)
{
int startChar = Mathf.Max(0, caretPositionInternal - m_DrawStart);
int endChar = Mathf.Max(0, caretSelectPositionInternal - m_DrawStart);

// Ensure pos is always less then selPos to make the code simpler
if (startChar > endChar)
{
int temp = startChar;
startChar = endChar;
endChar = temp;
}

endChar -= 1;
TextGenerator gen = m_TextComponent.cachedTextGenerator;

if (gen.lineCount <= 0)
return;

int currentLineIndex = DetermineCharacterLine(startChar, gen);

int lastCharInLineIndex = GetLineEndPosition(gen, currentLineIndex);

UIVertex vert = UIVertex.simpleVert;
vert.uv0 = Vector2.zero;
vert.color = selectionColor; // 高亮的颜色

int currentChar = startChar;
while (currentChar <= endChar && currentChar < gen.characterCount)
{
// 生成高亮显示的顶点
// ...

currentChar++;
}
}

这里省略了部分代码,但可以看到在函数的起始位置分别通过 caretPositionInternalcaretSelectPositionInternal 计算出了 startCharendChar,即选中的文本区域。然后生成顶点并填充至 vbo 中。

至于函数的调用链,GenerateHighlight() 会由 OnFillVBO() 调用,最终被 UpdateGeometry() 调用以将生成的网格全部提交给 CanvasRenderer