2019年頃、私は5本の壁紙アプリを同時に運営しながら、ある単純な事実に気づきました。「カード型UIのタップ時アニメーションを直すたびに、5本全てのプロジェクトを開き直している」という現実です。同じバグ修正を5回繰り返す。コードはほぼ同じなのに、プロジェクトは5つに分かれています。
Rork Max で SwiftUI ネイティブアプリを生成していると、この問題はさらに鮮明になります。AI が生成するコードは高品質ですが、複数アプリにまたがった設計の一貫性は自動では保たれません。似たようなカードビュー、ほぼ同じグラデーション定義、わずかに違うフォントサイズ——これらが5本のアプリに散在すると、「ブランドをちょっと変えたい」という判断が5倍のコストになります。
Swift Package Manager(SPM)で共通UIライブラリを構築すると、この問題は根本的に解消されます。修正は1箇所、テストも1箇所、リリースも連鎖的に反映されます。
ここでは私が実際に行った複数アプリ共通UIライブラリの設計と、Rork Max 生成コードとの統合パターンを詳しく解説します。
なぜ Rork Max の生成コードに SPM が必要なのか
Rork Max は SwiftUI のコードを自動生成しますが、その設計単位は「1アプリ」です。2本目のアプリを同じクオリティで作ろうとすると、最初のアプリで確立したデザインシステムを、別プロジェクトで一から再現することになります。
具体的に何が問題になるか整理します。
テーマカラーの分散: アプリごとに Color("PrimaryColor") のような定義が存在しますが、その RGB 値やダークモード対応は各プロジェクトで個別管理されます。ブランドカラーを #7B68EE から #6A5ACD に変更する際、5本を全部直す必要があります。
アニメーション定数の重複: spring(response: 0.4, dampingFraction: 0.75) のような定数が各ファイルに埋め込まれています。アニメーションのフィールを少し変えたいとき、同様に5本全部を直します。
ビューコンポーネントの微妙な差異: 「カード型コンポーネント」という共通概念があっても、各アプリで微妙に実装が異なります。これは品質のばらつきと、後のメンテナンスコストに直結します。
SPM でライブラリ化することで、これら全てが1つの Package.swift に集約されます。
パッケージ構成の設計
共通UIライブラリの構成を考えるとき、私は「何が変わらないか」と「何がアプリごとに違うか」を分けることから始めます。
変わらないもの(ライブラリに入れる):
- アニメーション定数とプリセット
- シャドウ・ブラー・コーナーラジアスのスタイル定数
- カスタムボタンスタイル(
ButtonStyleプロトコル準拠) - リピートできるビューコンポーネント(カード、バナー、タグチップ等)
- ネットワーク画像ローダー
- ハプティックフィードバックの共通ラッパー
アプリごとに違うもの(アプリ側で定義する):
- ブランドカラー(プライマリ・セカンダリ)
- フォントファミリー
- アプリ固有のアイコン
- ビジネスロジック
この区別を最初にしっかり決めておかないと、ライブラリがアプリ依存のコードを引き込んでしまいます。実際に私が失敗したのもここでした。最初に作ったライブラリは AdMob の GADBannerView ラッパーまで入れてしまい、SDK の依存関係がライブラリを汚染してしまいました。
Swift Package を作成する
Rork Max でプロジェクトを開き、ターミナルで以下を実行します。
# アプリプロジェクトと同じ階層にパッケージを作成
mkdir -p ~/Developer/DoliceUIKit
cd ~/Developer/DoliceUIKit
swift package init --type library --name DoliceUIKit生成される構造は次のようになります。
DoliceUIKit/
├── Package.swift
├── Sources/
│ └── DoliceUIKit/
│ └── DoliceUIKit.swift ← ここにコンポーネントを追加
└── Tests/
└── DoliceUIKitTests/
└── DoliceUIKitTests.swift
次に Package.swift を設定します。iOS 17 以上で @Observable を活用するため、最低サポートバージョンを明示します。
// Package.swift
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "DoliceUIKit",
platforms: [
.iOS(.v17)
],
products: [
.library(
name: "DoliceUIKit",
targets: ["DoliceUIKit"]
),
],
dependencies: [],
targets: [
.target(
name: "DoliceUIKit",
dependencies: [],
path: "Sources/DoliceUIKit"
),
.testTarget(
name: "DoliceUIKitTests",
dependencies: ["DoliceUIKit"],
path: "Tests/DoliceUIKitTests"
),
]
)ここで重要なのは、外部 SDK を依存に入れないことです。後からどうしても必要になった場合は、別のターゲットとして分離します。
テーマシステムの実装
アプリごとに異なるカラーテーマを注入できる仕組みを作ります。SwiftUI の EnvironmentValues を使うのが最もクリーンなパターンです。
// Sources/DoliceUIKit/Theme/AppTheme.swift
import SwiftUI
/// アプリのブランドテーマを定義するプロトコル
/// 各アプリはこのプロトコルに適合したテーマ型を用意し、環境に注入する
public protocol AppThemeable {
var primary: Color { get }
var secondary: Color { get }
var surface: Color { get }
var onSurface: Color { get }
var fontTitle: Font { get }
var fontBody: Font { get }
var fontCaption: Font { get }
}
/// デフォルトテーマ(壁紙アプリ系の紫グラデーション基調)
public struct DefaultAppTheme: AppThemeable {
public var primary: Color { Color(hex: "#7B68EE") }
public var secondary: Color { Color(hex: "#48D1CC") }
public var surface: Color { Color(.systemBackground) }
public var onSurface: Color { Color(.label) }
public var fontTitle: Font { .system(.title2, design: .rounded, weight: .bold) }
public var fontBody: Font { .system(.body, design: .default) }
public var fontCaption: Font { .system(.caption, design: .monospaced) }
public init() {}
}
/// 環境キー
private struct AppThemeKey: EnvironmentKey {
static let defaultValue: any AppThemeable = DefaultAppTheme()
}
public extension EnvironmentValues {
var appTheme: any AppThemeable {
get { self[AppThemeKey.self] }
set { self[AppThemeKey.self] = newValue }
}
}各アプリ側では、ルートビューでテーマを注入するだけです。
// WallpaperApp/ContentView.swift(アプリ側)
import DoliceUIKit
// アプリ固有テーマの定義
struct WallpaperAppTheme: AppThemeable {
var primary: Color { Color(hex: "#FF6B9D") } // ピンク系のブランドカラー
var secondary: Color { Color(hex: "#FFB347") }
var surface: Color { Color(.systemBackground) }
var onSurface: Color { Color(.label) }
var fontTitle: Font { .custom("ZenMaruGothic-Bold", size: 20) }
var fontBody: Font { .custom("ZenMaruGothic-Regular", size: 16) }
var fontCaption: Font { .custom("ZenMaruGothic-Regular", size: 12) }
}
@main
struct WallpaperApp: App {
var body: some Scene {
WindowGroup {
RootView()
.environment(\.appTheme, WallpaperAppTheme())
}
}
}この設計の良い点は、AppThemeable プロトコルに変更を加えれば(新しいカラーロールを追加するなど)、コンパイルエラーで全アプリ側の対応漏れをすぐに検出できることです。
カードコンポーネントの実装
壁紙・癒し系アプリで最も頻出するカード型UIを共通化します。@Environment でテーマを取得するパターンです。
// Sources/DoliceUIKit/Components/ContentCard.swift
import SwiftUI
/// 汎用コンテンツカード
/// - `content`: カード内に表示するビュー(ジェネリック)
/// - `style`: カードのスタイルプリセット
public struct ContentCard<Content: View>: View {
@Environment(\.appTheme) private var theme
@Environment(\.colorScheme) private var colorScheme
private let content: Content
private let style: CardStyle
public enum CardStyle {
case standard // 標準シャドウ
case elevated // 強めのシャドウ、プレミアムな印象
case flat // シャドウなし、ボーダーあり
}
public init(style: CardStyle = .standard, @ViewBuilder content: () -> Content) {
self.style = style
self.content = content()
}
public var body: some View {
content
.background(cardBackground)
.clipShape(RoundedRectangle(cornerRadius: cardCornerRadius))
.shadow(
color: shadowColor,
radius: shadowRadius,
x: 0,
y: shadowY
)
}
private var cardBackground: some View {
theme.surface
.overlay(
style == .flat
? RoundedRectangle(cornerRadius: cardCornerRadius)
.strokeBorder(theme.primary.opacity(0.2), lineWidth: 1)
: nil
)
}
private var cardCornerRadius: CGFloat { 16 }
private var shadowColor: Color {
switch style {
case .standard: return Color.black.opacity(colorScheme == .dark ? 0.3 : 0.08)
case .elevated: return theme.primary.opacity(0.2)
case .flat: return .clear
}
}
private var shadowRadius: CGFloat {
switch style {
case .standard: return 8
case .elevated: return 20
case .flat: return 0
}
}
private var shadowY: CGFloat {
switch style {
case .standard: return 4
case .elevated: return 8
case .flat: return 0
}
}
}Rork Max が生成するコードの典型的なカードコンポーネントと比較してみましょう。
Before(Rork Max 生成コード、各アプリに個別存在):
// WallpaperApp でのカード定義
struct WallpaperCard: View {
var body: some View {
content
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: Color.black.opacity(0.08), radius: 8, x: 0, y: 4)
}
}
// HealingApp でのカード定義(微妙に違う)
struct HealingCard: View {
var body: some View {
content
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14)) // ← 微妙に違う
.shadow(color: Color.black.opacity(0.1), radius: 6, x: 0, y: 3) // ← 微妙に違う
}
}After(DoliceUIKit を使った統一実装):
// どのアプリでも同じコンポーネント、テーマだけが違う
ContentCard(style: .elevated) {
WallpaperThumbnailView(wallpaper: item)
}コーナーラジアスが 16 か 14 か、シャドウの透明度が 0.08 か 0.1 かで品質がぶれていた問題が、一度の修正で全アプリに伝播するようになります。
アニメーションプリセットの共通化
Rork Max が生成するアニメーションコードは表現力がありますが、アプリをまたぐと統一感がなくなります。Animation の拡張として共通プリセットを定義します。
// Sources/DoliceUIKit/Animation/AnimationPresets.swift
import SwiftUI
public extension Animation {
/// 標準的なスプリングアニメーション(タップフィードバック等)
static let doliceSpring = Animation.spring(
response: 0.4,
dampingFraction: 0.75,
blendDuration: 0
)
/// 素早いフィードバック(ボタン押下等)
static let doliceSnap = Animation.spring(
response: 0.25,
dampingFraction: 0.8,
blendDuration: 0
)
/// ゆったりとした登場アニメーション(画面遷移等)
static let doliceEase = Animation.easeOut(duration: 0.35)
/// 壁紙・背景が切り替わる際のフェード
static let doliceContentTransition = Animation.easeInOut(duration: 0.5)
}
/// スプリングトランジション付きのスケールエフェクト
public struct DolicePressEffect: ButtonStyle {
@Environment(\.appTheme) private var theme
public init() {}
public func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.94 : 1.0)
.opacity(configuration.isPressed ? 0.85 : 1.0)
.animation(.doliceSnap, value: configuration.isPressed)
}
}このプリセットをプロジェクト全体に適用することで、「なんとなくアニメーションが違う気がする」という品質のばらつきを防げます。
Rork Max プロジェクトへの統合
Xcode で Rork Max の生成プロジェクトを開き、SPM パッケージを追加します。
File → Add Package Dependencies → ローカルパッケージを選択(開発中はローカルパスを指定)
/Users/masaki/Developer/DoliceUIKit
ローカルパッケージとして追加することで、ライブラリを変更した際に即座にアプリ側に反映されます。本番環境では GitHub リポジトリに push し、バージョンタグで管理します。
// GitHub で管理する場合の Package.swift (アプリ側)
dependencies: [
.package(
url: "https://github.com/dolice/DoliceUIKit.git",
from: "1.0.0"
)
],統合後、Rork Max で新しいコンポーネントを生成するたびに「これは DoliceUIKit にあるか」を確認する習慣が重要です。あるなら Rork Max 生成コードを DoliceUIKit の使用に置き換え、ないならライブラリに追加するかどうか判断します。
Rork Max の生成コードを本番レベルに仕上げるリファクタリング手法については、Rork Max が生成した SwiftUI コードを本番品質まで磨き上げる10のリファクタリング・パターンで詳しく解説しています。また、React Native(Rork 通常プロジェクト)での共通コンポーネント化についてはTurborepo モノレポで複数アプリの共有コードを一元管理するも参考になります。
テストとバリデーション
ライブラリのコンポーネントは @testable import でテストできます。SwiftUI コンポーネントのユニットテストは難しいですが、ビジネスロジックを持つものは確認できます。
// Tests/DoliceUIKitTests/ThemeTests.swift
import XCTest
@testable import DoliceUIKit
final class ThemeTests: XCTestCase {
func testDefaultThemeColors() {
let theme = DefaultAppTheme()
// プライマリカラーが定義済みであること
// Color の比較は直接できないため、nilでないことを確認
XCTAssertNotNil(theme.primary)
XCTAssertNotNil(theme.secondary)
}
func testAnimationPresetsExist() {
// コンパイル通過でプリセットが正しく定義されていることを確認
_ = Animation.doliceSpring
_ = Animation.doliceSnap
_ = Animation.doliceEase
}
}本格的なビジュアルテストには swift-snapshot-testing の導入を検討する価値があります。コンポーネントの見た目が変わっていないことをCIで保証できます。
複数アプリへの展開フロー
ライブラリが安定したら、複数アプリへの展開は次のフローで進めます。
まず、GitHub でセマンティックバージョニングを使います。破壊的変更は v2.0.0、新機能は v1.1.0、バグ修正は v1.0.1 とタグを付けます。
各アプリの Package.swift では、バージョン範囲を適度に広めに設定することで、バグ修正が自動的に取り込まれます。
// アプリ側 Package.swift での依存定義
.package(
url: "https://github.com/dolice/DoliceUIKit.git",
.upToNextMinor(from: "1.0.0") // 1.0.x の最新を自動取得、1.1.0 は手動アップデート
),破壊的変更(プロトコルの追加フィールドなど)は upToNextMinor で自動取得を防ぎ、各アプリで意図的に対応してからアップデートします。
壁紙アプリ・癒しアプリ・引き寄せアプリで共通テーマが 80% 一致していても、残り 20% のブランド個性は守られます。これが AppThemeable プロトコルを設計した動機でもあります。コアの品質と表現の多様性を、同時に成立させることができます。
実運用で分かった落とし穴
プレビューがクラッシュする問題: SwiftUI Preview で環境値が正しく渡らないケースがありました。PreviewProvider では必ず .environment(\.appTheme, DefaultAppTheme()) を明示する必要があります。
#Preview {
ContentCard {
Text("サンプルカード")
.padding()
}
.environment(\.appTheme, DefaultAppTheme()) // 必須
}Xcode Cloud でのローカルパス問題: CI 環境では当然ローカルパスは無効です。開発中はローカルパスを使い、CI ブランチに push する前に GitHub URL に切り替えるか、.xcode-version で Xcode バージョンを固定した上で GitHub Actions でも同じ切り替えを自動化します。
AppStore 審査でのバイナリサイズ: ライブラリに使わないコードが大量にあると、バイナリサイズが増加します。dead_code_stripping = YES が Xcode デフォルトで有効なため通常は問題になりませんが、Rork Max の生成プロジェクトでリリースビルドの設定を確認しておくと安心です。
全体を振り返って
Swift Package Manager で共通UIライブラリを構築することは、1本のアプリを作っている段階では「やりすぎ」に見えます。しかし2本目を作り始めた瞬間、投資は確実に回収されます。
まず小さく始めることをおすすめします。全てのコンポーネントを移行しようとすると労力がかかりすぎます。「最もよく使うコンポーネント3つ」を SPM に移行するところから始めて、自分のワークフローに合っているかを確認してから範囲を広げる方が、長続きします。
Rork Max のプロジェクトから ContentCard.swift を1つ切り出して SPM パッケージに移すことで、このアーキテクチャを体験してみてください。2本目のアプリを作るとき、そのコンポーネントが既に「完成されたライブラリ」として待っているときの感覚は、かなり気持ちがよいものです。
実装の参考になれば幸いです。