年末に反省したのに(→ 2018.12.29 )またもや青いカテゴリーの記事だ 。とはいえ、いつもみたいに Cocoa ネイティブの話だけではない。
快適に“ひ to り go と”を更新するため自分専用に作っている記事エディタがある。記事やデータベースのファイルはすべてローカルにあるものを編集し、あとから手動でサーバにコピーするから 1 台の Mac 内で完結しているのだけど 、CMS のエンジンにアクセスする都合上エディタも PHP(とクライアント側の小さな JavaScript)で動いている…というのは以前書いた通りだ。
Web ブラウザ内での使用感では飽き足らずそれをラップする Cocoa App まで作ったものの、WKWebView
の中では POST でフォーム上のデータを送信する仕組みのため、記事を保存するたびに画面が真っ白になって再読み込みがかかりエディタやプレビューのスクロール位置がリセットされてしまうものだった。保存せずに記事を離れたりウインドウを閉じるときでも警告すら出てくれない。
macOS 上で macOS らしくない体験はすべてノイズだ。純粋なネイティブ App ではないものの改善の余地はある。ちょうど別件で最近の JavaScript 事情を勉強していたところなので、ES2017 までの新機能をふんだんに使って記事エディタの中身を作り直したのである。
あらゆるものをウインドウの表示範囲の中で無理やり完結しているのが Electron みたいな Web ベースで作られたデスクトップ App の気持ち悪さの一因 だと思う。WKWebView
にさせる仕事がほとんどだとはいえ、せっかく Mac 専用に App を作るのだ。積極的にウインドウの外にもはみ出していこう!
NSAlert
を JavaScript から使う
これだけ長い前置きを書いたもののこの記事で触れるのは NSAlert
だ。これをシートとして表示するだけでずいぶんと Mac App らしくなる。好きなボタンを並べられるし、“保存しない”ボタンを command + delete で押せるようにするようなカスタマイズも簡単だ。「保存しますか?」をネイティブなシートで表示させてみよう。
タイトルバーがないとシートを表示する場所に困る。
まずは Cocoa 側を準備だ。前提として、WKWebView
内の JavaScript の世界から送られてくるメッセージは WKScriptMessageHandler
というプロトコルのメソッドを実装したオブジェクトが処理する。WKWebView
を生成するタイミングでハンドラ(JavaScript 側から呼び出す)ごとにそれらを登録しておくとよいだろう。ここでは "showAskForSaveAlert"
という名前にする。
let configuration = WKWebViewConfiguration(); do {
let userContentController = WKUserContentController(); do {
userContentController.add(self, name: "showAskForSaveAlert")
}
configuration.userContentController = userContentController
}
self.webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 300, height: 300), configuration: configuration)
WKWebView の生成
具体的な処理は WKScriptMessageHandler.userContentController(_:didReceive:)
で行う。message.name
には先ほど登録したハンドラ名 "showAskForSaveAlert"
、そして message.body
には JavaScript 側から与えるパラメータが Cocoa のオブジェクトに変換されて入ってくる。単体オブジェクトや配列でもいいけど、汎用性を考えるといつも [String: Any]
として必要なものとその名前を格納する習慣にしておくとよさそうだ。
受け取ったパラメータ {articleName, hasSavedIdentifier}
をもとに NSAlert
を表示するコードは下のような感じ。
Cocoa 側でボタンが押されたら JavaScript 側に用意した関数 _alertDidComplete()
を呼び出して結果を伝える。WKWebView
内の JavaScript を実行するには WKWebView.evaluateJavaScript(_:completionHandler:)
を使おう。
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage)
{
switch message.name {
case "showAskForSaveAlert":
let arguments = message.body as! [String: Any?]
let articleName = arguments["articleName"] as! String
let hasSavedIdentifier = arguments["hasSavedIdentifier"] as! Bool
let alert = NSAlert(); do {
if hasSavedIdentifier {
alert.messageText = "Do you want to save the changes made to the article “\(articleName)”?"
alert.informativeText = "Your changes will be lost if you don’t save them.";
} else {
alert.messageText = "Do you want to keep this new article “\(articleName)”?"
alert.informativeText = "You can choose to save your changes, or delete this article immediately. You can’t undo this action.";
}
alert.addButton(withTitle: "Save").keyEquivalent = String(utf16CodeUnits: [unichar(NSCarriageReturnCharacter)], count: 1);
alert.addButton(withTitle: "Cancel")
let dontSaveButton = alert.addButton(withTitle: (hasSavedIdentifier ? "Don't Save" : "Delete")); do {
//assign command + delete.
dontSaveButton.keyEquivalent = String(utf16CodeUnits: [unichar(NSDeleteCharacter)], count: 1);
dontSaveButton.keyEquivalentModifierMask = [.command]
}
}
let completionHandler = { (modalResponse: NSApplication.ModalResponse) -> Void in
let buttonIdentifier: String
switch modalResponse {
case .alertFirstButtonReturn:
buttonIdentifier = "save";
case .alertSecondButtonReturn:
buttonIdentifier = "cancel";
case .alertThirdButtonReturn:
buttonIdentifier = "dontSave";
default:
fatalError()
}
self.webView.evaluateJavaScript("_alertDidComplete(\"\(buttonIdentifier)\");", completionHandler: nil)
}
if let window = self.webview.window {
alert.beginSheetModal(for: window, completionHandler: completionHandler)
} else {
completionHandler(alert.runModal())
}
default:
break
}
}
WKScriptMessageHandler を実装
そして JavaScript の方ではアラートを表示するおおもとの関数 askForSave()
を用意。ここで引数のコールバックを保持しておき、 Cocoa から _alertDidComplete()
が呼び出されたら取り出す のがポイント。
Cocoa 側に登録したハンドラは window.webkit.messageHandlers.ハンドラ名.postMessage()
でパラメータを渡しながら呼び出すことができる。
let alertCompletionCallbacks = [];
const askForSave = (article, callback) => {
if (!article.hasUnsavedChanges) {
return callback('dontSave');
}
alertCompletionCallbacks.push(callback);
window.webkit.messageHandlers.showAskForSaveAlert.postMessage({
articleName: article.title,
hasSavedIdentifier: (article.savedIdentifier !== null)
});
};
const _alertDidComplete = buttonIdentifier => { //called by Cocoa.
let callback = alertCompletionCallbacks.pop();
callback(buttonIdentifier);
};
JavaScript 側の定義
簡単にネイティブなアラートを表示できるようになった。記事を閉じるまえに保存するかどうかを確認させてみよう。ボタンごとに処理を分岐している。
const performCloseArticle = () => {
try {
askForSave(
currentArticle,
buttonIdentifier => {
switch (buttonIdentifier) {
case 'save':
currentArticle.save();
currentArticle = new Article();
break;
case 'dontSave':
currentArticle = new Article();
case 'cancel':
//do nothing
break;
}
}
);
} catch (error) {
presentError(error);
throw error;
}
};
使用例
Promise を使うと
先ほどの定義だと呼び出すときの引数としてコールバックが存在したけど、いまどきの JavaScript であれば Promise
を使用する手もあるらしい。
let alertPromiseHandlers = [];
const askForSave = article => { //throws UserCancelError
return new Promise((resolve, reject) => {
if (!article.hasUnsavedChanges) {
return resolve(false);
}
alertPromiseHandlers.push({ resolve, reject });
window.webkit.messageHandlers.showAskForSaveAlert.postMessage({
articleName: article.title,
hasSavedIdentifier: (article.savedIdentifier !== null)
});
});
};
const _alertDidComplete = buttonIdentifier => { //called by Cocoa.
let handler = alertPromiseHandlers.pop();
if (buttonIdentifier === 'cancel') {
handler.reject(new UserCancelError());
} else {
handler.resolve(buttonIdentifier === 'save');
}
};
JavaScript 側の定義(Promise バージョン)
Promise
を作るときに得られる resolve
(エラーなら reject
)をコールバックの代わりに呼び出すというだけで基本的に同じだ。
ついでにキャンセルボタンが押されたときは Error
のサブクラス UserCancelError
(別の場所で定義しておく)を投げるようにしてみた。基本的には処理を途中で抜けてほしいのでこのやり方が好き。もちろんこれをキャッチしたときは何もしない。
それによって結果も保存するかどうかの二択になってシンプルだ。
const performCloseArticle = () => {
askForSave(currentArticle).then(save => {
if (save) {
currentArticle.save();
}
currentArticle = new Article();
}, error => {
if (error instanceof UserCancelError) {
//do nothing
return;
}
presentError(error);
throw error;
});
};
Promise.then() での使用例
また、Promise
であれば async
の関数内から await
をつけて呼び出すと直接結果を取得できる。ほかの Promise
や async
関数の呼び出しが増えてもシンプルに書けるし、ユーザの操作に対してエラーを表示する処理を共通化するのも簡単だ。
const performOrPresentError = async asyncHandler => {
try {
await asyncHandler();
} catch (error) {
if (error instanceof UserCancelError) { return; }
await presentError(error);
throw error;
};
}
const performCloseArticle = () => {
performOrPresentError(async () => {
if (await askForSave(currentArticle)) {
currentArticle.save();
}
currentArticle = new Article();
});
};
async await を使った使用例
ちなみに presentError()
というのは NSAlert
でエラーを表示してくれる関数だ。ネイティブ、ネイティブ!
App アイコン欲しい。
---
慣れないし古い知識しかなかった JavaScript。この本のおかげで少し頭が整理された気がする:
「最新の書き方でできれいに書く」が前提になっているところが気持ちいい。
Share
リンクも共有もお気軽に。記事を書くモチベーションの向上に役立てます。