koshimizu-takehito/screenmacros
**ScreenMacros** is a Swift macro package that generates type-safe SwiftUI views from enums.
Features
- π― Type-safe screen-to-view mapping
- π Automatic View protocol conformance
- π¦ Associated values support
- πΊοΈ Parameter mapping
- π§© SwiftUI navigation helpers
Table of Contents
Requirements
- Swift 6.0+
- iOS 17.0+ / macOS 14.0+ / tvOS 17.0+ / watchOS 10.0+ / visionOS 1.0+
Installation
Swift Package Manager
Add ScreenMacros as a dependency in your Package.swift:
dependencies: [
.package(url: "https://github.com/Koshimizu-Takehito/ScreenMacros.git", from: "1.0.0")
]Then add it to your target:
.target(
name: "YourFeature",
dependencies: [
.product(name: "ScreenMacros", package: "ScreenMacros")
]
)Xcode
- File β Add Package Dependencies...
- Enter:
https://github.com/Koshimizu-Takehito/ScreenMacros.git - Select version:
1.0.0or later
Example Project
A complete example iOS app is included in the Example/ directory. It demonstrates all features of ScreenMacros:
@Screensmacro with automatic View type inference@Screenwith explicit type and parameter mappingNavigationStackwithnavigationDestination(_:)sheet(item:)for modal presentationfullScreenCover(item:)for full-screen presentationScreensForEachwithTabView
Running the Example
xed ExampleThen build and run in Xcode. The first time you build, you may need to trust the macro:
- Build the project (βB)
- When prompted about macros, click "Trust & Enable"
Project Structure
Example/
βββ ScreenMacrosExample.xcodeproj
βββ ScreenMacrosExample/
βββ App.swift # App entry point
βββ ContentView.swift # Main view with TabView
βββ Info.plist
βββ Screens/
β βββ Screen.swift # Navigation screens
β βββ TabScreen.swift # Tab bar screens
β βββ ModalScreen.swift # Sheet screens
β βββ FullScreen.swift # Full-screen cover screens
βββ Views/
βββ Home.swift # Home screen
βββ Detail.swift # Detail screen
βββ Search.swift # Search screen
βββ Profile.swift # Profile tab screen
βββ ProfileView.swift # Profile screen (parameter mapping)
βββ Settings.swift # Settings modal
βββ EditProfile.swift # Edit profile modal
βββ Onboarding.swift # Onboarding full-screen
βββ Login.swift # Login full-screenMacros
@Screens
- Attached to: enum
- Generates:
- extension <Enum>: View, Screens - var body: some View
If no @Screen attributes are present, View types are inferred from case names by converting them to UpperCamelCase:
@Screens
enum Screen {
case home // β Home()
case detail(id: Int) // β Detail(id: id)
}Expands to:
extension Screen: View, ScreenMacros.Screens {
@MainActor @ViewBuilder
var body: some View {
switch self {
case .home:
Home()
case .detail(id: let id):
Detail(id: id)
}
}
}@Screen
- Attached to: enum case
- Purpose: Override the inferred View type and/or map parameter labels.
Specify View type
@Screen(ProfileView.self)
case profile // β ProfileView() instead of Profile()Specify View type with parameter mapping
@Screen(ProfileView.self, ["userId": "id"])
case profile(userId: Int) // β ProfileView(id: userId)Parameter mapping only (View type inferred)
@Screen(["userId": "id"])
case editProfile(userId: Int)
// β EditProfile(id: userId)Parameter Mapping
When case labels differ from View initializer parameter names, provide a mapping via @Screen:
@Screens
enum Screen {
@Screen(ProfileView.self, ["userId": "id"])
case profile(userId: Int)
}Expands to:
extension Screen: View, ScreenMacros.Screens {
@MainActor @ViewBuilder
var body: some View {
switch self {
case .profile(userId: let userId):
ProfileView(id: userId)
}
}
}- Mapping keys must match case parameter labels.
- For unlabeled associated values, use the generated parameter names (
param0,param1, ...) as mapping keys. - A mapping value of
"_"means "call the initializer without a label" for that parameter. - Unmapped parameters are passed through unchanged.
Access Control
@Screens automatically mirrors the access level of the source enum:
| Source | Generated | |--------|-----------| | public enum | public extension / public var body | | internal enum | internal extension / internal var body | | fileprivate enum | fileprivate extension / fileprivate var body | | private enum | private extension / private var body |
Example:
@Screens
public enum Screen {
case home
case detail(id: Int)
}Expands to:
public extension Screen: View, ScreenMacros.Screens {
@MainActor @ViewBuilder
public var body: some View {
switch self {
case .home:
Home()
case .detail(id: let id):
Detail(id: id)
}
}
}This prevents mismatches like an internal enum with a public body.
Associated Values
@Screens does not depend on concrete types of associated values. It simply:
- Binds each case parameter to a local
let - Forwards those bindings to the View initializer
This means Optional, Result, and other generic types work out of the box:
@Screens
enum Screen {
case detail(id: Int?)
case loadResult(result: Result<String, Error>)
}Expands to:
extension Screen: View, ScreenMacros.Screens {
@MainActor @ViewBuilder
var body: some View {
switch self {
case .detail(id: let id):
Detail(id: id)
case .loadResult(result: let result):
LoadResult(result: result)
}
}
}Unlabeled Associated Values
When an associated value has no label, it is passed to the View without a label. This allows Views with unlabeled initializer parameters (e.g., init(_ id: Int)) to work seamlessly:
@Screens
enum Screen {
case preview(Int) // Unlabeled
case article(Int, title: String) // Mixed: unlabeled + labeled
}Expands to:
extension Screen: View, ScreenMacros.Screens {
@MainActor @ViewBuilder
var body: some View {
switch self {
case .preview(let param0):
Preview(param0) // Passed without label
case .article(let param0, title: let title):
Article(param0, title: title) // First without label, second with label
}
}
}If you need to add a label to an unlabeled parameter, use the mapping:
@Screen(["param0": "id"])
case preview(Int) // β Preview(id: param0)You can also use "_" as a mapping value when you want to remove a label from a labeled parameter:
@Screen(Detail.self, ["id": "_"])
case detail(id: Int) // β Detail(id) β passed without labelForEach Helpers
ScreensForEach
Iterates over all cases of a CaseIterable enum with custom content:
@Screens
enum TabScreen: CaseIterable, Hashable {
case home
case search
case profile
var title: String { ... }
var icon: String { ... }
}
TabView {
ScreensForEach(TabScreen.self) { screen in
screen.tabItem {
Label(screen.title, systemImage: screen.icon)
}
}
}ScreensForEachView
Renders all cases directly as Views:
VStack {
ScreensForEachView(TabScreen.self)
}Development
Requirements
- macOS 15.0+
- Xcode 16.0+ (Swift 6.0+)
- Mint (installed automatically via
make setupwhen Homebrew is available)
Setup
# Clone the repository
git clone https://github.com/Koshimizu-Takehito/ScreenMacros.git
cd ScreenMacros
# Install dependencies
make setupAvailable Commands
| Command | Description | |---------|-------------| | make setup | Install Mint (if needed) and dependencies via Mint | | make sync | Pull latest changes and update all dependencies | | make build | Build the package | | make test | Run tests | | make lint | Run SwiftLint | | make format | Format code with SwiftFormat | | make format-check | Check code formatting (CI) | | make ci | Run all CI checks | | make clean | Clean build artifacts | | make help | Show available commands |
Before Submitting a PR
Run all CI checks locally to ensure your changes pass:
make ciLicense
ScreenMacros is available under the MIT License. See the LICENSE file for details.
Package Metadata
Repository: koshimizu-takehito/screenmacros
Default branch: main
README: README.md