🐰

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

2024/05/01に公開

NSToolbarのDisplay Mode(表示モード)とは、「アイコンとラベル」「アイコンのみ」「テキストのみ」の現状三種類あるモードをユーザーが任意に切り替えられる仕組みです。結論から言うと、macOS 14 Sonoma系まではDisplay Modeの変更を無効化する正規の方向が用意されておらず、UI構造を掘ったりして無理やり無効化するといったハックが必要です。しかし、macOS 15 Sequoia以降からはそれ専用のAPIがついに用意され、ハック技を使わずとも安全にDisplay Modeの変更と関係UIを無効化することができるようになる予定です。


コンテクストメニュー


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


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

余談ですが古いmacOS / Mac OS XではDisplay Modeのほかアイコンの大小モード(Size Mode)を変更できる仕組みがあり、当時は「小さなサイズを使用」というメニューおよびチェックボックスがUIに備わっていました。直近のmacOSではこの機能はシステムによって非表示にされていて、API自体も非推奨になっています。基本的にはSize Modeのことは考える必要はないでしょう。

2024-06-17追記:macOS 15 Sequoiaで表技が用意される予定

この記事で言及している裏技(ハック)はmacOS 14 Sonoma以前で必要だった手立てです。macOS 15 Sequoia以降では専用のAPIがついに用意される予定なので、危険なハックを行う必要がありません。

var allowsDisplayModeCustomization: Bool { get set }

https://developer.apple.com/documentation/appkit/nstoolbar/4357348-allowsdisplaymodecustomization?changes=latest_minor

どうやらmacOS 14 Sonoma以前では裏技を使うしかない

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

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


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

Display Modeの変更を塞ぐ

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

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

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

UIを消す:コンテクストメニュー項目を隠す


コンテクストメニュー

ツールバーのコンテクストメニューの一部項目を無効化します。これには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
			}
		}
	}
}

UIを消す:ツールバーパレットのUIを隠す


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

ツールバーパレット(カスタマイズ時のパレット)に存在するポップアップボタンと、日本語環境では「表示」と書かれたラベルを隠したいと思います。

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

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

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

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

参考: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の変更を無効化したいのであれば、紹介したこれらの方法しかないようです。ただし、macOS 15 Sequoia以降であれば安全なAPIが用意されるので、Sequoia以降ではこちらの方法を採用しつつ、Sonoma以前ではよく注意しながら対応を考えてみてください。

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

allowsUserCustomization

Discussion