
gittech. site
for different kinds of informations and explorations.
Spices β in-app debug menus in SwiftUI
π«πΆ Spices
π Introduction
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.

π Getting Started
This section details the steps needed to add an in-app debug menu using Spices.
Step 1: Add the Spices Swift Package
Add Spices to your Xcode project or Swift package.
let package = Package(
dependencies: [
.package(url: "[email protected]:shapehq/spices.git", from: "4.0.0")
]
)
Step 2: Create an In-App Debug Menu
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.

Step 3: Present the In-App Debug Menu
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.
SwiftUI Lifecycle
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)
}
}
}
UIKit Lifecycle
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)
Step 4: Observing Values
The currently selected value can be referenced through a spice store:
AppSpiceStore.environment
SwiftUI Lifecycle
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 ? "π" : "π"))
}
}
UIKit Lifecycle
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)
}
}
π§ͺ Example Projects
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.
π Documentation
The documentation is available on Swift Package Index.
The following sections document select APIs and use cases.
Toggles
Toggles are created for boolean variables in a spice store.
@Spice var enableLogging = false
Pickers
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"
}
}
}
Buttons
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
Text fields are created for string variables in a spice store.
@Spice var url = "http://example.com"
Hierarchical Navigation
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
}
Require Restart
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
Display Custom Name
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
Specify Editor Title
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")
Store Values in Custom UserDefaults
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")
}
Store Values Under Custom Key
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
Using with @AppStorage
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")
}
}
}