Skip to content

Commit f4f4082

Browse files
authored
Merge pull request #4 from MoonGoon72/feat/main-view#3
캐릭터 리스트 뷰 구현
2 parents 530689a + af6f1eb commit f4f4082

File tree

17 files changed

+470
-38
lines changed

17 files changed

+470
-38
lines changed

Tekken8 Frame Data/Tekken8 Frame Data.xcodeproj/project.pbxproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
E41737912D8BD84200FE79E1 /* Exceptions for "Tekken8 Frame Data" folder in "SupabaseAPITests" target */ = {
3939
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
4040
membershipExceptions = (
41-
Model/Character.swift,
41+
Character/Model/Character.swift,
4242
Model/Move.swift,
4343
Network/Supabase/SupabaseManageable.swift,
4444
Network/Supabase/SupabaseManager.swift,

Tekken8 Frame Data/Tekken8 Frame Data/App/SceneDelegate.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
1414
guard let windowScene = (scene as? UIWindowScene) else { return }
1515

1616
let window = UIWindow(windowScene: windowScene)
17-
let viewController = MainViewController()
17+
let rootViewController = UINavigationController(rootViewController: CharacterListViewController())
18+
let viewController = rootViewController
1819
window.rootViewController = viewController
1920

2021
self.window = window
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "mokujin.png",
5+
"idiom" : "universal",
6+
"scale" : "1x"
7+
},
8+
{
9+
"idiom" : "universal",
10+
"scale" : "2x"
11+
},
12+
{
13+
"idiom" : "universal",
14+
"scale" : "3x"
15+
}
16+
],
17+
"info" : {
18+
"author" : "xcode",
19+
"version" : 1
20+
}
21+
}
26.1 KB
Loading
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
//
2+
// CharacterListViewController.swift
3+
// Tekken8 Frame Data
4+
//
5+
// Created by 문영균 on 3/23/25.
6+
//
7+
8+
import Combine
9+
import SwiftUI
10+
import UIKit
11+
12+
final class CharacterListViewController: UIViewController {
13+
private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, Character>
14+
private typealias CharacterDataSource = UICollectionViewDiffableDataSource<Section, Character>
15+
16+
private let supabaseManager = SupabaseManager()
17+
private let characterCollectionView: CharacterCollectionView
18+
private let characterViewModel = CharacterListViewModel()
19+
private var filteredCancellable: AnyCancellable?
20+
private var dataSource: CharacterDataSource?
21+
22+
init() {
23+
characterCollectionView = CharacterCollectionView()
24+
25+
super.init(nibName: nil, bundle: nil)
26+
}
27+
28+
required init?(coder: NSCoder) {
29+
characterCollectionView = CharacterCollectionView()
30+
31+
super.init(nibName: nil, bundle: nil)
32+
}
33+
34+
override func viewDidLoad() {
35+
super.viewDidLoad()
36+
37+
setupDiffalbeDataSource()
38+
setupSearchController()
39+
setupDelegation()
40+
}
41+
42+
override func loadView() {
43+
super.loadView()
44+
45+
view = characterCollectionView
46+
fetchCharacters()
47+
bindViewModel()
48+
}
49+
50+
private func setupDelegation() {
51+
characterCollectionView.setCollectionViewDelegate(self)
52+
}
53+
54+
private func fetchCharacters() {
55+
Task {
56+
characterViewModel.fetchCharacters(using: supabaseManager)
57+
}
58+
}
59+
60+
private func bindViewModel() {
61+
filteredCancellable = characterViewModel
62+
.$filteredCharacters
63+
.receive(on: DispatchQueue.main)
64+
.sink { [weak self] filteredCharacters in
65+
self?.updateSnapshot(for: filteredCharacters)
66+
}
67+
}
68+
}
69+
70+
// MARK: - UISearchController method
71+
72+
private extension CharacterListViewController {
73+
func setupSearchController() {
74+
let searchController = UISearchController(searchResultsController: nil)
75+
76+
searchController.delegate = self
77+
searchController.searchResultsUpdater = self
78+
searchController.searchBar.placeholder = Texts.placeholder
79+
navigationItem.searchController = searchController
80+
}
81+
}
82+
83+
// MARK: UISearchController conformance
84+
85+
extension CharacterListViewController: UISearchControllerDelegate {
86+
func willDismissSearchController(_ searchController: UISearchController) {
87+
characterViewModel.resetFilter()
88+
}
89+
}
90+
91+
// MARK: UISearchBar conformance
92+
93+
extension CharacterListViewController: UISearchBarDelegate {
94+
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
95+
searchBar.resignFirstResponder()
96+
}
97+
}
98+
99+
// MARK: UISearchResultsUpdating conformance
100+
101+
extension CharacterListViewController: UISearchResultsUpdating {
102+
func updateSearchResults(for searchController: UISearchController) {
103+
guard let text = searchController.searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines) else { return }
104+
105+
characterViewModel.filter(by: text)
106+
}
107+
}
108+
109+
// MARK: - UICollectionViewDiffableDataSource method
110+
111+
private extension CharacterListViewController {
112+
func setupDiffalbeDataSource() {
113+
dataSource = CharacterDataSource( collectionView: characterCollectionView.characterCollectionView)
114+
{ collectionView, indexPath, itemIdentifier in
115+
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CharacterCell.reuseIdentifier, for: indexPath)
116+
cell.contentConfiguration = UIHostingConfiguration {
117+
CharacterCell(character: itemIdentifier, viewModel: self.characterViewModel)
118+
}
119+
cell.layer.borderWidth = 0.5
120+
cell.layer.cornerRadius = 5
121+
return cell
122+
}
123+
}
124+
125+
func updateSnapshot(for characters: [Character]) {
126+
var snapshot = Snapshot()
127+
snapshot.appendSections([.main])
128+
snapshot.appendItems(characters)
129+
dataSource?.apply(snapshot, animatingDifferences: false)
130+
}
131+
}
132+
133+
// MARK: - UICollectionViewDelegate Conformance
134+
135+
extension CharacterListViewController: UICollectionViewDelegate {
136+
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
137+
print(characterViewModel.characters[indexPath.row])
138+
// 네비게이션
139+
}
140+
}
141+
142+
private enum Section {
143+
case main
144+
}
145+
146+
private extension CharacterListViewController {
147+
enum Texts {
148+
static let placeholder = "캐릭터 이름을 입력해주세요."
149+
}
150+
}

Tekken8 Frame Data/Tekken8 Frame Data/Model/Character.swift renamed to Tekken8 Frame Data/Tekken8 Frame Data/Character/Model/Character.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77

88
import Foundation
99

10-
struct Character: Decodable {
10+
struct Character: Decodable, Hashable {
1111
let id: Int
1212
let name: String
13+
let imageURL: String
1314

1415
enum CodingKeys: String, CodingKey {
1516
case id
1617
case name
18+
case imageURL = "image_url"
1719
}
1820
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
//
2+
// CharacterCell.swift
3+
// Tekken8 Frame Data
4+
//
5+
// Created by 문영균 on 3/28/25.
6+
//
7+
8+
import Foundation
9+
import SwiftUI
10+
11+
struct CharacterCell: View, ReuseIdentifiable {
12+
var character: Character
13+
@ObservedObject var viewModel: CharacterListViewModel
14+
15+
var body: some View {
16+
HStack(alignment: .center) {
17+
if let image = viewModel.image(for: character) {
18+
Image(uiImage: image)
19+
.resizable()
20+
.scaledToFit()
21+
.frame(height: Constants.Literals.characterImageHeight)
22+
.clipShape(.rect(cornerRadius: Constants.Literals.characterImageCornerRadius))
23+
} else {
24+
Image("mokujin")
25+
.resizable()
26+
.scaledToFit()
27+
.frame(height: Constants.Literals.characterImageHeight)
28+
.clipShape(.rect(cornerRadius: Constants.Literals.characterImageCornerRadius))
29+
}
30+
Text(character.name)
31+
.font(.title2)
32+
.padding(.leading, Constants.Literals.cellPadding)
33+
Spacer()
34+
Image(systemName: "chevron.right")
35+
.padding(.trailing, Constants.Literals.cellPadding)
36+
}
37+
}
38+
}
39+
40+
private enum Constants {
41+
enum Literals {
42+
static let characterImageHeight = 80.0
43+
static let characterImageCornerRadius = 15.0
44+
static let cellPadding = 5.0
45+
}
46+
}
47+
48+
#Preview {
49+
CharacterCell(character: Character(id: 1, name: "니나 윌리엄스", imageURL: "https://i.ibb.co/GXN7B5k/nina.png"), viewModel: CharacterListViewModel())
50+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//
2+
// CharacterCollectionView.swift
3+
// Tekken8 Frame Data
4+
//
5+
// Created by 문영균 on 3/27/25.
6+
//
7+
8+
import UIKit
9+
10+
final class CharacterCollectionView: UIView {
11+
12+
// MARK: Subviews
13+
14+
let characterCollectionView: UICollectionView = {
15+
let layout = UICollectionViewFlowLayout()
16+
layout.scrollDirection = .vertical
17+
layout.itemSize = .init(width: UIScreen.main.bounds.width - 32, height: 80)
18+
layout.minimumLineSpacing = 8
19+
layout.sectionInset = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
20+
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
21+
22+
collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: CharacterCell.reuseIdentifier)
23+
return collectionView
24+
}()
25+
26+
override init(frame: CGRect) {
27+
super.init(frame: frame)
28+
29+
setupSubViews()
30+
setupLayout()
31+
}
32+
33+
required init?(coder: NSCoder) {
34+
super.init(coder: coder)
35+
36+
setupSubViews()
37+
setupLayout()
38+
}
39+
40+
private func setupSubViews() {
41+
addSubview(characterCollectionView)
42+
}
43+
44+
private func setupLayout() {
45+
setupCollectionViewLayouts()
46+
}
47+
48+
// MARK: Custom method
49+
50+
func setCollectionViewDelegate(_ delegate: UICollectionViewDelegate) {
51+
characterCollectionView.delegate = delegate
52+
}
53+
}
54+
55+
private extension CharacterCollectionView {
56+
func setupCollectionViewLayouts() {
57+
characterCollectionView.translatesAutoresizingMaskIntoConstraints = false
58+
59+
NSLayoutConstraint.activate([
60+
characterCollectionView.topAnchor.constraint(equalTo: self.topAnchor),
61+
characterCollectionView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
62+
characterCollectionView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
63+
characterCollectionView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
64+
])
65+
}
66+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//
2+
// CharacterListViewModel.swift
3+
// Tekken8 Frame Data
4+
//
5+
// Created by 문영균 on 4/3/25.
6+
//
7+
8+
import Foundation
9+
import UIKit
10+
11+
@MainActor
12+
final class CharacterListViewModel: ObservableObject {
13+
@Published private(set) var characterImages: [Int: UIImage] = [:]
14+
@Published private(set) var filteredCharacters: [Character] = []
15+
private(set) var characters: [Character] = []
16+
17+
func fetchCharacters(using manager: SupabaseManageable) {
18+
Task {
19+
do {
20+
let fetchedCharacters: [Character] = try await manager.fetchCharacter()
21+
characters = fetchedCharacters
22+
filteredCharacters = characters
23+
for character in characters {
24+
loadImage(for: character)
25+
}
26+
} catch {
27+
NSLog("❌ Error fetching characters: \(error)")
28+
}
29+
}
30+
}
31+
32+
func filter(by keyword: String) {
33+
if keyword.isEmpty {
34+
filteredCharacters = characters
35+
} else {
36+
filteredCharacters = characters.filter { $0.name.contains(keyword) }
37+
}
38+
}
39+
40+
func resetFilter() {
41+
filteredCharacters = characters
42+
}
43+
44+
func loadImage(for character: Character) {
45+
Task {
46+
if let image = await ImageCacheManager.shared.fetch(for: character.imageURL) {
47+
characterImages[character.id] = image
48+
}
49+
}
50+
}
51+
52+
func image(for character: Character) -> UIImage? {
53+
return characterImages[character.id]
54+
}
55+
}

Tekken8 Frame Data/Tekken8 Frame Data/Network/Supabase/SupabaseManageable.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@
88
import Foundation
99

1010
protocol SupabaseManageable {
11+
func fetchCharacter() async throws -> [Character]
1112
func fetchFrame() async throws -> [Move]
1213
}

0 commit comments

Comments
 (0)