Skip to content

Greeddk/TaxiParty

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

72 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

프로젝트 소개

스크린샷

‎‎택시팟 - 택시 합승 플랫폼

개발 기간과 버전별 기능

개발 기간

v1.0

  • 2024.04.10 ~ 2024.05.06 (27일)

v1.1

  • 2024.05.18 ~ 2024.05.28 (11일)

Configuration

  • 최소버전 16.0 / 라이트 모드 / 세로모드 / iOS전용

v1.0 기능

  1. 택시 합승 파티 만들기
  • 위치 키워드 검색 기능
  • 맵에서 스크롤하여 위치 설정 기능
  • 예상 경로 기능
  • 예상 택시비 / 1인당 예상 택시비 정보

  1. 근처 택시팟 찾기 기능
  • 맵에서 근처 택시팟 표시 기능
  • 줌에 따라 클러스터 처리

  1. 프로필 설정
  • 프로필 닉네임 변경
  • 프로필 사진 변경

  1. 택시팟 포스트 조회
  • 유저가 올린 포스트들 조회 기능

v1.1 기능

  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


구현 고려 사항

1. 채팅

  • 사용자와 상대방의 채팅을 구분하기 위해 두가지 테이블셀 사용
  • 유저 경험 고려해 한 사람의 채팅을 연속적으로 했을 때, 중복되는 프로필 사진과 시간을 제거
  • 이전 채팅을 읽어와서 보여줘야하는 부분을 고려해 realm에 이전 대화 내용 저장

2. Router들을 하나의 Enum으로 관리

  • 다양한 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)))

3. Property Wrapper로 UserInfo 관리

  • 보일러 플레이트 코드 제거
  • 코드 재사용성 향상
코드 보기
@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
    
}

4. RxSwift

  • 실시간으로 상호작용하는 반응형 프로그래밍을 쉽게 적용하기 위해 RxSwift를 적용
  • 서버와의 통신을 비동기로 처리하고 스레드를 관리가 용이하기 때문에 적용

5. NaverMap

  • 네이버에서 Direction(네비게이션) API를 제공하고 있으며, Map API를 제공하는 회사별로 서로 다른 좌표계를 제공하기 때문에 추가적인 작업이 필요없는 네이버맵을 사용하기로 결정
  • 뷰의 재활용을 위해 커스텀 뷰로 구현

6. MVVM Input - Output

  • Input - Output 패턴을 사용해 일정한 형태를 가진 MVVM 디자인 패턴을 적용
  • 데이터 흐름을 파악하고 상황에 맞는 Operator를 적용
코드 보기
protocol ViewModelProtocol {
    associatedtype Input
    associatedtype Output
    
    var disposeBag: DisposeBag { get set }
    
    func transform(input: Input) -> Output
}

7. Interceptor로 AccessToken 갱신

  • 사용자의 경험을 고려하여 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)
        }
    }
    
}

⚒️트러블 슈팅

1. Custom Bottom Sheet의 크기 조절

문제상황

  • 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)
        }
    }

}

2. Custom Bottom Sheet의 레이아웃이 원하는대로 나타나지 않는 이슈

문제상황

  • 바텀 시트 사이즈가 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)
            }
        }
    }

3. 하나의 채팅이 여러번 저장되는 이슈

문제상황

  • 채팅 뷰를 나갔다가 다시 들어왔을 때, 동일한 채팅이 중복되어 저장되는 버그 발생

해결방법

  • 뷰모델이 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)

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published