Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

오픈 마켓 [STEP 2] 애플사이다, 허황 #96

Merged
merged 21 commits into from
Jan 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
66ebfdf
feat: ViewTypeSegmentedControl 구현 #3
Jan 11, 2022
9b5bd96
feat: GridProductCell 타입 추가 #3
just1103 Jan 11, 2022
7e45f05
feat: fetchProductData() 메서드 구현 #3
Jan 11, 2022
3a6e226
feat: GridProductCell의 UI 요소 Layout 적용 #3
just1103 Jan 12, 2022
ca190dd
feat: ListProductCell 구성 및 UI 요소 Layout 적용 #3
Jan 12, 2022
8f8e8ad
feat: segmentedControlTouched 메서드 추가 #3
just1103 Jan 12, 2022
c9aa8e7
refactor: CollectionView cellForItemAt 메서드 개선 #3
Jan 13, 2022
090979a
feat: UICollectionViewDelegateFlowLayout 채택 #3
just1103 Jan 13, 2022
4effbec
feat: ActivityIndicator 추가 #3
Jan 13, 2022
5178c12
feat: touchUpAddProductButton 메서드 및 AddProductViewController 타입 추가 #3
just1103 Jan 13, 2022
7e15350
refactor: Products 데이터 가져오는 부분 개선 #3
Jan 13, 2022
a1638f3
feat: 화면전환 시 Scroll 위치를 유지하는 기능 추가 #3
just1103 Jan 13, 2022
1c8cb1b
fix: 화면전환 시 Scroll 위치를 유지하는 기능 버그 수정 #3
Jan 14, 2022
503d9ac
refactor: LayoutKind 열거형 추가 #3
just1103 Jan 14, 2022
17e9165
feat: ImageCacheManager 메모리 캐시 구현 #5
Jan 14, 2022
8b0087b
fix: 서버의 Price 타입 변경 (Int->Double)에 따른 수정 #3
just1103 Jan 15, 2022
ad32610
refactor: 상품 등록 화면 titleLabel 강제 언래핑 개선 #4
Jan 15, 2022
cb18939
refactor: OpenMarketViewController에 final 키워드 및 접근제어자 추가 #4
just1103 Jan 15, 2022
c767a0a
refactor: fetchData() 메서드 개선 #4
Jan 15, 2022
e8f8127
refactor: 탈출클로저에 캡쳐리스트 활용 #4
just1103 Jan 15, 2022
a54302b
refactor: fatalError() 제거 #4
Jan 15, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 48 additions & 16 deletions OpenMarket/OpenMarket.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions OpenMarket/OpenMarket/Controller/AddProductViewController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import UIKit

final class AddProductViewController: UIViewController {
private let titleLabel: UILabel = {
let label = UILabel()
label.text = "상품 등록"
label.textAlignment = .center
label.font = .preferredFont(forTextStyle: .title1)
return label
}()

override func viewDidLoad() {
super.viewDidLoad()
setupViewController()
setupTitleLabel()
}

private func setupViewController() {
view.backgroundColor = .white
}

private func setupTitleLabel() {
view.addSubview(titleLabel)
titleLabel.translatesAutoresizingMaskIntoConstraints = false
titleLabel.topAnchor.constraint(equalTo: view.topAnchor, constant: 20).isActive = true
titleLabel.widthAnchor.constraint(equalTo: view.widthAnchor).isActive = true
}
}
241 changes: 241 additions & 0 deletions OpenMarket/OpenMarket/Controller/OpenMarketViewController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
//
// OpenMarket - ViewController.swift
// Created by yagom.
// Copyright © yagom. All rights reserved.
//

import UIKit

final class OpenMarketViewController: UIViewController {
// MARK: - Properties
private enum LayoutKind: String, CaseIterable, CustomStringConvertible {
case list = "LIST"
case grid = "GRID"

var description: String {
return self.rawValue
}

var cellIdentifier: String {
switch self {
case .list:
return ListProductCell.identifier
case .grid:
return GridProductCell.identifier
}
}

var cellType: ProductCellProtocol.Type {
switch self {
case .list:
return ListProductCell.self
case .grid:
return GridProductCell.self
}
}
}

private var currentLayoutKind: LayoutKind = .list
private var products: [Product]?

private var segmentedControl: ViewTypeSegmentedControl!
private var productCollectionView: UICollectionView!
private var activityIndicator: UIActivityIndicatorView!

override func viewDidLoad() {
super.viewDidLoad()
setupViewController()
setupNavigationBar()
setupCollectionView()
setupActivityIndicator()
registerCell()

setupProducts()
}

private func setupViewController() {
view.backgroundColor = .white

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

내부 속성에 접근할 때 어떤 기준으로 self를 붙이고 계신가요

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

클로저 캡쳐리스트를 사용하거나, 함수의 매개변수 명과 프로퍼티 명이 같아 혼란을 줄 수 있을 때 self 키워드를 사용했습니다.
OpenMarketViewController.swift 148번 라인에 위와 같은 경우가 아닌데 사용한 self 키워드는 삭제했습니다.

}

private func reloadDataWithActivityIndicator(at collectionView: UICollectionView?) {
collectionView?.performBatchUpdates {
startActivityIndicator()
collectionView?.reloadData()
} completion: { [weak self] _ in
self?.endActivityIndicator()
}
}

private func setupProducts() {
NetworkDataTransfer().fetchData(api: ProductPageAPI(pageNumber: 1, itemsPerPage: 100),
decodingType: ProductPage.self) { [weak self] data in
self?.products = data.products
DispatchQueue.main.async {
self?.reloadDataWithActivityIndicator(at: self?.productCollectionView)
}
}
}
}

// MARK: - NavigationBar, Segmented Control
extension OpenMarketViewController {
private func setupNavigationBar() {
let itemsOfsegmentedControl = LayoutKind.allCases.map { $0.description }
segmentedControl = ViewTypeSegmentedControl(items: itemsOfsegmentedControl)
segmentedControl.addTarget(self, action: #selector(toggleViewTypeSegmentedControl), for: .valueChanged)

let navigationBarItem = navigationController?.navigationBar.topItem
navigationBarItem?.titleView = segmentedControl
navigationBarItem?.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add,
target: self,
action: #selector(touchUpAddProductButton))
}

@objc private func toggleViewTypeSegmentedControl(_ sender: UISegmentedControl) {
let currentScrollRatio: CGFloat = currentScrollRatio()
currentLayoutKind = LayoutKind.allCases[sender.selectedSegmentIndex]

productCollectionView.performBatchUpdates(nil) { [weak self] _ in
DispatchQueue.main.async {
if let nextViewMaxHeight = self?.productCollectionView.contentSize.height {
let offset = CGPoint(x: 0, y: nextViewMaxHeight * currentScrollRatio)
self?.productCollectionView.setContentOffset(offset, animated: false)
self?.productCollectionView.reloadData()
}
}
}
}

private func currentScrollRatio() -> CGFloat {
return productCollectionView.contentOffset.y / productCollectionView.contentSize.height
}

@objc private func touchUpAddProductButton() {
let addProductViewController = AddProductViewController()
self.present(addProductViewController, animated: true, completion: nil)
}
}

// MARK: - ActivityIndicator
extension OpenMarketViewController {
private func setupActivityIndicator() {
activityIndicator = UIActivityIndicatorView()
view.addSubview(activityIndicator)
activityIndicator.center = view.center

startActivityIndicator()
}

private func startActivityIndicator() {
DispatchQueue.main.async { [weak self] in
self?.activityIndicator.isHidden = false
self?.activityIndicator.startAnimating()
}
}

private func endActivityIndicator() {
DispatchQueue.main.async { [weak self] in
self?.activityIndicator.stopAnimating()
self?.activityIndicator.isHidden = true
}
}
}

// MARK: - CollectionView
extension OpenMarketViewController {
private func setupCollectionView() {
productCollectionView = UICollectionView(frame: view.bounds, collectionViewLayout: UICollectionViewFlowLayout())
self.view.addSubview(productCollectionView)

productCollectionView.translatesAutoresizingMaskIntoConstraints = false
productCollectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor)
.isActive = true
productCollectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
.isActive = true
productCollectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
.isActive = true
productCollectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
.isActive = true

productCollectionView.dataSource = self
productCollectionView.delegate = self
}

private func registerCell() {
LayoutKind.allCases.forEach {
productCollectionView.register($0.cellType, forCellWithReuseIdentifier: $0.cellIdentifier)
}
}
}

// MARK: - CollectionView Data Source
extension OpenMarketViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return products?.count ?? 0
}

func collectionView(_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: currentLayoutKind.cellIdentifier,
for: indexPath) as? ProductCellProtocol else {
return UICollectionViewCell()
}

guard let product = products?[indexPath.item] else {
return UICollectionViewCell()
}

cell.updateView(with: product)

return cell
}
}

// MARK: - CollectionView Delegate FlowLayout
extension OpenMarketViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath) -> CGSize {
switch currentLayoutKind {
case .list:
let listCellSize: (width: CGFloat, height: CGFloat) = (view.frame.width, view.frame.height * 0.077)
return CGSize(width: listCellSize.width, height: listCellSize.height)
case .grid:
let gridCellSize: (width: CGFloat, height: CGFloat) = (view.frame.width * 0.45, view.frame.height * 0.32)
return CGSize(width: gridCellSize.width, height: gridCellSize.height)
}
}

func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
insetForSectionAt section: Int) -> UIEdgeInsets {
let inset: Double = 10
return UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset)
}

func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
minimumLineSpacingForSectionAt section: Int) -> CGFloat {
switch currentLayoutKind {
case .list:
let listCellLineSpacing: CGFloat = 2
return listCellLineSpacing
case .grid:
let gridCellLineSpacing: CGFloat = 10
return gridCellLineSpacing
}
}

func collectionView(_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
switch currentLayoutKind {
case .list:
let listCellIteritemSpacing: CGFloat = 0
return listCellIteritemSpacing
case .grid:
let gridCellIteritemSpacing: CGFloat = 10
return gridCellIteritemSpacing
}
}
}
16 changes: 0 additions & 16 deletions OpenMarket/OpenMarket/Controller/ViewController.swift

This file was deleted.

28 changes: 28 additions & 0 deletions OpenMarket/OpenMarket/Extension/CALayer+Extension.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import UIKit

extension CALayer {
@discardableResult
func addBorder(_ edges: [UIRectEdge], color: UIColor, width: CGFloat = 1.0, radius: CGFloat = 10.0) -> CALayer {
let border = CALayer()
for edge in edges {
switch edge {
case .top:
border.frame = CGRect.init(x: 0, y: 0, width: frame.width, height: width)
case .bottom:
border.frame = CGRect.init(x: 0, y: frame.height - width, width: frame.width, height: width)
case .left:
border.frame = CGRect.init(x: 0, y: 0, width: width, height: frame.height)
case .right:
border.frame = CGRect.init(x: frame.width - width, y: 0, width: width, height: frame.height)
case .all:
borderColor = color.cgColor
cornerRadius = radius
borderWidth = width
default:
break
}
border.backgroundColor = color.cgColor
}
return border
}
}
9 changes: 9 additions & 0 deletions OpenMarket/OpenMarket/Extension/Double+Extension.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

extension Double {
func formattedWithComma() -> String {
let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .decimal
return numberFormatter.string(for: self) ?? ""
}
}
9 changes: 9 additions & 0 deletions OpenMarket/OpenMarket/Extension/Int+Extension.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

extension Int {
func formattedWithComma() -> String {
let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .decimal
return numberFormatter.string(for: self) ?? ""
}
}
11 changes: 11 additions & 0 deletions OpenMarket/OpenMarket/Extension/String+Extension.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import UIKit

extension String {
func strikeThrough() -> NSAttributedString {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extension을 사용해서 문자열에 기능을 사용하는것과 파라미터로 문자열을 받아서 기능을 입혀서 output을 뽑느것에는 어떤 차이가 있을까요

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

별도의 타입을 만들어서 인스턴스 메서드로 만들게 되면 메서드를 사용할 때마다 타입의 인스턴스를 생성해줘야 합니다.
위와 다르게, extension을 사용하여 메서드를 추가해주면 모든 String 타입의 인스턴스들에게 추가해준 메서드가 노출되게 됩니다.

여러가지 방법을 생각해봤습니다.

  1. Protocol + CustomLabel
    현재 취소선은 Cell의 priceLabel만 사용하는 기능이기 때문에 해당 Label에서만 메서드가 노출될 수 있도록 프로토콜과 익스텐션을 사용하는 방법
    Strikable 프로토콜을 만들고 UILabel을 상속해준 뒤 extension을 활용하여 메서드를 기본 구현했습니다.
    Strikable 프로토콜 채택한 StriableUILabel 타입을 만들어 Cell의 priceLabel의 타입으로 지정해줬습니다.

  2. UILabel Extension
    기존 String을 Extension하게되면 AttributedText와 관련 없는 String 인스턴스에 메서드가 노출되기 때문에 UILabel로 범위를 좁혀 확장하는 방법

1번 방법으로 처음 개선하려 했으나 2번 방법으로 진행하게 되면 Protocol이나 CustomLabel를 따로 구현하지 않아도 되기 때문에 2번 방법을 사용해서 개선했습니다.

let attributeString = NSMutableAttributedString(string: self)
attributeString.addAttribute(NSAttributedString.Key.strikethroughStyle,
value: NSUnderlineStyle.single.rawValue,
range: NSMakeRange(0, attributeString.length))
return attributeString
}
}
24 changes: 24 additions & 0 deletions OpenMarket/OpenMarket/Extension/UIImageView+Extension.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import UIKit

extension UIImageView {
func loadImage(of key: String) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

모든 UIImageView에서 필요한 기능일까요?

Copy link

@just1103 just1103 Jan 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 STEP에서 필요한 모든 ImageView가 서버에서 받아온 이미지이므로 해당 메서드를 사용해야 해서 익스텐션이 적절하다고 판단했습니다. 다음 STEP에서 다른 종류의 이미지를 사용하게 되면 그때 반영해보도록 하겠습니다. :)

let cacheKey = NSString(string: key)
if let cachedImage = ImageCacheManager.shared.object(forKey: cacheKey) {
self.image = cachedImage
return
}

DispatchQueue.global().async {
guard let imageURL = URL(string: key),
let imageData = try? Data(contentsOf: imageURL),
let loadedImage = UIImage(data: imageData) else {
return
}
ImageCacheManager.shared.setObject(loadedImage, forKey: cacheKey)

DispatchQueue.main.async {
self.image = loadedImage
}
}
}
}
4 changes: 0 additions & 4 deletions OpenMarket/OpenMarket/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,6 @@
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
Expand All @@ -43,8 +41,6 @@
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
Expand Down
Loading