diff --git a/SOPT-iOS/Projects/Features/StampFeature/Interface/Sources/StampFeatureBuildable.swift b/SOPT-iOS/Projects/Features/StampFeature/Interface/Sources/StampFeatureBuildable.swift index 73639469a..a151fdf13 100644 --- a/SOPT-iOS/Projects/Features/StampFeature/Interface/Sources/StampFeatureBuildable.swift +++ b/SOPT-iOS/Projects/Features/StampFeature/Interface/Sources/StampFeatureBuildable.swift @@ -13,6 +13,7 @@ import BaseFeatureDependency public enum RankingViewType { case all case currentGeneration(info: UsersActiveGenerationStatusViewResponse) + case partRanking } public protocol StampFeatureViewBuildable { @@ -29,5 +30,6 @@ public protocol StampFeatureViewBuildable { completionHandler: (() -> Void)? ) -> MissionCompletedViewControllable func makeRankingVC(rankingViewType: RankingViewType) -> RankingViewControllable + func makePartRankingVC(rankingViewType: RankingViewType) -> PartRankingViewControllable func makeStampGuideVC() -> StampGuideViewControllable } diff --git a/SOPT-iOS/Projects/Features/StampFeature/Interface/Sources/StampFeatureViewControllable.swift b/SOPT-iOS/Projects/Features/StampFeature/Interface/Sources/StampFeatureViewControllable.swift index cd1b331e5..7a6f8835d 100644 --- a/SOPT-iOS/Projects/Features/StampFeature/Interface/Sources/StampFeatureViewControllable.swift +++ b/SOPT-iOS/Projects/Features/StampFeature/Interface/Sources/StampFeatureViewControllable.swift @@ -30,4 +30,5 @@ public protocol RankingCoordinatable { var onSwiped: (() -> Void)? { get set } var onNaviBackTap: (() -> Void)? { get set } } +public protocol PartRankingViewControllable: ViewControllable & RankingCoordinatable { } public protocol StampGuideViewControllable: ViewControllable { } diff --git a/SOPT-iOS/Projects/Features/StampFeature/Sources/Coordinator/RankingCoordinator.swift b/SOPT-iOS/Projects/Features/StampFeature/Sources/Coordinator/RankingCoordinator.swift index 241723c71..52cc47475 100644 --- a/SOPT-iOS/Projects/Features/StampFeature/Sources/Coordinator/RankingCoordinator.swift +++ b/SOPT-iOS/Projects/Features/StampFeature/Sources/Coordinator/RankingCoordinator.swift @@ -31,7 +31,12 @@ final class RankingCoordinator: DefaultCoordinator { } public override func start() { + switch rankingViewType { + case .all, .currentGeneration: showRanking() + case .partRanking: + showPartRanking() + } } private func showRanking() { @@ -48,7 +53,22 @@ final class RankingCoordinator: DefaultCoordinator { } router.push(ranking) } - + + private func showPartRanking() { + var ranking = factory.makePartRankingVC(rankingViewType: self.rankingViewType) + ranking.onSwiped = { [weak self] in + self?.router.popModule() + } + ranking.onCellTap = { [weak self] (username, sentence) in + self?.showOtherMissionList(username, sentence) + } + ranking.onNaviBackTap = { [weak self] in + self?.router.popModule() + self?.finishFlow?() + } + router.push(ranking) + } + private func showOtherMissionList(_ username: String, _ sentence: String) { var otherMissionList = factory.makeMissionListVC( sceneType: .ranking(userName: username, sentence: sentence) diff --git a/SOPT-iOS/Projects/Features/StampFeature/Sources/Coordinator/StampBuilder.swift b/SOPT-iOS/Projects/Features/StampFeature/Sources/Coordinator/StampBuilder.swift index 25d5e4643..263e42d4e 100644 --- a/SOPT-iOS/Projects/Features/StampFeature/Sources/Coordinator/StampBuilder.swift +++ b/SOPT-iOS/Projects/Features/StampFeature/Sources/Coordinator/StampBuilder.swift @@ -76,4 +76,10 @@ extension StampBuilder: StampFeatureViewBuildable { let stampGuideVC = StampGuideVC() return stampGuideVC } + + public func makePartRankingVC(rankingViewType: RankingViewType) -> PartRankingViewControllable { + let vc = PartRankingVC(rankingViewType: rankingViewType) + vc.viewModel = PartRankingViewModel(rankingViewType: rankingViewType) + return vc + } } diff --git a/SOPT-iOS/Projects/Features/StampFeature/Sources/Coordinator/StampCoordinator.swift b/SOPT-iOS/Projects/Features/StampFeature/Sources/Coordinator/StampCoordinator.swift index 71bb08cb0..ce2da2fee 100644 --- a/SOPT-iOS/Projects/Features/StampFeature/Sources/Coordinator/StampCoordinator.swift +++ b/SOPT-iOS/Projects/Features/StampFeature/Sources/Coordinator/StampCoordinator.swift @@ -74,7 +74,7 @@ final class StampCoordinator: DefaultCoordinator { addDependency(rankingCoordinator) rankingCoordinator.start() } - + private func runMissionDetailFlow(_ model: MissionListModel, _ username: String?) { let missionDetailCoordinator = MissionDetailCoordinator( router: Router(rootController: rootController!), diff --git a/SOPT-iOS/Projects/Features/StampFeature/Sources/MissionListScene/VC/MissionListVC.swift b/SOPT-iOS/Projects/Features/StampFeature/Sources/MissionListScene/VC/MissionListVC.swift index ac2682bdc..77f847382 100644 --- a/SOPT-iOS/Projects/Features/StampFeature/Sources/MissionListScene/VC/MissionListVC.swift +++ b/SOPT-iOS/Projects/Features/StampFeature/Sources/MissionListScene/VC/MissionListVC.swift @@ -247,7 +247,7 @@ extension MissionListVC { partRankingFloatingButton.publisher(for: .touchUpInside) .withUnretained(self) .sink { owner, _ in - owner.onPartRankingButtonTap?(.all) + owner.onPartRankingButtonTap?(.partRanking) }.store(in: self.cancelBag) currentGenerationRankFloatingButton.publisher(for: .touchUpInside) diff --git a/SOPT-iOS/Projects/Features/StampFeature/Sources/RankingScene/CollectionView/PartRankingCompositionalLayout.swift b/SOPT-iOS/Projects/Features/StampFeature/Sources/RankingScene/CollectionView/PartRankingCompositionalLayout.swift new file mode 100644 index 000000000..f4267f1b3 --- /dev/null +++ b/SOPT-iOS/Projects/Features/StampFeature/Sources/RankingScene/CollectionView/PartRankingCompositionalLayout.swift @@ -0,0 +1,47 @@ +// +// PartRankingCompositionalLayout.swift +// StampFeature +// +// Created by Aiden.lee on 2024/03/31. +// Copyright © 2024 SOPT-iOS. All rights reserved. +// + +import UIKit + +extension PartRankingVC { + static let standardWidth = UIScreen.main.bounds.width - 40.adjusted + + func createLayout() -> UICollectionViewLayout { + return UICollectionViewCompositionalLayout { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in + switch RankingSection.type(sectionIndex) { + case .chart: return self.createChartSection() + case .list: return self.createListSection() + } + } + } + + private func createChartSection() -> NSCollectionLayoutSection { + let size = NSCollectionLayoutSize(widthDimension: .absolute(RankingVC.standardWidth - 28.adjusted), heightDimension: .estimated(300)) + let item = NSCollectionLayoutItem(layoutSize: size) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: size, subitem: item, count: 1) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = .init(top: 0, leading: 34.adjusted, bottom: 0, trailing: 34.adjusted) + section.orthogonalScrollingBehavior = .none + return section + } + + private func createListSection() -> NSCollectionLayoutSection { + let size = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(79.adjustedH)) + let item = NSCollectionLayoutItem(layoutSize: size) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: size, subitem: item, count: 1) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = .init(top: 28.adjustedH, leading: 20.adjusted, bottom: 60.adjustedH, trailing: 20.adjusted) + section.interGroupSpacing = .init(10.adjustedH) + section.orthogonalScrollingBehavior = .none + + return section + } +} + diff --git a/SOPT-iOS/Projects/Features/StampFeature/Sources/RankingScene/VC/PartRankingVC.swift b/SOPT-iOS/Projects/Features/StampFeature/Sources/RankingScene/VC/PartRankingVC.swift new file mode 100644 index 000000000..f57af6ac9 --- /dev/null +++ b/SOPT-iOS/Projects/Features/StampFeature/Sources/RankingScene/VC/PartRankingVC.swift @@ -0,0 +1,224 @@ +// +// PartRankingVC.swift +// StampFeature +// +// Created by Aiden.lee on 2024/03/31. +// Copyright © 2024 SOPT-iOS. All rights reserved. +// + +import UIKit + +import Core +import Domain +import DSKit + +import Combine +import SnapKit +import Then + +import StampFeatureInterface +import BaseFeatureDependency + +public class PartRankingVC: UIViewController, PartRankingViewControllable { + + // MARK: - Properties + + public var viewModel: PartRankingViewModel! + private var cancelBag = CancelBag() + + lazy var dataSource: UICollectionViewDiffableDataSource! = nil + + // MARK: - RankingCoordinatable + + public var onCellTap: ((String, String) -> Void)? + public var onSwiped: (() -> Void)? + public var onNaviBackTap: (() -> Void)? + + // MARK: - UI Components + + lazy var naviBar = STNavigationBar(self, type: .titleWithLeftButton, ignorePopAction: true) + .setTitleTypoStyle(.SoptampFont.h2) + .setTitle("파트별 랭킹") + .setRightButton(.none) + + private lazy var rankingCollectionView: UICollectionView = { + let cv = UICollectionView(frame: .zero, collectionViewLayout: self.createLayout()) + cv.showsVerticalScrollIndicator = true + cv.backgroundColor = .white + cv.refreshControl = refresher + return cv + }() + + private let refresher: UIRefreshControl = { + let rf = UIRefreshControl() + return rf + }() + + // MARK: - View Life Cycle + private let rankingViewType: RankingViewType + + init(rankingViewType: RankingViewType) { + self.rankingViewType = rankingViewType + + super.init(nibName: nil, bundle: nil) + + guard case .currentGeneration(let info) = rankingViewType else { return } + + let navigationTitle = String(describing: info.currentGeneration) + "기 랭킹" + self.naviBar.setTitle(navigationTitle) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + self.setUI() + self.setLayout() + self.setDelegate() + self.setGesture() + self.registerCells() + self.bindViews() + self.bindViewModels() + self.setDataSource() + } +} + +// MARK: - UI & Layouts + +extension PartRankingVC { + + private func setUI() { + self.view.backgroundColor = .white + self.navigationController?.isNavigationBarHidden = true + } + + private func setLayout() { + self.view.addSubviews(naviBar, rankingCollectionView) + + naviBar.snp.makeConstraints { make in + make.leading.top.trailing.equalTo(view.safeAreaLayoutGuide) + } + + rankingCollectionView.snp.makeConstraints { make in + make.top.equalTo(naviBar.snp.bottom).offset(4) + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview() + } + } +} + +// MARK: - Methods + +extension PartRankingVC { + + private func bindViews() { + naviBar.leftButtonTapped + .withUnretained(self) + .sink { owner, _ in + owner.onNaviBackTap?() + }.store(in: cancelBag) + } + + private func bindViewModels() { + let refreshStarted = refresher.publisher(for: .valueChanged) + .mapVoid() + .asDriver() + + let input = PartRankingViewModel.Input( + viewDidLoad: Driver.just(()), + refreshStarted: refreshStarted + ) + + let output = self.viewModel.transform(from: input, cancelBag: self.cancelBag) + } + + private func setDelegate() { + rankingCollectionView.delegate = self + } + + private func setGesture() { + let swipeGesture = UIPanGestureRecognizer(target: self, action: #selector(swipeBack(_:))) + swipeGesture.delegate = self + self.rankingCollectionView.addGestureRecognizer(swipeGesture) + } + + @objc + private func swipeBack(_ sender: UIPanGestureRecognizer) { + let velocity = sender.velocity(in: rankingCollectionView) + let velocityMinimum: CGFloat = 1000 + guard let navigation = self.navigationController else { return } + let isScrollY: Bool = abs(velocity.x) > abs(velocity.y) + 200 + let isNotRootView = navigation.viewControllers.count >= 2 + if velocity.x >= velocityMinimum + && isNotRootView + && isScrollY { + self.rankingCollectionView.isScrollEnabled = false + self.onSwiped?() + } + } + + private func registerCells() { + RankingChartCVC.register(target: rankingCollectionView) + RankingListCVC.register(target: rankingCollectionView) + } + + private func setDataSource() { + dataSource = UICollectionViewDiffableDataSource(collectionView: rankingCollectionView, cellProvider: { collectionView, indexPath, itemIdentifier in + switch RankingSection.type(indexPath.section) { + case .chart: + guard let chartCell = collectionView.dequeueReusableCell(withReuseIdentifier: RankingChartCVC.className, for: indexPath) as? RankingChartCVC, + let chartCellModel = itemIdentifier as? RankingChartModel else { return UICollectionViewCell() } + chartCell.setData(model: chartCellModel) + chartCell.balloonTapped = { [weak self] balloonModel in + guard let self = self else { return } + let item = balloonModel.toRankingListTapItem() + self.onCellTap?(item.username, item.sentence) + } + return chartCell + + case .list: + guard let rankingListCell = collectionView.dequeueReusableCell(withReuseIdentifier: RankingListCVC.className, for: indexPath) as? RankingListCVC, + let rankingListCellModel = itemIdentifier as? RankingModel else { return UICollectionViewCell() } + rankingListCell.setData(model: rankingListCellModel, + rank: indexPath.row + 1 + 3) + + return rankingListCell + } + }) + } + + func applySnapshot(model: [RankingModel]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.chart, .list]) + guard model.count >= 4 else { return } + guard let chartCellModels = Array(model[0...2]) as? [RankingModel], + let rankingListModel = Array(model[3...model.count-1]) as? [RankingModel] else { return } + let chartCellModel = RankingChartModel.init(ranking: chartCellModels) + snapshot.appendItems([chartCellModel], toSection: .chart) + snapshot.appendItems(rankingListModel, toSection: .list) + dataSource.apply(snapshot, animatingDifferences: false) + self.view.setNeedsLayout() + } + + private func endRefresh() { + self.refresher.endRefreshing() + } +} + +extension PartRankingVC: UICollectionViewDelegate { + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard indexPath.section >= 1 else { return } + + guard let tappedCell = collectionView.cellForItem(at: indexPath) as? RankingListTappable, + let item = tappedCell.getModelItem() else { return } + self.onCellTap?(item.username, item.sentence) + } +} + +extension PartRankingVC: UIGestureRecognizerDelegate { + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + true + } +} diff --git a/SOPT-iOS/Projects/Features/StampFeature/Sources/RankingScene/ViewModel/PartRankingViewModel.swift b/SOPT-iOS/Projects/Features/StampFeature/Sources/RankingScene/ViewModel/PartRankingViewModel.swift new file mode 100644 index 000000000..abcc3ba88 --- /dev/null +++ b/SOPT-iOS/Projects/Features/StampFeature/Sources/RankingScene/ViewModel/PartRankingViewModel.swift @@ -0,0 +1,45 @@ +// +// PartRankingViewModel.swift +// StampFeature +// +// Created by Aiden.lee on 2024/03/31. +// Copyright © 2024 SOPT-iOS. All rights reserved. +// + +import Combine + +import Core +import Domain +import StampFeatureInterface + +public class PartRankingViewModel: ViewModelType { + private var cancelBag = CancelBag() + + private let rankingViewType: RankingViewType + + // MARK: - Inputs + + public struct Input { + let viewDidLoad: Driver + let refreshStarted: Driver + } + + // MARK: - Outputs + + public class Output { + } + + // MARK: - init + + public init( + rankingViewType: RankingViewType + ) { + self.rankingViewType = rankingViewType + } +} + +extension PartRankingViewModel { + public func transform(from input: Input, cancelBag: Core.CancelBag) -> Output { + Output() + } +} diff --git a/SOPT-iOS/Projects/Features/StampFeature/Sources/RankingScene/ViewModel/RankingViewModel.swift b/SOPT-iOS/Projects/Features/StampFeature/Sources/RankingScene/ViewModel/RankingViewModel.swift index 22901c6db..11b25fac5 100644 --- a/SOPT-iOS/Projects/Features/StampFeature/Sources/RankingScene/ViewModel/RankingViewModel.swift +++ b/SOPT-iOS/Projects/Features/StampFeature/Sources/RankingScene/ViewModel/RankingViewModel.swift @@ -57,6 +57,8 @@ extension RankingViewModel { switch self.rankingViewType { case .all: owner.useCase.fetchRankingList(isCurrentGeneration: false) case .currentGeneration: owner.useCase.fetchRankingList(isCurrentGeneration: true) + default: + return } }.store(in: self.cancelBag)