リポジトリに含まれるコード量/比率をMackerelに記録する with GitHub Actions & action-mackerel-api

リポジトリを管理していると、何かの拍子にリポジトリのコードベースの変遷を可視化したくなる場合があります。
例えば、以下の場面です。

  • ある時からのリポジトリの成長
  • 実装言語を置き換える際のコードベースの遷移

やり方はいろいろありますが、今回は GitHub ActionsGitHub API v3 、そして Mackerel API を使って、Mackerelのサービスメトリックに記録してみます。

実行サンプル

Posting language lists to Mackerel with GitHub Ac…

GitHub APIの仕様上、特定ファイルの除外ができないことは、ここでは考慮していません。
JavaScriptは得意じゃないので、37行目からのscriptを効率化できる案があれば、教えてもらえると助かります。

グラフ例

Mackerel上でのおすすめのグラフ定義は、以下です。

  • 単位: bytes
  • グラフの種類: 積み上げ

f:id:yutailang0119:20200713021940p:plain
グラフ定義は bytes と 積み上げ を設定

利用しているグッズ

actions/github-script はyml内にJavaScriptが書けて、異常な便利さがあります。

yutailang0119/action-mackerel-api

上記の例でさらっと使っていますが、GitHub ActionsからMackerel APIを使用するために、Mackerel APIGitHub Actionsを作りました。
Mackerel APIの薄いWrapperです。

github.com

Marketplaceにも 公開 しています。
Marketplaceを検索する と、Mackerel と名のつくGItHub Actionsは他に公開されていないようです。*1

とりあえず動くものをと作ったので、まだ足りない部分がありますが、今後追加にご期待ください。

余談

shellが苦手すぎて、README/Usage が actions/github-script を使って、 body を作るサンプルになっています。
単にエスケープした文字列を入れればいいだけのはずなので、回答のPull Requestをお待ちしています。

*1:GitHub内検索 でもほとんどない

fastlane-plugin-mackerel_apiを作って、fastlaneにコントリビューションもした

rubygems.org

github.com

これはなに

Mackerel APIインターフェイスを、fastlane 向けに提供するPluginです。
実装は fastlane/actions/github_api.rb を元として、Mackerel API向けに調整しました。
Actions.shcurl を実行するだけでも事足りるのですが、せっかくなのでPlugin化しました。

例) ビルドにかかった時間をトラッキングする

発端は、はてなブックマークアプリでの開発環境にまつわる情報をトラッキングするために、MackerelのService Metricsを使おうと考えたことでした。
Service Metricsについては こちら

以下のように使うことで、処理にかかった時間を、Mackerelに投稿します。*1

start_time = Time.new.to_i
# scanなどの処理
end_time = Time.new.to_i
duration = (end_time.to_f - start_time.to_f) / 60 # 単位を分に変換

result = mackerel_api(
    api_key: ENV['MACKEREL_API_KEY'],
    http_method: "POST",
    path: "/api/v0/services/#{ENV['MACKEREL_SERVICE_NAME']}/tsdb",
    body: [
        {
            "name": "#{ENV['MACKEREL_GRAPH_NAME']}.#{ENV['MACKEREL_METRIC_NAME']}",
             "time": end_time,
             "value": duration
        }
    ]
)

結果

f:id:yutailang0119:20200522152407p:plain
社に了解を得て掲載しています

緑のグラフが跳ねているのは、Xcode 11.5を利用するように変えた時に、CIのキャッシュを破棄したからです。
こういった情報をグラフで記録できます!

ビルド時間の他にも、テストカバレッジの変動の記録にも使用しています。

感想

GWにゲームしながら、合間に作っていました。
fastlaneを使い始めて、早いもので3年強経ちますが、Pluginを作ったのは初めてでした。
最近はfastlaneのDSLよりも、Rubyらしく書けるようになりたいと思っていて、 fastlane new_action でActionとして作ることが多くなっています。
Pluginの実装は、Actionを作るのとほぼ同じように作れるため、何から始めればよいかはわかりやすかったです。

GitHub Actions for Android Lintを作ってる - がんばってなんか書く を作った時にも感じたことですが、テンプレートから生成して、そこにGitHub ActionsやTravis CIなどのテスト環境が用意されていると、ちゃんとテスト書くようになるのでよいですね。
特に慣れていないプログラミング言語のプロジェクトだと、実行結果があっているのかの自信もないので、自然とテストを書くようになれます。

fastlaneにコントリビューションもした

github.com

最近のCI環境は、もっぱらGitHub Actionsで暮らしています。
この fastlane-plugin-mackerel_api でも、GitHub Actions上でテストを行なっています。
これまで fastlane new_plugin からは、Travis CIとCircle CIでの実行テンプレートが生成されていました。
fastlane-plugin-mackerel_apiGitHub Actionsでの知見を培ったので、Pluginテンプレートに追加するPull Requestを出し、無事マージされました🎉
fastlane pluginの作成を始める開発者は、CI環境としてこれまでのTravis CIとCircle CIに追加して、新たにGitHub Actionsを選びやすくなりました。
v2.148.1 でリリースされています。

今回の変更の恩恵は、fastlaneのエンドユーザーでは感じることがないので、ぜひPluginを作ってみてください!

大変だったこと

1つ目は、テストの書き方やrakeなどのRubyを扱うそのもの。
Rubyに慣れていないので、Error throwsのテストの書き方がわからずに、めっちゃ調べました。

2つ目は、RubyGemsへの公開。
fastlaneのドキュメント Create your own fastlane pluginの中のPublishing your plugin には、

bundle install
rake install
rake release

で公開すると書かれていて、GitHub Actionsでやろうとしましたが、 rake release がgitのタグを打とうとする挙動を知らずに、はまりました。
bundle exec gem push pkg/*.gem とすることにしました。

その他に、GitHub ActionからのRubyGems公開には、OTP codeを突破できないという課題が残っています。
それを除けば、たぶん release.yml で動くと思うのだけど、OTPの回避方法を知っている人が教えてください。

まとめ

ぜひ、ご利用ください!

*1:実際にはこの処理をさらにWrapしたActionを作っています

GitHub Actions for Android Lintを作ってる

github.com

f:id:yutailang0119:20200413222656p:plain
Marketplace

Danger x Android Lint

自分は仕事ではAndroidアプリの開発もしています。
Android Lintも使っていて、danger/dangerloadsmart/danger-android_lintを組み合わせて、Pull RequestにLint結果を表示するという、一般にも使われていそうな方法で利用しています。

danger-android_lintの問題点

Dangerはすごく便利なのだけど、Lintからの指摘とDangerfileに指定したルールからの指摘とが混在してしまうのは、情報が多くなりがちで、目が滑るのが気がかりでした。
Dangerからの指摘には、Project specificな情報やレビュー上で確認必須なルールをDangerfileに指定して、Lintの情報は別にまとめたい。

GitHub Action for SwiftLint

ところで、最近はGitHub Actionsが時流らしいし、自分もaction.ymlを書くのにだいぶ慣れてきました。
Swiftだとnorio-nomura/action-swiftlintを使うと、GitHub Actionsで簡単にrealm/SwiftLintが実行できます。

norio-nomura/action-swiftlintのいいところは、実行が簡単なだけではありません。
Workflow commandswarningerrorを使って、Review CommentのようにPull Requestの対応コードにLint結果を表示します。
これが最高に便利。

ということで、Android Lint版を作った

github.com

やっていることは単純で、Android Lintで生成されたxmlをparseして、Workflow commandの形式に変換して echo しているだけです。
mobileposse/github-android-lint-action *1を参考にさせてもらいつつ、Annotationの表示にはWorkflow commandsを使うようにすることで、GitHubaccess tokenが不要になっています。

追加機能の予定

Android Lintが生成する lint-results.xml に載っている情報を全部使えているわけではないので、もう少しはアップデートしようと思っています。
id とか priority のフィルタリング機能とか。

あとは、このActionだけでAndroid Lintの実行までを行うようにすると嬉しいのかどうか...

まとめ

フィードバックをぜひお願いします!
みなさんもぜひ便利なGitHub Actionsを作って、公開してください!!!

[おまけ] GitHub Actionを作る

今回のActionは、xmlのparseして、 echo するだけで、環境依存はありません。
よって、Javascript actionで作りました。
かつ、型がある方が嬉しい*2ので、actions/typescript-actionをTemplateにして、作成を始めました。
TypeScriptを久々に触りながら、出来上がりを優先して作っているので、クオリティはいまいちかもしれませんが、テストはちゃんと書いているので、ある程度の品質は担保できているはず。

READMEとaction.ymlの情報で、Marketplaceに公開されます。
Update action.yml for branding · yutailang0119/action-android-lint@f183f3c · GitHub

f:id:yutailang0119:20200413183312p:plain
GitHub Marketplaceに公開するために、iconとcolorを指定するのおもしろい

*1:残念なことにアーカイブされてしまっている😐

*2:結果的にxmlのパース部分は、型定義ファイルがないので、 any を扱うことになったけど

WEB+DB PRESS Vol.116 特集1 「はじめてのトラブルシューティング」に寄稿しました #wdpress

宣伝

2020/04/24 (金) に発売予定WEB+DB PRESS Vol.116 *1 に寄稿しました!
担当は、特集1 「はじめてのトラブルシューティング」の第4章「モバイルアプリ」です。
id:Soudai さん、 id:rukiadia0401 さん、@maeponさんとの特集共著です。

一部の大手書店様では、4/16 (木) から、先行販売もあるようです。
外に出づらいご時世ですが、電子書籍版の販売もありますので、合わせてよろしくお願いします!

f:id:yutailang0119:20200410105814j:plain:w300
表紙
WEB+DB PRESS|gihyo.jp … 技術評論社

iOSAndroidの両方の解説で、誌面の関係で掲載を断念せざるを得なかった内容も多くあります。
以下を意識しています。

  • 4月発刊の新人歓迎号と銘打っているので、基礎の基礎から説明
  • 限られた誌面の中でも、できるだけ広い領域を拾えるように
  • 日頃からトラブルへの準備をできるように
  • プロフェッショナルな皆様にも、いくつかは学びがあるように

経緯

今回の寄稿は id:Soudai さんより、声をかけてもらいました。
たしか、去年の12/20 (金) *2
id:Soudaiid:yutailang0119 の組み合わせといえば、なつかしのマグロですね。

yutailang0119.hatenablog.com

Web+DB PRESSid:yutailang0119

読者としては、初めてWeb+DB PRESSを読んだのかが、いつかは覚えていないのですが、 builderscon tokyo 2017で「WEB+DB PRESS 100号記念特別企画」に関わらせてもらいました。

https://builderscon.io/tokyo/2017/session/17d5767a-4f26-11e7-aa42-42010af00d0a

www.youtube.com

このbuilderscon tokyo 2017開催レポートを、101号に寄稿させてもいただきました。

そういえば、「4月は新人歓迎号」という話も、この時に聞きましたね。
2年半経って、自分もついに技術記事で寄稿の機会をいただくことができたし、なんと 特集1 !!!

謝辞

同じ特集1の共著者メンバーの id:Soudai さん、 id:rukiadia0401 さん、@maeponさん、編集の稲尾さん、いろいろ大変な時期でしたが、お疲れ様でした!
ありがとうございました!

はてなの同僚の id:cockscomb id:ikesyo id:itokjp の皆様には、レビューと称して、かなり校正もしてもらいました。
ありがとうございました!

最後に

2020/04/24 (金) 発売予定 WEB+DB PRESS Vol.116 を、よろしくお願いします!

*1:URLはまだ404だけど、たぶんこれのはず

*2:忘年会中にDM来た

iOS/iPadOSアプリ PixelaUI 0.1.0 をリリースしました #pixela

PixelaUI

PixelaUI

  • Yutaro Muta
  • ユーティリティ
  • 無料
apps.apple.com

これはなに?

PixelaiOS/iPadOS向けクライアントアプリケーションです。
アプリ名は、後述する使用frameworkである SwiftUI ともかけています。
サポートOSバージョンは iOS/iPadOS 13.2以上 です。

どうやって使うの?

Pixelaの使い方を見てもらうとよいです。
これの主要機能を、iOS/IPadOSのUser Interfaceで提供しています。*1
blog.a-know.me

Pixela自体はAPIのみを提供していて、User Interfaceサードパーティ製というスタンスを取られています。
一方で、Programableと結びつきにくい日常生活の中で手軽に使うにはハードルが高い側面があります。
iOS/iPadOSで気軽に扱えるように、という思いで作りました。

どうやって作ったの?ほか

2020/01/07の深夜に思い立って、約2週間でリリースまで来ました。

開発時間の記録は、このグラフに取っています。
pixe.la

実装は SwiftUI を使っています。
WWDC 19での発表当初から遊んでいたので、20時間弱で一通りの使える機能実装はできていました。
仕事から帰ってからの時間だけでも、1週間くらいでできたし、Viewerとしてのアプリなら、もっと楽にできそうな印象です。*2

ここからマルチアカウント機能つけたり、Context Menuによるincrement*3を実装したり、エラーハンドルをちゃんとしたりで、なんだかんだで倍くらいかかってしまった。*4

仕事やPyCon JP名義 でのリリースはして来たけれど、実は個人としてのアプリリリースは初でした。

Special Thanks

(以下、敬称略)

Pixelaの生みの親で、はてなの同僚でもある id:a-know には、アイコン画像を提供してもらったり、API仕様の相談をしたり、機能提案をもらったりと、開発をとても助けていただきました!
改めてお礼を、ありがとうございます。

また、同僚の id:cockscomb, id:ikesyo にも、SwiftUIやEmbeded Frameworkの話で相談に乗ってもらいました。

それから、Twitterでアイディアをくれた @omochimetaru@giginet@tarunon@_bannzai_@nakiwo もありがとうございました!
実は v0.1.0 では、まだ修正に着手できていなくて、微妙な挙動をしています🙇‍♂️ *5

お知らせ

サポートOSバージョン

SwiftUIを使っている関係上、iOS/iPadOS 13.0以上は必須なのですが、開発環境で確認しやすいiOS/iPadOS 13.2以上とさせてもらいました。
また、今後のアップデートでも、SwiftUIアップデート時のAPI diffによっては、サポートOSバージョンを変更する可能性があります。
ご了承ください。

今後の非互換変更

実装がしっくりきていない部分があるので、 今後非互換変更を入れることになるかもしれません 🙇‍♂️
アプリ内からアカウントを作成した場合も、別途セキュアな領域に token を保存しておくことを、強くおすすめします。

そういった理由で、アプリバージョンも v0.1.0 としています。
非互換変更を経た後に、 v1.0.0 を迎えたいと思います。

完成度

上記以外にも、作り込みの足りない箇所が多々あります。
これは自分自身のSwiftUIへの理解度が足りていないことが原因でありつつも、SwiftUI自体にUIコンポーネントが全て出揃っている訳ではないことも理由の一つです。
id:yutailang0119 のレベルアップと、SwiftUIのアップデートにご期待ください。

有料化

前述の v1.0.0 のタイミングなどで、有料アプリに変更する可能性があります。
これは id:yutailang0119 の気まぐれで行われると思うので、 2020/01/23現在はいつ変更するのかは未定です。
サブスクモデルではなく、パッケージモデルのつもりなので、無料の内にダウンロードしておくと、お得かもしれません。

無料アプリだけど、ぜひ支援させてくれという方がいましたら、おたよりお待ちしています 🙏

おたより

おたよりはこちら

*1:今後のアップデートで、対応APIを追加予定です

*2:もちろん世間一般で言われているバギーな印象も持っています。

*3:f:id:yutailang0119:20200122121920p:plain:w250:rightグラフのint or floatに合わせて、incrementされます

*4:審査通ってからリリースボタンを昨日の昼に押したのですが、夜中まで公開されずやきもきしていました。

*5:APIの仕様を変更してもらいました。

Swift Playgroundsで快適なSwiftUIコーディング環境を #SwiftAdventCalendar

これは Swift Advent Calendar 2019 24日目のエントリーです。
昨日23日は Moto0124 さんの CGAffineTransformを知る でした。

はてなエンジニアAdvent Calendar 2019での Deletable Table with TextField on SwiftUI に続いて、今回も SwiftUI ネタです。

そもそもSwift Playgroundsって?

ここでいう Swift PlaygroundsXcodeに付属のPlaygroundではなく、iPadアプリとしての Swift Playgroundsを指しています。
以降、 Swift Playgrounds は 「iPadアプリのSwift Playground」、 Xcode PlaygroundXcodeに付属のPlaygroundと使い分けます。

ドキュメント: https://developer.apple.com/documentation/swift_playgrounds

SwiftUIをSwift Playgroundsで扱う

SwiftUIのアナウンス直後から、Swift Playground 3.1以降でSwiftUIが実行できることは話題になっていました。

Swift Playgrounds 3.1のVersion History

us: • Build with the SwiftUI framework in new playgrounds you create
jp: • 新しく作成するプレイグラウンドでは、SwiftUIフレームワークを使用してビルドできます

SwiftUIで表示してみる

すでに広く知られている話だとは思うけれど、まずは単純に表示してみましょう。

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello")
                .font(.largeTitle)
                .foregroundColor(.primary)
            Text("world")
                .font(.title)
                .foregroundColor(.secondary)
        }
    }
}

let host = UIHostingController(rootView: ContentView())
PlaygroundPage.current.liveView = host

f:id:yutailang0119:20191223171953j:plain
Swift Playgroundsで実行

Xcode Playground用ファイルとしてExportする

便利な使い方として、Xcode Playground用の拡張子 .playground として作成することもできます。
ファイル作成時に Starting Points > Xcode Playground を選択して、始める必要があります。

f:id:yutailang0119:20191223172525j:plain
Starting PointsからXcode Playgroundを選択

ここから作成したファイルをiCloudAirdropmacOSに送り、Xcodeで開くと、実行できます。

f:id:yutailang0119:20191223161553p:plain
Xcode Playgroundで実行

再利用できるViewの作り方

簡単なレイアウトのViewを作ってみるにはこれだけでも十分便利ですが、複雑なレイアウトを作ったり、再利用しやすいViewを作ったりには不向きに思えます。
また、Playground上での作成と言っても、最終的にはアプリ開発にViewを組み込みたいでしょう。

Swift Playgroundsでも、複数のSwiftファイルを扱うことができるので、各パーツのViewを別々のSwiftファイルに分割してみます。
参考: https://developer.apple.com/documentation/swift_playgrounds/structuring_content_for_swift_playgrounds/using_modules_to_share_code_in_a_playground_book

ファイルを追加

左上のファイルアイコンから、ファイルの追加ができます。
今回は3ファイル作成しました。

f:id:yutailang0119:20191223183537j:plain
左上のアイコンから、ファイルを追加

LargeTitleView.swift

import SwiftUI

struct LargeTitleView: View {
    var body: some View {
        Text("Hello")
            .font(.largeTitle)
            .foregroundColor(.primary)
    }
}

TitleView.swift

import SwiftUI

struct TitleView: View {
    var body: some View {
        Text("World")
            .font(.title)
            .foregroundColor(.secondary)
    }
}

Preview.swift

Preview のみ public 修飾子でアクセスできるようにしておきます。

import SwiftUI

public struct Preview: View {
    
    public init() {}
    
    public var body: some View {
        VStack {
            LargeTitleView()
            TitleView()
        }
    }
}

main

Swift Playgroundのmainファイルでは、PreviewUIHostingController で描画するようにします。
Swift Playgroundsでのmainと、その他のファイルとでは、別Module扱いになるため、 public 修飾子ないものにはアクセスできません。
import はいらない。

import SwiftUI
import PlaygroundSupport

let preview = Preview()
let host = UIHostingController(rootView: preview)
PlaygroundPage.current.liveView = host

f:id:yutailang0119:20191223185219j:plain
実行結果

これ以降はPreviewと各Viewを書き換えるだけで、実行ができるようになりました。
こうすることで、各View毎のstructをそれぞれファイルとして分割できたり、 public 修飾子を書き忘れたり、 init() 実装し忘れで実行できなかったりがなくなります。
iCloud経由で、Xcodeのアプリプロジェクトに追加もできるので、非常に便利ですね。
もちろん、copy&pasteするだけでも、動きます。

Xcode Playgroundで実行する時には注意点があって、Xcode Playground上でのファイルリンクは特殊で、struct定義から直接ファイルジャンプはできません。
上部のペインから、辿ってください。

f:id:yutailang0119:20191223184833p:plain
Jump to Definitionでは、ファイルジャンプできない

まとめ

iPad Proを買ってからというもの、Swift Playgroundsを使いたいと思うことが幾度とありましたが、やっと活用方法を見つけた気がします。
当たり前ですが、iCloudでファイルを同期する場合は、macOSで共有ディレクトリを開いていたりすると、最新の変更が更新されず、消えてしますこともあるので、ご注意を!
また、まだまだ不安定なのか、たまに .playground/Contents.swft が破損して、開けなくなることもありました。 *1

おまけ

このエントリー用の検証していて、以下のコードを書けることに気づいた。
なにか使い道ないかな。

import SwiftUI

public struct Preview<Body: View>: View {
    
    private let _body: Body
    
    public init(body: Body) {
        self._body = body
    }
    
    public var body: some View {
        _body
    }
}

let preview = Preview(body: ContentView())

*1:復帰方法は、macOSでパッケージの中のファイルをサルベージして、新しいPlaygroundとして作成し直す他ないようです...

Deletable Table with TextField on SwiftUI #はてなエンジニアAdventCalendar

これは はてなエンジニアAdvent Calendar 2019 1日目のエントリーです。
今年のAdvent Calendarでは初日を担当します id:yutailang0119 です!
去年はツール作りの話を書きましたが、今年は先日のアンケート記事でも言及していたSwiftUIの話です。

お題

  1. SwiftUIで
  2. 編集可能なテキストの
  3. リストを作り
  4. Rowごとに削除が可能

これが実は難しいお題だったので、解説していきます。

環境

Xcode 11.2.1 (11B500)

2024/02/19追記

Xcode 15.2 (15C500b) 現在 (おそらくこれ以前に) 、ForEachにBindingなinit(_:content:) が追加*1されたため、単純に実装が可能になりました。
ただし、1入力ごとにstateが確定し、キーボードが閉じてしまうため、この条件ではうまく機能しない気がします。

struct ContentView: View {
    @State private var animals: [String] = ["🐶", "🐱"]

    var body: some View {
        VStack {
            EditButton()
            List {
                ForEach($animals, id: \.self) { $animal in
                    TextField("", text: $animal)
                }
                .onDelete { indexSet in
                    self.animals.remove(atOffsets: indexSet)
                }
            }
        }
    }
}

解答

最初に解答なんですが、こちらが自分がstack overflowに投稿した質問。

stackoverflow.com

解説

リストを作る

まずは、SwiftUIの初歩とも言えるリストを作ってみます。

import SwiftUI

struct ContentView: View {
    private var animals: [String] = ["🐶", "🐱"]
    
    var body: some View {
        List {
            ForEach(animals, id: \.self) { animal in
                Text(animal)
            }
        }
    }
}

この後の形式と揃えるために ListForEach の両方を使用していますが、単一なViewのリストであれば、 List のみでも実現できます。

こんな感じ

import SwiftUI

struct ContentView: View {
    private var animals: [String] = ["🐶", "🐱"]

    var body: some View {
        List(animals, id: \.self) { animal in
            Text(animal)
        }
    }
}

テキストを編集可能にする

前述のリストの各項目を編集可能にしてみます。
Textで文字列を表示していた箇所をTextFieldに変更すればよさそうです。
しかし、 TextField のinitializerは、 Binding<String> を引数として要求して、更新を反映していきます。
ForEach のmapでは Binding<T> を取得することはできないので、配列のカウントをRangeでループさせ、indexを使って Binding<String> を扱えるようにします。 *2
合わせて、DataSourceである animals@State を付与しておきます。

import SwiftUI

struct ContentView: View {
    @State private var animals: [String] = ["🐶", "🐱"]

    var body: some View {
        List {
            ForEach(0..<animals.count) { i in
                TextField("", text: self.$animals[i])
            }
        }
    }

これで、各項目のテキストを編集できるようになりました。
ここまでのリストとTextFieldの組み合わせだけでも、ちょっとトリッキーなことをしている印象はありますね。

Rowを削除可能にする

最後の条件です。
ForEach には .onDeleteが実装されていて、これを使うと EditMode.active の時には、UITableView.isEditingと近い振る舞いをできるようになります。 *3
EditMode == .active である必要があるので、 EditButton も追加しておきましょう。

以上を満たした実装をします。

import SwiftUI

struct ContentView: View {
    @State private var animals: [String] = ["🐶", "🐱"]

    var body: some View {
        VStack {
            EditButton()
            List {
                ForEach(0..<animals.count) { i in
                    TextField("", text: self.$animals[i])
                }
                .onDelete { indexSet in
                    self.animals.remove(atOffsets: indexSet)
                }
            }
        }
    }
}

Previewで確認してみましょう。

Preview実行し、EditButtonを押したところ

できていそう!!!

これではRuntime Errorが起きる

PreviewでRowを削除してみると、Previewの端末画面が真っ白になってしまいます。
これはRuntimeでエラーが起きたということです。 *4

シミュレーターで実行し、再度エラーを発生させ、ログを見てみます。

Thread 1: Fatal error: Index out of range

Index out of Range が発生しているということがわかりました。

このログを考察すると、🐶を削除したにも関わらず、 ForEach のループは2回起きているようです。
念の為に、animals から🐶が削除できているのか確認してみます。

(lldb) po animals.count
1

(lldb) po animals
▿ 1 element
  - 0 : "🐱"

🤔🤔🤔

ここからいろいろ試してみますが、うまくいかず、冒頭のstack overflowの質問をしました。

例えば、最初の Text のリストの削除はうまくいく

import SwiftUI

struct ContentView: View {
    private var animals: [String] = ["🐶", "🐱"]
    
    var body: some View {
        List {
            ForEach(animals, id: \.self) { animal in
                Text(animal)
            }
            .onDelete { indexSet in
                self.animals.remove(atOffsets: indexSet)
            }
        }
    }
}

stack overflowでの解答

https://stackoverflow.com/a/58911168/11264346

解答にあるように、ForeEach.init(_ data: Range<Int>, @ViewBuilder content: @escaping (Int) -> Content) のDocument Commentを読んでみます。

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension ForEach where Data == Range<Int>, ID == Int, Content : View {

    /// Creates an instance that computes views on demand over a *constant*
    /// range.
    ///
    /// This instance only reads the initial value of `data` and so it does not
    /// need to identify views across updates.
    ///
    /// To compute views on demand over a dynamic range use
    /// `ForEach(_:id:content:)`.
    public init(_ data: Range<Int>, @ViewBuilder content: @escaping (Int) -> Content)
}

なるほど

  • data の初期値のみを読み取るので、更新時のView識別が必要ない
    • -> 要素数に更新があっても、初期値のみを読み取るので、それに追従されない。
  • ダイナミックに計算するには、 ForEach(_:id:content:)を使用する。

なるほど
それだと、 Binding<T> が扱えないんだけど!

答え

解答してくれた Asperiさんが、ちゃんと実装も答えてくれていました。
以下が解です。

import SwiftUI

struct ContentView: View {
    @State private var animals: [String] = ["🐶", "🐱"]

    var body: some View {
        VStack {
            EditButton()
            List {
                ForEach(animals, id: \.self) { animal in
                    EditorView(container: self.$animals, index: self.animals.firstIndex(of: animal)!, text: animal)
                }
                .onDelete { indexSet in
                    self.animals.remove(atOffsets: indexSet)
                }
            }
        }
    }
}

struct EditorView : View {
    var container: Binding<[String]>
    var index: Int

    @State var text: String

    var body: some View {
        TextField("", text: self.$text, onCommit: {
            self.container.wrappedValue[self.index] = self.text
        })
    }
}

ForEach では、要素数の変更を反映できるように .init(_:id:content:) を使用し、EditorViewの中で、TextFieldでの変更を元の animals に更新できるように作ると解決できました!

まとめ

SwiftUIはまだまだ足りないパーツも多いし、ちょっと凝ったことをしようとすると、ドキュメントが少なく苦労することが多いので、挫折しがちです。
特にEditModeが絡む実装をしようとすると、途端にハイレベルになる印象があります。
SwiftUIがよりよいものになるように、集合知を集めて行きましょう!!!

明日は id:masawada です!

はてなエンジニアAdvent Calendar 2019 をお楽しみに!

*1:iOS 13にBackport済み

*2:ちょっと綺麗な形ではないですね

*3:実際にはスワイプ削除等が効かないので、近い振る舞い

*4:Previewでのエラーはログが出ない