Building a Settings Row with Toggle and Detail
Goal
By the end of this tutorial, you will build a reusable settings row component with a toggle, optional detail text, and clear parent-owned state.
State it explicitly:
“Create one row component that works in multiple settings screens without hiding ownership or adding layout hacks.”
Requirements
- Basic SwiftUI knowledge
- Xcode 15+
- iOS 17+
The use case
Most apps have settings rows that look simple on day one and painful on day ten.
A team starts with one local Toggle, then adds subtitle text, then adds validation rules, then wants analytics, and suddenly every row has slightly different code.
The core mistake is usually not visual. It is ownership. If each row starts owning state for itself, screens lose control and reuse breaks. If every screen reimplements row layout, consistency breaks.
We want one reusable row that stays honest:
- screen owns state
- row renders and forwards edits
- layout adapts cleanly when detail text exists
Step 1 - The simplest working version
Start with one focused component and one state owner.
import SwiftUI
struct NotificationsSettingsScreen: View {
@State private var marketingEnabled = false
var body: some View {
Form {
Section("Notifications") {
SettingsToggleRow(
title: "Marketing updates",
isOn: $marketingEnabled
)
}
}
}
}
struct SettingsToggleRow: View {
let title: String
@Binding var isOn: Bool
var body: some View {
HStack(spacing: 12) {
Text(title)
Spacer()
Toggle("", isOn: $isOn)
.labelsHidden()
}
.padding(.vertical, 6)
}
}
What this already does correctly: state stays in the screen, while the row only receives a binding.
Quick verification checkpoint:
run this in preview, toggle the switch, and confirm the state value changes in NotificationsSettingsScreen.
Step 2 - Make it reusable
Now expand the row API for detail text and optional icon, while keeping ownership unchanged.
struct SettingsToggleRow: View {
let title: String
let detail: String?
let systemImage: String?
@Binding var isOn: Bool
var body: some View {
HStack(alignment: .top, spacing: 12) {
if let systemImage {
Image(systemName: systemImage)
.foregroundStyle(.secondary)
.frame(width: 20)
.padding(.top, detail == nil ? 2 : 4)
}
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.body)
if let detail {
Text(detail)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer(minLength: 12)
Toggle("", isOn: $isOn)
.labelsHidden()
.padding(.top, detail == nil ? 0 : 2)
}
.padding(.vertical, 6)
.contentShape(Rectangle())
}
}
Use it in two rows:
Section("Privacy") {
SettingsToggleRow(
title: "Share analytics",
detail: "Help us improve the app by sending anonymous diagnostics.",
systemImage: "chart.bar",
isOn: $marketingEnabled
)
SettingsToggleRow(
title: "Location reminders",
detail: nil,
systemImage: "location",
isOn: $locationEnabled
)
}
What changed: we increased configuration options without moving any state ownership into the row.
Quick verification checkpoint: add both rows, then compare vertical alignment with and without detail text. The toggle should stay visually balanced.
Step 3 - SwiftUI-specific refinement
Now add behavior polish that improves real-world interaction.
struct SettingsToggleRow: View {
let title: String
let detail: String?
let systemImage: String?
@Binding var isOn: Bool
let onValueChange: ((Bool) -> Void)?
init(
title: String,
detail: String? = nil,
systemImage: String? = nil,
isOn: Binding<Bool>,
onValueChange: ((Bool) -> Void)? = nil
) {
self.title = title
self.detail = detail
self.systemImage = systemImage
self._isOn = isOn
self.onValueChange = onValueChange
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
if let systemImage {
Image(systemName: systemImage)
.foregroundStyle(.secondary)
.frame(width: 20)
.padding(.top, detail == nil ? 2 : 4)
}
VStack(alignment: .leading, spacing: 4) {
Text(title)
if let detail {
Text(detail)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 12)
Toggle("", isOn: $isOn)
.labelsHidden()
.accessibilityLabel(title)
}
.padding(.vertical, 6)
.onChange(of: isOn) { _, newValue in
onValueChange?(newValue)
}
}
}
Refinement point:
onValueChange lets the parent attach analytics or side effects without polluting the row with business logic.
Quick verification checkpoint: pass a closure that prints values and ensure changes fire exactly once per toggle action.
Result
You now have a settings row that is:
- reusable
- ownership-safe
- ready for practical screen complexity
Common SwiftUI pitfalls
-
Mistake: Row declares
@Statefor feature-level values
Why it happens: extraction done quickly
How to fix it: keep ownership in the screen and pass@Binding -
Mistake: Using one giant row type for unrelated settings
Why it happens: premature abstraction
How to fix it: keep one clear row contract and compose wrappers around it -
Mistake: Putting network side effects in the row
Why it happens: row gets direct access to service calls
How to fix it: expose callbacks and let parent decide side effects
When NOT to use this
Do not force this component when the setting has complex child navigation, validation flows, or custom interaction models. In those cases, create a dedicated screen row type for that feature.
Takeaway
If you remember one thing: Reusable settings rows should forward edits, not own feature state.
Next steps
If you want to continue from this pattern into broader architecture and extraction strategy, these are the strongest follow-ups: