μνμ§ν₯μμνμ Daum κ²μ APIλ₯Ό ν΅ν΄ λ μ§λ³ λ°μ€μ€νΌμ€, μν μμΈμ 보λ₯Ό λΆλ¬μ€λ μ±μ λλ€.
νλ‘μ νΈ κΈ°κ° : 23/07/24~23/08/18
- νμ μκ°
- νμ λΌμΈ
- μκ°ν ꡬ쑰
- μ€ν νλ©΄
- ν΅μ¬ κ²½ν
- νΈλ¬λΈ μν
- μ°Έκ³ μλ£
- ν νκ³
Yetti | maxhyunm |
λ μ§ | λ΄μ© |
---|---|
2023.07.24. | json νμΌ νμ±μ μν BoxOfficeEntity νμ μμ±, λμ½λ© μ λν μ€νΈ μ§ν |
2023.07.25. | DecodingManagerμμ λμ½λ©λ§ μ§νν μ μλλ‘ κΈ°λ₯ λΆλ¦¬ |
2023.07.26. | NetworkingManager, BoxOfficeError, MoviewDetailEntity νμ μμ± |
2023.07.27. | extensionμΌλ‘ μ€μ²©νμ λΆλ¦¬, URLNamespace νμ μμ± |
2023.07.31. | νλ‘μ νΈ μ§νμ μν κ°μΈ 곡λΆμκ° |
2023.08.01. | νλ‘μ νΈ μ§νμ μν κ°μΈ 곡λΆμκ° |
2023.08.02. | CollectionViewμΈν , BoxOfficeRankingCell μμ± λ° μ κ΅¬μ± μΈν , DiffableDataSource μΈν λ° μ°κ²°, λνΉ μ¦κ°λΆ AttributedString μ²λ¦¬, collectionViewμ refreshControl μΆκ° |
2023.08.07. | 리뷰μ λ°λ₯Έ 리ν©ν λ§ μ§ν |
2023.08.08. | DaumImageEntity νμΌ μΆκ° λ° DAUM_API_KEY μΆκ°, MovieDetailViewController νμΌ μΆκ° λ° view μ ν λ©μλ μΆκ°, MoviewDetailViewControllerμ MoviewDetail λ€νΈμν¬ μ°κ²° |
2023.08.09. | νλ‘μ νΈ μ§νμ μν κ°μΈ 곡λΆμκ° |
2023.08.10. | μ€νλ·°μ textμ€μ λ©μλ κΈ°λ₯ λΆλ¦¬ λ° Namespace μμ±, MovieDetailView λ‘λ©νλ©΄ μμ |
2023.08.11. | README μμ± |
2023.08.14. | url νΈμΆ μ€ν¨μ completionμΌλ‘ μλ¬μ²λ¦¬ μΆκ°, MovieDetailViewControllerμμ isDataLoading, isImageLoading νΈμΆ μμΉ λ³κ²½ |
2023.08.15. | νλ‘μ νΈ μ§νμ μν κ°μΈ 곡λΆμκ° |
2023.08.16. | νλ©΄ λͺ¨λ λ³κ²½ λ²νΌ μΆκ° BoxOfficeRankingIconCell νμ μμ±, νλ©΄ λͺ¨λ λ³κ²½μ CollectionViewμ λ μ΄μμ μ€μ μ λ°κΎΈλ κΈ°λ₯ ꡬν, ν μ€νΈμ λ€μ΄λλ―Ή νμ μ μ© λ° auto layout μμ |
2023.08.17. | APIKey κ²μ¦κ³Όμ λ° μλ¬μ²λ¦¬ μΆκ°, λ μ§ λ³κ²½μ 컬λ μ λ·° λ μ΄μμ νμ΄μ§λ μ€λ₯ μμ |
2023.08.18. | README μμ± |
βββ BoxOffice
βΒ Β βββ Extension
βΒ Β βΒ Β βββ DateFormatter+.swift
βΒ Β βΒ Β βββ String+.swift
βΒ Β βββ Model
βΒ Β βΒ Β βββ BoxOfficeEntity.swift
βΒ Β βΒ Β βββ MovieDetailEntity.swift
βΒ Β βΒ Β βββ DaumImageEntity.swift
βΒ Β βΒ Β βββ DecodingManager.swift
βΒ Β βΒ Β βββ Error.swift
βΒ Β βΒ Β βββ NetworkingManager.swift
βΒ Β βΒ Β βββ URLSessionProtocol.swift
βΒ Β βββView
βΒ Β β βββ BoxOfficeRankingListCell.swift
βΒ Β β βββ BoxOfficeRankingIconCell.swift
βΒ Β β βββ MovieDetailStackView.swift
βΒ Β βββ Controller
βΒ Β βΒ Β βββ BoxOfficeViewController.swift
βΒ Β βΒ Β βββ CalendarViewController.swift
βΒ Β βΒ Β βββ MovieDetailViewController.swift
βΒ Β βββ Resource
βΒ Β βΒ Β βββ AppDelegate.swift
βΒ Β βΒ Β βββ SceneDelegate.swift
βΒ Β βΒ Β βββ NetworkConfiguration.swift
βΒ Β βΒ Β βββ Assets.xcassets
βΒ Β βΒ Β βββ box_office_sample.json
βΒ Β βββInfo.plist
βββ BoxOffice.xcodeproj
βββ BoxOfficeTests
βΒ Β βββ BoxOffice.xctestplan
βΒ Β βββ BoxOfficeDecodingTests.swift
βΒ Β βββ BoxOfficeNetworkingTest.swift
βΒ Β βββ TestDouble.swift
βββ README.md
μν μμΈμ 보 | λ μ§ λ³κ²½ | νλ©΄ λͺ¨λ λ³κ²½ |
---|---|---|
νκ²½ νμΌμ νμ©ν΄ μ격 μ μ₯μμ 곡μ λμ§ μμμΌ νλ key μ 보λ₯Ό κ΄λ¦¬νμμ΅λλ€.
Nested Type
μ νμ©νμ¬ μ¬λ¬ λ¨κ³λ‘ μ€μ²©λ ννμ jsonμ νμ±ν μ μλλ‘ νμκ³ , CodingKeys
λ₯Ό νμ©ν΄ μ΄ν΄νκΈ° μ΄λ €μ΄ νλΌλ―Έν°λͺ
μ λ³κ²½νμμ΅λλ€.
μμΈμ½λ
extension BoxOfficeEntity {
struct BoxOfficeResult: Decodable {
let boxOfficeType, showRange: String
let dailyBoxOfficeList: [DailyBoxOffice]
enum CodingKeys: String, CodingKey {
case boxOfficeType = "boxofficeType"
case showRange, dailyBoxOfficeList
}
}
}
λ€μν νμ
μ Entityλ₯Ό λ°νν΄μΌ νλ DecodingManager
μ λ©μλλ₯Ό Generic
μΌλ‘ ꡬννμμ΅λλ€.
μμΈμ½λ
func decode<T: Decodable>(_ data: Data?) throws -> T {
guard let data = data,
let decodedData = try? decoder.decode(T.self, from: data) else {
throw DecodingError.decodingFailure
}
return decodedData
}
Modern Collection View
λ₯Ό ν΅ν΄ λ°μ€μ€νΌμ€ λνΉ λ¦¬μ€νΈλ₯Ό ꡬννκΈ° μνμ¬ Diffable Data Source
μ Collection View List Cell
λ₯Ό νμ©νμμ΅λλ€.
μμΈμ½λ
private let collectionView: UICollectionView = {
let configuration = UICollectionLayoutListConfiguration(appearance: .plain)
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
...
}
private var dataSource: UICollectionViewDiffableDataSource<NetworkNamespace, BoxOfficeEntity.BoxOfficeResult.DailyBoxOffice>?
...
UICalendarView
λ₯Ό νμ©ν΄ Calendar
λ₯Ό ꡬννκ³ κ³Όκ±° λ μ§μ λ°μ΄ν°λ₯Ό λΆλ¬μ¬ μ μλλ‘ νμ©νμμ΅λλ€.
μμΈμ½λ
private let calendarView: UICalendarView = {
var calendarView = UICalendarView()
calendarView.translatesAutoresizingMaskIntoConstraints = false
calendarView.backgroundColor = .systemBackground
let endDate = Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()
let calendarViewDateRange = DateInterval(start: Date(timeIntervalSince1970: 0), end: endDate)
calendarView.availableDateRange = calendarViewDateRange
return calendarView
}()
λ°μ΄ν° fetch μνμ λ°λΌ UIActivityIndicatorView
μ μνκ°μ λ³κ²½νμ¬ λ‘λ© λ§ν¬κ° νμ±ν/λΉνμ±ν λλλ‘ κ΅¬ννμμ΅λλ€.
μμΈμ½λ
private let indicatorView: UIActivityIndicatorView = {
let indicatorView = UIActivityIndicatorView()
indicatorView.style = .large
indicatorView.translatesAutoresizingMaskIntoConstraints = false
return indicatorView
}()
private var isLoading: Bool = true {
willSet(newValue) {
if newValue == true {
indicatorView.isHidden = false
indicatorView.startAnimating()
} else {
indicatorView.isHidden = true
indicatorView.stopAnimating()
}
}
}
Collection View
μ UIRefreshColtrol
κ°μ²΄λ₯Ό μΆκ°νμ¬, μλλ‘ λΉκ²Όμ λ μλ‘κ³ μΉ¨μ μ§νν μ μλλ‘ νμμ΅λλ€.
μμΈμ½λ
collectionView.refreshControl = refreshControl
refreshControl.addTarget(self, action: #selector(refresh), for: .valueChanged)
UIToolBar
μ flexibleSpace
λ₯Ό νμ©νμ¬ νλ©΄ νλ¨ λ²νΌμ ꡬννμμ΅λλ€.
μμΈμ½λ
let modeChangeButton = UIBarButtonItem(title: "νλ©΄ λͺ¨λ λ³κ²½", style: .plain, target: self, action: #selector(hitChangeModeButton))
let flexibleSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: self, action: nil)
...
self.toolbarItems = [flexibleSpace, modeChangeButton, flexibleSpace]
νλ©΄ λͺ¨λ λ³κ²½μ UIAlertController
μ actionSheet
μ€νμΌλ‘ μμ΄μ½ λͺ¨λμ 리μ€νΈ λͺ¨λ νλ©΄μ μ νμ μΌλ‘ μ μ©ν μ μλλ‘ κ΅¬ννμμ΅λλ€.
μμΈμ½λ
@objc func hitChangeModeButton() {
let mode: String = isListMode == true ? "μμ΄μ½" : "리μ€νΈ"
let alert = UIAlertController(title: "νλ©΄ λͺ¨λ λ³κ²½", message: nil, preferredStyle: .actionSheet)
let modeChangeAction = UIAlertAction(title: mode, style: .default) { _ in
self.isListMode.toggle()
}
let cancelAction = UIAlertAction(title: "μ·¨μ", style: .cancel)
alert.addAction(modeChangeAction)
alert.addAction(cancelAction)
present(alert, animated: true)
}
νλμ λ μ΄λΈ μμμ μ¬λ¬ κ°μ§ μμμ νμνκΈ° μνμ¬ Attributed String
μ νμ©νμμ΅λλ€.
μμΈμ½λ
attributedString.addAttribute(.foregroundColor, value: UIColor.systemRed, range: (fixedIntensity as NSString).range(of: "β²"))
preferredFont
λ₯Ό νμ©νμ¬ ν°νΈμ DynamicType
μ μ μ©νμκ³ , adjustsFontSizeToFitWidth
μ€μ μ ν΅ν΄ κ°λ‘ λλΉμ λ§μΆ° ν
μ€νΈ ν¬κΈ°λ₯Ό μλ μ‘°μ ν μ μλλ‘ κ΅¬ννμμ΅λλ€.
μμΈμ½λ
private let audienceLabel: UILabel = {
let label = UILabel()
label.font = .preferredFont(forTextStyle: .body)
label.translatesAutoresizingMaskIntoConstraints = false
label.adjustsFontForContentSizeCategory = true
label.adjustsFontSizeToFitWidth = true
return label
}()
π¨ λ¬Έμ μ
κΈ°μ‘΄μλ dataTask
λ©μλμ URL
νμ
μ μ λ¬νμ¬ λ€νΈμν¬ μ°κ²°μ μ§ννλλ‘ κ΅¬ννμμ΅λλ€. νμ§λ§ μ΄λ²μ Daum API
κ° μΆκ°λλ©΄μ Authorization
ν€λλ₯Ό μΆκ°ν΄μΌ νκΈ° λλ¬Έμ ν΄λΉ μ 보λ₯Ό μ΄λ»κ² μ λ¬ν μ§ κ³ λ―Όνμμ΅λλ€.
π‘ ν΄κ²° λ°©λ²
URLRequest
λ₯Ό νμ©νλ©΄ setValue
λ₯Ό ν΅ν΄ ν€λ μ 보λ₯Ό μΆκ°ν μ μμμ νμΈνμμ΅λλ€. μ΄μ λ°λΌ κΈ°μ‘΄ ν
μ€νΈλλΈ λ° ν
μ€νΈμ½λ λͺ¨λ URLRequest
μ λ§κ² μμ μ μ§ννμμ΅λλ€.
var request = URLRequest(url: url)
request.setValue("KakaoAK \(NetworkNamespace.daumApiKey)", forHTTPHeaderField: "Authorization")
λν Daum API
μ μ λ¬ν 쿼리 λ΄μ©μλ λμ΄μ°κΈ°κ° μΆκ°λμ΄ μμ΄, URL
μ λΆμ¬μ 보λ΄κΈ°λ³΄λ€λ URLComponents
λ₯Ό μ¬μ©ν΄ νμν 쿼리 μμ΄ν
μ μΆκ°νλ κ² μ’κ² λ€λ μκ°μ΄ λ€μμ΅λλ€.
ν΄λΉ λ΄μ©μ μλμ κ°μ΄ ꡬννμμ΅λλ€.
var urlComponents = URLComponents(string: NetworkNamespace.daumImage.url)
urlComponents?.queryItems = [URLQueryItem(name: "query", value: "\(movieName) μν ν¬μ€ν°")]
π¨ λ¬Έμ μ
NetworkingManager
μ load()
λ©μλλ₯Ό νΈμΆν μμΉμμ dataTask
λ₯Ό ν΅ν΄ λ°μ μ¨ λ°μ΄ν°λ₯Ό νμ©ν μ μλ λ°©λ²μ λν΄ κ³ λ―Όμ΄ μμμ΅λλ€.
μ²μμλ λ νμ
μ λΈλ¦¬κ²μ΄νΈλ‘ μ°κ²°νμ¬ μ λ¬νλ λ±μ λ°©λ²μ μκ°νμ΅λλ€. νμ§λ§ λ°μ΄ν° μ²λ¦¬λ₯Ό μν΄ λ€νΈμνΉμ μ¬μ©νλ λͺ¨λ νμ
μ λΈλ¦¬κ²μ΄νΈλ‘ μ°κ²°νλ κ²μ κΆμ₯λλ λ°©μλ μλκ³ , ν¨μ¨μ μ΄μ§ λͺ»ν κ² κ°μμ΅λλ€.
π‘ ν΄κ²° λ°©λ²
@escaping
ν΄λ‘μ μ Result
νμ
μ νμ©νμ¬ ν΄κ²°νμμ΅λλ€. Result
λ μ±κ³΅/μ€ν¨ λ κ°μ§ κ°λ₯μ±μ λν λ°μ΄ν°νμ
μ λ°λ‘ μ§μ ν΄μ€ μ μμ΄ CompletionHandler
μ νμ©νκΈ°μ μ μ νλ€κ³ νλ¨νμ΅λλ€.
func load(_ urlString: String, completion: @escaping (Result<Data, BoxOfficeError>) -> Void) {
guard let url = URL(string: urlString) else {
return
}
let task = session.dataTask(with: url) { data, response, error in
if error != nil {
completion(.failure(BoxOfficeError.connectionFailure))
return
}
...
π¨ λ¬Έμ μ
APIλ₯Ό λ°μμμΌ νλ λλ©μΈμ΄ https
κ° μλ http
λ₯Ό νμ©νκ³ μμ΄ λ€νΈμν¬ μ°κ²°μμ μ€λ₯κ° λ°μνμμ΅λλ€.
π‘ ν΄κ²° λ°©λ²
ν΄λΉ λλ©μΈ λ° νμ λλ©μΈ μ 보λ₯Ό ATSμ Exception Domains
λ‘ μΆκ°νμ¬ μ μμ μΌλ‘ λ€νΈμνΉμ΄ κ°λ₯νλλ‘ κ΅¬ννμμ΅λλ€.
π¨ λ¬Έμ μ
λ€νΈμνΉμ ν΅ν΄ λ°μ μ¨ λ°μ΄ν°λ₯Ό μ²λ¦¬νλ κ³Όμ μμ μ±κ³΅ν κ²½μ°μλ§ λ‘λ©μ λλ΄κ³ λΉ μ Έλκ° μ μλλ‘ μ²λ¦¬νμλλ, μλ¬κ° λ¬μ λλ μλμ κ°μ΄ κ³μ λ‘λ©μ΄ λμκ°κ³ μλ κ²μ²λΌ 보μ΄λ κ²μ νμΈνμ΅λλ€.
λν RefreshControlμ μ’
λ£ μ²λ¦¬λ₯Ό μλμ κ°μ΄ μ§ννλ, λ°μ΄ν° λ‘λ©μ΄ μλ£λκΈ° μ μ λΉλκΈ°λ‘ λ€μ νΈμΆμ΄ μ§νλμ΄ μλ‘κ³ μΉ¨μ΄ λ°λ‘ λλλ²λ¦¬λ μ€λ₯κ° λ°μνμ΅λλ€.
@objc private func refresh() {
passFetchedData()
refreshControl.endRefreshing()
}
π‘ ν΄κ²° λ°©λ²
λ€νΈμνΉκ³Ό λμ½λ© μ¬λΆμ μκ΄μμ΄ λ‘λ©κ³Ό μλ‘κ³ μΉ¨μ μλ£ν μ μλλ‘ ν΄λΉ νΈμΆλΆλ₯Ό switchλ¬Έ λ°μΌλ‘ μ΄λνμμΌλ©°,
isLoading λ³μκ° falseμΌ λ endRefreshingλ νΈμΆν μ μλλ‘ λ³κ²½ν΄μ£Όμμ΅λλ€.
networkingManager?.load(url) { [weak self] (result: Result<Data, NetworkingError>) in
switch result {
case .success(let data):
...
case .failure(let error):
...
}
DispatchQueue.main.async {
self?.isLoading = false
self?.refreshControl.endRefreshing()
}
π¨ λ¬Έμ μ
Collection Viewμμ μ
μ μ¬μ¬μ©νλ©΄μ, κ²μ μμΌλ‘ λ€μ΄κ°μΌ νλ μμ ν
μ€νΈκ° λΉ¨κ°μμΌλ‘ μλͺ» λ€μ΄κ°λ λ¬Έμ κ° μμμ΅λλ€.
π‘ ν΄κ²° λ°©λ²
PrepareForReuse()
λ₯Ό ν΅ν΄ μλμ κ°μ΄ ν°νΈ μμμ μ΄κΈ°νν΄ μ£Όμμ΅λλ€.
override func prepareForReuse() {
rankIntensityLabel.textColor = .black
}
π¨ λ¬Έμ μ
μΈν°λ· μ°κ²°μ΄ μλ μνμμ λ€νΈμν¬ ν΅μ μ ν
μ€νΈνκΈ° μν΄ Test Double
μ μμ±νμμ΅λλ€. μ΄ κ³Όμ μμ ν
μ€νΈμ© Stub Session
κ³Ό μ€μ Session
μ¬μ΄μ νΈνμ΄ κ°λ₯νλλ‘ νκΈ° μν΄ URLSessionProtocol
μ ꡬννμλλ°, URLSession
μμ μ΄λ₯Ό μμνλ € νλ μλμ κ°μ κ²½κ³ κ° λ°μνμμ΅λλ€.
π‘ ν΄κ²° λ°©λ²
CompletionHandler typealias
μ @Sendable
μ μ±ννμ¬ ν΄κ²°νμμ΅λλ€.
typealias CompletionHandler = @Sendable (Data?, URLResponse?, Error?) -> Void
π¨ λ¬Έμ μ
μΈ κ°μ§ λ€λ₯Έ APIλ₯Ό νΈμΆνλ νλ‘κ·Έλ¨μ΄λ€ 보λ, κ° API λ΄μ©μ λ°λΌ λ§€λ² μλ‘μ΄ ν
μ€νΈ νκ²½μ λ§λ€μ΄μ£Όμ΄μΌ ν΄μ λ²κ±°λ‘μ μ΅λλ€.
π‘ ν΄κ²° λ°©λ²
μλμ κ°μ΄ ν
μ€νΈ νμ
μ νλΌλ―Έν°λ‘ μ λ¬λ°μ κ° νμ
μ λ§λ ν
μ€νΈνκ²½μ ꡬμ±ν μ μλλ‘ κ΅¬ννμμ΅λλ€.
func setUpSUT(isSuccess: Bool, apiType: NetworkNamespace) {
...
switch apiType {
case .boxOffice:
urlString = String(format: NetworkNamespace.boxOffice.url, NetworkNamespace.apiKey, "20230801")
asset = "box_office_sample"
case .movieDetail:
urlString = String(format: NetworkNamespace.movieDetail.url, NetworkNamespace.apiKey, "20230801")
asset = "movie_detail_sample"
case .daumImage:
urlString = String(format: NetworkNamespace.daumImage.url)
asset = "daum_image_sample"
}
...
if isSuccess {
...
let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)
...
} else {
let response = HTTPURLResponse(url: url, statusCode: 400, httpVersion: nil, headerFields: nil)
...
}
}
- URLSession 곡μλ¬Έμ π
- Fetching Website Data into Memory 곡μλ¬Έμ π
- UICollectionView 곡μλ¬Έμ π
- UICollectionViewListCell 곡μλ¬Έμ π
- UICollectionViewDiffableDataSource 곡μλ¬Έμ π
- URLRequest 곡μλ¬Έμ π
- URLComponents 곡μλ¬Έμ π
- AttributedString 곡μλ¬Έμ π
- UIRefreshControl 곡μλ¬Έμ π
- UIActivityIndicatorView 곡μλ¬Έμ π
- UICalendarView 곡μλ¬Έμ π
- UIAlertController 곡μλ¬Έμ π
- μΌκ³° λ·λ· - Unit Test