해커톤같은 개발자 행사에 같이 나갈 팀원을 구하는 앱 입니다. 개발자 행사들을 모아볼 수 있고, 손쉽게 구인 공고를올리거나 참가 신청을 할 수 있습니다.
- 1인 개발
- iOS 16.0+
- 로그인 / 회원가입 / 자동 로그인 기능
- 관심 있는 행사, 팀원 구인 글 북마크 기능
- 팀원 구인 글, 참가 신청 글 작성
- 광고 제거 결제 기능
- UIKit / SnapKit
- SwiftUI
- MVVM
- RxSwift
- Input / Output Pattern
- Moya
- Skeleton UI
- RxDataSource
- Iamport-iOS
- Input / Output ViewModel Protocol로 구조화
protocol InputOutputViewModelProtocol {
associatedtype Input
associatedtype Output
var disposeBag: DisposeBag { get }
func transform(input: Input) -> Output
}
- RxDataSource와 SkeletonUI를 활용해 자연스러운 애니메이션으로 사용자 경험 향상
let dataSource = RxTableViewSectionedAnimatedDataSource<DetailViewSectionModel>(animationConfiguration: AnimationConfiguration(insertAnimation: .fade)) { data, tableView, indexPath, item in
if data[indexPath.section].row == .empty {
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmptyTableViewCell.identifier, for: indexPath) as? EmptyTableViewCell else { fatalError() }
tableView.visibleCells.forEach { $0.hideSkeleton() }
tableView.separatorStyle = .none
return cell
}
guard let cell = tableView.dequeueReusableCell(withIdentifier: PartyTableViewCell.identifier, for: indexPath) as? PartyTableViewCell else { fatalError() }
if data[indexPath.section].row == .data {
cell.configureUI(item)
cell.bookmarkButton.rx.tap.map { item }
.bind(to: inputBookMarkCellButton)
.disposed(by: cell.disposeBag)
} else {
cell.configureSkeleton()
}
return cell
}
- Moya와 Router Pattern을 이용한 Alamofire 추상화
import Moya
enum PayRouter {
case payValidation(query: PayValidationModel)
case isUserBought
}
extension PayRouter: TargetType {
var baseURL: URL {
return URL(string: APIKey.baseURL.rawValue)!
}
var path: String {
switch self {
case .payValidation:
return "v1/payments/validation"
case .isUserBought:
return "v1/payments/me"
}
}
var method: Moya.Method {
switch self {
case .payValidation:
return .post
case .isUserBought:
return .get
}
}
var task: Moya.Task {
switch self {
case .payValidation(let query):
return .requestJSONEncodable(query)
case .isUserBought:
return .requestParameters(parameters: [:], encoding: URLEncoding.queryString)
}
}
var headers: [String : String]? {
return [
HTTPHeader.authorization.rawValue: UserDefaults.standard[.accessToken],
HTTPHeader.sesackey.rawValue: APIKey.sesacKey.rawValue
]
}
}
- Iamport를 이용한 결제, 결제 검증 및 에러 핸들링
1. AccessToken 갱신 로직이 각 요청마다 중복되는 이슈, Alamofire의 Interceptor Retry메서드로 Access Token을 재발급 받는 로직을 구현하여중복되는 코드를 줄임
func getPosts(query: PostsKind) -> Observable<EventPostsResultModel> {
let request = PostsRequestModel(next: "", product_id: query.requestValue)
return Observable.create { observer -> Disposable in
self.callRequest(.getPost(query: request), type: EventPostsResultModel.self)
.subscribe { event in
switch event {
case .success(let result):
observer.onNext(result)
case .failure(_):
self.refreshAccessToken {
self.callRequest(.getPost(query: request), type: EventPostsResultModel.self)
.subscribe(with: self) { _, result in
observer.onNext(result)
} onFailure: { _, error in
observer.onError(error)
}.disposed(by: self.disposeBag)
}
}
}
}
}
extension Interceptor: RequestInterceptor {
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, any Error>) -> Void) {
return completion(.success(urlRequest))
}
func retry(_ request: Request, for session: Session, dueTo error: any Error, completion: @escaping (RetryResult) -> Void) {
let requestManager = AuthRequestManager()
guard let response = request.response else {
completion(.doNotRetryWithError(error))
return
}
if response.statusCode != 401 || response.statusCode != 418 {
completion(.doNotRetryWithError(error))
return
}
requestManager.accessTokenRequest()
.subscribe { response in
switch response {
case .success(let result):
switch result {
case .success(let accessToken):
UserDefaults.standard[.accessToken] = accessToken.accessToken
completion(.retry)
case .failure(let error):
completion(.doNotRetryWithError(error))
}
case .failure(let error):
completion(.doNotRetryWithError(error))
}
}.disposed(by: disposeBag)
}
}
output.outputLoginResult
.drive(with: self) { owner, value in
let vc = value ? TabbarViewController() : UINavigationController(rootViewController: SignInViewController())
owner.view?.window?.rootViewController = vc
}.disposed(by: disposeBag)
3. NavigationLink의 Destination View 초기화 이슈, 광고 제거하기 뷰의 초기화 시점에 결제 검증 로직 동작을 의도 하였으나, NavigationLink는 Destination View를 항상 미리 초기화하는 문제 발생, NavigationLazyView를 구현하여Destination View를 초기화 하지 못하게 해결 및 불필요한 메모리 최적화 달성
private var iamportPayView: some View {
NavigationLink {
IamportPaymentViewController().toSwiftUIView()
} label: {
Label(
title: { Text("광고 제거 구매 (100원)") },
icon: { Image(systemName: "person.fill").foregroundStyle(.cyan) }
)
}
}
struct NavigationLazyView<T: View>: View {
let build: () -> T
init(_ build: @autoclosure @escaping () -> T) {
self.build = build
}
var body: some View {
build()
}
}
private var iamportPayView: some View {
NavigationLink {
NavigationLazyView(IamportPaymentViewController().toSwiftUIView())
} label: {
Label(
title: { Text("광고 제거 구매 (100원)") },
icon: { Image(systemName: "person.fill").foregroundStyle(.cyan) }
)
}
}
📦DevCommunity
┣ 📂Enum
┣ 📂Extension
┣ 📂Model
┃ ┣ 📂Codable
┃ ┣ 📂Decoding
┃ ┣ 📂Encoding
┃ ┗ 📂SectionModel
┣ 📂Protocol
┣ 📂Scene
┃ ┣ 📂AccountSettingScene
┃ ┣ 📂Base
┃ ┣ 📂DetailScene
┃ ┣ 📂EventsScene
┃ ┣ 📂IamportPaymentViewScene
┃ ┣ 📂InitialScene
┃ ┣ 📂PartyDetailScene
┃ ┣ 📂PartyJoinView
┃ ┣ 📂PartyPostAddScene
┃ ┣ 📂ProfileSettingScene
┃ ┣ 📂SettingScene
┃ ┣ 📂SignInScene
┃ ┣ 📂SignUpBottomSheet
┃ ┣ 📂SignUpCompleteScene
┃ ┣ 📂SignUpScene
┃ ┣ 📂TabbarScene
┃ ┣ 📂WebScene
┣ 📂Service
┃ ┣ 📂APIKey
┃ ┣ 📂Network
┃ ┃ ┣ 📂Router
┃ ┣ 📂NewNetwork
┃ ┃ ┣ 📂Auth
┃ ┃ ┣ 📂Base
┃ ┃ ┣ 📂Pay
┃ ┃ ┣ 📂Plugin
┃ ┃ ┃ ┣ 📂Cache
┃ ┃ ┃ ┣ 📂Interceptor
┃ ┃ ┃ ┗ 📂Logger
┃ ┃ ┣ 📂Post
┃ ┃ ┗ 📂Profile
┣ 📂View
┃ ┣ 📂BasePaddingLabel
┃ ┣ 📂BaseStepperView
┃ ┣ 📂CheckBoxView
┃ ┣ 📂CodeView
┃ ┣ 📂NavigationLazyView
┃ ┣ 📂SignUpLabelsView
┃ ┣ 📂SignUpTextFieldView
┗ ┗ 📂TitleView
뷰 | 이미지 |
---|---|
로그인 뷰 | ![]() |
회원가입 뷰 | ![]() |
메인 뷰 | ![]() |
행사 상세 뷰 | ![]() |
팀원 구인글 작성 뷰 | ![]() |
팀원 구인글 참가 뷰 | ![]() |
광고 제거 구매 뷰 | ![]() |