-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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