나의 발자취

[SwiftUI ver] 당근마켓 거래서비스 풀스택 구현하기 (Frontend) - 로그인, 회원가입, 상품 리스트 업데이트 본문

앱 개발/iOS

[SwiftUI ver] 당근마켓 거래서비스 풀스택 구현하기 (Frontend) - 로그인, 회원가입, 상품 리스트 업데이트

달모드 2024. 11. 15. 16:44

 

2024.11.15 - [Backend] - [SwiftUI ver] 당근마켓 거래서비스 풀스택 구현하기 (Backend)

 

[SwiftUI ver] 당근마켓 거래서비스 풀스택 구현하기 (Backend)

달라진 것- 에러핸들링: 에러코드별로 에러 내역을 불러준다. 2. saleRouter.js페이지네이션을 해서, 페이지에 맞춰서 컨테이너의 갯수가 나오게끔 한다.  const limit = pageSize; const offset = (page - 1) * pag

wildguess.tistory.com

에 이어서 작업한다.


SwiftUI 프로젝트 생성

MVVM 구조를 따라서 Model - View - View Model 폴더구조를 만들어준다.

공통적으로 사용되는 컴포넌트는 Common Views 폴더 안에 따로 관리를 해줄것이다.

우선 LoginTextField SwiftUI 파일을 새로 생성해준다.

 


LoginTextField.swift 

1) 로그인 TextField

 

2) TextField 디자인

 

3) 비밀번호 TextField

위에 isSecured:Bool = false 를 선언하고, if문으로 처리해준다.

  • 비밀번호 입력 시 마스킹: SecureField()를 사용해준다.
  • 비밀번호 자동 대문자: .autocapitalization
  • 비밀번호 자동 수정 비활성화: .autocorrectionDisabled

import SwiftUI

struct LoginTextField: View {
    // TextField
    var icon: String
    var placeholder: String
    @Binding var text: String
    // Passkey
    var isSecured: Bool = false
    
    var body: some View {
        HStack {
            Image(systemName: icon).foregroundStyle(.mint)
            if !isSecured {
                TextField(placeholder, text: $text)
                    .autocapitalization(.none)
                    .autocorrectionDisabled(true)
            } else {
                SecureField(placeholder, text: $text)
                    .autocapitalization(.none)
                    .autocorrectionDisabled(true)
            }
            
        }.padding()
            .background(Color.gray.opacity(0.1))
            .clipShape(RoundedRectangle(cornerRadius: 10))
            .overlay(RoundedRectangle(cornerRadius: 10) .stroke(.gray.opacity(0.5), lineWidth: 1))
            .padding(.horizontal)
        
        
    }
}

#Preview {
    LoginTextField(icon: "person.fill", placeholder: "사용자 ID를 입력하세요", text:.constant("lia"))
    LoginTextField(icon: "lock.fill", placeholder: "비밀번호를 입력하세요", text:.constant("lia1234"), isSecured: true)
}

 

 

 


그다음으로는 로그인, 회원가입 버튼을 만들어줄것이다.

WideImageButton.swift




LoginView.swift

 

새로 생성

 

 

일단 텍스트필드 먼저 실어주고,

 

그리고 버튼도 실어준다.


 

이제 로그인하는 요청을 구현해줄것이다.(AF)

로그인을 했을 때, 뷰가 아래로 자연스럽게 내려가도록 애니메이션을 설정해줄것이다.

또한, 로그인을 요청했을 때 뭔가 일을 하고 있다는 것을 보여주는 게 있어야한다. (Progress Wheel) -> https://github.com/SVProgressHUD/SVProgressHUD 

 

따라서 AF와 위의 라이브러리 spm을 설치한다.


뷰모델 안에 아래와 같이 새 파일을 만들어준다.

MemberViewModel.swift

 

이거는 AF로 로그인 요청을 보내서, 응답을 가져오는 뷰모델이다. 즉, 뷰에 실어주기 위한 모델을 구현하는 파일은 다 ViewModel 안에 들어간다. MemberViewModel은 멤버인지 로그인 요청을 보낸 후 멤버면 그걸 뷰에 반영해주는 역할을 한다.

 

따라서 이 아이의 상태변화를 계속 감시해야한다. (상태에 따라 처리를 해주어야하기때문에) ObservableObject 로 만들어준다.

(+ 참고로, endPoint의 경우 localhost는 무조건 https가 아닌 http로 해주어야한다. (그래서 위에 저렇게 하고 나서 후에 아래로 고침) 안그러면 제대로 로그인 버튼을 눌렀을 때 로그인이 안된다! (호스트로 안간다. )

 

예전에는 response 코드를 200..<300으로 처리해줬는데, 이번 백엔드에서는 200부터 500까지 있다.

// ErrorHandler.js
const errorHandler = (err, req, res, next) => {
  console.log('======error handler========');
  if (err instanceof Sequelize.UniqueConstraintError) {
    return res
      .status(409)
      .json({ message: 'Unique constraint violation: duplicate data' });
  } else if (err instanceof Sequelize.ValidationError) {
    return res
      .status(400)
      .json({ message: 'Validation error: invalid data format' });
  } else if (err instanceof Sequelize.ForeignKeyConstraintError) {
    return res.status(400).json({ message: 'Foreign key constraint error' });
  } else if (
    err instanceof Sequelize.ConnectionError ||
    err instanceof Sequelize.ConnectionRefusedError
  ) {
    return res.status(500).json({ message: 'Database connection error' });
  } else if (err instanceof Sequelize.TimeoutError) {
    return res.status(504).json({ message: 'Database query timeout' });
  } else {
    return res.status(500).json({ message: 'Internal Server Error' });
  }
};

 

 

따라서 케이스를 나눈다.

 

뭐 이런식으로?

그래서 일단 데이터를 가져와야하므로 Model.swift를 구현해준다.

 

Model.swift 안을 보면 APIError 구조체를 새로 추가했다.

 

이 부분은 위의 ErrorHandler.js 파일 안에 정의된 statusCode에 따라, case2에서 APIError.self에서 사용된다.

 


 

이제 MainView와 로그인해서 들어갔을 때 EntryView를 만들어줄 것이다. 둘 다 View 폴더 안에 생성해준다.

 

 

 

EntryView.swift

이 값을 넣어준다.

 

 

EntryView에는 지금 @StateObject인 memberVM이 있다. 이건 isLoggedIn 때문에 쓰는거다.

그런데 이 요소가 LoginView에도 필요하기 때문에, environmentObject로 정해주는 작업이 필요하다.

 

따라서 아래 프리뷰에 이렇게 해주고,

 

@StateObject를 @EnvironmentObject로 바꿔준다. 

 

@EnvironmentObject 선언 후 MemberViewModel() 생성자로 값을 넣어주면 객체가 새로 생성이 되므로, 선언만 해주고 값을 넣어주지 않는, 즉 뒤의 괄호를 없애주어야 에러가 나지 않는다.

 

import SwiftUI

struct EntryView: View {
    @EnvironmentObject var memberVM: MemberViewModel
    
    var body: some View {
        
        if memberVM.isLoggedIn {
            MainView()
        } else {
            LoginView(userID:"lia", password: "1234").transition(.move(edge:.bottom))
        }
    }
}

#Preview {
    let memberVM = MemberViewModel()
    EntryView().environmentObject(memberVM)
}

LoginView.swift

이제 환경오브젝트로 memberVM을 가지고 온다.

1) @EnvironmentObject memberVM 추가

 

2) 로그인 버튼 메서드 안에 memberVM.login(userName: userID, password: password) 추가


MainView.swift 작성

로그아웃 버튼을 구현해준다.

 

다하고 EntryView에 가서 테스트를 해준다.

프리뷰에서 로그인 버튼을 눌러줬을 때 아래 메인뷰가 보이면 성공.

 

 

EntryView.swift

로 가서 transition을 준다. 그럼 로그인이 되었을 때 부드럽게 transition이 내려가는 것이 보인다.

 


Progress Wheel

이제, Progress Wheel을 보여줄것이다.

MemberViewModel.swift

1) 임포트

 

2) func login 안에 

 

3) 마지막

 

 

EntryView.swift에 가서 progress wheel이 잘 나타나는지 테스트해준다. (너무 순식간에 지나가서 잘 안보임ㅎ..)


Alert

LoginView.swift

 

entryView.swift에서 테스트

 

 


회원가입 기능

MemberViewModel.swift

func login()을 복사해서 join으로 바꾸고, 안에 내용도 그에 맞게 수정한다.

   func join(userName: String, password: String) {
        SVProgressHUD.show()
        
        let url = "\(endPoint)/members/sign-up"
        let params:Parameters = ["userName":userName, "password":password]
        // request
        AF.request(url, method: .post, parameters: params)
            .response { response in // response handling
                if let statusCode = response.response?.statusCode {
                    self.isJoinSuccess = true
                    // error handling per statusCode
                    switch statusCode {
                        // case 1
                    case 200..<300:
                        if let data = response.data {
                            // data decode error handling
                            do {
                                let signUp = try JSONDecoder().decode(SignIn.self, from: data)
                                // change user status to logged in
                                self.message = signUp.message
                                
                            } catch let error {
                                
                                self.message =  error.localizedDescription
                            }
                            
                        }
                        // case 2
                    case 300..<600:
                        if let data = response.data {
                            do {
                                let apiError = try JSONDecoder().decode(APIError.self, from: data)
                                self.message = apiError.message
                            } catch let error {
                                self.message = error.localizedDescription
                            }
                        }
                        
                        // case default
                    default:
                        self.message = "알 수 없는 에러가 발생했습니다."
                        
                    }
                }
                
            }
        SVProgressHUD.dismiss()
    }

 

 

LoginView.swift

회원가입 버튼 안의 클로저를 채워주고, alert도 수정자로 넣어준다.

                WideImageButton(icon: "person.badge.key", title: "회원가입", backgroundColor: .pink) {
                    memberVM.join(userName: userID, password: password)
                }.alert("회원가입", isPresented: $memberVM.isJoinSuccess) {
                    Button("확인") {
                        memberVM.isJoinSuccess = false
                    }
                } message: {
                    Text(memberVM.message)
                }

 

 


SaleViewModel.swift

ViewModel 안에 새 파일을 만들어준다. 아래와 같이 큰 틀(함수 두개)을 잡는다.

 

 

무한 스크롤 구현

무한 스크롤을 할려면 기존에 있는 데이터 array에 데이터를 추가해주는 방식으로 보여주면 된다.

 

내가 데이터를 로딩하고 있는데 또 가지고 오면 안된다 (=로딩중이면 무시를 해야한다) -> 변수 생성

 

그리고

 

@AppStorage는 사용자 기본 설정(UserDefaults)에 값을 저장하고 읽어오는 데 사용된다.이 키워드를 사용하면 앱의 데이터를 로컬 저장소에 쉽게 저장할 수 있으며, 앱이 종료되더라도 데이터가 유지된다. 즉, UserDefaults로 쓰는걸 property wrapper로 만들어놓은거라 둘 다 큰 차이는 없다고 한다..?

 

 

 

그리고, Bearer가 아니면 401에러를 낸다.

 

 

 

무한스크롤

 


View에 Sale 폴더 생성 > SaleListView.swift 생성

 

MainView.swift에 SaleListView()를 실어주기


 

SaleListView.swift 에 saleVM 

 


EntryView.swift 에서

let sameVM = SaleViewModel ()

import SwiftUI

struct EntryView: View {
    @EnvironmentObject var memberVM: MemberViewModel
    
    
    var body: some View {
        
        if memberVM.isLoggedIn {
            MainView()
        } else {
            LoginView(userID:"lia", password: "1234").transition(.move(edge:.bottom))
        }
    }
}

#Preview {
    let memberVM = MemberViewModel()
    let saleVM = SaleViewModel()
    EntryView().environmentObject(memberVM).environmentObject(saleVM)
}

 

작업을 하고, SaleViewModel.swift에서 debug를 위한 콘솔 출력 한줄을 적는다.

 

그리고 로그에 출력되는 샘플 데이터를 가져와준다.

 

 

 

 


 

SaleRowView.swift

에서 작업을 해준다.

 

 

 

 

strURL의 경우는, 애져에서 스토리지 계정 > 스토리지 브라우저 > Blob 컨테이너 > 컨테이너명 선택 > 

 

해서 하나의 이미지 주소를 가져와준다.

 

 


 

SaleListView.swift

 

이렇게 되어있는데(좌),

오른쪽으로 업데이트를 해준다.

 

Model.swift를 보면, Document는 Identifiable, Equatable이 있는데 Equatable의 쓰임새는 이따가 보고, 일단 Identifiable 이라는 점

이렇게 완성한다.

import SwiftUI

struct SaleListView: View {
    @EnvironmentObject var saleVM: SaleViewModel
    var body: some View {
        List(saleVM.sales) {sale in
            SaleRowView(sale: sale)
        }.onAppear {
            saleVM.fetchSales()
        }
    }
}

#Preview {
    SaleListView()
}

 

 

 

EntryView.swift에서 검사한다.

 

Comments