Building a Family Score App with SwiftUI
In this tutorial, we'll walk through the process of creating a Family Score App using SwiftUI. This app allows users to keep track of scores for different family members, with features like adding new members, adjusting scores, and persisting data.
Step 1: Setting up the Project
First, create a new SwiftUI project in Xcode. Name it "FamilyScore".
Step 2: Creating the Data Model
Let's start by defining our data model. Create a new Swift file called Score.swift:
//
// Score.swift
// FamilyScore
//
// Created by william on 2024/10/23.
//
import Foundation
struct Score: Codable, Identifiable, Hashable {
var id = UUID()
var familyMemberName = "Family Name"
var score = 100
var colour = ColourHelper.blue
static let example = Score()
}
This struct represents a family member's score, with properties for id, name, score, and color.
Step 3: Implementing the Color Helper
To manage colors for each family member, create a ColourHelper.swift file:
//
// ColourHelper.swift
// FamilyScore
//
// Created by william on 2024/10/23.
//
import Foundation
import SwiftUI
enum ColourHelper: String, Codable, CaseIterable {
case red
case green
case blue
case yellow
case purple
case orange
var color: Color {
switch self {
case .red:
return .red
case .green:
return .green
case .blue:
return .blue
case .yellow:
return .yellow
case .purple:
return .purple
case .orange:
return .orange
}
}
}
This enum provides a set of colors and a convenient way to convert them to SwiftUI Color objects.
Step 4: Building the ViewModel
Create a ViewModel.swift file to manage the app's data and business logic:
//
// ViewModel.swift
// FamilyScore
//
// Created by william on 2024/10/23.
//
import Foundation
import Combine
class ViewModel: ObservableObject {
@Published var items: [Score]
private let savePath = FileManager.documentsDirectory.appendingPathComponent("SavedItems")
private var saveSubcription: AnyCancellable?
init() {
do {
let data = try Data(contentsOf: savePath)
items = try JSONDecoder().decode([Score].self, from: data)
} catch {
items = []
}
saveSubcription = $items
.debounce(for: 3, scheduler: RunLoop.main)
.sink { [weak self] _ in
self?.save()
}
}
func save() {
print("Data Saved!")
do {
let data = try JSONEncoder().encode(items)
try data.write(to: savePath, options: [.atomic, .completeFileProtection])
} catch {
print("Error Occoured while Saving Data!")
}
}
func add() {
var newItem = Score()
if let lastColour = items.last?.colour,
let currentIndex = ColourHelper.allCases.firstIndex(of: lastColour),
currentIndex + 1 < ColourHelper.allCases.count {
newItem.colour = ColourHelper.allCases[currentIndex + 1]
} else {
newItem.colour = ColourHelper.allCases[0]
}
items.append(newItem)
}
func delete(_ offsets: IndexSet) {
items.remove(atOffsets: offsets)
}
func deleteAll() {
items.removeAll()
}
func resetTheScore() {
for i in 0..<items.count {
items[i].score = 100
}
}
}
The ViewModel handles data persistence, adding and removing family members, and resetting scores.
Step 5: Creating the ScoreRow View
Now, let's create the UI for individual score rows. Create a ScoreRow.swift file:
//
// ScoreRow.swift
// FamilyScore
//
// Created by william on 2024/10/23.
//
import SwiftUI
struct ScoreRow: View {
@Binding var item: Score
var body: some View {
HStack(spacing: 5) {
Text(String(item.score))
.font(.title)
TextField("Family Member:", text: $item.familyMemberName)
.font(.title)
.frame(maxWidth: .infinity, alignment: .leading)
Button {
item.score -= 1
} label: {
Image(systemName: "minus.diamond")
.font(.title)
.frame(minWidth: 44, minHeight: 44)
.contentShape(Rectangle())
}
Button {
item.score += 1
} label: {
Image(systemName: "plus.diamond")
.font(.title)
.frame(minWidth: 44, minHeight: 44)
.contentShape(Rectangle())
}
}
.padding(15)
.animation(nil, value: item)
.background(Color(item.colour.color))
}
}
#Preview {
ScoreRow(item: .constant(.example))
}
This view displays a family member's name, score, and buttons to adjust the score.
Step 6: Implementing the Main Content View
Create the main ContentView.swift file:
//
// ContentView.swift
// FamilyScore
//
// Created by william on 2024/10/23.
//
import SwiftUI
struct ContentView: View {
@ObservedObject var model: ViewModel
@State private var isShowingAlert = false
var body: some View {
List {
ForEach($model.items, content: ScoreRow.init)
.onDelete(perform: model.delete)
Button(action: model.add) {
Label("Add Family Member", systemImage: "person.badge.plus")
.font(.title)
.frame(maxWidth: .infinity, minHeight: 44)
.contentShape(Rectangle())
}
.disabled(model.items.count == ColourHelper.allCases.count)
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(action: model.resetTheScore) {
Label("Reset the Score to 100", systemImage: "flag.pattern.checkered.circle")
}
.disabled(model.items.isEmpty)
}
ToolbarItem(placement: .topBarTrailing) {
Button {
isShowingAlert = true
} label: {
Label("Clear All", systemImage: "trash.slash.circle")
}
.disabled(model.items.isEmpty)
}
}
.alert("Are you sure???", isPresented: $isShowingAlert) {
Button("I am Sure!", role: .destructive, action: model.deleteAll)
Button("Let me think...", role: .cancel) { }
} message: {
Text("Are you sure to delete everything? This can't be undone!!!")
}
.animation(.default, value: model.items)
.navigationTitle("Family Score App")
.listStyle(.plain)
.buttonStyle(.plain)
}
}
#Preview {
ContentView(model: ViewModel())
}
This view displays the list of family members, provides buttons for adding new members, resetting scores, and clearing all data.
Step 7: Setting up the App Entry Point
Update the FamilyScoreApp.swift file:
//
// FamilyScoreApp.swift
// FamilyScore
//
// Created by william on 2024/10/23.
//
import SwiftUI
@main
struct FamilyScoreApp: App {
@StateObject var model = ViewModel()
@Environment(\.scenePhase) var scenePhase
var body: some Scene {
WindowGroup {
NavigationView {
ContentView(model: model)
}
.navigationViewStyle(.stack)
}
.onChange(of: scenePhase) { oldPhase, newPhase in
if newPhase == .background {
model.save()
}
}
}
}
This sets up the app's main structure and ensures data is saved when the app moves to the background.
Step 8: Implementing File Management
To handle file operations, create a FileManager-DocumentsDirectory.swift file:
//
// FileManager-DocumentsDirectory.swift
// FamilyScore
//
// Created by william on 2024/10/23.
//
import Foundation
extension FileManager {
static var documentsDirectory: URL {
let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
return paths[0]
}
}
This extension provides a convenient way to access the app's documents directory.
Step 9: Testing and Refinement
Run the app in the simulator or on a device. Test all functionalities:
Adding new family members
Adjusting scores
Resetting scores
Deleting family members
Clearing all data
Make any necessary adjustments to improve the user experience.
Conclusion
Congratulations! You've built a functional Family Score App using SwiftUI. This app demonstrates key concepts such as:
- SwiftUI views and navigation
State management with ObservableObject
Data persistence using Codable and FileManager
Custom UI components
Basic animations
Feel free to expand on this project by adding features like score history, achievements, or even a multiplayer mode!