# [Swift - UIKit] UIViewController 위에 여러 Protocol을 적용하여 동작하는 다양한 뷰 만들기

> 코드 베이스로 뷰를 그리는 연습을 하기 전, SB + Outlet 코드로 뷰를 그리는 연습을 하면서 최종적으로 만들어 본 TravelProject에 대한 정리 글입니다. 전체 코드는 **[해당 링크](https://github.com/hankyeol-dev/TravelProject)**에서 볼 수 있습니다.

![이런 느낌의 간단한 앱을 만들어 보면서 Storyboard + Code 형태로 간단하게 앱을 빌드하는 과정에 익숙해짐](https://upload.cafenono.com/image/slashpagePost/20240530/145509_FyQs9TpxPAYQqcisJr?q=75&s=1280x180&t=outside&f=webp)

Swift를 학습하면서 정말 다양한 뷰를 직접 만들어보고 있다. 24년 5월 27일부터 30일까지 스토리보드 기반으로 UIViewController 위에서 코드로 여러 화면을 그리는 연습을 했다. 그 과정에서 나 스스로 '이건 꼭 기록으로 남겨봐야겠다.' 하는 점들이 있어서 남겨본다.

### 1. UIViewController 위에 커스텀한 TableView 올려보기 (with. XIB)

![Image](https://upload.cafenono.com/image/slashpagePost/20240610/150500_mh7ae97nEkf6HLk0wU?q=75&s=1280x180&t=outside&f=webp)

- UIViewController에 UITable을 핸들링하기 위한 Delegation, Datasource 프로토콜을 적용해준다.

*Protocol을 통해 delegation을 위임하는 것에 대한 내용은 [요 링크](https://slashpage.com/hankyeol/91kwev26nv8752y46jpg)로 짧게 작성해봤다.

- UITable에 들어갈 커스텀한 TableCell을 작업하기 위해 Swift 파일과 XIB 파일을 함께 생성해준다.

    - XIB에서 작업한 커스텀 셀을 재사용이 가능한 셀로 만들기 위해 정확한 Identifier를 지정해줘야 한다.

    - 이 부분 역시 UITableViewCell 클래스를 상속받는 클래스들이 identifier라는 멤버 변수를 가질 수 있도록 [protocol + extension 조합](https://slashpage.com/hankyeol/1q3vdn2pq93nkmxy49pr)으로 코드를 미리 작성해두면 편하다.

- UIViewController 위에 올려진 UITableView의 인스턴스에 커스텀한 Cell을 연결하고, 어떤 행에 어떤 셀을 어떻게 보여줄 것인지를 위임받은 코드에 설정해준다.

코드로 한 번 보자.

```javascript
// UIViewController

class SomeUIViewController: UIViewController {

  @IBOutlet var someTable: UITableView!
  ...

  func configureTable() {
    // 각각의 프로토콜에 테이블 핸들링을 위임 받는다.
    someTable.delegate = self
    someTable.dataSource = self
    someTable.register(
      UINib(nibName: CustomTableCell.identifier, bundle: nil), 
      forCellReuseIdentifier: CustomTableCell.identifier
    )
  }
}

extension SomeUIViewController: UITableViewDelegate, UITableViewDataSource {
  // SomeUIViewController의 역할을 테이블 관련 프로토콜로 확장시킨다.

  func tableView(
    _ tableView: UITableView, 
    numberOfRowsInSection section: Int) -> Int {

    // 하나의 테이블 섹션에서 몇 개의 행을 생성할 지 결정해주어야 한다.
    return datas.count
  }

  func tableView(
    _ tableView: UITableView, 
    cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    // xib로 생성한 커스텀 Cell 인스턴스를 반환해줘야 한다.
    let cell = someTable.dequeueReusableCell(
      withIdentifier: CustomTableCell.identifier, for: indexPath
    ) as! CustomTableCell

    return cell
  }

  func tableView(
    _ tableView: UITableView, 
    didSelectRowAt indexPath: IndexPath) {

    // 특정 Cell이 선택(select)되었을 때, 어떤 동작을 처리할 지에 대한 로직이 온다.
    // 해당 메서드에 대한 구현은 필수가 아니지만, 보통 구현하는 것 같다.
  }
}
```

- 위 코드 형태가 Swift에서 테이블 뷰를 그리는 가장 기본적인 방법이다.

- `cellForRowAt` 에서 커스텀하게 생성한 TableViewCell 클래스의 인스턴스를 **as 키워드로 정확하게 캐스팅** 해줘야 해당 클래스 내부에 구현한 멤버 요소들을 불러와 사용할 수 있다.

### 2. 연결된 VC에 데이터 넘겨주기

![Image](https://upload.cafenono.com/image/slashpagePost/20240611/092459_HCXJbtNOzgcG5tx6xm?q=75&s=1280x180&t=outside&f=webp)

테이블 뷰에서 특정 셀을 터치하여 선택할 경우, 연결된 페이지 뷰(UIViewController)로 데이터를 전달해야하는 경우가 있다. 여러 방법이 있겠지만, 가장 쉽다고 생각하는 방법은 해당 VC의 멤버 변수(Outlet 변수 포함)에 값을 할당하는 것이다.

```javascript
// SomeViewController.swift
class SomeViewController: UIViewController {
    var datas: SomeDataSet?
  
    override func viewDidLoad() {
        super.viewDidLoad()
        ..
    }

    func configureViewWithData(dataSet: SomeDataSet) {
        // 멤버 변수로 선언한 datas에 값을 할당해준다.
        datas = dataSet

        // Optional 멤버 변수로 선언했기 때문에 unwrapping은 필수다.
        guard let datas else { return }

        someLabel.text = datas.somekey.text
        ..
    }
}

// UITableView가 컨트롤 되는 VC에서 작성
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let vc = storyboard?.instantiateViewController(
      withIdentifier: SomeViewController.id
    ) as! SomeViewController

    // pushViewController 메서드를 호출하기 전에 데이터를 VC 멤버들에 할당해준다.
    vc.configureViewWithData(dataSet: someDatas[indexPath.row])

    navigationController?.pushViewController(vc, animated: true)

    someTable.reloadRows(at: [indexPath], with: .none)
  
}

func tableView(_ tableView: UITableView, didSelect)
```

- 테이블의 특정 셀이 선택되어 **연결된 VC 인스턴스가 Present되기 전에 반드시 데이터 자체를 VC의 내부 멤버들에 할당**하는 코드가 반영되어야 한다.

    - VC가 Present되면 ViewDidLoad 메서드가 호출되고, 해당 뷰에 있는 각각의 뷰 객체들의 속성 값이 반영되어 있지 않으면 알 수 없는 nil 값을 만나서 앱이 종료된다.

    - 다른 SB에 있는 VC를 찾아 navigating하는 방법에 대해서는 [요 링크](https://slashpage.com/hankyeol/7vgjr4m157v8jmdwpy86)에 작성해뒀다.

    - 코드로 다른 VC를 연결해주는 방법은 오히려 더 간단하다. 그냥 VC 인스턴스를 생성해주면 된다.

```javascript
let vc = SomeViewController()
present(vc, animated: true)
```

- 유저의 액션에 의해서 VC간 데이터가 잘 전송되면 다양한 형태의 UI로 앱을 구성하기 좋다.

### 3. UISearchBar + UISegmentedControl + UITableView 조합으로 필터링된 데이터 보여주기

![Image](https://upload.cafenono.com/image/slashpagePost/20240611/093420_gbe4EUO66Z345Ba78W?q=75&s=1280x180&t=outside&f=webp)

유저가 원하는 조건으로 데이터를 필터링해서 보여줘야 할 경우가 많다. 검색을 할 수도 있고, 메뉴를 여러개 생성할 수도 있고, 조건 자체를 미리 여러 개 생성해 둘 수도 있다. 이번 프로젝트를 하면서 iOS에 내장된 SegmentedControl, SearchBar를 통해 데이터를 필터링 해볼 수 있었다. 물론 미리 정의된 Protocol을 불러와 역할을 ViewController에 위임하는 형식으로 코드를 작성했다.

1. 필터링 할 데이터셋 확인하기

- 데이터를 필터링하려면 데이터가 어떤 형식으로 생겼는지 알아야 한다. 

- 데이터는 기본적으로 (관계성이 있는 정형화되었다면) 동일한 키를 서로다른 값으로 채우는 형식이다. 

    - 이런 데이터를 특정 기준으로 필터링 하는데 가장 좋은 기준은 Bool 타입을 찾는 것이다.

```javascript
// City Model
struct City {
    let city_name: String
    let city_english_name: String
    let city_explain: String
    let city_image: String
    let domestic_travel: Bool
    
    var formattedCityName: String {
        return "\(city_name) | \(city_english_name)"
    }
}

struct Cities {
    static let cities: [City] = [ ... ]

    var isDomesticCities: [City] {
        Cities.cities.filter {
            return $0.domestic_travel
        }
    }

    var isNotDomesticCities: [City] {
        Cities.cities.filter {
            return !$0.domestic_travel
        }
    }
}
```

- City라는 구조체는 domestic_travel (국내 여행 여부) 라는 키(사실 멤버 변수명이지만)에 Bool 타입의 값이 들어와야 한다. 필터링하기 딱 좋은 기준이다.

- 해당 키를 기반으로 `.filter 메서드`를 활용해 국내 여행 / 비국내 여행을 구조체 내에서 계산형 변수로 반환해줄 수 있다. View 단에서 로직을 세울 필요가 없다.

1. 세그먼트의 선택에 따라서 필터링된 데이터 할당하기

- 데이터 처리가 준비되었다면, 이제 세그먼트 요소별 선택 값만 잘 지정해주면 된다.

```javascript
// View Controller
class SomeViewController {
    ..
    var data: [City]?
    @IBOutlet var segmentControl: UISegmentedControl!

    func configSegment() {
        ["전체", "국내", "해외"].enumerated().forEach { (idx, v) in
            // 이렇게 배열로 하지 않고 다른 방법을 사용해도 물론 괜찮다.
            segmentControl.setTitle(v, forSegmentAt: idx)
        }

        // 버튼에 addTarget하는 것과는 다르게, 
        // 세그먼트의 value가 변경되면 액션이 발생되도록 설정해줬다.
        segmentControl.addTarget(
            self, 
            action: #selector(selectSegment), 
            for: .valueChanged
        )
    }

    @objc func selectSegment(_ sender: UISegmentedControl) {
        // sender 인스턴스 자체에 selectedSegmentIndex 값이 있다.
        switch sender.selectedSegmentIndex {
        case 1:
            data = Cities.isDomesticCities
        case 2:
            data = Cities.isNotDomesticCities
        default:
            data = Cities.cities
        }

        // 물론 여기쯤에서 테이블 데이터를 reload하는 함수 호출이 필요할 수 있다.
    }
}
```

- UISegmentedControl 객체에 내장된 setTitle 메서드로 세그먼트 선택지 이름을 부여할 수 있다. 

- UIButton에 액션 타겟 함수를 지정해주는 것과 동일하게, 세그먼트 인스턴스에도 값이 변경될 때마다 불러낼 액션 타겟 함수를 지정할 수 있다.

- .selectedSegmentIndex 라는 접근자로 필터링 해두었던 데이터를 맵핑만 잘 해주면 된다. 

    - 필터링 된 데이터를 테이블에 보여주려면, 테이블 reload 함수를 호출하면 된다.

    - 기준이 명확하다면, 열거형으로 기준을 명확하게 정의해주는 것도 좋을 것 같다.

1. 검색어에 따라서 필터링된 데이터 보여주기

- UISearchBar 역시 VC에 그 역할을 위임할 수 있다. (UISearchBarDelegate) 

    - extension 키워드로 VC에 서치바에 대한 역할 코드를 구분하여 작성하면 코드를 관리하기 편하다.

```javascript
// View Controller
extension SomeViewController: UISearchBarDelegate { }
```

- 서치바에서 입력이 어떻게 이루어졌을 때, 검색어를 어떻게 처리해줄 지를 결정하는 것이 로직의 핵심이다. 아래 목록 중에 밑줄을 그어둔 부분이 서치바에서 내가 고려한 유저 이벤트다.

    - 유저가 보고 싶은 데이터를 키워드로 검색했을 때 (검색 완료) → 해당 키워드를 포함하는 or 키워드와 일치하는 데이터를 필터링해서 보여준다.

    - 유저가 서치바에 입력할 때마다 데이터를 검증하면서 해당하는 내용이 있으면 필터링해서 보여준다.

    - 유저가 서치바에 입력한 데이터를 다 지우면, 처음 데이터를 다시 보여준다.

```javascript
extension SomeViewController: UISearchBarDelegate {
    var data: [City]?
    @IBOutlet var searchBar: UISearchBar!
  
    // 1. 검색어를 입력하고, '검색' 버튼 or '입력키' 를 입력했을 때
    func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
        view.isEndEditing(true) // 키보드를 내려주고

        // 서치바에 입력한 값이 nil일 경우를 배제해주고
        guard let text = searchBar.text?.lowercase() else { return }

        if text.isEmpty {
            // 서치바를 비어둔 상태에서 입력 -> 기존 데이터 유지
            data = 기존 데이터 
        } else {
            data = 검색어로 필터링한 데이터
        }

        // 검색이 끝나면, 업데이트 된 데이터를 기반으로 테이블 리로드
        table.reloadSections(IndexSet(integer: 0), with: .none)
    }

    // 2. 검색어를 타이필 할 때마다 호출되는 메서드 (1번과 내부는 거의 동일함)
    func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
        guard let text = searchBar.text?.lowercased() else { return }
        
        if text.isEmpty {
            searchedData = 기존 데이터
        } else {
            searchedData = 검색어로 필터링한 데이터
        }

        table.reloadSections(IndexSet(integer: 0), with: .none)
    }

    // 검색어에 따라 기존 데이터를 필터링하는 helper function
    func _filteringData(_ dataSet: [City], keyword: String) -> [City] {
        return dataSet.filter {
            $0.city_name.contains(keyword)
            || $0.city_en_name.contains(keyword)
            || $0.city_explain.contains(keyword)
        }
    }
}
```

- 검색어를 어떻게 필터링 할 것인지는 개발자의 뜻에 달려있는 것 같다. 

    - 양이 정해진 데이터에서 검색을 할 때는 매번 필터링을 거쳐도 괜찮겠지만, 검색에 따라 API를 호출하여 외부 서버와 통신을 해야한다면 필터 함수에 적절한 지연이 필요해보인다.

1~3을 통해 서치바 + 세그먼트 컨트롤로 데이터를 필터링하여 뷰를 다르게 보여준 예시는 아래 영상과 같다.

[Video](https://vz-127031db-d43.b-cdn.net/f2885a37-11ec-4716-9e5c-06fca8c0c145/playlist.m3u8)

### 4. MapKit을 이용해서 지도상에 주소의 위치를 표시하고, UITextField에 PickerView 삽입하여 위치 데이터 선택하기

![Image](https://upload.cafenono.com/image/slashpagePost/20240612/092037_nfdmG2VgqDZB8GYmBa?q=75&s=1280x180&t=outside&f=webp)

iOS에서는 자체 내장 패키지로 MapKit을 지원한다. Apple Map이 있기 때문에, 해당 지도에 구현되어 있는 내부 API를 활용하여 앱 위에서 지도를 그려갈 수 있다. VC에서는 위에서 계속 해왔던 대로 Delegation을 잘 해주면 된다.

MapKit을 활용하기 위해서는, 다른 어떤 웹 지도와 동일하게 latitude, langitude 값이 필요하다. 위치에 대한 이름도 같이 기본적으로 표시하면 참 좋겠다.

```javascript
import MapKit

class SomeViewController: UIViewController, MKMapViewDelegate {
    var address: (String, Double, Double) = ("서울역", 37.554921, 126.970345)
  
    @IBOutlet var mapview: MKMapView!
    @IBOutlet var mapPickerTextField: UITextField!
    let picker = UIPickerView()

    func configureMapView() {
        mapview.delegate = self
        setAddressOnMap(address, map: mapview)
    }

    func setAddressOnMap(_ addressData: (String, Double, Double), map: MKMapView) {
        let annotation = MKPointAnnotation()
      
        map.setRegion(
            MKCoordinateRegion(
                center: CLLocationCoordinate2D(latitude: addressData.1, longitude: addressData.2),
                latitudinalMeters: 100, longitudinalMeters: 100)
            , animated: true)
      
        annotation.coordinate = CLLocationCoordinate2D(latitude: addressData.1, longitude: addressData.2)
        annotation.title = addressData.0
    
        map.addAnnotation(annotation)
    }
}
```

- MKMapViewDelgete 프로토콜에 내장된 setRegion API를 이용해서 위도, 경도, 주소의 이름 값으로 지도에 위치를 찾고, 반경 얼마만큼으로 보여줄 지를 결정할 수 있다. 

    - 이 API를 이용해서 주소값을 알고 있으면 동일 지도의 위치로 이동시켜주는 간단한 메서드를 작성해서 이용했다. `setAddressOnMap`

    - 위치 이름, 위도, 경도 순서대로 데이터를 관리하면 좋을 것 같아서 Tuple 타입을 이용했다. 사실 데이터의 명시성을 더 높이기 위해서는 구조체의 인스턴스를 이용하는게 더 좋았지 싶다.

- MKPointAnnotation 인스턴스를 이용해서 해당하는 주소 포인트에 Pin (Apple이 만들어주는)을 꼽을수도 있다.

- setAddressOnMap 메서드를 PickerView 선택 액션에서 호출될 수 있도록 코드를 작성하면 딱 깔끔할 것 같다.

```javascript
class SomeViewController {
    var mapDatas: [(String, Double, Double)] = [..]
    @IBOutlet var mapPickerTextField: UITextField!
    let picker = UIPickerView()

    func viewDidLoad() {
        ..
        // 관련 권한을 위임해준다.
        picker.delegate = self
        mapPickerTextField.inputView = picker
    }
}

// TextField 위에 UIPicker를 올려서 필드가 터치되면 피커가 bottom-up 되게 하려고 한다.
extension SomeViewController: UIPickerViewDelegate, UIPickerViewDataSource {

    // PickerView에 몇 개의 휠 컴포넌트가 있을지 결정한다.
    func numberOfComponents(in pickerView: UIPickerView) -> Int {
        return 1
    }

    // 휠 컴포넌트마다 몇 개의 데이터를 보여줄 지 결정한다.
    func pickerView(
      _ pickerView: UIPickerView, 
      numberOfRowsInComponent component: Int) -> Int {
        return mapDatas.count
    }

    // 컴포넌트에서 어떤 데이터를 보여줄 지 결정한다.
    func pickerView(
      _ pickerView: UIPickerView, 
      titleForRow row: Int, 
      forComponent component: Int) -> String? {
        return mapDatas[row].0
    }
    
    func pickerView(
      _ pickerView: UIPickerView, 
      didSelectRow row: Int, 
      inComponent component: Int) {
        // 마치 텍스트 필드에 선택 값이 반영된 것처럼 보여주고
        mapPickerTextField.text = mapDatas[row].0

        // 데이터에 해당하는 값을 맵핑하고
        present = (mapDatas[row].0, mapDatas[row].1, mapDatas[row].2)

        // 지도에서 보여주고
        setAddressOnMap(present)

        // 피커를 내려준다.
        view.endEditing(true)
    }
}
```

- UITextField에 UIPickerView를 InputView로 넣어준다. 그리고, 텍스트 필드의 UI를 마치 버튼인 것처럼 꾸며주면 그럴싸한 UI가 된다.

![Image](https://upload.cafenono.com/image/slashpagePost/20240613/090344_8ioCVzwJpEPx3pmtQ2?q=75&s=1280x180&t=outside&f=webp)

- 코드로 살펴보면 정말 별거 아닌 기능인데, 생각보다 UX적으로 신경써야 할 것이 많았던 것 같다. 

[Video](https://vz-127031db-d43.b-cdn.net/eddcd99f-0633-472a-a80e-2cb91722bf0a/playlist.m3u8)

> iOS 학습을 시작한지 2~3주차 시점에 스토리보드와 여러 View 클래스들을 이용해서 어느정도 동작하는 다양한 뷰를 반복적으로 만들어 보면서 확실히 개발 매커니즘에 조금은 익숙해질 수 있었다.
현재는 코드베이스로 UIKit 기반의 앱을 만드는 연습에 매진하고 있다. SB에서 코드베이스로 넘어온다고 코드의 대격변이 일어나지는 않기 때문에, 이번에 기록한 것들이 좋은 자양분이 될 것 같다.

For the site tree, see the [root Markdown](https://slashpage.com/hankyeol.md).
