<앱스토어 뉴스/잡지 순위 최대 11위 >
올해 벌써 3번째 개인 앱 출시이다. 3월 처음 필터앱을 개발할 때만 해도 본인 이름을 건 앱이 있다는 사실만으로 신기했는데 이제는 일희일비하지 않고 다음 업데이트를 고려하며 작업 계획을 세우고 수행하고 있는 모습이 뭔가 성장한 듯하면서도 아직도 갈 길이 멀다는 생각이 든다.
최초 아이디어는 예전 학교 취업지원팀 특강에서 자소서 준비 시에 최근 사회 이슈 관련 문항 준비를 위해 신문/뉴스 스터디가 방학 중에 준비되어있어야 한다는 기억에서 시작했다.
기사를 읽으면서 본인의 생각을 미리 정리하고 스터디원들에게 해당 내용을 공유하기 위해 적절한 문서 형태로 제공하는 앱을 만들자!
엄청난 기능으로 무장한 앱보다는 그동안 새싹에서 배운 내용들을 최대한 활용하면서 MVVM을 활용한 코드 모듈화 및 패턴화하는 것에 초점을 맞췄다.
차후 RxSwift나 Combine, 혹은 더 나아가 Clean Architecture까지 리팩토링을 고려하면 MVVM 패턴으로 ViewModel과 View 구분을 하는 것이 주요 포인트였다.
여기에 기능적으로 기사 관련 네트워크 통신, 태그와 함께 기록을 local DB 저장, PDF 문서 생성을 목록으로 추가했다.
<최초 기획>
너무 많이 잡아도 문제, 너무 적게 잡아도 문제
지난 유통기한 앱 개발 당시 주마다 달성해야할 목표를 설정하고 그 안에만 완성하고 팀 미팅만 하는 정도의 여유로운 공수 산정 계획만 잡고 진행했던 경험이 있었다.
이번에는 매일의 계획에 맞게 기능 구현 및 테스트를 진행하면서 너무 처지지도 혹은 너무 땡겨지지도 않도록 계획을 짜기 위해 일일 계획표와 예상 시간을 작성했다.
목표를 이뤄가면서 계획보다 너무 빨리 끝나지 않도록 워크로드를 조절하는 일이 특히 어려웠던 것 같다. 미리 끝나는 건이 많다는 것은 본인의 능력이 예상보다 더 높다는 긍정적인 의미가 될 수 있지만, 반대로 계획을 필요 이상으로 오래 잡아서 비용이 더 많이 들 수 있다는 부정적 의미도 될 수 있다.
경험적으로는 처음 활용해보는 기술들은 예상 소요 시간의 1.5배까지 걸려서 조금 더 여유를 두고 시작하고 계획을 잡는 것이 맞아보였다.
오히려 UI 구성에서 마음에 들 때까지 잡아야 하다보니 시간 소요가 여기서 더 오래 걸렸다. 최대 3~4배 차이까지 난 경우도 있어, UI 수정만 하다 하루 일과를 소화하지 못한 경우가 종종 나타났다.
원하는 기능 구현을 위해 생각 이상으로 처음 활용해보는 프레임워크나 라이브러리가 많아 우선 각 기능 구현을 하며 dummy data로 테스트하고 기능들을 합치는 과정에서 ViewModel로 분리하기로 했다.
UISheetPresentation은 결국 ViewController를 push하든 present하든 화면에 새로 띄울 때 적용할 수 있는 방식 중 하나이다.
이 방식은 ViewController 위에 새로운 ViewController가 마치 sheet처럼 올라와서 하단의 ViewController를 완전히 가리거나 아예 내릴 수 있다.
<초기 유저 입력을 받는 ViewController를 sheetPresentation으로 구현한 경우>
유저가 이를 완전히 내리는 경우, ViewController (여기선 MemoViewController)가 화면에서 사라지는 것을 의미하므로 viewDidDisappear를 호출한 뒤, 메모리에서 내려간다. 이렇게 되면 유저가 입력한 정보를 따로 저장하지 않는 한, MemoViewController를 새롭게 띄우면 이전에 입력했던 값들은 사라진다.
따라서 유저가 완전히 내리더라도 입력값을 임시 저장하기 위해 UserDefaults
를 활용, 새로 뉴스를 검색해서 새롭게 기사를 띄우지 않는 한, 해당 기사에서 임시 저장한 내용을 가져올 수 있도록 했다.
초기 디자인 당시에는 다음과 같은 과정으로 기사 cell을 나타내려 했다.
<LinkPresentation을 활용, url만으로 기사 나타내기>
LPLinkMetadata
를 가져오는 작업 마저도 결국 url을 매개로 다시 네트워크 통신을 하는 작업이었기에 WWDC에서도 해당 metadata를 캐싱해놓을 것을 권장했다.
참고) WWDC19: Embedding and Sharing Visually Rich Links
캐싱에는 메모리 캐싱과 디스크 캐싱이 존재하는데, 보통의 캐싱이라고하면 e-tag까지 활용해서 해당 데이터가 새로 받아오려는 data인지 판단해서 필요한 경우에만 새로 네트워크 통신을 진행한다.
LPLinkMetadata는 url만 전달해서 LinkPresentation 프레임워크가 내부적으로 알아서 metadata를 가져오는 작업이므로 따로 e-tag 활용을 하기 어려웠다. 또한 기사 컨텐츠는 삭제를 비롯해서 데이터가 변경될 요소가 다분했기에 바뀐 정보를 따로 담는 디스크까지 저장할 필요성이 작아서 메모리 캐시에만 저장하도록 했다.
기사 목록에서 LinkPresentation을 활용한 ViewController쪽은 UI로 특별한 인상을 남길 수 없었다. 따라서 입력한 저널 목록을 보여주는 ViewController에서 눈에 확 띄는 디자인이 필요했다.따라서 pinterest처럼 collectionView의 각 cell 높이가 입력한 저널 양에 따라 달라지도록 기획을 잡았다.
<메모 입력값 따라 cell 높이가 dynamic하게 나타나도록 함>
기존의 dynamic height를 나타내는 서비스들은 주로 image에서 width와 height 비율을 활용해 cell 전체를 UIImageView로 레이아웃을 잡아 쉽게 구현할 수 있었다. 하지만 본인의 경우는 width는 2줄 구성으로 고정값을 얻을 수 있다고 해도, 각 string 입력값의 높이를 구하는 과정을 거쳐 직접 계산한 값이 필요했다.
String Extension으로 CGSize로 String이 들어간 사각형을 만들어 해당 사각형의 높이를 구하는 메서드 구현을 했다.
extension String {
func height(width: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [NSAttributedString.Key.font: font], context: nil)
return boundingBox.height
}
}
초반에는 입력한 저널의 높이값만 필요한 줄 알았지만 실행을 하면 cell 높이 설정이 잘못되었다는 런타임 오류가 계속해서 나타났다.
유저가 입력한 저널 컨텐츠의 높이 뿐만 아니라 최근 수정 날짜 및 태그 정보, 제목을 나타내는 각 label들의 높이와 더불어 사이사이의 y축 offset 값을 모두 더해 cell의 총 높이를 구했어야 했다.
func calculateRatios(contentWidth: CGFloat, journals: [BookMarkedNews]) -> [Ratio] {
let width = (contentWidth - Constant.Frame.journalCollectionViewGroupInterItemSpace - Constant.Frame.journalCollectionViewSpacingForDoublePadding) / 2
var ratios = [Ratio]()
for journal in journals {
guard let memoHeight = journal.journal?.content.height(width: width, font: Constant.Font.journalRealmCellMemo) else { continue }
//cell height = titleTopOffset + titleHeight + titleBottomEditedTopOffset + editedHeight + editedBottomTagTopOffset + tagBottomMemoTopOffset + memoHeight + memoBottomOffset
let titleHeight = (width - Constant.Frame.journalRealmCellLabelInset) * Constant.Frame.journalRealmCellTitleLabelHeightMultiply
let editedDateHeight = (width - Constant.Frame.journalRealmCellLabelInset) * Constant.Frame.journalRealmCellDateHeightMultiply
let tagHeight = (width - Constant.Frame.journalRealmCellLabelInset) * Constant.Frame.journalRealmCellTagHeightMultiply
let offsetForDateAndTag = Constant.Frame.journalRealmCellDateTagInset
let offsetForTitleAndMemo = Constant.Frame.journalRealmCellLabelInset
let height = offsetForTitleAndMemo * 3 + titleHeight + offsetForDateAndTag * 2 + editedDateHeight + tagHeight + memoHeight
ratios.append(Ratio(ratio: width / height))
}
return ratios
}
PDFKit을 활용해서 String 및 Image data를 PDF 파일로 생성하는 작업의 경우, 각 data를 문서에 "그려내는" 작업의 반복이었다. 즉, CoreGraphics로 표현할 각 data들의 높이와 너비값을 계산해서 계속해서 그려나가는 과정이 필요했다.
각 페이지는 A4 사이즈로 설정을 하며 기사 당 저널이 1:1로 매칭하므로 다음의 작업 순서를 거쳤다.
반복문: 선택한 각 저널마다
1. 뉴스 제목
2. 기사 링크
3. 발행 날짜
4. 저널 제목
5. 최초 작성 날짜
6. 최근 수정 날짜
7. 작성한 저널 내용
순서로 (0,0) 위치부터 시작해 아래로 내려오면서 하나씩 위치를 잡는다.
기사 링크부터는 바로 위에서 위치를 잡은 요소의 끝나는 지점부터 시작해야 했으므로 각 요소의 영역을 그리는 메서드들은 모두 CGFloat 타입 값을 반환한다.
또한 padding을 준 가로 영역보다 문자열 길이가 긴 경우, 문자열이 그려질 사각형 높이를 더 키우는 분기처리도 필요했다. 처리하지 않을 경우, 범위를 벗어난 문자열은 나타나지 않는다.
//setup PDF Size
let pageRect = CGRect(origin: .zero, size: CGSize(width: Constant.Frame.pdfCreatorPageWidth, height: Constant.Frame.pdfCreatorPageHeight))
//PDFRenderer
let renderer = UIGraphicsPDFRenderer(bounds: pageRect, format: format)
//create PDF Data
let data = renderer.pdfData { context in
for journal in selectedJournals {
//start new page for each journal
context.beginPage()
//newsTitle
let newsTitleBottom = addNewsTitle(pageRect: pageRect, journal: journal)
//....
}
}
return data
private func addNewsTitle(pageRect: CGRect, journal: BookMarkedNews) -> CGFloat {
let titleFont = Constant.Font.pdfCreatorNewsTitle
let paragraphStyle = NSMutableParagraphStyle()
//왼쪽 정렬
paragraphStyle.alignment = .left
//단어 기준 newLine으로 넘기기
paragraphStyle.lineBreakMode = .byWordWrapping
let titleAttributes = [NSAttributedString.Key.paragraphStyle: paragraphStyle,
NSAttributedString.Key.font: titleFont]
let attributedTitle = NSAttributedString(string: journal.title, attributes: titleAttributes)
let titleStringSize = attributedTitle.size()
var titleStringRect: CGRect
//왼쪽에서 시작: 오른쪽 벗어나는 길이 고려하기
if titleStringSize.width > pageRect.width {
titleStringRect = CGRect(x: Constant.Frame.pdfCreatorPadding, y: Constant.Frame.pdfCreatorPadding, width: pageRect.width - Constant.Frame.pdfCreatorPadding * 2, height: titleStringSize.height * 2)
} else {
titleStringRect = CGRect(x: Constant.Frame.pdfCreatorPadding, y: Constant.Frame.pdfCreatorPadding, width: titleStringSize.width, height: titleStringSize.height)
}
//제목 영역 그리기
attributedTitle.draw(in: titleStringRect)
//제목이 포함된 사각형의 하단 왼쪽 꼭지점 y좌표 반환
return titleStringRect.origin.y + titleStringRect.size.height
}
한편 실제 저널 내용의 경우, 많이 길어져서 페이지를 넘어갈 수 있으므로 이에 대한 분기 처리도 필요했다. (가로 길이는 모두 padding값을 준 길이로 동일)
1. 저널 입력값 전체를 설정한 크기에 맞게 나눠서 각 TextView로 구성하기
1-1) 시작 위치가 0보다 큰 경우: 처음 저널 입력을 그리려는 위치
--> 해당 페이지에서 남은 영역만큼 사이즈 설정
1-2) 시작 위치가 0보다 작거나 같은 경우: 첫 페이지를 넘어서서 새로 그려지는 위치
--> 높이 padding값 제외 페이지 전체 크기만큼 사이즈 설정
2. 각 textView 위치 선정
2-1) 시작 위치가 0인 경우 (저널 제외 다른 요소들 다 draw하고도 영역이 남은 경우)
2-1-A) 남은영역 시작점에 textView 높이를 더한 값이 페이지 크기보다 더 클 경우
--> 다음 페이지에서 시작: 남은영역 시작점 update (기본 padding 준 시작점 y좌표값)
2-1-B) 남은영역 시작점에 textView 높이를 더한 값이 페이지 크기보다 작을 경우
--> 남은 영역 시작점부터 textView render 후 남은 영역 시작점 update
2-2) 그 외 (저널 제외 다른 요소들 다 draw하고 나니 영역이 부족)
--> 다음 페이지에 시작, 남은영역 시작점 update
(기본 padding 준 시작점 y좌표값)
3. 남은 영역 시작점부터 textView render
--> 전체 페이지 크기의 textView들만 매번 새 페이지를 생성하고 render하면 된다.
private func addMemoContent(pageRect: CGRect, textTop: CGFloat, context: UIGraphicsPDFRendererContext, memo: Journal) {
//paragraphStyle: how text should flow and wrap
let paragraphStyle = NSMutableParagraphStyle()
//natural alignment: localization of app
paragraphStyle.alignment = .natural
//lines wrap at word breaks
paragraphStyle.lineBreakMode = .byWordWrapping
let textAttributes = [
NSAttributedString.Key.paragraphStyle: paragraphStyle,
NSAttributedString.Key.font: Constant.Font.pdfCreatorContent]
let attributedText = NSAttributedString(string: memo.content, attributes: textAttributes)
//consider multiple pages for one document, especially for long memo
let layoutManager = NSLayoutManager(), textStorage = NSTextStorage()
textStorage.append(attributedText)
textStorage.addLayoutManager(layoutManager)
var textContainerSize = CGSize(width: pageRect.width - Constant.Frame.pdfCreatorPaddingForContentWidth * 2, height: pageRect.height - Constant.Frame.pdfCreatorPaddingForContentHeight * 2)
var textContainer: NSTextContainer
var textViews = [UITextView]()
var startPoint = textTop
let startContainerSize = CGSize(width: pageRect.width - Constant.Frame.pdfCreatorPaddingForContentWidth * 2, height: pageRect.height - textTop - Constant.Frame.pdfCreatorPaddingForContentHeight * 2)
repeat {
if startPoint > 0 {
textContainerSize = startContainerSize
startPoint = 0
} else {
textContainerSize = CGSize(width: pageRect.width - Constant.Frame.pdfCreatorPaddingForContentWidth * 2, height: pageRect.height - Constant.Frame.pdfCreatorPaddingForContentHeight * 2)
}
textContainer = NSTextContainer(size: textContainerSize)
layoutManager.addTextContainer(textContainer)
let textView = UITextView(frame: CGRect(x: Constant.Frame.pdfCreatorPaddingForContentWidth, y: Constant.Frame.pdfCreatorPaddingForContentHeight, width: textContainerSize.width, height: textContainerSize.height), textContainer: textContainer)
textViews.append(textView)
} while layoutManager.textContainer(forGlyphAt: layoutManager.numberOfGlyphs - 1, effectiveRange: nil) == nil
var remainingPoint = textTop
//draw each textView
for textView in textViews {
if startPoint == 0 {
if remainingPoint + startContainerSize.height > pageRect.height - Constant.Frame.pdfCreatorPaddingForContentHeight {
remainingPoint = Constant.Frame.pdfCreatorPaddingForContentHeight
startPoint = 1
context.beginPage()
} else {
startPoint = 1
context.cgContext.translateBy(x: Constant.Frame.pdfCreatorPaddingForContentWidth, y: remainingPoint)
textView.textContainerInset = .init(top: 10, left: 10, bottom: 10, right: 10)
textView.layer.render(in: context.cgContext)
remainingPoint += startContainerSize.height
}
} else {
context.beginPage()
remainingPoint = Constant.Frame.pdfCreatorPaddingForContentHeight
}
context.cgContext.translateBy(x: Constant.Frame.pdfCreatorPaddingForContentWidth, y: remainingPoint)
textView.textContainerInset = .init(top: Constant.Frame.pdfCreatorPaddingForContentInset, left: Constant.Frame.pdfCreatorPaddingForContentInset, bottom: Constant.Frame.pdfCreatorPaddingForContentInset, right: Constant.Frame.pdfCreatorPaddingForContentInset)
textView.layer.render(in: context.cgContext)
remainingPoint += textContainerSize.height
}
}
참고) WWDC17: Intoducing PDFKit on iOS
단순하게 cell item 하나를 선택하는 상황에서는 선택된 item에서 didSelect가, 이전에 선택된 item에서는 didDeselect가 자동으로 호출이 된다.
PDF 생성이나 여러 item을 한 번에 삭제를 하기 위해선 collectionView의 allowsMultipleSelection
property가 true인 상황에서 활용을 했어야 했다.
이 경우, cell의 isSelected property가 자동으로 update되지 않고 각 cell이 선택되는 시나리오에 따라 결과값이 매우 달라졌다.
시나리오 1
1-1) cell A 탭, pushViewController 호출: A's isSelected = true
1-2) popViewController 수행 후 allowsMultipleSelection이 true인 상황에서 cell A 다시 탭: A's isSelected = false
- 예상: A 선택하기
- 실제: A 선택되지 않음
시나리오 2
2-1) cell A 탭, pushViewController 호출:
A's isSelected = true
2-2) popViewController 수행 후 allowsMultipleSelection이 true인 상황에서 cell B 탭:
A's isSelected = true, B's isSelected = true
- 예상: A 선택되지 않고 B만 선택
- 실제: A와 B 둘다 선택
시나리오 3
3-1) cell A 탭, pushViewController 호출: A's isSelected = true
3-2) popViewController 수행 후 allowsMultipleSelection이 true인 상황에서 cell B와 C 탭: A's isSelected = true, B's isSelected = true, C's isSelected = true
3-3) B와 C 삭제 위해 isSelected = true인 item 삭제
- 예상: B와 C 삭제
- 실제: A, B, C 모두 삭제
따라서 isSelected만 바라보지 말고 어떤 시점에 원하는 작업이 될 수 있을지를 고려해서 시나리오를 작성해보니 다음과 같았다.
- didSelect가 호출될 시점: isSelected = true인 상황
- 선택하고 싶은 item이 isSelected = true이면 데이터 목록에 추가
- 해제하려는 item이 isSelected = true이면 데이터 목록에서 제거
- didDeselect가 호출될 시점: isSelected = false인 상황
- 선택하고 싶은 item이 isSelected = false이면 데이터 목록에 추가
- 해제하려는 item이 isSelected = false이면 데이터 목록에서 제거
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else {
//선택할 수 없음 알리기
showAlert(title: JournalRealmSetupValues.journalSelectionFailure, message: JournalRealmSetupValues.noJournalToBeSelected)
return
}
let cell = journalCollectionView.cellForItem(at: indexPath) as! BookMarkedNewsCell
if collectionView.allowsMultipleSelection {
if cell.isSelected {
journalVM.insertSelectedJournal(indexPath: indexPath, selected: item)
toggleJournalCheckMark(indexPath: indexPath)
} else {
if journalVM.removeSelectedJournal(indexPath: indexPath, selected: item) {
toggleJournalCheckMark(indexPath: indexPath)
} else {
journalVM.realmErrorMessage.value = JournalRealmSetupValues.noJournalToBeDeselected
}
}
}
}
}
func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
if collectionView.allowsMultipleSelection {
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else {
//선택 해제할 수 없음 알리기
showAlert(title: "", message: JournalRealmSetupValues.noJournalToBeDeselected)
return
}
let cell = journalCollectionView.cellForItem(at: indexPath) as! BookMarkedNewsCell
if !cell.isSelected {
journalVM.insertSelectedJournal(indexPath: indexPath, selected: item)
toggleJournalCheckMark(indexPath: indexPath)
} else {
if journalVM.removeSelectedJournal(indexPath: indexPath, selected: item) {
toggleJournalCheckMark(indexPath: indexPath)
} else {
journalVM.realmErrorMessage.value = JournalRealmSetupValues.noJournalToBeDeselected
}
}
}
}
realm에 저장되는 DB Table의 record들은 저장과 동시에 실시간으로 update가 되어서 다른 ViewController에서 realm에 접근하면 저장된 record를 확인할 수 있다. 반면, 삭제된 record에 접근하면 런타임 에러를 불러 일으킨다.
DiffableDataSource의 snapshot은 데이터 변화 이전과 이후 시점의 data를 각각 비교해서 그 차이만큼 애니메이션과 더불어 collectionView를 다시 그린다.
따라서 realm의 record를 데이터로 활용하는 collectionView의 diffableDataSource는 snapshot apply를 위해 data 비교를 하고, 이를 위해 이미 삭제된 realm data에 접근하게 되어 런타임 에러를 일으킨다.
이를 해결하기 위해선 다음의 과정을 거쳐야 한다.
- 삭제하려는 record를 제외한 데이터를 fetch
- snapshot update
- realm에서 해당 record 삭제
realm의 data structure를 직접 활용할 때의 해결책으로 이는 상황을 100% 해결해주지 않는다. 만약 realm DB table에 column이 추가될 경우, 그에 대한 schema update와 더불어 활용하는 data 구조를 다시 구성해줘야 하는 유지보수 문제가 남아있다.
근본적인 해결책은 realm에 실제로 저장 혹은 삭제될 때만 실제 realm structure로 저장하고 그 외에는 임시의 데이터 구조를 활용하며 collectionView의 snapshot update에 바로 활용될 수 있는 방법을 고려할 수 있다.
국내 기사 홈페이지의 상당수가 아직까지 http 프로토콜만 준수하며 해외 기사 API로 활용한 Media Stack의 경우, 결제 계정이 아니면 https 보안을 제공해주지 않았다.
아이폰은 보안을 이유로 기본 정책으로 http 접근을 차단한다. 어쩔 수 없이 info.plist에서 NSAppTransportSecurity
에서 NSAllowArbitraryLoads
를 true로 설정해야 했다.
이 경우, 앱 심사에서 왜 허가를 했는지 설명이 필요했으므로 심사 제출시의 메모에 이유를 작성했다.
MVVM 패턴을 활용하면서 이런 의문이 들었다.
error를 매번 ViewController까지 throw해서 ViewController에서 처리해야 할까?
당연하게 계좌 이체나 주식 및 코인 매수/매도, 상품 결제와 같이 매출과 연관된 하나의 transaction 처리의 중요도가 높은 작업은 ViewController까지의 error throw와 그에 대한 handling이 중요하다.
이 앱에서 대부분의 에러는 통신 에러 및 저널 저장, 삭제 에러로 유저에게 다시 시도해달라고 하는 알림을 주어서 다시 시도하면 대부분 해결이 되었다.
저널을 직접적으로 삭제하는 경우에만 한번 더 확인하는 용도로 UIAlert를 띄웠으며 그 외에는 toast message로 작업 결과를 알려주기로 설정했다.
따라서 알림에 대한 message를 구독하는 Observable 역할의 errorMessage에게 해당 에러의 에러 메시지를 전달하면 UIAlert나 오픈 소스를 활용한 토스트 메시지로 해당 에러를 알려주면 될 것이다.
//ViewModel
var realmErrorMessage: Observable<String> = Observable("")
//ViewControlelr
viewModel.realmErrorMessage.bind { message in
self.showErrorToastMessage(message: message)
}
dynamic linker가 UIKit framework 관련 함수/변수 연결에 실패했다고 알리며 코드 시작전부터 에러가 나타났다.
dyld[29641]: Symbol not found: _$sSo8UIActionC5UIKitE5title8subtitle5image10identifier20discoverabilityTitle10attributes5state7handlerABSS_SSSgSo7UIImageCSgSo0A10IdentifieraSgALSo23UIMenuElementAttributesVSo0nO5StateVyABctcfc
실패한 시도들은 다음과 같다.
결국 프로젝트 재생성 후, 작동하는 github의 검증 코드를 기반으로 1:1 비교하며 다시 작성하였다.
원인 추측으로는 Custom UIAction을 정의해서 활용하려 했으나 구현 과정에서 사용하지 않게 되었고 이것이 에러를 일으킨 것으로 보인다. 이를 제거하자 UIKit framework 연결이 원활하게 되면서 프로젝트 실행에 문제가 없었다.
기존 query와 Http Header에 들어갈 값들을 일일히 작성해서 넣기보다 외부에서 설정하지 않아도 내부에서 처리할 수 있도록 Alamofire의 URLRequestConvertible
를 활용, Router pattern으로 구현했다.
이렇게 하면 API 통신에 필요한 요소들을 다른 Manager나 ViewModel에 노출할 필요 없이 private으로 내부적으로 처리할 수 있다는 장점과 더불어 호출하는 입장에서도 parameter로 필요한 정보 요소들을 직접 전달할 필요가 없다는 장점이 있다.
또한 차후 활용하는 API에서 사용하는 메서드 외에 다른 메서드를 사용할 때도 Router에 case 추가만 해주면 되고, API 필수 요청값 변경점도 Router에서 수정만 하면 된다는 유지 보수 측면의 장점이 있다.
//NewsAPI Router 예시
enum NewsAPIRouter: URLRequestConvertible {
//검색 기능 활용
case newsSearch(query: String, page: Int, sortBy: NewsAPISearchSortType)
//HTTP Header 설정
private var headers: HTTPHeaders {
switch self {
case .newsSearch(query: _, page: _, sortBy: _):
return [NetworkSetupValues.newsAPIKeyHeader: APIKey.newsAPIAccessKey]
}
}
//HTTP method 설정
private var method: HTTPMethod {
return .get
}
//baseURL: API따라 달라지기 전의 공통 url 반환 가능
private var baseURL: URL? {
switch self {
case .newsSearch:
if let url = URL(string: NetworkSetupValues.newsAPIBaseURL) {
return url
}
return nil
}
}
//각 case마다 달라지는 url 지점
private var path: String {
switch self {
case .newsSearch:
return "everything"
}
}
//HTTP query 전달
private var query: [String: String] {
switch self {
case .newsSearch(query: let q, page: let page, sortBy: let sortType):
return ["q": q, "language": NetworkSetupValues.newsAPILanguages, "sortBy": sortType.rawValue, "pageSize": "\(Constant.APISetup.newsAPIPageSize)", "page": "\(page)"]
}
}
func asURLRequest() throws -> URLRequest {
guard let url = baseURL?.appendingPathComponent(path) else {
throw NewsAPINetworkError.sourceDoesNotExist
}
var request = URLRequest(url: url)
request.method = method
request.headers = headers
do {
//HTTP method 따라 query가 어디에 위치할 지 결정
//get으로 설정했으므로 query 위치로
request = try URLEncodedFormParameterEncoder(destination: .methodDependent).encode(query, into: request)
return request
} catch {
throw NewsAPINetworkError.parameterInvalid
}
}
}
초기 구현했던 기능들은 활용 가능하지만 유저 입장에서 어떻게 쉽게 활용할 수 있을지에 대한 고려가 부족했다. 또한 기본 제공되는 기사 없이 바로 검색 화면이 나타나면 유저 입장에서 어떤 것을 검색할 지 몰라서 사용을 꺼릴 수 있다는 의견을 받았다.
개선 포인트를 정리하니 다음과 같았다.
- "오늘의 기사"처럼 기본 기사를 제공해서 큰 어려움 없이 기사만 볼 수 있도록 시작해야 함
- 해외 기사도 같이 제공해서 컨텐츠 양이 늘어나야 함
- 기사에 대한 저널을 굳이 입력하지 않아도 나중에 입력할 수 있도록 북마크 기능으로 저장
- 기사를 읽고 저널을 저장하면 해당 기사는 저널을 작성했다는 표시 따로 해야 함
- 최근 검색어 제공, 자주 검색한 기준으로 sort해서 제공하기
- 기사 cell 디자인 변경 필요
타사 앱들은 왜 이렇게 만들었을까에 대한 고민을 할 때 내 앱에 대한 사용성 향상을 고려할 수 있으므로 "무슨 의도로 이렇게 동작하도록 했을까?"에 대한 고민이 필요함을 인지했다.
남은 기간 동안 피드백을 반영한 기능 구현 중도에 (10/16~19) 예비군 일정으로 시간 활용에 차질이 있었다. 일정 이후 코드 흐름에 다시 집중하는데 어려움이 있었지만 최소한 저녁마다 작은 UI 수정을 하며 흐름이 끊기지 않도록 노력했다.
피드백 이전에는 이런 방식으로 저널을 관리하도록 했다.
- 기사를 검색
- 해당 기사에 저널을 입력 & 저장
- 저널 리스트 탭에서 입력한 저널 확인
- 저널을 여럿 선택해서 삭제할 수도 혹은 PDF 문서로 추출
하지만 북마크 기능을 추가해서 중간 단계를 제공하자 시나리오를 변경해야 했다.
- 검색 or 기본 기사 cell 나타남
- 유저는 북마크만 할 수 있음 or 유저는 해당 기사를 보고 저널을 저장
- 저널 리스트 탭에서 북마크된 기사와 저널까지 입력된 기사 확인 가능
- 기본 기사 탭, 검색 탭, 저널 리스트 탭에서 북마크 버튼으로 기사를 제거 가능
(저널 입력된 기사의 경우, UIAlert로 저널도 삭제됨을 알리기)- 삭제를 위해 저널을 여럿 선택할 경우, 북마크만 기사 및 저널 입력한 기사 모두 선택 가능
- PDF 추출을 위해 저널을 여럿 선택할 경우, 저널 입력한 기사만 나타나게 해서 선택하도록 함
해외 기사를 가져오기 위해 추가적으로 API를 2개 더 활용하면서 뉴스 기사 관련 3개를 활용하게 되었다. 여러 API 활용하면서 각 API가 전달하는 데이터 구조도 다르기에 직접 활용 시, 사용 가능한 정보와 없는 정보 관리가 너무 복잡해지는 문제가 존재했다. 여기에 API에서 제공하는 값의 변경 시, 해당 구조의 data를 활용하는 모든 View에서 수정이 필수인데 이는 유지 보수 난도롤 높이는 측면이 존재했다.
이에 3가지 데이터를 통합하는 DTO 구조의 데이터 만들어서 모든 View에서 해당 구조의 데이터 활용하도록 구조 설계를 변경했다. 마치 데이터 레이어를 하나 더 설정하는 것처럼 앱 내부에서는 외부 API의 구조가 어떻게 되는 상관없이 DTO 구조만 가지고 각종 데이터 가공과 수정을 할 수 있었다.
이를 위해 각 자료구조마다 createDTONews 메서드를 구성해서 DTO 구조의 데이터를 생성하도록 했다.
//Naver API
func createDTONews() -> DTONews {
return DTONews(title: htmlReducedTitle, urlLink: existingLink, description: htmlReducedDescription, pubDate: pubDateInFormat, imageURL: nil)
}
//Media Stack API
func createDTONews() -> DTONews {
return DTONews(title: title, urlLink: url, description: description, pubDate: pubDateInFormat, imageURL: image)
}
기존의 태그 버튼은 다음과 같이 작동했다.
태그 선택: 해당 태그가 설정된 Journal만 나타나기
태그 해제: 전체 Journal 나타나기
사용자가 태그 동작 원리를 파악하고 활용하는 데에 러닝 커브가 높을 수 있다는 피드백에 따라 다음과 같이 변경했다.
"전체" Journal을 보여주는 tag button 추가
태그 선택: 해당 태그가 설정된 Journal만 나타나기
전체 태그 버튼을 눌러야 전체 Journal 보여주기
이를 위해 enum의 TagType의 case에 whole로 전체 타입을 추가해줬다.
실제 Journal을 입력하는 MemoViewController에서는 whole tag를 UIMenu에 추가하지 않았다.
오직 Journal list를 보여주는 JournalViewController에서만 whole case의 CustomTagButton을 추가하여 타입이 whole인 경우 전체 Journal을 fetch, 그 외의 태그는 해당 태그의 Journal Record만 fetch하도록 했다.
enum TagType: String, PersistableEnum {
case whole = "전체"
}
//JournalViewController
private let wholeButton = CustomTagButton(frame: .zero, type: .whole)
//JournalViewModel
func retrieveBookMarkedNewsWithTag() {
//none 버튼 존재하지 않음
if currentTagType.value != .whole {
if let news = repository.fetchWithTag(type: currentTagType.value) {
retrievedBookMarkedNews.value = news
isEmptyView.value = news.isEmpty ? true : false
} else {
//empty view
isEmptyView.value = true
}
} else {
retrieveJournals()
}
}
WWDC23에서 발표된 최신 사항으로 유저의 개인 정보 보호를 위해 UserDefaults API를 활용시에 보안 관련 안내 문구 작성이 필요하다는 내용이 존재했다. 세션 영샹에 따르면 Xcode 15부터 활용을 필수로 만들 계획이며 그 전까지는 정책적으로 권장하기로 언급이 되어있다.
앱 개발 당시 Xcode 14를 활용했으며 14에서는 Privacy Manifest 파일 생성이 불가능했다. 또한 당시 Xcode 15에서 에러가 많이 나타나던 시기이며 정책 사항은 실제 필수로 지정하기 전까지 변화가 계속해서 있을 수 있으므로 상황을 봐가면서 언제 필수로 적용하는지 확인해서 대응하기로 결정했다.
참고)WWDC23: Get Started With Privacy Manifest
북마크된 기사와 저널 기록에 따른 시나리오를 나눠서 저장되며 이에 따라 다른 tab에 속한 ViewController에 해당 Notification을 방출해야했다.
북마크로 등록한 경우에는 해당 기사 자체, DTONews data를 전달했고
저널 기록 및 북마크 삭제는 저장한 realm record 내 link property 값을 전달했다.
해당 기사가 이미 오늘의 기사나 검색 결과 내 존재 시, 해당 기사를 담은 cell에 bookmark 버튼과 저널 기록 이미지를 나타내도록 구현했다.
코드는 검색 탭에서의 Notification observer 메서드 구현 예시이다.
//Bookmark만 한 경우
@objc private func notificationRealmSavedObserverInSearch(notification: Notification) {
if let savedItem = notification.userInfo?[NotificationUserInfoName.dtoNewsToBeSavedInRealm] as? DTONews {
let indexPaths = newsSearchVM.checkIndexForDTONewsSavedInRealm(passedNews: savedItem)
if !indexPaths.isEmpty {
for indexPath in indexPaths {
let cell = linkPresentCollectionView.cellForItem(at: indexPath) as? NewsLinkPresentationCell
cell?.bookMarkButton.setSelected()
}
}
}
}
//Journal까지 기록한 경우
@objc private func notificationJournalSavedObserverInSearch(notification: Notification) {
if let savedItem = notification.userInfo?[NotificationUserInfoName.realmBookMarkedNewsLinkToBeSaved] as? String {
let indexPaths = newsSearchVM.checkIndexForBookMarkedNewsSavedInRealm(passedNewsLink: savedItem)
if !indexPaths.isEmpty {
for indexPath in indexPaths {
let cell = linkPresentCollectionView.cellForItem(at: indexPath) as? NewsLinkPresentationCell
cell?.bookMarkButton.setSelected()
cell?.showJournalWrittenImage()
}
}
}
}
//Bookmark 해제한 경우
@objc private func notificationRealmDeletedObserverInSearch(notification: Notification) {
if let deletedItem = notification.userInfo?[NotificationUserInfoName.realmBookMarkedNewsLinkToBeDeleted] as? [String] {
let indexPaths = newsSearchVM.checkDeletedBookmarkNewsInSearchNewsList(deleted: deletedItem)
if !indexPaths.isEmpty {
for indexPath in indexPaths {
let cell = linkPresentCollectionView.cellForItem(at: indexPath) as? NewsLinkPresentationCell
cell?.bookMarkButton.setUnselected()
cell?.hideJournalWrittenImage()
}
}
}
}
allowMultipleSelection가 true로 변화하면 유저는 여러 item을 선택 가능했지만, UI적으로는 아무 변화가 없어서 유저가 어떤 행동을 취해야 하는지 인지를 시켜줘야하는 피드백에 따라 다른 앱들의 예시를 찾아보았다.
대부분의 경우 선택 전에는 불투명 회색 뷰로 비활성화된 상태를, 선택 이후에는 회색 뷰를 제거해서 선택되었다는 인식을 주는 경우를 만들었다.
하지만 뷰의 색상 구성을 흰색, 버건디, 회색 계열로 잡아놓은 상태에서 다시 회색뷰를 또 활용하기엔 선택하라는 의미가 유저에게 제대로 와닿지 않을 것 같았다.
따라서 마치 iOS에서 앱 삭제 및 위치 변경 시 각 app icon이 흔들리는 듯한 animation처럼 비슷한 효과를 주도록 고려했다.
CAKeyframeAnimation을 활용하면 마치 영상 편집처럼 원하는 key frame의 길이마다 어떤 움직임을 보여줄 지 설정이 가능했다.
따라서 custom animation을 정의한 뒤, allowsMultipleSelection이 true일 때 각 cell layer에 해당 animation을 추가하고 false면 제거했다.
final class CustomWobbleAnimation: CAKeyframeAnimation {
override init() {
super.init()
configAnimation()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configAnimation() {
//어떤 애니메이션 설정할 지 key값
keyPath = WobbleAnimationSetupValues.keyPathString
//각 frame마다 설정할 값들
values = WobbleAnimationSetupValues.valuesArray
//해당 time마다 frame에 value 할당
keyTimes = WobbleAnimationSetupValues.keyTimesArray
duration = WobbleAnimationSetupValues.animationDuration
//현재 위치를 기준으로 할 것인지 설정
isAdditive = true
//Float type 최대값
repeatCount = Float.greatestFiniteMagnitude
}
}
//JournalViewController
private func startsWobbleAnimation() {
//wobble animation starts
journalCollectionView.indexPathsForVisibleItems.forEach { (indexPath) in
let cell = journalCollectionView.cellForItem(at: indexPath) as? BookMarkedNewsCell
cell?.layer.add(wobbleAnimation, forKey: JournalRealmSetupValues.bookmarkedCellWobbleAnimationKey)
}
}
private func stopsWobbleAnimation() {
//wobble animation done
journalCollectionView.indexPathsForVisibleItems.forEach { (indexPath) in
let cell = journalCollectionView.cellForItem(at: indexPath) as? BookMarkedNewsCell
cell?.layer.removeAllAnimations()
}
}
cell이 흔들리는 와중에 유저가 선택하면 체크 이미지가 나타나도록 설정했다.
일정 문제로 업데이트로 최근 검색어를 구현했어야 했지만, 미리 검색한 검색어를 저장해놓고 업데이트로 최근 검색어를 바로 보여주도록 했다.
검색어 Table의 primary key 구성 시, UUID나 Realm의 ObjectId를 활용하는 의미가 없어서 고민이 커진 와중, 검색어 중복을 막기 위해 검색어 자체를 primary key로 설정했다.
이렇게하면 각 검색어는 마치 Dictionary의 key와 같은 역할을 해, 동일 검색어가 저장될 여지를 제거했다.
또한 realm에서 검색어를 fetch할 때, 검색 횟수를 우선으로, 동일 횟수일 시 더 최근에 검색한 순서대로 나타나도록 sort를 할 예정이었다.
일반적인 sort를 연결해서 작성하면 먼저 설정한 검색 횟수 sort가 초기화되는 문제가 있으므로 SortDescriptor를 활용해 sorted 메서드에 parameter로 제공하도록 했다.
SortDescriptor는 keyPath와 order를 저장하는 구조체로 NSSortDescriptor를 query engine에서 더 빠르게 쓰기 위한 목적으로 활용한다.
func fetchUserSearchKeywords() -> Results<UserSearchKeyword>? {
guard let realm = realm else { return nil }
let sortProperties = [SortDescriptor(keyPath: RealmSetupValues.searchWordCount, ascending: false), SortDescriptor(keyPath: RealmSetupValues.lastTimeSearchedAt, ascending: false)]
return realm.objects(UserSearchKeyword.self).sorted(by: sortProperties)
}
이후 업데이트로 실제 저장된 검색어를 보여주기 위해 listConfiguration을 채택한 compositional collectionview layout을 활용했다.
마치 tableView와 같은 디자인을 보여주며 swipe action 구현도 가능했다.
var config = UICollectionLayoutListConfiguration(appearance: .insetGrouped)
config.headerMode = .supplementary
config.trailingSwipeActionsConfigurationProvider = { [weak self] indexPath in
guard let self = self, let keyword = self.diffableDataSourceForSearchKeywords.itemIdentifier(for: indexPath) else {
print("No keyword to be deleted")
return nil
}
let actionHandler: UIContextualAction.Handler = { action, view, completionHandler in
//update snapshot without that keyword
self.newsSearchVM.fetchKeywordWithoutWord(keyword: keyword)
//delete keyword data
self.newsSearchVM.deleteSearchWord(keyword: keyword)
completionHandler(true)
}
let action = UIContextualAction(style: .destructive, title: NewsSearchSetupValues.searchKeywordSwipeDeletionActionTitle, handler: actionHandler)
return UISwipeActionsConfiguration(actions: [action])
}
header 설정에서 조금 애를 먹었는데 datasource 메서드로 제공되는 일반적인 flowLayout과 달리, compositional layout에서는 따로 supplementary view 설정이 필요했다.
우선 ListConfiguration 설정에서 headerMode가 supplementary여야 하며 CellRegistration과 마찬가지로 SupplementaryRegistration으로 registration 설정 후, diffableDataSource에 해당 supplementaryViewProvider로 제공해야 한다.
let headerRegistration = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { supplementaryView, elementKind, indexPath in
var configuration = UIListContentConfiguration.groupedHeader()
configuration.text = NewsSearchSetupValues.headerTitle
supplementaryView.contentConfiguration = configuration
}
diffableDataSourceForSearchKeywords.supplementaryViewProvider = { collectionView, elementKind, indexPath in
collectionView.dequeueConfiguredReusableSupplementary(using: headerRegistration, for: indexPath)
}
피드백 이전 cell design에서는 LinkPresentation에서 LPLinkMetadata startFetch 메서드를 통해 이미지를 같이 가져와주었으므로 따로 image file caching에 대해 큰 고민이 없었다.
하지만 cell 디자인을 변경하면서 이미지 캐싱의 필요성 및 필요한 네트워크 횟수가 늘어나면서 이미지를 가져오는 순서 시나리오 설정이 필요했다.
- FileManager를 활용, 디스크에 해당 이미지 파일이 존재하면 fetch해서 cell에 띄워주기
- 존재하지 않을 경우 DTONews 구조에서 imageUrl string value가 존재하면 kingfisher library 활용, 이미지 파일 imageView에 나타내고 디스크에 저장하기
- image 링크가 존재하지 않으면 메모리에 캐싱된 LPLinkMetadata가 존재하는지 확인
3-a) metadata가 존재하면 LinkPresentation의 itemProvider를 활용해서 이미지를 가져오기, 가져올 수 없다면 default image asset을 활용하기
3-b) metadata가 존재하지 않으면 기사 link에서 LPLinkMetadata를 가져오기 (메서드: startFetch)
--> metadata fetch에 성공하면 3-a)를 다시 시도, fetch조차 할 수 없다면 default image asset을 활용하기
//예시 코드는 검색 탭에서 결과로 나오는 기사 cell
private func populateWithPassedData(news: DTONews) {
//1. Disk에 image 존재 확인
if let imageData = newsLinkPresentationVM.checkImageInDocuments(), let image = UIImage(data: imageData) {
setupLinkView(news: news, image: image)
return
}
//2. news에서 imagelink 존재 시 kingfisher 활용 이미지 가져오기
if let imageURL = news.imageURL, let url = URL(string: imageURL) {
newsImageView.kf.setImage(with: url) { _ in
//디스크에 이미지 저장하기
guard let image = self.newsImageView.image else { return }
self.newsLinkPresentationVM.saveImageIntoDocuments(image: image)
return
}
}
//3. 존재하지 않으면 cache에 metadata 존재 체크
if let metaData = newsLinkPresentationVM.checkMetadataInMemoryCache() {
//4-1. 존재하면 loadObject로 image 받아오기
newsLinkPresentationVM.retrieveImageFromLink(metaData: metaData) { image in
if let image = image {
//5-1. image 저장하고 cell 나타내기
self.newsLinkPresentationVM.saveImageIntoDocuments(image: image)
self.setupLinkView(news: news, image: image)
} else {
self.setupInvalidLinkPresentation(news: news)
}
}
} else {
//4-2. 존재하지 않으면 startFetch하기
newsLinkPresentationVM.fetchMediaNewsMetaData(url: news.urlLink) { result in
switch result {
case .success(let data):
//5-2-1. metaData 존재: memoryCache에 metadata 저장
self.newsLinkPresentationVM.saveMetadataIntoMemory(data: data)
//6-2-1. loadObject로 image 받아오기
self.newsLinkPresentationVM.retrieveImageFromLink(metaData: data) { image in
if let image = image {
//7-2-1. image 저장하고 cell 나타내기
self.newsLinkPresentationVM.saveImageIntoDocuments(image: image)
self.setupLinkView(news: news, image: image)
} else {
self.setupInvalidLinkPresentation(news: news)
}
}
case .failure(_):
//5-2-2. metaData 존재 X: default 이미지로 cell 구성하기
self.setupInvalidLinkPresentation(news: news)
}
}
}
}
이 과정에서 임시 LPLinkMetadata를 임시로 생성하는 과정은 제거했다. 원본 source link에서 LPLinkMetadata를 fetch하지 못하면 네트워크 API 결과물인 기사 데이터로 임시 LPLinkMetadata를 생성해도 결국 이미지를 fetch할 수 없기 때문이다. 이로 인해 임시 LPLinkMetadata를 다시 imageProvider로 네트워크 통신할 과정이 필요 없으므로 cell에서 네트워크 통신 처리 속도가 더 빨라졌다.
원래의 상세 검색 옵션 뷰는 확인과 초기화 버튼만 존재했고 직접 유저가 확인을 눌러야 옵션 뷰가 다시 내려가는 방식으로 작동했다.
하지만 옵션 선택을 확정하기 위한 액션으로 확인 버튼의 역할을 한정하고 그 외에는 설정하다 언제든지 닫을 수 있도록 닫기 버튼을 상단에 구현했다. 이를 위해 임시로 옵션 설정값을 가지고 있다가 확인 버튼이 눌리면 임시 옵션값을 실제 옵션값에 할당하도록 저장 방식을 변경해야 했다.
옵션을 변경해도 닫기 버튼을 누르면 | 이전 옵션 설정값을 보게 된다 |
처음에는 저널만 관리했기에 유저가 알아서 설정하도록 구현했고 이로 인해 저널을 입력했어도 태그 관련 UIMenu
는 .none
타입이 디폴트로 설정되어 나타났다.
이제는 북마크된 기사와 더불어 저널까지 한 곳에서 관리하므로 저널을 기록한 유저에게 편의를 제공하며 둘을 구분하기 위해서라도 태그 정보를 띄우기 위해 기본 UIMenu 버튼에서 저장된 TagType이 0번째 Menu로 나타나도록 swapAt 메서드를 활용했다.
private func setupSavedFirstTagButton(_ savedFirst: TagType) {
var menu = [firstNone, firstPolitics, firstEconomics, firstArt, firstEntertainment, firstScience, firstTechnology, firstHealth, firstLifestyle, firstSports, firstWorld]
switch savedFirst {
case .whole:
break
case .politics:
menu.swapAt(0, 1)
case .economy:
menu.swapAt(0, 2)
case .art:
menu.swapAt(0, 3)
case .entertainment:
menu.swapAt(0, 4)
case .science:
menu.swapAt(0, 5)
case .technology:
menu.swapAt(0, 6)
case .health:
menu.swapAt(0, 7)
case .lifestyle:
menu.swapAt(0, 8)
case .sports:
menu.swapAt(0, 9)
case .world:
menu.swapAt(0, 10)
case .none:
break
}
firstTagButton.menu = UIMenu(title: "", options: .singleSelection, children: menu)
}
navigationController로 pushViewController 메서드를 호출하면 기본으로 주어지는 backButton을 custom backbutton으로 대체했으므로 swipe action이 기본으로 작동하지 않게 되었다.
이를 위해 UISwipeGestureRecognizer를 활용, 오른쪽으로 swipe해서 왼쪽으로 pop하는 custom swipeAction을 따로 구성했다.
lazy private var swipeGestureRecognizer = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeToBack))
@objc private func handleSwipeToBack(gesture: UISwipeGestureRecognizer) {
if gesture.direction == .right {
self.navigationController?.popViewController(animated: true)
}
}
UISheetPresentation에서 높이 설정을 기본 제공되는 medium과 large로만 설정해서 최소 높이가 medium으로 설정되도록 했다.
문제는 medium이 전체 화면의 절반을 차지하는 높이기에 기사를 보려면 SheetPresentation을 매번 내려야하는 불편함이 존재했다. 팀원들또한 detent가 더 낮아서 기사를 보는데 불편함이 없었으면 한다는 중복되는 의견을 제시해줬고, custom detent로 전체 view의 0.3 정도 높이로 설정하자 좀 더 쾌적하고 기사를 읽을 수 있었다.
하지만 이 custom detent 설정이 iOS 16부터 가능했기에 iPhone 6S를 비롯한 최대 버전이 15인 기기에서는 medium이 최선의 대응이었다는 점이 아쉬웠다.
let nav = UINavigationController(rootViewController: memoVC)
if #available(iOS 16.0, *) {
let detentIdentifier = UISheetPresentationController.Detent.Identifier(WebViewSetupValues.sheetPresentationDetentIdentifier)
let customDetent = UISheetPresentationController.Detent.custom(identifier: detentIdentifier) { _ in
return self.view.safeAreaLayoutGuide.layoutFrame.height * Constant.Frame.sheetPresentationDetentHeightMultiply
}
if let sheetPresentationController = nav.sheetPresentationController {
sheetPresentationController.detents = [.large(), customDetent ]
sheetPresentationController.largestUndimmedDetentIdentifier = customDetent.identifier
sheetPresentationController.prefersGrabberVisible = true
sheetPresentationController.prefersScrollingExpandsWhenScrolledToEdge = false
}
present(nav, animated: true)
} else {
if let sheetPresentationController = nav.sheetPresentationController {
sheetPresentationController.detents = [.medium(), .large()]
sheetPresentationController.largestUndimmedDetentIdentifier = .medium
sheetPresentationController.prefersGrabberVisible = true
sheetPresentationController.prefersScrollingExpandsWhenScrolledToEdge = false
}
present(nav, animated: true)
}
임시 LPLinkMetadata를 생성하지 않고 image fetch 과정을 줄였음에도 특히 "오늘의 기사"를 가져오는 시간까지 로딩이 길다는 느낌이 들었다. 마치 거울 없는 엘리베이터마냥 결과물이 오기까지 유저에게 아무런 정보없이 무작정 대기해야 했기 때문이었다.
skeletonView library는 최상위 component에서 skeletonView 메서드를 실행하면 반복문을 돌며 하위 subView에서 isSkeletonable이 true인 view들만 메서드를 적용한다. 따라서 각 cell에서 image fetch를 시작할 때 cell에서 skeletonview animation을 시작하고 fetch가 완료하면 animation을 hide했다.
private func populateWithPassedData(news: DTONews) {
activateSkeletonAnimation()
//...작업 완료...
self.setupLinkView(news: news, image: image)
//image fetch 실패
self.setupInvalidLinkPresentation(news: news)
}
private func setupLinkView(news: DTONews, image: UIImage) {
DispatchQueue.main.asyncAfter(deadline: Constant.TimeDelay.skeletonDispatchAsyncAfter) {
self.hideSkeleton()
self.newsImageView.image = image
self.setupBasicNewsData(news: news)
}
}
private func setupInvalidLinkPresentation(news: DTONews) {
DispatchQueue.main.asyncAfter(deadline: Constant.TimeDelay.skeletonDispatchAsyncAfter) {
self.hideSkeleton()
self.newsImageView.image = UIImage(named: Constant.ImageString.notAvailable)
self.setupBasicNewsData(news: news)
}
}
그럼에도 skeletonView animation을 실행하기전에 먼저 네트워크 API 통신을 해서 기사 url을 가져와야 하므로 그 사이에 빈 공간동안 유저가 어떤 상태인지 인지하기 어렵다고 느꼈다. 따라서 emptyView를 구성하고 기사를 가져오는 중이라고 알려주면 유저 입장에선 skeletonView animation이 시작하기 전까지 공백동안 여유롭게 기다려 줄 수 있을 것이다.
emptyViewBeforeFetchingDefaultNews | skeletonView |
JDStatusBarNotification, Tabman 및 SkeletonView의 경우, 내부 작동 방식까지는 이해를 했지만 내부 작동 코드 원리는 아직 보지 못하고 우선 활용했으므로 내부 코드를 보면서 커스텀 요소가 있는지, 업데이트가 멈춰진 라이브러리는 향후 문제 요소가 없는지 살펴봐야 할 예정이다.
기간에 쫓겨서 아직 다 구현하지 못한 기능들은 다음과 같다.
기능 추가에 시간이 걸리겠지만 업데이트 주기를 가지고 정기적으로 추가할 계획이다.
코드 분리 및 모듈화가 프로젝트의 첫 목표였던만큼 향후 학습을 통해 combine, Coordinator 패턴 및 Clean Architecture까지 활용해보며 추상화 작업을 계속해서 진행할 예정이다.