[240122] Today I Learned

YoungHyun Kim·2024년 1월 22일
1

TIL ✍️

목록 보기
38/68

내배캠 앱개발 숙련 팀프로젝트

Super8oard (킥보드 어플)

비전공자 4명이 팀을 이루어 진행한 프로젝트입니다. 다소 어설픈 부분이 (많이)있을 수 있습니다.
프로젝트는 깃허브에서 보실 수 있습니다.
저는 프로젝트에서 회원가입/로그인 화면 직후 진입할 수 있는 지도 뷰를 작성했습니다.

1. MapViewController

//
//  ViewController.swift
//  NBCSuper8oard
//
//  Created by 영현 on 1/21/24.
//

import UIKit
import CoreLocation
import NMapsMap

class MapViewController: UIViewController, UIViewControllerTransitioningDelegate, NMFMapViewTouchDelegate, CLLocationManagerDelegate {
    
    lazy var mapView = NMFNaverMapView(frame: view.frame)
    var locationManager = CLLocationManager()
    weak var tabBarVC: TabBarController?
    lazy var boardList = [Board]()
    var numberOfDummyData = 30
    var ridingBoardNumber: Int?
    
    lazy var inUseLabel: UILabel = {
        let label = UILabel()
        label.text = "이용 중"
        label.font = UIFont.systemFont(ofSize: 20)
        label.textColor = .white
        label.backgroundColor = .systemBlue
        label.textAlignment = .center
        label.frame = CGRect(x: 150, y: 50, width: 100, height: 40)
        label.layer.cornerRadius = 20
        label.isHidden = true
        
        return label
    }()
    
    lazy var returnButton: UIButton = {
        let button = UIButton()
        button.setTitle("반납하기", for: .normal)
        button.backgroundColor = .systemBlue
        button.tintColor = .white
        button.frame = CGRect(x: 150, y: 700, width: 100, height: 40)
        button.isHidden = true
        button.layer.cornerRadius = 10
        button.addTarget(self, action: #selector(returnBoard), for: .touchUpInside)
        return button
    }()
    
    lazy var searchButton: UIButton = {
        let search = UIButton()
        search.setTitle("주소 검색", for: .normal)
        search.tintColor = .white
        search.backgroundColor = .systemBlue
        search.frame = CGRect(x: 280, y: 50, width: 100, height: 40)
        search.layer.cornerRadius = 10
        search.addTarget(self, action: #selector(searchButtonTapped), for: .touchUpInside)
        return search
    }()
    
    //MARK: Marker Click Event
    lazy var markerTapEvent = { [weak self] (overlay: NMFOverlay) -> Bool in
        let marker = overlay
        var tappedBoard: Board?
        for board in self!.boardList {
            if marker.userInfo["id"] as? Int == board.boardNumber {
                tappedBoard = board
            } else { continue }
        }
        let detailVC = DetailViewController(selectedBoard: tappedBoard, user: self?.tabBarVC?.user)
        
        detailVC.isRented = { [weak self] board in
            for i in 0..<self!.boardList.count {
                if self?.boardList[i].boardNumber == board.boardNumber {
                    self?.boardList[i].isAvailable = board.isAvailable
                }
            }
        }
        detailVC.hideMarker = { [weak self] isAvailable in
            marker.mapView = isAvailable ? self?.mapView.mapView : nil
            self?.tabBarVC?.user?.isRiding = !isAvailable
            self?.inUseLabel.isHidden = isAvailable
            self?.returnButton.isHidden = isAvailable
        }
        
        self?.ridingBoardNumber = tappedBoard?.boardNumber
        self?.present(detailVC, animated: true, completion: nil)
        return true
    }
    
    @objc func returnBoard() {
        let returnAlert = UIAlertController(title: "반납하기", message: "반납하시겠습니까?", preferredStyle: .alert)
        let returnAction = UIAlertAction(title: "반납", style: .default) { _ in
            self.inUseLabel.isHidden = true
            self.tabBarVC?.user?.isRiding = false
            if let ridingBoardNumber = self.ridingBoardNumber {
                let currentLocation = self.setCurrentLocation()
                let marker = NMFMarker(position: currentLocation)
                marker.iconImage = NMFOverlayImage(image: UIImage(named: "BoardMarkerIcon")!.resized(to: CGSize(width: 30, height: 30)))
                marker.touchHandler = self.markerTapEvent
                marker.minZoom = 10
                marker.userInfo["id"] = String(ridingBoardNumber)
                marker.mapView = self.mapView.mapView
                self.returnButton.isHidden = true
                
                for i in 0..<self.boardList.count {
                    if self.boardList[i].boardNumber == ridingBoardNumber {
                        self.boardList[i].isAvailable = true
                    }
                }
            }
        }
        let cancelAction = UIAlertAction(title: "취소", style: .cancel, handler: nil)
        returnAlert.addAction(returnAction)
        returnAlert.addAction(cancelAction)
        present(returnAlert, animated: true)
    }
    
    @objc func searchButtonTapped() {
        let searchVC = SearchViewController()
        
        present(searchVC, animated: true)
        
        searchVC.setCameraLocation = { lat, lng in
            let cameraUpdate = NMFCameraUpdate(scrollTo: NMGLatLng(lat: lat, lng: lng), zoomTo: 12)
            self.mapView.mapView.moveCamera(cameraUpdate)
            cameraUpdate.animation = .easeIn
        }
    }
    
    func makeDummyData() {
        for i in 0..<numberOfDummyData {
            let tempLoc = generateRandomNMGLatLng()
            let data = Board(boardType: "ninebot", boardNumber: i, boardBattery: 100, boardPrice: Int.random(in: 150...180), boardLocation: tempLoc, isAvailable: true)
            boardList.append(data)
        }
    }
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        mapView.mapView.touchDelegate = self
        locationManager.delegate = self
        switch locationManager.authorizationStatus {
        case .denied:
            print("위치 비허용")
        case .notDetermined, .restricted:
            locationManager.requestWhenInUseAuthorization()
        default:
            break
        }
        
        switch locationManager.accuracyAuthorization {
        case .reducedAccuracy:
            locationManager.requestTemporaryFullAccuracyAuthorization(withPurposeKey: "")
        case .fullAccuracy:
            break
        @unknown default:
            break
        }
        
        view.addSubview(mapView)
        setLocationData()
        mapView.showZoomControls = true
        mapView.showLocationButton = true
        mapView.showCompass = true
        mapView.mapView.minZoomLevel = 10
        mapView.mapView.maxZoomLevel = 15
        makeDummyData()
        for board in self.boardList {
            placeBoardOnMap(board: board).mapView = self.mapView.mapView
        }
        mapView.addSubview(inUseLabel)
        mapView.addSubview(returnButton)
        mapView.addSubview(searchButton)
        tabBarVC = parent as? TabBarController
    }
    
}

    

//MARK: Marker Config Methods
extension MapViewController {
    private func placeBoardOnMap(board: Board) -> NMFMarker {
        let marker = NMFMarker(position: board.boardLocation)
        marker.iconImage = NMFOverlayImage(image: UIImage(named: "BoardMarkerIcon")!.resized(to: CGSize(width: 30, height: 30)))
        marker.touchHandler = markerTapEvent
        marker.minZoom = 1
        marker.userInfo["id"] = board.boardNumber
        return marker
    }

    private func generateRandomNMGLatLng() -> NMGLatLng {
        return NMGLatLng(lat: Double.random(in: 37.3200...37.3700), lng: Double.random(in: 127.0800...127.1300))
    }
    
}

//MARK: Location Config
extension MapViewController {
    func setCurrentLocation() -> NMGLatLng {
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.requestAlwaysAuthorization()
        locationManager.startUpdatingLocation()
        let latitude = locationManager.location?.coordinate.latitude ?? 0
        let longitude = locationManager.location?.coordinate.longitude ?? 0
        let result = NMGLatLng(lat: latitude, lng: longitude)
        return result
      }
    
    func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
        print("현재 위치 권한 : \(manager.authorizationStatus)")
        switch manager.authorizationStatus {
        case .authorizedWhenInUse:
            break
        case .restricted, .denied:
            break
        case .notDetermined:
            break
        default:
            break
        }
    }
    
    func setLocationData() {
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.requestAlwaysAuthorization()
        locationManager.startUpdatingLocation()
        
        let latitude = locationManager.location?.coordinate.latitude ?? 0
        let longitude = locationManager.location?.coordinate.longitude ?? 0
        print(latitude, longitude)
        
        let cameraUpdate = NMFCameraUpdate(scrollTo: NMGLatLng(lat: latitude, lng: longitude), zoomTo: 12)
        mapView.mapView.moveCamera(cameraUpdate)
        cameraUpdate.animation = .easeIn
    }
    
    func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        if let location = locations.first {
            print("위도: \(location.coordinate.latitude)")
            print("경도: \(location.coordinate.longitude)")
        }
    }
    
    func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        print("error: \(error)")
    }
}
  • Naver의 지도 API인 NMapsMap, 사용자의 현재 위치 좌표를 사용하기 위해서 CoreLocation을 프로젝트에 사용했습니다.
  • Board 자료형을 요소로 가지는 배열에 더미데이터를 세팅하고, 지도 위의 마커를 생성하는데 사용합니다.
  • MapView가 Load되었을 때, 사용자에게 위치 권한을 요구합니다.
  • 마커를 누르면 킥보드를 대여할 수 있습니다(DetailView). 대여 중일 때 화면에 대여 중임을 알리는 라벨과, 킥보드를 반납할 수 있는 버튼이 활성화됩니다. 킥보드를 동시에 2대 이상 대여할 수 없도록, 대여 중인 컨디션에서 다른 킥보드를 의미하는 마커 터치 시, 대여하기 버튼이 비활성화되어 있습니다.
  • 주소 검색 버튼을 터치하면, 주소를 입력하고 검색버튼을 눌렀을 때 지도 화면을 이동시킬 수 있도록 하는 뷰(SearchView)를 표시합니다.

2. DetailViewController(킥보드 마커 터치 시)

//
//  DetailViewController.swift
//  NBCSuper8oard
//
//  Created by 영현 on 1/21/24.
//

import UIKit

class DetailViewController: UIViewController {
    
    var selectedBoard: Board?
    var user: User?
    var isRented: ((Board) -> ())?
    var isHidden: ((Board) -> ())?
    var hideMarker: ((Bool) -> ())?

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .white
        
        let boardImage = UIImageView(frame: CGRect(x: 10, y: 20, width: 100, height: 100))
        boardImage.image = UIImage(named: "Kickboard")
        view.addSubview(boardImage)
        
        
        let typeLabel = UILabel(frame: CGRect(x: 120, y: 20, width: view.frame.width - 120, height: 20))
        typeLabel.text = "킥보드 종류 : \(selectedBoard?.boardType ?? "")"
        typeLabel.textAlignment = .left
        view.addSubview(typeLabel)
        
        let priceLabel = UILabel(frame: CGRect(x: 120, y: 50, width: view.frame.width - 120, height: 20))
        priceLabel.text = "대여 가격: 분당 \(selectedBoard?.boardPrice ?? 0)₩"
        priceLabel.textAlignment = .left
        view.addSubview(priceLabel)
        
        let isAvailableLabel = UILabel(frame: CGRect(x: 120, y: 80, width: view.frame.width - 120, height: 20))
        isAvailableLabel.text = "대여 가능 여부: \(selectedBoard?.isAvailable ?? false)"
        isAvailableLabel.textAlignment = .left
        view.addSubview(isAvailableLabel)
        
        let rentButton = UIButton(type: .system)
        rentButton.setTitle("대여하기", for: .normal)
        rentButton.tintColor = .white
        rentButton.backgroundColor = .systemBlue
        rentButton.frame = CGRect(x: 0, y: 120, width: view.frame.width, height: 40)
        rentButton.layer.cornerRadius = 10
        rentButton.addTarget(self, action: #selector(rentButtonTapped), for: .touchUpInside)
        rentButton.isEnabled = selectedBoard?.isAvailable ?? false
        if let user = self.user {
            if user.isRiding {
                rentButton.isEnabled = false
            } else {
                rentButton.isEnabled = true
            }
        }
        view.addSubview(rentButton)
    }
    
    init(selectedBoard: Board? = nil, user: User? = nil) {
        self.selectedBoard = selectedBoard
        self.user = user
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    @objc func rentButtonTapped() {
        let confirmAlert = UIAlertController(title: "대여", message: "선택한 킥보드로 대여를 진행합니다.", preferredStyle: .alert)
        let rentAction = UIAlertAction(title: "확인", style: .default) { [self] _ in
            guard var temp = selectedBoard else { return }
            temp.isAvailable = temp.isAvailable ? false : true
            isRented?(temp)
            isHidden?(temp)
            hideMarker?(temp.isAvailable)
            dismiss(animated: true)
        }
        confirmAlert.addAction(rentAction)
        present(confirmAlert, animated: true)
    }
}
  • 킥보드의 이미지, 종류, 대여가격 및 대여 가능 여부를 표시해주는 UIImageView, UILabel을 위치시켰습니다.
  • 오토레이아웃을 각 요소들 간의 비율로 설정해야하지만, 일천한 지식으로 구현을 서둘러하기 위해서 절대적인 x, y값을 적어 요소를 배치하였습니다.
  • DetailView를 불러올 때, Board, User 자료를 받아와 사용자가 대여 중인 킥보드가 있는지와, 대여 후 킥보드의 대여가능 여부(Board.isAvailable)값을 처리합니다.
  • MapViewController -> DetailViewController 방향의 데이터 전달은 initializer를 사용하여 진행했으며, 반대 방향으로의 데이터 전달은 closure를 사용하여 진행했습니다.

3. SearchViewController(주소 검색 버튼 터치 시)

//
//  SearchViewController.swift
//  NBCSuper8oard
//
//  Created by 영현 on 1/21/24.
//

import UIKit

class SearchViewController: UIViewController {
    
    var searchTextField: UITextField!
    var currentLatitude: Double?
    var currentLongitude: Double?
    var setCameraLocation: ((Double, Double) -> ())?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        
        let searchLabel = UILabel(frame: CGRect(x: 10, y: 40, width: view.frame.width - 20, height: 20))
        searchLabel.text = "주소를 입력하세요."
        searchLabel.textAlignment = .left
        searchLabel.layer.cornerRadius = 5
        view.addSubview(searchLabel)
        
        searchTextField = UITextField(frame: CGRect(x: 10, y: 80, width: view.frame.width - 60, height: 40))
        searchTextField.placeholder = "검색하고 싶은 주소를 입력하세요."
        searchTextField.layer.cornerRadius = 5
        view.addSubview(searchTextField)
        
        let searchButton = UIButton(frame: CGRect(x: view.frame.width - 50, y: 80, width: 40, height: 40))
        searchButton.tintColor = .white
        searchButton.backgroundColor = .systemBlue
        searchButton.setTitle("검색", for: .normal)
        searchButton.layer.cornerRadius = 10
        searchButton.addTarget(self, action: #selector(moveCamera), for: .touchUpInside)
        view.addSubview(searchButton)
    }
    
    @objc func moveCamera() {
        NMGeocoding.shared.getGeoXY(searchTextField.text ?? "") { geoXY in
            if let geoXY = geoXY {
                geoXY.addresses.forEach { address in
                    self.currentLatitude = Double(address.y)
                    self.currentLongitude = Double(address.x)
                    print(address)
                }
            }
            if let lat = self.currentLatitude, let lng = self.currentLongitude {
                self.setCameraLocation?(lat, lng)
                self.dismiss(animated: true)
            } else {
                let cannotMoveAlert = UIAlertController(title: "입력된 주소를 확인해주세요.", message: nil, preferredStyle: .alert)
                let confirm = UIAlertAction(title: "확인", style: .default, handler: nil)
                cannotMoveAlert.addAction(confirm)
                self.present(cannotMoveAlert, animated: true)
            }
        }
    }
}
  • UITextField에 이동하고 싶은 주소를 적어넣고 검색버튼을 누르면, 모달로 올라왔던 SearchView가 내려가며, 지도가 해당 위치를 비춰줍니다.
  • 주소(String형)는 NMGeocode API와 통신하는데 사용하며, 결과로 받아온 JSON 형식의 데이터에서 좌표를 꺼내 사용합니다.

4. NMGeocoding

//
//  NMGeocoding.swift
//  NBCSuper8oard
//
//  Created by 영현 on 1/21/24.
//

import Foundation

struct GeoXY: Decodable {
    let addresses: [Address]
    struct Address: Decodable {
        let x: String
        let y: String
    }
}
class NMGeocoding {
    
    static let shared = NMGeocoding()
    let NAVER_CLIENT_ID = "*********"
    let NAVER_CLIENT_SECRET = "**************************"
    let baseURL = "https://naveropenapi.apigw.ntruss.com/map-geocode/v2/geocode"
    
    func getGeoXY(_ address: String, completion: @escaping (GeoXY?) -> Void) {
        
        var components = URLComponents(string: baseURL)!
        components.queryItems = [ URLQueryItem(name: "query", value: address) ]
        guard let url = components.url else {
            completion(nil)
            return
        }
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.addValue(NAVER_CLIENT_ID, forHTTPHeaderField: "X-NCP-APIGW-API-KEY-ID")
        request.addValue(NAVER_CLIENT_SECRET, forHTTPHeaderField: "X-NCP-APIGW-API-KEY")
        let session = URLSession.shared
        let task = session.dataTask(with: request) { (data, response, error) in
            guard error == nil else {
                completion(nil)
                return
            }
            guard let responseData = data else {
                completion(nil)
                return
            }
            do {
                let response = try JSONDecoder().decode(GeoXY.self, from: responseData)
                DispatchQueue.main.async {
                    completion(response)
                }
            } catch {
                DispatchQueue.main.async {
                    completion(nil)
                }
            }
        }
        task.resume()
    }
}
  • shared 객체를 생성해서 어디서든 접근할 수 있도록 만들었습니다.
  • 네이버 클라우드에서 발급받은 아이디와 시크릿키를 사용하여 통신할 수 있습니다.

회고

  1. API를 처음 사용해보다 보니, 학습하는데 많은 시간을 소비했습니다. 이 때문에 어플리케이션 구현이 미흡한 부분이 많아 아쉬운 프로젝트였습니다.
  2. 여전히 git 을 사용한 협업부분에서 많은 아쉬움이 있었습니다. 안전한 branch전략을 팀원들과 정의하고, 이를 강력히 준수하도록 하여 문제를 해결하도록 하려합니다.
  3. 소통을 자주 진행하는 것이 프로젝트의 원활한 진행에 대부분을 차지한다고 느꼈습니다. 적극적으로 소통하는 개발자가 되도록 노력하겠습니다.
profile
iOS 개발자가 되고 싶어요

0개의 댓글