diff --git a/iBox/Sources/Model/Bookmark.swift b/iBox/Sources/Model/Bookmark.swift new file mode 100644 index 0000000..2091151 --- /dev/null +++ b/iBox/Sources/Model/Bookmark.swift @@ -0,0 +1,13 @@ +// +// Bookmark.swift +// iBox +// +// Created by 이지현 on 1/30/24. +// + +import Foundation + +struct Bookmark: Codable { + let name: String + let url: String +} diff --git a/iBox/Sources/Model/Folder.swift b/iBox/Sources/Model/Folder.swift index cbb30cf..12f21e6 100644 --- a/iBox/Sources/Model/Folder.swift +++ b/iBox/Sources/Model/Folder.swift @@ -10,11 +10,7 @@ import Foundation struct Folder { let name: String let color: ColorName - let webs: [Web] + let bookmarks: [Bookmark] var isOpened: Bool = true } -struct Web: Codable { - let name: String - let url: String -} diff --git a/iBox/Sources/Presenter/BoxList/BoxListCell.swift b/iBox/Sources/Presenter/BoxList/BoxListCell.swift new file mode 100644 index 0000000..d457aac --- /dev/null +++ b/iBox/Sources/Presenter/BoxList/BoxListCell.swift @@ -0,0 +1,60 @@ +// +// BoxListCell.swift +// iBox +// +// Created by 이지현 on 1/30/24. +// + +import UIKit + +import SnapKit + +class BoxListCell: UITableViewCell { + var viewModel: BoxListCellViewModel? + static let reuseIdentifier = "boxListCell" + + private lazy var cellImageView = { + let view = UIImageView() + view.image = UIImage(systemName: "ellipsis.rectangle.fill") + view.tintColor = .label + view.contentMode = .scaleAspectFit + return view + }() + + private lazy var label = { + let label = UILabel() + return label + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .systemGroupedBackground + + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupLayout() { + contentView.addSubview(cellImageView) + cellImageView.snp.makeConstraints { make in + make.leading.equalToSuperview().inset(20) + make.top.bottom.equalToSuperview().inset(10) + make.width.equalTo(30) + } + + contentView.addSubview(label) + label.snp.makeConstraints { make in + make.top.trailing.bottom.equalToSuperview() + make.leading.equalTo(cellImageView.snp.trailing).offset(10) + } + } + + func configure(_ viewModel: BoxListCellViewModel) { + self.viewModel = viewModel + label.text = viewModel.name + } + +} diff --git a/iBox/Sources/Presenter/BoxList/BoxListView.swift b/iBox/Sources/Presenter/BoxList/BoxListView.swift index 2c6d5b9..9a7bddb 100644 --- a/iBox/Sources/Presenter/BoxList/BoxListView.swift +++ b/iBox/Sources/Presenter/BoxList/BoxListView.swift @@ -5,6 +5,7 @@ // Created by 이지현 on 1/3/24. // +import Combine import UIKit import SnapKit @@ -14,18 +15,27 @@ protocol BoxListViewDelegate: AnyObject { } class BoxListView: BaseView { + var viewModel: BoxListViewModel? + private var boxListDataSource: UITableViewDiffableDataSource! weak var delegate: BoxListViewDelegate? - var folderArr = [ - Folder(name: "기본 폴더", color: .gray, webs: [ - Web(name: "42 Intra", url: "https://profile.intra.42.fr/"), - Web(name: "42Where", url: "https://www.where42.kr/"), - Web(name: "42Stat", url: "https://stat.42seoul.kr/"), - Web(name: "집현전", url: "https://42library.kr/") - ]), - Folder(name: "새 폴더", color: .green, webs: [Web(name: "Cabi", url: "https://cabi.42seoul.io/")], isOpened: false), - Folder(name: "새 폴더(2)", color: .yellow, webs: [Web(name: "24HANE", url: "https://24hoursarenotenough.42seoul.kr/")], isOpened: false) - ] + private var cancellables = Set() + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .systemBackground + viewModel = BoxListViewModel() + + setupLayout() + configureDataSource() + bindViewModel() + viewModel?.input.send(.viewDidLoad) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } private lazy var backgroundView = { let view = UIView() @@ -43,29 +53,17 @@ class BoxListView: BaseView { private lazy var tableView = { let tableView = UITableView() - tableView.dataSource = self tableView.delegate = self - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") + tableView.register(BoxListCell.self, forCellReuseIdentifier: BoxListCell.reuseIdentifier) tableView.sectionHeaderTopPadding = 0 -// tableView.separatorInset = UIEdgeInsets(top: 0, left: 15, bottom: 0, right: 15) tableView.clipsToBounds = true tableView.layer.cornerRadius = 20 tableView.backgroundColor = .clear tableView.separatorColor = .clear + tableView.rowHeight = 50 return tableView }() - - override init(frame: CGRect) { - super.init(frame: frame) - backgroundColor = .systemBackground - - setupLayout() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } private func setupLayout() { addSubview(backgroundView) @@ -74,31 +72,37 @@ class BoxListView: BaseView { make.leading.trailing.bottom.equalTo(safeAreaLayoutGuide).inset(20) } } -} - -extension BoxListView: UITableViewDataSource { - func numberOfSections(in tableView: UITableView) -> Int { - return folderArr.count + private func configureDataSource() { + boxListDataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, itemIdentifier in + guard let self, let viewModel = self.viewModel else { fatalError() } + guard let cell = tableView.dequeueReusableCell(withIdentifier: BoxListCell.reuseIdentifier, for: indexPath) as? BoxListCell else { fatalError() } + cell.configure(viewModel.viewModel(at: indexPath)) + return cell + } } - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - if !folderArr[section].isOpened { - return 0 + private func applySnapshot(with: [BoxListSectionViewModel]) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections(with.map{ $0.id }) + for folder in with { + snapshot.appendItems(folder.boxListCellViewModels.map { $0.id }, toSection: folder.id) } - return folderArr[section].webs.count + boxListDataSource.apply(snapshot, animatingDifferences: true) } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) - cell.selectionStyle = .none - cell.backgroundColor = .clear - cell.textLabel?.text = folderArr[indexPath.section].webs[indexPath.row].name - cell.imageView?.image = UIImage(systemName: "ellipsis.rectangle.fill") - cell.imageView?.tintColor = ColorPalette.webIconColor - return cell + private func bindViewModel() { + guard let viewModel else { return } + let output = viewModel.transform(input: viewModel.input.eraseToAnyPublisher()) + + output.receive(on: DispatchQueue.main) + .sink { [weak self] event in + switch event { + case .sendBoxList(boxList: let boxList): + self?.applySnapshot(with: boxList) + } + }.store(in: &cancellables) } - } extension BoxListView: UITableViewDelegate { @@ -125,9 +129,10 @@ extension BoxListView: UITableViewDelegate { } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { - let button = FolderButton(isOpen: folderArr[section].isOpened) - button.setFolderName(folderArr[section].name) - button.setFolderColor(folderArr[section].color.toUIColor()) + guard let viewModel else { return nil } + let button = FolderButton(isOpen: viewModel.boxList[section].isOpened) + button.setFolderName(viewModel.boxList[section].name) + button.setFolderColor(viewModel.boxList[section].color.toUIColor()) button.tag = section button.addTarget(self, action: #selector(handleOpenClose), for: .touchUpInside) @@ -136,27 +141,15 @@ extension BoxListView: UITableViewDelegate { } @objc private func handleOpenClose(button: FolderButton) { - let section = button.tag - - var indexPaths = [IndexPath]() - for row in folderArr[section].webs.indices { - let indexPath = IndexPath(row: row, section: section) - indexPaths.append(indexPath) - } - - folderArr[section].isOpened.toggle() + guard let viewModel else { return } + viewModel.input.send(.folderTapped(section: button.tag)) button.toggleStatus() - - if folderArr[section].isOpened { - tableView.insertRows(at: indexPaths, with: .fade) - } else { - tableView.deleteRows(at: indexPaths, with: .fade) - } } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let webUrl = folderArr[indexPath.section].webs[indexPath.row].url - let webName = folderArr[indexPath.section].webs[indexPath.row].name + guard let viewModel else { return } + let webUrl = viewModel.boxList[indexPath.section].boxListCellViewModels[indexPath.row].url + let webName = viewModel.boxList[indexPath.section].boxListCellViewModels[indexPath.row].name delegate?.didSelectWeb(at: webUrl, withName: webName) } } diff --git a/iBox/Sources/Utils/UserDefaultsManager.swift b/iBox/Sources/Utils/UserDefaultsManager.swift index a60c061..479734c 100644 --- a/iBox/Sources/Utils/UserDefaultsManager.swift +++ b/iBox/Sources/Utils/UserDefaultsManager.swift @@ -19,7 +19,7 @@ final class UserDefaultsManager { ) static let favorite = UserDefaultValue( key: .favorite, - defaultValue: Web(name: "42 Intra", url: "https://profile.intra.42.fr/") + defaultValue: Bookmark(name: "42 Intra", url: "https://profile.intra.42.fr/") ) } diff --git a/iBox/Sources/ViewModel/BoxListCellViewModel.swift b/iBox/Sources/ViewModel/BoxListCellViewModel.swift new file mode 100644 index 0000000..68d89aa --- /dev/null +++ b/iBox/Sources/ViewModel/BoxListCellViewModel.swift @@ -0,0 +1,27 @@ +// +// BoxListCellViewModel.swift +// iBox +// +// Created by 이지현 on 1/30/24. +// + +import Foundation + +class BoxListCellViewModel: Identifiable { + private let bookmark: Bookmark + + init(bookmark: Bookmark) { + self.bookmark = bookmark + } + + let id = UUID() + + var name: String { + bookmark.name + } + + var url: String { + bookmark.url + } + +} diff --git a/iBox/Sources/ViewModel/BoxListSectionViewModel.swift b/iBox/Sources/ViewModel/BoxListSectionViewModel.swift new file mode 100644 index 0000000..70a928c --- /dev/null +++ b/iBox/Sources/ViewModel/BoxListSectionViewModel.swift @@ -0,0 +1,42 @@ +// +// BoxListSectionViewModel.swift +// iBox +// +// Created by 이지현 on 1/30/24. +// + +import Foundation + +class BoxListSectionViewModel: Identifiable { + private var folder: Folder + private var originalBoxListCellViewModels: [BoxListCellViewModel]! + + init(folder: Folder) { + self.folder = folder + originalBoxListCellViewModels = folder.bookmarks.map { BoxListCellViewModel(bookmark: $0) } + } + + var boxListCellViewModels: [BoxListCellViewModel] { + return isOpened ? originalBoxListCellViewModels : [] + } + + let id = UUID() + + var name: String { + folder.name + } + + var color: ColorName { + folder.color + } + + var isOpened: Bool { + get { + folder.isOpened + } + + set { + folder.isOpened = newValue + } + } +} diff --git a/iBox/Sources/ViewModel/BoxListViewModel.swift b/iBox/Sources/ViewModel/BoxListViewModel.swift new file mode 100644 index 0000000..cb73926 --- /dev/null +++ b/iBox/Sources/ViewModel/BoxListViewModel.swift @@ -0,0 +1,55 @@ +// +// BoxListViewModel.swift +// iBox +// +// Created by 이지현 on 1/30/24. +// + +import Combine +import Foundation + +class BoxListViewModel { + + var boxList = [ + BoxListSectionViewModel(folder: Folder(name: "기본 폴더", color: .gray, bookmarks: [ + Bookmark(name: "42 Intra", url: "https://profile.intra.42.fr/"), + Bookmark(name: "42Where", url: "https://www.where42.kr/"), + Bookmark(name: "42Stat", url: "https://stat.42seoul.kr/"), + Bookmark(name: "집현전", url: "https://42library.kr/") + ])), + BoxListSectionViewModel(folder: Folder(name: "새 폴더", color: .green, bookmarks: [Bookmark(name: "Cabi", url: "https://cabi.42seoul.io/")], isOpened: false)), + BoxListSectionViewModel(folder: Folder(name: "새 폴더(2)", color: .yellow, bookmarks: [Bookmark(name: "24HANE", url: "https://24hoursarenotenough.42seoul.kr/")], isOpened: false)) + ] + + enum Input { + case viewDidLoad + case folderTapped(section: Int) + } + + enum Output { + case sendBoxList(boxList: [BoxListSectionViewModel]) + } + + let input = PassthroughSubject() + private let output = PassthroughSubject() + private var cancellables = Set() + + func transform(input: AnyPublisher) -> AnyPublisher { + input.sink { [weak self] event in + guard let self else { return } + switch event { + case .viewDidLoad: + output.send(.sendBoxList(boxList: boxList)) + case let .folderTapped(section): + boxList[section].isOpened.toggle() + output.send(.sendBoxList(boxList: boxList)) + } + }.store(in: &cancellables) + return output.eraseToAnyPublisher() + } + + func viewModel(at indexPath: IndexPath) -> BoxListCellViewModel { + return boxList[indexPath.section].boxListCellViewModels[indexPath.row] + } + +}