Home Screen
Favorite habits와 followed users 두 가지 정보를 중심으로 화면을 구성하면 된다.
Define Home Screen Functionality
Habits Leaderboard
다른 사용자들과 비교해 favorite habits에 대한 현 상황 정보
Home 화면의 첫 번째 섹션은 각 favorite habit에 대한 상위 사용자들을 보여주는 leaderboard이다.
Followed Users
Set Up the Home View Controller Scene
Leaderboard Cell
새 커스텀 셀 클래스 생성, "LeaderboardHabitCollectionViewCell"
class LeaderboardHabitCollectionViewCell: UICollectionViewCell {
@IBOutlet weak var habitNameLabel: UILabel!
@IBOutlet weak var leaderLabel: UILabel!
@IBOutlet weak var secondaryLabel: UILabel!
}
- 3개의 레이블 포함
- Habit Name
- Leader
- Seconday
- Reuse identifier -> "LeaderboardHabit"
- Custom class -> "LeaderboardHabitCollectionViewCell"
Followed User Cell
새 커스텀 셀 클래스 생성, "FollowedUserCollectionViewCell"
class FollowedUserCollectionViewCell: UICollectionViewCell {
@IBOutlet weak var primaryTextLabel: UILabel!
@IBOutlet weak var secondaryTextLabel: UILabel!
}
- 레이블 2개 포함
- User Name
- Message
- Reuse identifier -> "FollowedUser"
- Custom class -> "FollowedUserCollectionViewCell"
Create a Model and View Model
HomeCollectionViewController
typealias DataSourceType = UICollectionViewDiffableDataSource<ViewModel.Section, ViewModel.Item>
enum ViewModel {
enum Section: Hashable {
case leaderboard
case followedUsers
}
enum Item: Hashable {
case leaderboardHabit(name: String, leadingUserRanking: String?, secondaryUserRanking: String?)
case followedUser(_ user: User, message: String)
func hash(into hasher: inout Hasher) {
switch self {
case .leaderboardHabit(let name, _, _):
hasher.combine(name)
case .followedUser(let User, _):
hasher.combine(User)
}
}
static func == (_ lhs: Item, _ rhs: Item) -> Bool {
switch (lhs, rhs) {
case (.leaderboardHabit(let lName, _, _), .leaderboardHabit(let rName, _, _)):
return lName == rName
case (.followedUser(let lUser, _), .followedUser(let rUser, _)):
return lUser == rUser
default:
return false
}
}
}
}
struct Model {
var userByID = [String: User]()
var habitsByName = [String: Habit]()
var habitStatistics = [HabitStatistics]()
var userStatistics = [UserStatistics]()
var currentUser: User {
return Settings.shared.currentUser
}
var users: [User] {
return Array(userByID.values)
}
var habits: [Habit] {
return Array(habitsByName.values)
}
var followedUsers: [User] {
return Array(userByID.filter{ Settings.shared.followedUserIDs.contains($0.key) }.values)
}
var favoriteHabits: [Habit] {
return Settings.shared.favoriteHabits
}
var nonFavoriteHabits: [Habit] {
return habits.filter{ !favoriteHabits.contains($0) }
}
}
var dataSource: DataSourceType!
var model = Model()
Add Network Code
CombinedStatistics.swift
struct CombinedStatistics {
let userStatistics: [UserStatistics]
let habitStatistics: [HabitStatistics]
}
extension CombinedStatistics: Codable {}
APIService
struct CombinedStatisticsRequest: APIRequest {
typealias Response = CombinedStatistics
var path: String { "/combinedStats" }
}
HomeCollectionViewController
User와 habit에 대한 데이터는 변하지 않기 때문에 여러 번 사용할 필요 없기 때문에 viewDidLoad()에서 호출하면 된다.
override func viewDidLoad() {
super.viewDidLoad()
// Uncomment the following line to preserve selection between presentations
// self.clearsSelectionOnViewWillAppear = false
// Register cell classes
self.collectionView!.register(UICollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)
userRequestTask = Task {
if let users = try? await UserRequest().send() {
self.model.userByID = users
}
self.updateCollectionView()
userRequestTask = nil
}
habitRequestTask = Task {
if let habits = try? await HabitRequest().send() {
self.model.habitsByName = habits
}
self.updateCollectionView()
habitRequestTask = nil
}
}
반대로, user and habit statistics는 변한다.
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
update()
updateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
self.update()
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
updateTimer?.invalidate()
updateTimer = nil
}
func update() {
combinedStatisticsRequestTask?.cancel()
combinedStatisticsRequestTask = Task {
if let combinedStatistics = try? await CombinedStatisticsRequest().send() {
self.model.userStatistics = combinedStatistics.userStatistics
self.model.habitStatistics = combinedStatistics.habitStatistics
} else {
self.model.userStatistics = []
self.model.habitStatistics = []
}
self.updateCollectionView()
combinedStatisticsRequestTask = nil
}
}
func updateCollectionView() {
var sectionIDs = [ViewModel.Section]()
}
Create the Leaderboard
섹션 구성이 복잡하기 때문에 하나씩 처리한다.
Leaderboard Snapshot
다음 절차를 따름
- Rank the user counts from highest to lowest
- Find the index of the current user's count, keeping in mind that it won't exist if the user hasn't logged that habit yet
- Examine the number of user counts for the statistic:
- If 0, set the leader label to "Nobody Yet!" and leave the secondary label nil
- If 1, set the leader label to the only user and count
- Otherwise, do the following:
- Set the leader label to the user count at index 0
- Check whether the index of the current user's count exists and is not 0
- If true, the user's count and ranking should be displayed in the secondary label
- if false, the second-place user count should be displayed
복잡한 코드를 작성할 때는 차근차근 주석으로 표시하는 것이 좋다.
updateCollectionView()
static let formatter: NumberFormatter = {
var f = NumberFormatter()
f.numberStyle = .ordinal
return f
}()
func ordinalString(from number: Int) -> String {
return Self.formatter.string(from: NSNumber(integerLiteral: number + 1))!
}
func updateCollectionView() {
var sectionIDs = [ViewModel.Section]()
let leaderboardItems = model.habitStatistics.filter{ statistic in
return model.favoriteHabits.contains{ $0.name == statistic.habit.name }
}
.sorted{ $0.habit.name < $1.habit.name }
.reduce(into: [ViewModel.Item]()) { partial, statistic in
// Rank the user counts from highest to lowest
let rankedUserCounts = statistic.userCounts.sorted{ $0.count < $1.count }
// Find the index of the current user's count, keeping in mind that it won't exist if the user hasn't logged that habit yet
let myCountIndex = rankedUserCounts.firstIndex{ $0.user.id == self.model.currentUser.id }
func userRankingString(from userCount: UserCount) -> String {
var name = userCount.user.name
var ranking = ""
if userCount.user.id == self.model.currentUser.id {
name = "You"
ranking = " (\(ordinalString(from: myCountIndex!)))"
}
return "\(name) \(userCount.count)" + ranking
}
var leadingRanking: String?
var secondaryRanking: String?
// Examine the number of user counts for the statistic:
switch rankedUserCounts.count {
case 0:
// If 0, set the leader label to "Nobody Yet!" and leave the secondary label nil
leadingRanking = "Nobody Yet!"
case 1:
// If 1, set the leader label to the only user and count
let onlyCount = rankedUserCounts.first!
leadingRanking = userRankingString(from: onlyCount)
default:
// Otherwise, do the following:
// Set the leader label to the user count at index 0
leadingRanking = userRankingString(from: rankedUserCounts[0])
// Check whether the index of the current user's count exists and is not 0
if let myCountIndex = myCountIndex,
myCountIndex != rankedUserCounts.startIndex {
// If true, the user's count and ranking should be displayed in the secondary label
secondaryRanking = userRankingString(from: rankedUserCounts[myCountIndex])
} else {
// if false, the second-place user count should be displayed
secondaryRanking = userRankingString(from: rankedUserCounts[1])
}
}
let leaderboardItem = ViewModel.Item.leaderboardHabit(name: statistic.habit.name, leadingUserRanking: leadingRanking, secondaryUserRanking: secondaryRanking)
partial.append(leaderboardItem)
}
sectionIDs.append(.leaderboard)
var itemBySection = [ViewModel.Section.leaderboard: leaderboardItems]
dataSource.applySnapshotUsing(sectionIDs: sectionIDs, itemsBySection: itemBySection)
}
Leaderboard Layout and Data Source
createDataSource()
func createDataSource() -> DataSourceType {
let dataSource = DataSourceType(collectionView: collectionView) { (collectionView, indexPath, item) -> UICollectionViewCell? in
switch item {
case .leaderboardHabit(let name, let leadingUserRanking, let secondaryUserRanking):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "LeaderboardHabit", for: indexPath) as! LeaderboardHabitCollectionViewCell
cell.habitNameLabel.text = name
cell.leaderLabel.text = leadingUserRanking
cell.secondaryLabel.text = secondaryUserRanking
return cell
default:
return nil
}
}
return dataSource
}
createLayout()
func createLayout() -> UICollectionViewCompositionalLayout {
let layout = UICollectionViewCompositionalLayout { (sectionIndex, environment) in
switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] {
case .leaderboard:
let leaderboardItemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(0.3))
let leaderboardItem = NSCollectionLayoutItem(layoutSize: leaderboardItemSize)
let verticalTrioSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.75), heightDimension: .fractionalHeight(0.75))
let leaderboardVerticalTrio = NSCollectionLayoutGroup.vertical(layoutSize: verticalTrioSize, repeatingSubitem: leaderboardItem, count: 3)
leaderboardVerticalTrio.interItemSpacing = .fixed(10)
let leaderboardSection = NSCollectionLayoutSection(group: leaderboardVerticalTrio)
leaderboardSection.interGroupSpacing = 20
leaderboardSection.orthogonalScrollingBehavior = .continuous
leaderboardSection.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 20, bottom: 20, trailing: 20)
return leaderboardSection
default:
return nil
}
}
return layout
}
마지막으로 viewDidLoad()에서 data source와 layout 설정
override func viewDidLoad() {
super.viewDidLoad()
dataSource = createDataSource()
collectionView.dataSource = dataSource
collectionView.collectionViewLayout = createLayout()
Create the Followed Users Screen
Followed Users Snapshot
- Followed 사용자와 현재 사용자가 동일한 habits를 기록
- Habit 하나 선택해 순위 비교 정보 표시
- Followed 사용자 순위가 더 높으면 현재 사용자가 해당 habit에 집중하도록 권장하는 메시지
- 반대는 현재 사용자를 축하하고 다른 친구 격려
- Habit 하나 선택해 순위 비교 정보 표시
- 두 사용자가 공통으로 기록한 습관이 없는 경우
- Followed 사용자가 기록한 habit 선택
- 현재 사용자에게 사용을 제안하며 followed 사용자의 통계와 순위 정보 표시
- 두 경우 모두 아닌 경우
- 현재 사용자에게 친구에게 추천하는 메시지 제시
updateCollectionView()
- 새 view model items를 위한 변수 선언
- 사용자가 기록한 habits의 names를 반한하는 helper 함수 정의
가이드라인
- Get the current user's logged habits and extract the favorites
- Loop through all the followed users
- If the users have a habit in common:
- Pick the habit to focus on
- Get the full statistics (all the user counts) for that habit
- Get the ranking for each user
- Construct the message depending on who's leading
- Otherwise, if the followed user has logged at least one habit:
- Get an arbitrary habit name
- Get the full statistics (all the user counts) for that habit
- Get the user's ranking for that habit
- Construct the message
- Otherwise, this user hasn't done anything
- If the users have a habit in common:
var followedUserItems = [ViewModel.Item]()
func loggedHabitNames(for user: User) -> Set<String> {
var names = [String]()
if let stats = model.userStatistics.first(where: { $0.user == user }) {
names = stats.habitCounts.map{ $0.habit.name }
}
return Set(names)
}
// Get the current user's logged habits and extract the favorites
let currentUserLoggedHabits = loggedHabitNames(for: model.currentUser)
let favoriteLoggedHabits = Set(model.favoriteHabits.map{ $0.name }).intersection(currentUserLoggedHabits)
// Loop through all the followed users
for followedUser in model.followedUsers.sorted(by: { $0.name < $1.name }) {
let message: String
let followedUserLoggedHabits = loggedHabitNames(for: followedUser)
// If the users have a habit in common:
let commonLoggedHabits = followedUserLoggedHabits.intersection(currentUserLoggedHabits)
if commonLoggedHabits.count > 0 {
// Pick the habit to focus on
let habitName: String
let commonFavoriteLoggedHabits = favoriteLoggedHabits.intersection(commonLoggedHabits)
if commonFavoriteLoggedHabits.count > 0 {
habitName = commonFavoriteLoggedHabits.sorted().first!
} else {
habitName = commonLoggedHabits.sorted().first!
}
// Get the full statistics (all the user counts) for that habit
let habitStats = model.habitStatistics.first { $0.habit.name == habitName }!
// Get the ranking for each user
let rankedUserCounts = habitStats.userCounts.sorted{ $0.count > $1.count }
let currentuserRanking = rankedUserCounts.firstIndex{ $0.user == model.currentUser }!
let followedUserRanking = rankedUserCounts.firstIndex{ $0.user == followedUser }!
// Construct the message depending on who's leading
if currentuserRanking < followedUserRanking {
message = "Currently #\(ordinalString(from: followedUserRanking)), behind you (#\(ordinalString(from: currentuserRanking)) in \(habitName). \nSend them a friendly reminder!"
} else if currentuserRanking > followedUserRanking {
message = "Currently #\(ordinalString(from: followedUserRanking)), ahead of you (#\(ordinalString(from: currentuserRanking)) in \(habitName). \nYou might catch up with a little extra effort!"
} else {
message = "You are tied at \(ordinalString(from: followedUserRanking)) in \(habitName)! Now's your chance to pull ahead."
}
// Otherwise, if the followed user has logged at least one habit:
} else if followedUserLoggedHabits.count > 0 {
// Get an arbitrary habit name
let habitName = followedUserLoggedHabits.sorted().first!
// Get the full statistics (all the user counts) for that habit
let habitStats = model.habitStatistics.first { $0.habit.name == habitName }!
// Get the user's ranking for that habit
let rankedUserCounts = habitStats.userCounts.sorted { $0.count > $1.count }
let followedUserRanking = rankedUserCounts.firstIndex { $0.user == followedUser }!
// Construct the message
message = "Crrently #\(ordinalString(from: followedUserRanking)), in \(habitName). \nMaybe you should give this habit a look."
// Otherwise, this user hasn't done anything
} else {
message = "This user doesn't seem to have done much yet. Check in to see if they need and help getting started."
}
followedUserItems.append(.followedUser(followedUser, message: message))
}
sectionIDs.append(.followedUsers)
itemBySection[.followedUsers] = followedUserItems
Followed Users Layout and Data Source
createDataSource()
case .followedUser(let user, let message):
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "FollowedUser", for: indexPath) as! FollowedUserCollectionViewCell
cell.primaryTextLabel.text = user.name
cell.secondaryTextLabel.text = message
return cell
createLayout()
case .followedUsers:
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100))
let followedUserItem = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(100))
let followedUserGroup = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: followedUserItem, count: 1)
let followedUserSection = NSCollectionLayoutSection(group: followedUserGroup)
return followedUserSection
'Develop in Swift Data Collections' 카테고리의 다른 글
Guided Project: Habits - 8 (0) | 2024.06.24 |
---|---|
Guided Project: Habits - 6 (0) | 2024.06.19 |
Guided Project: Habits - 5 (0) | 2024.06.18 |
Guided Project: Habits - 4 (0) | 2024.06.17 |
Guided Project: Habits - 3 (0) | 2024.06.14 |