RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Articles/Dev Tools
Dev Tools/2026-05-12Advanced

Building a Shared UI Component Library with Swift Package Manager in Rork Max

A practical guide to building a shared SwiftUI component library with Swift Package Manager for indie developers running multiple apps — eliminate duplicate code across wallpaper, healing, and similar app families.

Rork Max229Swift Package Manager2SwiftUI63Indie Dev36Component DesignMulti-App

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:

  1. Open Wallpaper App, update Color("PrimaryColor") in Assets.xcassets
  2. Open Healing App, repeat
  3. Open Manifestation App, repeat
  4. And so on

With a shared library:

  1. Change one value in DoliceUIKit/Sources/DoliceUIKit/Theme/AppTheme.swift
  2. Push version tag 1.0.1
  3. 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 ButtonStyle implementations
  • 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 DoliceUIKit

Configure 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.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Dev Tools2026-07-12
A symbolEffect Field Memo: Making Icons Move Nicely in Your Rork Max App
Animate SF Symbols in a Swift app generated by Rork Max using bounce, pulse, variableColor, and contentTransition, with working code, OS-version gating, and the mistakes I made from over-animating.
Dev Tools2026-07-09
Getting Rork Max's Swift Through Swift 6's Strict Concurrency Checking
A field record of taking a Rork Max-generated SwiftUI app through Swift 6 complete concurrency checking: 217 warnings cleared target by target, where @MainActor actually belongs, and measured before/after numbers.
Dev Tools2026-07-06
Migrating Rork Max SwiftUI to @Observable: Narrowing the Re-renders ObservableObject Was Spreading
Rork Max tends to generate SwiftUI apps built on ObservableObject and @Published, where a single state change re-evaluates every subscribing view. Moving to the Observation framework's @Observable narrows invalidation to the property level. Here is the migration path, plus the view-body execution counts I measured in Instruments before and after.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →