UITextInput 协议实现自定义输入框

2024, Nov 20    

平实开发过程中比较少接触的一个话题: 如何使用 iOS 底层的 UITextInput 协议与 UIKeyInput 协议实现自定义输入框; 其中由于 UITextInput 本身集成了 UIKeyInput,因此本文中对 UITextInput 的讨论实际上也会包含对 UIKeyInput 相关协议的讨论 (e.g. insertText(:String))

最近因为项目的关系使用了 UnrealEngine 的输入组件,由于组件本身并非原生 UITextField, 而是基于上述协议自己实现并通过引擎渲染的输入框;为了排查输入框使用英文输入时 自动补全失效的问题手写了一个简单的输入框组件尝试对该协议背后的运作方式进行理解.

1.基础模型

UITextPosition

用于表达输入框中特定位置,比如说指针位置; 这里我们使用 CustomTextPosition 实现

class CustomTextPosition: UITextPosition {

    var location: Int

    init(location: Int) {
        self.location = location
    }
}

UITextRange

通过两个 UITextPosition 组成的范围; 支持对应的范围标识/以及 UI 层的高亮渲染

class CustomTextRange: UITextRange {

    var innerStart: CustomTextPosition
    var innerEnd: CustomTextPosition

    override var isEmpty: Bool {
        return false
    }

    override var start: UITextPosition {
        get {
            innerStart
        }
        set {
            if let newValue = newValue as? CustomTextPosition {
                innerStart = newValue
            }
        }
    }

    override var end: UITextPosition {
        get {
            innerEnd
        }
        set {
            if let newValue = newValue as? CustomTextPosition {
                innerEnd = newValue
            }
        }
    }

    init(start: CustomTextPosition, end: CustomTextPosition) {
        self.innerStart = start
        self.innerEnd = end
    }
}

2.核心协议

由于网络资源有限,缺少对该协议的说明,经过一段时间的实验摸索,总结出以下的交互原理. 其中需要注意的点在于: 对于 Multi-Stage

[通用字段] selectedTextRange

当前活跃的文本范围;在实验以后,发现只有 selectedTextRange 长度不为 0 且指向特定的文本,才会反馈给 Tokenizer 进行词语预测或者非英文语言的 multi-stage 中的最后一步处理. selectedTextRange 改变可能由以下场景引起

  • 正常输入,在没有中断字符的时候(比如说 “ ”)则需要不断延长 selectedTextRange 来标识当前的活跃文案区间
  • 用户长按文本,手动识别出需要标识的文案范围

[Multi-Stage Typing 语言] markedTextRange

If there is no marked text, the value of the property is `nil`. Marked text is provisionally inserted text that requires user confirmation; it occurs in multistage text input. The current selection, which can be a caret or an extended range, always occurs within the marked text.
当前在 text 文本输入框中还未确定的,在 multi-stage 输入语言中待确定的文本范围 (

并且这里要求 selectedTextRange 始终在 markedTextRange 中) 因此可推导:

  • 在更新 markedTextRange 时, 需要同步更新 selectedTextRange, 这样才能及时更新 auto predict

[Multi-Stage Typing 语言] setMarkedText(:String,:Range)

将新的需要 marked text 更新到 当前的 range 中,这里的 range 就是目前存储的 markedTextRange;

  • 这里需要手动更新整体的字体,
  • 这里需要手动更新 markedTextRange,
  • 这里需要手动更新selectedTextRange

[Multi-Stage Typing 语言] unmarkText()

在用户在 markedText 中确认了 selectedTextRange 需要替换的文案时,点击自动提示栏的文案会触发, 这个函数只是用来清理 marketTextRange 以及 markedText

[Direct Typing 语言] replace(:String, :Range)

在非 mutli-stage 的语言类型,比如说英文等场景时,当前选中的文本需要被 auto correct 时,该函数被调用

[Direct Typing 语言] insertText(:String)

在非 mutli-stage 的语言类型,比如说英文等场景时,在正常用户使用键盘输入时,会调用该方法

3.边缘协议

一些 UITextPosition 之间的换算, 以及 UITextRange 的计算等逻辑,可以在自己提供 UITextPosition 以及 UITextRange 的自定义实现类后轻松推导; 函数列表如下:

func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? {
    guard let from = fromPosition as? CustomTextPosition,
          let to = toPosition as? CustomTextPosition else {
        return nil
    }

    guard from.location >= 0, from.location <= text.count,
          to.location >= 0, to.location <= text.count else {
        return nil
    }

    return CustomTextRange.init(start: from, end: to)
}

func position(from position: UITextPosition, offset: Int) -> UITextPosition? {
    guard let textPosition = position as? CustomTextPosition else {
        return nil
    }

    let newIndex = textPosition.location + offset
    guard newIndex >= 0 && newIndex <= text.count else {
        return nil
    }

    return CustomTextPosition(location: newIndex)
}

func position(from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int) -> UITextPosition? {
    guard let textPosition = position as? CustomTextPosition else {
        return nil
    }

    var newIndex = textPosition.location
    switch direction {
    case .right:
        newIndex += offset
    case .left:
        newIndex -= offset
    case .up, .down:
        return nil
    @unknown default:
        return nil
    }

    guard newIndex >= 0 && newIndex <= text.count else {
        return nil
    }

    return CustomTextPosition(location: newIndex)
}

func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult {
    guard let pos1 = position as? CustomTextPosition,
          let pos2 = other as? CustomTextPosition else {
        return .orderedSame
    }

    if pos1.location < pos2.location {
        return .orderedAscending
    } else if pos1.location > pos2.location {
        return .orderedDescending
    } else {
        return .orderedSame
    }
}

func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int {
    guard let fromPos = from as? CustomTextPosition,
          let toPos = toPosition as? CustomTextPosition else {
        return 0
    }

    return toPos.location - fromPos.location
}

func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? {
    guard let textRange = range as? CustomTextRange else {
        return nil
    }

    switch direction {
    case .left:
        return textRange.start
    case .right:
        return textRange.end
    case .up, .down:
        return nil
    @unknown default:
        return nil
    }
}

func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? {
    guard let textPosition = position as? CustomTextPosition else {
        return nil
    }

    let currentIndex = textPosition.location
    let nextIndex: Int?

    switch direction {
    case .right:
        nextIndex = currentIndex + 1 < text.count ? currentIndex + 1 : nil
    case .left:
        nextIndex = currentIndex - 1 >= 0 ? currentIndex - 1 : nil
    case .up, .down:
        return nil
    @unknown default:
        return nil
    }

    guard let validIndex = nextIndex else {
        return nil
    }

    let start = min(currentIndex, validIndex)
    let end = max(currentIndex, validIndex)

    return CustomTextRange(
        start: CustomTextPosition(location: start),
        end: CustomTextPosition(location: end)
    )
}

func baseWritingDirection(for position: UITextPosition, in direction: UITextStorageDirection) -> NSWritingDirection {
    return .leftToRight
}

func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) {

}

4.渲染实现

渲染实现需要关注以下点:

  • cursor 应该指向 selectedRange 中最后的位置, 并提供闪烁
  • selectedRange 应该高亮处理;参考苹果 UITextField 的编辑表现
  • cursor 以及 selectedRange 需要支持手势设置,因此需要支持能够将 touch 坐标转换到对应的字符位置.

因此渲染方案上需要采用 textStorage 加 textContainer 的实现方式:

private let textStorage = NSTextStorage()
private let layoutManager = NSLayoutManager()
private let textContainer = NSTextContainer()

// ...

override func draw(_ rect: CGRect) {
    super.draw(rect)

    // 在选择文案
    if selectedRange.length > 0 {
        let selectionRange = layoutManager.characterRange(forGlyphRange: selectedRange, actualGlyphRange: nil)
        layoutManager.enumerateEnclosingRects(forGlyphRange: selectionRange, withinSelectedGlyphRange: selectedRange, in: textContainer) { rect, stop in
            let selectionRect = rect.offsetBy(dx: 0, dy: 0)
            let selectionPath = UIBezierPath(rect: selectionRect)
            UIColor.systemBlue.withAlphaComponent(0.3).setFill()
            selectionPath.fill()
        }
    }

    // Caret 会根据动画周期性on/off 支持闪烁效果
    if isCaretVisible {
        let caretRect = self.caretRect(for: CustomTextPosition(location: selectedRange.location + selectedRange.length - 1))
        let caretPath = UIBezierPath(rect: caretRect)
        UIColor.red.setFill()
        caretPath.fill()
    }

    let glyphRange = layoutManager.glyphRange(for: textContainer)
    layoutManager.drawBackground(forGlyphRange: glyphRange, at: .zero)
    layoutManager.drawGlyphs(forGlyphRange: glyphRange, at: .zero)
}

这里需要注意的是在计算 caretRect 的过程中,传入的 position 指向的是 Swift 中 grapheme cluster 的位置, 而需要进一步转换成 NSLayoutManager 中对应 glyph 的位置再进行渲染; 由于 grapheme (字符簇) cluster 无法与 glyph (字形) index 直接交换. 因此中间需要通过 utf16 index 进行转化

func caretRect(for position: UITextPosition) -> CGRect {
    // numerical offset -> str index -> utf 16 index -> utf 16 offset -> glyph index
    let graphemeIndex = position.location
    let stringIndex = text.index(text.startIndex, offsetBy: graphemeIndex)
    let utf16Index = stringIndex.samePosition(in: text.utf16)
    let glyphIndex = layoutManager.glyphIndexForCharacter(at: utf16Offset)

    // detect caretRect bounds
    let rect = layoutManager.boundingRect(
        forGlyphRange: NSRange(location: glyphIndex, length: 1),
        in: textContainer
    )
}

此外,如果需要支持手势比如移动光标等操作, 相当于需要从手势位置通过 NSLayoutManager 换算 glyphIndex, 再通过 glyphIndex 换算 utf-16 character index, 最后将同样的 utf-16 character index 换算回 String 中的 grapheme cluster index:

func closestPosition(to point: CGPoint) -> UITextPosition? {
    // glyph index -> character index -> utf 16 index -> str index -> numerical offset
    let glyphIndex = layoutManager.glyphIndex(for: point, in: textContainer)
    let characterIndex = glyphIndex > 0 ? layoutManager.characterIndexForGlyph(at: glyphIndex) : 0
    guard characterIndex <= textStorage.length else { return CustomTextPosition(location: text.count) }
    let utf16Index = text.utf16.index(text.utf16.startIndex, offsetBy: characterIndex)
    guard let graphemeClusterIndex = utf16Index.samePosition(in: text) else { return CustomTextPosition(location: text.count) }

    let location = text.distance(from: text.startIndex, to: graphemeClusterIndex)
    return CustomTextPosition(location: location > 0 ? location + 1 : 0)
}

5.编辑流程实现

[Multi-Stage Typing]

核心逻辑总结如下:

  • 触发 Multi-Stage 输入,输入开始
    • Multi-Stage 字符更新, 此时缓存中的 markedRange 为空
    • 在光标位置插入 markedText
    • 创建 markedRange, loc = 当前 cursor 位置, length = 输入 markedText 长度
    • 同步 selectedRange 为 markedRange(selectedRange 需要在 markedRange 内)
  • 用户继续输入字母单词
    • Multi-Stage 字符更新, 此时缓存中的 markedRange 不为空
    • 将 markedRange 中的内容替换为传入的 markedText
    • 更新 markedRange, loc = markedRange.loc, length = markedText.count
    • 同步 selectedRange 为 markedRange(selectedRange 需要在 markedRange 内)
  • 用户在 Predictive Bar 选择最终的文案,
    • Multi-Stage 字符更新, 此处重复 用户继续输入字母单词的操作
    • 随后触发 unmarkText 操作:
      • 将 selectedRange 设置为 markedRange 最后的那个 position
      • 将 markedRange 至空,因为 Multi-Stage Input 模式已经结束

func setMarkedText(_ markedText: String?, selectedRange: NSRange) {

    guard let markedText = markedText else {
        markedRange = nil
        return
    }

    if let markedRange = self.markedRange {
        let startIndex = markedRange.location
        let endIndex = markedRange.location + markedRange.length
        let start: String.Index = text.index(text.startIndex, offsetBy: startIndex)
        let end: String.Index = text.index(text.startIndex, offsetBy: endIndex)
        self.text.replaceSubrange(start..<end, with: markedText)

        self.markedRange = NSRange.init(location: startIndex, length: markedText.count)
    } else {
        let cursorIndex = self.selectedRange.location >= text.count ? text.count : self.selectedRange.location
        let insertPosition = text.index(text.startIndex, offsetBy: cursorIndex)
        self.text.insert(contentsOf: markedText, at: insertPosition)

        self.markedRange = NSRange.init(location: cursorIndex, length: markedText.count)
    }

    self.selectedRange = self.markedRange!

    setNeedsDisplay()
}

func unmarkText() {

    self.selectedRange = NSRange.init(
        location: (markedRange != nil) ? markedRange!.location + markedRange!.length : text.count,
        length: 0
    )
    self.markedRange = nil
}

[Direct Typing]

核心逻辑总结如下:

  • 用户正常键盘输入
    • 情景 1: 非 “ “, “\n” 等分段切割字符时,selectedRange 长度保持递增,loc 不变
    • 情景 2: 出现 “ “, “\n” 等分段切割字符时, selectedRange 长度归零,loc 指向当前 cursor
  • 在情景 1 中,selectedRange 对应的文本会被提取到 tokenizer 进行分析,潜在的替换/autoCorrection 会提示在 predictive bar, 当用户从 predictive bar 进行选词后
    • 使用 predictive bar 反馈的文本对 selectedRange 中的内容进行替换
    • 输入 “ “ 分割字符,标注当前操作终止,开始下一段输入

func insertText(_ text: String) {
    self.text.append(text)

    if text != " ", let result = self.text.lastSubstringAndRange() {
        self.selectedRange.location = result.range.location
        self.selectedRange.length = result.range.length
    } else {
        self.selectedRange.location = self.text.count
        self.selectedRange.length = 0
    }

    setNeedsDisplay()
}

func replace(_ range: UITextRange, withText newText: String) {
    guard let textRange = range as? CustomTextRange else { return }
    let startIndex = textRange.innerStart.location
    let endIndex = textRange.innerEnd.location

    guard startIndex >= 0, endIndex <= text.count, startIndex <= endIndex else {
        return
    }

    let start = text.index(text.startIndex, offsetBy: startIndex)
    let end = text.index(text.startIndex, offsetBy: endIndex)
    text.replaceSubrange(start..<end, with: newText)

    markedRange = nil
    selectedRange = NSRange(location: startIndex + newText.count, length: 0)
    insertText(" ")

    setNeedsDisplay()
}
TOC