【Swift】デスクトップをカスタマイズしよう(AppKit)


## はじめに

[**GeekTool**](https://www.tynsoe.org/geektool/)というソフトウェア、ご存知でしょうか?
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/492914/7716972c-986a-0f8c-24d9-42c9490a2a2f.png" width="600">
GeekToolとは、macOSのデスクトップをいい感じにカスタマイズできるソフトウェアです。 **「macOS版のRainmeter」** というとイメージしやすいかもしれません。
2003年頃(要出典)にTynsoe氏によって開発が開始された非常に息の長いソフトで、2012年には日本の大手ネットメディア「LifeHacker」でも[紹介されています](https://www.lifehacker.jp/2012/07/120727geektool.html)。

実際に使ってみると↓こんな感じの画面を作ることができます。(もっと凝ることもできるのですが、あまりやりすぎると煩くなってしまうので控えめにしています)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/492914/0be4646e-ea5e-70b6-6c95-be634316459a.jpeg" width="600">

今回はこれの模倣…とまではいきませんが、似たものを作ってみました。

## NSWindowを透明にする

では早速作っていきましょう。
通常通りCocoaアプリケーションを作成すると`Main.storyboard`が自動生成され、そのまま実行するとただのウィンドウが開きます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/492914/21bdc102-9cb8-9378-da12-c41a7e6fc0a6.png" width="600">

これをベースに作っても良いのですが、デスクトップをカスタマイズするというからには**背景を透過させたい**ものです。
そこで `NSWindow`を継承したカスタムクラス`TransparentWindow`を作り…

```swift:TransparentWindow.swift

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" にチェックを入れます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/492914/8b8312ec-d9e0-73ec-9752-20bd2c774f88.png" width="600">
そしてこれを呼び出すコードを`AppDelegate.swift`に追記します。

```swift: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)
    }

    ...
}
```

実行すると…
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/492914/5fcca0a7-387f-b8fb-eecd-5376089cd929.gif" width="600">
半透明のウィンドウを表示することができました。

## デスクトップより手前、アイコンより後ろにウィンドウを配置する

次に、このウィンドウをどこに配置するかを設定していきます。
Cocoaでは、ディスプレイの前後方向の位置関係を **WindowLevel([NSWindow.Level](https://developer.apple.com/documentation/appkit/nswindow/level))** という概念で管理しています。

```swift:WindowLevelの設定
let windowLevelKey: CGWindowLevelKey = .normalWindow
self.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(windowLevelKey)))
```

例えば`.screenSaverWindow`を設定すれば、フォーカスが外れても最前面をキープできますし…
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/492914/f3ad0bd2-085c-2849-5fee-d2be32319479.png" width="300">
`.dockWindow`を設定すれば、Dockより手前に持ってくることも可能です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/492914/105d756a-c9ae-623b-ff0f-60785329bbd5.png" width="300">

今回は **デスクトップ背景より手前、かつフォルダアイコンより後ろ** にウィンドウを配置したいので、`.desktopIconWindow`を設定します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/492914/60094543-5f5e-a098-c971-28f1c457a244.png" width="400">

## タイトルバーを消し、Viewをドラッグして移動できるようにする

ウィンドウを目的の場所に配置する事はできましたが、ウィジェットにはタイトルバーは存在しません。これも消してしまいましょう。

単純に消すだけならばこれで十分です:

```swift:TransparentTitleBar
self.styleMask.remove(.titled)          // styleMaskを外す
self.titlebarAppearsTransparent = true  // タイトルバーを透明に
self.titleVisibility = .hidden          // タイトルを表示しない
```

しかし、何も考えずにタイトルバーを消すと**ウィンドウを移動できなくなってしまいます。**
そこで`TransparentWindow.swift`のイニシャライザにこれを追加し、

```swift:TransparentWindow.swift
self.isMovableByWindowBackground = true
```

`WidgetViewController`の`view`にカスタムクラス`WidgetBackgroundView`を設定します。

```swift:WidgetBackgroundView
class WidgetBackgroundView: NSView {
    override var mouseDownCanMoveWindow: Bool{
        get{return true}
    }
}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/492914/29f9b909-bfc9-8017-b071-bd13ed5a5b0f.gif" width="600">

これでタイトルバーのないウィンドウを移動できるようになりました。

## 使用例

透明なウィンドウの上に乗せた `WidgetBackgroundView` に色々載せて遊んでみます。

### Youtubeを埋め込む

Youtubeの動画をWebサイトなどに埋め込む際のリンク(`https://www.youtube.com/embed/(動画ID)`)を`WKWebView`に渡すことで、デスクトップで動画を再生できます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/492914/95c5b46a-fc9d-798f-fb0b-d71fed906f9b.png" width="600">

クリックイベントを拾うことができないので、再生・停止のコントロールは少し煩雑になります。ここでは`evaluateJavaScript` を用いて再生ボタンを探し、クリックイベントを発生させることで対応しています。

<details><summary>サンプルコード</summary><div>

```swift:WidgetViewController.swift

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}
    }
}
```
</div></details>

### 時計を表示する

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/492914/e764791e-1132-32e5-f164-5264adff7dbf.png" width="600">

フォントセレクタや表示形式の変更など、できることは色々ありそうです。

<details><summary>サンプルコード</summary><div>

```swift:WidgetViewController.swift
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)
    }
}
```
</div></details>

## まとめ

ここまで読んでいただきありがとうございました。
今回は透明なウィンドウを作っていくつかビューを乗せただけの簡素なものになってしまいましたが、これにエディット機能やバックグラウンド更新機能などを盛り込んでいけば普通に便利なウィジェットアプリを作れそうな気がします。
完全に向こう側を透過させられるので、デスクトップピクチャを背景として3Dモデルを表示させたり、画面下にオーディオビジュアライザを表示したり…可能性は無限大です!

それでは！