과제 시작 전 구성 예상
새로운 과제는 연락처 앱이다.
포켓몬 API 를 통한 랜덤 이미지 생성과 CoreData를 활용해 연락처 데이터를 저장하는 방식이다.
API 연결에 대한 실습과 CoreData를 활용해볼 수 있는 앱인 것 같다.
과제를 시작하기 전 어떤 식으로 앱을 구성할 지 생각을 해보았다.
대략적으로 MVC 패턴을 사용해보고자 하는 목표를 가지고 ETA의 경우 노션을 활용해 연습해보기로 했다.
타임라인을 활용해 예상 소요기간과 실제 소요시간을 레벨별로 구체적으로 기록해두어 비교해보기로 하였다.
네트워크 통신과 데이터 저장이라는 틀을 가지고 디렉토리를 분류해보기로 하고
UI에 대한 구성을 생각해보았다.
Lv 1
처음 페이지 UI 구성
UI에 대한 구상을 어느정도 한뒤, CoreData로 xcode 프로젝트를 생성하고
초기 세팅을 위해 코드베이스 설정 및 스냅킷,Alamofire를 추가하였다.
그리고 MVC 패턴을 위한 파일생성과 디렉토리 분리를 1차적으로 해두고 시작해보려고 하였다.
스토리 보드로 보면 네비게이션 컨트롤러가 있고 그 다음 rootViewController가 뷰컨트롤러가 되는 형태라고 생각할 수 있다.
네비게이션컨트롤러 -> ViewController ( 그 위에 테이블 뷰 ) 의 순서로 코드를 구현해 보았다.
네이게이션 컨트롤러 설정
SceneDelegate 에서 네비게이션 컨트롤러 설정을 해주어야 한다.
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// 네이게이션 바 세팅
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
let naviVC = UINavigationController(rootViewController: ViewController())
window?.rootViewController = naviVC
window?.makeKeyAndVisible()
}
네비게이션 바 관련 설정
네비게이션 바 관련 설정을 뷰컨트롤러에 추가하고, 테이블뷰를 safeArea에 꽉차도록 구현하였다.
final class ViewController: UIViewController {
private let tableView = UITableView()
override func viewDidLoad() {
super.viewDidLoad()
setupNaviBar()
setupTableViewConstraints()
}
func setupNaviBar() {
title = "친구 목록"
// 네비게이션바 설정관련
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground() // 불투명으로
appearance.backgroundColor = .white
navigationController?.navigationBar.standardAppearance = appearance
navigationController?.navigationBar.compactAppearance = appearance
navigationController?.navigationBar.scrollEdgeAppearance = appearance
}
// 테이블뷰의 오토레이아웃 설정
func setupTableViewConstraints() {
view.addSubview(tableView)
tableView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide.snp.top)
make.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
make.leading.trailing.equalToSuperview()
}
}
}
이제 셀과 더미변수를 구현해주어야 한다.
모델 관련 폴더를 생성하고 추후에 네트워킹, DetailView 등과의 연결성을 고려하여
Member 구조체 파일과 관련 로직을 위한 DataManager 파일을 따로 생성하였다.
// 멤버 비즈니스 로직 모델
final class MemberListManager {
private var membersList: [Member] = []
// 테스트 데이터
func makeMembersListDatas() {
membersList = [
Member(memberImage: UIImage(systemName: "mug.fill") , name: "김르탄", phone: "010-1111-2222"),
Member(memberImage: UIImage(systemName: "mug.fill") , name: "내배캠", phone: "010-2222-3333"),
Member(memberImage: UIImage(systemName: "mug.fill") , name: "앱개발", phone: "010-3333-4444"),
Member(memberImage: UIImage(systemName: "mug.fill") , name: "화이팅", phone: "010-5555-6666")
]
}
// 전체 멤버 리스트 얻기
func getMembersList() -> [Member] {
return membersList
}
}
테이블 뷰 셀에 표시할 DataSource 확장 및 코드 구현
테이블 뷰에 표시할 임시 데이터를 memberListManager를 통해 얻어오는 방식으로 구현해보았다.
extension ViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return memberListManager.getMembersList().count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: TableViewCell.id, for: indexPath) as! TableViewCell
let member = memberListManager.getMembersList()[indexPath.row]
cell.mainImageView.image = member.memberImage
cell.memberNameLabel.text = member.name
cell.phoneNumberLabel.text = member.phone
cell.selectionStyle = .none
return cell
}
}
private lazy var tableView: UITableView = {
let tableView = UITableView()
tableView.dataSource = self
tableView.rowHeight = 80
// 셀 등록 ⭐️
tableView.register(TableViewCell.self, forCellReuseIdentifier: TableViewCell.id)
return tableView
}()
구현 모습, 수정해야할 부분 확인
더미변수가 잘 들어가 나오는 것을 확인할 수 있었다.
'김르탄' 의 전화번호의 경우 1111 의 문자넓이가 상대적으로 짧아서 위치가 다르게 나오는 것을 확인할 수 있었다.
Lv 2를 진행하며 일정한 간격을 위해 스택뷰로 감싸보기로 하였다.
Lv 2 ~ 3
연락처 추가 화면 + 네비게이션 바
'추가' 버튼을 통해 추가화면 페이지로 이동
Lv 2 와 3 을 진행하기 전에 과제에 맞는 파일명으로 일부 수정을 하였다.
우선 추가 화면(PhoneBookView)의 UI를 구성하고 그 다음 이동을 위한 네비게이션 바 버튼을 추가하기로 했다.
과제 안내에서는 textView 를 사용하라고 되어있었는데 이름, 전화번호 한 줄 입력을 한다는 UX 를 생각해서 TextField로 구현 해 보았고
무엇을 입력해야할 지 알려주기 위한 레이블을 추가하였다.
네비게이션 바 버튼 설정
// 네비게이션바에 넣기 위한 버튼
private lazy var plusButton: UIBarButtonItem = {
let button = UIBarButtonItem(title: "추가", style: .plain, target: self, action: #selector(plusButtonTapped))
return button
}()
private func setupNaviBar() {
title = "친구 목록"
// 네비게이션바 설정관련
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground() // 불투명으로
appearance.backgroundColor = .white
navigationController?.navigationBar.tintColor = .gray
navigationController?.navigationBar.standardAppearance = appearance
navigationController?.navigationBar.compactAppearance = appearance
navigationController?.navigationBar.scrollEdgeAppearance = appearance
// 네비게이션바 오른쪽 상단 버튼 설정
self.navigationItem.rightBarButtonItem = self.plusButton
}
}
// 추가버튼 클릭 시 다음 화면으로 이동
@objc func plusButtonTapped() {
// 다음화면으로 이동 (멤버는 전달하지 않음)
let phoneBookVC = PhoneBookViewController()
// 화면이동
navigationController?.pushViewController(phoneBookVC, animated: true)
}
ViewController에 네비게이션 바 버튼을 생성하고 PhoneBookVC를 통해 PhoneBookView로 이동하는 코드를 구현하였다.
PhoneBookViewController에도 마찬가지로 버튼을 생성해 주었다.
네비게이션 버튼 색상의 변화
두 화면에 setupNavibar를 각각 구현하고 네비게이션 바 틴트컬러를 다르게 주었는데
다음화면에서 친구목록을 눌러 다시 돌아오니 '추가' 버튼의 색상이 변하는 현상을 발견했다.
처음 화면에서는 회색으로 구현했던 버튼이 바뀌어서 나오는 이상한 현상이였다.
관련 내용을 검색해보니 네비게이션 바의 경우 스타일은 각 뷰 컨트롤러에서 별도의 설정을 할 수 있지만
tintColor 는 전역 설정이 적용되어 처음 페이지에도 영향을 미친다고 한다.
버튼을 각각의 색상으로 설정하려면 tintColor의 경우
// 네비게이션바에 넣기 위한 버튼
private lazy var plusButton: UIBarButtonItem = {
let button = UIBarButtonItem(title: "추가", style: .plain, target: self, action: #selector(plusButtonTapped))
button.tintColor = .gray
return button
}()
// 두번째 페이지 네비게이션 바 버튼
private lazy var applyButton: UIBarButtonItem = {
let button = UIBarButtonItem(title: "적용")
button.tintColor = .systemBlue
return button
}()
이런식으로 각각의 버튼에 색상을 주어야 적용이 된다고 한다.
또한 네비게이션 바 스타일은 두 페이지 다 같은 설정을 해주기에 그럴 경우 SceneDelegate에서 구현해주면 모두 적용된다고 한다.
private func setupNaviBar() {
title = "친구 목록"
// 네비게이션바 설정관련
// let appearance = UINavigationBarAppearance()
// appearance.configureWithOpaqueBackground() // 불투명으로
// appearance.backgroundColor = .white
// // navigationController?.navigationBar.tintColor = .gray
// navigationController?.navigationBar.standardAppearance = appearance
// navigationController?.navigationBar.compactAppearance = appearance
// navigationController?.navigationBar.scrollEdgeAppearance = appearance
// 네비게이션바 오른쪽 상단 버튼 설정
self.navigationItem.rightBarButtonItem = self.plusButton
}
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// 네이게이션 바 세팅
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .white
appearance.titleTextAttributes = [.foregroundColor: UIColor.black]
UINavigationBar.appearance().standardAppearance = appearance
UINavigationBar.appearance().compactAppearance = appearance
UINavigationBar.appearance().scrollEdgeAppearance = appearance
let naviVC = UINavigationController(rootViewController: ViewController())
window?.rootViewController = naviVC
window?.makeKeyAndVisible()
}
겹치는 설정을 SceneDelegate로 옮기고, 각각의 버튼에 색상을 주니 과제 영상처럼 이동을 해도 다른 색상이 적용되는 것을 확인할 수 있었다.
하지만 내가 사용자라면 통일된 버튼 색상이 좋을 것 같다고 생각하여 통일하는 것이 좋을 것 같다.
전화번호 레이블 위치 설정
숫자의 넓이에 따라 뒤로 밀리는 전화번호를 방지하기 위해 스택뷰를 활용해
이름과 전화번호를 스택뷰에 넣었고
이름의 width를 적당히 길게 설정하고 spacing을 추가로 주어 시작 위치를 조정하였다.
전화번호의 총 넓이가 맞지 않아 불편하긴 하지만 숫자의 넓이에 따라 다른 것이기에 어쩔 수 없다고 판단하여
시작 위치만 맞춰주었다.
Lv 4
API 연결하여 랜덤 이미지 생성하기
포스트맨을 통해 API 가 잘 받아져 오는지 체크
quicktype.io 에서 원하는 부분만 잘라 스위프트의 구조체 형식으로 변환
API로 주는 이미지 파일 중 sprite - othet - home 안에 있는 front_shiny 를 받아오기로 하였다.
(과제에서 안내해준 이미지 부분은 sprites - front_default 지만 개인적으로 화질과 이로치의 사진을 담으면 더 좋을 것 같다고 생각함)
API 를 연결하기 위해 변환된 JSON 구조체를 프로젝트 모델 파일에 생성
struct PoketmonData: Codable {
let id: Int
let name: String
let height, weight: Int
let sprites: Sprites
}
struct Sprites: Codable {
let other: Other
}
struct Other: Codable {
let home: Home
}
struct Home: Codable {
let imageUrl: String
enum CodingKeys: String, CodingKey {
case imageUrl = "front_shiny"
}
}
파일 분리를 위해 네트워크 매니저를 싱글톤으로 따로 생성하였다.
// 사용하게될 API 문자열
let requestUrl = "https://pokeapi.co/api/v2/pokemon/"
//MARK: - Networking (서버와 통신하는) 클래스 모델
final class NetworkManager {
// 여러화면에서 통신을 한다면, 일반적으로 싱글톤으로 만듦
static let shared = NetworkManager()
// 여러객체를 추가적으로 생성하지 못하도록 설정
private init() {}
// 서버 데이터를 불러오는 메서드 - URLSession
func fetchData<T: Decodable>(url: URL, completion: @escaping (T?) -> Void) {
let session = URLSession(configuration: .default)
session.dataTask(with: URLRequest(url: url)) { data, response, error in
guard let data, error == nil else {
print("데이터 로드 실패")
completion(nil)
return
}
// http status code 성공 범위는 200번대
let succesRange = 200..<300
if let response = response as? HTTPURLResponse, succesRange.contains(response.statusCode) {
guard let decodedData = try? JSONDecoder().decode(T.self, from: data) else {
print("JSON 디코딩 실패")
completion(nil)
return
}
completion(decodedData)
} else {
print("응답 오류")
completion(nil)
}
}.resume()
}
// Alamofire 를 사용해서 서버 데이터를 불러오는 메서드
func fetchDateByAlamofire<T: Decodable>(url: URL, completion: @escaping (Result<T, AFError>) -> Void) {
AF.request(url).responseDecodable(of: T.self) { response in
completion(response.result)
}
}
날씨앱 강의에서 사용한 코드를 수정하여 URL 세션과 Alamofire를 두가지 방법 다 구현을 해보았는데
네트워크 매니저에서 fetchPoketmonData() 를 구현하려고 하니
파일 분리 과정에서 아직 어떤식으로 수정해야할지 잘 모르겠어서
나중에 보니 네크워크매니저에서 당연히 self가 PhoneBookViewController가 아니기 때문에 접근할 수 없다는 것을 알게 되었다.
일단 PhoneBookViewController에 해보았다.
날씨앱의 경우 ViewController 안에서 네트워킹을 해서 그런 것 같다.
우선 구현을 해보고 추후에 파일분리를 해보기로 했다.
let networkManager = NetworkManager.shared
@objc func randomButtonTapped(_ sender: UIButton) {
fetchPoketmonData()
}
// 서버에서 포켓몬 데이터를 받아오는 메서드
private func fetchPoketmonData() {
let randomNum = Int.random(in: 1...1000)
let url = URL(string: "\(requestUrl)\(randomNum)")
guard let urlString = url else {
print("잘못된 URL")
return
}
networkManager.fetchDateByAlamofire(url: urlString) { [weak self] (result: Result<PoketmonData, AFError>) in
guard let self else { return }
switch result {
case .success(let result):
guard let imageUrl = URL(string: result.sprites.other.home.imageUrl) else { return }
// Alamofire 를 사용한 이미지 로드
AF.request(imageUrl).responseData { response in
if let data = response.data, let image = UIImage(data: data) {
DispatchQueue.main.async {
self.phoneBookView.randomImageView.image = image
}
}
}
case .failure(let error):
print("데이터 로드 실패: \(error)")
}
}
// URL Session 사용시
networkManager.fetchData(url: url!) { [weak self] (result: PoketmonData?) in
guard let self, let result else { return }
guard let imageUrl = URL(string: result.sprites.other.home.imageUrl) else { return }
// image를 로드하는 작업은 백그라운드 쓰레드에서 작업
if let data = try? Data(contentsOf: imageUrl) {
if let image = UIImage(data: data) {
DispatchQueue.main.async {
self.phoneBookView.randomImageView.image = image
}
}
}
}
}
포켓몬 API를 랜덤으로 받아오기 위해 requestUrl 을 선언해두고
그 뒤에 랜덤 숫자가 들어가도록 구현했다.
let requestUrl = "https://pokeapi.co/api/v2/pokemon/"
let randomNum = Int.random(in: 1...1000)
let url = URL(string: "\(requestUrl)\(randomNum)")
버튼을 누를 때 마다 랜덤API를 받아와야 하기 때문에
버튼 안에 메서드를 넣으니 우선 구현은 잘 되어 랜덤이미지가 생성된다.
처음에 url 자체를 랜덤으로 했더니 하나의 정보만 받아오고 버튼을 눌러도 새로운 정보가 들어오지 않았었다.
추가적으로 텍스트 필드 관련 델리게이트 메서드도 구현해주었다.
// 다른 곳을 터치하면 키보드 내리기
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
phoneBookView.endEditing(true)
}
}
extension PhoneBookViewController: UITextFieldDelegate {
// 다음 눌렀을때
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
if textField == phoneBookView.nameTextField {
phoneBookView.phoneNumberTextField.becomeFirstResponder() // 다음 텍스트필드로 이동
} else if textField == phoneBookView.phoneNumberTextField {
textField.resignFirstResponder() // 키보드 내리기
}
return true
}
// 텍스트필드에 글자내용이 변할때마다 관련 메서드
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if textField == phoneBookView.nameTextField {
return Int(string) == nil // 이름 필드에 숫자 입력 안되게
} else if textField == phoneBookView.phoneNumberTextField {
return Int(string) != nil // 전화번호 필드에 문자 입력 안되게
}
return true
}
네비게이션 바 설정 관련 참고자료
'내일배움캠프 iOS' 카테고리의 다른 글
UIKit) TIL # 39 포켓몬 연락처 앱 과제 - URLSession 으로 수정해보기 (0) | 2024.12.12 |
---|---|
UIKit) TIL # 38 포켓몬 연락처 앱 과제 마무리, 트러블 슈팅 (0) | 2024.12.11 |
UIKit) TIL # 35 날씨앱 클론, 과제 맛보기 (0) | 2024.12.06 |
UIKit) TIL # 34 CRUD (CoreData , Networking) (2) | 2024.12.05 |
UIKit) TIL # 33 메모리 관리 이해 (1) | 2024.12.04 |