NSTextView ScrollView Zero Height Fix
SwiftUI on macOS: NSTextView を ScrollView に入れると高さが 0 近くになって表示されない問題
問題
iOS では、選択可能なテキスト表示のために UITextView を UIViewRepresentable で包む実装がうまく動いていました。
しかし macOS 向けに NSTextView 版を追加したところ、アプリはビルドも起動もできるのに、My Mac で文法詳細ペインだけが空白に見える状態になりました。
症状は次のとおりです。
- カテゴリ一覧は表示される
- 文法一覧も表示される
- 文法をクリックすると選択自体は変わっているように見える
Quizから表示する文法詳細は正常に出る- 3 カラム構成の文法詳細だけが空白に見える
つまり、問題は 3 カラムのナビゲーション構造そのものではなく、macOS 側のテキストビューのレイアウトにありました。
根本原因
詳細画面では、カスタムの SelectableTextView を使って NSTextView を表示していました。
ところが macOS の ScrollView と split view の detail カラムの組み合わせでは、SwiftUI が安定した幅を渡す前に representable のサイズを先に問い合わせることがあります。
その状態で NSTextView 側のレイアウト結果だけに依存すると、実質的に高さが 0 に近い値として扱われ、結果として「何も表示されていない」ように見えます。
たとえば、次のような実装は初回レイアウトで不安定になりやすいです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
func sizeThatFits(_ proposal: ProposedViewSize, nsView textView: NSTextView, context: Context) -> CGSize? {
guard
let width = proposal.width,
let textContainer = textView.textContainer,
let layoutManager = textView.layoutManager
else {
return nil
}
textContainer.containerSize = NSSize(width: width, height: CGFloat.greatestFiniteMagnitude)
layoutManager.ensureLayout(for: textContainer)
let usedRect = layoutManager.usedRect(for: textContainer)
return CGSize(width: width, height: usedRect.height)
}
proposal.width がまだ有効でないタイミングだと、この計算結果が崩れ、クラッシュはしないものの表示だけが消えたように見えます。
解決方法
NSTextView の内部レイアウト状態に初回から依存するのではなく、NSAttributedString 自体を直接計測し、必要ならフォールバック用の幅を与えるようにしました。
ポイントは次のとおりです。
textContainer.widthTracksTextView = trueを設定する- SwiftUI がまだ幅を渡していないときのために fallback 幅を用意する
- 高さ計算は
attributedText.boundingRect(...)を使って直接行う - 初回レイアウトで消えないよう、最小の可視高さを返す
動作した macOS 実装
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
53
#if canImport(AppKit)
import AppKit
import SwiftUI
struct SelectableTextView: NSViewRepresentable {
let attributedText: NSAttributedString
func makeNSView(context: Context) -> NSTextView {
let textView = NSTextView(frame: .zero)
textView.drawsBackground = false
textView.isEditable = false
textView.isSelectable = true
textView.isRichText = true
textView.importsGraphics = false
textView.textContainerInset = .zero
textView.textContainer?.lineFragmentPadding = 0
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.heightTracksTextView = false
textView.minSize = .zero
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
return textView
}
func updateNSView(_ textView: NSTextView, context: Context) {
textView.textStorage?.setAttributedString(attributedText)
}
func sizeThatFits(_ proposal: ProposedViewSize, nsView textView: NSTextView, context: Context) -> CGSize? {
let width = resolvedWidth(for: proposal, textView: textView)
let insetHeight = textView.textContainerInset.height * 2
let bounds = attributedText.boundingRect(
with: NSSize(width: width, height: CGFloat.greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading]
)
return CGSize(width: width, height: max(ceil(bounds.height + insetHeight), 22))
}
private func resolvedWidth(for proposal: ProposedViewSize, textView: NSTextView) -> CGFloat {
if let width = proposal.width, width > 0 {
return width
}
if textView.bounds.width > 0 {
return textView.bounds.width
}
return 640
}
}
#endif
なぜこれで直るのか
NSTextView の高さは、内部の textContainer がどれだけの幅を持っているかに強く依存します。
一方で SwiftUI の representable は、ScrollView 内では初回の measurement 時点でまだ安定した幅を持っていないことがあります。
そのタイミングで NSTextView の内部レイアウトだけに頼ると、高さ計算が破綻して 0 近くになり、detail が空白に見えます。
NSAttributedString を直接計測する方式なら、
- レイアウト結果が安定しやすい
- 初回表示でも消えにくい
- その後の SwiftUI 再レイアウトにも自然に追従できる
という利点があります。
この修正で変えていないこと
この問題を直すために、NavigationSplitView の全体構造を別のナビゲーション方式へ変える必要はありません。
今回のケースでは、3 カラム構成そのものが原因ではなく、detail 側に置いた macOS 用テキスト representable のサイズ計算が原因でした。
detail が空白だと navigation の問題に見えやすいのですが、実際には selection は正しく動いていて、表示コンポーネントだけが潰れていることがあります。
実務上のポイント
macOS の SwiftUI で detail ペインが空白に見えるときは、最初に navigation だけを疑わない方がよいです。
次の順で確認すると切り分けしやすいです。
- representable が有効な width を受け取れているか
sizeThatFitsが 0 に近い高さを返していないかNSTextViewの内部レイアウト結果に初回から依存しすぎていないか- 必要であれば attributed string を直接計測できるか
このパターンが役立つ場面
特に次のような条件で有効です。
- iOS では部分選択のために
UITextViewを残したい - macOS では
NSTextView版が必要 - 同じ詳細画面を
NavigationSplitViewの detail に表示している - 複数行テキストや attributed text を使っている
- 表示コンポーネントが
ScrollViewの中にある
要するに、
iOS では UITextView を維持しつつ、macOS だけ NSTextView 用の安定したサイズ計算を持たせる、という分け方が有効です。