-
Notifications
You must be signed in to change notification settings - Fork 15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Feat] #467 - 회원가입 UI 구현 #474
base: feat/#465-signup
Are you sure you want to change the base?
Conversation
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pr point
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) | ||
extension Publishers { | ||
|
||
public struct WithLatestFrom<Upstream, Other> : Publisher where Upstream : Publisher, Other: Publisher, Upstream.Failure == Other.Failure { | ||
|
||
public typealias Output = Other.Output | ||
public typealias Failure = Upstream.Failure | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
저는 RxSwift의 WithLatestFrom 연산자를 좋아하는데, Combine에 없어서 <Combine: withLatestFrom> 을 참고하여 연산자를 만들었습니다.
기능
- 특정 이벤트가 왔을 때 해당 element을 다른 스트림의 최신값으로 변경해주는 기능입니다.
- 만약 다른 스트림에 값이 없다면 값을 방출하지 않습니다.
public struct PhoneVerifyPolicy { | ||
public let phoneNumberCount: Int | ||
private let _timeLimit: Duration | ||
public var timeLimit: Int { Int(_timeLimit.components.seconds) } | ||
|
||
public init(phoneNumberCount: Int, timeLimit: Duration) { | ||
self.phoneNumberCount = phoneNumberCount | ||
self._timeLimit = timeLimit | ||
} | ||
} | ||
|
||
extension PhoneVerifyPolicy { | ||
static let `default` = Self(phoneNumberCount: 11, timeLimit: .seconds(180)) | ||
static let stub = Self(phoneNumberCount: 11, timeLimit: .seconds(10)) | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
기획서에 명시된 휴대폰번호 최대 길이 및 시간 제한에 관한 값은 Domain의 책임으로 결정했습니다. �해당 로직은 런타임에 변경될 일이 없기에 static 하게 선언 후 viewModel에서 해당 값을 사용하는 방식입니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_timeLimit은 객체 내부에서만 사용됨을 의미하며, Int가 아닌 Duration타입으로 구현한 이유는 Swift에서 권장하는 시간 단위를 사용하고 싶었기 때문이에용
단, 뷰모델에서 사용 편의성을 위해 외부로 노출하는 프로퍼티는 int 타입으로 하기위해 연산프로퍼티를 사용했습니다
public protocol PhoneVerifyUseCase { | ||
var policy: PhoneVerifyPolicy { get } | ||
|
||
var sideEffect: PassthroughSubject<PhoneVerifyError, Never> { get } | ||
|
||
func send(_ model: PhoneSendModel) -> AnyPublisher<Void, Never> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
useCase는 앞서 말씀드린 policy를 프로퍼티로 갖습니다
|
||
public func makeSignUp() -> SignUpPresentable { | ||
let useCase = StubPhoneVerifyUseCase() // TODO | ||
let vm = SignUpViewModel(useCase: useCase) | ||
let subVM = PhoneVerifyViewModel(useCase: useCase) | ||
let vc = SignUpVC(viewModel: vm, phoneVerifyViewModel: subVM) | ||
return (vc, vm) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
추후 변동 예정
public var viewModelInput: PhoneVerifyViewModel.Input { | ||
return .init( | ||
sendButtonTapped: sendButton.publisher(for: .touchUpInside).mapVoid().asDriver(), | ||
doneButtonTapped: doneButton.publisher(for: .touchUpInside).mapVoid().asDriver(), | ||
phoneTextFieldText: phoneTextField.publisher(for: .editingChanged).map { $0.text ?? "" }.asDriver(), | ||
codeTextFieldText: codeTextField.publisher(for: .editingChanged).map { $0.text ?? "" }.asDriver() | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
번호인증뷰는 회원가입, 계정 찾기, 소셜계정 재설정에 사용됩니다.
따라서 PhoneVerifyView의 재사용성을 높이고자 UIView로 따로 뺐습니다.
이때 SignUpVC는 PhoneVerifyView와 SignUpViewModel의 값을 바인딩해야 할 것 입니다.
VC가 View 값을 바인딩 하기 위해 아래 두 방법을 생각해봤는데요
- View의 프로퍼티의 접근제어자를 public으로 바꾸고 VC가 View 프로퍼티 의존
- view에서 Input을 미리 프로퍼티로 정의하여 VC가 해당 프로퍼티만 의존.
1안의 단점은 객체지향의 접근제어자 깨지는 것이고, 2안의 단점은 View가 ViewModel을 알게되는 점인 것 같아요.
저는 개인적으로 MVVM 패턴에선 View와 VC의 역할 차이를 크게 두지 않는 편이라 2안을 택했습니다.
input.sendButtonTapped | ||
.handleEvents(receiveOutput: { _ in | ||
output.isSent.send(true) | ||
output.failDescription.send(nil) } | ||
) | ||
.withLatestFrom(input.phoneTextFieldText) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
withLatestFrom 사용 예시
input.doneButtonTapped | ||
.withLatestFrom(output.timerIsRunning) | ||
.filter { $0 } | ||
.withLatestFrom(Publishers.Zip(input.phoneTextFieldText, input.codeTextFieldText)) | ||
.map { PhoneVerifyModel(name: nil, phone: $0, code: $1, type: .register)} | ||
.flatMap(useCase.verify) | ||
.withUnretained(self) | ||
.sink { owner, _ in | ||
owner.timerCancellable = nil | ||
output.verifySuccess.send(()) | ||
} | ||
.store(in: cancelBag) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
withLatestFrom을 여러 스트림에 대하여 할 때는 Zip 연산자를 활용했습니다.
public class SignUpVC: UIViewController, SignUpViewControllable { | ||
|
||
//MARK: - Properties | ||
|
||
private let phoneVerifyView = PhoneVerifyView() | ||
private let oAuthView = SignUpOAuthView() | ||
|
||
private let viewModel: SignUpViewModel | ||
private let phoneVerifyViewModel: PhoneVerifyViewModel | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
private let firstCircle = UILabel().then { | ||
$0.text = "1" | ||
$0.font = DSKitFontFamily.Suit.bold.font(size: 12) | ||
$0.backgroundColor = DSKitAsset.Colors.blue400.color | ||
$0.layer.cornerRadius = 11 | ||
$0.layer.masksToBounds = true | ||
$0.textAlignment = .center | ||
} | ||
|
||
private let checkImageView = UIImageView().then { | ||
$0.image = DSKitAsset.Assets.check.image.withAlignmentRectInsets(.init(top: -4, left: -4, bottom: -4, right: -4)) | ||
$0.contentMode = .scaleAspectFit | ||
$0.backgroundColor = DSKitAsset.Colors.blue400.color | ||
$0.layer.cornerRadius = 11 | ||
$0.layer.masksToBounds = true | ||
$0.isHidden = true | ||
} | ||
|
||
private let secondCircle = UILabel().then { | ||
$0.text = "2" | ||
$0.font = DSKitFontFamily.Suit.bold.font(size: 12) | ||
$0.backgroundColor = DSKitAsset.Colors.black40.color | ||
$0.layer.cornerRadius = 11 | ||
$0.layer.masksToBounds = true | ||
$0.textAlignment = .center | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
최근엔 재사용성을 위해 만든 뷰 역시 레거시로 남을 수 있다는 생각이 들어,
그냥 직관적으로 구현했습니다 ㅋ!
struct Input { | ||
let verifySuccess: Driver<Void> | ||
var oAuth: OAuth | ||
|
||
struct OAuth { | ||
let googleLoginTapped: Driver<Void> | ||
let appleLoginTapped: Driver<Void> | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🌴 PR 요약
🌱 작업한 브랜치
🌱 PR Point
📌 참고 사항
📸 스크린샷
로그인 -> 회원가입뷰 transition 단계에서 레이아웃이 깨지는 현상
📮 관련 이슈