SwiftUIでデザイン実装するときに気をつけること

## はじめに

SwiftUIは標準でいい感じにしてくれる一方、業務では厳格な余白（Padding）や間隔（Spacing）が求められることが多いです。

本記事ではSwiftUIを使ったデザインの実装時に気をつけるべきことを紹介します。

## 対象読者

- __:warning: Figmaなどのデザインツールで起こしたデザインを忠実にSwiftUIで実装する人__
  - 標準に寄せていい場合、本記事はあまり参考にならないかもしれない

## 環境

- OS: macOS Sonoma 14.5
- Xcode: 16.1 (16B40)
- Swift: 6.0.2

## SwiftUIでデザイン実装するときに気をつけること

### 1: 余白と間隔

余白（Padding）や間隔（Spacing）についてです。

余白と間隔については以下の記事をご参照ください。

https://qiita.com/uhooi/items/d1d2f0f4916e053f5746

#### 1-1: 決められた倍数になっているか

プロジェクトによりますが、余白や間隔は4や8の倍数と決められていることが多いです。
もしデザインツール上で中途半端な値になっている場合、デザイナーに意図的かどうか確認すべきです。

```swift
// 🔺: `7` が意図的かどうか気になる
Text("Foo")
    .padding(.horizontal, 7)

// ✅: 8の倍数なので合っていそう
Text("Foo")
    .padding(.horizontal, 8)
```

#### 1-2: `padding()` には必ず `length` を指定する

`.padding()` のように `length` を省略すると、デフォルトが使われます。
デフォルトはプラットフォームによって異なり、意図しない余白になる可能性があるため、 `length` は指定すべきです。

```swift
// ❌: `length` が省略されている
Text("Foo")
    .padding(.horizontal)

// ✅: `length` が指定されている
Text("Foo")
    .padding(.horizontal, 8)
```

#### 1-3: `VStack` と `HStack` には必ず `spacing` を指定する

`padding()` と同様、 `VStack` などで `spacing` を省略するとデフォルトが使われるため、指定すべきです。

```swift
// ❌: `spacing` が省略されている
VStack {
    Text("Foo")
    Text("Bar")
}

// ✅: `spacing` が指定されている
VStack(spacing: 8) {
    Text("Foo")
    Text("Bar")
}
```

#### 1-4: `Spacer()` を使う場合、 `VStack` などの `spacing` が適用されることを考慮する

「 `Spacer` の罠」を知らないと、デザインより間隔が大きくなる実装を意図せずしてしまうことがあります。

詳細は以下の記事をご参照ください。

https://qiita.com/uhooi/items/7dfe7ae29adf7aea435b

基本的には以下の考えでいいと思います。

- `frame(maxWidth: .infinity)` などを使い、できる限り `Spacer()` を使わない
- どうしても必要な場合、 `spacing` を `0` にすると意図したデザインになることが多い

```swift
// ❌: `spacing` を `1` 以上に指定しているのに `Spacer()` を使っている
HStack(spacing: 8) {
    Text("Foo")
    Spacer()
    Text("Bar")
}

// ✅: `spacing` を `0` に指定して `Spacer()` を使っている
HStack(spacing: 0) {
    Text("Foo")
    Spacer()
    Text("Bar")
}
```

### 2: 寄せ方

寄せ方（Alignment）についてです。

#### 2-1: デフォルトでない場合は必ず指定する

例えば `VStack` や `HStack` は `.center` がデフォルトなので、 `.leading` など `.center` 以外の場合は指定すべきです。

```swift
// ✅: `alignment` が指定されている
VStack(alignment: .leading, spacing: 8) {
    Text("Foo")
    Text("Bar)
}
```

プロジェクトによっては「デフォルトの場合でも必ず `alignment` を指定する」と決めてもいいと思います。

### 3: テキスト

テキスト（Text）についてです。

#### 3-1: 文字サイズと色を必ず指定する

文字サイズを `font()` 、色を `foregroundStyle()` で指定すべきです。

```swift
// ✅: `font()` と `foregroundStyle()` が指定されている
Text("Foo")
    .font(.body)
    .foregroundStyle(.secondary)
```

デフォルトの場合は指定しなくてもいいのですが、文字サイズと色は必ず指定することが多いので、指定漏れと区別がつくようにするのが望ましいです。

#### 3-2: 最大行数について、デフォルトでない場合は必ず指定する

デフォルト（無制限）でない場合は `lineLimit()` で指定すべきです。

```swift
// ✅: `lineLimit()` が指定されている
Text("Foo")
    .font(.body)
    .foregroundStyle(.secondary)
    .lineLimit(1)
```

#### 3-3: 複数行の寄せ方について、最大行数が2行以上のとき、デフォルトでない場合は必ず指定する

デフォルトは、例えば `VStack` 内ならその `alignment` に準じます。
デフォルトでない場合は `multilineTextAlignment()` で指定すべきです。

```swift
// ✅: `multilineTextAlignment()` が指定されている
VStack(spacing: 0) {
    Text("Foo")
        .font(.body)
        .foregroundStyle(.secondary)
        .lineLimit(3)
        .multilineTextAlignment(.leading)

    Text("bar")
        .font(.body)
        .foregroundStyle(.secondary)
        .lineLimit(3)
}
```

#### 3-4: 行間について、デフォルトでない場合は必ず指定する

デフォルト（おそらく `0` ）でない場合は `lineSpacing()` で指定すべきです。

```swift
// ✅: `lineSpacing()` が指定されている
Text("Foo")
    .font(.body)
    .foregroundStyle(.secondary)
    .lineSpacing(3)
```

#### 3-5: Dynamic Typeに対応するため、高さを固定しない

高さを `frame(height:)` で固定すると、文字サイズを変えたときに追従されず、レイアウトが崩れます。
最小の高さを指定したい場合は `frame(minHeight:)` を使います。

```swift
// ❌: `frame(height:)` が指定されている
Text("Foo")
    .font(.body)
    .foregroundStyle(.secondary)
    .frame(height: 8)

// ✅: `frame(minHeight:)` が指定されている
Text("Foo")
    .font(.body)
    .foregroundStyle(.secondary)
    .frame(minHeight: 8)
```

#### 3-6: 幅いっぱいに広げたい場合、 `frame(maxWidth: .infinity)` を指定する

幅いっぱいに広げたい（FigmaでいうFill）場合、 `frame(maxWidth: .infinity)` を指定します。
逆に文字の幅に合わせたい（FigmaでいうHug）場合、 `frame(maxWidth:)` を指定してはいけません。

```swift
// ✅: "Foo" がFill、"Bar" がHug
VStack(spacing: 0) {
    Text("Foo")
        .font(.body)
        .foregroundStyle(.secondary)
        .frame(maxWidth: .infinity, alignment: .leading)

    Text("Bar")
        .font(.body)
        .foregroundStyle(.secondary)
}

// ✅: "Foo" も "Bar" もHug
VStack(spacing: 0) {
    Text("Foo")
        .font(.body)
        .foregroundStyle(.secondary)

    Spacer()

    Text("Bar")
        .font(.body)
        .foregroundStyle(.secondary)
}
```

#### 3-7: 最大行数を指定していないのに文字が省略される場合、最終手段として `fixedSize()` を指定する

`lineLimit()` を指定していないのに文字が「…」と省略される場合、高さを `frame(height:)` や `frame(minHeight:)` で指定していることがほとんどです。
ただどちらも指定していないのに省略されることがあり、その場合は最終手段として `fixedSize(vertical: true)` を指定します。

```swift
// 🔺: `fixedSize(vertical: true)` が指定されている
Text("Foo")
    .font(.body)
    .foregroundStyle(.secondary)
    .fixedSize(vertical: true)
```

### 4: ボタン

ボタン（Button）についてです。

#### 4-1: `frame()` は `Button` 自体でなく `label` に付ける

`Button` に `frame()` を指定するとサイズは変わりますが、タップ領域は変わりません。
タップ領域まで変えたい場合、 `label` に付ける必要があります。

```swift
// ❌: `Button` 自体に `frame()` が指定されている
Button {
    print("Foo")
} label: {
    Text("Foo")
        .font(.body)
        .foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)

// ✅: `Button` の `label` に `frame()` が指定されている
Button {
    print("Foo")
} label: {
    Text("Foo")
        .font(.body)
        .foregroundStyle(.secondary)
        .frame(maxWidth: .infinity)
}
```

「基本的には `Button` 自体にモディファイアを付けず、 `label` に付ける」と覚えてもいいと思います。

#### 4-2: ボタンの背景色をハイライトしたい場合、 `Button` の `label` に背景色を付ける

`Button` の `label` に `background()` で背景色を指定していない場合、タップ時にハイライトされません。
ハイライトしたい場合は背景色を付ける必要があります。

```swift
// ❌: `Button` 自体に `background()` が指定されている
Button {
    print("Foo")
} label: {
    Text("Foo")
        .font(.body)
        .foregroundStyle(.white)
        .padding(4)
}
.background(.blue)

// ✅: `Button` の `label` に `background()` が指定されている
Button {
    print("Foo")
} label: {
    Text("Foo")
        .font(.body)
        .foregroundStyle(.white)
        .padding(4)
        .background(.blue)
}
```

#### 4-3: デフォルト以外のハイライトを指定したい場合、 `ButtonStyle` を自作する

ハイライトのデフォルトは半透明（ `opacity(0.5)` ）になるので、例えば薄い黒色を被せたいなどカスタマイズしたい場合、 `ButtonStyle` を自作するのがいいと思います。

以下の記事は `LabelStyle` の自作方法ですが、 `ButtonStyle` を自作するのにも役立つと思います。

https://qiita.com/uhooi/items/6aa53a3f07b867a8977a

### 5: 区切り線

区切り線（Divider）についてです。

#### 5-1: 標準の `Divider()` を使わない

標準の `Divider()` はサイズや色をカスタマイズしにくいため、 `Rectangle()` や `Color` を使って自作するのがいいと思います。

```swift
// ✅: 区切り線を自作している
import SwiftUI

public struct Separator: View {
    private let color: Color

    public var body: some View {
        Rectangle()
            .fill(color)
            .frame(height: 1)
            .frame(maxWidth: .infinity)
    }

    public init(
        color: Color
    ) {
        self.color = color
    }
}
```

### 6: おまけ

おまけです。

#### 6-1: パディングのサイズなどがパターン化されている場合、enumで定義する

例えばパディングのサイズがSmall・Medium・Largeの3パターンのみの場合、そのenumを定義し、引数として受け取ってパディングを返すモディファイアを用意すると便利です。

```swift
// ✅: パディングのサイズをenumで定義し、専用のモディファイアを用意する
import SwiftUI

public enum PaddingType {
    case small
    case medium
    case large
}

extension View {
    public func customPadding(_ edges: Edge.Set = .all, _ type: PaddingType) -> some View {
        padding(edges, type.length)
    }
}

// MARK: - Privates

private extension PaddingType {
    public var length: CGFloat {
        switch self {
        case .small: 4
        case .medium: 8
        case .large: 16
    }
}
```

ただオレオレモディファイアを用意すると気づきにくいというデメリットがあるため、用意する場合はプロジェクトでコーディング規約を作成し、そこに記載するといいです。

#### 6-2: ビューとビューの間に空白行を設ける

`Text()` などモディファイアがたくさん付くビューを組み合わせる場合、ビューとビューの間に空白行があると可読性が上がると思います。

```swift
// 🔺: ビューとビューの間に空白行がない
VStack(spacing: 0) {
    Text("Foo")
        .font(.body)
        .foregroundStyle(.secondary)
    Text("Bar")
        .font(.body)
        .foregroundStyle(.secondary)
    Text("Hoge")
        .font(.body)
        .foregroundStyle(.secondary)
}

// ✅: ビューとビューの間に空白行がある
VStack(spacing: 0) {
    Text("Foo")
        .font(.body)
        .foregroundStyle(.secondary)

    Text("Bar")
        .font(.body)
        .foregroundStyle(.secondary)

    Text("Hoge")
        .font(.body)
        .foregroundStyle(.secondary)
}
```

#### 6-3: ビューの一番上に名前と参考リンクをドキュメンテーションコメントで書く

ビューの一番上に、簡単でいいのでビュー名と、デザインや仕様書などのリンクをドキュメンテーションコメントで書くと、ビューを修正するときに便利です。

https://x.com/the_uhooi/status/1856502355245858876

```swift
// ✅: ビューの一番上にドキュメンテーションコメントが書かれている
import SwiftUI

/// 001: フービュー
///
/// - seeAlso: https://www.figma.com/design/...
/// - seeAlso: {仕様書のURL}
public struct FooView: View {
    public var body: some View {
        // ...
    }
}
```

URLがリンク切れとなることを危惧して書かないという考えもありますが、コメントと違ってURLは嘘になりづらいため、私はメンテナンスのコストを恐れずに書くことを推奨します。

## おわりに

SwiftUIでデザイン実装しているときに私が気をつけていることでした。
間違っている箇所や、他に気をつけていることがありましたら、コメントなどで教えてくださると嬉しいです :relaxed:

## 参考リンク

- [padding(_:_:) | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/view/padding(_:_:))
- [init(alignment:spacing:content:) | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/vstack/init(alignment:spacing:content:))
- [Spacer | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/spacer)
- [font(_:) | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/view/font(_:))
- [foregroundStyle(_:) | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/view/foregroundstyle(_:))
- [lineLimit(_:) | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/view/linelimit(_:)-513mb)
- [multilineTextAlignment(_:) | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/view/multilinetextalignment(_:))
- [lineSpacing(_:) | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/view/linespacing(_:))
- [Divider | Apple Developer Documentation](https://developer.apple.com/documentation/swiftui/divider)