FindCVS 코드리뷰
코드리뷰 ㄱㄱ
✅ 파일의 구조도
🟠 LocalAPI.swift
//
// LocalAPI.swift
// FindCVS
//
// Created by Hamlit Jason on 2022/02/19.
//
import Foundation
struct LocalAPI {
static let scheme = "https"
static let host = "dapi.kakao.com"
static let path = "/v2/local/search/category.json"
func getLocation(by mapPoint: MTMapPoint) -> URLComponents {
var components = URLComponents()
components.scheme = LocalAPI.scheme
components.host = LocalAPI.host
components.path = LocalAPI.path
components.queryItems = [
URLQueryItem(name: "category_group_code", value: "CS2"),
URLQueryItem(name: "x", value: "\(mapPoint.mapPointGeo().longitude)"),
URLQueryItem(name: "y", value: "\(mapPoint.mapPointGeo().latitude)"),
URLQueryItem(name: "radius", value: "500"),
URLQueryItem(name: "sort", value: "distance")
]
return components
}
}
🟠 LocalNetwork.swift
//
// LocalNetwork.swift
// FindCVS
//
// Created by Hamlit Jason on 2022/02/19.
//
import RxSwift
class LocalNetwork {
private let session: URLSession
let api = LocalAPI()
init(session: URLSession = .shared) {
self.session = session
}
func getLocation(by mapPoint: MTMapPoint) -> Single<Result<LocationData, URLError>> {
guard let url = api.getLocation(by: mapPoint).url else {
return .just(.failure(URLError(.badURL)))
}
let request = NSMutableURLRequest(url: url)
request.httpMethod = "GET"
request.setValue("KakaoAK { YOUR API KEY }", forHTTPHeaderField: "Authorization")
return session.rx.data(request: request as URLRequest)
.map { data in
do {
let locationData = try JSONDecoder().decode(LocationData.self, from: data)
return .success(locationData)
} catch {
return .failure(URLError(.cannotParseResponse))
}
}
.catch { _ in .just(Result.failure(URLError(.cannotLoadFromNetwork))) }
.asSingle()
}
}
네트워크 부분은 AF없이 rx로만 사용하는게 더욱 좋아 보이는 느낌도 든다.
🟠 LocationData.swift
//
// LocationData.swift
// FindCVS
//
// Created by Hamlit Jason on 2022/02/18.
//
import Foundation
struct LocationData: Codable {
let documents: [KLDocument]
}
🟠 LocationData.swift
//
// KLDocument.swift
// FindCVS
//
// Created by Hamlit Jason on 2022/02/18.
//
import Foundation
struct KLDocument: Codable {
let placeName: String
let addressName: String
let roadAddressName: String
let x: String
let y: String
let distance: String
enum CodingKeys: String, CodingKey {
case x, y, distance
case placeName = "place_name"
case addressName = "address_name"
case roadAddressName = "road_address_name"
}
}
🟠 DetailListCellData.swift
//
// DetailListCellData.swift
// FindCVS
//
// Created by Hamlit Jason on 2022/02/18.
//
import Foundation
struct DetailListCellData {
let placeName: String
let address: String
let distance: String
let point: MTMapPoint
}
🟠 MTMapViewError.swift
//
// MTMapViewError.swift
// FindCVS
//
// Created by Hamlit Jason on 2022/02/19.
//
import Foundation
enum MTMapViewError: Error {
case failedUpdateCurrentLocation
case locationAuthorizaationDenied
var errorDescription: String {
switch self {
case .failedUpdateCurrentLocation:
return "현재 위치를 불러오지 못했어요. 잠시 후 다시 시도해주세요."
case .locationAuthorizaationDenied:
return "위치 정보를 비활성화하면 사용자의 현재 위치를 알 수 없어요."
}
}
}
✅ 여기 아래부터가 로직을 담당하는 코드 부분
🟠 LocationInfomationView.swift
//
// LocationInfomationView.swift
// FindCVS
//
// Created by Hamlit Jason on 2022/02/18.
//
import UIKit
import CoreLocation
import RxSwift
import RxCocoa
import SnapKit
class LocationInformationViewController: UIViewController {
let disposeBag = DisposeBag()
let locationManager = CLLocationManager() // 현재 위치를 찾기 위함
let mapView = MTMapView() // 카카오 맵 SDK에 있는 맵뷰
let currentLocationButton = UIButton()
let detailList = UITableView()
let detailListBackgroundView = DetailListBackgroundView()
let viewModel = LocationInformationViewModel()
override func viewDidLoad() {
super.viewDidLoad()
// 아래에 프로토콜 상속받아서 구현되어 있다.
mapView.delegate = self
locationManager.delegate = self
bind(viewModel) // bind할 때 viewModel을 이렇게도 넘겨줌.
attribute()
layout()
}
private func bind(_ viewModel: LocationInformationViewModel) {
// LocationInformationViewModel이 DetailListBackgroundViewModel을 들고 있어서 이렇게하면 전달해줄 수 있다.
detailListBackgroundView.bind(viewModel.detailListBackgroundViewModel)
viewModel.setMapCenter // 현재 맵의 중심
.emit(to: mapView.rx.setMapCenterPoint) // 맵의 중심값으로 맞춤
.disposed(by: disposeBag)
viewModel.errorMessage
.emit(to: self.rx.presentAlert) // rx extension을 이용하여 구현.
.disposed(by: disposeBag)
viewModel.detailListCellData
.drive(detailList.rx.items) { tv, row, data in
let cell = tv.dequeueReusableCell(withIdentifier: DetailListCell.identifier, for: IndexPath(row: row, section: 0)) as! DetailListCell
cell.setData(data)
return cell
}
.disposed(by: disposeBag)
viewModel.detailListCellData
.map { $0.compactMap { $0.point } } // compactMap은 1차원 배열일 때, nil을 제거합니다.
.drive(self.rx.addPOIItems)
.disposed(by: disposeBag)
viewModel.scrollToSelectedLocation
.emit(to: self.rx.showSelectedLocation)
.disposed(by: disposeBag)
detailList.rx.itemSelected
.map { $0.row }
.bind(to: viewModel.detailListItemSelected)
.disposed(by: disposeBag)
currentLocationButton.rx.tap
.bind(to: viewModel.curentLocationButtonTapped)
.disposed(by: disposeBag)
}
private func attribute() {
title = "내 주변 편의점 찾기"
view.backgroundColor = .white
mapView.currentLocationTrackingMode = .onWithoutHeadingWithoutMapMoving
currentLocationButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
currentLocationButton.backgroundColor = .white
currentLocationButton.layer.cornerRadius = 20
detailList.register(DetailListCell.self, forCellReuseIdentifier: "DetailListCell")
detailList.separatorStyle = .none
detailList.backgroundView = detailListBackgroundView
}
private func layout() {
[mapView, currentLocationButton, detailList]
.forEach { view.addSubview($0) }
mapView.snp.makeConstraints {
$0.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
$0.bottom.equalTo(view.snp.centerY).offset(100)
}
currentLocationButton.snp.makeConstraints {
$0.bottom.equalTo(detailList.snp.top).offset(-12)
$0.leading.equalToSuperview().offset(12)
$0.width.height.equalTo(40)
}
detailList.snp.makeConstraints {
$0.centerX.leading.trailing.equalToSuperview()
$0.bottom.equalTo(view.safeAreaLayoutGuide).inset(8)
$0.top.equalTo(mapView.snp.bottom)
}
}
}
extension LocationInformationViewController: CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .authorizedAlways,
.authorizedWhenInUse,
.notDetermined:
return
default:
viewModel.mapViewError.accept(MTMapViewError.locationAuthorizaationDenied.errorDescription) // enum으로 이렇게 처리하는게 인상적이다.
return
}
}
}
extension LocationInformationViewController: MTMapViewDelegate {
func mapView(_ mapView: MTMapView!, updateCurrentLocation location: MTMapPoint!, withAccuracy accuracy: MTMapLocationAccuracy) {
#if DEBUG
viewModel.currentLocation.accept(MTMapPoint(geoCoord: MTMapPointGeo(latitude: 37.394225, longitude: 127.110341))) // 시뮬에서는 geo를 줄 수 없으므로 디버그에서 임의 지정
#else
viewModel.currentLocation.accept(location)
#endif
}
func mapView(_ mapView: MTMapView!, finishedMapMoveAnimation mapCenterPoint: MTMapPoint!) {
viewModel.mapCenterPoint.accept(mapCenterPoint)// 맵뷰의 중앙점 바꾸고 network 다시 수행해서 데이터 재세팅
}
func mapView(_ mapView: MTMapView!, selectedPOIItem poiItem: MTMapPOIItem!) -> Bool {
viewModel.selectPOIItem.accept(poiItem)
return false
}
func mapView(_ mapView: MTMapView!, failedUpdatingCurrentLocationWithError error: Error!) {
viewModel.mapViewError.accept(error.localizedDescription)
}
}
extension Reactive where Base: MTMapView {
var setMapCenterPoint: Binder<MTMapPoint> {
return Binder(base) { base, point in
base.setMapCenter(point, animated: true)
}
}
}
extension Reactive where Base: LocationInformationViewController {
var presentAlert: Binder<String> {
return Binder(base) { base, message in
let alertController = UIAlertController(title: "문제가 발생했어요", message: message, preferredStyle: .alert)
let action = UIAlertAction(title: "확인", style: .default, handler: nil)
alertController.addAction(action)
base.present(alertController, animated: true, completion: nil)
}
}
var showSelectedLocation: Binder<Int> {
return Binder(base) { base, row in
let indexPath = IndexPath(row: row, section: 0)
base.detailList.selectRow(at: indexPath, animated: true, scrollPosition: .top) // selectRow 테이블 뷰 안에 있는 scroll
}
}
var addPOIItems: Binder<[MTMapPoint]> {
return Binder(base) { base, points in
let items = points
.enumerated()
.map { offset, point -> MTMapPOIItem in
let mapPOIItem = MTMapPOIItem()
mapPOIItem.mapPoint = point
mapPOIItem.markerType = .redPin
mapPOIItem.showAnimationType = .springFromGround
mapPOIItem.tag = offset
return mapPOIItem
}
base.mapView.removeAllPOIItems()
base.mapView.addPOIItems(items)
}
}
}
🟠 LocationInformationViewModel.swift
//
// LocationInfomationViewModel.swift
// FindCVS
//
// Created by Hamlit Jason on 2022/02/18.
//
import RxSwift
import RxCocoa
struct LocationInformationViewModel {
let disposeBag = DisposeBag()
//subViewModels
let detailListBackgroundViewModel = DetailListBackgroundViewModel()
//viewModel -> view
let setMapCenter: Signal<MTMapPoint>
let errorMessage: Signal<String>
let detailListCellData: Driver<[DetailListCellData]>
let scrollToSelectedLocation: Signal<Int>
//view -> viewModel
let currentLocation = PublishRelay<MTMapPoint>()
let mapCenterPoint = PublishRelay<MTMapPoint>()
let selectPOIItem = PublishRelay<MTMapPOIItem>()
let mapViewError = PublishRelay<String>()
let curentLocationButtonTapped = PublishRelay<Void>()
let detailListItemSelected = PublishRelay<Int>()
private let documentData = PublishSubject<[KLDocument]>()
init(model: LocationInformationModel = LocationInformationModel()) {
//MARK: 네트워크 통신으로 데이터 불러오기
let cvsLocationDataResult = mapCenterPoint
.flatMapLatest(model.getLocation)
.share()
let cvsLocationDataValue = cvsLocationDataResult
.compactMap { data -> LocationData? in
guard case let .success(value) = data else { // case let으로 변수처럼 사용도 가능
return nil
}
return value
}
let cvsLocationDataErrorMessage = cvsLocationDataResult
.compactMap { data -> String? in
switch data {
case let .success(data) where data.documents.isEmpty: // 네트워크 작업에서 데이터가 비어 있다면
return """
500m 근처에 이용할 수 있는 편의점이 없어요.
지도 위치를 옮겨서 재검색해주세요.
"""
case let .failure(error):
return error.localizedDescription
default:
return nil
}
}
cvsLocationDataValue
.map { $0.documents }
.bind(to: documentData) // 네트워크 작업 통해서 가져온 값을 바꾼다.
.disposed(by: disposeBag)
//MARK: 지도 중심점 설정
let selectDetailListItem = detailListItemSelected // detailListItemSelected에서
.withLatestFrom(documentData) {
print($1) // withLatestFrom을 사용하면서 documentData이 $1
return $1[$0] // $0은 배열 인덱스
} // documentData을 withLatestFrom로 만든다.
.map(model.documentToMTMapPoint)
let moveToCurrentLocation = curentLocationButtonTapped
.withLatestFrom(currentLocation) // 터치 이벤트 들어오면 현재 위치만 담아서 변수에 담는다.
let currentMapCenter = Observable
.merge(
selectDetailListItem,
currentLocation.take(1), // currentLocation는 한번만
moveToCurrentLocation
)
setMapCenter = currentMapCenter
.asSignal(onErrorSignalWith: .empty())
errorMessage = Observable
.merge(
cvsLocationDataErrorMessage,
mapViewError.asObservable()
)
.asSignal(onErrorJustReturn: "잠시 후 다시 시도해주세요.")
detailListCellData = documentData
.map(model.documentsToCellData)
.asDriver(onErrorDriveWith: .empty())
documentData
.map { !$0.isEmpty } // Bool 타입으로 내려간다. 배열이 비지 않으면 false가 내려간다.
.bind(to: detailListBackgroundViewModel.shouldHideStatusLabel)
.disposed(by: disposeBag)
scrollToSelectedLocation = selectPOIItem
.map { $0.tag }
.asSignal(onErrorJustReturn: 0)
}
}
🟠 LocationInformationModel.swift
//
// LocationInformationModel.swift
// FindCVS
//
// Created by Hamlit Jason on 2022/02/19.
//
import Foundation
import RxSwift
struct LocationInformationModel {
let localNetwork: LocalNetwork
init(localNetwork: LocalNetwork = LocalNetwork()) {
self.localNetwork = localNetwork
}
func getLocation(by mapPoint: MTMapPoint) -> Single<Result<LocationData, URLError>> {
return localNetwork.getLocation(by: mapPoint)
}
func documentsToCellData(_ data: [KLDocument]) -> [DetailListCellData] {
return data.map {
let address = $0.roadAddressName.isEmpty ? $0.addressName : $0.roadAddressName
let point = documentToMTMapPoint($0)
return DetailListCellData(placeName: $0.placeName, address: address, distance: $0.distance, point: point)
}
}
func documentToMTMapPoint(_ doc: KLDocument) -> MTMapPoint {
let longitude = Double(doc.x) ?? .zero
let latitude = Double(doc.y) ?? .zero
return MTMapPoint(geoCoord: MTMapPointGeo(latitude: latitude, longitude: longitude))
}
}
🟠 DetailListCell.swift
//
// DetailListCell.swift
// FindCVS
//
// Created by Hamlit Jason on 2022/02/19.
//
import UIKit
class DetailListCell: UITableViewCell {
static let identifier = "DetailListCell"
let placeNameLabel = UILabel()
let addressLabel = UILabel()
let distanceLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
attribute()
layout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setData(_ data: DetailListCellData) {
placeNameLabel.text = data.placeName
addressLabel.text = data.address
distanceLabel.text = data.distance
}
private func attribute() {
backgroundColor = .white
placeNameLabel.font = .systemFont(ofSize: 16, weight: .bold)
addressLabel.font = .systemFont(ofSize: 14)
addressLabel.textColor = .gray
distanceLabel.font = .systemFont(ofSize: 12, weight: .light)
distanceLabel.textColor = .darkGray
}
private func layout() {
[placeNameLabel, addressLabel, distanceLabel]
.forEach { contentView.addSubview($0) }
placeNameLabel.snp.makeConstraints {
$0.top.equalToSuperview().offset(12)
$0.leading.equalToSuperview().offset(18)
}
addressLabel.snp.makeConstraints {
$0.top.equalTo(placeNameLabel.snp.bottom).offset(3)
$0.leading.equalTo(placeNameLabel)
$0.bottom.equalToSuperview().inset(12)
}
distanceLabel.snp.makeConstraints {
$0.centerY.equalToSuperview()
$0.trailing.equalToSuperview().inset(20)
}
}
}
🟠 DetailListBackgroundView.swift
//
// DetailListBackgroundView.swift
// FindCVS
//
// Created by Hamlit Jason on 2022/02/19.
//
import RxSwift
import RxCocoa
class DetailListBackgroundView: UIView {
let disposeBag = DisposeBag()
let statusLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
attribute()
layout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func bind(_ viewModel: DetailListBackgroundViewModel) {
viewModel.isStatusLabelHidden
.emit(to: statusLabel.rx.isHidden)
.disposed(by: disposeBag)
}
private func attribute() {
backgroundColor = .white
statusLabel.text = "🏪"
statusLabel.textAlignment = .center
}
private func layout() {
addSubview(statusLabel)
statusLabel.snp.makeConstraints {
$0.center.equalToSuperview()
$0.leading.trailing.equalToSuperview().inset(20)
}
}
}
🟠 DetailListBackgroundViewModel.swift
//
// DetailListBackgroundViewModel.swift
// FindCVS
//
// Created by Hamlit Jason on 2022/02/19.
//
import RxSwift
import RxCocoa
struct DetailListBackgroundViewModel {
// viewModel -> view
let isStatusLabelHidden: Signal<Bool>
// 외부에서 전달받을 값
let shouldHideStatusLabel = PublishSubject<Bool>()
init() {
isStatusLabelHidden = shouldHideStatusLabel
.asSignal(onErrorJustReturn: true)
}
}
✅ Info.plist 파일 세팅해주어야 함
'Archive > 패캠(초격차)' 카테고리의 다른 글
part4 (ch1). MyAssets 코드리뷰(feat. SwiftUI) (0) | 2022.02.23 |
---|---|
part5 (ch1). FindCVS UnitTest 코드리뷰 (feat. Stubber) (0) | 2022.02.20 |
part5 (ch6). KeywordNews XCTest 코드리뷰 (0) | 2022.02.17 |
part5 (ch6). KeywordNews 코드리뷰 (0) | 2022.02.15 |
part5 (ch6). 🪛 CI/CD란? (feat. bitrise) (0) | 2022.02.15 |