스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
SwiftUI 초점 요리책
SwiftUI 팀이 여러분의 앱의 초점 경험을 만들어 줄 강력한 도구를 들고 코딩 '부엌'으로 돌아왔습니다. 저희와 함께 앱에서 초점 중심 상호 작용을 지원하는 주재료에 대해 알아보세요. 사용자 설정 뷰에서의 초점 상호 작용, 키보드로 입력할 때 키 누름을 처리하는 핸들러, 초점 섹션을 이용해 움직임과 계층 구조를 지원하는 법을 배워 보세요. 앱에서 흔히 쓰이는 초점 패턴을 만드는 맛있는 레시피도 소개할 겁니다.
챕터
- 1:44 - What is focus
- 3:18 - Ingredients
- 3:35 - Ingredients: Focusable views
- 6:04 - Ingredients: Focus state
- 7:03 - Ingredients: Focused values
- 8:54 - Ingredients: Focused sections
- 10:45 - Recipes
- 11:21 - Recipes: Controlling focus
- 14:36 - Recipes: Custom focusable control
- 18:04 - Recipes: Grid view
리소스
관련 비디오
WWDC23
-
다운로드
♪ ♪
'SwiftUI 초점 요리책'에 오신 것을 환영합니다 저는 코디라고 하고 오늘 저는 SwiftUI의 초점 API를 이용해 근사한 사용자 경험을 요리하는 방법을 설명하려고 합니다 이 영상에서 제가 대접하는 3코스 요리에서는 맛있는 API 디테일로 구성된 고정 메뉴를 훌륭한 코드 예시들과 페어링했답니다 애피타이저 격으로 초점의 기초 원리 얘기를 좀 하겠습니다 초점은 무엇이고 어떤 기능을 할까요? 첫째 코스에서는 초점 경험을 구성하는 재료를 보며 입맛을 돋워 보세요 재료를 다 보여드린 다음 본격적으로 요리를 시작하겠습니다 메인 코스에서는 초점의 외관을 제어하고 초점의 움직임을 관찰하며 사용자 지정 컨트롤을 이용해 키보드 입력에 반응하게 하는 레시피를 자세히 살펴보겠습니다 자, 초점이란 무엇일까요? 초점이란 사용자의 행동에 대한 반응을 결정하는 도구입니다 키보드의 키를 누르거나 Apple TV 리모컨을 쓸어넘기거나 Apple Watch의 Digital Crown을 켜는 것 같은 행동 말이죠 이런 입력 방법에는 중요한 공통점이 있는데 입력이 화면의 어느 컨트롤을 겨냥하는지에 대한 정보를 입력 방법만 봐서는 제대로 알 수 없다는 겁니다 마우스, 트랙패드 혹은 터치스크린 등과 비교해 봅시다 마우스나 트랙패드를 사용하면 화면에 뜬 커서가 클릭된 부분을 화면 좌표와 연결하고 시스템은 그 좌표를 이용해 상호 작용의 타깃을 찾죠 초점은 포인터 커서 없이도 시스템이 입력의 방향을 잡는 데 필요한 정보를 추가로 제공합니다 뷰에 초점이 있으면 시스템은 초점을 출발점 삼아 키보드, Apple TV 리모컨 Apple Watch Digital Crown의 입력에 반응합니다
초점은 구현 단계 이후에도 신경 써야 할 사항입니다 앱 사용자에게도 마찬가지로 중요하죠 그래서 초점 뷰를 특별히 강조해서 표현하는 겁니다 macOS는 초점 뷰에 자동으로 테두리를 추가하여 이 뷰가 키보드 입력을 받을 것임을 나타냅니다 watchOS는 컨트롤에 녹색 테두리를 둘러서 Digital Crown을 돌리면 컨트롤값을 바꿀 수 있다고 신호를 보냅니다 그리고 tvOS는 초점 뷰에 호버 이펙트를 적용하여 다른 컨트롤 평면 위로 띄웁니다 초점 뷰를 강조하면 사용자에게 도움이 됩니다 사용자는 타자를 치거나 리모컨을 쓸어넘길 때 입력이 어디에 이뤄지는지 예측할 수 있고 레이아웃이 복잡하거나 상세해도 자신이 앱의 어떤 부분과 상호 작용하는지 한눈에 알 수 있으니까요 초점은 특수한 커서와 비슷하게 동작합니다 마우스 커서처럼 화면의 한 지점을 탐지하는 대신 UI의 어느 부분이 초점 입력의 타깃인지를 탐지하는 거죠 그래서 저는 초점이 사용자의 주의를 끄는 커서라고 생각합니다 초점이 무엇이고 앱에서 어떻게 보이는지 조금은 알게 되셨으니 이제 첫 번째 요리를 차릴 수 있겠군요 모든 앱의 초점 경험에 들어가는 기본 재료를 살펴봅시다 초점이 맞는 뷰, 초점 상태 초점값, 초점 섹션입니다 초점으로 요리할 때 고려해야 할 주재료는 바로 초점 뷰 자체입니다 시스템은 초점 뷰를 출발점으로 삼아 초점 입력에 반응하기 때문입니다 다양한 상황에서 다양한 이유로 다양한 컨트롤에 초점을 맞출 수 있습니다 macOS와 iPadOS의 텍스트 필드와 버튼을 비교해 보면 텍스트 필드에는 항상 초점을 맞출 수 있습니다 필드를 탭하거나 탭 키를 눌러 다른 컨트롤에서 초점을 옮겨올 수 있죠 이런 컨트롤은 편집할 때 초점을 지원합니다 연속적인 초점 입력을 포착해야 하니까요
버튼은 다릅니다 버튼의 역할은 클릭과 탭을 처리하는 겁니다 macOS와 iPadOS는 버튼을 탭해도 버튼에 초점을 맞추지 않습니다 탭 키로 버튼을 가리키려면 시스템 전체에서 키보드 탐색을 켜야만 하죠 이 설정을 잘 모르신다면 macOS 시스템 설정의 키보드 설정으로 들어가세요 '키보드 탐색'이라는 스위치가 있는데 그 스위치를 켜면 탭 키를 눌러서 버튼에 포커스를 맞추고 스페이스 바를 눌러 활성화할 수 있습니다
버튼은 활성화 때 초점을 지원합니다 이런 컨트롤은 초점이 없어도 작동할 수 있지만 시스템이 허용한다면 초점을 사용합니다 클릭과 탭을 대신하는 초점 중심 입력을 지원하기 위해서죠 iOS 17과 macOS Sonoma에 새로 생긴 사용자 지정 컨트롤을 이용해 포커스 시스템에 참여할 수 있습니다 '초점이 맞는' 뷰 수정자를 적용하면 그 결과로 나타나는 동작을 세밀하게 조정할 수 있습니다 컨트롤이 어떤 초점 상호 작용을 지원하는지 명시해서요 시간에 따른 상태를 업데이트하려 초점을 사용하는 컨트롤에는 edit 상호 작용을 명시하면 됩니다 포인터를 이용한 직접 활성화를 대신하기 위해 초점을 사용하는 컨트롤에는 activate 상호 작용을 명시하면 되죠
인수를 전혀 제시하지 않으면 시스템은 모든 상호 작용에 컨트롤 초점을 맞춥니다 macOS Sonoma 이전에는 초점 가능 수정자가 활성화 시맨틱만을 지원했습니다 macOS 코드에 초점 가능 수정자를 이미 쓰고 계신다면 바뀐 동작이 여러분의 사용 예에 부합하는지 확인하세요 interactions 인수를 추가해 코드를 업데이트해야 할 수도 있습니다 다음 재료는 시시각각 바뀌는 초점 시스템의 상태와 관련이 있습니다 재료 이름도 마침 FocusState네요 이 시스템은 어느 뷰에 초점이 맞춰졌는지를 탐지하는데 앱은 그 정보를 앱 로직에 활용하여 입력을 어떻게 처리하고 뷰를 어떻게 스타일링할지 결정합니다 시스템 상태를 관찰하려면 바인딩을 만들어 여러분이 제시하는 값을 특정 뷰에 맞춰진 초점과 연결 짓습니다 뷰는 이 바인딩을 읽음으로써 초점이 바뀔 때 알림을 받습니다 예컨대 특정 뷰에 초점이 맞거나 포커스가 해제될 때 말이죠 불리언 값이 있는 포커스 상태 프로퍼티는 초점이 맞는 뷰가 단 하나인지를 알려줍니다 여기 보이시죠 더 복잡한 경우에는 사용자 설정 데이터 유형을 사용해도 됩니다 나중에 이런 사례를 설명하면서 프로그램 차원에서 초점 상태를 바꾸는 법을 보여드리겠습니다
다음은 초점값 API입니다 초점값 API는 사용자 인터페이스에서 멀리 떨어진 부분들을 서로 연결하는 데이터 종속성을 빌드하는 문제를 해결합니다 이 API로 앱의 커맨드를 업데이트하세요 활성화된 장면에서 벌어지는 일을 토대로요 초점값 덕에 엘리먼트 사이에 데이터가 흐를 수 있게 됩니다 전 초점값 하나를 사용자 정의해서 그걸로 주 메뉴 콘텐츠를 만들겠습니다 초점값을 만들고 사용하는 건 맞춤형 환경 키와 객체를 만들고 사용하는 것과 비슷합니다 FocusedValueKey 프로토콜로 새 키를 정의한 다음 연산된 프로퍼티로 FocusedValue를 확장합니다 이 프로퍼티는 새 키를 사용해 값을 얻고 설정하죠 사용하는 데이터는 장면의 뷰에서 얻는데 이 데이터는 값, 바인딩 또는 관찰 가능한 객체일 수도 있습니다 어쨌든 일군의 뷰 수정자를 사용해 데이터를 연결 짓습니다 초점은 뷰 계층 구조의 그 부분에 두고요 환경값과 마찬가지로 초점값에 액세스하려면 동적 프로퍼티를 선언해야 합니다 이 예시에서 제가 쓴 초점값은 바인딩이므로 저는 @FocusedBinding 프로퍼티 래퍼를 사용하고 거기에 제가 사용자 설정한 키 패스를 제시합니다 @FocusedBinding은 초점 뷰와 초점 뷰의 선조들을 검토해 현재 이 키와 연결된 바인딩이 있는지를 알아봅니다 프로퍼티 래퍼가 바인딩을 자동으로 언래핑하므로 전 바인딩된 값을 대상으로 직접 작업할 수 있습니다 한 가지만 더 하면 되죠 뷰의 본문에 새 프로퍼티를 사용하는 겁니다 시간이 지나며 초점이 여러 컨트롤 사이를 옮겨 다니고 여러 창이 활성화되면 시스템은 뷰를 업데이트해 새로운 콘텍스트에서 찾은 값을 반영할 것입니다 마지막 재료는 초점 섹션 API입니다 초점 섹션을 이용하면 Apple TV 리모컨을 쓸어넘기거나 키보드의 탭 키를 누를 때 초점의 움직임에 영향을 미칠 수 있습니다 기본값에 따르면 초점은 가장 위에 있으면서 화면 앞쪽 가장자리와 가장 가까운 컨트롤에 먼저 주어집니다 거기서 탭 키를 누르면 초점이 다음 컨트롤로 넘어가는데 순서는 현재 로케일의 레이아웃 순서를 따라갑니다 화면의 마지막 컨트롤에 다다른 뒤 또 탭 키를 누르면 이 과정이 되풀이됩니다 Apple TV 리모컨의 초점은 방향성을 띠고 움직입니다 위, 아래 왼쪽, 오른쪽으로 쓸어넘겨서 초점을 다른 컨트롤로 넘길 수 있다는 겁니다 방향성 있는 움직임은 인접한 타깃 사이에서만 이뤄지죠 이 경우, 오른쪽으로 쓸어넘기면 크렘브륄레 버튼에서 다른 디저트로 넘어갈 수 있습니다 하지만 크렘브륄레 장식을 장보기 목록에 넣고 싶어도 아래로 쓸어넘겨서 넣을 수가 없습니다 그 버튼이 크렘브륄레 버튼 바로 아래 있지 않아서 제스처가 먹히지 않거든요 초점 타깃을 정렬해야 하니 아래쪽 버튼의 컨테이너를 초점 섹션으로 마크하겠습니다 초점 섹션은 움직임 제스처의 타깃이 되지만 거기에 초점을 맞추게 할 수는 없습니다 대신 초점 섹션은 가장 가까이 있으면서 초점을 맞출 수 있는 콘텐츠로 초점을 인도합니다 초점 섹션이 효과적으로 작동하려면 섹션이 섹션 콘텐츠보다 자리를 더 많이 차지해야 합니다 이 경우 저는 버튼 앞뒤에 스페이서를 추가하여 스택이 화면 너비를 꽉 채우도록 스택을 키우겠습니다 초점 타깃이 더 커졌으니 이제는 어디에서 아래로 쓸어넘겨도 아래 버튼에 다다를 수 있습니다 벌써 크렘브륄레 맛이 느껴지네요
이제 제가 방금 설명한 주재료를 조합한 레시피를 자세히 소개하겠습니다 이걸 이용해 사용자 지정 컨트롤의 룩 앤드 필을 다듬고 흔히 수행되는 태스크에서 마찰을 줄일 수 있답니다 요즘 전 동료 요리사인 커트가 만든 요리책 앱을 씁니다 커트를 WWDC22 영상에서 이미 보셨을지도 모르겠군요 이 섹션의 레시피는 제가 요즘 작업하고 있는 새 기능을 바탕으로 하는데 초점 동작을 신경 쓰면 기능에 도움이 될 겁니다 한 예로, 저는 앱 내에 장보기 목록을 추가했습니다 다음에 식료품점에 갈 때 뭘 살지 잘 기억하려고요 첫째 레시피는 프로그램 차원의 초점 움직임을 살짝 첨가하면 장보기 목록을 편집하는 경험이 즐거워질 수 있다는 걸 보여줍니다 장보기 목록 시트 끄트머리에는 늘 빈 아이템이 있습니다 빈 아이템을 탭하면 키보드가 떠서 제가 뭘 사야 하는지 적을 수 있죠 살 거리를 자주 추가하다 보니 탭하는 횟수를 줄이기 위해 목록이 뜰 때마다 빈 아이템에 자동으로 초점을 맞추고 싶습니다 앞에서 초점 상태 API를 이용해 어느 뷰에 초점이 맞는지 관찰하고 업데이트하는 법을 설명했었죠 같은 API를 여기에도 쓰겠습니다 이전 예시에서는 플래그를 이용해 단 하나의 뷰에만 초점이 맞았는지 아닌지 신호를 보냈습니다 이 장보기 목록에는 관찰해야 할 텍스트 필드가 너무 많습니다 이런 경우 해시 가능한 타입이라면 모두 FocusState의 값이 될 수 있습니다 제가 이 화면에 추가하는 재료에는 각각 고유 ID가 있으므로 초점이 맞은 텍스트 필드와 연결된 ID를 저장하면 초점을 추적할 수 있습니다 focused(_:equals:) 수정자를 사용해 각 텍스트 필드와 재료를 연결하겠습니다 이 수정자에 제시해야 할 인수는 두 가지인데 focusedItem 프로퍼티에 대한 바인딩과 재료의 ID입니다 초점이 텍스트 필드에 맞을 때 재료의 ID로 바인딩을 업데이트해야 하죠 이제 앱을 실행하면 장보기 목록 주위를 탭할 때 focusedItem 프로퍼티가 다양한 ID값으로 업데이트된다는 걸 확인할 수 있습니다
초점 상태 바인딩을 다 만들었으니 장보기 목록이 처음 화면에 뜰 때 프로그램 차원에서 초점을 텍스트 필드로 옮기는 데 필요한 건 모두 갖췄습니다 그러려면 defaultFocus(_:_:) 뷰 수정자를 목록에 추가하면 됩니다 이 수정자는 이제 iOS 17에서도 쓸 수 있습니다 시스템은 이 화면에서 처음으로 초점을 실행할 때 장보기 목록 마지막 아이템의 ID로 바인딩을 업데이트하려 할 겁니다
수정이 끝났으니 이젠 장보기 목록에 새 항목을 추가할 때 두 단계만 거치면 됩니다 도구 막대 버튼을 눌러 시트를 띄운 다음 타자만 치면 끝입니다 세 번째 단계는 없어요 장보기 목록이 불어나면서 깨달았는데 도구 막대의 더하기 버튼을 탭하면 목록에 빈 아이템이 새로 생기지만 초점은 그 자리에 그대로 있더군요 초점을 맞추려면 빈 아이템을 탭해야 하죠 이 경우에도 전 프로그램 차원에서 앱이 초점을 옮기게 하고 싶습니다 그래야 새 아이템이 나타날 때 곧바로 타자를 칠 수 있으니까요 아까와 다른 점은 변화 시점을 이제 제가 제어하고 싶다는 겁니다 다행히도 전에 만든 것과 같은 초점 상태 바인딩을 사용해 기본 초점을 설정할 수 있군요 GroceryListView에는 addEmptyItem 메서드가 있어서 모델에 새 아이템을 추가할 수 있습니다 이미 새 아이템의 TextField를 currentItemID 프로퍼티와 연결했기 때문에 도구 모음 버튼 동작의 일부로서 프로퍼티를 새 ID로 업데이트하기만 하면 됩니다
완성됐습니다! 이제는 장보기 목록을 새로 만들거나 업데이트할 때 초점을 맞춰야 할 곳을 굳이 탭할 필요 없이 그냥 타자만 치면 됩니다
다음으로, 재료를 좀 더 많이 써서 제가 만든 사용자 지정 컨트롤의 초점 상호 작용을 개선하겠습니다 지금까지 많은 레시피를 저장했는데 시도해 본 레시피 중 어떤 레시피의 결과가 좋았는지 어떤 레시피를 보완해야 할지 소금이라도 더 쳐야 할지 기억하고 싶습니다 기억을 돕기 위해 이모티콘을 넣은 사용자 설정 피커 컨트롤을 만들어 제 요리 여정에서 좋았던 순간과 나빴던 순간을 기록하려 합니다 이모티콘을 탭해서 각 레시피에 점수를 매길 수도 있지만 저는 평소에 키보드 탐색을 주로 하다 보니 탭 키로 컨트롤에 초점을 맞출 수 있고 화살표 키로 선택 영역을 바꿀 수 있으면 좋겠습니다 그렇게 해 봅시다 이모티콘 피커의 기본 구조는 이렇습니다 우선 컨트롤에 초점이 맞게 해야 하니 일단 인수 없이 focusable 수정자를 추가합니다 이러면 탭 키를 누를 때 컨트롤에 초점을 맞출 수 있죠 그런데 다른 버튼이나 비슷한 컨트롤에는 없는 동작이 눈에 띄는군요 예를 들어 제 컨트롤은 클릭할 때 초점이 맞지만 버튼이나 세그먼트 컨트롤은 그렇지 않습니다 이런 컨트롤에 초점을 맞추려면 키보드 탐색을 해야 합니다 제 컨트롤도 그래야죠 그렇게 동작시키기 위해 컨트롤을 활성화할 때 초점을 맞출 수 있다고 명시하겠습니다 활성화할 때 초점이 맞는 컨트롤은 클릭할 때 초점이 맞지 않고 '키보드 검색'을 켜서 키보드로 초점을 맞춰 줘야 합니다 다음으로 눈에 띄는 점은 macOS가 제 컨트롤에 두르는 초점 테두리가 네모라는 겁니다 생김새를 세련되게 다듬으려면 초점 테두리가 캡슐 모양 배경의 형태대로 그려지면 좋겠습니다 초점 테두리는 항상 뷰의 콘텐츠 모양을 따라가는데 제 경우 콘텐츠 모양의 기본값은 네모입니다 contentShape 수정자를 사용하고 지금 사용한 것과 같은 캡슐 모양을 제출하여 뷰의 형태를 깎아내겠습니다 이제 컨트롤에 초점이 맞게 됐으니 다음 단계는 컨트롤이 키 누름을 처리하게 하는 겁니다 왼쪽 및 오른쪽 화살표 키를 눌러 선택한 점수를 바꿀 수 있으면 좋겠습니다 onMoveCommand 수정자를 사용하면 플랫폼에 적합한 움직임 커맨드에 반응하여 수행할 동작을 제시할 수 있습니다 이런 커맨드는 Mac 키보드의 화살표 키를 누르거나 Apple TV 리모컨에서 방향 표시를 탭하는 것 등등을 가리킵니다 시스템은 움직임의 방향으로 동작을 호출하므로 저는 선택한 점수를 왼쪽이나 오른쪽으로 옮기겠습니다 아랍어나 히브리어처럼 오른쪽에서 왼쪽으로 쓰는 언어를 사용한다면 컨트롤 콘텐츠가 수평으로 뒤집혀야 하는데 그러려면 움직임 커맨드 동작이 반드시 환경의 layoutDirection을 사용하도록 해야 합니다 초점 동작을 구현할 때 근사한 점 중 하나는 Apple Watch 앱에서도 똑같은 컨트롤을 사용해 좋은 성과를 거둘 수 있다는 겁니다 watchOS에서 초점 입력을 처리하려면 digitalCrownRotation 수정자를 onMoveCommand 수정자 대신 씁니다 그리고 isFocused 환경값을 사용해 컨트롤에 초점이 맞을 때 익숙한 녹색 테두리가 컨트롤 주위에 그려지게 합니다
몇 가지 수정자만 가지고도 저는 단순한 컨트롤에 키보드 및 Digital Crown 지원을 추가할 수 있었습니다 마지막은 초점이 맞는 그리드 뷰를 만드는 레시피인데 이건 제가 만든 결과물의 사진을 전시하려고 빌드한 겁니다 이 그리드는 지연 그리드이고 선택 동작 몇 가지는 이미 구현했습니다 이미지를 클릭하면 이미지가 선택되고 더블클릭하면 레시피의 디테일 뷰가 뜨죠 이젠 그리드가 초점 상호 작용을 어떻게 처리하도록 할지 생각해야 합니다 구체적으로 말해 탭 키를 누르면 그리드에 초점이 맞게 하고 싶습니다 초점이 맞으면 화살표 키로 선택 영역을 업데이트하고 싶고 리턴 키를 누르면 선택한 레시피의 디테일이 나왔으면 합니다 앞서 설명한 재료 몇 가지와 다른 재료 몇 가지를 사용해 키 누름을 처리하는 걸 돕고 그리드에 초점이 맞을 때의 모습을 사용자화하겠습니다 앞의 예시와 마찬가지로 먼저 그리드에 초점이 맞도록 해야 합니다 이 경우에는 어떤 상호 작용도 명시할 필요가 없습니다 그리드를 클릭하거나 키보드 탭 키를 누르면 그리드에 초점이 맞는 게 기본값이거든요 '키보드 탐색'을 활성화하지 않았어도요 제가 원하는 게 바로 이겁니다 그리드에 초점을 맞췄으니 시스템은 그리드에 자동으로 초점 테두리를 두를 겁니다 선택 가능한 콘텐츠의 컨테이너엔 굳이 이런 효과가 없어도 됩니다 선택한 레시피에 제가 추가한 색깔 있는 테두리가 이미 그리드에 초점이 맞았는지를 보여주니까요 focusEffectDisabled 수정자를 사용하면 자동으로 생기는 초점 테두리를 끌 수 있습니다 SelectionShapeStyle을 사용해 뷰가 선택됐다는 걸 나타내는 테두리나 다른 인디케이터를 만드세요 색은 제가 선택한 악센트 컬러로 자동으로 변하고 어떤 선조 뷰에도 초점이 없으면 색이 회색으로 변합니다 초점이 그리드에서 사이드바로 옮겨갈 때처럼요 다음으로는 주 메뉴 커맨드를 연결해 선택한 레시피를 즐겨찾기로 표시하겠습니다 이 작업에는 초점값 API를 사용하고 선택에 대한 바인딩을 제출해 필요할 때마다 메뉴 커맨드가 업데이트되게 하겠습니다 화살표 키로 선택할 수 있도록 onMoveCommand 수정자를 사용하고 시스템에서 호출이 들어오면 움직임의 방향을 사용해 그리드에서 선택된 레시피를 업데이트하겠습니다 마지막으로 저는 리턴 키를 눌렀을 때 선택 영역으로 넘어가 탐색을 할 수 있었으면 합니다 onKeyPress 수정자를 사용하면 되는데 이 수정자는 macOS Sonoma와 iOS 17에 새로 생긴 겁니다 연결된 하드웨어 키보드에서 키 중 하나를 누르면 이 수정자는 키들 또는 문자들과 수행할 동작 하나를 취합니다 동작이 키 누름을 처리하지 못하면 ignored를 반환합니다 그러면 뷰 계층 구조를 따라 디스패치가 계속 올라갈 겁니다 이 기능은 보너스인데 저는 onKeyPress도 사용해 유형 선택을 구현하겠습니다 그러면 레시피 이름의 첫 글자만 쳐서 스크롤을 빨리 넘겨 레시피를 선택할 수 있으니까요
macOS에서 그리드를 쾌적하게 쓰는 키보드 경험을 만들어 냈으니 이번엔 tvOS의 그리드로 넘어갑시다 tvOS에서는 그리드의 셀 각각에 초점을 맞출 수 있습니다 그래서 리모컨으로 초점을 여러 방향으로 옮기면 그 방향에 있는 셀에 초점이 맞고 그 셀이 다른 셀보다 위로 들어올려집니다 시스템은 Button과 NavigationLink에 들어 올리기 호버 이펙트를 기본값으로 적용하는데 이 효과는 텍스트가 있는 뷰나 텍스트와 이미지를 조합한 뷰에 적합합니다 하지만 이 레시피 사진에는 다른 효과를 적용하는 게 낫겠습니다 이건 tvOS 17의 새 기능인데 초점이 맞는 뷰에 하이라이트 호버 이펙트를 적용할 수 있습니다 이 효과를 적용하면 리모컨을 쓸어넘길 때 초점이 맞은 아이템의 시점이 변하고 거울같이 반짝여서 미술 작품이나 제 레시피 섬네일 같은 사진과 아주 잘 어울리죠 덤으로 tvOS 앱에 초점 섹션도 넣겠습니다 그리드는 버튼 목록 옆에 있는데 전 그리드와 버튼 사이를 자주 왔다 갔다 해야 할 겁니다 앱을 사용하던 중에 흔한 문제가 제 눈에 띕니다 초점이 그리드의 아래쪽 열에 맞으면 왼쪽으로 쓸어넘겨도 분류 버튼으로 넘어가지 못합니다 초점 타깃이 인접해 있지 않으니까요 그래서 분류 목록을 높이가 레이아웃 전체 높이와 같은 초점 섹션 안에 넣겠습니다 이러면 크렘브륄레에서 왼쪽으로 쓸어넘길 때 초점이 분류로 이동하죠 제가 예상했던 대로요 이로써 그리드는 완성입니다
아름답네요 이 영상에서 많은 내용을 다뤘는데 이제는 여러분이 초점 관련 재료를 모아 뭘 만들 수 있는지 시도할 차례입니다 키보드 탐색을 활성화한 다음 macOS와 iPadOS 앱을 테스트해 보세요 기본 초점은 가장 쓸모 있는 곳에 두시고요 컨트롤은 초점 섹션 안에 정리해서 불규칙한 레이아웃에서 움직임이 매끄러워지게 하세요 감사합니다 맛있게 드세요
-
-
5:05 - Focusable views
// Focusable views struct RecipeGrid: View { var body: some View { LazyVGrid(columns: [GridItem(), GridItem()]) { ForEach(0..<4) { _ in Capsule() } } .focusable(interactions: .edit) } } struct RatingPicker: View { var body: some View { HStack { Capsule() ; Capsule() } .focusable(interactions: .activate) } }
-
6:12 - Focus state
// Focus state struct GroceryListView: View { @FocusState private var isItemFocused @State private var itemName = "" var body: some View { TextField("Item Name", text: $itemName) .focused($isItemFocused) Button("Done") { isItemFocused = false } .disabled(!isItemFocused) } }
-
7:32 - Focused values
// Focused values struct SelectedRecipeKey: FocusedValueKey { typealias Value = Binding<Recipe> } extension FocusedValues { var selectedRecipe: Binding<Recipe>? { get { self[SelectedRecipeKey.self] } set { self[SelectedRecipeKey.self] = newValue } } } struct RecipeView: View { @Binding var recipe: Recipe var body: some View { VStack { Text(recipe.title) } .focusedSceneValue(\.selectedRecipe, $recipe) } } struct RecipeCommands: Commands { @FocusedBinding(\.selectedRecipe) private var selectedRecipe: Recipe? var body: some Commands { CommandMenu("Recipe") { Button("Add to Grocery List") { if let selectedRecipe { addRecipe(selectedRecipe) } } .disabled(selectedRecipe == nil) } } private func addRecipe(_ recipe: Recipe) { /* ... */ } } struct Recipe: Hashable, Identifiable { let id = UUID() var title = "" var isFavorite = false }
-
10:03 - Focus sections
// Focus sections struct ContentView: View { @State private var favorites = Recipe.examples @State private var selection = Recipe.examples.first! var body: some View { VStack { HStack { ForEach(favorites) { recipe in Button(recipe.name) { selection = recipe } } } Image(selection.imageName) HStack { Spacer() Button("Add to Grocery List") { addIngredients(selection) } Spacer() } .focusSection() } } private func addIngredients(_ recipe: Recipe) { /* ... */ } } struct Recipe: Hashable, Identifiable { static let examples: [Recipe] = [ Recipe(name: "Apple Pie"), Recipe(name: "Baklava"), Recipe(name: "Crème Brûlée") ] let id = UUID() var name = "" var imageName = "" }
-
11:29 - Controlling focus
struct GroceryListView: View { @State private var list = GroceryList.examples @FocusState private var focusedItem: GroceryList.Item.ID? var body: some View { NavigationStack { List($list.items) { $item in HStack { Toggle("Obtained", isOn: $item.isObtained) TextField("Item Name", text: $item.name) .onSubmit { addEmptyItem() } .focused($focusedItem, equals: item.id) } } .defaultFocus($focusedItem, list.items.last?.id) .toggleStyle(.checklist) } .toolbar { Button(action: addEmptyItem) { Label("New Item", systemImage: "plus") } } } private func addEmptyItem() { let newItem = list.addItem() focusedItem = newItem.id } } struct GroceryList: Codable { static let examples = GroceryList(items: [ GroceryList.Item(name: "Apples"), GroceryList.Item(name: "Lasagna"), GroceryList.Item(name: "") ]) struct Item: Codable, Hashable, Identifiable { var id = UUID() var name: String var isObtained: Bool = false } var items: [Item] = [] mutating func addItem() -> Item { let item = GroceryList.Item(name: "") items.append(item) return item } } struct ChecklistToggleStyle: ToggleStyle { func makeBody(configuration: Configuration) -> some View { Button { configuration.isOn.toggle() } label: { Image(systemName: configuration.isOn ? "checkmark.circle.fill" : "circle.dashed") .foregroundStyle(configuration.isOn ? .green : .gray) .font(.system(size: 20)) .contentTransition(.symbolEffect) .animation(.linear, value: configuration.isOn) } .buttonStyle(.plain) .contentShape(.circle) } } extension ToggleStyle where Self == ChecklistToggleStyle { static var checklist: ChecklistToggleStyle { .init() } }
-
15:25 - Custom focusable control
struct RatingPicker: View { @Environment(\.layoutDirection) private var layoutDirection @Binding var rating: Rating? #if os(watchOS) @State private var digitalCrownRotation = 0.0 #endif var body: some View { EmojiContainer { ratingOptions } .contentShape(.capsule) .focusable(interactions: .activate) #if os(macOS) .onMoveCommand { direction in selectRating(direction, layoutDirection: layoutDirection) } #endif #if os(watchOS) .digitalCrownRotation($digitalCrownRotation, from: 0, through: Double(Rating.allCases.count - 1), by: 1, sensitivity: .low) .onChange(of: digitalCrownRotation) { oldValue, newValue in if let rating = Rating(rawValue: Int(round(digitalCrownRotation))) { self.rating = rating } } #endif } private var ratingOptions: some View { ForEach(Rating.allCases) { rating in EmojiView(rating: rating, isSelected: self.rating == rating) { self.rating = rating } } } #if os(macOS) private func selectRating( _ direction: MoveCommandDirection, layoutDirection: LayoutDirection ) { var direction = direction if layoutDirection == .rightToLeft { switch direction { case .left: direction = .right case .right: direction = .left default: break } } if let rating { switch direction { case .left: guard let previousRating = rating.previous else { return } self.rating = previousRating case .right: guard let nextRating = rating.next else { return } self.rating = nextRating default: break } } } #endif } private struct EmojiContainer<Content: View>: View { @Environment(\.isFocused) private var isFocused private var content: Content #if os(watchOS) private var strokeColor: Color { isFocused ? .green : .clear } #endif init(@ViewBuilder content: @escaping () -> Content) { self.content = content() } var body: some View { HStack(spacing: 2) { content } .frame(height: 32) .font(.system(size: 24)) .padding(.horizontal, 8) .padding(.vertical, 6) .background(.quaternary) .clipShape(.capsule) #if os(watchOS) .overlay( Capsule() .strokeBorder(strokeColor, lineWidth: 1.5) ) #endif } } private struct EmojiView: View { var rating: Rating var isSelected: Bool var action: () -> Void var body: some View { ZStack { Circle() .fill(isSelected ? Color.accentColor : Color.clear) Text(verbatim: rating.emoji) .onTapGesture { action() } .accessibilityLabel(rating.localizedName) } } } enum Rating: Int, CaseIterable, Identifiable { case meh case yummy case delicious var id: RawValue { rawValue } var emoji: String { switch self { case .meh: return "😕" case .yummy: return "🙂" case .delicious: return "🥰" } } var localizedName: LocalizedStringKey { switch self { case .meh: return "Meh" case .yummy: return "Yummy" case .delicious: return "Delicious" } } var previous: Rating? { let ratings = Rating.allCases let index = ratings.firstIndex(of: self)! guard index != ratings.startIndex else { return nil } let previousIndex = ratings.index(before: index) return ratings[previousIndex] } var next: Rating? { let ratings = Rating.allCases let index = ratings.firstIndex(of: self)! let nextIndex = ratings.index(after: index) guard nextIndex != ratings.endIndex else { return nil } return ratings[nextIndex] } }
-
18:50 - Grid view
struct ContentView: View { @State private var recipes = Recipe.examples @State private var selection: Recipe.ID = Recipe.examples.first!.id @Environment(\.layoutDirection) private var layoutDirection var body: some View { LazyVGrid(columns: columns) { ForEach(recipes) { recipe in RecipeTile(recipe: recipe, isSelected: recipe.id == selection) .id(recipe.id) #if os(macOS) .onTapGesture { selection = recipe.id } .simultaneousGesture(TapGesture(count: 2).onEnded { navigateToRecipe(id: recipe.id) }) #else .onTapGesture { navigateToRecipe(id: recipe.id) } #endif } } .focusable() .focusEffectDisabled() .focusedValue(\.selectedRecipe, $selection) .onMoveCommand { direction in selectRecipe(direction, layoutDirection: layoutDirection) } .onKeyPress(.return) { navigateToRecipe(id: selection) return .handled } .onKeyPress(characters: .alphanumerics, phases: .down) { keyPress in selectRecipe(matching: keyPress.characters) } } private var columns: [GridItem] { [ GridItem(.adaptive(minimum: RecipeTile.size), spacing: 0) ] } private func navigateToRecipe(id: Recipe.ID) { // ... } private func selectRecipe( _ direction: MoveCommandDirection, layoutDirection: LayoutDirection ) { // ... } private func selectRecipe(matching characters: String) -> KeyPress.Result { // ... return .handled } } struct RecipeTile: View { static let size = 240.0 static let selectionStrokeWidth = 4.0 var recipe: Recipe var isSelected: Bool private var strokeStyle: AnyShapeStyle { isSelected ? AnyShapeStyle(.selection) : AnyShapeStyle(.clear) } var body: some View { VStack { RoundedRectangle(cornerRadius: 20) .fill(.background) .strokeBorder( strokeStyle, lineWidth: Self.selectionStrokeWidth) .frame(width: Self.size, height: Self.size) Text(recipe.name) } } } struct SelectedRecipeKey: FocusedValueKey { typealias Value = Binding<Recipe.ID> } extension FocusedValues { var selectedRecipe: Binding<Recipe.ID>? { get { self[SelectedRecipeKey.self] } set { self[SelectedRecipeKey.self] = newValue } } } struct RecipeCommands: Commands { @FocusedBinding(\.selectedRecipe) private var selectedRecipe: Recipe.ID? var body: some Commands { CommandMenu("Recipe") { Button("Add to Grocery List") { if let selectedRecipe { addRecipe(selectedRecipe) } } .disabled(selectedRecipe == nil) } } private func addRecipe(_ recipe: Recipe.ID) { /* ... */ } } struct Recipe: Hashable, Identifiable { static let examples: [Recipe] = [ Recipe(name: "Apple Pie"), Recipe(name: "Baklava"), Recipe(name: "Crème Brûlée") ] let id = UUID() var name = "" var imageName = "" }
-
21:28 - Focusable grid on tvOS
struct ContentView: View { var body: some View { HStack { VStack { List(["Dessert", "Pancake", "Salad", "Sandwich"], id: \.self) { NavigationLink($0, destination: Color.gray) } Spacer() } .focusSection() ScrollView { LazyVGrid(columns: [GridItem(), GridItem()]) { RoundedRectangle(cornerRadius: 5.0) .focusable() } } .focusSection() } } }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.