はじめに
GeekToolというソフトウェア、ご存知でしょうか?
GeekToolとは、macOSのデスクトップをいい感じにカスタマイズできるソフトウェアです。 「macOS版のRainmeter」 というとイメージしやすいかもしれません。
2003年頃(要出典)にTynsoe氏によって開発が開始された非常に息の長いソフトで、2012年には日本の大手ネットメディア「LifeHacker」でも紹介されています。
実際に使ってみると↓こんな感じの画面を作ることができます。(もっと凝ることもできるのですが、あまりやりすぎると煩くなってしまうので控えめにしています)
今回はこれの模倣…とまではいきませんが、似たものを作ってみました。
NSWindowを透明にする
では早速作っていきましょう。
通常通りCocoaアプリケーションを作成するとMain.storyboard
が自動生成され、そのまま実行するとただのウィンドウが開きます。
これをベースに作っても良いのですが、デスクトップをカスタマイズするというからには背景を透過させたいものです。
そこで NSWindow
を継承したカスタムクラスTransparentWindow
を作り…
import Cocoa
class TransparentWindow: NSWindow {
// 透明ウィンドウをよしなに作ってくれる
init(contentViewController: NSViewController){
// frameとstyleを設定してwindow生成
let contentRect = contentViewController.view.frame
let styleMask: NSWindow.StyleMask = [.resizable, .titled]
let backingType: NSWindow.BackingStoreType = .buffered
super.init(contentRect: contentRect, styleMask: styleMask, backing: backingType, defer: false)
self.contentViewController = contentViewController // VCを設定
self.tabbingMode = .disallowed // タブでなく毎回新規ウィンドウで開く
self.hasShadow = false // ウィンドウに影をつけない
self.isOpaque = false // ウィンドウを透明にする
// 背景色を設定
let backgroundColor: NSColor = .init(white: 0, alpha: 0.3)
self.backgroundColor = backgroundColor
}
}
Storyboardのカスタムクラスに割り当てようとすると怒られます。
XXXXXX/TransparentWindow.swift: 10: 7: Fatal error: Use of unimplemented initializer 'init(contentRect:styleMask:backing:defer:)' for class 'XXXXXX.TransparentWindow'
どうやら、この方法ではStoryboardに配置したNSWindowにカスタムクラスを割り当てることはできないようです。
仕方がないのでStoryboardをもう一つ作り(Main.storyboard
はそのまま)、AppDelegate.swift
からViewControllerを直接呼び出す方法を考えます。
まずViewControllerを配置し、 "is Initial Controller" にチェックを入れます。
そしてこれを呼び出すコードをAppDelegate.swift
に追記します。
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Storyboardを呼び出し
let widgetStoryboard = NSStoryboard(name: "Widget", bundle: nil)
guard let widgetViewController = widgetStoryboard.instantiateInitialController() as? WidgetViewController else{
fatalError("Couldn't instantiate view controller!")
}
// VCをwindowに載せて表示
let widgetWindowController = NSWindowController(window: TransparentWindow(contentViewController: widgetViewController))
widgetWindowController.showWindow(self)
}
...
}
実行すると…
半透明のウィンドウを表示することができました。
デスクトップより手前、アイコンより後ろにウィンドウを配置する
次に、このウィンドウをどこに配置するかを設定していきます。
Cocoaでは、ディスプレイの前後方向の位置関係を WindowLevel(NSWindow.Level) という概念で管理しています。
let windowLevelKey: CGWindowLevelKey = .normalWindow
self.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(windowLevelKey)))
例えば.screenSaverWindow
を設定すれば、フォーカスが外れても最前面をキープできますし…
.dockWindow
を設定すれば、Dockより手前に持ってくることも可能です。
今回は デスクトップ背景より手前、かつフォルダアイコンより後ろ にウィンドウを配置したいので、.desktopIconWindow
を設定します。
タイトルバーを消し、Viewをドラッグして移動できるようにする
ウィンドウを目的の場所に配置する事はできましたが、ウィジェットにはタイトルバーは存在しません。これも消してしまいましょう。
単純に消すだけならばこれで十分です:
self.styleMask.remove(.titled) // styleMaskを外す
self.titlebarAppearsTransparent = true // タイトルバーを透明に
self.titleVisibility = .hidden // タイトルを表示しない
しかし、何も考えずにタイトルバーを消すとウィンドウを移動できなくなってしまいます。
そこでTransparentWindow.swift
のイニシャライザにこれを追加し、
self.isMovableByWindowBackground = true
WidgetViewController
のview
にカスタムクラスWidgetBackgroundView
を設定します。
class WidgetBackgroundView: NSView {
override var mouseDownCanMoveWindow: Bool{
get{return true}
}
}
これでタイトルバーのないウィンドウを移動できるようになりました。
使用例
透明なウィンドウの上に乗せた WidgetBackgroundView
に色々載せて遊んでみます。
Youtubeを埋め込む
Youtubeの動画をWebサイトなどに埋め込む際のリンク(https://www.youtube.com/embed/(動画ID)
)をWKWebView
に渡すことで、デスクトップで動画を再生できます。
クリックイベントを拾うことができないので、再生・停止のコントロールは少し煩雑になります。ここではevaluateJavaScript
を用いて再生ボタンを探し、クリックイベントを発生させることで対応しています。
サンプルコード
import WebKit
import Cocoa
class WidgetViewController: NSViewController {
@IBOutlet weak var webView: WKWebView!
override func viewDidLoad() {
super.viewDidLoad()
self.webView.navigationDelegate = self
}
override func viewDidAppear() {
self.webView.load(URLRequest(url: URL(string: "https://www.youtube.com/embed/GEZhD3J89ZE")!))
}
}
extension WidgetViewController: WKNavigationDelegate{
// document.onload
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// 再生ボタンを特定してクリック
self.webView.evaluateJavaScript("document.querySelector(\".ytp-large-play-button.ytp-button\").click()", completionHandler: nil)
}
}
class WidgetBackgroundView: NSView {
override var mouseDownCanMoveWindow: Bool{
get{return true}
}
}
時計を表示する
フォントセレクタや表示形式の変更など、できることは色々ありそうです。
サンプルコード
import Cocoa
class WidgetViewController: NSViewController {
@IBOutlet weak var clockLabel: NSTextField!
var timer: Timer?
override func viewDidLoad() {
super.viewDidLoad()
self.timer = Timer.scheduledTimer(timeInterval: 1, target: self, selector: #selector(timerUpdate), userInfo: nil, repeats: true)
}
override func viewWillDisappear() {
self.timer?.invalidate()
}
@objc func timerUpdate(){
self.clockLabel.stringValue = DateFormatter.localizedString(from: Date(), dateStyle: .short, timeStyle: .medium)
}
}
まとめ
ここまで読んでいただきありがとうございました。
今回は透明なウィンドウを作っていくつかビューを乗せただけの簡素なものになってしまいましたが、これにエディット機能やバックグラウンド更新機能などを盛り込んでいけば普通に便利なウィジェットアプリを作れそうな気がします。
完全に向こう側を透過させられるので、デスクトップピクチャを背景として3Dモデルを表示させたり、画面下にオーディオビジュアライザを表示したり…可能性は無限大です!
それでは!