Around 2019, I was running five wallpaper apps simultaneously when something obvious finally became unavoidable: every time I fixed a tap animation bug on a card component, I opened five separate Xcode projects and made the same change five times.
The code was nearly identical. The bug was identical. The fix was identical. But because each app lived in its own project, there was no way to share the improvement.
Rork Max makes this problem even more visible. The SwiftUI code it generates is high quality, but the AI optimizes for a single project at a time. Run similar prompts across five projects and you end up with five slightly different implementations of the same card — corner radius 16 in one, 14 in another, shadow opacity 0.08 here, 0.1 there. The divergence compounds silently.
Swift Package Manager solves this at the architectural level. One change propagates to all five apps. One test covers the shared code. One release carries the fix everywhere.
This article walks through the library structure I use across my app portfolio, including the theme injection pattern that lets each app keep its own personality while sharing the same underlying components.
Why Rork Max Projects Specifically Need SPM
Rork Max generates SwiftUI code scoped to a single app. This is entirely sensible — the AI can't know about your other projects. But it creates a specific problem when you're building a family of related apps.
Consider what happens when you want to update your brand color across all your apps. Without a shared library, the workflow is:
- Open Wallpaper App, update
Color("PrimaryColor")in Assets.xcassets - Open Healing App, repeat
- Open Manifestation App, repeat
- And so on
With a shared library:
- Change one value in
DoliceUIKit/Sources/DoliceUIKit/Theme/AppTheme.swift - Push version tag
1.0.1 - All apps pick it up on next build
The second pattern doesn't just save time — it eliminates the class of bugs that comes from partial updates, where you meant to change all five apps but missed one.
Designing the Library Boundary
Before writing a single line of code, decide what belongs in the library and what stays in each app. Getting this wrong is the most common mistake with shared libraries.
Goes in the library (stable, app-agnostic):
- Animation constants and presets
- Shadow, blur, and corner radius style tokens
- Custom
ButtonStyleimplementations - Reusable view components (cards, banners, tag chips)
- Network image loading wrappers
- Haptic feedback utilities
Stays in each app (changes per brand):
- Brand colors
- Font families
- App-specific icons
- Business logic
- External SDK wrappers (AdMob, analytics, etc.)
That last point about SDKs matters more than it seems. My first attempt at a shared library pulled in an AdMob banner view wrapper, which dragged the Google Mobile Ads SDK into every app, bloating binary sizes and creating dependency management headaches. The library should have zero third-party dependencies unless they're truly universal utilities.
Creating the Swift Package
From a terminal, create the package alongside your app projects:
mkdir -p ~/Developer/DoliceUIKit
cd ~/Developer/DoliceUIKit
swift package init --type library --name DoliceUIKitConfigure Package.swift with iOS 17 minimum deployment for @Observable support:
// 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"
),
]
)No external dependencies. This keeps the library lean and removes a whole category of potential build failures.
The Theme Injection System
Each app in a portfolio needs its own visual identity. A wallpaper app might use soft pinks; a meditation app might use deep teals. The component library needs to serve both without hardcoding either.
The solution is a protocol-based theme system injected through SwiftUI's EnvironmentValues:
// Sources/DoliceUIKit/Theme/AppTheme.swift
import SwiftUI
/// Each app conforms to this protocol to define its brand
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 }
}
/// Default theme used during development and previews
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) }
public var fontCaption: Font { .system(.caption) }
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 }
}
}In each app, define a conforming type and inject it at the root:
// WallpaperApp/WallpaperAppTheme.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())
}
}
}The compelling property of this pattern: if you add a new field to AppThemeable — say var accent: Color — every app that doesn't implement it will produce a compile error. The compiler enforces completeness across your entire app portfolio. Missing a brand update becomes impossible by definition.
Building the Card Component
The card pattern appears in nearly every consumer app. Here's a library implementation that reads from the injected theme:
// Sources/DoliceUIKit/Components/ContentCard.swift
import SwiftUI
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 // Subtle shadow
case elevated // Stronger shadow, premium feel
case flat // No shadow, thin border
}
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: 16))
.shadow(color: shadowColor, radius: shadowRadius, x: 0, y: shadowY)
}
private var cardBackground: some View {
theme.surface
.overlay(
style == .flat
? RoundedRectangle(cornerRadius: 16)
.strokeBorder(theme.primary.opacity(0.2), lineWidth: 1)
: nil
)
}
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
}
}
}Compare this with what Rork Max generates when you ask for a card in two different projects:
Before — two apps, two slightly-different implementations:
// In WallpaperApp
struct WallpaperCard: View {
var body: some View {
content
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(color: .black.opacity(0.08), radius: 8, x: 0, y: 4)
}
}
// In HealingApp — almost the same, but not quite
struct HealingCard: View {
var body: some View {
content
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14)) // different
.shadow(color: .black.opacity(0.1), radius: 6, x: 0, y: 3) // different
}
}After — one component, theme-aware, used identically in both apps:
ContentCard(style: .elevated) {
WallpaperThumbnailView(item: wallpaper)
}The subtle divergence between cornerRadius: 16 and cornerRadius: 14 across apps creates a perception of lower quality that's hard to pinpoint. Eliminating it with a shared component costs very little but adds up significantly across an app portfolio.
Animation Presets
Rork Max generates expressive animations, but the specific values drift between projects. Standardize them as Animation extensions:
// Sources/DoliceUIKit/Animation/AnimationPresets.swift
import SwiftUI
public extension Animation {
/// Standard spring for tap feedback and state transitions
static let doliceSpring = Animation.spring(
response: 0.4,
dampingFraction: 0.75,
blendDuration: 0
)
/// Quick snap for button press feedback
static let doliceSnap = Animation.spring(
response: 0.25,
dampingFraction: 0.8,
blendDuration: 0
)
/// Smooth ease for screen transitions
static let doliceEase = Animation.easeOut(duration: 0.35)
/// Content crossfade (wallpaper or background switching)
static let doliceContentTransition = Animation.easeInOut(duration: 0.5)
}
/// Press effect consistent with the app's motion language
public struct DolicePressEffect: ButtonStyle {
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)
}
}When every button across all your apps uses .doliceSnap, the motion language becomes coherent. Users who have multiple apps from the same developer — which happens more than you'd expect with wallpaper and wellness categories — notice that coherence even if they can't articulate it.
Integrating with Rork Max Projects
In Xcode, open your Rork Max project and add the local package:
File → Add Package Dependencies → Add Local...
Navigate to ~/Developer/DoliceUIKit. Xcode adds it as a local package dependency, which means changes to the library reflect immediately in the app without requiring a version bump.
For production, push the library to a private GitHub repository and reference it by URL:
// In your app's Package.swift or via Xcode's GUI
.package(
url: "https://github.com/dolice/DoliceUIKit.git",
.upToNextMinor(from: "1.0.0")
).upToNextMinor(from:) is the right constraint here. Patch versions (1.0.x) are pulled automatically — good for bug fixes. Minor versions (1.x.0) require an explicit update — protection against breaking changes when you add new required protocol fields.
Testing the Library
Components with no external state are straightforward to test:
// Tests/DoliceUIKitTests/ThemeTests.swift
import XCTest
@testable import DoliceUIKit
final class ThemeTests: XCTestCase {
func testDefaultThemeIsComplete() {
let theme = DefaultAppTheme()
XCTAssertNotNil(theme.primary)
XCTAssertNotNil(theme.secondary)
XCTAssertNotNil(theme.fontTitle)
}
func testAnimationPresetsCompile() {
_ = Animation.doliceSpring
_ = Animation.doliceSnap
_ = Animation.doliceEase
_ = Animation.doliceContentTransition
}
func testCardStylesAreDistinct() {
// Each style produces a different shadow configuration
// Verified by the enum switch coverage in ContentCard
let styles: [ContentCard<EmptyView>.CardStyle] = [.standard, .elevated, .flat]
XCTAssertEqual(styles.count, 3)
}
}For visual regression testing, consider swift-snapshot-testing. It captures rendered views as reference images and fails CI when the output changes unexpectedly — useful when you're touching components shared across five apps.
Pitfalls from Real Usage
Previews crash without environment injection: SwiftUI Previews don't automatically provide the environment values your components expect. Always add .environment(\.appTheme, DefaultAppTheme()) to preview containers:
#Preview {
ContentCard {
Text("Preview card")
.padding()
}
.environment(\.appTheme, DefaultAppTheme())
}Local paths break in CI: Xcode Cloud and GitHub Actions don't have access to ~/Developer/DoliceUIKit. Before merging any branch that will trigger CI, switch the dependency to the GitHub URL. A simple script in your pre-commit hook can catch this.
Binary size from unused code: Swift's dead code stripping (DEAD_CODE_STRIPPING = YES) removes unused symbols in release builds. Verify Rork Max projects have this set to YES in the Release configuration — it's the default but worth confirming.
Starting Small
The full design above represents months of iteration. If you're running two apps today and considering this architecture, don't try to migrate everything at once.
Start with one component — the card or the animation presets are good candidates because they're self-contained and appear everywhere. Extract it into a local Swift Package, integrate it into both apps, and verify that nothing broke. Then decide whether the workflow suits you before going further.
The moment you fix a real bug in the shared component and watch both apps update automatically, the value of the architecture becomes concrete. That's when it becomes worth maintaining.
For more on refactoring Rork Max-generated SwiftUI code to production quality, see 10 Refactoring Patterns for Rork Max-Generated SwiftUI Code. If you're working with Rork's React Native projects rather than Rork Max native, Turborepo Monorepo for Shared Components covers the equivalent pattern for that stack.
If you've been duplicating components across multiple Rork Max projects, this is the pattern that ends it.