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:

  1. 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!