스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
탐색을 위한 SwiftUI 쿡북
우수한 앱을 위한 레시피는 명확하고 강력한 탐색 구조에서 시작됩니다. SwiftUI 팀과 함께 유명한 코딩 맛집을 방문하여 앱을 위한 우수한 경험을 요리하는 방법을 알아보세요. SwiftUI의 탐색 스택 및 Split View 기능을 소개하고, 앱의 특정 영역으로 연결되는 링크 설정 방법을 보여드리며, 탐색 상태를 빠르고 쉽게 복원하는 방법을 알려드립니다.
리소스
- Bringing robust navigation structure to your SwiftUI app
- List
- Migrating to new navigation types
- NavigationSplitView
- NavigationStack
관련 비디오
Tech Talks
WWDC22
-
다운로드
♪감미로운 연주중심의 힙합음악♪ ♪ 안녕하세요. SwiftUI 팀의 엔지니어 Curt입니다 SwiftUI에는 탐색에 사용할 몇 가지 흥미로운 새로운 API가 있습니다 저는 이러한 새로운 API로 앱을 만들기를 좋아하며 여러분과 공유할 수 있어서 매우 기쁩니다 이러한 API는 Apple TV, iPhone 및 Apple Watch와 같은 기본 스택에서 강력한 다중 열 프레젠테이션으로 확장됩니다 새로운 API는 프로그래밍 방식의 탐색과 딥링크를 강력하게 지원하므로, 여러분은 앱에 완벽한 구조를 구축하기 위한 조각을 구성할 수 있습니다 이번 강의에서는 SwiftUI에서 내비게이션을 사용하여 앱을 만드는 간단한 방법을 알려드리겠습니다 이미 SwiftUI를 사용하고 계신 분들은 이 새로운 API를 통해 한 단계 도약할 수 있기를 바랍니다 먼저 새로운 데이터 기반 내비게이션 API에 들어가는 요소부터 시작하겠습니다 그런 다음 시식 메뉴로 이동하겠습니다 탐색을 프로그래밍 방식으로 완전히 제어할 수 있는 몇 가지 빠르고 쉬운 레시피입니다 디저트 강의에서는 새로운 API를 사용하여 앱의 탐색 상태를 유지하는 몇 가지 팁을 알려드리겠습니다 이전에 SwiftUI에서 내비게이션을 사용한 적이 있다면 새로운 API가 어떻게 다른지 궁금할 것입니다 자세히 알아보기 전에 몇 가지 기존 API를 살펴보겠습니다 기존 API는 다른 열이나 스택에 표시되는 보기를 전송하는 링크를 기반으로 합니다 예를 들어, 루트 뷰에 내비게이션 링크 목록이 있을 수 있습니다 이 링크 중 하나를 누르면 링크가 스택에 보기를 푸시합니다 이 기능은 기본 네비게이션에 적합하며 이 패턴을 계속 사용할 수 있습니다 다시 루트 뷰로 돌아가 보겠습니다 기존 내비게이션 API를 사용하여 프로그래밍 방식으로 링크를 표시하기 위해 링크에 바인딩을 추가하겠습니다 예를 들어, item.showDetail을 true로 설정하여 이 링크의 뷰를 표시할 수 있습니다 그러나 이것은 각 링크에 대해 별도의 바인딩이 필요하다는 것을 의미합니다 새로운 API를 통해 NavigationStack이라고 하는 전체 컨테이너로 바인딩을 전환합니다 여기서 경로는 스택에 푸시된 모든 값을 나타내는 컬렉션입니다 NavigationLinks는 경로에 값을 추가합니다 경로를 변환하여 심층 링크를 만들거나 경로에서 모든 항목을 제거하여 루트 뷰로 이동할 수 있습니다 이번 강의에서는 새로운 내비게이션 API가 데이터 기반 프로그래밍 방식 내비게이션을 가능하게 하는 방식을 설명하겠습니다 강력하고 사용하기 쉽다는 것을 알아보시기 바랍니다 새로운 내비게이션 API를 사용하기 위한 레시피를 보기 전에 메뉴 내용을 공유하면 도움이 될 것이라고 생각했습니다 요즘 요리하는 데 푹 빠져서 레시피를 추적하기 위한 앱을 만들고 있습니다 저에게는 이 정보를 표시하는 다양한 방법에 대해 많은 아이디어가 있습니다 예를 들어, 다음의 삼열 접근법입니다 첫 번째 열에서는 레시피 범주를 선택할 수 있습니다 범주를 선택하면 두 번째 열에 수집한 레시피가 나열됩니다 그리고 레시피를 선택하면 세부 영역에 해당 레시피의 재료들이 표시됩니다 세부 영역에는 관련 레시피의 선택에 대한 링크도 있습니다 우리 할머니는 항상 “껍질로 파이를 만든다"고 말씀하셨습니다 이것이 바로 오늘 우리가 요리하는 방식입니다 우리가 사용할 재료는 새로운 내비게이션 API입니다 이에 대해 자세히 알아본 다음 이 두 가지를 혼합한 특정 내비게이션 레시피를 살펴보겠습니다 새로운 네비게이션 API에는 앱의 구조를 설명하는 데 사용할 수 있는 몇 가지 새로운 컨테이너 유형과 고객이 해당 구조로 이동할 수 있도록 지원하는 NavigationLink의 새로운 종류가 소개되어 있습니다 첫 번째 새로운 컨테이너는 NavigationStack입니다 NavigationStack은 Apple Watch의 본인 찾기, likeiPhone의 설정 및 macOS Ventura의 새로운 시스템 설정 앱에서 볼 수 있는 것과 같은 푸시 팝 인터페이스를 나타냅니다 두 번째 새로운 컨테이너 유형은 NavigationSplitView입니다 NavigationSplitView는 Mac 및 iPad의 Mail 또는 Notes와 같은 다중 열 애플리케이션에 적합합니다 그리고 NavigationSplitView는 iPhone, iPad의 SlideOver, 심지어 Apple Watch와 Apple TV의 단일 열 스택에 자동으로 적응됩니다 NavigNavigationSplitView에는 두 가지 세트의 초기화 함수가 있습니다 여기 표시된 것처럼 한 세트는 2열 경험을 생성합니다 다른 초기화 함수 집합은 3열 경험을 생성합니다 NavigationSplitView에는 열 너비, 사이드바 프리젠테이션을 사용자 정의하고 프로그래밍 방식으로 열을 표시하거나 숨길 수 있는 구성 옵션이 있습니다 이번 강의에서는 구성 옵션에 대해 자세히 설명하지 않겠지만 이에 대해서는 제 동료 Raj의 강의 ”iPad의 SwiftUI: 인터페이스 구성"과 NavigationSplitView를 앱에 맞게 조정하는 방법에 대한 훌륭한 설명서를 참조하십시오 이전에는 NavigationLinks에 표시할 제목과 보기가 항상 포함되어 있었습니다 새로운 변화는 여전히 제목을 포함하지만, 관점 제시 대신 가치를 제시합니다 예를 들어, 이 링크는 애플 파이의 레시피를 제시하고 있습니다 보다시피 NavigationLink는 스마트합니다 링크의 동작은 해당 링크가 표시되는 NavigationStack 또는 목록에 따라 달라집니다 이 맛있는 새로운 API가 어떻게 함께 작동하는지 보기 위해 제 요리책 앱과 여러분의 앱에서 그것들을 사용하는 몇 가지 구체적인 방법을 살펴보겠습니다 첫 번째 레시피는 Apple Watch의 나를 찾기 또는 iPhone의 설정에서 볼 수 있는 기본적인 뷰 스택입니다 저에게는 각 범주에 대한 섹션이 있습니다 섹션 내에서 레시피를 누르면 자세한 내용을 볼 수 있습니다 어떤 레시피에서든 관련 레시피 중 하나를 눌러 스택에 푸시할 수 있습니다 돌아가기 버튼을 사용하여 원래의 레시피와 범주 목록으로 돌아갈 수 있습니다 이 레시피는 NavigationStack과 새로운 종류의 NavigationLink 및 내비게이션 대상 수정자를 결합합니다 그 방법을 봅시다 기본 NavigationStack부터 먼저 시작하겠습니다 내부에는 내 모든 범주와 navigationTitle이 반복되는 목록이 있습니다 목록에는 각 범주에 대한 섹션이 있습니다 다음으로, 각 섹션 안에 범주에 있는 각 레시피에 대한 NavigationLink를 추가하겠습니다 현재 링크를 통해 내 RecipeDetail 뷰를 제시하도록 하겠습니다 여기서는 기존 뷰 대상 NavigationLink를 사용하고 있습니다 그리고 그것은 이 네비게이션 경험으로 요리하기에 충분합니다 하지만 프로그래밍적인 내비게이션은 어떨까요? 프로그래밍적인 내비게이션을 추가하기 위해, 저는 이 네비게이션 링크를 두 부분, 즉 그것이 제시하는 가치와 그 값에 해당한 뷰로 분리할 필요가 있습니다 그 방법을 봅시다 먼저 링크에서 새로운 내비게이션 목적지 조정자로 목적지 뷰를 끌어다 놓겠습니다 이 수정자는 해당 수정자가 담당하는 제공된 데이터의 유형을 선언합니다 여기 이것이 레시피입니다 수정자에는 레시피 값이 표시될 때 스택에 푸시할 뷰를 설명하는 뷰 빌더가 있습니다 그러면 새로운 NavigationLinks 중 하나로 전환해서 레시피 값만 제시하겠습니다 속을 들여다보고 NavigationStack이 어떻게 이 기능을 수행하는지 살펴보도록 하겠습니다 모든 내비게이션 스택은 스택이 표시하는 모든 데이터를 나타내는 경로를 추적합니다 여기에 표시된 것처럼 스택이 루트 뷰만 표시하는 경우 경로는 비어 있습니다 다음으로, 스택은 또한 스택 내부 또는 스택에 푸시된 뷰 내부에서 선언된 모든 내비게이션 대상을 추적합니다 일반적으로 이것은 세트이지만, 이 예에서는 하나의 목적지만 있습니다 푸시된 뷰도 다이어그램에 추가하겠습니다 경로가 비어 있기 때문에 푸시된 뷰 목록도 비어 있습니다 우유와 쿠키처럼, 마법은 이것들을 모두 합칠 때 일어납니다 값 표시 링크를 누르면 해당 값이 경로에 추가됩니다 그런 다음 내비게이션 스택은 경로 값에 대상을 매핑하여 스택에서 푸시할 뷰를 결정합니다 자, 제 애플파이 레시피에서 파이 크러스트를 누르면, 그 링크도 경로에 추가됩니다 NavigationStack은 마법을 사용하여 스택에 다른 RecipeDetail 뷰를 푸시합니다 경로에 추가하는 모든 값에 대해 NavigationStack은 다른 보기를 푸시합니다 뒤로 가기 버튼을 누르면 NavigationStack이 경로 및 푸시된 뷰에서 마지막 항목을 제거합니다 NavigationStack에는 또 다른 요령이 있습니다 바인딩을 사용하여 이 경로에 연결할 수 있습니다 코드로 돌아가겠습니다 이것이 우리가 시작한 곳입니다 경로를 바인딩하려면 먼저 일부 상태를 추가하십시오 이 스택에 푸시되는 모든 값은 레시피이기 때문에 레시피 배열을 내 경로로 사용할 수 있습니다 스택에 다양한 데이터를 제공해야 하는 경우 새로운 유형 삭제 NavigationPath 컬렉션을 확인하십시오 경로 상태를 알면 NavigationStack에 인수를 추가하고 경로에 바인딩을 전달합니다 그것만 있으면 제 스택을 지글지글하게 만들 수 있어요 예를 들어, 특정 레시피로 건너뛰는 방법을 추가할 수 있습니다 또는 스택의 모든 위치에서 경로를 재설정하는 것만으로 루트로 다시 이동할 수 있습니다 이것이 바로 SwiftUI에서 새로운 NavigationStack, 가치 제시 NavigationLinks 및 valuNavigationDestinations를 사용하여 푸시 가능한 스택을 준비하는 방법입니다 이 레시피는 Mac을 포함한 모든 플랫폼에서 작동하지만 iPhone, Apple TV, Apple Watch에서는 더 우수합니다 작동하는 NavigationStack을 보려면 ”Apple Watch용 생산성 앱 개발."을 확인하십시오 다음 레시피는 Mac과 iPad의 메일에서 볼 수 있는 것처럼 스택이 없는 다열 프레젠테이션을 위한 것입니다 iPad에서는 사이드바가 처음에는 숨겨져 있습니다 그것을 표시하고 범주를 선택할 수 있습니다 그러면, 두 번째 열에서, 레시피를 선택할 수 있습니다 세 번째 열은 레시피 세부 정보를 보여줍니다 이 레시피는 NavigationSplitView를 새로운 종류의 NavigationLink 및 목록 선택과 결합합니다 이 레시피는 모달리티를 피하는 데 도움이 되기 때문에 대형 장치에 적합합니다 구체적으로 들어가지 않고도 모든 정보를 볼 수 있습니다 그 방법을 봅시다 내용 및 세부 정보에 대한 자리 표시자 뷰가 포함된 3 열 NavigationSplitView로 시작하겠습니다 그런 다음 사이드바에 내 모든 범주에 걸쳐 반복되는 목록과 navigationTitle을 추가하겠습니다 목록에는 각 범주에 대한 NavigationLink가 있습니다 다음으로,어떤 범주가 선택되었는지 추적할 수 있는 몇 가지 상태를 소개하겠습니다 selectedCategory를 사용하도록 사이드바의 목록을 변경하겠습니다 선택 항목에 대한 바인딩을 전달하고 있습니다 이렇게 하면 목록과 해당 내용에서 선택을 조절할 수 있습니다 일치하는 선택 유형이 있는 목록 내부에 값 표시 링크를 넣으면, 여기 범주에서 말이죠 해당 링크를 누르거나 클릭할 때 선택 항목이 자동으로 업데이트됩니다 따라서 지금 사이드바에서 범주를 선택하면 SwiftUI는 selectedCategory를 업데이트합니다 앞서 언급한 Raj의 “인터페이스 정리" 강의에서 선택 및 목록에 대한 유용한 정보를 확인하십시오 그런 다음 내용 열의 자리 표시자를 선택한 범주에 대한 레시피 목록으로 바꾸고 이 열에 대해 navigationTitle을 추가합니다 선택한 범주와 마찬가지로 동일한 기술을 사용하여 콘텐츠 목록에서 선택한 레시피를 추적할 수 있습니다 selectedRecipe에 대해 상태를 사용하고 컨텐츠 목록에서 해당 상태를 사용하도록 하며 각 레시피에 대해 가치 제시 링크를 사용하겠습니다 마지막으로 세부 정보 열을 업데이트하여 selectedRecipe에 대한 세부 정보를 표시하겠습니다 이를 통해 다시 내비게이션을 완전히 프로그래밍 방식으로 제어할 수 있게 되었습니다 예를 들어 오늘의 레시피로 이동하려면 선택 상태를 업데이트하면 됩니다 이것이 바로 새로운 NavigationSplitView, 값 표시 NavigationLinks 및 선택한 목록을 SwiftUI에서 사용하여 다중 열 내비게이션 환경을 준비하는 방법입니다 이렇게 목록 선택과 NavigationSplitView를 결합하면 SwiftUI가 iPhone 또는 iPad의 Slide Over에 분할 뷰를 자동으로 적용할 수 있습니다 선택 항목을 변경하면 iPhone에서 적절한 푸시 및 팝업으로 자동으로 변환됩니다 물론, 이 다중 열 프레젠테이션은 Mac에서도 잘 작동합니다 그리고 Apple TV와 Apple Watch는 여러 열을 표시하지 않지만 이러한 플랫폼은 단일 스택으로 자동 변환됩니다 SwiftUI의 NavigationSplitView는 모든 플랫폼에서 작동합니다 다음으로, iPad와 Mac의 사진에서처럼 2열로 구성된 내비게이션 환경을 구축하여 이러한 모든 구성 요소를 l함께 사용할 수 있는 방법에 대해 알아보겠습니다 범주를 선택하면 세부 정보 영역에 해당 범주의 모든 레시피가 표시됩니다 레시피를 누르면 그것은 디테일 영역에 있는 스택에 푸시됩니다 관련 레시피를 누르면 그것 또한 스택에 푸시됩니다 그리고 레시피의 그리드로 돌아갈 수 있습니다
이 레시피는 네비게이션 분할 보기, 스택, 링크, 대상 및 목록을 결합한 우리의 구성 요소입니다 이 모든 재료들이 어떻게 어우러지는지 한번 보겠습니다 두 열 NavigationSplitView로 시작하겠습니다 첫 열은 이전의 레시피와 똑같습니다 selectedCategory와 해당 상태에 대한 바인딩과 가치 제시 NavigationLink 및 필수 navigationTitle을 사용하는 목록을 추적할 수 있는 상태가 있습니다 이 레시피의 차이점은 세부 영역에 있습니다 새로운 내비게이션 API는 실제로 구성을 활용합니다 NavigationSplitView의 열 안에 목록을 넣을 수 있는 것처럼 열 안에 NavigationStack도 넣을 수 있습니다 이 내비게이션 스택의 루트 뷰는 저의 RecipeGrid입니다 RecipeGrid는 NavigationStack 내부에 있습니다 다시 말하면 RecipeGrid 내부에 스택 관련 수정자를 넣을 수 있습니다 RecipeGrid의 본문을 확대하여 무엇을 의미하는지 보겠습니다 RecipeGrid는 뷰이며 범주를 파라미터로 사용합니다 여기서는 범주가 선택 사항이므로 if-let로 시작하겠습니다 else 경우에는 빈 선택 항목을 처리합니다 if 구문 안에 스크롤 뷰와 lazy 그리드를 추가하겠습니다 Lazy 그리드 레이아웃은 일련의 뷰를 사용합니다 여기서는 For Each를 사용하여 내 레시피를 반복하고 있습니다 각 레시피에 대해 가치 제시 NavigationLink가 있습니다 이 링크는 레시피 값을 나타냅니다 이 후행 클로저에 있는 링크의 레이블은 썸네일과 제목이 붙어 있는 저의 RecipeTile입니다 그럼 이 그리드를 종료하기 위해 남은 것은 무엇일까요? NavigationStack에 레시피에서 세부 정보 뷰로 매핑하는 방법을 알려주지 않았습니다 첫 번째 레시피에서 언급한 것처럼 새로운 NavigationStack은 navigationDestination 수정자를 사용하여 경로의 값에서 스택에 표시된 뷰로 매핑합니다 이제 navigationDestination 수정자를 추가하겠습니다 그런데 어디에 추가해야 할까요? 링크에 직접 첨부하고 싶지만 이건 두 가지 이유로 인해 잘못된 것입니다 목록, 테이블 또는 LazyVGrid와 같은 Lazy 컨테이너는 모든 뷰를 즉시 로드하지 않습니다 여기에 수정자를 첨부하면 대상이 로드되지 않아 주변 NavigationStack에서 해당 수정자를 볼 수 없을 수 있습니다 둘째, 여기에 수정자를 첨부하면 그것은 그리드의 모든 항목에 대해 반복됩니다 대신 내 ScrollView에 수정자를 첨부하겠습니다 ScrollView 외부에 수정자를 부착하여 NavigationStack이 스크롤 위치와 상관없이 이 탐색 대상을 볼 수 있도록 합니다 여기에 수식어 첨부를 좋아하는 또 다른 점은 수식어가 대상인 링크에 여전히 가깝다는 것입니다 내비게이션 목적지는 저 또는 저의 팀이 원하는 방식으로 코드를 구성할 수 있는 유연성을 제공합니다 내 NavigationSplitView로 돌아가면 여기에 전체 프로그래밍 방식 내비게이션을 활성화하기 위해 필요한 한 가지가 더 있습니다 내비게이션 경로를 추가해야 합니다 경로를 유지할 상태를 추가하고 상태를 NavigationStack에 바인딩하겠습니다 전체 프로그래밍 방식 내비게이션을 통해 이 내비게이션 경험에서 오늘의 레시피를 보여줄 수 있는 방법을 작성할 수 있습니다 이렇게 하는 것이 SwiftUI에서 선택한 새로운 NavigationSplitView, NavigationStack, 가치 제시 NavigationLinks 및 Lists를 사용하여 스택을 사용한 다중 열 내비게이션 환경을 준비하는 방법입니다 이전 레시피와 마찬가지로 이 레시피도 좁은 프레젠테이션에 자동으로 적응하고 모든 플랫폼에서 작동합니다 내 앱에서 내비게이션을 구성하기 위한 이러한 레시피를 탐색하는 것은 재미있었지만, 네비게이션 연회는 디저트 없이는 완성되지 않을 것입니다 이를 위해 내비게이션 상태를 유지하는 방법을 살펴보겠습니다 내 앱에서 내비게이션 상태를 유지하려면 Codable과 SceneStorage라는 두 가지 구성 요소가 더 필요합니다 이 레시피는 세 가지 기본 단계를 가지고 있습니다 먼저 내 내비게이션 상태를 NavigationModel 유형으로 요약합니다 이를 통해 유닛으로 저장 및 복원하여 항상 일관성을 유지할 수 있습니다 그런 다음 제 내비게이션 모델을 Codable로 만듭니다 마지막으로 SceneStorage를 사용하여 모델을 저장하고 복원합니다 이 과정에 조심해야 할 것입니다. 내 앱이 잘못 만든 수플레처럼 부서지는 것을 원하지 않지만, 단계는 간단합니다 먼저 1단계를 보겠습니다 이것은 마지막 레시피의 코드입니다 내 내비게이션 상태는 선택한 범주 및 경로 속성에 저장됩니다 selectedCategory는 사이드바에서 선택 항목을 추적합니다 경로는 상세 영역의 스택에 푸시된 뷰를 추적합니다 새로운 NavigationModel 클래스를 도입하여 ObservableObject에 적합하도록 설정하겠습니다 그런 다음 내비게이션 상태를 모델 개체로 이동하여 속성 래퍼를 State에서 Published로 변경하겠습니다 그런 다음 내 NavigationModel의 인스턴스를 저장할 StateObject를 소개하고 새 모델 개체를 사용하도록 파라미터를 변경합니다 다음 제 내비게이션 모델을 Codable로 만듭니다 먼저 Codable 적합성을 클래스에 추가하는 것으로 시작하겠습니다 대부분의 경우 Swift는 자동으로 Codable 적합성을 생성할 수 있지만 여기서는 나만의 적합성을 구현하려고 합니다 주된 이유는 레시피가 모델 값이기 때문입니다 상태 복원을 위해 전체 모델 값을 저장하려고 하지 않습니다 여기에는 두 가지 이유가 있습니다 첫째, 제 레시피 데이터베이스에는 레시피에 대한 모든 세부 정보가 이미 포함되어 있습니다 저장된 내비게이션 상태에서 해당 정보를 반복하는 것은 스토리지에 대한 우수한 사용이 아닙니다 둘째, 예를 들어 드디어 동기화를 추가할 수 있게 되었기 때문에 레시피 데이터베이스가 로컬 내비게이션 상태와 독립적으로 변경될 수 있는 경우 로컬 내비게이션 상태에 오래된 데이터가 포함되어 있지 않았으면 합니다 사용자 지정 코드화를 위해 다음에는 CodingKey들을 추가하겠습니다 키 중 하나가 바로 selectedCategory입니다 하지만 다른 것은 “recipePathIds"라고 명명했습니다 저는 레시피의 식별자를 경로에 저장할 계획입니다 인코딩 방법에서는 코딩 키를 사용하여 키가 연결된 컨테이너를 만들고 선택한 범주를 컨테이너에 추가합니다 encodeIfPresent를 사용하므로 nil이 아닌 경우에만 값을 씁니다 그런 다음 레시피 경로 식별자를 추가하겠습니다 인코딩할 식별자를 가져오기 위해 경로를 매핑한다는 데 주목하십시오 예를 들어, 위의 그린 박스에 표시된 것처럼 경로에 포함된 Apple Pie와 Pie Crust와 함께 내 내비게이션 상태에 디저트가 선택한 범주로 포함되어 있다고 가정하겠습니다 이것은 이 다른 상자에 표시된 것처럼 JSON으로 인코딩될 수 있습니다 코드화를 끝내기 위해 필요한 초기화 함수를 추가하겠습니다 여기서 흥미로운 점은 레시피 ID를 디코딩한 다음 공유 데이터 모델을 사용하여 ID를 레시피로 다시 변환하는 것입니다 찾을 수 없는 레시피를 폐기하기 위해 compactMap을 사용합니다 예를 들어, 동기화 작업을 수행한 후 다른 장치에서 레시피를 삭제하면, 언젠가는 꼭 해야 할 일이지만, 이런 일이 발생할 수 있습니다 복원된 내비게이션 상태가 계속 유지되도록 하려면 앱에서 자신의 재량권을 사용해야 합니다 마지막으로 모델을 읽고 쓰기 위해 계산된 속성을 JSON 데이터로 추가합니다 이제 내비게이션 모델이 생겼고 자체로 인코딩하고 디코딩하는 방법을 알고 있기 때문에 이제 실제로 저장하고 복원하는 일만 남았습니다 이를 위해 SceneStorage를 사용하겠습니다 여기가 우리의 기본 뷰를 남긴 곳입니다 StateObject를 사용하여 NavigationModel을 보관하고 있습니다 이제 내 NavigationModel을 유지할 수 있는 몇 가지 SceneStorage를 소개하겠습니다 SceneStorage 속성은 관련 값을 자동으로 저장하고 복원합니다 여기 있는 내 데이터와 같이 저장소 유형이 선택적인 경우 새 씬이 생성될 때 nil이 됩니다 시스템이 씬을 복원하면 SwiftUI는 SceneStorage 속성의 값도 복원합니다 이 기능을 사용하여 내 NavigationModel을 유지할 수 있습니다 이를 위해 내 뷰에 작업 수정자를 추가하겠습니다 작업 수정자가 닫기를 비동기식으로 실행합니다 뷰가 나타나면 시작되고 뷰가 사라지면 취소됩니다 내 뷰가 나타날 때마다 먼저 이전에 실행한 앱의 기존 데이터가 있는지 확인합니다 그렇다면 그 데이터로 내비게이션 모델을 업데이트하겠습니다 그런 다음 내비게이션 모델이 변경될 때마다 반복되는 비동기식 for 루프를 시작하겠습니다 이 루프의 본문은 변경될 때마다 실행되므로 이를 사용하여 내 내비게이션 상태를 씬 스토리지 데이터에 다시 저장할 수 있습니다 다 되었습니다! 내가 웹에 있는 빈티지 줄리아 차일드 요리 쇼를 보러 가기 위해 내 앱을 떠나면 앱은 제가 있었던 장소를 기억합니다 다시 앱으로 돌아오면 앱은 제가 떠났던 그 화면을 펼칩니다 자, 어떤 요리책도 마지막에 유용한 주방 팁이 있는 이상한 섹션 없이는 완성될 수 없을 것입니다 고수를 대체할 세 가지 좋은 방법은 없지만 내비게이션에 대한 팁은 몇 가지 있습니다 가능한 한 빨리 새로운 NavigationStack 및 NavigationSplitView로 전환하십시오 스택 스타일과 함께 NavigationView를 사용하는 경우 NavigationStack으로 전환하십시오 NavigationStack은 또한 스택 스타일이 항상 기본값으로 설정되는 Apple TV, Apple Watch 또는 iPad와 iPhone의 시트에 사용할 우수한 첫 번째 선택입니다 다중 열 NavigationView를 사용하는 경우 NavigationSplitView로 전환하십시오 그리고 만약 이미 바인딩된 링크를 이용한 프로그래밍 방식의 내비게이션을 채택한 경우 내비게이션 경로 및 목록 선택과 함께 새로운 가치를 제공하는 NavigationLink로 이동하기를 강력히 권장합니다 이전 스타일의 프로그래밍 링크는 iOS 16 및 정렬된 릴리스에서 더 이상 사용되지 않습니다 새 API로 마이그레이션하는 방법에 대한 자세한 내용과 예시는 개발자 설명서의 "새 내비게이션 유형으로 마이그레이션" 문서를 참조하십시오 다음으로, List와 새로운 NavigationSplitView 및 NavigationStack을 함께 사용할 수 있게 되어있음을 명심하세요 이것들을 구성하여 고객이 좋아할 만한 내비게이션 경험을 만들어 보세요 내비게이션 스택을 사용할 때 내비게이션 대상은 스택 또는 하위 뷰 내의 모든 위치에 있을 수 있습니다 유지 관리를 쉽게 하기 위해 해당 링크 근처에 목적지를 두는 것을 고려하되 lazy 컨테이너 안에 목적지를 넣지 않도록 하십시오 마지막으로 이해가 되면 NavigationSplitView로 내비게이션 경험 쌓기를 시작할 것을 권장합니다 iPhone용으로 처음 개발하더라도 NavigationSplitView는 더 좁은 장치에 자동으로 적응합니다 iPhone Pro Max를 지원하거나 iPad 또는 Mac에 앱을 설치할 준비가 되었으면 NavigationSplitView는 추가 공간을 모두 활용합니다 새로운 SwiftUI Navigation API를 공유할 기회를 주셔서 감사합니다! 앞서 말씀드린 이야기 외에도 "SwiftUI 앱에 여러 창 가져오기"를 통해 앱에서 새 창과 장면을 여는 방법에 대한 유용한 정보를 확인하시기 바랍니다 저희 요리책 앱의 내비게이션 레시피가 입맛을 돋우셨기를 바랍니다 여러분이 직접 만든 앱에서 어떤 멋진 경험을 할 수 있을지 기대됩니다 맛있게 드십시오! ♪
-
-
6:05 - Pushable Stack
import SwiftUI // Pushable stack struct PushableStack: View { @State private var path: [Recipe] = [] @StateObject private var dataModel = DataModel() var body: some View { NavigationStack(path: $path) { List(Category.allCases) { category in Section(category.localizedName) { ForEach(dataModel.recipes(in: category)) { recipe in NavigationLink(recipe.name, value: recipe) } } } .navigationTitle("Categories") .navigationDestination(for: Recipe.self) { recipe in RecipeDetail(recipe: recipe) } } .environmentObject(dataModel) } } // Helpers for code example struct RecipeDetail: View { @EnvironmentObject private var dataModel: DataModel var recipe: Recipe var body: some View { Text("Recipe details go here") .navigationTitle(recipe.name) ForEach(recipe.related.compactMap { dataModel[$0] }) { related in NavigationLink(related.name, value: related) } } } class DataModel: ObservableObject { @Published var recipes: [Recipe] = builtInRecipes func recipes(in category: Category?) -> [Recipe] { recipes .filter { $0.category == category } .sorted { $0.name < $1.name } } subscript(recipeId: Recipe.ID) -> Recipe? { // A real app would want to maintain an index from identifiers to // recipes. recipes.first { recipe in recipe.id == recipeId } } } enum Category: Int, Hashable, CaseIterable, Identifiable, Codable { case dessert case pancake case salad case sandwich var id: Int { rawValue } var localizedName: LocalizedStringKey { switch self { case .dessert: return "Dessert" case .pancake: return "Pancake" case .salad: return "Salad" case .sandwich: return "Sandwich" } } } struct Recipe: Hashable, Identifiable { let id = UUID() var name: String var category: Category var ingredients: [Ingredient] var related: [Recipe.ID] = [] var imageName: String? = nil } struct Ingredient: Hashable, Identifiable { let id = UUID() var description: String static func fromLines(_ lines: String) -> [Ingredient] { lines.split(separator: "\n", omittingEmptySubsequences: true) .map { Ingredient(description: String($0)) } } } let builtInRecipes: [Recipe] = { var recipes = [ "Apple Pie": Recipe( name: "Apple Pie", category: .dessert, ingredients: Ingredient.fromLines(applePie)), "Baklava": Recipe( name: "Baklava", category: .dessert, ingredients: []), "Bolo de Rolo": Recipe( name: "Bolo de rolo", category: .dessert, ingredients: []), "Chocolate Crackles": Recipe( name: "Chocolate crackles", category: .dessert, ingredients: []), "Crème Brûlée": Recipe( name: "Crème brûlée", category: .dessert, ingredients: []), "Fruit Pie Filling": Recipe( name: "Fruit Pie Filling", category: .dessert, ingredients: []), "Kanom Thong Ek": Recipe( name: "Kanom Thong Ek", category: .dessert, ingredients: []), "Mochi": Recipe( name: "Mochi", category: .dessert, ingredients: []), "Marzipan": Recipe( name: "Marzipan", category: .dessert, ingredients: []), "Pie Crust": Recipe( name: "Pie Crust", category: .dessert, ingredients: Ingredient.fromLines(pieCrust)), "Shortbread Biscuits": Recipe( name: "Shortbread Biscuits", category: .dessert, ingredients: []), "Tiramisu": Recipe( name: "Tiramisu", category: .dessert, ingredients: []), "Crêpe": Recipe( name: "Crêpe", category: .pancake, ingredients: []), "Jianbing": Recipe( name: "Jianbing", category: .pancake, ingredients: []), "American": Recipe( name: "American", category: .pancake, ingredients: []), "Dosa": Recipe( name: "Dosa", category: .pancake, ingredients: []), "Injera": Recipe( name: "Injera", category: .pancake, ingredients: []), "Acar": Recipe( name: "Acar", category: .salad, ingredients: []), "Ambrosia": Recipe( name: "Ambrosia", category: .salad, ingredients: []), "Bok l'hong": Recipe( name: "Bok l'hong", category: .salad, ingredients: []), "Caprese": Recipe( name: "Caprese", category: .salad, ingredients: []), "Ceviche": Recipe( name: "Ceviche", category: .salad, ingredients: []), "Çoban salatası": Recipe( name: "Çoban salatası", category: .salad, ingredients: []), "Fiambre": Recipe( name: "Fiambre", category: .salad, ingredients: []), "Kachumbari": Recipe( name: "Kachumbari", category: .salad, ingredients: []), "Niçoise": Recipe( name: "Niçoise", category: .salad, ingredients: []), ] recipes["Apple Pie"]!.related = [ recipes["Pie Crust"]!.id, recipes["Fruit Pie Filling"]!.id, ] recipes["Pie Crust"]!.related = [recipes["Fruit Pie Filling"]!.id] recipes["Fruit Pie Filling"]!.related = [recipes["Pie Crust"]!.id] return Array(recipes.values) }() let applePie = """ ¾ cup white sugar 2 tablespoons all-purpose flour ½ teaspoon ground cinnamon ¼ teaspoon ground nutmeg ½ teaspoon lemon zest 7 cups thinly sliced apples 2 teaspoons lemon juice 1 tablespoon butter 1 recipe pastry for a 9 inch double crust pie 4 tablespoons milk """ let pieCrust = """ 2 ½ cups all purpose flour 1 Tbsp. powdered sugar 1 tsp. sea salt ½ cup shortening ½ cup butter (Cold, Cut Into Small Pieces) ⅓ cup cold water (Plus More As Needed) """ struct PushableStack_Previews: PreviewProvider { static var previews: some View { PushableStack() } }
-
10:40 - Multiple Columns
import SwiftUI // Multiple columns struct MultipleColumns: View { @State private var selectedCategory: Category? @State private var selectedRecipe: Recipe? @StateObject private var dataModel = DataModel() var body: some View { NavigationSplitView { List(Category.allCases, selection: $selectedCategory) { category in NavigationLink(category.localizedName, value: category) } .navigationTitle("Categories") } content: { List( dataModel.recipes(in: selectedCategory), selection: $selectedRecipe) { recipe in NavigationLink(recipe.name, value: recipe) } .navigationTitle(selectedCategory?.localizedName ?? "Recipes") } detail: { RecipeDetail(recipe: selectedRecipe) } } } // Helpers for code example struct RecipeDetail: View { var recipe: Recipe? var body: some View { Text("Recipe details go here") .navigationTitle(recipe?.name ?? "") } } class DataModel: ObservableObject { @Published var recipes: [Recipe] = builtInRecipes func recipes(in category: Category?) -> [Recipe] { recipes .filter { $0.category == category } .sorted { $0.name < $1.name } } } enum Category: Int, Hashable, CaseIterable, Identifiable, Codable { case dessert case pancake case salad case sandwich var id: Int { rawValue } var localizedName: LocalizedStringKey { switch self { case .dessert: return "Dessert" case .pancake: return "Pancake" case .salad: return "Salad" case .sandwich: return "Sandwich" } } } struct Recipe: Hashable, Identifiable { let id = UUID() var name: String var category: Category var ingredients: [Ingredient] var related: [Recipe.ID] = [] var imageName: String? = nil } struct Ingredient: Hashable, Identifiable { let id = UUID() var description: String static func fromLines(_ lines: String) -> [Ingredient] { lines.split(separator: "\n", omittingEmptySubsequences: true) .map { Ingredient(description: String($0)) } } } let builtInRecipes: [Recipe] = { var recipes = [ "Apple Pie": Recipe( name: "Apple Pie", category: .dessert, ingredients: Ingredient.fromLines(applePie)), "Baklava": Recipe( name: "Baklava", category: .dessert, ingredients: []), "Bolo de Rolo": Recipe( name: "Bolo de rolo", category: .dessert, ingredients: []), "Chocolate Crackles": Recipe( name: "Chocolate crackles", category: .dessert, ingredients: []), "Crème Brûlée": Recipe( name: "Crème brûlée", category: .dessert, ingredients: []), "Fruit Pie Filling": Recipe( name: "Fruit Pie Filling", category: .dessert, ingredients: []), "Kanom Thong Ek": Recipe( name: "Kanom Thong Ek", category: .dessert, ingredients: []), "Mochi": Recipe( name: "Mochi", category: .dessert, ingredients: []), "Marzipan": Recipe( name: "Marzipan", category: .dessert, ingredients: []), "Pie Crust": Recipe( name: "Pie Crust", category: .dessert, ingredients: Ingredient.fromLines(pieCrust)), "Shortbread Biscuits": Recipe( name: "Shortbread Biscuits", category: .dessert, ingredients: []), "Tiramisu": Recipe( name: "Tiramisu", category: .dessert, ingredients: []), "Crêpe": Recipe( name: "Crêpe", category: .pancake, ingredients: []), "Jianbing": Recipe( name: "Jianbing", category: .pancake, ingredients: []), "American": Recipe( name: "American", category: .pancake, ingredients: []), "Dosa": Recipe( name: "Dosa", category: .pancake, ingredients: []), "Injera": Recipe( name: "Injera", category: .pancake, ingredients: []), "Acar": Recipe( name: "Acar", category: .salad, ingredients: []), "Ambrosia": Recipe( name: "Ambrosia", category: .salad, ingredients: []), "Bok l'hong": Recipe( name: "Bok l'hong", category: .salad, ingredients: []), "Caprese": Recipe( name: "Caprese", category: .salad, ingredients: []), "Ceviche": Recipe( name: "Ceviche", category: .salad, ingredients: []), "Çoban salatası": Recipe( name: "Çoban salatası", category: .salad, ingredients: []), "Fiambre": Recipe( name: "Fiambre", category: .salad, ingredients: []), "Kachumbari": Recipe( name: "Kachumbari", category: .salad, ingredients: []), "Niçoise": Recipe( name: "Niçoise", category: .salad, ingredients: []), ] recipes["Apple Pie"]!.related = [ recipes["Pie Crust"]!.id, recipes["Fruit Pie Filling"]!.id, ] recipes["Pie Crust"]!.related = [recipes["Fruit Pie Filling"]!.id] recipes["Fruit Pie Filling"]!.related = [recipes["Pie Crust"]!.id] return Array(recipes.values) }() let applePie = """ ¾ cup white sugar 2 tablespoons all-purpose flour ½ teaspoon ground cinnamon ¼ teaspoon ground nutmeg ½ teaspoon lemon zest 7 cups thinly sliced apples 2 teaspoons lemon juice 1 tablespoon butter 1 recipe pastry for a 9 inch double crust pie 4 tablespoons milk """ let pieCrust = """ 2 ½ cups all purpose flour 1 Tbsp. powdered sugar 1 tsp. sea salt ½ cup shortening ½ cup butter (Cold, Cut Into Small Pieces) ⅓ cup cold water (Plus More As Needed) """ struct MultipleColumns_Previews: PreviewProvider { static var previews: some View { MultipleColumns() } }
-
14:10 - Multiple Columns with a Stack
import SwiftUI // Multiple columns with a stack struct MultipleColumnsWithStack: View { @State private var selectedCategory: Category? @State private var path: [Recipe] = [] @StateObject private var dataModel = DataModel() var body: some View { NavigationSplitView { List(Category.allCases, selection: $selectedCategory) { category in NavigationLink(category.localizedName, value: category) } .navigationTitle("Categories") } detail: { NavigationStack(path: $path) { RecipeGrid(category: selectedCategory) } } .environmentObject(dataModel) } } struct RecipeGrid: View { @EnvironmentObject private var dataModel: DataModel var category: Category? var body: some View { if let category = category { ScrollView { LazyVGrid(columns: columns) { ForEach(dataModel.recipes(in: category)) { recipe in NavigationLink(value: recipe) { RecipeTile(recipe: recipe) } } } } .navigationTitle(category.localizedName) .navigationDestination(for: Recipe.self) { recipe in RecipeDetail(recipe: recipe) } } else { Text("Select a category") } } var columns: [GridItem] { [GridItem(.adaptive(minimum: 240))] } } struct RecipeDetail: View { @EnvironmentObject private var dataModel: DataModel var recipe: Recipe var body: some View { Text("Recipe details go here") .navigationTitle(recipe.name) ForEach(recipe.related.compactMap { dataModel[$0] }) { related in NavigationLink(related.name, value: related) } } } struct RecipeTile: View { var recipe: Recipe var body: some View { VStack { Rectangle() .fill(Color.secondary.gradient) .frame(width: 240, height: 240) Text(recipe.name) .lineLimit(2, reservesSpace: true) .font(.headline) } .tint(.primary) } } class DataModel: ObservableObject { @Published var recipes: [Recipe] = builtInRecipes func recipes(in category: Category?) -> [Recipe] { recipes .filter { $0.category == category } .sorted { $0.name < $1.name } } subscript(recipeId: Recipe.ID) -> Recipe? { // A real app would want to maintain an index from identifiers to // recipes. recipes.first { recipe in recipe.id == recipeId } } } enum Category: Int, Hashable, CaseIterable, Identifiable, Codable { case dessert case pancake case salad case sandwich var id: Int { rawValue } var localizedName: LocalizedStringKey { switch self { case .dessert: return "Dessert" case .pancake: return "Pancake" case .salad: return "Salad" case .sandwich: return "Sandwich" } } } struct Recipe: Hashable, Identifiable { let id = UUID() var name: String var category: Category var ingredients: [Ingredient] var related: [Recipe.ID] = [] var imageName: String? = nil } struct Ingredient: Hashable, Identifiable { let id = UUID() var description: String static func fromLines(_ lines: String) -> [Ingredient] { lines.split(separator: "\n", omittingEmptySubsequences: true) .map { Ingredient(description: String($0)) } } } let builtInRecipes: [Recipe] = { var recipes = [ "Apple Pie": Recipe( name: "Apple Pie", category: .dessert, ingredients: Ingredient.fromLines(applePie)), "Baklava": Recipe( name: "Baklava", category: .dessert, ingredients: []), "Bolo de Rolo": Recipe( name: "Bolo de rolo", category: .dessert, ingredients: []), "Chocolate Crackles": Recipe( name: "Chocolate crackles", category: .dessert, ingredients: []), "Crème Brûlée": Recipe( name: "Crème brûlée", category: .dessert, ingredients: []), "Fruit Pie Filling": Recipe( name: "Fruit Pie Filling", category: .dessert, ingredients: []), "Kanom Thong Ek": Recipe( name: "Kanom Thong Ek", category: .dessert, ingredients: []), "Mochi": Recipe( name: "Mochi", category: .dessert, ingredients: []), "Marzipan": Recipe( name: "Marzipan", category: .dessert, ingredients: []), "Pie Crust": Recipe( name: "Pie Crust", category: .dessert, ingredients: Ingredient.fromLines(pieCrust)), "Shortbread Biscuits": Recipe( name: "Shortbread Biscuits", category: .dessert, ingredients: []), "Tiramisu": Recipe( name: "Tiramisu", category: .dessert, ingredients: []), "Crêpe": Recipe( name: "Crêpe", category: .pancake, ingredients: []), "Jianbing": Recipe( name: "Jianbing", category: .pancake, ingredients: []), "American": Recipe( name: "American", category: .pancake, ingredients: []), "Dosa": Recipe( name: "Dosa", category: .pancake, ingredients: []), "Injera": Recipe( name: "Injera", category: .pancake, ingredients: []), "Acar": Recipe( name: "Acar", category: .salad, ingredients: []), "Ambrosia": Recipe( name: "Ambrosia", category: .salad, ingredients: []), "Bok l'hong": Recipe( name: "Bok l'hong", category: .salad, ingredients: []), "Caprese": Recipe( name: "Caprese", category: .salad, ingredients: []), "Ceviche": Recipe( name: "Ceviche", category: .salad, ingredients: []), "Çoban salatası": Recipe( name: "Çoban salatası", category: .salad, ingredients: []), "Fiambre": Recipe( name: "Fiambre", category: .salad, ingredients: []), "Kachumbari": Recipe( name: "Kachumbari", category: .salad, ingredients: []), "Niçoise": Recipe( name: "Niçoise", category: .salad, ingredients: []), ] recipes["Apple Pie"]!.related = [ recipes["Pie Crust"]!.id, recipes["Fruit Pie Filling"]!.id, ] recipes["Pie Crust"]!.related = [recipes["Fruit Pie Filling"]!.id] recipes["Fruit Pie Filling"]!.related = [recipes["Pie Crust"]!.id] return Array(recipes.values) }() let applePie = """ ¾ cup white sugar 2 tablespoons all-purpose flour ½ teaspoon ground cinnamon ¼ teaspoon ground nutmeg ½ teaspoon lemon zest 7 cups thinly sliced apples 2 teaspoons lemon juice 1 tablespoon butter 1 recipe pastry for a 9 inch double crust pie 4 tablespoons milk """ let pieCrust = """ 2 ½ cups all purpose flour 1 Tbsp. powdered sugar 1 tsp. sea salt ½ cup shortening ½ cup butter (Cold, Cut Into Small Pieces) ⅓ cup cold water (Plus More As Needed) """ struct MultipleColumnsWithStack_Previews: PreviewProvider { static var previews: some View { MultipleColumnsWithStack() } }
-
18:12 - Use Scene Storage
import SwiftUI import Combine import Foundation // Use SceneStorage to save and restore struct UseSceneStorage: View { @StateObject private var navModel = NavigationModel() @SceneStorage("navigation") private var data: Data? @StateObject private var dataModel = DataModel() var body: some View { NavigationSplitView { List( Category.allCases, selection: $navModel.selectedCategory ) { category in NavigationLink(category.localizedName, value: category) } .navigationTitle("Categories") } detail: { NavigationStack(path: $navModel.recipePath) { RecipeGrid(category: navModel.selectedCategory) } } .task { if let data = data { navModel.jsonData = data } for await _ in navModel.objectWillChangeSequence { data = navModel.jsonData } } .environmentObject(dataModel) } } // Make the navigation model Codable class NavigationModel: ObservableObject, Codable { @Published var selectedCategory: Category? @Published var recipePath: [Recipe] = [] enum CodingKeys: String, CodingKey { case selectedCategory case recipePathIds } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(selectedCategory, forKey: .selectedCategory) try container.encode(recipePath.map(\.id), forKey: .recipePathIds) } init() {} required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.selectedCategory = try container.decodeIfPresent( Category.self, forKey: .selectedCategory) let recipePathIds = try container.decode([Recipe.ID].self, forKey: .recipePathIds) self.recipePath = recipePathIds.compactMap { DataModel.shared[$0] } } var jsonData: Data? { get { try? JSONEncoder().encode(self) } set { guard let data = newValue, let model = try? JSONDecoder().decode(NavigationModel.self, from: data) else { return } self.selectedCategory = model.selectedCategory self.recipePath = model.recipePath } } var objectWillChangeSequence: AsyncPublisher<Publishers.Buffer<ObservableObjectPublisher>> { objectWillChange .buffer(size: 1, prefetch: .byRequest, whenFull: .dropOldest) .values } } struct RecipeGrid: View { var category: Category? @EnvironmentObject private var dataModel: DataModel var body: some View { if let category = category { ScrollView { LazyVGrid(columns: columns) { ForEach(dataModel.recipes(in: category)) { recipe in NavigationLink(value: recipe) { RecipeTile(recipe: recipe) } } } } .navigationTitle(category.localizedName) .navigationDestination(for: Recipe.self) { recipe in RecipeDetail(recipe: recipe) } } else { Text("Select a category") } } var columns: [GridItem] { [GridItem(.adaptive(minimum: 240))] } } struct RecipeDetail: View { @EnvironmentObject private var dataModel: DataModel var recipe: Recipe var body: some View { Text("Recipe details go here") .navigationTitle(recipe.name) ForEach(recipe.related.compactMap { dataModel[$0] }) { related in NavigationLink(related.name, value: related) } } } struct RecipeTile: View { var recipe: Recipe var body: some View { VStack { Rectangle() .fill(Color.secondary.gradient) .frame(width: 240, height: 240) Text(recipe.name) .lineLimit(2, reservesSpace: true) .font(.headline) } .tint(.primary) } } class DataModel: ObservableObject { @Published var recipes: [Recipe] = builtInRecipes static var shared: DataModel { // Just instantiate each time for the example. A real app would need to // persist the data model as well. DataModel() } func recipes(in category: Category?) -> [Recipe] { recipes .filter { $0.category == category } .sorted { $0.name < $1.name } } subscript(recipeId: Recipe.ID) -> Recipe? { // A real app would want to maintain an index from identifiers to // recipes. recipes.first { recipe in recipe.id == recipeId } } } enum Category: Int, Hashable, CaseIterable, Identifiable, Codable { case dessert case pancake case salad case sandwich var id: Int { rawValue } var localizedName: LocalizedStringKey { switch self { case .dessert: return "Dessert" case .pancake: return "Pancake" case .salad: return "Salad" case .sandwich: return "Sandwich" } } } struct Recipe: Hashable, Identifiable { let id: UUID var name: String var category: Category var ingredients: [Ingredient] var related: [Recipe.ID] = [] var imageName: String? = nil } struct Ingredient: Hashable, Identifiable { let id = UUID() var description: String static func fromLines(_ lines: String) -> [Ingredient] { lines.split(separator: "\n", omittingEmptySubsequences: true) .map { Ingredient(description: String($0)) } } } let builtInRecipes: [Recipe] = { var recipes = [ "Apple Pie": Recipe( id: UUID(uuidString: "E35A5C9C-F1EA-4B3D-9980-E2240B363AC8")!, name: "Apple Pie", category: .dessert, ingredients: Ingredient.fromLines(applePie)), "Baklava": Recipe( id: UUID(uuidString: "B95B2D99-F45D-4B74-9EC4-526914FFC414")!, name: "Baklava", category: .dessert, ingredients: []), "Bolo de Rolo": Recipe( id: UUID(uuidString: "E17C729D-1E09-48F6-99E2-5BB959F5AE70")!, name: "Bolo de Rolo", category: .dessert, ingredients: []), "Chocolate Crackles": Recipe( id: UUID(uuidString: "89202A12-2B04-4EFE-ADC5-D1ECE7A25389")!, name: "Chocolate Crackles", category: .dessert, ingredients: []), "Crème Brûlée": Recipe( id: UUID(uuidString: "412EA92A-40B5-4CFE-9379-627A1C80FFE1")!, name: "Crème Brûlée", category: .dessert, ingredients: []), "Fruit Pie Filling": Recipe( id: UUID(uuidString: "4792C8AE-9596-4502-A9CB-806E2DFEA408")!, name: "Fruit Pie Filling", category: .dessert, ingredients: []), "Kanom Thong Ek": Recipe( id: UUID(uuidString: "331C25F6-4FED-4DA5-980E-7E619855DE92")!, name: "Kanom Thong Ek", category: .dessert, ingredients: []), "Mochi": Recipe( id: UUID(uuidString: "1EAA5288-8D2B-4969-AF97-ED591796B456")!, name: "Mochi", category: .dessert, ingredients: []), "Marzipan": Recipe( id: UUID(uuidString: "416F4F5A-A81C-40FD-87F1-060B0F57DE6D")!, name: "Marzipan", category: .dessert, ingredients: []), "Pie Crust": Recipe( id: UUID(uuidString: "D0820C1A-1AFB-4472-97DA-39A475304048")!, name: "Pie Crust", category: .dessert, ingredients: Ingredient.fromLines(pieCrust)), "Shortbread Biscuits": Recipe( id: UUID(uuidString: "3D9FEA8C-B38E-4739-8B4B-424885D76926")!, name: "Shortbread Biscuits", category: .dessert, ingredients: []), "Tiramisu": Recipe( id: UUID(uuidString: "586B9A4C-410A-40D2-AE40-BC32351A5C08")!, name: "Tiramisu", category: .dessert, ingredients: []), "Crêpe": Recipe( id: UUID(uuidString: "9BD6C3B2-30CB-425E-8D60-7F07D0BA720C")!, name: "Crêpe", category: .pancake, ingredients: []), "Jianbing": Recipe( id: UUID(uuidString: "117E5CD4-8FF9-43FB-ACAE-53C35A648F6F")!, name: "Jianbing", category: .pancake, ingredients: []), "American": Recipe( id: UUID(uuidString: "4584B877-E482-4FF2-824E-FC667BFAD271")!, name: "American", category: .pancake, ingredients: []), "Dosa": Recipe( id: UUID(uuidString: "5666FEB6-90DB-4CD2-91FA-D6F00986E90E")!, name: "Dosa", category: .pancake, ingredients: []), "Injera": Recipe( id: UUID(uuidString: "752DAEB8-123E-4C48-A190-79742AA56869")!, name: "Injera", category: .pancake, ingredients: []), "Acar": Recipe( id: UUID(uuidString: "F0D54AF2-04AD-4F08-ACE4-7886FCAE1F7B")!, name: "Acar", category: .salad, ingredients: []), "Ambrosia": Recipe( id: UUID(uuidString: "F7FD59E8-F1AE-4331-8667-D5534817F7E7")!, name: "Ambrosia", category: .salad, ingredients: []), "Bok L'hong": Recipe( id: UUID(uuidString: "3DE38C07-F985-4E05-810C-1108A777766B")!, name: "Bok L'hong", category: .salad, ingredients: []), "Caprese": Recipe( id: UUID(uuidString: "055D963C-0546-4578-AF18-6FBEE249EF35")!, name: "Caprese", category: .salad, ingredients: []), "Ceviche": Recipe( id: UUID(uuidString: "50B62AF4-89AF-4D00-9832-E200FEC01279")!, name: "Ceviche", category: .salad, ingredients: []), "Çoban Salatası": Recipe( id: UUID(uuidString: "87AD6B33-FFD2-4E5C-BC4B-59769F7AC7E3")!, name: "Çoban Salatası", category: .salad, ingredients: []), "Fiambre": Recipe( id: UUID(uuidString: "8A9BC0D5-A931-4381-BDA8-713DF6389FE7")!, name: "Fiambre", category: .salad, ingredients: []), "Kachumbari": Recipe( id: UUID(uuidString: "E9497D38-49E0-4A18-939B-63A3F2C7C0B4")!, name: "Kachumbari", category: .salad, ingredients: []), "Niçoise": Recipe( id: UUID(uuidString: "DE9F7106-4D0C-4EAC-B44C-A8D8ECD81087")!, name: "Niçoise", category: .salad, ingredients: []) ] recipes["Apple Pie"]!.related = [ recipes["Pie Crust"]!.id, recipes["Fruit Pie Filling"]!.id ] recipes["Pie Crust"]!.related = [recipes["Fruit Pie Filling"]!.id] recipes["Fruit Pie Filling"]!.related = [recipes["Pie Crust"]!.id] return Array(recipes.values) }() let applePie = """ ¾ cup white sugar 2 tablespoons all-purpose flour ½ teaspoon ground cinnamon ¼ teaspoon ground nutmeg ½ teaspoon lemon zest 7 cups thinly sliced apples 2 teaspoons lemon juice 1 tablespoon butter 1 recipe pastry for a 9 inch double crust pie 4 tablespoons milk """ let pieCrust = """ 2 ½ cups all purpose flour 1 Tbsp. powdered sugar 1 tsp. sea salt ½ cup shortening ½ cup butter (Cold, Cut Into Small Pieces) ⅓ cup cold water (Plus More As Needed) """ struct UseSceneStorage_Previews: PreviewProvider { static var previews: some View { UseSceneStorage() } }
-
25:33 - Biscuits
import SwiftUI struct Biscuits: View { @State private var step = 0 @ScaledMetric private var fontSize = 18 var body: some View { VStack(alignment: .leading) { HStack { Spacer() VStack { Text("Biscuits") .font(.headline) Text(subtitle) .font(.subheadline) } .padding(16) Spacer() } Spacer() Text(LocalizedStringKey(steps[step])) .font(.system( size: fontSize, weight: .semibold, design: .serif)) .padding(16) .lineLimit(1...) Spacer() HStack { Button { withAnimation { step -= 1 } } label: { Label("Previous", systemImage: "chevron.backward") } .disabled(step - 1 < 0) Spacer() Button { withAnimation { step += 1 } } label: { Label("Next", systemImage: "chevron.forward") } .disabled(step + 1 >= steps.count) } .buttonStyle(CarouselButtonStyle()) .padding(16) } .foregroundStyle(Color.white) .background(gradient) .ignoresSafeArea(edges: .bottom) } var subtitle: LocalizedStringKey { if step == 0 { return "Ingredients" } return "Step \(step)" } var gradient: AngularGradient { AngularGradient( colors: colors, center: UnitPoint(x: 0.5, y: 1.0), angle: .degrees(180 * Double(step) / Double(steps.count - 1))) } } struct CarouselButtonStyle: ButtonStyle { @Environment(\.isEnabled) private var isEnabled func makeBody(configuration: Configuration) -> some View { ZStack { Circle() .fill(.ultraThinMaterial.shadow(.inner( radius: configuration.isPressed ? 3 : 0))) .frame(width: 44, height: 44) configuration.label .labelStyle(.iconOnly) .foregroundStyle(isEnabled ? .black : .secondary) .opacity(configuration.isPressed ? 0.3 : 0.8) } } } let steps = [ """ 2 cups all-purpose flour ¼ teaspoons coarse salt 1 cup (2 sticks) unsalted butter, room temperature ¾ cup confectioners' sugar """, "Sift flour and salt, mix into bowl and set aside.", "Mix butter on high speed until fluffy (3 to 5 minutes).", "Gradually add sugar slowly, continuing to mix until pale and fluffy.", "Add flour all at once and mix until combined.", "Butter a square pan.", "Pat and roll shortbread into pan no more than 1/2-inch thick.", "Refrigerate for at least 30 minutes.", "Preheat oven to 300 F.", "Cut chilled shortbread into squares.", """ Bake until golden and make sure the middle is firm. \ Approximately 45 to 60 minutes. """, "Cool completely. Re-slice them, if necessary, and serve.", ] let colors = [Color.yellow, .red, .purple] struct Biscuits_Previews: PreviewProvider { static var previews: some View { Biscuits() } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.