如何调整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) } } } }
代码说明
- 键盘监听:通过
NotificationCenter获取键盘高度,用于计算扣除键盘后的屏幕可用区域。 - ScrollViewReader:借助命名空间标记自定义TextEditor的位置,实现精确的滚动控制。
- 光标位置计算:将光标坐标转换为窗口坐标系,计算出需要滚动的偏移量,确保光标行处于可见区域的中间位置。
- 异步滚动调整:等待ScrollView布局更新后再调整偏移量,避免滚动位置计算偏差。
内容的提问来源于stack exchange,提问作者Morpheus
相关产品推荐
相关产品推荐

