ごんれのラボ

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

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

参考

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

概要

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

ソースコード

OperationQueue sample