Spices generates native in-app debug menus from Swift code using the @Spice
property wrapper and SpiceStore
protocol and stores settings in UserDefaults.
We built Spices at Shape (becoming Framna) to provide a frictionless API for quickly creating these menus. Common use cases include environment switching, resetting state, and enabling features during development.
This section details the steps needed to add an in-app debug menu using Spices.
Add Spices to your Xcode project or Swift package.
let package = Package(
dependencies: [
.package(url: "git@github.com:shapehq/spices.git", from: "4.0.0")
]
)
Spices uses reflection to generate UI from the properties of a type conforming to the SpiceStore
protocol
Important
Reflection is a technique that should be used with care. We use it in Spices, a tool meant purely for debugging, in order to make it frictionless to add a debug menu.
The following shows an example conformance to the SpiceDispenser protocol. You may copy this into your project to get started.
enum ServiceEnvironment: String, CaseIterable {
case production
case staging
}
class AppSpiceStore: SpiceStore {
@Spice(requiresRestart: true) var environment: ServiceEnvironment = .production
@Spice var enableLogging = false
@Spice var clearCache = {
try await Task.sleep(for: .seconds(1))
URLCache.shared.removeAllCachedResponses()
}
@Spice var featureFlags = FeatureFlagsSpiceStore()
}
class FeatureFlagsSpiceStore: SpiceStore {
@Spice var notifications = false
@Spice var fastRefreshWidgets = false
}
Based on the above code, Spices will generate an in-app debug menu like the one shown below.
The app must be configured to display the spice editor. The approach depends on whether your app is using a SwiftUI or UIKit lifecycle.
Warning
The in-app debug menu may contain sensitive information. Ensure it's only accessible in debug and beta builds by excluding the menu's presentation code from release builds using conditional compilation (e.g., #if DEBUG
). The examples in this section demonstrate this technique.
Use the presentSpiceEditorOnShake(_:)
view modifier to show the editor when the device is shaken.
struct ContentView: View {
@StateObject var spiceStore = AppSpiceStore()
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
#if DEBUG
.presentSpiceEditorOnShake(editing: spiceStore)
#endif
}
}
Alternatively, manually initialize and display an instance of SpiceEditor
.
struct ContentView: View {
@StateObject var spiceStore = AppSpiceStore()
@State var isSpiceEditorPresented = false
var body: some View {
Button {
isSpiceEditorPresented = true
} label: {
Text("Present Spice Editor")
}
.sheet(isPresented: $isSpiceEditorPresented) {
SpiceEditor(editing: spiceStore)
}
}
}
Use the an instance of SpiceEditorWindow
to show the editor when the device is shaken.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
let windowScene = scene as! UIWindowScene
#if DEBUG
window = SpiceEditorWindow(windowScene: windowScene, editing: AppSpiceStore.shared)
#else
window = UIWindow(windowScene: windowScene)
#endif
window?.rootViewController = ViewController()
window?.makeKeyAndVisible()
}
}
Alternatively, initialize an instance of SpiceEditorViewController
and present it.
let viewController = SpiceEditorViewController(editing: AppSpiceStore.shared)
present(spicesViewController, animated: true)
The currently selected value can be referenced through a spice store:
AppSpiceStore.environment
Spice stores conforming to the SpiceStore
protocol also conform to ObservableObject, and as such, can be observed from SwiftUI using StateObject, ObservedObject, or EnvironmentObject.
class AppSpiceStore: SpiceStore {
@Spice var enableLogging = false
}
struct ContentView: View {
@StateObject var spiceStore = AppSpiceStore()
var body: some View {
Text("Is logging enabled: " + (spiceStore.enableLogging ? "👍" : "👎"))
}
}
Properties using the @Spice
property wrapper exposes a publisher that can be used to observe changes to the value using Combine.
class ContentViewController: UIViewController {
private let spiceStore = AppSpiceStore.shared
private var cancellables: Set<AnyCancellable> = []
override func viewDidLoad() {
super.viewDidLoad()
spiceStore.$enableLogging
.sink { isEnabled in
print("Is logging enabled: " + (isEnabled ? "👍" : "👎"))
}
.store(in: &cancellables)
}
}
The example projects in the Examples folder shows how Spices can be used to add an in-app debug menu to iOS apps with SwiftUI and UIKit lifecycles.
The documentation is available on Swift Package Index.
The following sections document select APIs and use cases.
Toggles are created for boolean variables in a spice store.
@Spice var enableLogging = false
Pickers are created for types conforming to both RawRepresentable and CaseIterable. This is typically enums.
enum ServiceEnvironment: String, CaseIterable {
case production
case staging
}
class AppSpiceStore: SpiceStore {
@Spice var environment: ServiceEnvironment = .production
}
Conforming the type to SpicesTitleProvider
lets you override the displayed name for each case.
enum ServiceEnvironment: String, CaseIterable, SpicesTitleProvider {
case production
case staging
var spicesTitle: String {
switch self {
case .production:
"🚀 Production"
case .staging:
"🧪 Staging"
}
}
}
Closures with no arguments are treated as buttons.
@Spice var clearCache = {
URLCache.shared.removeAllCachedResponses()
}
Providing an asynchronous closure causes a loading indicator to be displayed for the duration of the operation.
@Spice var clearCache = {
try await Task.sleep(for: .seconds(1))
URLCache.shared.removeAllCachedResponses()
}
An error message is automatically shown if the closure throws an error.
Text fields are created for string variables in a spice store.
@Spice var url = "http://example.com"
Spice stores can be nested to create a hierarchical user interface.
class AppSpiceStore: SpiceStore {
@Spice var featureFlags = FeatureFlagsSpiceStore()
}
class FeatureFlagsSpiceStore: SpiceStore {
@Spice var notifications = false
@Spice var fastRefreshWidgets = false
}
By default, a nested spice store is presented as a new screen in the navigation stack. This behavior is equivalent to:
@Spice(presentation: .push) var featureFlags = FeatureFlagsSpiceStore()
A nested spice store can also be presented as a modal instead of being pushed onto the navigation stack:
@Spice(presentation: .modal) var featureFlags = FeatureFlagsSpiceStore()
Alternatively, it can be displayed as an inlined section within the settings list:
@Spice(presentation: .inline) var featureFlags = FeatureFlagsSpiceStore()
When inlining a nested spice store, a header and footer can be provided for better context:
@Spice(
presentation: .push,
header: "Features",
footer: "Test features that are yet to be released."
)
var featureFlags = FeatureFlagsSpiceStore()
You can embed your own views into Spices, for example, to display static information.
The @Spice
property wrapper allows you to define custom views within Spices settings. These views can be inlined by default or presented using different styles.
By default, views are inlined within the settings list:
@Spice var version = LabeledContent("Version", value: "1.0 (1)")
You can change the presentation style using the presentation argument.
The .push
presentation pushes the view onto the navigation stack.
@Spice(presentation: .push) var helloWorld = VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
The .modal
presentation presents the view modally on top of Spices.
@Spice(presentation: .modal) var helloWorld = // ...
Setting requiresRestart
to true will cause the app to be shut down after changing the value. Use this only when necessary, as users do not expect a restart.
@Spice(requiresRestart: true) var environment: ServiceEnvironment = .production
By default, the editor displays a formatted version of the property name. You can override this by manually specifying a custom name.
@Spice(name: "Debug Logging") var enableLogging = false
By default the editor will be displayed with the title "Debug Menu". This can be customized as follows.
SwiftUI Lifecycle
The presentSpiceEditorOnShake(editing:title:)
view modifier takes a title as follows.
.presentSpiceEditorOnShake(editing: spiceStore, title: "Config")
The title can also be specified when manually creating and presenting an instance of SpiceEditor
.
SpiceEditor(editing: spiceStore, title: "Config")
UIKit Lifecycle
The SpiceEditorWindow
can be initialized with a title as follows.
SpiceEditorWindow(windowScene: windowScene, editing: AppSpiceStore.shared, title: "Config")
The title can also be specified when manually creating and presenting an instance of SpiceEditorViewController
.
let viewController = SpiceEditorViewController(editing: AppSpiceStore.shared, title: "Config")
By default, values are stored in UserDefaults.standard. To use a different UserDefaults instance, such as for sharing data with an app group, implement the userDefaults
property of SpiceStore
.
class AppSpiceStore: SpiceStore {
let userDefaults = UserDefaults(suiteName: "group.dk.shape.example")
}
Values are stored in UserDefaults using a key derived from the property name, optionally prefixed with the names of nested spice stores. You can override this by specifying a custom key.
@Spice(key: "env") var environment: ServiceEnvironment = .production
Values are stored in UserDefaults and can be used with @AppStorage for seamless integration in SwiftUI.
struct ExampleView: View {
@AppStorage("enableLogging") var enableLogging = false
var body: some View {
Toggle(isOn: $enableLogging) {
Text("Enable Logging")
}
}
}
The name "Spices" evolved from our original repository, "ConfigVars", which we used internally at Shape (becoming Framna) for several years. That early version didn’t use reflection, but when we experimented with a new implementation that did, we jokingly called it "spicing it up." The idea stuck, and we realized developers could also use the package to "spice up" their own projects, adding extra debugging "spices" as needed.
For the first few years, the project was called "Config Vars", but we never really loved that name. It felt too generic. When we decided to open-source the package, we considered reverting to the original name or using other generic alternatives like "configs," "variables," "tweaks," or "configuration variables."
However, these terms are so widely used and have so many different meanings that we worried about causing naming conflicts in developers' codebases. Ultimately, we stuck with "Spices" because it’s unique, memorable, and less likely to clash with existing concepts.