Selection Range in SwiftUI

在 SwiftUI 中,没有提供默认的 modifier 来监听 TextViewselectionRange

这对于文本编辑类 app 来说是很致命的,因为无法控制光标的位置,

但是可以从其他的文章中找到一些思路:

寻找 NSView / UIView 的方法来源于:siteline/SwiftUI-Introspect

本文来介绍一种解决方案,可以实现出以下的代码:

1
2
3
4
5
6
7
8
9
10
11
import SwiftUI

struct Example: View {
@State private var selectionRange = NSRange()
@State private var text = ""

var body: some View {
TextEditor(text: $text)
.selectionRange($selectionRange)
}
}

原理

首先从 SwiftUI 的底层入手,

SwiftUI 在构建 View 时会使用到 NSView(Controller) / UIVIew(Controller),

在他们的上层还会包一层 ViewHost

类似这样:

macOS View Hierachy

只要我们顺着 ViewHost 找到了对应的 NSView(Controller) / UIVIew(Controller),

就能实现对控件的自定义更改。

代码实现

STEP 1: 寻找 NSTextView / UITextView

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
fileprivate class _TextViewFinder: PlatformView {
init() {
super.init(frame: .zero)
isHidden = true
}

public override func hitTest(_ point: NSPoint) -> PlatformView? {
return nil
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

func findTextView(view: PlatformView?) -> TextView? {
var superview = view
while let s = superview {
if NSStringFromClass(type(of: s)).contains("ViewHost") {
let viewHost = s
guard let superview = viewHost.superview,
let entryIndex = superview.subviews.firstIndex(of: viewHost),
entryIndex > 0
else {
return nil
}

for subview in superview.subviews[0..<entryIndex].reversed() {
if let typed = findChild(in: subview) {
return typed
}
}

return nil
}
superview = s.superview
}

return nil
}

private func findChild(in root: PlatformView) -> TextView? {
for subview in root.subviews {
if let typed = subview as? TextView {
return typed
} else if let typed = findChild(in: subview) {
return typed
}
}
return nil
}
}
1
2
3
4
5
6
7
#if os(macOS)
fileprivate typealias TextView = NSTextView
fileprivate typealias PlatformView = NSView
#elseif !os(watchOS)
fileprivate typealias TextView = UITextView
fileprivate typealias PlatformView = NSView
#endif

STEP 2: 把 PlatformView 包装成 SwiftUI View

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fileprivate struct _TextViewFinderWrapper: ViewRepresentable {
@Binding var textView: TextView?

typealias _Finder = _TextViewFinder

func makeView(context: Context) -> _Finder {
_TextViewFinder()
}

func updateView(_ finder: _Finder, context: Context) {
DispatchQueue.main.async {
if let textView = finder.findTextView(view: finder.superview) {
self.textView = textView
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#if os(macOS)
protocol ViewRepresentable: NSViewRepresentable {
associatedtype NSViewType
func makeView(context: Context) -> NSViewType
func updateView(_ nsView: NSViewType, context: Context)
}
extension ViewRepresentable {
func makeNSView(context: Context) -> NSViewType {
makeView(context: context)
}
func updateNSView(_ nsView: NSViewType, context: Context) {
updateView(nsView, context: context)
}
}
#else
protocol ViewRepresentable: UIViewRepresentable {
associatedtype UIViewType
func makeView(context: Context) -> UIViewType
func updateView(_ uiView: UIViewType, context: Context)
}
extension ViewRepresentable {
func makeUIView(context: Context) -> UIViewType {
makeView(context: context)
}
func updateUIView(_ uiView: UIViewType, context: Context) {
updateView(uiView, context: context)
}
}
#endif

利用 finder.findTextView 寻找 TextView 并且将其保存到 textView

使用 @Binding 可以使其生命周期与视图保持同并与上层视图同步信息。

STEP 3: 用 _TextViewFinderWrapper 来寻找对应的 NSView / UIView

overlay 相当于 AppKit 和 UIKit 中的 addSubview

使用 superview 即可找到上层的 ViewRepresentation 也就是 SwiftUI View,

可以写一个 ViewModifier

1
2
3
4
5
6
fileprivate struct _SelectionRangeModifier: ViewModifier {
func body(content: Content) -> some View {
content
.overlay(_TextViewFinderWrapper(textView: $textView).frame(width: 0, height: 0))
}
}

STEP 4: 实现 selectionRange 的双向绑定

向上绑定:

1
2
3
4
5
6
.onReceive(
NotificationCenter.default.publisher(for: TextView.didChangeSelectionNotification, object: textView)
) { _ in
// Selection Range did change.
// Update selection range using `textView` instance.
}

向下绑定:

1
2
3
.task(id: range) {
// Selection range changed by SwiftUI State.
}

封装起来大致是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
fileprivate struct _SelectionRangeModifier: ViewModifier {
@Binding var range: NSRange
@State private var textView: TextView?

func body(content: Content) -> some View {
content
.overlay(_TextViewFinderWrapper(textView: $textView).frame(width: 0, height: 0))
#if os(macOS)
.onReceive(
NotificationCenter.default.publisher(for: TextView.didChangeSelectionNotification, object: textView)
) { _ in
guard let textView else { return }
range = textView.selectedRange()
}
#endif
.task(id: range) {
let newRange = range
#if os(macOS)
textView?.setSelectedRange(newRange)
#else
textView?.selectedRange = newRange
#endif
}
}
}

STEP 5: 扩展一下 View

1
2
3
4
5
6
7
extension View {
@available(watchOS, unavailable)
@available(iOS 13.0, macOS 10.15, tvOS 13.0, *)
func selectionRange(_ range: Binding<NSRange>) -> some View {
modifier(_SelectionRangeModifier(range: range))
}
}

好啦,搞定!

小问题

  • 本方法属于 Hack,很有可能在未来的 SwiftUI 大版本中失效(如果 SwiftUI 修改了 View Hierarchy)

  • 文本框还是可能会出现闪烁的问题,我已经尽可能地规避了,但是可能还是会出现…目前无解。

作者

LiYanan

发布于

2023-02-12

更新于

2023-03-01

许可协议

评论