KINTO Tech Blog
iOS

Using UITextView in SwiftUI with UIViewRepresentable

Cover Image for Using UITextView in SwiftUI with UIViewRepresentable

Hi! I’m Ryomm, developing the iOS app my route at KINTO Technologies. I think there are still many scenarios where UITextView is needed, particularly when you want to use TextKit. I tried integrating UITextView with SwiftUI using UIViewRepresentable, but I ran into difficulties adjusting the height. This article details how I resolved that issue.

Approach

Here’s how you can resolve the issue.

import UIKit
struct TextView: UIViewRepresentable {
    var text: NSAttributedString
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    func makeUIView(context: Context) -> UITextView {
        let view = UITextView()
        view.delegate = context.coordinator
        view.isScrollEnabled = false
        view.isEditable = false
        view.isUserInteractionEnabled = false
        view.isSelectable = false
        view.backgroundColor = .clear
        view.textContainer.lineFragmentPadding = 0
        view.textContainerInset = .zero
        return view
    }
    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.attributedText = text
    }
    func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
        guard let width = proposal.width else { return nil }
        let dimensions = text.boundingRect(
            with: CGSize(width: width, height: CGFloat.greatestFiniteMagnitude),
            options: [.usesLineFragmentOrigin, .usesFontLeading],
            context: nil)
        return .init(width: width, height: ceil(dimensions.height))
    }
}
extension TextView {
    final class Coordinator: NSObject, UITextViewDelegate {
        private var textView: TextView
        init(_ textView: TextView) {
            self.textView = textView
            super.init()
        }
        func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
            return true
        }
        func textViewDidChange(_ textView: UITextView) {
            self.textView.text = textView.attributedText
        }
    }
}

The screen looks like this Background color is added for clarity

Explanation

In the makeUIView() function, setting view.isScrollEnabled to false caused an issue.  Line breaks were no longer applied.
By using setContentHuggingPriority() and setContentCompressionResistancePriority(), line breaks were restored even when scrolling was disabled. However, the vertical display area was not adjusting correctly. When displaying text with more than two lines, any text that exceeded the vertical area was cut off.

func makeUIView(context: Context) -> UITextView {
    let view = UITextView()
    view.delegate = context.coordinator
    view.isScrollEnabled = false
    view.isEditable = false
    view.isUserInteractionEnabled = false
    view.isSelectable = true
    view.backgroundColor = .clear
    // For example like this?
    view.setContentHuggingPriority(.defaultHigh, for: .vertical)
    view.setContentHuggingPriority(.defaultHigh, for: .horizontal)
    view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    view.setContentCompressionResistancePriority(.required, for: .vertical)
    view.textContainer.lineFragmentPadding = 0
    view.textContainerInset = .zero
    return view
}

The area isn’t adjusting properly (・〜・)
So I decided to use sizeThatFits(). This is a method available from iOS 16 that can be overridden in UIViewRepresentable. By using this method, you can specify the size of the view based on the proposed size from the parent view.
In this case, I wanted to use NSAttributedString for the text passed to the view, so I calculated the height of the provided text. For the method to calculate the height, I referred to this article.

func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
    guard let width = proposal.width else { return nil }
    let dimensions = text.boundingRect(
        with: CGSize(width: width, height: CGFloat.greatestFiniteMagnitude),
        options: [.usesLineFragmentOrigin, .usesFontLeading],
        context: nil)
    return .init(width: width, height: ceil(dimensions.height))
}

If this is all, the view’s area becomes larger than the size calculated by sizeThatFits(), so I added the following two settings to makeUIView() to remove the padding:

textView.textContainer.lineFragmentPadding = 0
textView.textContainerInset = .zero

Done! Completed ◎

Conclusion

After quite a bit of trial and error, I discovered that using sizeThatFits() gives me the correct size. That insight inspired me to write this article🤓

Facebook

関連記事 | Related Posts

We are hiring!

【UI/UXデザイナー】クリエイティブ室/東京・大阪

クリエイティブ室についてKINTOやトヨタが抱えている課題やサービスの状況に応じて、色々なプロジェクトが発生しそれにクリエイティブ力で応えるグループです。所属しているメンバーはそれぞれ異なる技術や経験を持っているので、クリエイティブの側面からサービスの改善案を出し、周りを巻き込みながらプロジェクトを進めています。

フロントエンドエンジニア(レコメンドシステム)/マーケティングプロダクトG/東京

マーケティングプロダクトグループについてKINTOサービスサイト内で、パーソナライズ/ターゲティング/レコメンドなどのWEB接客系プロダクトを企画、開発、分析まで一貫して担当しています。そのほか、おでかけスポットをAIで提案するアプリ『Prism Japan』を開発・運営しています。

イベント情報

Cloud Security Night #2
製造業でも生成AI活用したい!名古屋LLM MeetUp#6
Mobility Night #3 - マップビジュアライゼーション -