diff --git a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetReactor.swift b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetReactor.swift index 6fd8b3b4..dcc225a2 100644 --- a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetReactor.swift +++ b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetReactor.swift @@ -1,10 +1,3 @@ -// -// AdminBottomSheetReactor.swift -// Poppool -// -// Created by 김기현 on 1/13/25. -// - import Foundation import ReactorKit diff --git a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetView.swift b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetView.swift index 09248345..ecdd6c73 100644 --- a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetView.swift +++ b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetView.swift @@ -1,12 +1,10 @@ -import UIKit - import DesignSystem import Infrastructure - import ReactorKit import RxCocoa import RxSwift import SnapKit +import UIKit final class AdminBottomSheetView: UIView { @@ -39,7 +37,6 @@ final class AdminBottomSheetView: UIView { let closeButton: UIButton = { let button = UIButton(type: .system) button.setImage(UIImage(named: "icon_xmark"), for: .normal) - button.tintColor = .black return button }() @@ -78,16 +75,12 @@ final class AdminBottomSheetView: UIView { trailing: 20 ) section.interGroupSpacing = 16 - return section } - - let collectionView = UICollectionView( - frame: .zero, - collectionViewLayout: layout - ) + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.backgroundColor = .clear collectionView.isScrollEnabled = false + // Register cell here if needed, e.g. collectionView.register(TagSectionCell.self, forCellWithReuseIdentifier: TagSectionCell.identifiers) return collectionView }() @@ -102,10 +95,7 @@ final class AdminBottomSheetView: UIView { ) button.isEnabled = false button.contentEdgeInsets = UIEdgeInsets( - top: 9, - left: 16, - bottom: 9, - right: 12 + top: 9, left: 16, bottom: 9, right: 12 ) return button }() @@ -120,10 +110,7 @@ final class AdminBottomSheetView: UIView { ) button.isEnabled = false button.contentEdgeInsets = UIEdgeInsets( - top: 9, - left: 16, - bottom: 9, - right: 12 + top: 9, left: 16, bottom: 9, right: 12 ) return button }() @@ -168,7 +155,7 @@ final class AdminBottomSheetView: UIView { private func setupConstraints() { containerView.snp.makeConstraints { make in make.left.right.bottom.equalToSuperview() - make.top.equalTo(headerView.snp.top) + make.top.equalToSuperview() } headerView.snp.makeConstraints { make in @@ -212,18 +199,40 @@ final class AdminBottomSheetView: UIView { } // MARK: - Public Methods - func updateContentVisibility(isCategorySelected: Bool) { - Logger.log("높이 변경 시작: \(isCategorySelected ? "카테고리" : "상태값")", category: .debug) - - let newHeight: CGFloat = isCategorySelected ? 200 : 160 - - // 애니메이션 없이 바로 적용 - contentHeightConstraint?.update(offset: newHeight) - contentCollectionView.invalidateIntrinsicContentSize() - - setNeedsLayout() - layoutIfNeeded() + func calculateCollectionViewHeight(for items: [String]) -> CGFloat { + let sectionInsets = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) + let itemSpacing: CGFloat = 12 + let lineSpacing: CGFloat = 16 + + let collectionViewWidth = UIScreen.main.bounds.width + let availableWidth = collectionViewWidth - sectionInsets.left - sectionInsets.right + + var currentRowWidth: CGFloat = 0 + var numberOfRows = 1 + + for (index, item) in items.enumerated() { + let text = item as NSString + let textSize = text.size(withAttributes: [ + .font: UIFont.korFont(style: .medium, size: 13) + ]) + let itemWidth = textSize.width + 32 // padding: 16 left/right each + + if index == 0 { + currentRowWidth = itemWidth + } else { + let widthWithSpacing = currentRowWidth + itemSpacing + itemWidth + if widthWithSpacing > availableWidth { + numberOfRows += 1 + currentRowWidth = itemWidth + } else { + currentRowWidth = widthWithSpacing + } + } + } - Logger.log("높이 변경 완료", category: .debug) + let itemHeight: CGFloat = 36 + return sectionInsets.top + sectionInsets.bottom + + (CGFloat(numberOfRows) * itemHeight) + + (CGFloat(max(0, numberOfRows - 1)) * lineSpacing) } } diff --git a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetViewController.swift b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetViewController.swift index a02c00e7..b27bb85b 100644 --- a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetViewController.swift +++ b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminBottomSheet/AdminBottomSheetViewController.swift @@ -1,12 +1,10 @@ -import UIKit - import DesignSystem import Infrastructure - import ReactorKit import RxCocoa import RxSwift import SnapKit +import UIKit final class AdminBottomSheetViewController: BaseViewController, View { @@ -14,9 +12,15 @@ final class AdminBottomSheetViewController: BaseViewController, View { // MARK: - Properties private let mainView = AdminBottomSheetView() - private let dimmedView = UIView() + private lazy var dimmedView: UIView = { + let view = UIView() + view.backgroundColor = .black.withAlphaComponent(0.4) + view.alpha = 0 + return view + }() var disposeBag = DisposeBag() private var containerViewBottomConstraint: Constraint? + private var containerHeightConstraint: Constraint? private var tagSection: TagSection? var onSave: (([String]) -> Void)? @@ -43,37 +47,30 @@ final class AdminBottomSheetViewController: BaseViewController, View { private func setupViews() { view.backgroundColor = .clear - Logger.log("초기 뷰 계층:", category: .debug) + view.addSubview(dimmedView) + dimmedView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } view.addSubview(mainView) - mainView.isUserInteractionEnabled = true - mainView.containerView.isUserInteractionEnabled = true - mainView.closeButton.isUserInteractionEnabled = true - mainView.segmentedControl.isUserInteractionEnabled = true - mainView.headerView.isUserInteractionEnabled = true - mainView.snp.makeConstraints { make in make.left.right.equalToSuperview() - make.height.equalTo(view.bounds.height * 0.45) + containerHeightConstraint = make.height.greaterThanOrEqualTo(400).constraint containerViewBottomConstraint = make.bottom.equalTo(view.snp.bottom).constraint } - Logger.log("mainView 추가 후 계층:", category: .debug) - - dimmedView.backgroundColor = .black.withAlphaComponent(0.4) - dimmedView.alpha = 0 - dimmedView.isUserInteractionEnabled = false + setupGestures() + } - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dimmedViewTapped)) + private func setupGestures() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTapDimmedView)) + tapGesture.delegate = self dimmedView.addGestureRecognizer(tapGesture) - tapGesture.cancelsTouchesInView = true // 터치 이벤트가 다른 뷰로 전달되도록 설정 - view.insertSubview(dimmedView, belowSubview: mainView) - - dimmedView.snp.makeConstraints { make in - make.edges.equalToSuperview() - } + dimmedView.isUserInteractionEnabled = true + } - Logger.log("최종 뷰 계층:", category: .debug) + @objc private func handleTapDimmedView() { + hideBottomSheet() } private func setupCollectionView() { @@ -86,11 +83,9 @@ final class AdminBottomSheetViewController: BaseViewController, View { // MARK: - Binding func bind(reactor: Reactor) { mainView.segmentedControl.rx.selectedSegmentIndex - .do(onNext: { _ in - }) - .map { Reactor.Action.segmentChanged($0) } - .bind(to: reactor.action) - .disposed(by: disposeBag) + .map { Reactor.Action.segmentChanged($0) } + .bind(to: reactor.action) + .disposed(by: disposeBag) mainView.resetButton.rx.tap .map { Reactor.Action.resetFilters } @@ -98,46 +93,48 @@ final class AdminBottomSheetViewController: BaseViewController, View { .disposed(by: disposeBag) mainView.contentCollectionView.rx.itemSelected - .withLatestFrom(reactor.state) { indexPath, state -> Reactor.Action in - let title = state.activeSegment == 0 ? - state.statusOptions[indexPath.item] : - state.categoryOptions[indexPath.item] - - return state.activeSegment == 0 ? - .toggleStatusOption(title) : - .toggleCategoryOption(title) - } - .bind(to: reactor.action) - .disposed(by: disposeBag) - - reactor.state.map { state in - let items = state.activeSegment == 0 ? - state.statusOptions : - state.categoryOptions - let selectedItems = state.activeSegment == 0 ? - state.selectedStatusOptions : - state.selectedCategoryOptions - - return items.map { - TagSectionCell.Input( - title: $0, - isSelected: selectedItems.contains($0), - id: nil - ) + .withLatestFrom(reactor.state) { indexPath, state -> Reactor.Action in + let title = state.activeSegment == 0 ? + state.statusOptions[indexPath.item] : + state.categoryOptions[indexPath.item] + + return state.activeSegment == 0 ? + .toggleStatusOption(title) : + .toggleCategoryOption(title) } - } - .bind(to: mainView.contentCollectionView.rx.items( - cellIdentifier: TagSectionCell.identifiers, - cellType: TagSectionCell.self - )) { _, item, cell in - cell.injection(with: item) - } - .disposed(by: disposeBag) + .bind(to: reactor.action) + .disposed(by: disposeBag) + + reactor.state + .map { state in + let items = state.activeSegment == 0 ? + state.statusOptions : + state.categoryOptions + let selectedItems = state.activeSegment == 0 ? + state.selectedStatusOptions : + state.selectedCategoryOptions + + return items.map { + TagSectionCell.Input( + title: $0, + isSelected: selectedItems.contains($0), + id: nil + ) + } + } + .bind(to: mainView.contentCollectionView.rx.items( + cellIdentifier: TagSectionCell.identifiers, + cellType: TagSectionCell.self + )) { _, item, cell in + cell.injection(with: item) + } + .disposed(by: disposeBag) + // 세그먼트 변경 시 전체 시트 높이 업데이트 reactor.state.map { $0.activeSegment } .distinctUntilChanged() - .bind { [weak self] index in - self?.mainView.updateContentVisibility(isCategorySelected: index == 1) + .bind { [weak self] _ in + self?.updateContainerHeight() } .disposed(by: disposeBag) @@ -167,7 +164,6 @@ final class AdminBottomSheetViewController: BaseViewController, View { } .disposed(by: disposeBag) - // View Events mainView.closeButton.rx.tap .bind { [weak self] in self?.hideBottomSheet() @@ -189,13 +185,30 @@ final class AdminBottomSheetViewController: BaseViewController, View { .disposed(by: disposeBag) } - // MARK: - Actions - @objc private func dimmedViewTapped() { - hideBottomSheet() + // MARK: - Height Management + private func updateContainerHeight() { + guard let reactor = reactor else { return } + + let items = reactor.currentState.activeSegment == 0 ? + reactor.currentState.statusOptions : + reactor.currentState.categoryOptions + + let collectionViewHeight = mainView.calculateCollectionViewHeight(for: items) + + let totalHeight = 60 + 50 + collectionViewHeight + 80 + 52 + 100 + + let finalHeight = min(max(totalHeight, 400), UIScreen.main.bounds.height * 0.8) + + containerHeightConstraint?.update(offset: finalHeight) + + self.view.layoutIfNeeded() } // MARK: - Show/Hide func showBottomSheet() { + // 초기 높이 설정 + updateContainerHeight() + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) { self.dimmedView.alpha = 1 self.containerViewBottomConstraint?.update(offset: 0) @@ -218,3 +231,13 @@ final class AdminBottomSheetViewController: BaseViewController, View { Logger.log("BottomSheet deinit", category: .debug) } } + +extension AdminBottomSheetViewController: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + if gestureRecognizer.view == dimmedView { + let touchPoint = touch.location(in: view) + return !mainView.containerView.frame.contains(touchPoint) + } + return true + } +} diff --git a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminView.swift b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminView.swift index 7393cd2a..36dc0b91 100644 --- a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminView.swift +++ b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminView.swift @@ -97,6 +97,8 @@ final class AdminView: UIView { override init(frame: CGRect) { super.init(frame: frame) setupLayout() + tableView.backgroundView = UIView() + tableView.backgroundView?.isUserInteractionEnabled = true } required init?(coder: NSCoder) { diff --git a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminViewController.swift b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminViewController.swift index c599adae..212eab12 100644 --- a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminViewController.swift +++ b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Admin/AdminViewController.swift @@ -3,6 +3,7 @@ import UIKit import DesignSystem import DomainInterface import Infrastructure +import PresentationInterface import ReactorKit import RxCocoa @@ -19,14 +20,17 @@ final class AdminViewController: BaseViewController, View { private var selectedFilterOption: String = "전체" private let nickname: String private let adminUseCase: AdminUseCase + private let detailFactory: DetailFactory // MARK: - Init init( nickname: String, - adminUseCase: AdminUseCase + adminUseCase: AdminUseCase, + detailFactory: DetailFactory = DetailFactoryImpl() ) { self.nickname = nickname self.adminUseCase = adminUseCase + self.detailFactory = detailFactory self.mainView = AdminView(frame: .zero) super.init() mainView.usernameLabel.text = nickname + "님" @@ -39,13 +43,10 @@ final class AdminViewController: BaseViewController, View { // MARK: - Life Cycle override func viewDidLoad() { super.viewDidLoad() - setUp() - setupMenuButton() - - let logoTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapLogo)) - mainView.logoImageView.isUserInteractionEnabled = true - mainView.logoImageView.addGestureRecognizer(logoTapGesture) - mainView.tableView.register(AdminStoreCell.self, forCellReuseIdentifier: AdminStoreCell.identifier) + self.setUp() + self.setupMenuButton() + self.setupGestureRecognizers() + self.setupTableView() } override func viewWillAppear(_ animated: Bool) { @@ -57,9 +58,11 @@ final class AdminViewController: BaseViewController, View { super.viewWillDisappear(animated) tabBarController?.tabBar.isHidden = false } +} - // MARK: - Setup - private func setUp() { +// MARK: - Setup +private extension AdminViewController { + func setUp() { view.addSubview(mainView) mainView.snp.makeConstraints { make in make.edges.equalTo(view.safeAreaLayoutGuide) @@ -68,7 +71,7 @@ final class AdminViewController: BaseViewController, View { mainView.dropdownButton.addTarget(self, action: #selector(didTapDropdownButton), for: .touchUpInside) } - private func setupMenuButton() { + func setupMenuButton() { let editAction = UIAction( title: "수정", image: UIImage(systemName: "pencil"), @@ -91,12 +94,29 @@ final class AdminViewController: BaseViewController, View { mainView.menuButton.showsMenuAsPrimaryAction = true } - // MARK: - Actions - @objc private func didTapLogo() { + func setupGestureRecognizers() { + let logoTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTapLogo)) + mainView.logoImageView.isUserInteractionEnabled = true + mainView.logoImageView.addGestureRecognizer(logoTapGesture) + + if let tableBgView = mainView.tableView.backgroundView { + let tableBackgroundTap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) + tableBgView.addGestureRecognizer(tableBackgroundTap) + } + } + + func setupTableView() { + mainView.tableView.register(AdminStoreCell.self, forCellReuseIdentifier: AdminStoreCell.identifier) + } +} + +// MARK: - Actions +private extension AdminViewController { + @objc func didTapLogo() { navigationController?.popViewController(animated: true) } - @objc private func didTapDropdownButton() { + @objc func didTapDropdownButton() { let reactor = AdminBottomSheetReactor() let bottomSheetVC = AdminBottomSheetViewController(reactor: reactor) @@ -119,7 +139,7 @@ final class AdminViewController: BaseViewController, View { self.adminBottomSheetVC = bottomSheetVC } - private func showEditOptions() { + func showEditOptions() { let alert = UIAlertController(title: "수정할 팝업스토어 선택", message: nil, preferredStyle: .actionSheet) reactor?.currentState.storeList.forEach { store in @@ -138,7 +158,7 @@ final class AdminViewController: BaseViewController, View { present(alert, animated: true) } - private func showDeleteOptions() { + func showDeleteOptions() { let alert = UIAlertController(title: "삭제할 팝업스토어 선택", message: nil, preferredStyle: .actionSheet) reactor?.currentState.storeList.forEach { store in @@ -149,7 +169,6 @@ final class AdminViewController: BaseViewController, View { alert.addAction(UIAlertAction(title: "취소", style: .cancel)) - // iPad support if let popoverController = alert.popoverPresentationController { popoverController.sourceView = mainView.menuButton popoverController.sourceRect = mainView.menuButton.bounds @@ -158,7 +177,7 @@ final class AdminViewController: BaseViewController, View { present(alert, animated: true) } - private func showDeleteConfirmation(for store: AdminStore) { + func showDeleteConfirmation(for store: AdminStore) { let alert = UIAlertController( title: "삭제 확인", message: "\(store.name)을(를) 삭제하시겠습니까?", @@ -173,7 +192,7 @@ final class AdminViewController: BaseViewController, View { present(alert, animated: true) } - private func editStore(_ store: AdminStore) { + func editStore(_ store: AdminStore) { adminUseCase.fetchStoreDetail(id: store.id) .observe(on: MainScheduler.instance) .subscribe( @@ -197,7 +216,7 @@ final class AdminViewController: BaseViewController, View { .disposed(by: disposeBag) } - private func deleteStore(_ store: AdminStore) { + func deleteStore(_ store: AdminStore) { adminUseCase.fetchStoreDetail(id: store.id) .observe(on: MainScheduler.instance) .subscribe( @@ -208,7 +227,6 @@ final class AdminViewController: BaseViewController, View { allImageUrls.append(storeDetail.mainImageUrl) - // 다른 모든 이미지 URL 추가 let otherImageUrls = storeDetail.images.map { $0.imageUrl } allImageUrls.append(contentsOf: otherImageUrls) @@ -236,9 +254,9 @@ final class AdminViewController: BaseViewController, View { } ) .disposed(by: disposeBag) - } - private func showErrorAlert(message: String) { + + func showErrorAlert(message: String) { let alert = UIAlertController( title: "오류", message: message, @@ -248,8 +266,15 @@ final class AdminViewController: BaseViewController, View { present(alert, animated: true) } - // MARK: - Reactor Binding + @objc func dismissKeyboard() { + view.endEditing(true) + } +} + +// MARK: - Reactor Binding +extension AdminViewController { func bind(reactor: Reactor) { + // MARK: - Input mainView.searchInput.rx.text.orEmpty .distinctUntilChanged() .debounce(.milliseconds(300), scheduler: MainScheduler.instance) @@ -270,6 +295,19 @@ final class AdminViewController: BaseViewController, View { }) .disposed(by: disposeBag) + mainView.tableView.rx.itemSelected + .subscribe(onNext: { [weak self] indexPath in + self?.view.endEditing(true) + guard let store = reactor.currentState.storeList[safe: indexPath.row] else { return } + + let detailVC = self?.detailFactory.make(popupID: Int(store.id)) + if let detailVC = detailVC { + self?.navigationController?.pushViewController(detailVC, animated: true) + } + }) + .disposed(by: disposeBag) + + // MARK: - Output reactor.state .map { $0.selectedStoreForEdit } .compactMap { $0 } @@ -294,3 +332,9 @@ final class AdminViewController: BaseViewController, View { .disposed(by: disposeBag) } } + +private extension Array { + subscript(safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} diff --git a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Map/MapView/MapViewController.swift b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Map/MapView/MapViewController.swift index 5160514e..86f9d4e2 100644 --- a/Poppool/PresentationLayer/Presentation/Presentation/Scene/Map/MapView/MapViewController.swift +++ b/Poppool/PresentationLayer/Presentation/Presentation/Scene/Map/MapView/MapViewController.swift @@ -14,7 +14,9 @@ import RxGesture import RxSwift import SnapKit -class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NMFMapViewTouchDelegate, NMFMapViewCameraDelegate, UIGestureRecognizerDelegate { +class MapViewController: BaseViewController, View { + // 최초 뷰포트 진입 여부 플래그 + private var isFirstViewportEntry = true typealias Reactor = MapReactor fileprivate struct CoordinateKey: Hashable { @@ -22,11 +24,23 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM let lng: Int init(latitude: Double, longitude: Double) { - self.lat = Int(latitude * 1_000_00) - self.lng = Int(longitude * 1_000_00) + self.lat = Int(latitude * Constants.coordinateMultiplier) + self.lng = Int(longitude * Constants.coordinateMultiplier) } } + private enum Constants { + static let carouselHeight: CGFloat = 140 + static let carouselBottomOffset: CGFloat = -24 + static let tooltipMarkerHeight: CGFloat = 32 + static let tooltipYOffset: CGFloat = 14 + static let coordinateMultiplier: Double = 100_000 + static let cameraDebounceMs: Int = 300 + static let swipeDuration: TimeInterval = 0.3 + static let panVelocityThreshold: CGFloat = 500 + static let middleRatio: CGFloat = 0.3 + static let defaultZoom: Double = 15.0 + } var currentTooltipView: UIView? var currentTooltipStores: [MapPopUpStore] = [] var currentTooltipCoordinate: NMGLatLng? @@ -182,7 +196,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM private func setupMapViewRxObservables() { mainView.mapView.addCameraDelegate(delegate: self) cameraIdle - .debounce(.milliseconds(300), scheduler: MainScheduler.instance) + .debounce(.milliseconds(Constants.cameraDebounceMs), scheduler: MainScheduler.instance) .map { [unowned self] in let bounds = self.getVisibleBounds() return MapReactor.Action.viewportChanged( @@ -232,11 +246,10 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM } let markerPoint = self.mainView.mapView.projection.point(from: marker.position) - let markerHeight: CGFloat = 32 - + let markerHeight = Constants.tooltipMarkerHeight tooltipView.frame = CGRect( x: markerPoint.x, - y: markerPoint.y - markerHeight - tooltipView.frame.height - 14, + y: markerPoint.y - markerHeight - tooltipView.frame.height - Constants.tooltipYOffset, width: tooltipView.frame.width, height: tooltipView.frame.height ) @@ -257,8 +270,8 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM view.addSubview(carouselView) carouselView.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() - make.height.equalTo(140) - make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-24) + make.height.equalTo(Constants.carouselHeight) + make.bottom.equalTo(view.safeAreaLayoutGuide).offset(Constants.carouselBottomOffset) } carouselView.isHidden = true mainView.mapView.touchDelegate = self @@ -284,7 +297,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM mapViewTapGesture.delegate = self } - private let defaultZoomLevel: Double = 15.0 + private let defaultZoomLevel: Double = Constants.defaultZoom private func setupPanAndSwipeGestures() { storeListViewController.mainView.grabberHandle.rx.swipeGesture(.up) .skip(1) @@ -346,7 +359,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM let cameraUpdate = NMFCameraUpdate(scrollTo: NMGLatLng( lat: location.coordinate.latitude, lng: location.coordinate.longitude - ), zoomTo: 15.0) + ), zoomTo: Constants.defaultZoom) self.mainView.mapView.moveCamera(cameraUpdate) } @@ -447,7 +460,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM let cameraUpdate = NMFCameraUpdate(scrollTo: NMGLatLng( lat: store.latitude, lng: store.longitude - ), zoomTo: 15.0) + ), zoomTo: Constants.defaultZoom) cameraUpdate.animation = .easeIn cameraUpdate.animationDuration = 0.3 self.mainView.mapView.moveCamera(cameraUpdate) @@ -504,7 +517,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM let cameraUpdate = NMFCameraUpdate(scrollTo: NMGLatLng( lat: firstStore.latitude, lng: firstStore.longitude - ), zoomTo: 15.0) + ), zoomTo: Constants.defaultZoom) cameraUpdate.animation = .easeIn cameraUpdate.animationDuration = 0.3 self.mainView.mapView.moveCamera(cameraUpdate) @@ -515,7 +528,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM // MARK: - List View Control private func toggleListView() { - UIView.animate(withDuration: 0.3) { + UIView.animate(withDuration: Constants.swipeDuration) { let middleOffset = -self.view.frame.height * 0.7 self.listViewTopConstraint?.update(offset: middleOffset) self.modalState = .middle @@ -612,9 +625,9 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM let middleY = view.frame.height * 0.3 let targetState: ModalState - if velocity.y > 500 { + if velocity.y > Constants.panVelocityThreshold { targetState = .bottom - } else if velocity.y < -500 { + } else if velocity.y < -Constants.panVelocityThreshold { targetState = .top } else if currentOffset < middleY * 0.7 { targetState = .top @@ -1218,101 +1231,38 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM .bind(to: reactor.action) .disposed(by: disposeBag) - reactor.state - .map { $0.viewportStores } - .distinctUntilChanged() - .filter { !$0.isEmpty } - .take(1) - .observe(on: MainScheduler.instance) - .subscribe(onNext: { [weak self] stores in - guard let self = self else { return } - - if let location = self.locationManager.location { - self.findAndShowNearestStore(from: location) - } else if let firstStore = stores.first, - let marker = self.findMarkerForStore(for: firstStore) { - _ = self.handleSingleStoreTap(marker, store: firstStore) - } - - self.currentStores = stores - self.updateMapWithClustering() - }) - .disposed(by: disposeBag) - + // 최초 진입시에만 자동 포커싱/캐러셀 동작 reactor.state .map { $0.viewportStores } .distinctUntilChanged() - .throttle(.milliseconds(200), scheduler: MainScheduler.instance) + .filter { !$0.isEmpty } .observe(on: MainScheduler.instance) .subscribe(onNext: { [weak self] stores in guard let self = self else { return } - - let effectiveViewport = self.getEffectiveViewport() - let bounds = self.getVisibleBounds() - - let visibleStores = stores.filter { store in - let storePosition = NMGLatLng(lat: store.latitude, lng: store.longitude) - return NMGLatLngBounds(southWest: bounds.southWest, northEast: bounds.northEast).contains(storePosition) - } - self.currentStores = visibleStores - - let currentZoom = self.mainView.mapView.zoomLevel - let level = MapZoomLevel.getLevel(from: Float(currentZoom)) - - if level == .detailed && !visibleStores.isEmpty { - let effectiveStores = visibleStores.filter { store in - let storePosition = NMGLatLng(lat: store.latitude, lng: store.longitude) - return effectiveViewport.contains(storePosition) - } - - self.currentCarouselStores = visibleStores - self.carouselView.updateCards(visibleStores) - self.carouselView.isHidden = false - self.mainView.setStoreCardHidden(false, animated: true) - - if let currentMarker = self.currentMarker { - if let currentStore = currentMarker.userInfo["storeData"] as? MapPopUpStore, - let index = visibleStores.firstIndex(where: { $0.id == currentStore.id }) { - self.carouselView.scrollToCard(index: index) - } else if let storeArray = currentMarker.userInfo["storeData"] as? [MapPopUpStore], - let firstStore = storeArray.first, - let index = visibleStores.firstIndex(where: { $0.id == firstStore.id }) { - self.carouselView.scrollToCard(index: index) - } else { - // 선택된 마커가 현재 뷰포트에 없는 경우 - self.updateMarkerStyle(marker: currentMarker, selected: false, isCluster: false) - self.currentMarker = nil - - // 첫 번째 스토어의 마커를 선택 상태로 설정 - if let firstStore = visibleStores.first, - let marker = self.findMarkerForStore(for: firstStore) { - self.updateMarkerStyle(marker: marker, selected: true, isCluster: false) - self.currentMarker = marker - } - - self.carouselView.scrollToCard(index: 0) - } - } else { - if let firstStore = visibleStores.first, - let marker = self.findMarkerForStore(for: firstStore) { - self.updateMarkerStyle(marker: marker, selected: true, isCluster: false) - self.currentMarker = marker - } - self.carouselView.scrollToCard(index: 0) - } - } else { - // 클러스터 레벨이거나 마커가 없는 경우 - self.carouselView.isHidden = true - self.carouselView.updateCards([]) - self.currentCarouselStores = [] - self.mainView.setStoreCardHidden(true, animated: true) - - if level == .detailed && visibleStores.isEmpty { - // 개별 마커 레벨인데 마커가 없는 경우 토스트 표시 - self.showNoMarkersToast() + // 최초 진입시에만 자동 포커싱/캐러셀 동작 + if self.isFirstViewportEntry { + self.isFirstViewportEntry = false + + if let location = self.locationManager.location { + self.findAndShowNearestStore(from: location) + } else if let firstStore = stores.first, + let marker = self.findMarkerForStore(for: firstStore) { + _ = self.handleSingleStoreTap(marker, store: firstStore) } } + self.currentStores = stores + self.updateMapWithClustering() + }) + .disposed(by: disposeBag) + // 지도 이동시 자동 캐러셀/포커싱 등 UI 업데이트 제거 + reactor.state + .map { $0.viewportStores } + .distinctUntilChanged() + .throttle(.milliseconds(200), scheduler: MainScheduler.instance) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] _ in + guard let self = self else { return } self.updateMapWithClustering() }) .disposed(by: disposeBag) @@ -1432,26 +1382,10 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM } func handleMicroClusterTap(_ marker: NMFMarker, storeArray: [MapPopUpStore]) -> Bool { - if currentMarker == marker { - currentTooltipView?.removeFromSuperview() - currentTooltipView = nil - currentTooltipStores = [] - currentTooltipCoordinate = nil - - carouselView.isHidden = true - carouselView.updateCards([]) - currentCarouselStores = [] - updateMarkerStyle(marker: marker, selected: false, isCluster: false, count: storeArray.count) - - currentMarker = nil - isMovingToMarker = false - return false - } - - isMovingToMarker = true - currentTooltipView?.removeFromSuperview() currentTooltipView = nil + currentTooltipStores = [] + currentTooltipCoordinate = nil if let previousMarker = currentMarker { updateMarkerStyle(marker: previousMarker, selected: false, isCluster: false) @@ -1460,22 +1394,23 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM updateMarkerStyle(marker: marker, selected: true, isCluster: false, count: storeArray.count) currentMarker = marker + // 3. 캐러셀/툴팁 갱신 currentCarouselStores = storeArray carouselView.updateCards(storeArray) carouselView.isHidden = false carouselView.scrollToCard(index: 0) - mainView.setStoreCardHidden(false, animated: true) + let cameraUpdate = NMFCameraUpdate(scrollTo: marker.position) cameraUpdate.animation = .easeIn cameraUpdate.animationDuration = 0.3 mainView.mapView.moveCamera(cameraUpdate) - if storeArray.count > 1 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in - guard let self = self else { return } - self.configureTooltip(for: marker, stores: storeArray) - self.isMovingToMarker = false - } + + // 4. 툴팁 갱신 및 위치 재계산 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + guard let self = self else { return } + self.configureTooltip(for: marker, stores: storeArray) + self.isMovingToMarker = false } return true @@ -1486,8 +1421,8 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM } } - // MARK: - CLLocationManagerDelegate - extension MapViewController { +// MARK: - CLLocationManagerDelegate +extension MapViewController: CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let location = locations.last else { return } @@ -1497,7 +1432,7 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM currentCarouselStores = [] let position = NMGLatLng(lat: location.coordinate.latitude, lng: location.coordinate.longitude) - let cameraUpdate = NMFCameraUpdate(scrollTo: position, zoomTo: 15.0) + let cameraUpdate = NMFCameraUpdate(scrollTo: position, zoomTo: Constants.defaultZoom) mainView.mapView.moveCamera(cameraUpdate) { [weak self] _ in guard let self = self else { return } self.findAndShowNearestStore(from: location) @@ -1507,8 +1442,8 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM } } - // MARK: - NMFMapViewTouchDelegate - extension MapViewController { +// MARK: - NMFMapViewTouchDelegate +extension MapViewController: NMFMapViewTouchDelegate { func mapView(_ mapView: NMFMapView, didTap marker: NMFMarker) -> Bool { if let clusterData = marker.userInfo["clusterData"] as? ClusterMarkerData { return handleRegionalClusterTap(marker, clusterData: clusterData) @@ -1544,8 +1479,8 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM } } - // MARK: - NMFMapViewCameraDelegate - extension MapViewController { +// MARK: - NMFMapViewCameraDelegate +extension MapViewController: NMFMapViewCameraDelegate { func mapView(_ mapView: NMFMapView, cameraWillChangeByReason reason: Int, animated: Bool) { if reason == NMFMapChangedByGesture && !isMovingToMarker { resetSelectedMarker() @@ -1577,8 +1512,8 @@ class MapViewController: BaseViewController, View, CLLocationManagerDelegate, NM cameraIdle.onNext(()) } } - // MARK: - UIGestureRecognizerDelegate - extension MapViewController { +// MARK: - UIGestureRecognizerDelegate +extension MapViewController: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { return true }