[iOS] Swift で端末の使用言語を取得する

環境

  1. Xcode12.1
  2. Swift 5.3
  3. シミュレータ: iOS 14.1

取得するコード

let language = NSLocale.preferredLanguages.first?.components(separatedBy: "-").first

print("🐱: \(String(describing: language))") // 🐱: Optional("ja")

取得できるのは jaen などのアルファベット2文字です。
これは言語の国際規格 ISO 639-1 で定義されている言語コードです。

メジャーではない言語に関しては ISO 639-2 で返却される可能性もありますがそれは調べていません😣
が、メジャーな言語は ISO 639-1 でカバーされているので、言語の専門アプリでもない限りそれで事足りるかと思います。

ISO 639 については、本ブログで以前書いた 言語の国際規格: ISO 639 について も参考になるかもしれません🙌

NSLocale.preferredLanguages の返り値についてもう少し詳しく

記事タイトルは上の項目でさくっと回収できましたが、返り値についてもう少しまとめておきます。

設定の優先順に複数の値を返す

NSLocale.preferredLanguages を呼び出すと以下のような String の配列が返ってきます。

["ja-JP", "en-JP", "en-GB", "es-ES"]

このときの端末の設定は以下のような状態です。

言語と地域の設定画面

言語を4つ設定しており、それが優先度順に返ってきている形です。

一つ一つの値について

配列の1つ1つは 言語コード - 地域コード という形式になっています。
参考: Apple の公式ドキュメント Language and Locale IDs

言語コードは先述の通り ISO 639 に、地域コードは国名の国際規格 ISO 3166-1 alpha-2 に沿っているようです。
(ISO 3166 に関しても本ブログの 国名の国際規格: ISO 3166 について に概要をまとめたので、よろしければ)

以下の例で言うと、

["ja-JP", "en-JP", "en-GB", "es-ES"]

ja-JP は言語が日本語地域が日本となります。
en-JP は言語が英語地域が日本となります。
これは上図の通り、「地域」を日本に設定しているからです。

en-GB は言語が英語地域がイギリスとなります。
es-ES は言語がスペイン語地域がスペインとなります。
地域が日本になっていないのはなぜなのか🤔

それは↓

端末の設定との関係について

以下のように末尾に (国名) と表示している言語は、地域コードをその国名で返すという仕様のようです。

国も含んだ言語設定

つまり、英語のイギリス方言は地域コードがイギリス(GB)になり、
スペイン語のスペイン方言は地域コードがスペイン(ES)になります。

言語と地域が被っていてやや分かりづらいので更に言うと、英語のオーストラリア方言は「en-AU」のようになります。

一方、以下のように国名が表示されていない言語は、端末設定の「地域」を地域コードとして採用するようです。

国を含めない言語設定

「地域」設定の存在感は実はやや薄めかも。。🤔

端末の「地域」設定について

ちなみにこの「地域」の設定は、日付表記などをその地域の慣習に合わせた形式で表現するために使用されるようです。
例えば日本だと日付は「年月日」ですが、アメリカだと「月日年」の順になる、というようなことです。
参考: Apple の公式ドキュメント Reviewing Language and Region Settings

しかし、シミュレータのカレンダーアプリで確認したところ、
en-JP の場合でも日付は「月日年」形式になっていて、日本の「年月日」形式ではありませんでした。

このあたりを実装する際は注意が必要かもしれません😔

最後に

NSLocale.preferredLanguages の理解が深まった感があります。

以上です。お疲れ様でした!

[iOS] Nimble を導入して、非同期処理も含めてユニットテストを楽に楽しく書く

環境

  1. Xcode12.1
  2. Swift 5.3

Nimble とは

Use Nimble to express the expected outcomes of Swift or Objective-C expressions. Inspired by Cedar.

https://github.com/Quick/Nimble#nimble

Swift (や Objective-C) のユニットテストで使うOSSで、評価と期待値を簡単に書けるようなっています。
テストフレームワークのQuick 内のツールのようですが単体でも使用できます。

現在の最新バージョン v9.0.0 のライセンスはこちら。
https://github.com/Quick/Nimble/blob/v9.0.0/LICENSE

こんな感じで書ける

        // XCTest の書き方
        XCTAssertTrue(hoge)
        
        // Nimble の書き方
        expect(hoge).to(beTrue())

ざっくり言うと expect(評価対象).to(期待値) という書き方になります。

メリットとデメリット

個人的には主に以下のようなメリデメがあると考えています。

  1. メリット
    1. こけたときの情報が分かりやすく、原因の特定が容易
    2. 非同期処理のテストを簡単に書ける
    3. 英語の文法に近いので何をしているか読みやすい
  2. デメリット
    1. OSSなのでいつまでサポートされるか分からない
    2. 学習コストが発生する(ただし非常に小さいと思う)

こけたときの情報が分かりやすい

こけたときの情報が分かりやすい

上図のように、素の書き方の方は実際値が nil でこけたのか false でこけたのかが分かりません。

一方Nimble の方は期待値とともに実際の値も示してくれるので、
原因の特定が非常に容易になります。

非同期処理のテストを簡単に書ける

class Hoge {
    var isDone = false
    func async() {
        DispatchQueue.main.async { [self] in
            isDone = true
        }
    }
}

例えば上記クラスのテストとして、
「async メソッドを呼ぶと、isDone が true になること」を担保したいとき。

素の書き方だと以下のようになると思います。

    func testExample() throws {
        let hoge = Hoge()
        hoge.async()
        
        let exp = expectation(description: "Async")
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
            XCTAssertTrue(hoge.isDone)
            exp.fulfill()
        }
        wait(for: [exp], timeout: 1.0)
    }

ぱっと見でもう辛い。
(もっといい書き方があれば教えていただきたいです🙏)

一方、Nimble だと以下のように一行で書けます。

    func testExample() throws {
        let hoge = Hoge()
        hoge.async()
        
        expect(hoge.isDone).toEventually(beTrue())
    }

一行。
非同期処理のテストが全然怖くなくなります。


個人的には前向きに検討してもよいくらいメリットがあるのかなと感じています。

特にTDDで開発する場合、テストを書く→実装する→リファクタする、
をいかにテンポよく書けるかで開発がノってくるかが左右されます。
そんなときにテスト記述でつまずいていたら開発に全くノれないですね。

その点Nimble はテスト記述で迷うことが少ないので、
その課題をうまく解消できるのではと思っています。

ということで、以降は導入とメソッドの一部をさらっと紹介します。

Nimble をインストール

Swift Package Manager でインストールしてみます。
Qiita に iOSアプリ開発にSwift Package Managerを使おう という記事があるので、その通りに進めます。

パッケージのリポジトリのURLは https://github.com/Quick/Nimble を入力します。

また、テスト時のみ使用すると思うので、テストターゲットを指定でよいです。

テストターゲットを指定

以上です!

Nimble の使い方

詳しくは Nimble の READMEQiita: Nimbleの使い方サンプルを作ってみた
を見てもらえるとよいと思います。

ここではざっくり程度に書くので雰囲気を掴んでいただければ。

// AAA が BBB であること
expect(AAA).to(equal(BBB))
// AAA が True であること
expect(AAA).to(beTrue())
// AAA が True ではないこと
expect(AAA).toNot(beTrue())
or
expect(AAA).notTo(beTrue())
// AAA が非同期でTrue になること(デフォルトでは1秒待ちます: GitHub の該当箇所)
expect(AAA).toEventually(beTrue())
// AAA が非同期でTrue 以外になること
expect(AAA).toEventuallyNot(beTrue())
or
expect(AAA).toNotEventually(beTrue())
// AAA が1以上であること
expect(AAA).to(beGreaterThanOrEqualTo(1))
// AAA が Hoge クラスのインスタンスであること
expect(AAA).to(beAnInstanceOf(Hoge.self))
// AAA が Hoge クラスもしくはその子クラスのインスタンスであること
expect(AAA).to(beAKindOf(Hoge.self))
// AAA が1.0のプラスマイナス0.2の範囲内の値であること
expect(AAA).to(beCloseTo(1.0, within: 0.2))

最後に

楽しくTDDをしたいですね!

以上です。お疲れ様でした!

[デザイン] Figma で月の呼吸 壱ノ型 闇月・宵の宮 の剣閃を10分で作る

Figma とは

Figmaは、ブラウザで実行されるインターフェイスデザインアプリケーションですが、実際にはそれだけではありません。チームベースの共同設計プロジェクトには、おそらくこれが最適なアプリケーションだと言っても過言ではありません。

https://webdesign.tutsplus.com/articles/what-is-figma–cms-32272

無料でワイヤーフレームやUIのプロトタイピングが作れる、ブラウザ上で動作するツールです。

フィギュアの方 ではありません!

三日月の形を作る

まずは月の呼吸の最大の特徴である三日月状のエフェクトを作ります。
手始めに大きさの異なる円を二つ作ります。

以下のように「Shape Tools」からEclipse を選択します。

Eclipse を選択

で、円を二つ描きます。
大きさは一つを200×200、もう一つを170×170としました。
(分かりやすいように片方を赤く塗っています)

円を二つ描く

それを右と下の境界が重なるように配置します。

右と下の境界が重なるように配置

両方の円を同時選択した状態で、以下のように「Subtract selection」をクリックします。

「Subtract selection」をクリック

すると、このように三日月状のシェイプができました!

三日月状のシェイプができた

そのままだと、以下のように2つのシェイプの状態が残ってしまっています。

2つのシェイプの状態が残ってしまっている

そこで「Flatten selection」をクリックします。

「Flatten selection」をクリック

すると1つのシェイプになりました!

1つのシェイプになtっtったった

次に色を塗っていきます。

グラデーションで背景色をつける

シェイプを選択した状態で以下のように「Fill」の真下の四角部分をクリックします。
すると、背景色の詳細な設定ができるツールチップが表示されます。

「Fill」の真下の四角部分をクリック

ツールチップの左上にあるように現状の設定は「Solid」で、単色のみ設定できる状態です。
グラデーションを付けたいので以下のように「Linear」を選択します。

「Linear」を選択

すると以下のような表示になります。
これが線形のグラデーションができるようになります。

「Linear」の初期表示

まずグラデーションの軸を調整します。
以下のように、三日月の角度と軸を一致させます。

三日月の角度とグラデーションの軸を一致させる

背景色を塗っていきます。
ツールチップの左右のつまみを選択し、それぞれ黄色に塗っていきます。

次にシェイプの中央にグラデーションを付けていきます。
以下のように色のバーの真ん中あたりをクリックします。

真ん中あたりをクリック

すると、ツールチップに吹き出しのようなものが一つ増えます。
この吹き出しはグラデーションを制御する目印で、
その場所ではこの色にしてください、という設定ができます。

gぐグラデーションの中央を白色に設定

真ん中を白に指定して完成です!

これで月の呼吸の特徴的なエフェクトが一つできました。

月の呼吸の特徴的なエフェクト

ここからは飛ばしていきます!

斬撃の本体を作る

斬撃の本体は微妙に楕円形にしようと思います。
大きさは一つを800×700、もう一つを700×600としました。

斬撃の本体用の楕円

これをこうして、

斬撃の本体を三日月型にする

こう!

斬撃の本体に色を付ける

三日月のエフェクトを散りばめる

あとは、最初に作った黄色いエフェクトをコピペし、大きさや角度を色々変えて、
本体の斬撃の上に散りばめてやります。

するとこうなりました!

月の呼吸 壱ノ型 闇月・宵の宮 の剣閃

枠線やシャドウを付けて完成

なんとなく寂しいので、枠線とシャドウを付けます。

まず枠線を付けます。
以下のように全シェイプを選択して、「Stroke」の右の「+」をクリックします。

「Stroke」の右の「+」をクリック

すると以下のようになりました。
デフォルトのストローク設定ですが、一気にアニメ塗りっぽくなりましたね!

アニメ塗りっぽくなった

続いてシャドウを付けます。
同じように全シェイプを選択して「Effects」の右の「+」をクリックします。

「Effects」の右の「+」をクリック

すると以下のようになりました。
これもデフォルトの設定ですが、ちょっとディテールが凝ってきた感が出始めました。

ちょっとディテールが凝ってきた感

これにて完成とします!

最後に

月の呼吸 壱ノ型 闇月・宵の宮の剣閃をざっくり会得できました🙌

以上です。お疲れ様でした!

[iOS] Decodable でJSONをデコードする時に要素の型を変換する

前提条件

Xcode: 12.1
Swift 5.3

やりたいこと

以下のようなJSONデータがあったとします。

{
    "食品名":"肉類",
    "食材": "鶏肉, 豚肉, 牛肉"
}

これを Decodable で何もいじらずデコードする場合、型は以下のようになります。

struct Food: Decodable {
    var 食品名: String
    var 食材: String
}

Food.食材 には "鶏肉, 豚肉, 牛肉" という String が入ります。
イケてないので、これを以下のような String の配列として扱いたいです!

struct Food: Decodable {
    var 食品名: String
    var 食材: [String]
}

前提としてデコード処理は以下のように呼び出しています。

        let food = try! JSONDecoder.init().decode(
            Food.self,
            from: data
        )

まずは最終的なコード

struct Food: Decodable {
    var 食品名: String
    var 食材: [String]
    
    enum コーディングキー: String, CodingKey {
        case 食品名
        case 食材
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: コーディングキー.self)
        
        食品名 = try container.decode(String.self, forKey: .食品名)
        let 食材String = try container.decode(String.self, forKey: .食材)
        食材 = 食材String.components(separatedBy: ",")
    }
}

JSONが日本語なので、コードもなんとなくそれに合わせて日本語にしています🤔
問題なく動作します。

自前のデコード処理が必要

ということで、以下順番にデコード処理を書いていきます。

CodingKey に準拠した enum を用意する

まず Food 構造体に、CodingKey プロトコルに準拠させた enum を追加します。

struct Food: Decodable {
    var 食品名: String
    var 食材: [String]
    
    enum コーディングキー: String, CodingKey {
        case 食品名
        case 食材
    }
}

この enum はJSONデータをデコードする際にキーとして利用します(CodingKey の読んで字の如く)。
JSONのキー名と同じ文字列を case に定義します。

ちなみに、デコードで実際に利用するのは enum の rawValue であるという理解がより正しそうです。
ですので、rawValue に別の文字列を定義することでJSONのキー名と異なるプロパティ名でデコードできる、
ということになりますが、それはまた別の機会に。。

デコード用の初期化メソッドを用意する

あとは以下のようにFood構造体の初期化メソッドを定義してやればOKです!

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: コーディングキー.self)
        
        食品名 = try container.decode(String.self, forKey: .食品名)
        let 食材String = try container.decode(String.self, forKey: .食材)
        食材 = 食材String.components(separatedBy: ",")
    }

try values.decode(String.self, forKey: .食材)"鶏肉, 豚肉, 牛肉" を文字列として 食材String に取り出した後、
, を区切り文字にして配列に分解しています!

これで文字列を配列に変更してパースできました🙌


さて、デコードできたはいいですが、init(from decoder: Decoder) throws で処理しているので、
全てのデコード処理を自前で書く必要があります。

要素の数が増えれば増えるほど記述するコストが増えてしまいます。

ある程度複雑な構造の場合、自前で全てデコード処理を書くのはつらいです。

そこで以下の記事にて、要素の一部のみ独自のデコード処理を適用する方法を書いていきます!
[iOS] Decodable でJSONをデコードする時に要素の型を変換する その2 KeyedDecodingContainer のエクステンションで一部の要素にのみ独自のデコード処理を適用する

最後に

これだけなら割と簡単ですね、お疲れ様でした!

世界の国や言語の分類と規格について

はじめに

世界の国や言語をどう整理・体系化するのがよさそうか気になったのでまとめてみました。

世界の国や地域の大別について

大州という分け方があります。
いくつか分け方はありますが、例えば六大州の場合は以下のようになります。

  1. アジア
  2. ヨーロッパ
  3. アフリカ
  4. 北アメリカ
  5. 南アメリカ
  6. オセアニア

世界の国について

日本が認めている国一覧は外務省のサイトで閲覧できます。

  1. アジア
  2. 大洋州
  3. 北米
  4. 中南米
  5. 欧州
  6. 中東
  7. アフリカ

国連加盟国と全く同一にはなっていません。
詳しくは外務省サイトの
世界と日本のデータを見る(世界の国の数,国連加盟国数,日本の大使館数など) を参照のこと。

国名の国際規格: ISO 3166 について

世界の国々の名称について略号を規定する国際規格となります。
ISOのサイトで検索できます。

大きく分けて3つのバージョンあります。

  1. ISO 3166-1:現在存在している国名をコードで表したもの。以下3種類ある
    1. ISO 3166-1 alpha-2: アルファベット2文字で表したもの
    2. ISO 3166-1 alpha-3: アルファベット3文字で表したもの
    3. ISO 3166-1 numeric: 3桁の数字で表したもの。アルファベットを使わない場合はこちらを使用する
  2. ISO 3166-2: 国を更に細分化して地域まで表現するコード。ISO 3166-1 alpha2 の後にハイフンと2〜3文字の英数字を付与することで表現する。
  3. ISO 3166-3: 旧国名を表すコード。ISO 3166-1 alpha2 の後に2文字のアルファベットを付与して表現する。

言語の国際規格: ISO 639 について

世界の言語の名称について略号を規定する国際規格となります。
ISO 639-1 や ISO 639-2 はアメリカ議会図書館のサイト を参照するのが良さそうです。
(ISO のサイトからもリンクが貼られている)

こちらも複数のバージョンがあり、現在は5バージョンあるようです。

  1. ISO 639-1: 主要な言語を2文字のコードで表したもの。一般的な用途ではこれで事足りるはず
  2. ISO 639-2: より広い範囲の言語をカバーするために3文字のコードで表している
  3. ISO 639-3: 同じく3文字のコードですが、絶滅した言語や古代の言語を含め、可能な限りあらゆる言語をカバーするもの
  4. ISO 639-4: 言語の分類ではなく、ISO 639 のコード自体をどう処理すべきかの原則やガイドラインを策定している
  5. ISO 639-5: 絶滅した言語を含め、語族の名前をコードで表している

最後に

だいぶ整理ができたので、海外も対象にしたサービスを検討しやすくなりました🙌

Airtable の概要と基本的な使い方のざっくりまとめ

Airtable とは

ブラウザで使えるクラウド型のデータベースツールで、ユニコーン企業のAirtable社 が提供しています。
基本利用だと無料で使えるので、個人でちょこちょこいじる分にはコストはかからないと思います。
ノーコードサービスの中でも大変有名・有力なものの一つとなっています。

どういうツールかざっくり言うと、
表計算は苦手だけど、リレーショナルデータベースのテーブルを簡単に組み合わせることができ、
様々な見せ方ができ、それらの操作を簡単にできる

という感じのツールとなっています!

もうちょい分解して言うとこんな感じです。

  1. エクセルのように表を作れる
  2. 入力値の「型」としてテキストはもちろん、画像やチェックボックスなど色々指定できる
  3. 他テーブルとのリレーションが簡単に作成できる
  4. フィルタリング、グルーピング、ソートが簡単に操作できる
  5. 表の見せ方(グリッドやカンバンなど)を簡単に変更できる

この辺も含めて、ツールの概要や基本的な使い方を紹介していきます!

ツールサイトの全体像

https://airtable.com/

トップページはこれ↑
ヘッダーに以下のようなリンクがあります。

Airtable のヘッダ部分
  1. Bases: トップページ。ここに自分の作成したテーブルなどがあります
  2. Templates: テーブルのテンプレートがたくさんおいて置いてあります。ここのテンプレートからテーブルを作ることもできます。Project TrackerBug tracker といったカッチリしたものから Favorite Movies など趣味的なものまで幅広く用意されています。見てるだけでも楽しい感じです。
  3. Marketplace: ここからプラグインを各テーブルにインストールして、よりリッチな見せ方ができるようになります。基本無料のものばかりかな?🤔 スクリプトもあるので、自分の好きな機能を追加することもできそうですね!
  4. Universe: 他の人が作ったAirtable のデータがたくさん公開されています。これも見てるだけでもなんか楽しいです🙌

色々見れて楽しいなと思ったところで、これ以降テーブルの作成・編集と見せ方の変更を書いていきます!

とりあえず表を作る

トップページ のMy First Workspace のところから、以下のように「+」ボタンをクリックします。
そのあとテンプレートから作るか、データをインポートするか、何もない状態から作るか選択肢が出てきます。
今回は何もない状態から作ってみるので、一番下をクリックします。

「+」ボタンをクリック

すると、以下のようなバルーンが表示されます。

ベースのタイトルなどを編集

Base の名前や、テーマカラー、アイコンの編集ができます。
このあたりはいつでも編集できるのでまぁ適当でよさそうです。

ちなみにBase とは複数のテーブルを一かたまりにする入れ物で、エクセルでいうブックのようなものです。

で、作ったBase のアイコンをクリックすると以下のような画面になります。

Base の初期画面

表っぽい画面が現れました。
最初にチュートリアルが表示されるのでそれに従えば初歩的な操作は分かるかなと思います。

次に何かしら項目を入力していきます。

表に項目を入力する

これ以降は最終的にこういうやつを作っていきます。

他テーブルとのリレーションがあるテーブル

まず最初に、Notes、Attachments、Status は使わないので消します。

Notes、Attachments、Status は使わないので消す

以下のように「Notes」の右側にある▼マークをクリックします。
するとメニューが出てくるので、一番下の「Delete Field」を選択します。

Notes を消す

するとNotes の列が消えます。
続いて Attachements、Status も同様に消します。

すっきりしたので、項目を入力していきます!

以下のあたりをクリックします。

するとテキスト入力可能な状態になるので入力していきます。

で、フィールドの名前が「Name」のままはアレなのでリネームします。
以下のように「Name」付近をダブルクリックします。

Name をダブルクリック

すると以下のように編集画面になるので「食品名」と編集します。

Name を編集する

Save をクリックして終了です。

項目を入力する方法はこれでマスターできた感あります!
次に、フィールドの型を指定していきます。

フィールドにテキスト以外の型を指定する

上記では「食品名」フィールドにテキストを入力しましたが、Airtable では様々な型を指定できます。

以下のようにフィールド名右端の「+」ボタンを押すと、
フィールド名とそこに入力可能な型を指定し、新しいフィールドを定義できます。

新しいフィールドを定義する

色々あるので見てください!
ちなみに画像を入力するには「Attachment」を選択します。

他のテーブルとのリレーションを作る

いよいよリレーショナルDBらしいことをしていきます。
肉類に色々な肉の種類の紐付けなどをしていきたいと思います。

まず他のテーブルを作ります。

以下のようにテーブル名の横の「+」ボタンから「Create empty table」をクリックします。

新しいテーブルを作成

テーブル名を入力して、以下のようなテーブルを作りました。
(ついでに左のテーブル名を「Table 1」 から「食品類」に変更しています)

食材テーブルを作った

で食品類テーブルに戻り、他のテーブルとのリレーションを持つ新規フィールドを追加します。

他のテーブルとのリレーションを作成

どのテーブルとのリレーションにするかを選択します。

リレーションを作成するテーブルを選択

クリックするとさらに2つのスイッチが現れます。

上のスイッチをオンにすると、そのフィールドは複数のレコードを紐づけられるようになります。

下のスイッチについてはちょっと説明が難しいので公式ブログを参照 してみてください。
フィールドの選択可否をビューによって切り替えられる機能のようです。

で、次にこの画面が出てきますが今回はスキップします。

ルックアップフィールドを追加するか

どのフィールドを持ってくるかを調整できる機能です。
分かりづらいので選択して試してみてください!

具体的なリレーションを埋めていきます!

「肉類」の右側のフィールドをクリックして編集状態にします。
すると以下のように「+」ボタンが出てきます。

「+」ボタンをクリックすると、以下のように「食材」テーブルのレコード一覧が出てきます!

「食材」テーブルのレコード一覧

肉類なので、鶏肉、豚肉、牛肉をクリック!(複数選択はできないっぽい)
すると以下のようになりました。

肉類が複数追加されている

ちなみに、このとき食材テーブルを見てみると、こちらも動的にリレーションが生成されています。

動的にリレーションが生成されている

これで他テーブルとのリレーションが作成できました!

リレーション完成

フィルタリングやグルーピングやソート

以下の赤枠のあたりをクリックすると可能です。

フィルタリングやグルーピングやソート

このあたりは直感的にできると思うので説明は省略します!
グルーピングなんかはダイナミックに動いておもしろいです。

グルーピングの例

グリッドやカンバンなど見せ方を変える

これはAirtable の大きな特徴の一つかなと思います。
これまではグリッド(格子状)の見た目でデータベースを表示・編集していましたが、
それの見せ方を大幅に・簡単に変えることができます!

まずは以下のあたりをクリックします。

View を表示する

すると左からview のパネルが出てきます。

下の方のCreate a view をクリックすると新しいview を作成できます。

例えばCalendarをクリックしてみると以下のようになります。

カレンダーをクリック直後

フィールドに関する設定を何やかんやした後、29日は肉の日なのでドラッグ&ドロップしてみます!

29日は肉の日

グリッドビューを見てみると、肉類は29日になりました!🤘

グリッドビューでも29日は肉の日

次に例えばカンバン。

こんな感じで野菜はDoing、果物はDone にできます!

野菜ing、果物ed

グリッドビューでも、野菜ing、果物ed になっています!🤔

以上で基本的な世界観は把握できたかなと思います。

最後に

データベースを非常に柔軟に加工できるWebツールですね。
大体の部分はクリックするかダブルクリックすると、編集や閲覧ができるようになっています。

今回は初歩的なところの紹介となりましたが、APIScript も提供されており、
もっとできることはあるので機会があればチャレンジしてみようと思います & してみてください!

以上です。
お疲れ様でした!

Mac で npm コマンド実行時にエラーが出た場合の対処法

エラー内容

$ npm install csvtojson
module.js:549
    throw err;
    ^

Error: Cannot find module '../lib/utils/unsupported.js'
    at Function.Module._resolveFilename (module.js:547:15)
    at Function.Module._load (module.js:474:25)
    at Module.require (module.js:596:17)
    at require (internal/module.js:11:18)
    at /usr/local/lib/node_modules/npm/bin/npm-cli.js:19:21
    at Object.<anonymous> (/usr/local/lib/node_modules/npm/bin/npm-cli.js:155:3)
    at Module._compile (module.js:652:30)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)

インストールすらできない😂

nvm のバージョンを確認してみる

$ node -v
v8.11.3

古い!

Node を入れ直す

ということで以前の記事 Mac で Node.js をアップデート でも書いた以下のコマンドでNode を入れ直します。

nvm install --lts --latest-npm

で、バージョンを確認すると最新になったようです。

$ node -v
v14.15.4

npmコマンドも正常に実行されるようになりました🙌

最後に

よかった!

[iOS] UIViewControllerTransitioningDelegate と UIViewControllerAnimatedTransitioning でいい感じにフルスクリーン表示する

前説

今回は UIViewControllerTransitioningDelegateUIViewControllerAnimatedTransitioning
使い方についてまとめていきます。

前回の記事 でいい感じに動画コンテンツみたくインライン表示、フルスクリーン表示を切り替えることができました。

ViewController を切り替えていい感じにフルスクリーン表示する

しかし、フルスクリーン用VCが遷移周りの処理も請け負ってしまい 神クラスっぽくなってしまいました。
やばそうな雰囲気を感じるので、遷移に関する責務を分離します!

前提条件

Xcode: 12.1
シミュレータ: iOS14系

というわけで早速作っていきます。

主な登場クラス

今回以下の4つになります。

  1. インライン用VC: 今回遷移のVC
  2. フルスクリーン用VC: 今回遷移のVC
  3. UIViewControllerTransitioningDelegate: 遷移時にどの UIViewControllerAnimatedTransitioning (↓これ) を利用するかを定義する
  4. UIViewControllerAnimatedTransitioning: 遷移時の具体的なアニメーションを定義する

Transitioning ほげほげクラスは何かややこしくて最初めっちゃ混乱しがちですが、実装が必要なクラスは
UIViewControllerTransitioningDelegateUIViewControllerAnimatedTransitioning の2種類のみです🙌

画面構成

画面構成

流れをシーケンス図で表示するとこうなる

インライン -> フルスクリーン

インライン -> フルスクリーン

フルスクリーン -> インライン

フルスクリーン -> インライン

上図の絵の列の「コンテナビュー」は、遷移時にiOSが自動で生成するUIView です(後述)

コードの概要

まず遷移前後における、各クラスの役割と内容をざっくり書きます。
役割がざっくり分かれば Transitioning 系の使い方をイメージできると思います!

1.インライン用VC

        // フルスクリーン用VCのインスタンス生成
        let sampleFullScreenVC = SampleFullScreenVC()
        
        // 遷移時のデリゲートとして、独自定義したクラスをセット
        sampleFullScreenVC.transitioningDelegate = SampleTransitioningDelegate()
        
        // 表示。
        // 上記デリゲートでセットした遷移動作をさせたいのでanimated は true にする必要アリ。
        present(sampleFullScreenVC, animated: true)

2.フルスクリーン用VC

遷移のみに話を絞れば、特に何も書く必要がありません。

dismiss するときに animated: true を忘れないように、くらいです。

3.UIViewControllerTransitioningDelegate

VC を present するとき、dismiss するときにどの
UIViewControllerAnimatedTransitioning を使うかを決める。だけ。

1.インライン用VCのコード内で、フルスクリーン用VCの遷移時のデリゲートにセットしたのがこいつです。

class SampleTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
    // present するときに使うUIViewControllerAnimatedTransitioning 
    func animationController(
        forPresented presented: UIViewController,
        presenting: UIViewController,
        source: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        return SamplePresentAnimatedTransition()
    }
    
    // dismiss するときに使うUIViewControllerAnimatedTransitioning 
    func animationController(
        forDismissed dismissed: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        return SampleDismissAnimatedTransition()
    }
}

4.UIViewControllerAnimatedTransitioning

遷移時のアニメーションなどを記述していくクラス。

上の UIViewControllerAnimatedTransitioning のコードにて、
present や dismiss するときにこれ使うよ〜と返却したのがこいつです。

class SamplePresentAnimatedTransition: NSObject, UIViewControllerAnimatedTransitioning {
    // 遷移の秒数を定義。正直これは無視できるので、定義されてるな〜程度に。
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.5
    }

    // 遷移の具体的な動きをここに書いていく。遷移時にOS側から呼ばれるデリゲートメソッドです。
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // 略
        
        // 遷移が完了したことを知らせるメソッドを最後に必ず呼ぶ(後述)
        transitionContext.completeTransition(true)
    }
}

以上4つです。
何となくイメージできたでしょうか。
再掲。

  1. インライン用VC: 今回遷移前のVC
  2. フルスクリーン用VC: 今回遷移後のVC
  3. UIViewControllerTransitioningDelegate: 遷移時にどの UIViewControllerAnimatedTransitioning (↓これ) を利用するかを定義する
  4. UIViewControllerAnimatedTransitioning: 遷移時の具体的なアニメーションを定義する

ではこれ以降、今回の実装に入っていきます!

フルスクリーン表示する

1.透明のフルスクリーン用VCをpresent する

インライン用VCにて以下のコードを書きます。

        let sampleFullScreenVC = SampleFullScreenVC()
        sampleFullScreenVC.view.backgroundColor = .clear
        
        if let transition = SampleTransitioningDelegate() {
            sampleFullScreenVC.transitioningDelegate = transition
        }
        
        present(sampleFullScreenVC, animated: true)

インライン用VCのコードは以上です
以降は Transitioning 系やフルスクリーン用VCのコードのみになります。

2.UIViewControllerTransitioningDelegate を定義する

上述のコードまんまですが。

class SampleTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
    func animationController(
        forPresented presented: UIViewController,
        presenting: UIViewController,
        source: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        return SamplePresentAnimatedTransition()
    }
}

present のコードのみ書き、いまはdismiss を省略しています。
UIViewControllerTransitioningDelegate のコードもこれで以上です。

3.フルスクリーン用VCに見た目が変わらない位置に要素を配置する

ここが今回の肝で、遷移の具体的な動きを書いていきます。
UIViewControllerAnimatedTransitioning プロトコルに準拠した SamplePresentAnimatedTransition です。

長くなるので分割して書きます。
(最後のコードまとめにまとめて載せています)

まず、クラス宣言と遷移時間を定義します。

class SamplePresentAnimatedTransition: NSObject, UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.5
    }

適当に0.5秒にしました。

次に、遷移アニメーションを記述していきます。
まず最初に、遷移前のVC(インライン用VC) と 遷移後のVC(フルスクリーン用VC) を取り出します。

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let from = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) as? UINavigationController,
              let playerVC = from.topViewController as? SamplePlayerAndCollectionVC,
              let to = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) as? SampleFullScreenVC else {
            transitionContext.completeTransition(false)
            return
        }

今回インラインVC は UINavigationController 配下にあったので、
UITransitionContextViewControllerKey.from で取得できるのは UINavigationController となっています。
UINavigationController を使っていない場合はもちろん .from で直接遷移元のVC を取得できます。

次に、遷移時のコンテナビューに、フルスクリーン用VCのview を貼り付けます。

        let containerView = transitionContext.containerView
        containerView.addSubview(to.view)

唐突に出てきたコンテナビューて何やねん感🤔

コンテナビューとは、遷移時にiOSが自動で生成するUIViewです。
今回で言うと、下図のようにインラインVCの上に入ります。

フルスクリーンVCのview をaddSubview した後のview hierarchy

では、このコンテナビューをどのように利用していくかですが、
遷移先のview を addSubview する以外は特に何も操作する必要はなさそうです。
遷移のビューなので、遷移中にコンテナビューにアニメーション用の要素を貼ってリッチに動かす、
的なこともしなくてよさそうです。

僕が試したところ、コンテナビュー自体の背景色をフェードインで黒くしようと思ったのですが、
いきなり黒になってしまい、うまくアニメーションしませんでした。
なので、フルスクリーン用VCの要素を操作してフェードインなどさせています🙌


さて次に、フルスクリーン用VCの上に、見た目が変わらない位置に要素を配置します。

        guard let targetView = playerVC.targetView,
              let originalSuperviewOfTargetView = targetView.superview else {
            transitionContext.completeTransition(false)
            return
        }
        
        let initialTargetViewFrame = to.view.convert(
            targetView.frame,
            from: targetView.superview
        )
        
        // この setup ではdismiss 時に使うためにフルスクリーン用VCのプロパティに保持しているだけ
        to.setup(
            targetView: targetView,
            originalSuperviewOfTargetView: originalSuperviewOfTargetView,
            initialTargetViewFrame: initialTargetViewFrame
        )
        
        let containerView = transitionContext.containerView
        containerView.addSubview(to.view)

        to.view.addSubview(targetView)
        targetView.frame = initialTargetViewFrame

このあたりの動きは前回の記事 1.透明のフルスクリーン用VCを表示し、見た目が変わらない位置に要素を配置する
でもう少し詳しく説明しています。

4.背景を黒くし、遷移完了のメソッドを呼ぶ

        UIView.animate(
            withDuration: transitionDuration(using: transitionContext),
            animations: {
                to.view.backgroundColor = .black
            },
            completion: { _ in
                // 遷移が完了したタイミングで必ず呼ぶ
                transitionContext.completeTransition(true)

                // 端末を回転させる
                to.rotateToLandscape()
            }
        )

アニメーションで背景を黒くしたあとで
遷移完了を知らせるメソッド transitionContext.completeTransition(_ didComplete: Bool) を呼んでいます。

これは遷移が完了したタイミングで必ず呼びます。
呼ばない場合、以降も遷移中のままとしてiOSに扱われ、遷移中に実行できる挙動しか受け付けなくなりそうです。

上の例で言うと、それを呼んだあとで端末を回転させています。
試したところ、完了メソッドを呼ぶ前に端末回転のコードを書いても、呼んだ後で回転しました。
遷移中は画面回転が許可されていないと思われ、
つまり完了メソッドを呼び忘れた場合、それ以降画面回転が効かなくなりそうです。

画面回転は「遷移」の範囲外の挙動なのでしょうね🤔

これで UIViewControllerAnimatedTransitioning の実装は完了です。

5.全画面表示にする

    func rotateToLandscape() {
        UIDevice.current.setValue(
            UIInterfaceOrientation.landscapeRight.rawValue,
            forKey: "orientation"
        )
        
        UIView.animate(
            withDuration: 0.5,
            animations: {
                if let superviewFrame = self.targetView?.superview?.frame {
                    self.targetView?.frame = CGRect(
                        x: 0.0,
                        y: 0.0,
                        width: superviewFrame.width,
                        height: superviewFrame.height
                    )
                }
                self.view.layoutIfNeeded()
            }
        )
    }

フルスクリーン用VCに書きます。
こちらも前回の記事の 3.画面を強制的に横向きにしつつ、要素を全画面に表示する
もう少しだけ詳しく説明しています。

以上でインラインVCからフルスクリーンVCへの遷移は完了です!

dismiss に関しては present の逆回しに考えていけばよいだけなので、
(本ポストも長くなってしまったので)割愛します。

以下にコードまとめを載せておきます。
dismiss の分も載せています。

コードまとめ

1.インライン用VC

class SamplePlayerAndCollectionVC: UIViewController {
    override var shouldAutorotate: Bool {
        return false
    }
    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .portrait
    }

    func openFullScreen() {
        let sampleFullScreenVC = self.storyboard?.instantiateViewController(withIdentifier: "SampleFullScreenVC") as! SampleFullScreenVC
        sampleFullScreenVC.view.backgroundColor = .clear
        sampleFullScreenVC.transitioningDelegate = SampleTransitioningDelegate()
        
        present(sampleFullScreenVC, animated: true)
    }
}

2.フルスクリーン用VC

class SampleFullScreenVC: UIViewController {
    private(set) var targetView: UIView?
    private(set) var originalSuperviewOfTargetView: UIView?
    private(set) var initialTargetViewFrame: CGRect?
    
    override var shouldAutorotate: Bool {
        return true
    }
    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .allButUpsideDown
    }
    
    func setup(
        targetView: UIView?,
        originalSuperviewOfTargetView: UIView?,
        initialTargetViewFrame: CGRect?
    ) {
        self.targetView = targetView
        self.originalSuperviewOfTargetView = originalSuperviewOfTargetView
        self.initialTargetViewFrame = initialTargetViewFrame
    }
    
    func rotateToLandscape() {
        UIDevice.current.setValue(
            UIInterfaceOrientation.landscapeRight.rawValue,
            forKey: "orientation"
        )
        
        UIView.animate(
            withDuration: 0.5,
            animations: {
                if let superviewFrame = self.targetView?.superview?.frame {
                    self.targetView?.frame = CGRect(
                        x: 0.0,
                        y: 0.0,
                        width: superviewFrame.width,
                        height: superviewFrame.height
                    )
                }
                self.view.layoutIfNeeded()
            }
        )
    }
    
    func rotateToPortrait(
        completion: (() -> ())?
    ) {
        UIDevice.current.setValue(
            UIInterfaceOrientation.portrait.rawValue,
            forKey: "orientation"
        )
        
        UIView.animate(
            withDuration: 0.5,
            animations: {
                if let initialTargetViewFrame = self.initialTargetViewFrame {
                    self.targetView?.frame = CGRect(
                        x: initialTargetViewFrame.minX,
                        y: initialTargetViewFrame.minY,
                        width: initialTargetViewFrame.width,
                        height: initialTargetViewFrame.height
                    )
                }
                self.view.layoutIfNeeded()
            },
            completion: { _ in
                completion?()
            }
        )
    }
    
    // フルスクリーンをdismiss するメソッド。どこからか呼ぶ必要あり
    func animateToClose() {
        rotateToPortrait { [weak self] in
            self?.dismiss(animated: true)
        }
    }
}

3.UIViewControllerTransitioningDelegate

class SampleTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
    func animationController(
        forPresented presented: UIViewController,
        presenting: UIViewController,
        source: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        return SamplePresentAnimatedTransition()
    }
    
    func animationController(
        forDismissed dismissed: UIViewController
    ) -> UIViewControllerAnimatedTransitioning? {
        return SampleDismissAnimatedTransition()
    }
}

4.UIViewControllerAnimatedTransitioning

class SamplePresentAnimatedTransition: NSObject, UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.5
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        
        guard let from = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) as? UINavigationController,
              let playerVC = from.topViewController as? SamplePlayerAndCollectionVC,
              let to = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) as? SampleFullScreenVC else {
            transitionContext.completeTransition(false)
            return
        }
        
        let containerView = transitionContext.containerView
        containerView.addSubview(to.view)
        
        guard let targetView = playerVC.targetView,
              let originalSuperviewOfTargetView = targetView.superview else {
            transitionContext.completeTransition(false)
            return
        }
        
        let initialTargetViewFrame = to.view.convert(
            targetView.frame,
            from: targetView.superview
        )
        
        to.setup(
            targetView: targetView,
            originalSuperviewOfTargetView: originalSuperviewOfTargetView,
            initialTargetViewFrame: initialTargetViewFrame
        )
        
        to.view.addSubview(targetView)
        targetView.frame = initialTargetViewFrame
        
        UIView.animate(
            withDuration: transitionDuration(using: transitionContext),
            animations: {
                to.view.backgroundColor = .black
            },
            completion: { _ in
                transitionContext.completeTransition(true)
                to.rotateToLandscape()
            }
        )
    }
}

class SampleDismissAnimatedTransition: NSObject, UIViewControllerAnimatedTransitioning {
    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.5
    }
    
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        guard let from = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) as? SampleFullScreenVC else {
            transitionContext.completeTransition(false)
            return
        }
        
        UIView.animate(
            withDuration: transitionDuration(using: transitionContext),
            animations: {
                from.view.backgroundColor = .clear
            },
            completion: { _ in
                if let targetView = from.targetView {
                    from.originalSuperviewOfTargetView?.addSubview(targetView)

                    if let initialTargetViewFrame = from.initialTargetViewFrame {
                        let frame = from.view.convert(
                            initialTargetViewFrame,
                            to: targetView.superview
                        )
                        targetView.frame = frame
                    }
                }
                
                transitionContext.completeTransition(true)
            }
        )
    }
}

最後に

長くなってしまいましたが以上です。

お疲れ様でした!

[iOS] ViewController を切り替えていい感じにフルスクリーン表示する

前説

こんな感じでいい感じに動画コンテンツみたくインライン表示、フルスクリーン表示を切り替えたい。

ViewController を切り替えていい感じにフルスクリーン表示する

前提条件

Xcode: 12.1
シミュレータ: iOS14系

というわけで作っていきます。

画面構成

画面構成

流れをシーケンス図で表示するとこうなる

インライン -> フルスクリーン

インライン -> フルスクリーン

フルスクリーン -> インライン

フルスクリーン -> インライン

前準備1. それぞれのVCの回転制御について

今回の前提として、インライン用VCは回転不可で縦方向のみサポート。

    override var shouldAutorotate: Bool {
        return false
    }
    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .portrait
    }

フルスクリーン用VCは回転可能で縦・横両方サポートとします。

    override var shouldAutorotate: Bool {
        return true
    }
    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .allButUpsideDown
    }

前準備2. インライン用VCにおける、対象の要素についてのView構成

今回はインライン用VCにある要素を、そのままフルスクリーン用VCに渡して全画面表示にします。
なのでフルスクリーン表示時には、インライン用VCからその要素がなくなっている状態のですが、
その間も画面が崩れないようにする必要があります。

そのため、インライン用VCにおいては、対象の要素の親のUIView を一つ作り、
表示崩れしないように領域を確保しておきます。

インライン用VCのビューヒエラルキー

では以下からいい感じにフルスクリーン表示する処理を記述していきます!
段階を追ってコードを細切れに書いていますが、最下部には必要なコードをまとめています。

フルスクリーン表示にするとき

1.透明のフルスクリーン用VCを表示し、見た目が変わらない位置に要素を配置する

まずインライン 表示用VCにてタップイベントなど適当なイベントのハンドラを作り、
そこをフルスクリーン表示用処理の入り口とします。
ここは今回の内容とは関係ないので割愛します。

処理を開始したらまず透明のフルスクリーン用VCを作成します。

        let sampleFullScreenVC = self.storyboard?.instantiateViewController(withIdentifier: "SampleFullScreenVC") as! SampleFullScreenVC
        sampleFullScreenVC.view.backgroundColor = .clear

フルスクリーン用VCに要素を渡し、後の位置調整やアニメーションの処理はそちらのVCで行うことにします。
フルスクリーン用VCに func animateToFullScreen(_ targetView: UIView) というメソッドを用意しました。

        present(sampleFullScreenVC, animated: false) { [weak self] in
            guard let self = self else {
                return
            }
            
            if let view = self.targetView {
                sampleFullScreenVC.animateToFullScreen(view)
            }
        }

インライン用VCで必要なコードは以上です。
これ以降インライン -> フルスクリーン、フルスクリーン -> インライン の処理はすべて
フルスクリーン用VCに書いていきます。

さて、インライン用VCから要素が渡されたので、まずは見た目上の位置が変わらないよう
フルスクリーン用VC に貼り付けます。

    func animateToFullScreen(_ targetView: UIView) {
        // フルスクリーン -> インライン に戻るときに操作するので、要素をプロパティに保持する
        self.targetView = targetView
        
        // フルスクリーン -> インライン で元いたView に貼り戻すため、元々の親View をプロパティに保持する
        self.originalSuperviewOfTargetView = targetView.superview
        
        // 貼り付けたときの見た目上の位置が変わらないよう、frame を変換する
        // また、フルスクリーン -> インライン で元の位置に戻すのに必要なため、
        // 最初の位置を frame としてプロパティに保持しておく
        self.initialTargetViewFrame = self.view.convert(
            targetView.frame,
            from: targetView.superview
        )
        if let frame = self.initialTargetViewFrame {
            targetView.frame = frame
        }
            
        self.view.addSubview(targetView)

上のコメントにもあるようにフルスクリーン -> インラインに戻るときに、
元の位置や、貼り戻す親のUIView が必要なため、以下のようにプロパティを用意します。

    private var targetView: UIView?
    private var originalSuperviewOfTargetView: UIView?
    private var initialTargetViewFrame: CGRect?

以上で、見た目は変わらないままで、要素をインラインVCからフルスクリーンVCに引き渡せました。

2.フルスクリーン用VCの背景色を黒くする

いまの段階ではフルスクリーン用VCの背景色が透明なため、
奥にあるインライン用VCが透けて見えています。
なので背景色を黒くするのですが、急に真っ暗になるとびっくりするので
アニメーションで徐々に黒くしていきます。

        UIView.animate(
            withDuration: 0.5,
            animations: {
                self.view.backgroundColor = .black
            },
            completion: { _ in
                // この後、このcompletion 内で画面横向き & 全画面表示の処理をします
            }
        )

上のコードでは背景色が黒くなるのを待ってから、横向き & 全画面表示の処理を実行しようとしています。
なぜかと言うと、背景色を変えるのと同時に横向きにすると、
奥にいるインラインVCも横向きになってしまうのが少し透けて見えてしまうからです。

以下のように見えてしまい、かっこ悪いですね!

背景が崩れて見えてかっこ悪い

3.画面を強制的に横向きにしつつ、要素を全画面に表示する

あとは以下のコードを、上記UIView.animate メソッドの completion 内に書けばOKです。

                // 端末を強制的に横向きにする
                UIDevice.current.setValue(
                    UIInterfaceOrientation.landscapeRight.rawValue,
                    forKey: "orientation"
                )
                
                // 要素を全画面にする
                UIView.animate(
                    withDuration: 0.5,
                    animations: {
                        if let superviewFrame = targetView.superview?.frame {
                            targetView.frame = CGRect(
                                x: 0.0,
                                y: 0.0,
                                width: superviewFrame.width,
                                height: superviewFrame.height
                            )
                        }
                        
                        self.view.layoutIfNeeded()
                    }
                )

以上でインライン表示 -> フルスクリーン表示の遷移は完了です!

一点注意として、要素に対してframe で位置や大きさを指定していますが、
これだと回転させると表示崩れを起こしてしまうかもしれません。
なのでAutoLayout で指定した方がいいとは思いますが、今回の主旨とは関係ないので割愛しています。

以降フルスクリーン表示 -> インライン表示に戻る時の処理も書きますが、
ここで書いた処理を逆にすればよいだけです。

インライン表示に戻るとき

1.画面を強制的に縦向きに戻しつつ、見た目上の元の位置に要素を戻す

以下のコードを書きます。

        // 端末を強制的に縦向きに戻す
        UIDevice.current.setValue(
            UIInterfaceOrientation.portrait.rawValue,
            forKey: "orientation"
        )
        
        // 見た目上の元の位置に要素を戻す
        UIView.animate(
            withDuration: 0.5,
            animations: {
                if let initialTargetViewFrame = self.initialTargetViewFrame {
                    self.targetView?.frame = CGRect(
                        x: initialTargetViewFrame.minX,
                        y: initialTargetViewFrame.minY,
                        width: initialTargetViewFrame.width,
                        height: initialTargetViewFrame.height
                    )
                }
                self.view.layoutIfNeeded()
            },
            completion: { _ in
                // この後、このcompletion 内でフルスクリーン用VCの背景色を透明にします
            }
        )

元の位置に戻すために、フルスクリーン表示時の最初に保存しておいた frame を利用しています。

2.フルスクリーン用VCの背景色を透明にする

以下のコードを書きます。
特筆することはありません。

                UIView.animate(
                    withDuration: 0.5,
                    animations: { [weak self] in
                        self?.view.backgroundColor = .clear
                    },
                    completion: { _ in
                        // この後、このcompletion 内で要素をインライン用VCに貼り直し、フルスクリーン用VCを消します
                    }
                )

3.要素をインライン用VCに貼り直し、フルスクリーン用VCを消す

以下のコードを書きます。
こちらも特筆することはありません。

                        if let targetView = self.targetView {
                            // インライン用VCの元いた親View に貼り戻す
                            self.originalSuperviewOfTargetView?.addSubview(targetView)

                            // 貼り付けたときの見た目上の位置が変わらないよう、frame を変換する
                            if let initialTargetViewFrame = self.initialTargetViewFrame {
                                let frame = self.view.convert(
                                    initialTargetViewFrame,
                                    to: targetView.superview
                                )
                                targetView.frame = frame
                            }
                        }
                        
                        self.dismiss(animated: false)

以上でできました!

コードまとめ

インライン用VC

class SamplePlayerAndCollectionVC: UIViewController {
    override var shouldAutorotate: Bool {
        return false
    }
    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .portrait
    }

    func openFullScreen() {
        let sampleFullScreenVC = self.storyboard?.instantiateViewController(withIdentifier: "SampleFullScreenVC") as! SampleFullScreenVC
        sampleFullScreenVC.view.backgroundColor = .clear
        
        present(sampleFullScreenVC, animated: false) { [weak self] in
            guard let self = self else {
                return
            }
            
            if let view = self.targetView {
                sampleFullScreenVC.animateToFullScreen(view)
            }
        }
    }
}

フルスクリーン用VC

class SampleFullScreenVC: UIViewController {
    private var targetView: UIView?
    private var originalSuperviewOfTargetView: UIView?
    private var initialTargetViewFrame: CGRect?
    
    override var shouldAutorotate: Bool {
        return true
    }
    override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
        return .allButUpsideDown
    }
    
    // インライン -> フルスクリーン
    func animateToFullScreen(_ targetView: UIView) {
        self.targetView = targetView
        self.originalSuperviewOfTargetView = targetView.superview
        self.initialTargetViewFrame = self.view.convert(
            targetView.frame,
            from: targetView.superview
        )
        if let frame = self.initialTargetViewFrame {
            targetView.frame = frame
        }
            
        self.view.addSubview(targetView)
        
        UIView.animate(
            withDuration: 0.5,
            animations: {
                self.view.backgroundColor = .black
            },
            completion: { _ in
                UIDevice.current.setValue(
                    UIInterfaceOrientation.landscapeRight.rawValue,
                    forKey: "orientation"
                )

                UIView.animate(
                    withDuration: 0.5,
                    animations: {
                        if let superviewFrame = targetView.superview?.frame {
                            targetView.frame = CGRect(
                                x: 0.0,
                                y: 0.0,
                                width: superviewFrame.width,
                                height: superviewFrame.height
                            )
                        }
                        self.view.layoutIfNeeded()
                    }
                )
            }
        )
    }
    
    // フルスクリーン -> インライン
    func animateToInline() {
        UIDevice.current.setValue(
            UIInterfaceOrientation.portrait.rawValue,
            forKey: "orientation"
        )
        
        UIView.animate(
            withDuration: 0.5,
            animations: {
                if let initialTargetViewFrame = self.initialTargetViewFrame {
                    self.targetView?.frame = CGRect(
                        x: initialTargetViewFrame.minX,
                        y: initialTargetViewFrame.minY,
                        width: initialTargetViewFrame.width,
                        height: initialTargetViewFrame.height
                    )
                }
                self.view.layoutIfNeeded()
            },
            completion: { _ in
                UIView.animate(
                    withDuration: 0.5,
                    animations: { [weak self] in
                        self?.view.backgroundColor = .clear
                    },
                    completion: { _ in
                        if let targetView = self.targetView {
                            self.originalSuperviewOfTargetView?.addSubview(targetView)
                            
                            if let initialTargetViewFrame = self.initialTargetViewFrame {
                                let frame = self.view.convert(
                                    initialTargetViewFrame,
                                    to: targetView.superview
                                )
                                targetView.frame = frame
                            }
                        }
                        
                        self.dismiss(animated: false)
                    }
                )
            }
        )
    }
}

最後に

長くなってしまいましたが、段階を追っていけばシンプルな手順かなと思います。

以上です。お疲れ様でした!

[iOS] UICollectionView でセルの領域外にはみ出る要素でいい感じにタップ検知したい

前説

以下動画のように、セルからはみ出した赤い要素でいい感じにタップ検知したい。

はみ出した赤い要素をタップすると青くなる

hitTest(_:with:) というメソッドの使い方と、それが呼ばれる順番がキモになります。

前提条件

Xcode: 12.1
シミュレータ: iOS14系

UICollectionView でセルの領域外にはみ出る要素を配置する

セルに対してUIView.layer.zPosition と UIView.layer.masksToBounds を設定すればサクッとできます。

本ブログの [iOS] UICollectionView でセルの領域外にはみ出る要素を配置する
にも方法を書いています。

以降、はみ出る要素でいい感じにタップ検知していきます。
hitTest(_:with:) を設定していきます!

hitTest の挙動についてざっくりと把握

というわけでhitTest(_:with:) を調整していきますが、その前に以下の画像でピンと来なかった場合、
これ以降の内容は分かりづらいかなと思います。
そのときはまずhtTest メソッドの使い所について調べるなり、
本ブログの [iOS] hitTest(_:with:) の挙動の概要 をサラッと見てもらった方がよさそうです。

まず、hitTest を調整しない場合の挙動

まずhitTest を調整しないで、領域からはみ出るView で
touchesEnded(_:with:) などでタップイベントを検知してみます。
すると以下のような挙動になります。
(赤いView は28のセルではなく30のセルにaddSubview されています。)

はみ出したView がうまくタップ検知されない

ぱっと見よく分からない感じになってますが、hitTest の挙動を考えるとしっくりきます。

青枠の領域をタップしたとき。
hitTest が向かって奥のViewから再帰的にコールされます。
で、28のセルまで処理がきたとき、28の子ビューは存在しない(つまり、はみ出した赤いView も存在しない)ため、
hitTest の再起処理は 28のセルで終わります。
結果、28のセルが最上位の要素だと判定されてしまうのです。

緑枠の領域をタップしたときも同様の考えです。
hitTest の処理が30のセルまできたとき、subview にはみ出した赤いView がいるため、
そちらまで再帰処理が及びます。
結果、はみ出したView が最上位の要素だと判定されます。

というわけでtestTest の調整をしていきます。

hitTest 調整の方針について

ではどうすれば、はみ出した赤いView 全体をタップ領域と判定させられるか。

30のセルのhitTest の処理を書き換え、
はみ出した赤いViewの領域がタップされたときは、そちらのhitTest を呼ぶようにすればOKです!

ここで疑問が。
セルからはみ出した部分はそもそも、例えば28のセルのhitTest が呼ばれるだけで
30のセルのhitTestは呼ばれないのでは?

そんなことはなくて、実はhitTest はCollectionView においては、
表示されているセル全てに対して順番に走ります。
そして最初にUIView を返却した要素を、タップされたと判定するわけです。

方針が決まったのであとは実装するだけです!

hitTest を調整する

まずはみ出たViewを以下のようなクラスとして定義しておきます。
タップ検知するために、UIControl の子クラスにします。

class TappableControl: UIControl {
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
    }
}

で、このTappableControl を適当なセルにaddSubview しています。

次にいよいよ、セルのhitTest を以下のように調整します。

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    for subview in contentView.subviews as [UIView] {
        if subview is TappableControl
            && subview.point(inside: convert(point, to: subview), with: event) {
                // タップされたポイントがはみ出た要素の領域内であれば、その要素を返却する
                return subview
        }
    }
    return super.hitTest(point, with: event)
} 

これで、いい感じにタップが検知できるようになりました!

全ての領域でタップされたと判定可能

完成!

と言いたいところですが、これで終わりではありません!🤧
実はまだタップ領域の判定は不安定です。

どういうことかというと。。
以下分かりづらいですが、2回タップしており、どちらもはみ出たView をタップしています。
1回目は反応して青くなりましたが、2回目はその下の28セルが青くなってしまっています。
つまり、タップ検知できる場合とできない場合が発生してしまいました。

はみ出したView のタップ検知ができたりできなかったり

どうしてこうなった🤔

hitTest は最初に返却したUIView をタップしたと判断する

見出し↑がどういうことかと言うと、先にさらっと書きましたが、
hitTest は表示されているセル全てに対して順番に走ります。
その順番は、iOSの内部の処理のため不明ですが、
少なくともUICollectionView では後から表示されたセル順のようです。

そのため上記動画の1回目のタップは28のセルよりも前に30のセルのhitTest メソッドが呼ばれています。
なぜなら30のセルの方が後で表示されたから。
そして2回目のタップは28のセルの方が早くhitTest メソッドが呼ばれています。
なぜなら28のセルの方が後で表示されたから。

なので、hitTest の順番に依らずにはみ出した要素のタップ検知をするためには、
はみ出したView が覆いかぶさっていないか、各セルのhitTest 内で逐一確認する必要があります。

はみ出したView が覆いかぶさっていないかを判断する

各セルは、他のセルのことを把握できないので、
はみ出た要素の位置をVCなどで把握し、覆いかぶさっているかどうか判定する必要があります。

VCなどに以下のように判定メソッドを追加します。

func isOverhangingViewCovering(_ view: UIView, point: CGPoint) -> Bool {
    let indexPath = IndexPath(item: 30, section: 0) // はみ出る要素を持つセルを取得
    guard let overhangingCell = collectionView?.cellForItem(at: indexPath) as? OverhangingCell else {
        return false
    }
    
    // はみ出る要素を取得
    guard let overhangingView = overhangingCell.overhangingView else {
        return false
    }
    
    // タップ領域の比較をするために、はみ出る要素のframe をコレクションビュー基準でのframe に変換
    let overhangingViewFrame = overhangingCell.convert(
        overhangingView.frame,
        to: collectionView
    )
    
    // 引数のpoint(タップ位置)をコレクションビュー基準でのpoint に変換
    let pointAtCollectionView = view.convert(point, to: self.collectionView)
    
    // タップ位置が、はみ出る要素のframe に含まれるかどうかを判定
    return overhangingViewFrame.contains(pointAtCollectionView)
} 

その後、セルのhitTest にて作成したメソッドを呼んでやります。

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    // 30のセルはこのfor文の中でreturn される
    for subview in contentView.subviews as [UIView] {
        if subview is TappableControl
            && subview.point(inside: convert(point, to: subview), with: event) {
                return subview
        }
    }
    // 30以外のセルはこの判定を通る
    if SampleViewController.isOverhangingViewCovering(self, point: point) {
        // 覆いかぶさっている領域をタップしていた場合は、
        // そのセルはタップ検知させたくないのでnil を返却
        return nil
    }
    
    return super.hitTest(point, with: event)
} 

これで、いい感じにタップ検知ができます!

完成!

いい感じにタップ検知できた

今度こそ完成です!

最後に

hitTest についてだいぶ理解が深まりました。
これでタップ領域を好きに制御できそうです。

以上です。お疲れ様でした!