5
5
iOS強化月間 - iOSアプリ開発の知見を共有しよう -

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

Last updated at Posted at 2023-09-21

はじめに

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

sheet_before.gif

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

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

実際の動作

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

sheet_after.gif

実装

中身

以下をコピペすれば使えます。

@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以外の使い方をすると意図しない挙動を示す可能性があるので、注意してください。

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)")
        }
    }
}

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

解説

currentItemnextItem

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

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

Wrapper

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

具体的には、 currentItemnextItem の中身が同じ場合、以下の部分で currentItem を更新できない(モノが変わらない)ことになります。

} else { // sheetが完全に閉じたとき
    currentItem = nextItem
    nextItem = nil
}

SwiftUIが「sheetを開く」という動作をしてくれるのは selectedItem の変更を検知したときなので、更新できなければsheetは開かれないのです。

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

あとがき

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

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

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

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5