Closes: https://github.com/damus-io/damus/pull/1666 Reviewed-by: William Casarin <jb55@jb55.com> Signed-off-by: William Casarin <jb55@jb55.com>
206 lines
9.8 KiB
Swift
206 lines
9.8 KiB
Swift
//
|
|
// TextViewWrapper.swift
|
|
// damus
|
|
//
|
|
// Created by Swift on 2/24/23.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct TextViewWrapper: UIViewRepresentable {
|
|
@Binding var attributedText: NSMutableAttributedString
|
|
@EnvironmentObject var tagModel: TagModel
|
|
@Binding var textHeight: CGFloat?
|
|
let initialTextSuffix: String?
|
|
|
|
let cursorIndex: Int?
|
|
var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil
|
|
let updateCursorPosition: ((Int) -> Void)
|
|
|
|
func makeUIView(context: Context) -> UITextView {
|
|
let textView = UITextView()
|
|
textView.backgroundColor = UIColor(DamusColors.adaptableWhite)
|
|
textView.delegate = context.coordinator
|
|
|
|
// Disable scrolling (this view will expand vertically as needed to fit text)
|
|
textView.isScrollEnabled = false
|
|
// Set low content compression resistance to make this view wrap lines of text, and avoid text overflowing to the right
|
|
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
textView.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
|
|
TextViewWrapper.setTextProperties(textView)
|
|
return textView
|
|
}
|
|
|
|
static func setTextProperties(_ uiView: UITextView) {
|
|
uiView.textColor = UIColor.label
|
|
uiView.font = UIFont.preferredFont(forTextStyle: .body)
|
|
let linkAttributes: [NSAttributedString.Key : Any] = [
|
|
NSAttributedString.Key.foregroundColor: UIColor(Color.accentColor)]
|
|
uiView.linkTextAttributes = linkAttributes
|
|
}
|
|
|
|
func updateUIView(_ uiView: UITextView, context: Context) {
|
|
uiView.attributedText = attributedText
|
|
|
|
TextViewWrapper.setTextProperties(uiView)
|
|
setCursorPosition(textView: uiView)
|
|
let range = uiView.selectedRange
|
|
|
|
// Set the text height that will fit all the text
|
|
// This is needed because the UIKit auto-layout prefers to overflow the text to the right than to expand the text box vertically, even with low horizontal compression resistance
|
|
self.setIdealHeight(uiView: uiView)
|
|
|
|
uiView.selectedRange = NSRange(location: range.location + tagModel.diff, length: range.length)
|
|
tagModel.diff = 0
|
|
}
|
|
|
|
/// Based on our desired layout, calculate the ideal size of the text box, then set the height to the ideal size
|
|
private func setIdealHeight(uiView: UITextView) {
|
|
DispatchQueue.main.async { // Queue on main thread, because modifying view state directly during re-render causes undefined behavior
|
|
let idealSize = uiView.sizeThatFits(CGSize(
|
|
width: uiView.frame.width, // We want to stay within the horizontal bounds given to us
|
|
height: .infinity // We can expand vertically without any resistance
|
|
))
|
|
if self.textHeight != idealSize.height { // Only update height when it changes, to avoid infinite re-render calls
|
|
self.textHeight = idealSize.height
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setCursorPosition(textView: UITextView) {
|
|
guard let index = cursorIndex, let newPosition = textView.position(from: textView.beginningOfDocument, offset: index) else {
|
|
return
|
|
}
|
|
textView.selectedTextRange = textView.textRange(from: newPosition, to: newPosition)
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(attributedText: $attributedText, getFocusWordForMention: getFocusWordForMention, updateCursorPosition: updateCursorPosition, initialTextSuffix: initialTextSuffix)
|
|
}
|
|
|
|
class Coordinator: NSObject, UITextViewDelegate {
|
|
@Binding var attributedText: NSMutableAttributedString
|
|
var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil
|
|
let updateCursorPosition: ((Int) -> Void)
|
|
let initialTextSuffix: String?
|
|
var initialTextSuffixWasAdded: Bool = false
|
|
|
|
init(attributedText: Binding<NSMutableAttributedString>,
|
|
getFocusWordForMention: ((String?, NSRange?) -> Void)?,
|
|
updateCursorPosition: @escaping ((Int) -> Void),
|
|
initialTextSuffix: String?
|
|
) {
|
|
_attributedText = attributedText
|
|
self.getFocusWordForMention = getFocusWordForMention
|
|
self.updateCursorPosition = updateCursorPosition
|
|
self.initialTextSuffix = initialTextSuffix
|
|
}
|
|
|
|
func textViewDidChange(_ textView: UITextView) {
|
|
if let initialTextSuffix, !self.initialTextSuffixWasAdded {
|
|
self.initialTextSuffixWasAdded = true
|
|
var mutable = NSMutableAttributedString(attributedString: textView.attributedText)
|
|
let originalRange = textView.selectedRange
|
|
addUnattributedText(initialTextSuffix, to: &mutable, inRange: originalRange)
|
|
attributedText = mutable
|
|
DispatchQueue.main.async {
|
|
self.updateCursorPosition(originalRange.location)
|
|
}
|
|
}
|
|
else {
|
|
attributedText = NSMutableAttributedString(attributedString: textView.attributedText)
|
|
}
|
|
processFocusedWordForMention(textView: textView)
|
|
}
|
|
|
|
private func processFocusedWordForMention(textView: UITextView) {
|
|
var val: (String?, NSRange?) = (nil, nil)
|
|
|
|
guard let selectedRange = textView.selectedTextRange else { return }
|
|
|
|
let wordRange = textView.tokenizer.rangeEnclosingPosition(selectedRange.start, with: .word, inDirection: .init(rawValue: UITextLayoutDirection.left.rawValue))
|
|
|
|
if let wordRange,
|
|
let startPosition = textView.position(from: wordRange.start, offset: -1),
|
|
let cursorPosition = textView.position(from: selectedRange.start, offset: 0)
|
|
{
|
|
let word = textView.text(in: textView.textRange(from: startPosition, to: cursorPosition)!)
|
|
val = (word, convertToNSRange(startPosition, cursorPosition, textView))
|
|
}
|
|
|
|
getFocusWordForMention?(val.0, val.1)
|
|
}
|
|
|
|
private func convertToNSRange( _ startPosition: UITextPosition, _ endPosition: UITextPosition, _ textView: UITextView) -> NSRange? {
|
|
let startOffset = textView.offset(from: textView.beginningOfDocument, to: startPosition)
|
|
let endOffset = textView.offset(from: textView.beginningOfDocument, to: endPosition)
|
|
let length = endOffset - startOffset
|
|
guard length >= 0, startOffset >= 0 else {
|
|
return nil
|
|
}
|
|
return NSRange(location: startOffset, length: length)
|
|
}
|
|
|
|
// This `UITextViewDelegate` method is automatically called by the editor when edits occur, to check whether a change should occur
|
|
// We will use this method to manually handle edits concerning mention ("@") links, to avoid manual text edits to attributed mention links
|
|
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
|
guard let attributedString = textView.attributedText else {
|
|
return true // If we cannot get an attributed string, just fail gracefully and allow changes
|
|
}
|
|
var mutable = NSMutableAttributedString(attributedString: attributedString)
|
|
|
|
let entireRange = NSRange(location: 0, length: attributedString.length)
|
|
var shouldAllowChange = true
|
|
var performEditActionManually = false
|
|
|
|
attributedString.enumerateAttribute(.link, in: entireRange, options: []) { (value, linkRange, stop) in
|
|
guard value != nil else {
|
|
return // This range is not a link. Skip checking.
|
|
}
|
|
|
|
if range.contains(linkRange.upperBound) && range.contains(linkRange.lowerBound) {
|
|
// Edit range engulfs all of this link's range.
|
|
// This link will naturally disappear, so no work needs to be done in this range.
|
|
return
|
|
}
|
|
else if linkRange.intersection(range) != nil {
|
|
// If user tries to change an existing link directly, remove the link attribute
|
|
mutable.removeAttribute(.link, range: linkRange)
|
|
// Perform action manually to flush above changes to the view, and to prevent the character being added from having an attributed link property
|
|
performEditActionManually = true
|
|
return
|
|
}
|
|
else if range.location == linkRange.location + linkRange.length && range.length == 0 {
|
|
// If we are inserting a character at the right edge of a link, UITextInput tends to include the new character inside the link.
|
|
// Therefore, we need to manually append that character outside of the link
|
|
performEditActionManually = true
|
|
return
|
|
}
|
|
}
|
|
|
|
if performEditActionManually {
|
|
shouldAllowChange = false
|
|
addUnattributedText(text, to: &mutable, inRange: range)
|
|
attributedText = mutable
|
|
|
|
// Move caret to the end of the newly changed text.
|
|
updateCursorPosition(range.location + text.count)
|
|
}
|
|
|
|
return shouldAllowChange
|
|
}
|
|
|
|
func addUnattributedText(_ text: String, to attributedString: inout NSMutableAttributedString, inRange range: NSRange) {
|
|
if range.length == 0 {
|
|
attributedString.insert(NSAttributedString(string: text, attributes: nil), at: range.location)
|
|
}
|
|
else {
|
|
attributedString.replaceCharacters(in: range, with: text)
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|