programmatic-uikit-layout について
UIKit の画面をコードで構築する開発者向けの Auto Layout ガイドです。型安全なアンカー、UIStackView を中心とした構成、共通のデザイン値を使う再利用可能なコンポーネントを扱います。
主な機能
- 01型安全なレイアウトアンカー
- 02UIStackView を優先した構成
- 03セーフエリアへの対応
- 04間隔と色の一元管理
- 05再利用可能な UI コンポーネント
- 06コンテンツサイズ優先度の調整
活用シーン
- 01Storyboard からコードベースの UI へ移行
- 02ビューを縦横に配置
- 03画面間の間隔を統一
- 04制約の競合や曖昧なレイアウトを調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dagba/ ios-mcp --skill programmatic-uikit-layoutインストール後、Claude Code で/programmatic-uikit-layoutを入力して読み込まれたか確認します。
詳細
Auto Layout によるプログラム的な UIKit レイアウト
概要
アンカーを使ったプログラム的な UIKit レイアウトにより、大規模な本番アプリで使われている、型安全で保守しやすい UI コードを実現できます。 スタックビューでレイアウトの 50% 以上の問題を解決でき、アンカーによってコンパイル時の安全性が得られ、デザインシステムによって一貫性が保たれます。
基本原則: アンカーのみを使用する(Visual Format Language は使わない)、スタックビューを優先して構成する、間隔と色を一元管理した再利用可能なコンポーネントを使用する。
基盤のセットアップ
手順 1:ストーリーボードを無効にする
Main.storyboard を削除します:
- Main.storyboard ファイルを削除します
- Info.plist の「Main storyboard file base name」エントリーを削除します
- Target → Info → Custom iOS Target Properties の「Storyboard Name」を削除します
SceneDelegate を設定します:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = MainViewController()
window?.makeKeyAndVisible()
}
}
手順 2:必須の UIView セットアップ
重要: すべてのビューで translatesAutoresizingMaskIntoConstraints = false が必要です。
// ❌ WRONG: Constraints conflict with autoresizing mask
let label = UILabel()
view.addSubview(label)
label.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
// Runtime error: conflicting constraints
// ✅ CORRECT: Disable autoresizing mask
let label = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
label.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
ルール: 制約を追加する前に、translatesAutoresizingMaskIntoConstraints = false を設定します。
レイアウトアンカー(推奨方法)
NSLayoutConstraint よりアンカーを使う理由
| 機能 | レイアウトアンカー | NSLayoutConstraint |
|---|---|---|
| 型安全性 | ✅ コンパイル時にチェック | ❌ 実行時エラー |
| 読みやすさ | ✅ 流れるように記述できる API | ❌ 冗長な初期化子 |
| エラー防止 | ✅ X 軸と Y 軸を混在させられない | ❌ ミスが起きやすい |
// ❌ WRONG: NSLayoutConstraint (verbose, error-prone)
NSLayoutConstraint(
item: label,
attribute: .top,
relatedBy: .equal,
toItem: view,
attribute: .top,
multiplier: 1.0,
constant: 20
).isActive = true
// ✅ CORRECT: Layout anchors (concise, type-safe)
label.topAnchor.constraint(equalTo: view.topAnchor, constant: 20).isActive = true
アンカーの種類
X 軸: leadingAnchor、trailingAnchor、leftAnchor、rightAnchor、centerXAnchor
Y 軸: topAnchor、bottomAnchor、centerYAnchor
寸法: widthAnchor、heightAnchor
型安全性:
// ✅ Compiles: Same axis
label.leadingAnchor.constraint(equalTo: view.leadingAnchor)
// ❌ Compiler error: Mixed axes
label.leadingAnchor.constraint(equalTo: view.topAnchor) // Won't compile!
基本的な制約パターン
端に固定:
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
NSLayoutConstraint.activate([
label.topAnchor.constraint(equalTo: view.topAnchor, constant: 20),
label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
label.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -20)
])
サイズを指定して中央に配置:
label.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
label.widthAnchor.constraint(equalToConstant: 200),
label.heightAnchor.constraint(equalToConstant: 50)
])
アスペクト比:
imageView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(imageView)
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor, multiplier: 9.0/16.0) // 16:9
])
セーフエリアレイアウトガイド
重要: 最新の iPhone(ノッチ、ダイナミックアイランド)では safeAreaLayoutGuide を使用します。
// ❌ WRONG: Content hidden behind notch
label.topAnchor.constraint(equalTo: view.topAnchor)
// ✅ CORRECT: Respects safe area
label.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
標準的なパターン:
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
contentView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
])
スタックビュー(スタックビューを優先する方針)
重要: スタックビューでレイアウトの 50% 以上の問題を解決できます。積極的に使用してください。
digraph layout_choice {
"Need to arrange multiple views?" [shape=diamond];
"All in line (row/column)?" [shape=diamond];
"Use UIStackView" [shape=box, style=filled, fillcolor=lightgreen];
"Use nested UIStackViews" [shape=box, style=filled, fillcolor=lightblue];
"Use anchors" [shape=box, style=filled, fillcolor=yellow];
"Need to arrange multiple views?" -> "All in line (row/column)?" [label="yes"];
"Need to arrange multiple views?" -> "Use anchors" [label="no"];
"All in line (row/column)?" -> "Use UIStackView" [label="yes"];
"All in line (row/column)?" -> "Use nested UIStackViews" [label="no (complex grid)"];
}
基本的なスタックビュー
let stackView = UIStackView()
stackView.axis = .vertical // or .horizontal
stackView.spacing = 16
stackView.alignment = .fill // .leading, .center, .trailing
stackView.distribution = .fill // .fillEqually, .equalSpacing, etc.
stackView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20)
])
stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(subtitleLabel)
stackView.addArrangedSubview(button)
ネストしたスタックビュー(複雑なレイアウト)
// Horizontal stack with icon + labels
let horizontalStack = UIStackView()
horizontalStack.axis = .horizontal
horizontalStack.spacing = 12
horizontalStack.alignment = .center
let iconImageView = UIImageView(image: UIImage(systemName: "star.fill"))
iconImageView.widthAnchor.constraint(equalToConstant: 24).isActive = true
iconImageView.heightAnchor.constraint(equalToConstant: 24).isActive = true
// Vertical stack for title + subtitle
let verticalStack = UIStackView()
verticalStack.axis = .vertical
verticalStack.spacing = 4
verticalStack.addArrangedSubview(titleLabel)
verticalStack.addArrangedSubview(subtitleLabel)
horizontalStack.addArrangedSubview(iconImageView)
horizontalStack.addArrangedSubview(verticalStack)
view.addSubview(horizontalStack)
// Pin horizontalStack with anchors
スペーサービュー
// Add flexible spacing in stack view
let spacer = UIView()
spacer.setContentHuggingPriority(.defaultLow, for: .vertical)
stackView.addArrangedSubview(spacer)
// Pattern: Button at bottom
stackView.addArrangedSubview(titleLabel)
stackView.addArrangedSubview(descriptionLabel)
stackView.addArrangedSubview(spacer) // Pushes button to bottom
stackView.addArrangedSubview(button)
デザインシステムのパターン
テーマ(間隔と色の一元管理)
enum Theme {
enum Spacing {
static let tiny: CGFloat = 4
static let small: CGFloat = 8
static let medium: CGFloat = 16
static let large: CGFloat = 24
static let xLarge: CGFloat = 32
}
enum CornerRadius {
static let small: CGFloat = 4
static let medium: CGFloat = 8
static let large: CGFloat = 12
static let xLarge: CGFloat = 16
}
enum Colors {
static let primary = UIColor.systemBlue
static let secondary = UIColor.systemGray
static let background = UIColor.systemBackground
static let text = UIColor.label
static let textSecondary = UIColor.secondaryLabel
}
}
// Usage:
stackView.spacing = Theme.Spacing.medium
button.layer.cornerRadius = Theme.CornerRadius.medium
label.textColor = Theme.Colors.text
再利用可能なコンポーネント
カードビュー:
final class CardView: UIView {
private let containerView = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) not implemented")
}
private func setupView() {
backgroundColor = Theme.Colors.background
layer.cornerRadius = Theme.CornerRadius.large
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.1
layer.shadowOffset = CGSize(width: 0, height: 2)
layer.shadowRadius = 4
containerView.translatesAutoresizingMaskIntoConstraints = false
addSubview(containerView)
NSLayoutConstraint.activate([
containerView.topAnchor.constraint(equalTo: topAnchor, constant: Theme.Spacing.medium),
containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Theme.Spacing.medium),
containerView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Theme.Spacing.medium),
containerView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Theme.Spacing.medium)
])
}
func setContent(_ view: UIView) {
containerView.subviews.forEach { $0.removeFromSuperview() }
view.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(view)
NSLayoutConstraint.activate([
view.topAnchor.constraint(equalTo: containerView.topAnchor),
view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
])
}
}
プライマリボタン:
final class PrimaryButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
setupButton()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) not implemented")
}
private func setupButton() {
backgroundColor = Theme.Colors.primary
setTitleColor(.white, for: .normal)
layer.cornerRadius = Theme.CornerRadius.medium
titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
contentEdgeInsets = UIEdgeInsets(
top: Theme.Spacing.medium,
left: Theme.Spacing.large,
bottom: Theme.Spacing.medium,
right: Theme.Spacing.large
)
}
}
レイアウト優先度とコンテンツハグ/圧縮抵抗
コンテンツハグ優先度
ビューが本来のコンテンツサイズを超えて拡大することへの抵抗の度合いを制御します。
// Default: 250 (low)
// Higher priority = resists growing more
label.setContentHuggingPriority(.required, for: .horizontal) // 1000
button.setContentHuggingPriority(.defaultLow, for: .horizontal) // 250
// Result: Button expands, label stays at intrinsic width
コンテンツ圧縮抵抗
ビューが本来のコンテンツサイズを下回って縮小することへの抵抗の度合いを制御します。
// Default: 750 (high)
// Higher priority = resists shrinking more
titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) // 1000
subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) // 250
// Result: Subtitle truncates before title
よくあるパターン:横方向のスタック内のラベル
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 8
let label = UILabel()
label.text = "Very long text that might truncate..."
label.setContentHuggingPriority(.defaultLow, for: .horizontal) // Allows expansion
label.setContentCompressionResistancePriority(.required, for: .horizontal) // Prevents shrinking
let button = UIButton()
button.setContentHuggingPriority(.required, for: .horizontal) // Stays at intrinsic width
stackView.addArrangedSubview(label)
stackView.addArrangedSubview(button)
よくある間違い
| 間違い | 実際に起きること | 対処法 |
|---|---|---|
| 「translatesAutoresizingMaskIntoConstraints = false の設定を忘れた」 | 制約が競合し、レイアウトが崩れる | 制約を追加する前に false に設定する |
| 「safeAreaLayoutGuide の代わりに view.topAnchor を使用する」 | ノッチの背後にコンテンツが隠れる | 必ず safeAreaLayoutGuide を使用する |
| 「各制約に個別に .isActive = true を設定する」 | 冗長で非効率 | NSLayoutConstraint.activate([]) を使用する |
| 「Visual Format Language のほうが簡単」 | VFL は非推奨で、エラーが起きやすい | アンカーのみを使用する |
| 「コンテンツハグ/圧縮は不要」 | ラベルが予期せず省略され、ボタンが不適切に拡大する | 優先度を明示的に設定する |
| 「制約を追加してからサブビューを追加する」 | クラッシュ:ビューが階層内にない | activate() の前に addSubview() を呼び出す |
レイアウトの問題をデバッグする
曖昧なレイアウト
// In UIViewController viewDidLoad or custom view init
#if DEBUG
DispatchQueue.main.async {
if self.view.hasAmbiguousLayout {
print("⚠️ Ambiguous layout detected")
self.view.exerciseAmbiguityInLayout() // Animate between valid layouts
}
}
#endif
制約の競合
実行時エラー: "Unable to simultaneously satisfy constraints..."
デバッグ方法:
- エラーメッセージから制約 ID を確認します
- 制約に識別子を追加します:
let constraint = label.topAnchor.constraint(equalTo: view.topAnchor)
constraint.identifier = "label-top"
constraint.isActive = true
- 重要度の低い制約の優先度を下げます:
let constraint = label.heightAnchor.constraint(equalToConstant: 50)
constraint.priority = .defaultHigh // 750 instead of 1000
constraint.isActive = true
クイックリファレンス
基本的なセットアップ:
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
parentView.addSubview(view)
NSLayoutConstraint.activate([
view.topAnchor.constraint(equalTo: parentView.safeAreaLayoutGuide.topAnchor),
view.leadingAnchor.constraint(equalTo: parentView.leadingAnchor),
view.trailingAnchor.constraint(equalTo: parentView.trailingAnchor),
view.bottomAnchor.constraint(equalTo: parentView.bottomAnchor)
])
スタックビュー:
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = Theme.Spacing.medium
stack.addArrangedSubview(view1)
stack.addArrangedSubview(view2)
デザインシステム:
view.backgroundColor = Theme.Colors.background
stackView.spacing = Theme.Spacing.medium
button.layer.cornerRadius = Theme.CornerRadius.large
危険信号 — いったん中止して再検討してください
- Visual Format Language を使用する → アンカーに切り替えてください
translatesAutoresizingMaskIntoConstraintsを無効にしない → 制約が競合します- セーフエリアのレイアウトガイドを無視する → 最新の iPhone でコンテンツが隠れます
- 制約を 5 個以上手動で作成する → UIStackView の使用を検討してください
- ハードコードした間隔の値(8、16、24)があちこちに散在する → Theme に集約してください
- 制約を個別に有効化する → activate([]) でまとめて有効化してください
- 本番環境でレイアウトが不明確 → 固有サイズまたは不足している制約を追加してください
実際の影響
変更前: VFL、アンカー、生の NSLayoutConstraint が混在していました。プロフィール画面に 500 行以上。制約の競合が頻繁に発生していました。
変更後: アンカーのみとスタックビューを使用。200 行で、競合はゼロになり、保守も容易になりました。
変更前: ハードコードした間隔(8px、12px、16px)が 30 個のビューコントローラーに散在していました。デザインを更新するには 30 個のファイルを編集する必要がありました。
変更後: Theme.Spacing.medium を使用。デザインの更新は 1 個のファイルを編集するだけで、アプリ全体に反映されます。
変更前: カードのレイアウトに複雑な制約ロジックを独自実装していました。100 行あり、デバッグが困難でした。
変更後: 再利用可能な CardView コンポーネントを使用。30 行で、15 か所で一貫して使用できます。