Sign In
Swift | 학습 내용 정리

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

한결
💡
코드 베이스로 뷰를 그리는 연습을 하기 전, SB + Outlet 코드로 뷰를 그리는 연습을 하면서 최종적으로 만들어 본 TravelProject에 대한 정리 글입니다. 전체 코드는 해당 링크에서 볼 수 있습니다.
이런 느낌의 간단한 앱을 만들어 보면서 Storyboard + Code 형태로 간단하게 앱을 빌드하는 과정에 익숙해짐
Swift를 학습하면서 정말 다양한 뷰를 직접 만들어보고 있다. 24년 5월 27일부터 30일까지 스토리보드 기반으로 UIViewController 위에서 코드로 여러 화면을 그리는 연습을 했다. 그 과정에서 나 스스로 '이건 꼭 기록으로 남겨봐야겠다.' 하는 점들이 있어서 남겨본다.

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

UIViewController에 UITable을 핸들링하기 위한 Delegation, Datasource 프로토콜을 적용해준다.
*Protocol을 통해 delegation을 위임하는 것에 대한 내용은 요 링크로 짧게 작성해봤다.
UITable에 들어갈 커스텀한 TableCell을 작업하기 위해 Swift 파일과 XIB 파일을 함께 생성해준다.
XIB에서 작업한 커스텀 셀을 재사용이 가능한 셀로 만들기 위해 정확한 Identifier를 지정해줘야 한다.
이 부분 역시 UITableViewCell 클래스를 상속받는 클래스들이 identifier라는 멤버 변수를 가질 수 있도록 protocol + extension 조합으로 코드를 미리 작성해두면 편하다.
UIViewController 위에 올려진 UITableView의 인스턴스에 커스텀한 Cell을 연결하고, 어떤 행에 어떤 셀을 어떻게 보여줄 것인지를 위임받은 코드에 설정해준다.
코드로 한 번 보자.
// 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에 데이터 넘겨주기

테이블 뷰에서 특정 셀을 터치하여 선택할 경우, 연결된 페이지 뷰(UIViewController)로 데이터를 전달해야하는 경우가 있다. 여러 방법이 있겠지만, 가장 쉽다고 생각하는 방법은 해당 VC의 멤버 변수(Outlet 변수 포함)에 값을 할당하는 것이다.
// 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하는 방법에 대해서는 요 링크에 작성해뒀다.
코드로 다른 VC를 연결해주는 방법은 오히려 더 간단하다. 그냥 VC 인스턴스를 생성해주면 된다.
let vc = SomeViewController() present(vc, animated: true)
유저의 액션에 의해서 VC간 데이터가 잘 전송되면 다양한 형태의 UI로 앱을 구성하기 좋다.

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

유저가 원하는 조건으로 데이터를 필터링해서 보여줘야 할 경우가 많다. 검색을 할 수도 있고, 메뉴를 여러개 생성할 수도 있고, 조건 자체를 미리 여러 개 생성해 둘 수도 있다. 이번 프로젝트를 하면서 iOS에 내장된 SegmentedControl, SearchBar를 통해 데이터를 필터링 해볼 수 있었다. 물론 미리 정의된 Protocol을 불러와 역할을 ViewController에 위임하는 형식으로 코드를 작성했다.
1.
필터링 할 데이터셋 확인하기
데이터를 필터링하려면 데이터가 어떤 형식으로 생겼는지 알아야 한다.
데이터는 기본적으로 (관계성이 있는 정형화되었다면) 동일한 키를 서로다른 값으로 채우는 형식이다.
이런 데이터를 특정 기준으로 필터링 하는데 가장 좋은 기준은 Bool 타입을 찾는 것이다.
// 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 단에서 로직을 세울 필요가 없다.
2.
세그먼트의 선택에 따라서 필터링된 데이터 할당하기
데이터 처리가 준비되었다면, 이제 세그먼트 요소별 선택 값만 잘 지정해주면 된다.
// 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 함수를 호출하면 된다.
기준이 명확하다면, 열거형으로 기준을 명확하게 정의해주는 것도 좋을 것 같다.
3.
검색어에 따라서 필터링된 데이터 보여주기
UISearchBar 역시 VC에 그 역할을 위임할 수 있다. (UISearchBarDelegate)
extension 키워드로 VC에 서치바에 대한 역할 코드를 구분하여 작성하면 코드를 관리하기 편하다.
// View Controller extension SomeViewController: UISearchBarDelegate { }
서치바에서 입력이 어떻게 이루어졌을 때, 검색어를 어떻게 처리해줄 지를 결정하는 것이 로직의 핵심이다. 아래 목록 중에 밑줄을 그어둔 부분이 서치바에서 내가 고려한 유저 이벤트다.
유저가 보고 싶은 데이터를 키워드로 검색했을 때 (검색 완료) → 해당 키워드를 포함하는 or 키워드와 일치하는 데이터를 필터링해서 보여준다.
유저가 서치바에 입력할 때마다 데이터를 검증하면서 해당하는 내용이 있으면 필터링해서 보여준다.
유저가 서치바에 입력한 데이터를 다 지우면, 처음 데이터를 다시 보여준다.
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을 통해 서치바 + 세그먼트 컨트롤로 데이터를 필터링하여 뷰를 다르게 보여준 예시는 아래 영상과 같다.

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

iOS에서는 자체 내장 패키지로 MapKit을 지원한다. Apple Map이 있기 때문에, 해당 지도에 구현되어 있는 내부 API를 활용하여 앱 위에서 지도를 그려갈 수 있다. VC에서는 위에서 계속 해왔던 대로 Delegation을 잘 해주면 된다.
MapKit을 활용하기 위해서는, 다른 어떤 웹 지도와 동일하게 latitude, langitude 값이 필요하다. 위치에 대한 이름도 같이 기본적으로 표시하면 참 좋겠다.
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 선택 액션에서 호출될 수 있도록 코드를 작성하면 딱 깔끔할 것 같다.
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가 된다.
코드로 살펴보면 정말 별거 아닌 기능인데, 생각보다 UX적으로 신경써야 할 것이 많았던 것 같다.
💡
iOS 학습을 시작한지 2~3주차 시점에 스토리보드와 여러 View 클래스들을 이용해서 어느정도 동작하는 다양한 뷰를 반복적으로 만들어 보면서 확실히 개발 매커니즘에 조금은 익숙해질 수 있었다.
현재는 코드베이스로 UIKit 기반의 앱을 만드는 연습에 매진하고 있다. SB에서 코드베이스로 넘어온다고 코드의 대격변이 일어나지는 않기 때문에, 이번에 기록한 것들이 좋은 자양분이 될 것 같다.
Ha
Subscribe to 'hankyeol'
Subscribe to my site to be the first to receive notifications and emails about the latest updates, including new posts.
Join Slashpage and subscribe to 'hankyeol'!
Subscribe
👍