Skip to content

Instantly share code, notes, and snippets.

@migueldeicaza
Forked from CrystDragon/MyTextLabel.swift
Created January 26, 2021 03:47
Show Gist options
  • Save migueldeicaza/4e7816d91f4b7cd6bf1b5a5d99d43ff6 to your computer and use it in GitHub Desktop.
Save migueldeicaza/4e7816d91f4b7cd6bf1b5a5d99d43ff6 to your computer and use it in GitHub Desktop.
Most basic custom UITextInput conformance, without selection interaction, no UI elements but only pure texts.
import UIKit
class MyTextLabel: UIView {
var textLayer = CATextLayer()
var textStorage: String = "" {
didSet {
textLayer.string = textStorage
}
}
override init(frame: CGRect) {
let initialPosition = MyTextPosition(offset: 0)
_selectedTextRange = MyTextRange(from: initialPosition, to: initialPosition)
super.init(frame: frame)
commonSetup()
}
required init?(coder aDecoder: NSCoder) {
let initialPosition = MyTextPosition(offset: 0)
_selectedTextRange = MyTextRange(from: initialPosition, to: initialPosition)
super.init(coder: aDecoder)
commonSetup()
}
func commonSetup() {
textLayer.isWrapped = true
textLayer.fontSize = 12
textLayer.alignmentMode = kCAAlignmentNatural
textLayer.foregroundColor = UIColor.magenta.cgColor
textLayer.string = textStorage
layer.addSublayer(textLayer)
}
override var canBecomeFirstResponder: Bool {
return true
}
override func becomeFirstResponder() -> Bool {
let success = super.becomeFirstResponder()
if success {
let initialPosition = MyTextPosition(offset: textStorage.count)
_selectedTextRange = MyTextRange(from: initialPosition, to: initialPosition)
}
return success
}
override func layoutSubviews() {
super.layoutSubviews()
textLayer.frame = bounds
}
// MARK: delegates
var inputDelegate: UITextInputDelegate?
lazy var tokenizer: UITextInputTokenizer = UITextInputStringTokenizer(textInput: self)
// MARK: - ranges
// we will update _selectedTextRange & _markedTextRange whenever string is changed
var _selectedTextRange: MyTextRange {
didSet {
print("\(textStorage), selected range change to \(_selectedTextRange)")
}
}
var selectedTextRange: UITextRange? {
get {
return _selectedTextRange
}
set {
if let range = newValue as? MyTextRange {
_selectedTextRange = range
} else {
fatalError()
}
}
}
var _markedTextRange: MyTextRange? {
didSet {
if let range = _markedTextRange {
print("\(textStorage), marked range change to \(range)")
} else {
print("\(textStorage), marked range cleared")
}
}
}
var markedTextRange: UITextRange? {
return _markedTextRange
}
var _markedTextStyle: [AnyHashable : Any]?
var markedTextStyle: [AnyHashable : Any]? {
set {
_markedTextStyle = newValue
}
get {
return _markedTextStyle
}
}
}
extension MyTextLabel: UITextInput {
// MARK: - basic
func insertText(_ text: String) {
// insert operation takes effect on current focus point (marked or selected)
print("\(textStorage), insertText: \(text)")
// after insertion, marked range is always cleared, and length of selected range is always zero
let rangeToReplace = _markedTextRange ?? _selectedTextRange
let rangeStartIndex = rangeToReplace.startPosition.offset
textStorage.replaceSubrange(rangeToReplace.fullRange(in: textStorage), with: text)
_markedTextRange = nil
let insertedPosition = MyTextPosition(offset: rangeStartIndex + text.count)
_selectedTextRange = MyTextRange(from: insertedPosition, to: insertedPosition)
}
func deleteBackward() {
// deleteBackward operation takes effect on current focus point (marked or selected)
print("\(textStorage), deleteBackward")
// after backward deletion, marked range is always cleared, and length of selected range is always zero
let rangeToDelete = _markedTextRange ?? _selectedTextRange
var rangeStartPosition = rangeToDelete.startPosition
var rangeStartIndex = rangeStartPosition.offset
if rangeToDelete.isEmpty {
if rangeStartIndex == 0 {
return
}
rangeStartIndex -= 1
textStorage.remove(at: textStorage.index(textStorage.startIndex, offsetBy: rangeStartIndex))
rangeStartPosition = MyTextPosition(offset: rangeStartIndex)
} else {
textStorage.removeSubrange(rangeToDelete.fullRange(in: textStorage))
}
_markedTextRange = nil
_selectedTextRange = MyTextRange(from: rangeStartPosition, to: rangeStartPosition)
}
var hasText: Bool {
return !textStorage.isEmpty
}
func setMarkedText(_ string: String?, selectedRange: NSRange) {
// setMarkedText operation takes effect on current focus point (marked or selected)
print("\(textStorage), setMarkedText: \(string as Any), selection: \(selectedRange)")
// after marked text is updated, old selection or markded range is replaced,
// new marked range is always updated
// and new selection is always changed to a new range with in
let rangeToReplace = _markedTextRange ?? _selectedTextRange
let rangeStartPosition = rangeToReplace.startPosition
if let newString = string {
textStorage.replaceSubrange(rangeToReplace.fullRange(in: textStorage), with: newString)
let rangeStartIndex = rangeStartPosition.offset
let swiftRange = Range(selectedRange, in: newString)!
let swiftRangeOffset = newString.distance(from: newString.startIndex, to: swiftRange.lowerBound)
let swiftRangeLength = newString.distance(from: swiftRange.lowerBound, to: swiftRange.upperBound)
let selectionStartIndex = rangeStartIndex + swiftRangeOffset
_markedTextRange = MyTextRange(from: rangeStartPosition, maxOffset: newString.count, in: textStorage)
_selectedTextRange = MyTextRange(from: MyTextPosition(offset: selectionStartIndex),
to: MyTextPosition(offset: selectionStartIndex + swiftRangeLength))
} else {
textStorage.removeSubrange(rangeToReplace.fullRange(in: textStorage))
_markedTextRange = nil
_selectedTextRange = MyTextRange(from: rangeStartPosition, to: rangeStartPosition)
}
}
func unmarkText() {
// unmarkText operation takes effect on current focus point (marked or selected)
print("\(textStorage), unmarkText")
// after unmark, marked range is cleared and selection range is at end of previously marked area
if let previouslyMarkedRange = _markedTextRange {
let rangeEndPosition = previouslyMarkedRange.endPosition
_selectedTextRange = MyTextRange(from: rangeEndPosition, to: rangeEndPosition)
_markedTextRange = nil
}
}
// MARK: - replacing text
func text(in range: UITextRange) -> String? {
guard let myRange = range as? MyTextRange else {
fatalError()
}
// print("asking for current range: \(myRange)")
if range.isEmpty {
return nil
} else {
return String(textStorage[myRange.fullRange(in: textStorage)])
}
}
func replace(_ range: UITextRange, withText text: String) {
// replace operation takes effect on designated range
guard let myRange = range as? MyTextRange else {
fatalError()
}
guard _markedTextRange == nil else {
fatalError("current logic relies on the assumption that when this method is called, there's no marked area")
}
print("\(textStorage), replacing range \(myRange) with text: \(text)")
// save enough dat before string manipulation
let insertionIndex = myRange.startPosition.offset
// after replacement is fulfilled, selected range might change
// if the replace range overlapses with selected range, selection is cleared
textStorage.replaceSubrange(myRange.fullRange(in: textStorage), with: text)
if myRange.endPosition.offset <= _selectedTextRange.startPosition.offset {
// selected range should change
let selectionOffset = _selectedTextRange.startPosition.offset - insertionIndex
let newSelectionOffset = selectionOffset - myRange.length + text.count
let newSelectionIndex = newSelectionOffset + insertionIndex
_selectedTextRange = MyTextRange(from: MyTextPosition(offset: newSelectionIndex),
to: MyTextPosition(offset: newSelectionIndex + _selectedTextRange.length))
} else if myRange.startPosition.offset >= _selectedTextRange.endPosition.offset {
// do nothing
} else {
// has intersection
let insertionEndPosition = MyTextPosition(offset: insertionIndex + text.count)
_selectedTextRange = MyTextRange(from: insertionEndPosition, to: insertionEndPosition)
}
}
// MARK: - Computing Ranges and Positions
func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? {
guard let from = fromPosition as? MyTextPosition, let to = toPosition as? MyTextPosition else {
fatalError()
}
print("[Geometry] form range [\(from) ..< \(to)]")
return MyTextRange(from: from, to: to)
}
func position(from position: UITextPosition, offset: Int) -> UITextPosition? {
guard let from = position as? MyTextPosition else {
fatalError()
}
print("[Geometry] form position \(from) + \(offset)")
// sometimes the system may want to know off-the-one positions, we should just return boundary
// if we return nil, a guarded fatal error will trigger somewhere else
let newOffset = max(min(from.offset + offset, textStorage.count), 0)
return MyTextPosition(offset: newOffset)
}
func position(from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int) -> UITextPosition? {
return self.position(from: position, offset: offset)
}
var beginningOfDocument: UITextPosition {
return MyTextPosition(offset: 0)
}
var endOfDocument: UITextPosition {
return MyTextPosition(offset: textStorage.count)
}
func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult {
guard let from = position as? MyTextPosition, let to = other as? MyTextPosition else {
fatalError()
}
if from.offset < to.offset {
return .orderedAscending
}
if from.offset > to.offset {
return .orderedDescending
}
return .orderedSame
}
func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int {
guard let from = from as? MyTextPosition, let to = toPosition as? MyTextPosition else {
fatalError()
}
// print("[Geometry] form offset \(to) - \(from)")
return to.offset - from.offset
}
// MARK: - Geometry
func firstRect(for range: UITextRange) -> CGRect {
return bounds
}
func caretRect(for position: UITextPosition) -> CGRect {
return bounds
}
func closestPosition(to point: CGPoint) -> UITextPosition? {
return MyTextPosition(offset: 0)
}
func selectionRects(for range: UITextRange) -> [Any] {
guard let myRange = range as? MyTextRange else {
fatalError()
}
return [MyTextSelectionRect(rect: bounds, range: myRange, string: textStorage)]
}
func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? {
guard let myRange = range as? MyTextRange else {
fatalError()
}
return myRange.startPosition
}
func characterRange(at point: CGPoint) -> UITextRange? {
return MyTextRange(from: MyTextPosition(offset: 0),
to: MyTextPosition(offset: textStorage.count))
}
// MARK: - Layout Direction
func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? {
return range.end
}
func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? {
guard let myPosition = position as? MyTextPosition else {
fatalError()
}
return MyTextRange(from: myPosition, to: MyTextPosition(offset: textStorage.count))
}
func baseWritingDirection(for position: UITextPosition, in direction: UITextStorageDirection) -> UITextWritingDirection {
return .leftToRight
}
func setBaseWritingDirection(_ writingDirection: UITextWritingDirection, for range: UITextRange) {
// do nothing
}
// MARK: - Dictation
func dictationRecordingDidEnd() {
print("\(textStorage), dictation recording end")
}
func dictationRecognitionFailed() {
print("\(textStorage), dictation failed")
}
func insertDictationResult(_ dictationResult: [UIDictationPhrase]) {
print("\(textStorage), insertDictationResult: \(dictationResult)")
let text = dictationResult.map{$0.text}.joined()
insertText(text)
}
// if we don't implement these 2 methods, a very special string will append to textStorage
var insertDictationResultPlaceholder: Any {
return "[DICT]"
}
func removeDictationResultPlaceholder(_ placeholder: Any, willInsertResult: Bool) {
print("removeDictationResultPlaceholder")
}
// MARK: - Optional
func position(within range: UITextRange, atCharacterOffset offset: Int) -> UITextPosition? {
guard let myRange = range as? MyTextRange else {
fatalError()
}
let endOffset = myRange.startPosition.offset + offset
if endOffset > myRange.endPosition.offset {
return nil
}
return MyTextPosition(offset: endOffset)
}
func characterOffset(of position: UITextPosition, within range: UITextRange) -> Int {
guard let myRange = range as? MyTextRange, let position = position as? MyTextPosition else {
fatalError()
}
return position.offset - myRange.startPosition.offset
}
}
import Foundation
import UIKit
class MyTextPosition: UITextPosition {
let offset: Int
init(offset: Int) {
self.offset = offset
}
}
extension MyTextPosition {
override var description: String {
return "\(offset)"
}
}
class MyTextRange: UITextRange {
let startPosition: MyTextPosition
let endPosition: MyTextPosition
// from may be larger than to
// from and to must each contain a valid indices
init(from: MyTextPosition, to: MyTextPosition) {
let start, end: MyTextPosition
if from.offset < to.offset {
start = from
end = to
} else {
start = to
end = from
}
self.startPosition = start
self.endPosition = end
}
// maxLength may be negative
// from must contain a valid index
init(from: MyTextPosition, maxOffset: Int, in baseString: String) {
if maxOffset >= 0 {
self.startPosition = from
let end = min(baseString.count, from.offset + maxOffset)
self.endPosition = MyTextPosition(offset: end)
} else {
self.endPosition = from
let begin = max(0, from.offset + maxOffset)
self.startPosition = MyTextPosition(offset: begin)
}
}
override var start: UITextPosition {
return startPosition
}
override var end: UITextPosition {
return endPosition
}
override var isEmpty: Bool {
return startPosition.offset >= endPosition.offset
}
func fullRange(in baseString: String) -> Range<String.Index> {
let beginIndex = baseString.index(baseString.startIndex, offsetBy: startPosition.offset)
let endIndex = baseString.index(beginIndex, offsetBy: endPosition.offset - startPosition.offset)
return beginIndex..<endIndex
}
var length: Int {
return endPosition.offset - startPosition.offset
}
}
extension MyTextRange {
override var description: String {
return "[\(startPosition.offset) ..< \(endPosition.offset)]"
}
}
class MyTextSelectionRect: UITextSelectionRect {
let _rect: CGRect
let _containsStart: Bool
let _containsEnd: Bool
override var writingDirection: UITextWritingDirection {
return .leftToRight
}
override var isVertical: Bool {
return false
}
override var rect: CGRect {
return _rect
}
override var containsStart: Bool {
return _containsStart
}
override var containsEnd: Bool {
return _containsEnd
}
init(rect: CGRect, range: MyTextRange, string: String) {
_rect = rect
_containsStart = range.startPosition.offset == 0
_containsEnd = range.endPosition.offset == string.count
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment