SwiftUI で Image をカスタム形状にクリッピング - 5つの実装パターン

はじめに

SwiftUI で Image を表示する際、見た目を工夫したいことはよくある。丸いプロフィール画像、角丸カード、複雑なロゴ形状...こうした場合に活躍するのが clipShape modifier だ。

この記事では、clipShape を使った 5 つの実装パターン、mask との使い分け、iOS 18 での新機能、実装時の注意点まで、実践的に解説する。

clipShape とは何か

基本的な役割

clipShape は、View を指定した Shape の形状でクリッピングする modifier。Image だけでなく、あらゆる View に適用できる。iOS 13.0 以降で使用可能。

使い方はシンプル。View に .clipShape() を追加して、括弧の中に使いたい Shape を指定するだけ。

clipShape と mask:何が違うのか

同じクリッピングに見えるが、内部動作は異なる。clipShape は Shape のアウトラインを使ったシンプルなクリッピング。mask はピクセルレベルで自由に制御できる。

使い分けの指針は明確だ。形状がシンプルなら clipShape。複雑な形状やグラデーション、半透明を含むなら mask。

項目 clipShape mask
用途 Shape ベースのクリップ ピクセルレベル制御
パフォーマンス 高速(推奨) 遅い
扱える形状 Circle, Rectangle 等 任意(グラデーション等)
一般的な選択 9 割はこちら 特殊な要件時のみ

よく使う 5 つのパターン

1. 円形クリップ(プロフィール画像)

最も一般的。プロフィール画像、アバター、丸いボタン...こうした場面では Circle を使う。

import SwiftUI

struct ProfileView: View {
    var body: some View {
        Image("profileImage")
            .resizable()
            .scaledToFill()
            .frame(width: 120, height: 120)
            .clipShape(Circle())
            .overlay(
                Circle()
                    .stroke(Color.blue, lineWidth: 2)
            )
    }
}

ポイント:resizable() と scaledToFill() でアスペクト比を保ったまま枠を埋める。overlay で枠線を追加すると、さらに見栄えが良くなる。

2. 角丸四角形(カード、サムネイル)

RoundedRectangle でコーナーを丸くする。cornerRadius で丸さを調整。カード、サムネイル、バナーに使う。

struct CardView: View {
    var body: some View {
        Image("thumbnail")
            .resizable()
            .scaledToFill()
            .frame(height: 200)
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .shadow(radius: 4)
    }
}

cornerRadius は 8-16 が標準的。大きすぎるとピルになってしまう。shadow() を組み合わせると、カードとして浮き上がって見える。

3. 楕円形(ロゴ、複雑な画像)

横長や縦長の楕円が必要な場合、Ellipse を使う。frame のアスペクト比に応じて形状が決まる。

struct LogoView: View {
    var body: some View {
        Image("logo")
            .resizable()
            .scaledToFill()
            .frame(width: 200, height: 100)
            .clipShape(Ellipse())
    }
}

4. カスタム形状(Path でオリジナル図形)

五角形、三角形、ハート...こうした複雑な形状は、Shape プロトコルに準拠したカスタムクラスで定義する。

struct StarClipView: View {
    var body: some View {
        Image("icon")
            .resizable()
            .scaledToFill()
            .frame(width: 150, height: 150)
            .clipShape(StarShape())
    }
}

// 五角形を定義
struct StarShape: Shape {
    func path(in rect: CGRect) -> Path {
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2

        var path = Path()

        for i in 0..<5 {
            let angle = CGFloat(i) * 0.8 * .pi - .pi / 2
            let x = center.x + radius * cos(angle)
            let y = center.y + radius * sin(angle)

            if i == 0 {
                path.move(to: CGPoint(x: x, y: y))
            } else {
                path.addLine(to: CGPoint(x: x, y: y))
            }
        }

        path.closeSubpath()
        return path
    }
}

Shape プロトコルには path(in:) メソッドを実装する。CGPath を使ってフリーハンドで図形を描く。

5. アニメーション付きクリップ

状態に応じて形状を変える。@State で isExpanded を持って、tap で切り替える。形状を動的に変える場合は AnyShape でラップする。

struct ExpandableClipView: View {
    @State private var isExpanded = false

    var body: some View {
        VStack {
            Image("icon")
                .resizable()
                .scaledToFill()
                .frame(
                    width: isExpanded ? 250 : 150,
                    height: isExpanded ? 250 : 150
                )
                .clipShape(
                    isExpanded ?
                    AnyShape(RoundedRectangle(cornerRadius: 32)) :
                    AnyShape(Circle())
                )
                .onTapGesture {
                    withAnimation(.spring()) {
                        isExpanded.toggle()
                    }
                }

            Text(isExpanded ? "Collapse" : "Expand")
                .font(.caption)
                .foregroundColor(.gray)
        }
        .padding()
    }
}

// 複数の Shape を一つの型で扱うためのラッパー
struct AnyShape: Shape {
    private let closure: (CGRect) -> Path

    init(_ shape: S) {
        self.closure = { shape.path(in: $0) }
    }

    func path(in rect: CGRect) -> Path {
        closure(rect)
    }
}

Tap でアニメーション付きで形状が変わる。withAnimation(.spring()) で自然な動きになる。

iOS 18 での新機能と対応

ResizableShape

iOS 18 では、一部の Shape がリサイズに対応した。従来は Path でカスタマイズが必要だった部分が、より簡潔に書ける。

ただし、iOS 17 以前との互換性を保つなら、条件分岐で対応版と非対応版を分ける必要がある。

互換性の保ち方

@available(iOS 18, *)
struct iOS18View: View {
    var body: some View {
        Image("icon")
            .resizable()
            .scaledToFill()
            .frame(width: 150, height: 150)
            .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

// iOS 17 以前向け
@available(iOS, introduced: 13.0, deprecated: 18.0)
struct LegacyView: View {
    var body: some View {
        Image("icon")
            .resizable()
            .scaledToFill()
            .frame(width: 150, height: 150)
            .clipShape(RoundedRectangle(cornerRadius: 12))
    }
}

実装時の注意点

resizable() は必須

Image のデフォルトサイズは固定。clipShape を使う前に .resizable() を必ず呼ぶ。さもないと期待サイズに拡大されない。

frame は clipShape の前

順序が大事。frame を先に指定して、その後に clipShape を適用する。逆順だと期待通りにクリップされない。

// ✓ 正しい順序
Image("icon")
    .resizable()
    .frame(width: 150, height: 150)
    .clipShape(Circle())

// ✗ 間違い
Image("icon")
    .clipShape(Circle())
    .frame(width: 150, height: 150)
    .resizable()

高い解像度では shadow を避ける

clipShape + shadow は、複数の render pass が必要になり、パフォーマンスが低下する場合がある。本当に必要でない限り、shadow は避ける。

複雑な Path は計算量に注意

カスタム Path を定義する場合、複雑な計算は避ける。View の再描画のたびに path() が呼ばれるため、重い計算は実行時パフォーマンスに影響する。

よくある間違いと対処

「クリップが効かない」

原因は、大体 .resizable() が漏れているか、frame が指定されていない。Image のデフォルトサイズで試しているケースが多い。

「アニメーション中に形状がちらつく」

clipShape アニメーションの制限。見栄えが重要なら、mask で代替えすること。

「カスタム Shape がうまく動かない」

Shape の path() メソッドが rect に対応しきれていない。デバッグは、異なるサイズで試して、形状が正しく拡大縮小されるか確認。

関連記事

まとめ

clipShape で Image をカスタム形状にクリップするのは、SwiftUI では基本的なテクニック。使い分けは単純。

シンプルな形(丸、角丸)なら clipShape。複雑な形やグラデーションマスクなら mask。この基準で判断すれば、9 割のケースは解決する。

5 つのパターンを把握していれば、ほぼすべての画像クリッピング要件に対応できるはずだ。