How to get dynamic height for UITextView in SwiftUI
Asked Answered
T

1

6

I'm getting really close to implementing dynamic height for a UITextView in SwiftUI. Please help me work out these kinks:

  1. The UITextView has the correct height when it appears but does not adjust height as editing is being performed; I would like it to adjust.
  2. I'm receiving this in the console every time I edit the text in the TextView: [SwiftUI] Modifying state during view update, this will cause undefined behavior.

Here's my code:

ItemEditView

TextView(text: $notes, textViewHeight: $textViewHeight)
    .frame(height: self.textViewHeight)

UITextView

import SwiftUI

struct TextView: UIViewRepresentable {
    @Binding var text: String
    @Binding var textViewHeight: CGFloat

    func makeUIView(context: Context) -> UITextView {
        let textView = UITextView()
        textView.delegate = context.coordinator
        textView.font = .systemFont(ofSize: 17)
        textView.backgroundColor = .clear

        return textView
    }

    func updateUIView(_ textView: UITextView, context: Context) {
        textView.text = text
    }

    class Coordinator: NSObject, UITextViewDelegate {
        var control: TextView

        init(_ control: TextView) {
            self.control = control
        }

        func textViewDidChange(_ textView: UITextView) {
            control.text = textView.text
        }
    }

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

Similar questions have been asked, but none of the solutions worked for me.

Thorson answered 18/3, 2020 at 2:22 Comment(6)
The following topic should be helpful How do I create a multiline TextField in SwiftUI?Shelby
Thanks, Asperi! I would have commented on your post but don't have enough points yet. I'm getting the following error: "Variable 'self.item' used before being initialized" on the line "self._showingPlaceholder = State<Bool>(initialValue: self.text.isEmpty)". Any ideas?Thorson
There is no self.item in my code, so I have no idea.Shelby
You're right––that was my mistake. I'm presenting the TextView in a sheet, and now I'm getting this error when I call my View: "Missing argument for parameter 'text' in call / Insert 'text: <#Binding<String>#>, '" Is there any way to get rid of this? I don't want to pass in a binding from that view.Thorson
Asperi, have you found a solution for the caret jumping to the end of the TextView when adding/removing paragraphs?Thorson
I found a great approach here shadowfacts.net/2020/swiftui-expanding-text-view, and that worked for me too.Superload
B
11

For anyone who comes across this thread in the future. I researched through the suggested answers and to me they weren't ideal. As of iOS 16, UIViewRepresentable has a overridable function sizeThatFits, this function will allow you to calculate the size of the resulting SwiftUI view using the proposed parent dimensions. Example below:

func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
    let dimensions = proposal.replacingUnspecifiedDimensions(
        by: .init(
            width: 0,
            height: CGFloat.greatestFiniteMagnitude
        )
    )
            
    let calculatedHeight = calculateTextViewHeight(
        containerSize: dimensions,
        attributedString: uiView.attributedText
    )
    
    return .init(
        width: dimensions.width,
        height: calculatedHeight
    )
}

private func calculateTextViewHeight(containerSize: CGSize,
                                     attributedString: NSAttributedString) -> CGFloat {
    let boundingRect = attributedString.boundingRect(
        with: .init(width: containerSize.width, height: .greatestFiniteMagnitude),
        options: [.usesLineFragmentOrigin, .usesFontLeading],
        context: nil
    )
    
    return boundingRect.height
}

Note: For this calculation method to work correctly, you may need to update the UITextView properties in the makeUIView function:

    textView.textContainer.lineFragmentPadding = 0
    textView.textContainerInset = .zero
Brambling answered 28/9, 2023 at 12:54 Comment(3)
This is great, as I didn't know about the new sizeThatFits() method on UIViewRepresentable. I have found that I don't need to use attributedString.boundingRect(), but instead can use uiView.sizeThatFits(dimensions) to get the correct size. Is there a reason you chose the boundingRect approach? I just want to make sure I'm not missing something.Francenefrances
Wow, thanks @Francenefrances for highlighting this approach. I just quickly tested your suggestions using sizeThatFits(dimensions) and it seems like a reliable solution! I appreciate Brandon solution, but I was having a few glitches while using boundingRect (e.g. starting the text with newline would result in a wrong size... or a longer text could get clipped in certain cases). Thank you both!Sheepdog
This works perfect still as of Aug 1, 2024 on iOS 16+ (sizeThatFits on UIViewRepresentable is only available on iOS 16+)Blowpipe

© 2022 - 2024 — McMap. All rights reserved.