How to change the padding between the current line and the keyboard for UITextView wrapper in ScrollView

29 views Asked by At

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.

wrong right

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)
    }
}
0

There are 0 answers