프로젝트 기간 : 2022.07.11 월 ~ 2022.08.05 금
리뷰어 : 콘
- 📜 프로젝트 소개
- 👥 팀원
- 💾 개발환경 및 라이브러리
- 💡 핵심경험
- 🕰 타임라인
- 📱 구현 화면
- 🧑💻 코드 설명
- ⛹🏻♀️ STEP 1 트러블 슈팅
- ⛹🏻♀️ STEP 2 트러블 슈팅
- ⛹🏻♀️ STEP 3 트러블 슈팅
- ⛹🏻♀️ STEP 4 트러블 슈팅
- ⌨️ 커밋 규칙
- 🔗 참고 링크
물건 팔아요~ 오픈 마켓 🛍 상품 목록 리스트와 그리드 뷰 형식으로 보여주는 화면 구성, 새로운 상품을 등록할 수 있는 화면 구성, 실제 서버와 통신하여 등록한 상품을 생성하고, 정보를 수정하고, 상품을 삭제할 수 있는 기능.
재재(ZZBAE) | 주디(Judy) |
---|---|
Github | Github |
- 파싱한 JSON 데이터와 매핑할 모델 설계
- URL Session을 활용한 서버와의 통신
- CodingKeys 프로토콜의 활용
- 네트워크 상황과 무관한 네트워킹 데이터 타입의 단위 테스트(Unit Test)
- Safe Area을 고려한 오토 레이아웃 구현
- Collection View의 활용
- Mordern Collection View 활용
- multipart/form-data의 구조 파악
- URLSession을 활용한 multipart/form-data 요청 전송
- 사용자 친화적인 UI/UX 구현 (적절한 입력 컴포넌트 사용, 알맞은 키보드 타입 지정)
- UIAlertController 액션의 completion handler 활용
- UIAlertController의 textFields 활용
- UICollectionView 를 통한 좌우 스크롤 기능 구현
첫째 주
날짜 | 내용 |
---|---|
7/11(월) | step1-1 모델 타입 구현 |
7/12(화) | URLSession, MockURLSession 구현, UnitTest (step1-2) 후 STEP 1 PR |
7/13(수) | 리뷰기반 STEP 1 리팩토링 (UICollectionViewList 연습) |
7/14(목) | UICollectionViewList 연습구현 후 기본 틀 생성 |
7/15(금) | List CollectionView 기본 구현, 리드미 작성 |
둘째 주
날짜 | 내용 |
---|---|
7/18(월) | 서버에서 데이터 가져온 후 ListCell 화면 구현 |
7/19(화) | GridCell 화면 구현 및 개인 공부 |
7/20(수) | View안 세부적인 부분 수정, 에러 핸들링, loadingIndicator 화면 구현 후 STEP 2 PR |
7/21(목) | 개인 공부 |
7/22(금) | 리드미 작성 및 STEP 2 리뷰 반영 리팩토링 진행 |
셋째 주
날짜 | 내용 |
---|---|
7/25(월) | step2의 간단한 리팩토링 및 multipart-formdata 공부 |
7/26(화) | 서버와 소통하는 post, patch, delete 연습용 코드로 multipart-formdata 기능 구현 |
7/27(수) | 상품등록화면 구성 구현, UIImagePickerController 활용하여 이미지 부분 구현 |
7/28(목) | 상품등록 textView와 키보드, 연습용 코드를 실제 서버와 소통할 수 있게끔 수정, 상품등록화면을 상품수정화면으로 재사용할 수 있게끔 구현 |
7/29(금) | 추가 리팩토링, Readme.md 작성 및 step1 PR |
넷째 주
날짜 | 내용 |
---|---|
8/1(월) | 사진 5장까지 post 가능하도록 구현, cache 적용,paramManager 생성 |
8/2(화) | step1 리팩토링 진행, 상품상세뷰, 상품수정뷰 틀 잡기 |
8/3(수) | UIAlertController액션의 completionhandler로 수정/삭제/취소 구현, 상품수정 (patch), 상품삭제 (delete) 서버와 통신하게끔 구현 |
8/4(목) | description 줄바꿈 해결, step2 PR |
8/5(금) | Readme.md 작성 |
상품의 ListView | 상품의 GridView | 상품을 등록하는 화면 |
---|---|---|
상품 기본 정보 입력 | 상품 상세 설명 입력 | 상품의 이미지 최대 5장까지 추가 |
---|---|---|
상품명, 상품가격, 할인가격, 재고수량 정보 입력할 때 해당 키보드가 나오게끔 구현 | 상품의 상세 설명이 길어져도 밑에서 올라오는 키보드가 설명칸을 가리지 않게끔 구현 | 상품 이미지를 정사각형으로 넣을 수 있고, 1장 ~ 5장을 추가할 수 있도록 구현 |
상품 수정 기능 | 상품 삭제 실패 | 상품 삭제 성공 |
---|---|---|
등록되어 있는 상품정보를 수정할 수 있음 | 등록된 상품의 비밀번호와 일치하지 않을 시 삭제 불가능 | 등록되어 있는 상품의 비밀번호와 일치할시 삭제 가능 |
상세 화면 이미지 넘기기 | 타인이 등록한 상품에 대한 수정 제한 |
---|---|
좌우로 밀어서 다음/이전 이미지 확인 | 본인이 등록하지 않은 상품은 수정할 수 없음 |
UML
STEP 1
상품 정보를 파싱할 구조체
- JSON 키 값을 스위프트의 네이밍에 맞게 변환하기 위해
CodingKeys
프로토콜의 활용
상품 리스트를 가진 페이지 정보를 파싱할 구조체
- JSON 키 값을 스위프트의 네이밍에 맞게 변환하기 위해
CodingKeys
프로토콜의 활용
통화의 단위를 나타내는 열거형
- case:
krw = "KRW"
,usd = "USD"
product Model에서 가져온 정보를 뷰에다가 뿌려줄 Item 정보들
productImage
(상품이미지),productName
(상품이름),price
(상품의 원래 가격),bargainPrice
(상품의 할인된 가격),stock
(잔여수량),devidePrice
(상품 가격을 천의 단위로 , 넣어주는 함수)
dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTaskProtocol
- 위 메서드를 구현하도록 함
URLSession
과MockURLSession
에서 채택
resume()
을 구현하도록 함URLSessionDataTask
와MockURLSessionDataTask
에서 채택
네트워킹에 사용할 세션을 제공하는 클래스
session
: 통신에 사용할 세션 프로퍼티 ->URLSession
또는MockURLSession
baseURL
: api-host를 담은 프로퍼티dataTask
:session
의dataTask
메서드를 통해 서버로 부터 데이터를 받아오는 메서드receivePage
:URLRequest
를 만들어dataTask
를 통해 상품 리스트를 받아오는 메서드
통신 과정에서 발생할 수 있는 에러를 정의한 열거형
- case:
incorrectResponseError
,invalidDataError
Mock 테스트를 위해 사용할 가짜 세션
isSuccess
: 인위적으로 결정할 통신 성공, 실패 여부 프로퍼티dataTask
: 실제로 통신하지 않고 지정된 결과를 반환하는 메서드
URLSessionDataTaskProtocol에 있는 resume 함수 변경
newResume()
은resume()
의 실행을 하는 함수
STEP 2
로딩화면을 시작하고 끝내는 클래스
showLoading
: 로딩화면을 실행하는 타입 메서드hideLoading
: 로딩화면을 정지하는 타입 메서드
상품들을 List 형태로 보여주는 컬렉션 뷰 셀
imageView
: 상품의 이미지accessoryImageView
: 우측>
이미지nameLabel
: 상품의 이름 라벨priceLabel
: 상품의 원가, 할인가격 라벨stockLabel
: 상품의 잔여수량 라벨horizontalStackView
: 상품 이름과subHorizontalStackView
를 묶어준 스택뷰subHorizontalStackView
: 잔여수량,>
를 묶어준 스택뷰verticalStackView
:horizontalStackView
와 상품 가격 라벨을 묶어준 스택뷰entireStackView
:verticalStackView
와 상품의 이미지를 묶어준 스택뷰arrangeSubView
: 각 스택뷰의 Constraint를 잡아준 함수extension CALayer {func addBottomBorder()}
: 셀 밑에 선처럼 보이게bottomBorder
를 만들어주는 함수
상품들을 Grid 형태로 보여주는 컬렉션 뷰 셀
imageView
: 상품의 이미지nameLabel
: 상품의 이름 라벨priceLabel
: 상품의 원가, 할인가격 라벨stockLabel
: 상품의 잔여수량 라벨verticalStackView
: 위에 4가지 라벨을 묶어준 스택뷰arrangeSubView
: 스택뷰의 Constraint를 잡아준 함수
상품 목록을 List와 Grid 형식으로 보여주는 컬렉션 뷰 셀을 관리하는 뷰컨트롤러
createListLayout
: List 형식의UICollectionViewLayout
을 반환하는 메서드createGridLayout
: Grid 형식의UICollectionViewLayout
을 반환하는 메서드makeListDataSource
: List Cell의 Registration을 설정하고 DataSource를 생성하는 메서드makeGridDataSource
: : Grid Cell의 Registration을 설정하고 DataSource를 생성하는 메서드receivePageData
: 서버를 통해 상품 데이터를 받아오는 메서드applySnapshots
: 받아온 데이터로 SnapShot을 생성해 데이터소스에 적용하는 메서드indexChanged
:segmentedControl
의 값이 변경되었을 때 뷰의 모양을 바꾸는 메서드
STEP 3
상품 등록 화면에 표시되는 해당 상품 정보들의 타입
vendorInfo
: 판매자의secret
과identifier
를 담은 열거형Param
: productName(상품명), price(상품가격), discountedPrice(할인가격), currency(통화단위), stock(재고수량), description(상품상세설명), secret(VendorInfo의 사용자 비밀번호)ImageParam
: imageName(이미지 파일 이름), imageType(이미지 파일 타입), imageData(이미지 데이터 타입)
새로운 상품을 등록(또는 수정)하는 화면
arrangeSubView
: 요소들을SubView
에 넣고 제약을 설정하는 메서드createParam
: 입력된 정보들을Param
으로 반환해주는 메서드configure
:Param
의 각 요소들을 각각의 칸(TextField)
에 넣어주는 메서드
상품 등록 화면에서 이미지의 설정을 해주는 셀
arrangeSubView
: 상품 이미지의 constraint를 설정해주는 메서드
상품등록(또는 수정)하는 뷰를 관리하는 컨트롤러
changeToEditMode
: 상품등록화면을 상품수정화면에서 사용할 수 있게끔 해주는 메서드configureUI
: 상단의 네비게이션 아이템 넣어준 부분의 layout 잡아주는 메서드goBack
: 상품리스트 화면으로 돌아가는 objc 메서드goBackWithUpdate
: 상품의 정보들을 입력해준 후 post 해주고, 성공과 실패의 얼럿을 띄워주는 메서드CollectionView's DataSource & Delegate
:UICollectionViewDataSource
,UICollectionViewDelegate
를 채택하여 뷰를 재사용할 수 있고, 이미지의 indexPath를 계산하여 얼럿을 띄워주고 제약을 걸어주는 메서드ImagePickerController
:UIImagePickerControllerDelegate
와UINavigationControllerDelegate
를 채택하여, post할 이미지의 설정과 제약을 걸어주는 메서드UITextView
:textView
에 작성을 시작하고 끝낼때의viewConstraint
를 조절해주는 메서드
STEP 4
상품상세정보에서 이미지와 이미지 순서를 나타내는 숫자label을 보여주는 콜렉션 뷰 셀
상품상세정보에서 상품 정보를 보여주는 콜렉션 뷰 셀
상품상세정보 뷰를 관리하는 컨트롤러
backBarButtonDidTapped
: 뒤로가는 버튼을 눌렀을 때, 리스트 화면으로 pop해주는 @objc 메서드editProductButtonDidTapped
: 수정 버튼을 눌렀을 때, 상품 상세 정보를 수정할 수 있게 해주는 @objc 메서드deleteAfterCheckSecret
: 사용자 secretKey가 상품정보의 secretKey와 일치하면 delete가 가능하게 해주는 메서드 (일치하지 않으면 실패 얼럿)configureUI
: navigationBar의 UI를 구성 및 설정해주는 메서드
요구명세서에 나와있는 것처럼 실제 API 서버와 통신하지 않고, MockData
를 사용하여 테스트를 하기 위해 MockURLSession
을 생성하고, URLSession
도 동일하게 protocol을 채택하고 extension 구현을 해주었습니다. 처음에는 mockURLSession
을 굳이 구현해주지 않고 어차피 나중에 사용할 (실제 서버와 소통을 하는) URLSession
을 활용해주려고 했는데, 그러면 step 1의 명세대로 구현을 하기가 어려울 것 같아 따로 mockURLSession
을 생성해주었습니다.
그리고 URLSessionProvider
생성 후 URLSessionProvider
에 있는 dataTask
메서드를 구현해준 뒤 MockURLSession
에서 그 메서드를 가로채와서 상세구현 후 MockData
로 UnitTest를 진행하는 방식으로 전반적인 step 1 의 방향을 잡았습니다
UnitTest는 서버와 소통을 하지 않는 MockURLSession
으로 진행했습니다. MockURLSession
내부의 dataTask
는 실제 서버 통신을 하지 않고 isSuccess
프로퍼티를 통해 임의로 지정한 성공-실패 여부에 따라 HTTPURLResponse
를 보내도록 했습니다. 성공 시 MockData를 반환하고, 실패 시 400번대 응답코드를 반환하도록 했습니다.
UnitTest에서는receivePage
메서드를 통해 서버요청이 성공한 경우에는 받아온 jsonData가 이미 가지고 있는 mockData와 같은지, 서버요청이 실패한 경우에는 에러를 반환하는지를 테스트하는 함수를 만들어주었습니다. 혹시 추가로 할 수 있는 테스트는 어떤 종류가 있을지, 아니면 더 이상 테스트를 하지 않아도 되는지 콘의 의견이 궁금합니다.
밑의 사진처럼 URLSessionDataTask
에서 deprecated warning이 떴는데, 코드 빌드에는 문제가 되지 않았지만 애플에서 지양하도록 권장하는 경고이기 때문에 해결을 하고 싶었습니다.
URLSessionDataTask
를 상속받으면서 생기는 에러니까 상속 대신에URLSessionDatatTaskProtocol
을 만들고 conform하면?MockURLSessionDataTask
를URLSessionDataTask
의 상속이 아니라URLSessionDatatTaskProtocol
을 채택하도록 변경resume
앞에 override 키워드 제거URLSessionProtocol
의dataTask
함수에서URLSessionDataTask
를 반환한 부분을URLSessionDataTaskProtocol
을 반환하도록 변경URLSession
에는URLSessionDataTaskProtocol
을 반환하는 기본 메서드가 없어서URLSession
안에서 해당 함수를 선언해주고, 이 안에서는dataTask(with:completionHandler:)
함수 호출
이런 흐름의 생각을 반영하여 URLSessionDatatTaskProtocol
을 생성한 후 URLSessionDataTask
과 MockURLSessionDataTask
이 protocol을 conform하게끔 만들어주었습니다.
// MarketGridCollectionViewCell.swift
self.addSubview(verticalStackView)
NSLayoutConstraint.activate([
verticalStackView.topAnchor.constraint(equalTo: self.topAnchor, constant: 8),
verticalStackView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -8),
verticalStackView.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8),
verticalStackView.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 8),
imageView.heightAnchor.constraint(equalTo: self.heightAnchor, multiplier: 0.58)
])
Grid 셀과 List 셀 모두 콘텐츠에 대한 제약을 self
와 관련하여 잡아주었습니다. CollectionViewCell에서 적어준 코드이니 self
는 셀 자신이고 contentView
는 셀 내부의 콘텐츠 영역이라고 생각해서 처음에는 contentView
로 제약을 걸어주었습니다
하지만 contentView
에 제약을 설정하니 아래 사진에서 같이 콘텐츠(특히 이미지)가 셀 크기를 벗어나는 문제가 있어 self
로 constraints
를 주었는데 self
로 셀에 직접 제약을 걸어도 되는지 궁금했습니다.
self 로만 제약 걸어줄 때 |
self.contentView 로 제약 걸어줄 때 |
---|---|
해당 질문에 대해 리뷰어인 콘이 contentView
에 해야한다고 알려주셨습니다. contentView
의 공식문서에서 편집 모드일 때 레이아웃에 적절히 배치되기 위해서는 contentView
에 아이템을 추가하는 것을 권장하고 있습니다.
"you should add them to the content view so they position appropriately as the cell transitions in to and out of editing mode."
처음에 contentView
에 제약을 걸었을 때 문제가 생겼던 이유는 self
에 addsubView()
로 stackView
를 추가하고 제약은 contentView
에 걸었기 때문이었습니다.. contentView
에 addsubView()
하는 것으로 변경하니 해결되었습니다.
translatesAutoresizingMaskIntoConstraints = false
는 전체를 감싼 stackView
에서만 해주어도 되는지, 혹은 stackView
내부에 있는 모든 콘텐츠(label, image 등)마다 따로 설정을 해줘야하는건지 궁금했는데, 뷰 내부적으로 자동으로 false로 설정이 된다고 리뷰어분께서 설명해주셨습니다. 하지만 그래도 뷰 정의시 false로 지정하는 습관을 가져가는게 좋다고 해주셔서 각각의 콘텐츠마다 설정을 해주었습니다.
처음 실행했을 때는 오류가 없다가 리스트 뷰에서 그리드 뷰로 전환하면 콘솔창에 LayoutConstraints
관련 에러 메세지가 출력되었습니다. 빌드할 때는 지장이 가지 않고 뷰도 잘 나오기 때문에 어떤 문제인지 찾기 어려웠습니다.
하지만 1번 트러블 슈팅 내용인 셀의 아이템들을 contentView
에 넣고 제약을 걸어주었더니 자연스럽게 해결된 것으로 보아 아마 self
로 셀 자체에 제약을 건 상태로 Layout
을 변경하면서 문제가 생겼던 것 같습니다.
컬렉션 뷰를 표시하면서 가장 문제였던 것은 이미지였습니다. List와 Grid 형식 모두 먼저 크게 하나의 StackView로 Cell에 제약을 걸고 그 StackView에 ImageView와 Label을 넣었습니다. 하지만 특히 이미지가 셀을 벗어나거나 크기가 제멋대로 나오는 문제가 자주 발생했습니다. 현재는 이미지에 크기를 지정하여 표시되는 것은 해결하였는데 혹시 StackView 안에 ImageView를 넣는 것이 좋지 않은 방법인가 고민했습니다.
리뷰어 콘이 말씀해주신 것처럼, 이미지뷰에 별도 사이즈를 지정하거나 제약을 가하지 않으면 이미지 크기가 이미지뷰의 크기가 되기 때문에 밑의 사진 처럼 나오는 것 (intrinsic content size)
이라고 이해했습니다.
제각각인 이미지 사이즈 예시 | 이미지 사이즈 제약 실패 예시 |
---|---|
TextView에 글자를 입력 시 글자가 키보드에 가려지지 않게 즉 키보드 위로 자동으로 스크롤되도록 하는 요구사항이 있습니다. 키보드 위로 글자가 보이게 하는 방법을 세가지로 시도해봤습니다.
extension ProductViewController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
productView.entireStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -300).isActive = true
}
func textViewDidEndEditing(_ textView: UITextView) {
productView.entireStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8).isActive = true
}
}
첫 번쩨는 TextView가 속해있는 StackView의 Constraint를 변경해보려고 했습니다. 키보드 크기만큼 bottom을 올렸다가 입력이 끝나면 내리도록 했는데 올리는 것은 성공했으나 다시 내려오지 않았습니다.
extension ProductViewController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
view.frame.size.height -= 300
}
func textViewDidEndEditing(_ textView: UITextView) {
view.frame.size.height += 300
}
}
두 번째 방법으로 전체 View의 높이를 변경해봤습니다. 키보드 크기만큼 올린 후 다시 내려오는 것이 가능했으니 빈 부분이 검정색으로 보이고 View가 압축되어 일그러졌습니다.
extension ProductViewController: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
productView.descriptionTextView.setContentOffset(CGPoint(x: 0, y: productView.descriptionTextView.contentSize.height - productView.descriptionTextView.bounds.height + 300), animated: false)
}
func textViewDidEndEditing(_ textView: UITextView) {
productView.descriptionTextView.setContentOffset(CGPoint(x: 0, y: productView.descriptionTextView.contentSize.height - productView.descriptionTextView.bounds.height), animated: false)
}
}
마지막으로는 TextView의 스크롤의 커서 위치를 조절하려 했습니다. 하지만 입력을 시작하자마자 키보드 위치로 커서가 이동해버렸고, 입력을 끝내면 다시 위로 이동시키는 것이 어려웠습니다.
StackView | View | Content Offset |
---|---|---|
TextView의 크기가 줄어들었으나 다시 돌아오지 않음 | 위로 올라간 후 다시 내려오지만 view가 압축되어 일그러짐 | 처음부터 중간에서 시작하게 되고 입력이 끝나면 아래로 이동 |
최종적으로 첫 번째 방법을 사용했습니다. 첫 시도에서 발생했던 문제는 계속 새로운 Constraint를 추가해서 상충됐기 때문이었습니다.
private lazy var viewConstraint = productView.entireStackView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -260)
extension AddProductViewController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
viewConstraint.isActive = true
}
func textViewDidEndEditing(_ textView: UITextView) {
viewConstraint.isActive = false
}
}
하나의 Constraint를 선언해 isActive
를 조절하는 방식으로 했습니다. 또한 기본적으로 뷰를 구성할 때 지정한 bottom 제약의 Priority를 낮춰서 상충없이 동작하도록 구현했습니다.
빠르게 스크롤 하다보면 엉뚱한 사진이 띄워져 있는 문제가 있었습니다. 서버를 통해 사진을 받아오는 요청은 비동기로 진행되고, Cell이 재사용되기 때문에 발생하는 문제였습니다. 사진의 용량이 크거나 느린 네트워크일 경우 이전에 요청한 사진이 뒤늦게 돌아와서 잘못된 이미지가 떴습니다.
[문제 예시 - 제다이가 된 주디와 웡빙 (원래는 연예인 사진)]
이미지를 요청한 Cell과 현재 Cell이 일치하지 않았을 때의 문제이므로 Cell에 이미지를 할당하기 전에 이미지를 요청했던 Cell 비교하여 Cell이 일치한 경우에만 이미지를 넣도록 하여 해결했습니다.
guard indexPath == self.collectionView.indexPath(for: cell) else { return }
이전 스텝에서 사용한 LIST와 Grid Cell이 중복된 코드가 많아 해결하고 싶었습니다.
UICollectionViewCell
을 상속받는 MarketCollectionViewCell
을 생성해 중복되는 UI와 메서드를 갖도록 했습니다. 이후 LIST와 Grid Cell이 MarketCollectionViewCell
를 상속받아 사용할 수 있도록 하여 코드의 중복을 줄이고 추상화해 줄 수 있었습니다.
get이나 post와 같은 서버 요청이 실패한 경우 얼럿을 띄워 사용자에게 알려주도록 했는데 통신이 실패했을 때 얼럿이 뜨지 않고 아래와 같은 에러가 발생했습니다.
Alert을 메인 스레드에서 하지 않았을 때 나타난 에러
얼럿을 띄우는 것 역시 UI 동작이기 때문에 Main Thread
에서 실행되도록 변경해 해결했습니다.
상품 상세 화면을 컬렉션 뷰로 구현해봤습니다. 상품 리스트 화면은 Lis일 때나, Grid일 때나 각 Cell의 형태는 동일해서 하나의 Custom Cell과 하나의 Section으로 표현 가능했지만 상세 화면은 다양한 구성을 가지고 있어 두 개의 섹션으로 표현했습니다.
enum Section: Int ,Hashable {
case image // 이미지를 스크롤 할 수 있는 섹션
case info // 상품의 정보를 보여주는 섹션
}
이미지 섹션의 snapshot을 지정할 때 어려움이 있었는데 우선 상품 정보를 컬렉션 뷰에 띄우기 위해 서버에서 GET한 데이터를 DetailProductItem
타입으로 디코딩한 후 사용했습니다. 따라서 snapshot으로 사용할 타입도 DetailProductItem
인데 문제는 이미지를 표현하는 섹션의 데이터 역시 DetailProductItem
으로 표현해야 했습니다.
그래서 처음에는 DetailProductItem
이 [String]
으로 이미지 url을 가지도록 했으나 이미지 데이터가 상품의 사진 개수만큼 있어야 띄울 수 있었습니다. 그래서 단순하게 동일한 DetailProductItem
를 배열에 넣어서 snapshot으로 사용하려 했으나 각 요소가 Hashable하지 않아 사용할 수 없었습니다.
// DetailProductItem.swift
init(detailItem: DetailProductItem, image: String) { ... }
결국 단일한 이미지 url을 가지는 DetailProductItem
을 사진 개수만큼 가진 배열을 사용해야 했습니다. 최종적으로는 DetailProductItem
에 이미지 url만 변경하는 init
을 추가했습니다.
typealias SnapShot = NSDiffableDataSourceSnapshot<Section, DetailProductItem>
private var detailProductItem: DetailProductItem? // 서버에서 받은 상품 상세 데이터를 decode한 정보
private var images: [String] = [] // 서버에서 받은 상품 이미지 url 배열
// 스냅샷 적용하는 코드
private func applySnapshots() {
var itemSnapshot = SnapShot()
guard let detailProduct = detailProductItem else { return }
var detailImages: [DetailProductItem] = []
images.forEach {
detailImages.append(DetailProductItem(detailItem: detailProduct, image: $0))
}
itemSnapshot.appendSections([.image, .info])
itemSnapshot.appendItems(detailImages , toSection: .image)
itemSnapshot.appendItems([detailProduct], toSection: .info)
dataSource.apply(itemSnapshot, animatingDifferences: false)
}
전체 이미지 url을 담은 배열을 forEach
를 통해 url만 변경한 DetailProductItem
을 생성해 배열로 만들어서 image 섹션의 snapshot으로 사용했습니다.
데이터는 잘 출력되지만 이미지만을 위해 DetailProductItem
타입을 사용하고, init
을 통해 불필요하게 타입을 복제하는 것 같아 고민이 필요할 것 같습니다.
상품 상세 화면에서 1개 이상의 이미지가 가로로 나열되어 있어 이미지를 스크롤해서 볼 수 있습니다. 하지만 다음 이미지를 보기 위해 수평 스크롤을 하였을 때, 이미지가 천천히 또는 빠르게 스크롤되고 좌우의 여백이 맞지 않는 상태여서 사용자 입장에서 보기 불편하다고 느껴졌습니다.
그래서 이미지 섹션의 orthogonalScrollingBehavior
설정을 .continuousGroupLeadingBoundary
에서 .groupPagingCentered
로 변경해주었습니다. 변경을 해준 뒤, 다음 이미지로 스크롤 하였을 때 이미지가 cell의 중앙에 맞춰져 멈추게 되어 한 장씩 넘길 수 있어 보기 편해졌습니다.
이전 step 1 (브랜치 step 3) 리뷰에서 알려주신 것처럼 이미지 타입을 구분하는 방법을 아래 사진처럼 적용해보았습니다.
여기서 저희가 발견한 문제점은, jpg는 구분이 잘 되지만 png 타입인 이미지를 어떤 방식으로 넣어도 png로 구분되지도 않고 출력되지도 않았습니다. 혹시나 시뮬레이터에서는 jpg 타입의 이미지만 가질 수 있는 것인가 의문이 들었습니다. 또한 핸드폰으로 실행해봤을 때 직접 찍은 사진이 아닌 스크린샷 또는 포털에서 다운받은 사진들은 Post되지도 않았습니다.
위 두 가지 문제는 모두 사진의 용량을 줄이는 compressImage
메서드 로직에 문제가 있었습니다.
private func compressImage(_ image: UIImage) -> Data {
guard var imageDataSize = image.jpegData(compressionQuality: 1.0)?.count else { return Data() }
var imageData = Data()
var scale = 0.9
while imageDataSize >= 300 * 1024 {
imageData = image.jpegData(compressionQuality: scale) ?? Data()
imageDataSize = imageData.count
scale -= 0.1
}
return imageData
}
사진 용량을 줄이기 위해 jpegData(compressionQuality: )
메서드를 사용했습니다. compressionQuality
을 지정해주면 해당 배율로 압축해서 사진을 Data
타입으로로 변환할 수 있습니다. 하지만 해당 메서드를 사용하면 어떤 사진도 jpeg로 변환되기 때문에 jpg 외에는 다른 타입이 나올 수 없었습니다 😅
위 코드를 보면 imageData
에 빈 데이터를 넣은 후 while
을 돌며 사진 용량이 300KB 이하가 될 때까지 압축한 후 다시 imageData
에 할당했습니다. 하지만 이런 로직이면 이미 300KB 이하여서 while
문을 실행하지 않으면 그대로 빈 데이터가 리턴되는 문제가 발생하고 있었습니다. 따라서 이미지가 존재하지 않으니 Post 역시 실패할 수밖에 없었습니다.
guard var imageData = image.jpegData(compressionQuality: 1.0) else { return Data() }
사진의 용량을 판단하기 전에 우선 jpegData
로 변환한 데이터를 imageData
로 할당해 빈 데이터가 리턴되는 문제를 해결했습니다.
- feat : 기능 추가 (새로운 기능)
- refactor : 리팩토링 (네이밍 수정 등)
- style : 스타일 (코드 형식, 세미콜론 추가: 비즈니스 로직에 변경 없음)
- docs : 문서 변경 (문서 추가, 수정, 삭제)
- test : 테스트 (테스트 코드 추가, 수정, 삭제: 비즈니스 로직에 변경 없음)
- chore : 기타 변경사항 (빌드 스크립트 수정 등)
[STEP 1]
wody's tistory
wody's notion
Fetching Website Data into Memory
Implementing Modern Collection Views
raywendelich test project
URLSession
오동나무의 비동기 테스트
[STEP 2]
contentView
wody - UILabel에 취소선 (strikethroughStyle) 적용하기
UICollectionViewCompositionalLayout
Lists in UICollectionView
UIActivityIndicatorView
modernCellConfiguration
UICollectionViewDataSource
implementing Modern CollectionView