v1.0
- 2024.04.10 ~ 2024.05.06 (27일)
v1.1
- 2024.05.18 ~ 2024.05.28 (11일)
- 최소버전 16.0 / 라이트 모드 / 세로모드 / iOS전용
- 택시 합승 파티 만들기
- 위치 키워드 검색 기능
- 맵에서 스크롤하여 위치 설정 기능
- 예상 경로 기능
- 예상 택시비 / 1인당 예상 택시비 정보
- 근처 택시팟 찾기 기능
- 맵에서 근처 택시팟 표시 기능
- 줌에 따라 클러스터 처리
- 프로필 설정
- 프로필 닉네임 변경
- 프로필 사진 변경
- 택시팟 포스트 조회
- 유저가 올린 포스트들 조회 기능
- 채팅 기능
- 1:1 채팅 기능
- 기존 채팅 내역 저장 기능
- UIKit / SwiftUI / MVVM input - output Pattern
- RxSwift / RxCoCoa / RxGesture / RxDataSources
- CodeBaseUI / SnapKit / Then
- Alamofire Router Pattern / Kingfisher
- SocketIO / Realm - repository Pattern
- Property Wrapper
- NaverMap / NVActivityIndicatorView / TextFieldEffects / AnimatedTabbar
- SPM / CocoaPods
- 사용자와 상대방의 채팅을 구분하기 위해 두가지 테이블셀 사용
- 유저 경험 고려해 한 사람의 채팅을 연속적으로 했을 때, 중복되는 프로필 사진과 시간을 제거
- 이전 채팅을 읽어와서 보여줘야하는 부분을 고려해 realm에 이전 대화 내용 저장
- 다양한 API의 EndPoint 유지보수와 가독성 측면을 고려해 Router를 관심사에 따라 분리
- 코드를 사용할 때 실수를 방지하기 위해 열거형으로 관리
코드 보기
enum APIRouter {
case authenticationRouter(AuthenticationRouter)
case refreshTokenRouter(RefreshTokenRouter)
case postRouter(PostRouter)
case profileRouter(ProfileRouter)
case geocodingRouter(GeocodingRouter)
func convertToURLRequest() -> RouterType {
switch self {
case .authenticationRouter(let authenticationRouter):
return authenticationRouter
case .refreshTokenRouter(let refreshTokenRouter):
return refreshTokenRouter
case .postRouter(let postRouter):
return postRouter
case .profileRouter(let profileRouter):
return profileRouter
case .geocodingRouter(let geocodingRouter):
return geocodingRouter
}
}
}
//사용시
networkManager.callRequest(type: ValidationEmailModel.self, router: .authenticationRouter(.validationEmail(query: ValidationEmail(email: email)))
- 보일러 플레이트 코드 제거
- 코드 재사용성 향상
코드 보기
@propertyWrapper
struct TokenDefaults<T> {
let key: String
let defaultValue: T
var wrappedValue: T {
get {
UserDefaults.standard.object(forKey: key) as? T ?? defaultValue
}
set {
UserDefaults.standard.set(newValue, forKey: key)
}
}
}
enum TokenManager {
enum Key: String {
case userId
case accessToken
case refreshToken
}
@TokenDefaults(key: Key.userId.rawValue, defaultValue: "id 없음")
static var userId
@TokenDefaults(key: Key.accessToken.rawValue, defaultValue: "액세스 토큰 없음")
static var accessToken
@TokenDefaults(key: Key.refreshToken.rawValue, defaultValue: "리프레시 토큰 없음")
static var refreshToken
}
- 실시간으로 상호작용하는 반응형 프로그래밍을 쉽게 적용하기 위해 RxSwift를 적용
- 서버와의 통신을 비동기로 처리하고 스레드를 관리가 용이하기 때문에 적용
- 네이버에서 Direction(네비게이션) API를 제공하고 있으며, Map API를 제공하는 회사별로 서로 다른 좌표계를 제공하기 때문에 추가적인 작업이 필요없는 네이버맵을 사용하기로 결정
- 뷰의 재활용을 위해 커스텀 뷰로 구현
- Input - Output 패턴을 사용해 일정한 형태를 가진 MVVM 디자인 패턴을 적용
- 데이터 흐름을 파악하고 상황에 맞는 Operator를 적용
코드 보기
protocol ViewModelProtocol {
associatedtype Input
associatedtype Output
var disposeBag: DisposeBag { get set }
func transform(input: Input) -> Output
}
- 사용자의 경험을 고려하여 AccessToken 만료 시 갱신되는 기능 구현
코드 보기
final class AuthInterceptor: RequestInterceptor {
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, any Error>) -> Void) {
let accessToken = TokenManager.accessToken
if accessToken == urlRequest.headers.dictionary[HTTPHeader.authorization.rawValue] {
completion(.success(urlRequest))
} else {
var urlRequest = urlRequest
urlRequest.setValue(TokenManager.accessToken, forHTTPHeaderField: HTTPHeader.authorization.rawValue)
print("새 accessToken 적용 \(urlRequest.headers)")
completion(.success(urlRequest))
}
}
func retry(_ request: Request, for session: Session, dueTo error: any Error, completion: @escaping (RetryResult) -> Void) {
guard let response = request.task?.response as? HTTPURLResponse, response.statusCode == 419 else {
completion(.doNotRetryWithError(error))
return
}
do {
let urlRequest = try RefreshTokenRouter.refreshToken.asURLRequest()
AF.request(urlRequest)
.validate(statusCode: 200..<300)
.responseDecodable(of: RefreshTokenModel.self) { response in
switch response.result {
case .success(let success):
TokenManager.accessToken = success.accessToken
completion(.retry)
case .failure(let error):
print(error)
completion(.doNotRetryWithError(NetworkError.expireRefreshToken))
}
}
} catch {
print(error)
}
}
}
- textField를 누르면 바텀시트가 전체화면으로, 뒤로가기 Button을 누르면 최소 크기로 변경되게 구현하고자 함
- 기존에 구현되어 있는 라이브러리는 바텀시트 드래그 앤 드랍을 통해 바텀시트의 크기가 변함
- Custom Sheet View로 구현
- Button과 textField에 타켓을 추가해 이벤트 발생 시 sheetView 크기를 동적으로 구현
코드 보기
final class BottomSheetView: PassThroughView {
enum Mode {
case tip
case full
}
private enum Const {
static let duration = 0.5
static let cornerRadius = 20.0
static let bottomSheetRatio: (Mode) -> Double = { mode in
switch mode {
case .tip:
return 0.7
case .full:
return 0
}
}
static let bottomSheetYPosition: (Mode) -> Double = { mode in
Self.bottomSheetRatio(mode) * UIScreen.main.bounds.height
}
}
let bottomSheetView = SearchAddressView()
let addressLabel = UILabel()
lazy var mode: Mode = .tip {
didSet {
switch self.mode {
case .tip:
break
case .full:
break
}
self.updateConstraint(offset: Const.bottomSheetYPosition(self.mode))
self.bottomSheetView.updateConstraints(isFullmode: self.mode == .full)
}
}
var bottomSheetColor: UIColor? {
didSet { self.bottomSheetView.backgroundColor = self.bottomSheetColor }
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init() has not been implemented")
}
override init(frame: CGRect) {
super.init(frame: frame)
self.layer.shadowRadius = 1
self.layer.shadowOpacity = 0.2
self.layer.shadowOffset = CGSize.zero
self.backgroundColor = .clear
self.bottomSheetView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
self.bottomSheetView.layer.cornerRadius = Const.cornerRadius
self.bottomSheetView.clipsToBounds = true
self.addSubview(self.bottomSheetView)
self.bottomSheetView.snp.makeConstraints {
$0.left.right.bottom.equalToSuperview()
$0.top.equalTo(Const.bottomSheetYPosition(.tip))
$0.bottom.equalTo(keyboardLayoutGuide)
}
self.bottomSheetView.startPointTextField.addTarget(self, action: #selector(textFieldTapped), for: .editingDidBegin)
self.bottomSheetView.destinationTextField.addTarget(self, action: #selector(textFieldTapped), for: .editingDidBegin)
self.bottomSheetView.backButton.addTarget(self, action: #selector(backButtonTapped), for: .touchUpInside)
}
@objc private func backButtonTapped(sender: UIButton) {
UIView.animate(
withDuration: Const.duration,
delay: 0,
options: .allowAnimatedContent,
animations: {
self.mode = .tip
self.bottomSheetView.backButton.isHidden = true
self.defocusTextField()
},
completion: nil
)
}
@objc private func textFieldTapped(sender: UITextField) {
UIView.animate(
withDuration: Const.duration,
delay: 0,
options: .allowAnimatedContent,
animations: {
self.mode = .full
self.bottomSheetView.backButton.isHidden = false
},
completion: nil
)
}
private func updateConstraint(offset: Double) {
self.bottomSheetView.snp.remakeConstraints {
$0.left.right.bottom.equalToSuperview()
$0.top.equalToSuperview().inset(offset)
}
}
}
- 바텀 시트 사이즈가 tip 일 때, 원하던 레이아웃이 그려지지 않는 버그가 발생
- 바텀 시트 사이즈가 tip일때는 보이지 않는 뷰객체들의 height을 0으로 설정
- 사이즈가 full로 바뀔 때 Snapkit의 remake를 통해 의도한 사이즈를 재설정
코드 보기
// bottomSheetView
lazy var mode: Mode = .tip {
didSet {
switch self.mode {
case .tip:
break
case .full:
break
}
self.updateConstraint(offset: Const.bottomSheetYPosition(self.mode))
self.bottomSheetView.updateConstraints(isFullmode: self.mode == .full)
}
}
// SearchAddressView
func updateConstraints(isFullmode: Bool) {
if isFullmode {
dividerView.snp.remakeConstraints { make in
make.top.equalTo(destinationTextField.snp.bottom).offset(30)
make.width.equalTo(self)
make.height.equalTo(8)
}
headerLabel.snp.remakeConstraints { make in
make.top.equalTo(dividerView.snp.bottom).offset(20)
make.leading.equalTo(15)
}
tableView.snp.remakeConstraints { make in
make.top.equalTo(headerLabel.snp.bottom).offset(10)
make.horizontalEdges.equalTo(self)
make.bottom.equalTo(self).offset(-85)
}
} else {
dividerView.snp.remakeConstraints { make in
make.top.equalTo(destinationTextField.snp.bottom).offset(30)
make.width.equalTo(self)
make.height.equalTo(0)
}
headerLabel.snp.remakeConstraints { make in
make.top.equalTo(dividerView.snp.bottom).offset(20)
make.leading.equalTo(15)
make.height.equalTo(0)
}
tableView.snp.remakeConstraints { make in
make.top.equalTo(headerLabel.snp.bottom).offset(10)
make.horizontalEdges.equalTo(self)
make.height.equalTo(0)
}
}
}
- 채팅 뷰를 나갔다가 다시 들어왔을 때, 동일한 채팅이 중복되어 저장되는 버그 발생
- 뷰모델이 deinit되지 않아서 bind가 중첩돼서 발생하는 버그
- 클로저 내부에서 강한 순환 참조가 되지 않게 하여 bind가 중첩되는 문제를 해결
코드 보기
이전 코드
//DetailChatViewModel
private var roomId: String!
init(roomId: String) {
self.roomId = roomId
}
fetchRecentDataTrigger
.flatMap {
let lastDate = self.repository.fetchLastChat(id: self.roomId)
return NetworkManager.shared.callRequest(type: ChatListModel.self, router: .chattingRouter(.fetchChat(roomId: self.roomId, lastDate: lastDate)))
}
.subscribe(with: self) { owner, response in
switch response {
case .success(let success):
print(success)
success.data.forEach { item in
messages.append(item.toChatInfo())
}
outputMessages.accept(messages)
scrollToBottomTrigger.accept(messages.count)
connetSocketTrigger.accept(())
case .failure(let error):
print(error)
}
}
.disposed(by: disposeBag)
수정 후 코드
//DetailChatViewModel
private var roomId: String!
init(roomId: String) {
self.roomId = roomId
}
fetchRecentDataTrigger
.withUnretained(self)
.flatMap { owner, _ in
let lastDate = owner.repository.fetchLastChat(id: owner.roomId)
return NetworkManager.shared.callRequest(type: ChatListModel.self, router: .chattingRouter(.fetchChat(roomId: owner.roomId, lastDate: lastDate)))
}
.subscribe(with: self) { owner, response in
switch response {
case .success(let success):
print(success)
for item in success.data {
let chatInfo = item.toChatInfo()
messages.append(chatInfo)
owner.repository.appendChatList(id: owner.roomId, chat: chatInfo)
messages = owner.updateInfo(messages: messages, chatModel: item)
}
outputMessages.accept(messages)
scrollToBottomTrigger.accept(messages.count)
connetSocketTrigger.accept(())
case .failure(let error):
print(error)
}
}
.disposed(by: disposeBag)