✅ 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)
}
}
'Project > MovieClip' 카테고리의 다른 글
❌ 컴파일 오류 발생... (0) | 2025.03.09 |
---|---|
📌 회원탈퇴 구현 (Storage, Firestore Database) (1) | 2025.03.04 |
❌ 문제 해결... ProfileItem이 Hashable 및 Equatable 프로토콜을 준수하지 않는다? (0) | 2025.03.01 |
💾 Firestore 에 유저 정보 저장 및 이미지 업로드 (0) | 2025.02.28 |
👤 Firebase에 로그인, 회원정보를 FireStore 저장, 회원정보 불러오기 (0) | 2025.02.27 |