[UIKit] InstagramClone: LoginView

Junyoung Park·2022년 11월 6일
0

UIKit

목록 보기
80/142
post-thumbnail

Build Instagram App: Part 3 (Swift 5) - 2022 - Xcode 11 - iOS Development

InstagramClone:

구현 목표

  • 로그인 뷰 UI 구현
  • MVVM 스타일 연결

구현 태스크

  • 로그인 뷰 모델 - 뷰 컨트롤러 연결
  • 뷰 컴포넌트의 컨트롤 핸들링

핵심 코드

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)
                    print("Email: \(text)")
                case .passwordInput(text: let text):
                    self?.passwordText.send(text)
                    print("Password: \(text)")
                case .loginButtonDidTap:
                    self?.handleLogin()
                case .createAccountButtonDidTap:
                    break
                case .termsButtonDidTap:
                    self?.output.send(.showTermsView)
                case .privacyButtonDidTap:
                    self?.output.send(.showPrivacyView)
                }
            }
            .store(in: &cancellables)
        return output.eraseToAnyPublisher()
    }
  • 뷰 컨트롤러의 모든 뷰 컴포넌트를 통한 컨트롤을 뷰 모델이 관리
  • 텍스트 필드의 각 텍스트, 버튼 클릭 이벤트 등 관리
private func bind() {
        let output = viewModel.transform(input: input.eraseToAnyPublisher())
        output
            .receive(on: DispatchQueue.main)
            .sink { [weak self] result in
                switch result {
                case .showTermsView:
                    self?.showTermsView()
                case .showPrivacyView:
                    self?.showPrivacyView()
                case .isLoginDidSucceed(result: _): break
                }
            }
            .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: .editingDidEndOnExit)
            .sink { [weak self] _ in
                self?.passwordTextField.resignFirstResponder()
                self?.loginButton.sendActions(for: .touchUpInside)
            }
            .store(in: &cancellables)
        loginButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.input.send(.loginButtonDidTap)
            }
            .store(in: &cancellables)
        termsButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.input.send(.termsButtonDidTap)
            }
            .store(in: &cancellables)
        privacyButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.input.send(.privacyButtonDidTap)
            }
            .store(in: &cancellables)
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(backgroundViewDidTap))
        view.addGestureRecognizer(tapGesture)
    }
  • 뷰 컨트롤러가 뷰 모델이 주는 아웃풋에 따라 어떤 동작을 뷰에 보여야 할지 결정
  • 뷰 컨트롤러의 버튼 클릭 이벤트 → 뷰 모델 인풋 → 뷰 모델 아웃풋 → 아웃풋에 대한 뷰 컨트롤러의 결정
  • termsButtonDidTap 등 버튼 이벤트에 대한 결과가 곧바로 sink 단에서 처리해도 상관없는 부분이 있지만 통일성을 위해 모두 뷰 모델에게 인풋을 보내는 방향으로 코드 작성

소스 코드

import Foundation
import Combine

class LoginViewModel {
    enum Input {
        case emailInput(text: String)
        case passwordInput(text: String)
        case loginButtonDidTap
        case createAccountButtonDidTap
        case termsButtonDidTap
        case privacyButtonDidTap
    }
    enum Output {
        case isLoginDidSucceed(result: AnyPublisher<Bool, Never>)
        case showTermsView
        case showPrivacyView
    }
    private let emailText: PassthroughSubject<String, Never> = .init()
    private let passwordText: PassthroughSubject<String, Never> = .init()
    private let output: PassthroughSubject<Output, Never> = .init()
    private var cancellables = Set<AnyCancellable>()
    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)
                    print("Email: \(text)")
                case .passwordInput(text: let text):
                    self?.passwordText.send(text)
                    print("Password: \(text)")
                case .loginButtonDidTap:
                    self?.handleLogin()
                case .createAccountButtonDidTap:
                    break
                case .termsButtonDidTap:
                    self?.output.send(.showTermsView)
                case .privacyButtonDidTap:
                    self?.output.send(.showPrivacyView)
                }
            }
            .store(in: &cancellables)
        return output.eraseToAnyPublisher()
    }
    
    private func handleLogin() {
        // authentifiaction manager with current text input
        output.send(.isLoginDidSucceed(result: Just(true).eraseToAnyPublisher()))
    }
}
import UIKit
import Combine
import SafariServices

class LoginViewController: UIViewController {
    struct Constants {
        static let cornerRadius: CGFloat = 8.0
    }
    
    private let emailTextField: UITextField = {
        let textField = UITextField()
        textField.placeholder = "UserName or Email"
        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.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.tag = 2
        return textField
    }()
    private let loginButton: UIButton = {
        let button = UIButton()
        button.setTitle("Log In", for: .normal)
        button.layer.masksToBounds = true
        button.layer.cornerRadius = Constants.cornerRadius
        button.backgroundColor = .systemBlue
        button.setTitleColor(.white, for: .normal)
        return button
    }()
    private let headerView: UIView = {
        let view = UIView()
        let backgroundImageView = UIImageView(image: UIImage(named: "gradient"))
        view.addSubview(backgroundImageView)
        view.clipsToBounds = true
        view.backgroundColor = .systemPurple
        return view
    }()
    private let termsButton: UIButton = {
        let button = UIButton()
        button.setTitle("Terms of Service", for: .normal)
        button.setTitleColor(.secondaryLabel, for: .normal)
        return button
    }()
    private let privacyButton: UIButton = {
        let button = UIButton()
        button.setTitle("Privacy Policy", for: .normal)
        button.setTitleColor(.secondaryLabel, for: .normal)
        return button
    }()
    private let createAccountButton: UIButton = {
        let button = UIButton()
        button.setTitleColor(.label, for: .normal)
        button.setTitle("Create Account", for: .normal)
        return button
    }()
    private let input: PassthroughSubject<LoginViewModel.Input, Never> = .init()
    private let viewModel = LoginViewModel()
    private var cancellables = Set<AnyCancellable>()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setUI()
        bind()
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        headerView.frame = CGRect(x: 0, y: 0, width: view.width, height: view.height / 3.0)
        emailTextField.frame = CGRect(x: 25, y: headerView.bottom + 40, width: view.width - 50, height: 52.0)
        passwordTextField.frame = CGRect(x: 25, y: emailTextField.bottom + 10, width: view.width - 50, height: 52.0)
        loginButton.frame = CGRect(x: 25, y: passwordTextField.bottom + 10, width: view.width - 50, height: 52.0)
        createAccountButton.frame = CGRect(x: 25, y: loginButton.bottom + 10, width: view.width - 50, height: 52.0)
        termsButton.frame = CGRect(x: 10, y: view.height - view.safeAreaInsets.bottom-100, width: view.width-20, height: 50)
        privacyButton.frame = CGRect(x: 10, y: view.height - view.safeAreaInsets.bottom-50, width: view.width-20, height: 50)
        configureHeaderView()
    }
    
    private func configureHeaderView() {
        guard
            headerView.subviews.count == 1,
            let backgroundImageView = headerView.subviews.first else { return }
        backgroundImageView.frame = headerView.bounds
        let logoView = UIImageView(image: UIImage(named: "text"))
        logoView.contentMode = .scaleAspectFit
        headerView.addSubview(logoView)
        logoView.frame = CGRect(x: headerView.width / 4.0, y: view.safeAreaInsets.top, width: headerView.width / 2, height: headerView.height - view.safeAreaInsets.top)
    }
    
    private func setUI() {
        view.backgroundColor = .systemBackground
        view.addSubview(emailTextField)
        view.addSubview(passwordTextField)
        view.addSubview(loginButton)
        view.addSubview(headerView)
        view.addSubview(termsButton)
        view.addSubview(privacyButton)
        view.addSubview(createAccountButton)
        emailTextField.becomeFirstResponder()
    }
    
    private func showTermsView() {
        guard let url = URL(string: "https://help.instagram.com/581066165581870") else { return }
        let vc = SFSafariViewController(url: url)
        present(vc, animated: true)
    }
    
    private func showPrivacyView() {
        guard let url = URL(string: "https://help.instagram.com/155833707900388") else { return }
        let vc = SFSafariViewController(url: url)
        present(vc, 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 .showTermsView:
                    self?.showTermsView()
                case .showPrivacyView:
                    self?.showPrivacyView()
                case .isLoginDidSucceed(result: _): break
                }
            }
            .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: .editingDidEndOnExit)
            .sink { [weak self] _ in
                self?.passwordTextField.resignFirstResponder()
                self?.loginButton.sendActions(for: .touchUpInside)
            }
            .store(in: &cancellables)
        loginButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.input.send(.loginButtonDidTap)
            }
            .store(in: &cancellables)
        termsButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.input.send(.termsButtonDidTap)
            }
            .store(in: &cancellables)
        privacyButton
            .tapPublisher
            .sink { [weak self] _ in
                self?.input.send(.privacyButtonDidTap)
            }
            .store(in: &cancellables)
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(backgroundViewDidTap))
        view.addGestureRecognizer(tapGesture)
    }
    
    @objc private func backgroundViewDidTap() {
        emailTextField.resignFirstResponder()
        passwordTextField.resignFirstResponder()
    }
}

구현 화면

거의 델리게이트를 사용하지 않아도 될 것 같다.

profile
JUST DO IT

0개의 댓글