0

I have a custom UITextView wrapped in SwiftUI using UIViewRepresentable. The goal is to have a dynamic height text view that grows as the user types. Here is a simplified version of my code:

    import SwiftUI

struct ContentView: View {
    @State private var text = populateText()
    @State private var minHeight: CGFloat = 36
    private let lineLimit: Int = 0

    var body: some View {
        NavigationStack {
            ScrollView {
                DynamicTextView(
                    text: $text,
                    font: UIFont.systemFont(ofSize: 16),
                    linesLimit: lineLimit
                )
                .frame(minHeight: minHeight)
                .overlay {
                    RoundedRectangle(cornerRadius: 8)
                        .stroke(Color.gray, lineWidth: 1)
                }
                .padding()
            }
        }
    }
}

struct DynamicTextView: UIViewRepresentable {
    @Binding var text: String
    
    let font: UIFont
    let linesLimit: Int
    
    private var isScrollEnabled: Bool {
        linesLimit > .zero ? true : false
    }
    
    func makeUIView(context: Context) -> UITextView {
        let textView = CustomTextView()
        textView.delegate = context.coordinator
        textView.font = font
        textView.text = text
        textView.backgroundColor = .clear
        textView.textContainer.lineFragmentPadding = 0
        textView.textContainerInset = .init(top: 8, left: 8, bottom: 8, right: 8)
        textView.isScrollEnabled = isScrollEnabled
        return textView
    }
    
    func updateUIView(_ uiView: UITextView, context: Context) {
        if uiView.text != text {
            uiView.text = text
        }
    }
    
    func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
        let width = proposal.width ?? uiView.bounds.width
        
        let fitting = CGSize(width: width, height: .greatestFiniteMagnitude)
        let calculatedHeight = uiView.sizeThatFits(fitting).height
        
        var height = calculatedHeight

        if linesLimit > 0 {
            let lineHeight = font.lineHeight
            let maxHeight = lineHeight * CGFloat(linesLimit) + uiView.textContainerInset.top + uiView.textContainerInset.bottom
            height = min(maxHeight, calculatedHeight)
        }
                
        return CGSize(width: width, height: height)
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(text: $text)
    }
    
    class Coordinator: NSObject, UITextViewDelegate {
        @Binding var text: String
        
        init(text: Binding<String>) {
            self._text = text
        }
        
        func textViewDidChange(_ textView: UITextView) {
            text = textView.text
        }
    }
}

final class CustomTextView: UITextView {
    init() {
        super.init(frame: .zero, textContainer: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

#Preview {
    ContentView()
}

func populateText() -> String {
    var text = ""
     for _ in 0..<300 {
         text += " Large text"
    }
    return text
}

The issue:

When typing a long text, if the system automatically wraps text to a new line, the entire text content jumps back to the top for a moment. The caret remains in the correct place on the new line. If I press Enter to insert a manual line break, the issue does not happen. When I continue typing, the text returns to the correct position, but the jumping is very noticeable.

Question:

Why does UITextView inside SwiftUI jump to the top when the text wraps to a new line, and how can I fix this so the text stays stable while typing?

What the bug looks like.

3
  • Coordinator(self) won’t work cause the representable is a value with no lifetime not an object, where did you get that from? Commented Sep 12 at 10:59
  • Yes, you’re right. I’ve edited the code in the example. But the problem remains. Commented Sep 12 at 11:50
  • That edit won't work if the representable is init again with a new binding because makeCoordinator is only called on the first init. Commented Sep 13 at 21:46

1 Answer 1

0

Avoid forcing layout recalculations on every update and let UITextView report its intrinsic content size. Combine this with .fixedSize(horizontal: false, vertical: true) in SwiftUI and preserve the selection/scroll position when updating.

Try This One :

import SwiftUI
import UIKit

struct ContentView: View {
    @State private var text = populateText()
    
    var body: some View {
        NavigationStack {
            ScrollView {
                DynamicTextView(
                    text: $text,
                    font: UIFont.systemFont(ofSize: 16),
                    linesLimit: 0 // 0 = unlimited
                )
                .fixedSize(horizontal: false, vertical: true) // 👈 expands dynamically
                .overlay {
                    RoundedRectangle(cornerRadius: 8)
                        .stroke(Color.gray, lineWidth: 1)
                }
                .padding()
            }
        }
    }
}

// MARK: - UIViewRepresentable wrapper
struct DynamicTextView: UIViewRepresentable {
    @Binding var text: String
    let font: UIFont
    let linesLimit: Int
    
    private var isScrollEnabled: Bool {
        linesLimit > 0
    }
    
    func makeUIView(context: Context) -> UITextView {
        let textView = CustomTextView()
        textView.delegate = context.coordinator
        textView.font = font
        textView.text = text
        textView.backgroundColor = .clear
        textView.textContainer.lineFragmentPadding = 0
        textView.textContainerInset = .init(top: 8, left: 8, bottom: 8, right: 8)
        textView.isScrollEnabled = isScrollEnabled
        return textView
    }
    
    func updateUIView(_ uiView: UITextView, context: Context) {
        // Only update if text changed (prevents jump)
        guard uiView.text != text else { return }
        let selectedRange = uiView.selectedRange
        uiView.text = text
        uiView.selectedRange = selectedRange
        uiView.scrollRangeToVisible(selectedRange)
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    class Coordinator: NSObject, UITextViewDelegate {
        var parent: DynamicTextView
        
        init(_ parent: DynamicTextView) {
            self.parent = parent
        }
        
        func textViewDidChange(_ textView: UITextView) {
            parent.text = textView.text
        }
    }
}

// MARK: - Custom TextView with intrinsic size
final class CustomTextView: UITextView {
    override var intrinsicContentSize: CGSize {
        let fittingSize = CGSize(width: bounds.width, height: .greatestFiniteMagnitude)
        let size = sizeThatFits(fittingSize)
        return CGSize(width: UIView.noIntrinsicMetric, height: size.height)
    }
    
    init() {
        super.init(frame: .zero, textContainer: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

#Preview {
    ContentView()
}

// MARK: - Sample long text
func populateText() -> String {
    var text = ""
    for _ in 0..<30 {
        text += " Large text"
    }
    return text
}
Sign up to request clarification or add additional context in comments.

1 Comment

Thank you for your response, but it didn’t help. I’ve added a description of what the bug looks like visually.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.