【问题标题】:Scrolling to TextEditor cursor position in SwiftUI在 SwiftUI 中滚动到 TextEditor 光标位置
【发布时间】:2022-01-09 16:18:33
【问题描述】:

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

我有一个List,最后一行是TextEditor。我正在滚动到List 的最后一行,以确保TextEditor 位于键盘上方。

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

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


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

【问题讨论】:

    标签: swift swiftui swiftui-list swiftui-texteditor


    【解决方案1】:

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

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

    获取光标位置

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

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

    但是请注意,这种方法可能会在 SwiftUI 的未来版本中失效,因为 Introspect 包依赖于内部 API,并且可能不再能够从 TextEditor 中提取 UITextView

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

    滚动

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

    我发现解决这个问题的唯一方法——而且我知道这听起来有多混乱——是渲染 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)
        }
    }
    

    属性anchorVertical 提供了相对于TextEditors 总高度的光标所在高度的良好估计。理论上,我们现在应该能够使用ScrollViewProxy 滚动到TextEditor 高度的这个百分比:

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

    很遗憾,这不起作用。 UnitPoint 具有自定义坐标的初始化程序,但是,scrollTo(_:anchor:) 对于非整数值的行为与预期不同。 IE。 UnitPoint.top.height = 0.0UnitPoint.bottom.height = 1.0 工作正常,但无论我在两者之间尝试什么数字(例如 0.30.9),它总是滚动到文本中相同的随机位置。

    这是 SwiftUI 中的一个错误,或者 scrollTo(_:anchor:) 从未打算与自定义/十进制 UnitPoints 一起使用。

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

    合理的解决方案

    我看到你有两个选择:要么在 UIKit 中实现整个子视图,要么稍微减少你的要求:

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

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 2022-11-10
      • 1970-01-01
      • 1970-01-01
      • 2021-10-04
      • 1970-01-01
      • 2021-04-18
      • 1970-01-01
      • 2010-10-15
      相关资源
      最近更新 更多