inSmartBank

AI家計簿アプリ「ワンバンク」を開発・運営する株式会社スマートバンクの Tech Blog です。より幅広いテーマはnoteで発信中です https://note.com/smartbankinc

SwiftUIにおける余白の適切な実装パターン

こんにちは、株式会社スマートバンクでアプリエンジニアをしている ロクネム です。

みなさんはSwiftUIでViewを実装する際に、”余白” をどのように実装していますか?

SwiftUIにおける余白の表現方法はいくつか存在しており、どの方法を用いても同じレイアウトを実装することが可能です。

しかし、一見同じに見えても意図しない余白が生まれてしまっていたり、変更に弱いレイアウトとなってしまったりするケースが存在しています。

本記事では、SwiftUIにおいて余白を表現する上でどのような実装方法を選択するのが適切であるかについて、具体例を3つほど挙げながら説明します。

要素間の余白の表現

まず、Textが縦に3つ並んだレイアウトについて考えてみます。

1つ目と2つ目のTextは16pt、2つ目と3つ目のTextは8ptの間隔が空いています。

SwiftUIでの実装方法は大きく3つあります。

  1. VStackの spacing に0を指定して、各Textに適切な padding を設定する
  2. VStackの spacing に0を指定して、各Textの間に高さ指定のSpacerを配置する
  3. VStackを2つ入れ子にして、それぞれの spacing に適切な値を設定する
// 1
VStack(spacing: 0) {
    Text("First")
        .padding(.bottom, 16)
    Text("Second")
        .padding(.bottom, 8)
    Text("Third")
}
// 2
VStack(spacing: 0) {
    Text("First")
    Spacer().frame(height: 16)
    Text("Second")
    Spacer().frame(height: 8)
    Text("Third")
}
// 3
VStack(spacing: 16) {
    Text("First")
    VStack(spacing: 8) {
        Text("Second")
        Text("Third")
    }
}

結論からいうと、3の実装方法がもっとも変更に強いレイアウトとなります。 では、なぜ変更に強いと言えるのでしょう。

例えば、「条件に応じて一番下のTextの表示/非表示を切り替える」ということを考えてみます。 実装方法1では、Textを非表示にした際に下部に不要な余白が生まれてしまいます。

VStack(spacing: 0) {
    Text("First")
        .padding(.bottom, 16)
    Text("Second")
        .padding(.bottom, 8)
    if showsThirdText {
        Text("Third")
    }
}

実装方法2では、TextとSpacerを共に非表示にすることで不要な余白を生まずに実装できますが、Spacerまで非表示にする上での判断コストと、それを誤ると意図しない余白が生まれうるという脆さが存在しています。

// 🙅 Textのみ非表示にする
VStack(spacing: 0) {
    Text("First")
    Spacer().frame(height: 16)
    Text("Second")
    Spacer().frame(height: 8)
    if showsThirdText {
        Text("Third")
    }
}

// 🙆 Spacerごと非表示にする
VStack(spacing: 0) {
    Text("First")
    Spacer().frame(height: 16)
    Text("Second")
    if showsThirdText {
        Spacer().frame(height: 8)
        Text("Third")
    }
}

一方実装方法3では、非表示対象である3つ目のTextのみを非表示にすればよく、直感的な実装が可能です。

VStack(spacing: 16) {
    Text("First")
    VStack(spacing: 8) {
        Text("Second")
        if showsThirdText {
            Text("Third")
        }
    }
}

このように、要素間の余白を表現する場合は padding やSpacerではなく、VStack/HStackの spacing を指定するのがもっとも適切と言えるでしょう。

配置によって生じる余白の表現

続いて、Imageと2つのTextが横に並んだレイアウトについて考えてみます。

Imageと1つ目のTextは左寄せ、2つ目のTextは右寄せで表示します。

まずはHStackを使って要素を横に並べます。

先に述べた通り、要素間の余白はHStackの spacing を用いて指定します。

HStack(spacing: 8) {
    Image(systemName: "person.circle.fill")
    Text("First")
    Text("Second")
}

次に、2つ目のTextを右寄せに表示する方法について考えます。 実装方法は大きく2つあります。

  1. 2つのText間にSpacerを配置する
  2. 1つ目のTextに .frame(maxWidth: .infinity) を指定する

1の実装方法について考えてみます。

Spacerを用いると意図したレイアウトになることがわかります。

HStack(spacing: 8) {
    Image(systemName: "person.circle.fill")
    Text("First")
    Spacer()
    Text("Second")
}

しかし、1つ目のTextの文字列が横幅いっぱいに表示される場合はどうでしょう。 HStackに指定したspacingがSpacerにもかかってしまうため、想定の2倍の余白が空いてしまいます。

この問題を回避するためには、 spacing に0を指定し、 minLength を指定したSpacerを2つのText間に設定し、ImageとTextの間に .frame(width:) を指定したSpacerを配置する必要があります。

HStack(spacing: 0) {
    Image(systemName: "person.circle.fill")
    Spacer().frame(width: 8)
    Text("First!!!!!!!!!!!!!!!!!!")
    Spacer(minLength: 8)
    Text("Second")
}

これで1つ目のTextの文字列が横幅いっぱいに表示される場合にも意図したレイアウトになりました。

しかし、これでは先に述べた通りImageを非表示にした際などに不要な余白が生まれてしまうため、変更に弱いレイアウトとなってしまいます。

では2の .frame(maxWidth: .infinity) を用いた実装方法はどうでしょう。

HStack(spacing: 8) {
    Image(systemName: "person.circle.fill")
    Text("First")
        .frame(maxWidth: .infinity, alignment: .leading)
    Text("Second")
}

1つ目のTextの文字列が横幅いっぱいに表示される場合やImageが非表示になる場合においても意図通りの余白を表現できていることがわかります。

このように、Viewの配置によって生じる余白は、Spacerを用いずに、 .frame(maxWidth:maxHeight:alignment:) を用いて表現することで、意図しない余白の生じない、変更に強いレイアウトを組むことができます。

要素外側の余白の表現

最後に、より具体的な例として、おすすめのユーザー一覧を表示するレイアウトについて考えてみます。

まず、各Cellのレイアウトを先に述べた方針で組んでいきます。

struct UserCell: View {
    let userName: String

    var body: some View {
        HStack(alignment: .top, spacing: 8) {
            Image(systemName: "person.circle.fill")
                .foregroundColor(Color(.label))
            VStack(alignment: .leading, spacing: 8) {
                HStack(spacing: 8) {
                    Text(userName)
                        .font(.headline)
                        .foregroundColor(Color(.label))
                        .frame(maxWidth: .infinity, alignment: .leading)
                    Image(systemName: "ellipsis")
                        .foregroundColor(Color(.label))
                }
                Text("Hello. My name is \(userName). I am looking forward to getting to know you all.")
                    .font(.subheadline)
                    .foregroundColor(Color(.label))
                    .multilineTextAlignment(.leading)
            }
        }
    }
}

次に、このCellの角丸の背景の描画について考えます。

.padding を用いてViewの描画領域を拡張した後に .background を適用して背景色を指定し、さらに .cornerRadius で角丸を指定します。

  HStack(alignment: .top, spacing: 8) {
      ...
  }
+ .padding(16)
+ .background(Color(.secondarySystemBackground))
+ .cornerRadius(12)

このように、要素の外側の余白は padding を用いて表現することができます。

続いて、おすすめのユーザー一覧のレイアウトについて考えてみます。 「Suggested」のTextとユーザー一覧のScrollViewをVStackで縦に並べます。

VStack(alignment: .leading, spacing: 16) {
    Text("Suggested")
        .font(.title)
    ScrollView(.horizontal, showsIndicators: false) {
        HStack(spacing: 16) {
            ForEach(0..<5) { index in
                UserCell(userName: "User \(index)")
                    .frame(width: 200, height: 150)
            }
        }
    }
    .frame(height: 150)
}

このまま表示してみると、デバイスの縁との間に余白がなく、窮屈な見た目となることがわかります。

外側のVStackに padding を付与してみましょう。

  VStack(alignment: .leading, spacing: 16) {
      Text("Suggested")
          .font(.title)
      ScrollView(.horizontal, showsIndicators: false) {
          HStack(spacing: 16) {
              ...
          }
      }
      .frame(height: 150)
  }
+ .padding(.horizontal, 16)

一見正常なレイアウトに見えますが、よくみるとScrollView右側に不要な余白が生じていることがわかります。

ScrollViewにおいては、スクロール対象のHStackに対して余白を設定するのが適切でしょう。 よって、外側のVStackに一括で padding を付与するのではなく、余白を持つべき各要素に対して適切に padding を付与していきます。

  VStack(alignment: .leading, spacing: 16) {
      Text("Suggested")
          .font(.title)
+         .padding(.horizontal, 16)
      ScrollView(.horizontal, showsIndicators: false) {
          HStack(spacing: 16) {
              ...
          }
+         .padding(.horizontal, 16)
      }
      .frame(height: 150)
  }
- .padding(.horizontal, 16)

このように、たとえ同じ padding の値を持つ要素がVStack/HStack内で並んでいたとしても、外側のVStack/HStackに一括で padding を付与するのではなく、各要素の描画領域を意識してそれぞれに padding を付与することが大切です。

まとめ

以上をまとめると、SwiftUIにおける余白の表現は以下のような実装パターンとなります。

  • 要素間の余白はVStack/HStackの spacing を用いて表現する
  • 要素の配置によって生じる余白は .frame(maxWidth:maxHeight:) を用いて表現する
  • 要素外側の余白は、どの要素が持つべき余白であるかを意識して padding を用いて表現する

本記事が、みなさんがSwiftUIでViewの余白を実装する上での参考になれば幸いです。

We create the new normal of easy budgeting, easy banking, and easy living.
In this tech blog, engineers and other members will share their insights.