iOS ) 투린더 v1 정리
🎯 프로젝트 소개
🔥 기획 및 일정 + tmi
엑셀로 관리하는 매매일지를 캘린더 형식으로 관리하도록 구성하였습니다.
네이밍은 투자 + 캘린더를 합친 정말 안일한 네이밍인, 투린더로 정했습니다.
플레이스토어에는 있지만 앱스토어에는 없어서 만들만 하다고 생각했습니다.
개발 일정은 한달이 소요되었습니다. 아마도 백엔드 연결은 좀 나중이 되지 않을까 싶습니다. 아직 백엔드 연결할 요소들을 생각중에 있습니다.
처음 iOS 공부하고 개발을 시작했는데, 입문이 쉽지는 않은 것 같습니다. SwiftUI는 좀 더 쉽다고 하는데, 기본적으로 UIKit 기반이여서 UIKit으로 시작했습니다. 아직 리펙토링이 부족한 것 같습니다. 귀찮아도 리펙토링 하려고 노력을 했지만, 넘어간 부분도 많아서 다음에는 리펙토링을 깔끔하게 해보고 싶습니다. 또 테스트코드 작성을 안해서 다음에는 이부분도 해보고 싶습니다.
🔥 개발 환경
xcode 13을 사용했습니다.
UIKit, RxSwift, RxCocoa, Then, Realm, Charts 를 사용했습니다.
💻 개발 정리
🔥 시작 전 정리
✏️ Storyboard 없이 개발하는 법
UIKit이 처음이지만, 스토리보드를 삭제하고 개발을 시작했습니다. 검색할때 팁은 Programmatically 만 붙이면 구글링이 쉬워집니다.
✏️ MVC 버리고 MVVM 적용하는 법
MVC의 단점은 컨트롤러가 무거워진다이지만, MVVM의 단점은 설계가 어렵다 입니다. MVVM 패턴을 적용시킨 결과 생각이 좀 더 가벼워져서 정신건강에 이롭다는 결론을 내었습니다.
저의 현재 프로젝트 구조입니다.
- M
- Entity
- Repository
- Service
- Model
- V
- View
- ViewController
- VM
- ViewModel
결국 한 화면을 구성하는 ViewController에 UI에 해당하는 모든 내용을 집어넣을 수 있지만, 재사용성을 높이기 위해 반복적인 View들을 분리하여 관리하였습니다.
ViewModel은 ViewController 하나당 1대1 대응이 되도록 생성해주었습니다. 결론적으로, 한 화면을 구성하는 건 ViewController와 ViewModel 입니다.
Entity는 원본 데이터를 그대로 파싱한 Class이고 이를 화면에서 사용하는 Model로 구성하였습니다. Model은 Struct로 선언하였습니다.
Repository는 Entity를 직접 건드립니다. 보통의 경우에는 서버에서 fetching해오는 코드가 여기에 작성되지만, 저는 Realm 데이터를 받아오는 코드를 여기에 작성하였습니다.
Service는 Repository를 통해 Entity를 받아와서 Model로 파싱해 줍니다. 실제 사용하는 Model과 Entity는 충분히 다를 수 있으며, 이를 파싱해주는 코드를 Service에서 작성하였습니다.
ViewModel은 최종적으로 Model을 가져오는 Service를 사용하여 Model을 받아오고 이를 UI에 대응 시킬 준비를 합니다.
✏️ Autolayout 도움이 되는 사이트
https://www.wtfautolayout.com/
위 사이트는 저에게 정말 도움이 되었습니다.
autolayout이 에러가 났을때 보면 도움되는 사이트입니다.
저의 경우에는 대부분 부모 컴포넌트가 아직 자리를 잡지 못했는데 자식 컴포넌트가 부모의 autolayout에 연결하려다 보니 오류가 발생했습니다.
🔥 UIKit 관련 정리
✏️ 캘린더를 직접 구현해보자
코드 설명 이전에 제가 구현한 방식을 소개하겠습니다 : D
UICollectionView는 Cell들이 화면을 분배해서 가져가는데, 가로줄이 가득차면 다음줄로 Cell들이 차게되는데 이것을 이용해서 캘린더를 구현하였습니다.
컬렉션 뷰의 레이아웃을 잡아놓고, Cell들을 채워넣어주었습니다. Cell의 가로는 일-월-화-수-목-금-토 7일 이기 때문에 컬렉션 뷰 가로를 7로 나누어준 값으로 설정했습니다. Cell의 세로는 5주로 생각을 하여 5로 나누어 주었습니다.
4월은 30개의 일이 있으니깐, Cell이 30개가 필요합니다. 각 달마다 해당 일 수를 얻을 수 있는 함수를 작성해서 Cell의 개수로 사용했습니다. month가 4이라면 30을 리턴하고, 5라면 31을 리턴하는 식입니다.
Cell을 터치하면 발생하는 이벤트를 처리해야 했습니다. 여러 방식이 있지만, RxCocoa로 구현을 하였습니다.
CalendarViewController
var calendarView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
.then {
$0.register(CalendarViewCell.self, forCellWithReuseIdentifier: CalendarViewCell.identifier)
}
뷰 컨트롤러에서 컬렉션 뷰 선언 부분입니다.
calendarView.rx.setDelegate(self)
.disposed(by: disposeBag)
viewModel.output.cellDatas
.bind(to : calendarView.rx.items(cellIdentifier: CalendarViewCell.identifier, cellType: CalendarViewCell.self)) {
index, cellData, cell in
cell.update(date: cellData.0, records: cellData.1)
}
.disposed(by: disposeBag)
바인딩 부분입니다.
캘린더 뷰의 델리게이트를 설정해주는 코드와, rx를 사용해 cell의 개수를 생성해주는 코드를 섞어서 작성하였습니다.
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let width = calendarView.frame.width / 7
let height = calendarView.frame.height / 5
return CGSize(width: width, height: height)
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
return .zero
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return .zero
}
델리게이트 부분인데, cell의 높이와 너비를 설정하고 간격을 설정해주었습니다. 이부분은 rx으로 충분히 대체 되는 것으로 알고있습니다. 이렇게 섞어서도 사용이 가능합니다.
calendarView
.rx
.itemSelected
.subscribe(onNext: { [weak self] indexPath in
let cell = self?.calendarView.cellForItem(at: indexPath) as! CalendarViewCell
self?.viewModel.input.cellData.onNext((cell.date, cell.records))
// 같은 것
if self?.viewModel.input.indexPath.value == indexPath {
self?.viewModel.input.indexPath.accept(IndexPath())
self?.showCalendarView()
}
// 다른 것
else {
self?.showHighlightCell(indexPath: indexPath)
self?.viewModel.input.indexPath.accept(indexPath)
self?.hideCalendarView()
}
})
.disposed(by: disposeBag)
셀이 클릭되면 이벤트 처리를 rx로 해주었습니다.
✏️ UIStackView 버리고 UITableView로 리스트 구현하는 법
UIStackView는 리스트 처럼 보이게 할 수 있지만, 각 요소의 클릭 이벤트를 구성할 수 없습니다.
var recordListView = UITableView()
.then {
$0.register(RecordListViewCell.self, forCellReuseIdentifier: RecordListViewCell.identifier)
$0.rowHeight = 25
$0.backgroundColor = .systemGray6
$0.separatorStyle = .none
}
우선 테이블 뷰를 선언해 주었습니다.
viewModel.output.cellRecords
.bind(to: recordListView.rx.items) {
(tableView: UITableView,
index: Int,
element: Record)
-> UITableViewCell in
guard let cell = tableView.dequeueReusableCell(withIdentifier: RecordListViewCell.identifier) as? RecordListViewCell else { fatalError() }
cell.update(record: element)
cell.selectionStyle = .none
return cell
}
.disposed(by: disposeBag)
이부분은 rx로 처리한 부분인데 cell의 개수를 정하고 cell을 업데이트 시켜주는 부분입니다.
전체적인 내용은 collectionView와 tableView 차이가 없습니다.
recordListView.rx.itemSelected
.withUnretained(self)
.bind { owner, indexPath in
let cell = owner.recordListView.cellForRow(at: indexPath) as! RecordListViewCell
let writingVC = WritingViewController()
writingVC.modalPresentationStyle = .fullScreen
writingVC.update(record: cell.record)
self.present(writingVC, animated: true)
}
.disposed(by: disposeBag)
셀이 클릭되면 이벤트 처리를 rx로 하였습니다. 해당 코드에서는 새로운 뷰 컨트롤러로 이동하는 코드를 작성하였습니다. 뷰 컨트롤러 간의 데이터 전달은 직접 전달을 사용하였습니다.
✏️ Animation 구현하기
Animation을 구현하는 것은 애니메이션되는 컴포넌트가 존재하는 Class 내부에서 함수를 만들어주었습니다.
collectionView안에 cell이 있고, cell 안에 item이 있다면 collectionView의 애니메이션을 구현할 때 cell의 크기는 자동적으로 애니메이션 구현이 되지만 cell 안에 있는 item은 애니메이션을 직접 구현해야합니다.
// item
func show() {
ContentViewHeightConstraint.constant = 12
UIView.animate(withDuration: 0.25, animations: {
self.ContentViewHeightConstraint.isActive = true
self.layoutIfNeeded()
})
}
func hide() {
ContentViewHeightConstraint.constant = 0
UIView.animate(withDuration: 0.25, animations: {
self.ContentViewHeightConstraint.isActive = true
self.layoutIfNeeded()
})
}
item의 애니메이션 코드 입니다.
// Cell
func show() {
tagViews.forEach { $0.show() }
UIView.animate(withDuration: 0.25, animations: {
self.divider.alpha = 1
self.layoutIfNeeded()
})
}
func hide() {
tagViews.forEach { $0.hide() }
UIView.animate(withDuration: 0.25, animations: {
self.divider.alpha = 0
self.layoutIfNeeded()
})
}
cell의 애니메이션 코드 입니다.
// collectionView
func showCalendarView() {
calendarViewBottomConstraint.constant = 0
UIView.animate(withDuration: 0.25, animations: {
self.calendarViewBottomConstraint.isActive = true
self.bottomSheet.alpha = 0
self.view.layoutIfNeeded()
self.calendarView.performBatchUpdates(nil)
})
for CalendarViewCell in calendarView.visibleCells {
let cell = CalendarViewCell as! CalendarViewCell
cell.show()
}
}
func hideCalendarView() {
calendarViewBottomConstraint.constant = -500
UIView.animate(withDuration: 0.25, animations: {
self.calendarViewBottomConstraint.isActive = true
self.bottomSheet.alpha = 1
self.view.layoutIfNeeded()
self.calendarView.performBatchUpdates(nil)
})
for CalendarViewCell in calendarView.visibleCells {
let cell = CalendarViewCell as! CalendarViewCell
cell.hide()
}
}
collectionView의 애니메이션 코드 입니다. collectionView에서 애니메이션이 호출되면, 모든 cell의 애니메이션이 호출되고 cell의 애니메이션이 호출되면 cell안에 있는 모든 item의 애니메이션이 호출되도록 코드를 작성하였습니다.
✏️ 나를 괴롭혔던 Autolayout
오토레이아웃 오류는 대부분 부모 자식 View간에 발생했습니다. 부모의 Height을 설정하지 않은 상태에서 자식이 Height을 고정시켰을때라던가, 등등에서 발생했는데 재사용할 일이 없고 그 화면 내에서만 사용할 것이라면 ViewController에서 선언해주는 것도 고려해볼만 합니다.
저의 경우에 UICollectionView와 UITableView는 ViewController에서 선언해 주었습니다. cell과 cell에 담길 item은 분리를 하였습니다.
🔥 Library
✏️ CocoaPods 사용하기
CocoaPods은 라이브러리를 다운로드하기 쉽게하는 툴입니다.
M1 칩에서 간혹 pod install이 안되는 경우가 있었는데 다음의 명령어로 해결하였습니다.
M1 칩) arch -x86_64 pod install
아래는 다운받은 라이브러리를 초기화 하는 명령어입니다.
sudo gem install cocoapods-deintegrate cocoapods-clean
pod deintegrate {프로젝트의 .xcodeproj 파일의 경로}
pod clean
pod install
저의 경우에는 맥북 용량이 부족하다고 떠서, 무슨 파일을 여러개 강제 삭제했는데, 알고보니 프로젝트 라이브러리였고, 빌드 오류가 나기 시작해서 라이브러리 초기화로 해결하였습니다. 원인은 xcode의 라이브러리 경로 설정의 오류일 확률이 높습니다.
✏️ RxSwift로 MVVM 패턴 적용을 했습니다.
RxSwift는 비동기 처리를 위한 라이브러리인데, MVVM 패턴을 적용하려면 UIKit에서 거의 필수적으로 알고있습니다. UIKit + RxSwift가 국룰인 조합이라고 해서 직접 찾아본 결과, 당근마켓, 토스 등 많은 곳에서 사용하는 조합입니다.
RxSwift의 핵심은 Observable과 Operation입니다. 객체를 관찰가능하게 변경하여, 값이 변경되면 UI가 변경 될 수 있도록 합니다. 서버로부터 값을 3초 뒤에 받을 수 있다면 0초 시점에 화면의 값이 3초 뒤 값을 받으면 변경이 되도록 코드를 작성합니다.
RxCocoa는 UIKit에서 사용하는 컴포넌트들과 연동하여 사용할 수 있도록 돕는 RxSwift의 확장 라이브러리입니다. 버튼 클릭이 되지 않았을 때를 0초라고 생각하고 3초 뒤에 버튼을 누르면 클릭된 버튼 값이 업데이트 되면서 화면이 변경되는 구조는 RxSwift와 같습니다.
ViewModel
protocol ViewModel {
associatedtype Input
associatedtype Output
var input: Input { get }
var output: Output { get }
}
ViewModel의 구조를 미리 한정해 놓고 상속받아서 뷰 모델을 작성하는 방식을 채택하였습니다.
Input에서는 UI의 입력값에 대응되는 데이터를 선언하고
Output에서는 Input값이 변경되었을때 UI에 변경되는 값을 전달하는 데이터를 선언해 주었습니다.
버튼이 있다면 isButtonClicked 라는 변수를 Input에 놓고
Output에는 isButtonClicked에 따라 변경되는 ButtonColor을 놓았습니다.
class ChartViewModel: ViewModel {
let recordService = RecordService()
var disposeBag = DisposeBag()
var input = Input()
var output = Output()
struct Input {
let recordZips = BehaviorSubject(value: [RecordZip()])
let tagRecordZips = BehaviorSubject(value: [RecordZip()])
let itemRecordZips = BehaviorSubject(value: [RecordZip()])
}
struct Output {
let recordChartDatas = BehaviorRelay(value: [RecordChartData()])
let priceSumChartData = BehaviorRelay(value: ([String()], [Double()]))
let incomeChartData = BehaviorRelay(value: ([Date()], [Double()]))
let tagBuyPriceSumData = BehaviorRelay(value: ([String()], [Double()]))
}
init() {
setBind()
}
}
아래는 ChartViewModel 입니다. Output에 선언된 데이터들은 Input이 변경되면 변경되는 값들입니다. setBind함수 안에 바인딩을 해주는 코드를 넣어서 init에 호출해주었습니다.
func setBind() {
input.recordZips.onNext(recordService.getRecordZips(tag: true, item: true))
input.tagRecordZips.onNext(recordService.getRecordZips(tag: true, item: false))
input.itemRecordZips.onNext(recordService.getRecordZips(tag: false, item: true))
input.itemRecordZips
.bind { [weak self] recordZips in
self?.output.incomeChartData.accept(self?.getIncomeBarChartData(date: Date(), recordZips: recordZips) ?? ([Date()], [Double()]))
self?.output.priceSumChartData.accept(self?.getBuyPriceSumPieChartData(date: Date(), recordZips: recordZips) ?? ([String()], [Double()]))
}
.disposed(by: disposeBag)
input.tagRecordZips
.withUnretained(self)
.bind { owner, tagRecordZips in
owner.output.tagBuyPriceSumData.accept(owner.getTagBuyPriceTotalChartData(recordZips: tagRecordZips))
}
.disposed(by: disposeBag)
}
input의 초기값을 설정해주고, 해당 초기값을 불러오면 output값이 변경되도록 하였습니다. 약한 참조를 사용하여 메모리에서 해제될 수 있도록 self 접근을 하였습니다.
ViewController
func setBind() {
viewModel.output.incomeChartData
.withUnretained(self)
.bind { owner, data in
owner.incomeBarChartView.update(dates: data.0, values: data.1)
}
.disposed(by: disposBag)
viewModel.output.priceSumChartData
.withUnretained(self)
.bind { owner, data in
owner.itemPieChartView.update(items: data.0, values: data.1)
}
.disposed(by: disposBag)
viewModel.output.tagBuyPriceSumData
.withUnretained(self)
.bind { owner, data in
owner.tagPieChartView.update(items: data.0, values: data.1)
}
.disposed(by: disposBag)
}
뷰 컨트롤러에서도 Output이 변경될 때 값을 변경해주는 코드를 작성하였습니다. 이렇게 되면 ViewModel의 Input값이 변경되면 Output값이 변경되고 Output값이 변경되면 UI가 업데이트 되는 구조가 됩니다.
✏️ Then으로 인스턴스 생성의 가독성을 높였습니다.
Then은 클로져로 인스턴스를 생성할 수 있도록 돕습니다. 코드의 간결성을 위해 사용하는데, 훨씬 직관적이어서 사용이 편했습니다. 당근 마켓에서 사용하는 것으로 확인했습니다.
var typeLabel = UILabel()
.then {
$0.font = Const.Font.footnote
$0.textAlignment = .center
$0.adjustsFontSizeToFitWidth = true
}
다음과 같이 UILabel을 생성할 때 .then으로 값을 설정해 줄 수 있습니다.
✏️ Charts로 그래프를 그렸습니다.
Swift의 Chart라이브러리는 Charts가 독보적이었습니다. 라이브러리를 커스터마이징 하는 것 까지는 못했고, 단순히 사용만 하였습니다.
func setChart(dataPoints: [String], values: [Double]) {
var dataEntries: [ChartDataEntry] = []
var colors: [UIColor] = []
for i in 0..<dataPoints.count {
let dataEntry = PieChartDataEntry(value: values[i], label: dataPoints[i])
dataEntries.append(dataEntry)
colors.append(UIColor.random)
}
let chartDataSet = PieChartDataSet(entries: dataEntries, label: "")
.then {
$0.colors = [.systemPink, .systemYellow, .systemPurple, .systemGreen, .systemOrange, .systemBlue]
}
let format = NumberFormatter()
.then {
$0.numberStyle = .none
}
let formatter = DefaultValueFormatter(formatter: format)
let chartData = PieChartData(dataSet: chartDataSet)
.then {
$0.setValueFormatter(formatter)
$0.setValueFont(Const.Font.caption5)
$0.setValueTextColor(.black)
}
pieChartView
.then {
$0.data = chartData
$0.highlightPerTapEnabled = false
$0.rotationEnabled = false
}
}
파이 차트의 데이터를 설정하는 함수입니다. dataEntry를 생성해주고, 이를 chartDataSet으로 변환해주고 이를 chartData로 변환하여 chartView에 data 설정을 해주면 됩니다.
✏️ Realm으로 내장 메모리를 사용하였습니다.
내장 메모리를 사용하는 라이브러리인데, 서버가 없는 프로젝트에서 사용하기에 적합합니다. MongoDB의 서비스 중 하나입니다. 쿼리 연산 속도가 빠른 것으로 알고 있습니다.
class RecordEntity: Object {
@objc dynamic var id: Int = -1
@objc dynamic var type: String = ""
@objc dynamic var tag: String = ""
@objc dynamic var item: String = ""
@objc dynamic var price: Double = 0.0
@objc dynamic var count: Double = 0.0
@objc dynamic var date: Date = Date()
@objc dynamic var memo: String = ""
override static func primaryKey() -> String? {
return "id"
}
convenience init(id: Int, type: String, tag: String, item: String, price: Double, count: Double, date: Date, memo: String) {
self.init()
self.id = id
self.type = type
self.tag = tag
self.item = item
self.price = price
self.count = count
self.date = date
self.memo = memo
}
}
Entity의 선언은 Class로 해주어야 하고, Object를 상속받아야 합니다. 고유키를 오버라이딩하여 설정할 수 있습니다.
let instance = try! Realm()
func postRecordEntity(recordEntity: RecordEntity) {
print(recordEntity)
if let object = instance.objects(RecordEntity.self).first(where: {$0.id == recordEntity.id}) {
if recordEntity.id != -1 {
try? instance.write {
object.tag = recordEntity.tag
object.item = recordEntity.item
object.type = recordEntity.type
object.date = recordEntity.date
object.price = recordEntity.price
object.count = recordEntity.count
object.memo = recordEntity.memo
}
return
}
}
var id = 0
if let lastRecord = instance.objects(RecordEntity.self).last {
id = lastRecord.id + 1
}
recordEntity.id = id
try? instance.write {
self.instance.add(recordEntity)
}
print(Realm.Configuration.defaultConfiguration.fileURL!)
}
다음은 Realm을 사용하는 함수입니다. id값을 비교한 후에 존재하는 id라면 업데이트를 해주고, 존재하지 않는 id라면 생성해주는 함수를 작성하였습니다.
Repository에서 Realm의 Entity를 직접 접근하고 Service에서 Repostitory를 통해 Entity를 가져오고 Model로 파싱하는 코드를 작성했습니다. ViewModel에서 Service를 사용하면 Model을 안전하게 가져올 수 있습니다.
📦 배포 정리
🔥 Error
✏️ AppIcon 변경 안되는 오류 해결
아래의 3가지를 변경해주고 오류가 해결되었습니다 : /
Project 설정 -> General -> App Icons and Launch Images -> include all app icon assets 클릭
Assets.xcassets -> xcode 화면 우측 바 -> 문서 버튼 -> Location -> Relative to Project로 변경
Assets.xcassets -> xcode 화면 우측 바 -> 문서 버튼 -> Target Membership -> {프로젝트 이름} 클릭
✏️ LaunchScreen 설정 해야함
LaunchScreen.stroyboard 생성하고 화면을 구성해야 합니다. : )
LaunchScreen.storyboard -> xcode 화면 우측 바 -> 문서 버튼 -> Interface Builder Document -> Use as LaunchScreen 클릭
LaunchScreen.storyboard -> storyboard 좌측 바 -> ViewController 클릭 -> 설정 버튼 -> View Controller -> Is Initial View Controller 클릭
Project 설정 -> General -> App Icons and Launch Images -> Launch Screen File에 LaunchScreen 설정하기 (storyboard 이름)
LaunchScreen을 찾아본 결과 storyboard를 사용해서 구성하는 방식이 유일하다는 글들이 많았습니다. info.plist에서 구성이 가능하지만, 간단한 이미지와 배경색을 설정하는 정도입니다.
저의 경우에는 storyboard의 View Controller의 Is Initial View Controller을 선택하지 않아서 빌드 오류가 발생했었습니다.
✏️ 잘못된 바이너리 오류
- AppIcon 문제를 해결했습니다.
- LaunchScreen 문제를 해결했습니다.
- 프로젝트 버전 및 빌드 버전 값을 수정하였습니다.
이 부분은 에러 메세지가 자세하지 않아서 찾기가 어려웠는데 기존의 에러를 해결해야 하는 것 같습니다.
🔥 그 외
✏️ 프로젝트 이름 변경
프로젝트 이름을 변경하는건 좀 위험한 행동이긴 하지만, 너무 마음에 걸려서 어쩔수 없다면 이름을 변경해야 합니다. 저의 경우에는 꽤 헤맸습니다. "xcode 프로젝트 이름 변경" 이라고 검색하고 충분히 따라해도 오류가 난다면 이부분을 유의깊게 보면 해결할 수 있을 것입니다.
- 이미 코코아 팟 인스톨을 한 경우
podfile에 쓰여진 프로젝트 이름을 변경해주어야 합니다. 그리고 그 이전에 xcode에서 프로젝트 설정을 잘 마쳐야 합니다. 그리고 .xcodeproj 파일을 삭제하고 다시 pod install을 진행해서 생성된 .xcodeproj를 xcode로 열어야 합니다. - 폴더의 이름을 직접 연결
파일의 이름이 변경되면 xcode에서 찾지 못하기 때문에 직접 폴더를 연결해 주어야 합니다. - info.plist 연결
프로젝트의 info.plist의 디렉토리가 변경되었으므로 이부분도 다시 연결해 주어야 합니다. - Target Member 연결
타겟 멤버의 연결이 끊겨있는 경우가 있었는데 다시 클릭해주어서 연결해주어야 합니다.
✏️ 테스트플라이트 구성원 추가
https://appstoreconnect.apple.com/access/users
여기에서 구성원을 추가해서 테스트 사용자를 추가할 수 있습니다.
✏️ 다크 모드 해제
info.plist -> Appearance -> Value에 Light 입력
앱의 다크모드를 허용했지만, 코드 내부에서 다크 모드를 대응하지 않았다면 다크 모드를 허용하지 않아야 합니다. 그렇지 않으면 화면 색이 원하는 코드대로 작동하지 않을 수 있습니다.
Author And Source
이 문제에 관하여(iOS ) 투린더 v1 정리), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@annapo/iOS-투린더-v1-정리저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)