// Credit: https://stackoverflow.com/a/58639072 import SwiftUI import UIKit private struct UITextViewWrapper: UIViewRepresentable { typealias UIViewType = UITextView @Binding var text: String @Binding var calculatedHeight: CGFloat @Binding var isEditing: Bool var textStyle: UIFont var onDone: (() -> Void)? func makeUIView(context: UIViewRepresentableContext) -> UITextView { let textField = UITextView() textField.delegate = context.coordinator textField.isEditable = true textField.isSelectable = true textField.isUserInteractionEnabled = true textField.isScrollEnabled = false textField.backgroundColor = UIColor.clear textField.smartDashesType = .no if nil != onDone { textField.returnKeyType = .next } textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return textField } func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext) { if uiView.text != self.text { uiView.text = self.text } let font = textStyle let fontMetrics = UIFontMetrics(forTextStyle: .largeTitle) uiView.font = fontMetrics.scaledFont(for: font) if uiView.window != nil && isEditing { DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) { uiView.becomeFirstResponder() } } UITextViewWrapper.recalculateHeight(view: uiView, result: $calculatedHeight) } fileprivate static func recalculateHeight(view: UIView, result: Binding) { let newSize = view.sizeThatFits(CGSize(width: view.frame.size.width, height: CGFloat.greatestFiniteMagnitude)) if result.wrappedValue != newSize.height { DispatchQueue.main.async { result.wrappedValue = newSize.height // !! must be called asynchronously } } } func makeCoordinator() -> Coordinator { return Coordinator(text: $text, height: $calculatedHeight, isFirstResponder: $isEditing, onDone: onDone) } final class Coordinator: NSObject, UITextViewDelegate { @Binding var isFirstResponder: Bool var text: Binding var calculatedHeight: Binding var onDone: (() -> Void)? init( text: Binding, height: Binding, isFirstResponder: Binding, onDone: (() -> Void)? = nil ) { self.text = text self.calculatedHeight = height self._isFirstResponder = isFirstResponder self.onDone = onDone } func textViewDidChange(_ uiView: UITextView) { text.wrappedValue = uiView.text UITextViewWrapper.recalculateHeight(view: uiView, result: calculatedHeight) } func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { if let onDone = self.onDone, text == "\n" { textView.resignFirstResponder() onDone() return false } return true } func textViewDidEndEditing(_ textView: UITextView) { self.isFirstResponder = false } } } struct MultilineTextField: View { private var placeholder: String private var textStyle: UIFont private var onCommit: (() -> Void)? @Binding var isFirstResponder: Bool @Binding private var text: String private var internalText: Binding { Binding(get: { self.text }) { // swiftlint:disable:this multiple_closures_with_trailing_closure self.text = $0 self.showingPlaceholder = $0.isEmpty } } @State private var dynamicHeight: CGFloat = 100 @State private var showingPlaceholder = false init ( _ placeholder: String = "", text: Binding, font: UIFont, isFirstResponder: Binding, onCommit: (() -> Void)? = nil ) { self.placeholder = placeholder self.onCommit = onCommit self.textStyle = font self._isFirstResponder = isFirstResponder self._text = text self._showingPlaceholder = State(initialValue: self.text.isEmpty) } var body: some View { UITextViewWrapper( text: self.internalText, calculatedHeight: $dynamicHeight, isEditing: $isFirstResponder, textStyle: textStyle, onDone: onCommit ) .frame(minHeight: dynamicHeight, maxHeight: dynamicHeight) .background(placeholderView, alignment: .topLeading) } var placeholderView: some View { Group { if showingPlaceholder { let font = Font(textStyle) Text(placeholder).foregroundColor(.gray) .padding(.leading, 4) .padding(.top, 8) .font(font) } } } }