Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DMVM-192(feat): navigation 구현 #22

Open
wants to merge 15 commits into
base: develop
Choose a base branch
from
Open

DMVM-192(feat): navigation 구현 #22

wants to merge 15 commits into from

Conversation

bokoo14
Copy link
Collaborator

@bokoo14 bokoo14 commented Nov 24, 2024

🎫 지라 티켓 이슈

https://dogbusiness.atlassian.net/browse/DMVM-192



🛠️ 작업 내용

해당 PR에서 중점적으로 구현한 부분은 화면간 이동을 위해 navigationStack을 구현하였습니다.
화면 간 이동을 구현하면서 각 View에 대해 피그마 확인하는 과정에서 일부 화면 리팩토링 및 디테일을 수정하였습니다.
하지만, 전체적인 View에 대해 리팩토링 및 디테일 수정을 한 것이 아니므로, 피쳐 작업을 하면서 추가적으로 뷰 수정이 필요해보이며, 이점 참고하여 추후 작업 시에 @bulmang 님께서도 수정 부탁드립니다.

간략한 작업 내용

  • navigationstack을 사용하여 뷰간 이동 구현
  • 하단 tabView를 연결하여 4개의 탭 아이템 구현 및 리펙토링
  • 상단의 Header 리펙토링
  • Home View 및 그와 연결된 뷰 리펙토링
  • Calandar View 리펙토링

📱 작업 화면

화면 이름 스크린샷
�HomeView

상단의 Header 리펙토링

HomeView(홈)와 CalandarView(예약내역)에서 사용되고 있는 상단의 TabHeaderComponent를 리팩토링하였습니다.

✅ AS-IS
기존의 코드에서는 아이콘이 1개만 사용 가능하였고, 그 위에 overlay로 2번째 아이콘을 추가하는 방식이었습니다. 또한, 각 icon은 Button으로 구성되어 있어 navigation에는 적합하지 않았습니다.
✅ TO-BE
리팩토링 후 개선된 코드에서는 재사용성을 고려하여 icon과 그에 따른 action에 대해 파라미터로 받고 있으며, optional로 처리하여 icon이 0개, 1개, 2개 있는 경우 모두 해당 컴포넌트를 사용할 수 있게 구현하였습니다.

import SwiftUI

public struct TabHeaderComponent<FirstDestination: View, SecondDestination: View>: View {
    let headerText: String
    let firstIconImageName: Image?
    let firstDestination: FirstDestination
    let secondIconName: Image?
    let secondDestination: SecondDestination

    public init(
        headerText: String,
        firstIconImageName: Image? = nil,
        firstDestination: FirstDestination,
        secondIconName: Image? = nil,
        secondDestination: SecondDestination
    ) {
        self.headerText = headerText
        self.firstIconImageName = firstIconImageName
        self.firstDestination = firstDestination
        self.secondIconName = secondIconName
        self.secondDestination = secondDestination
    }

    public init(
        headerText: String,
        firstIconImageName: Image? = nil,
        firstDestination: FirstDestination
    ) where SecondDestination == EmptyView {
        self.headerText = headerText
        self.firstIconImageName = firstIconImageName
        self.firstDestination = firstDestination
        self.secondIconName = nil
        self.secondDestination = EmptyView()
    }

    public init(headerText: String) where FirstDestination == EmptyView, SecondDestination == EmptyView {
        self.headerText = headerText
        self.firstIconImageName = nil
        self.firstDestination = EmptyView()
        self.secondIconName = nil
        self.secondDestination = EmptyView()
    }

    public var body: some View {
        HStack(spacing: 19) {
            Text(headerText)
                .font(.headline)
                .foregroundColor(.black)
            Spacer()
            if let firstIconImageName = firstIconImageName {
                NavigationLink(destination: firstDestination) {
                    firstIconImageName
                }
            }
            if let secondIconName = secondIconName {
                NavigationLink(destination: secondDestination) {
                    secondIconName
                }
            }
        } // HStack
        .padding(.horizontal, 20)
        .padding(.vertical, 18)
    }
}

#Preview("아이콘 2개 다 있는 Header") {
    TabHeaderComponent(
        headerText: "",
        firstIconImageName: Image.searchGrayIcon,
        firstDestination: Text("첫 번째 화면"),
        secondIconName: Image.bellIcon,
        secondDestination: Text("두 번째 화면")
    )
}

#Preview("아이콘 1개만 있는 Header") {
    TabHeaderComponent(
        headerText: "",
        firstIconImageName: Image.searchGrayIcon,
        firstDestination: Text("첫번째 화면")
    )
}

#Preview("Text만 있는 Header") {
    TabHeaderComponent(headerText: "")
}

화면 이름 스크린샷
�TabView

하단 tabView를 연결하여 4개의 탭 아이템 구현 및 리펙토링

하단의 TabView에 대하여 4개의 View가 정상적으로 연결되어 있지 않아, 각 탭에 해당하는 뷰를 연걸하고, TabView를 가독성 및 유지보수 측면을 고려하여 리팩토링하였습니다.

✅ AS-IS
4개의 Tab이 모두 화면에 연결되어 있지 않으며, Tab의 아이콘 및 텍스트가 적절히 구현되어 있지 않고 있습니다.
✅ TO-BE
피그마의 탭뷰의 이미지를 export하여 적용하였으며, 각 탭에 따른 뷰를 연결하였습니다.

TabView를 사용하는 화면에서 각 탭의 뷰, 아이콘, 텍스트 등을 효율적으로 관리할 수 있도록 TabItem을 enum 타입으로 정의하였습니다.
반복되는 코드를 줄이고, 탭 관련 데이터를 한 곳에서 관리하여 유지보수성을 향상시키는 것을 목표로 하였습니다.
CaseIterable을 채택하여 탭 항목을 쉽게 순회할 수 있도록 구현했습니다.

✅ 신경 쓴 부분

  • 유지보수성: 새로운 탭 추가 시, TabItem 열거형에 항목만 추가하면 모든 관련 데이터(view, title, images)가 자동으로 연결되도록 설계했습니다.
  • 코드 재사용성: TabItem의 각 속성(view, title, defaultImage, selectedImage)을 열거형 내에서 통합 관리하여, 뷰와 데이터가 분리되지 않도록 하였습니다.

(가독성을 위해 일부 코드만 발췌하였습니다)

enum TabItem: Int, CaseIterable {
    case home
    case calendar
    case chat
    case profile

    var view: some View {
        switch self {
        case .home:
            return AnyView(HomeView())
        case .calendar:
            return AnyView(CalendarView())
        case .chat:
            return AnyView(ChatView())
        case .profile:
            return AnyView(ProfileView())
        }
    }

    var title: String {
        switch self {
        case .home: return ""
        case .calendar: return "예약내역"
        case .chat: return "채팅"
        case .profile: return "마이"
        }
    }

    var defaultImage: String {
        switch self {
        case .home: return "HomeTabItem"
        case .calendar: return "ReservationTabItem"
        case .chat: return "ChatTabItem"
        case .profile: return "MyTabItem"
        }
    }

    var selectedImage: String {
        switch self {
        case .home: return "HomeTabItemSelected"
        case .calendar: return "ReservationTabItemSelected"
        case .chat: return "ChatTabItemSelected"
        case .profile: return "MyTabItemSelected"
        }
    }
}
struct MainView: View {
    @State var selectedTabItem = 0

    var body: some View {
                TabView(selection: $selectedTabItem) {
                    ForEach(TabItem.allCases, id: \.self) { tab in
                        tab.view
                            .tabItem {
                                Image(selectedTabItem == tab.rawValue ? tab.selectedImage : tab.defaultImage)
                                Text(tab.title)
                            }
                            .tag(tab.rawValue)
                    }
                } // TabView
    }
}

화면 이름 스크린샷
�navigationStack

navigationstack을 사용하여 뷰간 이동 구현

navigationStack 및 navigationLink를 사용하여 화면 간 이동을 구현하였습니다.
루트뷰인 MainView를 navigationStack으로 감싸주어야 navigation을 인식하여 화면 간 이동을 할 수 있습니다.
그리고, 이동하고 싶은 뷰를 NavigationLink의 destination에 넣어주고, 클로저 부분에는 화면에 노출되는 부분을 넣어 구현합니다.
예를 들어, CompanyListItem 클릭 시 CompanyDetailView로 이동하도록 구현했습니다.

✅ AS-IS
화면 전환이 구현되어 있지 않아 리스트에서 아이템 클릭 시 이동할 수 없었음
✅ TO-BE
navigationLink를 통해 화면 간 이동 가능

✅ 신경 쓴 부분
NavigationStack 사용 위치: MainView를 네비게이션의 루트로 설정하여, 앱의 전체 네비게이션 흐름을 쉽게 관리할 수 있도록 설계했습니다.
유연한 확장성: path를 ViewModel로 관리함으로써 다중 화면 이동 시에도 일관된 상태 관리를 유지할 수 있도록 준비했습니다.

✅ 검토 요청 부분
NavigationStack 및 NavigationLink의 사용 위치와 상태 관리 방법이 적절한지 의견 부탁드립니다.
화면 이동 로직에서 개선할 점이나 추가적으로 고려해야 할 사항이 있다면 알려주세요.

NavigationStack(path: $navigationVM.path) {
                TabView(selection: $selectedTabItem) {
                 // 생략
                } // TabView
}
```swift
NavigationLink(destination: CompanyDetailView()) {
    CompanyListItem(isLikedCompany: false)
}

화면 이름 스크린샷
�navigationStack

deeplink 구현

외부 링크를 통해 앱 실행 시 특정 화면으로 이동할 수 있는 딥링크 기능을 구현했습니다.

딥링크 URL 예시: mongle://searchCompany
✅ AS-IS
딥링크 X
✅ TO-BE
mongle://[host] 형식의 URL을 통해 앱이 실행되고, host 값에 따라 지정된 화면으로 이동 가능

✅ 신경 쓴 부분

  • URL 파싱 및 유효성 검사: URL의 유효성을 검사하고, 정의된 host 값 이외의 요청은 무시하도록 구현하여 안전성을 강화했습니다.
  • 확장성 고려: 새로운 화면이나 기능을 추가할 경우, Screen 열거형과 navigationDestination에 간단히 추가할 수 있도록 구조를 설계했습니다.

URLScheme 및 host 부분은 해당 이미지 참고 부탁드립니다.

딥링크를 구현하기 위해서는 3Step이 필요합니다
[Step1]
Targets > Info > URL Types에 원하는 host를 추가해줍니다. mongle을 추가해주었습니다.

[Step2]
info.plist에 scheme를 추가해줍니다.

[Step3]
코드를 구현해줍니다.

  • 딥링크 처리 로직 추가: URLComponents와 host를 기반으로 URL을 파싱하여 화면 이동을 처리하는 로직을 NavigationViewModel에 구현했습니다.
  • URL의 host 값에 따라 앱 내 특정 화면(home, searchCompany, notification, storeDetail)으로 이동하도록 설정했습니다.
  • onOpenURL 이벤트 처리: 딥링크를 통해 앱이 열릴 때 호출되는 onOpenURL을 사용해 URL을 처리하고 적절한 화면으로 이동하도록 구현했습니다.
public enum Screen: String {
    case home
    case searchCompany
    case notification
    case storeDetail
}

Host: 화면 이동에 사용되는 값(home, searchCompany, notification, storeDetail).

public class NavigationViewModel: ObservableObject {
    @Published public var path: [Screen] = []

    public init(path: [Screen]) {
        self.path = path
    }

    public func handleDeeplink(url: URL) {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
              let route = components.host else { return }

        switch route {
        case Screen.home.rawValue:
            path.append(.home)
        case Screen.searchCompany.rawValue:
            path.append(.searchCompany)
        case Screen.notification.rawValue:
            path.append(.notification)
        case Screen.storeDetail.rawValue:
            path.append(.storeDetail)
        default:
            break
        }
    }
}
struct MainView: View {
    @StateObject var navigationVM = NavigationViewModel(path: [])

    var body: some View {
            NavigationStack(path: $navigationVM.path) {
                TabView(selection: $selectedTabItem) {
                      // 생략
                } // TabView
                .navigationDestination(for: Screen.self) { route in
                    switch route {
                    case .home:
                        HomeView()
                    case .searchCompany:
                        SearchView()
                    case .storeDetail:
                        Text("test")
                    case .notification:
                        NotificationView()
                    }
                } // navigationDestination
                .environmentObject(navigationVM)
                .onOpenURL { url in
                    navigationVM.handleDeeplink(url: url)
                } // openURL
            }
    }
}

🗒️ Note (optional)

NavigationViewModel을 StateObject로 구현하였습니다.
더 좋은 방법이나, navigationPath를 활용하는 방법에 대한 의견이 있다면 공유 부탁드립니다.

TODO: Calandar 뷰에서 "다가오는 예약"이 없을 경우, "목욕업체 둘러보기" 버튼 클릭 시 Tab이 Home으로 이동되어야 함
TODO: Network 레이어 개발 및 Entity, DTO 분리 필요
TODO: Hi-Fi 작업이 완료되었으므로 전체적인 뷰의 디테일을 수정할 필요가 있어보이며, 화면 구현 및 로직 구현 또한 필요해보입니다.

[레퍼런스]
딥링크(Deep Link) - URL Scheme
SwiftUI NavigationStack Router로 깔끔하게 관리하기

@bokoo14 bokoo14 added Feat 새로운 기능을 추가하는 경우 Design UI 디자인 작업 labels Nov 24, 2024
@bokoo14 bokoo14 requested a review from bulmang November 24, 2024 15:34
@bokoo14 bokoo14 self-assigned this Nov 24, 2024
@bulmang

This comment was marked as off-topic.

Copy link
Contributor

@bulmang bulmang left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정말 많은 작업을 해주셨어요!
고생많으셨습니다!!!

Navigation 부분은 제가 이어받아 추가작업을 진행하도록 할게요!

}
.padding(.trailing, 40)
} // ScrollView
} // VStack
}
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#Preview {
    NavigationStack {
        HomeView()
    }
}

네비게이션 기능이 연결이 되었으므로 Preview에서도 네비게이션 기능을 확인할 수 있게 NavigationStack을 넣어주면 어떨까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영했습니다~

import SwiftUI

struct MainView: View {
@StateObject var navigationVM = NavigationViewModel(path: [])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NavigationViewModel에 path를 빈배열로 초기화 시키는 것을 인스턴스화 하는 과정에서 하는 것이 아닌
생성할때 빈 배열로 초기화시키면 가독성이 좋을 것 같습니다.

예시

MainView

@StateObject var navigationVM = NavigationViewModel()

NavigationViewModel

    public init(path: [Screen] = []) {
        self.path = path
    }

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

반영했습니다

Comment on lines +35 to +50
.navigationDestination(for: Screen.self) { route in
switch route {
case .home:
HomeView()
case .searchCompany:
SearchView()
case .storeDetail:
Text("test")
case .notification:
NotificationView()
}
} // navigationDestination
.environmentObject(navigationVM)
.onOpenURL { url in
navigationVM.handleDeeplink(url: url)
} // openURL
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이건 개인의 생각에 따라 다를 것 같은데 가독성을 봤을 때 긴 modifier를 맨 밑으로 가면 어떨까요?
저희가 modifier 규칙을 정하면 좋을 것 같아요!

Suggested change
.navigationDestination(for: Screen.self) { route in
switch route {
case .home:
HomeView()
case .searchCompany:
SearchView()
case .storeDetail:
Text("test")
case .notification:
NotificationView()
}
} // navigationDestination
.environmentObject(navigationVM)
.onOpenURL { url in
navigationVM.handleDeeplink(url: url)
} // openURL
.environmentObject(navigationVM)
.onOpenURL { url in
navigationVM.handleDeeplink(url: url)
} // openURL
.navigationDestination(for: Screen.self) { route in
switch route {
case .home:
HomeView()
case .searchCompany:
SearchView()
case .storeDetail:
Text("test")
case .notification:
NotificationView()
}
} // navigationDestination

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UI를 구성하는 구조체들은 한칸씩 개행하는거 어떨까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이게 무슨 말일까요?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lint 이야기 인데 저희 한 번 이야기 해봐요!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

struct Notification: Identifiable { } 을 해서
ForEach(notifications) { notification in { } 을 하는 방법도 있는데 따로 hashable하는 이유가 있는지 궁금합니다!
어떤 방법이 좋은지 저도 잘몰라서 질문드려요

}
.padding(.horizontal, 20)
} // VStack
.navigationBarBackButtonHidden(true)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UINavigationController를 사용한다면 마찬가지로 제거가 필요합니다.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

마찬가지로 한칸씩 개행하는거 어떠신가요? 이것도 회의하여 규칙을 정하면 좋겠네요!

Comment on lines 18 to 50
public init(
headerText: String,
firstIconImageName: Image? = nil,
firstDestination: FirstDestination,
secondIconName: Image? = nil,
secondDestination: SecondDestination
) {
self.headerText = headerText
self.firstIconImageName = firstIconImageName
self.firstDestination = firstDestination
self.secondIconName = secondIconName
self.secondDestination = secondDestination
}

public init(
headerText: String,
firstIconImageName: Image? = nil,
firstDestination: FirstDestination
) where SecondDestination == EmptyView {
self.headerText = headerText
self.firstIconImageName = firstIconImageName
self.firstDestination = firstDestination
self.secondIconName = nil
self.secondDestination = EmptyView()
}

public init(headerText: String) where FirstDestination == EmptyView, SecondDestination == EmptyView {
self.headerText = headerText
self.iconImageName = iconImageName
self.action = action
self.firstIconImageName = nil
self.firstDestination = EmptyView()
self.secondIconName = nil
self.secondDestination = EmptyView()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아래 코드로 하면 문제없이 작동됩니다. 기본값으로 EmptiyView()를 넣어서 생성자 코드를 한번만 작성해도 됩니다.

    public init(
        headerText: String,
        firstIconImageName: Image? = nil,
        firstDestination: FirstDestination = EmptyView(),
        secondIconName: Image? = nil,
        secondDestination: SecondDestination = EmptyView()
    ) {
        self.headerText = headerText
        self.firstIconImageName = firstIconImageName
        self.firstDestination = firstDestination
        self.secondIconName = secondIconName
        self.secondDestination = secondDestination
    }

@bokoo14
Copy link
Collaborator Author

bokoo14 commented Dec 3, 2024

정말 잘 쓰여진 PR입니다!!👍 저도 꼼꼼하게 보고 저의 의견을 남기겠습니다.😄 읽는 도중에 문제가 있는 부분이 있어 먼저 말씀드립니다!

⚠️
[Step1] 이미지에 저희 카카오톡 앱 키가 보여지고 있습니다.
혹시 블로그나 다른 공개된 곳에 올려져 있다면 내려주세요.

확인하시고 수정하시면 답변부탁드려요!

수정했어용

@bokoo14
Copy link
Collaborator Author

bokoo14 commented Dec 3, 2024

정말 많은 작업을 해주셨어요! 고생많으셨습니다!!!

Navigation 부분은 제가 이어받아 추가작업을 진행하도록 할게요!

오~~ 기대하고 있습니다 ㅎㅎ

@bokoo14 bokoo14 requested a review from bulmang December 20, 2024 01:58
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트 하셨을때 따로 문제 없었을까요?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Design UI 디자인 작업 Feat 새로운 기능을 추가하는 경우
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants