👋

【SwiftUI】Parameter Packs を使ってみた

に公開

先日、Parameter Packs を使ってみたので、軽くではありますが備忘録も兼ねてここに著します。

まず最初に結論(?)ですが、下記のコードのように改善が図れました。

// Before
CustomTabView("house", "camera", "bell", "person.crop.rectangle") {
    HomeView()
    SnapView()
    NotificationView()
    ProfileView()
}

// After
CustomTabView {
    CustomTab(systemImageName: "house") {
        HomeView()
    }
    CustomTab(systemImageName: "camera") {
        SnapView()
    }
    CustomTab(systemImageName: "bell") {
        NotificationView()
    }
    CustomTab(systemImageName: "person.crop.rectangle") {
        ProfileView()
    }
}

Before の CustomTabView の第一引数は、String の可変長引数です。

HomeView のタブアイコンにはシステムイメージの "house" が使用され、SnapView のタブアイコンには "camera" が使用されるといった感じです。

struct CustomTabView<TabContents: View>: View {
    private let systemImageNames: [String]
    private let tabContents: TabContents

    init(_ systemImageNames: String..., @ViewBuilder tabContents: () -> TabContents) {
        self.systemImageNames = systemImageNames.map(\.self)
        self.tabContents = tabContents()
    }

    var body: some View {
        VStack(spacing: .zero) {
            Group(subviews: tabContents) { subviewsCollection in
                subviewsCollection[0]
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .overlay(alignment: .bottom) {
            HStack(spacing: .zero) {
                ForEach(systemImageNames, id: \.self) { systemImageName in
                    Image(systemName: systemImageName)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 50, height: 20)
                        .foregroundStyle(systemImageName == systemImageNames[0] ? .black : .gray)
                        .symbolVariant(.fill)
                }
            }
            .padding(.vertical, 15)
            .padding(.horizontal, 10)
            .background(.gray.opacity(0.1), in: .capsule)
        }
    }
}

これでも問題はありませんが、以下の二点に注意を払う必要があります。

  1. システムイメージネームの順番
  2. ビューの数とシステムイメージネームの数が一致していること

これらの課題を Parameter Packs を使用することで解決できました。

まずは、systemImageName と content(ビュー)をプロパティとしてもつ CustomTab を定義します。

struct CustomTab<Content: View> {
    let systemImageName: String
    let content: Content

    init(systemImageName: String, @ViewBuilder _ content: () -> Content) {
        self.systemImageName = systemImageName
        self.content = content()
    }
}

次に、@resultBuilder を使用して、CustomTab のみを受け入れるリザルトビルダーを作成します。

CustomTab には、ジェネリック型 Content が定義されています。

通常、複数の CustomTab を引数として受け取る場合は、ジェネリック型 Content の型は全て同じ具象型でなければならず、はっきり言って使い物になりません。

ジェネリック型 Content の型が全て同じ具象型でないため、エラーが発生してしまう例
let customTab1 = CustomTab(systemImageName: "swift") { Text("Hello, world.") }
let customTab2 = CustomTab(systemImageName: "sun.min") { ProgressView() }
let customTab3 = CustomTab(systemImageName: "pencil") { Circle() }

func action1<Content: View>(customTabs: [CustomTab<Content>]) {} // 配列で複数の CustomTab を受け取る。

action1(customTabs: [customTab1, customTab2, customTab3]) // Error

func action2<Content: View>(customTabs: CustomTab<Content>...) {} // 可変長引数で複数の CustomTab を受け取る。

action2(customTabs: customTab1, customTab2, customTab3) // Error

この「複数の CustomTab を引数として受け取る場合は、ジェネリック型 Content の型は全て同じ具象型でなければならない」という制約には、オーバーロードで対処することはできます。

ただし、下記のコードのように冗長かつ、上限のある実装となってしまいます。

let customTab1 = CustomTab(systemImageName: "swift") { Text("Hello, world.") }
let customTab2 = CustomTab(systemImageName: "sun.min") { ProgressView() }
let customTab3 = CustomTab(systemImageName: "pencil") { Circle() }
let customTab4 = CustomTab(systemImageName: "pencil") { Button("action", action: {})}

func action<C1, C2>(_ customTab1: CustomTab<C1>, _ customTab2: CustomTab<C2>) {}
func action<C1, C2, C3>(_ customTab1: CustomTab<C1>, _ customTab2: CustomTab<C2>, _ customTab3: CustomTab<C3>) {}
func action<C1, C2, C3, C4>(_ customTab1: CustomTab<C1>, _ customTab2: CustomTab<C2>, _ customTab3: CustomTab<C3>, _ customTab4: CustomTab<C4>) {}

action(customTab1, customTab2)
action(customTab1, customTab2, customTab3)
action(customTab1, customTab2, customTab3, customTab4)

ここで Parameter Packs が力を発揮します。
Parameter Packs を使えば、CustomTab のジェネリック型 Content として任意の具象型を使用できます。また、先ほどの実装とは異なり、冗長性と上限もなくなります。

@resultBuilder
enum CustomTabBuilder {
    static func buildBlock<each Content: View>(
        _ components: repeat CustomTab<each Content>
    ) -> ([String], (repeat each Content))  {
        var systemImageNames = [String]()
        repeat systemImageNames.append((each components).systemImageName)

        return (systemImageNames, (repeat (each components).content))
    }
}
  • <each Content: View>は、ジェネリック型の Content は View プロトコルに準拠していれば任意の型で構わないことを表している。
  • repeat CustomTab<each Content>は、各々で任意の Content をもつ CustomTab の集合を表している。
  • 返り値のタプルの(repeat each Content)は、任意の Content の集合(タプル)を表している。

上記のように理解していただいて差し支えないと思います。

例えば、引数としてCustomTab<Text>, CustomTab<Circle>, CustomTab<Image>を受け取った場合、(repeat each Content)(Text, Circle, Image)となります。

CustomTabView も CustomTabBuilder と同様に Parameter Packs を使用します。

struct CustomTabView<each CustomTabContent: View>: View {
    private let systemImageNames: [String]
    private let customTabs: (repeat each CustomTabContent)

    init(
        @CustomTabBuilder _ customTabs: () -> ([String], (repeat each CustomTabContent))
    ) {
        let customTabs = customTabs()
        self.systemImageNames = customTabs.0
        self.customTabs = customTabs.1
    }

    var body: some View {
        VStack(spacing: .zero) {
            Group(subviews: TupleView(customTabs)) { subviews in
                subviews[0]
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .overlay(alignment: .bottom) {
            HStack(spacing: .zero) {
                ForEach(systemImageNames, id: \.self) { systemImageName in
                    Image(systemName: systemImageName)
                        .resizable()
                        .scaledToFit()
                        .frame(width: 50, height: 20)
                        .foregroundStyle(systemImageName == systemImageNames[0] ? .black : .gray)
                        .symbolVariant(.fill)
                }
            }
            .padding(.vertical, 15)
            .padding(.horizontal, 10)
            .background(.gray.opacity(0.1), in: .capsule)
        }
    }
}

customTabs は(repeat each CustomTabContent)ですが、実際はビューのタプルです。そのため、 Group(subviews:)に渡すことで柔軟な実装が可能です。

これで CustomTabView を下記のように書くことができるようになりました。

CustomTabView {
    CustomTab(systemImageName: "house") {
        HomeView()
    }
    CustomTab(systemImageName: "camera") {
        SnapView()
    }
    CustomTab(systemImageName: "bell") {
        NotificationView()
    }
    CustomTab(systemImageName: "person.crop.rectangle") {
        ProfileView()
    }
}
  1. システムイメージネームの順番
  2. ビューの数とシステムイメージネームの数が一致していること

Parameter Packs を使用することで、「システムイメージネームの順番、ビューの数とシステムイメージネームの数が一致していること、この二点を意識して使用しなければいけない」という課題は解決でき、呼び出し時の見栄え(?)も良くなりました。

ただ、念の為ネガティブな点をお伝えしておきます。

一つ目は、動的な実装をできないことです。
基本的にタブはアプリケーションの実行中に増減することはないため、今回ようなケースでは全く問題ありません。ですが、もし要素数や条件に応じて動的に振る舞う必要があるのであれば、Parameter Packs が力を発揮することはありません。

二つ目は、実装によるリターンが小さいことです。
実際には大したことはしていないにも関わらず、Parameter Packs を使い慣れていないと実装難易度は少々高く、コード量も増加しています。もしプロジェクト内の複数箇所で使うようであれば便利な機能だと思いますが、今回のように一箇所でしか効果を発揮できないようであれば、わざわざ Parameter Packs を使う必要はないように感じました。

参考:Generalize APIs with parameter packs

Discussion