ごんれのラボ

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

2021 年の振り返り

はじめに

2021 年の振り返り記事です。

本業

Android アプリチームに異動

2 月から 11 月まで Android チームに異動して、Android エンジニアに転向していました。
Android アプリ開発自体は浅く関わってきていたんですが、今年は本腰いれて開発してみたいと思って、チームを異動しました。
担当したタスクで思い入れがあるものを、いくつか具体例として挙げます。

とある画面のボトムシート化と付随する新機能開発

ドラッグで画面を閉じれないようにするという仕様があったのですが、ライブラリのバージョンの関係で isDraggable が使えない(バージョンをあげると他の影響が大きすぎて断念)ので、state を細かく制御して強引に実現しました。
だいぶ時間をかけてしまったけど、満足のいく機能になったかと思います。
isDraggable を使ったボトムシート制御もそのうち実装してみたいですね。

ListAdapter を導入

新画面で RecycleView を追加する必要があったので、プロジェクトに初めて ListAdapter を導入してみました。
iOS の CompositionalLayout と同じように差分更新を OS がいい感じにやってくれるのと、コードの記述量が少なく読みやすくもなるので、以降私が RecycleView + ListAdapter で実装するようになりました。
クリックイベントを ViewModel でハンドリングするように試みたりもして、RecycleView + ListAdapter + DataBinding の組み合わせはめっちゃ好きですね。

Java から Kotlin への置き換え

新機能の開発時に Java から Kotlin に置き換え、ついでにモダンな構造に置き換えました。
2 つの言語の違いがわかる作業で、好きな作業でした。

MVP から MVVM に置き換え

MVP の構造がコードを追いづらいよねっていう話がチーム内で定期的にあがるので、シンプルな画面を MVVM に置き換え、ついでに Coroutine を使ったり、LiveData に置き換えました。
Flow を使ってみたいなと思ったのですが、怒涛のタスクラッシュに見舞われて調査時間が取れず、誰でも理解できる LiveData で妥協しました。
この作業も楽しかったです。

Bitrise のワークフローの見直し

長年理由がわからず動いていなかった Bitrise 上の UITest を動くように直しました。
やったことは単純で、公式ドキュメントの Device testing for Android を参考にして、ワークフローを作り直しただけです。
iOS チームにいたときにも Bitrise のお守りをしていたので、CI/CD のお守りも結構好きな作業かもしれないです。

Arctic Fox にしたらビルドできなくなった UnitTest の見直し

Arctic Fox にしたらなぜか UnitTest のビルドができなくなってしまった問題が発生したので、直しました。
stackoverflow を参考に対応しただけ。

未経験で入社した方との 1on1 やメンター的なこと

実装ではないですが、未経験で入社した方との 1on1 やメンター的なことをやりました。

実装タスクだけじゃなくて、地ならしやチームマネジメントみたいなことまで、いろいろやりましたね。
なかなか満足のいく成果がだせたと思っています。

Kotlin の書き心地がいいのと、JetPack Compose が気になっているので、Android のキャッチアップは来年も続けていきたいです。

iOS エンジニアに戻った

12 月に転職して、iOS エンジニアに戻りました。
今後も Android エンジニアとしてキャリアを積んでいく道も考えたけど、やっぱ iOS の世界観が好きだなーって気づいて iOS エンジニアに戻ることにしました。
関係ないけど、Findy、Lapras のオファー的なのは Android のほうが多かった印象がありますね。
まだまだ Android エンジニア不足は続くのかしら。
そのうち転職エントリ的なものを書くかもしれないし、書かないかもしれない。

副業

DTP スクリプト開発の技術顧問

DTP スクリプト開発の技術顧問的なお仕事をしています。
大阪 DTP の勉強部屋で登壇したときの 資料 がきっかけで声をかけていただきました。
業務内容は、クライアントが実装したスクリプトのレビュー、ワークフローやスクリプトの困りごとの相談、スクリプトの実装です。
実装だけでなく、クライアントのコードレビューやワークフローの相談を受けることで、私も勉強になっています。
来年も引き続きお力添えしていきたいです。

カタログ系の自動化ツール開発

今年もカタログ系の自動化ツール開発を受注しました。
データの作りがしっかりしているのと、過去に受注したスクリプトでいろんなパターンをスクリプトに落とし込めているので、短期間で満足いただけるものを納品できてよかったです。
来年も受注できたらいいな。

もくもく会

DTPerのスクリプトもくもく会 を主催して、来年の 5 月で 5 周年になります。
今年もオンライン開催しかできなかったですが、6 回開催できました。
ご参加いただいた方、またいつも準備に余念がないメイン主催 id:uske_S 、ありがとうございました。

DTPerのスクリプトもくもく会 #21 - オンライン
DTPerのスクリプトもくもく会 #22 - オンライン
DTPerのスクリプトもくもく会 #23 - オンライン
DTPerのスクリプトもくもく会 #24 - オンライン
DTPerのスクリプトもくもく会 #25 - オンライン
DTPerのスクリプトもくもく会 #26 - オンライン

来年も引き続き開催していきたいので、ご参加お待ちしています。

作って公開したもの

作りかけのものはここに挙げたもの以外にもいくつかあるのですが、公開に至ったものだけ挙げています。
4 リポジトリしか公開に至らなかったので、来年は作りかけのものを完成させたり、別になにかを作ってもっと公開していきたいです。

レノ

体重が一時期8キロを超えてしまい、獣医と相談してダイエットフードに切り替えました。
原因は日々隙をみて虎次郎のご飯を盗み食いしているからです…。
切り替えたご飯は 猫用 pHコントロール+満腹感サポート ドライ です。
レノくんはストルバイトが原因で下部尿路疾患になったことがあるので、pH コントロールのご飯を食べる必要があって、ちょうどロイヤルカナンから新しいご飯が発売されたので、それに切り替えました。
最初はあんまり食べてくれなかったけど、最近は諦めたのか食べてくれるようになりました。
獣医に相談して、猫用鰹節をふりかけているからかもしれない。

虎次郎

幼少期からたまに発生していた発作が特発性てんかんと診断されました。
二ヶ月ぐらい各種検査(血液検査とか)をするために週末に通院する日々が続き、MRI もうけました。
毎日薬を飲ませる必要があって、日々試行錯誤の日々です。
医療用ちゅーるに乗せてみたり、ちゅーるに乗せてみたり、投薬用のおやつにくるんでみたり。
結果的にすべて見破られてしまい、結局保定して口の中に薬をいれてごっくんさせるという昔ながらの方法で落ち着きました。
薬を飲んだあとはちゅーるを袋半分だけあげるルールが定着しているので、最近は虎次郎も前ほど暴れずに受け入れてくれるようになりました。
焼きがつおちゅーるが約束されたおいしいものと認定されているので、もう我が家は焼きがつおちゅーるを欠かせなくなりました。

そのほか

正月に PUBLIC TOKYO で買った緑色のコートがお気に入りなので、来年はいろんなところに着ていけたらいいなと思っています。
また、M1 Mac が発売されてしばらく経ち、そろそろ手元にほしいなと思っていたところに、M1 Max MacBook Pro が発表されたので、二週間くらい悩んで買いました。
M1 Proで十分だった可能性が高いですが、まぁ大は小を兼ねると思って。
謎のスリープ中のバッテリー消費問題があるものの、大満足です。
Intel Mac は Adobe アプリのスクリプト開発で使うことがありそうなので、手元に残しています。
それ以外の開発環境を削除して容量をあけて、妻にレンタルする予定。

最後に

オフライン、オンラインでお会いした方々、また SNS で遊んでくれた方々、今年もお世話になりました。
来年はリアルで飲み会とかしたいですねぇ。
おいしい日本酒やウィスキーを好きな人とただただ飲む時間をすごしたい…。

また、個人開発でなにか作りたい欲もあるので、それもがんばりたい。
SwiftUI か Flutter かなやんでいるけど、どっちにしようかな。

せっかく M1 Max にしたので、Blender や Houdini などの 3DCG もやってみたい。

来年もいろいろやりたいことがありますね!
それではみなさん、よいお年を!

After Party iOSDC Japan 2021 に参加した

概要

After Party iOSDC Japan 2021 に参加したので、セッションのメモなどを残しておく。

cookpad.connpass.com

セッションメモ

チームでSwiftUIを書くために 〜読みやすく保守しやすいSwiftUIの設計について考えたこと〜

  • https://speakerdeck.com/natmark/after-party-iosdc-japan-2021-swiftui
  • 買い物機能はKaimono muduleで開発している
    • やっぱマルチモジュール
  • 買い物機能はSwiftUI
  • 買い物機能がクックパッドアプリの最大のモジュールになっているそう

    課題

  • iOS13対応が疲弊する
    • iOS13だけ挙動が違う
  • パフォーマンスが犠牲になっているめんがある
  • 柔軟性が高いためUIの組み方が人によって変わる
  • コンポーネント分割の粒度も人それぞれ

今後もSwiftUIを使うか

  • 使いたいと思っている
  • Try & Errorがしやすいと感じている

保守しやすい設計を考える

  • サービス特性を考える
    • ScrollViewに詰め込んでいる
    • 複雑多様な状態
  • 画面が縦に長くなりやすい
  • 状態が多いと分岐も多くなり、読みにくい

統一した書き方でレイアウトを設計する

  • RootViewのbodyに書くべきものを決める
  • 規約を決めたい
  • 画面をSectionとして意味ある単位に分割
    • さらにSection内で分割する
  • デザイナーと相談して決める
  • コンポーネントはAtomicデザインの考え方を適用
    • OrganismをSectionとして考えている
    • Atomicレベルのコンポーネント化はしていない

複雑多様な条件について対処する

  • deliveryStatusという状態でViewのだしわけを制御する
  • コード上の見通しはよくなった
  • Sanboxアプリ化して条件に応じた画面を出せるようにしている

意識をチームに浸透させる

  • ドキュメント化
  • 知見まとめた社内ブログ
  • 設計を語る会

QA

  • UIVIewRepresentableはなるべく使いたくないとのこと

Compositional Layoutsを用いたUI開発の事例とそこで得たtipsのご紹介

活用例

気をつけたいポイント

  • estimatedに気をつけろ
    • 文字数に応じて高さを変えたりしてくれる
    • カルーセルと組み合わせると動作が不安定になる
    • タップ領域がずれて押下できなくなる
    • シミュレータと実機で挙動が違う
  • カルーセルは突き抜けて表示される
    • デザイナーとすり合わせるしかない

良かった点

  • 実装料が減り、開発効率が向上
    • 複雑なレイアウトをひとつのCollectionViewで実現できるので実装料が減った
  • コードの見通しがよくなった

QA

  • 参考になるのはAppleのサンプル
  • iOS14以上ならlistを使うことでTableViewを使わずに済みそう

機能ごとに動作するミニアプリでプレビューサイクルを爆速にした話 After Talk

QA

  • Sandboxアプリは開発者の確認使っている
    • QAは接続テストも兼ねることがあるので、Mockのデータを表示するアプリではテストできない
    • デザイナーが自分でビルドするときに便利という声がある
  • Sanboxアプリの整備は誰がやっているか
    • 基盤チームのメンバーがやっている
    • スクリプトの整備、ダミー実装の整備など
    • 画面の実装や利用は開発者チームがやっている
  • 実際どれぐらい効率化できたか
    • Sanboxアプリの計測をしていなかった…
    • 今後定量的に計測する予定
  • SwiftUIの共存はどうしているか
    • Sanboxアプリ前提の仕組みになっている
  • Xcode Previewsは利用しているか
    • 最近までXcode Previewsが使えなかった
      • コード量が多すぎる?
    • ワークアラウンドで動かせそうな気配を感じているので、Sanboxアプリでも使える仕組みを用意している
    • SandboxアプリとXcode Previewsの共存になると思う
  • Sandboxアプリは使いすてか
    • コミットしてメンテしていく前提
    • 最低限のコードの安定性を担保するためにPRのたびにCIでチェックしている
  • Sanboxアプリを社内に推進する方法
    • マルチモジュールのセッションで詳しく話している
  • アカウント機能はどう提供しているか
    • 画面ごとに固定値を返す
    • 設定画面でランタイムで値を変更する方法も検討するとよい
  • Sandbox座談会気になる
    • 仕組みを整備する基盤チームがSandboxに関する開発チーム側の意見を取り入れ改善するために実施
    • 事前にスプレッドシートに意見を書いてもらって、わいわい談義する

Sandboxアプリどうですか

  • 特定のJSONでレイアウトが壊れることがあって、そのときにレスポンスのJSONを流し込んで確認できるSandboxアプリが便利
  • フルビルドが15分ぐらいかかってたので、角丸治すのにも時間かかってたけど、Sandboxアプリだと30秒ぐらいなので、よい

Multipeer Connectivityを使った動画のリアルタイム端末間共有 〜料理動画撮影アプリの事例〜

  • Multipeer Connectivity
    • iOS 7から使える
  • 資料あとで見直そう

後日談

Multipeer Connectivityでなにかつくりたい

  • いいアイデアがでてこないので、みんな教えて
    • ゲーム
  • 世界のおもしろ事例
    • FireChat
      • チャットアプリ
      • クローズ済
      • 政府によるインターネット利用の規制下や抗議運動などの際に広く使われた
    • iTranslate
      • 翻訳アプリ
      • 手元の端末でしゃべった内容が相手側で翻訳される
    • Metronome Touch
      • メトロノームのアプリ
      • 複数の端末でメトロノームが同期する
    • Playr Audibly
      • 音楽再生アプリ
      • ワイヤレスサラウンドシステムを実現できる

最後に

大規模開発をしながら新しいことを取り入れて改善していて、それが自然な姿になっているのですごいなと思った

iOSDC Japan 2021 に参加してきた

概要

9/17 - 9/19に開催されたiOSエンジニアのお祭り、iOSDC Japan 2021に参加してきました。

iosdc.jp

観た(観ようと思っている)セッション

Day 0

大規模リファクタリングの極意

運用6年目・500万人が使うアプリのDBをSQLiteからFirestoreに移行した話

  • あとで見る

SwiftUIで作ったアプリを1年間運用してみてわかったこと

  • 観た
  • SwiftUIを導入するなら必見って感じの内容だ
    • 罠が多すぎる…

PickGo_for_Partnerの移行方法から学ぶ_既存のネイティブアプリをFlutterへリプレイスする方法

  • あとで見る

iOSDC2021 - Compositional Layoutsで実現する疎結合な実装

  • 観ようかな→観た
  • Compositional Layoutのセクションの抽象化の話が聞けてよかった

iOS・Androidで使えるデザインシステムをどう実装するか iOSDC Japan 2021

  • KMMでデザインシステムの共通化を図る試みは面白いなと思った

動画プレイヤーアプリの開発を通じて学んだ機能を実現するための要点解説 iOSDC Japan 2021

  • あとで観る

Day 1

Source Editor ExtensionとSwiftSyntaxでコード自動生成ツールを作る iOSDC Japan 2021

  • 観る
  • DIやMock作るツールがすごかった
  • デモでスニペット使うテクニックが参考になった

Network ExtensionでiOSデバイス上で動くパケットキャプチャを作る iOSDC Japan 2021

実践 iOS オープンソースプロジェクトの始め方 iOSDC Japan 2021

  • 観る
  • ライセンス周り、難しい

知られざる課金ステータス iOSDC Japan 2021

  • 観る
  • プロモーションオファーを実装するときのハマリポイントが丁寧に説明されていてよかった
    • 実装したくはない

宣言的UIの状態管理とアーキテクチャ - SwiftUIとGraphQLによる実践 iOSDC Japan 2021

  • 観る

2tch博物館(アンカンファレンス)

GitHub - sonsongithub/museum2tch: 2tch博物館

iOSエンジニアがKMPで大規模アプリの ロジック共通化をしてうまくできている話 / iOSDC2021

  • あとで観る

Day 2

ランタイムデバッグのススメ iOSDC Japan 2021

大規模なアプリのマルチモジュール構成の実践 iOSDC Japan 2021

  • みる
  • 巨大なプロジェクトを丁寧にリアーキテクチャする手法が詰まっていた
  • 技術もさることながら、組織に浸透させるための努力を惜しんでいなくて、すごかった
    • どれだけ早くなるのか試算したり、ビルド時間比較のパラパラアニメを作ったりして、CTOに話を通すところとか、ちゃんとしてるなって

Hello, Swift Concurrency world. iOSDC Japan 2021

  • あとでみる

ケースに応じたUICollectionViewのレイアウト実装パターン iOSDC Japan 2021

  • 観る
  • 一番観たかったセッションで、内容もよかった!
    • また観直したい

async/awaitやactorでiOSアプリ開発がどう変わるか Before&Afterの具体例で学ぶ iOSDC Japan 2021

Swift Package中心のプロジェクト構成とその実践 iOSDC Japan 2021

SceneKitを使ってアプリのクオリティを劇的に上げる iOSDC Japan 2021

  • SceneKit、面白そうだった

最後に

2017年に初参加して以降、毎年参加しています。 毎年進化(変化)を遂げていてすごくワクワクとした時間をすごせました。

スタッフのみなさん、発表者のみなさん、ニコ生・Twitter・Discordでイベントを盛り上げてくださった参加者のみなさん、ありがとうございました。 来年は私もなにかした発表できたらいいなぁ。

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

参考