[UIKit] InstagramClone: Login Logic

Junyoung Park·2022년 11월 6일
0

UIKit

목록 보기
81/142
post-thumbnail

Build Instagram App: Part 4 (Swift 5) - 2020 - Xcode 11 - iOS Development

InstagramClone: Login Logic

구현 목표

  • 로그인 기능 구현

구현 태스크

  • 회원가입 뷰 UI
  • 회원가입 로직: (1). 텍스트 필드 입력 체크 (2). 파이어베이스 데이터베이스 유저 정보 중복 체크 (3). 파이어베이스 회원가입 완료 (4). 회원가입 뷰 전환
  • 로그인 로직: (1). 텍스트 필드 입력 체크 (2). 파이어베이스 로그인 완료 (3). 로그인 뷰 전환

핵심 코드

func registerNewUser(userName: String, email: String, password: String) -> AnyPublisher<Bool, Never> {
        return Future { [weak self] promise in
            var registerSubscription: AnyCancellable?
            registerSubscription = DatabaseManager.shared
                .canCreateNewUser(with: email, userName: userName)
                .sink(receiveValue: { isSucceeded in
                    if isSucceeded {
                        self?.auth.createUser(withEmail: email, password: password, completion: { result, error in
                            if error == nil, result != nil {
                                var insertSubscription: AnyCancellable?
                                insertSubscription = DatabaseManager.shared.insertNewUser(with: email, userName: userName)
                                    .sink { isSucceeded in
                                        if isSucceeded {
                                            promise(.success(true))
                                        } else {
                                            promise(.success(false))
                                        }
                                        insertSubscription?.cancel()
                                    }
                            } else {
                                promise(.success(false))
                            }
                        })
                    } else {
                        promise(.success(false))
                    }
                    registerSubscription?.cancel()
                })
        }
        .eraseToAnyPublisher()
    }
  • AuthManager의 회원가입 함수
  • 데이터베이스 검사를 통해 해당 유저 가입 여부 체크
  • 파이어베이스 API 함수를 통해 가입 결과 반환
func canCreateNewUser(with email: String, userName: String) -> AnyPublisher<Bool, Never> {
        let collection = database.collection(Constants.userNameCollectionPath)
        let query = collection.whereField("userName", isEqualTo: userName)
        return Future { promise in
            query.getDocuments { snapshots, error in
                if
                    error == nil,
                    let snapshots = snapshots,
                    snapshots.documents.isEmpty {
                    promise(.success(true))
                } else {
                    promise(.success(false))
                }
            }
        }
        .eraseToAnyPublisher()
    }
  • 기존의 컴플리션 핸들러를 컴바인 스타일로 변환
  • 파이어스토어 쿼리문을 통한 컬렉션 내 필드 확인
func insertNewUser(with email: String, userName: String) -> AnyPublisher<Bool, Never> {
        return Future { [weak self] promise in
            if let collection = self?.database.collection(Constants.userNameCollectionPath) {
                collection.document().setData(["userName": userName, "email": email]) { error in
                    if let error = error {
                        print(error.localizedDescription)
                        promise(.success(false))
                    } else {
                        promise(.success(true))
                    }
                }
            } else {
                promise(.success(false))
            }
        }
        .eraseToAnyPublisher()
    }
  • 파이어베이스 파이어스토어 내 컬렉션 내 문서 삽입
func loginUser(userName: String?, email: String?, password: String) -> AnyPublisher<Bool, Error> {
        return Future { [weak self] promise in
            if let email = email {
                self?.auth.signIn(withEmail: email, password: password, completion: { result, error in
                    if
                        result != nil,
                        error == nil {
                        promise(.success(true))
                        print("Sing In Succeed")
                    } else {
                        print("Sign In Error")
                        promise(.failure(AuthError.loginDidFail))
                    }
                })
            } else if let userName = userName {
                promise(.failure(AuthError.loginDidFail))
            } else {
                promise(.failure(AuthError.loginDidFail))
            }
        }
        .eraseToAnyPublisher()
    }
  • 파이어베이스의 로그인 함수의 컴플리션 핸들러를 컴바인 스타일로 변환해 리턴

소스 코드

import Foundation
import FirebaseAuth
import Combine

class AuthManager {
    enum AuthError: LocalizedError {
        case loginDidFail
        case createUserDidFail
    }
    
    static let shared = AuthManager()
    private let auth = FirebaseAuth.Auth.auth()
    
    private init() {}
    
    func isSignedIn() -> Bool {
        return auth.currentUser != nil
    }
    
    func logout() -> AnyPublisher<Bool, Error> {
        return Future { [weak self] promise in
            do {
                try self?.auth.signOut()
                promise(.success(true))
            } catch {
                print(error.localizedDescription)
                promise(.success(false))
            }
        }
        .eraseToAnyPublisher()
    }
    
    func loginUser(userName: String?, email: String?, password: String) -> AnyPublisher<Bool, Error> {
        return Future { [weak self] promise in
            if let email = email {
                self?.auth.signIn(withEmail: email, password: password, completion: { result, error in
                    if
                        result != nil,
                        error == nil {
                        promise(.success(true))
                        print("Sing In Succeed")
                    } else {
                        print("Sign In Error")
                        promise(.failure(AuthError.loginDidFail))
                    }
                })
            } else if let userName = userName {
                promise(.failure(AuthError.loginDidFail))
            } else {
                promise(.failure(AuthError.loginDidFail))
            }
        }
        .eraseToAnyPublisher()
    }
    
    func registerNewUser(userName: String, email: String, password: String) -> AnyPublisher<Bool, Never> {
        return Future { [weak self] promise in
            var registerSubscription: AnyCancellable?
            registerSubscription = DatabaseManager.shared
                .canCreateNewUser(with: email, userName: userName)
                .sink(receiveValue: { isSucceeded in
                    if isSucceeded {
                        self?.auth.createUser(withEmail: email, password: password, completion: { result, error in
                            if error == nil, result != nil {
                                var insertSubscription: AnyCancellable?
                                insertSubscription = DatabaseManager.shared.insertNewUser(with: email, userName: userName)
                                    .sink { isSucceeded in
                                        if isSucceeded {
                                            promise(.success(true))
                                        } else {
                                            promise(.success(false))
                                        }
                                        insertSubscription?.cancel()
                                    }
                            } else {
                                promise(.success(false))
                            }
                        })
                    } else {
                        promise(.success(false))
                    }
                    registerSubscription?.cancel()
                })
        }
        .eraseToAnyPublisher()
    }
    
}
  • 싱글턴 패턴으로 간단히 구현한 AuthManager
  • 파이어베이스 회원 관련 기능 제공하는 매니저 클래스
import Foundation
import Combine
import Firebase

class DatabaseManager {
    struct Constants {
        static let userNameCollectionPath = "userNameCollectionPath"
    }
    static let shared = DatabaseManager()
    private let database = Firestore.firestore()
    
    private init() {}
    
    func canCreateNewUser(with email: String, userName: String) -> AnyPublisher<Bool, Never> {
        let collection = database.collection(Constants.userNameCollectionPath)
        let query = collection.whereField("userName", isEqualTo: userName)
        return Future { promise in
            query.getDocuments { snapshots, error in
                if
                    error == nil,
                    let snapshots = snapshots,
                    snapshots.documents.isEmpty {
                    promise(.success(true))
                } else {
                    promise(.success(false))
                }
            }
        }
        .eraseToAnyPublisher()
    }
    
    func insertNewUser(with email: String, userName: String) -> AnyPublisher<Bool, Never> {
        return Future { [weak self] promise in
            if let collection = self?.database.collection(Constants.userNameCollectionPath) {
                collection.document().setData(["userName": userName, "email": email]) { error in
                    if let error = error {
                        print(error.localizedDescription)
                        promise(.success(false))
                    } else {
                        promise(.success(true))
                    }
                }
            } else {
                promise(.success(false))
            }
        }
        .eraseToAnyPublisher()
    }
}
  • 싱글턴 패턴으로 구현한 데이터베이스 매니저
  • 파이어베이스 파이어스토어와 관련된 기능을 제공하는 매니저 클래스
import UIKit
import Combine

class RegistrationViewController: UIViewController {
    struct Constants {
        static let cornerRadius: CGFloat = 8.0
    }
    private let nameTextField: UITextField = {
        let textField = UITextField()
        textField.placeholder = "UserName"
        textField.returnKeyType = .next
        textField.leftViewMode = .always
        textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 0))
        textField.autocapitalizationType = .none
        textField.autocorrectionType = .no
        textField.layer.cornerRadius = Constants.cornerRadius
        textField.layer.masksToBounds = true
        textField.backgroundColor = .secondarySystemBackground
        textField.layer.borderWidth = 1.0
        textField.layer.borderColor = UIColor.secondaryLabel.cgColor
        textField.clearButtonMode = .always
        textField.tag = 1
        return textField
    }()
    
    private let emailTextField: UITextField = {
        let textField = UITextField()
        textField.placeholder = "Email Address"
        textField.returnKeyType = .next
        textField.leftViewMode = .always
        textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 0))
        textField.autocapitalizationType = .none
        textField.autocorrectionType = .no
        textField.layer.cornerRadius = Constants.cornerRadius
        textField.layer.masksToBounds = true
        textField.backgroundColor = .secondarySystemBackground
        textField.layer.borderWidth = 1.0
        textField.layer.borderColor = UIColor.secondaryLabel.cgColor
        textField.clearButtonMode = .always
        textField.tag = 1
        return textField
    }()
    private let passwordTextField: UITextField = {
        let textField = UITextField()
        textField.textContentType = .newPassword
        textField.placeholder = "Password"
        textField.returnKeyType = .continue
        textField.leftViewMode = .always
        textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 0))
        textField.autocapitalizationType = .none
        textField.autocorrectionType = .no
        textField.layer.cornerRadius = Constants.cornerRadius
        textField.layer.masksToBounds = true
        textField.backgroundColor = .secondarySystemBackground
        textField.layer.borderWidth = 1.0
        textField.layer.borderColor = UIColor.secondaryLabel.cgColor
        textField.clearButtonMode = .always
        textField.tag = 2
        return textField
    }()
    private let registerButton: UIButton = {
        let button = UIButton()
        button.setTitle("Sign Up", for: .normal)
        button.layer.masksToBounds = true
        button.layer.cornerRadius = Constants.cornerRadius
        button.backgroundColor = .systemGreen
        button.setTitleColor(.white, for: .normal)
        return button
    }()
    private var cancellables = Set<AnyCancellable>()
    private let viewModel = RegistrationViewModel()
    private let input: PassthroughSubject<RegistrationViewModel.Input, Never> = .init()

    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        bind()
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        nameTextField.frame = CGRect(x: 20, y: view.safeAreaInsets.top + 10, width: view.width - 40, height: 52)
        emailTextField.frame = CGRect(x: 20, y: nameTextField.bottom + 10, width: view.width - 40, height: 52)
        passwordTextField.frame = CGRect(x: 20, y: emailTextField.bottom + 10, width: view.width - 40, height: 52)
        registerButton.frame = CGRect(x: 20, y: passwordTextField.bottom + 10, width: view.width - 40, height: 52)
    }
    
    private func setUI() {
        view.backgroundColor = .systemBackground
        view.addSubview(nameTextField)
        view.addSubview(emailTextField)
        view.addSubview(passwordTextField)
        view.addSubview(registerButton)
    }
    
    private func handleRegisterResult(result: Bool) {
        if result {
            dismiss(animated: true, completion: nil)
        } else {
            let alert = UIAlertController(title: "Sign Up Error", message: "Unable to Sign Up", preferredStyle: .alert)
            alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
            present(alert, animated: true)
        }
    }
    
    private func bind() {
        let output = viewModel.transform(input: input.eraseToAnyPublisher())
        output
            .receive(on: DispatchQueue.main)
            .sink { [weak self] result in
                switch result {
                case .isRegisterDidSucceed(result: let result): self?.handleRegisterResult(result: result)
                }
            }
            .store(in: &cancellables)
        nameTextField
            .textPublisher
            .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
            .sink { [weak self] text in
                self?.input.send(.nameInput(text: text))
            }
            .store(in: &cancellables)
        nameTextField
            .controlPublisher(for: .editingDidEndOnExit)
            .sink { [weak self] _ in
                self?.emailTextField.becomeFirstResponder()
            }
            .store(in: &cancellables)
        emailTextField
            .textPublisher
            .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
            .sink { [weak self] text in
                self?.input.send(.emailInput(text: text))
            }
            .store(in: &cancellables)
        emailTextField
            .controlPublisher(for: .editingDidEndOnExit)
            .sink { [weak self] _ in
                self?.passwordTextField.becomeFirstResponder()
            }
            .store(in: &cancellables)
        passwordTextField
            .textPublisher
            .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
            .sink { [weak self] text in
                self?.input.send(.passwordInput(text: text))
            }
            .store(in: &cancellables)
        passwordTextField
            .controlPublisher(for: .editingChanged)
            .sink { [weak self] _ in
                self?.passwordTextField.isSecureTextEntry = true
            }
            .store(in: &cancellables)
        passwordTextField
            .controlPublisher(for: .editingDidEndOnExit)
            .sink { [weak self] _ in
                self?.passwordTextField.resignFirstResponder()
                self?.registerButton.sendActions(for: .touchUpInside)
            }
            .store(in: &cancellables)
        registerButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.nameTextField.resignFirstResponder()
                self?.emailTextField.resignFirstResponder()
                self?.passwordTextField.resignFirstResponder()
                self?.input.send(.registerButtonDidTap)
            }
            .store(in: &cancellables)
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(backgroundViewDidTap))
        view.addGestureRecognizer(tapGesture)
    }
    
    @objc private func backgroundViewDidTap() {
        emailTextField.resignFirstResponder()
        nameTextField.resignFirstResponder()
        passwordTextField.resignFirstResponder()
    }
}
  • 로그인 뷰 컨트롤러와 동일한 형태의 UI
import Foundation
import Combine

class RegistrationViewModel {
    enum Input {
        case nameInput(text: String)
        case emailInput(text: String)
        case passwordInput(text: String)
        case registerButtonDidTap
    }
    enum Output {
        case isRegisterDidSucceed(result: Bool)
    }
    private let nameText: CurrentValueSubject<String?, Never> = .init(nil)
    private let emailText: CurrentValueSubject<String?, Never> = .init(nil)
    private let passwordText: CurrentValueSubject<String?, Never> = .init(nil)
    private var cancellables = Set<AnyCancellable>()
    private let authManager = AuthManager.shared
    private let output: PassthroughSubject<Output, Never> = .init()
    
    func transform(input: AnyPublisher<Input, Never>) -> AnyPublisher<Output, Never> {
        input
            .receive(on: DispatchQueue.global(qos: .default))
            .sink { [weak self] result in
                switch result {
                case .emailInput(text: let text):
                    self?.emailText.send(text)
                case .nameInput(text: let text):
                    self?.nameText.send(text)
                case .passwordInput(text: let text):
                    self?.passwordText.send(text)
                case .registerButtonDidTap:
                    self?.handleRegister()
                }
            }
            .store(in: &cancellables)
        return output.eraseToAnyPublisher()
    }
    
    private func handleRegister() {
        guard
            let email = emailText.value, !email.isEmpty,
            let password = passwordText.value, !password.isEmpty, password.count >= 6,
            let userName = nameText.value, !userName.isEmpty else { return }
        var registerSubscription: AnyCancellable?
        registerSubscription = authManager
            .registerNewUser(userName: userName, email: email, password: password)
            .sink(receiveValue: { [weak self] isSucceeded in
                self?.output.send(.isRegisterDidSucceed(result: isSucceeded))
                registerSubscription?.cancel()
            })
    }
}
  • 이메일, 이름, 비밀번호 텍스트 필드의 값을 통해 회원가입 함수를 사용하는 뷰 모델
  • 회원가입 뷰의 UI 컴포넌트와 관련된 모든 인풋을 총괄

구현 화면

profile
JUST DO IT

0개의 댓글