Tutorial: Counter App
Learn how to build an interactive counter app with Shaft step by step.
Interactive Demo
Try the complete counter app below:
Counter App
What You’ll Learn
- Using
@Observable
for reactive state management - Handling button clicks
- Styling widgets with
.textStyle()
and.decoration()
- Creating layouts with
Column
andRow
Step-by-Step Guide
Step 1: Create the State Model
Use Swift’s @Observable
to make your data reactive:
import Observation
@Observable class Counter {
var count = 0
func increment() {
count += 1
}
func decrement() {
count -= 1
}
func reset() {
count = 0
}
}
let counter = Counter()
Why this works:
@Observable
automatically notifies the UI whencount
changes- Methods encapsulate logic for modifying state
- Global instance can be accessed from any widget
Step 2: Build the UI Structure
Create a centered column layout:
final class CounterApp: StatelessWidget {
func build(context: any BuildContext) -> any Widget {
Column(mainAxisAlignment: .center, spacing: 24) {
// Widgets go here
}
.padding(.all(40))
}
}
Parameters:
mainAxisAlignment: .center
- Centers children verticallyspacing: 24
- Adds 24px between children.padding(.all(40))
- Adds space around the column
Step 3: Add the Title
Display the app name with styling:
Text("Counter App")
.textStyle(.init(fontSize: 28, fontWeight: .bold))
Styling:
.textStyle()
applies text formatting- Create a
TextStyle
withfontSize
andfontWeight
Step 4: Display the Count
Create a circular display with the current count:
SizedBox(width: 120, height: 120) {
Text("(counter.count)")
.textStyle(.init(
color: Color(0xFF_FFFFFF), // White text
fontSize: 48,
fontWeight: .bold
))
.center()
}
.decoration(.box(
color: Color(0xFF_FFA500), // Orange background
borderRadius: .circular(60) // Makes it circular
))
Key concepts:
SizedBox
fixes the widget sizeText("\(counter.count)")
displays the current value.decoration(.box(...))
adds background and rounded corners.center()
centers the text inside
Step 5: Add Control Buttons
Create three buttons in a row:
Row(mainAxisAlignment: .center, spacing: 12) {
// Decrement
Button {
counter.decrement()
} child: {
Text("−")
.textStyle(.init(fontSize: 32))
}
.padding(.all(16))
// Reset
Button {
counter.reset()
} child: {
Text("↻")
.textStyle(.init(fontSize: 24))
}
.padding(.all(16))
// Increment
Button {
counter.increment()
} child: {
Text("+")
.textStyle(.init(fontSize: 32))
}
.padding(.all(16))
}
Button structure:
- First closure: action when clicked
child:
closure: button content.padding()
adds space inside the button
Step 6: Run the App
runApp(CounterApp())
That’s it! Your counter app is complete.
Understanding Reactivity
When you call counter.increment()
:
- The
count
property changes @Observable
detects the change- Shaft automatically rebuilds affected widgets
- UI updates to show new count
No manual state management needed!
Key Takeaways
- @Observable makes state reactive - Changes automatically update UI
- Methods encapsulate logic -
increment()
is better thancount += 1
everywhere - Widgets compose naturally - Nest
Column
,Row
,Text
,Button
to build UI - No explicit setState - Shaft handles UI updates automatically
Next Steps
- Build a Todo List to learn about managing collections
- Explore Widget Styling to customize appearance
- Learn about State Management patterns
- Check out the Layout System for complex layouts