Open14

Effective SwiftUI 候補(仮説)

ピン留めされたアイテム
tobi462tobi462

Zenn スクラップだと目次がない、ディスカッションしにくいという欠点を感じていたので、GitHub Discussion に移行させていただきました。
https://github.com/YusukeHosonuma/Effective-SwiftUI

ある程度時間が経ったら、本スクラップはクローズしたいと思います。

tobi462tobi462

NavigationView は常に利用する側の View で指定する

Why?

NavigationViewの宣言位置は、他コントロールと組み合わせた場合に厳密に求められることが多く、共通的に利用される View 側に定義すると面倒なケースが多い。(単なる経験則)

また、利用される View が NavigationView を必須としていないのであれば、その View の再利用性が高まる。

struct ContentView: View {    
    var body: some View {
        TabView {
            NavigationView { // 🚫 Do not moving inside.
                ChildView()
                    .navigationTitle("Fruits")
                    .toolbar {
                        ToolbarItem {
                            Button(action: {}) {
                                Image(systemName: "square.and.arrow.up")
                            }
                        }
                    }
            }
            .tabItem {
                Image(systemName: "list.bullet")
            }
        }
    }
}

struct ChildView: View {
    var body: some View {
        List(["🍎", "🍌", "🍇"], id: \.self) { item in
            NavigationLink(destination: Text(item)) {
                Text(item)
            }
        }
    }
}

Screenshot

Why not?

NavigationViewの扱いを熟知してしまえば、単なる好みの問題かもしれない。

tobi462tobi462

ObservedObject への DI をonAppeartaskなどで決して行わない

Why?

@ObservedObjectの再生成とonAppearおよびtaskの実行タイミングは一致しない。そのため、(Viewごと)ObservedObjectが再生成されて DI が行われない、という不完全な状態を生むことがある(そしてそれは多くの場合、強制アンラップによるクラッシュを誘発する)。

struct ChildView: View {
    @Environment(\.authState) var authState: AuthState!

    // ⚠️ System might recreate a entire view at any time.
    @ObservedObject var observedObject: ChildViewModel = .init()
    
    // ✅ `@StateObject` is safe.
    @StateObject var stateObject: ChildViewModel = .init()
    
    var body: some View {
        Text("Hello")
            .task {
                // 🚫 Should be avoided!
                observedObject.inject(authState: authState)
                
                // ✅ OK.
                stateObject.inject(authState: authState)
            }
    }
}

Discussion

@StateObjectであれば、Viewごとに1つのインスタンスしか生成しない(再生成されない)ことが保証されるため問題ない[1]

References

https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app

脚注
  1. Xcode 13.3.0 RC において、macOSアプリで@StateObjectのインスタンスが複数回生成されるというバグに遭遇している。 ↩︎

tobi462tobi462

.tagを enum で指定する場合、型付けされた専用のメソッドを用意する

Why?

型安全になり、コード補完の恩恵も受けられるようになる。

fileprivate enum Item {
    case all, star
}

fileprivate extension View {
    func itemTag(_ tag: Item) -> some View {
        self.tag(tag)
    }
}

struct ContentView: View {
    @State private var selected: Item = .all

    var body: some View {
        TabView(selection: $selected) {
            Text("All")
                .tabItem {
                    Image(systemName: "list.bullet")
                }
                .itemTag(.all) // ✅ Typesafe and can auto-complete.
                .tag(.all)     // 🚫 Compile error.

            Text("Star")
                .tabItem {
                    Image(systemName: "star.fill")
                }
                .itemTag(.star)
        }
    }
}

Why not?

わざわざ extension で専用のメソッドを用意するのは過剰かもしれない。

あるいはループで処理できるのであれば、これをやるメリットはない。

fileprivate enum Item: String, CaseIterable {
    case all = "All"
    case star = "Star"
}

fileprivate extension Item {
    func tabItem() -> some View {
        switch self {
        case .all:
            return Image(systemName: "list.bullet")
        case .star:
            return Image(systemName: "star.fill")
        }
    }

    func content() -> some View { ... }
}

struct ContentView: View {
    @State private var selected: Item = .all

    var body: some View {
        TabView(selection: $selected) {
            ForEach(Item.allCases, id: \.self) { item in
                Text(item.content())
                    .tabItem {
                        item.tabItem()
                    }
                    .tag(item) // ✅ Type-safe is not needed.
            }
        }
    }
}

Discussion

マーカープロトコルなどを用意して、それをもとにコードを自動生成するのはありかもしれない。ただし、今回の例ではコード量は僅かなため、過剰な仕組みとなる可能性は高い。

tobi462tobi462

サブビューを生成する処理は、Computed-property よりもメソッドを好む。

Why?

Computed-property の場合、読み手に View の生成コストが0であると誤解させやすい。また、引数を導入したくなった場合は、結局メソッドで書き直す必要が発生する。

struct ContentView: View {
    var body: some View {
        Group {
            subView1
            subView2()
            subView3(label: "3")
        }
    }

    // 🚫 Not better.
    var subView1: some View {
        Text("1")
    }

    // ✅ Good.
    func subView2() -> some View {
        Text("2")
    }
    
    // ✅ Introducing arguments is also easy.
    func subView3(label: String) -> some View {
        Text(label)
    }
}

Discussion

Computed-propertyの方が()が無くて読みやすいと感じる人もいるかもしれない。

tobi462tobi462

私が SwiftUI を学習していく過程で、プラクティスとなりそうだと感じた 候補(仮説) を列挙したものです。

十分な経験に基づいているわけではないので、仮説には誤りが含まれていたり、場合によっては逆にアンチパターンであったというケースも考えられますので、その点はご了承ください🙏

ディスカッションについては歓迎しますので、気楽にコメントいただければ幸いです。

なお、本内容を『Effective SwiftUI』という本または記事にするかは未定です。

Twitter 元スレッド:
https://twitter.com/tobi462/status/1502660173672108035

tobi462tobi462

ViewModel で非同期通信が必要な場合、MainActorで宣言する

Why?

ViewModel 内で async/await を使えるように。オブジェクトの生成は同期的に行いたいケースもあるため、必要ならば Initializer は nonisolated で宣言する。

struct ContentView: View {
    @ObservedObject var object: SharedObject = .init() // ✅ Ease to initialize.
    
    var body: some View {
        Text("Hello")
            .task {
                await object.onAppear()
            }
    }
}

@MainActor
final class SharedObject: ObservableObject {
    
    nonisolated init() {} // 💡 with `nonisolated` if needed.
    
    func onAppear() async {
        // ✅ TODO: some asynchronous process.
    }
}
tobi462tobi462

シートを実装する際は Environment のisPresenteddismissを利用する

Why?

呼び出し側から表示・非表示を制御するBinding<Bool>を渡す必要がなくなり、コードがシンプルになる。また、表示対象の View がシート表示専用にならないため再利用性も上がる。

struct ContentView: View {
    @State var isPresented: Bool = false
    
    var body: some View {
        Button("Open sheet") {
            isPresented = true
        }
        .sheet(isPresented: $isPresented) {
            SomeView() // ✅ Not need to pass a binding like `$isPresented`.
        }
    }
}

struct SomeView: View {
    
    // ✅ iOS 15+
    @Environment(\.isPresented) var isPresented
    @Environment(\.dismiss) var dismiss

    // 💡 You can use `PresentationMode` in iOS 13+ (deprecated in iOS 15+)
    @Environment(\.presentationMode) var presentationMode
    
    var body: some View {
        Text("Hello")
        
        // 💡 This view was shown as sheet.
        if isPresented {
            Button("Close") {
                dismiss() // 💡 Dismiss sheet.
            }
        }
    }
}

References

https://developer.apple.com/documentation/swiftui/environmentvalues/ispresented
https://developer.apple.com/documentation/swiftui/environmentvalues/dismiss
https://developer.apple.com/documentation/swiftui/presentationmode

tobi462tobi462

プレビュー用に空の SwiftUI プロジェクトを用意する

Why?

SwiftUI のプレビューは便利だが、プロジェクトが大きくなるにつれて、ビルド時間の問題に悩まされる。空のプロジェクトに必要なコードをコピペして、そこでプレビューすると作業効率が良い。

Screenshot

Discussion

コピペするコード量(依存)が多いとこれでも手間になるので、プレビューを用意する View 最初から厳選したほうが良いかもしれない。(e.g. ObservableObject を持たない View)

tobi462tobi462

公式ドキュメントより SwiftOnTap を先に参照する

Why?

Apple の公式ドキュメントはサンプルコードが不足していることが多く、コードの書き方や動作イメージを想像するのが難しいケースが多い。

SwiftOnTap は有志により作成されたドキュメントではあるものの、サンプルコードや動作イメージが充実しており、初心者に優しい API ドキュメントに仕上がっている。

Screenshot

Why not?

最新の正確なAPI仕様については、引き続きAppleの公式ドキュメントを参照すること。

SwiftOnTap はあくまで有志による作成なので、最新のAPI仕様には追従できていないケースもある。また、すべてのコントロールについて十分なサンプルコードが含まれているわけではない。

ただ、ドキュメントは GitHub で管理されており、ドキュメントの追加(Contribution)についても歓迎されているので、不足を感じたら PR を投げることで、より質の高いドキュメントに一歩近づけることができる。

References

https://swiftontap.com/
https://twitter.com/tobi462/status/1502150257479921667?s=20&t=UOfih-vmk8aiyrSgg1jm6w

tobi462tobi462

[for Beginners] View.body のコンパイルエラーが解消できない場合、小さな関数に移動して試す

Why?

SwiftUI の View は Swift の言語機能(Opaque Result Type、Result Builder など)をフル活用しているため、少しの間違いで難解なエラーメッセージが出力されることがある。

うまくコンパイルできない箇所のコードをコピペして小さな関数で試すことで、何を間違えているのか気づきやすくなる。

@ViewBuilder // ✅ Allow top-level `if` and `switch`.
func v() -> some View {
    // 💡 Cut and paste from `View.body`.
    if true {
        Text("Hello")
    } else {
        Button(Image(systemName: "pencil")) {
            print("hello")
        }
    }
}

Screenshot

tobi462tobi462

[for Beginners] View を作る際は view スニペットを利用する

Why?

Viewおよびプレビュー用のコードが自動生成されるため、自分でそれを書く手間を省くことができる。

Screenshot

tobi462tobi462

Button の action を関数名で指定しない

Why?

関数名(参照)で指定すると宣言的でスッキリするが、デバッグ時にブレークポイントで気軽に止められないという欠点もある。また、asyncな関数の場合は(自分で拡張しない限り)そもそもそうした記述はできず、コードスタイルの統一性の観点からもクロージャ形式で指定したほうが良い。

struct ContentView: View {
    @ObservedObject var viewModel = ContentViewModel()
    
    var body: some View {
        
        // ⚠️ Can't stop by break-point. (but break when `body` was called)
        Button("A", action: viewModel.onTapButton)

        // ✅ Can stop by break-point.
        Button("B") {
            viewModel.onTapButton()
        }
        
        // 🚫 Not allowed when `action` is async function.
        //Button("C", action: viewModel.onTapButtonAsync)
        
        // ✅ Can stop by break-point.
        Button("D") {
            Task {
                await viewModel.onTapButtonAsync()
            }
        }
    }
}

@MainActor
class ContentViewModel: ObservableObject {
    
    nonisolated init() {}
    
    nonisolated func onTapButton() {
        print("Hello!")
    }
    
    func onTapButtonAsync() async {
        // TODO: some async process
    }
}

Discussion

Xcode 13から導入された列ブレークポイントを利用すれば、関数名(参照)箇所についても止めることができる。しかし、行ブレークポイントとしても機能するため、body関数の評価時にも止まってしまうデメリットも存在する。

なお、コードスタイルについてはチームメンバーの好みなどもあるはずなので、必要に応じて話し合うこと。

tobi462tobi462

[Trade-off] SFSafeSymbols が本当に必要かよく検討する

Why?

OSS として開発されている SFSafeSymbols を利用することで SFSymbol の指定がコンパイルタイムセーフになるが、意図した表示(アイコン)になることが保証されるわけではない。

標準APIは SF Symbols アプリ(ページ下部) からコピペできるというメリットもある。

どちらがより必要なものかよく検討すること。

struct ContentView: View {
    var body: some View {

        // ✅ Ease to copy from `SF Symbols` app.
        // ✅ Readable and highlighted.
        // ⚠️ But not compile-time safe.
        Image(systemName: "square.and.arrow.up")

        // ✅ Compile-time safe.
        // 🚫 But need to type it myself.
        // 🚫 No guarantee that it will render as expected.
        Image(systemSymbol: .squareAndArrowUpFill)
    }
}