A declarative SwiftUI framework for building settings interfaces with navigation, search, and customizable styling.
SettingsKit provides a declarative API for building settings interfaces that feel native to iOS and macOS. Define your settings hierarchy with simple, composable building blocks, and get automatic support for navigation, search, and multiple presentation styles out of the box.
- Declarative API - Build settings hierarchies with intuitive SwiftUI-style syntax
- Built-in Search - Automatic search functionality with intelligent filtering and scoring
- Multiple Styles - Choose from sidebar, grouped, card, or default presentation styles
- Customizable - Extend with custom styles and search implementations
- Platform Adaptive - Works seamlessly on iOS and macOS with appropriate navigation patterns
Add SettingsKit to your project through Xcode:
- File → Add Package Dependencies
- Enter the repository URL
https://github.com/aeastr/SettingsKit.git
- Select the version you want to use
Or add it to your Package.swift:
dependencies: [
.package(url: "https://github.com/aeastr/SettingsKit.git", from: "1.0.0")
]import SwiftUI
import SettingsKit
@Observable
class AppSettings {
var notificationsEnabled = true
var darkMode = false
var username = "Guest"
var fontSize: Double = 14.0
var soundEnabled = true
var autoLockDelay: Double = 300
var hardwareAcceleration = true
// ... 20+ more settings
}
struct MySettings: SettingsContainer {
@Environment(AppSettings.self) var appSettings
var settingsBody: some SettingsContent {
@Bindable var settings = appSettings
SettingsGroup("General", systemImage: "gear") {
SettingsItem("Notifications") {
Toggle("Enable", isOn: $settings.notificationsEnabled)
}
SettingsItem("Dark Mode") {
Toggle("Enable", isOn: $settings.darkMode)
}
}
SettingsGroup("Appearance", systemImage: "paintbrush") {
SettingsItem("Font Size") {
Slider(value: $settings.fontSize, in: 10...24, step: 1) {
Text("Size: \(Int(settings.fontSize))pt")
}
}
}
SettingsGroup("Privacy & Security", systemImage: "lock.shield") {
SettingsItem("Auto Lock Delay") {
Slider(value: $settings.autoLockDelay, in: 60...3600, step: 60) {
Text("Delay: \(Int(settings.autoLockDelay/60)) minutes")
}
}
}
// ... more groups
}
}A SettingsContainer is the root of your settings hierarchy:
struct AppSettings: SettingsContainer {
var settingsBody: some SettingsContent {
// Your settings groups here
}
}Groups organize related settings and can be presented as navigation links or inline sections:
// Navigation group (default) - appears as a tappable row
SettingsGroup("Display", systemImage: "sun.max") {
// Settings items...
}
// Inline group - appears as a section header
SettingsGroup("Quick Settings", .inline) {
// Settings items...
}For completely custom UI that doesn't fit the standard settings structure, use CustomSettingsGroup:
CustomSettingsGroup("Advanced Tools", systemImage: "hammer") {
VStack(spacing: 20) {
Text("Your Custom UI")
.font(.largeTitle)
// Any SwiftUI view you want
Button("Custom Action") {
performAction()
}
}
.padding()
}Custom groups are indexed and searchable (by title, icon, and tags), but their content is rendered as-is without indexing individual elements. This is perfect for:
- Complex custom interfaces that don't use standard settings controls
- Charts, graphs, or data visualizations
- Custom layouts that need full control over presentation
- Third-party UI components
Items are the individual settings within a group:
SettingsItem("Volume") {
Slider(value: $volume, in: 0...100)
}
SettingsItem("Auto-Lock", icon: "lock") {
Picker("", selection: $autoLockTime) {
Text("30 seconds").tag(30)
Text("1 minute").tag(60)
}
}Groups can contain other groups for deep hierarchies:
SettingsGroup("General", systemImage: "gear") {
SettingsGroup("About", systemImage: "info.circle") {
SettingsItem("Version") {
Text("1.0.0")
}
}
SettingsGroup("Language", systemImage: "globe") {
SettingsItem("Preferred Language") {
Text("English")
}
}
}Perfect for apps with split-view navigation (default on all platforms):
MySettings(settings: settings)
.settingsStyle(.sidebar)Clean, single-column list presentation:
MySettings(settings: settings)
.settingsStyle(.single)Create your own presentation styles by conforming to SettingsStyle:
struct MyCustomStyle: SettingsStyle {
func makeContainer(configuration: ContainerConfiguration) -> some View {
NavigationStack(path: configuration.navigationPath) {
ScrollView {
VStack(spacing: 20) {
configuration.content
}
.padding()
}
.navigationTitle(configuration.title)
}
}
func makeGroup(configuration: GroupConfiguration) -> some View {
VStack(alignment: .leading) {
configuration.label
.font(.headline)
configuration.content
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(12)
}
func makeItem(configuration: ItemConfiguration) -> some View {
HStack {
configuration.label
Spacer()
configuration.content
}
}
}
// Apply your custom style
MySettings(settings: settings)
.settingsStyle(MyCustomStyle())Search is automatic and intelligent, filtering by titles and tags:
Improve search discoverability with tags:
SettingsGroup("Notifications", systemImage: "bell")
.settingsTags(["alerts", "sounds", "badges"])Implement your own search logic:
struct FuzzySearch: SettingsSearch {
func search(nodes: [SettingsNode], query: String) -> [SettingsSearchResult] {
// Your custom search implementation
}
}
MySettings(settings: settings)
.settingsSearch(FuzzySearch())Extract complex groups into separate structures:
struct DeveloperSettings: SettingsContent {
@Bindable var settings: AppSettings
var body: some SettingsContent {
SettingsGroup("Developer", systemImage: "hammer") {
SettingsItem("Debug Mode") {
Toggle("Enable", isOn: $settings.debugMode)
}
if settings.debugMode {
SettingsItem("Verbose Logging") {
Toggle("Enable", isOn: $settings.verboseLogging)
}
}
}
}
}
// Use it in your main settings
var settingsBody: some SettingsContent {
// Other groups...
DeveloperSettings(settings: settings)
}Show or hide settings based on state:
SettingsGroup("Advanced", systemImage: "gearshape.2") {
SettingsItem("Enable Advanced Features") {
Toggle("Enable", isOn: $showAdvanced)
}
if showAdvanced {
SettingsItem("Advanced Option 1") { /* ... */ }
SettingsItem("Advanced Option 2") { /* ... */ }
}
}Mark items as non-searchable when they're not useful in search results:
SettingsItem("Current Status", searchable: false) {
Text("Connected")
.foregroundStyle(.secondary)
}Add tags to custom groups to improve searchability:
CustomSettingsGroup("Developer Tools", systemImage: "hammer")
.settingsTags(["debug", "testing", "advanced"])
{
YourCustomDeveloperUI()
}The group itself (title, icon, tags) will appear in search results, and tapping it navigates to your custom view.
SettingsKit uses a hybrid architecture that combines metadata-only nodes for indexing and search with a view registry system for dynamic rendering. This design enables powerful search capabilities while maintaining live, reactive SwiftUI views with proper state observation.
SettingsKit separates concerns between what settings exist (metadata) and how they render (views):
- Metadata Layer (Nodes) - Lightweight tree structure for indexing and search
- View Layer (Registry) - Dynamic view builders registered by ID
- Rendering Layer - Direct SwiftUI view hierarchy with proper state observation
This separation solves a critical challenge: making settings fully searchable while keeping interactive controls responsive and reactive.
When you define settings using SettingsGroup and SettingsItem, SettingsKit builds an internal node tree that represents your entire settings hierarchy:
- Declarative Definition - You write settings using SwiftUI-style syntax
- Node Tree Building - Each element converts to a
SettingsNodecontaining only metadata - View Registration - Each item registers its view builder in the global registry
- Lazy Indexing - The tree is built on-demand during rendering or searching
- Search & Navigation - The indexed tree powers both features
Every setting becomes a node in an indexed tree. Crucially, nodes store only metadata—no views or content:
SettingsNode Tree:
├─ Group: "General" (navigation)
│ ├─ Item: "Notifications" → ID: abc123
│ └─ Item: "Dark Mode" → ID: def456
├─ Group: "Appearance" (navigation)
│ └─ Item: "Font Size" → ID: ghi789
├─ CustomGroup: "Developer Tools" (navigation) → ID: xyz789
│ └─ (no children - custom content not indexed)
└─ Group: "Privacy & Security" (navigation)
└─ Item: "Auto Lock Delay" → ID: jkl012
Each node stores:
- UUID - Stable identifier (hash-based, not random) for navigation and registry lookup
- Title & Icon - Display information for search results
- Tags - Additional keywords for search discoverability
- Presentation Mode - Navigation link or inline section (for groups)
- Children - Nested groups and items (for groups; empty for custom groups)
⚠️ No Content - Views are NOT stored in nodes
The SettingsNodeViewRegistry is a global singleton that maps node IDs to view builder closures:
// When SettingsItem.makeNodes() is called:
SettingsNodeViewRegistry.shared.register(id: itemID) {
AnyView(Toggle("Enable", isOn: $settings.notificationsEnabled))
}
// When CustomSettingsGroup.makeNodes() is called:
SettingsNodeViewRegistry.shared.register(id: customGroupID) {
AnyView(YourCompletelyCustomView())
}
// Later, in search results:
if let view = SettingsNodeViewRegistry.shared.view(for: itemID) {
view // Renders the actual Toggle with live state binding
}This registry allows search results to render actual interactive controls (Toggle, Slider, TextField, etc.) rather than static text labels. For custom groups, the entire custom view is registered and navigated to when selected.
The default search implementation uses intelligent scoring:
- Normalization - Removes spaces, special characters, converts to lowercase
- Tree Traversal - Recursively searches all nodes by title and tags
- Scoring - Ranks matches by relevance:
- Exact match: 1000 points
- Starts with: 500 points
- Contains: 300 points
- Tag match: 100 points
- Result Grouping - Groups matched items by their parent group
- View Lookup - Retrieves actual view builders from registry for matched items
When you search for "notif", it finds "Notifications" and renders the actual Toggle control with live state binding—not just a text label.
SettingsKit uses two different rendering approaches depending on context:
Normal Rendering (Direct Hierarchy):
- Views render directly from the SwiftUI view hierarchy
- Full state observation through SwiftUI's dependency tracking
- Controls update reactively as state changes
- No registry lookup needed
Search Results Rendering (Registry Lookup):
- Matched items retrieve their view builders from the registry
- Views are instantiated fresh for each search
- State bindings remain live and reactive
- Allows showing actual controls in search results
This dual approach ensures optimal performance: normal navigation uses direct view hierarchies (fast), while search results use dynamic registry lookups (flexible).
SettingsKit provides two navigation styles that work with the same indexed tree:
Sidebar Style (NavigationSplitView):
- Split-view layout with sidebar and detail pane
- Top-level groups appear in the sidebar
- Uses destination-based NavigationLink on macOS for proper control updates
- Detail pane has its own NavigationStack for nested groups
- On iOS: uses selection-based navigation (no control update issues)
Single Column Style (NavigationStack):
- Push navigation for all groups
- Linear navigation hierarchy
- Inline groups render as section headers
- Search results push onto the navigation stack
The node tree's awareness of navigation vs. inline presentation ensures groups render correctly in both styles.
Node UUIDs are generated using hash-based stable IDs rather than random UUIDs:
var hasher = Hasher()
hasher.combine(title)
hasher.combine(icon)
let hashValue = hasher.finalize()
// Convert hash to UUID bytes...This ensures the same setting always gets the same ID across multiple makeNodes() calls, which is critical for:
- Matching search results to actual views in the registry
- Maintaining navigation state
- View identity and animation stability
This hybrid architecture solves multiple challenges simultaneously:
- ✅ Reactive Controls - Direct view hierarchy preserves SwiftUI state observation
- ✅ Powerful Search - Metadata nodes enable fast, comprehensive search
- ✅ Interactive Search Results - Registry allows rendering actual controls in search
- ✅ Performance - Lazy indexing builds the tree only when needed
- ✅ Dynamic Content - Supports conditional settings (if/else, ForEach)
- ✅ Platform Adaptive - Navigation adapts to macOS vs iOS patterns
- ✅ Extensibility - Custom search and styles work with the same tree
- ✅ Type Safety - SwiftUI result builders validate at compile time
The hybrid view registry architecture wasn't the original design—it emerged from solving a critical macOS bug. Here's how we got here:
Early versions stored view content directly in nodes using AnyView type erasure. This worked fine initially, but revealed a critical macOS-only bug: when using NavigationSplitView with selection-based navigation, interactive controls (Toggle, Slider, TextField, etc.) in the detail pane stopped updating visually. State changed correctly, but the UI appeared frozen.
We tried multiple approaches to fix the control update issue:
-
Force re-rendering with
.id()modifier ❌- Adding unique IDs to force SwiftUI to rebuild views
- Didn't work because the problem was deeper in the view hierarchy
-
Repositioning
navigationDestination❌- Moving the navigation destination modifier to different locations
- No effect on control updates
-
Extracting separate
DetailContentView❌- Thought reducing view nesting might help
- Same issue persisted
-
Rendering from nodes instead of cached content ❌
- Attempted to rebuild views from node metadata on each render
- Still had AnyView type erasure breaking state observation
-
Platform-specific navigation ✅ (Partial)
- macOS: Destination-based
NavigationLink(creates fresh view hierarchies) - iOS: Selection-based
NavigationLink(no issues observed) - Fixed control updates but created a new problem: views rebuilt on every state change, causing TextField to lose focus on each keystroke
- macOS: Destination-based
The core issue was AnyView type erasure combined with macOS NavigationSplitView's aggressive caching. When content was wrapped in AnyView and passed through the node system, SwiftUI's dependency tracking broke down. macOS's NavigationSplitView appeared to cache detail content more aggressively than iOS, making the problem platform-specific.
The key insight came from asking: "Can we have a hybrid system where we have nodes that we can grab their view for that particular node, and use it in search?"
This led to the current architecture:
- Nodes become metadata-only - No
AnyViewcontent stored in nodes - View registry maps IDs to builders - Global singleton stores
UUID → () -> AnyView - Normal rendering uses direct hierarchy - No type erasure, full state observation
- Search results use registry lookup - Can render actual controls dynamically
This architecture solves the problem because:
- No AnyView in normal paths - Direct view hierarchy preserves SwiftUI's state dependency tracking
- Platform-specific navigation is isolated - macOS workaround doesn't affect iOS
- Search gets actual views - Registry lookup provides real controls, not just metadata
- Stable IDs enable matching - Hash-based UUIDs ensure registry lookups succeed
- View identity is stable - No more TextField losing focus from unnecessary rebuilds
The hybrid approach gives us the best of both worlds: searchable metadata trees + reactive SwiftUI views.
- Uses
NavigationStackfor push navigation in single-column style - Uses
NavigationSplitViewwith selection-based navigation in sidebar style - Supports search with
.searchable() - Inline groups render as section headers
- Uses
NavigationSplitViewfor sidebar navigation in sidebar style - Destination-based navigation links for proper control state updates
- Detail pane has its own
NavigationStackfor deeper navigation - Search results show actual interactive controls via view registry
- iOS 17.0+ / macOS 14.0+ / watchOS 10.0+ / tvOS 17.0+ / visionOS 1.0+
- Swift 6.0+
- Xcode 16.0+
SettingsKit is available under the MIT license. See the LICENSE file for more info.
Contributions are welcome! Please feel free to submit a Pull Request.