David asks:
I was wondering if the cell row can move up dynamically as the click count increases.
Yes!
Think of your application's architecture. You probably have:
- A family member model. (name, age, clickCount)
- A family view (Show a list of everyone in a family. Options for sorting.)
- A member view (One ROW of your list. Just show details of ONE family member.)
If you've watched the videos on Model-View-ViewModel, you'll see what's missing is the ViewModel.
You'll need another file to hold your application's business rules. I think some developers call these The Intentions.
The Intentions
Think of what you want your family view to do. Write them down. Tinker. Doodle. Scratch out, review, and revise. These are your family view's intentions. Now get serious. You do NOT want to embed these rules into your view. Think of your view as a display case in a bakery. You just want to show the finished products, all tasty and lickable. You do not want the case full of flour, egg shells, spoons, flavour extracts, etc.
View Model
Your application's business rules and intentions should be in your ViewModel. What did you decide your family view should do? WIth just the code you provided, I came up with this short list.
- Load a family with family members.
- Sort the family members by age, name, or click value.
- Publish a list of family members for display.
- Notify views when the list of family members change, so that they may redraw themselves.
But wait! What about clicking on a family member? Well that deserves a view model for the Member view! Tapping a member should update that member's click value. Consider creating a view model for each view you have in your application. Decide on all the view's intentions and write them in the view model.
Member Model
This is a dead simple model for one member of a family. Because the member view only has one intention, I included it in this file.
//
// Dead Simple Member Model
// Created by Obelix on 2022.02.14
//
import SwiftUI
class Member : Identifiable, ObservableObject {
let id = UUID()
let name : String
let age : Int
@Published var clickCount = 0 // broadcast changes
var memberColor: Color {
switch name {
case "David":
return .red
case "Anna":
return .green
case "Nicholas":
return .blue
case "Alexandra":
return .purple
case "Obelix":
return .indigo
case "Taylor":
return .cyan
default:
return .black
}
}
// Not all struct's vars have defaults.
init( name: String, age: Int, clickCount: Int = 0) {
self.name = name
self.age = age
self.clickCount = clickCount
}
// Intentions (Should be in ViewModel) -----------------
func updateClick() { clickCount += 1 }
// For UI Testing.
static let example = Member(name: "Taylor", age: 22)
}
Member View
This is a view to only show ONE member. Notice the member view has one purpose. It shows one member. It doesn't know anything about sorting, or the family it is in.
// MemberView
// Created by Obelix on 2022.02.14
//
import SwiftUI
// Design this view to display ONE family member. That's it!
// This view should NOT know about a family or other members.
struct MemberView: View {
@ObservedObject var selectedMember: Member
let emphasize : FamilyViewModel.SortOptions
var body: some View {
HStack {
Text(selectedMember.name)
.font(emphasize == .name ? .largeTitle : .body)
Text("(\(selectedMember.age))")
.font(emphasize == .age ? .largeTitle : .body)
Spacer()
Text("Klix: \(selectedMember.clickCount)")
.font(emphasize == .clicks ? .largeTitle : .body)
}
.foregroundColor(selectedMember.memberColor)
}
}
// use the static Member for UI testing.
// Look at this in the preview pane! Cool!
struct MemberView_Previews: PreviewProvider {
static var previews: some View {
MemberView(selectedMember: Member.example, emphasize: .clicks)
.previewLayout(.sizeThatFits)
MemberView(selectedMember: Member.example, emphasize: .name)
.previewLayout(.sizeThatFits)
MemberView(selectedMember: Member.example, emphasize: .age)
.previewLayout(.sizeThatFits)
}
}
Family ViewModel
Remember the intentions you though about? They are coded here. One array of family members serves as the source of truth. It is private. No other view can see it or modify it. If it needs to be modified, you've just identified another intention! The other array of family members is the one published to views in your application.
// Created by Obelix on 2022.02.14.
// Love coding on Valentine's Day!
//
import SwiftUI
// Link between a Family model and the View showing members in a Family
// Your view wants to show members in a family.
// This is the Model for that view. It holds an array of family members.
class FamilyViewModel : ObservableObject {
@Published var viewFamily = [Member]() // external. push to views
private var familyMembers = [Member]() // internal. source of truth
init() { loadFamily() } // classes need initializers
// Assign strings to your options to use in the Picker view. Awesome!
enum SortOptions: String, Equatable, CaseIterable {
case name = "Name"
case clicks = "Clicks"
case age = "Age"
var localizedName: LocalizedStringKey { LocalizedStringKey(rawValue)}
}
// Intentions ----------------------------
// These are your business rules!
// Keep these OUT of the views.
func sortFamily(by sortOption: FamilyViewModel.SortOptions) {
switch sortOption {
case .clicks: familyMembers.sort { $0.clickCount > $1.clickCount }
case .age: familyMembers.sort { $0.age < $1.age }
default: familyMembers.sort { $0.name < $1.name }
}
viewFamily = familyMembers // replace it.
}
// Populate the View's model for testing.
func loadFamily() {
familyMembers.append( Member(name: "David", age: 52, clickCount: 12 ))
familyMembers.append( Member(name: "Anna", age: 50, clickCount: 10 ))
familyMembers.append( Member(name: "Nicholas", age: 20, clickCount: 5 ))
familyMembers.append( Member(name: "Alexandra", age: 18 ))
familyMembers.append( Member(name: "Obelix", age: 15 ))
sortFamily(by: .name) // default starting sort order
}
}
Family View
This is the display case. Keep this free of business rules. Just display. Pass the business to the view model to process.
// Created by Obelix on 2022.02.14.
//
import SwiftUI
struct FamilyView: View {
@StateObject var vm = FamilyViewModel() // wire up a new view model
@State private var sortSelection = FamilyViewModel.SortOptions.name // default
var body: some View {
NavigationView {
VStack { // Decide how the list is sorted ------------
Picker("", selection: $sortSelection) {
ForEach( FamilyViewModel.SortOptions.allCases, id: \.self ) { option in
Text(option.localizedName).tag(option)
}
}
.onChange(of: sortSelection, perform: { sortOption in
// What do you intend to happen then the selection changes?
// Tell the view model what your intention is!
vm.sortFamily(by: sortOption)
})
.pickerStyle(SegmentedPickerStyle())
.padding(.horizontal)
// -------------------- List ------------------
List (vm.viewFamily) { member in
MemberView(selectedMember: member, emphasize: sortSelection)
.onTapGesture {
// What do you intend to happen when the user taps a family member?
// Tell the objects your intentions!
member.updateClick() // let model handle the details
vm.sortFamily(by: sortSelection)
}
}
}.navigationTitle("Family List")
}.navigationViewStyle(.stack)
}
}
struct FamilyView_Previews: PreviewProvider { static var previews: some View { FamilyView() }
Whew! Happy Valentine's Day.
Love and Kisses,
Obelix