はじめに
macOS Big Sur 11でNSButtonにhasDestructiveActionが追加され,macOSでもiOSやiPadOSのような破壊的アクションのボタンを設定することができるようになりました。
「おぉ!」と思いきや,それはNSAlert内の選択肢NSButtonだけの話。残念ながら普通のNSButtonに設定しても何の変化もありません。
…ん??編集済み書類を閉じようとしたときに表示される保存パネルの“削除”ボタンに,さも見せびらかすように破壊的アクションのNSButtonが使われているではありませんか!
試しにNSButtonのattributedTitleにNSColorのsystemRedの色を付けたNSAttributedStringを設定してみましたが,色や挙動が異なります。
なんとか再現できないかと,血眼になりながら“削除”ボタンで内部的に使われているメソッドやリソースを探し回りました。
どうやらmacOSでは,システム共通のコンポーネントはシステムサービスが担っており,Appとは隔離され遠隔でAppのビューに描画/操作する仕組みになっているようです。加えて macOSはガードが堅く,Windowsのように他のアプリケーションであってもコンポーネントのウィンドウハンドルが取得できれば外部から好き勝手できるほど甘くはありません。
ちょっとした自己満足のつもりでしたが,特定するのに予想以上の手間がかかってしまいました。
⚠️ 注意 ⚠️
この記事で紹介するものは ドキュメント化されていない非公開の内部API であり,それらを使用したAppはApp Storeの審査で拒否される可能性があります。
また,将来的にAppleの都合で内部仕様が変更された場合,正常に動作しなくなることも想定されます。公開APIであってもコロコロ仕様を変えるAppleのことですから,非公開ともなれば尚更でしょう。
上記のことを理解した上で お使いください。
任意のNSButtonを破壊的アクションのボタンにする
Step 1. NSButtonの内部メソッドを拡張定義する
NSButtonに隠されている内部メソッドを拡張定義して呼び出せるようにします。
Objective-Cの場合は,呼び出すソースファイルに #import
すればOKです。
# import <AppKit/NSButton.h>
@interface NSButton ()
@property (getter=isDestructive) BOOL destructive API_AVAILABLE(macos(11.0));
@property (setter=_setUsesCautionaryAppearanceWhenActionIsDestructive:) BOOL _usesCautionaryAppearanceWhenActionIsDestructive API_AVAILABLE(macos(11.0));
@property (getter=isGuarded) BOOL guarded API_AVAILABLE(macos(10.12));
- (BOOL)isDestructive API_AVAILABLE(macos(11.0));
- (void)setDestructive:(BOOL)destructive API_AVAILABLE(macos(11.0));
- (BOOL)_usesCautionaryAppearanceWhenActionIsDestructive API_AVAILABLE(macos(11.0));
- (void)_setUsesCautionaryAppearanceWhenActionIsDestructive:(BOOL)usesCautionaryAppearance API_AVAILABLE(macos(11.0));
- (BOOL)isGuarded API_AVAILABLE(macos(10.12));
- (void)setGuarded:(BOOL)guarded API_AVAILABLE(macos(10.12));
@end
Objective-Cの例外を捕捉する(Swiftの場合)
Swiftでは Objective-C側で例外が発生するとクラッシュしてしまうので,NSExceptionをNSErrorに変換して Swift側で捕捉できる仕組みも作っておくとよいでしょう。
# import <Foundation/Foundation.h>
@interface ObjC : NSObject
+ (BOOL)tryPerform:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error;
@end
# import "ObjC.h"
@implementation ObjC
+ (BOOL)tryPerform:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error {
@try {
tryBlock();
return YES;
} @catch (NSException *exception) {
*error = [[NSError alloc] initWithDomain:exception.name code:0 userInfo:exception.userInfo];
return NO;
}
}
@end
Swiftの場合は,<Product Name>-Bridging-Header.h
の頭の方に
# import "ObjC.h"
# import "NSButtonEx.h"
を追記することもお忘れなく。
Step 2. NSButtonに定義したメソッドを呼び出す
あとは,viewDidLoadあたりに 以下のようなコードを追記するだけです。
Objective-C
// NSButton *button = ...
if (@available(macOS 11.0, *)) {
@try {
button.destructive = YES;
button._usesCautionaryAppearanceWhenActionIsDestructive = YES;
button.guarded = YES;
} @catch (NSException *exception) {
// 将来的な仕様変更で使用できなくなった場合
NSLog(@"%@", exception);
}
}
Swift
// let button : NSButton = ...
if #available(macOS 11.0, *) {
do {
try ObjC.tryPerform {
button.isDestructive = true
button._usesCautionaryAppearanceWhenActionIsDestructive = true
button.isGuarded = true
}
} catch let error {
// 将来的な仕様変更で使用できなくなった場合
print(error)
}
}
これで NSButtonが破壊的アクションのボタンとなり,文字色が赤色になりました。
ボタンを押下時には ちゃんと色が変わります。
おまけ: 破壊的アクション色を取得する
ボタンを破壊的アクションにする以外にも,AppKitフレームワークのバンドルのAsset Catalogに こっそり定義されている色を直接取得することで,破壊的アクションのコントロールテキスト用の文字色を取得することができます。
なお,この色をラベルなどのコンポーネントの文字色に使うのは構いませんが,ボタンの文字色としてそのまま設定することは好ましくありません。
先述のとおり attributedTitleで文字スタイルを指定すると,その文字色で固定されてしまいボタン押下時にも変化しなくなります。
Objective-C
[NSColor colorNamed:@"_NSDestructiveActionControlTextColor" bundle:[NSBundle bundleWithIdentifier:@"com.apple.AppKit"]];
Swift
AppKit
NSColor(named: "_NSDestructiveActionControlTextColor", bundle: Bundle(identifier: "com.apple.AppKit"))
SwiftUI
Color("_NSDestructiveActionControlTextColor", bundle: Bundle(identifier: "com.apple.AppKit"))
最後に
このようなことに拘るような方は 滅多におられないかと存じますが,ぜひ何かの参考になれば幸いです。
この度は,本記事をお読みくださり ありがとうございました。