It’s now or never

IT系の技術ブログです。気になったこと、勉強したことを備忘録的にまとめて行きます。

【SwiftUI】独自のViewModifierを定義する方法(ViewModifierプロトコル、View extension)

概要

SwiftUIを使ってアプリケーションを作成していると、Viewのコードが長くなり、Modifierの処理を共通化したいケースがでてくると思います。
そんな時にViewModifierを独自で定義して共通化するのも選択肢の1つです。
今回は、このような場合に独自でViewModifierを定義する方法について記述します。

環境

  • Swift: 5.2.2
  • Xcode: 11.4.1

独自のViewModifierの作り方

独自のViewModifierを作るには、 ViewModifier プロトコルに準拠した struct を作成します。

// NOTE: 文字色を赤にするカスタムViewModifier
struct CustomTextModifier: ViewModifier {
    func body(content: Content) -> some View {
        // NOTE: contentに元のViewが渡ってくるため加工したい内容を処理する
        content.foregroundColor(Color.red)
    }
}

ViewModifier は、Viewプロトコルと同じように body という some Viewを返すメソッドを1つだけ持ちます。
Viewプロトコルのbodyと異なるのは、bodyの引数に content が渡ってくる点です。
この content に対して、加工したい処理を実装し some View を返すことで独自のModifierとして動作します。

Modifierの適用

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello world")
                 // NOTE: 独自のModifierを適用するには modifierメソッドを使う
                .modifier(CustomTextModifier())
        }
    }
}

独自のViewModifierをViewに適用するには、modifier とうメソッドを使います。
modifier メソッドに対して、作成した独自のViewModifierを渡すことで適用することができます。

modifier メソッドは、ModifiedContent という構造体を返します。
そのため、独自のModifierのもう一つの適用方法として、 ModifiedContentを直接定義するという方法も可能です。

struct ContentView: View {
    var body: some View {
        VStack {
            ModifiedContent(content: Text("Hello world"),
                            modifier: CustomTextModifier())
        }
    }
}

この2つの方法はおそらく、同等の処理なのではないかと思います。

(おまけ) Viewのextentionで独自のViewModifierを作る

ViewModifierは、Content(適用前のView) を受け取って、何か加工してから some View(適用後のVIew) として返すというものなので、Viewに対して拡張(extension)する方法でも同じようなことができます。

extension View {
    func redText() -> some View {
        foregroundColor(Color.red)
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello world")
                .redText()
        }
    }
}

この方法だとViewに対してメソッドを使いしているため、標準のModifierと似たような書き方ができます。

僕の場合は、例えばpadding というViewModifierを拡張して使っています。
例えば、「上パディング」だけ設定したい時に他の値を定義するのが面倒なので、次のようなメソッドを追加して記述が少なくできるようにしています。

extension View {
    // NOTE: 他の値を設定するのが面倒なので、デフォルトで0を入れている
    func padding(top: CGFloat = 0, leading: CGFloat = 0, bottom: CGFloat = 0, trailing: CGFloat = 0) -> some View {
        padding(.init(top: top, leading: leading, bottom: bottom, trailing: trailing))
    }
    
    // NOTE: 特定の箇所だけ設定する場合は、専用のメソッドを使う
    func paddingRight(_ value: CGFloat) -> some View {
        padding(trailing: value)
    }
    
    func paddingLeft(_ value: CGFloat) -> some View {
        padding(leading: value)
    }
    
    func paddingTop(_ value: CGFloat) -> some View {
        padding(top: value)
    }
    
    func paddingBottom(_ value: CGFloat) -> some View {
        padding(bottom: value)
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            Text("1")
                .padding(bottom: 10)
            Text("2")
                .paddingTop(100)
        }
    }
}

個人的には、extensionで実現できるのであれば、こちらのほうがシンプルに書けて良いかなと思っていますが、ViewMofierプロトコルを使う場合と比べて何か副作用があるのか気にはなっています。
(適切にスコープする必要があるなど、ライブラリ提供をする際などは考えることはありそうですが。。)

【SwiftUI】SwiftUIで使われるDSL構文について(@ViewBuilderとか@_functionBuilderとか)

概要

SwiftUIのViewの書き方には今までのSwiftにはなかった特徴があります。
次のような、クロージャにViewを連続で渡すようなDSL記法です。

今までのSwiftを書いたことがある人は、「なぜこんな書き方ができるんだろう?」と疑問に思われた人もいるかもしれません。

struct ContentView: View {
    var body: some View {
        VStack {
            Text("1行目")
            Text("2行目")
            Text("3行目")
        }
    }
}

これには、@ViewBuilder という仕組みが使われているのですが、今回はこの @ViewBuilderとその元の拡張につかわれている @_functionBuilderについて調べてみました。

環境

VStackのイニシャライザを確認

今回は、VStackという標準ViewComponentを例にみてみます。
まずは、VStackのイニシャライザを確認してみましょう。

@frozen public struct VStack<Content> : View where Content : View {
    @inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)
    public typealias Body = Never
}

init() の引数の最後にクロージャが定義されています。

VStack {
    Text("1行目")
    Text("2行目")
    Text("3行目")
}

これが、上記の{} で囲まれた部分です。

VStackのイニシャライザのクロージャには、@ViewBuilder という修飾子がついています。
この @ViewBuilderDSL記法を実現している仕組みの箇所です。

@ViewBuilder

次は、@ViewBuilder の中身を見ていきます。

@_functionBuilder public struct ViewBuilder {
    public static func buildBlock() -> EmptyView
    public static func buildBlock<Content>(_ content: Content) -> Content where Content : View
}

ViewBuilder structの定義は上記のようになっています。
buildBlock というメソッドが2つ定義されており、1つはContentが引数として渡されます。
なんとなくこのContentにクロージャで返す、Contentが渡されてくるのではないかと想像できます。

このViewBuilderは @_functionBuilder という拡張が定義されています。
この @_functionBuilder という拡張がこの記法を実現している仕組みです。

@_functionBuilder

@_functionBuilder 拡張は、クロージャの引数に付与する修飾を定義するための拡張 で、この@_functionBuilderで作られた修飾をつけられたクロージャは出力されるまえに buildXXXX で定義されたメソッドで処理されます。

struct VStack<Content> : View where Content : View {
  init(... @ViewBuilder content: () -> Content)
}
VStack {
    Text("1行目")
    Text("2行目")
    Text("3行目")
}

つまり、今回の場合、このVStackに渡される3つのTextは一度buildBlock メソッドで処理され結果が出力されるということになります。

複数のViewが渡される仕組み

ViewBuilderでは、複数のViewを処理するために、その数分の引数を持つ buildBlock が定義されています。
ViewBuilder の定義を見ていくと、 buildBlock メソッドは 2つだけではなく、extentionとして他にも定義されています。

extension ViewBuilder {
    public static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View
    public static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0 : View, C1 : View, C2 : View
    public static func buildBlock<C0, C1, C2, C3>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3) -> TupleView<(C0, C1, C2, C3)> where C0 : View, C1 : View, C2 : View, C3 : View
  ・・・
    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View, C9 : View
}

このように引数が最大10個まで buildBlock メソッドが拡張されています。

VStack {
    Text("1行目")
    Text("2行目")
    Text("3行目")
}

例えば上記のような、3つのViewが渡されるクロージャでは、次のような3つの引数の buildBlock を使って処理をします。

public static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2) -> TupleView<(C0, C1, C2)> where C0 : View, C1 : View, C2 : View

つまり、クロージャの1構文(=1View)が各buildBlockメソッドの引数に割り当てられるということです。

このため、現在のバージョンでは、指定するクロージャでは、最大10個までしかViewを渡せない という制限があります。

また buildBlockメソッドでは、複数のViewを処理する場合は、TupleView というタプルをイニシャライザに渡せるViewをつかって1つのViewとして返しています。

試してみる

なんとなく、ViewBuilderについて理解が深まってきたので実際にコードで試してみます。

@_functionBuilder struct CustomViewBuilder {
    static func buildBlock<Content>(_ content: Content) -> Content where Content : View {
        content
    }
    static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View {
        // NOTE: TupleViewという複数のViewをタプルで受け取って1つのViewにするViewを使う
        TupleView((c0, c1))
    }
}

まずは、@_functionBuilder CustomViewBuilder を作ります。
このViewBuilderでは引数に2つまでしかViewを受け取れません。
また、1つのViewを返すために、タプルで複数のViewを受け取れる TupleView というViewを使っています。

struct MyVStack<Content>: View where Content: View {
    var content: () -> Content
   
    // NOTE: CustomViewBuilderをつけることでクロージャにViewを複数受け取れるようになる
    init(@CustomViewBuilder content: @escaping () -> Content) {
        self.content = content
    }
    
    var body: some View {
        VStack {
            self.content()
        }
    }
}

このCustomViewBuilderを使って、contentを受け取れるようなViewを作っていきます。

struct MainView: View {
    var body: some View {
        VStack {
            MyVStack {
                Text("1行目")
                Text("2行目")
            }
        }
    }
}

所感

初めは???となることも多かったViewBuilderですが仕組みがわかってしまえば、「なるほど〜」という感じでした。
bodyの直下では、普通にforやif構文が使えるのにViewの引数のクロージャではifしか使えないなどの理由がわかりスッキリしました。

個人的には、クロージャの構文をパースして引数に割り当てる処理がどのように実現されてるのかが気になりましたが、swiftのコードを読めば理解できるのでしょうか?(余裕があればいつか見てみたいです)

【SwiftUI】sheetやNavigationLinkで遷移するときに子Viewが再描画されてしまうのを防ぐ

はじめに

この記事の内容は、実装方法に依存する話です。
実装によっては必要ない場合もありますのでその前提で読んでいただければと思います。

環境

概要

SwiftUIの画面描画は基本的には階層構造になっています。

struct FirstView: View {
    var body: some View {
        VStack {
            SecondView()
        }
    }
}

struct SecondView: View {
    var body: some View {
        VStack {
            ThirdView()
        }
    }
}

このように、親のViewから子Viewをbodyプロパティ内から呼び出す方式になっており親Viewのbodyプロパティが再計算されると、その子Viewも再度生成されます。
(「生成される=構造体が作り直される」のため、init()メソッドが呼ばれます)

最近SwiftUIでアプリを実装するにあたり、この仕組みによってハマったことがあり、今回はその話です。

発生していた問題

実装しているアプリで、次のような sheet を使ったモーダルViewを表示しているときの話です。

struct FirstView: View {
    @State var isShowSecondView = false
    var body: some View {
        VStack {
            ・・・
            Button(action: {
                self.isShowSecondView.toggle()
            }) {
                Text("show SecondView")
            }
            .sheet(isPresented: $isShowSecondView) {
                SecondView()
            }
        }
    }
}

sheetで呼び出すSecondViewは次のようになっています。

struct SecondView: View {
    var body: some View {
        VStack {
            Text("SecondView")
            // 初期データを表示する
            ・・・
        }
        .onAppear {
            // DBから初期データを読み込む
            loadInitData()
        }
    }
}

SecondViewでは、onAppearで初期データを読み込んでおり、データを画面に表示していました。

また、このアプリでは、定期的にデータをサーバーから再取得する処理があり、「FirstViewではデータが再取得されたタイミングで画面を再描画」していました。

このような構成で実装していると、なぜか SecondViewの初期データが表示されない という事象が度々発生していました。

問題の原因

これは概要にもあるViewの階層構造の読み込みに起因する話で、FirstViewのbodyプロパティが再計算されたタイミング(=再描画されたタイミング)でSecondViewも再度生成されます。

これが原因で onAppearで読みこんでいた初期データの内容が、SecondViewの再描画時に消えてしまっていたというのが原因でした。
(onAppear は初回描画時にのみ呼ばれ、再描画では呼ばれないため2回目の描画時にデータが消えてしまった)

解決方法

結論としては、この問題を解決するため次のように ボタン+sheet modifierを別のViewに切り分ける ことで対応しました。

struct ShowSecondViewButton: View {
    @State var isShowSecondView = false
    var body: some View {
        Button(action: {
            self.isShowSecondView.toggle()
        }) {
            Text("show SecondView")
        }
        .sheet(isPresented: $isShowSecondView) {
            SecondView()
        }
    }
}

struct FirstView: View {
    var body: some View {
        VStack {
            ・・・
            ShowSecondViewButton()
        }
    }
}

なぜこれで解決できるのか?

sheet modifierとButtonがFirstViewのbody内にある場合、その他のbodyの要素が更新されるとこれらのViewも強制的に更新されてしまい。
よって、モーダルが表示されている状態では、そのモーダルViewであるSecondViewも強制的に更新されます。

しかし、Viewの計算型プロパティであるbodyは、中身が変わらない場合 Viewが再生成されても、呼び出されません
よって、sheet modifierとButtonだけを別Viewとして切り出せば、そのbodyの内容が変わらない限りは再描画することはありません。

上の例でいうと FirstView が再描画されたタイミングで ShowSecondViewButton のinit()は呼ばれますが、bodyのプロパティは再度呼ばれないため、モーダルである SecondView も再描画されない。ということです。

補足(この現象がよく発生するパターン)

今回の事象は、SecondViewのbodyプロパティの内容が変更されなければ再描画されないため、必ず発生するわけではありません。

struct SecondView: View {
    @Environment(\.presentationMode) var presentationMode

    var body: some View {
        NavigationView {
            HStack {
                Text("second view")
            }
            .navigationBarItems(leading: Button(action: {
                self.presentationMode.wrappedValue.dismiss()
            }) {
                Text("閉じる")
            })
        }
    }
}

上記のようなモーダル画面の場合は、再生成時にbodyが再計算されないため今回のような事象は起きません。

ではどんな場合に発生するかというと、1つは「ViewでObservedObjectを使っているケース」があります。

class SecondViewModel: ObservableObject { }
struct SecondView: View {
    @Environment(\.presentationMode) var presentationMode
    @ObservedObject(initialValue: SecondViewModel()) var viewModel: SecondViewModel

    var body: some View {
        NavigationView {
            HStack {
                Text("second view")
            }
            .navigationBarItems(leading: Button(action: {
                self.presentationMode.wrappedValue.dismiss()
            }) {
                Text("閉じる")
            })
        }
    }
}

先程のSecondViewに SecondViewModel という@ObservedObjectを宣言します。
そうするとこのViewは、生成時に毎回 body内が再計算されるようになります。
(詳しい原理までは、理解しきれてませんが監視のロジック的にそういう挙動になるのだと思っています)

僕のアプリでもこのような実装をしていたので、毎回再描画が発生していました。

その他、initメソッド内でbody内で参照するプロパティを動的に更新するケースでも再描画が発生します。

まとめ・所感

SwiftUIを始めたばかりの頃、onAppear でデータを初期化する実装で、データがよく消えるという事象が起こり結構はまりました。
initでデータ初期化する方法もあるとは思いますが、ネットワークから取ってくる場合などは再描画のたびに通信されるため、あまり現実的ではありません。

自分の中の結論としては、レンダリングの範囲が限定されるように細かくViewを分けるのがベストだと思っており、とくに画面遷移部分は今回のように分離してガードしています。
もっといい方法がありそうな気もしていますが、見つかるまではこの方法で対応しようと思います。 (そのうちバージョンが上がればもっと簡単に実装できるようになりそう?)

今回のような事象は、階層構造の上位Viewで頻繁に画面描画が発生するアプリ(タイマー系など)は起きがちなので注意が必要かもしれません。

以上、似たような事象で困っている人の参考になれば幸いです。

【SwiftUI】@Stateの基本的な使い方

概要

前回の記事では、propertyWrapperの基本について書きました。

inon29.hateblo.jp

SwiftUIではこのpropertyWrapperの機能を使って様々な機能が提供されています。
今回は、値の更新を検知してViewを再描画させるためのpropertyWrapper @State についてみていきます。

環境

@Stateとは

@State は Viewの更新を監視するための propertyWrapper で、SwiftUIを使って画面を実装するための基本的な機能の1つです。

@StateをつけたView内のプロパティが変更されるとViewの body プロパティが再評価されViewが更新されます。

次のコードは簡単なサンプルです。

struct ContentView: View {
    @State private var count = 0
    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button(action: {
                self.count += 1
            })
            {
                Text("count up")
            }
        }
    }
}

上記サンプルでは、count というViewプロパティが @Stateで宣言されています。
「count up」のラベルがつけられたボタンを押下すると count プロパティの値が1つずつ加算され、その変更を検知するたびにViewが再描画されます。

@Stateで宣言されたプロパティの特徴

@Stateで宣言されたプロパティの特徴で気になったものを記述します。

① mutating キーワードをつけた関数以外から更新できる

Viewは構造体のため、プロパティを構造体内部から更新するためには、mutating キーワードをつけたメソッドから更新する必要がありますが、@Stateをつけたプロパティは View内のどこからでも値の更新が可能 です。

② View内部と子View内からしか参照/変更できない

@Stateをつけたプロパティは、基本的にView内部(bodyのgetter内)とその子View内からのみ参照/更新されます。
そのため@Stateのプロパティは private として宣言するのが推奨のようです。

③ Viewが再描画(再初期化)されても@Stateの値を保持する

SwiftUIのView構造は、親View内から子Viewを宣言する階層構造になっており、基本的に親Viewが再描画されるタイミングでは子Viewも再描画されます。
(再描画時には、init() が呼ばれ構造体自体が再生成されます。)
その時、子Viewが @Stateのプロパティを持っていた場合、再描画されたタイミングで@Stateの値は初期化されません

次のサンプルをみてみます。

struct ParentView: View {
    @State private var count = 0
    var body: some View {
        VStack {
            Text("Count: \(count)")
            Button(action: {
                self.count += 1
            })
            {
                Text("count up")
            }
            // NOTE: 子Viewの宣言
            ChildView()
        }
    }
}

struct ChildView: View {
    @State private var text = ""
    
    init() {
        // NOTE: 親ViewのStateが変わるたびにinitが呼び出される。
        print("init child view.")
    }
    
    var body: some View {
        VStack {
            // NOTE: 構造体が作り直されても @Stateで保持している値は初期化されない
            Text("Text: \(text)")
            Button(action: {
                self.text = "Hello"
            })
            {
                Text("set text")
            }
        }
    }
}

ParentViewは、count という @Stateのプロパティを持っています。 また、子Viewとして ChildViewをbody内で描画しています。
ChildViewは @Stateのプロパティとして text という文字列を持っています。

このコードを実行し、まずChildViewの「set text」ボタンを押下します。
このタイミングで、ChildViewの text プロパティに「”Hello”」という文字列がセットされます。

次に、ParentViewの「count up」ボタンを押下します。
このタイミングでcountプロパティが更新されParentViewは再描画されます。
同時にChildViewも作り直されます。

この時、先程設定した text プロパティの文字列「”Hello"」の値は構造体が変わっても初期化されずに残ります。
これは@StateのpropertyWrapperで管理する内部の値が独自のストレージ管理をされているからのようですが、詳細の仕組みまではよくわかっていません。

@Stateで宣言するプロパティのデータ型

@Stateで管理できるプロパティのデータ型について、基本的なパターンを見ていきます。

基本データ型

IntString など基本データ型を @Stateで管理する場合です。

struct ContentView: View {
    @State var name = ""

    var body: some View {
        return VStack {
            Text("name: \(name)")
            Button(action: {
                self.setName()
            }) {
                Text("set string name")
            }
        }
    }
    
    func setName() {
        name = "A"
    }
}

これは、特に意識せず値を再代入することでViewが再描画できます。

構造体

@Stateで管理するデータ型が構造体(struct)の場合です。

struct ContentView: View {
    struct User {
        var name = ""
    }
    @State var user: User = User()

    var body: some View {
        return VStack {
            Text("name: \(user.name)")
            Button(action: {
                self.setName()
            }) {
                Text("set struct name")
            }
        }
    }
    
    func setName() {
        user.name = "A"
    }
}

@Stateで管理するデータ型が構造体の場合、構造体のプロパティの値が変わってもViewは再描画されます。

func setName() {
    var u = User()
    u.name = "A"
    user = u
}

このように構造体自体を作り直す必要はありません。(上記でも再描画は発生します)

クラス

@Stateで管理するデータ型がクラス(class)の場合です。

struct ContentView: View {
    class User {
        var name = ""
    }
    @State var user: User = User()

    var body: some View {
        return VStack {
            Text("name: \(user.name)")
            Button(action: {
                self.setName()
            }) {
                Text("set class name")
            }
        }
    }
    
    func setName() {
        user.name = "A"
    }
}

クラスのプロパティを変更しても、Viewの変更は検知されずViewは再描画されません
ただし、次のようにインスタンス自体を更新した場合は、再描画されます。

func setName() {
    var u = User()
    u.name = "A"
    user = u
}

クラスを監視対象として使用したい場合は、別に @ObservedObject というpropertyWrapperが提供されているのでそちらを使うのが一般的です。

【SwiftUI】SwiftUIにおける基本的な画面遷移について

概要

SwiftUIにおける基本的な画面遷移の方法についてまとめてみました。

SwiftUIはぱっと1画面を試すのは非常にシンプルで便利なのですが、個人的にはまだ画面遷移の方法に慣れない部分もあり、整理してみました。

環境

① NavigationLink

https://developer.apple.com/documentation/swiftui/navigationlink はViewをスタック構造(push/pop)で管理して遷移する方法です。
UIKitにおけるUINavigationControllerによる遷移と同じ動きをするiOSの伝統的な画面遷移の方法になります。

NavigationLinkは、NavigationView の配下に設置したSubViewでのみ動作します。

実装例

struct RootView: View {
    var body: some View {
        NavigationView {
            // NOTE: NavigationLinkはNavigationViewの内側でなければならない
            NavigationLink(destination: SubView()) {
                // NOTE: Labelを指定すると遷移先へのリンクが自動的に生成される
                Text("Move to SubView")
            }
        }
    }
}

struct SubView: View {
    var body: some View {
        VStack {
            Text("SubView")
        }
        // NOTE: navigationBarに関連するModifierはNavigationViewのSubviewでなければならない
        .navigationBarTitle("SubView")
    }
}

NavigationLink を配置すると、引数に渡したクロージャによって返すLabelを使って画面遷移のボタンを自動的に生成してくれます。

コードによる自動的な遷移

例えばログイン後にHomeへ自動遷移する場合など、ボタンアクション以外で遷移したい場合は、前述のようにボタンの配置は必要ありません。
このような、NavigationLinkをつかって自動的に画面遷移を行いたい場合は、次のように記載します。

struct RootView: View {
    @State var isActiveSubView = false
    var body: some View {
        NavigationView {
            VStack {
                // NOTE: コード上のイベントで遷移したい場合は、LabelにEmptyViewを指定する
                NavigationLink(destination: SubView(),
                               isActive: $isActiveSubView) {
                                EmptyView()
                }
                Button(action: {
                    self.isActiveSubView.toggle()
                }) {
                    Text("画面遷移イベント擬似的に発火")
                }
            }
        }
    }
}

struct SubView: View {
    var body: some View {
        VStack {
            Text("SubView")
        }
        .navigationBarTitle("SubView")
    }
}

NavigationLinkのLabelに EmptyView() を指定することで画面には何も表示されなくなります。
あとは、isActiveで Binding<Bool> の値をイベントの発火で変更してあげることで、画面遷移を発生させます。
なにか強引な気もするのですが、今の所NavigationLinkにLabelなしのイニシャライザが存在しないため、僕はこの方法で実装しています。

② sheet

sheetは、画面下から表示されるモーダル画面による遷移です。
UIKitにおけるUIViewControllerのpresentによる画面遷移と同等の遷移方法になります。

実装例

struct RootView: View {
    @State var isPresentedSubView = false
    var body: some View {
        VStack {
            Button(action: {
                self.isPresentedSubView.toggle()
            }) {
                Text("モーダル画面を表示")
            }
            .sheet(isPresented: $isPresentedSubview) {
                SubView()
            }
        }
    }
}

struct SubView: View {
    var body: some View {
        VStack {
            Text("SubView")
        }
    }
}

.sheet というModifierを使うとモーダル表示を行うことができます。
isPresented にわたす Binding<Bool> の値を変更することで画面遷移を発生させられます。

③ それ以外

最後は①、②以外の方法で画面遷移を行う場合についてです。
これは特別なメソッドを使うわけではなく、単純にBoolのフラグによってSubViewをレンダリングするかしないかで制御します。
遷移時にアニメーションをつけたい場合は、ViewのAnimationを自前で実装します。

実装例

struct RootView: View {
    @State var isShowSubViw = false
    var body: some View {
        ZStack {
            // NOTE: 画面をレンダリングするかで画面遷移を発生する
            if isShowSubViw {
                SubView()
            } else {
                Button(action: {
                    withAnimation() {
                        self.isShowSubViw.toggle()
                    }
                }) {
                    Text("SubViewへ遷移")
                }
            }
        }
    }
}

struct SubView: View {
    var body: some View {
        // NOTE: 画面遷移アニメーションは自前で書く
        GeometryReader { geometory in
            ZStack {
                VStack {
                    Text("SubView")
                }
            }
            .frame(width: geometory.size.width,
                   height: geometory.size.height)
            .background(Color.green)
            .animation(.easeInOut(duration: 0.42))
        }
        .transition(.move(edge: .bottom))
    }
}

上記サンプルでは、画面したからアニメーションで表示される画面遷移を自前で実装しています。

所感

③のフラグで遷移する方法は、UIKItに慣れてきた分、個人的にはすこし違和感があり慣れません^^;。

ですが、NavigationLinkの画面遷移のみではNavigationBarの制御などがうまく行かないケースがありこの方法で画面遷移を行う箇所もあります。

ViewがレンダリングされるタイミングでOnAppearなどの呼び出しも発生するため、この方法でも他遷移と同じ挙動なのですが、原始的な感じもします。

もっとスマートな方法があるのでないかとも思っていますがどうなのでしょうか?

【SwiftUI】SwiftUIでハマったところ(onAppaerが呼ばれるタイミング/environmentの伝播の範囲)

概要

最近はSwiftUIでアプリを書いているんですが、色々と戸惑うところが多かったので備忘録的にまとめておきます。

環境

Swift version 5.1

onAppearが呼ばれるタイミング

画面再描画時の挙動

SwiftUIには、Viewが初めて描画されるタイミングで呼ばれるコールバックメソッドとして onAppear が用意されています。
(この逆のメソッドとしてViewが非表示になるタイミングで呼ばれる onDisappear もあります。)

SwiftUIでは、Viewの状態の変更変更を検知し、画面の再描画をすることでリアクティブなプログラムを実現していますが、 onAppearは、この画面再描画とは関係なく初めてViewが表示される1回目に呼ばれます。

Viewが再描画される時は、Viewのstructは基本的に再度生成されます。(initメソッドが呼ばれます)
しかし、ViewがinitializeされてもonAppearは呼ばれません。

この辺を理解せずに、onAppearでデータの初期化処理を書いている場合、意図しないタイミングで再描画が走り、データが消えてしまうことがあります。
(僕は何度かハマりました..)

画面遷移時の挙動

SwiftUIの画面遷移は、大きく分けて NavigationLink を使ったスタックの遷移(UIKitでいうUINavigationControllerによるpush/pop)と、 sheet を使ったモーダルの遷移があります。

これらの画面遷移によって画面が変わった場合の onAppear が呼ばれるタイミングにも違いがあります。

NavigationLinkを使った画面遷移の場合は、親画面へ戻る時には親画面の再描画が走ります。そしてこのタイミングで onAppearが呼ばれます

親View -> 子ViewへNavigationLinkで遷移 -> 戻るボタンで親Viewへ遷移 -> onAppear が呼ばれる

しかし、sheetを使ってモーダルとして画面を表示した場合、モーダルを閉じたタイミングでは onAppearは呼ばれません

親View -> 子Viewをsheetで表示 -> dismiss親Viewへ遷移 -> onAppear は呼ばれない

これは、UIKitでも似たような挙動です。

UIKitの画面管理クラスであるViewControllerにも、画面表示時に呼ばれる viewWillAppear というメソッドが存在します。

また、UIKitのスタックによる画面遷移には、UINavigationControllerが使用されますが、このUINavigationControllerを使って画面遷移した場合、 親の画面へ戻った時には、viewWillAppearが呼ばれます

一方でモーダルの遷移には present というメソッドを使いますが、このメソッドでモーダル遷移した場合は戻ったときにviewWillAppearは呼ばれません

この辺は、UIKitの挙動を理解されている方ならば直感的かもしれません。

sheetによるモーダル遷移は、Environment(EnvironmentObject)の値が伝播しない

SwiftUIにはViewの環境変数のようなもので、View階層の一番上のViewに設定したEnvironmentは階層下の子Viewでも参照することが可能です。

View間で値を受け渡すときに非常に重宝するEnvironmentなのですが、画面遷移の方法によって値の伝播のされ方が異なります。

前述の通り、SwiftUIの画面遷移は NavigationLink を使ったスタックの遷移と、sheetを使ったモーダル遷移があります。

NavigationLinkを使ったスタックによる画面遷移を行った場合、Environmentは遷移先のViewにも伝播され使用することができます。
しかし、sheetを使ったモーダル遷移の場合、Environmentは遷移先のViewには伝播されません。

// テスト用のEnvironmentObject
class EnvObject: ObservableObject {
    var text = "envObject"
}

struct ParentView: View {
    @State var isActiveSheet = false
    @State var isActiveLink = false

    var body: some View {
        NavigationView {
            VStack {
                Button(action: {
                    self.isActiveSheet = true
                }) {
                    Text("show sheet")
                }
                // NOTE: NavigationLinkでの遷移は、親のEnvironmentをそのまま引き継ぐ
                NavigationLink(destination: NavigationSubView(), isActive: $isActiveLink) {
                    Text("show link")
                }
            }
            .onAppear {
                print("onAppear")
            }
            .sheet(isPresented: $isActiveSheet) {
                // NOTE: sheetの遷移では再度environmentをセットしないと値を参照できない
                SheetView()
                    .environmentObject(EnvObject())
            }
        }
        .environmentObject(EnvObject())
    }
}

struct SheetView: View {
    @EnvironmentObject var envObject: EnvObject
    
    var body: some View {
        VStack {
            Text("Sheet")
            Text(envObject.text)
        }
    }
}

struct NavigationSubView: View {
    @EnvironmentObject var envObject: EnvObject
    
    var body: some View {
        VStack {
            Text("Navigation")
            Text(envObject.text)
        }
    }
}

上記サンプルは、EnvironmentObjectを使った値の伝播例ですが、sheetを使った画面遷移時は environmentObject メソッドで再度値を渡さないとエラーになります。

UIKit時代からのViewControllerの階層構造を考えると理解は出来る仕様なのですが、久しぶりのiOSでSwiftUIから入ったため何度か戸惑いました。

まとめ

今回は以上です。
SwiftUIはUIKitとは全く異なる画期的な変化だと思いつつもUIKitの挙動を知らないと???となる時が時々あります。
ほかにも色々とハマりどころがあるのでこまめにまとめていきます。

【Swift】【SwiftUI】Property wrappers に入門してみた

概要

Swift 5.1から「Property Wrappers」という機能が導入されました。

これはプロパティの読み込み/書き込みをカプセル化するためのデータ構造です。

SwiftUIの導入により、Swiftには様々な機能が追加されましたが、このProperty Wrappersはその中でも特に重要な根本機能です。
まずはこの基本を理解して、その他の新機能についても深堀りできればと思います。

実行環境

  • Swift 5.1

実装例

簡単な例でみてみましょう。

struct Test {
    @DefaultValue(defaultValue: 10)
    var num: Int
}

@DefaultValue がプロパティラッパです。
このようにプロパティの前に 「@xxx」のように記述することでプロパティラッパを付与することができます。

この例の「@DefaultValue」では、ある数値プロパティに対して予めデフォルト値を設定しておくことができます。
仕様としては、プロパティに0が設定された時(値が設定されていない時)には、予め設定されているデフォルト値が設定されるようにします。

参照例は次のとおりです。

var t = Test()
print(t.num) // => 10(デフォルト値)
t.num = 20
print(t.num) // => 20
t.num = 0
print(t.num) // => 10(デフォルト値)

これを実現するためのコードは次のようになります。

@propertyWrapper
struct DefaultValue<Value> where Value: Numeric {
    private var value: Value
    private var defaultValue: Value
    
    // デフォルトのイニシャライザ
    init(wrappedValue v: Value) {
        value = v
        self.defaultValue = 0
    }
    
    // カスタムイニシャライザ(デフォルト値の設定)
    init(defaultValue: Value) {
        value = defaultValue
        self.defaultValue = defaultValue
    }
    
    var wrappedValue: Value {
        get { value }
        set {
            // 値が0の場合はデフォルト値を設定する
            if newValue == 0 {
                value = defaultValue
            } else {
                value = newValue
            }
        }
    }
}

プロパティラッパを定義するには、構造体、列挙型、クラスの定義の前に「@propertyWrapper」を置きます。
@propertyWrapperとして定義すると、計算型プロパティ「wrappedValue」の実装が必須になります。
initを必須ではありませんが、多くの場合は初期値設定として実装することが多いかと思います。
また、プロパティラッパは様々な型のプロパティへ付与されるためジェネリクスを利用して方を定義する場合が多いです。
(今回は数値を例にしているのでNumericに適用する型を定義しています)

プロパティラッパをあるプロパティに設定した場合、そのプロパティへの参照は、この「wrappedValue」へのアクセスへ仲介されます。

var t = Test()
// 動作時にはDefaultValue構造体のwrappedValueのsetterが呼び出される
t.num = 100

このようにして、値への操作をカプセル化することが可能です。

今回の例では、値をセットする時に値が0であればデフォルト値が設定されるように「wrappedValue」のsetterを実装しています。

ストレージプロパティ

プロパティラッパを付与したプロパティへのアクセスは、「wrrappedValue」を仲介されますが、プロパティラッパの実装領域へ直接アクセスすることも可能です。

struct Test {
    @DefaultValue(defaultValue: 10)
    var num: Int
}

このプロパティ num は、 _num とすることでプロパティラッパの構造へアクセスすることができます。

struct Test {
    @DefaultValue(defaultValue: 10)
    var num: Int
    
    func showNum() {
        // DefaultValueのプロパティwrappedValueへアクセスできる 
        print(_num.wrappedValue)
    }
}

var t = Test()
t.showNum() // => 10

このようにプロパティラッパの実装領域へ直接アクセスできるプロパティ(_をつけたプロパティ)を ストレージプロパティ といいます。
ストレージプロパティの可視範囲はprivateになっており、構造体の外からはアクセスできません。

射影値(projected value)

ストレージプロパティは、privateのため外部参照がでませんが、目的によっては外部から意図的にアクセスしたいケースが存在します。
そのためにプロパティラッパには 射影値(projected value) という計算型プロパティが用意されています。
この射影値を使うとプロパティを定義した構造体やクラスの外部からプロパティ内部の値へアクセスすることが可能です。

@propertyWrapper
struct DefaultValue<Value> where Value: Numeric {
    ・・・ 
    var projectedValue: Value {
        get { value }
        set {
            if newValue == 0 {
                value = defaultValue
            } else {
                value = newValue
            }
        }
    }
}

このように @propertyWrapper を定義した構造体やクラスに projectedValue という計算型プロパティを実装します。
こうすることで、プロパティラッパを付与した構造体やクラスからは、$<プロパティ名> でのアクセスが可能となります。

var t = Test()
t.$num = 20
print(t.$num) // => 20

$ をプロパティ名の先頭につけ $num としてアクセスすることで「projectedValue」プロパティを仲介することができます。

まとめ

「Property Wrappers」を使うことにより、様々なプロパティの値をカプセル化して処理することが可能になりました。
また射影値を使うことでクラスや構造体から外部の値を参照や更新することが可能となりSwiftUIの機能の根幹ともなっています。
一方で初見では理解が難しい機能だとも感じており、開発を円滑に進めるためにも少しずつ根本理解していく必要がありそうです。