.onLongPressGesture를 추가해서 롱프레스를 구현할 수 있다
minimumDuration은 최소 몇초동안 누를건지, maximumDistance는 누르고 얼마나 움직여도 되는지를 설정해주는 프로퍼티
struct LongPressGestureBootcamp: View {
@State var isComplete: Bool = false
@State var isSuccess: Bool = false
var body: some View {
VStack {
Rectangle()
.fill(isSuccess ? Color.green : Color.blue)
.frame(maxWidth: isComplete ? .infinity : 0)
.frame(height: 55)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.gray)
HStack {
Text("Click Here")
.foregroundColor(.white)
.padding()
.background(Color.black)
.cornerRadius(10)
.onLongPressGesture(minimumDuration: 1.0, maximumDistance: 50) {
// at the min duration
withAnimation(.easeInOut) {
isSuccess.toggle()
}
} onPressingChanged: { isPressing in
//start of press -> min duration
if isPressing {
withAnimation(.easeInOut(duration: 1.0)) {
isComplete = true
}
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
if !isSuccess {
withAnimation(.easeInOut) {
isComplete = false
}
}
}
}
}
Text("Reset")
.foregroundColor(.white)
.padding()
.background(Color.black)
.cornerRadius(10)
.onTapGesture {
isComplete = false
isSuccess = false
}
}
}
}
}
.onLongPressGesture의 프로퍼티를 다 불러줬을 때
onPressingChanged일 때는 누르고있는 동안의 시점이고
그위가 minimumDuration일 때 실행되는 로직임
DispatchQueue를 준건 async로 살짝의 delay를 줘서 위의 로직이랑 동시에 실행 안되게 해준거
zoom in zoom out 제스쳐임
struct MagnificationGestureBootcamp: View {
@State var currentAmount: CGFloat = 0
@State var lastAmount: CGFloat = 0
var body: some View {
Text("Hello")
.font(.title)
.padding(40)
.background(Color.red.cornerRadius(10))
.scaleEffect(1 + currentAmount + lastAmount)
.gesture(
MagnificationGesture()
.onChanged { value in
currentAmount = value - 1
}
.onEnded { value in
lastAmount += currentAmount
currentAmount = 0
}
)
}
}
struct RotationGestureBootcamp: View {
@State var angle: Angle = Angle(degrees: 0)
var body: some View {
Text("Hello, World!")
.font(.largeTitle)
.fontWeight(.semibold)
.foregroundColor(.white)
.padding(50)
.background(Color.blue.cornerRadius(10))
.rotationEffect(angle)
.gesture(
RotationGesture()
.onChanged { value in
angle = value
}
.onEnded { value in
withAnimation(.spring()) {
angle = Angle(degrees: 0)
}
}
)
}
}
gesture종류는 추가하는 게 비슷함
struct DragGestureBootcamp: View {
@State var offset: CGSize = .zero
var body: some View {
RoundedRectangle(cornerRadius: 20)
.frame(width: 100, height: 100)
.offset(offset)
.gesture(
DragGesture()
.onChanged { value in
withAnimation(.spring()) {
offset = value.translation
}
}
.onEnded { value in
withAnimation(.spring()) {
offset = .zero
}
}
)
}
}
이런 식으로 구성해줄 수 있는데 이때 Drag는 .onChanged에서 보이는 것처럼
value.translation으로 offset을 알 수 있음
화면의 위치에 따라서 scale의 값을 다르게 해주고 싶음
Screen의 width값을 반으로 나누고, currentAmount를 offset의 width값으로 설정한 다음
currentAmount/ max를 하게되면 rectangle의 위치에 따른 scale의 상대적인 값을 구해줄 수 있겠죠 (abs는 절대값 표현하는 메소드)
근데 이렇게 하니까 RoundedRectangle의 위치가 화면밖으로 나갈 경우에 scale값이 -가 넘어가게 되서 위치가 바뀌게 된다
min은 두가지 값 중에 더 작은 값을 반환하는 메소드
이걸 사용해서 위치가 화면을 벗어나더라도 크기가 너무 작아지지 않게 해줌
(그리고 이 min값에 *0.5 를 해줬다)
위치에 따른 Rotation값도 변경될 수 있게 해주자
같은 방법으로 percentage를 만들어주고 Angle에 이 값을 전달해줄거라 Double로 변환을 한 번 거쳐줌
그 다음에 최대 Angle을 설정해두고 이 값을 percentage 값과 곱했다
그리고 .rotationEffect를 추가해주면 됨
또다른 RealWorld Example 은 하단에 있다가 drag해서 일정 위치까지 올리면 촤라락 올라오는 뷰를 만드는 거!
MySignUpView라는 뷰를 구성해주고 이 뷰의 offset을 조절해서 만들어볼 수 있음
onChanged 메소드!
.onChanged될 때 currentDragOffsetY에 value를 넘겨주고
.onEnded일 땐 다시 0이 되게 해줌
이제 일정 이상 height으로 드래그되면 상단에 촤라락 올라오게 해주면 될 것 같다
struct DragGestureBootcamp2: View {
@State var startingOffsetY: CGFloat = UIScreen.main.bounds.height * 0.85
@State var currentDragOffsetY: CGFloat = 0
@State var endingOffsetY: CGFloat = 0
var body: some View {
ZStack {
Color.green.ignoresSafeArea()
MySignUpView()
.offset(y: startingOffsetY)
.offset(y: currentDragOffsetY)
.offset(y: endingOffsetY)
.gesture(
DragGesture()
.onChanged { value in
withAnimation(.spring()) {
currentDragOffsetY = value.translation.height
}
}
.onEnded { value in
withAnimation(.spring()) {
if currentDragOffsetY < -150 {
endingOffsetY = -startingOffsetY
} else if endingOffsetY != 0 && currentDragOffsetY > 150 {
endingOffsetY = 0
}
currentDragOffsetY = 0
}
}
)
Text("\(currentDragOffsetY)")
}
.ignoresSafeArea(edges: .bottom)
}
}
새로운 offset들을 추가해서 로직을 구성해줌
스크롤뷰리더는 특정 지점으로 바로 스크롤이 되게 해주는 뷰이다
ScrollView 내에 ScrollViewReader를 구성해주면 됨
ScrollViewReader의 클로져에 proxy는 ScrollViewProxy 타입인데
뷰 계층내에서 proxy value를 가지고 프로그래매틱 하게 스크롤링 하게 해주는 뷰임
proxy.scrollTo 로직을 가지는 버튼을 구성해주고 어떤 id로 이동할 건지 정해주면 된다
이 때 ForEach로 그려지는 뷰들에 id를 .id(index)를 통해서 구성해줌
anchor는 이동하게 되었을 때 어디에 위치하게 할 건지 정해주는 거임
.top .bottom .center 이렇게 정해주면 된다
struct ScrollViewReaderBootcamp: View {
@State var textFieldText: String = ""
@State var scrollToIndex: Int = 0
var body: some View {
VStack {
TextField("Enter a # here...", text: $textFieldText)
.frame(height: 55)
.border(.gray)
.padding(.horizontal)
.keyboardType(.numberPad)
Button("Scroll Now") {
withAnimation(.spring()) {
if let index = Int(textFieldText) {
scrollToIndex = index
}
}
}
ScrollView {
ScrollViewReader { proxy in
ForEach(0..<50) { index in
Text("This is item #\(index)")
.font(.headline)
.frame(height: 200)
.frame(maxWidth: .infinity)
.background(.white)
.cornerRadius(10)
.shadow(radius: 10)
.padding()
.id(index)
}
.onChange(of: scrollToIndex) { newValue in
withAnimation(.spring()) {
proxy.scrollTo(newValue, anchor: .top)
}
}
}
}
}
}
}
쪼꼼 복잡해보이긴 한데 생각보다 복잡하진 않음
Button을 ScrollView외부에 만들게 되면 proxy에 대해서 모르잖음
그래서 scrollToIndex라는 프로퍼티를 하나 만들어주고 TextField에 입력된 넘버를 전달해주는거임
그리고 ForEach뷰에 .onChange를 달아서 scrollToIndex값이 변하게 되었을 때 newValue를 이용해서 proxy.scrollTo해주면 됨
id를 ForEach 뷰의 객체들에 설정해주는게 중요하다!!
스크린의 정확한 사이즈와 크기를 알 수 있는 뷰!
근데 한번에 여러개를 사용하게 되면 코스트가 많이 들어가서 앱이 느리게 동작할 수도 있다!
struct GeometryReaderBootcamp: View {
var body: some View {
GeometryReader { geometry in
HStack(spacing: 0) {
Rectangle().fill(Color.green)
.frame(width: geometry.size.width * 0.6666)
Rectangle().fill(Color.blue)
}
.ignoresSafeArea()
}
}
}
요런식으로 geometry를 사용하고 싶은 뷰를 감싸주면 됨
이걸 사용해서 애니메이션 효과를 활용해보자
struct GeometryReaderBootcamp: View {
var body: some View {
ScrollView(.horizontal, showsIndicators: false ) {
HStack {
ForEach(0..<20) { index in
GeometryReader { geometry in
RoundedRectangle(cornerRadius: 20)
.rotation3DEffect(Angle(degrees: getPercentage(geo: geometry) * 40), axis: (x: 0.0, y: 1.0, z: 0.0))
}
.frame(width: 300, height: 250)
.padding()
}
}
}
}
func getPercentage(geo: GeometryProxy) -> Double {
let maxDistance = UIScreen.main.bounds.width / 2
let currentX = geo.frame(in: .global).midX
return Double(1 - (currentX / maxDistance))
}
}
Rectangle의 위치에 따라서 각도가 변할 수 있게 해줌
maxDistance는 스크린의 width의 반,
currentX는 들어온 geo의 global에서의 frame의 Rectangle의 midX위치를 나타낸다
... 아무튼 그렇다..
sheet 모디파이어의 클로져에는 conditional 로직이 들어가면 안 됨!!
제일 많이 하는 실수이니 주의하기
struct RandomModel: Identifiable {
var id = UUID().uuidString
let title: String
}
struct MultipleSheetsBootcamp: View {
@State var selectedModel: RandomModel = RandomModel(title: "Starting Title")
@State var showSheet: Bool = false
var body: some View {
VStack(spacing: 20) {
Button("Button1") {
selectedModel = RandomModel(title: "One")
showSheet.toggle()
}
Button("Button2") {
selectedModel = RandomModel(title: "Two")
showSheet.toggle()
}
}
.sheet(isPresented: $showSheet) {
NextScreen(selectedModel: selectedModel)
}
}
}
struct NextScreen: View {
let selectedModel: RandomModel
var body: some View {
Text(selectedModel.title)
.font(.largeTitle)
}
}
자자 요렇게 뷰를 구성했다고 가정하자
RandomModel의 경우 Button들에서 새로운 값이 할당되고 showSheet이 토글되면
.sheet Modifier에서 NextScreen이 뜨게되야함
근데!!
버튼들을 눌렀을 때 한번에 모델이 안바뀌고 있는걸 볼 수 있음
두번째가 되서야 RandomModel에 담긴 값이 바뀌고 있다
-> .sheet은 뷰가 로드된 순간에 촥 붙어 있게되서 그럼
이렇게 구성해도 마찬가지로 한번에 모델이 안바뀌고 있음
그럼 어떻게 해결할 수 있을까?
3가지 방법이 있다.
bindingd을 사용하는 방법, multiple .sheets를 사용하는 방법 $item을 사용하는 방법이 있음
뷰 각각에 따로 붙여주는 방법임
아이템의 값이 변경되면 .sheet이 작동하게 됨
자주 사용하진 않지만 알아두자!
예를 들어서 Rating을 별로 표시하는 뷰를 구성한다고 가정해보자
struct MaskBootcamp: View {
@State var rating: Int = 3
var body: some View {
ZStack {
HStack {
ForEach(1..<6) { index in
Image(systemName: "star.fill")
.font(.largeTitle)
.foregroundColor(rating >= index ? Color.yellow : Color.gray)
.onTapGesture {
rating = index
}
}
}
}
}
}
이렇게 rating에 따라서 star의 색상이 변경되게 해줄 수도 있겠죠
다른 방법인 mask를 사용해서 애니메이션을 만들어봅시다
지금 구성해준 뷰를 starsView 변수로 따로 빼주고
이 뷰에 overlay로 mask를 넣어줘봅시다
촤락 마스크가 생겼습니다
overlay로 표현하게 될 뷰를 만들려면 현재 뷰의 width값을 알아야하니까 GeometryReader를 사용하고, alignment를 .leading으로 맞춰주기 위해서 ZStack으로 감싸줬다
그리고 rating의 값에 따라서 frame의 width가 변하게 해줌
근데 또 frame이 넓어졌을 때 mask안에 있는 뷰가 터치가 안되서 스타가 제대로 안바뀌는 게 보임
.allowsHitTesting값을 false로 주면 해결된다
그리고 ratingdp withAnimation 주면 끝!
SwiftUI는 아니고 AVKit (Audio, Video Kit)을 사용할거임
보통 class를 구성하고 view에서 사용할 때 :ObservableObject
프로토콜 채택해주고
@StateObject로 받을 텐데
Sound같은 경우엔 UI업뎃해주는 게 없으니까 그렇게 해주지 않아도됨.
거기에 Sound같은 경우는 뷰 어디서든 사용할 수 있으니까
Singleton으로 구성해주는거임~!
요렇게해주면 SoundManager.instance로 어디서든 바로 접근 가능하겠죠
이렇게 보니까 .shared도 같은 원리인 거 같네요
이제 player를 class 내부에 구성해주고, playSound라는 메소드가 필요합니다
player는 AVAudioPlayer인데 여기에 contentsOf로 url (파일의 위치)를 전달해주면 되는데
이 로직 자체는 error를 throw할 수 있어서 do catch블록으로 받아야합니다
catch 구문엔 error가 뜨면 어떻게 할지 정해주면 되는데 catch let error는
발생하게 되는 error를 let error라고 할게~ 미리 정해주는 거임
그러고 player를 play해주면됨!
이제 Sounds를 넣어줘야겠죠
Bundle은
그 안에 있는 main 은 같은 directory에 있는 resources에 접근이 가능함
의 url forResource엔 파일이름, withExtension은 확장자를 입력해주면 끝
SoundOption이란 enum 케이스를 구성하고 :String으로 rawValue를 만들어주면 더 쉽게 메소드 구성이 가능하겠죠
캬
HapticManager도 싱글톤으로 만들어줄거
이렇게!
noti 타입의 햅틱과, impact 타입의 햅틱을 메소드로 실행할 수 있게 추가해줌
SwiftUI 개념은 아님
타임 베이스로 혹은 캘린더 베이스로 로컬에서 노티를 보낼 수 있다.
UserNotifications 임포트해주고
싱글톤 인스턴스 만들어준 후에 메소드로 request를 요청할거임
근데 UNUserNotificationCenter.current().requestAuthorization의 options에 들어가는 타입을 지정해줘야함
알람이 뜰 때 .alert, .sound, .badge의 형태로 뜰거라고 얘기해주는 겁니다
그리고 이 과정은 Bool 값과 error를 클로져로 가지게 되는데 만약에 error가 있다면 error를 프린트할 수 있게 해줬습니다
요거 만들어준거에요
앱 첨실행하면 물어보는 알람창
scheduleNotification() 메소드를 이제 구성해주는데
content를 선언하고, title subtitle sound badge 지정해주면 됩니다
그리고 request 를 구성해주고 id 같은 경우엔 알람이 여러개일 경우엔 신경써줘야하겠지만 지금은 그런 건 아니니 임시로 넣어주고, content 다음에 또 trigger가 필요해서
timeInterval 노티로 트리거를 구성해줌
그리고 노티센터에 add request 해주면 됩니다
그리고 저 뱃지 지워줄라면 .onAppear일 때 UIApplication inatance의 applicationIconBadgeNumber 를 0으로 바까줘야합니다
이게 제대로 된 방법인지에 대한 확신이 안든다. 뱃지가 간헐적으로 남아 있는 경우도 있음
찾아따!!!!
import SwiftUI
@main
struct SwiftfulThinkingContinuedLearningApp: App {
@Environment(\.scenePhase) var scenePhase
var body: some Scene {
WindowGroup {
LocalNotificationBootcamp()
.onChange(of: scenePhase) { newPhase in
if newPhase == .active {
UIApplication.shared.applicationIconBadgeNumber = 0
}
}
}
}
}
main App 파일에 Environment로 접근해서 바꿔주는게 정확합니다~~!!
스택오버플로우엔 신기한 사람들이 많군
calendar로 트리거하는 경우엔 dateComponents를 구성해주면 되는데
지금처럼 시, 분 을 정해주면 된다
요일은 dateComponents.weekday = 1 과 같은 형식으로 추가해줄 수 있는데
일요일이 1 임 월요일은 2고
이 이외에도 dateComponents는 얼마든 커스텀이 가능하다
위치에 따른 노티는
CoreLocation을 추가해줘야함
좌표 전달해주고, 들어올때 알려줄지와, 나갈 때 알려줄 지 설정해주면 됨
캔슬 메소드도 작성 가능
보통 콜렉션에 Identifiable을 채택해서 데이터를 넘기게 될텐데
id를 안넘겨주고 싶을 수 있잖음
그럴 때 Hashable 사용함
HashValue 란?
데이터를 간단히 숫자로 변환한 것
Equatable도 뭔지 알아야함
값이 같은지 비교할 수 있는 형식
이 개념 자체는 프로그래밍에서 통용되는 개념이니 꼭 익히자
struct HashableBootcamp: View {
let data: [String] = [
"One", "Two", "Three", "Four", "Five"
]
var body: some View {
ScrollView {
VStack {
ForEach(data, id: \.self) { item in
Text(item)
.font(.headline)
}
}
}
}
}
요렇게 ForEach로 넘겨주는 간단한 뷰가 있다고 치자
ForEach로 받는 프로퍼티에 id: 파라미터가 있는 걸 볼 수 있음
키패스가 뭘까?
String은 기본적으로 Hashable함. 그래서 data에 들어있는 각각의 item들은 유니크한 아이디를 다 가지고 있게됨. 그래서 .self로 id를 넘겨도 무방한겨
그럼 한번 hashValue를 실제로 확인해보자
요런 값인 거임
이제 진짜 structure로 된 모델을 한번 전달해보자
String 배열로 전달되던 data array를 MyCustomModel array로 바꿔줬음
그러니까 에러가 바로 뜨는 걸 볼 수 있다!
MyCustomModel은 'Hashable' 하지 않습니다 라고
Identifiable 을 붙여주게 되면 Hashable 해지게 됨!!
🤔 왜 그런지는 찾아서 추가하는걸로...
근데 이렇게 id 안넣고 싶다면 Hashable을 추가해줘야하게 됨
id는 .self로 해주고
이런 모델이 있다고 가정해보자
class ArrayModificationViewModel: ObservableObject {
@Published var dataArray: [UserModel] = []
init() {
getUsers()
}
func getUsers() {
let user1 = UserModel(name: "Nick", points: 5, isVerified: true)
let user2 = UserModel(name: "Chris", points: 0, isVerified: false)
let user3 = UserModel(name: "Joe", points: 20, isVerified: true)
let user4 = UserModel(name: "Emily", points: 50, isVerified: true)
let user5 = UserModel(name: "Samantha", points: 45, isVerified: false)
let user6 = UserModel(name: "Jason", points: 23, isVerified: false)
let user7 = UserModel(name: "Sarah", points: 76, isVerified: true)
let user8 = UserModel(name: "Lisa", points: 45, isVerified: false)
let user9 = UserModel(name: "Steve", points: 1, isVerified: true)
let user10 = UserModel(name: "Amanda", points: 100, isVerified: false)
self.dataArray.append(contentsOf: [
user1, user2, user3, user4, user5,
user6, user7, user8, user9, user10,
])
}
}
그리고 뷰모델에서 UserModel array를 가지고 있게되고,
struct ArraysBootcamp: View {
@StateObject var vm = ArrayModificationViewModel()
var body: some View {
ScrollView {
VStack(spacing: 20) {
ForEach(vm.dataArray) { user in
VStack(alignment: .leading) {
Text(user.name)
.font(.headline)
HStack {
Text("Points: \(user.points)")
Spacer()
if user.isVerified {
Image(systemName: "flame.fill")
}
}
}
.foregroundColor(.white)
.padding()
.background(Color.blue.cornerRadius(10))
.padding(.horizontal)
}
}
}
}
}
뷰모델을 가지고 ForEach뷰로 뷰를 구성해줌
이렇게!
근데 원하는 조건에 맞춰서 정렬해주고 싶을 수 있잖음
filteredArray라는 새로운 배열을 구성해줌 그리고 updateFilteredArray 메소드로 정렬된 array를 전달해줄 예정
이해를 돕기 위해서 새로운 상수를 만들었지만
요렇게 바로 대입해줘도 되겠죠
filteredArray = dataArray.sorted(by: { $0.points > $1.points })
축약하면 요렇게
filter는 조건을 만족하는 애들만 반환하게 되는 메소드
filteredArray = dataArray.filter { $0.isVerified }
축약하면 요런식으로!
map은 타입을 바꿔줄 때 사용할 수 있음
mappedArray = dataArray.map { $0.name }
축약하면 요렇게
compactMap은 optional한 값이 있을 경우에 nil값을 걸러주는 메소드
mappedArray = dataArray.compactMap { $0.name }
축약하면 요렇게
여러 조건을 이렇게 중첩해줄 수도 있습니다~!
처음 프로젝트 만들 때 CoreData 체크해주고 만들면 되는데
🤔체크 안하고 만들었을 땐 복붙말고 방법이 없나?
CoreData를 체크해주고 프로젝트를 만들게되면 템플릿코드들이 구성되어 있음
App파일을 보면 persistenceController라는 싱글톤 객체가 만들어져 있다!
그리고 .environment로 persistenceController를 넣어준 걸 볼 수 있음
어떤 뷰에서도 접근이 가능하겠죠
PersistenceController structure를 살펴보자
import CoreData
struct PersistenceController {
static let shared = PersistenceController()
static var preview: PersistenceController = {
let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext
for _ in 0..<10 {
let newItem = Item(context: viewContext)
newItem.timestamp = Date()
}
do {
try viewContext.save()
} catch {
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
}()
let container: NSPersistentContainer
init(inMemory: Bool = false) {
container = NSPersistentContainer(name: "CoreDataReviewBoot")
if inMemory {
container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
}
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? {
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
container.viewContext.automaticallyMergesChangesFromParent = true
}
}
container라는 프로퍼티가 있고, init할 때 container의 name을 "CoreDataReviewBoot"로 설정이 되는데 이 이름은
dataModel 파일 이름이랑 똑같음
그리고 container.loadPersistentStores로 로드 하고 있고!
container는 우리가 가진 데이터들을 저장하는 곳이라 생각하면 됨
콘텐트뷰 파일도 한번 보자
@FetchRequest라는 프로퍼티 래퍼를 통해 FetchedResults 프로퍼티를 선언하고 있는데
이 FetchedResults는 CoreData가 관리하는 objects의 콜렉션들을 SwiftUI View에게 제공하게 됨
이 items를 가지고 ForEach 뷰를 그려주는데
.toolbar에 보면 addItem하는 기능이 있다
addItem 메소드에선
새로운 아이템을 구성하고, newItem의 timestamp를 새로운 Date()로 갱신,
이 걸 viewContext에 저장하고 있고, 이 과정에서 에러가 발생하면 fatalError를 프린트함
deleteItems도 한번보자
offSets로 받은 IndexSet을 map을 통해서 [Item] array로 바꿔주고, forEach로 viewContext에서 delete해줌
그리고 save하는 과정은 위에서 addItem했을 때랑 비슷하고!
Item이 어디서 나온건지 보면 CoreData파일 들가면 Entities에 선언되어 있음!
이제 한번 커스텀해봅시다
CoreDataModel에서 FruitEntity를 만들었음
name이란 attribute를 구성해주고(프로퍼티라고 생각하면됨)
새로 만들어준 FruitEntity가 저장되게 해줘야하는데
Persistence 파일을 조금 수정해야하겠다
ContentView의 프리뷰에서
preview라는 항목을 넣어주고 있음
기존의 프리뷰용 데이터들을 보면 Item이라는 Entity를 viewContext에 저장시켜주고 있고!
이렇게 자동완성 안되면 커맨드시프트K 한번 눌러주면된다!
프리뷰 내용을 newFruit으로 바꿔줬다!
이제 콘텐트 뷰로 돌아가봅시다
기존에 있던 @FetchRequest를 수정해줄겨
@FetchRequest(entity: FruitEntity.entity(), sortDescriptors: []) var fruits: FetchedResults<FruitEntity>
FruitEntity를 entity로 넘겨주고, sortDescriptors는 우선 빈 배열로,
fruits라는 FetchedResults타입을 선언해줬습니다
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(entity: FruitEntity.entity(), sortDescriptors: []) var fruits: FetchedResults<FruitEntity>
var body: some View {
NavigationView {
List {
ForEach(fruits) { fruit in
Text(fruit.name ?? "")
}
.onDelete(perform: deleteItems)
}
.listStyle(.plain)
.navigationTitle("Fruits")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button(action: addItem) {
Label("Add Item", systemImage: "plus")
}
}
}
Text("Select an item")
}
}
private func addItem() {
withAnimation {
let newFruit = FruitEntity(context: viewContext)
newFruit.name = "Orange"
saveItems()
}
}
private func deleteItems(offsets: IndexSet) {
withAnimation {
//offsets.map { fruits[$0] }.forEach(viewContext.delete)
guard let index = offsets.first else { return }
let fruitEntity = fruits[index]
viewContext.delete(fruitEntity)
saveItems()
}
}
private func saveItems() {
do {
try viewContext.save()
} catch {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
}
}
새로만들어준 FruitEntity에 맞게 코드 수정해줌
지금 정렬이 안되어 있는데 정렬이 되게 해보자
FetchRequest에서 sortDescriptors 프로퍼티를 정해주면 됩니다
텍스트필드에 입력한 텍스트를 추가하는 방법으로 응용할수도 있겠죠
update는 어떻게 하냐!
ForEach 뷰를 보면 FruitEntity 타입의 fruit을 받고 있음
FruitEntity를 받는 updateItem 메소드를 만들고
onTapGesture 같은 곳에서 updateItem 해주면 됩니다
그니까 entity를 가져와서, entity를 update해주고 저장해주면 끝임
시뮬레이터에선 잘 되는데
캔버스에서는 swipe Delete이 제대로 작동을 안하는 현상이 있음
왜 그런걸까? 🤔
// View - UI
// Model - data point
// ViewModel - manages the data for a view
모델 뷰 뷰모델에 맞춰서 CoreData를 구성해봅시다
CoreData를 쓰려면 먼저 framework 임포트 해줘야함
container라는 (NSPersistentContainer) Core Data 담을 상수를 만들어줬음
init에서 초기화가 필요한데
container의 이름은 아직 CoreDataModel파일을 안만들어서 빈 스트링으로 주고,
container를 로드하게 해줌
그 다음은 DataModel을 만들어줘야해요
이름은 FruitsContainer로 만들어주겠습니다
FruitEntity를 새로 만들어주고 Entity의 Attribute를 스트링 타입으로 하나 구성해줍니다
다시 CoreDataBootcamp struct로 돌아와서 방금 만들어준 FruitsContainer를 name으로 넣읍시다. (FruitEntity의 Attribute name이 아니에요!!)
error가 없다면 Successfully 로드 되었다고 프린트해줬어요
캬
이번엔 viewModel 클래스 내에서 container를 구성해주고 있어서 struct에선 가능했던 @FetchRequest를 만들수가 없어요
다르게 request를 만들 수 있습니다
바로바로
func fetchFruits() {
let request = NSFetchRequest(entityName: "FruitEntity")
}
NSFetchRequest를 사용할 수 있음
이 때 entityName으로 들어가는 "FruitEntity"는 아까 만들어줬던 FruitsContainer의 Entity이름이 됩니다
이렇게만 작성하면 에러가 뜨는데
generic 파라미터인 ResultType은 추론할 수 없다고 얘기하고 있는겨
꺽쇠로 어떤 타입을 요청할 것인지도 적어줘야함
이렇게!
이제 container에 접근해서 fetch 요청해주면 되는데
🤔 container.viewContext.fetch(request)하게 되는데
viewContext 프로퍼티가 뭘까?
다시 한번 보러오기
fetch 메소드는 error를 throws 할 수 있어서 do catch 블록으로 받아줘야함
fetch한 다음엔 어딘 가에 이 결과 값을 담아줘야겠죠
class CoreDataViewModel: ObservableObject {
@Published var savedEntities: [FruitEntity] = []
func fetchRequest() {
let request = NSFetchRequest<FruitEntity>(entityName: "FruitEntity")
do {
savedEntities = try container.viewContext.fetch(request)
} catch let error {
print("Error fetching. \(error)")
}
}
}
그리고 init에 loadPersistentStores를 끝낸후에 fetchFruits를 호출해주면됨
새로운 데이터를 add 할 수 있는 메소드도 작성해보자
func addFruit(text: String) {
let newFruit = FruitEntity(context: container.viewContext)
newFruit.name = text
saveData()
}
func saveData() {
do {
try container.viewContext.save()
fetchFruits()
} catch let error {
print("Error saving. \(error)")
}
}
fetch 하던 메소드에서 처리하던 데이터는
@Published 프로퍼티래퍼로 감싸져 있어서 새로 갱신을 안해줘도 됬었는데
지금처럼 addFruit을 해주고 saveData하는 경우에 viewContext에 저장은 됐는데 이 내용이
UI를 업데이트하지는 않을거임
그래서 saveData() 메소드에서 container에 저장을 한 후에 fetch를 불러서 갱신을 해주는겨
ViewModel 작성은 끝났고
이 viewModel을 이용해서 뷰를 가지고 놀아봅시다
텍스트필드랑 버튼 만들고 버튼눌리면 텍스트필드 값이 저장이 되고 이걸 리스트로 보여주는
뷰를 구성해줬음
이후에 viewModel에 delete로직, update로직도 만들었다!!
과정 자체는 투두앱 만들 때랑 비슷한데 entity의 프로퍼티가 지금 옵셔널해서
그거 처리해주는 거 신경쓰면 될듯
relationship... 정말 헷갈리는 부분이지만
그만큼 CoreData에서 중요한 부분이기도 함!
코어데이터 설명 읽어보기!
마음 단단히 먹구!
코딩시작
뷰모델을 우선 선언해줌
촵
다음은 CoreData를 관리하는 객체를 싱글톤으로 만들어줄겨
이제 CoreData를 실제로 다뤄야하니까 import CoreData 해줬다
container랑 이 안에 존재하는 context를 상수로 선언해줌
초기값을 따로 안넣었으니까 init이 필요하겠죠
전이랑 다르게 context에 container.viewContext를 저장해주네
save 메소드도 하나 필요할 것 같음
아아 context.save()로 쉽게 쓸라고 한거구나
container.viewContext.save()를 조금 더 보기 쉽게할라고
CoreDataManager구성은 우선 마쳤고
viewModel로 돌아와서 방금 만든 매니저를 선언해주자
CoreDataContainer 라는 이름의 DataModel파일 작성!
3개의 entity를 만들어줄거임
BusinessEntity, DepartmentEntity, EmployeeEntity
이렇게 3가지
비즈니스 안에는 부서가 있을거고, 이 부서들 안에는 직원들이 있을 예정
비즈니스Entity에 name attribute를 구성하고,
부서Entity에 name attribute를 구성했다
두개를 연결해주자!
비즈니스Entity Relationships에departments라는 관계를 만들어줌
Destination은 관계의 방향에서 목적지, inverse는 어떤 관계랑 연결이 될지를 나타냄
Inspector창을 열면 Type항목이 있는데 하나로 대응될 지 여러개로 대응될지 정해줄 수 있음
원래는 하나의 비즈니스에선 여러개의 부서를 가지고
각각의 부서들은 그 상위의 비즈니스랑만 연결이 될텐데
지금 같은 경우엔 하나의 부서가 여러개의 비즈니스랑 대응될 수도 있을 거잖음
그래서 To Many로 골라줬음 (이건 개발자의 몫인거. 어떻게 관계를 맺을지는 온전히 나의 상황에 맞추면됨)
EmployeeEntity도 만들어줌! 나이랑 언제 출근시작했는지, 이름 같은 attribute를 만들어줬음
이번엔 이 직원들은 각각 하나 비즈니스랑 대응되는 관계 하나, 부서랑 대응되는 관계 하나로만 가능하게 해줬음
직원이 몸이 여러개도 아니고 출근을 여기저기 몸 담을 순 없잖음
비즈니스Entity에선 여러 갈래의 직원들이 있을거니까 many로 대응 되게 해줌
옼께이
부서도 마찬가지로 여러명의 직원들이 있을테니까 To Many로 대응되게 해줌
참 옛날처럼 editor모드 바꾸는 건 없어짐!
관계 설정끝!
다시 뷰모델 작성한 곳으로 돌아와서
@Published로 [BusinessEntity] 배열을 만들어줌
그리고 addBusiness 메소드를 구성해줄거임
manager에서 save로 context를 저장했던걸 다시 save()라는 메소드로 만들고
addBusiness에 추가해줌
간단한 뷰를 구성해주고
실행!
잘 실행됨
이제 저장된 Business Entity를 가져와봅시다
뷰모델에서 작성해준 getBusiness
struct BusinessView: View {
let entity: BusinessEntity
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text("Name: \(entity.name ?? "")")
.bold()
if let departments = entity.departments?.allObjects as? [DepartmentEntity] {
Text("Departments:")
.bold()
ForEach(departments) { department in
Text(department.name ?? "")
}
}
if let employees = entity.employees?.allObjects as? [EmployeeEntity] {
Text("Employees:")
.bold()
ForEach(employees) { employee in
Text(employee.name ?? "")
}
}
}
.padding()
.frame(maxWidth: 300, alignment: .leading)
.background(Color.gray.opacity(0.5))
.cornerRadius(10)
.shadow(radius: 10)
}
}
그리고 BusinessView를 구성해줬다
여서 보면 BusinessEntity안엔 departments랑 employees가 다 있음
entity.departments는 set라서 array로 바꿔주기 위해선 allObjects로 [Any]타입의 array를 만든 다음에 [DepartmentsEntyty] 타입으로 캐스팅해줘야함
employees도 마찬가지!
이제 만들어준 BusinessView를 원래의 뷰에서 horizontal ScrollView안에 ForEach로 돌려보자
save가 호출될 때 getBusinesses도 같이 호출되어야 뷰가 업뎃 됨
그리고 business가 add 될 때 departments랑 employees도 업뎃 되어야함
func addBusiness() {
let newBusiness = BusinessEntity(context: manager.context)
newBusiness.name = "Apple"
// add existing departments to the new business
//newBusiness.departments = []
// add existing employees to the new business
//newBusiness.employees = []
// add new business to existing department
//newBusiness.addToDepartments(<#T##value: DepartmentEntity##DepartmentEntity#>)
// add new business to existing employee
//newBusiness.addToEmployees(<#T##value: EmployeeEntity##EmployeeEntity#>)
save()
}
department를 추가해주는 메소드를 작성하고
기존에 cta Button로직을 vm.addDepartment로 바꿔줌
글고 지금 누적된거 지워줄라고 save될 때 기존의 businesses 안에 담긴 배열 싹 지우고
dispatchQueue.main.asyncAfter로 살짝 딜레이 줘서 save되게 해줌
DepartmentView도 만들었다!
BusinessView 그대로 복사하고
살짝 수정해줌
struct DepartmentView: View {
let entity: DepartmentEntity
var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text("Name: \(entity.name ?? "")")
.bold()
if let businesses = entity.businesses?.allObjects as? [BusinessEntity] {
Text("Businesses:")
.bold()
ForEach(businesses) { business in
Text(business.name ?? "")
}
}
if let employees = entity.employees?.allObjects as? [EmployeeEntity] {
Text("Employees:")
.bold()
ForEach(employees) { employee in
Text(employee.name ?? "")
}
}
}
.padding()
.frame(maxWidth: 300, alignment: .leading)
.background(Color.green.opacity(0.5))
.cornerRadius(10)
.shadow(radius: 10)
}
}
bootcamp뷰에서
비즈니스 뷰 스크롤로 보이는 거 밑에
departments 뷰도 그려줄 거
vm 에 새로운 [DepartmentEntity] 배열이 필요하겠죠
구성해주고, getDepartments() 만들고 init 될 때 실행되게 해줌
save메소드에도 한번 removeall한 다음에 getDepartments로 업뎃 될 수 있게 해주고
DepartmentEntity를 추가할 때 어떤 Entity에 붙을건지 설정이 가능하고,
이 설정들은 유기적으로 연결이 되어 있음
그니까 한번 Business를 추가해놓고
그 아래로 Departments, Employees 들을 추가했는데도
기존에 있던 Business에도 항목들이 추가되는 걸 볼 수 있음
Core Data에선 관계들이 내가 dataModel에서 설정한대로 유기적으로 연결되어 있다!!
sort 하는법
get할 때
fetchRequest를 할 때 sort 해주면 됨
filter는 predicate 추가하면되고
delet하는 과정에서 관계에 다른 entity를 어떻게 처리할지 정해줄 수 있음
default는 nullify인데 다른 애는 남겨두는 형태로 지워짐
casacade는 관계에 있는 다른 entity도 다 지움
deny는 다른 관계의 entity가 존재한다면 안 지워짐
struct BackgroundThreadBootcamp: View {
@StateObject var vm = BackgroundThreadViewModel()
var body: some View {
ScrollView {
VStack(spacing: 10) {
Text("Load Data")
.font(.largeTitle)
.fontWeight(.semibold)
.onTapGesture {
vm.fetchData()
}
ForEach(vm.dataArray, id: \.self) { item in
Text(item)
.font(.headline)
.foregroundColor(.green)
}
}
}
}
}
class BackgroundThreadViewModel: ObservableObject {
@Published var dataArray: [String] = []
func fetchData() {
let newData = downloadData()
dataArray = newData
}
private func downloadData() -> [String] {
var data: [String] = []
for x in 0..<100 {
data.append("\(x)")
print(data)
}
return data
}
}
뷰모델에서 dataArray를 구성해주고 downloadData했다고 가정함
download된걸 fetch에서 dataArray에 넣어주고,
이걸 뷰에서 ForEach뷰로 그려줄 예정
시뮬레이터로 실행하고
디버그 네비게이터에서 cpu를 클릭해보자
앱의 규모가 커진다면 종종 확인해야하는 창
밑에 Thread가 보이는데 일종의 엔진이라 생각하면 된다
Thread1은 Main 스레드인데 여기서 UI 업데이트 관련된 Task를 처리함
근데 많은 양의 데이터를 처리하게 될경우에 Thread1에서 처리하는 것보단
다른 스레드에서 처리해주는 게 효과적임
fetchData 메소드를 백그라운드 스레드에서 실행되게 해보자
실행하고 탭하면 다른 Thread에서 스파크가 튀는 걸 볼 수 있다!
함께 보라색 에러도 발견할 수 있는데 ui를 업데이트 하는 로직은 background에서 실행되면 안된다고 경고하고 있음
dataArray에 newData넣어주는 과정을 main 스레드에서 동작하게 해주면 됨!
오케이!
그리고 global의 프로퍼티에 qos (퀄리티 오브 서비스)를 설정해줄 수 가 있는데
.background로 설정하고,
기존의 VStack을 LazyVStack으로 변경해줌
다른 스레드도 활성화 되는 걸 볼 수 있죠
__
메인스레드에서 동작하는지 확인해볼 수도 있는데
오케이!
스레드는 ui에 영향을 미치게된다면 main 스레드에서!!
그게 아니고 많은양의 데이터를 로드하게 된다면 background 스레드에서 처리해주면 좋다!!
ARC에 대해서 알아야함
참조에 대한 카운팅을 해서 더 이상 필요가 없을 경우에 메모리에서 할당을 해제해주는 거
NavigationView를 구성하고 NavigationLink로 두번째 스크린으로 가게끔 구성해줌
class WeakSelfSecondScreenViewModel: ObservableObject {
@Published var data: String? = nil
init() {
print("Initialize now")
getData()
}
deinit {
print("Deinitialize now")
}
func getData() {
data = "New Data!!!"
}
}
간단한 뷰모델을 구성해주고
vm.data에 값이 있다면 Text에 data를 넣어줬다
시뮬레이터를 실행해서 print를 확인해보자
SecondView로 진입하게 되면 Initialize now가 뜨고
뒤로 돌아왔는데 아무것도 안 뜨는 걸 볼 수 있음
StateObject로 하나가 들어가게 되서 그런 것 같다
다시 진입하게되면 Initialize now랑 Deinitialize now가 둘다 뜨게 됨
@StateObject로 들어갔던 건 Deinit되고, 새로운 뷰모델이 Initialize 되고 있습니다
한번 더 뒤로 갔다 진입하면 똑같은 Init, Deinit이 뜨게되죠
이제 숫자를 넣어서 카운트를 추적해보자
overlay로 우측상단에 숫자를 띄워두고
이 숫자를 UserDefaults로 저장해서 띄워줄겨
@AppStorage로 "count"라는 이름의 count 값을 선언해줌
그리고 overlay의 Text에 넣어줬다
그리고 뷰모델이 init될 때 UserDefaults에 저장된 count 값을 가져오고
UserDefaults에 새로운 count값을 1 증가시켜서 저장해줌
deinit 될 때도 비슷하게 구성해주고 이번엔 반대로 count값을 -1 해줌
첫번째 뷰로 돌아와서 처음 Init될 때 count값을 0으로 초기화해주고
실행해보면
한번 navigationLink 타고 들어가면 1이 남게 되는 걸 볼 수 있다!!
지금까진 문제 없죠
1에서 고정되어 있으니까!
1 카운트된것도 @StateObject로 하나의 카운트를 먹고 있는거지
카운트가 여기서 더 안늘어납니다
그럼 어떨 때 문제가 발생하느냐?
asynchronous 작업을 요청하게 될 때 그래요
func getData() {
DispatchQueue.global().async {
self.data = "New Data!!!"
}
}
뷰모델에 있던 getData를 백그라운드 스레드에서 처리되게 해줬음
이렇게 되면 뷰모델에 대해서 Strong Reference를 만들게 된다
이 말은 getData()를 처리하는 과정이 남아있다면 이 메소드를 가지고 있는
ViewModel은 메모리에서 해제하지 않겠다고 이야기하는 겨
스레드를 메인으로 바꿔주고 asyncAfter, 실행되는 시간에 delay를 줘서 테스트해보자
카운트가 계속 늘어난다...🥹
그만큼 메모리 누수가 발생하겠죠
콘솔에서도 Deinit 프린트는 안나오고
그럼 이걸 어떻게 해결할 수 있을까?
[weak self] in을 클로져 구문에 붙여주면 된다!
지금 에러가 발생하는 건 self를 옵셔널로 바꿔줘야 된다고 하는거라
self?로 바꿔주면 됨
이제는 deinit이 제대로 됩니다!!
넷플릭스 같은 어플을 만든다고 가정해보자
무비모델을 만들어줬음
이런 구조로 잘 사용하고 있다가 어플의 모델 이름을 바꿔서 표현해야 할 수 있잖음
티비쇼가 추가 되었다던가
근데 이전에 MovieModel을 필요로 하는 메소드 같은 것들이 존재했다면 지금처럼 바꿔치기 하는 게 의미가 없음. 타입이 달라졌으니까
그럴 때 호출하는 이름의 별칭만 바꿔서 전달해주는겨
typealias를 사용해서!
간단한 뷰모델과 뷰를 구성해주고 이걸 터치했을 때 getData가 되게 해줬음
뷰모델에서 이 Data에 관한걸 처리할 예정
지금 같은 경우엔 downloadData의 작업은 즉시 실행되겠지만
네트워킹을 하게 된다면 asynchronous하게 해줘야할거임
이걸 연습해보자
downloadData2라는 메소드를 구성해줄려고함
asyncAfter로 처리될 예정인데 이대로 작성하면 에러가 발생한다!
바로 return이 되는 로직이 아니라서!!
이렇게 프로퍼티 completionHandler처럼 클로져를 받게끔 수정해줘야함
그럼 getData하는 과정에도 오류가 생김
newData에 값을 넣어주는 과정이 바로 실행되는게 아니라서!!
형태를 바꿔줘야함
completion에 들어가게되는 건 아래와 같은 함수인거!
Dispatchqueue로 completionHandler를 실행하려고하면 또 에러가 발생하게 됨
이 때 @escaping을 붙여주는거!!
그럼 getData하는 부분에서 또 에러가 발생하는데
self를 붙여줘야한다. 이럼 또 strong reference가 발생해서 weak self를 붙여주게 되는 형식인 거임!!
deinit할 때 참조가 살아 있어도 카운팅을 없애주세요~ 라고 하는 느낌이다
downloadData3 메소드를 조금 더 보기 좋게 만들어보자
DownloadResult 모델을 만들고
downloadData의 completionHandler에서 DownloadResult타입을 받는 클로져를 작성,
completionHandler에 result 값을 넣어준다
getData에서 클로져를 마무리해주면 됨
더 가독성이 좋게 만든다면 typealias로 DownloadCompletion을 지정해주면 되겠죠
많이 봤던 느낌이라 친근하다
이제야 어떤 플로우로 구성이 되었는지 알게됨
이외에도 @escaping 클로져가 값을 반환하게 되는 형태도 있을 텐데
지금처럼 Void를 반환하는 형태를 제일 많이 쓰게 됨
internet에서 data를 받아오는 과정이 있다면 해당 데이터를 직접적으로 사용하기위해선
인코딩, 디코딩 하는 과정이 필요함
그걸 쉽게 해주는 게 Codable이라는 프로토콜이다!
CustomerModel이라는 모델을 구성하고, ForEach뷰에서 쓸거라 Identifiable 프로토콜을 채택해줬다 평소랑 다르게 UUID를 쓰지 않은건 해당 모델을 다운받아서 쓰게 될 경우엔 id도 같이 다운 받아서 쓰는 경우가 많기 때문에 초기값은 넣어주지 않고 String타입으로 선언해줌
그리고 뷰모델을 구성해주고!
뷰모델에 옵셔널 타입의 CustomerModel을 선언해줌
nil로 customer 다시 바꿔주고
데이터를 가져오는 과정을 비슷하게 만들어주자
jsonData를 임시로 만들어줌!(실제로 이렇게 구현하진 않음, fakeModel임)
getData에서 실제로 어떤 JSON이 프린트되나 확인해보자
bytes 가 보임!
string 형태로 보고 싶으니까 jsonString을 추가해줌
오케이! json을 볼 수 있게 프린트했습니다~
이걸 바로 쓸 순 없으니까 우리가 쓸 수 있는 모델로 바꿔줄 필요가 있다
data를 가지고 jsonObject로 담은 다음에 dictionary를 선언해주는데 캐스팅이 필요했다
아래에 있는게 다 옵셔널이라 if let, 으로 한번에 묶어줌
지금처럼 하는 방법은 수동으로 일일이 다 매핑해주는 방법임
더 간단한 방법도 있다!
그 간단한 방법 전에 어떻게 플로우가 흘러가냐면
struct CustomerModel: Identifiable, Decodable {
let id: String
let name: String
let points: Int
let isPremium: Bool
enum CodingKeys: String, CodingKey {
case id
case name
case points
case isPremium
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id)
self.name = try container.decode(String.self, forKey: .name)
self.points = try container.decode(Int.self, forKey: .points)
self.isPremium = try container.decode(Bool.self, forKey: .isPremium)
}
}
이런 식으로 흘러감
Decodable프로토콜을 채택하면 init 메소드 중에 from을 가진 애가 나타나는데 container 를 선언하고, CodingKeys를 넘겨주면 된다
그리고 container가 가진 데이터를 디코딩하면 됨!
그럼 getData를 이렇게 바꿔줄 수 있게됨
do catch 안쓰고 try 옵셔널 쓰면 더 깔끔하게 작성 가능
그럼 인코딩은 어떻게 할까?
모델에 encodable을 채택하고
encode 메소드를 작성하면 됨!
getJSONData도 이렇게 수정해주면 된다
이 encode decode 둘다 가능하게 해주는 게 Codable임!
그리고 좋은게 머냐면 지금처럼 메뉴얼하게 작성한 로직들을 전부 알아서 다 처리해줌
캬
combine을 사용해서 처리하는 게 일반적이지만 아직 다루지 않았으니 @escaping으로 처리할 예정
뷰모델과 뷰를 구성하고
뷰모델의 getPost에선 url 태스크를 만들어줄거다
url을 만들고, URLSession에서 dataTask를 url을 사용해서 실행하는데 이 결과가 나오면 클로져에서 받아줄거
func getPost() {
guard let url = URL(string: "") else { return }
URLSession.shared.dataTask(with: url) { data, response, error in
guard let data = data else {
print("No data.")
return
}
guard error == nil else {
print("Error: \(String(describing: error))")
return
}
guard let response = response as? HTTPURLResponse else {
print("Inavalid response")
return
}
guard response.statusCode >= 200 && response.statusCode < 300 else {
print("Status code should be 2xx, but is \(response.statusCode)")
return
}
print("Successfully Downloaded Data")
print(data)
let jsonString = String(data: data, encoding: .utf8)
print(jsonString)
}.resume()
}
data와 response, error에 대한 처리를 각각 해줌
response같은 경우 statusCode가 200~300 미만이어야 해서 그게 아니라면 또 StatusCode가 잘못 나왔다고 프린트되게 해주고
이 모든 게 성공적이었다면 data와 data를 String으로 변환한 print문을 출력되게 해줬다
url로 넣어줄 링크는
jsonplaceholder 사이트
post 1을 가지고 오자
들어가서 주소복사하고 url에 넣어줌
시뮬레이터 실행해서 테스트해보면!
이 jsonData를 실제로 쓸 수 있게 만드려면 Model이 필요한데
jsonData를 참고해서 만들어주면 됨
근데 초보자면 이렇게 만드는 게 헷갈릴 수 있잖음 그럴 땐
이 사이트 이용하면됨 app.quicktype
사용방법은 json 코드 복사해서 왼쪽에 붙여넣으면
우측에서 어떤 구조로 만들어줘야하는지 나옵니다~!
뷰모델에서 posts array만들어주고
getData메소드 마지막에 data를 디코딩하고, 이걸 posts에 append 해준다
지금 이 구문들이 dataTask의 클로져 안에 있단말임
그리고 이 task는 백그라운드에서 실행되고 있을 거임
data를 디코딩한 posts는 뷰를 업뎃하게 될거니까 main큐에서 실행이 되어야해서
DispatchQueue.main.async에서 실행되게 해준다
근데 또 strong Reference가 될 수 있으니까
[weak self] 를 붙여주게 됨
뷰로 돌아와서 posts array를 이용한 ForEach뷰를 그려주게 되면
잘 나온다!
코드 정리를 조금 해주자
guard 묶기!
오케이 완전깔끔해졌다
그리고 지금 이 URLSession 과정은 generic하게 전반적으로 쓰게하면 편할 거 같음
근데 url 작업의 결과가 언제올지 모르니까 @escaping으로 선언해줘야함
이렇게 completionHandeler에 @escaping으로 선언해주면 된다
이 안에 URLSession을 그대로 복붙해줍시다
compeltionHandler에 data만 넣어주면 됩니다
근데 또 completionHandler가 호출이 안될 경우( 즉 에러가 발생했을 때 )
알 방법이 없으니까 else 구문 안에 completionHandler(nil)을 넣어줍니다
downloadData를 호출하고, clousre에서 받은 data로 디코딩하던 작업 실행되게 해주면 리팩토링 끝!!
시뮬레이션 실행하면 잘 나오는 걸 볼 수 있습니다~
downloadData라는 메소드 언제든 url만 넣으면 이제 작업을 요청할 수 있겠죠!!
와... 이걸 이해하게 되는 날이 오다니!!!
두세번 더 보면 좋을 듯
Combine을 사용해서 json data를 다운받아보자!
기본적으론 이전에 했던 @escaping이랑 비슷하지만 Combine을 사용한다는 점에서 살짝 차이점이 있다
iOS13이전은 @escaping으로 구현을 했어야했는데 그 이상 버전부턴 Combine을 사용하면 보다 쉽게 구현 가능!
이전에 했던 구조랑 거의 비슷하게 작성한다!
뷰모델에 getPosts 메소드 작성해주고
여기서 Combine이 등장하게 됨
콤바인엔 Publisher랑 Subscriber가 있음
Real Life example로 비유하자면
어떤 패키지에 대한 월간 구독을 sign up했다고 하면
회사에선 패키지를 준비할 거고, 패키지를 집 앞에서 받아볼 수 있을 거임
box가 손상되진 않았는지 확인할 거고 패키지를 열어서 들어 있는 내용물이 맞는지 확인할 거죠
그리고 이 item을 사용하게 됩니다
이 과정에서 언제든지 구독 취소도 할 수 있을 거고요
콤바인은 대충 이런 뉘앙스라고 생각하면 됨
그 다음엔 이 세션이 백그라운드에서 동작되게 해줘야 할텐데
기본적으로 작성하지 않아도 되어 있지만 어떤 플로우로 돌아가는지 명시해줄라고
.subscribe on으로 백그라운드에서 동작되게 해줌
받을 땐 이 동작이 ui를 업뎃하게되면 메인스레드에서 실행되어야하니까
.receive on으로 main으로 큐를 바꿔줌
그리고 데이터가 잘 넘어오는지 체크해줘야함
tryMap은 Map인데 실패할 수 있고 error를 던질 수 있는 메소드
data와 response를 받아서 Data?를 반환하는 클로져를 작성해주고
response가 정상일 때 guard문으로 걸러준다
정상이 아닐 땐 throw Error하게 해줌
이 때 throw에서 던지게 되는 urlError의 정의를 타고 들어가면 여러가지 error들이 미리 설정되어 있는 걸 볼 수 있다
이 중에서 .badServerResponse를 사용해봅시다
tryMap 클로져에서 반환하던거 Data?였는데
이거 뭔가 갑자기 빼준다고 함
🤔왤까? 진행하는데 있어서 코드 길어져서 그런가..
tryMap에서 나온 건 jsonData일 테니까
디코딩도 해줘야함
.decode를 붙여서 어떤 타입으로 디코딩할건지, decoder는 어떤 디코더일지 정해준다
sink를 사용해서 아이템을 우리 앱으로 넣어줄거임
completion은 성공할 지 실패할지 알려줄 클로져고,
receiveValue는 받은 value로 뭐 할 건지 선언해주는 클로져
decode로 나온 데이터는 postsArray일 거임
이걸 우리의 posts에 넣어주는데 strong reference를 방지 하기 위해서
[weak self] 붙여줬다!
그리고 이걸 저장해줘야겠죠 combine 임포트해주고
AnyCancellable을 선언해줌 여기다 저장할 예정
오케이~
실행하면 잘 나옵니다~!~!~!
tryMap을 function으로 바꿔보자
이렇게! 깔끔해졌죠 .tryMap부분
sink 부분을 switch문으로 바꿔주며 편함
이렇게 말고 다르게도 가능하다
replaceError를 쓰면 에러가 뜰 때 내가 원하는 값을 임의로 넣어줄 수 있음
이렇게될 경우엔 completion이 필요없으니까 sink에서 receiveValue만 작성해주면됨!
value를 시간에 맞춰 publish 하는 타이머! 를 사용해보자
ZStack으로 간단한 뷰를 구성해줌
timer를 선언해줄 건데 publish 메소드는 Timer.TimerPublisher를 리턴하는 걸 볼 수 있음
timer를 사용하는데에 Combine 프레임웤을 import할 필요는 없지만 콤바인과 되게 유사함
1초마다 publish되게 할 거고, on에는 어떤 스레드에서 동작할 건지 설정함
🤔 in 에는 어떤 걸 필요로하는지 정확하게는 모르겠다! common을 자주 사용한다고함
리뷰 때 찾아보기!!
그리고 .autoconnect()로 스크린이 로드될 때 자동으로 커넥트 되게 해줌
오케이! 이제 타이머를 선언해줬는데
실제 뷰에서 타이머가 publish하는 걸 받게 해주자
onReceive에선 publisher를 넣어주면됨
perform에선 Publisher의 output을 처리하게 되는데
publisher가 내보내는 value를 가지고 요리하면 됩니다
timer의 경우 date를 내보내게됨
currentDate라는 date 프로퍼티를 선언해주고
Text에서 볼 수 있게 해줬다!
그리고 currentDate에 timer가 내보내는 value를 넣어주면 끝!
timer가 내보내느 밸류를 이제 뷰에서 듣게 되는겨
현재 보이는 date description이 보기 힘드니까 dateFormatter로 보기 좋게 포매팅해주자
dateFormatter라는 DateFormatter 컴퓨티드 프로퍼티를 선언해주고
dateformatter.string(from:)
을 이용해서 타임만 표시되게 해줌
오케이!
formatter에서는 다양하게 커스텀이 가능함
이번엔 카운트다운으로 구성해보자
이전에 작성했던 Date랑 DateFormatter는 코멘트아웃해주고
count와 텍스트를 추가해줌
onReceive에선 나오는 Date value값을 쓰지 않고 1초마다 로직을 처리해주면
카운트다운이 되는겨
호오
그니까 finishedText를 옵셔널로 해놓고 첨엔 nil이었다가 0초가 되면 finishedText에 값을 넣어서 표시하고 nil일 동안은 count값을 보여주는 방식으로 구현했구나!
시간에 맞춘 카운트다운도 구현해볼 수 있겠죠
futureDate라는 Date프로퍼티를 선언해주고
byAdding을 사용하면 date의 기간을 미래의 시간으로 설정이 가능함
지금으로부터 하루 미래로 설정함
updateTimeRemaining이라는 메소드를 구성해주고 onReceive에서 실행되게 해줄겨
(function은 보통 아래에 써주지만 지금은 보기 쉽게 위에 써줬다!)
얼마나 남았나를 remaining이라는 상수로 선언하고
dateComponents 안에는 어떤 것들이 들어갈지 정해준다
[.hour, .minute, .second]
를 넣어주고 from은 현재 시점으로부터, to는 futureDate까지
그리고 hour, minute, second 상수를 선언해주고 remaining.hour 같은 방법으로 접근해서 int 값을 가져옴 옵셔널이라 ?? 0을 붙여줬고
timeRemaining 변수에서 지금 만들어준 hour minute second를 써서 시간을 표시하면 되겠죠
futureDate에 이렇게 .hour를 추가해서 1시간뒤로 설정할 수도 있겠죠
이건 따로 dateFormatter가 필요없겠구나
Date에서 hour, minute, second 값만 받아서 timeRemaining에서 표현하면 되니까
Animation counter 도 구현해볼 수 있음
이렇게
여기에 offset을 줘서 애니메이션을 만들어볼거
if문은 지금보다 간단하게 한줄로도 표현 가능함
count가 3이라면 0을 카운트에 넣어주고, 그게 아니라면 count + 1을 해준다를
3항연산자 사용해줬음
여기에 withAnimation 주면 애니메이션 되겠죠!
오케이!!
애니메이션 타이밍이 느린 거 같아서 timer every 1초로 되어 있던거 0.5초로 바꿔줬다
탭뷰로 조금 더 커스텀해봅시다
탭뷰의 셀렉션에 count값을 넣어주고,
.onReceive에서 로직을 처리해주면 이런 carousel도 만들 수 있습니다~!
바로 전시간엔 뷰에서 timer를 만들었는데 이번엔 뷰모델에서 타이머를 만들어보자
뷰모델에 Timer를 만들어주는데
struct내에서 선언해서 만들 땐 .onReceive로 받아서 처리했을 거임
근데 지금은 뷰모델 안에 있으니까 같은 방법으로 구현하기 어려움
전엔 .store로 Set<Anycancellable>()
에 저장해줬다면
지금은 timer라는 변수에 AnyCancellable?을 구성하고 setUpTimer에서 방금만든
Timer Publisher를 저장해줌
init될 때 실행해주면 되겠죠
timer를 Cancellable한 객체로 만들었기 때문에 지금처럼
cancel도 언제든 가능함
이 때 self? 이거 옵셔널하게 사용하는 걸 좀 더 안전하게 사용하는게
guard let self = self else { return }
임
캬 이게 이래서 쓰는 거였구나
지금처럼 타이머를 하나만 쓸때는 AnyCancellable을 쓰면 되지만 여러개를 관리해야할 수도 있잖음
그럴 때 cancellables를 Set로 선언하고
.store로 저장하는거!!!!
cancel하고 싶다면 for in구문으로 전체를 다 cancel시켜줄 수도 있음
타이머는 Publisher로 개념을 익히기엔 쉬울 거임
이번엔 다른 Publisher도 만들어보자
timer가 매초 값을 publish했다면
비슷하게 textFieldText의 값도 텍스트가 입력될 때마다 값을 publish해줄 수 있음
addTextFieldSubscriber()라는 메소드를 구성해줬다.
map을 사용해서 다른 타입으로 textFieldText를 바꿔줄겨
text를 받아서 bool값을 리턴하는 클로져이고,
text.count값에 따라서 return값이 달라지게 해줬다
바로 이전엔 .sink를 통해서 로직을 처리했는데 이번엔 다른 방법인
.assign을 사용해보자
map을 통해서 나온 return 불리언 값을 assign을 통해서 어디에 값을 넣어줄 지 정하고
이 Publisher를 cancellables에 저장해줌
이제 이걸 뷰모델이 init될 때 넣어주고
뷰에서 확인해보자
글자를 4개입력하면 텍스트가 true로 바뀌는 걸 볼 수 있다!!
.assign을 사용했을 때 단점은 [weak self]를 사용해서 약한 참조로 못바꿔준다는거!!
그래서 .sink를 통해서 로직을 처리하는 게 더 안전함
어떤 차이점이 있나 보기위해서 .assign을 사용해봤다
오케이?!
.sink가 더 안전함!!
textField에 overlay를 사용해서 text가 일정갯수 입력 되었다면 checkmark나 xmark 중에 표시되게끔 하는 방법으로도 활용 가능하겠죠
한 가지 알아두면 좋은 건
.debounce라는 메소드가 있음
텍스트의 입력이 전부 마쳐진 뒤에 몇초 기다리고 debounce뒤의 로직들을 처리해주는 용도로 쓰게 됨
막 검색하거나 그럴 때 있잖음. 그때 text입력 하나하나마다 로직 처리하게되면 로직이 복잡해질 수록 앱은 느려질거임 그러니까 텍스트 다 입력하고 나면 살짝 딜레이 후에 로직이 처리되게끔 해주면 좋겠죠
여러개의 publisher를 subscribe 하는 것도 가능함 -> 콤바인~!~!
Button을 하나 만들어주고 showButton 불 값에 따라서 opacity랑 disabled가 바뀌게 해줄거
뷰모델에서 새로운 메소드를 만들어봅시다
addButtonSubscriber 메소드는
textIsValid의 값도 들을거고, count의 값도 같이 들어줄거임
.sink에서 로직을 처리해줬습니다~!
캬
이렇게 두가지의 publisher의 값을 처리해줄 수 있다!!
UserDefaults랑 CoreData 모두 사용해봤을 거임
FileManager는 iOS에서 저장하는 또 다른 방법이다!
FileManager에서는 CoreData나 UserDefaults에서 저장할 수 없었던 데이터도 바로 저장을 할 수 있다 (예를들면 이미지파일)
이미지 파일을 에셋 폴더 안에 넣어주자
잡스형님 넣어줌
에셋에 넣어줄 때 주의할 건
에셋 폴더 안에 넣는 리소스들은 앱을 제출할 때 실제 앱의 용량에 영향을 미치게 됨
꼭 쓸 리소스들만 넣어주는 게 좋다!!
간단한 뷰를 구성해주고 뷰모델을 만들겨
UIImage로 선언한 이유는 그냥 Image뷰를 사용하는 것보다 class내에서 이동하기 편해서!
class FileManagerViewModel: ObservableObject {
@Published var image: UIImage? = nil
let imageName: String = "steve-jobs"
init() {
getImageFromAssetsFolder()
}
func getImageFromAssetsFolder() {
image = UIImage(named: imageName)
}
}
init될 때 image에 UIImage named 스티브 잡스를 가져오게 해줌
일반적으로 앱에서 Image를 사용하게 될 땐 인터넷에서 다운로드를 받은 다음에
FileManager에 저장해줄텐데 지금은 인터넷에서 다운받는 것 까진 구현하지 않고
assets폴더에 담긴 이미지를 가져온 다음에 FileManager에 저장을 해주자
버튼을 하나 만들어주고 이걸 눌렀을 때 저장되게 해줄 예정
새로운 LocalFileManager를 만들어줬다
뷰모델에 넣지 않고 새로 만든 이유는 이건 generic하게 앱 전반에서 쓸 수 있기 때문
static instance를 하나 만들어주고 saveImage메소드를 만들거
image를 한번 바꿔줘야함
jpg파일이면 위에꺼, png면 밑에껄로
image.jepgData 얼마나압축할지 정한 걸 data라는 상수에 넣고 가드문으로 안전하게 처리해줌
data.write를 이용해서 저장시킬url 위치를 지정해야하는데 이제 FileManager가 필요하다
보면 urls 에서 for 에는 저장시킬 위치를 나타내게됨
애플 문서를 읽으면 .documentDirectory가 적합한 위치라는 걸 알게 된다
다른 디렉토리도 알아두자
.cachesDirectory는 섬네일 같은거 이미지 받아두고 사용할 때,
.temporaryDirectory는 말그대로 temp폴더임
디렉토리 프린트 해서 한번 확인해봅시다
manager 싱글톤 뷰모델에 선언해주고 saveImage라는 메소드를 만듬
그리고 뷰에서 버튼로직에 넣어줬다
실행하고 save버튼 누르면 디렉토리 위치 프린트 되는 걸 볼 수 있음
우리는 이제 cachesDirectory를 사용해서 저장할거임
directory1, 3은 코멘트아웃해주고 진행해보자
프린트문을 보면 디렉토리 위치가 array로 들어가있는걸 볼 수 있음
.first를 사용해서 array를 벗겨주고
directory.appendingPathComponent를 사용해서 이미지 파일 이름을 설정해준다
🤔현재 iOS버전은 appendingPathComponent에 다른 프로퍼티도 추가된걸 볼 수 있는데 이거 확인해주기 (이전 표현은 deprecated됨)
프린트해서 어떻게 저장되는지 확인!
근데 지금 directory도 path도 옵셔널이라 벗겨줘야함
위에 쓴 directory랑 path를 하나로 합쳐서 표현하면
아래의 guard 구문이 된다!
class LocalFileManager {
static let instance = LocalFileManager()
func saveImage(image: UIImage, name: String) {
guard
let data = image.jpegData(compressionQuality: 1.0) else {
print("Error getting data.")
return
}
guard
let path = FileManager
.default
.urls(for: .cachesDirectory, in: .userDomainMask)
.first?
.appendingPathComponent("\(name).jpg") else {
print("Error getting path.")
return
}
do {
try data.write(to: path)
print("Success saving!")
} catch let error {
print("Error saving. \(error)")
}
}
}
LocalFileManager 클래스는 요런 형식이 되는겁니다
saveImage라는 메소드에선 UIImage랑 파일의 이름이 될 name을 받고
UIImage를 데이터로 변환해서 path에 저장해주게 되는거죠
data.write(to: path)
를 쓰기 위한 여정이었던겨
Save버튼을 누르면 성공적으로 save됐다고 나옴
LocalFileManager 클래스에 getImage 메소드도 만들어줄건데 데이터를 저장시킬 때와 유사함
패스를 가져오는 과정을 메소드로 빼면 편리할 거 같다
요렇게! URL?을 반환하는 메소드를 만들어줌
그리고 guard로 묶어주면 되겠죠
그리고 getImage는 이렇게 path에 방금 따로빼준 getPathForImage를 넣고,
FileManager의 이 path에 파일이 존재한다면 UIImage를 contentsOfFile: path로 반환하고 그게 아니라면 nil을 반환하게 해줬다!!
이제 뷰모델에서
class FileManagerViewModel: ObservableObject {
@Published var image: UIImage? = nil
let imageName: String = "steve-jobs"
let manager = LocalFileManager.instance
init() {
getImageFromAssetsFolder()
}
func getImageFromAssetsFolder() {
image = UIImage(named: imageName)
}
func getImageFromFileManager() {
image = manager.getImage(name: imageName)
}
func saveImage() {
guard let image = image else { return }
manager.saveImage(image: image, name: imageName)
}
}
getImageFromFileManager라는 메소드를 구성해주고 로직을 처리하게 해줌
이대로 실행해서 저장한번 누르고 init() 에서 방금 파일매니저에서 이미지를 가져오는 로직을 넣어주면~~
잘나옵니다! (에셋폴더에서 삭제하고 빌드해보셈 잘나옴)
그럼 삭제는 어떻게 할까?
FileManager로 저장하는 경우 따로 삭제하지 않는 이상 계속해서 남아있게 됨
LocalFileManager에 deleteImage라는 메소드를 만들어줌
여기서 조금 다른점은 removeItem(at: URL)이 들어가야해서
path를 .path로 스트링으로 바꿨던 걸 지워주고
FileManager.default.fileExists로 path.path를 통해서 확인해주게 됨
🤔현재버전의 iOS에선 path.path가 사라지게되는 표현이라고 함
다른 거 찾아보기
뷰모델에서 manager의 deleteImage를 호출할 메소드를 구성해주고
뷰에서 실행해보자
delete를 한번 누르면 잘 지워지고 한번 더 누르면 Error가 나오는 걸 볼 수 있는데
fileExists로 safety 체크를 한번더 해주고 있기 때문!
잘 저장되고 지워지고 있나 텍스트로 표현해보자
뷰모델에서 infoMessage라는 스트링을 만들고
LocalFileManager에서 saveImage랑 getImage가 스트링 값을 반환하게 해줌
그리고 테스트 해보면 제대로 되는 걸 볼 수 있다!
지금은 path를 하나하나 만들어줬는데
폴더로 관리하면 더 편할 거 같지않음?
class LocalFileManager {
static let instance = LocalFileManager()
init() {
createFolderIfNeeded()
}
func createFolderIfNeeded() {
guard
let path = FileManager
.default
.urls(for: .cachesDirectory, in: .userDomainMask)
.first?
.appendingPathComponent("MyApp_Images")
.path else {
return
}
if !FileManager.default.fileExists(atPath: path) {
do {
try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil)
print("Success creating folder.")
} catch let error {
print("Error creating folder. \(error)")
}
}
}
}
폴더를 만들어주는 메소드를 구성해줌
getPathImage에 폴더 이름도 추가해줘야지 제대로 가져오겠죠
deleteFolder 도 마찬가지임
func deleteFolder() {
guard
let path = FileManager
.default
.urls(for: .cachesDirectory, in: .userDomainMask)
.first?
.appendingPathComponent(folderName)
.path else {
return
}
do {
try FileManager.default.removeItem(atPath: path)
print("Success deleting folder.")
} catch let error {
print("Error deleting folder. \(error)")
}
}
맞다 이거 path.path로 넣는게 아니라 atPath쓰는 방법이 있어서
removeItem 아까 이전에 했던 것도 수정해줌