diff --git a/GameScope/GameScope.xcodeproj/project.pbxproj b/GameScope/GameScope.xcodeproj/project.pbxproj index 995d4a5..3622f74 100644 --- a/GameScope/GameScope.xcodeproj/project.pbxproj +++ b/GameScope/GameScope.xcodeproj/project.pbxproj @@ -23,6 +23,8 @@ F1E20C7C2A4D6E1C00772B80 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F1E20C7B2A4D6E1C00772B80 /* Assets.xcassets */; }; F1E20C7F2A4D6E1C00772B80 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F1E20C7D2A4D6E1C00772B80 /* LaunchScreen.storyboard */; }; F1E20C8D2A4EACF900772B80 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = F1E20C8C2A4EACF900772B80 /* SnapKit */; }; + F60B0D062A6E03CA004CE3D5 /* DetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60B0D052A6E03CA004CE3D5 /* DetailViewController.swift */; }; + F60B0D082A6E42C5004CE3D5 /* GameDetailImageCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60B0D072A6E42C5004CE3D5 /* GameDetailImageCell.swift */; }; F6B45FA82A4DA96000F1D0DB /* popular-pvp-games.json in Resources */ = {isa = PBXBuildFile; fileRef = F6B45FA72A4DA96000F1D0DB /* popular-pvp-games.json */; }; F6B45FAA2A4DA9C200F1D0DB /* detail-game-pubg.json in Resources */ = {isa = PBXBuildFile; fileRef = F6B45FA92A4DA9C200F1D0DB /* detail-game-pubg.json */; }; F6B45FAC2A4E71DA00F1D0DB /* GameListDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B45FAB2A4E71DA00F1D0DB /* GameListDTO.swift */; }; @@ -43,8 +45,10 @@ F6B45FD32A4EC36C00F1D0DB /* detail-game-pubg.json in Resources */ = {isa = PBXBuildFile; fileRef = F6B45FA92A4DA9C200F1D0DB /* detail-game-pubg.json */; }; F6B45FD52A4EC5D900F1D0DB /* GameDetailDTOTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B45FD42A4EC5D900F1D0DB /* GameDetailDTOTests.swift */; }; F6B45FD62A4EC6A000F1D0DB /* wrong-game-pubg.json in Resources */ = {isa = PBXBuildFile; fileRef = F6B45FD22A4EBF4A00F1D0DB /* wrong-game-pubg.json */; }; - F6B45FD82A500F5200F1D0DB /* DetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B45FD72A500F5200F1D0DB /* DetailViewController.swift */; }; F6B45FDF2A57A10D00F1D0DB /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B45FDE2A57A10D00F1D0DB /* HeaderView.swift */; }; + F6B45FEA2A5F962200F1D0DB /* GameDetailInformationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B45FE92A5F962200F1D0DB /* GameDetailInformationCell.swift */; }; + F6B45FEC2A5F97DF00F1D0DB /* InformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B45FEB2A5F97DF00F1D0DB /* InformationView.swift */; }; + F6B45FEE2A63711F00F1D0DB /* GameDetailDescriptionCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B45FED2A63711F00F1D0DB /* GameDetailDescriptionCell.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -75,6 +79,8 @@ F1E20C7B2A4D6E1C00772B80 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; F1E20C7E2A4D6E1C00772B80 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; F1E20C802A4D6E1C00772B80 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F60B0D052A6E03CA004CE3D5 /* DetailViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DetailViewController.swift; sourceTree = ""; }; + F60B0D072A6E42C5004CE3D5 /* GameDetailImageCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameDetailImageCell.swift; sourceTree = ""; }; F6B45FA72A4DA96000F1D0DB /* popular-pvp-games.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "popular-pvp-games.json"; sourceTree = ""; }; F6B45FA92A4DA9C200F1D0DB /* detail-game-pubg.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "detail-game-pubg.json"; sourceTree = ""; }; F6B45FAB2A4E71DA00F1D0DB /* GameListDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListDTO.swift; sourceTree = ""; }; @@ -88,8 +94,10 @@ F6B45FD02A4EBEEA00F1D0DB /* wrong-pvp-games.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "wrong-pvp-games.json"; sourceTree = ""; }; F6B45FD22A4EBF4A00F1D0DB /* wrong-game-pubg.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "wrong-game-pubg.json"; sourceTree = ""; }; F6B45FD42A4EC5D900F1D0DB /* GameDetailDTOTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameDetailDTOTests.swift; sourceTree = ""; }; - F6B45FD72A500F5200F1D0DB /* DetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailViewController.swift; sourceTree = ""; }; F6B45FDE2A57A10D00F1D0DB /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = ""; }; + F6B45FE92A5F962200F1D0DB /* GameDetailInformationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameDetailInformationCell.swift; sourceTree = ""; }; + F6B45FEB2A5F97DF00F1D0DB /* InformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InformationView.swift; sourceTree = ""; }; + F6B45FED2A63711F00F1D0DB /* GameDetailDescriptionCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameDetailDescriptionCell.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -115,6 +123,7 @@ isa = PBXGroup; children = ( F14B01752A513FEF00D43B1D /* GameListCollectionViewController.swift */, + F60B0D052A6E03CA004CE3D5 /* DetailViewController.swift */, ); path = Controllers; sourceTree = ""; @@ -149,7 +158,12 @@ F15550702A4EDCEC00832E92 /* View */ = { isa = PBXGroup; children = ( + F6B45FDE2A57A10D00F1D0DB /* HeaderView.swift */, + F6B45FE92A5F962200F1D0DB /* GameDetailInformationCell.swift */, + F6B45FEB2A5F97DF00F1D0DB /* InformationView.swift */, + F6B45FED2A63711F00F1D0DB /* GameDetailDescriptionCell.swift */, F15550712A4EDD0400832E92 /* GameListCollectionViewCell.swift */, + F60B0D072A6E42C5004CE3D5 /* GameDetailImageCell.swift */, ); path = View; sourceTree = ""; @@ -160,6 +174,7 @@ F1E20C712A4D6E1B00772B80 /* GameScope */, F6B45FC12A4EB9CC00F1D0DB /* GameScopeTests */, F1E20C702A4D6E1B00772B80 /* Products */, + F60B0D042A6E036E004CE3D5 /* Recovered References */, ); sourceTree = ""; }; @@ -177,8 +192,6 @@ children = ( F1E20C722A4D6E1B00772B80 /* AppDelegate.swift */, F1E20C742A4D6E1B00772B80 /* SceneDelegate.swift */, - F6B45FD72A500F5200F1D0DB /* DetailViewController.swift */, - F6B45FDE2A57A10D00F1D0DB /* HeaderView.swift */, F1E20C782A4D6E1B00772B80 /* Main.storyboard */, F1E20C7B2A4D6E1C00772B80 /* Assets.xcassets */, F1E20C7D2A4D6E1C00772B80 /* LaunchScreen.storyboard */, @@ -197,6 +210,13 @@ path = GameScope; sourceTree = ""; }; + F60B0D042A6E036E004CE3D5 /* Recovered References */ = { + isa = PBXGroup; + children = ( + ); + name = "Recovered References"; + sourceTree = ""; + }; F6B45FB42A4E7A1800F1D0DB /* DTO */ = { isa = PBXGroup; children = ( @@ -350,17 +370,21 @@ F1E20C732A4D6E1B00772B80 /* AppDelegate.swift in Sources */, F14B017B2A5195DD00D43B1D /* GameManager.swift in Sources */, F14B01762A513FEF00D43B1D /* GameListCollectionViewController.swift in Sources */, + F60B0D062A6E03CA004CE3D5 /* DetailViewController.swift in Sources */, F6B45FAE2A4E752900F1D0DB /* GameDetailDTO.swift in Sources */, F6B45FB02A4E768B00F1D0DB /* MinimumSystemRequirementsDTO.swift in Sources */, F14B01952A56655000D43B1D /* URLResponse+.swift in Sources */, F1E20C752A4D6E1B00772B80 /* SceneDelegate.swift in Sources */, + F6B45FEC2A5F97DF00F1D0DB /* InformationView.swift in Sources */, F1813CD92A623DED00D77CD8 /* DateHandler.swift in Sources */, F6B45FB22A4E775100F1D0DB /* ScreenshotDTO.swift in Sources */, F6B45FDF2A57A10D00F1D0DB /* HeaderView.swift in Sources */, - F6B45FD82A500F5200F1D0DB /* DetailViewController.swift in Sources */, + F6B45FEE2A63711F00F1D0DB /* GameDetailDescriptionCell.swift in Sources */, + F60B0D082A6E42C5004CE3D5 /* GameDetailImageCell.swift in Sources */, F6B45FB92A4EA9E700F1D0DB /* GameList.swift in Sources */, F14B017D2A52AF2B00D43B1D /* JSONDeserializer.swift in Sources */, F6B45FAC2A4E71DA00F1D0DB /* GameListDTO.swift in Sources */, + F6B45FEA2A5F962200F1D0DB /* GameDetailInformationCell.swift in Sources */, F14B018E2A565B7C00D43B1D /* NetworkDispatcher.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/GameScope/GameScope/Controller/DetailViewController.swift b/GameScope/GameScope/Controller/DetailViewController.swift new file mode 100644 index 0000000..e103006 --- /dev/null +++ b/GameScope/GameScope/Controller/DetailViewController.swift @@ -0,0 +1,190 @@ +// +// DetailViewController.swift +// GameScope +// +// Created by 박재우 on 2023/07/01. +// + +import UIKit +import SnapKit + +class DetailViewController: UIViewController, UICollectionViewDelegate { + + static let headerElementKind = "header-element-kind" + + enum Section: String, CaseIterable { + case thumbnail + case about = "About" + case information = "Information" + case screenshots = "ScreenShots" + } + + private lazy var detailCollectionView = { + let collectionView = UICollectionView( + frame: view.bounds, + collectionViewLayout: generateLayout()) + + collectionView.backgroundColor = .systemBackground + collectionView.delegate = self + collectionView.dataSource = self + collectionView.register( + HeaderView.self, + forSupplementaryViewOfKind: DetailViewController.headerElementKind, + withReuseIdentifier: HeaderView.reuseIdentifier) + collectionView.register( + GameDetailInformationCell.self, + forCellWithReuseIdentifier: GameDetailInformationCell.reuseIdentifier) + collectionView.register( + GameDetailDescriptionCell.self, + forCellWithReuseIdentifier: GameDetailDescriptionCell.reuseIdentifier) + return collectionView + }() + private let boundarySupplementaryHeader = { + let headerSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(44)) + let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: DetailViewController.headerElementKind, + alignment: .top) + return sectionHeader + }() + + private var detail: GameDetail? + + convenience init(gameDetail: GameDetail) { + self.init() + detail = gameDetail + } + + override func viewDidLoad() { + super.viewDidLoad() + navigationItem.title = detail?.title + + view.addSubview(detailCollectionView) + } + +} + +extension DetailViewController { + + private func generateLayout() -> UICollectionViewLayout { + let layout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in + let sectionLayoutKind = Section.allCases[sectionIndex] + switch sectionLayoutKind { + case .thumbnail: + return nil + case .about: + return self.generateAboutLayout() + case .information: + return self.generateInformationLayout() + case .screenshots: + return nil + } + } + return layout + } + + private func generateAboutLayout() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(100)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(0.9), + heightDimension: .estimated(100)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, + subitem: item, + count: 1) + + let section = NSCollectionLayoutSection(group: group) + section.boundarySupplementaryItems = [boundarySupplementaryHeader] + section.orthogonalScrollingBehavior = .groupPagingCentered + + return section + } + + private func generateInformationLayout() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(0.9), + heightDimension: .fractionalWidth(0.4)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, + subitem: item, + count: 1) + + let section = NSCollectionLayoutSection(group: group) + section.boundarySupplementaryItems = [boundarySupplementaryHeader] + section.orthogonalScrollingBehavior = .groupPagingCentered + + return section + } +} + +extension DetailViewController: UICollectionViewDataSource { + + func numberOfSections(in collectionView: UICollectionView) -> Int { + Section.allCases.count + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + switch Section.allCases[section] { + case .about, .information: + return 1 + default: + return 0 + } + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + switch Section.allCases[indexPath.section] { + case .about: + guard let detail, + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: GameDetailDescriptionCell.reuseIdentifier, + for: indexPath) as? GameDetailDescriptionCell else { + return UICollectionViewCell() + } + cell.configure(description: detail.description) + cell.delegate = self + return cell + case .information: + guard let detail = detail, + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: GameDetailInformationCell.reuseIdentifier, + for: indexPath) as? GameDetailInformationCell else { + return UICollectionViewCell() + } + cell.configure(information: detail) + return cell + default: + return UICollectionViewCell() + } + } + + func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + if kind == DetailViewController.headerElementKind, + let HeaderView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: HeaderView.reuseIdentifier, for: indexPath) as? HeaderView { + HeaderView.label.text = Section.allCases[indexPath.section].rawValue + return HeaderView + } + + return UICollectionReusableView() + } + +} + +extension DetailViewController: GameDetailDescriptionCellDelegate { + func gameDetailDescriptionCell( + _ gameDetailDescriptionCell: GameDetailDescriptionCell, + didButtonTapped sender: UIButton + ) { + gameDetailDescriptionCell.expandDetailDescription() + self.detailCollectionView.reloadData() + } +} diff --git a/GameScope/GameScope/Controllers/DetailViewController.swift b/GameScope/GameScope/Controllers/DetailViewController.swift new file mode 100644 index 0000000..7f11ea5 --- /dev/null +++ b/GameScope/GameScope/Controllers/DetailViewController.swift @@ -0,0 +1,235 @@ +// +// DetailViewController.swift +// GameScope +// +// Created by 박재우 on 2023/07/01. +// + +import UIKit +import SnapKit + +class DetailViewController: UIViewController, UICollectionViewDelegate { + + static let headerElementKind = "header-element-kind" + + enum Section: String, CaseIterable { + case thumbnail + case about = "About" + case information = "Information" + case screenshots = "ScreenShots" + } + + private lazy var detailCollectionView = { + let collectionView = UICollectionView( + frame: view.bounds, + collectionViewLayout: generateLayout()) + + collectionView.backgroundColor = .systemBackground + collectionView.delegate = self + collectionView.dataSource = self + collectionView.register( + HeaderView.self, + forSupplementaryViewOfKind: DetailViewController.headerElementKind, + withReuseIdentifier: HeaderView.reuseIdentifier) + collectionView.register( + GameDetailInformationCell.self, + forCellWithReuseIdentifier: GameDetailInformationCell.reuseIdentifier) + collectionView.register( + GameDetailDescriptionCell.self, + forCellWithReuseIdentifier: GameDetailDescriptionCell.reuseIdentifier) + collectionView.register( + GameDetailImageCell.self, + forCellWithReuseIdentifier: GameDetailImageCell.reuseIdentifier) + return collectionView + }() + private let boundarySupplementaryHeader = { + let headerSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(44)) + let sectionHeader = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: DetailViewController.headerElementKind, + alignment: .top) + return sectionHeader + }() + + private var detail: GameDetail? + + convenience init(gameDetail: GameDetail) { + self.init() + detail = gameDetail + } + + override func viewDidLoad() { + super.viewDidLoad() + navigationItem.title = detail?.title + + view.addSubview(detailCollectionView) + } + +} + +extension DetailViewController { + + private func generateLayout() -> UICollectionViewLayout { + let layout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in + let sectionLayoutKind = Section.allCases[sectionIndex] + switch sectionLayoutKind { + case .thumbnail, .screenshots: + return self.generateImageLayout() + case .about: + return self.generateAboutLayout() + case .information: + return self.generateInformationLayout() + } + } + return layout + } + + private func generateAboutLayout() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .estimated(100)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(0.9), + heightDimension: .estimated(100)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, + subitem: item, + count: 1) + + let section = NSCollectionLayoutSection(group: group) + section.boundarySupplementaryItems = [boundarySupplementaryHeader] + section.orthogonalScrollingBehavior = .groupPagingCentered + + return section + } + + private func generateInformationLayout() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(0.9), + heightDimension: .fractionalWidth(0.4)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, + subitem: item, + count: 1) + + let section = NSCollectionLayoutSection(group: group) + section.boundarySupplementaryItems = [boundarySupplementaryHeader] + section.orthogonalScrollingBehavior = .groupPagingCentered + + return section + } + + private func generateImageLayout() -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .fractionalHeight(1.0)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(0.9), + heightDimension: .fractionalWidth(0.6)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, + subitem: item, + count: 1) + group.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 5, bottom: 0, trailing: 5) + + let section = NSCollectionLayoutSection(group: group) + section.boundarySupplementaryItems = [boundarySupplementaryHeader] + section.orthogonalScrollingBehavior = .groupPagingCentered + + return section + } +} + +extension DetailViewController: UICollectionViewDataSource { + + func numberOfSections(in collectionView: UICollectionView) -> Int { + Section.allCases.count + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard let detail else { + return 0 + } + + switch Section.allCases[section] { + case .thumbnail, .about, .information: + return 1 + case .screenshots: + return detail.screenshots.count + } + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let detail else { + return UICollectionViewCell() + } + + switch Section.allCases[indexPath.section] { + case .thumbnail: + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: GameDetailImageCell.reuseIdentifier, + for: indexPath) as? GameDetailImageCell else { + return UICollectionViewCell() + } + cell.configure(urlString: detail.thumbnail) + return cell + case .about: + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: GameDetailDescriptionCell.reuseIdentifier, + for: indexPath) as? GameDetailDescriptionCell else { + return UICollectionViewCell() + } + cell.configure(description: detail.description) + cell.delegate = self + return cell + case .information: + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: GameDetailInformationCell.reuseIdentifier, + for: indexPath) as? GameDetailInformationCell else { + return UICollectionViewCell() + } + cell.configure(information: detail) + return cell + case .screenshots: + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: GameDetailImageCell.reuseIdentifier, + for: indexPath) as? GameDetailImageCell else { + return UICollectionViewCell() + } + cell.configure(urlString: detail.screenshots[indexPath.row]) + return cell + } + } + + func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + if kind == DetailViewController.headerElementKind, + let HeaderView = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: HeaderView.reuseIdentifier, + for: indexPath) as? HeaderView { + HeaderView.label.text = Section.allCases[indexPath.section].rawValue + return HeaderView + } + + return UICollectionReusableView() + } + +} + +extension DetailViewController: GameDetailDescriptionCellDelegate { + func gameDetailDescriptionCell( + _ gameDetailDescriptionCell: GameDetailDescriptionCell, + didButtonTapped sender: UIButton + ) { + gameDetailDescriptionCell.expandDetailDescription() + self.detailCollectionView.reloadData() + } +} diff --git a/GameScope/GameScope/Controllers/GameListCollectionViewController.swift b/GameScope/GameScope/Controllers/GameListCollectionViewController.swift index 0a1ebf6..2fd5e6d 100644 --- a/GameScope/GameScope/Controllers/GameListCollectionViewController.swift +++ b/GameScope/GameScope/Controllers/GameListCollectionViewController.swift @@ -90,6 +90,7 @@ final class GameListCollectionViewController: UIViewController { collectionView.register( GameListCollectionViewCell.self, forCellWithReuseIdentifier: GameListCollectionViewCell.identifier) + collectionView.delegate = self return collectionView }() @@ -223,3 +224,21 @@ extension GameListCollectionViewController { } } + +extension GameListCollectionViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let url = Bundle.main.url(forResource: "detail-game-pubg", withExtension: "json") else { + return + } + + do { + let data = try String(contentsOf: url).data(using: .utf8)! + let gameDetailDTO = try JSONDecoder().decode(GameDetailDTO.self, from: data) + let result = gameDetailDTO.convert() + let albumDetailVC = DetailViewController(gameDetail: result) + navigationController?.pushViewController(albumDetailVC, animated: true) + } catch { + print(error) + } + } +} diff --git a/GameScope/GameScope/DetailViewController.swift b/GameScope/GameScope/DetailViewController.swift deleted file mode 100644 index 260ac6f..0000000 --- a/GameScope/GameScope/DetailViewController.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// DetailViewController.swift -// GameScope -// -// Created by 박재우 on 2023/07/01. -// - -import UIKit -import SnapKit - -class DetailViewController: UIViewController, UICollectionViewDelegate { - - static let headerElementKind = "header-element-kind" - - enum Section: String, CaseIterable { - case thumbnail - case about = "About" - case information = "Information" - case screenshots = "ScreenShots" - } - - private typealias DataSource = UICollectionViewDiffableDataSource - - private lazy var dataSource = configureDataSource() - private lazy var detailCollectionView = { - let collectionView = UICollectionView( - frame: view.bounds, - collectionViewLayout: generateLayout()) - - view.addSubview(collectionView) - - collectionView.backgroundColor = .systemBackground - collectionView.delegate = self - collectionView.register( - HeaderView.self, - forSupplementaryViewOfKind: DetailViewController.headerElementKind, - withReuseIdentifier: HeaderView.reuseIdentifier) - - return collectionView - }() - - private var detail: GameDetail? - - convenience init(gameDetail: GameDetail) { - self.init() - detail = gameDetail - } - - override func viewDidLoad() { - super.viewDidLoad() - navigationItem.title = detail?.title - } - -} - -extension DetailViewController { - - private func configureDataSource() -> DataSource { - let dataSource = DataSource(collectionView: detailCollectionView) { collectionView, indexPath, item in - let sectionType = Section.allCases[indexPath.section] - switch sectionType { - case .thumbnail: - return nil - case .about: - return nil - case .information: - return nil - case .screenshots: - return nil - } - } - - dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in - guard let supplementaryView = collectionView.dequeueReusableSupplementaryView( - ofKind: kind, - withReuseIdentifier: HeaderView.reuseIdentifier, - for: indexPath - ) as? HeaderView else { - fatalError("Cannot create header view") - } - supplementaryView.label.text = Section.allCases[indexPath.section].rawValue - return supplementaryView - } - - return dataSource - } - - func generateLayout() -> UICollectionViewLayout { - let layout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in - let sectionLayoutKind = Section.allCases[sectionIndex] - switch sectionLayoutKind { - case .thumbnail: - return nil - case .about: - return nil - case .information: - return nil - case .screenshots: - return nil - } - } - return layout - } - -} diff --git a/GameScope/GameScope/Util/GameManager.swift b/GameScope/GameScope/Util/GameManager.swift index 4205ab2..34d1dd6 100644 --- a/GameScope/GameScope/Util/GameManager.swift +++ b/GameScope/GameScope/Util/GameManager.swift @@ -78,6 +78,28 @@ final class GameManager { } } + func dispatchImage(of url: String) async throws -> UIImage? { + let cacheKey = NSString(string: url) + if let chachedImage = ImageCacheManager.shared.object(forKey: cacheKey) { + return chachedImage + } + + guard let thumbnailURL = URL(string: url) else { throw NetworkError.invalidURL } + let urlRequest = URLRequest(url: thumbnailURL) + + let imageResult = try await networkDispatcher.performRequest(urlRequest) + + switch imageResult { + case .success(let data): + guard let thumbnailImage = UIImage(data: data) else { throw NetworkError.emptyData} + ImageCacheManager.shared.setObject(thumbnailImage, forKey: cacheKey) + return thumbnailImage + case .failure(let error): + print(error.errorDescription) + return nil + } + } + // MARK: - Private private func fetchGameList(of listKind: dummyConstants) -> GameListDTO? { guard let dataUrl = Bundle.main.url( diff --git a/GameScope/GameScope/View/GameDetailDescriptionCell.swift b/GameScope/GameScope/View/GameDetailDescriptionCell.swift new file mode 100644 index 0000000..a47d495 --- /dev/null +++ b/GameScope/GameScope/View/GameDetailDescriptionCell.swift @@ -0,0 +1,99 @@ +// +// GameDetailDescriptionCell.swift +// GameScope +// +// Created by 박재우 on 2023/07/16. +// + +import UIKit + +class GameDetailDescriptionCell: UICollectionViewCell { + + static let reuseIdentifier = "GameDetailDescriptionCell" + + private let descriptionLabel = { + let label = UILabel() + label.numberOfLines = 3 + label.sizeToFit() + return label + }() + private let readMoreButton = { + let button = UIButton() + button.isHidden = true + button.setTitle("+ Read More", for: .normal) + button.setTitleColor(#colorLiteral(red: 0.2980392157, green: 0.8196078431, blue: 0.6980392157, alpha: 1), for: .normal) + return button + }() + private let descriptionStackView = { + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .leading + stackView.spacing = 5 + stackView.distribution = .fill + return stackView + }() + private var isHiddenReadMoreButton = false { + willSet { + readMoreButton.isHidden = newValue + } + } + + weak var delegate: GameDetailDescriptionCellDelegate? + + override func layoutSubviews() { + setupHiddenReadMoreButtonIfNeeded() + } + + func configure(description: String) { + addSubview(descriptionStackView) + descriptionStackView.addArrangedSubview(descriptionLabel) + descriptionStackView.addArrangedSubview(readMoreButton) + descriptionStackView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.lessThanOrEqualToSuperview() + } + + descriptionLabel.text = description + readMoreButton.addTarget(self, + action: #selector(tappedReadMoreButton), + for: .touchDown) + } + + @objc private func tappedReadMoreButton(_ sender: UIButton) { + delegate?.gameDetailDescriptionCell(self, didButtonTapped: sender) + } + + private func setupHiddenReadMoreButtonIfNeeded() { + guard isHiddenReadMoreButton == false, + descriptionLabel.maxNumberOfLines > 3 else { + return + } + + readMoreButton.isHidden = false + } + + func expandDetailDescription() { + isHiddenReadMoreButton = true + descriptionLabel.numberOfLines = 0 + } +} + +private extension UILabel { + var maxNumberOfLines: Int { + let maxSize = CGSize(width: frame.size.width, height: .infinity) + let text = (self.text ?? "") as NSString + let textHeight = text.boundingRect(with: maxSize, + options: .usesLineFragmentOrigin, + attributes: [.font: font as Any], + context: nil).height + let lineHeight = font.lineHeight + return Int(ceil(textHeight / lineHeight)) + } +} + +protocol GameDetailDescriptionCellDelegate: NSObject { + func gameDetailDescriptionCell( + _ gameDetailDescriptionCell: GameDetailDescriptionCell, + didButtonTapped sender: UIButton + ) +} diff --git a/GameScope/GameScope/View/GameDetailImageCell.swift b/GameScope/GameScope/View/GameDetailImageCell.swift new file mode 100644 index 0000000..07160d6 --- /dev/null +++ b/GameScope/GameScope/View/GameDetailImageCell.swift @@ -0,0 +1,47 @@ +// +// GameDetailImageCell.swift +// GameScope +// +// Created by 박재우 on 2023/07/24. +// + +import UIKit + +class GameDetailImageCell: UICollectionViewCell { + + static let reuseIdentifier = "GameDetailImageCell" + + private let imageView = { + let imageView = UIImageView() + imageView.backgroundColor = #colorLiteral(red: 0.7496843338, green: 0.7496843338, blue: 0.7496843338, alpha: 1) + imageView.contentMode = .scaleToFill + return imageView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayout() { + layer.cornerRadius = 20 + layer.masksToBounds = true + + addSubview(imageView) + + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func configure(urlString: String) { + Task { + let image = try await GameManager().dispatchImage(of: urlString) + imageView.image = image + } + } +} diff --git a/GameScope/GameScope/View/GameDetailInformationCell.swift b/GameScope/GameScope/View/GameDetailInformationCell.swift new file mode 100644 index 0000000..15f8090 --- /dev/null +++ b/GameScope/GameScope/View/GameDetailInformationCell.swift @@ -0,0 +1,83 @@ +// +// GameDetailInformationCell.swift +// GameScope +// +// Created by 박재우 on 2023/07/13. +// + +import UIKit + +class GameDetailInformationCell: UICollectionViewCell { + + static let reuseIdentifier = "GameDetailInformationCell" + + enum Information: String { + case genre = "Genre" + case developer = "Developer" + case releaseDate = "Release Date" + case publisher = "Publisher" + } + + private let genreInformationView: InformationView = { + let informationView = InformationView() + informationView.titleLabel.text = Information.genre.rawValue + return informationView + }() + private let developerInformationView: InformationView = { + let informationView = InformationView() + informationView.titleLabel.text = Information.developer.rawValue + return informationView + }() + private let releaseDateInformationView: InformationView = { + let informationView = InformationView() + informationView.titleLabel.text = Information.releaseDate.rawValue + return informationView + }() + private let publisherInformationView: InformationView = { + let informationView = InformationView() + informationView.titleLabel.text = Information.publisher.rawValue + return informationView + }() + + override func layoutSubviews() { + backgroundColor = #colorLiteral(red: 0.9568627451, green: 0.9568627451, blue: 0.9568627451, alpha: 1) + + addSubview(genreInformationView) + addSubview(developerInformationView) + addSubview(releaseDateInformationView) + addSubview(publisherInformationView) + + genreInformationView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(5) + make.leading.equalToSuperview().inset(15) + make.trailing.equalTo(snp.centerX).inset(5) + make.bottom.equalTo(snp.centerY).offset(-5) + } + developerInformationView.snp.makeConstraints { make in + make.top.equalToSuperview().inset(5) + make.leading.equalTo(snp.centerX).inset(5) + make.trailing.equalToSuperview().inset(10) + make.bottom.equalTo(snp.centerY).offset(-5) + } + releaseDateInformationView.snp.makeConstraints { make in + make.top.equalTo(snp.centerY).offset(5) + make.leading.equalToSuperview().inset(15) + make.trailing.equalTo(snp.centerX).inset(5) + make.bottom.equalToSuperview().inset(5) + } + publisherInformationView.snp.makeConstraints { make in + make.top.equalTo(snp.centerY).offset(5) + make.leading.equalTo(snp.centerX).inset(5) + make.trailing.equalToSuperview().inset(10) + make.bottom.equalToSuperview().inset(5) + } + } + + func configure(information: GameDetail) { + genreInformationView.informationLabel.text = information.genre + developerInformationView.informationLabel.text = information.developer + releaseDateInformationView.informationLabel.text = information.releaseDate + publisherInformationView.informationLabel.text = information.publisher + } + +} diff --git a/GameScope/GameScope/HeaderView.swift b/GameScope/GameScope/View/HeaderView.swift similarity index 78% rename from GameScope/GameScope/HeaderView.swift rename to GameScope/GameScope/View/HeaderView.swift index 8453a06..109a9e7 100644 --- a/GameScope/GameScope/HeaderView.swift +++ b/GameScope/GameScope/View/HeaderView.swift @@ -16,7 +16,8 @@ class HeaderView: UICollectionReusableView { addSubview(label) label.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(20) + make.leading.trailing.equalToSuperview().inset(20) + make.top.bottom.equalToSuperview() } label.font = .preferredFont(forTextStyle: .title2) diff --git a/GameScope/GameScope/View/InformationView.swift b/GameScope/GameScope/View/InformationView.swift new file mode 100644 index 0000000..3721f99 --- /dev/null +++ b/GameScope/GameScope/View/InformationView.swift @@ -0,0 +1,28 @@ +// +// InformationView.swift +// GameScope +// +// Created by 박재우 on 2023/07/13. +// + +import UIKit + +class InformationView: UIStackView { + + let titleLabel = UILabel() + let informationLabel = UILabel() + + override func layoutSubviews() { + addArrangedSubview(titleLabel) + addArrangedSubview(informationLabel) + + axis = .vertical + distribution = .fillEqually + + titleLabel.font = .preferredFont(forTextStyle: .title3) + titleLabel.textColor = #colorLiteral(red: 0.3450980392, green: 0.3450980392, blue: 0.3450980392, alpha: 1) + + informationLabel.font = .preferredFont(forTextStyle: .title3) + } + +}