Streaming is available in most browsers,
and in the Developer app.
-
The SwiftUI cookbook for navigation
The recipe for a great app begins with a clear and robust navigation structure. Join the SwiftUI team in our proverbial coding kitchen and learn how you can cook up a great experience for your app. We'll introduce you to SwiftUI's navigation stack and split view features, show you how you can link to specific areas of your app, and explore how you can quickly and easily restore navigational state.
Resources
- Bringing robust navigation structure to your SwiftUI app
- List
- Migrating to new navigation types
- NavigationSplitView
- NavigationStack
Related Videos
Tech Talks
WWDC22
-
Download
♪ instrumental hip hop music ♪ ♪ Hi. I'm Curt, an engineer on the SwiftUI team. There are some exciting new APIs for navigation in SwiftUI. I've been enjoying building apps with these new APIs and I'm thrilled to be able to share them with you. These APIs scale from basic stacks -- like on Apple TV, iPhone, and Apple Watch -- to powerful multicolumn presentations. The new APIs bring robust support for programmatic navigation and deep linking, letting you compose pieces to build the perfect structure for your app. In this talk, I'll give you some straightforward recipes for cooking up an app with navigation in SwiftUI. And if you're already using SwiftUI, we hope these new APIs will help you kick it up a notch. I'll start with the ingredients that go into the new data-driven navigation APIs. Then, we'll move to our tasting menu: several quick and easy recipes for full programmatic control of navigation. For the dessert course, I'll share some tips on using the new APIs to persist navigation state in your apps. If you've used navigation in SwiftUI before, you might be wondering how the new APIs are different. So before digging in, let's review some of the existing API. The existing APIs are based on links that send views that are shown in other columns or on a stack. For example, I might have a list of navigation links in a root view. When I tap one of these links, the link pushes its view on the stack. This works great for basic navigation, and you can continue using this pattern. But let's pop back to the root view. With the existing navigation API, to present a link programmatically, I add a binding to the link. For example, I can present this link's view by setting item.showDetail to true. But this means I need a separate binding for each link. With the new API, we lift the binding up to the entire container, called a NavigationStack. The path here is a collection that represents all the values pushed on the stack. NavigationLinks append values to the path. You can deep link by mutating the path; or pop to the root view by removing all the items from the path. In this talk, I'll show you how the new navigation API enables data-driven programmatic navigation. I hope you find it powerful and easy to use. Before jumping into recipes for using the new navigation APIs, I thought it would be helpful to share what's on the menu. I've really gotten into cooking lately and I've been working on an app to keep track of my recipes. I have a lot of ideas about different ways to present this info. For example, here's a three-column approach. The first column lets me select a recipe category. When I select a category, the second column lists the recipes I've collected. And when I select a recipe, the detail area shows the ingredients for that recipe. The detail area also has links to a selection of related recipes. My grandma always said, "The crust makes the pie." So that's what we're cooking up today. Our ingredients are the new navigation APIs. Let's dig into those, then we'll look at some specific navigation recipes that mix them together. The new navigation APIs introduce a couple of new container types that you can use to describe the structure of your app, along with a fresh new varietal of NavigationLink for helping your guests move around that structure. The first new container is NavigationStack. NavigationStack represents a push-pop interface like you see in Find My on Apple Watch, Settings on iPhone, and the new System Settings app on macOS Ventura. The second new container type is NavigationSplitView. NavigationSplitView is perfect for multicolumn apps like Mail or Notes on Mac and iPad. And NavigationSplitView automatically adapts to a single-column stack on iPhone, in Slide Over on iPad, and even on Apple Watch and Apple TV. NavigationSplitView has two sets of initializers. One set, like shown here, creates a two-column experience. The other set of initializers creates a three-column experience. NavigationSplitView comes with a cartload of configuration options that let you customize column widths, sidebar presentation, and even programmatically show and hide columns. I won't dive into the configuration options in this talk, but please check out my colleague Raj's talk, "SwiftUI on iPad: Organize your interface" and the great documentation on how to tune NavigationSplitView to be just right for your app. Previously, NavigationLinks always included a title and view to present. The new varieties still include a title, but instead of a view to present, they present a value. For example, this link is presenting the recipe for apple pie. As we'll see, NavigationLink is smart. A link's behavior depends on the NavigationStack or list that it appears in. To see how these tasty new APIs work together, let's look at some specific recipes for using them in my cookbook app, and in your apps. Our first recipe is a basic stack of views, like you'd find in Find My on Apple Watch or Settings on iPhone. I have a section for each category. Within a section, I can tap on a recipe to see the details. Within any recipe, I can tap one of the related recipes to push it onto the stack. I can use the back button to return to the original recipe and then to the categories list. This recipe combines a NavigationStack with the new variety of NavigationLink, and a navigation destination modifier. Let's see how. I'll start with a basic NavigationStack. Inside, I have a List that iterates over all my categories and a navigationTitle. Inside the List, I have a section for each category. Next, inside each section, I'll add a NavigationLink for each recipe in the category. For now, I'll make the link present my RecipeDetail view. This is using the existing view destination NavigationLink. And that's enough to get this navigation experience cooking along. But what about programmatic navigation? To add programmatic navigation, I need to tease apart two pieces of this navigation link: the value it presents and the view that goes with that value. Let's see how. First, I'll pull the destination view out of the link and into the new navigationDestination modifier. This modifier declares the type of the presented data that it's responsible for; here, that's a Recipe. The modifier takes a view builder that describes what view to push onto the stack when a recipe value is presented. Then, I'll switch to one of the new NavigationLinks and just present the recipe value. Let's peek under the hood and see how NavigationStack makes this work. Every navigation stack keeps track of a path that represents all the data that the stack is showing. When the stack is just showing its root view, like shown here, the path is empty. Next, the stack also keeps track of all the navigation destinations declared inside it, or inside any view pushed onto the stack. In general, this is a set, though for this example, we only have one destination. Let's add the pushed views to the diagram, too. Now, because the path is empty, so is the list of pushed views. Now, like milk and cookies, the magic happens when we put these together. When I tap a value-presenting link, it appends that value to the path. Then, the navigation stack maps its destinations over the path values to decide which views to push on the stack. Now, from my apple pie recipe, if I tap Pie Crust, the link appends that to the path, too. NavigationStack does its magic and pushes another RecipeDetail view onto the stack. For every value I add to the path, NavigationStack pushes another view. When I tap the back button, NavigationStack removes the last item from the path and from the pushed views. And NavigationStack has one more trick to offer. It lets us connect to this path using a binding. Let's go back to our code. Here's where we were. To bind the path, first I'll add some State. Because every value pushed on this stack is a recipe, I can use an array of recipes as my path. If you need to present a variety of data on a stack, be sure to check out the new type-erasing NavigationPath collection. Once I have my path state, I add an argument to my NavigationStack and pass a binding to the path. With that in place, I can make my stack sizzle. For example, I could add a method to jump to a particular recipe. Or from anywhere on my stack, I can pop back to the root just by resetting the path. That's how to prepare a pushable stack using the new NavigationStack, value-presenting NavigationLinks, and navigationDestinations in SwiftUI. This recipe works on all platforms, including the Mac, but really shines on iPhone, Apple TV, and Apple Watch. To see NavigationStack in action, be sure to check out "Build a productivity app for Apple Watch." Our next recipe is for multicolumn presentation without any stacks, like you'd find in Mail on Mac and iPad. On iPad, the sidebar is initially hidden. I can reveal it and choose a category. Then, in the second column, I can choose a recipe. The third column shows the recipe details. This recipe combines a NavigationSplitView with the new variety of NavigationLink, and a List selection. This recipe is great on larger devices because it helps avoid modality. I can see all my information without having to drill in. Let's see how. I'll start with a three-column NavigationSplitView with placeholder views for the content and detail. Then, I'll add a List in the sidebar that iterates over all my categories, and a navigationTitle. Inside the List, I have a NavigationLink for each category. Next, I'll introduce some State to keep track of which category is selected. I'll tweak our list in the sidebar to use the selectedCategory. Note that we're passing a binding to the selection. This lets the list and its contents manipulate the selection. When you put a value-presenting link inside a list with a matching selection type -- category here -- the link will automatically update the selection when tapped or clicked. So now when I select a category in the sidebar, SwiftUI updates the selectedCategory. Check out Raj's "Organize your interface" talk that I mentioned earlier for some great information on selection and lists. Next, I'll replace my placeholder in the content column with a list of the recipes for the selected category, and add a navigationTitle for this column too. Just like for the selected category, I can use the same technique to keep track of the selected recipe in the content list. I'll use State for the selectedRecipe, have my content list use that state, and use a value-presenting link for each recipe. Finally, I'll update the detail column to show, well, the details for the selectedRecipe. With this in place, I again have full programmatic control over navigation. For example, to navigate to my recipe of the day, I just need to update my selection state. That's how to prepare a multi-column navigation experience using the new NavigationSplitView, value-presenting NavigationLinks, and Lists with selection in SwiftUI. One super cool thing about combining List selection and NavigationSplitView like this, is that SwiftUI can automatically adapt the split view to a single stack on iPhone or in Slide Over on iPad. Changes to selection automatically translate into the appropriate pushes and pops on iPhone. Of course, this multicolumn presentation also works great on the Mac. And although Apple TV and Apple Watch don't show multiple columns, those platforms also get the automatic translation to a single stack. NavigationSplitView in SwiftUI works on all platforms. Next, let's look at how we can put all these ingredients together by building a two-column navigation experience like that in Photos on iPad and Mac. When I select a category, the detail area shows a grid of all my recipes in that category. When I tap a recipe, it's pushed onto a stack in the detail area. When I tap a related recipe, it's also pushed onto the stack. And I can navigate back to the grid of recipes.
This recipe is our pièce de résistance, combining navigation split view, stack, link, destination, and list. Let's see how all these ingredients go together. I'll start with a two-column NavigationSplitView. The first column is exactly like the previous recipe. I have some State to track the selectedCategory and a List that uses a binding to that state and a value-presenting NavigationLink, and the requisite navigationTitle. The differences in this recipe are in the detail area. The new navigation APIs really take advantage of composition. Just like I can put a list inside a column of a NavigationSplitView, I can also put a NavigationStack inside a column. The root view of this Navigation Stack is my RecipeGrid. Notice that the RecipeGrid is inside the NavigationStack. That means I can put stack-related modifiers inside RecipeGrid. Let's zoom in to the body of RecipeGrid to see what that means. RecipeGrid is a view and takes a category as a parameter. Because category is optional here, I'll start with an if-let. The else case handles an empty selection. Inside my if, I'll add a scroll view and a lazy grid. Lazy grid layout takes a sequence of views. Here, I'm using ForEach to iterate over my recipes. For each recipe, I have a value-presenting NavigationLink. The link presents a recipe value. The link's label, in this trailing closure, is my RecipeTile with the thumbnail and title. So what's left to finish this grid? Well, I haven't told the NavigationStack how to map from recipes to detail views. Like I mentioned with the first recipe, the new NavigationStack uses the navigationDestination modifier to map from values on its path to views shown on the stack. So let's add a navigationDestination modifier. But where should I attach it? I'm tempted to attach it directly to the link, but this is wrong for two reasons. Lazy containers, like List, Table, or, here, LazyVGrid, don't load all of their views immediately. If I put the modifier here, the destination might not be loaded, so the surrounding NavigationStack might not see it. Second, if I put the modifier here, it will be repeated for every item in my grid. Instead, I'll attach the modifier to my ScrollView. By attaching the modifier outside the ScrollView, I ensure that the NavigationStack can see this navigationDestination regardless of the scroll position. Another thing I like about putting the modifier here is that it's still close to the links that target it. Navigation destination gives me flexibility to organize my code the way that makes sense to me or my team. Popping back to my NavigationSplitView, there's just one more thing to enable full programmatic navigation here. I need to add a navigation path. I'll add State to hold the path and bind the state to my NavigationStack. With full programmatic navigation in place, I can write a method to show my recipe of the day in this navigation experience. That's how to prepare a multicolumn navigation experience with stacks using the new NavigationSplitView, NavigationStack, value-presenting NavigationLinks, and Lists with selection in SwiftUI. As with the previous recipe, this one also automatically adapts to narrow presentations and works on all platforms. It was fun exploring these recipes for structuring the navigation in my app, but our navigation feast wouldn't be complete without dessert. For that, let's look at how to persist the navigation state. To persist navigation state in my app, I just need two more ingredients: Codable and SceneStorage. This recipe has three basic steps. First, I'll encapsulate my navigation state in a NavigationModel type. That lets me save and restore it as a unit so it's always consistent. Then, I'll make my navigation model Codable. Finally, I'll use SceneStorage to save and restore my model. I'll have to take care along the way -- I don't want my app to crash like a fallen soufflé -- but the steps are straightforward. Let's look at step one. Here's the code from the end of our last recipe. My navigation state is stored in the selectedCategory and path properties. The selectedCategory tracks the selection in the sidebar. The path tracks the views pushed onto the stack in the detail area. I'll introduce a new NavigationModel class and make it conform to ObservableObject. Next, I'll move my navigation state into my model object, changing the property wrappers from State to Published. Then, I'll introduce a StateObject to hold an instance of my NavigationModel and change the parameters to use the new model object. Next, I'll make my navigation model Codable. I'll start by adding the Codable conformance to the class. In many cases, Swift can automatically generate Codable conformance, but I want to implement my own conformance here. The main reason is that Recipe is a model value. I don't want to store the entire model value for state restoration. There are two reasons for this. First, my recipe database already contains all the details for the recipe. It's not a good use of storage to repeat that information in my saved navigation state. Second, if my recipe database can change independently of my local navigation state -- say, because I finally get around to adding syncing -- I don't want my local navigation state to contain stale data. For custom codability, next I'll add CodingKeys. One of the keys is just selectedCategory. But notice that I named the other "recipePathIds” I'm planning to just store the identifiers of the recipes on the path. In my encode method, I'll create a keyed container using my coding keys and add the selected category to the container. I'm using encodeIfPresent, so I only write the value if it's non-nil. Then, I'll add the recipe path identifiers. Note that I'm mapping over the path to get the identifiers to encode. For example, suppose my navigation state included Dessert as a selected category, with Apple Pie and Pie Crust on the path, like shown in the green box on top. This might be encoded to JSON, like shown in this other box. To finish up Codability, I'll add the required initializer. The interesting bit is here where I decode the recipe IDs, then use my shared data model to convert the IDs back into recipes. I'm using compactMap to discard any recipes that couldn't be found. For example, this might happen if I delete a recipe on another device after I have sync working -- something I'm definitely going to do someday. This is a place you'll need to use discretion in your own apps to make sure any restored navigation state still makes sense. Finally, I'll add a computed property for reading and writing my model as JSON data. Now that I have a navigation model and it knows how to encode and decode itself, all that's left is to actually save and restore it. For that I'll use SceneStorage. Here's where we left our main view. I was using a StateObject to hold my NavigationModel. Now, I'll introduce some SceneStorage to persist my NavigationModel. SceneStorage properties automatically save and restore their associated values. When the type of the storage is optional, like my data here, the value is nil when a new scene is created. When the system restores a scene, SwiftUI ensures that the value of the SceneStorage property is also restored. I'll take advantage of this to persist my NavigationModel. To do that, I'll add a task modifier to my view. The task modifier runs its closure asynchronously. It starts when the view appears and is cancelled when the view goes away. Whenever my view appears, I'll first check whether I have any existing data from a previous run of the app. If so, I'll update my navigation model with that data. Then, I'll start an asynchronous for loop that will iterate whenever my navigation model changes. The body of this loop will run on each change, so I can use that to save my navigation state back to my scene storage data. And that's it! When I leave my app to go check out some vintage Julia Child cooking shows on the web, it remembers where I was. When I return to the app, it takes me back to where I left off. Now, no cookbook would be complete without a weird section at the end with handy kitchen tips. I don't have three great substitutes for cilantro, but I do have some navigation tips to share. Switch to the new NavigationStack and NavigationSplitView as soon as you can. If you're using NavigationView with the stack style, switch to NavigationStack. NavigationStack is also a good first choice on Apple TV, Apple Watch, or in sheets on iPad and iPhone, where the stack style has always been the default. If you're using a multicolumn NavigationView, switch to NavigationSplitView. And if you've already adopted programmatic navigation using the links that take bindings, I strongly encourage you to move to the new value-presenting NavigationLink along with navigation paths and list selection. The old-style programmatic links are deprecated beginning in iOS 16 and aligned releases. For details and examples on migrating to the new APIs, check out the article, "Migrating to new navigation types" in the developer documentation. Next, keep in mind that List and the new NavigationSplitView and NavigationStack were made to mix together. Compose them to create navigation experiences your guests will love. When using navigation stacks, navigation destinations can be anywhere inside the stack or its subviews. Consider putting destinations near the corresponding links to make maintenance easier, but remember not to put them inside of lazy containers. Finally, I'd encourage you to start building your navigation experiences with NavigationSplitView when it makes sense. Even if you're initially developing for iPhone, NavigationSplitView will automatically adapt to the narrower device. And when you're ready to support iPhone Pro Max in landscape, or to bring your app to iPad or Mac, NavigationSplitView will take advantage of all that additional space. Thanks for the chance to share the new SwiftUI Navigation APIs with you! Besides the talks I mentioned earlier, I invite you to check out "Bring multiple windows to your SwiftUI app" for some great info on opening new windows and scenes in your apps. I hope that these recipes for navigation in our cookbook app were palate-pleasing. I'm looking forward to seeing the great experiences you cook up in your own apps. Bon appétit! ♪
-
-
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() } }
-
-
Looking for something specific? Enter a topic above and jump straight to the good stuff.
An error occurred when submitting your query. Please check your Internet connection and try again.