🪜

macOS+SwiftUI: Xcodeのようなインスペクタを作る

2023/01/22に公開

macOS+SwiftUI: Xcodeのようなインスペクタ(right sidebar)を作る(macOS 13.0以降)

macOS 13.0以降でNavigationSplitViewが導入されて、3カラム表示もできるじゃん!と喜んだのもつかの間、Xcodeのインスペクタのようなことはできずにがっかりしている皆さん、こんにちは!

どうもNavigationSplitViewの3カラムは、macOS標準の「メモ」のような3列、つまり左側2列が階層構造・インデックス的になっていて、一番右が実際のコンテンツ、というような表示に使うモノのように見えます。
メールソフトなんかで、1列目=フォルダ、2列目=コンパクト表示でのメール一覧、3列目=実際のメール表示、みたいな使い方になるんでしょうか。

Xcodeの表示

Xcodeでは、左側のサイドバーが階層構造、右側のサイドバーがインスペクタ、真ん中がコンテンツ、というような感じになっています。

これがやりたいのに!満を持して登場したんじゃないの?NavigationSplitViewよ!

というような期待はもともとしてなくて、今日たまたまNavigationViewを使って実装を進めてて、ちょっとドキュメントを調べたらdeplicatedと出て驚いただけでした。
使っていたXcodeが古くて(macOS12.3までのSDKしか入ってなかった)、macOS 13.1でdeplicatedになったよ!と言ってくれなかったのです。
なのでXcodeもアップデートしました。アップデートしないとNavigationSplitViewがないのです。

結論から先に(「コンテンツ+インスペクタ」を持つコンテナビュー)

NagivationSplitViewだけではダメそうなので、NagivationSplitViewは2列で使うことにして、右側には別のビューを入れ、その内側にコンテンツのためのビューとインスペクタのためのビューを入れます。

…という「コンテンツ+インスペクタ」(+右側のインスペクタ用ツールバーボタン)のコンテナビューを作ったのでここで紹介します。

さっそく、コードを載せましょう。

InspectableView.swift
struct InspectableView<ContentViewT, InspectorViewT> : View where ContentViewT:View, InspectorViewT:View {
    let contentView: ContentViewT
    let inspectorView: InspectorViewT
    let inspectorMinWidth: CGFloat
    let inspectorMaxWidth: CGFloat
    let dividerThickness: CGFloat
    let dividerColor : Color
    @State var isInspectorVisible: Bool = true
    @State var inspectorWidth: CGFloat = 100.0
    
    init(inspectorMinWidth: CGFloat = 100.0, inspectorMaxWidth: CGFloat = 1000.0, inspectorInitialWidth: CGFloat = 100.0, dividerThickness: CGFloat = 1.0, dividerColor: Color = .black, @ViewBuilder content: () -> TupleView<(ContentViewT, InspectorViewT)>) {
        
        self.inspectorMinWidth = inspectorMinWidth
        self.inspectorMaxWidth = inspectorMaxWidth
        self.dividerThickness = dividerThickness
        self.dividerColor = dividerColor

        let contentTuple = content()
        self.contentView = contentTuple.value.0
        self.inspectorView = contentTuple.value.1

        self.inspectorWidth = inspectorInitialWidth
    }

    var body: some View {
        HStack(spacing: 0) {
            self.contentView
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .layoutPriority(1)
            inspectorDivider()
            if (isInspectorVisible) {
                self.inspectorView
                    .frame(width:inspectorWidth)
                    .transition(.asymmetric(insertion:.move(edge: .trailing),
                                            removal: .move(edge: .trailing)))
            }
        }
        .toolbar {
            Button(action: {
                withAnimation {
                    self.isInspectorVisible.toggle()
                    if (self.isInspectorVisible) {
                        if (self.inspectorWidth < self.inspectorMinWidth) {
                            self.inspectorWidth = self.inspectorMinWidth
                        }
                    }
                }
            }) {
                Label("Toggle Inspector", systemImage: "sidebar.right")
            }
        }
    }
    
    func inspectorDivider() -> some View {
        Divider()
            .frame(width: self.dividerThickness)
            .background(self.dividerColor)
            .opacity(self.isInspectorVisible ? 1.0 : 0.0)
            .gesture(
                DragGesture()
                    .onChanged { gestureValue in
                        //gestureValueの各値は、このDividerに対する相対座標のため、ドラッグしてDividerの位置が変わると
                        //座標系が変わるようなイメージ。
                        //startLocationの値は一定で、locationがDividerに着いてくるような動きになる
                        let delta = gestureValue.translation.width
                        //移動距離がある程度大きい場合のみスプリッタの移動をする(でないと無限再帰呼び出しのような動きをする)
                        if 1.0 <= abs(delta) {
                            let newWidth = self.inspectorWidth - delta;
                            if (self.isInspectorVisible && newWidth < self.inspectorMinWidth - 10) {
                                NSCursor.resizeLeft.set()
                                self.inspectorWidth = 0
                                self.isInspectorVisible = false
                            }
                            else if (!self.isInspectorVisible && self.inspectorMinWidth + 10 < newWidth) {
                                self.inspectorWidth = min(self.inspectorMaxWidth, newWidth)
                                self.isInspectorVisible = true
                            }
                            else {
                                self.inspectorWidth = min(self.inspectorMaxWidth, newWidth)
                            }
                            //NSLog("NewWidth:%f Visible:%d", newWidth, self.isInspectorVisible ? 1 : 0)
                        }
                    }
                    .onEnded { _ in
                        NSCursor.arrow.set()
                    }
            )
            .onHover(perform: { hovering in
                if (self.isInspectorVisible) {
                    hovering ? NSCursor.resizeLeftRight.set() : NSCursor.arrow.set()
                }
            })
    }
}

使い方は次のような感じになります。

MainView.swift
struct MainView: View {
    var body: some View {
        NavigationSplitView (
            sidebar: {
                MySidebarView()
            },
            detail: {
                InspectableView(inspectorMinWidth: 100, inspectorMaxWidth: 1000, inspectorInitialWidth: 150, dividerThickness: 2, dividerColor: Color(red: 0.2, green: 0.2, blue: 0.2)) {
                    MyContentView()
                    MyInspectorView()
                }
            }
        )
        .navigationTitle("Document title etc...")
        .toolbar {
            ToolbarItem(id:"play", placement: .navigation) {
                Button(action: {}, label: {
                    Label("Play", systemImage: "play.fill")
                })
            }
            ToolbarItem(id:"add", placement: .navigation) {
                Button(action: {}, label: {
                    Label("Add", systemImage: "plus.app")
                })
            }
            
        }
        
    }
}

struct MySidebarView: View {
    var body: some View {
        VStack {
            Text("Sidebar Top")
            Spacer()
            Text("Bottom")
        }
    }
}

struct MyContentView: View {
    var body: some View {
        VStack {
            Text("Content Top")
            Spacer()
            Text("Bottom")
        }
    }
}

struct MyInspectorView: View {
    var body: some View {
        VStack {
            Text("Inspector Top")
            Spacer()
            Text("Bottom")
        }
    }
}

struct MainView_Previews: PreviewProvider {
    static var previews: some View {
        MainView()
    }
}

これを実際に表示するとこんな感じになります。

表示・動きの説明

左側がNavigationSplitViewのサイドバー、右側が自前コンテナに埋め込まれたインスペクタ、真ん中が同じく自前コンテナに埋め込まれたコンテンツ表示用のビュー、になります。
コンテンツとインスペクタを分けるスプリッタは左右にドラッグ可能で、ドラッグするとインスペクタの幅を変えられます。
インスペクタをインスペクタ最小幅より狭くするとインスペクタが自動で隠れます。隠れてもスプリッタをドラッグしている間は広げて戻すことができますが、隠れた状態でドラッグをやめるとスプリッタが消えて、戻らなくなります(つまり非表示のまま広げられない)。ツールバー右端のボタンで表示・非表示を切り替えて戻します。

最小幅、最大幅、スプリッタの幅、色はInspectableViewの初期化の際に指定できます。
(この辺はもう少しSwiftUIらしい書き方がありそう)
またスプリッタはDividerを使っていますが、幅を広くしてもDivider自体は広がらず背景だけが広がって、真ん中にDividerの線が残りますのであまり広くすると格好悪くなります。

使い方の説明

コンテンツ+右側インスペクタ、という形だけ使うなら、以下のような感じで、コンテンツのビューとインスペクタのビューをそれぞれ1つずつ指定します。2つじゃないとエラーになります。順序も関係あります。先がコンテンツ、後がインスペクタです。

  InspectableView(...) {
    MyContentView()
    MyInspectorView()
  }

左側のサイドバーも使うならサンプルのMainView.swiftにおけるMaiViewのようにNavigationSplitViewdetail:InspectableViewを使うとよいでしょう。

はまった点

本当はHSplitViewが使えると良いのですが、こいつがまた仕様なのか不具合なのか、分割したビューの表示・非表示を切り替える際に、アニメーションしてくれません。左側のサイドバーというかNavigationSplitViewが表示・非表示の切り替えでアニメーションする以上、右側も同様にアニメーションさせる必要があります。それでまた、どういう訳かHStackだとアニメーションします。

なのでHStackHSplitViewの代わりに使うのですが、こいつには当然スプリッタというかディバイダがありません。なので、そこは自前で実装します。
単にサイズが変われば良いのかと思いましたが、よくよくNavigationSplitViewやらXcodeのサイドバーの動きを見るとドラッグでのサイズ変更の時にちょっとしたトリッキーな動作(ある程度小さくなるとピョコッと消える)をしてることが分かりました。
このためこの辺の動きもまねてみました。

スプリッタの幅を広げると、Dividerは線が残るので代わりにRectangleを使ったらどうなるの?ということをしたらなんか色が飛んでしまいました。微妙に色は変わるので何か上に白いものが乗っているような感じになるのですが…よく分からないのでDividerに戻しました。

ちょっと気に入らない点

これ(InspectableView)というよりは、NavigationSplitViewの気に入らない点、という感じですが、ツールバーが左サイドバーの上に伸びてないので、左サイドバーの表示非表示トグルボタンを押すとトグルボタン自体が移動してしまい元と同じ位置で押せない、というところですかね。
Xcodeはサイドバーの上にツールバーがある、というかタイトルバーにツールバーが統合されています
WindowGroup.windowToolbarStyleとか.windowStyle(.hiddenTitleBar)とかで同じようにできるのかな)。
Safariはサイドバーにはツールバーがかぶっていませんが、よくよく見るとサイドバーを表示した瞬間にサイドバーにトグルボタンが移動しているような動きになってます。

なんか細かなところを見てくと色々面倒そうですね。

参考にしたサイト・記事

Discussion