How to set width of editable UITextView in SwiftUI ScrollView?

Viewed 867

I'm building a SwiftUI app and I want to put an editable UITextView (wrapped in UIViewRepresentable) into a SwiftUI ScrollView. The reason for this is that I have other SwiftUI content that I want to put above the text and I want this to scroll together with the text. I need a TextView because I want rich text & a toolbar.

I thought I could disable the scroll on the UITextView and give it infinite height, but this also gives it infinite width, so the text scrolls of the edge of the screen horizontally until you add a newline. I have tried setting the content size and frame size of the textview to the screen width as suggested in posts like How to disable vertical scrolling in UITextView? but I can't make it work. I can update the textview content size successfully but the changes seem to be overwritten later (see print statements in textViewDidChange).

Is there any way of achieving this? i.e. an editable UITextView inside a SwiftUI ScrollView. Thanks in advance!


import SwiftUI


struct TextView: UIViewRepresentable {
    var attributedString: NSAttributedString
    var fixedWidth: CGFloat
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    
    
    class Coordinator: NSObject, UITextViewDelegate {
        var parent: TextView
        
        init(_ parent: TextView) {
            self.parent = parent
        }
        
        func textViewDidChange(_ textView: UITextView) {
            print("pre content size update: \(textView.contentSize)") // width is over limit
            let newSize = textView.sizeThatFits(CGSize(width: parent.fixedWidth, height: CGFloat.greatestFiniteMagnitude))
            textView.contentSize = CGSize(width: max(newSize.width, parent.fixedWidth), height: newSize.height)
            print("post content size update: \(textView.contentSize)") // width is updated
        }
    }
    
    
    
    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView(frame: .zero)
        textView.isEditable = true
        textView.isScrollEnabled = false
        textView.backgroundColor = .cyan // just to make it easier to see
        textView.delegate = context.coordinator
        return textView
    }
    
    func updateUIView(_ textView: UITextView, context: Context) {
        textView.attributedText = self.attributedString
        
    }
}





struct ContentView: View {
    var body: some View {
        GeometryReader { geo in
            ScrollView(.vertical) {
                VStack(alignment: .leading, spacing: /*@START_MENU_TOKEN@*/nil/*@END_MENU_TOKEN@*/){
                    //Other content
                    Image(systemName: "photo")
                        .font(.largeTitle)
                    Button("A button"){
                        print("i'm a button")
                    }
                    
                    //Editable textview
                    TextView(attributedString: NSAttributedString(string: "Here is a long string as you can see it is not wrapping but instead scrolling off the edge of the screen"), fixedWidth: geo.size.width)
                        .frame(width: geo.size.width)
                        .frame(maxHeight: .infinity)
                    
                }
                .padding(.horizontal)
            }
        }
        
    }
}

Some other things I have tried:

  • just using a normal scrollable textview and putting all my content in as text attachments in an NSAttributed string -> way too restrictive for my content and hard to detect user interactions
  • setting anchor constraints on the textview in updateUIView() -> works great when the text is empty but if the screen loads with text in already then the layout of the parent view gets completely rearranged
  • passing a binding from the parent to the textview for height & width like in How to use an NSAttributedString with a ScrollView in SwiftUI? -> doesn't work. I think the problems are less similar than they first appear - I want to set a fixed size for editable text whereas this question wants an adjustable content size for non-editable text
1 Answers

Set width constraint to text view and set GeometryReader at the top of the view.

The reason for the set GeometryReader at top of, because inside the scroll view it will not allow to full scroll.

UIViewRepresentable Text View

struct TextView: UIViewRepresentable {
    var attributedString: NSAttributedString
    var fixedWidth: CGFloat
    
    let textView = UITextView(frame: .zero)
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(self)
    }
    
    
    class Coordinator: NSObject, UITextViewDelegate {
        var parent: TextView
        init(_ parent: TextView) {
            self.parent = parent
        }
    }
    
    func makeUIView(context: Context) -> UITextView {
        textView.isEditable = true
        textView.isScrollEnabled = false
        textView.backgroundColor = .cyan // just to make it easier to see
        textView.delegate = context.coordinator
        textView.translatesAutoresizingMaskIntoConstraints = false
        textView.widthAnchor.constraint(equalToConstant: fixedWidth).isActive = true //<--- Here
        return textView
    }
    
    func updateUIView(_ textView: UITextView, context: Context) {
        DispatchQueue.main.async { //<--- Here
            textView.attributedText = self.attributedString
        }
    }
}

ContentView

struct ContentView: View {
    
    @State private var textViewSize: CGSize = CGSize()
    
    var body: some View {
        GeometryReader { geo in //<--- Here
            ScrollView(.vertical) {
                VStack(alignment: .leading, spacing: 0){
                    //Other content
                    Image(systemName: "photo")
                        .font(.largeTitle)
                    Button("A button"){
                        print("i'm a button")
                    }
                    
                    TextView(attributedString: NSAttributedString(string: "Here is a long string as you can see it is not wrapping but instead scrolling off the edge of the screenHere is a long string as you can see it is not wrapping but instead scrolling off the edge of the screenHere is a long string as you can see it is not wrapping but instead scrolling off the edge of the screenHere is a long string as you can see it is not wrapping but instead scrolling off the edge of the screenHere is a long string as you can see it is \n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new line\n1new lineinstead scrolling off the edge of the screenHere is a long string as you can see it is not wrapping but instead scrolling off the edge of the screenHere is a long string as you can see it is not wrapping but instead scrolling off the edge of the screen ------end"), fixedWidth: geo.size.width - 15 * 2) //<--- Here minus the padding for both size
                }.padding(.horizontal, 15)
            }
        }
    }
}

enter image description here

Related