[SwiftUI] sheetを快適に高速開閉するためのハック

# はじめに
SwiftUIのsheetを閉じるとき、sheetが完全に閉じきるまで、次のsheetを開くことができません。
再度開くには、前のsheetが完全に閉じてからもう一度開くボタンを押す必要があるため、せっかちなユーザーはうんざりするかもしれません。

![sheet_before.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/875930/f1bf7691-c7e6-e3b1-d8d2-b529870c92ec.gif)


また、タイミングによっては、sheetが自動で無限に開閉を繰り返してしまうというバグも発生するらしいです（そうなったらAppをkillするしかないとか）（手元では再現できず…）

こんな（ニッチな？）問題を解決するためのpropertyWrapperを実装してみました。

# 実際の動作
sheetが完全に閉じる前にOpenボタンを押すと、閉じた瞬間に自動で次のsheetが開きます。

![sheet_after.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/875930/655e5bd4-a990-ec9e-e41d-8dc3586b2a94.gif)


# 実装
## 中身
以下をコピペすれば使えます。

```swift
@propertyWrapper
struct SheetItemState<Item: Identifiable>: DynamicProperty {
    private struct Wrapper<Item: Identifiable> {
        var item: Item
        private let uuid = UUID()
    }
    
    @State private var currentItem: Wrapper<Item>?
    @State private var nextItem: Wrapper<Item>?
    
    var wrappedValue: Item? {
        get {
            currentItem?.item
        }
        nonmutating set {
            if let newItem = newValue.map(Wrapper.init) { // 開くボタンを押したとき
                if currentItem == nil { // sheetを開いていないとき
                    if nextItem == nil {
                        currentItem = newItem
                    } else {
                        currentItem = nextItem
                        nextItem = newItem
                    }
                } else { // sheetが閉じきっていないとき
                    if nextItem == nil {
                        nextItem = newItem
                    }
                }
            } else { // sheetが完全に閉じたとき
                currentItem = nextItem
                nextItem = nil
            }
        }
    }
    
    var projectedValue: Binding<Item?> {
        Binding {
            wrappedValue
        } set: { newValue in
            wrappedValue = newValue
        }
    }
}
```

## 使い方
普段 `@State` しているところを `@SheetItemState` にするだけです。

基本的には普通のStateと同様に扱えます（子ViewのBinding引数として `$selectedItem` を渡すなども可）。
ただし、sheetのitem以外の使い方をすると意図しない挙動を示す可能性があるので、注意してください。

```swift
struct ContentView: View {
    // @State var selectedItem: Item?
    // から
    @SheetItemState var selectedItem: Item?
    // に変更
    
    var body: some View {
        Button("Open") {
            selectedItem = Item(id: 0)
        }
        .sheet(item: $selectedItem) { item in
            Text("\(item.id)")
        }
    }
}
```

※ `Item` は `Identifiable` protocolに準拠している必要があります( `sheet(item:content)` がそういうものなので)

## 解説
### `currentItem` と `nextItem`
sheetを閉じるとき、 `selectedItem` が `nil` になるのは、sheetが完全に閉じた瞬間です。  
つまり、sheetが閉じきる前に `selectedItem` の中身をセットしても、閉じ切った瞬間 `nil` になってしまい、次のsheetを開くことができないのです。

これを解決するため、バッファを用意しました。  
すなわち、今sheetに使われている `currentItem` とは別で、次に使うべき `nextItem` を内部で保持するようにしました。  
`currentItem` が存在するときにボタンを押すと、新しいitemは一旦 `nextItem` に格納され、sheetを閉じることで `currentItem` が `nil` になったタイミングで、`nextItem` が `currentItem` に送られます。

### `Wrapper`
上記だけだと、sheetが閉じきる前にセットされるitemが同じ場合、閉じきったときに `selectedItem` が `nil` にならず、残り続けることになります。  
こうなってしまうと、他のitemをselectするまで、一生sheetを開くことができません。

具体的には、 `currentItem` と `nextItem` の中身が同じ場合、以下の部分で `currentItem` を更新できない（モノが変わらない）ことになります。
```swift
} else { // sheetが完全に閉じたとき
    currentItem = nextItem
    nextItem = nil
}
```
SwiftUIが「sheetを開く」という動作をしてくれるのは `selectedItem` の変更を検知したときなので、更新できなければsheetは開かれないのです。

これを解決するため、UUIDを持つ `Wrapper` を用いることにしました。
itemをセットするたび新たな `Wrapper` インスタンスを生成することで、同じ内容の `currentItem` と `nextItem` であってもUUIDが異なるようにし、無事セットされたタイミングによってこれらを区別できるようになりました。

# あとがき
sheetが完全に閉じる前に開くボタンを押した際に、前のsheetが閉じきった瞬間自動で次のsheetが開くような実装をしてみました。

せっかちなユーザーへの対策としては、sheetが完全に閉じるまでボタンを無効にするなどの策も考えられますが、次のsheetが自動で開くのが望ましい場合もあると思います（たぶん）。
自分で使う機会があるかは謎ですが、propertyWrapperの勉強になったので良かったです。

もっといい実装方法やバグがあれば、コメントで教えていただけると助かります！