えいむーさんは明日も頑張るよ

Identifiableに適合させるお話

価格

# Identifiable (opens new window)

Swift における Identifiable とは要するに識別可能で、データが重複しないことを保証するためのプロパティである。

# ForEach

例えば、SwiftUI ではForEachを使うときに以下のようなコードを書いた経験が誰にでもあると思う。

# Range

struct ContentView: View {
    var body: some View {
        Form {
            ForEach(1 ..< 6) { index in
                Text("\(index)")
            }
        }
    }
}

Range 型について

整数の範囲をとるので1 ... 5のような書き方はできない。これはコンパイルエラーになる

# 配列 + インデックス

ForEachはもともと配列をループするための関数なので、配列を使って以下のように書くこともできる。

struct ContentView: View {
    let users: [String] = ["Mike", "John", "Kate", "Mary"]
    var body: some View {
        Form {
            ForEach(0 ..< users.count) { index in
                Text(self.users[index])
            }
        }
    }
}

より便利な方法として、

struct ContentView: View {
    let users: [String] = ["Mike", "John", "Kate", "Mary"]
    var body: some View {
        Form {
            ForEach(users.indices) { index in
                Text(self.users[index])
            }
        }
    }
}

という書き方を知っている人がいるかも知れない。indicesindexの複数形で要するに配列のインデックスを返す。

つまり、この場合だと[0, 1, 2, 3]が返ってくるというわけである。

# 配列 + オブジェクト

ただ、インデックスではなくてオブジェクト自体がほしいという場合もある。

何故ならオブジェクト自体が返ってくるのであれば以下のようにより完結にコードを書くことができるためだ。

struct ContentView: View {
    let users: [String] = ["Mike", "John", "Kate", "Mary"]
    var body: some View {
        Form {
            ForEach(users) { user in
                Text(user)
            }
        }
    }
}

ここで問題となるのは果たしてForEach(users)というのは有効な書き方なのかどうかということである。

結論から言えばこの書き方はコンパイルエラーを招く、何故か?

コンパイルエラー

正確にはコンパイルの前に、

Referencing initializer 'init(_:content:)' on 'ForEach' requires that 'String' conform to 'Identifiable'

というエラーが発生するのでそもそもコンパイルを実行できない。

# SwiftUI は Identifiable を前提としている

SwiftUI では「View やそれらのコンポーネントが識別可能で互いに異なるものである」ということを前提としている。

なので、ForEach で View を複数生成するときに「全く同じもの」を生成してしまうとバグが発生するのである。

Identifiableに適合させるためにはidというプロパティを設定する必要があるのですが、適合していなくてもプログラマが「Identifiableである」ということをForEachに情報として与えてやれば利用することができます。

先程のコードの場合はForEach(users, id:\.self)と書くことで「idとしてselfを利用する」ということが明示できます。

struct ContentView: View {
    let users: [String] = ["Mike", "John", "Kate", "Mary"]
    var body: some View {
        Form {
            ForEach(users, id:\.self) { user in
                Text(user)
            }
        }
    }
}

このとき、それぞれの文字列は別々のインスタンスですので、互いに識別可能で SwiftUI がクラッシュするようなこともありません。

文字列が重複しても大丈夫

\.selfプロパティはそれ自身の中身ではなくてインスタンス自体を参照するのでlet users: [String] = ["Mary", "Mary", "Mary"]のように重複していても大丈夫です。

structのオブジェクトもコピーを生成するので問題ないですが、classのオブジェクトはコピーではなくポインタを利用するので全く同一のオブジェクトが作成されるためこの方法は利用できません、多分。

# Struct を渡す場合

例えば、以下のようにUserの構造体を作成してそれをForEachでループすることを考えます。

struct ContentView: View {
    let users: [User] = [
        User(name: "Mary", age: 20),
        User(name: "Kate", age: 24),
        User(name: "Mike", age: 22)
    ]

    var body: some View {
        Form {
            ForEach(users, id:\.self) { user in
                HStack(content:  {
                    Text(user.name)
                    Spacer()
                    Text("\(user.age)")
                })
            }
        }
    }

    struct User {
        internal init(name: String, age: Int) {
            self.name = name
            self.age = age
        }

        let name: String
        let age: Int
    }
}

すると、Generic struct 'ForEach' requires that 'ContentView.User' conform to 'Hashable'というエラーが発生します。

struct User: Hashable {
    internal init(name: String, age: Int) {
        self.name = name
        self.age = age
    }

    let name: String
    let age: Int
}

このときは構造体にHashableであることを明示することでエラーを解消できます。

# Class を渡す場合

クラスの場合は構造体と違ってHashableに適合させる必要はありません。

struct ContentView: View {
    let users: [User] = [
        User(name: "Mary", age: 20),
        User(name: "Kate", age: 24),
        User(name: "Mike", age: 22)
    ]

    var body: some View {
        Form {
            ForEach(users) { user in
                HStack(content:  {
                    Text(user.name)
                    Spacer()
                    Text("\(user.age)")
                })
            }
        }
    }

    class User: Identifiable {
        internal init(name: String, age: Int) {
            self.name = name
            self.age = age
        }

        let name: String
        let age: Int
    }
}

ちょっとだけ短く書くことができますね。

# Identifiable + SwiftUI

で、ここからが本番になります。

SwiftUI でアラートなどを表示させる際には予めエラー用の View を用意しておいてそこにデータを代入して表示させるような仕組みになっています。

もし、エラーがたった一つしか種類がないのであれば、

struct ContentView: View {
    @State var isPresented: Bool = false

    var body: some View {
        Button(action: {
            isPresented.toggle()
        }, label: {
            Text("ALERT")
        })
        .alert(isPresented: $isPresented, content: {
            Alert(title: Text("ERROR"))
        })
    }
}

のようなコードで実装できます。要するに、アラートを表示するかどうかをisPresentedで制御しているということです。

ただ、場合によっては複数のエラーが返ってくる可能性があるようなケースもあります。そのときにエラーの数だけalertisPresentedを定義するのは馬鹿らしいです。

そこで利用できるのが.alert(item: <Binding<Identifiable?>, content: (Identifiable) -> Alert)というプロパティです。

# item を利用する

この仕組みの面白いところはitemに指定されたプロパティが変化すればisPresented == trueと同じ処理が実行されアラートが表示されるということです。

そしてアラートを閉じればそのときにプロパティにnilが代入されます。要するに、複数のisPresentedを定義しなくてもこのitemさえあればすべてまかなえることになります。

そして、重要な点はこのitemIdentifiableに適合していなくてはいけないという点です。

struct ContentView: View {
    @State var appError: APPError? = nil // 初期値はnilにしておこう

    var body: some View {
        Button(action: {
            // ランダムにエラーを一つ発生させる
            appError = APPError.allCases.randomElement()
        }, label: {
            Text("ALERT")
        })
        .alert(item: $appError, content: { appError in
            // オプショナルを外してエラーの中身を取得し、表示
            Alert(title: Text(appError.type))
        })
    }

    enum APPError: Error, CaseIterable {
        case server
        case app

        var type: String {
            switch self {
            case .server:
                return "SERVER"
            case .app:
                return "APPLICATION"
            }
        }
    }
}

サンプルコードはこのような感じで、エラー型プロトコルに準拠したAPPErrorというEnumを作成し、ボタンを押せばランダムにどちらかのエラーが発生するようにします。

で、このままだとコンパイルエラーがでるのでビルドできません。先程も言ったようにIdentifiableに準拠していないからです。

そこでidのプロパティを追加して、Identifiableに準拠させます。

idは識別可能である必要があるので、被ってはいけません。そこで非常に低い確率でしか重複しないUUIDを利用してみます。すると、コードは以下のように書けます。

enum APPError: Error, CaseIterable, Identifiable {
    var id: UUID { UUID() }

    case server
    case app

    var type: String {
        switch self {
        case .server:
            return "SERVER"
        case .app:
            return "APPLICATION"
        }
    }
}

で、これはちゃんと動いているように見えるのですが恐ろしいバグをはらんでいるのです...

# アラートが二回表示されるバグ

一つの View だけで動作させている場合にはこれで上手くいっているように見えるのですが、ライブラリ化などした場合に問題が発生します。

というのもこの書き方だとエラーの Enum が呼ばれるたびに UUID がセットされてしまうので、「異なるエラーは異なる ID を持つ」という条件を満たすのですが、「同一のエラーであれば同じ ID を持つ」という条件が満たされないのです。

よって、この APPError がライブラリ等から呼ばれた際に、同じエラーにも関わらず違う ID が与えられているために.alert(item: <Binding<Identifiable?>, content: (Identifiable) -> Alert)が二回呼び出され、結果的にアラートが無意味に二回表示されるというバグが発生するのです。

なお、これはalertに限らずitemを利用するViewModifier全てで発生する可能性があります。

# 正しい Identifiable を設定

このバグを直すためには同一の Enum であれば常に同じ ID を返すようにすれば良いです。

いろいろ方法はありますが、簡単なのはRawValueを利用することでしょう。Enumは必ずRawValueはそれぞれのcaseで異なる必要があるので、RawValueを Id とすれば絶対に重複しません

// RawValueとしてIntを設定する
enum APPError: Int, Error, CaseIterable, Identifiable {
    var id: Int { rawValue }

    case server
    case app

    var type: String {
        switch self {
        case .server:
            return "SERVER"
        case .app:
            return "APPLICATION"
        }
    }
}

型自体は何でも良いので、とりあえず型付き Enum にしてやれば問題は解決します。

価格
    えいむーさんは明日も頑張るよ © 2021