스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
SwiftUI의 새로운 기능
그 어느 때보다 SwiftUI로 앱을 개발하기 가장 좋아졌습니다. 목록, 버튼, 텍스트 필드를 포함한 UI 프레임워크의 최신 업데이트를 살펴보고 이러한 기능을 통해 앱에 SwiftUI를 더 완벽하게 도입하는 방법을 알아보세요. 캔버스 보기, 머티리얼 및 향상된 기호를 사용하여 시각적으로 풍부한 그래픽을 만드는 방법을 알아보세요. macOS의 다중 열 표, 집중 모드 및 키보드 상호작용의 개선 사항, 멀티 플랫폼 검색 API를 살펴본 다음 Swift 동시성, 새로운 AttributedString, 서식 스타일, 현지화 등 기능을 활용하는 방법을 다룹니다.
리소스
관련 비디오
WWDC21
- 훌륭한 위젯의 원리
- Apple Watch용 운동 앱 개발하기
- Bring Core Data concurrency to Swift and SwiftUI
- Demystify SwiftUI
- Direct and reflect focus in SwiftUI
- Discover concurrency in SwiftUI
- Localize your SwiftUI app
- Meet Shortcuts for macOS
- SF Symbols in SwiftUI
- Swift concurrency: Update a sample app
- SwiftUI 앱에 풍부한 그래픽 추가하기
- SwiftUI Accessibility: Beyond the basics
- SwiftUI로 검색 경험 제작하기
- watchOS 8의 새로운 기능
- What's new in Foundation
- What‘s new in Swift
- What’s new in SF Symbols
-
다운로드
♪ Bass music playing ♪ ♪ Matt Ricketson: Welcome to “What’s New in SwiftUI.” I’m Matt, and later on, I’ll be joined by Taylor. This session is all about SwiftUI, Apple’s declarative UI framework. SwiftUI is still young, but we’ve come so far already. SwiftUI was first released in 2019, introducing a powerful new way to build user interfaces in a declarative, state-driven style. We took the next big step with SwiftUI’s second release, enabling 100 percent SwiftUI apps with the new App and Scene APIs. This year we’re focused on supporting even deeper adoption of SwiftUI in your apps with a great set of rich new features. Now if you haven’t yet had a chance to try out SwiftUI yourself, that’s OK! Only you know what’s best for your app. But here are a few tips to keep in mind as you learn about all the new features available this year. A good way to dip your toes into SwiftUI is to use it to create brand-new features in an existing app, like how it powers the new activity stream in Notes for iOS, iPadOS, and macOS. Or the new avatar picker in macOS, also built with SwiftUI. Remember, you can mix in SwiftUI alongside your existing UIKit or AppKit code. SwiftUI is also a useful tool for expanding your app to new platforms, like how SwiftUI was used to build the new Shortcuts app on macOS. With SwiftUI, you can easily share common code between platforms while still crafting a unique experience for each device. And when you’re ready to redesign your app, that’s the perfect time to bring in SwiftUI to help. The all-new Apple Pay purchase flow was redesigned using SwiftUI, which was also used to bring a fresh coat of paint to the new Help Viewer on macOS and the Tips app on watchOS. And finally, we can’t forget the gorgeous new Weather app for iOS, also rebuilt from the ground up in SwiftUI. These are just several examples of how SwiftUI is helping build the next generation of apps. For this session, we’d like to share some of the great new APIs that made it all possible. We’ll start by walking through improvements to how we build collections of content with lists and grids. Next, we’ll go beyond lists, introducing new features to take your data-driven apps to the next level. Third, we’ll show off some stunning new tools for driving graphics and visual effects. We’ll talk about enhancements to text, keyboards, and focus-based navigation. And finally, we’ll give some love to buttons. So let’s dive in, beginning with lists and grids, which are critical features for organizing and displaying data within SwiftUI apps. This year, we’re making it even easier to write rich, interactive lists and grids. Let’s start with a fun one. SwiftUI now has built-in support for loading images asynchronously. SwiftUI makes loading these images feel easy with the new AsyncImage view. Just give it a URL, and SwiftUI will automatically fetch and display the remote image for you, and even provide a default placeholder. AsyncImage can also be customized. For example, we can add modifiers to the loaded image and also define custom placeholders, like I’m doing here to add some fun colors. We can even add custom animations and error handling! And AsyncImage is available on all platforms. We hope you check it out. AsyncImage loads its content immediately, but sometimes your app needs to load content on-request, like when showing a feed. This is a great use case for supporting pull-to-refresh on iOS and iPadOS, using the new refreshable modifier. This modifier configures a refresh action and passes down through the environment. Lists on iOS and iPadOS use this action to automatically add pull-to-refresh, but you can also use it to build your own custom refresh behaviors. You may have noticed this new await keyword, which is one of the new concurrency language features in Swift 5.5. This indicates that the updateItems method is an async action, which lets us refresh our list without blocking the UI. Another new concurrency-related SwiftUI feature is the task modifier. This API lets you attach an async task to the lifetime of your view. That means the task will kick off when the view first loads and automatically cancel itself when the view is removed. This is a great way for us to load the first batch of photos automatically. These new concurrency modifiers look simple on the surface, but can be used to build sophisticated async behaviors into your app. For example, here I’ve set up a task for loading the newest photos as they become available. I’ve written just a regular for loop, but you’ll notice the await keyword used here as well. That’s because newestCandidates is actually an async sequence, which is another new concurrency feature in Swift 5.5. This means we’ll wait for the newest candidate asynchronously, iterating the loop only when the next candidate is available. That means we’re actually packing a ton of functionality into just this single modifier. The view starts a task that listens for candidates asynchronously as soon as it appears, updating the list every time a new candidate becomes available and then automatically canceling the task when the view disappears, all without blocking our app’s UI. There’s a lot more to learn about Swift concurrency and how to take advantage of it in SwiftUI, so we’ve prepared a few other talks to dig into the details. “Discover concurrency in SwiftUI” will explain how concurrency relates to SwiftUI’s update model and demo some the new features we just discussed. And in “Swift concurrency: Update a sample app,” we walk you step by step through upgrading an existing project with async model code. Next up, we’re giving you new and better ways to build interactivity into your list content. In this example, I’ve written a simple list for sharing the directions to my super secret hideout. This looks nice, but the text isn’t editable. Let’s fix that. We can make text editable by swapping it out for a text field instead. However, a text field requires a binding to the text. Within our list’s content closure, we’re only given a plain value for each element in our collection, not a binding. In situations like this, it can be tricky to figure out how to get a binding to the collection element for each row. One common approach is to iterate over the indices of the collection instead, using a subscript to get a binding to the element at that index. However, this technique is not recommended, because SwiftUI will be forced to reload the entire list when anything changes. In fact, we’ve prepared a whole talk discussing this topic in much more detail. To learn more, I’d recommend watching “Demystify SwiftUI.” For now, let’s undo these changes and take a look at a better solution. This year, SwiftUI is providing a much easier way to get access to bindings for individual elements within a collection. Simply pass a binding to your collection into the list, using the normal dollar sign operator, and SwiftUI will pass back a binding to each individual element within the closure. Code that only needs to read the value can stay exactly the same as before, just like you’re used to. But now we can easily add interactive controls like a text field using the normal binding syntax that we’re used to, which means I can finally fill in the super secret door code I forgot to include earlier. This new syntax is part of the Swift language, so it works everywhere you would expect, not just lists. For example, we can use the same technique in a ForEach view within our list instead. And better yet, you can even back-deploy this code to any prior release supported by SwiftUI. But we’re not just making your existing code easier to write. Lists are gaining some great new features too! Let’s start with some new ways to visually customize your lists. With the new listRowSeparatorTint modifier, you can change the color of individual row separators, like I’ve done here to align the separator and icon colors for each row. SwiftUI also has an equivalent modifier for section separators. For this app though, all those separators seem a little distracting. I want my directions to feel like a single, unified flow. Maybe we should try removing them, which we can now do with the new listRowSeparator modifier, configuring our separators to be hidden. Now our directions feel much less cluttered. Let’s look at another app I’m making...
...which helps comic book authors keep track of all their superheroes and villains. This app uses swipe actions to quickly and conveniently pin and delete characters, but without cluttering up our UI with extra controls. New this year, SwiftUI allows you to define completely custom swipe actions using the new swipeActions modifier. You configure swipe actions just like any other kind of menu in SwiftUI, defining actions using buttons. You can also customize their color by adding the new tint modifier, which I’m using to make my pin action yellow. By default, SwiftUI shows swipe actions on the trailing edge of the row. But you can switch them to the leading side using modifier’s edge parameter. You can even support both leading and trailing swipe actions by adding multiple modifiers with different edge configurations. And lastly, the swipeActions modifier is available on every platform that supports them, making it easy to share code within your multiplatform app. Speaking of other platforms, let’s check in on the macOS version of my app. It shows a multicolumn interface, which makes use of the extra space available on the Mac. Instead of cramming all of my data into the sidebar, I have an overview tab that lists all of my characters. That lets me just keep my pinned characters in the sidebar. This list does feel a little plain though. Let’s try to spruce it up a bit. Here’s my existing code. I’m currently using the inset list style to smoothly fit the list within my window. And we’re able to express this style beautifully in code using the new enum-like syntax available on all view styles this year. Also new this year, the inset list style is gaining a new trick. It is now able to alternate the backgrounds of the rows by just modifying the style with the alternatesRowBackgrounds flag. Our list is looking a lot better now, with each row clearly distinguished from the other. But for a macOS app, it still feels like we’re not taking full advantage of all that space in our window. So for the next section, let’s go beyond lists to get even more out of your app. To help us make better use of all this space, let’s upgrade our list to a rich, multicolumn table! With four columns, I now get four lists for the price of one! But the best part is that a moderately complex table like this can be declared with so little code it fits on a single slide. That’s because tables use the same kind of declarative construction that you’re used to throughout SwiftUI. Just like with lists, you can create a table from a single collection of content. But unlike a list, a table is made up of TableColumns that define content within each visual column. Each of these columns are visually labeled and use data from the collection to define their visual content with some shorthand conveniences for common cases like just displaying text. But tables are also interactive, supporting row selection both for single rows and multiple rows, just like in regular lists. Tables also support sorting with the help of key paths to sortable values on the columns. Now, tables support several other features, including multiple different visual styles as well as fine-tuning the appearance of each column. But let’s talk more about the data you provide to a table or list. This year, we have several new enhancements to SwiftUI’s support for CoreData fetch requests. FetchRequests now provide a binding to their sort descriptors, which we can pass on to the Table, allowing us to write a fully Core Data-driven table, complete with selection and sortable columns, in just a few lines of code. SwiftUI now also offers a sectioned fetch request, allowing for complex, multisection lists like the one on the right to be driven from a single request. In this example, we partition our data into sections based on whether or not they're pinned. We use multiple SortDescriptors to arrange the data, first to split it into pinned and unpinned sections, and second to order recently modified characters last. Next we specify that any changes should be animated. And finally we construct the sections and rows of our list dynamically, based on the results of the request. All together, this single request is able to drive the animated list on the right. For more information on building apps for macOS, working with tables, and integrating Core Data with SwiftUI, be sure to check out these other talks. The “SwiftUI on the Mac” two-part series will take you step-by-step through building an app that’s optimized for the Mac. And “Bring Core Data concurrency to Swift and SwiftUI” will cover the new Core Data fetch request APIs in much more detail. Now it’s time to step back and think about how we can help users find what they need amidst all of this data. Of course, I’m talking about search. Search is a critical part of all of our platforms. It helps users find what they need exactly when they need it. You’ll find it on large devices like the Apple TV, even all the way down to the smallest devices, like the Apple Watch. So since search is a multiplatform problem, it needs a multiplatform solution that can scale across all these devices. Luckily, adding search to your app couldn’t be easier; just add the searchable modifier, like we’ve done here on our NavigationView. With this one modifier, SwiftUI will automatically add a search field to the appropriate location in your app and optionally show suggestions in a platform- and context-appropriate way. The modifier takes a binding to the search text, allowing you to filter your data based on the current value. Now, there is a lot more to say about search in SwiftUI, but luckily we have a whole session to walk you through how to think about search capabilities on multiple platforms. Check out “Craft search experiences in SwiftUI” to learn more. So far we’ve explored how to load, display, organize, and search through your app’s data using lists and grids. Now let’s talk about how to share that data beyond your app. One of the simplest methods of sharing data is by just dragging it out of your app. In my Heroes & Villains app, I’ve configured the character icon on the detail screen to be draggable using the existing onDrag modifier. New this year, you can now add custom previews to your draggable views. This preview is shown instead of the view while it’s being dragged. Drag and drop is powered by item providers, which allow data to be copied and shared between different processes. This year, SwiftUI is providing several more ways to use item providers to integrate with other apps and services such as configuring your app to support importing item providers from external services, using the new importsItemProviders modifier. In this example, we’ve configured our view to be able to import images, and add them as attachments to our story characters. We can pair this capability a new macOS feature: Continuity Camera. By adding the “Import from Devices” commands to our app’s main menu, we’re now able to use an iPhone or iPad to just take photos to import into our Mac app. Let’s try it out! The symbol of the View Builder superhero is her trusty hammer. It would be great to attach a picture of it to her profile. Luckily, I happen to have it right here! From within my app, I can access the “Import from devices” commands in the File menu. Then, I can choose to take a picture using my iPhone...
....which automatically opens the Camera app so we can quickly take a picture.
And the new photo is imported and added to my app, using the importsItemProviders modifier we showed earlier. SwiftUI also supports exporting data out of our app. Exporting data allows you to take advantage of other services, such as being able to trigger shortcuts from directly within your app. In SwiftUI, you can export data using the new exportsItemProviders modifier. This exposes your app’s data to the rest of the system, for example, allowing it to be used by services and shortcuts on macOS. Let’s take a look at how this appears for people using the app. I can now see quick actions show up in my app’s Services menu when I’ve selected one of my pinned characters. This is a handy shortcut for adding a title banner to the most recent photo, which I can use to share my latest superhero ideas with my friends. I found this great photo to use for my Stylizer superhero, who happens to also be an adorable dog. My custom shortcut added this fun banner to the top and overlaid the hero’s name. My shortcut also lets me share the photo. I’d love to get Taylor’s feedback, since he knows a thing or two about cool graphics. I can just add Taylor as a recipient and type in a quick message and send it off! What do you think, Taylor? Taylor Kelly: Thank you, Matt. It looks perfect. And it's definitely going to be your new contact photo. This adorable image is a great segue to the next section, Advanced Graphics. There's a bunch of exciting enhancements this year: from symbol updates, materials and vibrancy, to a powerful new canvas view. First up are symbols. SF Symbols are a great and easy way of adding beautiful iconography throughout your app. Not only are there many new ones this year, but they come with several new features to make their use in your app even easier and more expressive. There are two new rendering modes that give you even more control over how symbols are styled. Hierarchical uses the current foreground style to color the symbol, just like monochrome, but automatically adds multiple levels of opacity to really emphasize the key elements of the symbol. And palette gives you even more fine-grained control over individual layers of a symbol with custom fills. Check out "What's new in SF Symbols" for more information and design guidance on these new modes. Pairing perfectly with these is an update to the set of colors available in SwiftUI. These colors are optimized for all the different configurations they appear in: light and Dark Mode, specific appearances over blurs, and even the specific platform they’re shown on. In addition to different colors, symbols come in many different shapes. Many symbols have modifiers to show up as filled, circled, and more. Previously you had to hardcode these variants. But more than that, you had to know which variant was right to use in which context. The iOS Human Interface Guidelines describes how in tab bars, filled variants should be preferred, so you had to specifically include that .fill modifier in the name. This year, you don’t have to worry that. SwiftUI will automatically choose the right variant for you based on the context you use it in. All you have to do is provide the base symbol you’d like to use. And by not over-specifying the exact configuration you want, you also get code that is more reusable. For example, if we run this same code on macOS, we get the correct variant for that platform: outlines. To learn how to take advantage of this automatic support in your own custom views, as well as more symbol enhancements, check out "SF Symbols in SwiftUI." There are now a lot of SF Symbols, so I wanted to build a cool visualizer to browse through all of them. This is a great use for SwiftUI's new Canvas view. Canvas supports immediate-mode drawing similar to drawRect from UIKit or AppKit. When composing lots of graphical elements that don't need individual tracking or invalidation, this is a great tool. Here I have a canvas displaying every single SF Symbol that comes with the OS. And for all 3166 of them, it draws each of them into their own frame. Canvas works on every platform. And since Canvas is a view like any other, we can also attach gestures, accessibility information, and update it based on state or the environment such as adapting to Dark Mode. Here, I've added a gesture that lets me set a focalPoint to zoom in on. And I'll update the frame and opacity of each symbol based on that. Now I can click and drag around and every symbol smoothly updates as the cursor moves around the screen. We can also make sure this is fully accessible by taking advantage of a new accessibilityChildren modifier. What’s so cool is that you reuse the same views you're used to using in SwiftUI to refine how it comes across through accessibility features. In this case, the symbols can now be enumerated like someone would otherwise browse elements in a list, speaking each element as they navigate through. This modifier isn’t restricted to just Canvas, but can be used with any view to really polish its accessibility experience. One final thing we can add to our canvas is updating over time, using the new TimelineView. A refinement to make for tvOS is to have that focalPoint animatedly move around the screen, acting like a screensaver. TimelineView is created with a schedule -- in this case, the animation schedule -- and it provides the current time it's rendering for. And so we can use that time to update the focalPoint in the transform, creating our beautiful symbol screensaver. This TimelineView can do so much more. A really cool feature of the Apple Watch is its Always On display. Previously your app would be blurred with the time overlaid when it enters the Always On state. And with watchOS 8 your app now dims by default, and you have more control over how it appears with SwiftUI giving you the tools you need, one of which is TimelineView. Once the watch goes into its Always On state, TimelineView can preload the display of your views at future dates. And as we move into the future, those view will automatically be displayed onscreen without ever taking your app from the background. A critical part of this is the TimelineSchedule. In this example, I'm using the simple everyMinute schedule so TimelineView will preload out the display of each minute on the minute, showing me the next symbol in the browser. There are several other kinds of schedules as well to help suit the needs of your app, such as a collection of explicit of dates, which works great for when there will be events at specific times. Now, another important aspect of this mode is hiding user-sensitive information since it could be visible to others. I'd really like to keep my favorite symbol private. And by simply adding the privacySensitive modifier, it will automatically be redacted when the watch enters the Always On state. Check out "What's new in watchOS 8" for more information on the Always On display and more. And this privacy-sensitive modifier also works in widgets as well. Widgets that are added to the Lock screen will use this to hide sensitive information while the device is still locked, and reveal once the device is unlocked. "Principals of great widgets" will go into more detail on this and other ways of building wonderful widgets for your apps. Materials are used across all of Apple’s platforms and apps to create beautiful visual effects that really emphasize their content, and now you can create them directly in SwiftUI! I've been experimenting with adding color and materials to my Symbol Browser, and I'm adding a material-backed overlay to display the number of symbols. Adding a material is as easy as adding a background. I'm using the ultraThinMaterial, and can give it any custom shape to fill. These materials automatically come with the expected vibrant blending of content on top of them when using primary, secondary, tertiary, and now even quaternary foreground styles. And emojis are automatically excluded from that, so they look exactly as they should. On the Mac, system context like sidebars and popovers automatically have blur material backgrounds and will also now have that expected vibrant appearance for the content within them. These new materials work great in conjunction with the new safeAreaInset modifier, which allows you to place content on top of a scrollable view and have the content position still start and end as expected. The "rich graphics" session goes into a lot more detail on canvas, materials, and more. And to wrap it up, complementing new ways to define these beautiful custom views are a couple of enhancements to SwiftUI previews in Xcode. First is a new preview orientation modifier that allows you to specify the orientation of the iOS device in the previews, and even mix and match previews across different orientations. And second is a big improvement to how you edit and view your app's accessibility in previews. The property editor now has a curated list of accessibility modifiers, making it even easier to polish views' accessibility behavior. And there is an entirely new way of viewing your previews with a new Accessibility Preview tab. You'll be shown a live, textual representation of the accessibility elements and their properties. This is the same information that powers accessibility features, but is now presented to you in a format that might be more familiar to you. Check out the "SwiftUI Accessibility" session for more information on this and much more about how to create an amazing accessibility experience for your app! Now, up next is a range of enhancements to text, text-related controls, and keyboard navigation. Text is so fundamental to every app. It’s one of the main ways your app communicates to people; it’s often the very first view you write. And this year, its gaining a lot of new exciting features from styling to localization, to interactions and formatting. First up is Markdown support. Text can now contain Markdown formatting directly inline. This can be used to add strong emphasis, links -- which can be interacted with -- and even code-style presentation. And this is all built on top of the new, powerful Swift-based AttributedString in Foundation. In addition to Markdown support, it brings an entire suite of rich, type-safe attributes, and the ability to define your own attributes and even use them within Markdown syntax. For more information on this and the amazing new Automatic Grammar agreement, check out "What's new in Foundation." Importantly, text also localizes its content so that people across the world can use your app. And this is true of the new Markdown support as well, allowing language-sensitive attributes to be properly localized. Another great improvement to localization comes from Xcode 13. It now uses the Swift compiler to generate strings and localization catalogs from every use of LocalizedStringKey and the new localizedString and attributedString initializers. To learn more about this and other localization tips and tricks, check out "Localize Your SwiftUI app." Now, in addition to these new ways of displaying text, there are new ways of making text even more dynamic. The first is an important accessibility feature: Dynamic Type. SwiftUI has supported Dynamic Type since its inception, and this year has a new API to allow restricting the range of type sizes a UI supports to keep it from getting too big or too small. This shows what our header looks like at the default large size. I personally use Dynamic Type to get some extra information density into my content, and this shows how the header stayed the same size at the small type size, since it's restricted to be, at minimum, the large size. At the other end of the spectrum, using the accessibility sizes does result in our header growing larger, but only up to the extra extra large size. While macOS doesn't support Dynamic Type, it does support another important text interaction: selectable text. This allows people to take action on noneditable text from your app, and that can now be enabled using the textSelection modifier. That modifier can be applied to any view, and it applies to all the text within it -- in this example, now applying to the text in the header. And we also introduced this modifier on iOS and iPadOS where it enables text to be copied or shared on long-press. Finally, Foundation's new format-style APIs make formatting text so much simpler, yet still allowing precise presentation. Here we have a date that applies the default formatting. And this is a variant that displays only the time, as used in the activity list. And finally, an expanded format that allows specifying the exact components to display. Our activity list also featured formatting an array of people into a properly localized presentation. Let's quickly walk through this. We're mapping our person values into an array of PersonNameComponents and formatting it using a list format style. And for each member in the list, using the PersonNameComponent format with a short style, showing just the first name. And finally, joining it with an "and" conjunction. All together, creating a performant and type-safe expression of formatting that properly handles any number of people. TextField has also gained support for these new format styles, allowing you to add editable formatted text with a type-safe binding to some underlying value. The new attendee field is bound to a PersonNameComponents value, and it's formatted using the standard name format. This takes care of parsing the input and producing the resulting person name. "What’s new in Foundation" also goes into detail on the power of these new format styles. TextField now also supports adding an explicit prompt, separate from its label, to let users know what kind of content a field is expecting. And when adding TextField to forms on macOS, they’ll align their labels similar to other controls and use the prompt as its placeholder content. Now, the entire point of a text field is adding text, and keyboards are our tool to do that. From software keyboards on iPhone to the iPad, which supports both software and hardware keyboards, and of course, macOS, which always has a hardware keyboard. This year, there’s several enhancements to make the experience of using a keyboard even better. With the new onSubmit modifier, you can easily add supplementary actions for when the field’s text is submitted by the user, such as by pressing the Return key. This modifier provides some extra flexibility in that it can even be applied to an entire form of controls. And to help give users a hint of what kind of action will occur when submitting a field, there's the new submitLabel modifier. On software keyboards, this will be used as the label for the Return key. And finally, we’ve made it possible to add accessory views to the keyboard using the new keyboard toolbar placement. These views will be shown in a toolbar above the software keyboard on iOS and iPadOS or in the Touch Bar on macOS. This is a great way to give users quick access to actions above the keyboard without dismissing it to avoid interrupting your app’s editing experience. Keyboards also serve another important role of navigation and focus, and this functionality exists on every platform; from using focus on watchOS to direct Digital Crown input, to using the Siri Remote to navigate around content on tvOS. For most things, SwiftUI just takes care of what views are focusable and how it moves between them. But sometimes there are extra refinements you can make to create even smoother experiences in your app. To help with that, SwiftUI has a new, powerful tool called FocusState. This is a property wrapper that both reflects the state of focus and provides precise control over it. At its simplest, it can reflect a Boolean value. This can be tied to a focusable view using the focused modifier. When that view is focused, the value will be true, and false when not. This value can also be written to, to control focus. For instance, in response to someone pressing a button. This example can act as an accelerator, allowing the user to immediately start typing after performing a related action. This Boolean version is a convenience for its full form, which is representing any hashable type. This code is functionally equivalent to the previous slide but with some increased flexibility. Let's walk through it. First, I've defined a simple enumeration of the fields that I might want to know are focused. The FocusState property uses that type to reflect the current state. It's optional to indicate potentially none of those having focused. Our focused modifier is still bound to that same focus state, but only when it equals addAttendee. And finally, when we want to focus that field, we set our focus state value to addAttendee. This new flexibility allows adding additional functionality, such as building the toolbar buttons from before, moving focus between each of the fields, and reflecting if focus reaches the beginning or the end. Focus state also provides a great way for iOS apps to dismiss the software keyboard by clearing out its value. If you're interested in learning more about other ways to refine the focus experience in your app, check out this year’s session, "Direct and reflect focus in SwiftUI." Last, we’re going to focus in on buttons because buttons are important. We all know what a typical button looks like; it varies from platform to platform, and it is one of the simplest ways of allowing people to interact with your app. And especially in SwiftUI, buttons are used for a lot of things. Matt discussed earlier how swipe actions are composed out of buttons. And this year, there is a lot new with buttons. First, SwiftUI now has standard bordered buttons on iOS. You can make a button bordered just by adding a buttonStyle modifier, like I'm doing with this Add button. Like other style modifiers, this can be added to a group of controls and applies to all of them. It supports tinting for cases where you want a specific appearance for a given button. But for this UI, I like the default appearance that uses the accent color. There's more customization built in as well. First up is control size and prominence. I'm using these to customize the buttons representing the tags. They're using the new standard small control size and have a tint with increased prominence to really make them stand out. We can use these same modifiers to create another common kind of button: these large sized ones now built in to SwiftUI. By specifying the large control size, you'll automatically get these beautiful, rounded rectangle buttons. And to give them a sense of hierarchy, I modified the most important one to have increased prominence, filling it with a high-contrast accent color. And the secondary button can still be tinted but has a lower contrast. These buttons have few modifiers to make them great on the iPad, too. The text labels have a maximum width so that the overall button is flexible but doesn't get comically large. And the primary button has a default action keyboard shortcut, so when using the app with a keyboard, I can quickly hit the Return key to add this button to my jar. Now, many of this API has already existed on macOS, making it even easier to build apps for multiple platforms. The one new addition is adding increased prominent tint support to let you tastefully add these bright buttons to your apps. Note that nonprominent buttons, like these Add buttons, do not display any tint since their chrome is what indicates their interactivity on macOS. Having learned about prominence, I might be tempted to go and apply that to all of my Add buttons, but it can get overwhelming and confusing to have so many prominent buttons onscreen. It's best to reserve it for singular primary actions. The lower prominence tint is a great alternative for adding a splash of color on iOS. Now, my favorite thing about these new button styles is they automatically have the expected pressed and disabled states, Dark Mode support, and of course are fully accessible and compatible with Dynamic Type. And they help give consistency between apps. And buttons' new API doesn't stop here. SwiftUI has also added first-class support for buttons with additional semantics, such as marking a button as destructive, which will automatically give them the expected red tint. One new context this can also be used in are confirmation dialogs, which lets users confirm actions that have serious impact to their data. On iOS, this shows as an action sheet, on iPad as a popover, and on macOS as an alert. SwiftUI automatically handles following the design sensibilities of each platform. Next, let's talk about buttons that aren't "capital B" buttons. Currently, the app's Add buttons just add to the user's default jar. But for avid collectors, I want to support adding to specific jars. This is a perfect use case for a menu button. We'll use the same "Add" label, but present a menu of all of the possible jars once the button is clicked. However, these menu buttons visually carry a lot of prominence. We could hide the indicators using a new menuIndicator modifier added this year. And even without an indicator, this button still presents a menu on click. But for these buttons, ideally we'd get the best of both worlds: with a simple single click to add to the default jar and the flexibility of showing the menu of others. New this year is the ability to customize a menu's primary action to help with this kind of case. By default, a menu with a primary action has a two-segmented appearance on macOS. The main part of the button triggers the primary action in the indicator presenting the menu. And when the indicator is hidden, it again looks visually just like the button that I started with but has a behavioral distinction. A click triggers the primary action and a long-press shows the menu. And what's great is that this same thing works on iOS, too! Now these menus give a lot of flexibility, really catering to how your app needs to use them. Another new example of a control gaining a button style is Toggle. This creates a button that when tapped, visually turns on and off and can be used just like any other toggle. And joining these new control styles is a container that groups related controls; aptly called ControlGroup. On iOS, the controls in a group are organized a little tighter in the toolbar. And on macOS, there are visual affordances indicating the two grouped buttons. And to wrap this all up, naturally all of these things can be composed together. For instance, these standard back/forward buttons are a ControlGroup of two menus. Each of these menus have a primaryAction that is performed when clicked. And once the menu is long-pressed, they'll present their contents. Now, with just a few additional customizations on buttons and these new styles, a lot of flexibility has opened up on how you can use these controls in your apps. We ran through a lot in this session, and there’s even more that we didn’t have time to cover. We’re excited for you to take advantage of these new features in your own SwiftUI apps and adopt SwiftUI in even more places. Thank you and have a great rest of your 2021! ♪
-
-
3:29 - AsyncImage
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 420))]) { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) .frame(width: 400, height: 266) .mask(RoundedRectangle(cornerRadius: 16)) } } .padding() } .navigationTitle("Superhero Recruits") } .navigationViewStyle(.stack) } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] } struct Photo: Identifiable { var id: URL { url } var url: URL }
-
3:45 - AsyncImage with custom placeholder
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 420))]) { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { randomPlaceholderColor() .opacity(0.2) } .frame(width: 400, height: 266) .mask(RoundedRectangle(cornerRadius: 16)) } } .padding() } .navigationTitle("Superhero Recruits") } .navigationViewStyle(.stack) } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] } struct Photo: Identifiable { var id: URL { url } var url: URL } func randomPlaceholderColor() -> Color { placeholderColors.randomElement()! } let placeholderColors: [Color] = [ .red, .blue, .orange, .mint, .purple, .yellow, .green, .pink ]
-
4:00 - AsyncImage with custom animations and error handling
struct Contentiew: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 420))]) { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url, transaction: .init(animation: .spring())) { phase in switch phase { case .empty: randomPlaceholderColor() .opacity(0.2) .transition(.opacity.combined(with: .scale)) case .success(let image): image .resizable() .aspectRatio(contentMode: .fill) .transition(.opacity.combined(with: .scale)) case .failure(let error): ErrorView(error) @unknown default: ErrorView() } } .frame(width: 400, height: 266) .mask(RoundedRectangle(cornerRadius: 16)) } } .padding() } .navigationTitle("Superhero Recruits") } .navigationViewStyle(.stack) } } struct ErrorView: View { var error: Error? init(_ error: Error? = nil) { self.error = error } var body: some View { Text("Error") // Display the error } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] } struct Photo: Identifiable { var id: URL { url } var url: URL } func randomPlaceholderColor() -> Color { placeholderColors.randomElement()! } let placeholderColors: [Color] = [ .red, .blue, .orange, .mint, .purple, .yellow, .green, .pink ]
-
4:24 - refreshable() modifier
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { List { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) .frame(minHeight: 200) .mask(RoundedRectangle(cornerRadius: 16)) .listRowSeparator(.hidden) } } .listStyle(.plain) .navigationTitle("Superhero Recruits") .refreshable { await photoStore.update() } } } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] func update() async { // Fetch new photos } } struct Photo: Identifiable { var id: URL { url } var url: URL }
-
4:58 - task() modifier
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { List { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) .frame(minHeight: 200) .mask(RoundedRectangle(cornerRadius: 16)) .listRowSeparator(.hidden) } } .listStyle(.plain) .navigationTitle("Superhero Recruits") .refreshable { await photoStore.update() } .task { await photoStore.update() } } } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] func update() async { // Fetch new photos } } struct Photo: Identifiable { var id: URL { url } var url: URL }
-
5:28 - task() modifier iterating over an AsyncSequence
struct ContentView: View { @StateObject private var photoStore = PhotoStore() var body: some View { NavigationView { List { ForEach(photoStore.photos) { photo in AsyncImage(url: photo.url) .frame(minHeight: 200) .mask(RoundedRectangle(cornerRadius: 16)) .listRowSeparator(.hidden) } } .listStyle(.plain) .navigationTitle("Superhero Recruits") .refreshable { await photoStore.update() } .task { for await photo in photoStore.newestPhotos { photoStore.push(photo) } } } } } class PhotoStore: ObservableObject { @Published var photos: [Photo] = [/* Default photos */] var newestPhotos: NewestPhotos { NewestPhotos() } func update() async { // Fetch new photos from remote service } func push(_ photo: Photo) { photos.append(photo) } } struct NewestPhotos: AsyncSequence { struct AsyncIterator: AsyncIteratorProtocol { func next() async -> Photo? { // Fetch next photo from remote service } } func makeAsyncIterator() -> AsyncIterator { AsyncIterator() } } struct Photo: Identifiable { var id: URL { url } var url: URL }
-
7:02 - Non-interactive directions list
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List(directions) { direction in Label { Text(direction.text) } icon: { DirectionsIcon(direction) } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
8:08 - Interactive directions list
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List($directions) { $direction in Label { TextField("Instructions", text: $direction.text) } icon: { DirectionsIcon(direction) } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
8:49 - Interactive directions list using ForEach
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List { ForEach($directions) { $direction in Label { TextField("Instructions", text: $direction.text) } icon: { DirectionsIcon(direction) } } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
9:09 - listRowSeparatorTint() modifier
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List { ForEach($directions) { $direction in Label { TextField("Instructions", text: $direction.text) } icon: { DirectionsIcon(direction) } .listRowSeparatorTint(direction.color) } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
9:38 - listRowSeparator() modifier
struct ContentView: View { @State var directions: [Direction] = [ Direction(symbol: "car", color: .mint, text: "Drive to SFO"), Direction(symbol: "airplane", color: .blue, text: "Fly to SJC"), Direction(symbol: "tram", color: .purple, text: "Ride to Cupertino"), Direction(symbol: "bicycle", color: .orange, text: "Bike to Apple Park"), Direction(symbol: "figure.walk", color: .green, text: "Walk to pond"), Direction(symbol: "lifepreserver", color: .blue, text: "Swim to the center"), Direction(symbol: "drop", color: .indigo, text: "Dive to secret airlock"), Direction(symbol: "tram.tunnel.fill", color: .brown, text: "Ride through underground tunnels"), Direction(symbol: "key", color: .red, text: "Enter door code:"), ] var body: some View { NavigationView { List { ForEach($directions) { $direction in Label { TextField("Instructions", text: $direction.text) } icon: { DirectionsIcon(direction) } .listRowSeparator(.hidden) } } .listStyle(.sidebar) .navigationTitle("Secret Hideout") } } } struct Direction: Identifiable { var id = UUID() var symbol: String var color: Color var text: String } private struct DirectionsIcon: View { var direction: Direction init(_ direction: Direction) { self.direction = direction } var body: some View { Image(systemName: direction.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) .background(direction.color, in: RoundedRectangle(cornerRadius: 8)) } }
-
10:08 - Swipe actions
struct ContentView: View { @State private var characters = CharacterStore(StoryCharacter.previewData) var body: some View { NavigationView { List { if !characters.pinned.isEmpty { Section("Pinned") { sectionContent(for: $characters.pinned) } } Section("Heroes & Villains") { sectionContent(for: $characters.unpinned) } } .listStyle(.sidebar) .navigationTitle("Characters") } } @ViewBuilder private func sectionContent(for characters: Binding<[StoryCharacter]>) -> some View { ForEach(characters) { $character in CharacterProfile(character) .swipeActions { Button { togglePinned(for: $character) } label: { if character.isPinned { Label("Unpin", systemImage: "pin.slash") } else { Label("Pin", systemImage: "pin") } } .tint(.yellow) } } } private func togglePinned(for character: Binding<StoryCharacter>) { withAnimation { var tmp = character.wrappedValue tmp.isPinned.toggle() tmp.lastModified = Date() character.wrappedValue = tmp } } private func delete<C: RangeReplaceableCollection & MutableCollection>( _ character: StoryCharacter, in characters: Binding<C> ) where C.Element == StoryCharacter { withAnimation { if let i = characters.wrappedValue.firstIndex(where: { $0.id == character.id }) { characters.wrappedValue.remove(at: i) } } } } struct CharacterProfile: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { NavigationLink { Text(character.name) } label: { HStack { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 8)) } else { symbol .background(character.color, in: Circle()) } } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center) { Text(character.name) .bold() .foregroundStyle(.primary) } HStack(spacing: 4) { Text(character.isVillain ? "VILLAIN" : "HERO") .bold() .font(.caption2.weight(.heavy)) .foregroundStyle(.white) .padding(.vertical, 1) .padding(.horizontal, 3) .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) Text(character.powers) .font(.footnote) .foregroundStyle(.secondary) } } } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
10:27 - Swipe actions on the leading edge
struct ContentView: View { @State private var characters = CharacterStore(StoryCharacter.previewData) var body: some View { NavigationView { List { if !characters.pinned.isEmpty { Section("Pinned") { sectionContent(for: $characters.pinned) } } Section("Heroes & Villains") { sectionContent(for: $characters.unpinned) } } .listStyle(.sidebar) .navigationTitle("Characters") } } @ViewBuilder private func sectionContent(for characters: Binding<[StoryCharacter]>) -> some View { ForEach(characters) { $character in CharacterProfile(character) .swipeActions(edge: .leading) { Button { togglePinned(for: $character) } label: { if character.isPinned { Label("Unpin", systemImage: "pin.slash") } else { Label("Pin", systemImage: "pin") } } .tint(.yellow) } } } private func togglePinned(for character: Binding<StoryCharacter>) { withAnimation { var tmp = character.wrappedValue tmp.isPinned.toggle() tmp.lastModified = Date() character.wrappedValue = tmp } } private func delete<C: RangeReplaceableCollection & MutableCollection>( _ character: StoryCharacter, in characters: Binding<C> ) where C.Element == StoryCharacter { withAnimation { if let i = characters.wrappedValue.firstIndex(where: { $0.id == character.id }) { characters.wrappedValue.remove(at: i) } } } } struct CharacterProfile: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { NavigationLink { Text(character.name) } label: { HStack { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 8)) } else { symbol .background(character.color, in: Circle()) } } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center) { Text(character.name) .bold() .foregroundStyle(.primary) } HStack(spacing: 4) { Text(character.isVillain ? "VILLAIN" : "HERO") .bold() .font(.caption2.weight(.heavy)) .foregroundStyle(.white) .padding(.vertical, 1) .padding(.horizontal, 3) .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) Text(character.powers) .font(.footnote) .foregroundStyle(.secondary) } } } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
10:32 - Swipe actions on both edges
struct ContentView: View { @State private var characters = CharacterStore(StoryCharacter.previewData) var body: some View { NavigationView { List { if !characters.pinned.isEmpty { Section("Pinned") { sectionContent(for: $characters.pinned) } } Section("Heroes & Villains") { sectionContent(for: $characters.unpinned) } } .listStyle(.sidebar) .navigationTitle("Characters") } } @ViewBuilder private func sectionContent(for characters: Binding<[StoryCharacter]>) -> some View { ForEach(characters) { $character in CharacterProfile(character) .swipeActions(edge: .leading) { Button { togglePinned(for: $character) } label: { if character.isPinned { Label("Unpin", systemImage: "pin.slash") } else { Label("Pin", systemImage: "pin") } } .tint(.yellow) } .swipeActions(edge: .trailing) { Button(role: .destructive) { delete(character, in: characters) } label: { Label("Delete", systemImage: "trash") } Button { // Open "More" menu } label: { Label("More", systemImage: "ellipsis.circle") } .tint(Color(white: 0.8)) } } } private func togglePinned(for character: Binding<StoryCharacter>) { withAnimation { var tmp = character.wrappedValue tmp.isPinned.toggle() tmp.lastModified = Date() character.wrappedValue = tmp } } private func delete<C: RangeReplaceableCollection & MutableCollection>( _ character: StoryCharacter, in characters: Binding<C> ) where C.Element == StoryCharacter { withAnimation { if let i = characters.wrappedValue.firstIndex(where: { $0.id == character.id }) { characters.wrappedValue.remove(at: i) } } } } struct CharacterProfile: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { NavigationLink { Text(character.name) } label: { HStack { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 8)) } else { symbol .background(character.color, in: Circle()) } } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center) { Text(character.name) .bold() .foregroundStyle(.primary) } HStack(spacing: 4) { Text(character.isVillain ? "VILLAIN" : "HERO") .bold() .font(.caption2.weight(.heavy)) .foregroundStyle(.white) .padding(.vertical, 1) .padding(.horizontal, 3) .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) Text(character.powers) .font(.footnote) .foregroundStyle(.secondary) } } } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
11:14 - Basic macOS list
struct ContentView: View { @State private var characters = StoryCharacter.previewData @State private var selection = Set<StoryCharacter.ID>() var body: some View { List(selection: $selection) { ForEach(characters) { character in Label { Text(character.name) } icon: { CharacterIcon(character) } .padding(.leading, 4) } } .listStyle(.inset) .navigationTitle("All Characters") } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
11:35 - Inset list style alternating row backgrounds
struct ContentView: View { @State private var characters = StoryCharacter.previewData @State private var selection = Set<StoryCharacter.ID>() var body: some View { List(selection: $selection) { ForEach(characters) { character in Label { Text(character.name) } icon: { CharacterIcon(character) } .padding(.leading, 4) } } .listStyle(.inset(alternatesRowBackgrounds: true)) .navigationTitle("All Characters") } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
12:13 - Tables
struct ContentView: View { @State private var characters = StoryCharacter.previewData var body: some View { Table(characters) { TableColumn("") { CharacterIcon($0) } .width(20) TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") } .width(40) TableColumn("Name", value: \.name) TableColumn("Powers", value: \.powers) } } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
12:49 - Tables with selection
struct ContentView: View { @State private var characters = StoryCharacter.previewData // Single selection @State private var singleSelection: StoryCharacter.ID? // Multiple selection @State private var multipleSelection: Set<StoryCharacter.ID>() var body: some View { Table(characters, selection: $singleSelection) { // or `$multipleSelection` TableColumn("") { CharacterIcon($0) } .width(20) TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") } .width(40) TableColumn("Name", value: \.name) TableColumn("Powers", value: \.powers) } } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
12:57 - Tables with selection and sorting
struct ContentView: View { @State private var characters = StoryCharacter.previewData @State private var selection = Set<StoryCharacter.ID>() @State private var sortOrder = [KeyPathComparator(\StoryCharacter.name)] @State private var sorted: [StoryCharacter]? var body: some View { Table(sorted ?? characters, selection: $selection, sortOrder: $sortOrder) { TableColumn("") { CharacterIcon($0) } .width(20) TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") } .width(40) TableColumn("Name", value: \.name) TableColumn("Powers", value: \.powers) } .onChange(of: characters) { sorted = $0.sorted(using: sortOrder) } .onChange(of: sortOrder) { sorted = characters.sorted(using: $0) } } } struct CharacterIcon: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(4) .frame(width: 20, height: 20) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 4)) } else { symbol .background(character.color, in: Circle()) } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { get { all.prefix { $0.isPinned } } set { if let end = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(all.startIndex..<end, with: newValue) } } } var unpinned: [StoryCharacter] { get { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } set { if let start = all.firstIndex(where: { !$0.isPinned }) { all.replaceSubrange(start..<all.endIndex, with: newValue) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
13:15 - CoreData Tables
@FetchRequest(sortDescriptors: [SortDescriptor(\.name)]) private var characters: FetchedResults<StoryCharacter> @State private var selection = Set<StoryCharacter.ID>() Table(characters, selection: $selection, sortOrder: $characters.sortDescriptors) { TableColumn("") { CharacterIcon($0) } .width(20) TableColumn("Villain") { Text($0.isVillain ? "Villain" : "Hero") } .width(40) TableColumn("Name", value: \.name) TableColumn("Powers", value: \.powers) }
-
13:34 - Sectioned fetch requests
@SectionedFetchRequest( sectionIdentifier: \.isPinned, sortDescriptors: [ SortDescriptor(\.isPinned, order: .reverse), SortDescriptor(\.lastModified) ], animation: .default) private var characters: SectionedFetchResults<...> List { ForEach(characters) { section in Section(section.id ? "Pinned" : "Heroes & Villains") { ForEach(section) { character in CharacterRowView(character) } } } }
-
15:20 - searchable() modifier
struct ContentView: View { @State private var characters = CharacterStore(StoryCharacter.previewData) var body: some View { NavigationView { List { if characters.filterText.isEmpty { if !characters.pinned.isEmpty { Section("Pinned") { sectionContent(for: characters.pinned) } } Section("Heroes & Villains") { sectionContent(for: characters.unpinned) } } else { sectionContent(for: characters.filtered) } } .listStyle(.sidebar) .searchable(text: $characters.filterText) .navigationTitle("Characters") } } @ViewBuilder private func sectionContent(for characters: [StoryCharacter]) -> some View { ForEach(characters) { character in CharacterProfile(character) } } } struct CharacterProfile: View { var character: StoryCharacter init(_ character: StoryCharacter) { self.character = character } var body: some View { NavigationLink { Text(character.name) } label: { HStack { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(6) .frame(width: 33, height: 33) if character.isVillain { symbol .background(character.color, in: RoundedRectangle(cornerRadius: 8)) } else { symbol .background(character.color, in: Circle()) } } VStack(alignment: .leading, spacing: 2) { HStack(alignment: .center) { Text(character.name) .bold() .foregroundStyle(.primary) } HStack(spacing: 4) { Text(character.isVillain ? "VILLAIN" : "HERO") .bold() .font(.caption2.weight(.heavy)) .foregroundStyle(.white) .padding(.vertical, 1) .padding(.horizontal, 3) .background(.quaternary, in: RoundedRectangle(cornerRadius: 3)) Text(character.powers) .font(.footnote) .foregroundStyle(.secondary) } } } } } } struct CharacterStore { var all: [StoryCharacter] { get { _all } set { _all = newValue; sortAll() } } var _all: [StoryCharacter] var pinned: [StoryCharacter] { all.prefix { $0.isPinned } } var unpinned: [StoryCharacter] { if let start = all.firstIndex(where: { !$0.isPinned }) { return Array(all.suffix(from: start)) } else { return [] } } var filterText: String = "" var filtered: [StoryCharacter] { if filterText.isEmpty { return all } else { return all.filter { $0.name.contains(filterText) || $0.powers.contains(filterText) } } } init(_ characters: [StoryCharacter]) { _all = characters sortAll() } private mutating func sortAll() { _all.sort { lhs, rhs in if lhs.isPinned && !rhs.isPinned { return true } else if !lhs.isPinned && rhs.isPinned { return false } else { return lhs.lastModified < rhs.lastModified } } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() } extension StoryCharacter { static let previewData: [StoryCharacter] = [ StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true), StoryCharacter( id: 1, name: "The Truth Duplicator", symbol: "eyes", color: .blue, powers: "Distorts reality.", isVillain: true), StoryCharacter( id: 2, name: "The Previewer", symbol: "viewfinder", color: .indigo, powers: "Reveals the future.", isPinned: true), StoryCharacter( id: 3, name: "The Type Eraser", symbol: "eye.slash", color: .black, powers: "Steals identities.", isVillain: true, isPinned: true), StoryCharacter( id: 4, name: "The Environment Modifier", symbol: "leaf", color: .green, powers: "Controls the physical world."), StoryCharacter( id: 5, name: "The Unstable Identifier", symbol: "shuffle", color: .brown, powers: "Shape-shifter, uncatchable.", isVillain: true), StoryCharacter( id: 6, name: "The Stylizer", symbol: "wand.and.stars.inverse", color: .red, powers: "Quartermaster of heroes."), StoryCharacter( id: 7, name: "The Singleton", symbol: "diamond", color: .purple, powers: "An evil robotic hive mind.", isVillain: true), StoryCharacter( id: 8, name: "The Geometry Reader", symbol: "ruler", color: .orange, powers: "Instantly scans any structure."), StoryCharacter( id: 9, name: "The Opaque Typist", symbol: "app.fill", color: .teal, powers: "Creates impenetrable disguises."), StoryCharacter( id: 10, name: "The Unobservable Man", symbol: "hand.raised.slash", color: .black, powers: "Impervious to detection.", isVillain: true), ] }
-
16:22 - Drag previews
struct ContentView: View { let character = StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true ) var body: some View { CharacterIcon(character) .controlSize(.large) .padding() .onDrag { character.itemProvider } preview: { Label { Text(character.name) } icon: { CharacterIcon(character) .controlSize(.small) } .padding(.vertical, 8) .frame(width: 150) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() var itemProvider: NSItemProvider { let item = NSItemProvider() item.registerObject(name as NSString, visibility: .all) return item } } struct CharacterIcon: View { var character: StoryCharacter #if os(iOS) || os(macOS) @Environment(\.controlSize) private var controlSize #endif init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(symbolPadding) .frame(width: symbolLength, height: symbolLength) if character.isVillain { symbol .background( character.color, in: RoundedRectangle(cornerRadius: cornerRadius)) } else { symbol .background(character.color, in: Circle()) } } } var symbolPadding: CGFloat { switch controlSize { case .small: return 4 case .large: return 10 default: return 6 } } var symbolLength: CGFloat { switch controlSize { case .small: return 20 case .large: return 60 default: return 33 } } var cornerRadius: CGFloat { switch controlSize { case .small: return 4 case .large: return 16 default: return 8 } } }
-
16:48 - importsItemProviders() modifier
import UniformTypeIdentifiers @main private struct Catalog: App { var body: some Scene { WindowGroup { ContentView() } .commands { ImportFromDevicesCommands() } } } struct ContentView: View { @State private var character: StoryCharacter = StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true ) var body: some View { VStack { CharacterIcon(character) .controlSize(.large) .onDrag { character.itemProvider } preview: { Label { Text(character.name) } icon: { CharacterIcon(character) .controlSize(.small) } .padding(.vertical, 8) .frame(width: 150) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } if let headerImage = character.headerImage { headerImage .resizable() .aspectRatio(contentMode: .fill) .frame(width: 150, height: 150) .mask(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } .padding() .importsItemProviders(StoryCharacter.headerImageTypes) { itemProviders in guard let first = itemProviders.first else { return false } async { character.headerImage = await StoryCharacter.loadHeaderImage(from: first) } return true } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() var headerImage: Image? static var headerImageTypes: [UTType] { NSImage.imageTypes.compactMap { UTType($0) } } var itemProvider: NSItemProvider { let item = NSItemProvider() item.registerObject(name as NSString, visibility: .all) return item } static func loadHeaderImage(from itemProvider: NSItemProvider) async -> Image? { for type in Self.headerImageTypes.map(\.identifier) { if itemProvider.hasRepresentationConforming(toTypeIdentifier: type) { return await withCheckedContinuation { continuation in itemProvider.loadDataRepresentation(forTypeIdentifier: type) { data, error in guard let data = data, let image = NSImage(data: data) else { return } continuation.resume(returning: Image(nsImage: image)) } } } } return nil } } struct CharacterIcon: View { var character: StoryCharacter #if os(iOS) || os(macOS) @Environment(\.controlSize) private var controlSize #endif init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(symbolPadding) .frame(width: symbolLength, height: symbolLength) if character.isVillain { symbol .background( character.color, in: RoundedRectangle(cornerRadius: cornerRadius)) } else { symbol .background(character.color, in: Circle()) } } } var symbolPadding: CGFloat { switch controlSize { case .small: return 4 case .large: return 10 default: return 6 } } var symbolLength: CGFloat { switch controlSize { case .small: return 20 case .large: return 60 default: return 33 } } var cornerRadius: CGFloat { switch controlSize { case .small: return 4 case .large: return 16 default: return 8 } } }
-
18:17 - exportsItemProviders() modifier
import UniformTypeIdentifiers @main private struct Catalog: App { var body: some Scene { WindowGroup { ContentView() } .commands { ImportFromDevicesCommands() } } } struct ContentView: View { @State private var character: StoryCharacter = StoryCharacter( id: 0, name: "The View Builder", symbol: "hammer", color: .pink, powers: "Conjures objects on-demand.", isPinned: true ) var body: some View { VStack { CharacterIcon(character) .controlSize(.large) .onDrag { character.itemProvider } preview: { Label { Text(character.name) } icon: { CharacterIcon(character) .controlSize(.small) } .padding(.vertical, 8) .frame(width: 150) .background(.white, in: RoundedRectangle(cornerRadius: 8)) } if let headerImage = character.headerImage { headerImage .resizable() .aspectRatio(contentMode: .fill) .frame(width: 150, height: 150) .mask(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } .padding() .importsItemProviders(StoryCharacter.headerImageTypes) { itemProviders in guard let first = itemProviders.first else { return false } async { character.headerImage = await StoryCharacter.loadHeaderImage(from: first) } return true } .exportsItemProviders(StoryCharacter.contentTypes) { [character.itemProvider] } } } struct StoryCharacter: Identifiable, Equatable { var id: Int64 var name: String var symbol: String var color: Color var powers: String var isVillain: Bool = false var isPinned: Bool = false var lastModified = Date() var headerImage: Image? static var contentTypes: [UTType] { [.utf8PlainText] } static var headerImageTypes: [UTType] { NSImage.imageTypes.compactMap { UTType($0) } } var itemProvider: NSItemProvider { let item = NSItemProvider() item.registerObject(name as NSString, visibility: .all) return item } static func loadHeaderImage(from itemProvider: NSItemProvider) async -> Image? { for type in Self.headerImageTypes.map(\.identifier) { if itemProvider.hasRepresentationConforming(toTypeIdentifier: type) { return await withCheckedContinuation { continuation in itemProvider.loadDataRepresentation(forTypeIdentifier: type) { data, error in guard let data = data, let image = NSImage(data: data) else { return } continuation.resume(returning: Image(nsImage: image)) } } } } return nil } } struct CharacterIcon: View { var character: StoryCharacter #if os(iOS) || os(macOS) @Environment(\.controlSize) private var controlSize #endif init(_ character: StoryCharacter) { self.character = character } var body: some View { HStack { let symbol = Image(systemName: character.symbol) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(.white) .padding(symbolPadding) .frame(width: symbolLength, height: symbolLength) if character.isVillain { symbol .background( character.color, in: RoundedRectangle(cornerRadius: cornerRadius)) } else { symbol .background(character.color, in: Circle()) } } } var symbolPadding: CGFloat { switch controlSize { case .small: return 4 case .large: return 10 default: return 6 } } var symbolLength: CGFloat { switch controlSize { case .small: return 20 case .large: return 60 default: return 33 } } var cornerRadius: CGFloat { switch controlSize { case .small: return 4 case .large: return 16 default: return 8 } } }
-
19:47 - Symbol rendering modes
struct ContentView: View { var body: some View { VStack { HStack { symbols } .symbolRenderingMode(.monochrome) HStack { symbols } .symbolRenderingMode(.multicolor) HStack { symbols } .symbolRenderingMode(.hierarchical) HStack { symbols } .symbolRenderingMode(.palette) .foregroundStyle(Color.cyan, Color.purple) } .foregroundStyle(.blue) .font(.title) } @ViewBuilder var symbols: some View { Group { Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "pc") Image(systemName: "phone.down.circle") Image(systemName: "hourglass") Image(systemName: "heart.fill") Image(systemName: "airplane.circle.fill") } .frame(width: 40, height: 40) } }
-
20:27 - Symbol variants
struct ContentView: View { var body: some View { VStack { HStack { symbols } HStack { symbols } .symbolVariant(.fill) } .foregroundStyle(.blue) } @ViewBuilder var symbols: some View { let heart = Image(systemName: "heart") Group { heart heart.symbolVariant(.slash) heart.symbolVariant(.circle) heart.symbolVariant(.square) heart.symbolVariant(.rectangle) } .frame(width: 40, height: 40) } }
-
20:42 - Tab symbol variants: iOS 13
struct TabExample: View { var body: some View { TabView { CardsView().tabItem { Label("Cards", systemImage: "rectangle.portrait.on.rectangle.portrait.fill") } RulesView().tabItem { Label("Rules", systemImage: "character.book.closed.fill") } ProfileView().tabItem { Label("Profile", systemImage: "person.circle.fill") } SearchPlayersView().tabItem { Label("Magic", systemImage: "sparkles") } } } } struct CardsView: View { var body: some View { Color.clear } } struct RulesView: View { var body: some View { Color.clear } } struct ProfileView: View { var body: some View { Color.clear } } struct SearchPlayersView: View { var body: some View { Color.clear } }
-
20:50 - Tab symbol variants
@main struct SnippetsApp: App { var body: some Scene { WindowGroup { #if os(iOS) TabExample() #else VStack{ Text("Open Preferences") Text("⌘,").font(.title.monospaced()) } .fixedSize() .scenePadding() #endif } #if os(macOS) Settings { TabExample() } #endif } } struct TabExample: View { var body: some View { TabView { CardsView().tabItem { Label("Cards", systemImage: "rectangle.portrait.on.rectangle.portrait") } RulesView().tabItem { Label("Rules", systemImage: "character.book.closed") } ProfileView().tabItem { Label("Profile", systemImage: "person.circle") } SearchPlayersView().tabItem { Label("Magic", systemImage: "sparkles") } } } } struct CardsView: View { var body: some View { Color.clear } } struct RulesView: View { var body: some View { Color.clear } } struct ProfileView: View { var body: some View { Color.clear } } struct SearchPlayersView: View { var body: some View { Color.clear } }
-
21:31 - Canvas
struct ContentView: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) var body: some View { Canvas { context, size in let metrics = gridMetrics(in: size) for (index, symbol) in symbols.enumerated() { let rect = metrics[index] let image = context.resolve(symbol.image) context.draw(image, in: rect.fit(image.size)) } } } func gridMetrics(in size: CGSize) -> SymbolGridMetrics { SymbolGridMetrics(size: size, numberOfSymbols: symbols.count) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } } struct SymbolGridMetrics { let symbolWidth: CGFloat let symbolsPerRow: Int let numberOfSymbols: Int let insetProportion: CGFloat init(size: CGSize, numberOfSymbols: Int, insetProportion: CGFloat = 0.1) { let areaPerSymbol = (size.width * size.height) / CGFloat(numberOfSymbols) self.symbolsPerRow = Int(size.width / sqrt(areaPerSymbol)) self.symbolWidth = size.width / CGFloat(symbolsPerRow) self.numberOfSymbols = numberOfSymbols self.insetProportion = insetProportion } /// Returns the frame in the grid for the symbol at `index` position. /// It is not valid to pass an index less than `0` or larger than the number of symbols the grid metrics was created for. subscript(_ index: Int) -> CGRect { precondition(index >= 0 && index < numberOfSymbols) let row = index / symbolsPerRow let column = index % symbolsPerRow let rect = CGRect( x: CGFloat(column) * symbolWidth, y: CGFloat(row) * symbolWidth, width: symbolWidth, height: symbolWidth) return rect.insetBy(dx: symbolWidth * insetProportion, dy: symbolWidth * insetProportion) } } extension CGRect { /// Returns a rect with the aspect ratio of `otherSize`, fitting within `self`. func fit(_ otherSize: CGSize) -> CGRect { let scale = min(size.width / otherSize.width, size.height / otherSize.height) let newSize = CGSize(width: otherSize.width * scale, height: otherSize.height * scale) let newOrigin = CGPoint(x: midX - newSize.width/2, y: midY - newSize.height/2) return CGRect(origin: newOrigin, size: newSize) } }
-
22:03 - Canvas with gesture
struct ContentView: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) @GestureState private var focalPoint: CGPoint? = nil var body: some View { Canvas { context, size in let metrics = gridMetrics(in: size) for (index, symbol) in symbols.enumerated() { let rect = metrics[index] let (sRect, opacity) = rect.fishEyeTransform(around: focalPoint) context.opacity = opacity let image = context.resolve(symbol.image) context.draw(image, in: sRect.fit(image.size)) } } .gesture(DragGesture(minimumDistance: 0).updating($focalPoint) { value, focalPoint, _ in focalPoint = value.location }) } func gridMetrics(in size: CGSize) -> SymbolGridMetrics { SymbolGridMetrics(size: size, numberOfSymbols: symbols.count) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } } struct SymbolGridMetrics { let symbolWidth: CGFloat let symbolsPerRow: Int let numberOfSymbols: Int let insetProportion: CGFloat init(size: CGSize, numberOfSymbols: Int, insetProportion: CGFloat = 0.1) { let areaPerSymbol = (size.width * size.height) / CGFloat(numberOfSymbols) self.symbolsPerRow = Int(size.width / sqrt(areaPerSymbol)) self.symbolWidth = size.width / CGFloat(symbolsPerRow) self.numberOfSymbols = numberOfSymbols self.insetProportion = insetProportion } /// Returns the frame in the grid for the symbol at `index` position. /// It is not valid to pass an index less than `0` or larger than the number of symbols the grid metrics was created for. subscript(_ index: Int) -> CGRect { precondition(index >= 0 && index < numberOfSymbols) let row = index / symbolsPerRow let column = index % symbolsPerRow let rect = CGRect( x: CGFloat(column) * symbolWidth, y: CGFloat(row) * symbolWidth, width: symbolWidth, height: symbolWidth) return rect.insetBy(dx: symbolWidth * insetProportion, dy: symbolWidth * insetProportion) } } extension CGRect { /// Returns a rect with the aspect ratio of `otherSize`, fitting within `self`. func fit(_ otherSize: CGSize) -> CGRect { let scale = min(size.width / otherSize.width, size.height / otherSize.height) let newSize = CGSize(width: otherSize.width * scale, height: otherSize.height * scale) let newOrigin = CGPoint(x: midX - newSize.width/2, y: midY - newSize.height/2) return CGRect(origin: newOrigin, size: newSize) } /// Returns a transformed rect and relative opacity based on a fish eye effect centered around `point`. /// The rectangles closer to the center of that point will be larger and brighter, and those further away will be smaller, up to a distance of `radius`. func fishEyeTransform(around point: CGPoint?, radius: CGFloat = 300, zoom: CGFloat = 1.0) -> (frame: CGRect, opacity: CGFloat) { guard let point = point else { return (self, 1.0) } let deltaX = midX - point.x let deltaY = midY - point.y let distance = sqrt(deltaX*deltaX + deltaY*deltaY) let theta = atan2(deltaY, deltaX) let scaledClampedDistance = pow(min(1, max(0, distance/radius)), 0.7) let scale = (1.0 - scaledClampedDistance)*zoom + 0.5 let newOffset = distance * (2.0 - scaledClampedDistance)*sqrt(zoom) let newDeltaX = newOffset * cos(theta) let newDeltaY = newOffset * sin(theta) let newSize = CGSize(width: size.width * scale, height: size.height * scale) let newOrigin = CGPoint(x: (newDeltaX + point.x) - newSize.width/2, y: (newDeltaY + point.y) - newSize.height/2) // Clamp the opacity to be 0.1 at the lowest let opacity = max(0.1, 1.0 - scaledClampedDistance) return (CGRect(origin: newOrigin, size: newSize), opacity) } }
-
22:24 - Canvas with accessibility children
struct ContentView: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) @GestureState private var focalPoint: CGPoint? = nil var body: some View { Canvas { context, size in let metrics = gridMetrics(in: size) for (index, symbol) in symbols.enumerated() { let rect = metrics[index] let (sRect, opacity) = rect.fishEyeTransform(around: focalPoint) context.opacity = opacity let image = context.resolve(symbol.image) context.draw(image, in: sRect.fit(image.size)) } } .gesture(DragGesture(minimumDistance: 0).updating($focalPoint) { value, focalPoint, _ in focalPoint = value.location }) .accessibilityLabel("Symbol Browser") .accessibilityChildren { List(symbols) { Text($0.name) } } } func gridMetrics(in size: CGSize) -> SymbolGridMetrics { SymbolGridMetrics(size: size, numberOfSymbols: symbols.count) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } } struct SymbolGridMetrics { let symbolWidth: CGFloat let symbolsPerRow: Int let numberOfSymbols: Int let insetProportion: CGFloat init(size: CGSize, numberOfSymbols: Int, insetProportion: CGFloat = 0.1) { let areaPerSymbol = (size.width * size.height) / CGFloat(numberOfSymbols) self.symbolsPerRow = Int(size.width / sqrt(areaPerSymbol)) self.symbolWidth = size.width / CGFloat(symbolsPerRow) self.numberOfSymbols = numberOfSymbols self.insetProportion = insetProportion } /// Returns the frame in the grid for the symbol at `index` position. /// It is not valid to pass an index less than `0` or larger than the number of symbols the grid metrics was created for. subscript(_ index: Int) -> CGRect { precondition(index >= 0 && index < numberOfSymbols) let row = index / symbolsPerRow let column = index % symbolsPerRow let rect = CGRect( x: CGFloat(column) * symbolWidth, y: CGFloat(row) * symbolWidth, width: symbolWidth, height: symbolWidth) return rect.insetBy(dx: symbolWidth * insetProportion, dy: symbolWidth * insetProportion) } } extension CGRect { /// Returns a rect with the aspect ratio of `otherSize`, fitting within `self`. func fit(_ otherSize: CGSize) -> CGRect { let scale = min(size.width / otherSize.width, size.height / otherSize.height) let newSize = CGSize(width: otherSize.width * scale, height: otherSize.height * scale) let newOrigin = CGPoint(x: midX - newSize.width/2, y: midY - newSize.height/2) return CGRect(origin: newOrigin, size: newSize) } /// Returns a transformed rect and relative opacity based on a fish eye effect centered around `point`. /// The rectangles closer to the center of that point will be larger and brighter, and those further away will be smaller, up to a distance of `radius`. func fishEyeTransform(around point: CGPoint?, radius: CGFloat = 300, zoom: CGFloat = 1.0) -> (frame: CGRect, opacity: CGFloat) { guard let point = point else { return (self, 1.0) } let deltaX = midX - point.x let deltaY = midY - point.y let distance = sqrt(deltaX*deltaX + deltaY*deltaY) let theta = atan2(deltaY, deltaX) let scaledClampedDistance = pow(min(1, max(0, distance/radius)), 0.7) let scale = (1.0 - scaledClampedDistance)*zoom + 0.5 let newOffset = distance * (2.0 - scaledClampedDistance)*sqrt(zoom) let newDeltaX = newOffset * cos(theta) let newDeltaY = newOffset * sin(theta) let newSize = CGSize(width: size.width * scale, height: size.height * scale) let newOrigin = CGPoint(x: (newDeltaX + point.x) - newSize.width/2, y: (newDeltaY + point.y) - newSize.height/2) // Clamp the opacity to be 0.1 at the lowest let opacity = max(0.1, 1.0 - scaledClampedDistance) return (CGRect(origin: newOrigin, size: newSize), opacity) } }
-
22:48 - Canvas with TimelineView
struct ContentView: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) var body: some View { TimelineView(.animation) { let time = $0.date.timeIntervalSince1970 Canvas { context, size in let metrics = gridMetrics(in: size) let focalPoint = focalPoint(at: time, in: size) for (index, symbol) in symbols.enumerated() { let rect = metrics[index] let (sRect, opacity) = rect.fishEyeTransform( around: focalPoint, at: time) context.opacity = opacity let image = context.resolve(symbol.image) context.draw(image, in: sRect.fit(image.size)) } } } } func gridMetrics(in size: CGSize) -> SymbolGridMetrics { SymbolGridMetrics(size: size, numberOfSymbols: symbols.count) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } } struct SymbolGridMetrics { let symbolWidth: CGFloat let symbolsPerRow: Int let numberOfSymbols: Int let insetProportion: CGFloat init(size: CGSize, numberOfSymbols: Int, insetProportion: CGFloat = 0.1) { let areaPerSymbol = (size.width * size.height) / CGFloat(numberOfSymbols) self.symbolsPerRow = Int(size.width / sqrt(areaPerSymbol)) self.symbolWidth = size.width / CGFloat(symbolsPerRow) self.numberOfSymbols = numberOfSymbols self.insetProportion = insetProportion } /// Returns the frame in the grid for the symbol at `index` position. /// It is not valid to pass an index less than `0` or larger than the number of symbols the grid metrics was created for. subscript(_ index: Int) -> CGRect { precondition(index >= 0 && index < numberOfSymbols) let row = index / symbolsPerRow let column = index % symbolsPerRow let rect = CGRect( x: CGFloat(column) * symbolWidth, y: CGFloat(row) * symbolWidth, width: symbolWidth, height: symbolWidth) return rect.insetBy(dx: symbolWidth * insetProportion, dy: symbolWidth * insetProportion) } } extension CGRect { /// Returns a rect with the aspect ratio of `otherSize`, fitting within `self`. func fit(_ otherSize: CGSize) -> CGRect { let scale = min(size.width / otherSize.width, size.height / otherSize.height) let newSize = CGSize(width: otherSize.width * scale, height: otherSize.height * scale) let newOrigin = CGPoint(x: midX - newSize.width/2, y: midY - newSize.height/2) return CGRect(origin: newOrigin, size: newSize) } /// Returns a transformed rect and relative opacity based on a fish eye effect centered around `point`. /// The rectangles closer to the center of that point will be larger and brighter, and those further away will be smaller, up to a distance of `radius`. func fishEyeTransform(around point: CGPoint?, radius: CGFloat = 200, zoom: CGFloat = 3.0) -> (frame: CGRect, opacity: CGFloat) { guard let point = point else { return (self, 1.0) } let deltaX = midX - point.x let deltaY = midY - point.y let distance = sqrt(deltaX*deltaX + deltaY*deltaY) let theta = atan2(deltaY, deltaX) let scaledClampedDistance = pow(min(1, max(0, distance/radius)), 0.7) let scale = (1.0 - scaledClampedDistance)*zoom + 0.5 let newOffset = distance * (2.0 - scaledClampedDistance)*sqrt(zoom) let newDeltaX = newOffset * cos(theta) let newDeltaY = newOffset * sin(theta) let newSize = CGSize(width: size.width * scale, height: size.height * scale) let newOrigin = CGPoint(x: (newDeltaX + point.x) - newSize.width/2, y: (newDeltaY + point.y) - newSize.height/2) // Clamp the opacity to be 0.1 at the lowest let opacity = max(0.1, 1.0 - scaledClampedDistance) return (CGRect(origin: newOrigin, size: newSize), opacity) } /// Returns a transformed rect and relative opacity based on a fish eye effect centered around `point`, based on a preset path indexed using `time`. func fishEyeTransform(around point: CGPoint, at time: TimeInterval) -> (frame: CGRect, opacity: CGFloat) { // Arbitrary zoom and radius calculation based on time let zoom = cos(time) + 3.0 let radius = ((cos(time/5) + 1)/2) * 150 + 150 return fishEyeTransform(around: point, radius: radius, zoom: zoom) } } /// Returns a focal point within `size` based on a preset path, indexed using `time`. func focalPoint(at time: TimeInterval, in size: CGSize) -> CGPoint { let offset: CGFloat = min(size.width, size.height)/4 let distance = ((sin(time/5) + 1)/2) * offset + offset let scalePoint = CGPoint(x: size.width / 2 + distance * cos(time / 2), y: size.height / 2 + distance * sin(time / 2)) return scalePoint }
-
24:10 - Privacy sensitive
Button { showFavoritePicker = true } label: { VStack(alignment: .center) { Text("Favorite Symbol") .foregroundStyle(.secondary) Image(systemName: favoriteSymbol) .font(.title2) .privacySensitive(true) } } .tint(.purple)
-
24:27 - Privacy sensitive (widgets)
VStack(alignment: .leading) { Text("Favorite Symbol") .textCase(.uppercase) .font(.caption.bold()) ContainerRelativeShape() .fill(.quaternary) .overlay { Image(systemName: favoriteSymbol) .font(.system(size: 40)) .privacySensitive(true) } }
-
25:03 - Materials
struct ColorList: View { let symbols = Array(repeating: Symbol("swift"), count: 3166) var body: some View { ZStack { gradientBackground materialOverlay } } var materialOverlay: some View { VStack { Text("Symbol Browser") .font(.largeTitle.bold()) Text("\(symbols.count) symbols 🎉") .foregroundStyle(.secondary) .font(.title2.bold()) } .padding() .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16.0)) } var gradientBackground: some View { LinearGradient( gradient: Gradient(colors: [.red, .orange, .yellow, .green, .blue, .indigo, .purple]), startPoint: .leading, endPoint: .trailing) } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } }
-
25:40 - Safe area inset
struct ContentView: View { let newSymbols = Array(repeating: Symbol("swift"), count: 645) let systemColors: [Color] = [.red, .orange, .yellow, .green, .mint, .teal, .cyan, .blue, .indigo, .purple, .pink, .gray, .brown] var body: some View { ScrollView { symbolGrid } .safeAreaInset(edge: .bottom, spacing: 0) { VStack(spacing: 0) { Divider() VStack(spacing: 0) { Text("\(newSymbols.count) new symbols") .foregroundStyle(.primary) .font(.body.bold()) Text("\(systemColors.count) system colors") .foregroundStyle(.secondary) } .padding() } .background(.regularMaterial) } } var symbolGrid: some View { LazyVGrid(columns: [.init(.adaptive(minimum: 40, maximum: 40))]) { ForEach(0 ..< newSymbols.count, id: \.self) { index in newSymbols[index].image .foregroundStyle(.white) .frame(width: 40, height: 40) .background(systemColors[index % systemColors.count]) } } .padding() } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } }
-
26:03 - Preview orientation
struct ColorList_Previews: PreviewProvider { static var previews: some View { ColorList() .previewInterfaceOrientation(.portrait) ColorList() .previewInterfaceOrientation(.landscapeLeft) } } struct ColorList: View { let newSymbols = Array(repeating: Symbol("swift"), count: 645) let systemColors: [Color] = [.red, .orange, .yellow, .green, .mint, .teal, .cyan, .blue, .indigo, .purple, .pink, .gray, .brown] var body: some View { ScrollView { symbolGrid } .safeAreaInset(edge: .bottom, spacing: 0) { VStack(spacing: 0) { Divider() VStack(spacing: 0) { Text("\(newSymbols.count) new symbols") .foregroundStyle(.primary) .font(.body.bold()) Text("\(systemColors.count) system colors") .foregroundStyle(.secondary) } .padding() } .background(.regularMaterial) } } var symbolGrid: some View { LazyVGrid(columns: [.init(.adaptive(minimum: 40, maximum: 40))]) { ForEach(0 ..< newSymbols.count, id: \.self) { index in newSymbols[index].image .foregroundStyle(.white) .frame(width: 40, height: 40) .background(systemColors[index % systemColors.count]) } } .padding() } } struct Symbol: Identifiable { let name: String init(_ name: String) { self.name = name } var image: Image { Image(systemName: name) } var id: String { name } }
-
27:06 - Hello, World!
Text("Hello, World!")
-
27:17 - Markdown Text: strong emphasis
Text("**Hello**, World!")
-
27:24 - Markdown Text: links
Text("**Hello**, World!") Text(""" Have a *happy* [WWDC](https://developer.apple.com/wwdc21/)! """)
-
27:30 - Markdown Text: inline code
Text(""" Is this *too* meta? `Text("**Hello**, World!")` `Text(\"\"\"` `Have a *happy* [WWDC](https://developer.apple.com/wwdc21/)!` `\"\"\")` """)
-
27:37 - AttributedString
struct ContentView: View { var body: some View { Text(formattedDate) } var formattedDate: AttributedString { var formattedDate: AttributedString = Date().formatted(Date.FormatStyle().day().month(.wide).weekday(.wide).attributed) let weekday = AttributeContainer.dateField(.weekday) let color = AttributeContainer.foregroundColor(.orange) formattedDate.replaceAttributes(weekday, with: color) return formattedDate } }
-
29:17 - Text selection
struct ContentView: View { var activity: Activity = .sample var body: some View { VStack(alignment: .leading, spacing: 0) { ActivityHeader(activity) Divider() Text(activity.info) .textSelection(.enabled) .padding() Spacer() } .background() .navigationTitle(activity.name) } } struct ActivityHeader: View { var activity: Activity init(_ activity: Activity) { self.activity = activity } var body: some View { VStack(alignment: alignment.horizontal, spacing: 8) { HStack(alignment: .firstTextBaseline) { #if os(macOS) Text(activity.name) .font(.title2.bold()) Spacer() #endif Text(activity.date.formatted(.dateTime.weekday(.wide).day().month().hour().minute())) .foregroundStyle(.secondary) } HStack(alignment: .firstTextBaseline) { Image(systemName: "person.2") Text(activity.people.map(\.nameComponents).formatted(.list(memberStyle: .name(style: .short), type: .and))) } } #if os(macOS) .padding() #else .padding([.horizontal, .bottom]) #endif .frame(maxWidth: .infinity, alignment: alignment) .background(activity.tint.opacity(0.1).ignoresSafeArea()) } private var alignment: Alignment { #if os(macOS) .leading #else .center #endif } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") } struct Person { var givenName: String var familyName: String = "" var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
29:28 - Text selection: view hierarchy
struct ContentView: View { var activity: Activity = .sample var body: some View { VStack(alignment: .leading, spacing: 0) { ActivityHeader(activity) Divider() Text(activity.info) .padding() Spacer() } .textSelection(.enabled) .background() .navigationTitle(activity.name) } } struct ActivityHeader: View { var activity: Activity init(_ activity: Activity) { self.activity = activity } var body: some View { VStack(alignment: alignment.horizontal, spacing: 8) { HStack(alignment: .firstTextBaseline) { #if os(macOS) Text(activity.name) .font(.title2.bold()) Spacer() #endif Text(activity.date.formatted(.dateTime.weekday(.wide).day().month().hour().minute())) .foregroundStyle(.secondary) } HStack(alignment: .firstTextBaseline) { Image(systemName: "person.2") Text(activity.people.map(\.nameComponents).formatted(.list(memberStyle: .name(style: .short), type: .and))) } } #if os(macOS) .padding() #else .padding([.horizontal, .bottom]) #endif .frame(maxWidth: .infinity, alignment: alignment) .background(activity.tint.opacity(0.1).ignoresSafeArea()) } private var alignment: Alignment { #if os(macOS) .leading #else .center #endif } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") } struct Person { var givenName: String var familyName: String = "" var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
30:03 - Text formatting: List
struct ContentView: View { var activity: Activity = .sample var body: some View { Text(activity.people.map(\.nameComponents).formatted(.list(memberStyle: .name(style: .short), type: .and))) .scenePadding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") } struct Person { var givenName: String var familyName: String = "" var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
30:43 - Text field formatting
struct ContentView: View { @State private var newAttendee = PersonNameComponents() var body: some View { TextField("New Person", value: $newAttendee, format: .name(style: .medium)) } }
-
31:09 - Text field prompts and labels
struct ContentView: View { @State var activity: Activity = .sample var body: some View { Form { TextField("Name:", text: $activity.name, prompt: Text("New Activity")) TextField("Location:", text: $activity.location) DatePicker("Date:", selection: $activity.date) } .frame(minWidth: 250) .padding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") } struct Person { var givenName: String var familyName: String = "" var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
31:39 - Text field submission
struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() var body: some View { TextField("New Person", value: $newAttendee, format: .name(style: .medium) ) .onSubmit { activity.append(Person(newAttendee)) newAttendee = PersonNameComponents() } } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
31:59 - Text field submission: submit label
struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() var body: some View { TextField("New Person", value: $newAttendee, format: .name(style: .medium) ) .onSubmit { activity.append(Person(newAttendee)) newAttendee = PersonNameComponents() } .submitLabel(.done) } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
32:07 - Keyboard toolbar
struct ContentView: View { @State private var activity: Activity = .sample @FocusState private var focusedField: Field? var body: some View { Form { TextField("Name", text: $activity.name, prompt: Text("New Activity")) TextField("Location", text: $activity.location) DatePicker("Date", selection: $activity.date) } .toolbar { ToolbarItemGroup(placement: .keyboard) { Button(action: selectPreviousField) { Label("Previous", systemImage: "chevron.up") } .disabled(!hasPreviousField) Button(action: selectNextField) { Label("Next", systemImage: "chevron.down") } .disabled(!hasNextField) } } } private func selectPreviousField() { focusedField = focusedField.map { Field(rawValue: $0.rawValue - 1)! } } private var hasPreviousField: Bool { if let currentFocusedField = focusedField { return currentFocusedField.rawValue > 0 } else { return false } } private func selectNextField() { focusedField = focusedField.map { Field(rawValue: $0.rawValue + 1)! } } private var hasNextField: Bool { if let currentFocusedField = focusedField { return currentFocusedField.rawValue < Field.allCases.count } else { return false } } } private enum Field: Int, Hashable, CaseIterable { case name, location, date, addAttendee } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
33:05 - Focus state
struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() @FocusState private var addAttendeeIsFocused: Bool var body: some View { VStack { Form { TextField("Name:", text: $activity.name, prompt: Text("New Activity")) TextField("Location:", text: $activity.location) DatePicker("Date:", selection: $activity.date) } TextField("New Person", value: $newAttendee, format: .name(style: .medium)) .focused($addAttendeeIsFocused) } .frame(minWidth: 250) .scenePadding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
33:16 - Focus state: setting focus
struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() @FocusState private var addAttendeeIsFocused: Bool var body: some View { VStack { Form { TextField("Name:", text: $activity.name, prompt: Text("New Activity")) TextField("Location:", text: $activity.location) DatePicker("Date:", selection: $activity.date) } VStack(alignment: .leading) { TextField("New Person", value: $newAttendee, format: .name(style: .medium)) .focused($addAttendeeIsFocused) ControlGroup { Button { addAttendeeIsFocused = true } label: { Label("Add Attendee", systemImage: "plus") } } .fixedSize() } } .frame(minWidth: 250) .scenePadding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
33:30 - Focus state: Hashable value
private enum Field: Int, Hashable, CaseIterable { case name, location, date, addAttendee } struct ContentView: View { @State private var activity: Activity = .sample @State private var newAttendee = PersonNameComponents() @FocusState private var focusedField: Field? var body: some View { VStack { Form { TextField("Name:", text: $activity.name, prompt: Text("New Activity")) .focused($focusedField, equals: .name) TextField("Location:", text: $activity.location) .focused($focusedField, equals: .location) DatePicker("Date:", selection: $activity.date) .focused($focusedField, equals: .date) } VStack(alignment: .leading) { TextField("New Person", value: $newAttendee, format: .name(style: .medium)) .focused($focusedField, equals: .addAttendee) ControlGroup { Button { focusedField = .addAttendee } label: { Label("Add Attendee", systemImage: "plus") } } .fixedSize() } } .frame(minWidth: 250) .scenePadding() } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
34:03 - Focus state: back/forward controls
private enum Field: Int, Hashable, CaseIterable { case name, location, date, addAttendee } struct ContentView: View { @State private var activity: Activity = .sample @FocusState private var focusedField: Field? var body: some View { Form { TextField("Name", text: $activity.name, prompt: Text("New Activity")) TextField("Location", text: $activity.location) DatePicker("Date", selection: $activity.date) } .toolbar { ToolbarItemGroup(placement: .keyboard) { Button(action: selectPreviousField) { Label("Previous", systemImage: "chevron.up") } .disabled(!canSelectPreviousField) Button(action: selectNextField) { Label("Next", systemImage: "chevron.down") } .disabled(!canSelectNextField) } } } private func selectPreviousField() { focusedField = focusedField.map { Field(rawValue: $0.rawValue - 1)! } } private var canSelectPreviousField: Bool { if let currentFocusedField = focusedField { return currentFocusedField.rawValue > 0 } else { return false } } private func selectNextField() { focusedField = focusedField.map { Field(rawValue: $0.rawValue + 1)! } } private var canSelectNextField: Bool { if let currentFocusedField = focusedField { return currentFocusedField.rawValue < Field.allCases.count } else { return false } } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
34:13 - Focus state: keyboard dismissal
private enum Field: Int, Hashable, CaseIterable { case name, location, date, addAttendee } struct ContentView: View { @State private var activity: Activity = .sample @FocusState private var focusedField: Field? var body: some View { Form { TextField("Name", text: $activity.name, prompt: Text("New Activity")) TextField("Location", text: $activity.location) DatePicker("Date", selection: $activity.date) } } func endEditing() { focusedField = nil } } struct Activity { var name: String var date: Date var location: String var people: [Person] var info: AttributedString var tint: Color = .purple static let sample = Activity(name: "What's New in SwiftUI", date: Date(), location: "Apple Park", people: [.init(givenName: "You")], info: "This is some info.") mutating func append(_ person: Person) { people.append(person) } } struct Person { var givenName: String var familyName: String init(givenName: String, familyName: String = "") { self.givenName = givenName self.familyName = familyName } init(_ nameComponents: PersonNameComponents) { givenName = nameComponents.givenName ?? "" familyName = nameComponents.familyName ?? "" } var nameComponents: PersonNameComponents { get { var components = PersonNameComponents() components.givenName = givenName if !familyName.isEmpty { components.familyName = familyName } return components } set { givenName = newValue.givenName ?? "" familyName = newValue.familyName ?? "" } } }
-
34:55 - Bordered buttons
Button("Add") { // ... } .buttonStyle(.bordered)
-
35:03 - Bordered buttons: view hierarchy
struct ContentView: View { var body: some View { ScrollView { LazyVStack { ForEach(0..<10) { _ in Button("Add") { //... } } } } .buttonStyle(.bordered) } }
-
35:09 - Bordered buttons: tinting
struct ContentView: View { var body: some View { ScrollView { LazyVStack { ForEach(0..<10) { _ in Button("Add") { //... } } } } .buttonStyle(.bordered) .tint(.green) } }
-
35:16 - Control size and prominence
struct ContentView: View { var entry: ButtonEntry = .sample var body: some View { HStack { ForEach(entry.tags) { tag in Button(tag.name) { // ... } .tint(tag.color) } } .buttonStyle(.bordered) .controlSize(.small) .controlProminence(.increased) } } struct ButtonEntry { struct Tag: Identifiable { var name: String var color: Color var id: String { name } } var name: String var tags: [Tag] static let sample = ButtonEntry(name: "Stroopwafel", tags: [Tag(name: "1960s", color: .purple), Tag(name: "bronze", color: .yellow)]) }
-
35:34 - Large buttons
struct ContentView: View { var body: some View { VStack { Button(action: addToJar) { Text("Add to Jar").frame(maxWidth: 300) } .controlProminence(.increased) .keyboardShortcut(.defaultAction) Button(action: addToWatchlist) { Text("Add to Watchlist").frame(maxWidth: 300) } .tint(.accentColor) } .buttonStyle(.bordered) .controlSize(.large) } private func addToJar() {} private func addToWatchlist() {} }
-
37:14 - Destructive buttons
struct ContentView: View { var entry: ButtonEntry = .sample var body: some View { ButtonEntryCell(entry) .contextMenu { Section { Button("Open") { // ... } Button("Delete...", role: .destructive) { // ... } } Section { Button("Archive") {} Menu("Move to") { ForEach(Jar.allJars) { jar in Button("\(jar.name)") { //addTo(jar) } } } } } } } struct ButtonEntryCell: View { var entry: ButtonEntry = .sample init(_ entry: ButtonEntry) { self.entry = entry } var body: some View { Text(entry.name) .padding() } } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { struct Tag: Identifiable { var name: String var color: Color var id: String { name } } var name: String var tags: [Tag] static let sample = ButtonEntry(name: "Stroopwafel", tags: [Tag(name: "1960s", color: .purple), Tag(name: "bronze", color: .yellow)]) }
-
37:25 - Confirmation dialogs
struct ContentView: View { var entry: ButtonEntry = .sample @State private var showConfirmation: Bool = false var body: some View { ButtonEntryCell(entry) .contextMenu { Section { Button("Open") { // ... } Button("Delete...", role: .destructive) { showConfirmation = true // ... } } Section { Button("Archive") {} Menu("Move to") { ForEach(Jar.allJars) { jar in Button("\(jar.name)") { //addTo(jar) } } } } } .confirmationDialog( "Are you sure you want to delete \(entry.name)?", isPresented: $showConfirmation ) { Button("Delete", role: .destructive) { // delete the entry } } message: { Text("Deleting \(entry.name) will remove it from all of your jars.") } } } struct ButtonEntryCell: View { var entry: ButtonEntry = .sample init(_ entry: ButtonEntry) { self.entry = entry } var body: some View { Text(entry.name) .padding() } } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { struct Tag: Identifiable { var name: String var color: Color var id: String { name } } var name: String var tags: [Tag] static let sample = ButtonEntry(name: "Stroopwafel", tags: [Tag(name: "1960s", color: .purple), Tag(name: "bronze", color: .yellow)]) }
-
37:59 - Menu buttons
struct ContentView: View { var buttonEntry: ButtonEntry = .sample @StateObject private var jarStore = JarStore() var body: some View { Menu("Add") { ForEach(jarStore.allJars) { jar in Button("Add to \(jar.name)") { jarStore.add(buttonEntry, to: jar) } } } .menuStyle(BorderedButtonMenuStyle()) .scenePadding() } } class JarStore: ObservableObject { var allJars: [Jar] = Jar.allJars func add(_ entry: ButtonEntry, to jar: Jar) {} } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { var name: String static let sample = ButtonEntry(name: "Stroopwafel") }
-
38:10 - Menu buttons: hidden indicator
struct ContentView: View { var buttonEntry: ButtonEntry = .sample @StateObject private var jarStore = JarStore() var body: some View { Menu("Add") { ForEach(jarStore.allJars) { jar in Button("Add to \(jar.name)") { jarStore.add(buttonEntry, to: jar) } } } .menuStyle(BorderedButtonMenuStyle()) .menuIndicator(.hidden) .scenePadding() } } class JarStore: ObservableObject { var allJars: [Jar] = Jar.allJars func add(_ entry: ButtonEntry, to jar: Jar) {} } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { var name: String static let sample = ButtonEntry(name: "Stroopwafel") }
-
38:31 - Menu buttons: primary action
struct ContentView: View { var buttonEntry: ButtonEntry = .sample @StateObject private var jarStore = JarStore() var body: some View { Menu("Add") { ForEach(jarStore.allJars) { jar in Button("Add to \(jar.name)") { jarStore.add(buttonEntry, to: jar) } } } primaryAction: { jarStore.addToDefaultJar(buttonEntry) } .menuStyle(BorderedButtonMenuStyle()) .scenePadding() } } class JarStore: ObservableObject { var allJars: [Jar] = Jar.allJars func add(_ entry: ButtonEntry, to jar: Jar) {} func addToDefaultJar(_ entry: ButtonEntry) {} } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { var name: String static let sample = ButtonEntry(name: "Stroopwafel") }
-
38:42 - Menu buttons: primary action, indicator hidden
struct ContentView: View { var buttonEntry: ButtonEntry = .sample @StateObject private var jarStore = JarStore() var body: some View { Menu("Add") { ForEach(jarStore.allJars) { jar in Button("Add to \(jar.name)") { jarStore.add(buttonEntry, to: jar) } } } primaryAction: { jarStore.addToDefaultJar(buttonEntry) } .menuStyle(BorderedButtonMenuStyle()) .menuIndicator(.hidden) .scenePadding() } } class JarStore: ObservableObject { var allJars: [Jar] = Jar.allJars func add(_ entry: ButtonEntry, to jar: Jar) {} func addToDefaultJar(_ entry: ButtonEntry) {} } struct Jar: Identifiable { var name: String var id: String { name } static let allJars = [Jar(name: "Secret Stash")] } struct ButtonEntry { var name: String static let sample = ButtonEntry(name: "Stroopwafel") }
-
39:01 - Toggle buttons
Toggle(isOn: $showOnlyNew) { Label("Show New Buttons", systemImage: "sparkles") } .toggleStyle(.button)
-
39:13 - Control group
ControlGroup { Button(action: archive) { Label("Archive", systemImage: "archiveBox") } Button(action: delete) { Label("Delete", systemName: "trash") } }
-
39:26 - Control group: back/forward control
struct ContentView: View { @State var current: String = "More buttons" @State var history: [String] = ["Text and keyboard", "Advanced graphics", "Beyond lists", "Better lists"] @State var forwardHistory: [String] = [] var body: some View { Color.clear .toolbar{ ToolbarItem(placement: .navigation) { ControlGroup { Menu { ForEach(history, id: \.self) { previousSection in Button(previousSection) { goBack(to: previousSection) } } } label: { Label("Back", systemImage: "chevron.backward") } primaryAction: { goBack(to: history[0]) } .disabled(history.isEmpty) Menu { ForEach(forwardHistory, id: \.self) { nextSection in Button(nextSection) { goForward(to: nextSection) } } } label: { Label("Forward", systemImage: "chevron.forward") } primaryAction: { goForward(to: forwardHistory[0]) } .disabled(forwardHistory.isEmpty) } .controlGroupStyle(.navigation) } } .navigationTitle(current) } private func goBack(to section: String) { guard let index = history.firstIndex(of: section) else { return } forwardHistory.insert(current, at: 0) forwardHistory.insert(contentsOf: history[...history.index(before: index)].reversed(), at: 0) history.removeSubrange(...index) current = section } private func goForward(to section: String) { guard let index = forwardHistory.firstIndex(of: section) else { return } history.insert(current, at: 0) history.insert(contentsOf: forwardHistory[...forwardHistory.index(before: index)].reversed(), at: 0) forwardHistory.removeSubrange(...index) current = section } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.