본문 바로가기

Project/MovieClip

✅ 프로필 수정하기! (기존 프로필 입력 창 사용하기)

 

ProfileCell의 editButton을 눌렀을 때, ProfileDataFormViewController로 이동 & Firebase 업데이트 흐름

현재 구현하려는 기능을 정리하면:

1️⃣ ProfileCell의 editButton을 누르면 ProfileDataFormViewController로 이동
2️⃣ 이동한 ProfileDataFormViewController에서 기존 user 정보를 받아서 표시
3️⃣ 유저가 정보 수정 후 "완료" 버튼을 누르면 Firebase에 업데이트
4️⃣ ProfileViewController에서 UI를 업데이트하여 변경된 프로필 정보를 반영

🔥 가장 좋은 방법은?

Delegate 패턴을 활용해서 수정된 데이터를 ProfileViewController로 전달
Firebase에 저장된 유저 정보를 업데이트한 후, UI에도 반영

 

 

1️⃣ ProfileDataFormViewControllerDelegate 프로토콜 생성

ProfileDataFormViewController에서 유저 정보를 수정한 후,
👉 ProfileViewController로 변경된 데이터를 전달하기 위한 Delegate를 만든다.

protocol ProfileDataFormViewControllerDelegate: AnyObject {
    func didUpdateUser(_ updatedUser: MovieClipUser)
}

 

2️⃣ ProfileDataFormViewController에서 수정된 유저 정보 전달

📍 ProfileDataFormViewController에서 기존 user 데이터를 화면에 표시

👉 viewModel을 초기화하면서 기존 user 정보를 세팅

📍 유저가 수정 후 "완료" 버튼을 누르면 Firebase에 업데이트

👉 viewModel.uploadAvatar() 실행 → Firebase Storage에 사진 업로드 후 updateUserData() 실행

📍 Firebase 업데이트 후, Delegate를 통해 변경된 user 데이터를 ProfileViewController로 전달

👉 delegate?.didUpdateUser(updatedUser)

class ProfileDataFormViewController: UIViewController {
    
    // MARK: - Variable
    private var viewModel = ProfileDataFormViewModel()
    private var cancelable: Set<AnyCancellable> = []
     
    weak var delegate: ProfileDataFormViewControllerDelegate?     // ✅ 대리자 선언
    var user: MovieClipUser   // ✅ 기존 유저 정보 저장 
    
    
    // MARK: - UI Component
    private let basicScrollView: UIScrollView = UIScrollView()
    private let hintLabel: UILabel = UILabel()
    private let usernameTextField: UITextField = UITextField()
    private let avatarPlaceholderImageView: UIImageView = UIImageView()
    private let bioTextView: UITextView = UITextView()
    private let submitButton: UIButton = UIButton()

    
    // MARK: - Init
    init(user: MovieClipUser, isInitialProfileSetup: Bool = false) {
        self.user = user
        self.viewModel.isInitialProfileSetup = isInitialProfileSetup
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    
    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black
        
        //usernameTextField.delegate = self
        bioTextView.delegate = self
        
        isModalInPresentation = true   // ✅ 창 내리기 방지
        bindViews()
        view.addGestureRecognizer(UIGestureRecognizer(target: self, action: #selector(didTapToDismiss)))
        submitButton.addTarget(self, action: #selector(didTapSubmit), for: .touchUpInside)
        
        configureAvatarImage()
        configureUI()
        
        setUserData()
    }
    
    
    // MARK: - Functio
    // ✅ 기존 유저 정보를 UI에 표시 
    private func setUserData() {
        usernameTextField.text = user.username
        bioTextView.text = user.bio
        if let avatarPath = URL(string: user.avatarPath) {
            avatarPlaceholderImageView.sd_setImage(with: avatarPath)
        }
        
        // 기존 프로필 이미지 ViewModel에 저장
        viewModel.existingAvatarPath = user.avatarPath
        
        // viewModel에도 기존 데이터 반영
        viewModel.username = user.username
        viewModel.bio = user.bio
        viewModel.avatarPath = user.avatarPath
    }
    
    
    
    /// 프로필 이미지 선택 메서드
    private func configureAvatarImage() {
        let imageTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapToUpload))
        avatarPlaceholderImageView.addGestureRecognizer(imageTapGesture)
    }
    
    private func bindViews() {
        usernameTextField.addTarget(self, action: #selector(didUpdateUsername), for: .editingChanged)
        
        viewModel.$isFormValid
            .sink { [weak self] buttonState in
                self?.submitButton.isEnabled = buttonState
                dump("\(self!.submitButton.isEnabled)")
                self?.submitButton.backgroundColor = buttonState ? .systemBlue : .systemGray
            }
            .store(in: &cancelable)
        
        viewModel.$isOnboardingFinished
            .sink { [weak self] success in
                if success {
                    // ✅ 기존의 모든 화면을 닫고, OnboardingViewController로 이동
                    self?.view.window?.rootViewController?.dismiss(animated: true, completion: {
                        if let sceneDelegate = UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate {
                            sceneDelegate.window?.rootViewController = MainTabBarController()
                        }
                    })
                }
            }
            .store(in: &cancelable)
    }
    
    
    // MARK: - Action
    @objc private func didTapToDismiss() {
        view.endEditing(true)
    }
    
    @objc private func didTapToUpload() {
        ...
    }
    
    @objc private func didUpdateUsername() {
        viewModel.username = usernameTextField.text
        viewModel.validateUserProfileForm()
    }
    
    @objc private func didTapSubmit() {
        viewModel.uploadAvatar()
    }

    
    // MARK: - UI Layout
    private func configureUI() {
        ...
    }
}


// MARK: - Extension: UITextViewDelegate
extension ProfileDataFormViewController: UITextViewDelegate {
    ...
}


extension ProfileDataFormViewController: PHPickerViewControllerDelegate {
    ..
}


// ✅ 프르토콜 선언 
// MARK: - Protocol
protocol ProfileDataFormViewControllerDelegate: AnyObject {
    func didUpdateUser(_ updatedUser: MovieClipUser)
}

 

 

3️⃣ ProfileViewController에서 Delegate 채택 & Firebase 데이터 반영

👉 ProfileViewController가 ProfileDataFormViewControllerDelegate를 구현하여 수정된 유저 데이터를 받음
👉 받은 데이터를 Firebase에서 다시 불러와 UI를 최신 상태로 유지

 

class ProfileViewController: UIViewController, ProfileDataFormViewControllerDelegate {
    
    
    // MARK: - Variable
    private var dataSource: UICollectionViewDiffableDataSource<ProfileSection, ProfileItem>!
    private var viewModel = ProfileViewModel()
    private var cancelable: Set<AnyCancellable> = []
    
    
    // MARK: - UI Component
    private var profileCollectionView: UICollectionView!
    
    
    // MARK: - Life Cycle
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black
        
        configureNavigationBarAppearance()
        configureNavigationLeftTitle()
        setupCollectionView()
        createDataSource()
        setupBindings()  // ✅ 사용자 정보 변경 시 자동으로 UI 업데이트
        
        // 로그아웃 버튼
        navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "rectangle.portrait.and.arrow.right"), style: .plain, target: self, action: #selector(didTapSignOut))
        navigationItem.rightBarButtonItem?.tintColor = .white
        
    }
    
    
    // MARK: - Function
    private func setupCollectionView() {
      ...
    }
    
    private func configure<T: SelfConfiguringProfileCell>(_ cellType: T.Type, with model: ProfileItem, for indexPath: IndexPath) -> T {
        ...
    }
    
    private func createDataSource() {
        dataSource = UICollectionViewDiffableDataSource<ProfileSection, ProfileItem>(collectionView: profileCollectionView) { collectionView, indexPath, item in
            let section: ProfileSection = ProfileSection.allCases[indexPath.section]
            
            switch section {
            case .profile:
                
                let cell = self.configure(ProfileCell.self, with: item, for: indexPath)
                
                // ✅ ProfileCell 델리게이트 대리자 할당
                cell.delegate = self
                
                return cell
                
            default:
                return self.configure(ProfileCell.self, with: item, for: indexPath)
                
            }
        }
        
       ...
    }
    
    // ✅ ViewModel의 데이터가 변경될 때 자동으로 UI 업데이트하는 바인딩 설정
    private func setupBindings() {
        viewModel.$user
            .sink { [weak self] _ in
                self?.reloadData()  // ✅ user가 변경될 때 자동으로 데이터 업데이트
            }
            .store(in: &cancelable)
    }
    
    
    private func reloadData() {
        ...
    }
    
    
    private func createCompositionalLayout() -> UICollectionViewLayout {
        
        ...
    }
    
    
    private func createFeaturedSection(using section: ProfileSection) -> NSCollectionLayoutSection {
        
       ...
    }
    
    
    private func createSectionHeader() -> NSCollectionLayoutBoundarySupplementaryItem {
        ...
    }
    
    
    private func configureNavigationLeftTitle() {
       ...
    }
    
    private func configureNavigationBarAppearance() {
       ...
    }
    
    
    // ✅ 프로필 수정 화면으로 이동
    func navigateToProfileEdit() {
        let editVC = ProfileDataFormViewController(user: viewModel.user, isInitialProfileSetup: false)
       
        editVC.delegate = self // ✅ Delegate 설정
        
        navigationController?.pushViewController(editVC, animated: true)
    }
    
    // ✅ ProfileDataFormViewController에서 수정 완료 후 호출됨
    func didUpdateUser(_ updatedUser: MovieClipUser) {
        viewModel.user = updatedUser  // ✅ ViewModel 업데이트
        reloadData()  // ✅ UI 업데이트

        // ✅ Firebase에서 최신 데이터 다시 불러오기 (Optional)
        viewModel.retreiveUser()
    }
    
    
    // MARK: - Action
    @objc private func didTapSignOut() {
        do {
            try Auth.auth().signOut()
            
            // ✅ 기존의 모든 화면을 닫고, OnboardingViewController로 이동
            self.view.window?.rootViewController?.dismiss(animated: true, completion: {
                if let sceneDelegate = UIApplication.shared.connectedScenes.first?.delegate as? SceneDelegate {
                    sceneDelegate.window?.rootViewController = UINavigationController(rootViewController: OnboardingViewController())
                }
            })
        } catch {
            print("로그아웃 실패: \(error.localizedDescription)")
        }
    }
}


// MARK: - Extension: ProfileCellDelegate 구현
extension ProfileViewController: ProfileCellDelegate {
    func didTapEditButton() {
        navigateToProfileEdit()
    }
}

 

4️⃣ ProfileCell에서 editButton을 눌렀을 때 ProfileViewController로 전달

👉 ProfileCell에서 editButton이 눌리면 ProfileViewController에 이벤트를 전달해야 함.
👉 이를 위해 ProfileCellDelegate를 추가하고, 버튼 액션을 ProfileViewController로 넘겨줌.

class ProfileCell: UICollectionViewCell, SelfConfiguringProfileCell {
    
    
    // MARK: - Variable
    static var reuseIdentifier: String = "ProfileCell"
    
    
    weak var delegate: ProfileCellDelegate?
    
    
    // MARK: - UI Component
    private let profileImage: UIImageView = UIImageView()
    private let usernameLabel: UILabel = UILabel()
    private let seperator: UIView = UIView()
    private let overviewTextView: UITextView = UITextView()
    private let editButton: UIButton = UIButton(type: .system)
    
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.backgroundColor = .white
        setupUI()
        setupEditButton()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    
    // MARK: - Function
    func configure(with data: ProfileItem) {
        ...
    }
    
    func setupEditButton() {
        editButton.addTarget(self, action: #selector(didTapEditButton), for: .touchUpInside)
    }
    
    
    // MARK: - Action
    @objc private func didTapEditButton() {
        delegate?.didTapEditButton()
    }
    
    // MARK: - Constraints
    private func setupUI() {
        ...
}


// MARK: - Protocol
protocol ProfileCellDelegate: AnyObject {
    func didTapEditButton()
}

 

 

📌 ProfileDataFormViewController를 공용으로 사용

회원가입 시 최초 프로필 작성프로필 수정을 같은 ProfileDataFormViewController에서 처리하도록 설계했습니다.
이를 위해 isInitialProfileSetup 변수를 추가하여 최초 프로필 설정과 수정 모드를 구분하였습니다.

 

final class ProfileDataFormViewModel: ObservableObject {
    
    // MARK: - @Published
    @Published var username: String?
    @Published var bio: String?
    @Published var avatarPath: String?
    @Published var existingAvatarPath: String?  // ✅ 기존 프로필 이미지 URL 저장
    @Published var imageData: UIImage?
    @Published var isFormValid: Bool = false
    @Published var error: String = ""
    @Published var isOnboardingFinished: Bool = false
    @Published var isInitialProfileSetup: Bool = false   // ✅ 최초, 수정 모드 구분
    
    private var cancelable: Set<AnyCancellable> = []

    
    // MARK: - Function    
    func validateUserProfileForm() {
        // ✅ 초기 프로필 작성 시
        if isInitialProfileSetup {
            guard let username = username, username.count > 2 else {
                isFormValid = false
                return
            }
        }
        // ✅ 프로필 수정 시: username이 없어도 기존 데이터 유지 가능
        else {
            guard let username = username, !username.isEmpty else {
                isFormValid = false
                return
            }
        }
        
        isFormValid = true
    }

    
    /// 사용자의 프로필 사진을 Firebase Storage에 업로드하고, 업로드된 이미지의 다운로드 URL을 가져와 저장하는 메서드
    func uploadAvatar() {
        
        let userID = Auth.auth().currentUser?.uid ?? ""
        
        // ✅ 이미지가 없을 경우 기존 프로필 이미지를 유지 (수정 모드)
        if imageData == nil {
            if !isInitialProfileSetup, let existingAvatarPath {
                avatarPath = existingAvatarPath   // 기존 프로필 이미지 유지
            } else {
                // 최초 프로필 설정일 경우 기본 이미지 사용
                avatarPath = "https://ssl.pstatic.net/static/pwe/address/img_profile.png"
            }
            updateUserData()
            return
        }
        

        guard let imageData = imageData?.jpegData(compressionQuality: 0.5) else { return }
        let metaData = StorageMetadata()
        metaData.contentType = "image/jpeg"
        
        StorageManager.shared.uploadProfilePhoto(with: userID, image: imageData, metaData: metaData)
            .flatMap { metaData in
                StorageManager.shared.getDownloadURL(for: metaData.path)
            }
            .sink { [weak self] completion in
                switch completion {
                case .failure(let error):
                    print(error.localizedDescription)
                    self?.error = error.localizedDescription
                case .finished:
                    self?.updateUserData()
                }
            } receiveValue: { [weak self] url in
                self?.avatarPath = url.absoluteString
                self?.existingAvatarPath = url.absoluteString 
            }
            .store(in: &cancelable)
    }
    
    /// 프로필에 작성된 내용을 파이어베이스 데이터베이스에 저장하는 메서드
    private func updateUserData() {
        guard let username,
              let bio,
              let avatarPath,
              let id = Auth.auth().currentUser?.uid else { return }
        
        let updateFields: [String: Any] = [
            "username": username,
            "bio": bio,
            "avatarPath": avatarPath,
            "isUserOnboarded": true
        ]
        
        DatabaseManager.shared.collectionUsers(updateFields: updateFields, for: id)
            .sink { [weak self] completion in
                if case .failure(let error) = completion {
                    print(error.localizedDescription)
                    self?.error = error.localizedDescription
                }
            } receiveValue: { [weak self] onboardingState in
                self?.isOnboardingFinished = onboardingState
            }
            .store(in: &cancelable)

    }
}