macOS+SwiftUI: Xcodeのようなインスペクタを作る
macOS+SwiftUI: Xcodeのようなインスペクタ(right sidebar)を作る(macOS 13.0以降)
macOS 13.0以降でNavigationSplitView
が導入されて、3カラム表示もできるじゃん!と喜んだのもつかの間、Xcodeのインスペクタのようなことはできずにがっかりしている皆さん、こんにちは!
NavigationSplitViewの3カラム
どうも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列で使うことにして、右側には別のビューを入れ、その内側にコンテンツのためのビューとインスペクタのためのビューを入れます。
…という「コンテンツ+インスペクタ」(+右側のインスペクタ用ツールバーボタン)のコンテナビューを作ったのでここで紹介します。
さっそく、コードを載せましょう。
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()
}
})
}
}
使い方は次のような感じになります。
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
のようにNavigationSplitView
のdetail:
にInspectableView
を使うとよいでしょう。
はまった点
本当はHSplitView
が使えると良いのですが、こいつがまた仕様なのか不具合なのか、分割したビューの表示・非表示を切り替える際に、アニメーションしてくれません。左側のサイドバーというかNavigationSplitView
が表示・非表示の切り替えでアニメーションする以上、右側も同様にアニメーションさせる必要があります。それでまた、どういう訳かHStack
だとアニメーションします。
なのでHStack
をHSplitView
の代わりに使うのですが、こいつには当然スプリッタというかディバイダがありません。なので、そこは自前で実装します。
単にサイズが変われば良いのかと思いましたが、よくよくNavigationSplitView
やらXcodeのサイドバーの動きを見るとドラッグでのサイズ変更の時にちょっとしたトリッキーな動作(ある程度小さくなるとピョコッと消える)をしてることが分かりました。
このためこの辺の動きもまねてみました。
スプリッタの幅を広げると、Divider
は線が残るので代わりにRectangle
を使ったらどうなるの?ということをしたらなんか色が飛んでしまいました。微妙に色は変わるので何か上に白いものが乗っているような感じになるのですが…よく分からないのでDivider
に戻しました。
ちょっと気に入らない点
これ(InspectableView
)というよりは、NavigationSplitView
の気に入らない点、という感じですが、ツールバーが左サイドバーの上に伸びてないので、左サイドバーの表示非表示トグルボタンを押すとトグルボタン自体が移動してしまい元と同じ位置で押せない、というところですかね。
Xcodeはサイドバーの上にツールバーがある、というかタイトルバーにツールバーが統合されています
(WindowGroup.windowToolbarStyle
とか.windowStyle(.hiddenTitleBar)
とかで同じようにできるのかな)。
Safariはサイドバーにはツールバーがかぶっていませんが、よくよく見るとサイドバーを表示した瞬間にサイドバーにトグルボタンが移動しているような動きになってます。
なんか細かなところを見てくと色々面倒そうですね。
Discussion