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 and Row

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 when count 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 vertically
  • spacing: 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 with fontSize and fontWeight

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 size
  • Text("\(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():

  1. The count property changes
  2. @Observable detects the change
  3. Shaft automatically rebuilds affected widgets
  4. UI updates to show new count

No manual state management needed!

Key Takeaways

  1. @Observable makes state reactive - Changes automatically update UI
  2. Methods encapsulate logic - increment() is better than count += 1 everywhere
  3. Widgets compose naturally - Nest Column, Row, Text, Button to build UI
  4. No explicit setState - Shaft handles UI updates automatically

Next Steps