SwiftUI View: NavigationSplitView

Doldamul·2022년 12월 19일
0
post-thumbnail
struct NavigationSplitView<Sidebar, Content, Detail> where Sidebar : View, Content : View, Detail : View
// SwiftUI 4+

2분할 또는 3분할된 View를 만들 때, 즉 1개 또는 2개의 Sidebar를 만들 때 사용한다.

각 기종 및 윈도우 영역에 따라 NavigationStack 형태로 자동 변환되므로 편리하다. 예를 들어, iOS(portrait), watchOS, tvOS 등 사이드바를 지원하지 않는 기기의 경우 NavigationStack 형태로 변환되어 표시된다. iPadOS 또는 macOS에서도 화면분할, 스테이지 매니저, 윈도우 리사이징 등의 이유로 앱의 윈도우 영역이 좁은 경우 NavigationStack 형태로 변환되어 표시된다.

사용 방법

init(sidebar: () -> Sidebar, detail: () -> Detail) where Content == EmptyView
init(sidebar: () -> Sidebar, content: () -> Content, detail: () -> Detail)
init(columnVisibility: Binding<NavigationSplitViewVisibility>, sidebar: () -> Sidebar, detail: () -> Detail) where Content == EmptyView
init(columnVisibility: Binding<NavigationSplitViewVisibility>, sidebar: () -> Sidebar, content: () -> Content, detail: () -> Detail)
  • 2분할 View(Sidebar 1개) : init(sidebar:, detail:)
  • 3분할 View(Sidebar 2개) : init(sidebar:, content:, detail:)
  • columnVisibility : 사이드바 표시 유무를 저장하고 접근하기 위해 사용한다. State 변수를 선언하여 Binding으로 전달한다. NavigationStack 형태로 표시되는 경우, 해당 인자는 무시된다.
    struct NavigationSplitViewVisibility: Equatable, Codable
    4개의 static 타입 프로퍼티가 있다:
    • automatic : 해당 기종 및 윈도우 영역에 따른 기본값을 사용한다.
    • all : 모든 사이드바를 표시한다.
    • doubleColumn : 하나의 사이드바만 표시한다. 이 경우 3분할 View에서의 첫번째 사이드바는 표시되지 않는다.
    • detailOnly : 사이드바를 표시하지 않는다.

sidebar 인자 또는 content 인자의 클로저는 다음과 같이 사용해야 한다:

  • selection 인자를 받는 List + value 인자를 받는 NavigationLink
var categories : [Category]

// NavigationPath를 사용하면 다양한 타입을 단일 State 변수에 저장하여 path로 사용할 수 있다.
@State var selectedCategory : Category? = nil
@State var selectedItem : Item? = nil

var body: some View {
    NavigationSplitView {
        List(categories, selection: $selectedCategory) { category in
            NavigationLink(category.title, value: category)
            // 선택시 List의 selection 인자에 value 인자값을 대입한다.
        }
    } content: {
        List(selectedCategory?.items ?? [], selection: $selectedItem) { item in
            NavigationLink(item.title, value: item)
        }
    } detail: {
        Text(selectedItem?.title ?? "nothing selected")
    }
}

NavigationStackNavigationSplitView는 서로의 클로저 내에 중첩해서 사용할 수 있다.

팁: List를 사용하지 않는 Sidebar

위 사용방법은 대부분의 경우에 깔끔하게 동작하지만, Sidebar에는 반드시 List를 포함해야만 한다는 전제조건이 붙는다. 즉, 다음과 같은 경우에 직면하면 골치 아프다:

  • SidebarList 이외의 View를 사용하고 싶은 경우

다행히도 꼼수를 부려 해결할 수 있다. 다음 예제는 위 예제와 거의 동일하지만, 사이드바에 LazyVGrid를 사용한다:

// 위와 동일...

var row = [GridItem(), GridItem(), GridItem()]

var body: some View {
    NavigationSplitView {
        LazyVGrid(columns: row) {
            ForEach(categories) { category in
                Button(category.title) { selectedCategory = category }
            }
        }

        // List의 selection 인자는 NavigationSplitView와 상호작용한다.
        List(selection: $selectedCategory) {}
    } content: {
        // 위와 동일...
    } detail: {
        // 위와 동일...
    }
}
  1. List를 추가하고 selection 인자를 채운다.
  2. Sidebar의 각 요소를 Button, .onTapGesture 등으로 래핑하고, 선택 이벤트 발생시 selection 인자로 사용된 변수에 해당 요소 값을 대입한다. (앞서 사용한 NavigationLink를 대체한다)
  3. content 클로저에서는 각 selection 인자에 해당하는 목록을 보여준다.

주의사항:

  • List는 눈에 보이거나 공간을 차지할 필요가 없고, 사용 여부는 상관없으나, 반드시 활성화된 상태로 존재해야 한다.
  • NavigationLink를 사용할 수 없다. value 인자를 받는 NavigationLinkselection 인자를 받는 List의 내부 또는 .navigationDestination 수정자가 사용된 View의 내부에 위치해야 하기 때문이다.

앞선 경우보다는 덜 우아하고 약간의 오버헤드가 발생하지만, 어쨌든 별다른 제약 없이 NavigationSplitView를 사용할 수 있다.

관련 수정자

func navigationSplitViewStyle<S>(_ style: S) -> some View where S : NavigationSplitViewStyle
func navigationSplitViewColumnWidth(_ width: CGFloat) -> some View
func navigationSplitViewColumnWidth(min: CGFloat? = nil, ideal: CGFloat, max: CGFloat? = nil) -> some View

위 수정자들로 Sidebar의 스타일을 지정하거나, Sidebardetail View의 폭을 지정할 수 있다.

NavigationSplitViewStyle 프로토콜은 3개의 static 타입 프로퍼티를 가진다.

  • automatic : 현재 맥락(기기 및 윈도우 크기)에 기반하여 자동으로 적합한 스타일로 표시한다.
  • balanced : Sidebar를 띄울 때 detail View의 영역을 줄여 공간을 확보한다. Sidebardetail View를 가리지 않는다.
  • prominentDetail : Sidebar를 띄울 때 detail View의 영역을 조절하지 않고 오버레이한다. Sidebardetail View를 가린다.

해당 프로토콜에서는 스타일을 커스텀할 수 있도록 makeBody(configuration:) 함수 및 NavigationSplitViewStyleConfiguration 구조체를 제공하지만, 구조체 내에 공개된 프로퍼티는 없다.

navigationSplitViewColumnWidth(_:)를 사용하여 사이드바의 폭을 특정 값으로 고정할 수 있다.

navigationSplitViewColumnWidth(min:, ideal:, max:)를 사용하여 사이드바의 폭을 윈도우 크기에 기반하여 유연하게 조정되도록 할 수 있다.

NavigationSplitView {
    MySidebar()
        .navigationSplitViewColumnWidth(150)
} contents: {
    MyContents()
        .navigationSplitViewColumnWidth(
            min: 150, ideal: 200, max: 400)
} detail: {
    MyDetail()
}

마치며

요새 간단한 앱이라도 만들어보려고 이것저것 들쑤셔보고 있는데, 이 빌어먹을 스플릿뷰가 나를 멈춰세웠다. SwiftUI는 참 이쁘고 좋은 언어지만, 'List를 사용하지 않는 Sidebar'처럼 예외 케이스가 나오는 경우... 온갖 극찬이 절로 나온다. 보통 다음 셋 중 하나로 귀결된다:

  • 눈물을 머금고 기존 요구 스펙을 포기한다.
  • UIKit을 끌어와서 봉합한다.
  • api의 동작 구조를 유추해내서 우회법을 찾아낸다.

어느 쪽이던 속이 보글보글 끓는 일이다. 와 맛있겠다

나는 세번째를 선택했고, 결론적으로는 꽤 간단한 해결책이 도출되었지만 그 과정이 쉽지만은 않았다.
애플 개발자 포럼에도 나처럼 머릿속에 라면을 끓이는 분들이 많은 것 같아서 답변을 달아드렸다.

developer.apple.com/forums: Driving NavigationSplitView with something other than List?

이쯤되니 어느정도 정리글이 윤곽이 잡혀버려서, 결국엔 블로그에도 올리게 됐다.
사실 블로그에 올리고 싶었던 글들은 따로 있다. 모두 얼마나 걸릴지는 모르겠다:

  • Core Data 가이드 및 API 정리 문서 소개
    (노션에 정리하고 있다, 양이 방대하니 분명 미완성 상태로 올려버려야되는데 이놈의 욕심 때문에 쉽지 않다)
  • 간단한 앱 개발 및 배포 후기
    (저번 글에 언급한거. 개발속도가 느린데, 아무튼 스플릿뷰 때문임)
  • NSPredicate 정리 문서 소개
    (Core Data 정리하다 슬쩍 건드려본건데 자료 찾기가 쉽지 않다. obj-c랑 영어 실력 좀 늘듯)
  • 타입 시스템 설명(Protocol, Opaque, Existential, Generic)
    (올리겠다고 마음먹은게 몇달 전인데 너무 오래걸려서 멈췄었다)

이렇게 써놓으면 언젠간 올리겠지.

참고자료

developer.apple.com: NavigationSplitView
WWDC22: The SwiftUI cookbook for navigation

profile
덕질은 삶의 활력소다. 내가 애플을 좋아하는 이유. 재밌거덩

0개의 댓글