스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.
-
Apple Watch용 생산성 앱 빌드
손목의 생산성이 그 어느 때보다 향상됩니다. SwiftUI 및 시스템 기능을 사용하여 Apple Watch용으로 우수한 생산성 앱을 빌드하는 방법을 확인하세요. 손목을 위한 우수한 작업 경험을 디자인하는 방법을 소개하고 텍스트 입력을 받고, 기본 차트를 표시하며, 친구와 콘텐츠를 공유하는 방법을 알아보겠습니다.
리소스
관련 비디오
WWDC22
-
다운로드
안녕하세요, 환영합니다! 저는 Anne Hitchcock watchOS 소프트웨어 엔지니어입니다 오늘은 watchOS에서 생산성 앱을 만드는 방법을 보여드리고자 합니다 watchOS 6에 SwiftUI와 Independent Watch 앱이 도입된 이후로 Watch 앱에서 더 많은 작업을 수행할 수 있게 됐습니다 매년 watchOS의 SwiftUI가 제공하는 기능이 늘어납니다 동시에 watchOS는 키보드처럼 완전히 새로운 Watch 앱을 구축할 수 있게 하는 새 기능이 생겼습니다 할 일 목록을 추적하는 앱을 구축하기 위해 이런 기능 중 일부를 결합하는 법을 보여드리겠습니다 새 Watch 앱을 만들어서 간단한 항목 목록을 표시하게 하고 사람들이 목록에 항목을 추가하고 편집할 수 있게 해 보겠습니다 이러한 기능을 추가하면서 Watch 앱의 일반적인 앱 탐색 전략과 올바른 것을 고르는 법도 알아보겠습니다
짐을 나누기 위해 친구와 항목을 공유할 겁니다
그런 다음 앱에 차트를 추가해 생산성 추세를 파악하고 동기를 얻겠습니다
그리고 Digital Crown으로 차트를 스크롤할 수 있게 해서 더 큰 데이터 범위를 표시하겠습니다
새 앱을 만들어 시작해 봅시다
Xcode에서 새 프로젝트를 만드세요 watchOS 탭에서 앱을 선택하고 Next를 클릭하세요
제품 이름을 선택한 후 몇 가지 선택 사항이 있습니다 가장 중요한 것은 Watch 전용 앱을 만들지 아니면 동반 iOS 앱이 있는 Watch 앱을 만들지입니다 훌륭한 Watch 앱을 만드는 요소와 동반 앱이 필요한 경우에 대해 이야기해 보겠습니다
훌륭한 Watch 앱은 Workout의 인터페이스에서 좋아하는 운동을 빨리 시작할 수 있는 것처럼 빠른 상호 작용을 가능하게 합니다 아무도 멀뚱멀뚱 서서 팔을 들고 장치를 두드리고 싶어 하지 않습니다 훌륭한 Watch 앱으로 중요한 정보와 기능에 쉽게 액세스할 수 있습니다
훌륭한 Watch 앱은 앱의 기본 목적에 주목합니다 예를 들어 날씨 앱은 오늘의 일기 예보와 관련 현재 기상 및 간단한 10일 예측을 표시합니다
사람들이 정보와 필요한 작업을 쉽게 찾을 수 있도록 앱의 필수 요소에 집중하세요
훌륭한 Watch 앱은 동반 iPhone과 독립적으로 사용하도록 설계되었습니다 예를 들어 연락처 앱은 휴대전화와 동기화되지만 iPhone이 근처에 있지 않아도 Apple Watch의 연락처 정보에 액세스합니다
Watch 앱을 위한 동반 iOS 앱이 필요한 데엔 여러 가지 이유가 있습니다 피트니스 앱처럼 Apple Watch에서 캡처한 데이터의 과거 기록, 추세에 대한 자세한 분석을 제공한다든지요
우리 앱에는 집중 기능 세트와 빠른 상호 작용 제한된 데이터가 있기 때문에 Watch 전용 앱을 만들 것입니다
이 시점에서 생성된 대상에 몇 분만 할애하겠습니다
과거에 Watch 앱을 빌드했다면 프로젝트에는 Watch 관련 대상이 두 가지 있습니다 스토리보드와 자산 일부 현지화 관련 파일이 있는 WatchKit 앱 대상과 모든 앱 코드가 포함된 WatchKit 확장 대상입니다 이러한 이중 대상은 watchOS 초기부터 유지된 것입니다 이젠 여러 Watch 대상이 있어야 할 이유가 없습니다 Xcode 14부터 새 Watch 앱은 단일 Watch 앱 대상만 가집니다 모든 코드, 자산, 현지화 Siri Intent나 위젯 확장까지 Watch 앱에 관련된 모든 것이 이 대상에 속합니다
좋은 소식은 단일 대상 Watch 앱이 watchOS 7에서 다시 지원됩니다! 최신 watchOS가 아닌 고객을 계속 지원하면서 프로젝트 구조를 단순화하고 혼란과 중복을 줄일 수 있습니다
WatchKit Extension 대상이 있는 기존 앱이 있는 경우 계속 작동하며 Xcode를 사용하여 앱을 계속 업데이트할 수 있습니다 App Store를 통해 앱을 게시하세요
SwiftUI 수명 주기를 쓰는 Watch 앱이 이미 있다면 Xcode 14 마이그레이션 도구로 단일 대상으로 쉽게 전환할 수 있습니다 대상을 선택하고 편집기 메뉴에서 Validate Settings를 선택합니다 배포 대상이 watchOS 7 이상인 경우 축소 옵션이 제공됩니다
아직 업그레이드를 하지 않았다면 지금부터 SwiftUI 수명 주기를 사용하도록 앱을 전환하면 좋습니다 단일 대상 Watch 앱의 단순함과 SwiftUI의 모든 기능을 즐길 수 있습니다
Xcode 14에선 대상만 단순화된 게 아닙니다 앱 아이콘도 훨씬 쉽게 추가할 수 있습니다 하나의 1024x1024 픽셀 이미지만 있으면 됩니다
앱 아이콘 이미지는 모든 Watch 장치에 올바르게 표시되도록 크기가 조정됩니다
앱 아이콘을 iPhone Watch 앱의 홈 화면이나 알림 앱 설정에서 테스트하기 바랍니다
필요한 경우 특정한 작은 크기에는 사용자 맞춤형 이미지를 추가할 수 있습니다 예를 들어 앱 아이콘에 작은 디테일이 있어서 크기를 줄였을 때 보이지 않는다면 이미지 디테일을 제거한 특정 아이콘 이미지를 추가할 수 있습니다 이제 할 일 목록을 추가해 앱에 몇 가지 기능을 추가해 보겠습니다 할 일 목록에 대한 데이터 모델을 만드는 것으로 시작하겠습니다 ListItem 구조는 식별, 해시가 가능합니다 표시할 설명을 적습니다
그런 다음 데이터를 저장하고 항목의 배열을 게시하는 간단한 모델을 만듭니다
마지막으로 뷰가 모델에 액세스할 수 있도록 모델을 환경 개체로 추가합니다
이제 데이터 모델로 SwiftUI에서 목록을 만듭니다 아직 작업이 없기 때문에 미리 볼 때 빈 목록이 표시됩니다
이에 대해 조치를 취해야 합니다 사람들이 목록에 작업을 추가할 방법을 제공해야 합니다
탭해서 목록에 새 항목을 추가할 수 있도록 버튼을 추가하고 싶습니다 watchOS 9의 새 기능인 텍스트 필드 링크를 쓰면 여러 텍스트 입력 옵션을 호출할 수 있습니다 앱을 편안하게 느낄 수 있도록 여러 스타일 옵션을 제공합니다
간단한 문자열로 된 기본적인 텍스트 필드 링크를 만들거나 Lable로 더 개성 있는 버튼을 만들 수 있습니다
View 한정자로 버튼 모양을 바꿀 수 있습니다 foregroundColor나 foregroundStyle buttonStyle 등입니다
앱에서 사용 중인 텍스트 필드 링크의 스타일과 동작을 캡슐화하기 위해 AddItemLink 뷰를 생성합시다
버튼에 사용자 정의 Label을 사용하고 누군가가 텍스트를 입력하면 목록에 새 항목을 추가합니다
이제 텍스트 필드 링크로 항목을 추가하는 버튼을 추가하기로 결정했으므로 텍스트 필드 링크를 어디에 둘지 생각해야 합니다
Watch 앱의 목록에 작업을 추가할 때 몇 가지 옵션이 있습니다 짧은 목록의 기본 작업은 목록 끝에 버튼, 탐색 링크 텍스트 필드 링크를 사용하세요 목록 끝에 항목으로 작업을 추가하는 것은 기본 작업에 좋습니다 World Clock의 도시 목록처럼 짧은 목록에서 유용하죠 그러나 목록이 길 것으로 예상되는 경우 작업을 수행할 때마다 목록의 끝까지 계속 스크롤해야 합니다 긴 목록에서 자주 사용되는 작업은 ToolbarItem을 사용하세요
ToolbarItem을 추가하려면 목록에 toolbar 변경자를 넣고 작업 보기를 콘텐츠로 사용하세요 그러면 자동 toolbar 항목 배치로 단일 toolbar 항목이 목록에 추가됩니다 저는 할 일 목록을 짧게 쓰려고는 하는데 결국은 그렇게 안 되더라고요 그래서 toolbar 항목에 텍스트 필드 링크를 넣어 쉽게 액세스할 수 있게 하겠습니다
잠시 시간을 내 결과를 검토해 보겠습니다 목록 항목에 대한 모델을 만들어 환경 개체로 저장했습니다 항목을 표시하는 목록을 만들고 새 항목을 추가하는 텍스트 필드 링크를 추가했습니다
설명만 있는 항목을 만드는 것은 간단하지만 그다지 유용하지 않습니다 항목을 완료로 표시할 수 있어야 하고 우선 순위를 설정하는 방법도 필요할 수 있습니다 또는 작업에 대한 작업량의 추정치를 추가할 수 있으면 좋겠죠 이를 위해 상세 뷰를 추가합니다 이 작업을 수행하기 전에 Watch의 SwiftUI에서 앱 탐색 구조 옵션을 살펴봅시다 목록-세부 사항 관계를 가진 뷰에는 계층적 탐색이 사용됩니다 watchOS 9부터 SwiftUI NavigationStack으로 이런 유형의 탐색 구조를 가진 인터페이스를 생성할 수 있습니다
페이지 기반 탐색은 모든 뷰가 피어인 플랫 구조 뷰에 사용됩니다
페이지 기반 탐색의 좋은 예는 운동 앱의 운동 중 뷰입니다 사람들이 운동 중 운동 제어, 지표 및 재생 제어를 쉽게 스와이프하며 전환할 수 있습니다
전체 화면 앱에는 전체 디스플레이를 사용하는 단일 뷰가 있습니다 일반적으로 게임이나 단일 기본 뷰가 있는 기타 앱에 사용됩니다
전체 화면 보기의 경우 ignoresSafeArea 한정자를 사용해 콘텐츠를 디스플레이의 가장자리로 확장하고 가시성 값이 숨김으로 설정된 toolbar 한정자로 탐색 바를 숨기세요
모달 시트는 현재 뷰 위로 미끄러지는 전체 화면 뷰입니다 현재 워크플로의 일부로 완료해야 하는 중요한 작업에 사용해야 합니다
언제 계층적 흐름을 사용하고 언제 모달 시트를 사용할지 구별하는 것이 중요합니다
메일은 계층적 스타일로 메시지 목록을 표시하고 각 메시지 또는 스레드를 상세 뷰로 표시합니다 메시지 세부 정보에서 수행할 수 있는 작업이 있지만 목록으로 돌아가기 전에 수행해야 하는 작업은 없습니다
목록으로 돌아가서 New Message를 탭하면 메일은 모달 시트를 사용해 New Message 뷰를 표시합니다 계속하기 전에 새 메시지의 세부 정보를 입력하거나 취소해야 하므로 모달 시트가 올바른 선택입니다
모달 시트를 표시하려면 시트 표시 상태를 제어하는 속성을 만듭니다 사용자 인터페이스의 작업을 기반으로 속성을 설정하고 시트 한정자를 사용해서 프레젠테이션 상태 속성이 true일 때 사용자 정의 모달 시트 내용을 표시합니다
사용자 정의 toolbar 항목을 항목을 모달 시트에 추가하려면 항목이 있는 toolbar를 추가하세요 toolbar 항목은 ConfirmationAction과 cancellationAction destructiveAction 등 모달 배치를 사용해야 합니다
항목을 편집 중이므로 상세 뷰에 모달 시트를 사용할 것입니다 우린 작업을 완료하고 Done을 탭하기 전까지 이 한 작업에 집중할 거니까요
Swift UI의 NavigationStack에 대한 자세한 내용을 포함해 프로그래밍식 탐색에 대해 자세히 알아보려면 ‘탐색을 위한 SwiftUI 쿡북’을 확인하세요
이제 상세 뷰로 이동하는 방법을 결정했으므로 목록 항목 구조를 업데이트하겠습니다 예상 작업, 생성 날짜 및 완료 날짜를 저장하는 새로운 속성이 생겼습니다
이러한 세부 정보를 보고 편집할 수 있는 방법을 제공합시다
설명을 편집하는 텍스트 필드와 작업 완료 여부를 표시하는 토글이 있는 상세 뷰를 만듭시다 근데 예상 작업량으로 무엇을 해야 할까요? 값이 모두 숫자라는 것을 알고 있으며 유효한 값의 범위를 지정할 수 있습니다
watchOS 9부터 Stepper를 사용할 수 있습니다 Stepper는 순차 값을 편집할 수 있게 세분화된 제어를 제공할 때 훌륭한 옵션입니다
값 범위를 지정하고 선택적으로 단계를 제공할 수 있습니다
또한 Stepper를 사용하여 논리적, 순차적으로 편집할 수 있지만 반드시 숫자일 필요는 없습니다 예를 들어 항목에 대한 예상 스트레스 수준을 기록하고 싶을 수 있습니다
스트레스 수준을 나타내는 미모티콘 배열을 만들고 Stepper를 생성해 이모지 배열에서 선택한 인덱스에 값을 바인딩해서 범위를 이모티콘 인덱스의 범위로 설정합니다 값은 단계별로 증가하거나 감소해서 항목에 대한 예상 스트레스를 표시합니다
WWDC 세션 준비는 재미있지만 훌륭한 Watch 앱 개발을 공유하는 것은 파티 같네요 스트레스를 주는 항목이 목록에 있거나 그냥 항목이 많아서 스트레스를 받을 때 목록에 있는 항목을 친구와 공유해 도움을 요청하면 좋을 것 같습니다
사람들이 항목을 공유할 수 있도록 공유 시트를 사용해서 상세 뷰에 버튼을 추가하겠습니다 상세 뷰의 버튼을 탭해서 항목을 공유하려고 합니다 친구 목록에서 친구를 선택해 도움을 요청하고 메시지를 편집한 후 보낼 수 있으면 좋겠네요
이를 위해 watchOS 9의 SwiftUI에서 새 도구를 사용할 것입니다 ShareLink입니다 항목으로 ShareLink를 만들어 목록 항목을 공유할 수 있습니다 원한다면 메시지의 초기 텍스트를 제목과 메시지로 사용자 정의할 수 있습니다 그리고 누군가가 항목을 공유할 때 공유 시트에 표시할 미리보기를 제공합니다 ShareLink를 사용하여 iOS, macOS 및 watchOS의 SwiftUI 앱에서 공유할 수 있습니다
'Transferable 소개’를 꼭 확인해 보세요 ShareLink에 대한 자세한 내용과 옵션을 알 수 있습니다 이제 항목을 완료한 시간을 추적하고 작업을 완료하기 위해 도움을 요청할 수 있으므로 생산성을 보기 위해 차트를 추가하고 싶습니다 일련의 단일 데이터와 고유한 데이터 값이 있기 때문에 막대 차트를 사용하기로 했습니다 막대 차트는 Watch 디스플레이에 데이터를 명확하게 표시합니다 한 번에 표시하는 데이터의 양을 제한하는 한은요 앱의 탐색 구조에 차트 보기를 추가하는 것으로 시작하겠습니다 항목 목록과 차트 사이에는 목록-세부 사항 관계가 없기 때문에 페이지 기반 탐색 전략을 선택했습니다 언제든지 목록과 차트 사이를 스와이프할 수 있습니다
목록과 차트에 페이지 기반 탐색을 추가하려면 목록 뷰를 캡슐화하는 ItemList 구조부터 만들어야 합니다
콘텐츠 뷰의 전체 콘텐츠를 이 새 항목 목록으로 옮겼습니다 여기 항목 목록을 캡슐화하면 콘텐츠 보기에서 간단하고 읽기 쉬운 탭 뷰 코드가 생깁니다
또한 차트 뷰에 대한 구조를 만들어야 합니다
차트를 작성하기 전에 탐색 구조에 집중할 수 있도록 임시로 자리 표시자를 넣겠습니다
이제 콘텐츠 뷰를 설정하겠습니다 항목 목록과 차트로 구성된 탭 2개가 있는 페이지 스타일 탭 뷰입니다
탐색 구조를 설정했으므로 이 차트를 작성하는 법에 대해 이야기해 보겠습니다 SwiftUI Canvas를 사용해서 차트를 그릴 순 있지만 watchOS 9부터는 더 쉬운 답이 있습니다 바로 Swift Charts입니다 Swift Charts는 iOS, macOS tvOS에서도 사용할 수 있으므로 SwiftUI를 사용하는 모든 곳에서 차트를 재사용할 수 있습니다
차트에 표시할 데이터를 집계한 다음 Swift Charts가 데이터를 표시하도록 합니다
이 차트는 날짜별로 완료된 항목 수를 표시하려고 합니다 차트의 집계 데이터를 저장할 구조를 만듭니다
그런 다음 차트 데이터 요소로 목록 항목 데이터를 집계하는 작은 메서드를 작성합니다
표시할 데이터를 지정하고 데이터에서 계열을 정의해 간단한 차트를 표시합니다 날짜를 x 값으로 사용하고 있습니다 y 값은 완료된 항목의 수입니다
Watch 디스플레이에서 원하는 모양을 얻기 위해 차트의 chartXAxis 한정자를 x축을 사용자 정의하고 있습니다 AxisValueLabel의 형식 스타일을 지정합니다 또한 세로 눈금선을 원하지 않으므로 AxisGridLine 표시를 생략했습니다 또 chartYAxis 한정자로 y축을 사용자 정의합니다 Watch의 차트와 잘 어울리는 눈금선 스타일을 지정합니다 AxisValueLabel의 형식을 정수로 지정하고 상단 레이블을 생략해서 차트 상단이 잘리는 것을 방지합니다 Swift Charts로 달성할 수 있는 놀라운 것을 자세히 알아보려면 'Swift Charts 소개’와 'Swift Charts: 기준을 높이다’를 확인하세요
우리 차트는 꽤 괜찮아 보이지만 더 많은 데이터를 보여주고 싶습니다 훌륭한 Watch 환경은 유지하고 싶으므로 스크롤 가능하게 만들겠습니다 이를 달성하기 위해 새 digitalCrownRotation 한정자를 사용할 겁니다 Digital crown 이벤트에 대한 콜백을 설정할 수 있습니다 그리고 차트에 대한 사용자 정의 스크롤 동작을 구현하겠습니다
차트를 스크롤 할 때 상태를 저장할 속성을 추가해서 digitalCrownRotation 한정자를 추가할 준비를 합시다 HighlightedDateIndex는 현재 스크롤 위치에 대한 데이터 포인트의 인덱스입니다
사람이 차트를 스크롤할 때 현재 crown 위치를 표시할 수 있도록 crown 오프셋을 저장합니다 crown이 움직이는 동안 데이터 포인트 위 또는 사이의 중간 값입니다
활발하게 스크롤하는 경우를 추적하기 위해 유휴 상태를 저장합니다 이 정보를 사용하여 crown 스크롤이 멈추고 시작될 때 약간의 애니메이션을 추가합니다
이제 값을 저장할 속성이 있으므로 digitalCrownRotation 한정자를 추가할 수 있습니다
멈춤쇠 값을 HighlightDateIndex 속성에 바인딩합니다
멈춤쇠는 기계적 용어로 움직일 만큼 충분한 힘이 가해질 때까지 무언가를 제자리에 고정시키는 메커니즘입니다 예를 들어, 제가 차 문을 열 때 문이 안착되는 '정지' 위치가 있습니다 문을 조금 더 세게 밀어 또 다른 '정지' 위치까지 더 열 수도 있습니다 문을 닫으려면 '정지'에서 빼낼 수 있을 만큼 세게 당겨서 저항을 이겨내야 합니다 그렇지 않으면 문은 '정지' 위치로 되돌아갑니다 그게 멈춤쇠입니다 자동차 도어의 정지는 이 API의 멈춤쇠를 이해하는 데 도움이 됩니다 멈춤쇠는 뷰에서 crown의 정지 톱니 역할입니다
onChange 콜백 핸들러에서 isCrownIdle 값을 false로 설정합니다 crown이 스크롤되는 것을 알고 있기 때문에 crown 오프셋 값을 현재 값으로 설정해서 스크롤하는 동안 차트상 현재 위치를 표시합니다
onIdle 콜백 핸들러에서 isCrownIdle 값을 true로 설정합니다
이제 차트에서 스크롤할 때 crown의 위치를 표시할 수 있습니다 이를 위해 Swift Charts의 RuleMark 유형을 사용합니다 RuleMark는 차트의 직선입니다 수평선 또는 수직선으로 임계값을 표시하거나 경사선을 표시할 수 있습니다
crown 오프셋 날짜 값으로 RuleMark를 만들어서 crown 스크롤의 현재 위치를 표시하겠습니다
조금 더 보기 좋게 하기 위해 crown이 움직이지 않을 때 crown 위치 선이 희미해지게 하고 싶습니다 추가했던 isCrownIdle 속성을 사용하면 간단하게 애니메이션을 적용할 수 있습니다
RuleMark의 foregroundStyle에서 사용 중인 색상의 불투명도를 저장하는 속성을 추가합니다
그리고 차트에 onChange 한정자를 추가해 isCrownIdle 값이 변경될 때 crownPositionOpacity 값 변경을 애니메이션이 되게 만듭니다
그리고 불투명도를 사용하도록 RuleMark의 foregroundStyle을 업데이트합니다
스크롤할 때 차트의 막대 옆에 값을 표시하려면 BarMark에 주석을 추가하면 됩니다 마지막 막대일 때는 막대의 맨 위쪽 선행에 주석을 배치합니다 그렇지 않으면 상단 후행에 배치합니다
잠시 시간을 내어 digitalCrownRotation 한정자로 무엇을 달성했는지 살펴보겠습니다 Swift Charts의 RuleMark 간단한 SwiftUI 애니메이션
사용자 정의 스크롤 가능한 차트를 만드는 마지막 단계는 누군가가 스크롤할 때 차트의 데이터 범위를 조정하는 것입니다 가시 범위를 저장할 속성을 만듭니다
차트에 범위 데이터를 제공하기 위해 chartData 변수를 생성합니다 HighlightedDateIndex가 변경되면 메소드를 호출해 chartDataRange를 확인하고 필요한 경우 업데이트합니다
Digital Crown을 사용해 차트를 스크롤하면 차트가 스크롤되어 사용 가능한 데이터가 표시됩니다 이제 계획했던 모든 기능의 구현을 완료했습니다
watchOS 9에서 사용할 수 있는 새로운 SwiftUI 기능에 대해 자세히 알아보려면 'SwiftUI의 새로운 기능‘을 확인하세요 새 Watch 앱이나 Watch 앱 기능을 계획할 때 훌륭한 Watch 앱 경험이 무엇인지 생각해 보세요 앱을 디자인하는 동안 앱 탐색 전략을 고려해서 앱이 쉽고 직관적이게 하세요 더 간단하고 풍부한 개발 옵션을 위해 SwiftUI를 사용하세요 멋진 Watch 앱을 계속 구축하세요 기억하세요, 여러분 여러분 덕분에 이를 위한 앱이 생겼습니다!
-
-
6:12 - Initial ListItem struct
struct ListItem: Identifiable, Hashable { let id = UUID() var description: String init(_ description: String) { self.description = description } }
-
6:24 - ItemListModel
class ItemListModel: NSObject, ObservableObject { @Published var items = [ListItem]() }
-
6:30 - Add the ItemListModel as an EnvironmentObject
@main struct WatchTaskListSampleApp: App { @StateObject var itemListModel = ItemListModel() @SceneBuilder var body: some Scene { WindowGroup { ContentView() .environmentObject(itemListModel) } } }
-
6:37 - Create a simple SwiftUI List
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { List { ForEach($model.items) { $item in ItemRow(item: $item) } if model.items.isEmpty { Text("No items to do!") .foregroundStyle(.gray) } } .navigationTitle("Tasks") } }
-
7:11 - TextFieldLink with a simple String
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { VStack { TextFieldLink("Add") { model.items.append(ListItem($0)) } } .navigationTitle("Tasks") } }
-
7:16 - TextFieldLink with a Label
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { VStack { TextFieldLink { Label( "Add", systemImage: "plus.circle.fill") } onSubmit: { model.items.append(ListItem($0)) } } .navigationTitle("Tasks") } }
-
7:20 - TextFieldLink with foregroundStyle modifier
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { VStack { TextFieldLink { Label( "Add", systemImage: "plus.circle.fill") } onSubmit: { model.items.append(ListItem($0)) } .foregroundStyle(.tint) } .navigationTitle("Tasks") } }
-
7:27 - TextFieldLink with buttonStyle modifier
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { VStack { TextFieldLink { Label( "Add", systemImage: "plus.circle.fill") } onSubmit: { model.items.append(ListItem($0)) } .buttonStyle(.borderedProminent) } .navigationTitle("Tasks") } }
-
struct AddItemLink: View { @EnvironmentObject private var model: ItemListModel var body: some View { TextFieldLink(prompt: Text("New Item")) { Label("Add", systemImage: "plus.circle.fill") } onSubmit: { model.items.append(ListItem($0)) } } }
-
8:38 - Add a toolbar item to allow people to add new list items
struct ContentView: View { @EnvironmentObject private var model: ItemListModel var body: some View { List { ForEach($model.items) { $item in ItemRow(item: $item) } if model.items.isEmpty { Text("No items to do!") .foregroundStyle(.gray) } } .toolbar { AddItemLink() } .navigationTitle("Tasks") } }
-
11:40 - Display a modal sheet
struct ItemRow: View { @EnvironmentObject private var model: ItemListModel @Binding var item: ListItem @State private var showDetail = false var body: some View { Button { showDetail = true } label: { HStack { Text(item.description) .strikethrough(item.isComplete) Spacer() Image(systemName: "checkmark").opacity(item.isComplete ? 100 : 0) } } .sheet(isPresented: $showDetail) { ItemDetail(item: $item) } } }
-
11:58 - Display a modal sheet with custom toolbar items
struct ItemRow: View { @EnvironmentObject private var model: ItemListModel @Binding var item: ListItem @State private var showDetail = false var body: some View { Button { showDetail = true } label: { HStack { Text(item.description) .strikethrough(item.isComplete) Spacer() Image(systemName: "checkmark").opacity(item.isComplete ? 100 : 0) } } .sheet(isPresented: $showDetail) { ItemDetail(item: $item) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showDetail = false } } } } } }
-
12:36 - Add more properties to the ListItem
struct ListItem: Identifiable, Hashable { let id = UUID() var description: String var estimatedWork: Double = 1.0 var creationDate = Date() var completionDate: Date? init(_ description: String) { self.description = description } var isComplete: Bool { get { completionDate != nil } set { if newValue { guard completionDate == nil else { return } completionDate = Date() } else { completionDate = nil } } } }
-
12:48 - Create the ItemDetail View with the Stepper
struct ItemDetail: View { @Binding var item: ListItem var body: some View { Form { Section("List Item") { TextField("Item", text: $item.description, prompt: Text("List Item")) } Section("Estimated Work") { Stepper(value: $item.estimatedWork, in: (0.0...14.0), step: 0.5, format: .number) { Text("\(item.estimatedWork, specifier: "%.1f") days") } } Toggle(isOn: $item.isComplete) { Text("Completed") } } } }
-
13:29 - A Stepper with Emoji
// Use a Stepper to edit the stress level of an item struct StressStepper: View { private let stressLevels = [ "😱", "😡", "😳", "🙁", "🫤", "🙂", "🥳" ] @State private var stressLevelIndex = 5 var body: some View { VStack { Text("Stress Level") .font(.system(.footnote, weight: .bold)) .foregroundStyle(.tint) Stepper(value: $stressLevelIndex, in: (0...stressLevels.count-1)) { Text(stressLevels[stressLevelIndex]) } } } }
-
14:43 - Add a ShareLink to the ItemDetail View
struct ItemDetail: View { @Binding var item: ListItem var body: some View { Form { Section("List Item") { TextField("Item", text: $item.description, prompt: Text("List Item")) } Section("Estimated Work") { Stepper(value: $item.estimatedWork, in: (0.0...14.0), step: 0.5, format: .number) { Text("\(item.estimatedWork, specifier: "%.1f") days") } } Toggle(isOn: $item.isComplete) { Text("Completed") } ShareLink(item: item.description, subject: Text("Please help!"), message: Text("(I need some help finishing this.)"), preview: SharePreview("\(item.description)")) .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle) .listRowInsets( EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) ) } } }
-
16:39 - Page-style TabView with navigation titles for each page
struct ContentView: View { var body: some View { TabView { NavigationStack { ItemList() } NavigationStack { ProductivityChart() } }.tabViewStyle(.page) } }
-
17:20 - ChartData struct for aggregate data
/// Aggregate data for charting productivity. struct ChartData { struct DataElement: Identifiable { var id: Date { return date } let date: Date let itemsComplete: Double } /// Create aggregate chart data from list items. /// - Parameter items: An array of list items to aggregate for charting. /// - Returns: The chart data source. static func createData(_ items: [ListItem]) -> [DataElement] { return Dictionary(grouping: items, by: \.completionDate) .compactMap { guard let date = $0 else { return nil } return DataElement(date: date, itemsComplete: Double($1.count)) } .sorted { $0.date < $1.date } } }
-
17:36 - Static sample data for chart and basic bar chart
extension ChartData { /// Some static sample data for displaying a `Chart`. static var chartSampleData: [DataElement] { let calendar = Calendar.autoupdatingCurrent var startDateComponents = calendar.dateComponents( [.year, .month, .day], from: Date()) startDateComponents.setValue(22, for: .day) startDateComponents.setValue(5, for: .month) startDateComponents.setValue(2022, for: .year) startDateComponents.setValue(0, for: .hour) startDateComponents.setValue(0, for: .minute) startDateComponents.setValue(0, for: .second) let startDate = calendar.date(from: startDateComponents)! let itemsToAdd = [ 6, 3, 1, 4, 1, 2, 7, 5, 2, 0, 5, 2, 3, 9 ] var items = [DataElement]() for dayOffset in (0..<itemsToAdd.count) { items.append(DataElement( date: calendar.date(byAdding: .day, value: dayOffset, to: startDate)!, itemsComplete: Double(itemsToAdd[dayOffset]))) } return items } } struct ProductivityChart: View { let data = ChartData.createData( ListItem.chartSampleData) var body: some View { Chart(data) { dataPoint in BarMark( x: .value("Date", dataPoint.date), y: .value( “Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) } .navigationTitle("Productivity") .navigationBarTitleDisplayMode(.inline) } }
-
17:50 - Chart with chartXAxis modifier
struct ProductivityChart: View { let data = ChartData.createData( ListItem.chartSampleData) private var shortDateFormatStyle = DateFormatStyle(dateFormatTemplate: "Md") var body: some View { Chart(data) { dataPoint in BarMark( x: .value("Date", dataPoint.date), y: .value( “Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } .navigationTitle("Productivity") .navigationBarTitleDisplayMode(.inline) } } /// `ProductivityChart` uses this type to format the dates on the x-axis. struct DateFormatStyle: FormatStyle { enum CodingKeys: CodingKey { case dateFormatTemplate } private var dateFormatTemplate: String private var formatter: DateFormatter init(dateFormatTemplate: String) { self.dateFormatTemplate = dateFormatTemplate formatter = DateFormatter() formatter.locale = Locale.autoupdatingCurrent formatter.setLocalizedDateFormatFromTemplate(dateFormatTemplate) } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) dateFormatTemplate = try container.decode(String.self, forKey: .dateFormatTemplate) formatter = DateFormatter() formatter.setLocalizedDateFormatFromTemplate(dateFormatTemplate) } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(dateFormatTemplate, forKey: .dateFormatTemplate) } func format(_ value: Date) -> String { formatter.string(from: value) } }
-
19:05 - Add the digitalCrownRotation modifier
struct ProductivityChart: View { let data = ChartData.createData( ListItem.chartSampleData) /// The index of the highlighted chart value. This is for crown scrolling. @State private var highlightedDateIndex: Int = 0 /// The current offset of the crown while it's rotating. This sample sets the offset with /// the value in the DigitalCrownEvent and uses it to show an intermediate /// (between detents) chart value in the view. @State private var crownOffset: Double = 0.0 @State private var isCrownIdle = true private var chart: some View { Chart(data) { dataPoint in BarMark( x: .value("Date", dataPoint.date), y: .value( “Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } } var body: some View { chart .focusable() .digitalCrownRotation( detent: $highlightedDateIndex, from: 0, through: data.count - 1, by: 1, sensitivity: .medium ) { crownEvent in isCrownIdle = false crownOffset = crownEvent.offset } onIdle: { isCrownIdle = true } .navigationTitle("Productivity") .navigationBarTitleDisplayMode(.inline) } }
-
21:07 - Add a RuleMark to the Chart to show the current Digital Crown position
/// The date value that corresponds to the crown offset. private var crownOffsetDate: Date { let dateDistance = data[0].date.distance( to: data[data.count - 1].date) * (crownOffset / Double(data.count - 1)) return data[0].date.addingTimeInterval(dateDistance) } private var chart: some View { Chart(data) { dataPoint in BarMark( x: .value("Date", dataPoint.date), y: .value( "Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) RuleMark(x: .value("Date", crownOffsetDate)) .foregroundStyle(Color.appYellow) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } }
-
21:37 - Add animation to dim the crown position line when the scrolling idle state changes
struct ProductivityChart: View { let data = ChartData.createData( ListItem.chartSampleData) /// The index of the highlighted chart value. This is for crown scrolling. @State private var highlightedDateIndex: Int = 0 /// The current offset of the crown while it's rotating. This sample sets the offset with /// the value in the DigitalCrownEvent and uses it to show an intermediate /// (between detents) chart value in the view. @State private var crownOffset: Double = 0.0 @State private var isCrownIdle = true @State var crownPositionOpacity: CGFloat = 0.2 private var chart: some View { Chart(data) { dataPoint in BarMark( x: .value("Date", dataPoint.date), y: .value( “Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) RuleMark(x: .value("Date", crownOffsetDate)) .foregroundStyle(Color.appYellow.opacity(crownPositionOpacity)) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } } var body: some View { chart .focusable() .digitalCrownRotation( detent: $highlightedDateIndex, from: 0, through: data.count - 1, by: 1, sensitivity: .medium ) { crownEvent in isCrownIdle = false crownOffset = crownEvent.offset } onIdle: { isCrownIdle = true } .onChange(of: isCrownIdle) { newValue in withAnimation(newValue ? .easeOut : .easeIn) { crownPositionOpacity = newValue ? 0.2 : 1.0 } } .navigationTitle("Productivity") .navigationBarTitleDisplayMode(.inline) } }
-
22:14 - Add an annotation to the bar chart to display the current value
private func isLastDataPoint(_ dataPoint: ChartData.DataElement) -> Bool { data[chartDataRange.upperBound].id == dataPoint.id } private var chart: some View { Chart(chartData) { dataPoint in BarMark(x: .value("Date", dataPoint.date, unit: .day), y: .value("Completed", dataPoint.itemsComplete)) .foregroundStyle(Color.accentColor) .annotation( position: isLastDataPoint(dataPoint) ? .topLeading : .topTrailing, spacing: 0 ) { Text("\(dataPoint.itemsComplete, format: .number)") .foregroundStyle(dataPoint.date == crownOffsetDate ? Color.appYellow : Color.clear) } RuleMark(x: .value("Date", crownOffsetDate, unit: .day)) .foregroundStyle(Color.appYellow.opacity(crownPositionOpacity)) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } }
-
22:44 - Make the chart data range scrollable
@State var chartDataRange = (0...6) private func updateChartDataRange() { if (highlightedDateIndex - chartDataRange.lowerBound) < 2, chartDataRange.lowerBound > 0 { let newLowerBound = max(0, chartDataRange.lowerBound - 1) let newUpperBound = min(newLowerBound + 6, data.count - 1) chartDataRange = (newLowerBound...newUpperBound) return } if (chartDataRange.upperBound - highlightedDateIndex) < 2, chartDataRange.upperBound < data.count - 1 { let newUpperBound = min(chartDataRange.upperBound + 1, data.count - 1) let newLowerBound = max(0, newUpperBound - 6) chartDataRange = (newLowerBound...newUpperBound) return } } private var chartData: [ChartData.DataElement] { Array(data[chartDataRange.clamped(to: (0...data.count - 1))]) } private var chart: some View { Chart(chartData) { dataPoint in BarMark(x: .value("Date", dataPoint.date, unit: .day), y: .value("Completed", dataPoint.itemsComplete) ) .foregroundStyle(Color.accentColor) .annotation( position: isLastDataPoint(dataPoint) ? .topLeading : .topTrailing, spacing: 0 ) { Text("\(dataPoint.itemsComplete, format: .number)") .foregroundStyle(dataPoint.date == crownOffsetDate ? Color.appYellow : Color.clear) } RuleMark(x: .value("Date", crownOffsetDate, unit: .day)) .foregroundStyle(Color.appYellow.opacity(crownPositionOpacity)) } .chartXAxis { AxisMarks(format: shortDateFormatStyle) } } var body: some View { chart .focusable() .digitalCrownRotation( detent: $highlightedDateIndex, from: 0, through: data.count - 1, by: 1, sensitivity: .medium ) { crownEvent in isCrownIdle = false crownOffset = crownEvent.offset } onIdle: { isCrownIdle = true } .onChange(of: isCrownIdle) { newValue in withAnimation(newValue ? .easeOut : .easeIn) { crownPositionOpacity = newValue ? 0.2 : 1.0 } } .onChange(of: highlightedDateIndex) { newValue in withAnimation { updateChartDataRange() } } .navigationTitle("Productivity") .navigationBarTitleDisplayMode(.inline) }
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.