Reputation: 290
I have a UITextView
wrapped inside a UIViewRepresentable
. Whenever I edit text inside the view which is binded to a swiftui with a string state variable, the cursor jumps up around 100px and then after like 30 milliseconds back down. (See image)
In the image the cursor is currently directly after "pr"
I have tried things like settings the content adjustment behavior:
textView.contentInsetAdjustmentBehavior = .never
or removing the content inset:
textView.contentInset = UIEdgeInsets.zero
The only thing that removed this behavior was settings the isScrollEnabled
or the textview to false, but that is obviously not an option because now you can't scroll.
Do you have any idea why that happens?
Here is a (not so minimal) example on how to reproduce the error:
Usage:
struct TestView: View {
@State private var text = "Hello, World!"
var body: some View {
SourceEditor(text: $text)
.showsLineNumbers(true)
.textReplacements([])
}
}
Definition:
struct EditorTextReplacement: Equatable {
var replace: String
var with: String
var cursorMovement: EditorCursorMovement
}
enum EditorCursorMovement: Equatable {
case stay
case goback(Int)
case goforth(Int)
var amount: Int? {
switch self {
case .stay:
return nil
case .goback(let amount), .goforth(let amount):
return amount
}
}
}
struct EditorTheme {
var lineNumberSelected: UIColor
var lineNumber: UIColor
var textColor: UIColor
var backgroundColor: UIColor
var selectionColor: UIColor
var gutterWidth: CGFloat
}
extension EditorTheme {
static let defaultLight = EditorTheme(
lineNumberSelected: UIColor(red: 0.75, green: 0.78, blue: 0.80, alpha: 1.0),
lineNumber: UIColor(red: 0.55, green: 0.56, blue: 0.57, alpha: 1.0),
textColor: .black,
backgroundColor: .white,
selectionColor: UIColor(red: 0.75, green: 0.82, blue: 0.96, alpha: 1.0),
gutterWidth: 50
)
static let defaultDark = EditorTheme(
lineNumberSelected: UIColor(red: 0.47, green: 0.50, blue: 0.52, alpha: 1.0),
lineNumber: UIColor(red: 0.60, green: 0.60, blue: 0.60, alpha: 1.0),
textColor: .white,
backgroundColor: UIColor(red: 0.16, green: 0.17, blue: 0.18, alpha: 1.0),
selectionColor: UIColor(red: 0.32, green: 0.37, blue: 0.43, alpha: 1.0),
gutterWidth: 50
)
}
struct SourceEditor: View {
@Binding var text: String
@Environment(\.colorScheme) var colorScheme
private var showLineNumbers: Bool = true
private var editorFont: UIFont = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular)
private var textReplacements: [EditorTextReplacement] = []
private var editorThemeLight: EditorTheme = .defaultLight
private var editorThemeDark: EditorTheme = .defaultDark
init(text: Binding<String>) {
self._text = text
}
var body: some View {
SourceEditorView(
text: $text,
font: editorFont,
showLineNumbers: showLineNumbers,
textReplacements: textReplacements,
theme: colorScheme == .light ? editorThemeLight : editorThemeDark
)
}
func editorTheme(_ theme: EditorTheme, forScheme: ColorScheme? = nil) -> SourceEditor {
var view = self
if let forScheme {
if forScheme == .light {
view.editorThemeLight = theme
} else {
view.editorThemeDark = theme
}
} else {
view.editorThemeLight = theme
view.editorThemeDark = theme
}
return view
}
func editorFont(_ font: UIFont) -> SourceEditor {
var view = self
view.editorFont = font
return view
}
func textReplacements(_ replacements: [EditorTextReplacement]) -> SourceEditor {
var view = self
view.textReplacements = replacements
return view
}
func showsLineNumbers(_ show: Bool) -> SourceEditor {
var view = self
view.showLineNumbers = show
return view
}
}
struct SourceEditorView: UIViewRepresentable {
@Binding var text: String
var font: UIFont
var showLineNumbers: Bool
var textReplacements: [EditorTextReplacement
var theme: EditorTheme
func makeUIView(context: Context) -> SourceEditorClass {
let textView = SourceEditorClass()
textView.delegate = context.coordinator
textView.font = font
textView.textColor = theme.textColor
textView.backgroundColor = theme.backgroundColor
textView.isEditable = true
textView.textContainer.lineBreakMode = .byWordWrapping
textView.autocorrectionType = .no
textView.contentInset = UIEdgeInsets.zero
textView.contentInsetAdjustmentBehavior = .never
textView.keyboardType = .asciiCapable
textView.textReplacements = textReplacements
return textView
}
func updateUIView(_ uiView: SourceEditorClass, context: Context) {
if uiView.text != text {
uiView.text = text
}
uiView.font = font
uiView.updateTheme(theme)
uiView.textReplacements = textReplacements
context.coordinator.textDidChange = { newText in
text = newText
}
}
func makeCoordinator() -> Coordinator {
Coordinator()
}
class Coordinator: NSObject, UITextViewDelegate {
var textDidChange: ((String) -> ())?
func textViewDidChange(_ textView: UITextView) {
textDidChange?(textView.text)
}
}
}
class SourceEditorClass: UITextView {
var textReplacements: [EditorTextReplacement] = []
private var currentLines: [Int] = []
private var theme: EditorTheme = .defaultLight
func updateTheme(_ newTheme: EditorTheme) {
self.theme = newTheme
self.backgroundColor = theme.backgroundColor
self.textColor = theme.textColor
setNeedsDisplay()
}
override func draw(_ rect: CGRect) {
super.draw(rect)
drawLineNumbers()
}
private func drawLineNumbers() {
guard let context = UIGraphicsGetCurrentContext() else { return }
context.saveGState()
context.setFillColor(UIColor.clear.cgColor)
context.fill(bounds)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .left
let attributes: [NSAttributedString.Key: Any] = [
.font: font ?? UIFont.monospacedSystemFont(ofSize: 15, weight: .regular),
.paragraphStyle: paragraphStyle,
.foregroundColor: theme.lineNumber
]
let activeAttributes: [NSAttributedString.Key: Any] = [
.font: font ?? UIFont.monospacedSystemFont(ofSize: 15, weight: .regular),
.paragraphStyle: paragraphStyle,
.foregroundColor: theme.lineNumberSelected
]
var lineNum = 1
var lineRect = CGRect.zero
let fullRange = NSRange(location: 0, length: self.textStorage.length)
var isWrapped = false
layoutManager.enumerateLineFragments(forGlyphRange: fullRange) { (rect, _, _, glyphRange, _) in
let charRange: NSRange = self.layoutManager.glyphRange(forCharacterRange: glyphRange, actualCharacterRange: nil)
let paraRange: NSRange? = (self.textStorage.string as NSString?)?.paragraphRange(for: charRange)
let wrapped = charRange.location == paraRange?.location
if wrapped {
lineRect = CGRect(x: 8, y: rect.origin.y + self.textContainerInset.top, width: self.theme.gutterWidth, height: rect.height)
let attributedString = NSAttributedString(string: "\(lineNum)", attributes: self.currentLines.contains(lineNum) ? activeAttributes : attributes)
attributedString.draw(in: lineRect)
lineNum += 1
}
isWrapped = !wrapped
}
if self.textStorage.string.isEmpty {
let attributedString = NSAttributedString(string: "\(lineNum)", attributes: self.currentLines.contains(lineNum) ? activeAttributes : attributes)
attributedString.draw(at: CGPoint(x: 8, y: self.textContainerInset.top))
}
if self.textStorage.string.hasSuffix("\n") {
let rect = lineRect.offsetBy(dx: 0, dy: isWrapped ? (lineRect.height * 2) : lineRect.height)
let attributedString = NSAttributedString(string: "\(lineNum)", attributes: self.currentLines.contains(lineNum) ? activeAttributes : attributes)
attributedString.draw(in: rect)
}
context.restoreGState()
}
override func layoutSubviews() {
super.layoutSubviews()
textContainerInset.left = theme.gutterWidth
setNeedsDisplay()
}
override var text: String! {
didSet {
setNeedsDisplay()
}
}
override func insertText(_ text: String) {
var superDidInsert = false
for replacement in textReplacements {
if text == replacement.replace {
super.insertText(replacement.with)
switch replacement.cursorMovement {
case .stay:
break
case .goback(let int):
if let newPosition = self.position(from: self.beginningOfDocument, offset: self.offset(from: self.beginningOfDocument, to: self.selectedTextRange!.start) - int) {
self.selectedTextRange = self.textRange(from: newPosition, to: newPosition)
}
case .goforth(let int):
if let newPosition = self.position(from: self.beginningOfDocument, offset: self.offset(from: self.beginningOfDocument, to: self.selectedTextRange!.start) + int) {
self.selectedTextRange = self.textRange(from: newPosition, to: newPosition)
}
}
superDidInsert = true
}
}
if !superDidInsert {
super.insertText(text)
}
}
}
Upvotes: 0
Views: 55
Reputation: 290
I managed to solve it myself.
Step 1:
override func layoutSubviews() {
super.layoutSubviews()
textContainerInset.left = theme.gutterWidth
textContainerInset.right = 0
setNeedsDisplay()
}
Step 2:
func updateUIView(_ uiView: SourceEditorClass, context: Context) {
let selectedRange = uiView.selectedRange // Save the current cursor position
let contentOffset = uiView.contentOffset // Save the current scroll position
if uiView.text != text {
UIView.performWithoutAnimation {
uiView.text = text
uiView.layoutIfNeeded() // Ensure the layout updates
}
}
uiView.font = font
uiView.updateTheme(theme)
uiView.textReplacements = textReplacements
// Adjust scroll offset only if necessary
if uiView.contentSize.height > uiView.bounds.height {
uiView.setContentOffset(contentOffset, animated: false)
}
// Restore the cursor position if it changed
uiView.selectedRange = selectedRange
context.coordinator.textDidChange = { newText in
text = newText
}
}
Step 3:
override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) {
let previousOffset = self.contentOffset
super.setContentOffset(contentOffset, animated: animated)
// Prevent unnecessary scroll jumps
if !animated && previousOffset != contentOffset {
super.setContentOffset(previousOffset, animated: false)
}
}
Upvotes: 0