Develop in Swift Data Collections

Guided Project: Habits - 7

GayoonKim 2024. 6. 21. 14:22

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에 집중하도록 권장하는 메시지
      • 반대는 현재 사용자를 축하하고 다른 친구 격려
  • 두 사용자가 공통으로 기록한 습관이 없는 경우
    • 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
		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