Post

NSTextView ScrollView Zero Height Fix

NSTextView ScrollView Zero Height Fix

SwiftUI on macOS: NSTextViewScrollView に入れると高さが 0 近くになって表示されない問題

問題

iOS では、選択可能なテキスト表示のために UITextViewUIViewRepresentable で包む実装がうまく動いていました。

しかし 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 だけを疑わない方がよいです。

次の順で確認すると切り分けしやすいです。

  1. representable が有効な width を受け取れているか
  2. sizeThatFits が 0 に近い高さを返していないか
  3. NSTextView の内部レイアウト結果に初回から依存しすぎていないか
  4. 必要であれば attributed string を直接計測できるか

このパターンが役立つ場面

特に次のような条件で有効です。

  • iOS では部分選択のために UITextView を残したい
  • macOS では NSTextView 版が必要
  • 同じ詳細画面を NavigationSplitView の detail に表示している
  • 複数行テキストや attributed text を使っている
  • 表示コンポーネントが ScrollView の中にある

要するに、

iOS では UITextView を維持しつつ、macOS だけ NSTextView 用の安定したサイズ計算を持たせる、という分け方が有効です。

This post is licensed under CC BY 4.0 by the author.