I made a custom wrapper for UITextView in SwiftUI for various reasons. This wrapper is placed inside a ScrollView, with other elements above and below.
struct ContentView: View {
@State private var text = ""
var body: some View {
ScrollView {
VStack {
Rectangle().fill(Color.orange.opacity(0.5)).frame(height: 300)
TextEditor(text: $text, minimumLines: 8, lineSpacing: 10)
.background(Color.gray.opacity(0.2))
Rectangle().fill(Color.orange.opacity(0.5)).frame(height: 300)
}
.padding(.horizontal, 16)
}
.background(Color.white)
}
}
When typing in this text view and going to the next line, the scroll view automatically scrolls to place the current line just above the keyboard, which is nice. However, I'd like to increase the space with the keyboard, and have the current line automatically placed somewhere in the middle of the visible screen.

How to change this behavior?
Full minimal example code:
import SwiftUI
import UIKit
struct TextEditor: View {
@Binding var text: String
var minimumLines: Int = 1
var lineSpacing: CGFloat = 6
@State private var dynamicHeight: CGFloat = 20
@State private var width: CGFloat = 0
// MARK: - Body
var body: some View {
ZStack {
UITextViewWrapper(
text: $text,
calculatedHeight: $dynamicHeight,
width: width,
minimumLines: minimumLines,
lineSpacing: lineSpacing
)
.frame(minHeight: dynamicHeight, maxHeight: dynamicHeight)
.overlay(placeholderView, alignment: .topLeading)
}
.frame(maxWidth: .infinity)
.readSize { size in
guard size.width != width else { return }
self.width = size.width
}
}
@ViewBuilder
var placeholderView: some View {
if text.isEmpty {
Text("Enter text here")
.font(.body)
.foregroundStyle(.black.opacity(0.5))
.allowsHitTesting(false)
.lineSpacing(lineSpacing)
.padding(8)
}
}
}
// MARK: - Internal Text View
fileprivate struct UITextViewWrapper: UIViewRepresentable {
typealias UIViewType = UITextView
@Binding var text: String
@Binding var calculatedHeight: CGFloat
var width: CGFloat
var minimumLines: Int
var lineSpacing: CGFloat
var attributes: [NSAttributedString.Key:Any] {
[
.paragraphStyle: NSParagraphStyle.with(lineSpacing: lineSpacing),
.font: UIFont.preferredFont(forTextStyle: .body),
.foregroundColor: UIColor.black
]
}
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
let textView = CustomTextView()
textView.customFont = UIFont.preferredFont(forTextStyle: .body)
textView.delegate = context.coordinator
textView.isEditable = true
textView.isSelectable = true
textView.isUserInteractionEnabled = true
textView.isScrollEnabled = false
textView.backgroundColor = UIColor.clear
textView.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
textView.textContainer.lineFragmentPadding = 0
textView.typingAttributes = attributes
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
return textView
}
func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
if textView.text != self.text {
textView.text = self.text
}
textView.typingAttributes = attributes
let contentHeight = textView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)).height
let minHeightText = String(repeating: "\n", count: minimumLines-1)
let minHeight = minHeightText.height(withConstrainedWidth: textView.frame.width, attributes: attributes) + 16
let newHeight = max(contentHeight, minHeight)
if calculatedHeight != newHeight {
DispatchQueue.main.async {
calculatedHeight = newHeight // !! must be called asynchronously
}
}
}
func makeCoordinator() -> Coordinator {
Coordinator(text: $text)
}
final class Coordinator: NSObject, UITextViewDelegate {
var text: Binding<String>
init(text: Binding<String>) {
self.text = text
}
func textViewDidChange(_ uiView: UITextView) {
text.wrappedValue = uiView.text
}
}
}
class CustomTextView: UITextView {
// only used for computing caret size
var customFont: UIFont?
override func caretRect(for position: UITextPosition) -> CGRect {
var superRect = super.caretRect(for: position)
guard let font = customFont else { return superRect }
superRect.size.height = font.pointSize - font.descender
return superRect
}
}
struct ContentView: View {
@State private var text = ""
var body: some View {
ScrollView {
VStack {
Rectangle().fill(Color.orange.opacity(0.5)).frame(height: 300)
TextEditor(text: $text, minimumLines: 8, lineSpacing: 10)
.background(Color.gray.opacity(0.2))
Rectangle().fill(Color.orange.opacity(0.5)).frame(height: 300)
}
.padding(.horizontal, 16)
}
.background(Color.white)
}
}
// MARK: - Helpers
extension View {
func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
background(
GeometryReader { geometryProxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: geometryProxy.size)
}
)
.onPreferenceChange(SizePreferenceKey.self, perform: onChange)
}
}
struct SizePreferenceKey: PreferenceKey {
static var defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
extension NSParagraphStyle {
static func with(lineSpacing: CGFloat) -> NSParagraphStyle {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = lineSpacing
return paragraphStyle
}
}
extension String {
func height(withConstrainedWidth width: CGFloat, attributes: [NSAttributedString.Key:Any]) -> CGFloat {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: .usesLineFragmentOrigin, attributes: attributes, context: nil)
return ceil(boundingBox.height)
}
}