From 1447acf85f1a7a872f84881283631972d2ceaffd Mon Sep 17 00:00:00 2001 From: LeeSeungmin Date: Fri, 6 Oct 2023 17:03:14 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[#35]=20UserCardCollectionView=20UI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TFBaseCollectionViewCell 커스텀 - 임시 UserResponse, UserSection DTO 및 모델 구현 - viewWillAppear 이벤트를 받 trigger input 구현 - 임시 userList output 구현 - state -> timeState --- .../Base/TFBaseCollectionViewCell.swift | 31 +++++ .../API/MainAPI/DTO/UserResponse.swift | 24 ++++ .../Main/Cell/MainCollectionViewCell.swift | 131 ++++++++++++++++++ Falling/Sources/Feature/Main/MainView.swift | 95 ++----------- .../Feature/Main/MainViewController.swift | 45 +++++- .../Sources/Feature/Main/MainViewModel.swift | 27 +++- .../Feature/Main/Subviews/CardTimerView.swift | 14 +- .../Sources/Feature/Main/UserSection.swift | 35 +++++ 8 files changed, 307 insertions(+), 95 deletions(-) create mode 100644 Falling/Sources/Base/TFBaseCollectionViewCell.swift create mode 100644 Falling/Sources/DataLayer/API/MainAPI/DTO/UserResponse.swift create mode 100644 Falling/Sources/Feature/Main/Cell/MainCollectionViewCell.swift create mode 100644 Falling/Sources/Feature/Main/UserSection.swift diff --git a/Falling/Sources/Base/TFBaseCollectionViewCell.swift b/Falling/Sources/Base/TFBaseCollectionViewCell.swift new file mode 100644 index 00000000..99f2c66d --- /dev/null +++ b/Falling/Sources/Base/TFBaseCollectionViewCell.swift @@ -0,0 +1,31 @@ +// +// TFBaseCollectionViewCell.swift +// Falling +// +// Created by SeungMin on 2023/10/02. +// + +import UIKit + +import RxSwift + +class TFBaseCollectionViewCell: UICollectionViewCell { + + var disposeBag = DisposeBag() + + override init(frame: CGRect) { + super.init(frame: frame) + makeUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + self.disposeBag = DisposeBag() + } + + func makeUI() { } +} diff --git a/Falling/Sources/DataLayer/API/MainAPI/DTO/UserResponse.swift b/Falling/Sources/DataLayer/API/MainAPI/DTO/UserResponse.swift new file mode 100644 index 00000000..49aef964 --- /dev/null +++ b/Falling/Sources/DataLayer/API/MainAPI/DTO/UserResponse.swift @@ -0,0 +1,24 @@ +// +// UserResponse.swift +// Falling +// +// Created by SeungMin on 2023/10/06. +// + +struct UserResponse: Codable { + let userList: [UserDTO] +} + +struct UserDTO: Codable { + let userIdx: Int +// enum CodingKeys: CodingKey { +// +// } +} + +struct UserSectionMapper { + static func map(list: [UserDTO]) -> [UserSection] { + let mutableSection: [UserSection] = [] + return mutableSection + } +} diff --git a/Falling/Sources/Feature/Main/Cell/MainCollectionViewCell.swift b/Falling/Sources/Feature/Main/Cell/MainCollectionViewCell.swift new file mode 100644 index 00000000..62dacda1 --- /dev/null +++ b/Falling/Sources/Feature/Main/Cell/MainCollectionViewCell.swift @@ -0,0 +1,131 @@ +// +// UserCollectionViewCell.swift +// Falling +// +// Created by SeungMin on 2023/10/02. +// + +import UIKit +import RxSwift + +final class MainCollectionViewCell: TFBaseCollectionViewCell { + + lazy var userImageView: UIImageView = { + let imageView = UIImageView() + imageView.image = .add + return imageView + }() + + lazy var userContentView: UIView = { + let view = UIView() + view.backgroundColor = FallingAsset.Color.clear.color + return view + }() + + lazy var progressContainerView: UIView = { + let view = UIView() + view.layer.cornerRadius = 15 + view.backgroundColor = FallingAsset.Color.dimColor.color.withAlphaComponent(0.5) + return view + }() + + lazy var timerView = CardTimerView() + + lazy var progressView = CardProgressView() + + override func layoutSubviews() { + self.backgroundColor = .systemGray + } + + override func makeUI() { + // TODO: cornerRadius 동적으로 설정해야 할 것. + self.layer.cornerRadius = 15 + + self.addSubview(userImageView) + self.addSubview(userContentView) + + self.userContentView.addSubview(progressContainerView) + + self.progressContainerView.addSubviews([ + timerView, + progressView + ]) + + self.userImageView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.equalToSuperview() + $0.bottom.equalToSuperview() + $0.trailing.equalToSuperview() + } + + self.userContentView.snp.makeConstraints { + $0.top.equalToSuperview() + $0.leading.equalToSuperview() + $0.bottom.equalToSuperview() + $0.trailing.equalToSuperview() + } + + self.progressContainerView.snp.makeConstraints { + $0.top.equalTo(self.safeAreaLayoutGuide).inset(12) + $0.leading.trailing.equalToSuperview().inset(12) + $0.height.equalTo(32) + } + + self.timerView.snp.makeConstraints { + $0.leading.equalToSuperview().inset(9) + $0.centerY.equalToSuperview() + $0.width.equalTo(22) + $0.height.equalTo(22) + } + + self.progressView.snp.makeConstraints { + $0.leading.equalTo(timerView.snp.trailing).offset(9) + $0.trailing.equalToSuperview().inset(12) + $0.centerY.equalToSuperview() + $0.height.equalTo(6) + } + } + + func dotPosition(progress: Double, rect: CGRect) -> CGPoint { + var progress = progress + // progress가 -0.05미만 혹은 1이상은 점(dot)을 0초에 위치시키기 위함 + let strokeRange: Range = -0.05..<0.95 + if !(strokeRange ~= progress) { progress = 0.95 } + let radius = CGFloat(rect.height / 2 - timerView.strokeLayer.lineWidth / 2) + let angle = 2 * CGFloat.pi * CGFloat(progress) - CGFloat.pi / 2 + let dotX = radius * cos(angle + 0.35) + let dotY = radius * sin(angle + 0.35) + + let point = CGPoint(x: dotX, y: dotY) + + return CGPoint( + x: rect.midX + point.x, + y: rect.midY + point.y + ) + } +} + +extension Reactive where Base: MainCollectionViewCell { + var timeState: Binder { + return Binder(self.base) { (base, timeState) in + base.timerView.trackLayer.strokeColor = timeState.fillColor.color.cgColor + base.timerView.strokeLayer.strokeColor = timeState.color.color.cgColor + base.timerView.dotLayer.strokeColor = timeState.color.color.cgColor + base.timerView.dotLayer.fillColor = timeState.color.color.cgColor + base.timerView.timerLabel.textColor = timeState.color.color + base.progressView.progressBarColor = timeState.color.color + + base.timerView.dotLayer.isHidden = timeState.isDotHidden + + base.timerView.timerLabel.text = timeState.getText + + base.progressView.progress = CGFloat(timeState.getProgress) + + // TimerView Animation은 소수점 둘째 자리까지 표시해야 오차가 발생하지 않음 + let strokeEnd = round(CGFloat(timeState.getProgress) * 100) / 100 + base.timerView.dotLayer.position = base.dotPosition(progress: strokeEnd, rect: base.timerView.bounds) + + base.timerView.strokeLayer.strokeEnd = strokeEnd + } + } +} diff --git a/Falling/Sources/Feature/Main/MainView.swift b/Falling/Sources/Feature/Main/MainView.swift index 9abb159a..cb2f5389 100644 --- a/Falling/Sources/Feature/Main/MainView.swift +++ b/Falling/Sources/Feature/Main/MainView.swift @@ -12,90 +12,25 @@ import SnapKit final class MainView: TFBaseView { - lazy var backgroundView: UIView = { - let v = UIView() - v.layer.cornerRadius = 15 - v.backgroundColor = FallingAsset.Color.dimColor.color.withAlphaComponent(0.5) - return v + lazy var collectionView: UICollectionView = { + let flowLayout = UICollectionViewFlowLayout() + flowLayout.minimumLineSpacing = 14 + flowLayout.scrollDirection = .vertical + let collectionView = UICollectionView(frame: .zero, + collectionViewLayout: flowLayout) + collectionView.register(cellType: MainCollectionViewCell.self) + collectionView.backgroundColor = FallingAsset.Color.neutral700.color + return collectionView }() - lazy var timerView = CardTimerView() - - lazy var progressView = CardProgressView() - - override func layoutSubviews() { - self.backgroundColor = .systemGray - } - override func makeUI() { - self.addSubview(backgroundView) - - backgroundView.addSubviews([ - timerView, - progressView - ]) - - backgroundView.snp.makeConstraints { - $0.top.equalTo(self.safeAreaLayoutGuide).inset(12) - $0.leading.trailing.equalToSuperview().inset(12) - $0.height.equalTo(32) - } - - timerView.snp.makeConstraints { - $0.leading.equalToSuperview().inset(9) - $0.centerY.equalToSuperview() - $0.width.equalTo(22) - $0.height.equalTo(22) - } + self.addSubview(collectionView) - progressView.snp.makeConstraints { - $0.leading.equalTo(timerView.snp.trailing).offset(9) - $0.trailing.equalToSuperview().inset(12) - $0.centerY.equalToSuperview() - $0.height.equalTo(6) - } - } - - func dotPosition(progress: Double, rect: CGRect) -> CGPoint { - var progress = progress - // progress가 -0.05미만 혹은 1이상은 점(dot)을 0초에 위치시키기 위함 - let strokeRange: Range = -0.05..<1 - if !(strokeRange ~= progress) { progress = 0.95 } - let radius = CGFloat(rect.height / 2 - timerView.strokeLayer.lineWidth / 2) - let angle = 2 * CGFloat.pi * CGFloat(progress) - CGFloat.pi / 2 - let dotX = radius * cos(angle + 0.35) - let dotY = radius * sin(angle + 0.35) - - let point = CGPoint(x: dotX, y: dotY) - - return CGPoint( - x: rect.midX + point.x, - y: rect.midY + point.y - ) - } -} - -extension Reactive where Base: MainView { - var timeState: Binder { - return Binder(self.base) { (base, state) in - base.timerView.trackLayer.strokeColor = state.fillColor.color.cgColor - base.timerView.strokeLayer.strokeColor = state.color.color.cgColor - base.timerView.dotLayer.strokeColor = state.color.color.cgColor - base.timerView.dotLayer.fillColor = state.color.color.cgColor - base.timerView.timerLabel.textColor = state.color.color - base.progressView.progressBarColor = state.color.color - - base.timerView.dotLayer.isHidden = state.isDotHidden - - base.timerView.timerLabel.text = state.getText - - base.progressView.progress = CGFloat(state.getProgress) - - // TimerView Animation은 소수점 둘째 자리까지 표시해야 오차가 발생하지 않음 - let strokeEnd = round(CGFloat(state.getProgress) * 100) / 100 - base.timerView.dotLayer.position = base.dotPosition(progress: strokeEnd, rect: base.timerView.bounds) - - base.timerView.strokeLayer.strokeEnd = strokeEnd + self.collectionView.snp.makeConstraints { + $0.top.equalToSuperview().inset(8) + $0.leading.equalToSuperview() + $0.bottom.equalToSuperview() + $0.trailing.equalToSuperview() } } } diff --git a/Falling/Sources/Feature/Main/MainViewController.swift b/Falling/Sources/Feature/Main/MainViewController.swift index 29befb34..9554c0c3 100644 --- a/Falling/Sources/Feature/Main/MainViewController.swift +++ b/Falling/Sources/Feature/Main/MainViewController.swift @@ -6,7 +6,10 @@ // import UIKit + import RxCocoa +import RxDataSources +import SwiftUI final class MainViewController: TFBaseViewController { @@ -16,6 +19,7 @@ final class MainViewController: TFBaseViewController { init(viewModel: MainViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) + setupDelegate() } override func loadView() { @@ -40,10 +44,43 @@ final class MainViewController: TFBaseViewController { } override func bindViewModel() { - let output = viewModel.transform(input: MainViewModel.Input()) + let initialTrigger = self.rx.viewWillAppear.map { _ in }.asDriverOnErrorJustEmpty() + + let output = viewModel.transform(input: MainViewModel.Input(trigger: initialTrigger)) + + // output.state + // .drive(mainView.rx.timeState) + // .disposed(by: disposeBag) - output.state - .drive(mainView.rx.timeState) - .disposed(by: disposeBag) + let dataSource = RxCollectionViewSectionedAnimatedDataSource { dataSource, collectionView, indexPath, item in + + let cell = collectionView.dequeueReusableCell(for: indexPath, + cellType: MainCollectionViewCell.self) + output.timeState + .drive(cell.rx.timeState) + .disposed(by: self.disposeBag) + return cell + } + + output.userList + .drive(mainView.collectionView.rx.items(dataSource: dataSource)) + .disposed(by: self.disposeBag) + } + + private func setupDelegate() { + mainView.collectionView.delegate = self + } +} + +extension MainViewController: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return CGSize(width: view.frame.width - 32, + height: (view.frame.width - 32) * 1.64) + } +} + +struct MainViewControllerPreView: PreviewProvider { + static var previews: some View { + MainViewController(viewModel: MainViewModel(navigator: MainNavigator(controller: UINavigationController()))).toPreView() } } diff --git a/Falling/Sources/Feature/Main/MainViewModel.swift b/Falling/Sources/Feature/Main/MainViewModel.swift index 19c904ff..05aceb70 100644 --- a/Falling/Sources/Feature/Main/MainViewModel.swift +++ b/Falling/Sources/Feature/Main/MainViewModel.swift @@ -100,11 +100,12 @@ final class MainViewModel: ViewModelType { var disposeBag: DisposeBag = DisposeBag() struct Input { - + let trigger: Driver } struct Output { - let state: Driver + let userList: Driver<[UserSection]> + let timeState: Driver } init(navigator: MainNavigator) { @@ -112,16 +113,34 @@ final class MainViewModel: ViewModelType { } func transform(input: Input) -> Output { + let listSubject = BehaviorSubject<[UserSection]>(value: []) + + let userSections = [UserSection(header: "ass", + items: [ + UserDTO(userIdx: 0), + UserDTO(userIdx: 1), + UserDTO(userIdx: 2), + ])] + +// let userList = listSubject.onNext(userSections) + +// let refreshResponse = input.trigger.map { +// listSubject.onNext(userSections) +// } + + let userList = Observable.just(userSections).asDriver(onErrorJustReturn: []) + let time = Observable.interval(.milliseconds(10), scheduler: MainScheduler.instance) .take(8 * 100 + 1) .map { round((8 - Double($0) / 100) * 100) / 100 } .asDriver(onErrorDriveWith: Driver.empty()) - let state = time.map { TimeState(rawValue: $0) } + let timeState = time.map { TimeState(rawValue: $0) } return Output( - state: state + userList: userList, + timeState: timeState ) } } diff --git a/Falling/Sources/Feature/Main/Subviews/CardTimerView.swift b/Falling/Sources/Feature/Main/Subviews/CardTimerView.swift index 87f769e4..7bbccee1 100644 --- a/Falling/Sources/Feature/Main/Subviews/CardTimerView.swift +++ b/Falling/Sources/Feature/Main/Subviews/CardTimerView.swift @@ -10,13 +10,13 @@ import UIKit final class CardTimerView: TFBaseView { lazy var timerLabel: UILabel = { - let l = UILabel() - l.font = .thtCaption1M - l.textAlignment = .center - l.backgroundColor = FallingAsset.Color.unSelected.color - l.layer.cornerRadius = 16 / 2 - l.clipsToBounds = true - return l + let label = UILabel() + label.font = .thtCaption1M + label.textAlignment = .center + label.backgroundColor = FallingAsset.Color.unSelected.color + label.layer.cornerRadius = 16 / 2 + label.clipsToBounds = true + return label }() lazy var trackLayer: CAShapeLayer = { diff --git a/Falling/Sources/Feature/Main/UserSection.swift b/Falling/Sources/Feature/Main/UserSection.swift new file mode 100644 index 00000000..870d5065 --- /dev/null +++ b/Falling/Sources/Feature/Main/UserSection.swift @@ -0,0 +1,35 @@ +// +// UserSection.swift +// Falling +// +// Created by SeungMin on 2023/10/06. +// + +import RxDataSources + +struct UserSection { + var header: String + var items: [Item] +} + +extension UserSection: AnimatableSectionModelType { + typealias Item = UserDTO + var identity: String { + return self.header + } + + init(original: UserSection, items: [Item]) { + self = original + self.items = items + } +} + +extension UserDTO: IdentifiableType, Equatable { + static func == (lhs: UserDTO, rhs: UserDTO) -> Bool { + lhs.identity == rhs.identity + } + + var identity: Int { + return userIdx + } +} From c8aa15f7976d6284d4fce428666fc7f863db8bcc Mon Sep 17 00:00:00 2001 From: LeeSeungmin Date: Sat, 7 Oct 2023 17:06:56 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[#35]=20=ED=98=84=EC=9E=AC=20=EB=B3=B4?= =?UTF-8?q?=EC=9D=B4=EB=8A=94=20=EC=9C=A0=EC=A0=80=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=85=80=EC=97=90=EC=84=9C=EC=9D=98=20=ED=83=80=EC=9E=84?= =?UTF-8?q?=EC=9D=B4=200=EC=9D=B4=20=EB=90=98=EB=A9=B4=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=EC=9C=BC=EB=A1=9C=20=EB=8B=A4=EC=9D=8C=20=EC=85=80?= =?UTF-8?q?=EB=A1=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cell 모델 구현 - isTimeOver driver의 파라미터가 true가 되면 delegate로 scrollToNext함수를 호출 - delegate를 준수하는 vc에서 scrollToNext를 호출하면 timeOverTrigger event에서 void를 emit하도록 해서 timeOverTrigger가 emit할 때만 해당 셀에 대한 구독을 생성(bindViewModel) --- .../Main/Cell/MainCollectionViewCell.swift | 32 ++++- .../MainCollectionViewItemViewModel.swift | 132 ++++++++++++++++++ Falling/Sources/Feature/Main/MainView.swift | 1 + .../Feature/Main/MainViewController.swift | 47 ++++++- .../Sources/Feature/Main/MainViewModel.swift | 125 +++-------------- .../Feature/Main/Subviews/CardTimerView.swift | 1 + 6 files changed, 225 insertions(+), 113 deletions(-) create mode 100644 Falling/Sources/Feature/Main/Cell/MainCollectionViewItemViewModel.swift diff --git a/Falling/Sources/Feature/Main/Cell/MainCollectionViewCell.swift b/Falling/Sources/Feature/Main/Cell/MainCollectionViewCell.swift index 62dacda1..c0dc9733 100644 --- a/Falling/Sources/Feature/Main/Cell/MainCollectionViewCell.swift +++ b/Falling/Sources/Feature/Main/Cell/MainCollectionViewCell.swift @@ -8,8 +8,15 @@ import UIKit import RxSwift +@objc protocol TimeOverDelegate: AnyObject { + @objc func scrollToNext() +} + final class MainCollectionViewCell: TFBaseCollectionViewCell { + var viewModel: MainCollectionViewItemViewModel! + weak var delegate: TimeOverDelegate? + lazy var userImageView: UIImageView = { let imageView = UIImageView() imageView.image = .add @@ -86,6 +93,29 @@ final class MainCollectionViewCell: TFBaseCollectionViewCell { } } + override func prepareForReuse() { + super.prepareForReuse() + disposeBag = DisposeBag() + } + + func setup(item: UserDTO) { + viewModel = MainCollectionViewItemViewModel(userDTO: item) + } + + func bindViewModel() { + let output = viewModel.transform(input: MainCollectionViewItemViewModel.Input()) + + output.timeState + .drive(self.rx.timeState) + .disposed(by: self.disposeBag) + + output.isTimeOver + .do { value in + if value { self.delegate?.scrollToNext() } + }.drive() + .disposed(by: self.disposeBag) + } + func dotPosition(progress: Double, rect: CGRect) -> CGPoint { var progress = progress // progress가 -0.05미만 혹은 1이상은 점(dot)을 0초에 위치시키기 위함 @@ -106,7 +136,7 @@ final class MainCollectionViewCell: TFBaseCollectionViewCell { } extension Reactive where Base: MainCollectionViewCell { - var timeState: Binder { + var timeState: Binder { return Binder(self.base) { (base, timeState) in base.timerView.trackLayer.strokeColor = timeState.fillColor.color.cgColor base.timerView.strokeLayer.strokeColor = timeState.color.color.cgColor diff --git a/Falling/Sources/Feature/Main/Cell/MainCollectionViewItemViewModel.swift b/Falling/Sources/Feature/Main/Cell/MainCollectionViewItemViewModel.swift new file mode 100644 index 00000000..3ea8f44a --- /dev/null +++ b/Falling/Sources/Feature/Main/Cell/MainCollectionViewItemViewModel.swift @@ -0,0 +1,132 @@ +// +// MainCollectionViewItemViewModel.swift +// Falling +// +// Created by SeungMin on 2023/10/06. +// + +import Foundation + +import RxSwift +import RxCocoa + +final class MainCollectionViewItemViewModel: ViewModelType { + + let userDTO: UserDTO + + init(userDTO: UserDTO) { + self.userDTO = userDTO + } + + enum TimeState { + case initial(value: Double) // 7~8 + case five(value: Double) // 6~7 + case four(value: Double) // 5~6 + case three(value: Double) // 4~5 + case two(value: Double) // 3~4 + case one(value: Double) // 2~3 + case zero(value: Double) // 1~2 + case over(value: Double) // 0~1 + + init(rawValue: Double) { + switch rawValue { + case 7.0..<8.0: + self = .initial(value: rawValue) + case 6.0..<7.0: + self = .five(value: rawValue) + case 5.0..<6.0: + self = .four(value: rawValue) + case 4.0..<5.0: + self = .three(value: rawValue) + case 3.0..<4.0: + self = .two(value: rawValue) + case 2.0..<3.0: + self = .one(value: rawValue) + case 1.0..<2.0: + self = .zero(value: rawValue) + default: + self = .over(value: rawValue) + } + } + + var color: FallingColors { + switch self { + case .zero, .five: + return FallingAsset.Color.primary500 + case .four: + return FallingAsset.Color.thtOrange100 + case .three: + return FallingAsset.Color.thtOrange200 + case .two: + return FallingAsset.Color.thtOrange300 + case .one: + return FallingAsset.Color.thtRed + default: + return FallingAsset.Color.neutral300 + } + } + + var isDotHidden: Bool { + switch self { + case .initial, .over: + return true + default: + return false + } + } + + var fillColor: FallingColors { + switch self { + case .over: + return FallingAsset.Color.neutral300 + default: + return FallingAsset.Color.clear + } + } + + var getText: String { + switch self { + case .initial, .over: + return String("-") + case .five(let value), .four(let value), .three(let value), .two(let value), .one(let value), .zero(let value): + return String(Int(value) - 1) + } + } + + var getProgress: Double { + switch self { + case .initial: + return 1 + case .five(let value), .four(let value), .three(let value), .two(let value), .one(let value), .zero(let value), .over(let value): + return (value - 2) / 5 + } + } + } + + var disposeBag: DisposeBag = DisposeBag() + + struct Input { + + } + + struct Output { + let timeState: Driver + let isTimeOver: Driver + } + + func transform(input: Input) -> Output { + let time = Observable.interval(.milliseconds(10), + scheduler: MainScheduler.instance) + .take(8 * 100 + 1) + .map { round((8 - Double($0) / 100) * 100) / 100 } + .asDriver(onErrorDriveWith: Driver.empty()) + + let timeState = time.map { TimeState(rawValue: $0) } + let isTimeOver = time.map { $0 == 0.0 } + + return Output( + timeState: timeState, + isTimeOver: isTimeOver + ) + } +} diff --git a/Falling/Sources/Feature/Main/MainView.swift b/Falling/Sources/Feature/Main/MainView.swift index cb2f5389..85c03f1d 100644 --- a/Falling/Sources/Feature/Main/MainView.swift +++ b/Falling/Sources/Feature/Main/MainView.swift @@ -19,6 +19,7 @@ final class MainView: TFBaseView { let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout) collectionView.register(cellType: MainCollectionViewCell.self) + collectionView.isScrollEnabled = false collectionView.backgroundColor = FallingAsset.Color.neutral700.color return collectionView }() diff --git a/Falling/Sources/Feature/Main/MainViewController.swift b/Falling/Sources/Feature/Main/MainViewController.swift index 9554c0c3..9a950227 100644 --- a/Falling/Sources/Feature/Main/MainViewController.swift +++ b/Falling/Sources/Feature/Main/MainViewController.swift @@ -7,6 +7,7 @@ import UIKit +import RxSwift import RxCocoa import RxDataSources import SwiftUI @@ -46,25 +47,48 @@ final class MainViewController: TFBaseViewController { override func bindViewModel() { let initialTrigger = self.rx.viewWillAppear.map { _ in }.asDriverOnErrorJustEmpty() - let output = viewModel.transform(input: MainViewModel.Input(trigger: initialTrigger)) + let timerOverTrigger = self.rx.timeOverTrigger.map { _ in + }.asDriverOnErrorJustEmpty() + + let output = viewModel.transform(input: MainViewModel.Input(trigger: initialTrigger, timeOverTrigger: timerOverTrigger)) + + var count = 0 + output.userList + .drive { userSection in + count = userSection[0].items.count + }.disposed(by: disposeBag) + - // output.state - // .drive(mainView.rx.timeState) - // .disposed(by: disposeBag) let dataSource = RxCollectionViewSectionedAnimatedDataSource { dataSource, collectionView, indexPath, item in let cell = collectionView.dequeueReusableCell(for: indexPath, cellType: MainCollectionViewCell.self) - output.timeState - .drive(cell.rx.timeState) + cell.setup(item: item) + output.currentPage + .do { index in + if index == indexPath.row { + cell.bindViewModel() + } + }.drive() .disposed(by: self.disposeBag) + cell.delegate = self return cell } output.userList .drive(mainView.collectionView.rx.items(dataSource: dataSource)) .disposed(by: self.disposeBag) + + output.currentPage + .do(onNext: { index in + let index = index >= count ? count - 1 : index + let indexPath = IndexPath(row: index, section: 0) + self.mainView.collectionView.scrollToItem(at: indexPath, + at: .top, + animated: true) + }).drive() + .disposed(by: self.disposeBag) } private func setupDelegate() { @@ -79,6 +103,17 @@ extension MainViewController: UICollectionViewDelegateFlowLayout { } } +extension MainViewController: TimeOverDelegate { + @objc func scrollToNext() { } +} + +extension Reactive where Base: MainViewController { + var timeOverTrigger: ControlEvent { + let source = methodInvoked(#selector(Base.scrollToNext)).map { _ in } + return ControlEvent(events: source) + } +} + struct MainViewControllerPreView: PreviewProvider { static var previews: some View { MainViewController(viewModel: MainViewModel(navigator: MainNavigator(controller: UINavigationController()))).toPreView() diff --git a/Falling/Sources/Feature/Main/MainViewModel.swift b/Falling/Sources/Feature/Main/MainViewModel.swift index 05aceb70..b61423f3 100644 --- a/Falling/Sources/Feature/Main/MainViewModel.swift +++ b/Falling/Sources/Feature/Main/MainViewModel.swift @@ -7,105 +7,20 @@ import RxSwift import RxCocoa -import Foundation final class MainViewModel: ViewModelType { - enum TimeState { - case initial(value: Double) // 7~8 - case five(value: Double) // 6~7 - case four(value: Double) // 5~6 - case three(value: Double) // 4~5 - case two(value: Double) // 3~4 - case one(value: Double) // 2~3 - case zero(value: Double) // 1~2 - case over(value: Double) // 0~1 - - init(rawValue: Double) { - switch rawValue { - case 7.0..<8.0: - self = .initial(value: rawValue) - case 6.0..<7.0: - self = .five(value: rawValue) - case 5.0..<6.0: - self = .four(value: rawValue) - case 4.0..<5.0: - self = .three(value: rawValue) - case 3.0..<4.0: - self = .two(value: rawValue) - case 2.0..<3.0: - self = .one(value: rawValue) - case 1.0..<2.0: - self = .zero(value: rawValue) - default: - self = .over(value: rawValue) - } - } - - var color: FallingColors { - switch self { - case .zero, .five: - return FallingAsset.Color.primary500 - case .four: - return FallingAsset.Color.thtOrange100 - case .three: - return FallingAsset.Color.thtOrange200 - case .two: - return FallingAsset.Color.thtOrange300 - case .one: - return FallingAsset.Color.thtRed - default: - return FallingAsset.Color.neutral300 - } - } - - var isDotHidden: Bool { - switch self { - case .initial, .over: - return true - default: - return false - } - } - - var fillColor: FallingColors { - switch self { - case .over: - return FallingAsset.Color.neutral300 - default: - return FallingAsset.Color.clear - } - } - - var getText: String { - switch self { - case .initial, .over: - return String("-") - case .five(let value), .four(let value), .three(let value), .two(let value), .one(let value), .zero(let value): - return String(Int(value) - 1) - } - } - - var getProgress: Double { - switch self { - case .initial: - return 1 - case .five(let value), .four(let value), .three(let value), .two(let value), .one(let value), .zero(let value), .over(let value): - return (value - 2) / 5 - } - } - } - private let navigator: MainNavigator var disposeBag: DisposeBag = DisposeBag() struct Input { let trigger: Driver + let timeOverTrigger: Driver } struct Output { let userList: Driver<[UserSection]> - let timeState: Driver + let currentPage: Driver } init(navigator: MainNavigator) { @@ -114,33 +29,31 @@ final class MainViewModel: ViewModelType { func transform(input: Input) -> Output { let listSubject = BehaviorSubject<[UserSection]>(value: []) + let currentIndex = BehaviorSubject(value: 0) - let userSections = [UserSection(header: "ass", - items: [ - UserDTO(userIdx: 0), - UserDTO(userIdx: 1), - UserDTO(userIdx: 2), - ])] - -// let userList = listSubject.onNext(userSections) + let timeOverTrigger = input.timeOverTrigger -// let refreshResponse = input.trigger.map { -// listSubject.onNext(userSections) -// } + let userSectionList = [UserSection(header: "header", + items: [ + UserDTO(userIdx: 0), + UserDTO(userIdx: 1), + UserDTO(userIdx: 2), + ])] - let userList = Observable.just(userSections).asDriver(onErrorJustReturn: []) + let userList = Observable.just(userSectionList).asDriver(onErrorJustReturn: []) - let time = Observable.interval(.milliseconds(10), - scheduler: MainScheduler.instance) - .take(8 * 100 + 1) - .map { round((8 - Double($0) / 100) * 100) / 100 } - .asDriver(onErrorDriveWith: Driver.empty()) + let currentPage = currentIndex.map{ $0 }.asDriver(onErrorJustReturn: 0) - let timeState = time.map { TimeState(rawValue: $0) } + timeOverTrigger.do(onNext: { + do { + currentIndex.onNext(try currentIndex.value() + 1) + } catch { } + }).drive() + .disposed(by: disposeBag) return Output( userList: userList, - timeState: timeState + currentPage: currentPage ) } } diff --git a/Falling/Sources/Feature/Main/Subviews/CardTimerView.swift b/Falling/Sources/Feature/Main/Subviews/CardTimerView.swift index 7bbccee1..a4e267bb 100644 --- a/Falling/Sources/Feature/Main/Subviews/CardTimerView.swift +++ b/Falling/Sources/Feature/Main/Subviews/CardTimerView.swift @@ -23,6 +23,7 @@ final class CardTimerView: TFBaseView { let layer = CAShapeLayer() layer.lineWidth = 2 layer.fillColor = FallingAsset.Color.clear.color.cgColor + layer.strokeColor = FallingAsset.Color.neutral300.color.cgColor return layer }() From 2305ef003797b9f0c59c402499b4b3f693392b06 Mon Sep 17 00:00:00 2001 From: kangho lee Date: Sun, 8 Oct 2023 12:15:45 +0900 Subject: [PATCH 3/4] =?UTF-8?q?Refactor:=20preview=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B7=B8=EC=97=90=20=EC=B6=94=EA=B0=80,=20ti?= =?UTF-8?q?meOver=20initial=20=EB=B6=80=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Feature/Main/MainViewController.swift | 11 +++++++++-- .../Sources/Feature/Main/MainViewModel.swift | 17 +++++++---------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/Falling/Sources/Feature/Main/MainViewController.swift b/Falling/Sources/Feature/Main/MainViewController.swift index 9a950227..961e625e 100644 --- a/Falling/Sources/Feature/Main/MainViewController.swift +++ b/Falling/Sources/Feature/Main/MainViewController.swift @@ -10,7 +10,6 @@ import UIKit import RxSwift import RxCocoa import RxDataSources -import SwiftUI final class MainViewController: TFBaseViewController { @@ -113,9 +112,17 @@ extension Reactive where Base: MainViewController { return ControlEvent(events: source) } } +#if DEBUG +import SwiftUI struct MainViewControllerPreView: PreviewProvider { static var previews: some View { - MainViewController(viewModel: MainViewModel(navigator: MainNavigator(controller: UINavigationController()))).toPreView() + let navigator = MainNavigator(controller: UINavigationController()) + + let viewModel = MainViewModel(navigator: navigator) + + return MainViewController(viewModel: viewModel) + .toPreView() } } +#endif diff --git a/Falling/Sources/Feature/Main/MainViewModel.swift b/Falling/Sources/Feature/Main/MainViewModel.swift index b61423f3..f1343bfb 100644 --- a/Falling/Sources/Feature/Main/MainViewModel.swift +++ b/Falling/Sources/Feature/Main/MainViewModel.swift @@ -4,6 +4,7 @@ // // Created by SeungMin on 2023/08/15. // +import Foundation import RxSwift import RxCocoa @@ -40,16 +41,12 @@ final class MainViewModel: ViewModelType { UserDTO(userIdx: 2), ])] - let userList = Observable.just(userSectionList).asDriver(onErrorJustReturn: []) - - let currentPage = currentIndex.map{ $0 }.asDriver(onErrorJustReturn: 0) - - timeOverTrigger.do(onNext: { - do { - currentIndex.onNext(try currentIndex.value() + 1) - } catch { } - }).drive() - .disposed(by: disposeBag) + let userList = Driver.just(userSectionList) + + let currentPage = timeOverTrigger.withLatestFrom(currentIndex.asDriver(onErrorJustReturn: 0)) { _, page in + currentIndex.onNext(page + 1) + return page + 1 + }.startWith(0) return Output( userList: userList, From d59e69754190bff699e851d63003ce190cfc168b Mon Sep 17 00:00:00 2001 From: kangho lee Date: Sun, 8 Oct 2023 13:02:52 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Refactor:=20=ED=94=84=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9D=84=20=EC=9C=84=ED=95=B4=20RxDataSource?= =?UTF-8?q?=20->=20DiffableDataSource=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserDTO -> UserDomain --- .../Main/Cell/MainCollectionViewCell.swift | 4 +- .../MainCollectionViewItemViewModel.swift | 6 +-- .../Feature/Main/MainViewController.swift | 43 ++++++++++--------- .../Sources/Feature/Main/MainViewModel.swift | 8 +++- .../Sources/Feature/Main/UserSection.swift | 16 +++++++ 5 files changed, 50 insertions(+), 27 deletions(-) diff --git a/Falling/Sources/Feature/Main/Cell/MainCollectionViewCell.swift b/Falling/Sources/Feature/Main/Cell/MainCollectionViewCell.swift index c0dc9733..fe703e8c 100644 --- a/Falling/Sources/Feature/Main/Cell/MainCollectionViewCell.swift +++ b/Falling/Sources/Feature/Main/Cell/MainCollectionViewCell.swift @@ -98,8 +98,8 @@ final class MainCollectionViewCell: TFBaseCollectionViewCell { disposeBag = DisposeBag() } - func setup(item: UserDTO) { - viewModel = MainCollectionViewItemViewModel(userDTO: item) + func setup(item: UserDomain) { + viewModel = MainCollectionViewItemViewModel(userDomain: item) } func bindViewModel() { diff --git a/Falling/Sources/Feature/Main/Cell/MainCollectionViewItemViewModel.swift b/Falling/Sources/Feature/Main/Cell/MainCollectionViewItemViewModel.swift index 3ea8f44a..ec2f2d79 100644 --- a/Falling/Sources/Feature/Main/Cell/MainCollectionViewItemViewModel.swift +++ b/Falling/Sources/Feature/Main/Cell/MainCollectionViewItemViewModel.swift @@ -12,10 +12,10 @@ import RxCocoa final class MainCollectionViewItemViewModel: ViewModelType { - let userDTO: UserDTO + let userDomain: UserDomain - init(userDTO: UserDTO) { - self.userDTO = userDTO + init(userDomain: UserDomain) { + self.userDomain = userDomain } enum TimeState { diff --git a/Falling/Sources/Feature/Main/MainViewController.swift b/Falling/Sources/Feature/Main/MainViewController.swift index 961e625e..6ead5ae7 100644 --- a/Falling/Sources/Feature/Main/MainViewController.swift +++ b/Falling/Sources/Feature/Main/MainViewController.swift @@ -15,7 +15,7 @@ final class MainViewController: TFBaseViewController { private let viewModel: MainViewModel private lazy var mainView = MainView() - + private var dataSource: UICollectionViewDiffableDataSource! init(viewModel: MainViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) @@ -54,30 +54,32 @@ final class MainViewController: TFBaseViewController { var count = 0 output.userList .drive { userSection in - count = userSection[0].items.count + count = userSection.count }.disposed(by: disposeBag) - - - - let dataSource = RxCollectionViewSectionedAnimatedDataSource { dataSource, collectionView, indexPath, item in - - let cell = collectionView.dequeueReusableCell(for: indexPath, - cellType: MainCollectionViewCell.self) + + // DiffableDataSource + + let profileCellRegistration = UICollectionView.CellRegistration { [weak self] cell, indexPath, item in cell.setup(item: item) - output.currentPage - .do { index in - if index == indexPath.row { - cell.bindViewModel() - } - }.drive() - .disposed(by: self.disposeBag) cell.delegate = self - return cell + output.currentPage + .filter { $0 == indexPath.item } + .drive(onNext: {_ in + cell.bindViewModel() + }) + .disposed(by: cell.disposeBag) } - + + dataSource = UICollectionViewDiffableDataSource(collectionView: mainView.collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in + return collectionView.dequeueConfiguredReusableCell(using: profileCellRegistration, for: indexPath, item: itemIdentifier) + }) output.userList - .drive(mainView.collectionView.rx.items(dataSource: dataSource)) - .disposed(by: self.disposeBag) + .drive(onNext: { [weak self] list in + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.profile]) + snapshot.appendItems(list) + self?.dataSource.apply(snapshot, animatingDifferences: true) + }).disposed(by: disposeBag) output.currentPage .do(onNext: { index in @@ -112,6 +114,7 @@ extension Reactive where Base: MainViewController { return ControlEvent(events: source) } } + #if DEBUG import SwiftUI diff --git a/Falling/Sources/Feature/Main/MainViewModel.swift b/Falling/Sources/Feature/Main/MainViewModel.swift index f1343bfb..fee92c13 100644 --- a/Falling/Sources/Feature/Main/MainViewModel.swift +++ b/Falling/Sources/Feature/Main/MainViewModel.swift @@ -20,7 +20,7 @@ final class MainViewModel: ViewModelType { } struct Output { - let userList: Driver<[UserSection]> + let userList: Driver<[UserDomain]> let currentPage: Driver } @@ -41,7 +41,11 @@ final class MainViewModel: ViewModelType { UserDTO(userIdx: 2), ])] - let userList = Driver.just(userSectionList) + let userList = Driver.just([ + UserDomain(userIdx: 0), + UserDomain(userIdx: 1), + UserDomain(userIdx: 2), + ]) let currentPage = timeOverTrigger.withLatestFrom(currentIndex.asDriver(onErrorJustReturn: 0)) { _, page in currentIndex.onNext(page + 1) diff --git a/Falling/Sources/Feature/Main/UserSection.swift b/Falling/Sources/Feature/Main/UserSection.swift index 870d5065..241339b8 100644 --- a/Falling/Sources/Feature/Main/UserSection.swift +++ b/Falling/Sources/Feature/Main/UserSection.swift @@ -4,9 +4,25 @@ // // Created by SeungMin on 2023/10/06. // +import Foundation import RxDataSources +enum MainProfileSection { + case profile +} + +struct UserDomain: Hashable { + let identifier = UUID() + let userIdx: Int +} + +extension UserDTO { + func toDomain() -> UserDomain { + UserDomain(userIdx: self.userIdx) + } +} + struct UserSection { var header: String var items: [Item]