SwiftUIカスタムViewModifier:デザインシステム向け再利用可能パターン
一貫したデザインシステムのためにSwiftUIでカスタムViewModifierを構築します。iOSビューを効率的にスタイリングするためのパターン、ベストプラクティス、実用的な例を紹介します。

ViewModifierは、堅牢で保守しやすいSwiftUIデザインシステムの基礎を成すものです。スタイルとふるまいを再利用可能なコンポーネントにカプセル化することで、視覚的な一貫性を保証し、コードの重複を劇的に削減します。このモジュラーなアプローチは、現代のiOSインターフェースの構築方法を大きく変えます。
本記事では、効果的なカスタムViewModifierを作成するための重要なパターンを、基本構造から完全なデザインシステム向けの高度な構成まで紹介します。
ViewModifierの構造
ViewModifierは、任意のビューに適用可能な変換を定義するプロトコルです。Viewのextensionとは異なり、ViewModifierは内部状態を保持し、設定可能なパラメーターを受け取ることができます。
import SwiftUI
// Basic structure of a custom ViewModifier
struct CardModifier: ViewModifier {
// Configurable parameters
var cornerRadius: CGFloat = 12
var shadowRadius: CGFloat = 4
// The body method applies transformations
func body(content: Content) -> some View {
content
.padding()
.background(Color(.systemBackground))
.cornerRadius(cornerRadius)
.shadow(
color: .black.opacity(0.1),
radius: shadowRadius,
x: 0,
y: 2
)
}
}
// Extension for fluent syntax
extension View {
func cardStyle(
cornerRadius: CGFloat = 12,
shadowRadius: CGFloat = 4
) -> some View {
modifier(CardModifier(
cornerRadius: cornerRadius,
shadowRadius: shadowRadius
))
}
}Contentパターンは、modifierが適用される対象のビューを表します。この抽象化によって、同じmodifierが任意のSwiftUIビュータイプ上で動作できるようになります。
CardModifierを実際に使う
一度作成すれば、modifierの適用は直感的になり、コード上で自然に読めるようになります。
struct ProductCard: View {
let product: Product
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Product image
AsyncImage(url: product.imageURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Rectangle()
.fill(Color.gray.opacity(0.2))
}
.frame(height: 150)
.clipped()
// Product information
Text(product.name)
.font(.headline)
Text(product.price, format: .currency(code: "USD"))
.foregroundStyle(.secondary)
}
.cardStyle() // Applying the modifier
}
}
struct Product: Identifiable {
let id: UUID
let name: String
let price: Decimal
let imageURL: URL?
}コードの明瞭さは保守性を大幅に向上させます。アプリのすべてのカードのスタイルを変更するには、ViewModifierへの一度の変更だけで済みます。
@ViewBuilderによる条件付きmodifier
ViewModifierは、コンテキストに応じてふるまいを適応させるために条件分岐ロジックを含めることができます。@ViewBuilder属性により、分岐を含む複雑なビューを構築できます。
struct BadgeModifier: ViewModifier {
let count: Int
let color: Color
let showZero: Bool
init(count: Int, color: Color = .red, showZero: Bool = false) {
self.count = count
self.color = color
self.showZero = showZero
}
// @ViewBuilder enables conditions in the body
@ViewBuilder
func body(content: Content) -> some View {
if count > 0 || showZero {
content.overlay(alignment: .topTrailing) {
badgeView
}
} else {
// Returns the view without modification
content
}
}
// Badge view extracted for readability
private var badgeView: some View {
Text(count > 99 ? "99+" : "\(count)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color)
.clipShape(Capsule())
.offset(x: 8, y: -8)
}
}
extension View {
func badge(
count: Int,
color: Color = .red,
showZero: Bool = false
) -> some View {
modifier(BadgeModifier(
count: count,
color: color,
showZero: showZero
))
}
}このパターンを使うと、宣言的かつ設定可能な方法で、任意のインターフェース要素に通知バッジを追加できます。
SwiftUIは@ViewBuilderの分岐を自動的に最適化します。表示されないビューはレンダリングされないため、複雑な条件下でもパフォーマンスが維持されます。
スタイルトークンによるデザインシステム
成熟したデザインシステムはトークンに基づいています。アプリの視覚的アイデンティティを定義する意味的な定数です。ViewModifierはこれらのトークンを完璧にカプセル化します。
import SwiftUI
// Spacing tokens
enum Spacing {
static let xs: CGFloat = 4
static let sm: CGFloat = 8
static let md: CGFloat = 16
static let lg: CGFloat = 24
static let xl: CGFloat = 32
}
// Radius tokens
enum Radius {
static let sm: CGFloat = 4
static let md: CGFloat = 8
static let lg: CGFloat = 16
static let full: CGFloat = 9999
}
// Typography tokens
enum Typography {
static let displayLarge = Font.system(size: 34, weight: .bold)
static let displayMedium = Font.system(size: 28, weight: .bold)
static let titleLarge = Font.system(size: 22, weight: .semibold)
static let titleMedium = Font.system(size: 17, weight: .semibold)
static let bodyLarge = Font.system(size: 17, weight: .regular)
static let bodyMedium = Font.system(size: 15, weight: .regular)
static let caption = Font.system(size: 12, weight: .regular)
}
// Semantic color tokens
enum SemanticColor {
static let primary = Color("PrimaryColor")
static let secondary = Color("SecondaryColor")
static let success = Color.green
static let warning = Color.orange
static let error = Color.red
static let surface = Color(.systemBackground)
static let surfaceVariant = Color(.secondarySystemBackground)
}トークンベースのViewModifier
これらのトークンは、アプリの標準スタイルを定義するViewModifierに自然に統合されます。
// Modifier for page titles
struct PageTitleModifier: ViewModifier {
func body(content: Content) -> some View {
content
.font(Typography.displayMedium)
.foregroundStyle(Color.primary)
}
}
// Modifier for section titles
struct SectionTitleModifier: ViewModifier {
func body(content: Content) -> some View {
content
.font(Typography.titleLarge)
.foregroundStyle(Color.primary)
}
}
// Modifier for secondary text
struct SecondaryTextModifier: ViewModifier {
func body(content: Content) -> some View {
content
.font(Typography.bodyMedium)
.foregroundStyle(.secondary)
}
}
// Fluent extensions for all text styles
extension View {
func pageTitle() -> some View {
modifier(PageTitleModifier())
}
func sectionTitle() -> some View {
modifier(SectionTitleModifier())
}
func secondaryText() -> some View {
modifier(SecondaryTextModifier())
}
}使い方は表現力に富み、自己ドキュメント化されているため、新しいチームメンバーでもコードを読みやすくなります。
iOSの面接対策はできていますか?
インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。
状態を持つインタラクティブなmodifier
ViewModifierは、複雑なインタラクティブなふるまいを生み出すために独自の状態を管理できます。このパターンはアニメーションやユーザーフィードバックにおいて強力です。
struct PressableModifier: ViewModifier {
// Internal modifier state
@State private var isPressed = false
// Configuration
let scale: CGFloat
let opacity: CGFloat
let animation: Animation
init(
scale: CGFloat = 0.95,
opacity: CGFloat = 0.8,
animation: Animation = .easeInOut(duration: 0.1)
) {
self.scale = scale
self.opacity = opacity
self.animation = animation
}
func body(content: Content) -> some View {
content
.scaleEffect(isPressed ? scale : 1.0)
.opacity(isPressed ? opacity : 1.0)
.animation(animation, value: isPressed)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
if !isPressed {
isPressed = true
}
}
.onEnded { _ in
isPressed = false
}
)
}
}
extension View {
func pressable(
scale: CGFloat = 0.95,
opacity: CGFloat = 0.8
) -> some View {
modifier(PressableModifier(scale: scale, opacity: opacity))
}
}shimmerエフェクトによる読み込みアニメーション
内部状態のもう一つの例として、読み込み用プレースホルダーのshimmerエフェクトを紹介します。
struct ShimmerModifier: ViewModifier {
// Continuous animation
@State private var phase: CGFloat = 0
let duration: Double
let bounce: Bool
init(duration: Double = 1.5, bounce: Bool = false) {
self.duration = duration
self.bounce = bounce
}
func body(content: Content) -> some View {
content
.overlay(
shimmerOverlay
.mask(content)
)
.onAppear {
withAnimation(
.linear(duration: duration)
.repeatForever(autoreverses: bounce)
) {
phase = 1
}
}
}
private var shimmerOverlay: some View {
GeometryReader { geometry in
LinearGradient(
colors: [
.clear,
.white.opacity(0.5),
.clear
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: geometry.size.width * 2)
.offset(x: -geometry.size.width + (geometry.size.width * 2 * phase))
}
}
}
extension View {
func shimmer(duration: Double = 1.5) -> some View {
modifier(ShimmerModifier(duration: duration))
}
}
// Usage for loading skeletons
struct SkeletonCard: View {
var body: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
RoundedRectangle(cornerRadius: Radius.md)
.fill(Color.gray.opacity(0.3))
.frame(height: 120)
RoundedRectangle(cornerRadius: Radius.sm)
.fill(Color.gray.opacity(0.3))
.frame(height: 20)
RoundedRectangle(cornerRadius: Radius.sm)
.fill(Color.gray.opacity(0.3))
.frame(width: 150, height: 16)
}
.shimmer()
}
}ViewModifierの合成
ViewModifierの真の力はその合成にあります。複数のmodifierを組み合わせることで、シンプルなブロックから複雑なスタイルを構築できます。
// Base modifier for primary buttons
struct PrimaryButtonStyleModifier: ViewModifier {
let isEnabled: Bool
func body(content: Content) -> some View {
content
.font(Typography.bodyLarge.weight(.semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.md)
.background(isEnabled ? SemanticColor.primary : Color.gray)
.cornerRadius(Radius.md)
}
}
// Modifier for secondary buttons (outline)
struct SecondaryButtonStyleModifier: ViewModifier {
let isEnabled: Bool
func body(content: Content) -> some View {
content
.font(Typography.bodyLarge.weight(.semibold))
.foregroundStyle(isEnabled ? SemanticColor.primary : .gray)
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.md)
.background(Color.clear)
.overlay(
RoundedRectangle(cornerRadius: Radius.md)
.stroke(
isEnabled ? SemanticColor.primary : Color.gray,
lineWidth: 2
)
)
}
}
// Extension combining style and interaction
extension View {
func primaryButton(isEnabled: Bool = true) -> some View {
self
.modifier(PrimaryButtonStyleModifier(isEnabled: isEnabled))
.pressable(scale: isEnabled ? 0.98 : 1.0)
.allowsHitTesting(isEnabled)
}
func secondaryButton(isEnabled: Bool = true) -> some View {
self
.modifier(SecondaryButtonStyleModifier(isEnabled: isEnabled))
.pressable(scale: isEnabled ? 0.98 : 1.0)
.allowsHitTesting(isEnabled)
}
}この合成によって、コードを重複させずにバリエーションを生み出せます。各modifierは独立してテスト可能で変更可能なまま保たれます。
modifierの適用順序は視覚的な結果に影響を与えます。paddingをbackgroundの前に置くとコンテンツと背景の間に空間ができますが、逆順では背景の周りにpaddingが追加されます。
Environmentによる適応的なmodifier
ViewModifierはSwiftUIのenvironment値を読み取り、コンテキストに適応できます。ダークモード、アクセシブルなテキストサイズ、向きなどです。
struct AdaptiveCardModifier: ViewModifier {
// Reading environment values
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
func body(content: Content) -> some View {
content
.padding(adaptivePadding)
.background(backgroundColor)
.cornerRadius(Radius.lg)
.shadow(
color: shadowColor,
radius: shadowRadius,
x: 0,
y: shadowOffset
)
}
// Padding adapted to text size
private var adaptivePadding: CGFloat {
switch dynamicTypeSize {
case .xSmall, .small, .medium:
return Spacing.md
case .large, .xLarge:
return Spacing.lg
default:
return Spacing.xl
}
}
// Background color based on theme
private var backgroundColor: Color {
colorScheme == .dark
? Color(.secondarySystemBackground)
: Color(.systemBackground)
}
// More subtle shadow in dark mode
private var shadowColor: Color {
colorScheme == .dark
? .clear
: .black.opacity(0.1)
}
private var shadowRadius: CGFloat {
colorScheme == .dark ? 0 : 8
}
private var shadowOffset: CGFloat {
colorScheme == .dark ? 0 : 4
}
}
extension View {
func adaptiveCard() -> some View {
modifier(AdaptiveCardModifier())
}
}このmodifierは、利用するビュー側に追加のコードを必要とせず、システムの設定に自動的に適応します。
完全なデザインシステムの組織化
構造化されたデザインシステムでは、発見性と保守性を高めるために、ViewModifierを機能カテゴリーごとにグループ化します。
// MARK: - Namespace for the Design System
enum DS {
// MARK: Text Styles
enum Text {
static func title(_ content: some View) -> some View {
content.modifier(PageTitleModifier())
}
static func section(_ content: some View) -> some View {
content.modifier(SectionTitleModifier())
}
static func body(_ content: some View) -> some View {
content.font(Typography.bodyLarge)
}
static func caption(_ content: some View) -> some View {
content.modifier(SecondaryTextModifier())
}
}
// MARK: Container Styles
enum Container {
static func card(_ content: some View) -> some View {
content.cardStyle()
}
static func adaptiveCard(_ content: some View) -> some View {
content.adaptiveCard()
}
}
// MARK: Button Styles
enum Button {
static func primary(_ content: some View, enabled: Bool = true) -> some View {
content.primaryButton(isEnabled: enabled)
}
static func secondary(_ content: some View, enabled: Bool = true) -> some View {
content.secondaryButton(isEnabled: enabled)
}
}
// MARK: Effects
enum Effect {
static func shimmer(_ content: some View) -> some View {
content.shimmer()
}
static func pressable(_ content: some View) -> some View {
content.pressable()
}
}
}デザインシステムの利用
この組織化により、自身の意図を文書化する表現力豊かなコードが生まれます。
struct ProfileScreen: View {
let user: User
@State private var isLoading = false
var body: some View {
ScrollView {
VStack(spacing: Spacing.lg) {
// Header with title style
Text("Profile")
.pageTitle()
// User card
UserCard(user: user)
.adaptiveCard()
// Settings section
VStack(alignment: .leading, spacing: Spacing.md) {
Text("Settings")
.sectionTitle()
SettingsRow(title: "Notifications")
SettingsRow(title: "Privacy")
SettingsRow(title: "Appearance")
}
.adaptiveCard()
// Logout button
Button("Log Out") {
// Action
}
.secondaryButton()
}
.padding(Spacing.md)
}
}
}
struct UserCard: View {
let user: User
var body: some View {
HStack(spacing: Spacing.md) {
AsyncImage(url: user.avatarURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.shimmer()
}
.frame(width: 60, height: 60)
.clipShape(Circle())
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(user.name)
.font(Typography.titleMedium)
Text(user.email)
.secondaryText()
}
Spacer()
}
}
}ViewModifierのテスト
ViewModifierは独立してテスト可能であり、正しいふるまいを保証できます。
import XCTest
import SwiftUI
final class ViewModifierTests: XCTestCase {
func testCardModifierAppliesCorrectCornerRadius() {
// Arrange
let modifier = CardModifier(cornerRadius: 20, shadowRadius: 4)
// Assert
XCTAssertEqual(modifier.cornerRadius, 20)
}
func testBadgeModifierHidesWhenCountIsZero() {
// Badge should not display if count = 0 and showZero = false
let modifier = BadgeModifier(count: 0, showZero: false)
// Verify behavior via snapshot tests or UI tests
}
func testPressableModifierInitialState() {
// Initial state should be not pressed
// Testable via ViewInspector or UI tests
}
}ViewInspectorライブラリを使うと、ユニットテスト内でSwiftUIビューの内容を検査でき、ViewModifierの検証が容易になります。
まとめ
ViewModifierは、一貫性があり保守しやすいSwiftUIデザインシステムを構築するための最適なツールとなります。スタイル、アニメーション、ふるまいを再利用可能なコンポーネントにカプセル化する能力により、現代のiOSアプリケーションのアーキテクチャが大きく変わります。
SwiftUIデザインシステムのチェックリスト
- ✅ デザイントークンを定義する(spacing、radius、typography、colors)
- ✅ 各スタイルに対応するアトミックなViewModifierを作成する
- ✅ 条件付きmodifierには
@ViewBuilderを使用する - ✅ インタラクションのために
@Stateで内部状態を管理する - ✅ 複雑なスタイルを生み出すためにmodifierを合成する
- ✅ 自動適応のためにenvironmentを読み取る
- ✅ modifierを明確なnamespaceで整理する
- ✅ 流暢な構文のためにViewのextensionを提供する
- ✅ modifierを独立してテストする
- ✅ 具体的な例で使い方を文書化する
今すぐ練習を始めましょう!
面接シミュレーターと技術テストで知識をテストしましょう。
タグ
共有
関連記事

SwiftUIパフォーマンス:LazyVStackと複雑なリストの最適化
LazyVStackとSwiftUIリストの最適化テクニック。メモリ消費を削減し、スクロールパフォーマンスを向上させ、よくある落とし穴を回避します。

SwiftUI @Observable vs @State: 2026年にどちらをいつ使うか
SwiftUIにおける@Observableと@Stateの違いを理解し、iOSアプリに最適な状態管理ツールを選択しましょう。

SwiftUI:iOSのモダンなインターフェース構築ガイド
SwiftUIを使ったモダンなUIの構築方法を解説します。宣言的構文、コンポーネント、アニメーション、iOS 18のベストプラクティスを網羅しています。