20191223のSwiftに関する記事は12件です。

iOS 13でMapKitに新しく追加されたPOI(Point of Interest)フィルタリングを触ってみる

はじめに

WWDC19で発表された内容が濃すぎて,
調べて使ってみたいけど扱えていない内容がまだまだたくさんです?

今回はそのひとつで iOS 13 から新しく MapKit に追加され
Point of Interest(POI) フィルタリングを触ってみました。

このセッションの短い時間ではありましたが紹介されています。(10分あたりから)
What’s New in MapKit and MapKit JS
https://developer.apple.com/videos/play/wwdc2019/236/

MapKit における POI とは

MapKit における POI とは Map に表示されている,
レストランとか学校など様々な場所表示です。それぞれアイコンもあります。
言われてみればあーあれかってなりますよね。
スクリーンショット 2019-12-23 18.22.04.png
種類的には,執筆時点で 40種類あります。(セッション資料より)
Apple Park みたいな特別な場所は専用のものが用意されているようです。
スクリーンショット 2019-12-23 18.22.04.png
MapKit チームはデベロッパにもっとこういうのあったらどうか?と
ユースケースや提案があれば是非頼むというスタンスのようです。

参考:MKPointOfInterestCategory
https://developer.apple.com/documentation/mapkit/mkpointofinterestcategory?language=swift

iOS 12 までの POI のフィルタリング

通常の Map アプリでは情報が多くてもいいと思います。
都市部だと明らかにアイコンの乱立がありユーザの目移りが問題になります。
それぞれのアプリの Map の用途によって
POI のフィルタリング(出し分け)ができるとアプリの UX が上がりそうです。

iOS 12 までは,POI の全表示 or 全非表示はできていました。
MapView の showsPointsOfInterest に Bool 値を与えていました。
コードは下記です。

let mapView = MKMapView()
mapView.showsPointsOfInterest = false

こんな感じで POI が非表示になります。全部非表示にはならないんだなぁ。
純正 Map 自体も更新されているだろうし,APIの方がついていってない感じ。

デフォルト 設定OFF
IMG_1919.PNG IMG_1920.PNG

参考:showsPointsOfInterest
https://developer.apple.com/documentation/mapkit/mkmapview/1452102-showspointsofinterest?language=swift

iOS 13 以降の POI フィルタリング

今までは,POI を全表示 or 全非表示しかできなかったですが,
iOS 13 からフィルタリングが可能になりました。

showsPointsOfInterest
iOS 13 以降では,レファレンスの通り Deprecated になっています。
代わりに pointOfInterestFilter を使うように変わっています。

参考:pointOfInterestFilter
https://developer.apple.com/documentation/mapkit/mkmapview/3143417-pointofinterestfilter?language=swift

pointOfInterestFilter を使って,POI のフィルタリングができます。
前述した MKPointOfInterestCategory が出し分けとして利用されます。

以下 3種類の POI フィルタリングを紹介します。

都合によりスクショを貼っていますが,
マップの拡大・縮小を行なっても設定は維持されます。

この POI だけ表示したい,ケース

POI が多いので レストランやカフェ,フードマーケットだけ表示させたいなぁ〜
そういう場合は,MKPointOfInterestCategory を格納する Array を用意して,
レストランとカフェ,フードマーケットをフィルタリングします。
include なので直感的でわかりやすいですね。

let category: [MKPointOfInterestCategory] = [.restaurant, .cafe, .foodMarket]
let filter = MKPointOfInterestFilter(including: category)
mapView.pointOfInterestFilter = filter

実行結果はこちらです。まだ多い気もするけどだいぶ絞れましたね。
よく見るとデフォルトだと表示されないレストランも表示されています。
これでレストラン探しが捗りそうな気がしますね!

デフォルト フィルタリング後
IMG_0521.PNG IMG_0523.PNG

この POI だけ表示したくない,ケース

逆に飲食店を非表示にしたいなぁ〜 みたいなケースがあったとしましょう。

逆に exclude で該当のカテゴリのみ非表示にできます。

let category: [MKPointOfInterestCategory] = [.restaurant, .foodMarket, .cafe]
let filter = MKPointOfInterestFilter(excluding: category)
mapView.pointOfInterestFilter = filter

こちらも飲食店が非表示になって表示されるようになったお店などがあります。

デフォルト フィルタリング後
IMG_0521.PNG IMG_0524.PNG

全部表示しない,ケース

該当のアノテーション以外の情報はいらないんや〜
という場合は excludingAll を使います。

let filter = MKPointOfInterestFilter.excludingAll
mapView.pointOfInterestFilter = filter

結果は下記です(左側)。
今までの showsPointsOfInterestfalse のときよりも POI が消えてますね。
あまりないケースとは思います。

[新]全て非表示 [旧]全て非表示
IMG_1920.PNG IMG_1920.PNG

具体的なユースケース(妄想)?

POIのフィルタリングはむやみに設定するものではなく,
アプリを利用するユーザのことを意識して行うべきです。
何個か妄想してみました。

例1

例えば,飲食店のアプリでお店の場所を示すのに住所と Map を掲載するとします。
お店に駐車場がないので周りのコインパーキングなどを利用してほしいとします。
この場合,駐車場のアイコンだけを表示するようにすると
ユーザは他のロケーションに目移りせずに駐車場の位置を意識できます。

let category: [MKPointOfInterestCategory] = [.parking]]
let filter = MKPointOfInterestFilter(including: category)
mapView.pointOfInterestFilter = filter
フィルタリング前 フィルタリング後
IMG_0526.PNG IMG_0525.PNG

例2

つぎは海外カンファレンスなどのイベント系アプリを考えてみます。
同じく会場の場所を示すのに住所と Map を掲載するとします。
世界各国のデベロッパが会場のある街(都市,国)に訪れます。
ユーザが気になるのは会場近くのレストラン,ホテル,駅なのではないでしょうか?
あるいは観光する人もいるかもしれません。

let category: [MKPointOfInterestCategory]
    = [.hotel,
       .restaurant, .cafe,
       .airport, .publicTransport,
       .beach, .amusementPark, .nightlife]
let filter = MKPointOfInterestFilter(including: category)
mapView.pointOfInterestFilter = filter
フィルタリング前 フィルタリング後
IMG_0527.PNG IMG_0528.PNG

うーんイマイチかな。マップ拡大しないと恩恵少ない。
WWDC は会場から近いホテルがおすすめです。(私はフェアモント派)
VTA ライトレール使えるので駅近もいいですね。
現地では,体調第一でいろいろ決めると良いです。時差もありますしね。
来年も行きたいなぁ?

SwiftUI でも使える

SwiftUI でも MapKit を使っているので,書き方はほぼ同じです。
SwiftUI での MapView の使い方の復習と
POIフィルタリングを記事にしようと思っていたのですが計画狂っちゃいました。

ContentView.swift
import SwiftUI
struct ContentView: View {
    var body: some View {
        ScrollView {
            VStack(alignment: .leading) {
                // MapView Part
                MapView()
                    .frame(height: 300.0)
                // 省略
            }
        }
    }
}

UIViewRepresentable に準拠させます。
makeUIView 関数内で frame のコード書いても無視されるっぽくて
初期化する View で設定するとちゃんと意図通りに動くようです。

MapView.swift
import SwiftUI
import MapKit    // これが必須

struct MapView: UIViewRepresentable {

    func makeUIView(context: Context) -> MKMapView {
        let mapView = MKMapView(frame: .zero)
        return mapView
    }

    // Required
    func updateUIView(_ uiView: MKMapView, context: UIViewRepresentableContext<MapView>) {
        let center = CLLocationCoordinate2DMake(緯度, 経度)
        let span = MKCoordinateSpan(latitudeDelta: 0.002, longitudeDelta: 0.002)
        let region = MKCoordinateRegion(center: center, span: span)
        uiView.setRegion(region, animated: true)

        // POIフィルタリング(UIKitと使い方は変わらない)
        let category: [MKPointOfInterestCategory] = [.parking, .publicTransport]
        let filter = MKPointOfInterestFilter(including: category)
        uiView.pointOfInterestFilter = filter

        // アノテーションを表示
        let annotation = MKPointAnnotation()
        annotation.coordinate = center
        annotation.title = "アノテーションタイトル"
        uiView.addAnnotation(annotation)
    }
}

Enjoy SwiftUI vol2 でライブコーディングした
サンプルアプリのイベント詳細画面を更新してみました。
フィルタリングは [.parking, .publicTransport] にしています。
駐車場や駅の情報が確認できますね。

RPReplay_Final1577104328.gif

サンプルコードはこちらです。
https://github.com/MilanistaDev/StudyGroupEventFetcherForSwiftUI

connpass の API 叩いてリストに表示する部分の記事もよろしければご覧ください。
【SwiftUI】外部APIを叩いて取得した結果をListに表示する
https://qiita.com/MilanistaDev/items/64dca8c9d5099a19529e

おわりに

今回は,iOS 13 の Mapkit に新しく追加された
POI のフィルタリングについて書きました。

頭の片隅に置いて,これからは Map を実装する際は,
Map 上の POI のことを少し意識してみてはいかがでしょうか?
ユーザの目線で考えることが大事です。

ご覧いただきありがとうございました!

今年も多くの方に支えられて楽しく1年過ごすことができました。
来年も引き続きアウトプット駆動で精進していこうと思っておリます!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

SwiftUIで非同期で画像を表示する方法

はじめに

この記事は、ObservableObjectを使って非同期に画像をダウンロードする方法を紹介します。
List表示時などに使うと便利だと思います。
TIPSとして活用してもらえたらと思います。

作り方

Step1.準備

画像を用意します。今回は、いらすとやの画像を活用させていただきます。

スマートフォンを使うペンギンのイラスト
https://www.irasutoya.com/2019/07/blog-post_4.html

画像のURLはhttps://1.bp.blogspot.com/-_CVATibRMZQ/XQjt4fzUmjI/AAAAAAABTNY/nprVPKTfsHcihF4py1KrLfIqioNc_c41gCLcBGAs/s400/animal_chara_smartphone_penguin.pngでした。

Step2.非同期で画像をダウンロードする

ObservableObjectを活用して、データを取得します。
バッググラウンドスレッドで画像を読み込み、@Publishedした変数に格納するときはメインスレッドで更新します。
(Data Flow Through SwiftUI20:05あたりのスライド87ページより)

ImageDownloader.swift
import Foundation

class ImageDownloader : ObservableObject {
    @Published var downloadData: Data? = nil

    func downloadImage(url: String) {

        guard let imageURL = URL(string: url) else { return }

        DispatchQueue.global().async {
            let data = try? Data(contentsOf: imageURL)
            DispatchQueue.main.async {
                self.downloadData = data
            }
        }
    }
}

Step3. SwiftUIで扱えるUIパーツを作る

ObservableObjectを使い、画像読み込み終わるとUIImageを使い表示します。
読み込み中の場合は、ランアップできないので今回はSF Symbolの画像を表示します。
SF Symbolの画像以外に任意の画像に置き換えればいいと思います。

URLImage.swift
import SwiftUI

struct URLImage: View {

    let url: String
    @ObservedObject private var imageDownloader = ImageDownloader()

    init(url: String) {
        self.url = url
        self.imageDownloader.downloadImage(url: self.url)
    }

    var body: some View {
        if let imageData = self.imageDownloader.downloadData {
            let img = UIImage(data: imageData)
            return VStack {
                Image(uiImage: img!).resizable()
            }
        } else {
            return VStack {
                Image(uiImage: UIImage(systemName: "icloud.and.arrow.down")!).resizable()
            }
        }
    }
}

Step4.SwiftUIから読み込む

Step3で作成したURLImageをSwiftUIから呼び出します。
画像のURLを引数に呼び出すと自動で画像ダウンロードされたら表示されるようになります。

ContentView.swift
import SwiftUI

struct ContentView: View {

    var body: some View {
        VStack {
            URLImage(url: "https://1.bp.blogspot.com/-_CVATibRMZQ/XQjt4fzUmjI/AAAAAAABTNY/nprVPKTfsHcihF4py1KrLfIqioNc_c41gCLcBGAs/s400/animal_chara_smartphone_penguin.png")
                .aspectRatio(contentMode: .fit)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

参考サイト

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

swiftで、simpleライフゲームを作る

swiftのコマンドラインで動くライフゲームの作り方を解説します。
ライフゲームとは、セルを生命に見立てたシミュレーションゲームです。
詳しくは、ウィキペディア「ライフゲーム」を、読んでください。
今回は、コマンドラインでゲーム画面が表示され、簡単で分かり易く作ります。
作りたくない方は、ここからGitHubに行ってで落として下さい。
こんな感じです。生存は⬛️、死は⬜️、で表示しています。

example.swift
|0|1|2|3|4|
⬜️⬜️⬜️⬜️⬜️:0
⬜️⬜️⬜️⬜️⬜️:1
⬜️⬛️⬛️⬜️⬛️:2
⬛️⬜️⬜️⬜️⬜️:3
⬜️⬛️⬜️⬜️⬜️:4

導入する動作は、以下の4つです
1.マップを生成
2.画面を表示
3.次の世代に進める
4.特定のマスを操作
5.コマンドラインで遊べる

Conway's Game of Lifeのルール

ルールを確認しておきます。
自身のセルに、隣接し生存しているセルの数によって、「生」「死」が決まります

  • 誕生 生存セル3つと隣接している -> セルに生命が誕生
  • 生存 生存セル2つと隣接しており、自身が生存している -> 次世代でも生存を継続
  • 過疎 隣接する生存セルが、1以下 -> 次世代では死滅
  • 過密 隣接する生存セルが、4以上 -> 次世代では死滅

基礎的データ構造について lifeData

今回は、説明を省くため、多重配列を使用します。
配列の中に配列が入っています。外側の配列をx軸。内側の配列をy軸として扱います
例えば、左上0行目の左から3列目の値を取り出すときは、配列名[0][3] となります。
内部の型は、真理値です
trueが生存
falseが死です

lifeData.swift
//ライフゲームの基礎データ[X軸[Y軸]] 要素0で初期化するなら初期値は不要
var lifeData:[[Bool]]

マップを生成する mapCreate

マップを生成に、必要なものは、2つです。
1. マップの大きさを決める。 今回は、最大でも50*50マス程度にします
2. 初期値の基準 ランダムなのか、空白なのか、すべて生存で埋まっているのか。 

引数として受け取ります。今回の場合、初期値は、ランダムであった方が楽です。
そのためデフォルト値をクロージャー{Bool.random()}で、与えています。
これにより、呼び出しの際ランダム生成の場合、マップの大きさを入力するだけになっています。

mapCreate.swift
//マップを生成してくれる 引数 X軸,Y軸,値生成方法(デフォルはランダム)省略可
func mapCreate(Xjiku x:Int,Yjiku y:Int,seisei s:()->Bool = {Bool.random()} ) -> [[Bool]] {
    //上書きしてしまうので、初期値を入れたほうが安全
    var map = [[Bool]]()
    for _ in 0..<x {
        //一度に列を入れるために一度変数に入れる。
        var yjiku = [Bool]()
        for _ in 0..<y {
            //値生成部分
            yjiku.append(s())
        }
        map.append(yjiku)
    }
    return map
}

lifeData = mapCreate(Xjiku: 10, Yjiku:10 )
print(lifeData)

実行結果
[[true, false, true, true, true], [true, true, false, true, false], [true, false, true, true, false], [true, true, false, false, true], [false, false, false, false, false]]

見にくいですよね。これを見やすくしています。

画面を表示 lifeView

コンソールに表示するには、標準出力printを使って並べて行きます。
前にも書きましたが、生存は⬛️、死は⬜️を使って表現します。
表番号がついていないと、指示しづらいので、表番号をつけます。
ただし、コンソールで行っているので、多少のズレは発生します。
最後に、現在の生存セル数を表示します。
これをしておかないと、変化があったのか分かりづらいですから必要です。

構造は、一行分のデータを表示したら改行し次の行に行きます。これの繰り返しです。
一行目は、列番号を書き込みます。lifeDataの外側の配列の個数(count)を基準にします
lifeDataを受け取り、1セルづつ生死を確認して行きます。端まできたら、行番号を振って改行します。
死んでいる状態の時でも、セルを設置するのが、四角く表示するコツです。

lifeView.swift
//ブロック状に表示してくれる。
func lifeView(world w:[[Bool]]) {
    print("現在の世界を表示します")
    //今回は生存は、黒、絶滅は白の記号で表示していく
    let life = "⬛️"
    let death = "⬜️"
    //生存者集を計算数変数
    var ikinokori = 0
    print("|", separator: "", terminator: "")
    for y in 0..<w[0].count{
        //列番号の表示 きれいに表示されるのは,10*10くらいまで
        print("\(y%10)|", separator: "", terminator: "")
    }
    print("")
    //ループを回して、マップを読み込む
    for y in 0..<w[0].count {
        for x in 0..<w.count{
            //値を把握して、どちらを表示するか決める
            if w[x][y] == true {
                ikinokori += 1
                print(life, separator: "", terminator: "")
            }else{
                print(death, separator: "", terminator: "")
            }
        }
        //改行コード 端まできたら改行する
        //行番号の表示
        print(":\(y)", separator: "", terminator: "\n")
    }
    print("現在生き残りは、\(ikinokori)です。約\(ikinokori*100/(w.count * w[0].count))%です。")
}


lifeView(world: lifeData)

実行結果

lifeViewExample.swift
|0|1|2|3|4|
⬛️⬛️⬜️⬛️⬛️:0
⬜️⬛️⬛️⬛️⬜️:1
⬛️⬜️⬜️⬜️⬛️:2
⬛️⬛️⬜️⬜️⬛️:3
⬜️⬜️⬛️⬛️⬜️:4

これで見慣れたゲーム画面になってきました。
次から、世代を導入します。

次の世代に進める nextLife

次の世代に進めるには、いくつかルールの違いがありますが、今回は、最も基礎ルールで行きます。
つまり、Conway's Game of Lifeのルールで示した4つのルールです。
* 誕生 生存セル3つと隣接している -> セルに生命が誕生
* 生存 生存セル2つと隣接しており、自身が生存している -> 次世代でも生存を継続
* 過疎 隣接する生存セルが、1以下 -> 次世代では死滅
* 過密 隣接する生存セルが、4以上

そのため、端は死のセルであるとして扱われます。

今回は、楽をするために2つだけ変わった組み方をします。

次世代のlifeDataをすべて死んだ状態からにすることで、誕生と生存だけ調べるだけで済むようにします。

隣接する生存セル数をカウントする際に、セル毎に、カウントしていると遅くなるので、
セル隣接数マップkamitudoを導入します。マップの端を繋がないので、毎回IF文で、端化どうかの調査が必要ですが遅くなるので、端の調査を避けます。

どのようにするか、まず三つの同じ大きさのmapを用意します。
1. 現在のlifeMap:引数として読み込んだもの
2. 未来のlifeMap:返値として利用するもの
3. セル隣接数マップ(kamitudo):周辺の生存セル数を表示するもの

セル隣接数マップ(kamitudo)大きさを左右前後に1マスづつ拡大します。
現在のlifeMapを縦横にひとマスずらして対応させます。現在のlifeMapでは、[2][1]の位置に有ったものを、[3][2]の位置にあるとして扱うと言うことです。
そして、現在のlifeMapを読み込み、生存しているのであれば、セル隣接数マップの8方向セルに、数値を加算します。すべてのセルに対して行います。加算し終わったなら、kamitudoから隣接情報を読み込み、ルールに基づき、次世代セルに書き込みます。
 端のデータは、対応する次世代データを作る際に読み込まないので、対応する必要がなくなります。

nextLife.swift
//1ターン進める 今回は、生存条件を変更不可能にする。
func nextLife(world w:[[Bool]]) -> [[Bool]] {

    //毎回読み込ませると時間がかかるので、定数として読み込ませる
    let xCount = w.count
    let yCount = w[0].count

    //周辺の密度を保存する。型がIntのため、mapCreateを使わない。端っこかどうかの計算をなくすために、一マスづつ前後に大きくしています。両側ぶんで2足します
    var kamitudo:[[Int]] = Array(repeating:{Array(repeating: 0, count: yCount + 2)}(), count: xCount + 2)

    //返値を保存する場所 生命は減っていく傾向にあるのでfalse指定{false}。
    var nextWorld  = mapCreate(Xjiku: xCount, Yjiku: yCount, seisei: {false})

    //引数worldを読み込み過密状況を調査する
    for x in 0..<xCount {
        for y in 0..<yCount{
            //マスに生命が存在したら、周辺の過密度を上昇させる
            if w[x][y] == true{
                //過密度を書き込むループ 9方向に加算する
                // ハードコード(直接書き込む事)したほうが早いが、読みづらいのでforループを使う
                for i in 0...2 {
                    for t in 0...2{
                        kamitudo[x+i][y+t] += 1
                    }
                }
                //自分は隣接する個数に含まれないので、1減らす
                kamitudo[x+1][y+1] -= 1
            }
        }
    }

    // kamitudo(過密度)に基づき生存判定をしていく
    for x in 1...xCount{
        for y in 1...yCount {
            switch kamitudo[x][y] {
                //3なら誕生
            case 3 :
                nextWorld[x-1][y-1] = true
                //2なら、マスに生命がいれば生存させる
            case 2 :
                if w [x-1][y-1] == true {
                    nextWorld[x-1][y-1] = true
                }
                //それ以外は、基礎値でfalseのまま
            default:
                //xcodeのエラー抑止 *defaultに何も設定しないとエラーが出ます。
                {}()
            }
        }
    }

    return nextWorld
}


lifeView(world: lifeData)
lifeData = nextLife(world: lifeData)
print("一年進めました")
lifeView(world: lifeData)

実行結果
今回は、変化を見るために二回実行しています。

nextLifeExample.swift
現在の世界を表示します
|0|1|2|3|4|
⬜️⬜️⬛️⬜️⬛️:0
⬜️⬛️⬛️⬛️⬜️:1
⬛️⬛️⬛️⬜️⬛️:2
⬜️⬜️⬜️⬛️⬜️:3
⬜️⬜️⬛️⬜️⬜️:4
現在生き残りは 11です 約44%です 
一年進めました
現在の世界を表示します
|0|1|2|3|4|
⬜️⬛️⬛️⬜️⬜️:0
⬛️⬜️⬜️⬜️⬛️:1
⬛️⬜️⬜️⬜️⬛️:2
⬜️⬜️⬜️⬛️⬜️:3
⬜️⬜️⬜️⬜️⬜️:4
現在生き残りは 7です 約28%です

中央付近が、過密で死亡しています
最も下のマスは、過疎で死亡しています。左上は、生命が誕生しています。

特定のマスを操作 kamiNoTe

修正地点を受け取り、値を変更して上書きしています。
変更する規則は、クロージャ(無名関数)で受け取ります。デフォルトで反転になっています。
特定の値にしたい場合
正にする {_ in true}
偽にする {_ in false}
で、変えることが可能です。 _ in は引数を受け取っても使わないと言う指定です。
デフォルトの{!$0}は、!は反転。$0は、第一引数の意味です。
クロージャは、返値が{}のなかに1つしか無い場合returnは不要です。
 
lifeDataを全面的に作り直すと、効率が良く無いのでinoutを指定して、参照渡しにしています。
参照渡しとは、データそのもの受け取ると言うことです。
対義語は、値渡し(あたいわたし)です。swiftの場合、ほぼ全ての場合で、値渡しです。
値渡しは、データがコピーされて渡されます。オリジナルに影響を与えないことが、利点です。
値渡しのデータは、変更しても、オリジナルに影響はありませんが、参照渡しの値は、オリジナル(参照元)が変更されます。データの消失に気をつけて下さい。

swiftで参照渡しを行う場合、仮引数の型に、inout属性を与え、呼び出す際には、実引数にをつけて、利用します。

kamiNote.swift
//特定のマスを指示してデータを操作する関数 worldは現在の状態、pointは編集する場所(X軸,Y軸)、sayouは、セルに行う操作 デフォルトは、反転
func kamiNoTe(world w :inout [[Bool]],point p :(Int,Int),sayou s:(Bool)->Bool = {!$0}) {
    w[p.0][p.1] = s(w[p.0][p.1])
}


//一番下の行を反転させる
lifeView(world: lifeData)
print("一番下の行を反転させます")
//一番下の行を反転させる
for i in 0..<lifeData[0].count {
    //&をつけて、参照渡し。
    kamiNoTe(world: &lifeData, point: (i,lifeData.count - 1))
}

実行結果

kamiNotTeExample.swift
現在の世界を表示します
|0|1|2|3|4|
⬜️⬜️⬛️⬜️⬛️:0
⬜️⬛️⬛️⬛️⬜️:1
⬛️⬛️⬛️⬜️⬛️:2
⬜️⬜️⬜️⬛️⬜️:3
⬜️⬜️⬛️⬜️⬜️:4
現在生き残りは11です約44%です
一番下の行を反転させます
現在の世界を表示します
|0|1|2|3|4|
⬜️⬜️⬛️⬜️⬛️:0
⬜️⬛️⬛️⬛️⬜️:1
⬛️⬛️⬛️⬜️⬛️:2
⬜️⬜️⬜️⬛️⬜️:3
⬛️⬛️⬜️⬛️⬛️:4
現在生き残りは14です約56%です

一番下の行が反転しています。
これで基本的機能の説明は終わりです。
これを使ってコマンドラインでのゲーム化に入ります。

コマンドラインで遊べる gameMode

コマンドラインでは、標準入力(readLine)を使って操作します。
今回はXcode上で実行するので右下のコンソールエリアに、入出力します。
ココに入力っとなっている場所です。
IMG_0385.JPG

コンソールエリアに、文字列を入力することでゲームを操作します。
この際、入力を求める内容を忘れないようにします。何が起きているのか分からなくなりますからね。

ゲームの流れを説明します。
まず、数字の入力を求めます。
ゲームマップを大きさを決めます

ライフゲームの操作に入ります。
できる操作は、以下の5つです。
1. next:次の時代に進みます
2. change:対象のマスを変更します
3. changeAll:すべてを変更します 
4. view:現在の状態を表示します 即時実行されます 
5. exit:終了します

以上の機能を実装します。

readLineについて、
コンソールに入力された文字列を、入手できる関数です。
使用するとコンソールが入力待機状態になります。そのため、文字入力を求める前に、何を求めるのか説明する必要があります。
 次に、文字列がどのようなものかからないため注意する必要があります。何も入力されていないnilであったりすると、プログラムが止まる原因になります。nil合体演算子 ??を使って安全に利用しています。

オプショナル(nilの可能性のある値) ?? 代替値

で利用します。オプショナルとは、nilになる選択肢(オプション)がある値を示すものです。

readLineの使い方について、ユーザーの行動によって、動作が変わるため、有効な値と無効な値の反応を作る必要があります。今回は、無効な値の場合、再度やり直す構造にします。有効な値を受け取るまで、同じ動作を繰り返すようにします。repeat_while文を使用します。

repeat {プログラム}while 条件

条件が、真の場合に繰り返します。今回は、入力が異常値だった場合、繰り返しています。
ただし、readLineを呼ぶなど、入力待機中になるコードの場合、実行中で止められなくなってしまう場合があるので、注意して利用して下さい。
swiftの場合、main.swiftの最後まで行けば動作を停止しますので、ループを抜けらようにしておきます。
終了した際にはProgram ended with exit code: 0とコンソールに表示されるので、見てみて下さい。

gameMode.swift
//世界の大きさ
var ookisa:Int = 0
//ゲームモードのマップ
var gameMap:[[Bool]]

repeat {
    print("数字を入力してください1~50まで")
    //readLineで入力を受け付ける
    let readOokisa = readLine() ?? "0"
    ookisa = Int(readOokisa) ?? 0
}while ookisa == 0 || ookisa > 50

print("\(ookisa)を受け取りました。マップを製造します")
gameMap = mapCreate(Xjiku: ookisa, Yjiku: ookisa)
lifeView(world: gameMap)

//操作するループ next change changeAll view exti
//文字入力用文字列
var readString = ""
repeat{
    print("操作を英字で入力して下さい。\n next:次の時代に進みます \n change:対象のマスを変更します \n changeAll:すべてを変更します \n view:現在の状態を表示します 即時実行されます \n exit:終了します")
    readString = readLine() ?? ""
    //switch文で条件分岐
    switch readString {
    case "next":
        var readKaisuu = ""
        var nextkaisuu = 0
        repeat {
            print("どれくらい進めますか?1回以上")
            readKaisuu = readLine() ?? "0"
            nextkaisuu = Int(readKaisuu) ?? 0
        }while nextkaisuu == 0
        for _ in 0..<nextkaisuu{
            gameMap = nextLife(world: gameMap)
        }
    case "change":
        //x軸
        let xMax = gameMap.count
        var xjiku:Int = xMax
        repeat {
            print("x軸を入力して下さい。最大値は\(xMax - 1)です")
            let readX = readLine() ?? ""
            xjiku = Int(readX) ?? xjiku
        }while xjiku >= xMax
        //y軸
        let yMax = gameMap[0].count
        var yjiku:Int = yMax
        repeat {
            print("y軸を入力して下さい。最大値は\(yMax - 1)です")
            let ready = readLine() ?? ""
            yjiku = Int(ready) ?? yjiku
        }while yjiku >= yMax
        //操作部
        print("x:\(xjiku) y:\(yjiku)を、反転させます")
        kamiNoTe(world: &gameMap, point: (xjiku,yjiku))
    case "changeAll":
        print("世界を再構成します")
        //新たにマップを作って上書きする。
        gameMap = mapCreate(Xjiku: ookisa, Yjiku: ookisa)
    case "view":
        lifeView(world: gameMap)
    case "exit":
        print("終了します")
    default:
        print("指示を理解できません")
    }
    //exitが入力されない限り繰り返す
}while readString != "exit"

実行結果
全ての操作を行ってみます。
ここからコードを配布していますので、自分の環境で実行してみた下さい

gameModeExample.swift
数字を入力してください1~50まで
3
3を受け取りましたマップを製造します
現在の世界を表示します
|0|1|2|
⬛️⬜️⬛️:0
⬜️⬜️⬛️:1
⬜️⬛️⬛️:2
現在生き残りは5です約55%です
操作を英字で入力して下さい
 next:次の時代に進みます 
 change:対象のマスを変更します 
 changeAll:すべてを変更します 
 view:現在の状態を表示します 即時実行されます 
 exit:終了します
next
どれくらい進めますか1回以上
1
操作を英字で入力して下さい
/*省略*/
view
現在の世界を表示します
|0|1|2|
⬜️⬛️⬜️:0
⬜️⬜️⬛️:1
⬜️⬛️⬛️:2
現在生き残りは4です約44%です
操作を英字で入力して下さい
/*省略*/
changeAll
世界を再構成します
操作を英字で入力して下さい
/*省略*/
view
現在の世界を表示します
|0|1|2|
⬜️⬜️⬜️:0
⬜️⬛️⬜️:1
⬜️⬜️⬜️:2
操作を英字で入力して下さい
/*省略*/
change
x軸を入力して下さい最大値は2です
0
y軸を入力して下さい最大値は2です
3
y軸を入力して下さい最大値は2です
2
x:0 y:2反転させます
操作を英字で入力して下さい
/*省略*/
view
現在の世界を表示します
|0|1|2|
⬜️⬜️⬜️:0
⬜️⬛️⬜️:1
⬛️⬜️⬜️:2
現在生き残りは2です約22%です
操作を英字で入力して下さい
/*省略*/
error
指示を理解できません
操作を英字で入力して下さい
/*省略*/
exit
終了します
Program ended with exit code: 0

終わりに

一通り動作しました。これでsimpleライフゲームの完成です。説明も終わります。
 コンソールで遊ぶ以上テキストベースにはなってしまいますが、今回作ったコードUIViewなどと組み合わせて使えば、リアルタイムで反応するlifeGameになります。リアルタイムに動いているようなゲームでも、根底にあるのは、データの変更です。アクションゲームもパズルゲームもその点では何も変わりません。
今回は非常簡単なデータ構造[[Bool]]でしたが、それなりにゲームになっていますよね?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[swift]SearchBarやTextField にてUIMenuControllerを非表示にする

どんなもの?

スクリーンショット 2019-12-23 17.48.31.png

長押しすると出てくるやつです。
そもそもこのバー自体出したくなかったので対応しました。

ソース

    // UIMenuController を非表示にする
    override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
        OperationQueue.main.addOperation({
            UIMenuController.shared.setMenuVisible(false, animated: false)
        })
        return super.canPerformAction(action, withSender: sender)
    }
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

iOS13対応したら、SKProductsRequest周りでつまずいた

これiOS13、Xcode11 私はこうしてつまずいた Advent Calendar 2019 の23日目を担当するアガツマです
普段は、ビジネス版マッチングアプリ yenta のiOS版を開発をしています。

前日の記事
iOS13のメインスレッドチェック厳格化とNotificationCenterの組合せでつまづいた
と少し被るのですが、1つのケースとして誰かの役に立てたら幸いです!

つまずいた経緯

iOS13対応の諸々を完了し、アプリの課金プランについて記述している画面を開こうとしたところクラッシュ。。。
xcodeでは、以下のようなエラーメッセージが出力されていました。

Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Modifications to the layout engine must not be performed from a background thread after it has been accessed from the main thread.'

UIの更新は Main Thread で行うべきというのは以前からありましたが、iOS13に対応したらこのエラーが出るように。。。。

環境設定

iOS 13.2.2
Xcode11.2.0

やったこと

原因となっている箇所を特定

Main Thread Checker を活用して、Main Threadで呼ばれるべき処理が、実行時に Main Thread以外で呼ばれていないかをチェック!

Main Thread Checker 設定方法

Breakpoint Editor を開く
スクリーンショット 2019-12-23 15.09.03.png

Runtime Issue Breakpoint を追加
スクリーンショット 2019-12-23 15.13.17.png

追加した際に出る設定画面で type を Main Thread Checker を選択
スクリーンショット 2019-12-23 15.09.31.png

これで、実行時に Main Thread以外でUI更新をしている部分で処理が止まるので、原因となっている部分を特定しに行きました。

原因の特定と調査

このような過程で原因を調査していったところ、、、
SKProductsRequestDelegateメソッドの中で Main Thread 以外でUIに関する処理をしていることがわかりました。

SKProductsRequestは、アプリ内課金アイテムの情報を取得する際に利用しているのですが、色々と調べてみると下記2つのメソッドが Main Thread 以外で実行されるようになったみたいです。
なぜ、その様になったかはまだはっきりとはわかっていません。。。

この2つのメソッドが Main Thread で実行されることを想定していた実装をしている場合は注意が必要です。

func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {}
func request(_ request: SKRequest, didFailWithError error: Error) {}

改善案

上で挙げた2つのメソッド内の処理を、DispatchQueue.main.syncを用いて明示的にメインスレッド Main Thread で実行されるようにしました。
ここではproductsRequest(...)の例のみ記載します。

修正前のコード

// MARK: - SKProductsRequestDelegate
extension ViewController: SKProductsRequestDelegate {
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        //...(省略)...

        // 以下の処理でクラッシュする(Main Thread 以外でUI更新している)
        collectionView.reloadData()
        button.isHidden = false
    }
}

修正後のコード

// MARK: - SKProductsRequestDelegate
extension ViewController: SKProductsRequestDelegate {
    func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
        DispatchQueue.main.sync { // 書く
            //...(省略)...

            // Main Threadで実行される
            collectionView.reloadData()
            button.isHidden = false
        }
    }
}

さいごに

iOS13に対応したことで、想定外の部分でクラッシュする画面が出てくるのはなかなかつまづくポイントだなと思います。
このようなアップデート時にテストを入念にするというのはもちろんですが、iOSの自動テストなどを組み込みアップデートにより起こってしまう問題を検知する方法もしっかり模索していかなければいけないなと思いました。

参考

Are SKProductsRequestDelegate methods always called on the main thread?
Multithreaded rendering only crashes on iOS 13
【iOS】iOS13からSKProductsRequestのDelegateメソッドがメインスレッドで呼ばれなくなった

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Property Wrappersで値にアクセスしたら自動的に指定した値に戻る仕組みを作る

SwiftのProperty Wrappersで値にアクセスしたら指定した値に戻る仕組みを作ってみました。

指定した値に戻るとはtrue -> falseOptional.some -> Optional.noneのようなことです。戻る値はProtocolで指定することができます。

私は例えば

  • エラーメッセージを表示したい場合に一度アクセスして表示したら次回は値が存在しなくなる
  • 画面遷移を発生させる時にtrueをセットしておいて、値アクセス後はfalseに自動で戻る

という様に利用しています。

値用Protocolの作成

まず戻る値を指定するProtocolを作成します。
以下のように、否定値として定義しました。

protocol Negatable {
    static var negativeValue: Self { get }
}

extension Bool: Negatable {
    static var negativeValue: Bool { false }
}

extension Optional: Negatable {
    static var negativeValue: Optional<Wrapped> { .none }
}

Property Wrappersの作成

次にその値に戻る仕組みをProperty Wrappersで作成します。
とても簡単な仕組みで、getしたら保存してある値をnegativeに変更するだけです。

@propertyWrapper
class Negativile<N: Negatable> {
    private var value: N = N.negativeValue

    var wrappedValue: N {
        get {
            let result = value
            value = N.negativeValue
            return result
        }
        set {
            value = newValue
        }
    }
}

使い方

以下のようなクラスを作成して利用してみると自動的に値が変更されているのが分かります。

class A {
    @Negativile var b: Bool
    @Negativile var s: String?
}

let a = A()
a.b = true
print(a.b)  // true
print(a.b)  // false
a.s = "test"
print(String(describing: a.s))  //Optional("test")
print(String(describing: a.s))  // nil
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

今年はクリぼっちが本当に少ない / Flutter

今年はクリぼっちが本当に少ない / Flutter
今年もやってまいりました!!クリスマス!

この記事でわかること

  • Flutterでアプリを作りましたので、その技術内容と悩み
  • 恋をすることの素晴らしさ
  • クリスマスの考察

この3つを中心に書いていければなと思います。

私の周りでクリぼっちが減った!?

最近、私の周りでは幸せ報告が後を絶えません。

いわゆる結婚ラッシュってやつでしょうか。

  • 結婚しましたー(例のシンデレラの人)
  • 結婚したぷるぷる!(大学の先輩)
  • 結婚しました〜(インスタグラム多数)
  • 付き合った報告(思い切って恋をしてみました!)

しかもそれだけでなく、恋をした効果なのか

  • 結婚(シンデレラ) → 勇気を出してデザインのフリーランスになれた!
  • 結婚(大学の先輩) → CEOでめちゃめちゃ稼ぐ
  • 付き合った報告 → CTOになれた!

など恋をしつつも自分にコミットできている方が今年は多いと感じられました。

いや逆に、恋をしているからもっと頑張れるわけです。

やっぱり恋ってすごい

あらためて恋ってすごいなーと思います。

昔から、「衣食住」といいますが

「衣食住恋」で4大要素として入れるべきだと思います。

  • 恋はパワー
  • 恋は頑張れる
  • 恋駆動開発したほうが生産性が上がると最近すごく思います!!!

コードギアスのシャーリーさんもこう言っていましたよね

「恋はパワーなの!誰かを好きになるとね、すっごいパワーが出るの。
毎日毎日その人のことを考えてアプリを書いちゃったり、
早起きしちゃったり、
マフラーを編んじゃったり、
CIを組んじゃったり、
滝に飛び込んでその人の名前をリファクタしたり、
私だって…!…その、ルルにはないの?誰かのために、いつも以上の何かが」

(コードギアスより)

ということで、今日からは「恋駆動開発」を応援し

生産性をあげよう!!!ということで

思い出フォトアプリを作りました!!

ヒューチャーシンク.png

「まずは、ノープランでお出かけしてください。」

  • 1: 写真スポットに近づくと通知が来ます
  • 2: カメラは好きなカメラでとって下さい
  • 3: Instagramでどんな写真が投稿されているかわかります

IMG_2375 (1) copy.png

技術要素

  • Flutterで作ってみました(4~5人日ぐらい)
    • CleanArchitectureでやってみた
    • StatelessWidgetを細かく分けるよう心がけました
  • Firebase
    • Firestore(位置情報一覧)
      • GeoHashという概念を知らなかった
    • FirebaseDistribusion
  • GithubActionsでCI/CDを構築
    • FirebaseDistribusionでAdhoc配信してテスト
  • GeoFlutterFire
    • GPSまわりの計算が楽+Firestoreからデータを抜いてこれる!!
  • LP
    • studio.designで構築
      • まじで早い

Flutterを使ってよかったこと

1: 位置情報系の計算もFirestoreとGeoFlutterFireで楽に計算できる

恥ずかしながら、GeoHashという概念を初めて知りました。

2008年2月にスタートしたgeohash.orgサービスの目的は、地上の地点を特定するための短いURLを提供することにあった。
電子メールやウェブサイト、ウェブサイトへの書き込みの際に便利になるからである。

例えば、緯度及び経度の組 57.64911,10.40744 からは u4pruydqqvj というハッシュが導き出され、
http://geohash.org/u4pruydqqvj というURLで表現される。

(Wikipediaより)

位置情報を、GeoHash値で格納することによって検索パフォーマンスが上がり楽になるそうです!
GeoHash以外にも方法はあるみたいなので気になる方は調べてみるといいと思います。

Firestoreに格納されている位置情報に対して
今の、緯度経度と範囲を指定するだけでデータが検索できてしまいます!便利でした!

2: GitHubActionsでCI/CD

iOSネイティブの頃はCircleCIを主に使っていましたが、
GitHubActionsだとまた少ない定義で構築することができました!!!

これによってiOS/Androidアプリを
FirebaseDistributionで一気に配信でき便利でした!
(iOSがPod周りのpath系めんどかった)

3: CleanArchitectureでやってみた

こちらのリンクを参考に、iOSでの思想をFlutterにも入れて見ましたが、
Flutterの特性を活かせつついい感じで構築できたのではないかと思います。

https://matteomanferdini.com/ios-architecture-lotus-mvc-pattern/

Flutterで感じた悩み

  • ホットリロードは早いけど、初回インストールテストをするときにCocoaPodsなのでbuildが遅い!!
  • どうしてもUIのネストが深くなりがち
  • SwiftっぽいExtensionとかほしい

Flutterに対する考察

  • 他のクロスプラットフォーム(Xamarin/React Native/Titanium)と比べて強い勢いを感じており、普及はもう少し進むのではないでしょうか

    実際に勢いを調べてみました

Screen Shot 2019-12-13 at 11.25.55.png

これは勢いがありそうですね

実際、Titanium、Xamarinが出たときも振り切るか迷いましたが、OSSの管理がいつまで続くか、定着率はどれぐらいか、などを判断するとまだ振り切れませんでした。

  • WebもすべてGoogleによって統一される日が来る

  • アプリサイドから攻めているのもいい

    • 今までってWebと同じでかけるぜー感が少し嫌だったのですが、ネイティブアプリエンジニアのアプリ開発が楽になるし、Webもいけるようになる感が、アプリエンジニアに広まりやすくなる切っ掛けにもなるのではないでしょうか

ミスったところ

1: あんまり考えずに、インスタの画像をハッシュタグ検索で抜いて持ってこようと思っていた

インスタの制限がきついのはなんとなく知っていただどうにかなると思っていたが、
どうやっても画像が抜けない

  • Instagram Graph APIは7日30件までしか検索できないため断念
  • Instagramはスクレイピング禁止
  • WebViewで表示するも、読み込みが遅いのと毎回ログインを聞かれる(最悪)

○InstaAPIで行く案
・InstaAPI(古い)はもうkeyの新規発行してない
・GraphAPIはリミット制限

○InstaSDK for Flutterで行く案
・そもそもタグ検索がない

○WebViewで行く案

モバイル

document.querySelector('.xZ2Xk');

PC

document.querySelector('.ZUqME.N9d2H[style="width: 100%;"]').style.display ="none";

でログイン画面が消える(macのブラウザでuser agent操作)けど、flutter webviewだと消えないくそう

onPageFinished
onWebViewCreated

で実行するもできない

○Twitter
○Tumblr
○投稿型にする
○画像を表示しない
○手作業で2000件選ぶ
などなど手はありそうではあるが

有識者の方アドバイスください。

2: GitHub Actionsでアプリを配信するようにした

  • MacOSのビルドはLinuxのコンテナに比べて10倍料金が高いため、何度かfastlane実行してたりテストしてたらお金がかかっていた(従量制にしていた) -> ブランチ制限などで対応

やっぱりものづくりって面白い

  • 何より楽しい!
  • みんなが使ってくれることを想像し、にやにやしてしまいます。結局これって恋と同じかもしれません。

クリぼっちは少ないのか

  • 私の周りの方で、恋をしてみようかなと思ってくださって行動した方がいます。
  • そして見事に、恋の良さに気づいて生産性がバク上がりした人もいます。(よく逆に捉えられます)
  • 感謝をしてくれたのがとても嬉しかったです。

結論:私の身の回りでは「クリぼっちが少ない」

恋をしよう!!

  • ペアーズとWithはおすすめです!

https://www.pairs.lv/
https://with.is/welcome

クレジット

まとめ

  • Flutterは勢いがある!
  • 恋をして生産性をあげよう!

そしてこの度、no plan(ノープラン)株式会社を立ち上げて初のサービスリリースになりました!

今回のグラマブルは
ノープランのまま散歩しても、思い出をしっかりと残せるサービス となり

ノープラン株式会社が、
ノープランの人たちに、
ノープランであることが幸せに思える
ノープランファーストなサービスを作ろうと思います

ノープランファーストをまずは1つ実現できたのではないかと思います!!!

アプリは即興なので使いにくいところありますがブラッシュアップできればと思います!!!

ありがとうございました!!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Swift] iOS13とフォントのお話

はじめに

Combineを使ってMVVMでNotificationCenterを実装するでも書きましたが、ありがたいことにiOS13以上の案件に携わりました。そこでは、新しくiOS13で追加されたフォント周りに関しても触れることができ、大変良い経験となりました。

また、それに付随してフォントの話で登壇する機会をいただきました。
(画像をクリックすると資料に飛べます)

thumbnail

内容はスライドをみていただくとわかりますが、iOS13周りで増えたフォントに関しての話をさせていただき、、
具体的なコードの話はあまり触れなかったので、こちらで実装面に関して触れていきたいと思います。

主に、フォント周りでできるようになったことを中心に記載していきます。

iOS13とフォント

iOS13からCTFontまわりのものがいくつか増えました。

スクリーンショット 2019-12-23 4.57.03.png

この中のAPIを使ってフォント周りを操作していきます。
それでは実装コードを早速みていきましょう。

カスタムフォントをサクッと使う

資料でもこちらは触れているのでさらっと書いていきます。
UIFontPickerViewControllerを使うことでAppleが用意してくれたフォントを簡単に使うことができます。
(以下、実際に作成された方の動画をお借りしています。)

UIFontPickerViewController.gif

選択したフォントが即座にラベルに反映されているのがみてわかります。

原理としてはとても単純で

スクリーンショット 2019-12-23 6.56.36.png

Delegateを通りしてFontを取得できるので、そちらをセットしているだけです。

自前のカスタムフォントを使えるように設定する

フォントの扱うには、準備が必要なので先に行いましょう。

① フォントをアプリ内に用意する

今回は例として「NotoSans-Black」のフォントを使用します。

Assetにフォントのリソースを追加します。

スクリーンショット 2019-12-23 3.31.10.png

その後、Resource Tagにも追加します。

スクリーンショット 2019-12-23 3.31.00.png

上記で、追加したリソースタグ名を「Font」という名前を設定していますが、これは後々コードでも使用することになります。
(なので各自でユニークな名前を設定してください)

② フォントのentitlementsを追加する

これがないといくら実装してもエラーになります。

「Signin & Capabilities」から「+」で追加しましょう。
(Xcode11からUIが微妙に変わって探しづらいので注意)

スクリーンショット 2019-12-23 5.04.00.png

「fonts」と検索すると該当するものが出てくるので追加します。

スクリーンショット 2019-12-23 4.03.42.png

無事に追加したらチェックをつけましょう。

スクリーンショット 2019-12-23 4.03.53.png

これで準備は完了です。

フォントのインストール / アンインストール

1. フォントをインストールする

いくつかの工程を挟むため段階的に説明していきます。

① リソースアクセスの確認

フォントをインストールするには、まずリソースにアクセスできるか確認する必要があります。
(しないとコードでエラーが出てしまうため)

アクセスできるかを確認するにはNSBundleResourceRequestで確認します。

private var resourceRequest: NSBundleResourceRequest?

func requestFont(tags: Set<String>, fonts: CFArray) {
    resourceRequest = NSBundleResourceRequest(tags: tags)
    resourceRequest?.conditionallyBeginAccessingResources { [weak self] isAvailable in
        if isAvailable {
            debugPrint("is available")
        } else {
            debugPrint("is not available")
        }
    }
}

1つ目の引数tagsは先ほど追加したリソースタグ名になります。
2つ目の引数fontsはインストールしたいフォント名を引数として渡します。

② リソースにアクセスする

アクセスができない場合は、アクセスできるようにします。
(①の処理は初回でfalseになるとはずなのでこちらを通るはず)

func accessFont(fonts: CFArray) {
    resourceRequest?.beginAccessingResources { [weak self] error in
        if error == nil {
            debugPrint("success")
        } else {
            debugPrint("failure", error?.localizedDescription ?? "")
        }
        self?.resourceRequest?.endAccessingResources()
    }
}

③ インストールする

iOS13から追加されたCTFontManagerRegisterFontsWithAssetNamesを使用してインストールします。

func installFont(fonts: CFArray) {
    CTFontManagerRegisterFontsWithAssetNames(fonts, CFBundleGetMainBundle(), .persistent, true) { errors, _ -> Bool in
        if 1 <= CFArrayGetCount(errors) {
            debugPrint("font install failure: \(unsafeBitCast(CFArrayGetValueAtIndex(errors, 0), to: CFError.self).localizedDescription)")
            return false
        } else {
            debugPrint("font install success")
            return true
        }
    }
}

引数にはインストールしたいフォント名を引数として渡します。
エラーをみたい場合はunsafeBitCastで取り出す必要があります。

総括

①~③のコードを連携させたものです。
(単発のコード群だったので、ちゃんと機能するものを記載しておきます。)

private var resourceRequest: NSBundleResourceRequest?

func requestFont(tags: Set<String>, fonts: CFArray) {
    resourceRequest = NSBundleResourceRequest(tags: tags)
    resourceRequest?.conditionallyBeginAccessingResources { [weak self] isAvailable in
        if isAvailable {
            debugPrint("is available")
            self?.installFont(fonts: fonts)
        } else {
            debugPrint("is not available")
            self?.accessFont(fonts: fonts)
        }
    }
}

func accessFont(fonts: CFArray) {
    resourceRequest?.beginAccessingResources { [weak self] error in
        if error == nil {
            self?.installFont(fonts: fonts)
        } else {
            debugPrint("failure", error?.localizedDescription ?? "")
        }
        self?.resourceRequest?.endAccessingResources()
    }
}

func installFont(fonts: CFArray) {
    CTFontManagerRegisterFontsWithAssetNames(fonts, CFBundleGetMainBundle(), .persistent, true) { errors, _ -> Bool in
        if 1 <= CFArrayGetCount(errors) {
            debugPrint("font install failure: \(unsafeBitCast(CFArrayGetValueAtIndex(errors, 0), to: CFError.self).localizedDescription)")
            return false
        } else {
            debugPrint("font install success")
            return true
        }
    }
}

実際の実行コード例は以下になります。

let fontList = ["NotoSans-Black"] as CFArray
let assetList: Set<String> = ["Font"]
requestFont(tags: assetList, fonts: fontList)

実際に設定画面で確認してみるとインストールされていることが確認できます。

スクリーンショット 2019-12-23 4.33.17 2.png

この設定画面からアンインストールすることも可能ですが、次はコードからの実装をみてみましょう。

2. フォントをアンインストールする

アンインストール用の型が決まっているため、それを用意するためにStringのExtensionを用意しました。
CTFontDescriptorの配列を用意する必要があるため、フォント名から取得できるようにStringから拡張できるようにしています。
(個人的な好みでExtensionにしたので、メソッドでもかまいません)

extension String {

    var fontDescriptor: CTFontDescriptor? {
        return (CTFontManagerCopyRegisteredFontDescriptors(.persistent, true) as? [CTFontDescriptor])?.first {
            CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String == self
        }
    }
}

CTFontManagerCopyRegisteredFontDescriptorsは後々の文章で説明しますが、行なっていることとしては、
「指定したフォント名がインストールされていればCTFontDescriptorを返す」
というExtensionになっています。

では、実際のアンインストールコードをみていきます。

func uninstall(fontDescriptors: [CTFontDescriptor]) {
    CTFontManagerUnregisterFontDescriptors(fontDescriptors as CFArray, .persistent) { errors, _ -> Bool in
        if 1 <= CFArrayGetCount(errors) {
            debugPrint("font uninstall failure: \(unsafeBitCast(CFArrayGetValueAtIndex(errors, 0), to: CFError.self).localizedDescription)")
            return false
        } else {
            debugPrint("font uninstall success")
            return true
        }
    }
}

お気付きの方もいるかもしれませんが、先ほどのインストールメソッドとほとんど変わりません。
実際の実行コード例は以下になります。

let fontList = ["NotoSans-Black"]
let fontDescriptors = fontList.compactMap { $0.fontDescriptor }
uninstall(fontDescriptors: fontDescriptors)

先ほどと同様に設定画面からフォントがアンインストールされているか確認することができます。

フォントをアプリ内で使用する

1. フォントを参照する

CTFontManagerCopyRegisteredFontDescriptors
でそのアプリでいれたフォントを参照することができます。

ただし
他のアプリや外部からインストールしたものは参照できない!!!
ので注意してください。

例として「Noto Sans」の「NotoSans-Bold」をアプリからインストールしていた場合は以下のようになります。

let descriptors = CTFontManagerCopyRegisteredFontDescriptors(.persistent, true) as? [CTFontDescriptor]

print(descriptors ?? [])

printの内容は以下

[UICTFontDescriptor <0x6000026827c0> = {
    NSCTFontFileURLAttribute = "file:///Users/XXXXXX/Library/Developer/CoreSimulator/Devices/60946E20-27DB-42F6-BAA1-35ABB6308F7B/data/Containers/Data/Application/E6695D3B-D062-4C35-9AEE-48DC6F50CD03/tmp/NotoSans-Bold-C91C8631-7E83-45F7-AD21-EC0502A772C5";
    NSFontFamilyAttribute = "Noto Sans";
    NSFontNameAttribute = "NotoSans-Bold";
}]

// ※ XXXXXXはユーザー名

このようにインストール済みのフォント情報を配列で参照することができます。

2. フォントを使用する

取り出したUICTFontDescriptorUIFontでそのまま使用できます。

let descriptors = CTFontManagerCopyRegisteredFontDescriptors(.persistent, true) as? [CTFontDescriptor]

/// 今回はとりあえず1つなので先頭のものを取り出しています。
guard let notoSansBoldDescriptor = descriptors?.first else { return }

label.font = UIFont(descriptor: notoSansBoldDescriptor, size: 30.0)

実際のところフォント情報の配列で返ってくるので、WWDCの動画にもあった「フォント名」をStringの配列にして保持しておくのが良いかと思います。

(以下動画の抜粋)
スクリーンショット 2019-11-04 14.26.17.png

先の自分が記載したコードでは1つのフォント(descriptors?.firstの部分)しか取りだしていませんが、このように「フォント名」をStringの配列で保持することで、必要なフォントを選択できるような実装が可能です。

CTFontDescriptorCopyAttribute を使用することで CTFontDescriptorからフォントの名前だけを取り出すことが可能です。

先のコードの延長として簡易なコードを記載しておきます。

// インストール済みフォント一覧取得
let descriptors = CTFontManagerCopyRegisteredFontDescriptors(.persistent, true) as? [CTFontDescriptor]

// Descriptor の取得
guard let notoSansBoldDescriptor = descriptors?.first else { return }

// Font名 の取得
guard let notoSansBold = CTFontDescriptorCopyAttribute(notoSansBoldDescriptor, kCTFontNameAttribute) as? String else { return }

label.font = UIFont(name: notoSansBold, size: 30.0)

フォントに使用期限を設ける

主にサブスクリプションなどを行う際に必要になるかと思います。
設定することでOS側でフォントを消してくれるようになります。

実際に設定すると、その期限になった時にアラートがでるようになります。

スクリーンショット 2019-12-23 6.27.47.png

設定するためには、info.plistに追加の記載が必要になります。

スクリーンショット 2019-12-23 6.20.03.png

この追加する「FontProviderSubscriptionSupportInfo」ですが、
公式に公開されているAPIではないので補完されてでてきません
なので、plistにコードで直接追加してください。

スクリーンショット 2019-12-23 6.20.12.png

設定項目としては

- warn: 警告を表示するまでの日数
- expire: フォントを削除するまでの日数
- url: openを押した際のscheme設定
- test: テストモードのon/off

となっており、この日数はフォントをインストールしてから換算されます。

warnで設定したアラートを無視し続けると、いずれexpireで設定した日にアラートが出てフォントは自動で削除されます。
testをYesにすると、warn/expireで設定した1日が1分換算になり、すぐテストできるようになります。(ただしシミュレータのみ)

終わりに

iOS13ではSwiftUIやCombineに目がいきがちですが、、、
こういったデザイン周りに関わってくるアップデートもたくさんあり、着目してみると面白いものです。

まだ、情報としてあまり出回っていないので、面白い知見があれば是非お教え願いたいです!

サクッと動作するものを置いておきます
Github: Font_Install_Demo

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UICollectionViewCompositionalLayout & DiffableDataSourceを利用したUIとCombineを利用したMVVMパターンでのAPI通信関連処理との組み合わせた実装の紹介とまとめ

1. はじめに

皆様お疲れ様です。「iOS Advent Calendar」の24日目を担当させて頂きます、fumiyasac(Fumiya Sakai)と申します。何卒よろしくお願い致します。

まずは僕自身の今年のトピックスとしては、技術書典5で頒布した書籍の商業化・業務では新たな現場での新規iOSアプリ開発を通じてRxSwift・Laravel・Nuxt.jsに触れる機会・新たな書籍の執筆&技術系同人誌イベントの参加・iOSDCからリジェクトコンでの2日連続での登壇...等々と昨年以上に変化とバラエティに富んだ1年ではありましたが、何とか楽しく過ごせておりました。

また今年のWWDC19では、WWDC19で押さえておきたいと思ったセッション10選でもまとめられているように、SwiftUIをはじめとして様々な新機能が紹介されたこともありキャッチアップしたいトピックがたくさんありました。

今回はその中でも僕が特に気になった、

  • UICollectionViewCompositionalLayout
  • DiffableDataSource
  • Combine

の3つのトピックに焦点を当てて、これらを活用した 「UICollectionViewを利用した複雑な画面レイアウトを構成する必要があるUI実装事例」 及び 「Combine+MVVMパターンを利用したAPI通信を利用したデータ取得から画面への反映までの処理の実装事例」 をある程度の形にまとめたUIサンプル実装を通して紹介できればと思います。

【以前登壇した際の発表資料】

今回の内容(主に2.〜 5.のセクションで解説している内容)につきましては、potatotips #66 (iOS/Android開発Tips共有会)にて登壇の際に利用した資料を下記のリンクにて共有しておりますので、こちらも是非ご活用頂けますと幸いです。

【Githubで公開しているサンプルコード】

この記事で紹介しているサンプルについては下記の2つになります。どちらも画面数や機能は多くはありませんが、どちらもiOS13以降の新機能となる、UICollectionViewCompositionalLayout / NSDiffableDataSource / Combineを活用して普段の業務で利用しているものに少し近しい形にまとめてみたものになります。

※ 「もっとこうした方が良い」というご意見があったり「この実装はあまりよろしくない」等のご意見等が御座いましたらIssueやPullRequest等をお送り頂けますと幸いです!

2. サンプル概要について

本記事では、解説に当たって2種類のサンプルを準備しました。表現しているデザインは異なりますが、アーキテクチャの基本方針は類似した形にしています。

⭐️2-1. サンプル紹介(ComplexCollectionViewStyleExample)

こちらのサンプルについては、

  1. UICollectionViewCompositionalLayoutを活用した少し複雑なレイアウトへの構築
  2. 異なるセクションで取り得るセルのデザインや表示データが異なる場合の表示

をテーマとしたサンプルになります。

【画面デザイン】

sample1_thumbnail1.jpg

sample1_thumbnail2.jpg

【利用したライブラリ】

ライブラリ名 ライブラリの機能概要
Nuke 画像キャッシュ用のライブラリ
FontAwesome.swift 「Font Awesome」アイコンを利用するためのライブラリ
ActiveLabel.swift 押下可能なURLリンク・ハッシュタグ・メンション要素等を作りやすくするライブラリ

⭐️2-2. サンプル紹介(DiffableDataSourceExample)

こちらのサンプルについては、UICollectionViewCompositionalLayoutを利用してPinterestの様なレイアウトを構築する点に加えて、

  1. UICollectionView及びUITableViewでDiffableDataSourceを利用した実装
  2. 頻出のPullToRefreshやスクロール最下部到達時の追加読み込み

を実現してみました。

※ 前述のサンプルよりはシンプルな構成となっています。

【画面デザイン】

sample2_thumbnail1.jpg

sample2_thumbnail2.jpg

【利用したライブラリ】

ライブラリ名 ライブラリの機能概要
PTCardTabBar DesignicなTabBarを実現するライブラリ
AlamofireImage 画像キャッシュ用ライブラリ

⭐️2-3. サンプルに関する補足事項

サンプルで利用しているAPIモックサーバーについて:

今回紹介しているサンプルについては、検証用Mockサーバーをnode.js製の「json-server」を利用しています。

※ 動作方法と環境構築方法については各サンプルのREADMEを参照して下さい。

環境やバージョンについて:

  • Xcode 11.1
  • Swift 5.1
  • MacOS Catalina (Ver10.15.1)

3. UICollectionViewCompositionalLayoutを活用してSectionごとにバリエーションの異なるセルのデザインを構築する

UICollectionViewCompositionalLayoutを活用した場合の大きなメリットとしては、UICollectionViewを利用した複雑なレイアウトを構築する際にも、アプローチがしやすい形になった点だと個人的に感じています。

よくお目にかかるのですぐできるのでは?感じるレイアウトであっても、いざUICollectionViewで構築してみるとなかなか一手間加えないと難しかったという経験はあるかと思います(僕もこのような経験をすることはしばしばあります...?)。しかしUICollectionViewCompositionalLayoutでの実装で置き換えると、従来の実装よりも構築時のイメージがし易くかつシンプルな形で落とし込む事ができる場合も多いと思います。

ここでは、UICollectionViewCompositionalLayoutの実装やレイアウトを考える際に押さえておくと良さそうな点を、実際のレイアウト構築事例を交えながら解説していきます。

⭐️3-1. 1つの画面の中に異なる属性の要素が多数存在する場合を考える

まずはUICollectionViewを利用した実装において、下図のような構造を例に考えてみます。頑張って単一のUICollectionViewとSectionを利用しても実現できるかもしれませんが、各要素毎に複雑なレイアウトの実装が必要な場合やデータ取得先が異なる場合においては、表示要素を小さな単位で切り出すことが多いかと思います。

uicollectionview_complex_layout.png

また、このような画面を構築する際のアプローチの方針の例として、

  • UITableView + UICollectionViewの組み合わせで実現するアプローチ
  • ContainerView + UICollectionViewの組み合わせで実現するアプローチ
  • 「IGListKit」等のライブラリを利用した差分更新と構造管理をするアプローチ

等の選択肢が考えられると思いますが、必要以上に画面を構成するための表示要素が増えると管理が煩雑になってしまう点やレイアウトや表示の整合性を合わせる処理の難易度が上がってしまう場合もありそうです。

このような問題を上手に解決する際のアプローチとしてUICollectionViewCompositionalLayoutを活用するアプローチは今後は主流になっていきそうにも感じています。

⭐️3-2. UICollectionViewCompositionalLayoutにおけるポイントになる部分とレイアウト構築時における考え方

UICollectionViewCompositionalLayoutを利用したUI実装をする場合に、従来までの実装方法と大きく変わる点を簡潔にまとめると、

  1. UICollectionViewCompositionalLayoutを利用したSection毎に定義したレイアウトを組み立てて適用する処理の実装方法
  2. UICollectionViewDiffableDataSourceを利用した各種セル表示要素とDataSourceの実装方法
  3. NSDiffableDataSourceSnapshotを利用した差分更新が考慮された表示要素の反映方法

の3点になります。クラス名も長いので一見すると複雑そうな印象がありますが、実際に表示データやレイアウトを組み立てていく処理を紐解いていくと、個人的な所管にはなりますがセクション毎の構成がつかみやすく、とても美しい構成だと感じています。

【構成要素や概要に関するポイント】

改めて前述した、UICollectionViewCompositionalLayoutを利用したUI実装をする場合において利用するクラスと役割をまとめると下図の様な形になります。

fundamental_point.png

UIに表示するためのデータを格納して管理するNSDiffableDataSourceSnapShot及びデータ反映のためのUICollectionViewDiffableDataSourceについては、セクション毎に表示対象のModelにおいて、データの型が異なる場合でもHashableに適合していれば対応できる形にしています。

この点を踏まえた、本サンプル(ComplexCollectionViewStyleExample)における実装部分の概要をまとめると下記のような形になります。

MainViewController.swift
// MEMO: セクション毎に定義したEnum値

enum MainSection: Int, CaseIterable {
    case FeaturedBanners
    case FeaturedInterviews
    case RecentKeywords
    case NewArrivalArticles
    case RegularArticles
}

// ① UICollectionViewを差分更新するためのNSDiffableDataSourceSnapshot
// → Section毎に表示するModelデータ定義が違うが、Hashableプロトコルに適合している必要がある

private var snapshot: NSDiffableDataSourceSnapshot<MainSection, AnyHashable>!

// ② UICollectionViewを組み立てるためのDataSource
// → Section毎に表示するModelデータ定義が違うが、Hashableプロトコルに適合している必要がある

private var dataSource: UICollectionViewDiffableDataSource<MainSection, AnyHashable>! = nil

// ③ UICollectionViewCompositionalLayoutの設定
// → Section毎に定義したレイアウトを適用する

private lazy var compositionalLayout: UICollectionViewCompositionalLayout = {
    let layout = UICollectionViewCompositionalLayout { [weak self] (sectionIndex: Int, layoutEnvironment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in

        // MainSection毎に定義したレイアウトを適用する
        // → デザインに応じてNSLayoutCollectionを組み立てる

        switch sectionIndex {

        // MainSection: 0 (FeaturedBanners)
        case MainSection.FeaturedBanners.rawValue:
            return self?.createFeaturedBannersLayout()

        // MainSection: 1 (FeaturedInterviews)
        case MainSection.FeaturedInterviews.rawValue:
            return self?.createFeaturedInterviewsLayout()

        // MainSection: 2 (RecentKeywords)
        case MainSection.RecentKeywords.rawValue:
            return self?.createRecentKeywordsLayout()

        // MainSection: 3 (NewArrivalArticles)
        case MainSection.NewArrivalArticles.rawValue:
            return self?.createNewArrivalArticles()

        // MainSection: 4 (RegularArticles)
        case MainSection.RegularArticles.rawValue:
            return self?.createRegularArticles()

        default:
            fatalError()
        }
    }
    return layout
}()

【セル要素・Header・Footer部分の組み立てる場合のポイント】

セル要素を組み立てる処理はUICollectionViewDiffableDataSourceを利用する形になりますが、実際にセルを組み立てる処理についてはクロージャー内にセル要素を組み立てる処理を記載する形となります。

UICollectionViewDiffableDataSource<MainSection, AnyHashable>(collectionView: collectionView) { (collectionView: UICollectionView, indexPath: IndexPath, model: AnyHashable) -> UICollectionViewCell? in ...

    // MEMO: この中にセルを組み立てるための処理を記載する
    // → Section毎に定義するModelが異なる場合にはModelの型で判定する

    // (例) let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! ExampleCollectionViewCell

}

また任意のセクションの中にHeader・Footerが必要な場合には、UICollectionViewDiffableDataSourceのsupplementaryViewProviderプロパティのクロージャー内にHeader・Footer用のUICollectionReusableViewを継承したView要素を組み立てる処理を記載する形となります。

MainViewController.swift
dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, kind: String, indexPath: IndexPath) -> UICollectionReusableView? in

    // MEMO: Header・Footerを組み立てるための処理を記載する
    // → indexPath.sectionでセクションを判定 & kindでelementKindSectionHeader(Footer)を判定

    // (例) let header = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "Header", for: indexPath) as! ExampleCollectionHeaderView
}

この点を踏まえた、本サンプル(ComplexCollectionViewStyleExample)におけるセル要素を組み立てる処理の概要をまとめると下記のような形になります。

MainViewController.swift
final class MainViewController: UIViewController {

    ・・・(省略)・・・

    // MARK: - Override

    override func viewDidLoad() {
        super.viewDidLoad()

        setupCollectionView()

       ・・・(省略)・・・   
    }

    ・・・(省略)・・・

    private func setupCollectionView() {

        // このレイアウトで利用するセル要素・Header・Footerの登録

        // MainSection: 0 (FeaturedBanner)
        collectionView.registerCustomCell(FeaturedCollectionViewCell.self)

        // MainSection: 1 (FeaturedInterview)
        collectionView.registerCustomCell(FeaturedInterviewCollectionViewCell.self)

        // MainSection: 2 (RecentKeyword)
        collectionView.registerCustomCell(KeywordCollectionViewCell.self)
        collectionView.registerCustomReusableHeaderView(KeywordCollectionHeaderView.self)
        collectionView.registerCustomReusableFooterView(KeywordCollectionFooterView.self)

        // MainSection: 3 (NewArrivalArticle)
        collectionView.registerCustomCell(NewArrivalCollectionViewCell.self)
        collectionView.registerCustomCell(PhotoCollectionViewCell.self)
        collectionView.registerCustomReusableHeaderView(NewArrivalCollectionHeaderView.self)

        // MainSection: 4 (RegularArticle)
        collectionView.registerCustomCell(ArticleCollectionViewCell.self)
        collectionView.registerCustomReusableHeaderView(ArticleCollectionHeaderView.self)

        // UICollectionViewDelegateについては従来通り
        collectionView.delegate = self

        // UICollectionViewCompositionalLayoutを利用してレイアウトを組み立てる
        collectionView.collectionViewLayout = compositionalLayout

        // DataSourceはUICollectionViewDiffableDataSourceを利用してUICollectionViewCellを継承したクラスを組み立てる
        dataSource = UICollectionViewDiffableDataSource<MainSection, AnyHashable>(collectionView: collectionView) { (collectionView: UICollectionView, indexPath: IndexPath, model: AnyHashable) -> UICollectionViewCell? in

            switch model {

            // MainSection: 0 (FeaturedBanner)
            case let model as FeaturedBanner:

                let cell = collectionView.dequeueReusableCustomCell(with: FeaturedCollectionViewCell.self, indexPath: indexPath)
                cell.setCell(model)
                return cell

            // MainSection: 1 (FeaturedInterview)
            case let model as FeaturedInterview:

                let cell = collectionView.dequeueReusableCustomCell(with: FeaturedInterviewCollectionViewCell.self, indexPath: indexPath)
                cell.setCell(model)
                return cell

            // MainSection: 2 (RecentKeyword)
            case let model as Keyword:

                let cell = collectionView.dequeueReusableCustomCell(with: KeywordCollectionViewCell.self, indexPath: indexPath)
                cell.setCell(model)
                return cell

            // MainSection: 3 (NewArrivalArticle)
            case let model as NewArrival:

                // MEMO: 3で割って1余るインデックス値の場合は大きなサイズのセルを適用する
                if model.id % 3 == 1 {
                    let cell = collectionView.dequeueReusableCustomCell(with: NewArrivalCollectionViewCell.self, indexPath: indexPath)
                    cell.setCell(model, index: indexPath.row + 1)
                    return cell
                } else {
                    let cell = collectionView.dequeueReusableCustomCell(with: PhotoCollectionViewCell.self, indexPath: indexPath)
                    cell.setCell(model, index: indexPath.row + 1)
                    return cell
                }

            // MainSection: 4 (RegularArticle)
            case let model as Article:

                let cell = collectionView.dequeueReusableCustomCell(with: ArticleCollectionViewCell.self, indexPath: indexPath)
                cell.setCell(model)
                return cell

            default:
                return nil
            }
        }

        // Header・Footerの表記についてもUICollectionViewDiffableDataSourceを利用して組み立てる
        dataSource.supplementaryViewProvider = { (collectionView: UICollectionView, kind: String, indexPath: IndexPath) -> UICollectionReusableView? in

            switch indexPath.section {

            // MainSection: 2 (RecentKeyword)
            case MainSection.RecentKeywords.rawValue:
                if kind == UICollectionView.elementKindSectionHeader {
                    let header = collectionView.dequeueReusableCustomHeaderView(with: KeywordCollectionHeaderView.self, indexPath: indexPath)
                    header.setHeader(
                        title: "最近の「キーワード」をチェック",
                        description: "テレビ番組で人気のお店や特別な日に使える情報をたくさん掲載しております。気になるキーワードはあるけれども「あのお店なんだっけ?」というのが具体的に思い出せない場面が結構あると思います。最新情報に早めにキャッチアップしたい方におすすめです!"
                    )
                    return header
                }
                if kind == UICollectionView.elementKindSectionFooter {
                    let footer = collectionView.dequeueReusableCustomFooterView(with: KeywordCollectionFooterView.self, indexPath: indexPath)
                    return footer
                }

            // MainSection: 3 (NewArrivalArticle)
            case MainSection.NewArrivalArticles.rawValue:
                if kind == UICollectionView.elementKindSectionHeader {
                    let header = collectionView.dequeueReusableCustomHeaderView(with: NewArrivalCollectionHeaderView.self, indexPath: indexPath)
                    header.setHeader(
                        title: "新着メニューの紹介",
                        description: "アプリでご紹介しているお店の新着メニューを紹介しています。新しいお店の発掘やさらなる行きつけのお店の魅力を見つけられるかもしれません。"
                    )
                    return header
                }

            // MainSection: 4 (RegularArticle)
            case MainSection.RegularArticles.rawValue:
                if kind == UICollectionView.elementKindSectionHeader {
                    let header = collectionView.dequeueReusableCustomHeaderView(with: ArticleCollectionHeaderView.self, indexPath: indexPath)
                    header.setHeader(
                        title: "おすすめ記事一覧",
                        description: "よく行くお店からこちらで厳選してみました。というつもりです…。でも結構美味しそうなのではないかと思いますよので是非ともご堪能してみてはいかがでしょうか?"
                    )
                    return header
                }

            default:
                break
            }
            return nil
        }

        ・・・(省略)・・・
    }

    ・・・(省略)・・・
}

※ この部分はもっと実装を整理できる余地がある部分かと思います...?

【UICollectionViewCompositionalLayoutのレイアウト作成時のポイント】

UICollectionViewCompositionalLayoutのレイアウトを組み立てていく際には、レイアウトを構成する4つの要素 「Layout / Section / Group / Item」 の関係に注目して、NSCollectionLayoutSizeを設定していく点がポイントになるかと思います。

uicollectionviewcompositional_layout_fundamental.png

また、本サンプル(ComplexCollectionViewStyleExample)で1つのUICollectionViewに配置しているセクション構築のバリエーションは下記のような形になります。従来の実装方法ではレイアウトが複雑な表現がそれぞれのセクションで展開される形はなかなか実現がしんどく感じることが多い場合もありますが、UICollectionViewCompositionalLayoutのおかげで綺麗にまとめやすい形なのは嬉しいですね。

uicollectionviewcompositional_layout_pattern.png

⭐️3-3. (レイアウト例1) Section内の表示セル要素が横方向にスクロールする表現

まずは、バナー表示カルーセルの様なスクロールをするレイアウト及び、キーワード一覧を横に並べてスクロールを伴う形にするレイアウトを実現するためのコードは下記の様な形になります。セクションを構築する際には「Item → Group → Section」という順番でレイアウトを考えていくとイメージがよりしやすいのではないかと思います。スクロールのバリエーションについてもorthogonalScrollingBehaviorプロパティで決定可能である点や、contentInsetsプロパティを利用した間隔調整もItem・Group・ Sectionで可能な点を活用してより柔軟なレイアウトの構成ができます。

MainViewController.swift
// ① バナー表示カルーセル表現をするレイアウト構築例
private func createFeaturedBannersLayout() -> NSCollectionLayoutSection {

    // 1. Itemのサイズ設定
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(1.0))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    item.contentInsets = .zero

    // 2. Groupのサイズ設定
    // MEMO: 1列に表示するカラム数を1として設定し、itemのサイズがgroupのサイズで決定する形にしている
    let groupHeight = UIScreen.main.bounds.width * (3 / 8)
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(groupHeight))
    let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitem: item, count: 1)
    group.contentInsets = .zero

    // 3. Sectionのサイズ設定
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)
    // MEMO: スクロール終了時に水平方向のスクロールが可能で中心位置で止まる
    section.orthogonalScrollingBehavior = .groupPagingCentered
    return section
}

// ② キーワード一覧を横に並べてスクロールを伴う表現をするレイアウト構築例
private func createRecentKeywordsLayout() -> NSCollectionLayoutSection {

    // 1. Itemのサイズ設定
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    item.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 6, bottom: 0, trailing: 6)

    // 2. Groupのサイズ設定
    // MEMO: 1列に表示するカラム数を1として設定し、itemのサイズがgroupのサイズで決定する形にしている
    let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(160), heightDimension: .absolute(40))
    let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitem: item, count: 1)

    // 3. Sectionのサイズ設定
    let section = NSCollectionLayoutSection(group: group)
    // MEMO: HeaderとFooterのレイアウトを決定する
    let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(65.0))
    let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
    let footerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(28.0))
    let footer = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: footerSize, elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom)
    section.boundarySupplementaryItems = [header, footer]
    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 6, bottom: 16, trailing: 6)
    // MEMO: スクロール終了時に水平方向のスクロールが可能で速度が0になった位置で止まる
    section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary

    return section
}

⭐️3-4. (レイアウト例2) Instagramのフィード表示のようなDynamicHeightSizing

次にInstagramのフィード表示のような1行のセル表示でDynamicHeightSizing(高さが可変になる)表現を考えてみます。高さを可変にしたい場合には、ItemとGroupのサイズを設定する際に高さを予測値を一番データ表示が少ない場合の高さを設定すると良いかと思います。

dynamic_sizing_example.png

MainViewController.swift
private func createFeaturedInterviewsLayout() -> NSCollectionLayoutSection {

    // MEMO: 該当のセルを基準にした高さの予測値を設定する
    let estimatedHeight = UIScreen.main.bounds.width + 180.0

    // 1. Itemのサイズ設定
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(estimatedHeight))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    item.contentInsets = .zero

    // 2. Groupのサイズ設定
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(estimatedHeight))
    let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
    group.contentInsets = .zero

    // 3. Sectionのサイズ設定
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)

    return section
}

⭐️3-5. (レイアウト例3) Instagramの写真表示のようなMosaicLayout

もう一つUICollectionViewの複雑なレイアウトの実装例としてInstagramの写真表示のようなMosaicLayoutの表現を考えてみます。Groupの入れ子構造を組み合わせてレイアウトを組み立てていく点がポイントになります。

mosaic_layout_example.png

MainViewController.swift
private func createNewArrivalArticles() -> NSCollectionLayoutSection {

    // 1. Itemのサイズ設定
    // MEMO: 全体幅2/3の正方形を作るために左側の幅を.fractionalWidth(0.67)に決める
    let twoThirdItemSet = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.67), heightDimension: .fractionalHeight(1.0)))
    twoThirdItemSet.contentInsets = NSDirectionalEdgeInsets(top: 0.5, leading: 0.5, bottom: 0.5, trailing: 0.5)
    // MEMO: 右側に全体幅1/3の正方形を2つ作るために高さを.fractionalHeight(0.5)に決める
    let oneThirdItem = NSCollectionLayoutItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.5)))
    oneThirdItem.contentInsets = NSDirectionalEdgeInsets(top: 0.5, leading: 0.5, bottom: 0.5, trailing: 0.5)
    // MEMO: 1列に表示するカラム数を2として設定し、Group内のアイテムの幅を1/3の正方形とするためにGroup内の幅を.fractionalWidth(0.33)に決める
    let oneThirdItemSet = NSCollectionLayoutGroup.vertical(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.33), heightDimension: .fractionalHeight(1.0)), subitem: oneThirdItem, count: 2)

    // 2. Groupのサイズ設定
    // MEMO: leadingItem(左側へ表示するアイテム1つ)とtrailingGroup(右側へ表示するアイテム2個のグループ1個)を合わせる
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.33)), subitems: [twoThirdItemSet, oneThirdItemSet])

    // 3. Sectionのサイズ設定
    let section = NSCollectionLayoutSection(group: group)
    // MEMO: HeaderとFooterのレイアウトを決定する
    let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
    let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
    section.boundarySupplementaryItems = [header]
    section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 16, trailing: 0)

    return section
}

4. DiffableDataSourceを利用した表示内容の反映と表示用のModelデータに関する部分について

次にDiffableDataSourceを利用した処理に関する部分にも触れてみます。UICollectionViewにおけるDataSourceの更新を反映する処理はreloadData()をはじめ頻出の実装ではありますが、用件や更新タイミングに関する処理がシビアで複雑な場合は、「データとUIの表示状態の食い違い」に注意が必要でした。

新しく登場したDiffableDataSourceは、従来までのperformBatchUpdatesを利用した処理でも難しかった「データとUIの表示状態の食い違いの防止」を内部で解決してくれる点も大きな魅力の1つかと思います。

※iOS13以降であれば、UITableViewを利用した場合でもNSDiffableDataSourceを利用する事が可能です。

⭐️4-1. NSDiffableDataSourceを利用する際における基本的なデータの更新方法

取得したデータの取得〜データの反映までの流れを簡潔にまとめると、

  1. NSDiffableDataSourceSnapshotに定義したセクションに該当するデータをセットする
  2. UICollectionViewDiffableDataSourceのapplyメソッドでDiffableDataSourceSnapshotの内容を反映する

となります。下記は、本サンプル(ComplexCollectionViewStyleExample)におけるセル要素を取得して反映させる処理部分のコードを抜粋したものになります。

MainViewController.swift
// ① NSDiffableDataSourceSnapshotの初期設定
// → Section毎のEnum定義(MainSection)に応じて表示するModelデータ定義が違うが、Hashableプロトコルに適合している必要がある

snapshot = NSDiffableDataSourceSnapshot<MainSection, AnyHashable>()
snapshot.appendSections(MainSection.allCases)
for mainSection in MainSection.allCases {
    snapshot.appendItems([], toSection: mainSection)
}
dataSource.apply(snapshot, animatingDifferences: false)

// ② UICollectionViewを差分更新するためのNSDiffableDataSourceSnapshotの更新
// → APIからのデータ取得ができた際に該当セクションの値を更新してUICollectionViewDiffableDataSource<MainSection, AnyHashable>に反映する
// 補足: 更新時のアニメーション可否はanimatingDifferencesで行う

let featuredInterviews: [FeaturedInterview] = receiverdFeaturedInterviews
snapshot.appendItems(featuredInterviews, toSection: .FeaturedInterviews)
dataSource.apply(snapshot, animatingDifferences: false)

⭐️4-2. NSDiffableDataSourceを利用する際におけるModel作成時におけるポイント

APIモックサーバーを経由して取得するUIに表示するデータについては、JSON経由で取得する想定で作成しているのでDecodableに適合させている点に加えて、NSDiffableDataSourceで利用可能な形にするためにHashableにも適合させる必要があります。
※ 今回は取得したデータをシンプルにUIに反映させるだけの処理なので、IDをハッシュに設定しています。

各セクションで表示データのModel定義及びAPIモックサーバーのエンドポイントは異なりますが、基本的にはDecodable, Hashableに適合した形でJSONの形に合わせた定義としています。下記は、本サンプル(ComplexCollectionViewStyleExample)におけるModel定義の例を抜粋したものになります。

FeaturedInterview.swift
struct FeaturedInterview: Hashable, Decodable {

    let id: Int
    let profileName: String
    let dateString: String
    let imageUrl: String
    let title: String
    let description: String
    let tags: String

    // MARK: - Enum

    private enum Keys: String, CodingKey {
        case id
        case profileName = "profile_name"
        case dateString = "date_string"
        case imageUrl = "image_url"
        case title
        case description
        case tags
    }

    // MARK: - Initializer

    init(from decoder: Decoder) throws {

        // JSONの配列内の要素を取得する
        let container = try decoder.container(keyedBy: Keys.self)

        // JSONの配列内の要素にある値をDecodeして初期化する
        self.id = try container.decode(Int.self, forKey: .id)
        self.profileName = try container.decode(String.self, forKey: .profileName)
        self.dateString = try container.decode(String.self, forKey: .dateString)
        self.imageUrl = try container.decode(String.self, forKey: .imageUrl)
        self.title = try container.decode(String.self, forKey: .title)
        self.description = try container.decode(String.self, forKey: .description)
        self.tags = try container.decode(String.self, forKey: .tags)
    }

    // MARK: - Hashable

    // MEMO: Hashableプロトコルに適合させるための処理

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (lhs: FeaturedInterview, rhs: FeaturedInterview) -> Bool {
        return lhs.id == rhs.id
    }
}

5. Combineを利用してAPIリクエストをMVVMパターンでハンドリングする部分について

こちらも、iOS13から新しく登場したCombineを利用したAPIリクエストをハンドリングするための実装をしています。今年からの実務では「RxSwift + MVVM + ViewModelへのアクセス時に入力(Input)・出力(Output)を明記する」構成でのiOSアプリ開発に触れる時間が多かったので、RxSwiftの部分をCombineを利用した実装にリプレイスしていく方針を試してみました。

combine_rxswift_mvvm.png

Kickstarter-iOSで利用しているViewModelの設計と実装については、下記の資料も参考にするとより理解が深まるかと思います。

⭐️5-1. API通信処理の部分をCombineを利用した実装解説

API通信処理部分をRxSwiftで実装する場合には、Single<T>を利用して成功か失敗かのいずれかのイベントを1度だけ流すことを保証するオペレータを活用した実装や、Alamofireをラップしたライブラリの「Moya」を活用する選択をすることが多いかと思いますが、CombineではSingle<T>と類似した振る舞いをするFuture<Output, Failure>を利用してAPI通信部分の処理を組み立てています。

ここに加えて、それぞれ異なるModel定義に合致したJSONレスポンスの形にうまく対応させるために、T: Decodable & HashableのGenericsにしている点もポイントになります。

これらの点を踏まえた、本サンプル(ComplexCollectionViewStyleExample)におけるAPI通信処理に関する実装をまとめると下記のような形になります。

APIRequestManager.swift
import Foundation
import Combine

// MARK: - Protocol

enum APIError : Error {
    case error(String)
}

protocol APIRequestManagerProtocol {
    func getFeaturedBanners() -> Future<[FeaturedBanner], APIError>
    func getFeaturedInterviews() -> Future<[FeaturedInterview], APIError>
    func getKeywords() -> Future<[Keyword], APIError>
    func getNewArrivals() -> Future<[NewArrival], APIError>
    func getArticles() -> Future<[Article], APIError>
}

class APIRequestManager {

    // MEMO: MockサーバーへのURLに関する情報
    private static let host = "http://localhost:3000/api/mock"
    private static let version = "v1"
    private static let path = "gourmet"

    private let session = URLSession.shared

    // MARK: - Singleton Instance

    static let shared = APIRequestManager()

    private init() {}

    // MARK: - Enum

    private enum EndPoint: String {

        case featuredBanner = "featured_banners"
        case featuredInterview = "featured_interviews"
        case keyword = "keywords"
        case newArrival = "new_arrivals"
        case article = "articles"

        func getBaseUrl() -> String {
            return [host, version, path, self.rawValue].joined(separator: "/")
        }
    }
}

// MARK: - APIRequestManagerProtocol

extension APIRequestManager: APIRequestManagerProtocol {

    // MARK: - Function

    func getFeaturedBanners() -> Future<[FeaturedBanner], APIError> {
        let featuresdBannersAPIRequest = makeUrlForGetRequest(EndPoint.featuredBanner.getBaseUrl())
        return handleSessionTask(FeaturedBanner.self, request: featuresdBannersAPIRequest)
    }

    ・・・(以降は同様にAPIリクエストを実行する処理を実施する)・・・

    // MARK: - Private Function

    private func handleSessionTask<T: Decodable & Hashable>(_ dataType: T.Type, request: URLRequest) -> Future<[T], APIError> {
        return Future { promise in

            let task = self.session.dataTask(with: request) { data, response, error in
                // MEMO: レスポンス形式やステータスコードを元にしたエラーハンドリングをする
                if let error = error {
                    promise(.failure(APIError.error(error.localizedDescription)))
                    return
                }
                guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
                    promise(.failure(APIError.error("Error: invalid HTTP response code")))
                    return
                }
                guard let data = data else {
                    promise(.failure(APIError.error("Error: missing response data")))
                    return
                }
                // MEMO: 取得できたレスポンスを引数で指定した型の配列に変換して受け取る
                do {
                    let hashableObjects = try JSONDecoder().decode([T].self, from: data)
                    promise(.success(hashableObjects))
                } catch {
                    promise(.failure(APIError.error(error.localizedDescription)))
                }
            }
            task.resume()
        }
    }

    private func makeUrlForGetRequest(_ urlString: String) -> URLRequest {
        guard let url = URL(string: urlString) else {
            fatalError()
        }
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = "GET"
        urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
        return urlRequest
    }
}

⭐️5-2. Combineを利用したModel → ViewModel部分の実装解説

次に、Combineを利用したViewModelについて紹介していきます。Input(何らかの処理を発火させるためのトリガー)Output(処理によって取得できた結果を反映させる変数)の定義を前述したAPI通信処理と組み合わせることによって、

  • 処理の実行: mainViewModel.inputs.●●●Trigger.send()
  • 結果の反映: mainViewModel.outputs.●●●.subscribe(on: RunLoop.main).sink({ ... })

の流れをつくり、ViewControllerにおけるデータの取得処理・反映処理を繋げられる様な形にしています。

本サンプル(ComplexCollectionViewStyleExample)におけるViewModelの実装をまとめると下記のような形になります。

MainViewModel.swift
import Foundation
import Combine

// MARK: - Protocol

protocol MainViewModelInputs {
    var fetchFeaturedBannersTrigger: PassthroughSubject<Void, Never> { get }
    var fetchFeaturedInterviewsTrigger: PassthroughSubject<Void, Never> { get }
    var fetchKeywordsTrigger: PassthroughSubject<Void, Never> { get }
    var fetchNewArrivalsTrigger: PassthroughSubject<Void, Never> { get }
    var fetchArticlesTrigger: PassthroughSubject<Void, Never> { get }
}

protocol MainViewModelOutputs {
    var featuredBanners: AnyPublisher<[FeaturedBanner], Never> { get }
    var featuredInterviews: AnyPublisher<[FeaturedInterview], Never> { get }
    var keywords: AnyPublisher<[Keyword], Never> { get }
    var newArrivals: AnyPublisher<[NewArrival], Never> { get }
    var articles: AnyPublisher<[Article], Never> { get }
}

protocol MainViewModelType {
    var inputs: MainViewModelInputs { get }
    var outputs: MainViewModelOutputs { get }
}

final class MainViewModel: MainViewModelType, MainViewModelInputs, MainViewModelOutputs {

    // MARK: - MainViewModelType

    var inputs: MainViewModelInputs { return self }
    var outputs: MainViewModelOutputs { return self }

    // MARK: - MainViewModelInputs

    let fetchFeaturedBannersTrigger = PassthroughSubject<Void, Never>()
    let fetchFeaturedInterviewsTrigger = PassthroughSubject<Void, Never>()
    let fetchKeywordsTrigger = PassthroughSubject<Void, Never>()
    let fetchNewArrivalsTrigger = PassthroughSubject<Void, Never>()
    let fetchArticlesTrigger = PassthroughSubject<Void, Never>()

    // MARK: - MainViewModelOutputs

    var featuredBanners: AnyPublisher<[FeaturedBanner], Never> {
        return $_featuredBanners.eraseToAnyPublisher()
    }
    var featuredInterviews: AnyPublisher<[FeaturedInterview], Never> {
        return $_featuredInterviews.eraseToAnyPublisher()
    }
    var keywords: AnyPublisher<[Keyword], Never> {
        return $_keywords.eraseToAnyPublisher()
    }
    var newArrivals: AnyPublisher<[NewArrival], Never> {
        return $_newArrivals.eraseToAnyPublisher()
    }
    var articles: AnyPublisher<[Article], Never> {
        return $_articles.eraseToAnyPublisher()
    }

    private let api: APIRequestManagerProtocol

    private var cancellables: [AnyCancellable] = []

    // MARK: - @Published

    // MEMO: このコードではNSDiffableDataSourceSnapshotの差分更新部分で利用する
    @Published private var _featuredBanners: [FeaturedBanner] = []
    @Published private var _featuredInterviews: [FeaturedInterview] = []
    @Published private var _keywords: [Keyword] = []
    @Published private var _newArrivals: [NewArrival] = []
    @Published private var _articles: [Article] = []

    // MARK: - Initializer

    init(api: APIRequestManagerProtocol) {

        // MEMO: 適用するAPIリクエスト用の処理
        self.api = api

        // MEMO: InputTriggerとAPIリクエストをするための処理を結合する
        fetchFeaturedBannersTrigger
            .sink(
                receiveValue: { [weak self] in
                    self?.fetchFeaturedBanners()
                }
            )
            .store(in: &cancellables)
        fetchFeaturedInterviewsTrigger
            .sink(
                receiveValue: { [weak self] in
                    self?.fetchFeaturedInterviews()
                }
            )
            .store(in: &cancellables)
        fetchKeywordsTrigger
            .sink(
                receiveValue: { [weak self] in
                    self?.fetchKeywords()
                }
            )
            .store(in: &cancellables)
        fetchNewArrivalsTrigger
            .sink(
                receiveValue: { [weak self] in
                    self?.fetchNewArrivals()
                }
            )
            .store(in: &cancellables)
        fetchArticlesTrigger
            .sink(
                receiveValue: { [weak self] in
                    self?.fetchArticles()
                }
            )
            .store(in: &cancellables)
    }

    // MARK: - deinit

    deinit {
        cancellables.forEach { $0.cancel() }
    }

    // MARK: - Privete Function

    private func fetchFeaturedBanners() {
        api.getFeaturedBanners()
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    // MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
                    case .finished:
                        print("finished fetchFeaturedBanners(): \(completion)")
                    // MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
                    case .failure(let error):
                        print("error fetchFeaturedBanners(): \(error.localizedDescription)")
                    }
                },
                receiveValue: { [weak self] hashableObjects in
                    print(hashableObjects)
                    self?._featuredBanners = hashableObjects
                }
            )
            .store(in: &cancellables)
    }

    private func fetchFeaturedInterviews() {
        api.getFeaturedInterviews()
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    // MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
                    case .finished:
                        print("finished fetchFeaturedInterviews(): \(completion)")
                    // MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
                    case .failure(let error):
                        print("error fetchFeaturedInterviews(): \(error.localizedDescription)")
                    }
                },
                receiveValue: { [weak self] hashableObjects in
                    print(hashableObjects)
                    self?._featuredInterviews = hashableObjects
                }
            )
            .store(in: &cancellables)
    }

    private func fetchKeywords() {
        api.getKeywords()
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    // MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
                    case .finished:
                        print("finished fetchKeywords(): \(completion)")
                    // MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
                    case .failure(let error):
                        print("error fetchKeywords(): \(error.localizedDescription)")
                    }
                },
                receiveValue: { [weak self] hashableObjects in
                    print(hashableObjects)
                    self?._keywords = hashableObjects
                }
            )
            .store(in: &cancellables)
    }

    private func fetchNewArrivals() {
        api.getNewArrivals()
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    // MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
                    case .finished:
                        print("finished fetchNewArrivals(): \(completion)")
                    // MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
                    case .failure(let error):
                        print("error fetchNewArrivals(): \(error.localizedDescription)")
                    }
                },
                receiveValue: { [weak self] hashableObjects in
                    print(hashableObjects)
                    self?._newArrivals = hashableObjects
                }
            )
            .store(in: &cancellables)
    }

    private func fetchArticles() {
        api.getArticles()
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: { completion in
                    switch completion {
                    // MEMO: 値取得成功時(※本当は厳密にエラーハンドリングする必要がある)
                    case .finished:
                        print("finished getArticles(): \(completion)")
                    // MEMO: エラー時(※本当は厳密にエラーハンドリングする必要がある)
                    case .failure(let error):
                        print("error getArticles(): \(error.localizedDescription)")
                    }
                },
                receiveValue: { [weak self] hashableObjects in
                    print(hashableObjects)
                    self?._articles = hashableObjects
                }
            )
            .store(in: &cancellables)
    }
}

⭐️5-3. Combineを利用したViewModel → ViewController部分の実装解説

最後に、Combineを利用したViewControllerの実装について紹介していきます。ViewModelのOutput定義におけるreceiveValue:の中にNSDiffableDataSourceの更新処理を組み合わせることによって、API通信処理と連動したセクション毎に定義したセルのデータ反映をする形にしています。

本サンプル(ComplexCollectionViewStyleExample)におけるViewControllerの実装をまとめると下記のような形になります。

MainViewController.swift
final class MainViewController: UIViewController {

    // MARK: - Variables

    private var cancellables: [AnyCancellable] = []

    // MEMO: API経由の非同期通信からデータを取得するためのViewModel
    private let viewModel: MainViewModel = MainViewModel(api: APIRequestManager.shared)

    // MEMO: UICollectionViewを差分更新するためのNSDiffableDataSourceSnapshot
    private var snapshot: NSDiffableDataSourceSnapshot<MainSection, AnyHashable>!

    // MEMO: UICollectionViewを組み立てるためのDataSource
    private var dataSource: UICollectionViewDiffableDataSource<MainSection, AnyHashable>! = nil

    ・・・(省略)・・・

    // MARK: - deinit

    deinit {
        cancellables.forEach { $0.cancel() }
    }

    // MARK: - Override

    override func viewDidLoad() {
        super.viewDidLoad()

        ・・・(省略)・・・
        bindToMainViewModelOutputs()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        // MEMO: ViewModelのInputsを経由したAPIでのデータ取得処理を実行する
        viewModel.inputs.fetchFeaturedBannersTrigger.send()
        viewModel.inputs.fetchFeaturedInterviewsTrigger.send()
        viewModel.inputs.fetchKeywordsTrigger.send()
        viewModel.inputs.fetchNewArrivalsTrigger.send()
        viewModel.inputs.fetchArticlesTrigger.send()
    }

    ・・・(省略)・・・

    private func bindToMainViewModelOutputs() {

        // 1. ViewModelのOutputsを経由した特集バナーデータの取得とNSDiffableDataSourceSnapshotの入れ替え処理
        viewModel.outputs.featuredBanners
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] featuredBanners in
                    guard let self = self else { return }
                    self.snapshot.appendItems(featuredBanners, toSection: .FeaturedBanners)
                    self.dataSource.apply(self.snapshot, animatingDifferences: false)
                }
            )
            .store(in: &cancellables)

        // 2. ViewModelのOutputsを経由した特集インタビューデータの取得とNSDiffableDataSourceSnapshotの入れ替え処理
        viewModel.outputs.featuredInterviews
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] featuredInterviews in
                    guard let self = self else { return }
                    self.snapshot.appendItems(featuredInterviews, toSection: .FeaturedInterviews)
                    self.dataSource.apply(self.snapshot, animatingDifferences: false)
                }
            )
            .store(in: &cancellables)

        // 3. ViewModelのOutputsを経由したキーワードデータの取得とNSDiffableDataSourceSnapshotの入れ替え処理
        viewModel.outputs.keywords
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] keywords in
                    guard let self = self else { return }
                    self.snapshot.appendItems(keywords, toSection: .RecentKeywords)
                    self.dataSource.apply(self.snapshot, animatingDifferences: false)
                }
            )
            .store(in: &cancellables)

        // 4. ViewModelのOutputsを経由した新着データの取得とNSDiffableDataSourceSnapshotの入れ替え処理
        viewModel.outputs.newArrivals
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] newArrivals in
                    guard let self = self else { return }
                    self.snapshot.appendItems(newArrivals, toSection: .NewArrivalArticles)
                    self.dataSource.apply(self.snapshot, animatingDifferences: false)
                }
            )
            .store(in: &cancellables)

        // 5. ViewModelのOutputsを経由した記事データの取得とNSDiffableDataSourceSnapshotの入れ替え処理
        viewModel.outputs.articles
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] articles in
                    guard let self = self else { return }
                    self.snapshot.appendItems(articles, toSection: .RegularArticles)
                    self.dataSource.apply(self.snapshot, animatingDifferences: false)
                }
            )
            .store(in: &cancellables)
    }
}

⭐️5-4. それぞれの処理における流れの概略図

本サンプル(ComplexCollectionViewStyleExample)で定義しているViewModelにおける内部的な値の関係と引き渡す流れをまとめると下図の様な形になります。

input_output_viewmodel.png

値の中継地点となる変数@Published private var _article: [Article]を定義しておくことで、BehaviorRelayのような役割を担う形にする点など、RxSwiftのオペレータを利用した場合との細かな相違点はありますが、Combineで提供されている機能を活用することで近しい形のデータフローを作成することができるかと思います。

6. UICollectionViewCompositionalLayout + DiffableDataSource + Combineを利用した無限スクロール&WaterFallLayoutを実現した事例紹介

最後に、これまで紹介してきたサンプル(ComplexCollectionViewStyleExample)とは、別のサンプル(DiffableDataSourceExample)での実装事例を簡単ではありますが紹介していきます。

UICollectionViewCompositionalLayoutを利用した「Pinterestの様なWaterFallLayout」と「Scrollが最下部に達した際に次ページが追加されるような実装とRefreshControl部分」をCombineを利用した実装で実現したUI実装サンプルになります。

⭐️6-1. UICollectionViewCompositionalLayoutを利用したWaterFallLayoutの実装部分を組み立てる

Pinterestの様な、写真の縦横比を維持してかつセルの高さを合わせて変更するような処理については、UICollectionViewを利用する場合においても難しい表現の1つてあると思います。この様な表現をする場合でもUICollectionViewCompositionalLayoutを利用すると、比較的見通しが良い形で実装ができるように思います。

water_fall_layout_introduction.png

本サンプル(DiffableDataSourceExample)では、JSONのレスポンス内に予めサムネイル画像における縦横サイズを持っている形になっているので、この値を利用することで配置対象セルのサイズを決定することができます。

【レイアウトのサイズと配置に関する計算部分の抜粋】

MainViewController.swift
// UICollectionViewCompositionalLayoutを利用したレイアウトを組み立てる処理
private func createWaterFallLayoutSection() -> NSCollectionLayoutSection {

    if snapshot.numberOfItems == 0 {
        return applyForNoItemLayoutSection()
    } else {
        return applyForWaterFallLayoutSection()
    }
}

private func applyForNoItemLayoutSection() -> NSCollectionLayoutSection {

    // MEMO: .absoluteや.estimatedを設定する場合で0を入れると下記のようなログが出ます。
    // → Invalid estimated dimension, must be > 0. NOTE: This will be a hard-assert soon, please update your call site.

    // 1. Itemのサイズ設定
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(0.5))
    let item = NSCollectionLayoutItem(layoutSize: itemSize)
    item.contentInsets = .zero

    // 2. Groupのサイズ設定
    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(0.5))
    let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
    group.contentInsets = .zero

    // 3. Sectionのサイズ設定
    let section = NSCollectionLayoutSection(group: group)
    section.contentInsets = .zero

    return section
}

private func applyForWaterFallLayoutSection() -> NSCollectionLayoutSection {

    // MEMO: 表示するアイテムが存在する場合は各セルの高さの適用とそれに基くUICollectionView全体の高さを計算する

    // Model内で持っているheightの値を適用することでWaterFallLayoutの様な見た目を実現する
    var leadingGroupHeight: CGFloat = 0.0
    var trailingGroupHeight: CGFloat = 0.0
    var leadingGroupItems: [NSCollectionLayoutItem] = []
    var trailingGroupItems: [NSCollectionLayoutItem] = []

    let photos = snapshot.itemIdentifiers(inSection: .WaterFallLayout)
    let totalHeight = photos.reduce(CGFloat(0)) { $0 + $1.height }
    let columnHeight = CGFloat(totalHeight / 2.0)

    var runningHeight = CGFloat(0.0)

    // 1. Itemのサイズ設定
    for index in 0..<snapshot.numberOfItems {

        let photo = photos[index]
        let isLeading = runningHeight < columnHeight
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(photo.height))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)

        runningHeight += photo.height

        if isLeading {
            leadingGroupItems.append(item)
            leadingGroupHeight += photo.height
        } else {
            trailingGroupItems.append(item)
            trailingGroupHeight += photo.height
        }
    }

    // 2. Groupのサイズ設定
    let leadingGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .absolute(leadingGroupHeight))
    let leadingGroup = NSCollectionLayoutGroup.vertical(layoutSize: leadingGroupSize, subitems: leadingGroupItems)

    let trailingGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.5), heightDimension: .absolute(trailingGroupHeight))
    let trailingGroup = NSCollectionLayoutGroup.vertical(layoutSize: trailingGroupSize, subitems: trailingGroupItems)

    let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(max(leadingGroupHeight, trailingGroupHeight)))
    let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [leadingGroup, trailingGroup])

    // 3. Sectionのサイズ設定
    let section = NSCollectionLayoutSection(group: group)

    return section
}

【JSONで取得したレスポンスをマッピングする部分の抜粋】

PhotoList.swift
import Foundation
import UIKit

// MARK: - Struct (PhotoList)

struct PhotoList: Hashable, Decodable {

    private let uuid = UUID()

    let page: Int
    let photos: [Photo]
    let hasNextPage: Bool

    // MARK: - Enum

    private enum Keys: String, CodingKey {
        case page
        case photos
        case hasNextPage = "has_next_page"
    }

    // MARK: - Initializer

    init(from decoder: Decoder) throws {

        // JSONの配列内の要素を取得する
        let container = try decoder.container(keyedBy: Keys.self)

        // JSONの配列内の要素にある値をDecodeして初期化する
        self.page = try container.decode(Int.self, forKey: .page)
        self.photos = try container.decode([Photo].self, forKey: .photos)
        self.hasNextPage = try container.decode(Bool.self, forKey: .hasNextPage)
    }

    // MARK: - Hashable

    // MEMO: Hashableプロトコルに適合させるための処理
    func hash(into hasher: inout Hasher) {
        hasher.combine(uuid)
    }

    static func == (lhs: PhotoList, rhs: PhotoList) -> Bool {
        return lhs.uuid == rhs.uuid
    }
}

// MARK: - Struct (Photo)

struct Photo: Hashable, Decodable {

    let id: Int
    let title: String
    let summary: String
    let image: Image
    let gift: Gift

    private(set) var height: CGFloat = 0.0

    // MARK: - Enum

    private enum Keys: String, CodingKey {
        case id
        case title
        case summary
        case image
        case gift
    }

    // MARK: - Initializer

    init(from decoder: Decoder) throws {

        // JSONの配列内の要素を取得する
        let container = try decoder.container(keyedBy: Keys.self)

        // JSONの配列内の要素にある値をDecodeして初期化する
        self.id = try container.decode(Int.self, forKey: .id)
        self.title = try container.decode(String.self, forKey: .title)
        self.summary = try container.decode(String.self, forKey: .summary)
        self.image = try container.decode(Image.self, forKey: .image)
        self.gift = try container.decode(Gift.self, forKey: .gift)

        // MEMO: 写真のサイズに基づいて算出した縦横比を利用して適用したセルのサイズを算出する
        let screenHalfWidth = UIScreen.main.bounds.width * 0.5
        let ratio = CGFloat(self.image.height) / CGFloat(self.image.width)
        let titleAndSummaryHeight: CGFloat = 90.0

        self.height = screenHalfWidth * ratio + titleAndSummaryHeight
    }

    // MARK: - Hashable

    // MEMO: Hashableプロトコルに適合させるための処理
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }

    static func == (lhs: Photo, rhs: Photo) -> Bool {
        return lhs.id == rhs.id
    }
}

// MARK: - Photo Extension

extension Photo {

    struct Image: Decodable {
        let url: String
        let width: Int
        let height: Int
    }

    struct Gift: Decodable {
        let flag: Bool
        let price: Int?
    }
}

⭐️6-2. 表示ModelデータのHash値が等しいデータがあった場合は新しいものに上書きする

4章でも軽く触れましたが、表示データのModelはHashableに適合している関係で、データそれぞれにHash値を持っていますが、APIのレスポンスで次ページの内容を取得した際に既に表示したデータが更新のタイミング等で含まれてしまった際にHash値の衝突が発生してしまいます。

本サンプル(DiffableDataSourceExample)では、既に表示しているデータ次ページの内容を取得した際に、更新された内容を反映する必要があるので下記に示したコードの様な形でデータのHash値を比較して、新しいデータが存在する場合には既存で表示しているものを置き換えるようにしています。
(ここでは一意となるidをHash値作成時に利用しています。)

UniqueDataArrayBuilder.swift
import Foundation

struct UniqueDataArrayBuilder {

    // MARK: - Static Function

    // モデル内に定義したハッシュ値の同一性を検証して一意な表示用データ配列を作成する
    static func fillDifferenceOfOldAndNewLists<T: Decodable & Hashable>(_ dataType: T.Type, oldDataList: [T], newDataList: [T]) -> [T] {

        // 引数より受け取った新しいデータ配列
        var newDataList = newDataList

        // 返却用の配列
        var dataList: [T] = []

        // 既存の表示データ配列をループさせて同一のものがある場合は新しいデータへ置き換える
        // ここはもっと綺麗に書ける余地がある部分だと思う...
        for oldData in oldDataList {
            var shouldAppendOldData = true
            for (newIndex, newData) in newDataList.enumerated() {

                // 同一データの確認(写真表示用のモデルはHashableとしているのでidの一致で判定できるようにしている部分がポイント)
                if oldData == newData {
                    shouldAppendOldData = false
                    dataList.append(newData)
                    newDataList.remove(at: newIndex)
                    break
                }
            }
            if shouldAppendOldData {
                dataList.append(oldData)
            }
        }

        // 置き換えたものを除外した新しいデータを後ろへ追加する
        for newData in newDataList {
            dataList.append(newData)
        }
        return dataList
    }
}

⭐️6-3. APIからのデータ取得から画面表示までの流れに関する実装とUIScrollViewDelegateと連動したUI表現に関するまとめ

本サンプル(DiffableDataSourceExample)では、APIリクエストからデータを反映させる部分についても基本的には、これまでの解説で触れてきた「Combine + MVVM』の構成で実装をしています。

Scrollが最下部に達した際に次ページが追加されるような実装については、UIScrollViewDelegateを利用してコンテンツ表示位置が最下部まで到達した時をトリガーとして、ViewModel側に定義した次のページ表示用のAPIリクエストを実行している点がポイントになります。

また、RefreshControlを伴う表示データのリセット処理についても、ViewModel側に別途Input用のトリガーを準備しておき、これまで表示していた内容を一度リセットしてから1ページ目のAPIリクエストを実行して実現させています。

【該当部分におけるViewModelでの実装】

PhotoViewModel.swift
import Foundation
import Combine

// MARK: - Protocol

protocol PhotoViewModelInputs {
    var fetchPhotoTrigger: PassthroughSubject<Void, Never> { get }
    var refreshPhotoTrigger: PassthroughSubject<Void, Never> { get }
}

protocol PhotoViewModelOutputs {
    var photos: AnyPublisher<[Photo], Never> { get }
    var apiRequestStatus: AnyPublisher<APIRequestStatus, Never> { get }
}

protocol PhotoViewModelType {
    var inputs: PhotoViewModelInputs { get }
    var outputs: PhotoViewModelOutputs { get }
}

final class PhotoViewModel: PhotoViewModelType, PhotoViewModelInputs, PhotoViewModelOutputs {

    // MARK: - PhotoViewModelType

    var inputs: PhotoViewModelInputs { return self }
    var outputs: PhotoViewModelOutputs { return self }

    // MARK: - PhotoViewModelInputs

    let fetchPhotoTrigger = PassthroughSubject<Void, Never>()
    let refreshPhotoTrigger = PassthroughSubject<Void, Never>()

    // MARK: - MainViewModelOutputs

    var photos: AnyPublisher<[Photo], Never> {
        return $_photos.eraseToAnyPublisher()
    }
    var apiRequestStatus: AnyPublisher<APIRequestStatus, Never> {
        return $_apiRequestStatus.eraseToAnyPublisher()
    }

    private let api: APIRequestManagerProtocol

    private var nextPageNumber: Int = 1
    private var hasNextPage: Bool = true

    private var cancellables: [AnyCancellable] = []

    // MARK: - @Published

    // MEMO: このコードではNSDiffableDataSourceSnapshotの差分更新部分で利用する
    @Published private var _photos: [Photo] = []
    @Published private var _apiRequestStatus: APIRequestStatus = .none

    // MARK: - Initializer

    init(api: APIRequestManagerProtocol) {

        // MEMO: 適用するAPIリクエスト用の処理
        self.api = api

        // MEMO: ページング処理を伴うAPIリクエスト
        // → 実行時はViewController側でviewModel.inputs.fetchPhotoTrigger.send()で実行する
        fetchPhotoTrigger
            .sink(
                receiveValue: { [weak self] in
                    guard let self = self else { return }

                    // MEMO: 次のページが存在しない場合は以降の処理を実施しないようにする
                    guard self.hasNextPage else {
                        return
                    }
                    self.fetchPhotoList()
                }
            )
            .store(in: &cancellables)

        // MEMO: 現在まで取得したデータのリフレッシュ処理を伴うAPIリクエスト
        // → 実行時はViewController側でviewModel.inputs.refreshPhotoTrigger.send()で実行する
        refreshPhotoTrigger
            .sink(
                receiveValue: { [weak self] in
                    guard let self = self else { return }
                    self.nextPageNumber = 1
                    self.hasNextPage = true
                    self._photos = []
                    self.fetchPhotoList()
                }
            )
            .store(in: &cancellables)
    }

    // MARK: - deinit

    deinit {
        cancellables.forEach { $0.cancel() }
    }

    // MARK: - Privete Function

    private func fetchPhotoList() {

        // APIとの通信処理ステータスを「実行中」へ切り替える
        _apiRequestStatus = .requesting

        // APIとの通信処理を実行する
        api.getPhotoList(perPage: nextPageNumber)
            .receive(on: RunLoop.main)
            .sink(
                receiveCompletion: {  [weak self] completion in
                    guard let self = self else { return }

                    switch completion {

                    // MEMO: 値取得に成功した場合のハンドリング
                    case .finished:

                        // MEMO: APIリクエストの処理結果を成功の状態に更新する
                        self._apiRequestStatus = .requestSuccess
                        print("receiveCompletion finished fetchPhotoList(): \(completion)")

                    // MEMO: 値取得に失敗した場合のハンドリング
                    case .failure(let error):

                        // MEMO: APIリクエストの処理結果を失敗の状態に更新する
                        self._apiRequestStatus = .requestFailure
                        print("receiveCompletion error fetchPhotoList(): \(error.localizedDescription)")
                    }
                },
                receiveValue: { [weak self] hashableObjects in
                    guard let self = self else { return }

                    if let photoList = hashableObjects.first {

                        // MEMO: ViewModel内部処理用の変数を更新する
                        self.nextPageNumber = photoList.page + 1
                        self.hasNextPage = photoList.hasNextPage

                        // MEMO: 表示対象データを差分更新する
                        self._photos = UniqueDataArrayBuilder.fillDifferenceOfOldAndNewLists(Photo.self, oldDataList: self._photos, newDataList: photoList.photos)
                        print("receiveValue fetchPhotoList(): \(photoList)")
                    }
                }
            )
            .store(in: &cancellables)
    }
}

【該当部分におけるViewControllerでの実装(抜粋)】

MainViewController.swift
final class MainViewController: UIViewController {

    // MARK: - Variables

    // UICollectionViewに設置するRefreshControl
    private let mainRefrashControl = UIRefreshControl()

    // MEMO: API経由の非同期通信からデータを取得するためのViewModel
    private let viewModel: PhotoViewModel = PhotoViewModel(api: APIRequestManager.shared)

    // MEMO: Cancellableの保持用(※RxSwiftでいうところのDisposeBagの様なイメージ)
    private var cancellables: [AnyCancellable] = []

    // MEMO: UICollectionViewを差分更新するためのNSDiffableDataSourceSnapshot
    private var snapshot: NSDiffableDataSourceSnapshot<PhotoSection, Photo>!

    // MEMO: UICollectionViewを組み立てるためのDataSource
    private var dataSource: UICollectionViewDiffableDataSource<PhotoSection, Photo>! = nil

    ・・・(省略)・・・

    // MARK: - Override

    override func viewDidLoad() {
        super.viewDidLoad()

        ・・・(省略)・・・

        bindToViewModelOutputs()
    }

    // MARK: - Private Function

    // UICollectionViewにおけるPullToRefresh実行時の処理
    @objc private func executeRefresh() {

        // MEMO: ViewModelに定義した表示データのリフレッシュ処理を実行する
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.36) {
            self.viewModel.inputs.refreshPhotoTrigger.send()
        }
    }

    // ViewModelのOutputとこのViewControllerでのUIに関する処理をバインドする
    private func bindToViewModelOutputs() {

        // MEMO: APIへのリクエスト状態に合わせたUI側の表示におけるハンドリングを実行する
        viewModel.outputs.apiRequestStatus
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] status in

                    guard let self = self else { return }
                    switch status {
                    case .requesting:
                        self.mainRefrashControl.beginRefreshing()
                    case .requestFailure:
                        // MEMO: 通信失敗時はアラート表示 & RefreshControlの状態変更
                        self.mainRefrashControl.endRefreshing()
                        self.showAlertWith(completionHandler: nil)
                    default:
                        self.mainRefrashControl.endRefreshing()
                    }
                }
            )
            .store(in: &cancellables)

        // MEMO: APIへのリクエスト状態に合わせたUI側の表示におけるハンドリングを実行する
        viewModel.outputs.photos
            .subscribe(on: RunLoop.main)
            .sink(
                receiveValue: { [weak self] photos in

                    guard let self = self else { return }
                    // MEMO: ID(Identifier)が重複する場合における衝突の回避をする
                    let beforePhoto = self.snapshot.itemIdentifiers(inSection: .WaterFallLayout)
                    self.snapshot.deleteItems(beforePhoto)
                    self.snapshot.appendItems(photos, toSection: .WaterFallLayout)
                    self.dataSource.apply(self.snapshot, animatingDifferences: false)
                }
            )
            .store(in: &cancellables)
    }

    ・・・(省略)・・・
}

・・・(省略)・・・

extension MainViewController: UIScrollViewDelegate {

    // MEMO: NSCollectionLayoutSectionのScroll(section.orthogonalScrollingBehavior)ではUIScrollViewDelegateは呼ばれない
    func scrollViewDidScroll(_ scrollView: UIScrollView) {

        // MEMO: UIRefreshControl表示時は以降の処理を行わない(※APIリクエストの状態とRefreshControlの状態を連動させている点がポイント)
        if mainRefrashControl.isRefreshing {
            return
        }

        // MEMO: UIScrollViewが一番下の状態に達した時にAPIリクエストを実行する
        if scrollView.contentOffset.y + scrollView.frame.size.height > scrollView.contentSize.height {
            viewModel.inputs.fetchPhotoTrigger.send()
        }
    }
}

※ 本サンプル(DiffableDataSourceExample)では、UITableViewにおける類似した表現を実装した画面もありますので、是非見ていただけますと幸いです。

7. 今回紹介した実装における参考資料

UICollectionViewCompositionalLayout及びCombineを利用した実装を進めていく際や特徴の理解を進めていく上で僕が参考にした記事を下記にまとめてみました。

本記事で紹介している記事は英語記事であっても、コードを交えた解説がされているものが多いので、比較的読みやすいかと思いますので少しでも参考になれば幸いです。

⭐️7-1. UICollectionViewCompositionalLayoutを利用した今回の実装をする上での参考資料集

参考記事:

参考コード:

⭐️7-2. Combineを利用した今回の実装をする上での参考資料集

参考記事:

参考コード:

8. あとがき

結構長い記事になってしまって恐縮ではありますが、今回紹介したサンプル実装や記事の執筆を通して感じたことを簡単ではありますがまとめてみました。

⭐️8-1. UICollectionViewを利用した複雑な画面でも実装の見通しが立てやすくなった

iOS13から登場したUICollectionViewCompositionalLayout & DiffableDataSourceを活用したサンプルUIの実装に触れてみると、UICollectionViewを活用した画面レイアウトにおける複雑な表現がよりシンプルかつ直感的になったと感じています。

従来のUICollectionViewを活用して複雑なレイアウトを実装する方法では、UICollectionViewLayoutを継承したクラスを利用して、LayoutAttributesを調整する必要がある点に難しさがあるかと思いますが、その実装方法と比べてもコンパクトな形にまとめることができるのは大きなメリットではないかと思います。

※具体的な実装の事例を挙げると 「Pinterestの様なWaterFallLayout」「Instagramの様なMosaicLayout」 を実現する場合には、その良さをより実感できるかもしれません。

また、場合によっては画面要素や構成するViewControllerを分割して実装する方針を取る必要がありそうなレイアウトについても単一のUICollectionViewの中に上手にまとめ上げることができる点も注目すべき大きな魅力の1つであるように思います。

※もちろん、UIの用件や仕様によっては従来通りの方法を採用した方が良い場合もあるので、画面設計の際の選択肢の1つしてケースバイケースで取捨選択していく方針でも今のところは良さそうに思います。

特にUICollectionViewCompositionalLayoutを利用したUI実装をする際には、

  1. NSCollectionLayoutSection / NSCollectionLayoutGroup / NSCollectionLayoutItem を組み合わせて実現するUICollectionViewCompositionalLayout組み立て方
  2. UICollectionViewDiffableDataSource / NSDiffableDataSourceSnapshotを利用したデータ反映ロジックの構築

の2点に注目すると、より理解がしやすくなるのではないかと感じております。

※iOS13以降では、UITableViewについてもUITableViewDiffableDataSource / NSDiffableDataSourceSnapshotを利用して差分更新のロジックを実現することができます。

⭐️8-2. CombineについてもRxSwift等と比較すると動きのイメージが掴みやすくなる

この記事で解説した2つのサンプルではどちらも「Combine + MVVMパターン」での実装をしていますが、元になっているのは「RxSwift + MVVMパターン」での実装を参考にしています。現段階ではRxSwift等では用意されているものの、Combineでは相当するものがないオペレータやコンポーネントもありますが、比較的シンプルな実装をCombineで置き換えていくような場合には、下記で紹介している 「RxSwiftとCombineを比較したチートシート」 等を参考にしながら進めていくと良いかと思います。

⭐️8-3. iOS12以前でも類似した表現を実現する際に役に立つライブラリのご紹介

今回紹介したUI表現や内部ロジックに関する実装については、iOS13以降で利用可能な機能を活用して実現しているものになりますが、iOS12以前のバージョンもサポートする必要がある場合でも似た様な表現や内部ロジックを実現する必要がある場合には、まずは下記に紹介しているライブラリを活用する形にする方針でも良いかと思います。

様々な要素からなる複雑な画面を差分を考慮して構築する際に役立つライブラリ:

iOS12以前でもCollectionViewCompositionalLayoutの様な構造に対応できるライブラリ:

iOS12以前でもUICollectionViewやUITableViewの差分更新を実現するライブラリ:

⭐️8-4. 最後に

今回は「現在携わっている業務の中で頻出なUI実装を参考にして、iOS13以降の新機能を利用した形にすこしずつ置き換えてみる」というテーマで実装をしたサンプルを元にした解説という形にしました。現在は特にRxSwiftを利用したMVVMパターンやUICollectionViewをフル活用した形の実装に触れる機会が多いということもあったので、慣れ親しんだ実装方法をヒントとして比較しながら実装や検証を進めていくことで、具体的な動きのイメージや構築の流れが掴めてくる実感があったように感じています。

僕自身もiOS13以降から登場した新機能については、まだまだキャッチアップや動作検証が行き届いていない部分も多々あると思いますが、少しでも皆様の参考になれば幸いに思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Xcode11 ABI安定化につまずいた

はじめに

みなさん待望の ABI 安定化が Swift5 でついに達成されました:tada:

ついに ABI 安定化がきたか!(ABI 安定化ってなんだ?:speak_no_evil:

ABI 安定化については下記参考

ほーん:thinking:つまり Swift のバージョンが異なるバイナリ同士をリンクできるようになったてことか:exclamation:

Module Stability

異なるバージョンでいけるってことはこの Carthage でライブラリを導入したやつ(Xcode11.0 Swift5.1)は Xcode11.3 Swift5.1.3 でビルドできるってことか:exclamation:

error1

なんやて:interrobang:

結局ターミナルでもう一度下記コマンドしないとむりでした...

carthage update --platform iOS

なんでや?と思っていたらピンポイントの質問を発見:heart_eyes:

そのライブラリのビルド設定において、Module Stabilityが有効になっていないからです。

(中略)

つまり、Swift 5.0ではABI Stabilityによって異なるバージョンのコンパイラが生成したライブラリをリンクできますが、インポートできないので実質的にサードパーティのライブラリに対してはABI Stabilityの恩恵を受けることはできません。
(インポートの段階で失敗するのでリンクの段階まで到達しないため)

そういうことらしい:open_mouth:

どうやらライブラリの互換を達成するには *.swiftinterface ファイルが必要でライブラリ側の設定で BUILD_LIBRARY_FOR_DISTRIBUTION というフラグを YES にする必要があるらしい。

ほーん...まじかそんなんやってねぇ:scream:全部やってねぇ...)

setting

ここを YES にして carthage update したらとりあえず framework の Modules に *.swiftinterface ファイルができました:clap:

ビルド!!!

error2

:poop::poop::poop:

...よくわかりませんが Xcode11.0, 11.1 <-> Xcode11.2.1, 11.3 は無理でした:scream_cat:

結果
Xcode11.0 (Swift5.1) Xcode11.1 (Swift5.1) :o:
Xcode11.0 (Swift5.1) Xcode11.2.1 (Swift5.1.2) :x:
Xcode11.0 (Swift5.1) Xcode11.3 (Swift5.1.3) :x:
Xcode11.1 (Swift5.1) Xcode11.0 (Swift5.1) :o:
Xcode11.1 (Swift5.1) Xcode11.2.1 (Swift5.1.2) :x:
Xcode11.1 (Swift5.1) Xcode11.3 (Swift5.1.3) :x:
Xcode11.2.1 (Swift5.1.2) Xcode11.0 (Swift5.1) :x:
Xcode11.2.1 (Swift5.1.2) Xcode11.1 (Swift5.1) :x:
Xcode11.2.1 (Swift5.1.2) Xcode11.3 (Swift5.1.3) :o:
Xcode11.3 (Swift5.1.3) Xcode11.0 (Swift5.1) :x:
Xcode11.3 (Swift5.1.3) Xcode11.1 (Swift5.1) :x:
Xcode11.3 (Swift5.1.3) Xcode11.2.1 (Swift5.1.2) :o:

でも、Xcode11.2.1(Swift5.1.2) <-> Xcode11.3(Swift5.1.3) はビルドできたので互換性はあるみたいです。

ということでライブラリを作成する際は BUILD_LIBRARY_FOR_DISTRIBUTIONYES に設定しましょう!!

※Swift のバージョンについて
ターミナルで下記コマンドで調べました

swift -version
Xcode Swift
11.0 5.1 (swiftlang-1100.0.270.13 clang-1100.0.33.7)
11.1 5.1 (swiftlang-1100.0.270.13 clang-1100.0.33.7)
11.2.1 5.1.2 (swiftlang-1100.0.278 clang-1100.0.33.9)
11.3 5.1.3 (swiftlang-1100.0.282.1 clang-1100.0.33.15)

さいごに

早くライブラリ全部 BUILD_LIBRARY_FOR_DISTRIBUTIONYES に設定しなきゃ:speak_no_evil:

[iOSDC Japan 2019 リポート]「ライブラリのインポートとリンクの仕組み完全解説」というセッションを聞いてきましたこの記事とスライドもみなきゃ:laughing:(なんかわかった気になります:neutral_face:

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【UICollectionView】セルを生成する際にsizeForItemAtをCGSize.zeroにするとcellForItemAtの挙動が変わる

はじめに

UICollectionViewのセルを生成する際,サイズにCGSize.zeroを指定するとセルのインスタンスを生成するcollectionView(_:cellForItemAt:)の挙動が変わるのでまとめてみました.

検証

検証のために画面中央にCollectionViewを配置した簡単なViewControllerを用います.

スクリーンショット 2019-12-23 0.05.47.png

今回は,

  • CollectionViewCellというUICollectionViewCellのカスタムクラスを使用
  • UICollectionViewDelegateFlowLayoutのcollectionView(_:sizeForItemAt:)でサイズを返却する

という状況を想定します.

import UIKit

class ViewController: UIViewController {

    @IBOutlet weak var collectionView: UICollectionView! {
        didSet {
            self.collectionView.dataSource = self
            self.collectionView.delegate = self
        }
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }
}

extension ViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 10
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "CollectionViewCell", for: indexPath) as! CollectionViewCell
        cell.label.text = indexPath.debugDescription
        print("cellForItemAt: \(indexPath)")
        return cell
    }
}

extension ViewController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {

        print("sizeForItemAt: \(indexPath)")
        return CGSize.zero //ここでindexPathに対応するセルのサイズを返却
    }
}

CGSize(width: 70.0, height: 70.0)を返す場合

通常の固定サイズを返却する場合です.collectionView(_:sizeForItemAt:)が常にCGSize(width: 70.0, height: 70.0)を返すようにします.実際に実行すると想定されているように10個のセルがきちんと表示されています.

スクリーンショット 2019-12-22 23.55.51.png

コンソールは出力は以下のようになります.

sizeForItemAt: [0, 0]
sizeForItemAt: [0, 1]
sizeForItemAt: [0, 2]
sizeForItemAt: [0, 3]
sizeForItemAt: [0, 4]
sizeForItemAt: [0, 5]
sizeForItemAt: [0, 6]
sizeForItemAt: [0, 7]
sizeForItemAt: [0, 8]
sizeForItemAt: [0, 9]
cellForItemAt: [0, 0]
cellForItemAt: [0, 1]
cellForItemAt: [0, 2]
cellForItemAt: [0, 3]
cellForItemAt: [0, 4]
cellForItemAt: [0, 5]
cellForItemAt: [0, 6]
cellForItemAt: [0, 7]
cellForItemAt: [0, 8]
cellForItemAt: [0, 9]

この出力を見てわかるように

  1. 全てのindexPathについてsizeForItemAt (サイズ計算)を実行
  2. 全てのindexPathに対してcellForItemAt (インスタンス生成)を実行

の順に処理が行われます.

sizeForItemAt→cellForItemAtがindexPath毎に逐一実行されるというわけではないんですね.

セルサイズの一部がCGSize.zeroを含む場合

次に特定のindexPathに対して,sizeForItemがCGSize.zeroを返却する場合を考えます.collectionView(_:sizeForItemAt:)を以下のように書き換えました.

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {

    print("sizeForItemAt: \(indexPath)")
    if indexPath.item % 2 == 0 {
      return CGSize.zero
    } else {
      return CGSize(width: 70.0, height: 70.0)
    }
}

この場合で実行すると以下のように画面は表示されます.先ほどの条件と比べて,CGSize.zeroを当ててられたセルは表示されず,表示されたセルの配置も先ほどとズレてしまいます.

image-20191223005410730.png

コンソールの出力を確認するとWarningが出力されています.

sizeForItemAt: [0, 0]
sizeForItemAt: [0, 1]
sizeForItemAt: [0, 2]
sizeForItemAt: [0, 3]
sizeForItemAt: [0, 4]
sizeForItemAt: [0, 5]
sizeForItemAt: [0, 6]
sizeForItemAt: [0, 7]
sizeForItemAt: [0, 8]
sizeForItemAt: [0, 9]
cellForItemAt: [0, 0]
XXXX-XX-XX XX:XX:XX CollectionViewTest[62020:545496] [Warning] Warning once only: Detected a case where constraints ambiguously suggest a size of zero for a collection view cell's content view. We're considering the collapse unintentional and using standard size instead. Cell: <CollectionViewTest.CollectionViewCell: 0x7fa5edd18890; baseClass = UICollectionViewCell; frame = (0 35; 0 0); clipsToBounds = YES; opaque = NO; layer = <CALayer: 0x600003c01460>>
cellForItemAt: [0, 1]
cellForItemAt: [0, 2]
cellForItemAt: [0, 3]
cellForItemAt: [0, 4]
cellForItemAt: [0, 5]
cellForItemAt: [0, 6]
cellForItemAt: [0, 7]
cellForItemAt: [0, 8]
cellForItemAt: [0, 9]

これはCGSize.zeroをCollectionViewCellに対して割り当てたので,意図しない表示崩れを防ぐためにframe = (0 35; 0 0)を当てたというものです.

この処理が入ったため,[0,1]のセルを生成する際,画面上には[0,0]のセルが表示されていないものの,実在しているとみなされ,セルの間のスペースが取られた状態で描画処理が行われているものと考えられます.(要検証)

いずれにせよ,sizeForItemAt→cellForItemAtのライフサイクルは通常の場合と変わりません.

セルサイズが全てCGSize.zeroの場合

最後にcollectionView(_:sizeForItemAt:)が常にCGSize.zeroを返す場合です.全てのセルが表示されていません.

スクリーンショット 2019-12-23 0.05.47.png

セルサイズが全てCGSize.zeroの場合,上記の2つとは異なり,cellForItemAtが実行されません.

sizeForItemAt: [0, 0]
sizeForItemAt: [0, 1]
sizeForItemAt: [0, 2]
sizeForItemAt: [0, 3]
sizeForItemAt: [0, 4]
sizeForItemAt: [0, 5]
sizeForItemAt: [0, 6]
sizeForItemAt: [0, 7]
sizeForItemAt: [0, 8]
sizeForItemAt: [0, 9]

すなわち,CollectionViewが含むセルのサイズが全てCGSize.zeroだった場合はセルのインスタンスの生成を行わないということになります.同じcollectionView(_:sizeForItemAt:)がCGSize.zeroを返す場合であっても,インスタンス自体は存在するが描画はされない「セルサイズの一部がCGSize.zeroを含む場合」とは挙動が異なります.

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ペアノ自然数とペアノ自然数での加算の推論規則をSwiftで表現してみる

はじめに

プログラミング言語の基礎概念をある程度読み進めてて、

「そういえば第1章の1.1のペアノ自然数の加算乗算の推論規則、Swiftのenumで楽に再現できそうだなー」

とふと思いました。

そこでやってみたら案外楽しかったので、Advent Calendarの記事として残しておくことにしました。

環境

Swift5.1で動作確認していますが、多分Swift4くらいまで動くと思います。

ペアノ自然数って?

私達は日常生活では通常アラビア文字を利用しています。

0
1
2
3

ペアノ自然数1とは、これをSZ()で表現したものです。

Z          => 0
S(Z)       => 1
S(S(Z))    => 2
S(S(S(Z))) => 2

Sは successor(次に来るもの)の頭文字を取ったものです。
簡単に言えばInt型で使える++と似ているようなものと考えてください。2

Zは0とすると、S(Z)はZの次なので1ですね。

では、Swiftのenumでそれっぽいものを作ってみましょう。

// ペアノ自然数
indirect enum N {
    // N + 1
    case S(N)
    // 0
    case Z
}

はい、Associated Valuesを使えば簡単にできますね。
Sでは再帰的にまた列挙型Nを利用するので、enumの宣言の先頭にindirectが必要です。

加算はどうやるの?

加算/乗算は以下の推論規則で証明することができます。(N1, N2, N3にはペアノ数が入ります。)

Z + N1 = N1
N1 + N2 = N3 ならば S(N1) + n2 = S(N3)

これらの推論規則3を利用して、ペアノ数での加算の結果を算出したいと思います。
では、実装するとどうなるでしょうか?

// 足し算の推論規則
// lhs + rhsの結果を出している
func +(lhs: N, rhs: N) -> N {
    switch lhs {
    case .Z:
        // 0 + N1 = N1
        return rhs
    case .S(let value):
        // N1 + N2 = N3 -> S(N1) + N2 = S(N3)
        return .S(value + rhs)
    }
}

これが正しいかを検証するために、ペアノ数をアラビア数に変換する関数を定義します。

indirect enum N {

    ... // さっきのコードと同じなので省略

    // デバッグ用、ペアノ自然数 -> アラビア数字
    func print() -> Int {
        var selfValue: N = self
        var i: Int = 0

        while true {
            if case .S(let value) = selfValue {
                selfValue = value
                i += 1
            } else {
                return i
            }
        }
    }
}

では、試してみるとあら不思議、ちゃんと正しく計算されています。

(.S(.S(.Z)) + .S(.S(.Z))).print()          // 4
(.S(.S(.Z)) + .S(.Z)).print()              // 3
(.Z + .Z).print()                          // 0

実装の考え方

詳しい実装の考え方について、フォーカスします。

Z + N1 = N1 について

まず、Z + N1 = N1に関しては、片方がゼロ(Z)であれば、N1の値をそのまま返せば良いことになります。これは簡単。

case .Z:
   // 0 + N1 = N1
   return rhs

N1 + N2 = N3 -> S(N1) + N2 = S(N3) について

N1 + N2 = N3 -> S(N1) + N2 = S(N3) が曲者ですが、次のように考えると簡単に実装できます。

この規則は言い換えれば

  • N1 + N2さえ求めることできれば、N1 + N2の結果をSでくるむとS(N3)も求まる

という意味です。

一歩進んで考えると、

  • N1 + N2の証明がいまは無理なら、N1 - 1 + N24を導き出してみる
  • その結果をSでくるめば(+ 1 すること)、N1 + N2が導き出せる
  • N1 + N2の結果をSでくるむとS(N3)も求まる(再掲)

と考えればよいです。

そうなると次はN1 - 1 + N2の証明に移ればいいですが、N1 - 1 をやっていけば、いずれかはゼロ(Z)に到達するし、その時の規則はすでに以下のように証明されているので、一番目のcaseの実装に行くということになります。

Z + X = X (上のN1と混同するのでXに置換)

まとめると、

  • N1 + N2の証明がいまは無理なら、N1 - 1 + N24を導き出してみる(再掲)
  • N1 - 1 + N24を導き出していこうとすると、1番目の Z + X = X の推論規則になる(再掲)
  • その結果をSでくるめば(+ 1 すること)、N1 + N2が導き出せる(再掲)

    • 例)2 + 1
    S(S(Z)) + S(Z)   // 2 + 1
    S((S(Z)) + S(Z)) // (1 + 1) + 1
    S(S(Z + S(Z)))   // (0 + 1) + 1 + 1
    // Z + S(Z) = S(Z) に置換できる
    S(S(S(Z))) 
    
  • N1 + N2の結果をSでくるむとS(N3)も求まる(再掲)

// value は N1 - 1
case .S(let value):
   // N1 + N2 = N3 -> S(N1) + N2 = S(N3)
   // 結局はS(N1 - 1 + N2) をしている
   return .S(value + rhs)

おまけ

https://gist.github.com/freddi-kit/248ce9f1cb33510a7067f94b440c51b6 にソースをまとめていますが、掛け算もあるので見てみてください。
もし、自分の力でチャレンジしたければ以下に推論規則を書いておきます。これはそれなりに難しいと思います。

0 * N = 0
N1 * N2 = N3 かつ N2 + N3 = N4 ならば S(N1) * N2 = N4

余談

C++だとこうなるらしい


  1. ペアノジュゼッペ・ペアノさんの名前からとったもの 

  2. あくまで、「似ているよねー」くらいの話で性質は全然違います。++は「対象の値をインクリメントする演算子」ですが、S(N)は「Nの次の数の表現」であることは念頭に入れてください。 

  3. こんな短い注釈で推論規則を解説するのは難しいですが、例えば文中のN1 + N2 = N3S(N1) + n2 = S(N3)はそれぞれ一つのルールになっていて、そのいくつかのルールから生み出されたルール(規則)を指します。「N1 + N2 = N3ということがなら、そこからS(N1) + n2 = S(N3)が導き出せる」というルールができていますね。興味があれば論理学の大学の資料を見ると良いかも。 

  4. わかりやすく- 1という記法を使っていますが、ペアノ数での定義上この書き方は間違いなので注意。エラーで言うとシンタックスエラーです。 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む