post Image
Unity + Mac + Swift で透過最前面ウィンドウを作る

やりたいこと

  • Unityで、Mac OS X 向けアプリケーションを作る。
  • 背景が透過されたウィンドウに描画する。
  • 非アクティブ時でも最前面に来るようにする。
  • 非アクティブ時はタイトルバーなどを隠し、アクティブ時のみタイトルバーなどを描画する。
  • Objective-Cではなく、Swiftで書く。

つまり、こういうものを作る。

前回までのあらすじ

この記事は単独でも読めるようになっているが、一応前回記事のリンクを貼っておく。

Unity + Mac で透過最前面ウィンドウを作る

前回は背景透過処理と最前面処理をObjective-Cで書いたが、コピペして少しいじっただけで、あまり内容までは踏み込まなかった。
今回はSwiftで再実装することで内容を理解し、よりわかりやすくナウいコーディングを目指す。
また、追加処理としてウィンドウのアクティブイベントを検知し、タイトルバーの可視属性を変化させてみる。

最終的なSwiftコード

せっかちな人のために、先にコードの全文を載せよう。

transparent.swift
//
//  transparent.swift
//  TransparentInSwift
//
//  Created by kriver1 on 2018/05/23.
//  Copyright © 2018年 kriver1. All rights reserved.
//

import Foundation
import Cocoa

// from: http://tatsudoya.blog.fc2.com/blog-entry-244.html
// see: https://qiita.com/mybdesign/items/fe3e390741799c1814ad

public class NativeWindowManager : NSObject {

    // style mask
    // see: https://developer.apple.com/documentation/appkit/nswindow.stylemask
    private static let styleMask: NSWindow.StyleMask = [.closable, .titled, .resizable]

    /// Initialize Unity window and set it to transparent and front.
    public static func initializeTransparent() -> Void {
        // get the window used by Unity (frontmost window object)
        let unityWindow: NSWindow = NSApp.orderedWindows[0]

        // step 1: set the Unity window transparent
        transparentizeWindow(window: unityWindow)

        // step 2: set the Unity view transparent
        transparentizeContentView(window: unityWindow)

        // step 3: make the window permanently front
        frontizeWindow(window: unityWindow)

        // step 4: observe notification
        // see: https://qiita.com/mono0926/items/754c5d2dbe431542c75e
        let center = NotificationCenter.default
        center.addObserver(forName: Notification.Name.NSWindowDidBecomeMain, object: nil, queue: nil, using: becomeMainListener(notification:))
        center.addObserver(forName: Notification.Name.NSWindowDidResignMain, object: nil, queue: nil, using: resignMainListener(notification:))
    }

    /// Set the window transparent.
    ///
    /// - Parameters:
    ///   - window: window to be transparent
    ///   - mask: window style (title bar, closable, or something else)
    private static func transparentizeWindow(window: NSWindow) -> Void {
        // set its style mask
        window.styleMask = styleMask
        // make it transparent
        window.backgroundColor = NSColor.clear
        window.isOpaque = false
        // remove its shadow
        window.hasShadow = false
    }

    /// Set the view transparent.
    ///
    /// - Parameter window: its content view is transparentized.
    private static func transparentizeContentView(window: NSWindow) -> Void {
        // if content view is nil, then do nothing
        if let view: NSView = window.contentView {
            // make it layer-backed
            // see: https://blog.fenrir-inc.com/jp/2011/07/nsview_uiview.html
            view.wantsLayer = true
            // make its layer transparent
            view.layer?.backgroundColor = CGColor.clear
            view.layer?.isOpaque = false
        }
    }

    /// Make the window permanently front.
    /// see: https://qiita.com/ocadaruma/items/790e96245c99e7af42a3
    ///
    /// - Parameter window: window to be permanently front
    private static func frontizeWindow(window: NSWindow) -> Void {
        window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
        window.level = NSFloatingWindowLevel
    }

    /// A listener for become main notification.
    ///
    /// - Parameter notification: Notification
    @objc private static func becomeMainListener(notification: Notification) -> Void {
        if let window = getWindowFromNotification(notification: notification) {
            toggleBorderAppearance(window: window, isShow: true)
        }
    }

    /// A listener for resign main notification.
    ///
    /// - Parameter notification: Notification
    @objc private static func resignMainListener(notification: Notification) -> Void {
        if let window = getWindowFromNotification(notification: notification) {
            toggleBorderAppearance(window: window, isShow: false)
        }
    }

    /// Safely get a window object from a notification object.
    ///
    /// - Parameter notification: Notification
    private static func getWindowFromNotification(notification: Notification) -> NSWindow? {
        if let window = (notification.object as? NSWindow) {
            return window
        } else {
            return nil
        }
    }

    /// Hide or show the border of the given window.
    ///
    /// - Parameters:
    ///   - window: a window to show/hide
    ///   - isShow: boolean value to indicate show or hide
    private static func toggleBorderAppearance(window: NSWindow, isShow: Bool) {
        window.styleMask = isShow ? styleMask : [.borderless]
        window.titlebarAppearsTransparent = !isShow
        window.titleVisibility = isShow ? .visible : .hidden
    }
}

前回のプログラムの修正

前回のプログラムでは、このサイトにある通り、新規ウィンドウを作成してそれを透過することで透過ウィンドウを実現していた。
しかし、その後いくつかトライアンドエラーを繰り返した結果、実はこの工程はいらなかったことが発覚した。単にUnityのウィンドウを透過させる処理を書けば良かったのである。
前回のプログラムから、newWindowに関する処理をすべて削除してもそのまま動作する。
これでコードがかなりきれいになった。

Objective-CからSwiftへ

Objective-Cで書かれたネイティブコードをSwiftに移植するのは、コード量が少なければ全然難しくない。基本的に、Objective-Cで書かれたコードをルールに従って一対一対応させていくだけで移植が完了する。

例えば、以下のようなアクセスを考える。

[window setBackgroundColor:[NSColor clearColor]];

これはSwiftに直すと以下のようになる。

window.backgroundColor = NSColor.clear

[a b]と書いていたアクセスを、他の言語と同じようにa.bで書くことができて、とても気持ちがいい。

Objective-CからSwiftに移行するメリットはたくさんあるが、例えばnilを安全に扱えるなどが挙げられる。
Swiftではnilになりうる型はOptionalとしてnilにならない型と厳密に区別されるので、変数がnilかどうかビクビク震えながらコードを書く必要がなくなる(あるいは、関数を呼ぶたびにnilかどうかを判定しなくてよくなる)。

さて、Objective-CからSwiftにするときは、Unityから見えるインタフェースをObjective-Cで書かなければならない。とはいえこれは簡単で、次のようなファイルを作ればよい。

hoge.swift
import Foundation

public class Hoge : NSObject {
    public static func huga() -> Void {
        ...
    }
}
hoge.mm
#import <Foundation/Foundation.h>
#import "[Your-Awesome-Project-Name]-Swift.h"

extern "C" {
    void _ex_callHugaOfHoge() {
        [Hoge huga];
    }
}
callHoge.cs
using UnityEngine;
public class callHoge : MonoBehaviour
{

#if UNITY_STANDALONE_OSX
    [DllImport("[Your-Awesome-Project-Name]")]
    private static extern void _ex_callHugaOfHoge();
#endif

    // Use this for initialization
    [RuntimeInitializeOnLoadMethod]
    static void doStuff()
    {
#if UNITY_STANDALONE_OSX
        _ex_callHugaOfHoge();
#endif
    }
}

UnityのC# codeがplugin bundle内のObjective-C++ codeを読み込み、Objective-C++ codeが同bundle内のSwift codeを参照する。この書き方によって、実装をすべてSwiftに任せることができる。

ちなみに、

Library not loaded: @rpath/libswiftAppKit.dylib

というエラーメッセージがUnity側で出る場合は、ここにある通りXCodeの設定で標準ライブラリをbundleに埋め込むことで解決する。
(リンク先は「これでは解決しなかった」というissueだが、僕の場合はこれで解決した。)

アクティブ・非アクティブを検知する

AppKitにはNotificationCenterなるものがあり、こいつにobserverを登録することでイベントを監視することができる(イベントが発火したときに呼んでもらうコールバックを登録することができる)。

この記事がわかりやすかった。
https://qiita.com/mono0926/items/754c5d2dbe431542c75e

コールバックが発火したかどうかを目で見たいときは、コールバックとしてメッセージを出すようなコードを書けばよい。
こういう感じでプッシュ通知が出せるので、printf的な気持ちで使える。

callback.swift
import Foundation
import Cocoa

public class CallBackManager : NSObject {
    public static func CallBackRegister() -> Void {
        let center = NotificationCenter.default
        center.addObserver(forName: Notification.Name.NSWindowDidBecomeMain, object: nil, queue: nil, using: becomeMainListener(notification:))
    }
    @objc private static func becomeMainListener(notification: Notification) -> Void {
        var pushNotification = NSUserNotification()
        pushNotification.title = "OK I called!"
        pushNotification.informativeText = "Notified with: \(notification) "
        pushNotification.soundName = NSUserNotificationDefaultSoundName
        NSUserNotificationCenter.defaultUserNotificationCenter().deliverNotification(pushNotification)
    }

}

Unityのビルド済みウィンドウをアクティブにして、プッシュ通知が来れば成功だ。

先述のコードのうち、下記の部分がこの処理に該当する。

transparent.swift
import Foundation
import Cocoa

...

public class NativeWindowManager : NSObject {

    ...

    /// Initialize Unity window and set it to transparent and front.
    public static func initializeTransparent() -> Void {

        ...

        // step 4: observe notification
        // see: https://qiita.com/mono0926/items/754c5d2dbe431542c75e
        let center = NotificationCenter.default
        center.addObserver(forName: Notification.Name.NSWindowDidBecomeMain, object: nil, queue: nil, using: becomeMainListener(notification:))
        center.addObserver(forName: Notification.Name.NSWindowDidResignMain, object: nil, queue: nil, using: resignMainListener(notification:))
    }

    ...

    /// A listener for become main notification.
    ///
    /// - Parameter notification: Notification
    @objc private static func becomeMainListener(notification: Notification) -> Void {
        if let window = getWindowFromNotification(notification: notification) {
            toggleBorderAppearance(window: window, isShow: true)
        }
    }

    /// A listener for resign main notification.
    ///
    /// - Parameter notification: Notification
    @objc private static func resignMainListener(notification: Notification) -> Void {
        if let window = getWindowFromNotification(notification: notification) {
            toggleBorderAppearance(window: window, isShow: false)
        }
    }

    /// Safely get a window object from a notification object.
    ///
    /// - Parameter notification: Notification
    private static func getWindowFromNotification(notification: Notification) -> NSWindow? {
        if let window = (notification.object as? NSWindow) {
            return window
        } else {
            return nil
        }
    }

    /// Hide or show the border of the given window.
    ///
    /// - Parameters:
    ///   - window: a window to show/hide
    ///   - isShow: boolean value to indicate show or hide
    private static func toggleBorderAppearance(window: NSWindow, isShow: Bool) {
        window.styleMask = isShow ? styleMask : [.borderless]
        window.titlebarAppearsTransparent = !isShow
        window.titleVisibility = isShow ? .visible : .hidden
    }
}

まとめ

途中詰まるところはいくつかあったものの、上記のような流れで最終的に透過&最前面&タイトルバー非表示なウィンドウを作ることができた。
みなさんがデスクトップに好きな女の子を召喚するときの助けになれば幸いである。

宣伝

技術的な話を除いた一部始終をはてなブログにまとめたので、ぜひ読んで下さい。
デスクトップに神降ろし – ブログ村


『 Swift 』Article List