diff --git a/Projects/Feature/Sources/Scene/Main/User/View/MainViewController.swift b/Projects/Feature/Sources/Scene/Main/User/View/MainViewController.swift index fda53a51..dc89aa3b 100644 --- a/Projects/Feature/Sources/Scene/Main/User/View/MainViewController.swift +++ b/Projects/Feature/Sources/Scene/Main/User/View/MainViewController.swift @@ -157,7 +157,8 @@ public final class MainViewController: BaseViewController, UICollectionViewDataS private func showProfileOverlay() { if profileVC != nil { return } - let vc = UserProfileViewController() + // 현재 캐시된 프로필 정보를 넘겨줘서 뷰가 열릴 때 기본 이미지로 깜빡이는 현상 방지 + let vc = UserProfileViewController(initialProfile: profileViewModel.profileInfo) profileVC = vc addChild(vc) @@ -180,6 +181,8 @@ public final class MainViewController: BaseViewController, UICollectionViewDataS profile.view.removeFromSuperview() profile.removeFromParent() profileVC = nil + + fetchData() } func selectHomeTab() { @@ -415,7 +418,7 @@ public final class MainViewController: BaseViewController, UICollectionViewDataS } group.enter() - mainViewModel.getProfile { result in + mainViewModel.getProfile { _ in group.leave() } @@ -424,12 +427,17 @@ public final class MainViewController: BaseViewController, UICollectionViewDataS group.leave() } + group.enter() + profileViewModel.loadProfileInfo { _, _ in + group.leave() + } + group.notify(queue: .main) { [weak self] in guard let self = self, self.isVisible else { self?.refreshControl.endRefreshing() return } - + self.setupViewComponents() self.refreshControl.endRefreshing() } @@ -466,8 +474,12 @@ public final class MainViewController: BaseViewController, UICollectionViewDataS if let urlString = profileViewModel.profileInfo?.profileImageUrl, let url = URL(string: urlString) { - basicsProfileView.profileImageView.kf.setImage(with: url, placeholder: UIImage.image.profile.image, options: [.forceRefresh]) - profileView.profileImageView.kf.setImage(with: url, placeholder: UIImage.image.profile.image, options: [.forceRefresh]) + let options: KingfisherOptionsInfo = [ + .keepCurrentImageWhileLoading, + .transition(.none) + ] + basicsProfileView.profileImageView.kf.setImage(with: url, placeholder: UIImage.image.profile.image, options: options) + profileView.profileImageView.kf.setImage(with: url, placeholder: UIImage.image.profile.image, options: options) } else { basicsProfileView.profileImageView.image = .image.profile.image profileView.profileImageView.image = .image.profile.image @@ -574,7 +586,7 @@ public final class MainViewController: BaseViewController, UICollectionViewDataS } logo.snp.makeConstraints { - $0.leading.equalToSuperview().inset(20) + $0.leading.equalTo(view.safeAreaLayoutGuide.snp.leading).offset(6) $0.top.equalTo(contentView.snp.top) $0.height.equalTo(56) $0.width.equalTo(135) @@ -639,12 +651,6 @@ public final class MainViewController: BaseViewController, UICollectionViewDataS $0.height.equalTo(84) } - latecomerLabel.snp.remakeConstraints { - $0.top.equalTo(profileView.snp.bottom).offset(24) - $0.leading.equalToSuperview().inset(20) - $0.height.equalTo(32) - } - basicsProfileView.snp.remakeConstraints { $0.leading.trailing.equalToSuperview().inset(20) $0.height.equalTo(84) @@ -654,9 +660,21 @@ public final class MainViewController: BaseViewController, UICollectionViewDataS if isClockOn { profileView.isHidden = false basicsProfileView.isHidden = true + + latecomerLabel.snp.remakeConstraints { + $0.top.equalTo(profileView.snp.bottom).offset(24) + $0.leading.equalToSuperview().inset(20) + $0.height.equalTo(32) + } } else { profileView.isHidden = true basicsProfileView.isHidden = false + + latecomerLabel.snp.remakeConstraints { + $0.top.equalTo(basicsProfileView.snp.bottom).offset(24) + $0.leading.equalToSuperview().inset(20) + $0.height.equalTo(32) + } } view.layoutIfNeeded() } diff --git a/Projects/Feature/Sources/Scene/Main/View/LateNilView.swift b/Projects/Feature/Sources/Scene/Main/View/LateNilView.swift index 30da7c66..dfbc37af 100644 --- a/Projects/Feature/Sources/Scene/Main/View/LateNilView.swift +++ b/Projects/Feature/Sources/Scene/Main/View/LateNilView.swift @@ -31,6 +31,13 @@ final class LateNilView: UIView { $0.font = .suit(size: 16, weight: .semibold) } + private let stackView = UIStackView().then { + $0.axis = .horizontal + $0.alignment = .center + $0.distribution = .fill + $0.spacing = 6 + } + // MARK: - Initializer override init(frame: CGRect) { super.init(frame: frame) @@ -42,25 +49,19 @@ final class LateNilView: UIView { fatalError("init(coder:) has not been implemented") } - private func addView() { - let stack = UIStackView(arrangedSubviews: [icon, mainLabel]) - stack.axis = .horizontal - stack.alignment = .center - stack.spacing = 6 - self.addSubview(stack) + [icon, mainLabel].forEach { stackView.addArrangedSubview($0) } + self.addSubview(stackView) + } - stack.snp.makeConstraints { - $0.centerX.equalToSuperview() - $0.centerY.equalToSuperview().offset(10) + private func setLayout() { + stackView.snp.makeConstraints { + $0.centerX.centerY.equalToSuperview() + $0.top.bottom.equalToSuperview() } icon.snp.makeConstraints { $0.width.height.equalTo(24) } } - - private func setLayout() { - // Layout handled inside addView() with stack constraints - } } diff --git a/Projects/Feature/Sources/Scene/Main/View/ProfileCardView.swift b/Projects/Feature/Sources/Scene/Main/View/ProfileCardView.swift index 8dc3df13..60782c2a 100644 --- a/Projects/Feature/Sources/Scene/Main/View/ProfileCardView.swift +++ b/Projects/Feature/Sources/Scene/Main/View/ProfileCardView.swift @@ -71,7 +71,7 @@ final class ProfileCardView: UIView { profileImageView.kf.setImage( with: url, placeholder: UIImage.image.gomsBasicProfile.image, - options: [.transition(.fade(0.2))] + options: [.keepCurrentImageWhileLoading, .transition(.fade(0.2))] ) } else { profileImageView.image = .image.gomsBasicProfile.image diff --git a/Projects/Feature/Sources/Scene/Profile/Controller/ChangPassword/ViewModel/ProfileViewModel.swift b/Projects/Feature/Sources/Scene/Profile/Controller/ChangPassword/ViewModel/ProfileViewModel.swift index 10fa78e2..06d160a2 100644 --- a/Projects/Feature/Sources/Scene/Profile/Controller/ChangPassword/ViewModel/ProfileViewModel.swift +++ b/Projects/Feature/Sources/Scene/Profile/Controller/ChangPassword/ViewModel/ProfileViewModel.swift @@ -11,6 +11,7 @@ import Service import Moya import Combine import UIKit +import Kingfisher public struct MyRoleResponse: Decodable { public let memberId: Int @@ -165,6 +166,14 @@ public final class ProfileViewModel: BaseViewModel, ObservableObject { case 200: do { let data = try JSONDecoder().decode(ProfileImageResponse.self, from: response.data) + // 변경된 이미지 URL의 캐시를 제거해서 다음 로드 시 최신 이미지를 받아오도록 처리 + if let oldUrlString = self.profileInfo?.profileImageUrl, + let oldUrl = URL(string: oldUrlString) { + KingfisherManager.shared.cache.removeImage(forKey: oldUrl.absoluteString) + } + if !data.imageUrl.isEmpty, let newUrl = URL(string: data.imageUrl) { + KingfisherManager.shared.cache.removeImage(forKey: newUrl.absoluteString) + } self.profileInfo = ProfileResponse( name: self.profileInfo?.name ?? "", grade: self.profileInfo?.grade ?? 0, @@ -207,6 +216,11 @@ public final class ProfileViewModel: BaseViewModel, ObservableObject { case .success(let response): switch response.statusCode { case 204: + // 기본 프로필로 변경 시 기존 이미지 캐시 제거 + if let oldUrlString = self.profileInfo?.profileImageUrl, + let oldUrl = URL(string: oldUrlString) { + KingfisherManager.shared.cache.removeImage(forKey: oldUrl.absoluteString) + } self.profileInfo = ProfileResponse( name: self.profileInfo?.name ?? "", grade: self.profileInfo?.grade ?? 0, diff --git a/Projects/Feature/Sources/Scene/Profile/Controller/User/UserProfileViewController.swift b/Projects/Feature/Sources/Scene/Profile/Controller/User/UserProfileViewController.swift index 5418f7ab..44f606a8 100644 --- a/Projects/Feature/Sources/Scene/Profile/Controller/User/UserProfileViewController.swift +++ b/Projects/Feature/Sources/Scene/Profile/Controller/User/UserProfileViewController.swift @@ -34,6 +34,7 @@ public class UserProfileViewController: BaseViewController, UIImagePickerControl $0.layer.cornerRadius = 32 $0.clipsToBounds = true $0.translatesAutoresizingMaskIntoConstraints = false + $0.image = UIImage.image.gomsBasicProfile.image // 최초 기본 이미지 지정으로 깜빡임 방지 } let userProfilePencil = UIButton().then { @@ -47,15 +48,19 @@ public class UserProfileViewController: BaseViewController, UIImagePickerControl } let userName = UILabel().then { - $0.text = "김준표" + $0.text = " " $0.textColor = .color.mainText.color $0.font = .suit(size: 18, weight: .bold) + $0.adjustsFontSizeToFitWidth = true + $0.minimumScaleFactor = 0.7 } let userGradeDepartment = UILabel().then { - $0.text = "9기 | IoT" + $0.text = " " $0.textColor = .color.sub2.color $0.font = .suit(size: 14, weight: .medium) + $0.adjustsFontSizeToFitWidth = true + $0.minimumScaleFactor = 0.7 } let perceptionCount = UILabel().then { @@ -186,6 +191,17 @@ public class UserProfileViewController: BaseViewController, UIImagePickerControl $0.backgroundColor = .color.gomsDivider.color } + public init(initialProfile: ProfileResponse? = nil) { + super.init(nibName: nil, bundle: nil) + if let initialProfile = initialProfile { + self.profileViewModel.profileInfo = initialProfile + } + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + @objc func withdrawalButtonTapped() { GOMSAlert.show( in: self, @@ -350,7 +366,7 @@ public class UserProfileViewController: BaseViewController, UIImagePickerControl setNeedsStatusBarAppearanceUpdate() } - @IBAction func ShowActionSheetProfilImageChange(_ sender: UIButton) { + @objc func ShowActionSheetProfilImageChange(_ sender: UIButton) { updateImage(isActionSheetShowing: true) let actionSheet = UIAlertController(title: "프로필 사진 선택", message: nil, preferredStyle: .actionSheet) @@ -399,9 +415,16 @@ public class UserProfileViewController: BaseViewController, UIImagePickerControl if let selectedImage = info[.originalImage] as? UIImage { userProfile.image = selectedImage if let jpegData = selectedImage.jpegData(compressionQuality: 0.5) { + // MARK: - 통신 트리거 및 데이터 새로고침 동기화 profileViewModel.updateProfileImage(imageData: jpegData) - .sink { completion in - if case .failure(let error) = completion { print("이미지 PATCH 실패: \(error)") } + .sink { [weak self] completion in + switch completion { + case .finished: + // 업로드 성공 후 유저 프로필 정보를 최신 상태로 새로고침 + self?.profileViewModel.loadProfileInfo { _, _ in } + case .failure(let error): + print("이미지 PATCH 실패: \(error)") + } } receiveValue: { [weak self] response in if let url = URL(string: response.imageUrl) { self?.userProfile.kf.setImage(with: url) @@ -420,41 +443,57 @@ public class UserProfileViewController: BaseViewController, UIImagePickerControl super.viewDidLoad() applySavedTheme() self.navigationController?.navigationBar.prefersLargeTitles = false + + // 구독 등록 전에 이미 profileInfo가 있으면 이미지를 즉시 세팅해서 깜빡임 방지 + if let initialProfile = profileViewModel.profileInfo { + if let urlString = initialProfile.profileImageUrl, let url = URL(string: urlString) { + userProfile.kf.setImage(with: url, placeholder: UIImage.image.gomsBasicProfile.image, options: [.keepCurrentImageWhileLoading]) + } else { + userProfile.image = UIImage.image.gomsBasicProfile.image + } + } + profileViewModel.loadProfileInfo { _, _ in } + // 토글 버튼들의 초기 값 할답 (최초 1회만 고정 실행되도록 격리 유지) alarmsettingButton.isOn = UserDefaults.standard.bool(forKey: "isAlarmOn") cameraNowOntoggleButton.isOn = UserDefaults.standard.bool(forKey: "isCameraOn") clockToggleButton.isOn = UserDefaults.standard.bool(forKey: "isClockOn") - profileViewModel.$profileInfo.sink { [weak self] profileInfo in - guard let profileInfo = profileInfo else { return } - DispatchQueue.main.async { - self?.userName.text = profileInfo.name - self?.perceptionNum.text = String(describing: profileInfo.lateCount) - - let majorText: String - switch profileInfo.department { - case Major.sw.rawValue: - majorText = "SW" - case Major.iot.rawValue: - majorText = "IoT" - default: - majorText = "AI" - } - - self?.userGradeDepartment.text = "\(profileInfo.grade)기 | \(majorText)" - if let urlString = profileInfo.profileImageUrl, let url = URL(string: urlString) { - self?.userProfile.kf.setImage( - with: url, - placeholder: self?.userProfile.image, - options: [.transition(.fade(0.2)), .keepCurrentImageWhileLoading] - ) - } else { - self?.userProfile.image = UIImage.image.gomsBasicProfile.image + profileViewModel.$profileInfo + .compactMap { $0 } // nil 데이터는 스킵하여 불필요한 레이아웃 갱신 및 깜빡임 차단 + .sink { [weak self] profileInfo in + DispatchQueue.main.async { + // 애니메이션 없이 데이터만 매끄럽게 변경하도록 처리하여 깜빡임 제거 + UIView.performWithoutAnimation { + self?.userName.text = profileInfo.name + self?.perceptionNum.text = String(describing: profileInfo.lateCount) + + let majorText: String + switch profileInfo.department { + case Major.sw.rawValue: + majorText = "SW" + case Major.iot.rawValue: + majorText = "IoT" + default: + majorText = "AI" + } + + self?.userGradeDepartment.text = "\(profileInfo.grade)기 | \(majorText)" + + if let urlString = profileInfo.profileImageUrl, let url = URL(string: urlString) { + self?.userProfile.kf.setImage( + with: url, + placeholder: self?.userProfile.image, + options: [.transition(.none), .keepCurrentImageWhileLoading] // transition 무효화로 로딩 시 깜빡임 차단 + ) + } else { + self?.userProfile.image = UIImage.image.gomsBasicProfile.image + } + } } } - } - .store(in: &cancellables) + .store(in: &cancellables) view.backgroundColor = .color.background.color imagePickerController.delegate = self @@ -536,10 +575,9 @@ public class UserProfileViewController: BaseViewController, UIImagePickerControl $0.top.equalTo(cameraNowOnDescription.snp.bottom).offset(24) } themeChangText.snp.makeConstraints { - $0.width.equalTo(93) - $0.height.equalTo(28) - $0.top.equalTo(themeTopLine.snp.top).offset(24) $0.leading.equalToSuperview().inset(28) + $0.top.equalTo(themeTopLine.snp.top).offset(24) + $0.trailing.lessThanOrEqualTo(view.snp.trailing).offset(-28) } themeChangRec.snp.makeConstraints { $0.height.equalTo(64) @@ -547,10 +585,10 @@ public class UserProfileViewController: BaseViewController, UIImagePickerControl $0.top.equalTo(themeChangText.snp.bottom).offset(8) } themeSettingText.snp.makeConstraints { - $0.width.equalTo(106) $0.height.equalTo(28) $0.top.equalTo(themeChangRec.snp.top).offset(18) $0.leading.equalTo(themeChangRec.snp.leading).offset(12) + $0.trailing.lessThanOrEqualTo(themeSettingImg.snp.leading).offset(-8) } themeSettingImg.snp.makeConstraints { $0.width.equalTo(24) @@ -585,10 +623,9 @@ public class UserProfileViewController: BaseViewController, UIImagePickerControl $0.centerY.equalTo(alarmText) } cameraNowOnText.snp.makeConstraints { - $0.width.equalTo(184) - $0.height.equalTo(28) $0.leading.equalToSuperview().inset(28) $0.top.equalTo(alarmDescription.snp.bottom).offset(25.5) + $0.trailing.lessThanOrEqualTo(cameraNowOntoggleButton.snp.leading).offset(-8) } cameraNowOnDescription.snp.makeConstraints { $0.leading.equalToSuperview().inset(28)