LoginSignup
0
1

Mac Catalystで「常に手前に表示」を実現する

Last updated at Posted at 2023-07-18

はじめに

前回、Mac CatalystAppkit固有のAPIを使用する方法について、SwiftUIで書いた「逆ポーランド記法で入力する電卓アプリ」を例に紹介した。
前回の記事はこちら

今回は、同じアプリにて、Mac Catalystの場合に『常に手前に表示(Always on Top)』を実装する方法について説明する。Mac CatalystアプリでAppKitを使用する、より実用的な内容である。

1. AppKitによるAlways on Topの実装

これの実装は、ごくごく簡単で、mainWindowlevelプロパティを設定するだけ。

Appkit
guard let window = NSApplication.shared.mainWindow else { return }
if isOnTop {
    window.level = .floating    //最前面に固定
} else {
    window.level = .normal      //通常
}


次に、『「常に手前に表示」する/しない』 の選択を、アプリのウィンドウタイトルバー(ツールバー)に置いたアイコンのクリックにて、トグル式に切り替えられるようにする。

アイコン
(通常時)
pin1.png
アイコン
(常に手前に表示)
pin2.png

アイコンのイメージデータは、上のような2種類を用意した。サイズは30×30程度。

 

2. アプリのウィンドウタイトルバーにアイコンを置く

アプリのウィンドウタイトルバーにアイコンを置くためには、いくつかの手順を踏む必要があり、順に説明していく。

  1. NSToolbarDelegateを実装する
  2. UIWindowSceneDelegateを実装する

2.1 NSToolbarDelegateを実装する

  • NSToolbarDelegateはAppKitのAPIであるため、ToolbarDelegate独自クラスでラップしてiOSでもコンパイルが通るようにする
  • ToolbarDelegateクラスのextensionNSToolbarDelegateを実装する。このextensionはコンパイルオプションでMac Catalystのみ有効とする

新規にSwiftファイルを追加し、ToolbarDelegate.swiftと名付ける。内容は以下の通り。

ToolbarDelegate.swift
class ToolbarDelegate: NSObject {
    private var pinned = false
}

#if targetEnvironment(macCatalyst)
extension ToolbarDelegate: NSToolbarDelegate {
    func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
        let toolbarItem = NSToolbarItem(itemIdentifier: itemIdentifier)
        toolbarItem.label = "Always on Top"
        toolbarItem.paletteLabel = "Always on Top"
        toolbarItem.toolTip = "Always on Top"
        toolbarItem.tag = toolbar.items.count
        toolbarItem.target = self
        toolbarItem.isEnabled = true
        toolbarItem.image = UIImage(named: "pin")
        toolbarItem.action = #selector(onClicked)
        return toolbarItem
    }
    func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        let itemId = NSToolbarItem.Identifier("ALWAYS_ON_TOP")
        let identifiers: [NSToolbarItem.Identifier] = [ itemId ]
        return identifiers
    }
    func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
        return toolbarDefaultItemIdentifiers(toolbar)
    }

    @objc func onClicked(_ toolbarItem: NSToolbarItem) {
        pinned.toggle()
        toolbarItem.image = pinned ? UIImage(named: "pinned")?.withTintColor(.tintColor) : UIImage(named: "pin")
        loadPlugin(pinned)
    }
}
#endif

NSToolbarDelegateプロトコルの3つの関数で、以下を実装する。

  • ツールバーの識別子を定義(ここではALWAYS_ON_TOP
  • 上記識別子に対応したツールバーアイテムを作成
  • ツールバーアイテムにアイコン(イメージデータ)を設定
  • ツールバーアイテムのアクション(onClicked)を設定
  • onClicked(「常に手前に表示」する/しない切り替え)関数は、状態の切り替えと後述するプラグインのロードを行う

ここで、ツールバーアイテムに設定するイメージデータは、本来はNSImageのはずだが、なぜかUIImageを要求してくる。理由は不明だが、UIImageで実際に動作するため、深追いはやめた。

2.2 UIApplicationDelegateを実装する

上で定義したツールバーの作成を、UIWindowSceneDelegateプロトコルのsceneメソッドで行うのだが、UIWindowSceneDelegateを実装するためには、まずAppDelegateUIApplicationDelegate)を実装する必要がある。

SwiftUIアプリの場合、AppDelegateSceneDelegateは、プロジェクト作成時には普通は作られないため、次の内容で作成する。各Delegateは本来はSwiftファイルを分けるべきだが、今回はAppMainにまとめて書くことにする。

AppMain.swift(変更前:元々の内容)
@main
struct RPNCalcApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

勝手にAppMainと呼んでしまったが、元々は上のような内容。ここに@UIApplicationDelegateAdaptorディレクティブを追加し、AppDelegateSceneDelegateの実装クラスを定義する。

AppMain.swift(変更後)
@main
struct RPNCalcApp: App {
+   @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

+ //以降を追加
class AppDelegate: NSObject, UIApplicationDelegate {
    func application( _ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        let config = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
        config.delegateClass = SceneDelegate.self
        return config
    }
}

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    //中身はこの次で実装する
}

2.3 UIWindowSceneDelegateを実装する

コンパイルオプションでMac Catalystのみ有効として、ツールバーの作成とToolbarDelegateのインスタンスを設定する。

SceneDelegate
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var toolbarDelegate = ToolbarDelegate()

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }

#if targetEnvironment(macCatalyst)
        let toolbar = NSToolbar(identifier: "main")
        toolbar.delegate = toolbarDelegate
        toolbar.displayMode = .iconOnly
        toolbar.isVisible = true
        
        if let titlebar = windowScene.titlebar {
            titlebar.toolbar = toolbar
        }
#endif
    }
    func sceneDidDisconnect(_ scene: UIScene) {
    }
}

3. Plugin.swiftを作り直す

前回の記事で作成したPlugin.swiftの内容を以下のように変更する。(前回のloadPluginメソッドは削除する)

Plugin.swift
import Foundation

@objc(Plugin)
protocol Plugin: NSObjectProtocol {
    init()
    
    func alwaysOnTop(isOnTop: Bool)
}

4. MacPlugin.swiftを作り直す

前回の記事で作成したMacPlugin.swiftの内容を以下のように変更する。

MacPlugin.swift
import AppKit

class MacPlugin: NSObject, Plugin {
    required override init() {
    }
    
    func alwaysOnTop(isOnTop: Bool) {
        guard let window = NSApplication.shared.mainWindow else { return }
        if isOnTop {
            window.isOpaque = true
            window.alphaValue = 0.9
            window.level = .floating
        } else {
            window.isOpaque = false
            window.alphaValue = 1.0
            window.level = .normal
        }
    }
}

mainWindowlevelプロパティの設定に加え、「常に手前に表示」にした場合は、裏に隠れるウィンドウがうっすら透けて見えるように、window.alphaValue値を0.9とした。

5. loadPluginを作り直す

ToolbarDelegateクラスのextension内に、新たなloadPluginを作成する。

ToolbarDelegate.swift
#if targetEnvironment(macCatalyst)
extension ToolbarDelegate: NSToolbarDelegate {

    : : 中略
    
    private func loadPlugin(_ pinned: Bool) {
        let bundleFileName = "MacPlugin.bundle"
        guard let bundleURL = Bundle.main.builtInPlugInsURL?.appendingPathComponent(bundleFileName), let bundle = Bundle(url: bundleURL), let pluginClass = bundle.principalClass as? Plugin.Type else { return }
        let plugin = pluginClass.init()
        plugin.stayOnTop(isOnTop: pinned)
    }
}
#endif

6. ContentView内の前回の例を削除

前回の例でContentViewに加えた処理を削除(または、コメントアウト)して元に戻しておく。

ContentView.swift

    : : 
    
                Button(action: {
- #if targetEnvironment(macCatalyst)
+ //#if targetEnvironment(macCatalyst)
                    let stacks = stack.isEmpty ? "(empty)" : stack.reversed().map { $0.string }.joined(separator: "\n")
-                   loadPlugin(stacks)
+                   //loadPlugin(stacks)
- #else
+ //#else
                    showingStack.toggle()
- #endif
+ //#endif
                }, label: {
                    Text("Show Stack")
                })

    : : 
    

以上で必要なすべての実装が完了した。

Mac Catalystでビルドして実行すると、ウィンドウタイトルバーにアイコンが表示されるはずだ。

アイコンをクリックして、『「常に手前に表示」する/しない』 を試してもらいたい。
 


もちろん、iPhoneやiPadでビルドして実行しても、何も起きない(これまでと変化がない)ようになっている。

おわりに

今回説明した実装を使った、とあるMac CatalystのMacアプは、Appleの審査を通過した実績を持っている。
ぜひ参考にしてもらいたい。

以上

0
1
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
0
1