스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
Direct and reflect focus in SwiftUI
With device input — as with all things in life — where you put focus matters. Discover how you can move focus in your app with SwiftUI, programmatically dismiss the keyboard, and build large navigation targets from small views. Together, these APIs can help you simplify your app's interface and make it more powerful for people to find what they need.
리소스
관련 비디오
WWDC22
WWDC21
-
다운로드
♪ Bass music playing ♪ ♪ Tanu Singhal: Hello, everyone.
My name is Tanu, and I'm a SwiftUI engineer on the Apple TV team.
Today, we'll talk about some new ways of working with focus in SwiftUI.
One of the great things about SwiftUI is how much functionality you get for free, right out of the box.
Built-in components base their default behavior on SwiftUI's knowledge of platform conventions, resulting in an overall experience that is sensible and familiar in various contexts.
We see this intelligence at work when we look at focus.
Focus is the system that lets your app take input from keyboards, remotes, game controllers, accessible switch controls, and other sources that -- unlike touch inputs -- are not tied to specific screen coordinates.
Often, the focus view is drawn with special embellishments, making it easy for people to predict where their input will be directed.
For the most part, SwiftUI manages focus on your behalf.
When someone clicks in a text field or presses the Tab key or when someone taps an adjustable watch complication or swipes on the Siri Remote, SwiftUI decides how focus is affected and how its next placement is indicated.
This is great for simple cases where the right behavior can be decided by convention.
There are, however, some cases where you may want a more accelerated experience around focus.
In this example from the Notes app, when we select the new note button, we want focus to automatically move to the newly created note.
This type of behavior certainly requires custom implementation.
Over here, we have a scenario in which we want to move focus from a button on the bottom-left side to content near the top of the screen when a user swipes right on the remote.
Since the Music button and the App tiles are not adjacent to each other, SwiftUI cannot automatically guess where to move focus.
It needs more information before distant focus targets can be linked.
And in this example from iOS, we simply want the keyboard to go away when a user selects an event.
Thanks to new APIs we're introducing this year, you can now do all of this in SwiftUI.
In this talk, we will focus on two kinds of use cases.
We'll see how we can move focus to a particular view without any direct input, and we'll make large navigation targets out of small views so that nothing is out of the user's reach.
Let's first look an example where we may want to move focus to help direct the user's attention.
We're so ready to go on a vacation, and we've started working on a vacation planner app.
This app works across all Apple platforms, and it allows you to browse destinations as well as book trips.
When you launch this app, we present an email field, a password field, and a Sign in with Apple button so you can log into your account.
On this login screen, if we discover that the email entered was not in the correct format, we want to programmatically move focus back to the email field.
To accomplish this behavior, we'll use the FocusState APIs.
The existing code for our login view contains a VStack that has a TextField and a SecureField.
Now, we'll add a FocusState property wrapper to this view.
FocusState is a new API that we have introduced this year! This is a special type of state that changes depending on the current location of focus.
We'll use FocusState to hold an identifier for the field that is focused.
The focusedField variable is of an enum type that we created for this example.
You can use strings, integers, or any other hashable value type for FocusState.
Note that the FocusState value is optional.
In general, types used for FocusState must be both hashable and optional, with nil used for cases where focus is in an unrelated part of the screen.
Next we'll add a focused modifier to the TextField and the SecureField.
Also new this year, this modifier creates a link between the placement of focus and the value of the focusedField property.
This simple link is a powerful tool, because it means you can use the current placement of focus for making other decisions in your app.
We can watch that play out in our login form.
When the screen first appears, nothing has focus, so the value of focusedField is nil.
But if someone then taps on the email text field, that field gains focus and the keyboard appears.
Since the focused text field is bound to a FocusState value, the value of focusedField will automatically be updated to hold the identifier for the email text field.
The link between focus placement and FocusState works both ways.
This means that we are not limited to reacting to focus changes; we can move focus programmatically just by updating our FocusState property.
So for example, if we programmatically set the value of focusedField to .password, SwiftUI will know that our SecureField is associated with the new value we're setting and focus will automatically move to the password field.
Now that our focus bindings are in place, we can put them to work.
In the Vacation Planner app, when a user submits their data, we want to validate it.
If the email is not in the expected format, we set the focusedField to email.
This will send focus back to the email text field, if it's not there already.
Further, we'd like to highlight the email field with a border if the email was invalid.
We want this border to appear only while focus is on the email field.
To do that, we can easily read the value of focusedField when we create the border.
Let's see how this all comes together.
Note the email field doesn't have a valid address.
Focus is currently on the password field.
When we hit Go, the onSubmit callback is triggered where the focusedField is set.
This causes the cursor to move back to the email field.
While the email field is focused, we see the red border around it.
However, once we move focus away from the email field, our focusedField is no longer equal to the email identifier, and so the red border disappears.
In the scenario where all the form data is valid, we want to simply dismiss the keyboard.
To dismiss the keyboard, we will set our FocusState variable to nil.
Since the focusedField is an optional, we use nil to indicate that focus has left this view.
In the video, notice that the email address has been updated.
This time when we submit, the keyboard gets dismissed since we have set the FocusState variable to nil.
We've seen how it can be helpful to programmatically control focus when our app has text fields.
However, FocusStates are not just for text fields.
They can be used to programmatically control focus for any focusable view on iOS, tvOS, watchOS, or macOS! In the next section, we'll discuss the role of focus-based navigation in our apps.
Let's take a look at the tvOS version of our Vacation Planner app.
We have leveraged the extra space on TV by adding photos from some destinations that you may want to visit.
You can view more photos by clicking the Browse Photos button even before you've logged in.
Notice that focus is initially on the Email field.
If we swipe right on the Siri Remote, we'd expect focus to move to the Browse Photos button.
However, that doesn't work by default.
This is because directional focus navigation is based on adjacency relationships.
When swiping to move focus, focus will only move if there is something adjacent and focusable in the given direction.
Take a look at the focusable views in this app.
Since there's no focusable view adjacent to the login fields on the left, the button on the bottom is unreachable.
To make this screen navigable, we will extend the Browse button's focusable area, so it becomes adjacent to the login fields.
This is done using the new FocusSections API.
Let's see how easy that is.
Here we have a simplified version of the Vacation Planner code for TV.
It contains an HStack with two VStacks; one for the login fields, and another VStack for the image and the button.
We want to create a larger logical focus target around the button so focus can behave as if the button was adjacent to the login fields.
This can be done by simply adding a focusSection() modifier to the VStack that contains the button.
When focusSection() modifier is added to any view, the frame of that view becomes capable of accepting focus if it contains any focusable subviews.
Since we also want to move focus back to the login fields when swiping left on the button, we'll add another focusSection() modifier to the first VStack.
Now when we run this app, users can move focus between the input fields and the Browse button by swiping left and right on the remote.
As we wrap things up, I'd encourage you to think about focus, which can often look different on different platforms.
SwiftUI has great default behaviors built in for most cases.
The new focus states and FocusSections APIs can help you take advantage of focus to create even more streamlined experiences.
As you work on your apps, take a moment to observe the many ways in which focus impacts user behavior.
We hope this session has equipped you with the tools that'll help users focus on what's most important.
Thanks for watching, and have an awesome WWDC! ♪
-
-
3:38 - Slide 13 - Textfield and Securefield
import SwiftUI import AuthenticationServices struct ContentView: View { @State private var email: String = "" @State private var password: String = "" var body: some View { ZStack { Image("backgroundImage") .resizable() .opacity(0.7) .ignoresSafeArea() VStack(alignment: .center) { Text("Vacation Planner") .font(.custom("Baskerville-SemiBoldItalic", size: 60)) .foregroundColor(.black.opacity(0.8)) .frame(alignment: .top) Spacer(minLength: 30) TextField("Email", text: $email) .submitLabel(.next) .textContentType(.emailAddress) .keyboardType(.emailAddress) .padding() .frame(height: 50) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) SecureField("Password", text: $password) .submitLabel(.go) .padding() .frame(height:50) .textContentType(.password) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) Spacer().frame(height: 20) HStack { Rectangle().frame(height: 1) Text("or").bold().padding() Rectangle().frame(height: 1) } .foregroundColor(.black.opacity(0.7)) Spacer().frame(height: 20) SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.fullName, .email] } onCompletion: { result in switch result { case .success (_): print("Authorization successful.") case .failure (let error): print("Authorization failed: " + error.localizedDescription) } } .frame(height: 50) .cornerRadius(15) Spacer().frame(height: 20) } .frame(width: 280, height: 500, alignment: .bottom) } } }
-
3:49 - Slide 14 - Focus State
import SwiftUI import AuthenticationServices struct ContentView: View { @FocusState private var focusedField: Field? @State private var email: String = "" @State private var password: String = "" var body: some View { ZStack { Image("backgroundImage") .resizable() .opacity(0.7) .ignoresSafeArea() VStack(alignment: .center) { Text("Vacation Planner") .font(.custom("Baskerville-SemiBoldItalic", size: 60)) .foregroundColor(.black.opacity(0.8)) .frame(alignment: .top) Spacer(minLength: 30) TextField("Email", text: $email) .submitLabel(.next) .textContentType(.emailAddress) .keyboardType(.emailAddress) .padding() .frame(height: 50) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) SecureField("Password", text: $password) .submitLabel(.go) .padding() .frame(height:50) .textContentType(.password) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) Spacer().frame(height: 20) HStack { Rectangle().frame(height: 1) Text("or").bold().padding() Rectangle().frame(height: 1) } .foregroundColor(.black.opacity(0.7)) Spacer().frame(height: 20) SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.fullName, .email] } onCompletion: { result in switch result { case .success (_): print("Authorization successful.") case .failure (let error): print("Authorization failed: " + error.localizedDescription) } } .frame(height: 50) .cornerRadius(15) Spacer().frame(height: 20) } .frame(width: 280, height: 500, alignment: .bottom) } } }
-
4:07 - Slide 15 - Focus Field
import SwiftUI import AuthenticationServices enum Field: Hashable { case email case password } struct ContentView: View { @FocusState private var focusedField: Field? @State private var email: String = "" @State private var password: String = "" var body: some View { ZStack { Image("backgroundImage") .resizable() .opacity(0.7) .ignoresSafeArea() VStack(alignment: .center) { Text("Vacation Planner") .font(.custom("Baskerville-SemiBoldItalic", size: 60)) .foregroundColor(.black.opacity(0.8)) .frame(alignment: .top) Spacer(minLength: 30) TextField("Email", text: $email) .submitLabel(.next) .textContentType(.emailAddress) .keyboardType(.emailAddress) .padding() .frame(height: 50) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) SecureField("Password", text: $password) .submitLabel(.go) .padding() .frame(height:50) .textContentType(.password) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) Spacer().frame(height: 20) HStack { Rectangle().frame(height: 1) Text("or").bold().padding() Rectangle().frame(height: 1) } .foregroundColor(.black.opacity(0.7)) Spacer().frame(height: 20) SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.fullName, .email] } onCompletion: { result in switch result { case .success (_): print("Authorization successful.") case .failure (let error): print("Authorization failed: " + error.localizedDescription) } } .frame(height: 50) .cornerRadius(15) Spacer().frame(height: 20) } .frame(width: 280, height: 500, alignment: .bottom) } } }
-
4:32 - Slide 17 - focused modifiers
import SwiftUI import AuthenticationServices enum Field: Hashable { case email case password } struct ContentView: View { @FocusState private var focusedField: Field? @State private var email: String = "" @State private var password: String = "" var body: some View { ZStack { Image("backgroundImage") .resizable() .opacity(0.7) .ignoresSafeArea() VStack(alignment: .center) { Text("Vacation Planner") .font(.custom("Baskerville-SemiBoldItalic", size: 60)) .foregroundColor(.black.opacity(0.8)) .frame(alignment: .top) Spacer(minLength: 30) TextField("Email", text: $email) .submitLabel(.next) .textContentType(.emailAddress) .keyboardType(.emailAddress) .padding() .frame(height: 50) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) .focused($focusedField, equals: .email) SecureField("Password", text: $password) .submitLabel(.go) .padding() .frame(height:50) .textContentType(.password) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) .focused($focusedField, equals: .password) Spacer().frame(height: 20) HStack { Rectangle().frame(height: 1) Text("or").bold().padding() Rectangle().frame(height: 1) } .foregroundColor(.black.opacity(0.7)) Spacer().frame(height: 20) SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.fullName, .email] } onCompletion: { result in switch result { case .success (_): print("Authorization successful.") case .failure (let error): print("Authorization failed: " + error.localizedDescription) } } .frame(height: 50) .cornerRadius(15) Spacer().frame(height: 20) } .frame(width: 280, height: 500, alignment: .bottom) } } }
-
6:07 - Slide 25 - onSubmit
import SwiftUI import AuthenticationServices enum Field: Hashable { case email case password } struct ContentView: View { @FocusState private var focusedField: Field? @State private var email: String = "" @State private var password: String = "" @State private var submittedEmail: String = "" var body: some View { ZStack { Image("backgroundImage") .resizable() .opacity(0.7) .ignoresSafeArea() VStack(alignment: .center) { Text("Vacation Planner") .font(.custom("Baskerville-SemiBoldItalic", size: 60)) .foregroundColor(.black.opacity(0.8)) .frame(alignment: .top) Spacer(minLength: 30) TextField("Email", text: $email) .submitLabel(.next) .textContentType(.emailAddress) .keyboardType(.emailAddress) .padding() .frame(height: 50) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) .focused($focusedField, equals: .email) SecureField("Password", text: $password) .submitLabel(.go) .padding() .frame(height:50) .textContentType(.password) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) .focused($focusedField, equals: .password) Spacer().frame(height: 20) HStack { Rectangle().frame(height: 1) Text("or").bold().padding() Rectangle().frame(height: 1) } .foregroundColor(.black.opacity(0.7)) Spacer().frame(height: 20) SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.fullName, .email] } onCompletion: { result in switch result { case .success (_): print("Authorization successful.") case .failure (let error): print("Authorization failed: " + error.localizedDescription) } } .frame(height: 50) .cornerRadius(15) Spacer().frame(height: 20) } .frame(width: 280, height: 500, alignment: .bottom) .onSubmit { submittedEmail = email if !isEmailValid { focusedField = .email } } } } private var isEmailValid : Bool { let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" let predicate = NSPredicate(format:"SELF MATCHES %@", regex) return submittedEmail.isEmpty || predicate.evaluate(with: submittedEmail) } }
-
6:25 - Slide 26 - border
import SwiftUI import AuthenticationServices enum Field: Hashable { case email case password } struct ContentView: View { @FocusState private var focusedField: Field? @State private var email: String = "" @State private var password: String = "" @State private var submittedEmail: String = "" var body: some View { ZStack { Image("backgroundImage") .resizable() .opacity(0.7) .ignoresSafeArea() VStack(alignment: .center) { Text("Vacation Planner") .font(.custom("Baskerville-SemiBoldItalic", size: 60)) .foregroundColor(.black.opacity(0.8)) .frame(alignment: .top) Spacer(minLength: 30) TextField("Email", text: $email) .submitLabel(.next) .textContentType(.emailAddress) .keyboardType(.emailAddress) .padding() .frame(height: 50) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) .focused($focusedField, equals: .email) .border(Color.red, width: (focusedField == .email && !isEmailValid) ? 2 : 0) SecureField("Password", text: $password) .submitLabel(.go) .padding() .frame(height:50) .textContentType(.password) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) .focused($focusedField, equals: .password) Spacer().frame(height: 20) HStack { Rectangle().frame(height: 1) Text("or").bold().padding() Rectangle().frame(height: 1) } .foregroundColor(.black.opacity(0.7)) Spacer().frame(height: 20) SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.fullName, .email] } onCompletion: { result in switch result { case .success (_): print("Authorization successful.") case .failure (let error): print("Authorization failed: " + error.localizedDescription) } } .frame(height: 50) .cornerRadius(15) Spacer().frame(height: 20) } .frame(width: 280, height: 500, alignment: .bottom) .onSubmit { submittedEmail = email if !isEmailValid { focusedField = .email } } } } private var isEmailValid : Bool { let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" let predicate = NSPredicate(format:"SELF MATCHES %@", regex) return submittedEmail.isEmpty || predicate.evaluate(with: submittedEmail) } }
-
7:17 - Slide 29 - dismiss keyboard with nil
import SwiftUI import AuthenticationServices enum Field: Hashable { case email case password } struct ContentView: View { @FocusState private var focusedField: Field? @State private var email: String = "" @State private var password: String = "" @State private var submittedEmail: String = "" var body: some View { ZStack { Image("backgroundImage") .resizable() .opacity(0.7) .ignoresSafeArea() VStack(alignment: .center) { Text("Vacation Planner") .font(.custom("Baskerville-SemiBoldItalic", size: 60)) .foregroundColor(.black.opacity(0.8)) .frame(alignment: .top) Spacer(minLength: 30) TextField("Email", text: $email) .submitLabel(.next) .textContentType(.emailAddress) .keyboardType(.emailAddress) .padding() .frame(height: 50) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) .focused($focusedField, equals: .email) .border(Color.red, width: (focusedField == .email && !isEmailValid) ? 2 : 0) SecureField("Password", text: $password) .submitLabel(.go) .padding() .frame(height:50) .textContentType(.password) .background(Color.white.opacity(0.9)) .cornerRadius(15) .padding(10) .focused($focusedField, equals: .password) Spacer().frame(height: 20) HStack { Rectangle().frame(height: 1) Text("or").bold().padding() Rectangle().frame(height: 1) } .foregroundColor(.black.opacity(0.7)) Spacer().frame(height: 20) SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.fullName, .email] } onCompletion: { result in switch result { case .success (_): print("Authorization successful.") case .failure (let error): print("Authorization failed: " + error.localizedDescription) } } .frame(height: 50) .cornerRadius(15) Spacer().frame(height: 20) } .frame(width: 280, height: 500, alignment: .bottom) .onSubmit { submittedEmail = email if !isEmailValid { focusedField = .email } else { focusedField = nil // Show progress indicator, and log in. } } } } private var isEmailValid : Bool { let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" let predicate = NSPredicate(format:"SELF MATCHES %@", regex) return submittedEmail.isEmpty || predicate.evaluate(with: submittedEmail) } }
-
9:24 - tv code
import SwiftUI import AuthenticationServices struct ContentView: View { @State private var email: String = "" @State private var password: String = "" var body: some View { HStack { VStack(alignment: .leading) { Spacer(minLength:60).frame(height: 150) Text("Vacation\nPlanner") .font(.custom("Baskerville-SemiBoldItalic", size: 60)) .foregroundColor(Color.black.opacity(0.8)) .lineLimit(nil) .multilineTextAlignment(.center) .padding(.horizontal, 40) Spacer().frame(height:80) TextField("Email", text: $email) .submitLabel(.next) .textContentType(.emailAddress) .keyboardType(.emailAddress) Spacer().frame(height:30) SecureField("Password", text: $password) .submitLabel(.go) .textContentType(.password) HStack { Rectangle().frame(height: 1) Text("or").bold().padding() Rectangle().frame(height: 1) } .foregroundColor(Color.black.opacity(0.7)) Spacer().frame(height: 20) SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.fullName, .email] } onCompletion: { result in switch result { case .success (_): print("Authorization successful.") case .failure (let error): print("Authorization failed: " + error.localizedDescription) } } .frame(height: 50) Spacer() } .frame(width: 350, alignment: .center) VStack { Image(photoName) .resizable() .frame(width: 1400) .aspectRatio(contentMode: .fit) .ignoresSafeArea(edges: [.trailing]) BrowsePhotosButton() } }.preferredColorScheme(.light) } }
-
9:47 - focus section 1
import SwiftUI import AuthenticationServices struct ContentView: View { @State private var email: String = "" @State private var password: String = "" var body: some View { HStack { VStack(alignment: .leading) { Spacer(minLength:60).frame(height: 150) Text("Vacation\nPlanner") .font(.custom("Baskerville-SemiBoldItalic", size: 60)) .foregroundColor(Color.black.opacity(0.8)) .lineLimit(nil) .multilineTextAlignment(.center) .padding(.horizontal, 40) Spacer().frame(height:80) TextField("Email", text: $email) .submitLabel(.next) .textContentType(.emailAddress) .keyboardType(.emailAddress) Spacer().frame(height:30) SecureField("Password", text: $password) .submitLabel(.go) .textContentType(.password) HStack { Rectangle().frame(height: 1) Text("or").bold().padding() Rectangle().frame(height: 1) } .foregroundColor(Color.black.opacity(0.7)) Spacer().frame(height: 20) SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.fullName, .email] } onCompletion: { result in switch result { case .success (_): print("Authorization successful.") case .failure (let error): print("Authorization failed: " + error.localizedDescription) } } .frame(height: 50) Spacer() } .frame(width: 350, alignment: .center) VStack { Image(photoName) .resizable() .frame(width: 1400) .aspectRatio(contentMode: .fit) .ignoresSafeArea(edges: [.trailing]) BrowsePhotosButton() } .focusSection() }.preferredColorScheme(.light) } }
-
10:06 - focus section 2
import SwiftUI import AuthenticationServices struct ContentView: View { @State private var email: String = "" @State private var password: String = "" var body: some View { HStack { VStack(alignment: .leading) { Spacer(minLength:60).frame(height: 150) Text("Vacation\nPlanner") .font(.custom("Baskerville-SemiBoldItalic", size: 60)) .foregroundColor(Color.black.opacity(0.8)) .lineLimit(nil) .multilineTextAlignment(.center) .padding(.horizontal, 40) Spacer().frame(height:80) TextField("Email", text: $email) .submitLabel(.next) .textContentType(.emailAddress) .keyboardType(.emailAddress) Spacer().frame(height:30) SecureField("Password", text: $password) .submitLabel(.go) .textContentType(.password) HStack { Rectangle().frame(height: 1) Text("or").bold().padding() Rectangle().frame(height: 1) } .foregroundColor(Color.black.opacity(0.7)) Spacer().frame(height: 20) SignInWithAppleButton(.signIn) { request in request.requestedScopes = [.fullName, .email] } onCompletion: { result in switch result { case .success (_): print("Authorization successful.") case .failure (let error): print("Authorization failed: " + error.localizedDescription) } } .frame(height: 50) Spacer() } .frame(width: 350, alignment: .center) .focusSection() VStack { Image(photoName) .resizable() .frame(width: 1400) .aspectRatio(contentMode: .fit) .ignoresSafeArea(edges: [.trailing]) BrowsePhotosButton() } .focusSection() }.preferredColorScheme(.light) } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.