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

自作ModalWindowがとじれないのでアップデートしてみた

価格

# SwiftyUI (opens new window)

自作 ModalWindow がつくれるライブラリ SwiftyUI なのですが、利用していた新たなバグが見つかったのでその原因を調べようと思います。

sheet fullScreenCover present
モーダルデザイン PageSheet のみ FullScreen のみ 任意
閉じ方 画面外タップなど 専用のボタンが必須 画面外タップなど
サポート iOS13 iOS14 SwiftyUI
import SwiftUI
import SwiftyUI

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                ForEach(Range(0...10)) { index in
                    ButtonView(id: index)
                }
            }
            .navigationTitle("Presentation Demo")
        }
    }
}

例えば、上のようになんの変哲もないリスト内に 10 個のボタンを表示するような View を考えます。

import SwiftUI

struct ButtonView: View {
    let id: Int
    @State var isPresented: Bool = false

    var body: some View {
        Button(action: {
            isPresented.toggle()
        }, label: {
            Text("OPEN \(id)")
        })
        .sheet(isPresented: $isPresented, content: {
            UserView(id: id)
        })
    }
}

そしてそれぞれのボタンにはisPresentedsheetを紐つけておきます。

イメージとしてはこんな漢字で、10 個のボタンそれぞれにisPresentedと遷移先の View が割り当てられます。

# 実はこれでも動く

一見するとヤバそうなコードなのですが、これでも動きます。

import SwiftUI
import SwiftyUI

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

    var body: some View {
        NavigationView {
            List {
                ForEach(Range(0...10)) { index in
                    Button(action: {
                        isPresented.toggle()
                    }, label: {
                        Text("OPEN \(index)")
                    })
                }
                .sheet(isPresented: $isPresented, content: {
                    UserView(id: index)
                })
            }
            .navigationTitle("Presentation Demo")
        }
    }
}

ContentView 自体がisPresentedと遷移先の View を持っているため、どのボタンから呼び出されたかわからなくて困るんじゃないかと思うのですが、実はこれで動いてしまいます。そこはきっと Apple か何かの公式に闇の力が働いているんだと思います。

ただし、このコードは SwiftyUI で提供している自作 ModalWindow ではバグが発生して(複数の ModalWindow が同時に呼ばれてしまう)利用することができません。

ちゃんと最初に載せたようにボタンごとにisPresentedを割り当てるようにしてください。

この書き方について

この書き方は全く推奨されない。むしろちゃんと動いてしまうsheetfullScreenCoverの挙動がおかしい。

sheet fullScreenCover present
List, Form OK OK NG
VStack, LazyVStack NG NG NG

いろいろ試したところListForm限定でこの書き方は正しく動作するようでVStackLazyVStackなどを利用した場合には正しく動作しない。

ここから先は推測になるのだが、ListFormでは各要素がIdentifiableになっておりtagなどの要素でどのボタンから押されたかをisPresentedが認識している可能性がある。が、結局 VStack や LazyVStack で動かない以上、このようなコードは書くべきではない。

# PresentationMode

PresentationModeというのは SwiftUI で使える環境変数の一つでそのViewがどこからか遷移してきたかの情報を持つと書かれている文献が多い。

が、これは考え方によっては少し正しくない(これについては後述する)

例えばNavigationLinksheetfullScreenCoverで表示された遷移先の View ではこの環境変数のisPresentedの値は常にtrueになっている。

PresentationMode NavigationLink Sheet FullScreenCover
isPresented OK OK OK

調べてはいないのだが他のViewを呼び出すModifierでもそうなっていると思われる。

ではここで先程までのコードを少し変更してButtonViewから遷移した先のUserViewを以下のようにコーディングしてみる。

import SwiftUI

struct UserView: View {
    let id: Int

    var body: some View {
        Button(action: {
            // 押したらModalWindowを閉じる処理を書く
        }, label: {
            Text("CLOSE \(id)")
        })
    }
}

ここまでの UI を大雑把にフローチャートで示すと以下のようになり、List 内のButtonViewからそれぞれUserViewが呼び出せれるという仕組みになっている。

ここには載せていないがButtonViewUserViewにはそれぞれidが割り当てられているのでどこのButtonViewから呼び出されたかがわかるようになっている。

で、ここで一つ困った問題が生じる。

というのも ModalWindow として表示されたUserViewは自身を閉じる(dismiss)する方法を持たないからだ。UserViewを閉じるにはButtonViewが持つisPresentedの値をfalseにするしかないのだが、UserViewのイニシャライザにisPresentedは与えられていないためその値を変更することができない。

# 非推奨の解決法

全く推奨されない解決策が以下のコードになる。

import SwiftUI

struct UserView: View {
    @Binding var isPresented: Bool
    let id: Int

    var body: some View {
        Button(action: {
            isPresented.toggle()
            // 押したらModalWindowを閉じる処理を書く
        }, label: {
            Text("CLOSE \(id)")
        })
    }
}

これはUserViewisPresentedの値をBinding<Bool>として与え、UserView内から切り替えられるようにするものである。このコードの問題点は以下の通り。

  • イニシャライザに与える引数が増える
  • View が階層構造になっていた場合、延々とBindingを続けなければいけない

Binding の理由

賢明な読者の方なら理解されていると思うが、一応補足説明をしておく。SwiftUI の View はstructなので内部で値を更新するためにはmutatingをつけなければいけないが、それではコンパイルが通らない。

よって普通は@Stateをつけて SwiftUI フレームワークで値を変更するように委任するわけである。そして SwiftUI フレームワークは@Stateのプロパティが変更されたタイミングで View の再レンダリングを行う。

なので単にvar isPresented: Boolと書いてしまうとisPresented.toggle()の部分でコンパイルエラーがでる。じゃあ@State var isPresented: Boolならいいのではないかと思うかもしれないが、それではダメである。

何故ならisPresentedの値はそもそもButtonViewのプロパティだからである。

@Stateをつけてしまうと SwiftUI はUserViewに対してisPresentedの値が変わったときにUserViewの UI を再レンダリングしてしまう。ButtonViewが再レンダリングされないと ModalWindow がとじないのでこれでは意味がない。

結論から言えば、@Binding属性をつけるというのは「お前もこの変数の値変えてもええで、変わったらわいは UI 更新するわ」という意味なのである。

# 環境変数を利用する

そこで利用できるのが環境変数で、これを使えばイニシャライザに渡さなくても値をとってくることができます。

import SwiftUI

struct UserView: View {
    // 環境変数読み込み
    @Environment(\.presentationMode) var present
    let id: Int

    var body: some View {
        Button(action: {
            present.wrappedValue.dismisss()
        }, label: {
            Text("CLOSE \(id)")
        })
    }
}

で、このpresentationModeはその View が現在表示されているかどうかのフラグを持っているので、present.wrappedValue.dismiss()とすれば何故か View を閉じることができる。

# 意味

では何故present.wrappedValue.dismiss()でとじることができるのかということなのだが、実はこれはpresent.wrappedValue=isPresentedになっているからだ。

これだけだとわけがわからないと思うのでコードで書くと次のようになる。

import SwiftUI

struct ButtonView: View {
    let id: Int
    @State var isPresented: Bool = false

    var body: some View {
        Button(action: {
            isPresented.toggle()
        }, label: {
            Text("OPEN \(id)")
        })
        .sheet(isPresented: $isPresented, content: {
            UserView(id: id)
                .environment(\.presentationMode, $isPresented) // 内部的にこのような処理になっている
        })
    }
}

つまりUserViewに対して内部的にpresentationModeという環境変数として$isPresentedが割り当てられているのでpresentationModeの値を変えると$isPresentedの値が切り替わり、その結果として ModalView がとじるという仕組みになっている。

で、この内部的な処理が SwiftyUI では行われていないのでpresentationModeではとじることができないのだ。

- sheet fullScreenCover present
presentationMode OK OK NG

じゃあ SwiftyUI でも内部的にpresentationModeの値を割り当てれば良いような気がするのだが、それが何故か上手くいかない。

というのもPresentationModeは構造体であり、次のようなコードになっているため。

public struct PresentationMode {

    /// Indicates whether a view is currently presented.
    public var isPresented: Bool { get }

    /// Dismisses the view if it is currently presented.
    ///
    /// If `isPresented` is false, `dismiss()` is a no-op.
    public mutating func dismiss()
}

イニシャライザがないので上手く利用することができなかった。

# 自作環境変数を利用する

上手くPresentationModeを利用する方法があればよいのだが、わからなかったので別の方法を試すことにする。

今回は、環境変数としてModalIsPresentationというものを作成することにした。

struct PresentationStyle {
    private(set) var isPresented: Binding<Bool>

    public mutating func dismiss() {
        isPresented.wrappedValue.toggle()
    }

    init(_ isPresented: Binding<Bool>) {
        self.isPresented = isPresented
    }
}

struct ModalIsPresented: EnvironmentKey {

    static var defaultValue: Binding<PresentationStyle> = .constant(PresentationStyle(.constant(false)))

    typealias Value = Binding<PresentationStyle>
}

extension EnvironmentValues {
    var modalIsPresented: Binding<PresentationStyle> {
        get {
            return self[ModalIsPresented.self]
        }
        set {
            self[ModalIsPresented.self] = newValue
        }
    }
}

少々ややこしいがPresentationModeと同様の機能を取り入れるためにはこのようなコードにならざるを得なかった。

要するにmodalIsPresentedにアクセスするとそれは結局Binding<PresentationStyle>にアクセスしているのと同じで、PresentationStyledismiss()というメソッドを持っており、これを使えばisPresentedの値が反転するので View がとじるという仕組みである。

また、無理やりisPresentedの値を変更されないようにprivate(set) var isPresented: Binding<Bool>と宣言した。

これによって、setterだけがprivateになるので外部から値を変更できないようになるというわけである。

# 課題は残る

で、これでボタンでとじる動作はできるようになったのであるがまだ一つ課題が残ってしまっていた。

というのも、デバイスを傾けた際にisPresentedが変化したときと同様にupdateUIViewControllerが呼ばれてしまうという点である。

そして Form や List を用いずに一つの View に複数のpresentが表示されるような状態だと、デバイスを傾けた際にdismiss()が呼ばれてしまいモーダルがとじてしまうのだ。

モーダルを表示したままデバイスを傾けるようなことがないのであればいいのだが、プログラムとしてそういう欠点が残っているのは気がかりである。

# 解決策?

現在はUIAdaptivePresentationControllerDelegateを利用するコードに切り替えているが、前に実装していたUIPopoverPresentationControllerDelegateでも同様のことが発生するのかは気になるところである。

またはpresentViewModifierを公式のsheetと同じようにIdentifiableにできれば解決できるような気はしている。

記事は以上。

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