滚动到 SwiftUI 中的 TextEditor 光标位置

unk*_*own 8 swift swiftui swiftui-list swiftui-texteditor

是否可以滚动到TextEditor当前光标位置?

我有 aList作为TextEditor最后一行。我滚动到最后一行,List以确保 位于TextEditor键盘上方。

问题是,由于我将滚动锚设置为.bottom,因此在编辑长文本时,当滚动视图滚动到行的底部时,顶部的文本不可见。

在此输入图像描述

您应该能够使用以下代码重新创建问题。只需输入一些长文本,然后尝试在顶部编辑该文本。


import SwiftUI


struct ContentView: View {

    @State var items: [String] = ["Apples", "Oranges", "Bananas", "Pears", "Mangos", "Grapefruit","Apples", "Oranges", "Bananas", "Pears", "Mangos", "Grapefruit"]

    @State private var newItemText : String = ""


    var body: some View {
        ScrollViewReader { (proxy: ScrollViewProxy) in
                List{
                     ForEach(items, id: \.self) {
                         Text("\($0)")
                     }
                     TextEditor(text: $newItemText)
                        .onChange(of: newItemText) { newValue in
                            proxy.scrollTo("New Item TextField", anchor: .bottom)
                        }.id("New Item TextField")
                }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Run Code Online (Sandbox Code Playgroud)

the*_*max 6

不幸的是,似乎没有一个“纯粹的”SwiftUI 解决方案。准确实现您所描述的内容的唯一选择似乎是在 UIKit 中实现整个视图,并将其集成到应用程序的其余部分中UIViewRepresentable

不过,既然您询问了 SwiftUI,我可以向您展示仅使用它可以走多远。

获取光标位置

这里你有两个选择。首先,您可以使用Introspect包。它允许您访问用于实现某些 SwiftUI 的底层 UIKit 元素View。这看起来像这样:

import SwiftUI
import Introspect


struct ContentView: View {

    @State var items: [String] = ["Apples", "Oranges", "Bananas", "Pears", "Mangos", "Grapefruit","Apples", "Oranges", "Bananas", "Pears", "Mangos", "Grapefruit"]

    @State private var newItemText : String = ""

    @State private var uiTextView: UITextView?

    @State private var cursorPosition: Int = 0

    var body: some View {
        ScrollViewReader { (proxy: ScrollViewProxy) in
                List{
                     ForEach(items, id: \.self) {
                         Text("\($0)")
                     }
                     TextEditor(text: $newItemText)
                        .introspectTextView { uiTextView in
                            self.uiTextView = uiTextView
                        }
                        .onChange(of: newItemText) { newValue in
                            if let textView = uiTextView {
                                if let range = textView.selectedTextRange {
                                    let cursorPosition = textView.offset(from: textView.beginningOfDocument, to: range.start)

                                    self.cursorPosition = cursorPosition
                                }

                            }
                        }.id("New Item TextField")
                }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

但请注意,这种方法可能会在 SwiftUI 的未来版本中失效,因为 Introspect 包依赖于内部 API,并且可能无法再UITextViewTextEditor.

另一种解决方案可能是在每次更改时将newValueofnewItemText与旧的进行比较,并从末尾开始比较每个索引的字符,直到发现不匹配。请注意,如果用户决定输入一长串相同的字符,则这是有问题的。a例如,在比较"aaaaaaa"和时,您不知道 被插入到哪里"aaaaaa"

滚动

我们现在知道光标在文本中的位置。但是,由于我们不知道空格和换行符在哪里,因此我们不知道光标的高度。

我发现解决这个问题的唯一方法 - 我知道这听起来有多混乱 - 是渲染一个版本,TextEditor其中包含的文本在光标位置被切断,并使用以下方法测量其高度GeometryReader

struct ContentView: View {

    @State var items: [String] = ["Apples", "Oranges", "Bananas", "Pears", "Mangos", "Grapefruit","Apples", "Oranges", "Bananas", "Pears", "Mangos", "Grapefruit"]

    @State private var newItemText : String = ""
    
    @Namespace var cursorPositionId
    
    @State private var cursorPosition: Int = 0
    
    @State private var uiTextView: UITextView?
    
    @State private var renderedTextSize: CGSize?
    
    @State private var renderedCutTextSize: CGSize?
    
    var anchorVertical: CGFloat {
        if let renderedCutTextSize = renderedCutTextSize, let renderedTextSize = renderedTextSize, renderedTextSize.height > 0 {
            return renderedCutTextSize.height / renderedTextSize.height
        }

        return 1
    }


    var body: some View {
        ZStack {
            List {
                VStack {
                    Text(newItemText)
                        .background(GeometryReader {
                                        Color.clear.preference(key: TextSizePreferenceKey.self,
                                                               value: $0.size)
                                    })
                    Text(String(newItemText[..<(newItemText.index(newItemText.startIndex, offsetBy: cursorPosition, limitedBy: newItemText.endIndex) ?? newItemText.endIndex)]))
                        .background(GeometryReader {
                                        Color.clear.preference(key: CutTextSizePreferenceKey.self,
                                                               value: $0.size)
                                    })
                }
            }

            ScrollViewReader { (proxy: ScrollViewProxy) in
                // ...
            }
        }
        .onChange(of: newItemText) { newValue in
            // ...
        }
        .onPreferenceChange(CutTextSizePreferenceKey.self) { size in
            self.renderedCutTextSize = size
        }
        .onPreferenceChange(TextSizePreferenceKey.self) { size in
            self.renderedTextSize = size
        }
    }
}

private struct TextSizePreferenceKey: PreferenceKey {
    static let defaultValue = CGSize.zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        let newVal = nextValue()
        value = CGSize(width: value.width + newVal.width, height: value.height + newVal.height)
    }
}
    
private struct CutTextSizePreferenceKey: PreferenceKey {
    static let defaultValue = CGSize.zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
        let newVal = nextValue()
        value = CGSize(width: value.width + newVal.width, height: value.height + newVal.height)
    }
}
Run Code Online (Sandbox Code Playgroud)

该属性anchorVertical可以很好地估计光标所在位置相对于TextEditor总高度的高度。理论上,我们现在应该能够TextEditor使用以下命令滚动到高度的这个百分比ScrollViewProxy

            ScrollViewReader { (proxy: ScrollViewProxy) in
                List{
                    ForEach(items, id: \.self) {
                        Text("\($0)")
                    }

                    TextEditor(text: $newItemText)
                       .introspectTextView { uiTextView in
                           self.uiTextView = uiTextView
                       }
                       .id(cursorPositionId)
                }
                .onChange(of: cursorPosition) { pos in
                    proxy.scrollTo(cursorPositionId, anchor: UnitPoint.init(x: 0, y: anchorVertical))
                }
            }
Run Code Online (Sandbox Code Playgroud)

不幸的是,这不起作用。UnitPoint有一个自定义坐标的初始值设定项,但是scrollTo(_:anchor:)对于非整数值,其行为不符合预期。即UnitPoint.top.height = 0.0UnitPoint.bottom.height = 1.0工作正常,但无论我在之间尝试什么数字(例如0.30.9),它总是滚动到文本中相同的随机位置。

这要么是 SwiftUI 中的一个错误,要么scrollTo(_:anchor:)从未打算与 custom/decimal 一起使用UnitPoint

因此,我认为没有办法使用 SwiftUI API 让它滚动到正确的位置。

合理的解决方案

我认为您有两个选择:要么在 UIKit 中实现整个子视图,要么减少一点要求:

您可以将cursorPosition与 的newValue长度进行比较.onChange(of: newItemText),并且仅当光标位置位于/接近字符串末尾时才滚动到底部。