LoginSignup
8
9

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-02-10

はじめに

GeekToolというソフトウェア、ご存知でしょうか?

GeekToolとは、macOSのデスクトップをいい感じにカスタマイズできるソフトウェアです。 「macOS版のRainmeter」 というとイメージしやすいかもしれません。
2003年頃(要出典)にTynsoe氏によって開発が開始された非常に息の長いソフトで、2012年には日本の大手ネットメディア「LifeHacker」でも紹介されています

実際に使ってみると↓こんな感じの画面を作ることができます。(もっと凝ることもできるのですが、あまりやりすぎると煩くなってしまうので控えめにしています)

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

NSWindowを透明にする

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

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

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" にチェックを入れます。

そしてこれを呼び出すコードをAppDelegate.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)
    }

    ...
}

実行すると…

半透明のウィンドウを表示することができました。

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

次に、このウィンドウをどこに配置するかを設定していきます。
Cocoaでは、ディスプレイの前後方向の位置関係を WindowLevel(NSWindow.Level) という概念で管理しています。

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

例えば.screenSaverWindowを設定すれば、フォーカスが外れても最前面をキープできますし…

.dockWindowを設定すれば、Dockより手前に持ってくることも可能です。

今回は デスクトップ背景より手前、かつフォルダアイコンより後ろ にウィンドウを配置したいので、.desktopIconWindowを設定します。

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

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

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

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

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

TransparentWindow.swift
self.isMovableByWindowBackground = true

WidgetViewControllerviewにカスタムクラスWidgetBackgroundViewを設定します。

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

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

使用例

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

Youtubeを埋め込む

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

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

サンプルコード
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}
    }
}

時計を表示する

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

サンプルコード
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)
    }
}

まとめ

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

それでは!

8
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
9