Hyperion-iOSをもうちょっと便利にする

こんにちは、nirazoです。
Airメイトという飲食店の経営支援サービスのiOSアプリエンジニアをやっています。

iOSエンジニアの皆さん、[Hyperion-iOS](https://github.com/willowtreeapps/Hyperion-iOS)は使っていますか？
iPhoneの実機やSimulator上でデザインチェックができるという素晴らしいツールです:tools:

![inspector.gif](https://qiita-image-store.s3.amazonaws.com/0/13042/6d20f422-1388-1a46-efec-6b4acdb0c13a.gif)
（公式のReadmeより拝借）

今年に入って記事も書かれてきて、徐々にその便利さに気付き始めた人が増えてきているかと思います。

機能の説明は公式の[Readme](https://github.com/willowtreeapps/Hyperion-iOS)や、以下の記事にお任せするとして、もうちょっと便利にできたら…と思うところがあったのでちょっといじってみました。

- [iPhone上でデザインチェックできるツールが神がかっている](https://qiita.com/kaoryuuu/items/36ddbaa0d82db53f2690)
- [アプリ上でデザインレビューできるHyperion-iOSが便利すぎる](https://qiita.com/toshi586014/items/e9a585687de72809fb13)

今回、2種類の方法でHyperion-iOSをちょっと便利にしてみたのでお付き合いください。

# デバッグメニューの出し方変更
Hyperionのデバッグメニューは、画面右端からスワイプするか、シェイクジェスチャー(これはこの記事を書くために調べてて初めて気づきましたw) を行うことで表示できます。
ただ、特に横スクロールを行う画面やUITableViewCellを削除するような画面だと、普通に操作していたのにデバッグメニューが表示されてしまいストレスを感じてしまうことがあるかと思います。

**そんなあなたに朗報です！！
デバッグメニューの出し方は簡単に変更できるのです** :star2:

やり方は非常に簡単で、専用の設定ファイルを作成してプロジェクト内に配備するだけです。
公式のReadmeにも記載があるので、それに則ってやってみましょう:sunny:

## ファイル名、パス
ファイル名は`HyperionConfiguration.plist`とします。
配備するディレクトリについては特に指定はありませんが、作成した設定ファイルをBuild Phaseの`Copy Bundle Resources`に指定しておくことだけお忘れなく。

## ファイルの中身
設定ファイルはこんな感じになっています。

```xml:HyperionConfiguration.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
    <dict>
        <key>Default</key>
        <dict>
            <key>Triggers</key>
            <array>
                <string>ThreeFingerSingleTap</string>
            </array>
        </dict>
        <key>Simulator</key>
        <dict>
            <key>Triggers</key>
            <array>
                <string>TwoFingerDoubleTap</string>
            </array>
        </dict>
        <key>Device</key>
        <dict>
            <key>Triggers</key>
            <array>
                <string>Shake</string>
            </array>
        </dict>
    </dict>
</plist>
```

`Default`, `Simulator`, `Device`のそれぞれのキーの中に、デバッグメニューを表示するためのトリガーとなるジェスチャを記載します。

### Default
ここに記載したTriggerは実機、シミュレーターの両方で有効になります。

### Simulator, Device
これらの配下に記載したTriggerは、それぞれシミュレーター、実機のみで有効になります。

### Trigger
デバッグメニューを表示するためのジェスチャを記載します。
指定できるアクションは[HYPActivationGestureOptions.h](https://github.com/willowtreeapps/Hyperion-iOS/blob/28bad723c71e194b562c1528e684f336690a5a09/Core/Public/HYPActivationGestureOptions.h)ファイルに記述してあります。

```objc:HYPActivationGestureOptions.h(一部抜粋)
typedef NS_OPTIONS(NSUInteger, HYPActivationGestureOptions) {
    /**
     *  Represents a two finger double tap gesture.
     */
    HYPActivationGestureTwoFingerDoubleTap      = 1 << 0,
    /**
     *  Represents a three finger single tap gesture.
     */
    HYPActivationGestureThreeFingerSingleTap    = 1 << 1,
    /**
     *  Represents a right edge swipe gesture.
     */
    HYPActivationGestureRightEdgeSwipe          = 1 << 2,
    /**
     *  Represents a shake gesture.
     */
    HYPActivationGestureShake = 1 << 3
};
```

キー名とコメントのまんまですが、それぞれ2本指ダブルタップ、3本指シングルタップ、画面右端スワイプ、シェイクジェスチャの4種類です。

先程の設定ファイルで、Simulatorでは2本指ダブルタップでデバッグメニューが開くようになりました！

![trigger.gif](https://qiita-image-store.s3.amazonaws.com/0/13042/062d6aa3-e4b8-9ebf-c365-f7d52eaa8cb8.gif)


これでストレス無くHyperionが使えますね！

# プラグインを作ってみる
Hyperion-iOSは、記事執筆時点では3種類の標準プラグインのみ用意されています(Android版はもう少し多くのプラグインがあります)。
どれも便利なのですが、「こんなプラグインあったら良いな」と考える気持ちもありますね。しかしReadmeには

> The plugin creation guide is a work in progress, but if you are feeling ambitious you can reference the plugins we have already created along with our documentation.

とあります。プラグイン開発手順のドキュメントはwork in progress...
いつ開発手順ドキュメントが公開されるのもわからないが、リファレンスはある…OSSなので当然実装も見れる…

作れますね:santa_tone1:

と、いうわけで簡単なプラグインを作ってみましょう！

# 今回作るもの
イメージしやすいように、先に作るものを。
ビューをタップするとクラス名を表示するという、シンプルなプラグインです。

![classNamePlugin.gif](https://qiita-image-store.s3.amazonaws.com/0/13042/514c4c01-7f55-dacd-f558-f14eecb70656.gif)


# 開発手順の概要
[こちらの記事](https://qiita.com/takahirom/items/2f6557f945ddd7c5e074)の中でHyperion-Androidプラグインが開発されていたので、拝読して実装を見てみたところ、iOSもプラグインの構成としてはAndroidと似たような感じだということがわかりました。

1. HYPPlugin protocolに準拠したクラスを作成する
2. HYPPluginModule protocolに準拠したクラスを作成し、1. で作ったクラス内でinit
3. HYPSnapshotInteractionViewのサブクラスを作成し、プラグインの中身を書く

（3. については後述するHYPSnapshotPluginModuleの場合です）

たったこれだけ！何だかいける気がしてきましたね！
ということでやっていきましょう！

なお、今回は既存のプロジェクト内で実装をしています。
Frameworkとして切り出した方が良いとは思いますがご容赦ください:bow_tone1:

## HYPPlugin protocolに準拠したクラスを作成する
早速ソースコードから。

```swift:ClassNamePlugin.swift
import Foundation
import HyperionCore

class ClassNamePlugin: NSObject, HYPPlugin {
    static func createPluginModule(_ pluginExtension: HYPPluginExtension) -> HYPPluginModuleProtocol {
        return ClassNamePluginModule(with: pluginExtension)
    }

    static func pluginVersion() -> String {
        return "1.0.0"
    }
}
```

HYPPluginプロトコルの必須メソッドは上記の2つのみです。
とりあえずバージョンは適当に入れています。
NSObjectを継承するのをお忘れなく！

## HYPPluginModule protocolに準拠したクラスを作成
こちらもソースから。

```swift:ClassNamePluginModule.swift
import Foundation
import HyperionCore

class ClassNamePluginModule: HYPSnapshotPluginModule {
    var currentPluginView: ClassNamePluginInteractiveView?

    required init(with ext: HYPPluginExtension) {
        super.init(with: ext)
    }

    override func pluginMenuItemTitle() -> String {
        return "Class Name"
    }

    override func pluginMenuItemImage() -> UIImage {
        return UIImage(named: "classNamePluginIcon")!
    }

    override func activateSnapshotPluginView(withContext context: UIView) {
        super.activateSnapshotPluginView(withContext: context)
        currentPluginView?.removeFromSuperview()
        currentPluginView = ClassNamePluginInteractiveView(with: `extension`)
        currentPluginView?.translatesAutoresizingMaskIntoConstraints = false
        context.addSubview(currentPluginView!)
        currentPluginView?.topAnchor.constraint(equalTo: context.topAnchor).isActive = true
        currentPluginView?.leadingAnchor.constraint(equalTo: context.leadingAnchor).isActive = true
        currentPluginView?.bottomAnchor.constraint(equalTo: context.bottomAnchor).isActive = true
        currentPluginView?.trailingAnchor.constraint(equalTo: context.trailingAnchor).isActive = true
    }
}
```
このクラスの中に、Hyperionのデバッグメニュー上の表示と、該当のプラグインが選択された際の挙動を記述していきます。

#### pluginMenuItemTitle()
デバッグメニュー上に表示する、プラグインの名前を記述します。

#### pluginMenuItemImage()
デバッグメニュー上に表示する、プラグインのアイコン画像（UIImage）を指定します。
なお、ここで指定した画像は、表示されるときはグレースケール画像になるのでご注意ください。

#### activateSnapshotPluginView(withContext context: UIView)
プラグインが選択された際の挙動をここに記述します。
このメソッドは、シンプルな`HYPPluginModule`プロトコルではなく、`HYPSnapshotPluginModule`のサブクラスが選択された際に呼ばれるものです。
標準プラグインであるAttribute InspectorやMeasurementsのように、デバッグメニューを表示した際のスナップショットを撮っておき、その上にオーバーレイ表示させる際に使用するPluginModuleです。
Slow Animationのようにオーバーレイ表示をさせないプラグインの場合は、HYPPluginModuleとHYPPluginMenuItemDelegateに準拠する必要があるようです。
そちらについては今回実装しませんが、Slow Animationプラグインのソースコードを参照すると良いかと思います。

- [HYPSlowAnimationsPluginModule.h](https://github.com/willowtreeapps/Hyperion-iOS/blob/master/SlowAnimations/HYPSlowAnimationsPluginModule.h)
- [HYPSlowAnimationsPluginModule.m](https://github.com/willowtreeapps/Hyperion-iOS/blob/master/SlowAnimations/HYPSlowAnimationsPluginModule.m)

先程掲載したNewPluginModule.swiftでは、後述のNewPluginInteractionViewをスナップショットのUIViewに乗せる処理を行っています。ここまで、やっていることはかなりシンプルですね！


## HYPSnapshotInteractionViewのサブクラスを作成し、プラグインの中身を書く
HYPSnapshotPluginModuleプラグインの場合、やりたいことは主にこちらに記述することになるかと思います。

```swift:ClassNamePluginInteractiveView.swift
import Foundation
import HyperionCore

class ClassNamePluginInteractiveView: HYPSnapshotInteractionView {
    let highlightView = UIView(frame: .zero)
    let classNameView = ClassNamePluginPopupView()

    override init(frame: CGRect) {
        super.init(frame: .zero)
        setup()
    }

    override init(with ext: HYPPluginExtension?) {
        super.init(with: ext)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setup() {
        backgroundColor = UIColor(red: 50.0/255.0, green: 50.0/255.0, blue: 50.0/255.0, alpha: 0.3)
        let tapGr = UITapGestureRecognizer(target: self, action: #selector(viewTapped(_:)))
        addGestureRecognizer(tapGr)

        highlightView.backgroundColor = UIColor(red: 43.0/255.0, green: 87.0/255.0, blue: 244.0/255.0, alpha: 0.4)
        highlightView.isHidden = true
        addSubview(highlightView)

        classNameView.translatesAutoresizingMaskIntoConstraints = true
        classNameView.isHidden = true
        addSubview(classNameView)
    }

    @objc func viewTapped(_ sender: UITapGestureRecognizer) {
        guard let ext = `extension` else { return }
        let attachedWindow = ext.attachedWindow
        let location = sender.location(in: self)
        let selectedViews = HYPPluginHelper.findSubviews(in: attachedWindow(), intersecting: location)
        print("selectedViews: \(String(describing: selectedViews?.description))")
        if let v = selectedViews?.firstObject as? UIView {
            viewSelected(v)
        }
    }

    override func interactionViewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator?) {
        super.interactionViewWillTransition(to: size, with: coordinator)
        `extension`?.snapshotContainer().dismissCurrentPopover()
    }

    override func interactionViewDidTransition(to size: CGSize) {
        super.interactionViewDidTransition(to: size)
    }
}

extension ClassNamePluginInteractiveView: HYPViewSelectionDelegate {
    func viewSelected(_ selection: UIView!) {
        guard let selection = selection, let ext = `extension` else { return }
        highlightView.isHidden = !highlightView.isHidden
        let f = selection.convert(selection.bounds, to: ext.attachedWindow())
        highlightView.frame = f

        classNameView.frame = CGRect(x: f.minX, y: f.maxY, width: 300, height: 40)
        classNameView.isHidden = !classNameView.isHidden
        classNameView.configure(text: NSStringFromClass(type(of: selection)))
    }
}
```

コード内に出てくる`ClassNamePluginPopupView`は、クラス名表示用のただのUIViewのサブクラスのため説明は割愛します。

大体は普通のUIViewを作るのと同様ですが、いくつかピックアップして説明します。
#### ビューの検索
`viewTapped(_:)`メソッド内、タップされたビュー（正確には、NewPluginInteractionView内のタップされた座標の下にあるオリジナルのビュー）を特定し、そのビューに対しての処理を行う場合、下記の様に行います。

```swift
let selectedViews = HYPPluginHelper.findSubviews(in: attachedWindow(), intersecting: location)
if let v = selectedViews?.firstObject as? UIView {
            viewSelected(v)
        }
```
`HYPPluginHepler`クラスの`findSubviews(in:, intersecting:)`メソッドが、ビューを検索してくれます。
その中から好きなUIViewを取り出して、`HYPViewSelectionDelegate`の`viewSelected(_:)`を呼びましょう。

#### ビューに対する処理
ビューの座標を使用して何か処理を行う場合、CGRectの変換を行う必要があります。
`convert(_:, to:)`メソッドを使いますが、toの引数には`extension.attachedWindow()`を指定しましょう。
普通に使う分にはselfでも問題無いのですが、HYPSnapshotInteractionViewはピンチイン、アウトでの拡大・縮小が可能で、その状態でビューをタップすると座標がずれます。
`extension.attachedWindow()`を指定しておけば、拡大、縮小をしても正しく選択したビューの座標を取得してくれます。

あとは説明を割愛したClassNamePluginPopupViewを実装すれば、先にお見せした動画のプラグインの完成です :tada: :tada:

割とシンプルに実装できました :relaxed:
ソースコードは[こちら](https://github.com/nirazo/HyperionPluginSample)に上げているので、ぜひお手元で試してみてください！

# おわりに
Hyperion-iOSは最近はコミットが活発でなく、本記事執筆時点での最終コミットは4月（feature/log_overlay_viewブランチ）となっていますが、プラグイン開発手法が公開されればたちまち活発に開発が行われるのではないか期待しています。
今回試しにプラグインを作ってみてわかりましたが、決して難解なものでは無かったので、ひとまず自分用に気軽に作ってみたり、carthageなどで公開したりして少しずつでも広まって欲しいなと思っています。

公式がプラグイン開発手順を公開したら本記事の後半は価値を失う気がしますが、そうなればHyperion界隈がもっと活発になるはずなので私は決して悲しむことなど無いでしょう:angel_tone2:

それでは、Enjoy your Hyperion life!