🤝

[SwiftUI] Binding<Array<T>>をRandomAccessCollectionに準拠させる

2021/02/25に公開

ForEach内部のViewでBindingしたい時があります。EditViewはtextを編集するViewです。

ForEach(texts){text in
    EditView(text: $text) //できない
}

最も単純な解決法としては以下があります。

ForEach(texts.indices, id: \.self){i in
    EditView(text: $texts[i])
}

ただ、SwiftUIの不具合か自分の実装の誤りか、この方法でForEachを使うと以下のようにした場合にListの編集で原因不明のFatalErrorが出ました。

List{
    ForEach(texts.indices, id: \.self){i in
        EditView(text: $texts[i])
    }
    .onDelete(perform: delete)
    .onMove(perform: move)
}

納得はいかないのですが、ForEach.init(Data, content: (Data.Element) -> Content)を呼ぶかForEach.init(Data, id: KeyPath<Data.Element, ID>, content: (Data.Element) -> Content)を呼ぶかで結果が分かれるのはなくはなさそうですし、実際に前者ではエラーが起こらないので、そういうものではないかと思います。

このような状況のため、どうしても前者のinitを呼びたくなりました。そこで次の解決策がこれです。

class EditString: ObservableObject, Identifiable {
    let id = UUID()
    @Published var text: String
}
struct ContentView: View {
    @State private var texts: [EditString] = [...] //初期化

    var body: some View {
        ForEach(texts){text in
            EditView(text: text)
        }
    }
}

struct EditView: View {
    @ObservedObject private var text: EditString
    init(text: EditString){
        self.text = text
    }
    
    var body: some View {
        //View
    }
}

この方法であればListの不具合は起こりません。ところがこの方法では、NavigationLinkを介した時に値の変更がうまく通知されませんでした。

そこで第3の解決策として、Bindingな配列をForEachに渡そうとしてみましたが、これはBinding<[String]>RandomAccessCollectionに準拠してないよ、と怒られます。

ForEach($texts, id: \.self.wrappedValue){text in
    EditView(text: text)
}

しかし準拠していないなら自分で準拠させれば良いのではないでしょうか。ということで以下のようにしました。

extension Binding: IteratorProtocol where Value: IteratorProtocol{
    public mutating func next() -> Binding<Value.Element>? {
        guard var item = wrappedValue.next() else {
            return nil
        }
        return Binding<Value.Element>(
            get: {
                item
            },
            set: {
                item = $0
            }
        )
    }
}

extension Binding: Sequence where Value: BidirectionalCollection{
    public typealias Element = Binding<Value.Element>
    public typealias Iterator = Binding<Value.Iterator>

    public __consuming func makeIterator() -> Binding<Value.Iterator> {
        var iterator = wrappedValue.makeIterator()
        return Binding<Value.Iterator>(
            get: { iterator },
            set: { iterator = $0}
        )
    }
}

extension Binding: Collection where Value: BidirectionalCollection & MutableCollection{
    public subscript(position: Value.Index) -> Binding<Value.Element> {
        get {
            return Binding<Value.Element>(
                get: {
                    wrappedValue[position]
                },
                set: {
                    wrappedValue[position] = $0
                }
            )
        }
    }
    public var startIndex: Value.Index {
        wrappedValue.startIndex
    }

    public var endIndex: Value.Index {
        wrappedValue.endIndex
    }

    public typealias Index = Value.Index
}

extension Binding: BidirectionalCollection where Value: BidirectionalCollection & MutableCollection {
    public func index(after i: Value.Index) -> Value.Index {
        wrappedValue.index(after: i)
    }

    public func index(before i: Value.Index) -> Value.Index {
        wrappedValue.index(before: i)
    }
}

extension Binding: RandomAccessCollection where Value: BidirectionalCollection & MutableCollection {}
//ついでにIdentifiableにも準拠させておく
extension Binding: Identifiable where Value: Identifiable{
    public var id: Value.ID {
        wrappedValue.id
    }

    public typealias ID = Value.ID
}

コンパイラに怒られるままに実装を書いたので過不足があるかもしれませんが、これでBinding<[String]>はばっちりRandomAccessCollectionに準拠します。実行してみたところ全てのエラーが解決しました。

ForEach($texts){text in
    EditView(text: text)
}

正直SwiftUIに標準で備わっていても良い機能のように思いますが、ひとまず解決してよかったです。

Discussion