ごんれのラボ

iOS、Android、Adobe系ソフトの自動化スクリプトのことを書き連ねています。

Figma ドキュメント上のテキストを textlint でチェックする Figma Plugin のプロトタイプを作った

概要

タイトルにあるように、Figma ドキュメント上の TextNode に対して、ローカルサーバーに用意した API を通して textlint を実行し、その結果を Figma ドキュメントにコメントとして投稿する Figma Plugin を作ってみました。

経緯

所属している会社では Figma を使ってデザインデータを作成しています。
そのデザインデータに対してレビューを行うのですが、デザインや仕様に関する指摘よりも単純な文言の指摘に時間を割かれがちという現状があり、あんまり本質的ではないなと思っていました。
また、仕様書や一部プロダクトでは CI + Danger + textlint を組み合わせて、GitHub の Pull Request が作成されたときに自動的に文言チェックを行っているので、Figma でもなんとかしたい気持ちが強くなっていきました。

そんな折、技術書典 9 で『Figma Developers Book - Figma Web REST API + Plugins 開発入門』を購入していたことを思い出し、この本を片手にプロトタイプを作ってみることにしました。

できたもの

デモ動画

macOS アプリケーションの Figma で開いたドキュメント上の TextNode (デモでは 綺麗既に)に対して、ローカルサーバーの API を介して textlint を実行し、その結果をコメントして投稿しています。

ソースコード

GitHub で公開しています。
どんな感じで動いているかの雰囲気を掴んでいただけるかと思います。
https://github.com/macneko-ayu/figma-textlint-with-server

インストール方法

$ git clone git@github.com:macneko-ayu/figma-textlint-with-server.git
$ cd figma-textlint-with-server

textlint server

$ cd textlint-server
$ npm install

Figma Plugin

開発環境の設定

$ cd figma-textlint
$ npm install

Figma Plugin の設定

  1. Figma の macOS アプリケーションを開く
  2. メニューの Plugins > Development > Create Plugin... を選択する
  3. Click to choose a manifest.json file をクリックする
  4. figma-textlint のディレクトリ内の manifest.json を選択する

使い方

  1. 以下のコマンドを実行して localhost:3000 でローカルサーバーを立てる $ npm run dev
  2. Figma アプリケーションでドキュメントを開く
  3. TextNode が存在しなかったら、適当に作成する
  4. メニューから Plugins > Development > figma-textlint を選択する
  5. ファイルを選択ボタンをクリックする
  6. Personal Access Token が記載されたファイルを選択する
    • Personal Access Token については こちら を参照
  7. Run ボタンをクリックする

仕様・実装

textlint API

Node.js + Express でローカルサーバーを用意して、そこに渡されたテキストに対して textlint を実行する API を生やしています。
またFigma Plugin から API を叩くと CORS の制限にひっかかるので、 cors モジュールを導入して、制限を回避しています。
Express にした理由は特になくて、Figma Plugin と同じ言語、かつ早く実装できるものを選びました。

textlint のルールは textlint-rule-prefer-tari-taritextlint-rule-preset-ja-technical-writingtextlint-rule-prh を導入していますが、お好きなものを利用できます。
本プロトタイプでは特にカスタマイズしていませんが、 textlint-rule-prh を導入すれば独自のルールを作成することができ、小回りが効くので気に入っています。

API のコアの部分( textlint.ts )は以下のような実装になっています。

import express from 'express';
import cors from 'cors';
import { TextLintEngine } from 'textlint';

const router = express.Router();

router.post('/', cors(), async (req: express.Request, res: express.Response) => {
    try {
        const body = req.body.text;
        const engine = new TextLintEngine();
        const results = await engine.executeOnText(body);
        const messages = results[0].messages.map(item => item.message);
        res.status(200).json({ messages: messages });
    } catch (error) {
        res.status(400).json({ message: error.message });
    }
});

export default router;

Figma Plugin

ui.html に UI と自前の API 及び Figma API との通信処理、code.ts に Figma ドキュメント側の処理を実装しています。

code.ts では、ドキュメント上の TextNode をすべて取得して TextNode ごとに Object に詰め直して ui.html に渡しています。
Object は Figma API のリクエスト時に必要な fileKey 、コメント投稿時にコメントを TextNode と関連づけるために id 、TextNode のコンテンツの characters で構成されています。
fileKey とはブラウザで Figma ドキュメントを開いたときの URL の https://www.figma.com/file/(ここ)/ の文字列です。

コードの全体像は以下のようになっています。

figma.showUI(__html__);

figma.ui.onmessage = msg => {
  if (msg.type === 'execute') {
    const textNodes = figma.currentPage.findAll(node => node.type === 'TEXT');
    const textInfos = textNodes.map(node => {
      return {
        fileKey: figma.fileKey,
        characters: (node as TextNode).characters,
        nodeId: node.id
      };
    });
    figma.ui.postMessage({ type: 'send-text', textInfos })
  }

  if (msg.type === 'close') {
    figma.closePlugin();
  }
};

ui.html では、ダイアログの UI と、各ボタンをタップしたときの処理、各種リクエスト処理を行っています。
Figma Plugin では通信に XMLHttpRequest を使う必要があり、初めて使ったのでちょっと戸惑いました。

Token が記載されたテキストファイルの読込処理は以下のようになっています。
FileReader でファイルの内容を読み込んで、変数に代入してリクエスト時に使うようにしました。

const reader = new FileReader();
const input = document.getElementById('file');
input.addEventListener('change', () => {
  reader.readAsText(input.files[0], 'UTF-8');
  reader.onload = () => {
    token = reader.result;
  };
});

ダイアログの Run ボタンをクリックすると、parent.postMessage~ を介して、code.tsfigma.ui.onmessage を呼びます。

document.getElementById('execute').onclick = () => {
  parent.postMessage({pluginMessage: {type: 'execute'}}, '*');
}

そして次に code.ts から figma.ui.postMessage を介して、ui.htmlonmessage が呼ばれます。

本プロトタイプでは onmessage で受け取った値をもとに textlint API にリクエストしたり、その結果をコメントとして投稿したりしています。

onmessage = async (event) => {
  if (event.data.pluginMessage.type !== 'send-text') return;

  const textInfos = event.data.pluginMessage.textInfos;
  await Promise.all(textInfos.map(async textInfo => {
    const fileKey = textInfo.fileKey;
    const nodeId = textInfo.nodeId;
    const characters = textInfo.characters;

    const messages = await getTextlintMessages(fileKey, nodeId, characters)
      .catch((e) => {
        alert(e);
      });
    if (messages !== undefined) {
      await Promise.all(messages.map(async message => {
        await postComment(fileKey, nodeId, message)
          .catch((e) => {
            alert(e);
          });
      }));
    }
  }));
  alert('Done.');
  sendCloseMessage();
}

textlint API へのリクエスト処理は以下のようになっています。
エラー処理は適当です…。

async function getTextlintMessages(fileKey, nodeId, characters) {
  return new Promise((resolve, reject) => {
    const request = new XMLHttpRequest();
    request.open('POST', 'http://localhost:3000/v1/textlint');
    request.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
    request.responseType = 'json';
    request.onload = () => {
      if (request.status !== 200) {
        reject(new Error(`Error: Textlint post request is ${request.status}. ${request.statusText}`));
      } else {
        resolve(request.response.messages);
      }
    };
    request.onerror = () => {
      reject(new Error('Error: Textlint post request is network error'));
    };
    request.send(`text=${characters}`);
  });
}

続いて、textlint API の結果を Figma API を介してコメントに投稿します。
コメントの投稿に必要なパラメータは data Object にまとめました。
message はコメント本文、client_meta.node_id はコメントを紐付ける Node の id、client_meta.node_offset はコメントのバッジをつける位置です。
Figma API へのリクエストには X-FIGMA-TOKEN というヘッダーに token を渡す必要があります。
request.status は Figma API の rate limit に達したときに 429 が返されるので、そこだけ分岐をわけています。
https://www.figma.com/developers/api#errors
こちらもエラー処理は適当です。

コメント投稿処理は以下のようになっています。

async function postComment(fileKey, nodeId, comment) {
  return new Promise((resolve, reject) => {
    const data = {
      "message": comment,
      "client_meta": {
        "node_id": nodeId,
        "node_offset": {
          "x": 10,
          "y": 10
        }
      }
    }

    const request = new XMLHttpRequest();
    request.open('POST', `https://api.figma.com/v1/files/${fileKey}/comments`);
    request.setRequestHeader('X-FIGMA-TOKEN', token);
    request.setRequestHeader('Content-Type', 'application/json');
    request.onload = () => {
      if (request.status === 429) {
        reject(new Error(`Error: Figma api is rate limit. ${request.statusText}`));
      }
      if (request.status !== 200) {
        reject(new Error(`Error: Comment post request is ${request.status}. ${request.statusText}`));
      }
      resolve();
    }
    request.onerror = () => {
      reject(new Error('Error: Comment post request is network error'));
    };
    request.send(JSON.stringify(data));
  });
}

実用するにあたって解決しないといけなさそうなこと

コードを見ていただくとおわかりかと思うのですが、プロトタイプという名目で可能な限り手を抜いたので、実用にあたって解決しないといけない部分が多々あるかと思います。
私がなんとなく書き出したものだけでも以下の量なので、実用するにはまだ手がかかりそうです。

  • textlint-serverのデプロイ先を決める
  • textlint-serverのセキュリティ対策(いまノーガード)
  • textlint-serverのリクエスト数
    • TextNodeの数分だけリクエストするので、現実的じゃない
    • 選択したものだけ処理するにしても限界を決めないとだめ
  • Figma APIのリクエスト数
    • TextNode数:lintの数が 1 : n なので、こちらも現実的じゃなさそう
  • リクエストのエラーハンドリング
  • ダイアログのUIがダサい
  • アカウントのパーソナルトークンをファイルで読み込んでいる
    • OAuth2 でやるといいのかも

まとめ

作り始めるまでは面倒そうとか、私に実装できるかなとか、いろいろ考えてましたが、いざ手を動かしてみると意外とコード量も少なく実装できました。
API もほとんど書いたことがなくてお作法がわからず、まぁ動けばいいよねって感じで書いたけど、ちゃんと?動いててよかった。
また機会があったら、他の Figma Plugin 作ってみよう

SingleChoiceItems をカスタマイズして任意の要素を disable にできる DialogFragment を作った

概要

SingleChoiceItems をカスタマイズして、特定の条件のときに任意の要素を disable にできるリストを内包したダイアログを実装したので、公開しました

経緯

案件で「特定の条件のときにリストの要素を disable にしてユーザーが選択できないようにするダイアログ」が必要になりました。
よくある要件に思えたのでググってみたんですが、意外なことにいいサンプルが見つからなかったので、自分で実装してみました。

ソースコード

CustomSingleChoiceItemDialogFragment

SingleChoiceItems を内包した DialogFragment です。
以下にソースコードを転記して、要所だけコメントを追記しました。

package com.macneko.customsinglechoiceitemdialog.view

import android.app.AlertDialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import com.macneko.customsinglechoiceitemdialog.adapter.CustomAdapter

class CustomSingleChoiceItemDialogFragment : DialogFragment() {
  private lateinit var title: String // ダイアログのタイトル
  private lateinit var entries: List<String> // リストに表示する配列
  private var entryIndex = 0 // リストで選択状態にする index。`-1` で選択なし
  private var disableIndex = -1 // リストで disable 状態にする index。`-1` で disable なし
  private lateinit var adapter: CustomAdapter // Adapter
  private lateinit var requestKey: String // 呼び出し元が `setFragmentResultListener` で選択結果を受け取るときの Key

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    arguments?.run {
      title = getString(PARAM_TITLE) ?: throw IllegalArgumentException("arg is null")
      entries = getStringArrayList(PARAM_ENTRIES) ?: throw IllegalArgumentException(
        "arg is null"
      )
      entryIndex = getInt(PARAM_ENTRY_INDEX)
      disableIndex = getInt(PARAM_DISABLE_INDEX, -1)
      requestKey = getString(PARAM_REQUEST_KEY) ?: throw IllegalArgumentException("arg is null")
    }

    adapter = CustomAdapter(entries, disableIndex)
  }

  override fun onCreateDialog(savedInstanceState: Bundle?) =
    AlertDialog.Builder(context).apply {
      setTitle(title)
      setSingleChoiceItems(adapter, entryIndex) { _, selectedIndex ->
        val bundle = Bundle().apply {
          putInt(RESULT_KEY, selectedIndex) // 選択した index を bundle に詰める
        }
        parentFragmentManager.setFragmentResult(requestKey, bundle) // `setFragmentResult` で結果を返す
        dismiss()
      }
      setNegativeButton(android.R.string.cancel) { _, _ ->
        dismiss()
      }
    }.create() ?: super.onCreateDialog(savedInstanceState)

  companion object {
    private const val PARAM_TITLE = "param_title"
    private const val PARAM_ENTRIES = "param_entries"
    private const val PARAM_ENTRY_INDEX = "param_entry_index"
    private const val PARAM_DISABLE_INDEX = "param_disable_index"
    private const val PARAM_REQUEST_KEY = "PARAM_REQUEST_KEY"
    const val RESULT_KEY = "result_key"

    fun newInstance(
      title: String,
      entries: List<String>,
      entryIndex: Int,
      disableIndex: Int = -1,
      requestKey: String
    ) =
      CustomSingleChoiceItemDialogFragment().apply {
        arguments = Bundle().apply {
          putString(PARAM_TITLE, title)
          putStringArrayList(PARAM_ENTRIES, ArrayList(entries))
          putInt(PARAM_ENTRY_INDEX, entryIndex)
          putInt(PARAM_DISABLE_INDEX, disableIndex)
          putString(PARAM_REQUEST_KEY, requestKey)
        }
      }
  }
}

CustomAdapter

特定の要素を disable にする BaseAdapter のサブクラスです。
以下にソースコードを転記して、要所だけコメントを追記しました。

package com.macneko.customsinglechoiceitemdialog.adapter

import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.TextView

class CustomAdapter(
  private val items: List<String>,
  private val disableIndex: Int // コンストラクタで disable にする index を受け取る
) : BaseAdapter() {
  override fun getCount() = items.size

  override fun getItem(position: Int) = items[position]

  override fun getItemId(position: Int): Long = 0

  override fun isEnabled(position: Int) = position != disableIndex 

  override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
    val view: View
    val holder: ViewHolder
    if (convertView == null) {
      view = LayoutInflater.from(parent?.context)
        .inflate(android.R.layout.simple_list_item_single_choice, parent, false) // `android.R.layout.simple_list_item_single_choice` は SimpleChoiceItems のレイアウトXML
      holder = ViewHolder()
      holder.textView = view.findViewById<View>(android.R.id.text1) as TextView
      view.tag = holder
    } else {
      view = convertView
      holder = view.tag as ViewHolder
    }
    setViewItems(position, holder)
    return view
  }

  private fun setViewItems(position: Int, holder: ViewHolder) {
    holder.textView?.apply {
      text = getItem(position)
      // disableIndex と一致したら `isEnabled = false` になる
      isEnabled = isEnabled(position)
    }
  }

  private class ViewHolder {
    var textView: TextView? = null
  }
}

プロジェクトへの導入方法

  1. CustomSingleChoiceItemDialogFragmentCustomAdapter をプロジェクトに追加する
  2. Activity などのダイアログを表示するクラスにダイアログを表示するコードを書く
val dialog = CustomSingleChoiceItemDialogFragment.newInstance(
    "title", // ダイアログのタイトル
    ["First", "Second", "Third"], // リストに表示する値の配列
    0, // リストで選択状態にする index
    1, // リストで disable 状態にする index。`-1` で disable なし
    "REQUEST_KEY_CUSTOM" // setFragmentResultListener で選択結果を受け取るときの Key
)
dialog.show(supportFragmentManager, "CustomSingleChoiceItemDialogFragment")
  1. 2 のクラスの onCreate にダイアログの選択結果を受け取るコードを書く
supportFragmentManager.setFragmentResultListener("REQUEST_KEY_CUSTOM", this) { _, bundle ->
      Log.d("sample", bundle.getInt(CustomSingleChoiceItemDialogFragment.RESULT_KEY, -1)) // bundle に選択した index が入っているので、取り出してログに出力する
}

サンプルアプリケーション

ソースコード

以下のリポジトリで公開しています
https://github.com/macneko-ayu/CustomSingleChoiceItemDialog

デモ動画

画面の説明

合番 説明
1 4 をタップして表示されたダイアログで選択した要素を表示します
2 disable にする要素を選択するダイアログを表示します
3 2 で disable にした要素の position を表示します
4 2 で選択した要素を disable にしたダイアログを表示します

実装してみて思ったこと

今回初めて使った SingleChoiceItems ですが、簡単にリスト選択型のダイアログが作れて便利ですね。

ExtendScript の everyItem() の使い方

概要

教えてもらったコードで everyItem() を使っていて、そういえばどう使うか理解してなかったなと思ったので、簡単な例をメモとして残しておく。

使用例

InDesignの表組内のすべてのセルのオーバーフローしているかを配列で取得するスクリプト

var tableObj = ... // tablesのインスタンス
tableObj.cells.everyItem().overflows;

// result [true, false, false, true]

InDesignの表組内のすべてのセルの長体率を配列で取得するスクリプト

everyItem() のあとにプロパティを指定して、そこからさらに everyItem() をつなげることも可能

var tableObj = ... // tablesのインスタンス
tableObj.cells.everyItem().texts.everyItem().horizontalScale;

// result [100, 90, 100, 50]

まとめ

プロパティの現在地を一気に取得したいときに使えますね。

UIKit版のOutlineのサンプルを実装してハマった

概要

WWDC20のセッション動画を観て「へー、なるほど、わかったわかった」と思ってたけど、業務でUICollectionView Compositional Layoutを使ったときに結構ハマって、これはOutlineも触っておいたほうがいいなって思ったので、簡単なサンプルを実装してみました。

ソースコード

GitHubにあげています。

github.com

実装したもの

この記事のサンプルは OutlineListSample を参照してください。 サンプルとして、セクションなしの SimpleOutlineListと、セクションありの OutlineListWithSection の2種類を実装しました。

どちらも表示するModelは同じものを使用して、Listに反映する型を変えています。

Modelを階層表示したものが以下です。
詳細は後述しますが、セクションあり版は 動物食べ物 をセクションヘッダーとして表示しています。

動物
├─ 犬
│     ├─ 柴犬
│     ├─ ヨークシャテリア
│     └─ ミニチュアシュナウザー
└─ 猫
       ├─ アメリカンショートヘア
       ├─ ノルウェージャンフォレストキャット
       └─ 三毛猫
食べ物
├─ 肉
│     ├─ 豚肉
│     ├─ 牛肉
│     └─ 馬肉
└─ 野菜
       ├─ セロリ
       ├─ ブロッコリー
       └─ トマト

SimpleOutlineList

Outlineを展開した状態のスクリーンショットです。

このあとに紹介する OutlineListWithSection を先に実装していたので、コードを流用しつつ、DataSourceとSnapshotのつなぎこみの部分を見直しただけですみました。

実装の説明

実務ではViewModelに値をもたせることが多いので、このサンプルでもViewModelを用意しました。

struct SimpleOutlineListViewModel {
    let items: [Item]

    init() {
        var items: [Item] = []
        Kind.allCases.forEach { kind in
            switch kind {
            case .animal:
                let headers = Animal.allCases.map { animal -> Item in
                    return Item(title: animal.description, children: animal.names.map { Item(title: $0, children: []) })
                }
                items.append(Item(title: kind.description, children: headers))
            case .food:
                let headers = Food.allCases.map { food -> Item in
                    return Item(title: food.description, children: food.names.map { Item(title: $0, children: []) })
                }
                items.append(Item(title: kind.description, children: headers))
            }
        }
        self.items = items
    }
}

extension SimpleOutlineListViewModel {
    enum Section: Hashable {
        case main
    }

    struct Item: Hashable {
        private let identifier = UUID()
        let title: String
        let children: [Item]
        var hasChildren: Bool {
            return !children.isEmpty
        }
    }
}

Hashable に準拠した Item という型を用意して、init()enumで表現したModelを前述した構造になるように多次元配列を生成しています。
個人的にはこの構造は好きではなくて、階層ごとに別の型を用意したいんですよね。
このサンプルではシンプルなプロパティしかもってないけど、階層ごとに必要なプロパティが違う場合は、その階層では不要なプロパティに対して値を渡す必要があって、きれいじゃないな、と。
optionalにしてinit時に初期値として nil を渡せばいいんだろうけど、やっぱなんだかなぁって思ってしまいます。
AnyHashable にするぐらいならこれでもいいかな…。

続いて、ViewControllerは以下です。

import UIKit

final class SimpleOutlineListViewController: UIViewController {
    typealias Section = SimpleOutlineListViewModel.Section
    typealias Item = SimpleOutlineListViewModel.Item

    @IBOutlet var collectionView: UICollectionView!
    var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
    private let viewModel = SimpleOutlineListViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "SimpleOutlineList"
        collectionView.collectionViewLayout = createLayout()
        configureDataSource()
        applyInitialSnapshots()
    }
}

extension SimpleOutlineListViewController {
    private func createLayout() -> UICollectionViewLayout {
        let config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
        return UICollectionViewCompositionalLayout.list(using: config)
    }

    private func createHeaderCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            cell.contentConfiguration = content
            cell.accessories = [.outlineDisclosure(options: .init(style: .header))]
        }
    }

    private func createCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            cell.contentConfiguration = content
            cell.accessories = [.reorder()]
        }
    }

    private func configureDataSource() {
        let headerCellRegistration = createHeaderCellRegistration()
        let cellRegistration = createCellRegistration()

        dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
            if item.hasChildren {
                return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: item.title)
            }
            return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: item.title)
        }
    }

    private func applyInitialSnapshots() {
        var snapshot = NSDiffableDataSourceSectionSnapshot<Item>()

        func addItems(_ menuItems: [Item], to parent: Item?) {
            snapshot.append(menuItems, to: parent)
            for menuItem in menuItems where menuItem.hasChildren {
                addItems(menuItem.children, to: menuItem)
            }
        }

        addItems(viewModel.items, to: nil)
        dataSource.apply(snapshot, to: .main, animatingDifferences: false)
    }
}

configureDataSource()hasChildrentrue のときは子要素を持っており開閉可能にするので、 cell.accessories = [.outlineDisclosure(options: .init(style: .header))] としています。
また、DataSource生成時に AnyHashable を使わずに済み、すっきりしますね。

applyInitialSnapshots() はdataSourceにSnapshotを適用するメソッドです。
InnerFunctionとして定義した addItems(_ menuItems: [Item], to parent: Item?) はSnapchotにappendする再帰処理です。
Appleのサンプルコードから拝借しました。

OutlineListWithSection

Outlineを展開した状態のスクリーンショットです。

SimpleOutlineList ではすべてのItemを親子構造にしましたが、OutlineListWithSectionではルートのItemをセクションヘッダーにしました。

このサンプルは一度実装したあとに構造を見直しており、旧版のコードも紹介しつつ、なぜ作り直したかを説明します。

旧版の実装の説明

SimpleOutlineList と同様にViewModelを用意しました。

struct ViewModel {
    private let items: [AnyHashable]

    init() {
        var items: [AnyHashable] = []
        Section.allCases.forEach { section in
            switch section {
            case .animal:
                Animal.allCases.forEach { animal in
                    items.append(Header(title: animal.description,
                            kind: section,
                            category: animal.rawValue))
                    animal.names.forEach {
                        items.append(Children(title: $0, kind: section, category: animal.rawValue))
                    }
                }
            case .food:
                Food.allCases.forEach { food in
                    items.append(Header(title: food.description,
                            kind: section,
                            category: food.rawValue))
                    food.names.forEach {
                        items.append(Children(title: $0, kind: section, category: food.rawValue))
                    }
                }
            }
        }
        self.items = items
    }

    func getHeader(kind: Section, category: String) -> AnyHashable? {
        return items.first { item in
            guard let item = item as? Header else { return false }
            if item.kind == kind, item.category == category {
                return true
            }
            return false
        }
    }

    func getChildren(kind: Section, category: String) -> [AnyHashable]? {
        return items.filter { item in
            guard let item = item as? Children else { return false }
            if item.kind == kind, item.category == category {
                return true
            }
            return false
        }
    }
}

extension ViewModel {
    enum Section: Int, Hashable, CaseIterable {
        case animal, food

        var description: String {
            switch self {
            case .animal:
                return "動物"
            case .food:
                return "食べ物"
            }
        }
    }

    struct Header: Hashable {
        private let identifier = UUID()
        let title: String
        let kind: Section
        let category: String
    }

    struct Children: Hashable {
        private let identifier = UUID()
        let title: String
        let kind: Section
        let category: String
    }

    enum Animal: String, CaseIterable {
        case dog, cat

        var description: String {
            switch self {
            case .dog:
                return "犬"
            case .cat:
                return "猫"
            }
        }

        var names: [String] {
            switch self {
            case .dog:
                return [" 柴犬", "ヨークシャテリア", "ミニチュアシュナウザー"]
            case .cat:
                return ["アメリカンショートヘア", "ノルウェージャンフォレストキャット", "三毛猫"]
            }
        }
    }

    enum Food: String, CaseIterable {
        case meat, vegetable

        var description: String {
            switch self {
            case .meat:
                return "肉"
            case .vegetable:
                return "野菜"
            }
        }

        var names: [String] {
            switch self {
            case .meat:
                return ["豚肉", "牛肉", "馬肉"]
            case .vegetable:
                return ["セロリ", "ブロッコリー", "トマト"]
            }
        }
    }
}

DataSourceの型として、セクションの Section、開閉可能なセルの Header、通常のセルのChildren という3つの型を用意しました。
HeaderChildren をひとつの配列に詰め込むので、 itemsAnyHashable の配列になっています。
getHeader(kind: Section, category: String) -> AnyHashable?getChildren(kind: Section, category: String) -> [AnyHashable]?items から特定の型のdataを取り出すメソッドです。
我ながらなかなか苦しい設計になってしまって、書きながら「罪深い…これは罪深い…」とつぶやいてました。
サンプルなので動けばいいとはいえ、もうちょっとうまく書けるようになりたい…。

続いて、ViewControllerです。

import UIKit

final class ViewController: UIViewController {
    typealias Section = ViewModel.Section

    @IBOutlet var collectionView: UICollectionView!
    var dataSource: UICollectionViewDiffableDataSource<Section, AnyHashable>!
    private let viewModel = ViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "Outline List Sample"
        collectionView.collectionViewLayout = createLayout()
        configureDataSource()
        applyInitialSnapshots()
    }
}

extension ViewController {
    private func createLayout() -> UICollectionViewLayout {
        let sectionProvider = { (_: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            var config = UICollectionLayoutListConfiguration(appearance: .sidebar)
            config.headerMode = .supplementary
            let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment)
            section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 0, trailing: 10)
            return section
        }
        return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider)
    }

    private func createHeaderCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            cell.contentConfiguration = content
            cell.accessories = [.outlineDisclosure(options: .init(style: .header))]
        }
    }

    private func createCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            cell.contentConfiguration = content
            cell.accessories = [.reorder()]
        }
    }

    private func createSectionHeaderRegistration() -> UICollectionView.SupplementaryRegistration<UICollectionViewListCell> {
        return UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: "Header") { (headerView, _, indexPath: IndexPath) in
            guard let section = Section(rawValue: indexPath.section) else {
                return
            }
            var configuration = headerView.defaultContentConfiguration()
            configuration.text = section.description

            configuration.textProperties.font = .boldSystemFont(ofSize: 16)
            configuration.textProperties.color = .systemBlue
            configuration.directionalLayoutMargins = .init(top: 20.0, leading: 0.0, bottom: 10.0, trailing: 0.0)

            headerView.contentConfiguration = configuration
        }
    }

    private func configureDataSource() {
        let headerCellRegistration = createHeaderCellRegistration()
        let cellRegistration = createCellRegistration()

        dataSource = UICollectionViewDiffableDataSource<Section, AnyHashable>(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
            switch item {
            case let model as ViewModel.Header:
                return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: model.title)
            case let model as ViewModel.Children:
                return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: model.title)
            default:
                fatalError()
            }
        }

        dataSource.supplementaryViewProvider = { (_, _, indexPath) in
            return self.collectionView.dequeueConfiguredReusableSupplementary(using: self.createSectionHeaderRegistration(), for: indexPath)
        }
    }

    private func applyInitialSnapshots() {
        let sections = Section.allCases
        var snapshot = NSDiffableDataSourceSnapshot<Section, AnyHashable>()
        snapshot.appendSections(sections)
        dataSource.apply(snapshot, animatingDifferences: false)

        Section.allCases.forEach { section in
            var outlineSnapshot = NSDiffableDataSourceSectionSnapshot<AnyHashable>()
            switch section {
            case .animal:
                ViewModel.Animal.allCases.forEach { animal in
                    guard let header = viewModel.getHeader(kind: section, category: animal.rawValue),
                          let children = viewModel.getChildren(kind: section, category: animal.rawValue) else {
                        return
                    }
                    outlineSnapshot.append([header])
                    outlineSnapshot.append(children, to: header)
                }
            case .food:
                ViewModel.Food.allCases.forEach { food in
                    guard let header = viewModel.getHeader(kind: section, category: food.rawValue),
                          let children = viewModel.getChildren(kind: section, category: food.rawValue) else {
                        return
                    }
                    outlineSnapshot.append([header])
                    outlineSnapshot.append(children, to: header)
                }
            }
            dataSource.apply(outlineSnapshot, to: section, animatingDifferences: false)
        }
    }
}

Compositional Layoutの話になりますが、セクションヘッダーは dataSource.supplementaryViewProviderクロージャで設定していて、Viewは createSectionHeaderRegistration() で定義しています。
Cellと同じような感じで実装できるので、いいですね。

configureDataSource() でDataSourceを生成するメソッドです。
型が AnyHashable になっているので、switch で型を判別する処理をいれています。

switch item {
case let model as ViewModel.Header:
    return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: model.title)
case let model as ViewModel.Children:
    return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: model.title)
default:
    fatalError()
}

SimpleOutlineList の節でも書きましたが、この処理が好きじゃないんですよね。
Hashable に準拠した型ならなんでも渡せてしまうので、defaultが必要になるのが…。

applyInitialSnapshots()はDataSourceにSnapshotを適用するメソッドです。
Section ごとに HeaderChildren が親子関係になるようにDataを詰めています。

Section.allCases.forEach { section in
    var outlineSnapshot = NSDiffableDataSourceSectionSnapshot<AnyHashable>()
    switch section {
    case .animal:
        ViewModel.Animal.allCases.forEach { animal in
            guard let header = viewModel.getHeader(kind: section, category: animal.rawValue),
                    let children = viewModel.getChildren(kind: section, category: animal.rawValue) else {
                return
            }
            outlineSnapshot.append([header])
            outlineSnapshot.append(children, to: header)
        }
    case .food:
        ViewModel.Food.allCases.forEach { food in
            guard let header = viewModel.getHeader(kind: section, category: food.rawValue),
                    let children = viewModel.getChildren(kind: section, category: food.rawValue) else {
                return
            }
            outlineSnapshot.append([header])
            outlineSnapshot.append(children, to: header)
        }
    }
}

以上が旧版です。

新版の実装の説明

AnyHashable を使わずに済む方法を模索したものが新版です。
GitHubのmainブランチにあげているものになります。

ViewModelは以下です。

import Foundation

struct OutlineListWithSectionViewModel {
    let items: [SectionHeader]

    init() {
        var items: [SectionHeader] = []
        Kind.allCases.forEach { kind in
            switch kind {
            case .animal:
                let headers = Animal.allCases.map { animal -> Header in
                    return Header(title: animal.description, children: animal.names.map { Children(title: $0) })
                }
                items.append(SectionHeader(title: kind.description, headers: headers))
            case .food:
                let headers = Food.allCases.map { food -> Header in
                    return Header(title: food.description, children: food.names.map { Children(title: $0) })
                }
                items.append(SectionHeader(title: kind.description, headers: headers))
            }
        }
        self.items = items
    }
}

extension OutlineListWithSectionViewModel {
    enum ListItem: Hashable {
        case sectionHeader(SectionHeader)
        case header(Header)
        case children(Children)
    }

    struct SectionHeader: Hashable {
        private let identifier = UUID()
        let title: String
        let headers: [Header]
    }

    struct Header: Hashable {
        private let identifier = UUID()
        let title: String
        let children: [Children]
    }

    struct Children: Hashable {
        private let identifier = UUID()
        let title: String
    }
}

新版では itemsSectionHeader の配列にしました。
DataSourceの型として、セクションの SectionHeader、開閉可能なセルの Header、通常のセルのChildren という3つの型を用意し、SectionHeader が子要素となる Header の配列をもち、Header が子要素となる Children の配列をもっています。
DataSourceからは ListItem というenumを介して、各caseがassociated valueで保持したItemを取得するようにしています。

enum ListItem: Hashable {
    case sectionHeader(SectionHeader)
    case header(Header)
    case children(Children)
}

続いて、ViewContollerは以下です。

import UIKit

final class OutlineListWithSectionViewController: UIViewController {
    typealias ListItem = OutlineListWithSectionViewModel.ListItem
    typealias SectionHeader = OutlineListWithSectionViewModel.SectionHeader
    typealias Header = OutlineListWithSectionViewModel.Header
    typealias Children = OutlineListWithSectionViewModel.Children

    @IBOutlet var collectionView: UICollectionView!
    var dataSource: UICollectionViewDiffableDataSource<SectionHeader, ListItem>!
    private let viewModel = OutlineListWithSectionViewModel()

    override func viewDidLoad() {
        super.viewDidLoad()

        title = "OutlineListWithSection"
        collectionView.collectionViewLayout = createLayout()
        configureDataSource()
        applyInitialSnapshots()
    }
}

extension OutlineListWithSectionViewController {
    private func createLayout() -> UICollectionViewLayout {
        let sectionProvider = { (_: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
            var config = UICollectionLayoutListConfiguration(appearance: .sidebar)
            config.headerMode = .firstItemInSection
            let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: layoutEnvironment)
            section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
            return section
        }
        return UICollectionViewCompositionalLayout(sectionProvider: sectionProvider)
    }

    private func createHeaderCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            cell.contentConfiguration = content
            cell.accessories = [.outlineDisclosure(options: .init(style: .header))]
        }
    }

    private func createCellRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            let marginLeft = content.directionalLayoutMargins.leading + content.textProperties.font.pointSize
            content.directionalLayoutMargins = .init(top: 0, leading: marginLeft,  bottom: 0, trailing: 0)
            cell.contentConfiguration = content
            cell.accessories = [.reorder()]
        }
    }

    private func createSectionHeaderRegistration() -> UICollectionView.CellRegistration<UICollectionViewListCell, String> {
        return UICollectionView.CellRegistration<UICollectionViewListCell, String> { (cell, _, title) in
            var content = cell.defaultContentConfiguration()
            content.text = title
            content.textProperties.font = .boldSystemFont(ofSize: 16)
            content.textProperties.color = .systemBlue
            cell.contentConfiguration = content
        }
    }

    private func configureDataSource() {
        let sectionHeaderRegistration = createSectionHeaderRegistration()
        let headerCellRegistration = createHeaderCellRegistration()
        let cellRegistration = createCellRegistration()

        dataSource = UICollectionViewDiffableDataSource<SectionHeader, ListItem>(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
            switch item {
            case let .sectionHeader(model):
                return collectionView.dequeueConfiguredReusableCell(using: sectionHeaderRegistration, for: indexPath, item: model.title)
            case let .header(model):
                return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: model.title)
            case let .children(model):
                return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: model.title)
            }
        }
    }

    private func applyInitialSnapshots() {
        var dataSourceSnapshot = NSDiffableDataSourceSnapshot<SectionHeader, ListItem>()

        dataSourceSnapshot.appendSections(viewModel.items)
        dataSource.apply(dataSourceSnapshot)

        for sectionHeader in viewModel.items {
            var sectionSnapshot = NSDiffableDataSourceSectionSnapshot<ListItem>()

            let sectionHeaderItem = ListItem.sectionHeader(sectionHeader)
            sectionSnapshot.append([sectionHeaderItem])

            sectionHeader.headers.forEach { header in
                let headerItem = ListItem.header(header)
                let childrenItems = header.children.map { ListItem.children($0) }
                sectionSnapshot.append([headerItem])
                sectionSnapshot.append(childrenItems, to: headerItem)
            }

            dataSource.apply(sectionSnapshot, to: sectionHeader, animatingDifferences: false)
        }
    }
}

以下のように、headerMode.firstItemInSection を設定すると、RootのItemをセクションとして処理してくれます。
便利ですね。

var config = UICollectionLayoutListConfiguration(appearance: .sidebar)
config.headerMode = .firstItemInSection

前述したように configureDataSource() 内の switch でDataを取り出しています。
switch でDataを振り分けるのは旧版と同様ですがdefaultが必要なくなっています。

switch item {
case let .sectionHeader(model):
    return collectionView.dequeueConfiguredReusableCell(using: sectionHeaderRegistration, for: indexPath, item: model.title)
case let .header(model):
    return collectionView.dequeueConfiguredReusableCell(using: headerCellRegistration, for: indexPath, item: model.title)
case let .children(model):
    return collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: model.title)
}

これはこれで微妙な気もしますが、旧版よりは扱いやすくなったかと思います。

まとめ

案の定ハマりました。
セッション動画や記事を観るだけではなくて、簡単でもいいから自分でサンプルを実装してみないとわからないことは多いんだぁと、改めて実感しました。

おまけ

SwiftUI版のサンプルも実装しました。
同じリポジトリにあげています。
記事にするのはもう少しあとになると思うので、興味のある方はコードをみてみてください。

参考

OperationQueueに積んだOperationをキャンセルするサンプルを書いた

概要

友人からSwiftで非同期処理をキャンセルする方法を聞かれたので、OperationQueueに積んだOperationをキャンセルするサンプルを書いた。
ググって見つかる記事は古いか、枝葉が多いかしたので、実用性はないがシンプルなコードにしてみた。

ソースコード

OperationQueue sample

数年前に書いた習作のmacOSアプリをSwiftUIで書き直したら、意外とハマりどころがあって面白かった話

はじめに

本記事はSwift/Kotlin愛好会 Advent Calendar 2020の17日目の記事です。

qiita.com

数年ぶりにアドベントカレンダーに記事を書いてみたいと思って、思いついたのがSwift/Kotlin愛好会アドベントカレンダーでした。
会自体に参加したことはないので、来年は参加できるといいなと思っています。

概要

なんとなくSwiftUIでアプリのViewを書いてみたくなって、数年前にSwiftの勉強用に書いたmacOSアプリをSwiftUIで書き直してみました。
意外とハマりどころがあって面白かったので、誰かのお役に立てるかと思ってブログにまとめました。

実装環境

  • macOS 10.15.7
  • Xcode 12.2

AdobeなどBig Surでの動作に不安がありそうなアプリケーションを使用する機会が多く、Catalinaで実装しています。
最新版のSwiftUIではないので、すでに解消されているハマりどころもあるかもしれません。

ソースコード

GitHubにあげています。

github.com

RunningAppsターゲットがAppKit版、RunningAppsSwiftUIがSwiftUI版です。

書き換え対象のアプリケーション

起動しているアプリケーションを一覧表示して、行をクリックしたらそのアプリケーションがアクティブになる、いわゆるランチャー系のmacOSアプリです。
当時はSwiftUIもなかったので、AppKitで書いています。

当時の記事

www.macneko.com

構造はシンプルで、ViewControllerにNSTableViewを設置して、Cellを表示しているだけです。
アーキテクチャはMVVMを採用してはいます。

アプリケーションが起動・終了したときにViewを更新するロジックとして、Cocoa Bindingを採用しています。
なぜRxSwiftなどのライブラリを使用していないかというと、当時使ったことがなくて学習コストが高いと感じたのと、早く動くものを作らないと飽きて捨てることになるからで、深い意味はないです。

書き換えたアプリケーションのスクリーンショット

https://user-images.githubusercontent.com/5406126/102351849-74d5d500-3fea-11eb-9e21-50b18ea97b21.png

左がSwiftUI版、右がAppKit版です。
背景色がちょっと違いますが、ご愛嬌ということにしました。

書き換えにあたってハマったところ

さて、ここからが本題です。 いくつかのパートにわけて私がハマったところを紹介します。 私の知識不足が原因でSwiftUIの問題ではない部分もあるかと思いますが、そこはTwitterなどでやんわりご指摘いただけるとうれしいです。

ScrollViewでハマったところ

スクロールできなくなった

ScrollView のインジケータを表示したくないなぁと思って showsIndicatorsfalse にしたところ、スクロールできなくなりました。
いろいろ調べたのですが原因がわからず、なんとなくtrue に戻したところ、スクロールできるようになりました。
インジケータの値がスクロール可能かどうかに影響するとは…。

採用したコードは以下のような内容です。

// showsIndicatorsをfalseにするとスクロールできなくなるのでtrueにしている
ScrollView(.vertical, showsIndicators: true) {
    // 子Viewをなんか実装する
}

ウィンドウサイズを縮小したらクラッシュするようになった

AppKit版ではウィンドウサイズを一定の幅より縮めることはできず、好きな幅に拡げられるようになっていました。
SwiftUI版では少し仕様を変えて幅は子Viewの最大幅にして、伸縮できないようにすることにしました。
その試行錯誤の過程で ScrollViewframe(maxWidth: geometry.frame(in: .global).width, maxHeight: geometry.frame(in: .global).height) を設定したところ、ウィンドウサイズを指定したサイズより小さくすると「Contradictory frame constraints specified.」でクラッシュするようになってしまいました。

(この記事を書くために再現するコードを書こうと試行錯誤したのですが、クラッシュせずに普通に縮小できてしまいました。
ScrollView だけじゃなく、子Viewにも frame を設定していたので、そのあたりも影響していそうです)

調べたところ、以下の記事を見つけました。

swiftui-lab.com

この記事によると、どうやら最小サイズや理想的なサイズが最大サイズより大きくなってはいけないのに、ウィンドウを縮小したときにその制約が壊れてクラッシュしているっぽいです。
ですよねー。
そこで、 GeometryReader を使ってglobalのサイズを使ってたのをやめて minWidth だけ指定するようにしたら、サイズを変えてもクラッシュしないし、指定したいサイズより小さくできなくなりました。

さらに調べたところ、子Viewの中の一番大きなサイズにFitさせる fixedSize() の存在を知りました。
しかし、 fixedSize() を指定すると高さもFitするようになり、起動しているアプリケーションの数を増やすと行がMacの画面サイズをこえてしまって、かつスクロールできなくなってしまいました。
引数なしの場合は、幅も高さもFitさせるので、想定通りですね。
メソッドの引数を指定して fixedSize(horizontal: true, vertical: false) としたら幅は子Viewの最大幅、高さは前回終了時の高さ?で起動し、、スクロールもできるようになりました。
いろいろつまづきましたが、これで私がやりたかったことを実現できました。

採用したコードは以下のような内容です。

ScrollView(.vertical, showsIndicators: true) {
    // 子Viewをなんか実装する
}
// 子View内の最大サイズにあわせてFitする
.fixedSize(horizontal: true, vertical: false)

Buttonでハマったところ

グレーのViewが表示されて、そのViewだけしかタップできない

NSTableViewCell のように行全体をタップ範囲にすることを期待して、 Buttonlabel にViewを設定したところ、期待していたとおりにはいかず、 Button の真ん中あたりにグレーのViewが表示されただけでした。
子Viewの幅とも高さとも一致しておらず、タップ範囲もこのグレーの部分のみになっていました。

https://user-images.githubusercontent.com/5406126/102468266-a5724900-4094-11eb-9a8e-a4ef29e13861.png

画像の状態のコードは以下のような内容です。

Button(action: {
    // タップ時の処理
}) {
    AppListView(metaData: data)
}

なにかしら設定があるだろうとAppleのドキュメントを眺めていたところ、 buttonStyle の存在を知り、その中の PlainButtonStyle() を設定したところ、 label で指定したViewのサイズに沿った透明ボタンが作成されることがわかりました。
最初からドキュメント読みましょうという話ですね…。

採用したコードは以下のような内容です。

Button(action: {
    // タップ時の処理
}) {
    AppListView(metaData: data)
}
// これを指定するとボタン内のグレーのViewがなくなり、AppListViewのサイズの透明ボタンができる
.buttonStyle(PlainButtonStyle())

行の余白部分をタップしても反応しない

上述した処理でグレーのViewを消し去ることに成功したものの、今度は文字や画像はタップできるが、行の余白部分はタップできないという問題が発生しました。

この問題には結構時間を使ってしまったのですが、思いつきで Buttonforground で青を設定したら原因がわかりました。
以下の画像のように、アイコンと青い文字列のみが Button として認識されていたのでした。背景部分にはViewがないのでタップできないということですね。

https://user-images.githubusercontent.com/5406126/102470675-a2c52300-4097-11eb-80d8-056093b2da40.png

UIButton も同じ挙動になるので、理由がわかったときはかなりがっかりしました。
つい数ヶ月前にハマっているんですよね、これ…。

この問題は、 Buttonlabel に指定するView側で background() を指定して背景にViewを敷き、かつ contentShape() を指定することで、Viewの最大幅と最大高さまでタップ範囲を広げることができました。

採用したコードは以下のような内容です。

HStack {
    Image(nsImage: metaData.icon ?? NSImage())
        .resizable()
        .renderingMode(.original)
        .aspectRatio(contentMode: .fit)
        .frame(width: 36, height: 36, alignment: .center)

    VStack(alignment: .leading) {
        Text(metaData.name)
            .font(.system(size: 12, weight: .bold, design: .default))
            .padding(.bottom, 7)

        Text(metaData.versionDescription)
            .font(.system(size: 12, weight: .regular, design: .default))
    }
}
.padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
// 背景に透明なViewを敷く
.background(Rectangle().foregroundColor(.clear))
// contentShapeを設定する
.contentShape(Rectangle())

ForEachでハマったところ

各行の間に余分なpaddingが設定されてしまう

このアプリでは ScrollView の中で ForEach を使用して行となる各Viewを生成するようにしたのですが、各Viewの間に私が設定していない余分なpaddingが設定されてしまう問題が生じました。
ScrollViewButtonDivider のすべてに padding(0) を設定しても解消しなかったので、どこかで私の関知していないViewが生成されているようです。

調べたところ、以下の記事を見つけました。

www.reddit.com

記事によると、 ForEach でViewを生成すると内包したViewの間にpaddingが設定されてしまうようです。
この問題は、 ScrollViewForEach の間に VStack(spacing: 0) を追加することで、paddingを消すことができました。

採用したコードは以下のような内容です。

ScrollView(.vertical, showsIndicators: true) {
    // ForEachで作られたView間にpaddingが設定されてしまうので、↓のVStack(spacing: 0)が必要
    // via https://www.reddit.com/r/SwiftUI/comments/e607z3/swiftui_scrollview_foreach_padding_weird/
    // 行の揃えもVStackで指定する
    VStack(alignment: .leading, spacing: 0) {
        ForEach(viewModel.metaData) { data in
            // 子Viewをなんか実装する
        }
    }
}
.fixedSize(horizontal: true, vertical: false)

Listでハマったところ

macOSではSeparatorが引かれない

List での実装を試したときに、なぜか Separator が引かれないという問題が生じました。
「SwiftUI List Separator」というワードでググってもiOSの情報しかなく、ほとんどが「Separatorを消すにはこうするとよいです!」という内容のものでした。
違うんだ、私はSeparatorを引きたいんだ…。

List 内に Text を描画するだけのシンプルなアプリを実装して試したのですが、iOSは自動でSeparatorが引かれるのに対して、macOSではやはりSeparatorは引かれませんでした。

試したコードは以下のような内容です。

struct ContentView: View {
    var body: some View {
        List(0 ..< 5) { index in
            Text("number \(index)")
        }
    }
}

OS間の差異なんですかねぇ。

この問題は、自前で Divider を引くことでSeparatorを引くことができました。

余白部分がタップできない

Button の項でも同じ問題にハマっていましたが、 List でもハマってしまいました。
ListScrollView に書き換えると問題なく動作するので、別の問題が起きていそうです。

これに関しては ScrollView に書き換えれば動くことがわかっているので、諦めました。

参考

SwiftUIの実装を始める前に佐藤さんの同人誌を読んで勉強して、実装中も何度も読み直しました。
実際に機能のあるアプリケーションを実装するパートもあって、読み応えがあります。
私は同人誌版を購入したのですが、現在は商業出版されているので、そちらのリンクを紹介します。

www.amazon.co.jp

まとめ

初めて自分でSwiftUIを使って実装したのですが、面白いですね。
Storyboardで実装しているときの触っただけで差分発生するイライラから解放されるって素晴らしい。
Previewも最高ですね。
AppKitとUIKitの違いに振り回されることも少なそうなので、今後macOSアプリを書くことがあればSwiftUIを採用すると思います!

Illustratorファイル(ai, eps)の作成アプリバージョンと保存バージョンを抽出するコードをSwiftで書いてみた

概要

敬愛するものかのさんが、Illustratorファイル(ai, eps)の作成アプリバージョンと保存バージョンを抽出するコードをPythonで書いたコードを公開されたので、同じようなものをSwiftで書いてみたいと思って書いてみました。
Tweetにあるとおり当初はCLIを書こうかと思ったんですが、ぐちゃっとしたコードを書き直しながらCLI化していると飽きてしまいそうなので、XcodeのPlaygroundにコードを貼り付けたら動くという状態で公開することにしました。

ソースコード

Extract the creation application version and saved version of an Illustrator file (ai, eps) ref: https://gist.github.com/monokano/8bffac0c07401627c5a1ebf020b93b0e

やっていること

  1. ファイルを1MBバイトずつ読み込んで、Data型に変換する
  2. バージョンが表記してある箇所の前後にある文字列をキーとして、バージョンが表記されているRangeを取得する
    • 作成バージョンは AI8_CreatorVersion:%%For の間にある
    • 保存バージョンは %Creator: Adobe Illustrator(R)%%AI8_CreatorVersion: の間にある
  3. 取得したRangeを使って1のDataから値を抽出してString型に変換する
  4. 作成バージョンと保存バージョンが抽出できたらクロージャを実行して、バージョンを出力する

やっていないこと

ものかのさんのPythonスクリプトではCCのバージョンへの読み替えを行っていますが、私のコードではやっていません。

まとめ

私、がんばった