Project/HiddenGem

🔨 화면 UI을 동시에 나오게 해서 사용자 친화적으로 해보기

밤새는 탐험가89 2025. 5. 19. 14:21

 

❌ 각 UI 가 동시에 나오지 않음 -> 보기 불편함... 

 

 

🎯 문제 요약

  • 섹션 헤더 -> 즉시 표시죔 -> 하드코딩되어 있기 때문 
  • 음식점 리스트 -> 비동기 API 호출 후 표시 -> fetchEateries() 가 완료되어야 표시됨
  • 다른 데이터 (예: 카테고리) -> 또 다른 ViewModel에서 별도 로딩 -> 동시에 로딩 중이지만, 완료 타이밍이 다름 

 

✅ 사용자 경험 개선 목표

 

  • 모든 필요한 데이터가 준비된 후 한 번에 UI를 표시하고 싶음 → "플래시 화면 없이 완전한 콘텐츠"
  • 중간에 뜨는 일부 UI 요소 제거 → UI의 일관성과 신뢰감 유지

 

🛠 해결 전략 제안

✅ @Published var isLoading: Bool = true 사용

 

  • EateryViewModel, CategoryViewModel 각각에 @Published var isLoading: Bool 있음
  • LoadingViewModel에서 Combine을 사용하여 둘의 로딩 상태를 통합
  • ViewController는 LoadingViewModel의 isLoading을 관찰해서
    • true → 로딩 화면 (ActivityIndicator)
    • false → collectionView.reloadData()

 

✅ CategroyViewModel

@MainActor
class CategoryViewModel: ObservableObject {
    
    // MARK: - Variable
    @Published var categories: [StoreCategory] = []
    @Published var emojiCategories: [CategoryEmogi] = []
    
    // ✅ isLoading 변수 
    @Published var isLoading: Bool = false
    @Published var errorMessage: String? = nil
    
    private var cancellables: Set<AnyCancellable> = []
    
    
    // MARK: - Function
    
    /// 음식점 카테고리 정보를 받아오는 메서드
    func fetchCategories() async {
        isLoading = true
        errorMessage = nil
        
        do {
            let result = try await NetworkManager.shared.getStoreCategories()
            self.categories = result
            print("✅ ViewModel: \(result.count)개 카테고리 로딩")
        } catch {
            self.errorMessage = error.localizedDescription
            print("❌ ViewModel: 에러 \(error.localizedDescription)")
        }
        
        isLoading = false
    }
}

 

 

 

✅ EateryViewModel

@MainActor
class EateryViewModel: ObservableObject {
    
    // MARK: - Variable
    @Published var eateries: [EateryItem] = []
    @Published var gyeonggiEateries: [EateryItem] = []
    @Published var errorMessage: String? = nil
    
    // ✅ isLoading 변수
    @Published var isLoading = true
    
    // MARK: - Function
    func fetchEateries() async {
        errorMessage = nil
        isLoading = true
        
        // ✅ async let → 병렬 실행
        async let nationwideTask: [EateryItem] = NetworkManager.shared.getEateryLists()
        async let gyeonggiTask: [EateryItem] = NetworkManager.shared.getEateryLists(areaCode: 31)
        
        do {
            let (nationwide, gyeonggi) = try await (nationwideTask, gyeonggiTask)
            self.eateries = nationwide
            self.gyeonggiEateries = gyeonggi
            print("✅ ViewModel: 전국 \(nationwide.count)개, 경기 \(gyeonggi.count)개 음식점 로딩")

        } catch {
            self.errorMessage = error.localizedDescription
            print("❌ ViewModel 에러: \(error.localizedDescription)")
        }
        
        self.isLoading = false // 성공, 실패 모두
    }
}

 

✅ LoadingViewModel

@MainActor
final class LoadingViewModel {
    @Published var isLoading: Bool = true
    
    private var cancellables = Set<AnyCancellable>()
    
    // ✅init을 통해 각 값을 감지
    init(eateryVM: EateryViewModel, categoryVM: CategoryViewModel) {
        Publishers.CombineLatest(
            eateryVM.$isLoading,
            categoryVM.$isLoading
        )
            .map { $0 || $1 } // 하나라도 로딩 중이면 true
            .assign(to: \.isLoading, on: self)
            .store(in: &cancellables)
    }
}

 

 

✅ HomeViewController

class HomeViewController: UIViewController {
    
    // MARK: - Variable
    
    private let categoriesViewModel: CategoryViewModel = CategoryViewModel()
    private let eateriesViewModel: EateryViewModel = EateryViewModel()
    private var loadingViewModel: LoadingViewModel!
    
    private var cancellables: Set<AnyCancellable> = []

    ... 
}

extension HomeViewController {
    
    // MARK: - Functions
    
    // ✅ ViewModel의 데이터 변경 시 CollectionView Snapshot 갱신
    private func bindViewModel() {
        
        Publishers.CombineLatest3(
            categoriesViewModel.$emojiCategories,
            eateriesViewModel.$eateries,
            eateriesViewModel.$gyeonggiEateries
        )
        .receive(on: DispatchQueue.main)
        .sink { [weak self] _, _, _ in
            self?.reloadData()
        }
        .store(in: &cancellables)
        
    }
    
    // ✅ 각 ViewModel에서 데이터를 받았는지 여부를 isLoading에 저장, 비교하여 감지하는 메서드 
    private func bindLoading() {
        loadingViewModel = LoadingViewModel(
            eateryVM: eateriesViewModel,
            categoryVM: categoriesViewModel
        )
        
        // ✅ 각 로직으로 확인
        loadingViewModel.$isLoading
            .receive(on: DispatchQueue.main)
            .sink { [weak self] isLoading in
                if isLoading {
                    self?.activityIndicator.startAnimating()
                    self?.recommendationCollectionView.isHidden = true
                } else {
                    self?.activityIndicator.stopAnimating()
                    self?.recommendationCollectionView.isHidden = false
                    self?.recommendationCollectionView.reloadData()
                }
            }
            .store(in: &cancellables)
    }
    
    
    private func fetchCategories() {
        
        // 각각의 함수가 독립적으로 실행
        Task {
            async let categoriesFetch: () = categoriesViewModel.fetchCategories()
            async let eateriesFetch: () = eateriesViewModel.fetchEateries()
            
            await categoriesFetch
            categoriesViewModel.updateCategories()
            
            await eateriesFetch
            //print("🍛 음식점 목록:")
            //eateriesViewModel.eateries.forEach { print("- \($0.title)") }
        }
    }
}