Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

UITextView - adjust size based on the content in SwiftUI

I am trying to figure out how to make UITextView size dependent on it's contents in SwiftUI. I wrapped the UITextView in UIViewRepresentable as following:

struct TextView: UIViewRepresentable {

    @Binding var showActionSheet: Bool

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UITextView {

        let uiTextView = UITextView()
        uiTextView.delegate = context.coordinator

        uiTextView.font = UIFont(name: "HelveticaNeue", size: 15)
        uiTextView.isScrollEnabled = true
        uiTextView.isEditable = true
        uiTextView.isUserInteractionEnabled = true
        uiTextView.backgroundColor = UIColor(white: 0.0, alpha: 0.05)
        uiTextView.isEditable = false

        return uiTextView
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.attributedText = prepareText(question: question)

        var frame = uiView.frame
        frame.size.height = uiView.contentSize.height
        uiView.frame = frame
    }

    func prepareText(text: string) -> NSMutableAttributedString {
        ...................
        return attributedText
    }

    class Coordinator : NSObject, UITextViewDelegate {

        var parent: TextView

        init(_ view: TextView) {
            self.parent = view
        }

        func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange) -> Bool {
            parent.showActionSheet = true
            return false
        }
    }
}

Further, I tried to change the frame size in updateUIView based on it's content size, but it did not have any effect. I suppose that at this stage, the view is not even layout and its frame is being overridden somewhere else. I would really appreciate if someone could point me in a correct direction.

like image 264
Adam Avatar asked Nov 02 '19 10:11

Adam


1 Answers

I was able to get this to work by binding a variable in the TextView that is used by the encapsulating view to set the frame height. Here is the minimal TextView implementation:

struct TextView: UIViewRepresentable {

    @Binding var text: String?
    @Binding var attributedText: NSAttributedString?
    @Binding var desiredHeight: CGFloat

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    func makeUIView(context: Context) -> UITextView {

        let uiTextView = UITextView()
        uiTextView.delegate = context.coordinator

        // Configure text view as desired...
        uiTextView.font = UIFont(name: "HelveticaNeue", size: 15)

        return uiTextView
    }

    func updateUIView(_ uiView: UITextView, context: Context) {
        if self.attributedText != nil {
            uiView.attributedText = self.attributedText
        } else {
            uiView.text = self.attributedText
        }

        // Compute the desired height for the content
        let fixedWidth = uiView.frame.size.width
        let newSize = uiView.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.greatestFiniteMagnitude))

        DispatchQueue.main.async {
            self.desiredHeight = newSize.height
        }
    }

    class Coordinator : NSObject, UITextViewDelegate {

        var parent: TextView

        init(_ view: TextView) {
            self.parent = view
        }

        func textViewDidEndEditing(_ textView: UITextView) {
            DispatchQueue.main.async {
                self.parent.text = textView.text
                self.parent.attributedText = textView.attributedText
            }
        }
    }
}

The key is the binding of desiredHeight, which is computed in updateUIView using the UIView sizeThatFits method. Note that this is wrapped in a DispatchQueue.main.async block to avoid the SwiftUI "Modifying state during view update" error.

I can now use this view in my ContentView:

struct ContentView: View {

    @State private var notes: [String?] = [
        "This is a short Note",
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Lorem ipsum dolor sit amet consectetur. Morbi enim nunc faucibus a. Nunc pulvinar sapien et ligula ullamcorper malesuada proin libero.",
    ]

    @State private var desiredHeight: [CGFloat] = [0, 0]

    var body: some View {
        List {
            ForEach(0..<notes.count, id: \.self) { index in
                TextView(
                    desiredHeight: self.$desiredHeight[index],
                    text: self.$notes[index],
                    attributedText: .constant(nil)
                )
                .frame(height: max(self.desiredHeight[index], 100))
            }
        }
    }
}

Here I have a couple of notes in a String array, along with an array of desiredHeight values to bind to the TextView. The height of the TextView is set in the frame modifier on the TextView. In this example, I also set a minimum height to give some space for the intial edit. The frame height only updates when one of the State values (in this case the notes) changes. In the implementation of the TextView here, this only occurs when editing ends on the text view.

I tried updating the text in the textViewDidChange delegate function in the Coordinator. This updates the frame height as you add text, but makes it so that you can only ever type text at the end of the TextView since updating the view resets the insertion point to the end!

like image 188
RocketDoc Avatar answered Nov 04 '22 17:11

RocketDoc