🐰

NSToolbarのDisplay Modeを無効化する裏技

2024/05/01に公開

NSToolbarのDisplay Mode(表示モード)とは、「アイコンとラベル」「アイコンのみ」「テキストのみ」の現状三種類あるモードをユーザーが任意に切り替えられる仕組みです。古いmacOS / Mac OS Xではアイコンの大きさにも大小があり、「小さなサイズを使用」というメニューおよびチェックボックスが備わっていました。現在のmacOS (Sonoma, 14.x系)や直近のOSではこのメニューとチェックボックスは非表示にされています。


コンテクストメニュー


macOS Sonoma版Finderのツールバーパレット


古いMac OS Xのツールバーパレット/出典:https://book.mynavi.jp/macfan/detail_summary/id=26650

どうやら裏技を使うしかない

このNSToolbarのDisplay Modeは、表向きには無効化することができないようです。そのような公開APIが備わっていないためです。しかしSafariのUIを見るとこれらのコントロールが無効化されているので、Appleは何かしらの方法を使ってこれを実現する手段を持っているようです。この方法が知りたいのですが、残念ながら3rdの立場では安全には不可能です。

Stack Overflowなどでは愚直にNSViewのサブビューを掘ってisHiddenフラグを変更する方法しか見つからなかったため、とりあえずそれらを試してみた上で効果があったものを紹介します。


Safariのツールバーパレットには、Display Modeのポップアップボタンが備わっていない

Display Modeの変更を塞ぐ

単純にNSToolbarのサブクラスからdisplayModeをオーバーライドし、いずれかのモードのみに固定します。この方法は単にサブクラス化のテクニックなので裏技でもなんでもなく、何も問題はありません。

これによりDisplay Modeの変更は機能しなくなります。しかし標準で備わっているUIコントロールは機能しなくなっていますがそのまま存在し続けているため、それらが気になるようなら更に別の裏技が必要になります。

MyToolbar
// 「アイコンのみ」に無理やり固定
override var displayMode: NSToolbar.DisplayMode {
	get {
		.iconOnly
	}
	set {
		super.displayMode = .iconOnly
	}
}

コンテクストメニュー項目を無効化


コンテクストメニュー

ツールバーのコンテクストメニューの一部項目を無効化します。これにはNSToolbarに対してvalue(forKey:)でプライベートのプロパティにアクセスする方法をとります。まず"_toolbarView"をキーとしてtoolbarViewへの参照を取得し、次にmenuでコンテクストメニューのNSMenuオブジェクトへの参照を取得します。

メニューオブジェクトを得たら、NSSelectorFromString("changeToolbarDisplayMode:")でアクションを評価し、一致するNSMenuItemのisHiddenフラグを立ててそれらを隠します。

App Storeに載せるアプリでは使えない可能性がありますが、問題がなかったという声も一部にあり、いずれにしろあまり良いやり方ではありませんが今の所一番効果的な方法のようです。

MyToolbar
/// メニュー項目を無効化
func disableToolbarMenuActions() {
    // _toolbarView を取得、次にコンテクストメニューを取得
	if let toolbarView = value(forKey: "_toolbarView") as? NSView,
	   let contextMenu = toolbarView.menu {
		for item in contextMenu.items {
            // 対象のメニュー項目を隠す
			if item.action == NSSelectorFromString("changeToolbarDisplayMode:") {
				item.isHidden = true
			}
		}
	}
}

ツールバーパレットのポップアップボタンとラベルを隠す


macOS Sonoma版Finderのツールバーパレット

このポップアップボタンと、日本語環境では「表示」と書かれたラベルを隠したいと思います。

runCustomizationPalette(_ sender: Any?)をオーバーライドして、パレットのシートが表示された直後にサブビューを掘っていって目当てのものを特定し、isHiddenで隠します。

表示直後にこれを行う必要があるため、どうしても一瞬だけポップアップボタンが見えてしまう問題があります。

NSWindowDelegateのwindow(_:willPositionSheet:using:)でシートが表示される直前を検知できるので、メインウインドウのデリゲートでこれを実装します。次に、パレットのシートに相当するウインドウのcontentViewを取得し、あとは愚直にサブビューを掘っていきます。サブビューの特定方法ですが、どうやら最近のmacOS(Sonoma, 14.4.1でのみ確認)ではNSStackViewやNSBoxを使って階層化されているようで、いくつかの工夫が必要です。

runCustomizationPalette()をオーバーライドする方法では一瞬だけ隠したいボタン類が表示されてしまうことがありましたが、NSWindowDelegateを使った方法では表示の直前に割り込めるため、これを回避可能です。

参考:https://stackoverflow.com/questions/8351346/customize-nstoolbar-disable-use-small-size

NSWindowDelegate
func window(_ window: NSWindow, willPositionSheet sheet: NSWindow, using rect: NSRect) -> NSRect {
	if let sheetView = sheet.contentView {
		var needsRedraw = false
		// すべてのサブビューを平坦化した配列
		let allSubviews = sheetView.allSubviews()
		
		var labels = [NSTextField]()
		
		for view in allSubviews {
			// ポップアップボタンを特定して隠す
			if let button = view as? NSPopUpButton {
				button.isHidden = true
				needsRedraw = true
			}
			if let label = view as? NSTextField {
				labels.append(label)
			}
		}
		
		// 「表示」のラベルを隠す
		// 特定が難しいので、sheetViewの座標上で一番下にあるものを“それ”として扱う
		let targetLabel = labels.sorted { l0, l1 in
			var f0: NSRect {
				if let s0 = l0.superview, s0 != sheetView {
					return s0.convert(l0.frame, to: sheetView)
				}
				else {
					return l0.frame
				}
			}
			var f1: NSRect {
				if let s1 = l1.superview, s1 != sheetView {
					return s1.convert(l1.frame, to: sheetView)
				}
				else {
					return l1.frame
				}
			}
			
			return f0.minY < f1.minY
		}.first
						   
		if let targetLabel {
			targetLabel.isHidden = true
			needsRedraw = true
		}
		
		// 再描画を実行
		if needsRedraw {
			sheetView.display()
		}
	}
	
	return rect
}
NSView+allSubviews
extension NSView {	
	func allSubviews() -> [NSView] {
		var allSubviews = [NSView]()
		for view in subviews {
			allSubviews.append(contentsOf: view.allSubviews())
		}
		return allSubviews
	}
}

結論

まあお察しの通り、「Display Modeの変更を塞ぐ」を除くほか二つの方法はあまり安全ではありません。OSのバージョンアップでいつ動かなくなってもおかしくないし、プライベートな領域に触れているのでApp Storeに載せるアプリの場合はもしかしたらAppleに怒られる懸念もあります。

ここまでしてでもツールバーのDisplay Modeの変更を無効化したいのであれば、紹介したこれらの方法しかないようです。

あるいは、そもそもツールバーのカスタマイズ機能自体を無効化してしまいましょう。ツールバーの配置をユーザーが自由に変えることはできなくなりますが、少なくとも公開APIによって制御できるため、こちらはとても安全だと思います。こちらの場合は今回紹介した三つの方法いずれも不要です。

allowsUserCustomization

Discussion