You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何调整ScrollView中UITextView包装器当前行与键盘的间距

调整自定义TextEditor在ScrollView中的键盘滚动位置,使光标行居中显示

要实现输入时光标行自动处于屏幕可见区域中间,需要结合键盘高度监听、UITextView代理跟踪光标位置和ScrollView滚动控制三个部分修改代码,具体步骤如下:


1. 添加键盘监听与ScrollView滚动控制基础

在ContentView中添加键盘高度状态变量,监听键盘显示/隐藏事件,并通过ScrollViewReader实现精确滚动控制:

struct ContentView: View {
    @State private var text = ""
    @State private var keyboardHeight: CGFloat = 0
    @Namespace private var textEditorNamespace // 标记TextEditor的滚动锚点

    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                VStack {
                    Rectangle().fill(Color.orange.opacity(0.5)).frame(height: 300)
                    
                    TextEditor(text: $text, 
                               minimumLines: 8, 
                               lineSpacing: 10,
                               keyboardHeight: $keyboardHeight,
                               scrollProxy: proxy,
                               namespace: textEditorNamespace)
                        .background(Color.gray.opacity(0.2))
                        .id(textEditorNamespace) // 绑定锚点ID
                    
                    Rectangle().fill(Color.orange.opacity(0.5)).frame(height: 300)
                }
                .padding(.horizontal, 16)
            }
            .background(Color.white)
            .onAppear {
                // 监听键盘显示事件
                NotificationCenter.default.addObserver(forName: UIKeyboardWillShowNotification, object: nil, queue: .main) { notification in
                    guard let frame = notification.userInfo?[UIKeyboardFrameEndUserInfoKey] as? CGRect else { return }
                    keyboardHeight = frame.height
                }
                // 监听键盘隐藏事件
                NotificationCenter.default.addObserver(forName: UIKeyboardWillHideNotification, object: nil, queue: .main) { _ in
                    keyboardHeight = 0
                }
            }
            .onDisappear {
                NotificationCenter.default.removeObserver(self)
            }
        }
    }
}

2. 修改自定义TextEditor结构体,传递必要参数

更新TextEditor定义,新增键盘高度绑定、滚动代理和命名空间参数,为后续滚动控制提供支持:

struct TextEditor: View {
    
    @Binding var text: String
    @Binding var keyboardHeight: CGFloat // 新增:键盘高度绑定
    let scrollProxy: ScrollViewProxy      // 新增:ScrollView滚动代理
    let namespace: Namespace.ID           // 新增:滚动锚点命名空间
    
    var minimumLines: Int = 1
    var lineSpacing: CGFloat = 6

    @State private var dynamicHeight: CGFloat = 20
    @State private var width: CGFloat = 0

    var body: some View {
        ZStack {
            UITextViewWrapper(
                text: $text,
                calculatedHeight: $dynamicHeight,
                width: width,
                minimumLines: minimumLines,
                lineSpacing: lineSpacing,
                keyboardHeight: $keyboardHeight, // 传递给包装器
                scrollProxy: scrollProxy,        // 传递给包装器
                namespace: namespace             // 传递给包装器
            )
            .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)
        }
    }
}

3. 更新UITextViewWrapper与协调器,实现光标居中滚动

修改UITextViewWrapper的协调器,在textViewDidChangeSelection方法中计算光标位置,调整ScrollView滚动偏移使光标行处于屏幕中间:

fileprivate struct UITextViewWrapper: UIViewRepresentable {
    typealias UIViewType = UITextView
    
    @Binding var text: String
    @Binding var calculatedHeight: CGFloat
    @Binding var keyboardHeight: CGFloat // 新增
    let scrollProxy: ScrollViewProxy      // 新增
    let namespace: Namespace.ID           // 新增
    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
            }
        }
    }
    
    func makeCoordinator() -> Coordinator {
        // 协调器初始化传入新增参数
        Coordinator(text: $text, 
                    keyboardHeight: $keyboardHeight, 
                    scrollProxy: scrollProxy, 
                    namespace: namespace)
    }

    final class Coordinator: NSObject, UITextViewDelegate {
        var text: Binding<String>
        var keyboardHeight: Binding<CGFloat>
        let scrollProxy: ScrollViewProxy
        let namespace: Namespace.ID

        init(text: Binding<String>, 
             keyboardHeight: Binding<CGFloat>, 
             scrollProxy: ScrollViewProxy, 
             namespace: Namespace.ID) {
            self.text = text
            self.keyboardHeight = keyboardHeight
            self.scrollProxy = scrollProxy
            self.namespace = namespace
        }
        
        func textViewDidChange(_ uiView: UITextView) {
            text.wrappedValue = uiView.text
        }
        
        // 新增:处理光标变化时的滚动逻辑
        func textViewDidChangeSelection(_ textView: UITextView) {
            guard let selectedRange = textView.selectedTextRange else { return }
            
            // 获取光标相对于窗口的坐标
            let caretRect = textView.caretRect(for: selectedRange.start)
            let windowCaretRect = textView.convert(caretRect, to: UIApplication.shared.windows.first)
            
            // 计算屏幕可见区域的中间位置(考虑安全区域和键盘高度)
            let screenBounds = UIScreen.main.bounds
            let safeArea = UIApplication.shared.windows.first?.safeAreaInsets ?? .zero
            let availableHeight = screenBounds.height - keyboardHeight.wrappedValue - safeArea.top - safeArea.bottom
            let targetMidY = safeArea.top + availableHeight / 2
            
            // 计算需要调整的滚动偏移量
            let offsetToAdjust = windowCaretRect.midY - targetMidY
            
            // 先滚动到TextEditor锚点,再调整偏移量(异步等待布局更新)
            scrollProxy.scrollTo(namespace, anchor: .top)
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
                let textViewOriginInScroll = textView.convert(textView.bounds.origin, to: textView.superview?.superview)
                let totalOffset = textViewOriginInScroll.y + offsetToAdjust
                self.scrollProxy.scrollTo(self.namespace, anchor: .top, offset: totalOffset)
            }
        }
    }
}

代码说明

  1. 键盘监听:通过NotificationCenter获取键盘高度,用于计算扣除键盘后的屏幕可用区域。
  2. ScrollViewReader:借助命名空间标记自定义TextEditor的位置,实现精确的滚动控制。
  3. 光标位置计算:将光标坐标转换为窗口坐标系,计算出需要滚动的偏移量,确保光标行处于可见区域的中间位置。
  4. 异步滚动调整:等待ScrollView布局更新后再调整偏移量,避免滚动位置计算偏差。

内容的提问来源于stack exchange,提问作者Morpheus

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.01 12:57:32